diff --git a/.devcontainer/dev-setup.sh b/.devcontainer/dev-setup.sh new file mode 100644 index 0000000000..333fb1edb0 --- /dev/null +++ b/.devcontainer/dev-setup.sh @@ -0,0 +1,34 @@ +#!/bin/bash + +# Uncomment this to fail on the first error. This is useful to debug the script. +# However, it is not recommended for production. +# set -e + +sudo git config core.fileMode false +git config --global --add safe.directory /__w/komodo-defi-framework/komodo-defi-framework +sudo chmod -R u+rwx /home/komodo/workspace +sudo chown -R komodo:komodo /home/komodo/workspace + +mkdir -p android/app/src/main/cpp/libs/armeabi-v7a +mkdir -p android/app/src/main/cpp/libs/arm64-v8a +mkdir -p web/src/mm2 + +rustup default stable +cargo install wasm-pack +rustup default nightly-2023-06-01 + +cd /kdf +export PATH="$HOME/.cargo/bin:$PATH" +export PATH=$PATH:/android-ndk/bin +CC_aarch64_linux_android=aarch64-linux-android21-clang CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=aarch64-linux-android21-clang cargo rustc --target=aarch64-linux-android --lib --release --crate-type=staticlib --package mm2_bin_lib +CC_armv7_linux_androideabi=armv7a-linux-androideabi21-clang CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=armv7a-linux-androideabi21-clang cargo rustc --target=armv7-linux-androideabi --lib --release --crate-type=staticlib --package mm2_bin_lib +wasm-pack build --release mm2src/mm2_bin_lib --target web --out-dir ../../target/target-wasm-release + +mv /kdf/target/aarch64-linux-android/release/libkdflib.a /home/komodo/workspace/android/app/src/main/cpp/libs/arm64-v8a/libmm2.a +mv /kdf/target/armv7-linux-androideabi/release/libkdflib.a /home/komodo/workspace/android/app/src/main/cpp/libs/armeabi-v7a/libmm2.a +rm -rf /home/komodo/workspace/web/src/mm2/ +cp -R /kdf/target/target-wasm-release/ /home/komodo/workspace/web/src/mm2/ + +cd /home/komodo/workspace +flutter pub get +npm i && npm run build \ No newline at end of file diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000000..34288dfca7 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,38 @@ +{ + "name": "flutter_docker", + "context": "..", + "dockerFile": "komodo-wallet-android-dev.dockerfile", + "remoteUser": "komodo", + "postAttachCommand": "sh .devcontainer/dev-setup.sh", + "runArgs": [ + "--privileged" + ], + "workspaceMount": "source=${localWorkspaceFolder},target=/home/komodo/workspace,type=bind,consistency=delegated", + "workspaceFolder": "/home/komodo/workspace", + "hostRequirements": { + "cpus": 4, + "memory": "16gb", + "storage": "32gb" + }, + "customizations": { + "vscode": { + "extensions": [ + "FelixAngelov.bloc", + "Dart-Code.dart-code", + "Dart-Code.flutter", + "DavidAnson.vscode-markdownlint", + "pflannery.vscode-versionlens", + "GitHub.copilot", + "GitHub.copilot-chat" + ], + "settings": { + "terminal.integrated.shell.linux": null, + "extensions.verifySignature": false, // https://github.com/microsoft/vscode/issues/174632 + "dart.showTodos": true, + "dart.debugExternalPackageLibraries": true, + "dart.promptToGetPackages": false, + "dart.debugSdkLibraries": false + } + } + } +} \ No newline at end of file diff --git a/.devcontainer/komodo-wallet-android-dev.dockerfile b/.devcontainer/komodo-wallet-android-dev.dockerfile new file mode 100644 index 0000000000..6182c4a8f1 --- /dev/null +++ b/.devcontainer/komodo-wallet-android-dev.dockerfile @@ -0,0 +1,177 @@ +FROM docker.io/ubuntu:22.04 + +ARG KDF_BRANCH=main +ENV KDF_DIR=/kdf +ENV FLUTTER_VERSION="3.22.3" +ENV FLUTTER_HOME="/home/komodo/.flutter-sdk" +ENV USER="komodo" +ENV USER_ID=1000 +ENV PATH=$PATH:$FLUTTER_HOME/bin +ENV AR=/usr/bin/llvm-ar-16 +ENV CC=/usr/bin/clang-16 +ENV PATH="$HOME/.cargo/bin:$PATH" +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 + +# Libz is distributed in the android ndk, but for some unknown reason it is not +# found in the build process of some crates, so we explicit set the DEP_Z_ROOT +ENV CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=x86_64-linux-android-clang \ + CARGO_TARGET_X86_64_LINUX_ANDROID_RUNNER="qemu-x86_64 -cpu qemu64,+mmx,+sse,+sse2,+sse3,+ssse3,+sse4.1,+sse4.2,+popcnt" \ + CC_x86_64_linux_android=x86_64-linux-android-clang \ + CXX_x86_64_linux_android=x86_64-linux-android-clang++ \ + CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=armv7a-linux-androideabi21-clang \ + CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_RUNNER=qemu-arm \ + CC_armv7_linux_androideabi=armv7a-linux-androideabi21-clang \ + CXX_armv7_linux_androideabi=armv7a-linux-androideabi21-clang++ \ + CC_aarch64_linux_android=aarch64-linux-android21-clang \ + CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=aarch64-linux-android21-clang \ + CC_armv7_linux_androideabi=armv7a-linux-androideabi21-clang \ + CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=armv7a-linux-androideabi21-clang \ + DEP_Z_INCLUDE=/android-ndk/sysroot/usr/include/ \ + OPENSSL_STATIC=1 \ + OPENSSL_DIR=/openssl \ + OPENSSL_INCLUDE_DIR=/openssl/include \ + OPENSSL_LIB_DIR=/openssl/lib \ + RUST_TEST_THREADS=1 \ + HOME=/home/komodo/ \ + 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=34 +ENV ANDROID_BUILD_TOOLS_VERSION=34.0.0 + +# https://developer.android.com/ndk/downloads +ENV ANDROID_NDK_VERSION=26.3.11579264 + +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 + +USER $USER + +RUN sudo apt-get update -y && \ + sudo 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 && \ + sudo apt-get clean + +RUN sudo ln -s /usr/bin/python3 /bin/python &&\ + sudo curl --output llvm.sh https://apt.llvm.org/llvm.sh && \ + sudo chmod +x llvm.sh && \ + sudo ./llvm.sh 16 && \ + sudo rm ./llvm.sh && \ + sudo ln -s /usr/bin/clang-16 /usr/bin/clang && \ + PROTOC_ZIP=protoc-25.3-linux-x86_64.zip && \ + sudo curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v25.3/$PROTOC_ZIP && \ + sudo unzip -o $PROTOC_ZIP -d /usr/local bin/protoc && \ + sudo unzip -o $PROTOC_ZIP -d /usr/local 'include/*' && \ + sudo rm -f $PROTOC_ZIP && \ + sudo mkdir $KDF_DIR && \ + sudo chown -R $USER:$USER $KDF_DIR + +RUN PATH="$HOME/.cargo/bin:$PATH" && \ + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \ + export PATH="$HOME/.cargo/bin:$PATH" && \ + sudo chown -R $USER:$USER $HOME/.cargo && \ + rustup toolchain install nightly-2023-06-01 --no-self-update --profile=minimal && \ + rustup default nightly-2023-06-01 && \ + rustup target add aarch64-linux-android && \ + rustup target add armv7-linux-androideabi && \ + rustup target add wasm32-unknown-unknown && \ + sudo apt install -y python3 python3-pip git curl nodejs python3-venv sudo && \ + git clone https://github.com/KomodoPlatform/komodo-defi-framework.git $KDF_DIR && \ + cd $KDF_DIR && \ + git fetch --all && \ + git checkout origin/$KDF_BRANCH && \ + if [ "$(uname -m)" = "x86_64" ]; then \ + bash ./scripts/ci/android-ndk.sh x86 23; \ + elif [ "$(uname -m)" = "aarch64" ]; then \ + bash ./scripts/ci/android-ndk.sh arm64 23; \ + else \ + echo "Unsupported architecture: $(uname -m)"; \ + exit 1; \ + fi + +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 git clone https://github.com/flutter/flutter.git ${FLUTTER_HOME} \ + && cd ${FLUTTER_HOME} \ + && git fetch \ + && git checkout tags/$FLUTTER_VERSION \ + && flutter config --no-analytics \ + && flutter precache \ + && yes "y" | flutter doctor --android-licenses \ + && flutter doctor \ + && flutter update-packages \ No newline at end of file diff --git a/.docker/android-sdk.dockerfile b/.docker/android-sdk.dockerfile new file mode 100644 index 0000000000..4735d8996a --- /dev/null +++ b/.docker/android-sdk.dockerfile @@ -0,0 +1,69 @@ +FROM docker.io/ubuntu:22.04 + +# Credit to Cirrus Labs for the original Dockerfile +# LABEL org.opencontainers.image.source=https://github.com/cirruslabs/docker-images-android + +ENV USER="komodo" +ENV USER_ID=1000 + +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 + +USER $USER + +ENV ANDROID_HOME=/opt/android-sdk-linux \ + LANG=en_US.UTF-8 \ + LC_ALL=en_US.UTF-8 \ + LANGUAGE=en_US:en + +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=34 +ENV ANDROID_BUILD_TOOLS_VERSION=34.0.0 + +# https://developer.android.com/ndk/downloads +ENV ANDROID_NDK_VERSION=26.3.11579264 + +RUN set -o xtrace \ + && sudo chown -R $USER:$USER /opt \ + && cd /opt \ + && sudo apt-get update \ + && sudo apt-get install -y jq openjdk-17-jdk nodejs npm \ + 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 Linux build + xz-utils \ + clang cmake git \ + ninja-build pkg-config \ + libgtk-3-dev liblzma-dev \ + libstdc++-12-dev \ + # 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/* \ + && sh -c 'echo "en_US.UTF-8 UTF-8" | sudo tee -a /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 \ + && rm android-sdk-tools.zip \ + && yes | sdkmanager --licenses \ + && sdkmanager platform-tools \ + && sudo mkdir -p /root/.android \ + && sudo chown -R $USER:$USER /root \ + && touch /root/.android/repositories.cfg \ + && 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" \ + && yes | sdkmanager "ndk;$ANDROID_NDK_VERSION" \ + && sudo chown -R $USER:$USER $ANDROID_HOME \ No newline at end of file diff --git a/.docker/build.sh b/.docker/build.sh new file mode 100644 index 0000000000..8ea6253ead --- /dev/null +++ b/.docker/build.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +DEFAULT_BUILD_TARGET="web" +DEFAULT_BUILD_MODE="release" + +if [ "$#" -eq 0 ]; then + BUILD_TARGET=$DEFAULT_BUILD_TARGET + BUILD_MODE=$DEFAULT_BUILD_MODE +elif [ "$#" -eq 2 ]; then + BUILD_TARGET=$1 + BUILD_MODE=$2 +else + echo "Usage: $0 [ ]\nE.g. $0 web release" + exit 1 +fi + +echo "Building with target: $BUILD_TARGET, mode: $BUILD_MODE" + +if [ "$(uname)" == "Darwin" ]; then + PLATFORM_FLAG="--platform linux/amd64" +else + PLATFORM_FLAG="" +fi + +docker build $PLATFORM_FLAG -f .docker/kdf-android.dockerfile . -t komodo/kdf-android --build-arg KDF_BRANCH=main +docker build $PLATFORM_FLAG -f .docker/android-sdk.dockerfile . -t komodo/android-sdk:34 +docker build $PLATFORM_FLAG -f .docker/komodo-wallet-android.dockerfile . -t komodo/komodo-wallet + +# Use the provided arguments for flutter build +docker run $PLATFORM_FLAG --rm -v ./build:/app/build \ + -u $(id -u):$(id -g) \ + komodo/komodo-wallet:latest bash -c "flutter pub get && flutter analyze && flutter build $BUILD_TARGET --$BUILD_MODE || flutter build $BUILD_TARGET --$BUILD_MODE" diff --git a/.docker/kdf-android.dockerfile b/.docker/kdf-android.dockerfile new file mode 100644 index 0000000000..be59a4fe36 --- /dev/null +++ b/.docker/kdf-android.dockerfile @@ -0,0 +1,102 @@ +FROM docker.io/ubuntu:22.04 + +LABEL Author="Onur Özkan " +ARG KDF_BRANCH=main + +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 \ + binutils && \ + apt-get clean + +RUN ln -s /usr/bin/python3 /bin/python &&\ + curl --output llvm.sh https://apt.llvm.org/llvm.sh && \ + chmod +x llvm.sh && \ + ./llvm.sh 16 && \ + rm ./llvm.sh && \ + ln -s /usr/bin/clang-16 /usr/bin/clang && \ + PROTOC_ZIP=protoc-25.3-linux-x86_64.zip && \ + curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v25.3/$PROTOC_ZIP && \ + unzip -o $PROTOC_ZIP -d /usr/local bin/protoc && \ + unzip -o $PROTOC_ZIP -d /usr/local 'include/*' && \ + rm -f $PROTOC_ZIP + +ENV AR=/usr/bin/llvm-ar-16 +ENV CC=/usr/bin/clang-16 + +RUN mkdir -m 0755 -p /etc/apt/keyrings + +RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y && \ + export PATH="/root/.cargo/bin:$PATH" && \ + rustup toolchain install nightly-2023-06-01 --no-self-update --profile=minimal && \ + rustup default nightly-2023-06-01 && \ + rustup target add aarch64-linux-android && \ + rustup target add armv7-linux-androideabi && \ + apt install -y python3 python3-pip git curl nodejs python3-venv sudo && \ + git clone https://github.com/KomodoPlatform/komodo-defi-framework.git /app && \ + cd /app && \ + git fetch --all && \ + git checkout origin/$KDF_BRANCH && \ + if [ "$(uname -m)" = "x86_64" ]; then \ + bash ./scripts/ci/android-ndk.sh x86 23; \ + elif [ "$(uname -m)" = "aarch64" ]; then \ + bash ./scripts/ci/android-ndk.sh arm64 23; \ + else \ + echo "Unsupported architecture"; \ + exit 1; \ + fi + +ENV PATH="/root/.cargo/bin:$PATH" + +ENV PATH=$PATH:/android-ndk/bin + +# Libz is distributed in the android ndk, but for some unknown reason it is not +# found in the build process of some crates, so we explicit set the DEP_Z_ROOT +ENV CARGO_TARGET_X86_64_LINUX_ANDROID_LINKER=x86_64-linux-android-clang \ + CARGO_TARGET_X86_64_LINUX_ANDROID_RUNNER="qemu-x86_64 -cpu qemu64,+mmx,+sse,+sse2,+sse3,+ssse3,+sse4.1,+sse4.2,+popcnt" \ + CC_x86_64_linux_android=x86_64-linux-android-clang \ + CXX_x86_64_linux_android=x86_64-linux-android-clang++ \ + CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=armv7a-linux-androideabi21-clang \ + CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_RUNNER=qemu-arm \ + CC_armv7_linux_androideabi=armv7a-linux-androideabi21-clang \ + CXX_armv7_linux_androideabi=armv7a-linux-androideabi21-clang++ \ + CC_aarch64_linux_android=aarch64-linux-android21-clang \ + CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=aarch64-linux-android21-clang \ + CC_armv7_linux_androideabi=armv7a-linux-androideabi21-clang \ + CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=armv7a-linux-androideabi21-clang \ + DEP_Z_INCLUDE=/android-ndk/sysroot/usr/include/ \ + OPENSSL_STATIC=1 \ + OPENSSL_DIR=/openssl \ + OPENSSL_INCLUDE_DIR=/openssl/include \ + OPENSSL_LIB_DIR=/openssl/lib \ + RUST_TEST_THREADS=1 \ + HOME=/tmp/ \ + TMPDIR=/tmp/ \ + ANDROID_DATA=/ \ + ANDROID_DNS_MODE=local \ + ANDROID_ROOT=/system \ No newline at end of file diff --git a/.docker/komodo-wallet-android.dockerfile b/.docker/komodo-wallet-android.dockerfile new file mode 100644 index 0000000000..6fef3b9146 --- /dev/null +++ b/.docker/komodo-wallet-android.dockerfile @@ -0,0 +1,41 @@ +FROM komodo/kdf-android:latest AS build + +RUN cd /app && \ + rustup default nightly-2023-06-01 && \ + rustup target add aarch64-linux-android && \ + rustup target add armv7-linux-androideabi && \ + export PATH=$PATH:/android-ndk/bin && \ + CC_aarch64_linux_android=aarch64-linux-android21-clang CARGO_TARGET_AARCH64_LINUX_ANDROID_LINKER=aarch64-linux-android21-clang cargo rustc --target=aarch64-linux-android --lib --release --crate-type=staticlib --package mm2_bin_lib && \ + CC_armv7_linux_androideabi=armv7a-linux-androideabi21-clang CARGO_TARGET_ARMV7_LINUX_ANDROIDEABI_LINKER=armv7a-linux-androideabi21-clang cargo rustc --target=armv7-linux-androideabi --lib --release --crate-type=staticlib --package mm2_bin_lib && \ + mv target/aarch64-linux-android/release/libkdflib.a target/aarch64-linux-android/release/libmm2.a && \ + mv target/armv7-linux-androideabi/release/libkdflib.a target/armv7-linux-androideabi/release/libmm2.a + +FROM komodo/android-sdk:34 AS final + +ENV FLUTTER_VERSION="3.22.3" +ENV FLUTTER_HOME="/home/komodo/.flutter-sdk" +ENV USER="komodo" +ENV PATH=$PATH:$FLUTTER_HOME/bin +ENV ANDROID_AARCH64_LIB=android/app/src/main/cpp/libs/arm64-v8a +ENV ANDROID_AARCH64_LIB_SRC=/app/target/aarch64-linux-android/release/libmm2.a +ENV ANDROID_ARMV7_LIB=android/app/src/main/cpp/libs/armeabi-v7a +ENV ANDROID_ARMV7_LIB_SRC=/app/target/armv7-linux-androideabi/release/libmm2.a + +USER $USER + +WORKDIR /app +COPY --chown=$USER:$USER . . + +RUN mkdir -p android/app/src/main/cpp/libs/armeabi-v7a && \ + mkdir -p android/app/src/main/cpp/libs/arm64-v8a && \ + git clone https://github.com/flutter/flutter.git ${FLUTTER_HOME} && \ + cd ${FLUTTER_HOME} && \ + git fetch && \ + git checkout tags/$FLUTTER_VERSION + +COPY --from=build --chown=$USER:$USER ${ANDROID_AARCH64_LIB_SRC} ${ANDROID_AARCH64_LIB} +COPY --from=build --chown=$USER:$USER ${ANDROID_ARMV7_LIB_SRC} ${ANDROID_ARMV7_LIB} + +RUN flutter config --no-analytics \ + && yes "y" | flutter doctor --android-licenses \ + && flutter doctor \ No newline at end of file diff --git a/.firebaserc b/.firebaserc new file mode 100644 index 0000000000..289586b464 --- /dev/null +++ b/.firebaserc @@ -0,0 +1,5 @@ +{ + "projects": { + "default": "komodo-wallet-official" + } +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000..8c5acd5bec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,34 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: bug +assignees: '' + +--- + +**To Reproduce** +1. Go to '...' +2. Click on '....' +3. Scroll down to '....' +4. See error + +**Screenshots** +If applicable, add screenshots to help explain your problem. +Try to include surrounding elements, like browser address-bar, open dev console etc. + +**Expected behavior** +A clear and concise description of what you expected to happen. + +**Found in** +Branch name, link to deployed version, commit SHA etc. + +**Environment:** + - Platform: [e.g. Desktop/Mobile] + - Device: [e.g. iPhone6] + - OS: [e.g. iOS8.1] + - Browser [e.g. stock browser, safari] + - Build mode [e.g. Debug/Release] + +**Additional context** +Add any other context about the problem here. diff --git a/.github/ISSUE_TEMPLATE/dev.md b/.github/ISSUE_TEMPLATE/dev.md new file mode 100644 index 0000000000..3b94f6b402 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/dev.md @@ -0,0 +1,11 @@ +--- +name: Dev +about: I'm having problem setting up project or dev environment, building or running + app, testing, debugging, etc +title: '' +labels: dev +assignees: '' + +--- + + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000..11fc491ef1 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: enhancement +assignees: '' + +--- + +**Is your feature request related to a problem? Please describe.** +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +**Describe the solution you'd like** +A clear and concise description of what you want to happen. + +**Describe alternatives you've considered** +A clear and concise description of any alternative solutions or features you've considered. + +**Additional context** +Add any other context or screenshots about the feature request here. diff --git a/.github/ISSUE_TEMPLATE/placeholder--note.md b/.github/ISSUE_TEMPLATE/placeholder--note.md new file mode 100644 index 0000000000..015726b2f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/placeholder--note.md @@ -0,0 +1,12 @@ +--- +name: Placeholder, note +about: Place for future issue +title: '' +labels: placeholder +assignees: '' + +--- + +This issue is a placeholder and not supposed to be planned or prioritised until more details provided by its author. + + diff --git a/.github/workflows/firebase-hosting-merge.yml b/.github/workflows/firebase-hosting-merge.yml new file mode 100644 index 0000000000..846a5ca925 --- /dev/null +++ b/.github/workflows/firebase-hosting-merge.yml @@ -0,0 +1,111 @@ +name: Deploy to Firebase Hosting on merge +on: + push: + branches: + - dev +jobs: + build_and_deploy: + runs-on: ubuntu-latest + + steps: + - name: Shortify commit sha + shell: bash + run: echo "sha_short=$(echo ${GITHUB_SHA::7})" >> $GITHUB_OUTPUT + id: shortify_commit + + - name: Get branch + shell: bash + run: echo "ref_short=$(echo ${GITHUB_REF##*/})" >> $GITHUB_OUTPUT + id: get_branch + + - name: Setup GH Actions + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Get stable flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.22.x' + channel: 'stable' + + - name: Prepare build directory + run: | + flutter clean + rm -rf build/* + rm -rf web/src/mm2/* + rm -rf web/src/kdfi/* + rm -rf web/dist/* + + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + - name: Fetch packages and generate assets + run: | + echo "Running \`flutter build\` to generate assets for the deployment build" + flutter pub get > /dev/null 2>&1 + flutter build web --release > /dev/null 2>&1 || true + flutter pub get > /dev/null 2>&1 + echo "Done fetching packages and generating assets" + + - name: Build Komodo Wallet web + run: | + flutter doctor -v + flutter build web --csp --profile --no-web-resources-cdn + + - name: Validate build + run: | + # Check that the web build folder contains a file with format build/web/dist/*.wasm + if [ ! -f build/web/dist/*.wasm ]; then + echo "Error: Web build failed. No wasm file found in build/web/dist/" + + echo "Listing files in build/web recursively" + ls -R build/web + + echo "Listing files in web recursively" + ls -R web + + exit 1 + fi + # Check that the index.html is present and that it is equal to the source index.html + if ! cmp -s web/index.html build/web/index.html; then + echo "Error: Web build failed. index.html is not equal to the source index.html" + exit 1 + fi + # Check that the index.html has uncommitted changes to ensure that the placeholder was replaced with the generated content + if git diff --exit-code web/index.html; then + echo "Error: Web build failed. index.html has no uncommitted changes which indicates an issue with the \`template.html\` to \`index.html\` generation" + exit 1 + fi + # Decode the AssetManifest.bin and check for the coin icon presence + if [ ! -f build/web/assets/AssetManifest.bin ]; then + echo "Error: AssetManifest.bin file not found." + exit 1 + fi + if ! strings build/web/assets/AssetManifest.bin | grep -q "assets/coin_icons/png/kmd.png"; then + echo "Error: Coin icon not found in AssetManifest.bin" + exit 1 + fi + + - name: Deploy Komodo Wallet Web dev preview (`dev` branch) + if: github.ref == 'refs/heads/dev' + uses: FirebaseExtended/action-hosting-deploy@v0.7.1 + with: + repoToken: '${{ secrets.GITHUB_TOKEN }}' + firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_KOMODO_WALLET_OFFICIAL }}' + channelId: live + target: walletrc + projectId: komodo-wallet-official + + - name: Deploy Komodo Wallet Web RC (`master` branch) + if: github.ref == 'refs/heads/master' + uses: FirebaseExtended/action-hosting-deploy@v0.7.1 + with: + repoToken: '${{ secrets.GITHUB_TOKEN }}' + firebaseServiceAccount: '${{ secrets.FIREBASE_SERVICE_ACCOUNT_KOMODO_WALLET_OFFICIAL }}' + channelId: live + target: prodrc + projectId: komodo-wallet-official diff --git a/.github/workflows/firebase-hosting-pull-request.yml b/.github/workflows/firebase-hosting-pull-request.yml new file mode 100644 index 0000000000..f2b622fe8e --- /dev/null +++ b/.github/workflows/firebase-hosting-pull-request.yml @@ -0,0 +1,129 @@ +# This file was auto-generated by the Firebase CLI +# https://github.com/firebase/firebase-tools + +name: Deploy to Firebase Hosting on PR +on: + pull_request: + branches: + - dev + workflow_dispatch: + +jobs: + build_and_preview: + if: ${{ github.event.pull_request.head.repo.full_name == github.repository || github.event_name == 'workflow_dispatch' }} + runs-on: ubuntu-latest + + steps: + - name: Shortify commit sha + shell: bash + run: echo "sha_short=$(echo ${GITHUB_SHA::7})" >> $GITHUB_OUTPUT + id: shortify_commit + + - name: Get branch + shell: bash + run: echo "ref_short=$(echo ${GITHUB_REF##*/})" >> $GITHUB_OUTPUT + id: get_branch + + - name: Get Preview Channel ID + shell: bash + run: echo "preview_channel_id=$(echo ${GITHUB_REF#*/})" >> $GITHUB_OUTPUT + id: get_preview_channel_id + + - name: Setup GH Actions + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Get stable flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: "3.22.x" + channel: "stable" + + - name: Prepare build directory + run: | + flutter clean + rm -rf build/* + rm -rf web/src/mm2/* + rm -rf web/src/kdfi/* + rm -rf web/dist/* + + - name: Fetch packages and generate assets + run: | + echo "Running \`flutter build\` to generate assets for the deployment build" + flutter pub get > /dev/null 2>&1 + flutter build web --release > /dev/null 2>&1 || true + flutter pub get > /dev/null 2>&1 + echo "Done fetching packages and generating assets" + + - name: Build Komodo Wallet web + run: | + flutter doctor + # https://github.com/flutter/flutter/issues/60069#issuecomment-1913588937 + flutter build web --csp --no-web-resources-cdn + + - name: Validate build + run: | + # Check that the web build folder contains a wasm file in the format build/web/dist/*.wasm + if [ ! -f build/web/dist/*.wasm ]; then + echo "Error: Web build failed. No wasm file found in build/web/dist/" + # List files for debugging + echo "Listing files in build/web recursively" + ls -R build/web + + echo "Listing files in web recursively" + ls -R web + + exit 1 + fi + # Check that the index.html is present and that it is equal to the source index.html + if ! cmp -s web/index.html build/web/index.html; then + echo "Error: Web build failed. index.html is not equal to the source index.html" + exit 1 + fi + # Check that the index.html has uncommitted changes to ensure that the placeholder was replaced with the generated content + if git diff --exit-code web/index.html; then + echo "Error: Web build failed. index.html has no uncommitted changes which indicates an issue with the \`template.html\` to \`index.html\` generation" + exit 1 + fi + # Decode the AssetManifest.bin and check for the coin icon presence + if [ ! -f build/web/assets/AssetManifest.bin ]; then + echo "Error: AssetManifest.bin file not found." + exit 1 + fi + if ! strings build/web/assets/AssetManifest.bin | grep -q "assets/coin_icons/png/kmd.png"; then + echo "Error: Coin icon not found in AssetManifest.bin" + exit 1 + fi + + - name: Deploy Komodo Wallet web feature preview (Expires in 7 days) + uses: FirebaseExtended/action-hosting-deploy@v0.7.1 + with: + repoToken: "${{ secrets.GITHUB_TOKEN }}" + firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_KOMODO_WALLET_OFFICIAL }}" + channelId: "${{ steps.get_preview_channel_id.outputs.preview_channel_id }}" + target: walletrc + expires: 7d + projectId: komodo-wallet-official + + - name: Deploy Komodo Wallet Web dev preview (`dev` branch) + if: github.ref == 'refs/heads/dev' && github.event_name != 'workflow_dispatch' + uses: FirebaseExtended/action-hosting-deploy@v0.7.1 + with: + repoToken: "${{ secrets.GITHUB_TOKEN }}" + firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_KOMODO_WALLET_OFFICIAL }}" + channelId: dev-preview + target: walletrc + projectId: komodo-wallet-official + + - name: Deploy Komodo Wallet Web Production (`master` branch) + if: github.ref == 'refs/heads/master' && github.event_name != 'workflow_dispatch' + uses: FirebaseExtended/action-hosting-deploy@v0.7.1 + with: + repoToken: "${{ secrets.GITHUB_TOKEN }}" + firebaseServiceAccount: "${{ secrets.FIREBASE_SERVICE_ACCOUNT_KOMODO_WALLET_OFFICIAL }}" + channelId: live + projectId: komodo-wallet-official diff --git a/.github/workflows/ui-tests-on-pr.yml b/.github/workflows/ui-tests-on-pr.yml new file mode 100644 index 0000000000..f5f4536da4 --- /dev/null +++ b/.github/workflows/ui-tests-on-pr.yml @@ -0,0 +1,141 @@ +name: UI flutter tests on PR + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + tests: + name: Test ${{ matrix.name }} + runs-on: ${{ matrix.os }} + timeout-minutes: 45 + strategy: + fail-fast: false + matrix: + name: [ + web-app-linux, + web-app-macos, + ] + include: + - name: web-app-linux + os: [self-hosted, Linux] + + - name: web-app-macos + os: [self-hosted, macos] + + steps: + + - name: Setup GH Actions + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - run: | + npx @puppeteer/browsers install chromedriver@stable + + - name: Get stable flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.22.x' + channel: 'stable' + + - name: Prepare build directory + run: | + flutter clean + rm -rf build/* + rm -rf web/src/mm2/* + rm -rf web/src/kdfi/* + rm -rf web/dist/* + + - name: Fetch packages and generate assets + run: | + echo "Running \`flutter build\` to generate assets for the deployment build" + flutter pub get > /dev/null 2>&1 + flutter build web --profile > /dev/null 2>&1 || true + flutter pub get > /dev/null 2>&1 + flutter build web --release > /dev/null 2>&1 || true + echo "Done fetching packages and generating assets" + + - name: Validate build + run: | + # Check that the web build folder contains a wasm file in the format build/web/dist/*.wasm + if [ ! -f build/web/dist/*.wasm ]; then + echo "Error: Web build failed. No wasm file found in build/web/dist/" + # List files for debugging + echo "Listing files in build/web recursively" + ls -R build/web + + echo "Listing files in web recursively" + ls -R web + + exit 1 + fi + # Check that the index.html is present and that it is equal to the source index.html + if ! cmp -s web/index.html build/web/index.html; then + echo "Error: Web build failed. index.html is not equal to the source index.html" + exit 1 + fi + # Check that the index.html has uncommitted changes to ensure that the placeholder was replaced with the generated content + if git diff --exit-code web/index.html; then + echo "Error: Web build failed. index.html has no uncommitted changes which indicates an issue with the \`template.html\` to \`index.html\` generation" + exit 1 + fi + # Decode the AssetManifest.bin and check for the coin icon presence + if [ ! -f build/web/assets/AssetManifest.bin ]; then + echo "Error: AssetManifest.bin file not found." + exit 1 + fi + if ! strings build/web/assets/AssetManifest.bin | grep -q "assets/coin_icons/png/kmd.png"; then + echo "Error: Coin icon not found in AssetManifest.bin" + exit 1 + fi + + - name: Test air_dex chrome (unix) (Linux) + if: runner.name == 'ci-builder-radon' + id: tests-chrome + continue-on-error: true + timeout-minutes: 35 + run: | + chromedriver --port=4444 --silent --enable-chrome-logs --log-path=chrome_console.log & + dart run_integration_tests.dart -d 'headless' -b '1600,1024' --browser-name=chrome + + - name: Enable safaridriver (sudo) (MacOS) + if: runner.name == 'ci-builder-astatine' + timeout-minutes: 1 + run: | + defaults write com.apple.Safari IncludeDevelopMenu YES + defaults write com.apple.Safari AllowRemoteAutomation 1 + sudo /usr/bin/safaridriver --enable || echo "Failed to enable safaridriver!" + + - name: Run safaridriver in background (MacOS) + if: runner.name == 'ci-builder-astatine' + continue-on-error: true + run: | + safaridriver -p 4444 & + + - name: Test air_dex safari (MacOS) + if: runner.name == 'ci-builder-astatine' + id: tests-safari + continue-on-error: true + timeout-minutes: 35 + run: | + flutter pub get + dart run_integration_tests.dart --browser-name=safari + + - name: Upload logs (Linux) + if: runner.name == 'ci-builder-radon' + uses: actions/upload-artifact@v4 + with: + name: ${{ runner.os }}-chromedriver-logs.zip + path: ./chrome_console.log + + - name: Fail workflow if tests failed (Linux) + if: runner.name == 'ci-builder-radon' && steps.tests-chrome.outcome == 'failure' + run: exit 1 + + - name: Fail workflow if tests failed (MacOS) + if: runner.name == 'ci-builder-astatine' && steps.tests-safari.outcome == 'failure' + run: exit 1 diff --git a/.github/workflows/unit-tests-on-pr.yml b/.github/workflows/unit-tests-on-pr.yml new file mode 100644 index 0000000000..b31ae0a9ac --- /dev/null +++ b/.github/workflows/unit-tests-on-pr.yml @@ -0,0 +1,86 @@ +name: Run unit test on PR + +on: + pull_request: + types: [opened, synchronize, reopened] + +jobs: + build_and_preview: + runs-on: [self-hosted, Linux] + timeout-minutes: 15 + + steps: + - name: Setup GH Actions + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '18' + + - run: | + npx @puppeteer/browsers install chromedriver@stable + + - name: Get stable flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.22.x' + channel: 'stable' + + - name: Prepare build directory + run: | + flutter clean + rm -rf build/* + rm -rf web/src/mm2/* + rm -rf web/src/kdfi/* + rm -rf web/dist/* + + - name: Fetch packages and generate assets + run: | + echo "Running \`flutter build\` to generate assets for the deployment build" + flutter pub get > /dev/null 2>&1 + flutter build web --release > /dev/null 2>&1 || true + flutter pub get > /dev/null 2>&1 + flutter build web --release > /dev/null 2>&1 || true + echo "Done fetching packages and generating assets" + + - name: Validate build + run: | + # Check that the web build folder contains a wasm file in the format build/web/dist/*.wasm + if [ ! -f build/web/dist/*.wasm ]; then + echo "Error: Web build failed. No wasm file found in build/web/dist/" + # List files for debugging + echo "Listing files in build/web recursively" + ls -R build/web + + echo "Listing files in web recursively" + ls -R web + + exit 1 + fi + # Check that the index.html is present and that it is equal to the source index.html + if ! cmp -s web/index.html build/web/index.html; then + echo "Error: Web build failed. index.html is not equal to the source index.html" + exit 1 + fi + # Check that the index.html has uncommitted changes to ensure that the placeholder was replaced with the generated content + if git diff --exit-code web/index.html; then + echo "Error: Web build failed. index.html has no uncommitted changes which indicates an issue with the \`template.html\` to \`index.html\` generation" + exit 1 + fi + # Decode the AssetManifest.bin and check for the coin icon presence + if [ ! -f build/web/assets/AssetManifest.bin ]; then + echo "Error: AssetManifest.bin file not found." + exit 1 + fi + if ! strings build/web/assets/AssetManifest.bin | grep -q "assets/coin_icons/png/kmd.png"; then + echo "Error: Coin icon not found in AssetManifest.bin" + exit 1 + fi + + - name: Test unit_test (unix) + id: unit_tests + continue-on-error: false + timeout-minutes: 15 + run: | + flutter test test_units/main.dart diff --git a/.github/workflows/validate-code-guidelines.yml b/.github/workflows/validate-code-guidelines.yml new file mode 100644 index 0000000000..eed0250384 --- /dev/null +++ b/.github/workflows/validate-code-guidelines.yml @@ -0,0 +1,34 @@ +# Rule for running static analysis and code formatting checks on all PRs +name: Validate Code Guidelines +on: + pull_request: + branches: + - '*' +jobs: + build_and_deploy: + runs-on: ubuntu-latest + + steps: + + - name: Setup GH Actions + uses: actions/checkout@v4 + + - name: Get stable flutter + uses: subosito/flutter-action@v2 + with: + flutter-version: '3.22.x' + channel: 'stable' + + - name: Fetch packages and generate assets + run: | + echo "Running \`flutter build\` to generate assets for the deployment build" + flutter pub get > /dev/null 2>&1 + flutter build web --release > /dev/null 2>&1 || true + flutter pub get > /dev/null 2>&1 + echo "Done fetching packages and generating assets" + + - name: Validate dart code + run: | + flutter analyze + # Currently skipped due to many changes. Will be enabled in the future after doing full sweep of the codebase + # dart format --set-exit-if-changed . diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000..775a362c6b --- /dev/null +++ b/.gitignore @@ -0,0 +1,92 @@ +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# IntelliJ related +*.iml +*.ipr +*.iws +.idea/ + +# The .vscode folder contains launch configuration and tasks you configure in +# VS Code which you may wish to be included in version control, so this line +# is commented out by default. +.vscode/ +.vs/ + +# Firebase extras +.firebase/ + +# Flutter/Dart/Pub related +**/doc/api/ +**/ios/Flutter/.last_build_id +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +.pub-cache/ +.pub/ +/build/ +contrib/coins_config.json + +# Web related +web/dist/*.js +web/dist/*.wasm +web/dist/*LICENSE.txt +web/src/mm2/ +web/src/kdfi/ + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Android Studio will place build artifacts here +/android/app/debug +/android/app/profile +/android/app/release + +# CI/CD Extras +demo_link +airdex-build.tar.gz +**/test_wallet.json +**/debug_data.json +**/config/firebase_analytics.json + +# js +node_modules + +assets/config/test_wallet.json +assets/**/debug_data.json +contrib/coins_config.json + +# api native library +libmm2.a +windows/**/mm2.exe +linux/mm2/mm2 +macos/mm2 +libkdf.a +windows/**/kdf.exe +linux/mm2/kdf +macos/kdf +**/.api_last_updated* + +# Android C++ files +android/app/.cxx/ + +# Coins asset files +assets/config/coins.json +assets/config/coins_config.json +assets/config/coins_ci.json +assets/coin_icons/ + +# Python +venv/ \ No newline at end of file diff --git a/.metadata b/.metadata new file mode 100644 index 0000000000..d661586e18 --- /dev/null +++ b/.metadata @@ -0,0 +1,45 @@ +# This file tracks properties of this Flutter project. +# Used by Flutter tool to assess capabilities and perform upgrades etc. +# +# This file should be version controlled. + +version: + revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + channel: unknown + +project_type: app + +# Tracks metadata for the flutter migrate command +migration: + platforms: + - platform: root + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: android + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: ios + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: linux + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: macos + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: web + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + - platform: windows + create_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + base_revision: b06b8b2710955028a6b562f5aa6fe62941d6febf + + # User provided section + + # List of Local paths (relative to this file) that should be + # ignored by the migrate tool. + # + # Files that are not part of the templates will be ignored by default. + unmanaged_files: + - 'lib/main.dart' + - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/README.md b/README.md new file mode 100644 index 0000000000..50f713273e --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# Komodo Wallet & DEX + +[![style: very good analysis](https://img.shields.io/badge/style-very_good_analysis-B22C89.svg)](https://pub.dev/packages/very_good_analysis) + +![web_app](https://github.com/KomodoPlatform/komodo-wallet-archive/assets/10762374/ca06f4bc-2e7a-40c6-9e06-e0872a32cbdf) + +[![Open in GitHub Codespaces](https://github.com/codespaces/badge.svg)](https://codespaces.new/KomodoPlatform/komodo-wallet?quickstart=1) + +### Runs on: + - [Web](https://app.komodoplatform.com) + - Desktop + - Windows + - MacOS + - Linux + - Mobile + - Android + - iOS + +---- + +## Developer guide. + +Current production version is available here: https://app.komodoplatform.com + +### Index +- [Code of Conduct](docs/CODE_OF_CONDUCT.md) +- [Project setup](docs/PROJECT_SETUP.md) +- [Firebase Setup](docs/FIREBASE_SETUP.md) +- [Coins config, update](docs/COINS_CONFIG.md) +- [API module, update](docs/UPDATE_API_MODULE.md) +- [App version, update](docs/UPDATE_APP_VERSION.md) +- [Run the App](docs/BUILD_RUN_APP.md) +- [Build release version of the App](docs/BUILD_RELEASE.md) +- [Manual testing and debugging](docs/MANUAL_TESTING_DEBUGGING.md) +- [Localization](docs/LOCALIZATION.md) +- [Unit testing](docs/UNIT_TESTING.md) +- [Integration testing](docs/INTEGRATION_TESTING.md) +- [Gitflow and branching strategy](docs/GITFLOW_BRANCHING.md) +- [Issue: create and maintain](docs/ISSUE.md) ...in progress +- [Contribution guide](docs/CONTRIBUTION_GUIDE.md) + diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000000..111e0b9647 --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,41 @@ +# This file configures the analyzer, which statically analyzes Dart code to +# check for errors, warnings, and lints. +# +# The issues identified by the analyzer are surfaced in the UI of Dart-enabled +# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be +# invoked from the command line by running `flutter analyze`. + +# 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 + +linter: + # The lint rules applied to this project can be customized in the + # section below to disable rules from the `package:flutter_lints/flutter.yaml` + # included above or to enable additional rules. A list of all available lints + # and their documentation is published at + # https://dart-lang.github.io/linter/lints/index.html. + # + # Instead of disabling a lint rule for the entire project in the + # section below, it can also be suppressed for a single line of code + # or a specific dart file by using the `// ignore: name_of_lint` and + # `// ignore_for_file: name_of_lint` syntax on the line or in the file + # producing the lint. + rules: + # avoid_print: false # Uncomment to disable the `avoid_print` rule + # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule + + # Turned off to avoid redundant dependecies (already included to other dependencies), + # and reduce amount of work on secure code review + depend_on_referenced_packages: false + # Converting all (97) widgets to const will require significant amount of testing, + # debugging and refactoring, so disabled for now. + use_key_in_widget_constructors: false + + # TO ALL CONTRIBUTORS: Please, enable the following lint rule and apply fixes + # to all code created/updated in your PRs. This will help to keep the code clean + # and consistent by making the formatting deterministic and consistent. + # require_trailing_commas: true + +# Additional information about this file can be found at +# https://dart.dev/guides/language/analysis-options diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000000..6f568019d3 --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,13 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties +**/*.keystore +**/*.jks diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000000..babbe598c0 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,77 @@ +plugins { + id "com.android.application" + id "kotlin-android" + id "dev.flutter.flutter-gradle-plugin" +} + +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +android { + namespace 'com.komodoplatform.atomicdex' + + compileSdk 34 + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + kotlinOptions { + jvmTarget = '11' + } + sourceSets { + main.java.srcDirs += 'src/main/kotlin' + } + + defaultConfig { + applicationId "com.komodoplatform.atomicdex" + minSdkVersion 28 + targetSdkVersion 34 + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName + + externalNativeBuild { + cmake { + // mm2_lib.a requires libc++ + cppFlags "-std=c++17 -stdlib=libc++" + } + } + ndk { + //noinspection ChromeOsAbiSupport + abiFilters 'armeabi-v7a', 'arm64-v8a' + } + } + ndkVersion '25.1.8937393' + + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } + } + + externalNativeBuild { + cmake { + path "src/main/cpp/CMakeLists.txt" + } + } +} + +flutter { + source '../..' +} diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 0000000000..4d1a38062a --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,39 @@ +{ + "project_info": { + "project_number": "", + "project_id": "", + "storage_bucket": "" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "", + "android_client_info": { + "package_name": "" + } + }, + "oauth_client": [ + { + "client_id": "", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "", + "client_type": 3 + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/android/app/src/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..172885d366 --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..72421b4df8 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt new file mode 100644 index 0000000000..b368a53295 --- /dev/null +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -0,0 +1,27 @@ +cmake_minimum_required(VERSION 3.4.1) + +find_library(log-lib log) +find_package(ZLIB) + +set(IMPORT_DIR ${CMAKE_SOURCE_DIR}/libs) + +add_library(mm2-lib + SHARED + mm2_native.cpp +) + +add_library(mm2-api + STATIC + IMPORTED +) + +set_target_properties(mm2-api + PROPERTIES IMPORTED_LOCATION + ${IMPORT_DIR}/${ANDROID_ABI}/libmm2.a +) + +target_link_libraries(mm2-lib + mm2-api + ${log-lib} + ZLIB::ZLIB +) diff --git a/android/app/src/main/cpp/libs/arm64-v8a/.gitkeep b/android/app/src/main/cpp/libs/arm64-v8a/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/android/app/src/main/cpp/libs/armeabi-v7a/.gitkeep b/android/app/src/main/cpp/libs/armeabi-v7a/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/android/app/src/main/cpp/mm2_native.cpp b/android/app/src/main/cpp/mm2_native.cpp new file mode 100644 index 0000000000..7bfc682aa6 --- /dev/null +++ b/android/app/src/main/cpp/mm2_native.cpp @@ -0,0 +1,209 @@ +#include +#include +#include +#include + +extern "C" { + +/// Defined in "mm2_lib.rs". +int8_t mm2_main(const char *conf, void (*log_cb)(const char *line)); + +/// Checks if the MM2 singleton thread is currently running or not. +/// 0 .. not running. +/// 1 .. running, but no context yet. +/// 2 .. context, but no RPC yet. +/// 3 .. RPC is up. +int8_t mm2_main_status(); + +/// Defined in "mm2_lib.rs". +/// 0 .. MM2 has been stopped successfully. +/// 1 .. not running. +/// 2 .. error stopping an MM2 instance. +int8_t mm2_stop(); + +} + +#define STRINGIFY(x) #x +#define TOSTRING(x) STRINGIFY(x) +#define LTAG "mm2_native:" TOSTRING(__LINE__) "] " + +#define LOG_D(format, ...) __android_log_print(ANDROID_LOG_DEBUG, LTAG, format "\n", ##__VA_ARGS__) +#define LOG_E(format, ...) __android_log_print(ANDROID_LOG_ERROR, LTAG, format "\n", ##__VA_ARGS__) + +class LogHandler { +public: + static std::optional create(JNIEnv *env, jobject log_listener) { + JavaVM *jvm = nullptr; + // Returns “0” on success; returns a negative value on failure. + if (env->GetJavaVM(&jvm)) { + LOG_E("Couldn't get JavaVM"); + return std::nullopt; + } + + // Returns a global reference, or NULL if the system runs out of memory. + jobject listener = env->NewGlobalRef(log_listener); + if (!listener) { + LOG_E("Couldn't create a listener global reference"); + return std::nullopt; + } + + jclass obj = env->GetObjectClass(listener); + // Returns a method ID, or NULL if the specified method cannot be found. + jmethodID log_callback = env->GetMethodID(obj, "onLog", "(Ljava/lang/String;)V"); + if (!log_callback) { + LOG_E("Couldn't get method ID"); + // GetMethodID could threw an exception. + exception_check(env); + return std::nullopt; + } + + return LogHandler(jvm, listener, log_callback); + } + + explicit LogHandler(JavaVM *jvm, jobject listener, jmethodID log_callback) + : m_jvm(jvm), + m_listener(listener), + m_log_callback(log_callback) { + } + + // Note: LogHandler::release() must be called before the destructor. + ~LogHandler() = default; + + void release(JNIEnv *env) { + env->DeleteGlobalRef(m_listener); + // Do not release m_jvm and m_log_callback. + } + + void replaceInvalidUtf8Bytes(const char *src, char *dst) { + unsigned int len = strlen(src); + unsigned int j = 0; + + for (unsigned int i = 0; i < len;) { + unsigned char byte = static_cast(src[i]); + + if (byte <= 0x7F) { + dst[j++] = byte; + i += 1; + } else if ((byte & 0xE0) == 0xC0 && i + 1 < len && (src[i + 1] & 0xC0) == 0x80) { + dst[j++] = byte; + dst[j++] = src[i + 1]; + i += 2; + } else if ((byte & 0xF0) == 0xE0 && i + 2 < len && (src[i + 1] & 0xC0) == 0x80 && (src[i + 2] & 0xC0) == 0x80) { + dst[j++] = byte; + dst[j++] = src[i + 1]; + dst[j++] = src[i + 2]; + i += 3; + } else if ((byte & 0xF8) == 0xF0 && i + 3 < len && (src[i + 1] & 0xC0) == 0x80 && (src[i + 2] & 0xC0) == 0x80 && (src[i + 3] & 0xC0) == 0x80) { + dst[j++] = byte; + dst[j++] = src[i + 1]; + dst[j++] = src[i + 2]; + dst[j++] = src[i + 3]; + i += 4; + } else { + // Replace invalid byte sequence with '?' (0x3F) + dst[j++] = '?'; + i += 1; + } + } + + dst[j] = '\0'; + } + + + void process_log_line(const char *line) { + JNIEnv *env = nullptr; + int env_stat = m_jvm->GetEnv((void **)&env, JNI_VERSION_1_6); + if (env_stat == JNI_EDETACHED) { + // Should another thread need to access the Java VM, it must first call AttachCurrentThread() + // to attach itself to the VM and obtain a JNI interface pointer. + // https://docs.oracle.com/javase/9/docs/specs/jni/invocation.html#attaching-to-the-vm + + if (m_jvm->AttachCurrentThread(&env, nullptr) != 0) { + LOG_E("Failed to attach"); + return; + } + } else if (env_stat == JNI_EVERSION) { + LOG_E("Version not supported"); + return; + } else if (env_stat != JNI_OK) { + LOG_E("Unexpected error"); + return; + } + + char rplc_line[strlen(line) * 3 + 1]; // Space for the worst case scenario (all bytes replaced by the 3-byte replacement character sequence) + replaceInvalidUtf8Bytes(line, rplc_line); + + jstring jline = env->NewStringUTF(rplc_line); + // Call a Java callback. + env->CallVoidMethod(m_listener, m_log_callback, jline); + // CallVoidMethod could threw an exception. + exception_check(env); + + if (env_stat == JNI_EDETACHED) { + // Detach itself before exiting. + m_jvm->DetachCurrentThread(); + } + } + +private: + static void exception_check(JNIEnv *env) { + if (env->ExceptionCheck()) { + LOG_E("An exception is being thrown"); + // Prints an exception and a backtrace of the stack to a system error-reporting channel, such as stderr + env->ExceptionDescribe(); + // Clears any exception that is currently being thrown + env->ExceptionClear(); + } + } + + JavaVM *m_jvm = nullptr; + jobject m_listener = nullptr; + jmethodID m_log_callback = nullptr; +}; + +static std::mutex LOG_MUTEX; +static std::optional LOG_HANDLER; + +extern "C" JNIEXPORT jbyte JNICALL +Java_com_komodoplatform_atomicdex_MainActivity_nativeMm2Main( + JNIEnv *env, + jobject, /* this */ + jstring conf, + jobject log_listener) { + { + const auto lock = std::lock_guard(LOG_MUTEX); + if (LOG_HANDLER) { + LOG_D("LOG_HANDLER is initialized already, release it"); + LOG_HANDLER->release(env); + } + LOG_HANDLER = LogHandler::create(env, log_listener); + } + + const char *c_conf = env->GetStringUTFChars(conf, nullptr); + + const auto result = mm2_main(c_conf, [](const char *line) { + const auto lock = std::lock_guard(LOG_MUTEX); + if (!LOG_HANDLER) { + LOG_E("LOG_HANDLER is not initialized"); + return; + } + LOG_HANDLER->process_log_line(line); + }); + + env->ReleaseStringUTFChars(conf, c_conf); + return static_cast(result); +} + +extern "C" JNIEXPORT jbyte JNICALL +Java_com_komodoplatform_atomicdex_MainActivity_nativeMm2MainStatus( + JNIEnv *, + jobject /* this */) { + return static_cast(mm2_main_status()); +} + +extern "C" JNIEXPORT jbyte JNICALL +Java_com_komodoplatform_atomicdex_MainActivity_nativeMm2Stop( + JNIEnv *, + jobject /* this */) { + return static_cast(mm2_stop()); +} diff --git a/android/app/src/main/ic_launcher-playstore.png b/android/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000000..002c03d8f8 Binary files /dev/null and b/android/app/src/main/ic_launcher-playstore.png differ diff --git a/android/app/src/main/java/com/komodoplatform/atomicdex/MainActivity.java b/android/app/src/main/java/com/komodoplatform/atomicdex/MainActivity.java new file mode 100644 index 0000000000..23a56f8da4 --- /dev/null +++ b/android/app/src/main/java/com/komodoplatform/atomicdex/MainActivity.java @@ -0,0 +1,230 @@ +package com.komodoplatform.atomicdex; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.NonNull; + +import java.io.BufferedOutputStream; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.OutputStream; +import java.io.OutputStreamWriter; +import java.util.Map; + +import io.flutter.embedding.android.FlutterActivity; +import io.flutter.embedding.engine.FlutterEngine; +import io.flutter.plugin.common.BinaryMessenger; +import io.flutter.plugin.common.EventChannel; +import io.flutter.plugin.common.MethodChannel; +import io.flutter.plugins.GeneratedPluginRegistrant; + + +public class MainActivity extends FlutterActivity { + private EventChannel.EventSink logsSink; + private final Handler logHandler = new Handler(Looper.getMainLooper()); + + private boolean isSafBytes = false; + private MethodChannel.Result safResult; + private String safData; + private byte[] safDataBytes; + private static final int CREATE_SAF_FILE = 21; + private static final String TAG_CREATE_SAF_FILE = "CREATE_SAF_FILE"; + + static { + System.loadLibrary("mm2-lib"); + } + + + private void nativeC(FlutterEngine flutterEngine) { + BinaryMessenger bm = flutterEngine.getDartExecutor().getBinaryMessenger(); + EventChannel logsChannel = new EventChannel(bm, "komodo-web-dex/event"); + logsChannel.setStreamHandler(new EventChannel.StreamHandler() { + @Override + public void onListen(Object arguments, EventChannel.EventSink events) { + logsSink = events; + } + + @Override + public void onCancel(Object arguments) { + logsSink = null; + } + }); + + // https://flutter.dev/docs/development/platform-integration/platform-channels?tab=android-channel-kotlin-tab#step-3-add-an-android-platform-specific-implementation + new MethodChannel(bm, "komodo-web-dex") + .setMethodCallHandler((call, result) -> { + switch (call.method) { + case "start": { + Map arguments = call.arguments(); + onStart(arguments, result); + break; + } + case "status": { + onStatus(result); + break; + } + case "stop": { + onStop(result); + break; + } + default: { + result.notImplemented(); + } + } + }); + + } + + private void setupSaf(FlutterEngine flutterEngine) { + BinaryMessenger bm = flutterEngine.getDartExecutor().getBinaryMessenger(); + new MethodChannel(bm, "komodo-web-dex/AndroidSAF") + .setMethodCallHandler((call, result) -> { + if (call.method.equals("saveFile")) { + Log.i(TAG_CREATE_SAF_FILE, "Triggered saveFile method"); + if (call.arguments() == null) { + result.error("NEED_ARGUMENTS", "Not enough arguments", null); + return; + } + String ext = call.argument("ext"); + String filetype = call.argument("filetype"); + String filename = call.argument("filename"); + + Log.i(TAG_CREATE_SAF_FILE, String.format("File to save is %s.%s, of type %s", filename, ext, filetype)); + + Intent intent = new Intent(Intent.ACTION_CREATE_DOCUMENT); + intent.addCategory(Intent.CATEGORY_OPENABLE); + intent.setType(filetype); + intent.putExtra(Intent.EXTRA_TITLE, String.format("%s.%s", filename, ext)); + + safResult = result; + boolean isDataBytes = Boolean.TRUE.equals(call.argument("isDataBytes")); + if (isDataBytes) { + Log.i(TAG_CREATE_SAF_FILE, "Data to save is in BYTES format"); + safDataBytes = call.argument("data"); + isSafBytes = true; + } else { + Log.i(TAG_CREATE_SAF_FILE, "Data to save is in TEXT format"); + safData = call.argument("data"); + isSafBytes = false; + } + Log.i(TAG_CREATE_SAF_FILE, "Triggering file picker"); + startActivityForResult(intent, CREATE_SAF_FILE); + } else { + result.notImplemented(); + } + }); + } + + @Override + public void onActivityResult(int requestCode, int resultCode, + Intent resultData) { + // MRC: Needed so the custom SAF implementation doesn't break the file_picker plugin + super.onActivityResult(requestCode, resultCode, resultData); + + if (requestCode == CREATE_SAF_FILE + && resultCode == Activity.RESULT_OK) { + Log.i(TAG_CREATE_SAF_FILE, "File picker finished"); + Uri uri; + if (resultData != null) { + Log.i(TAG_CREATE_SAF_FILE, "Grabbing URI returned from file picker"); + uri = resultData.getData(); + Log.d(TAG_CREATE_SAF_FILE, String.format("Target URI = %s", uri.toString())); + try { + OutputStream outputStream = getContentResolver().openOutputStream(uri); + if (isSafBytes) { + Log.i(TAG_CREATE_SAF_FILE, "Starting saving data bytes"); + + BufferedOutputStream bufferedOutStream = new BufferedOutputStream(outputStream); + bufferedOutStream.write(safDataBytes); + bufferedOutStream.flush(); + bufferedOutStream.close(); + outputStream.close(); + } else { + Log.i(TAG_CREATE_SAF_FILE, "Starting saving data as text"); + + BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(outputStream)); + writer.write(safData); + writer.flush(); + writer.close(); + outputStream.close(); + } + Log.i(TAG_CREATE_SAF_FILE, "Successfully saved data"); + safResult.success(true); + } catch (IOException e) { + Log.i(TAG_CREATE_SAF_FILE, "An error happened while saving", e); + safResult.error("ERROR", "Could not write to file", e); + } + } + } + Log.i(TAG_CREATE_SAF_FILE, "Cleaning up..."); + safResult = null; + safData = null; + safDataBytes = null; + } + + + private void onStart(Map arguments, MethodChannel.Result result) { + + if (arguments == null) { + result.success(0); + return; + } + String arg = arguments.get("params"); + if (arg == null) { + result.success(0); + return; + } + + int ret = startMm2(arg); + result.success(ret); + } + + private void onStatus(MethodChannel.Result result) { + int status = nativeMm2MainStatus(); + result.success(status); + } + + private void onStop(MethodChannel.Result result) { + int ret = nativeMm2Stop(); + result.success(ret); + } + + private int startMm2(String conf) { + + sendLog("START MM2 --------------------------------"); + + return nativeMm2Main(conf, this::sendLog); + } + + private void sendLog(String line) { + if (logsSink != null) { + logHandler.post(() -> logsSink.success(line)); + + } + } + + /// Corresponds to Java_com_komodoplatform_atomicdex_MainActivity_nativeMm2MainStatus in main.cpp + private native byte nativeMm2MainStatus(); + + /// Corresponds to Java_com_komodoplatform_atomicdex_MainActivity_nativeMm2Main in main.cpp + private native byte nativeMm2Main(String conf, JNILogListener listener); + + /// Corresponds to Java_com_komodoplatform_atomicdex_MainActivity_nativeMm2Stop in main.cpp + private native byte nativeMm2Stop(); + + @Override + public void configureFlutterEngine(@NonNull FlutterEngine flutterEngine) { + GeneratedPluginRegistrant.registerWith(flutterEngine); + nativeC(flutterEngine); + setupSaf(flutterEngine); + } +} + +interface JNILogListener { + void onLog(String line); +} \ No newline at end of file diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000000..853b66d0e3 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,21 @@ + + + + + + + + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000000..59bf5e7623 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,12 @@ + + + + + diff --git a/android/app/src/main/res/drawable/launch_background.xml b/android/app/src/main/res/drawable/launch_background.xml new file mode 100644 index 0000000000..596a7db5bd --- /dev/null +++ b/android/app/src/main/res/drawable/launch_background.xml @@ -0,0 +1,7 @@ + + + + + + + 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 new file mode 100644 index 0000000000..f30783b210 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000..f2a0963b70 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher.png differ 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 new file mode 100644 index 0000000000..dfab2e2f45 Binary files /dev/null and b/android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-mdpi/ic_launcher.png b/android/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000000..271e511ed8 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher.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 new file mode 100644 index 0000000000..2e892aa481 Binary files /dev/null and b/android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000..6b6ae80050 Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher.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 new file mode 100644 index 0000000000..408327f72d Binary files /dev/null and b/android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000..af3fd3cd5a Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.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 new file mode 100644 index 0000000000..8821d0e149 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000..3375cf6eaa Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.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 new file mode 100644 index 0000000000..03417db507 Binary files /dev/null and b/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/android/app/src/main/res/values-night/styles.xml b/android/app/src/main/res/values-night/styles.xml new file mode 100644 index 0000000000..3db14bb539 --- /dev/null +++ b/android/app/src/main/res/values-night/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/main/res/values/ic_launcher_background.xml b/android/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000000..4629b3a08d --- /dev/null +++ b/android/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #1C2632 + \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml new file mode 100644 index 0000000000..d460d1e921 --- /dev/null +++ b/android/app/src/main/res/values/styles.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/android/app/src/profile/AndroidManifest.xml b/android/app/src/profile/AndroidManifest.xml new file mode 100644 index 0000000000..172885d366 --- /dev/null +++ b/android/app/src/profile/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle new file mode 100644 index 0000000000..bc157bd1a1 --- /dev/null +++ b/android/build.gradle @@ -0,0 +1,18 @@ +allprojects { + repositories { + google() + mavenCentral() + } +} + +rootProject.buildDir = '../build' +subprojects { + project.buildDir = "${rootProject.buildDir}/${project.name}" +} +subprojects { + project.evaluationDependsOn(':app') +} + +tasks.register("clean", Delete) { + delete rootProject.buildDir +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 0000000000..c37d1f2c71 --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,5 @@ +android.defaults.buildfeatures.buildconfig=true +# R8 set to compat temporily due to an issue with org.xmlpull.v1.XmlPullParser +android.enableR8.fullMode=false +android.useAndroidX=true +org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000000..381baa9cef --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionSha256Sum=544c35d6bd849ae8a5ed0bcea39ba677dc40f49df7d1835561582da2009b961d +distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/settings.gradle b/android/settings.gradle new file mode 100644 index 0000000000..69473a136c --- /dev/null +++ b/android/settings.gradle @@ -0,0 +1,25 @@ +pluginManagement { + def flutterSdkPath = { + def properties = new Properties() + file("local.properties").withInputStream { properties.load(it) } + def flutterSdkPath = properties.getProperty("flutter.sdk") + assert flutterSdkPath != null, "flutter.sdk not set in local.properties" + return flutterSdkPath + }() + + includeBuild("$flutterSdkPath/packages/flutter_tools/gradle") + + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +plugins { + id "dev.flutter.flutter-plugin-loader" version "1.0.0" + id "com.android.application" version "8.3.0" apply false + id "org.jetbrains.kotlin.android" version "1.8.22" apply false +} + +include ":app" \ No newline at end of file diff --git a/app_build/.gitignore b/app_build/.gitignore new file mode 100644 index 0000000000..6769e21d99 --- /dev/null +++ b/app_build/.gitignore @@ -0,0 +1,160 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ \ No newline at end of file diff --git a/app_build/BUILD_CONFIG_README.md b/app_build/BUILD_CONFIG_README.md new file mode 100644 index 0000000000..df66436c3e --- /dev/null +++ b/app_build/BUILD_CONFIG_README.md @@ -0,0 +1,124 @@ +# Build Configuration Guide + +## TL;DR + +- **Configure API**: Ensure your `build_config.json` includes the correct API commit hash, update flags, source URLs, and platform-specific paths and targets. +- **Configure Coins**: Set up the coins repository details, including commit hashes, URLs, branches, and runtime update settings. +- **Run Build Process**: The build steps are automatically executed as part of Flutter's build process. Ensure Node.js 18 is installed. + +--- + +## `build_config.json` Structure + +### Top-Level Keys + +- `api`: Configuration related to the DeFi API updates and supported platforms. +- `coins`: Configuration related to coin assets and updates. + +### Example Configuration + +```json +{ + "api": { + "api_commit_hash": "b0fd99e8406e67ea06435dd028991caa5f522b5c", + "branch": "main", + "fetch_at_build_enabled": true, + "source_urls": [ + "https://api.github.com/repos/KomodoPlatform/komodo-defi-framework", + "https://sdk.devbuilds.komodo.earth" + ], + "platforms": { + "web": { + "matching_keyword": "wasm", + "valid_zip_sha256_checksums": [ + "f4065f8cbfe2eb2c9671444402b79e1f94df61987b0cee6d503de567a2bc3ff0" + ], + "path": "web/src/mm2" + }, + "ios": { + "matching_keyword": "ios-aarch64", + "valid_zip_sha256_checksums": [ + "17156647a0bac0e630a33f9bdbcfd59c847443c9e88157835fff6a17738dcf0c" + ], + "path": "ios" + }, + "macos": { + "matching_keyword": "Darwin-Release", + "valid_zip_sha256_checksums": [ + "9472c37ae729bc634b02b64a13676e675b4ab1629d8e7c334bfb1c0360b6000a" + ], + "path": "macos" + }, + "windows": { + "matching_keyword": "Win64", + "valid_zip_sha256_checksums": [ + "f65075f3a04d27605d9ce7282ff6c8d5ed84692850fbc08de14ee41d036c4c5a" + ], + "path": "windows/runner/exe" + }, + "android-armv7": { + "matching_keyword": "android-armv7", + "valid_zip_sha256_checksums": [ + "bae9c33dca4fae3b9d10d25323df16b6f3976565aa242e5324e8f2643097b4c6" + ], + "path": "android/app/src/main/cpp/libs/armeabi-v7a" + }, + "android-aarch64": { + "matching_keyword": "android-aarch64", + "valid_zip_sha256_checksums": [ + "435c857c5cd4fe929238f490d2d3ba58c84cf9c601139c5cd23f63fbeb5befb6" + ], + "path": "android/app/src/main/cpp/libs/arm64-v8a" + }, + "linux": { + "matching_keyword": "Linux-Release", + "valid_zip_sha256_checksums": [ + "16f35c201e22db182ddc16ba9d356d324538d9f792d565833977bcbf870feaec" + ], + "path": "linux/mm2" + } + } + }, + "coins": { + "update_commit_on_build": true, + "bundled_coins_repo_commit": "6c33675ce5e5ec6a95708eb6046304ac4a5c3e70", + "coins_repo_api_url": "https://api.github.com/repos/KomodoPlatform/coins", + "coins_repo_content_url": "https://raw.githubusercontent.com/KomodoPlatform/coins", + "coins_repo_branch": "master", + "runtime_updates_enabled": true, + "mapped_files": { + "assets/config/coins_config.json": "utils/coins_config_unfiltered.json", + "assets/config/coins.json": "coins" + }, + "mapped_folders": { + "assets/coin_icons/png/": "icons" + } + } +} +``` + +--- + +## `api` Configuration + +### Parameters and Explanation + +- **api_commit_hash**: Specifies the commit hash of the API version currently in use. This ensures the API is pulled from a specific commit in the repository, providing consistency and stability by locking to a known state. +#### Platform Configuration + +Each platform configuration contains: + +--- + +## `coins` Configuration + +### Parameters and Explanation + +- **update_commit_on_build**: A boolean flag indicating whether the commit hash should be updated on build. This ensures the coin configurations are in sync with the latest state of the repository. +- **bundled_coins_repo_commit**: Specifies the commit hash of the bundled coins repository. This ensures the coin configurations are in sync with a specific state of the repository, providing consistency and stability. +--- + +## Configuring and Running the Build Process + +1. **Modify `build_config.json`**: Ensure the configuration file reflects your project requirements. Update commit hashes, URLs, and paths as needed. +The build steps are automatically executed as part of Flutter's build process. For any issues or further assistance, refer to the detailed comments within the build scripts or seek support from the project maintainers. diff --git a/app_build/build_config.json b/app_build/build_config.json new file mode 100644 index 0000000000..32890506c7 --- /dev/null +++ b/app_build/build_config.json @@ -0,0 +1,77 @@ +{ + "api": { + "api_commit_hash": "35e92394928825c337f246f8e19fbfab1f65c4a8", + "branch": "main", + "fetch_at_build_enabled": true, + "source_urls": [ + "https://api.github.com/repos/KomodoPlatform/komodo-defi-framework", + "https://sdk.devbuilds.komodo.earth" + ], + "platforms": { + "web": { + "matching_keyword": "wasm", + "valid_zip_sha256_checksums": [ + "b29dd447cbc6a116c31cac8e222732e74b9cc28bbd43cb0e141ccced9c75492b" + ], + "path": "web/src/mm2" + }, + "ios": { + "matching_keyword": "ios-aarch64", + "valid_zip_sha256_checksums": [ + "e44e3dd81b35e739afce30628373ac4e9dc85e665954f2084725ca9a45678b77" + ], + "path": "ios" + }, + "macos": { + "matching_keyword": "Darwin-Release", + "valid_zip_sha256_checksums": [ + "71dd75505781d531fd1b4e2621b91aeee253ff6ac8501059773a9a82452a5b3f" + ], + "path": "macos" + }, + "windows": { + "matching_keyword": "Win64", + "valid_zip_sha256_checksums": [ + "27a4b10b4016d3ef04989b9b1ad8bb94db503efe50edac9e259482fff84e4213" + ], + "path": "windows/runner/exe" + }, + "android-armv7": { + "matching_keyword": "android-armv7", + "valid_zip_sha256_checksums": [ + "51c15be880abeddf24c34e5c802fd0a1ebacc7acde87b686f15bffabb1ae836d" + ], + "path": "android/app/src/main/cpp/libs/armeabi-v7a" + }, + "android-aarch64": { + "matching_keyword": "android-aarch64", + "valid_zip_sha256_checksums": [ + "37ef40e6b5d2a91c24d097ccbe4ac7e29e1dc65aa3940d93b5530fc54aac5577" + ], + "path": "android/app/src/main/cpp/libs/arm64-v8a" + }, + "linux": { + "matching_keyword": "Linux-Release", + "valid_zip_sha256_checksums": [ + "8993a0d31ef7a3554089be77b0c587bae3e7a1aa075bd1ca9ae6db6b96ae0c31" + ], + "path": "linux/mm2" + } + } + }, + "coins": { + "update_commit_on_build": true, + "bundled_coins_repo_commit": "bc7e8a7dcb4e3616331b4d6224b2cf46b26e0105", + "coins_repo_api_url": "https://api.github.com/repos/KomodoPlatform/coins", + "coins_repo_content_url": "https://raw.githubusercontent.com/KomodoPlatform/coins", + "coins_repo_branch": "master", + "runtime_updates_enabled": true, + "mapped_files": { + "assets/config/coins_config.json": "utils/coins_config_unfiltered.json", + "assets/config/coins.json": "coins" + }, + "mapped_folders": { + "assets/coin_icons/png/": "icons" + } + } +} diff --git a/app_theme/lib/app_theme.dart b/app_theme/lib/app_theme.dart new file mode 100644 index 0000000000..0b73ddc9ea --- /dev/null +++ b/app_theme/lib/app_theme.dart @@ -0,0 +1,34 @@ +library app_theme; + +import 'package:flutter/material.dart'; + +import 'src/common/theme_custom_base.dart'; +import 'src/dark/theme_custom_dark.dart'; +import 'src/light/theme_custom_light.dart'; +import 'src/new_theme/new_theme_dark.dart'; +import 'src/new_theme/new_theme_light.dart'; +import 'src/theme_global.dart'; + +export 'src/new_theme/extensions/color_scheme_extension.dart'; +export 'src/new_theme/extensions/text_theme_extension.dart'; + +final theme = AppTheme(); + +class AppTheme { + final ThemeDataGlobal global = ThemeDataGlobal(); + ThemeMode mode = ThemeMode.dark; + + ThemeCustomBase get custom => + mode == ThemeMode.dark ? _themeCustomDark : _themeCustomLight; + ThemeData get currentGlobal => + mode == ThemeMode.dark ? global.dark : global.light; +} + +ThemeCustomBase get _themeCustomLight => ThemeCustomLight(); + +ThemeCustomBase get _themeCustomDark => ThemeCustomDark(); + +DexPageTheme get dexPageColors => theme.custom.dexPageTheme; + +ThemeData get newThemeDark => newThemeDataDark; +ThemeData get newThemeLight => newThemeDataLight; diff --git a/app_theme/lib/src/common/theme_custom_base.dart b/app_theme/lib/src/common/theme_custom_base.dart new file mode 100644 index 0000000000..d92889371f --- /dev/null +++ b/app_theme/lib/src/common/theme_custom_base.dart @@ -0,0 +1,276 @@ +import 'package:flutter/material.dart'; + +abstract class ThemeCustomBase { + const ThemeCustomBase({ + required this.headerIconColor, + required this.headerFloatBoxColor, + required this.simpleButtonBackgroundColor, + required this.disabledButtonBackgroundColor, + required this.authorizePageBackgroundColor, + required this.authorizePageLineColor, + required this.defaultCheckboxColor, + required this.borderCheckboxColor, + required this.checkCheckboxColor, + required this.defaultSwitchColor, + required this.defaultGradientButtonTextColor, + required this.settingsMenuItemBackgroundColor, + required this.passwordButtonSuccessColor, + required this.defaultBorderButtonBackground, + required this.defaultBorderButtonBorder, + required this.defaultCircleButtonBackground, + required this.userRewardBoxColor, + required this.rewardBoxShadowColor, + required this.buttonColorDefault, + required this.buttonColorDefaultHover, + required this.buttonTextColorDefaultHover, + required this.noColor, + required this.increaseColor, + required this.decreaseColor, + required this.successColor, + required this.protocolTypeColor, + required this.zebraDarkColor, + required this.zebraLightColor, + required this.zebraHoverColor, + required this.tradingDetailsTheme, + required this.coinsManagerTheme, + required this.dexPageTheme, + required this.asksColor, + required this.bidsColor, + required this.targetColor, + required this.dexFormWidth, + required this.dexInputWidth, + required this.specificButtonBorderColor, + required this.specificButtonBackgroundColor, + required this.balanceColor, + required this.subBalanceColor, + required this.subCardBackgroundColor, + required this.lightButtonColor, + required this.filterItemBorderColor, + required this.warningColor, + required this.progressBarColor, + required this.progressBarPassedColor, + required this.progressBarNotPassedColor, + required this.dexSubTitleColor, + required this.selectedMenuBackgroundColor, + required this.tabBarShadowColor, + required this.smartchainLabelBorderColor, + required this.walletEditButtonsBackgroundColor, + required this.mainMenuSelectedItemBackgroundColor, + required this.mainMenuItemColor, + required this.mainMenuSelectedItemColor, + required this.searchFieldMobile, + required this.swapButtonColor, + required this.suspendedBannerBackgroundColor, + required this.bridgeFormHeader, + required this.fiatAmountColor, + required this.tradingFormDetailsLabel, + required this.tradingFormDetailsContent, + required this.keyPadColor, + required this.keyPadTextColor, + required this.dexCoinProtocolColor, + required this.dialogBarrierColor, + required this.noTransactionsTextColor, + }); + + final Color headerIconColor; + final Color headerFloatBoxColor; + final Color simpleButtonBackgroundColor; + final Color disabledButtonBackgroundColor; + final Gradient authorizePageBackgroundColor; + final Color authorizePageLineColor; + final Color defaultCheckboxColor; + final Color borderCheckboxColor; + final Color checkCheckboxColor; + final Gradient defaultSwitchColor; + final Color defaultGradientButtonTextColor; + final Color mainMenuSelectedItemBackgroundColor; + final Color mainMenuItemColor; + final Color mainMenuSelectedItemColor; + final Color settingsMenuItemBackgroundColor; + final Color passwordButtonSuccessColor; + final Color defaultBorderButtonBackground; + final Color defaultBorderButtonBorder; + final Color defaultCircleButtonBackground; + final Gradient userRewardBoxColor; + final Color rewardBoxShadowColor; + final Color buttonColorDefault; + final Color buttonColorDefaultHover; + final Color buttonTextColorDefaultHover; + final Color noColor; + final Color increaseColor; + final Color decreaseColor; + final Color successColor; + final Color protocolTypeColor; + final Color zebraDarkColor; + final Color zebraHoverColor; + final Color zebraLightColor; + + final TradingDetailsTheme tradingDetailsTheme; + final CoinsManagerTheme coinsManagerTheme; + final DexPageTheme dexPageTheme; + + final Color asksColor; + final Color bidsColor; + final Color targetColor; + final double dexFormWidth; + final double dexInputWidth; + final Color specificButtonBackgroundColor; + final Color specificButtonBorderColor; + final Color balanceColor; + final Color subBalanceColor; + final Color subCardBackgroundColor; + final Color lightButtonColor; + final Color filterItemBorderColor; + final Color warningColor; + final Color progressBarColor; + final Color progressBarPassedColor; + final Color progressBarNotPassedColor; + final Color dexSubTitleColor; + final Color selectedMenuBackgroundColor; + final Color tabBarShadowColor; + final Color smartchainLabelBorderColor; + final Color searchFieldMobile; + final Color walletEditButtonsBackgroundColor; + final Color swapButtonColor; + final Color suspendedBannerBackgroundColor; + + final TextStyle bridgeFormHeader; + final Color fiatAmountColor; + final TextStyle tradingFormDetailsLabel; + final TextStyle tradingFormDetailsContent; + final Color keyPadColor; + final Color keyPadTextColor; + final Color dexCoinProtocolColor; + final Color dialogBarrierColor; + final Color noTransactionsTextColor; +} + +class TradingDetailsTheme { + const TradingDetailsTheme({ + this.swapStatusColors = const [ + Color.fromRGBO(130, 168, 239, 1), + Color.fromRGBO(106, 77, 227, 0.59), + Color.fromRGBO(106, 77, 227, 0.59), + Color.fromRGBO(34, 121, 241, 0.59), + ], + this.swapFailedStatusColors = const [ + Color.fromRGBO(229, 33, 103, 0.6), + Color.fromRGBO(226, 22, 169, 0.6) + ], + this.swapStepTimerColor = const Color.fromRGBO(162, 175, 187, 1), + this.swapStepCircleNormalColor = const Color.fromRGBO(137, 147, 236, 1), + this.swapStepCircleFailedColor = const Color.fromRGBO(229, 33, 106, 1), + this.swapStepCircleDisabledColor = const Color.fromRGBO(194, 203, 210, 1), + this.swapStepTextFailedColor = const Color.fromRGBO(229, 33, 103, 1), + this.swapStepTextDisabledColor = const Color.fromRGBO(162, 176, 188, 1), + this.swapStepTextCurrentColor = const Color.fromRGBO(72, 137, 235, 1), + }); + final List swapStatusColors; + final List swapFailedStatusColors; + final Color swapStepTimerColor; + final Color swapStepCircleNormalColor; + final Color swapStepCircleFailedColor; + final Color swapStepCircleDisabledColor; + final Color swapStepTextFailedColor; + final Color swapStepTextDisabledColor; + final Color swapStepTextCurrentColor; +} + +class CoinsManagerTheme { + const CoinsManagerTheme({ + this.searchFieldMobileBackgroundColor = + const Color.fromRGBO(242, 242, 242, 1), + this.filtersPopupShadow = const BoxShadow( + offset: Offset(0, 0), + blurRadius: 13, + color: Color.fromRGBO(0, 0, 0, 0.06), + ), + this.filterPopupItemBorderColor = const Color.fromRGBO(136, 146, 235, 1), + this.listHeaderBorderColor = const Color.fromRGBO(234, 234, 234, 1), + this.listItemProtocolTextColor = Colors.white, + this.listItemZeroBalanceColor = const Color.fromRGBO(215, 223, 248, 1), + }); + final Color searchFieldMobileBackgroundColor; + final BoxShadow filtersPopupShadow; + final Color filterPopupItemBorderColor; + final Color listHeaderBorderColor; + final Color listItemProtocolTextColor; + final Color listItemZeroBalanceColor; +} + +class DexPageTheme { + const DexPageTheme({ + this.takerLabelColor = const Color.fromRGBO(47, 179, 239, 1), + this.makerLabelColor = const Color.fromRGBO(106, 77, 227, 1), + this.successfulSwapStatusColor = const Color.fromRGBO(73, 212, 162, 1), + this.failedSwapStatusColor = const Color.fromRGBO(229, 33, 103, 1), + this.successfulSwapStatusBackgroundColor = + const Color.fromRGBO(73, 212, 162, 0.12), + this.activeOrderFormTabColor = const Color.fromRGBO(89, 107, 231, 1), + this.inactiveOrderFormTabColor = const Color.fromRGBO(206, 210, 247, 1), + this.takerLabel = const Color.fromRGBO(47, 179, 239, 1), + this.makerLabel = const Color.fromRGBO(106, 77, 227, 1), + this.successfulSwapStatus = const Color.fromRGBO(73, 212, 162, 1), + this.failedSwapStatus = const Color.fromRGBO(229, 33, 103, 1), + this.successfulSwapStatusBackground = + const Color.fromRGBO(73, 212, 162, 0.12), + this.activeOrderFormTab = const Color.fromRGBO(89, 107, 231, 1), + this.inactiveOrderFormTab = const Color.fromRGBO(206, 210, 247, 1), + this.formPlateGradient = const LinearGradient( + colors: [ + Color.fromRGBO(218, 235, 255, 1), + Color.fromRGBO(234, 233, 255, 1), + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + this.frontPlate = const Color.fromRGBO(255, 255, 255, 1), + this.frontPlateInner = const Color.fromRGBO(255, 255, 255, 1), + this.frontPlateBorder = const Color.fromRGBO(239, 239, 239, 1), + this.activeText = const Color.fromRGBO(69, 96, 120, 1), + this.inactiveText = const Color.fromRGBO(168, 178, 204, 1), + this.blueText = const Color.fromRGBO(80, 104, 214, 1), + this.smallButton = const Color.fromRGBO(241, 244, 246, 1), + this.smallButtonText = const Color.fromRGBO(69, 96, 120, 1), + this.pagePlateDivider = const Color.fromRGBO(244, 244, 244, 1), + this.coinPlateDivider = const Color.fromRGBO(244, 244, 244, 1), + this.formPlateDivider = const Color.fromRGBO(218, 224, 246, 1), + this.emptyPlace = const Color.fromRGBO(239, 239, 239, 1), + this.tokenName = Colors.white, + this.expandMore = const Color.fromRGBO(153, 168, 181, 1), + }); + + final Color takerLabelColor; + final Color makerLabelColor; + final Color successfulSwapStatusColor; + final Color failedSwapStatusColor; + final Color successfulSwapStatusBackgroundColor; + final Color activeOrderFormTabColor; + final Color inactiveOrderFormTabColor; + + final Color activeOrderFormTab; + final Color inactiveOrderFormTab; + final Color takerLabel; + final Color makerLabel; + final Color successfulSwapStatus; + final Color failedSwapStatus; + final Color successfulSwapStatusBackground; + final Color activeText; + final Color inactiveText; + final Color blueText; + final Color smallButton; + final Color smallButtonText; + + final Color pagePlateDivider; + final Color coinPlateDivider; + final Color formPlateDivider; + final Color emptyPlace; + + final Color tokenName; + final Color frontPlate; + final Color frontPlateInner; + final Color frontPlateBorder; + final Color expandMore; + + final LinearGradient formPlateGradient; +} diff --git a/app_theme/lib/src/dark/theme_custom_dark.dart b/app_theme/lib/src/dark/theme_custom_dark.dart new file mode 100644 index 0000000000..2e410f3922 --- /dev/null +++ b/app_theme/lib/src/dark/theme_custom_dark.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; + +import '../../app_theme.dart'; +import '../common/theme_custom_base.dart'; + +class ThemeCustomDark implements ThemeCustomBase { + @override + final Color mainMenuItemColor = const Color.fromRGBO(173, 175, 198, 1); + @override + final Color mainMenuSelectedItemColor = Colors.white; + @override + final Color checkCheckboxColor = Colors.white; + @override + final Color borderCheckboxColor = const Color.fromRGBO(62, 70, 99, 1); + @override + final TextStyle tradingFormDetailsLabel = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ); + @override + final TextStyle tradingFormDetailsContent = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color.fromRGBO(106, 139, 235, 1), + ); + @override + final Color fiatAmountColor = const Color.fromRGBO(168, 177, 185, 1); + @override + final Color headerFloatBoxColor = const Color.fromRGBO(98, 121, 233, 1); + @override + final Color headerIconColor = const Color.fromRGBO(255, 255, 255, 1); + @override + final Color buttonColorDefault = const Color.fromRGBO(23, 29, 48, 1); + + @override + final Color buttonColorDefaultHover = const Color.fromRGBO(76, 128, 233, 1); + @override + final Color buttonTextColorDefaultHover = + const Color.fromRGBO(245, 249, 255, 1); + @override + final Color noColor = Colors.transparent; + @override + final Color increaseColor = const Color.fromRGBO(0, 195, 170, 1); + @override + final Color decreaseColor = const Color.fromRGBO(229, 33, 103, 1); + @override + final Color zebraDarkColor = const Color.fromRGBO(18, 20, 32, 1); + @override + final Color zebraLightColor = Colors.transparent; + @override + final Color zebraHoverColor = const Color.fromRGBO(37, 40, 58, 1); + @override + final Color passwordButtonSuccessColor = + const Color.fromRGBO(90, 230, 205, 1); + @override + final Color simpleButtonBackgroundColor = + const Color.fromRGBO(136, 146, 235, 0.2); + @override + final Color disabledButtonBackgroundColor = + const Color.fromRGBO(90, 104, 230, 0.3); + @override + final Gradient authorizePageBackgroundColor = const RadialGradient( + center: Alignment.bottomCenter, + colors: [ + Color.fromRGBO(42, 188, 241, 0.1), + Color.fromRGBO(42, 188, 241, 0), + ], + ); + @override + final Color authorizePageLineColor = const Color.fromRGBO(255, 255, 255, 0.1); + @override + final Color defaultGradientButtonTextColor = Colors.white; + @override + final Color defaultCheckboxColor = const Color.fromRGBO(81, 121, 233, 1); + @override + final Gradient defaultSwitchColor = const LinearGradient( + stops: [0, 93], + colors: [Color.fromRGBO(29, 128, 176, 1), Color.fromRGBO(91, 105, 230, 1)], + ); + @override + final Color settingsMenuItemBackgroundColor = + const Color.fromRGBO(46, 52, 112, 1); + @override + final Gradient userRewardBoxColor = const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color.fromRGBO(18, 20, 32, 1), Color.fromRGBO(22, 25, 39, 1)], + stops: [0.05, 0.33]); + @override + final Color rewardBoxShadowColor = const Color.fromRGBO(0, 0, 0, 0.1); + @override + final Color defaultBorderButtonBorder = + const Color.fromRGBO(136, 146, 235, 1); + @override + final Color defaultBorderButtonBackground = + const Color.fromRGBO(22, 25, 39, 1); + @override + final Color successColor = const Color.fromRGBO(0, 192, 88, 1); + + @override + final Color defaultCircleButtonBackground = + const Color.fromRGBO(222, 235, 255, 0.56); + @override + final TradingDetailsTheme tradingDetailsTheme = const TradingDetailsTheme(); + @override + final Color protocolTypeColor = const Color(0xfffcbb80); + @override + final CoinsManagerTheme coinsManagerTheme = const CoinsManagerTheme( + searchFieldMobileBackgroundColor: Color.fromRGBO(51, 57, 72, 1)); + @override + final DexPageTheme dexPageTheme = const DexPageTheme( + activeOrderFormTabColor: Color.fromRGBO(255, 255, 255, 1), + inactiveOrderFormTabColor: Color.fromRGBO(152, 155, 182, 1), + activeOrderFormTab: Color.fromRGBO(255, 255, 255, 1), + inactiveOrderFormTab: Color.fromRGBO(152, 155, 182, 1), + formPlateGradient: LinearGradient( + colors: [ + Color.fromRGBO(134, 213, 255, 1), + Color.fromRGBO(178, 107, 255, 1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + frontPlate: Color.fromRGBO(32, 35, 55, 1), + frontPlateInner: Color.fromRGBO(21, 25, 33, 1), + frontPlateBorder: Color.fromRGBO(43, 49, 87, 1), + activeText: Colors.white, + inactiveText: Color.fromRGBO(123, 131, 152, 1), + blueText: Color.fromRGBO(100, 124, 233, 1), + smallButton: Color.fromRGBO(35, 45, 72, 1), + smallButtonText: Color.fromRGBO(141, 150, 167, 1), + pagePlateDivider: Color.fromRGBO(32, 37, 63, 1), + coinPlateDivider: Color.fromRGBO(44, 51, 81, 1), + formPlateDivider: Color.fromRGBO(48, 57, 96, 1), + emptyPlace: Color.fromRGBO(40, 44, 69, 1), + tokenName: Color.fromRGBO(69, 96, 120, 1), + expandMore: Color.fromRGBO(153, 168, 181, 1), + ); + @override + final Color asksColor = const Color(0xffe52167); + @override + final Color bidsColor = const Color(0xFF00C3AA); + @override + final Color targetColor = Colors.orange; + @override + final double dexFormWidth = 480; + @override + final double dexInputWidth = 320; + @override + final Color specificButtonBorderColor = const Color.fromRGBO(38, 40, 52, 1); + @override + final Color specificButtonBackgroundColor = + const Color.fromRGBO(14, 16, 27, 1); + @override + final Color balanceColor = const Color.fromRGBO(106, 139, 235, 1); + @override + final Color subBalanceColor = const Color.fromRGBO(124, 136, 171, 1); + @override + final Color subCardBackgroundColor = const Color.fromRGBO(18, 20, 32, 1); + @override + final Color lightButtonColor = const Color.fromRGBO(137, 182, 255, 0.12); + @override + final Color filterItemBorderColor = const Color.fromRGBO(52, 56, 77, 1); + @override + final Color warningColor = const Color.fromRGBO(229, 33, 103, 1); + @override + final Color progressBarColor = const Color.fromRGBO(69, 96, 120, 0.33); + @override + final Color progressBarPassedColor = const Color.fromRGBO(137, 147, 236, 1); + @override + final Color progressBarNotPassedColor = + const Color.fromRGBO(194, 203, 210, 1); + @override + final Color dexSubTitleColor = const Color.fromRGBO(255, 255, 255, 1); + @override + final Color selectedMenuBackgroundColor = + const Color.fromRGBO(46, 52, 112, 1); + @override + final Color tabBarShadowColor = const Color.fromRGBO(255, 255, 255, 0.08); + @override + final Color smartchainLabelBorderColor = const Color.fromRGBO(32, 22, 49, 1); + @override + final Color mainMenuSelectedItemBackgroundColor = + const Color.fromRGBO(146, 187, 255, 0.12); + @override + final Color searchFieldMobile = const Color.fromRGBO(42, 47, 62, 1); + @override + final Color walletEditButtonsBackgroundColor = + const Color.fromRGBO(29, 33, 53, 1); + @override + final Color swapButtonColor = const Color.fromRGBO(64, 146, 219, 1); + @override + final Color suspendedBannerBackgroundColor = + theme.currentGlobal.colorScheme.onSurface; + @override + final bridgeFormHeader = const TextStyle( + fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: 3.5); + @override + final Color keyPadColor = const Color.fromRGBO(18, 20, 32, 1); + @override + final Color keyPadTextColor = const Color.fromRGBO(129, 151, 182, 1); + @override + final Color dexCoinProtocolColor = const Color.fromRGBO(168, 177, 185, 1); + @override + final Color dialogBarrierColor = const Color.fromRGBO(3, 26, 43, 0.36); + @override + final Color noTransactionsTextColor = const Color.fromRGBO(196, 196, 196, 1); +} diff --git a/app_theme/lib/src/dark/theme_global_dark.dart b/app_theme/lib/src/dark/theme_global_dark.dart new file mode 100644 index 0000000000..3526a8e8fc --- /dev/null +++ b/app_theme/lib/src/dark/theme_global_dark.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; + +ThemeData get themeGlobalDark { + const Color inputBackgroundColor = Color.fromRGBO(51, 57, 72, 1); + const Color textColor = Color.fromRGBO(255, 255, 255, 1); + + SnackBarThemeData snackBarThemeLight() => const SnackBarThemeData( + elevation: 12.0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))), + actionTextColor: Colors.green, + behavior: SnackBarBehavior.floating, + ); + + OutlineInputBorder outlineBorderLight(Color lightAccentColor) => + OutlineInputBorder( + borderSide: BorderSide(color: lightAccentColor), + borderRadius: BorderRadius.circular(18), + ); + + //TODO! Implement all light-theme equivalent properties + final ColorScheme colorScheme = ColorScheme.fromSeed( + brightness: Brightness.dark, + seedColor: const Color.fromRGBO(61, 119, 233, 1), + primary: const Color.fromRGBO(61, 119, 233, 1), + secondary: const Color.fromRGBO(90, 104, 230, 1), + tertiary: const Color.fromRGBO(28, 32, 59, 1), // - @ColorScheme: Updated + surface: const Color.fromRGBO(22, 25, 39, 1), + onSurface: const Color.fromRGBO(18, 20, 32, 1), + error: const Color.fromRGBO(202, 78, 61, 1), + ); + + final TextTheme textTheme = TextTheme( + headlineMedium: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: textColor), + headlineSmall: const TextStyle( + fontSize: 40, fontWeight: FontWeight.w700, color: textColor), + titleLarge: const TextStyle( + fontSize: 26.0, color: textColor, fontWeight: FontWeight.w700), + titleSmall: const TextStyle(fontSize: 18.0, color: textColor), + bodyMedium: const TextStyle( + fontSize: 16.0, color: textColor, fontWeight: FontWeight.w300), + labelLarge: const TextStyle(fontSize: 16.0, color: textColor), + bodyLarge: TextStyle(fontSize: 14.0, color: textColor.withOpacity(0.5)), + bodySmall: TextStyle( + fontSize: 12.0, + color: textColor.withOpacity(0.8), + fontWeight: FontWeight.w400, + ), + ); + + return ThemeData( + useMaterial3: false, + fontFamily: 'Manrope', + scaffoldBackgroundColor: colorScheme.onSurface, + cardColor: colorScheme.surface, + cardTheme: CardTheme( + color: colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(18)), + ), + ), + colorScheme: colorScheme, + primaryColor: colorScheme.primary, + dividerColor: const Color.fromRGBO(56, 67, 108, 1), + appBarTheme: AppBarTheme(color: colorScheme.surface), + iconTheme: IconThemeData(color: colorScheme.primary), + progressIndicatorTheme: + ProgressIndicatorThemeData(color: colorScheme.primary), + dialogBackgroundColor: const Color.fromRGBO(14, 16, 27, 1), + dialogTheme: const DialogTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(16), + ), + ), + ), + canvasColor: colorScheme.surface, + hintColor: const Color.fromRGBO(183, 187, 191, 1), + snackBarTheme: snackBarThemeLight(), + textSelectionTheme: TextSelectionThemeData( + cursorColor: const Color.fromRGBO(57, 161, 238, 1), + selectionColor: const Color.fromRGBO(57, 161, 238, 1).withOpacity(0.3), + selectionHandleColor: const Color.fromRGBO(57, 161, 238, 1), + ), + inputDecorationTheme: InputDecorationTheme( + enabledBorder: outlineBorderLight(Colors.transparent), + disabledBorder: outlineBorderLight(Colors.transparent), + border: outlineBorderLight(Colors.transparent), + focusedBorder: outlineBorderLight(Colors.transparent), + errorBorder: outlineBorderLight(colorScheme.error), + fillColor: inputBackgroundColor, + focusColor: inputBackgroundColor, + hoverColor: Colors.transparent, + errorStyle: TextStyle(color: colorScheme.error), + filled: true, + contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 22), + hintStyle: TextStyle( + color: textColor.withOpacity(0.58), + ), + labelStyle: TextStyle( + color: textColor.withOpacity(0.58), + ), + prefixIconColor: textColor.withOpacity(0.58), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) return Colors.grey; + return colorScheme.primary; + }, + ), + ), + ), + segmentedButtonTheme: SegmentedButtonThemeData( + style: SegmentedButton.styleFrom( + backgroundColor: colorScheme.surfaceContainerLowest, + surfaceTintColor: Colors.purple, + selectedBackgroundColor: colorScheme.primary, + foregroundColor: textColor.withOpacity(0.7), + selectedForegroundColor: textColor, + side: BorderSide(color: colorScheme.outlineVariant), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + ), + ), + tabBarTheme: TabBarTheme( + labelColor: textColor, + indicator: UnderlineTabIndicator( + borderSide: BorderSide( + width: 2.0, + color: colorScheme.primary, + ), + // Match the card's border radius + insets: const EdgeInsets.symmetric(horizontal: 18), + ), + ), + // outlinedButtonTheme: OutlinedButtonThemeData( + // style: ButtonStyle( + // // TODO! + // // onPrimary: textColor, + // // shape: RoundedRectangleBorder( + // // borderRadius: BorderRadius.circular(18), + // // ), + // ), + // ), + checkboxTheme: CheckboxThemeData( + checkColor: WidgetStateProperty.all(Colors.white), + fillColor: WidgetStateProperty.all(colorScheme.primary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colorScheme.primary, + ), + textTheme: textTheme, + scrollbarTheme: ScrollbarThemeData( + thumbColor: + WidgetStateProperty.all(colorScheme.primary.withOpacity(0.8)), + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + // remove icons shift + type: BottomNavigationBarType.fixed, + backgroundColor: colorScheme.surface, + selectedItemColor: textColor, + unselectedItemColor: const Color.fromRGBO(173, 175, 198, 1), + unselectedLabelStyle: + const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + selectedLabelStyle: + const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + ); +} diff --git a/app_theme/lib/src/light/theme_custom_light.dart b/app_theme/lib/src/light/theme_custom_light.dart new file mode 100644 index 0000000000..2bebc013fa --- /dev/null +++ b/app_theme/lib/src/light/theme_custom_light.dart @@ -0,0 +1,209 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +import '../common/theme_custom_base.dart'; + +class ThemeCustomLight implements ThemeCustomBase { + @override + final Color mainMenuItemColor = const Color.fromRGBO(69, 96, 120, 1); + @override + final Color mainMenuSelectedItemColor = const Color.fromRGBO(34, 121, 241, 1); + @override + final Color checkCheckboxColor = Colors.white; + @override + final Color borderCheckboxColor = const Color.fromRGBO(62, 70, 99, 0.5); + @override + final TextStyle tradingFormDetailsLabel = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ); + @override + final TextStyle tradingFormDetailsContent = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Color.fromRGBO(106, 139, 235, 1), + ); + @override + final Color fiatAmountColor = const Color.fromRGBO(168, 177, 185, 1); + @override + final Color headerFloatBoxColor = const Color.fromRGBO(98, 121, 233, 1); + @override + final Color headerIconColor = const Color.fromRGBO(34, 121, 241, 1); + @override + final Color buttonColorDefault = const Color.fromRGBO(245, 249, 255, 1); + @override + final Color buttonColorDefaultHover = const Color.fromRGBO(76, 128, 233, 1); + @override + final Color buttonTextColorDefaultHover = + const Color.fromRGBO(245, 249, 255, 1); + @override + final Color noColor = Colors.transparent; + @override + final Color increaseColor = const Color.fromRGBO(0, 192, 88, 1); + @override + final Color decreaseColor = const Color.fromRGBO(229, 33, 103, 1); + @override + final Color zebraDarkColor = const Color.fromRGBO(251, 251, 251, 1); + @override + final Color zebraLightColor = Colors.transparent; + @override + final Color zebraHoverColor = const Color.fromRGBO(245, 245, 245, 1); + @override + final Color passwordButtonSuccessColor = + const Color.fromRGBO(90, 230, 205, 1); + @override + final Color simpleButtonBackgroundColor = + const Color.fromRGBO(136, 146, 235, 0.2); + @override + final Color disabledButtonBackgroundColor = + const Color.fromRGBO(90, 104, 230, 0.3); + @override + final Gradient authorizePageBackgroundColor = const RadialGradient( + center: Alignment.bottomCenter, + colors: [ + Color.fromRGBO(202, 225, 245, 1), + Color.fromRGBO(241, 242, 250, 1), + ], + ); + @override + final Color authorizePageLineColor = const Color.fromRGBO(197, 212, 247, 1); + @override + final Color defaultGradientButtonTextColor = Colors.white; + @override + final Color defaultCheckboxColor = const Color.fromRGBO(81, 121, 233, 1); + @override + final Gradient defaultSwitchColor = const LinearGradient( + stops: [0, 93], + colors: [ + Color.fromRGBO(136, 146, 235, 1), + Color.fromRGBO(157, 212, 243, 1) + ], + ); + @override + final Color settingsMenuItemBackgroundColor = + const Color.fromRGBO(245, 249, 255, 1); + @override + final Gradient userRewardBoxColor = const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Colors.white, Color.fromRGBO(218, 228, 251, 1)], + stops: [0.05, 0.33]); + @override + final Color rewardBoxShadowColor = const Color.fromRGBO(0, 0, 0, 0.1); + @override + final Color defaultBorderButtonBorder = + const Color.fromRGBO(136, 146, 235, 1); + @override + final Color successColor = const Color.fromRGBO(0, 192, 88, 1); + @override + final Color defaultBorderButtonBackground = + const Color.fromRGBO(226, 234, 253, 1); + @override + final Color defaultCircleButtonBackground = + const Color.fromRGBO(222, 235, 255, 0.56); + @override + final TradingDetailsTheme tradingDetailsTheme = const TradingDetailsTheme(); + @override + final Color protocolTypeColor = const Color(0xfffcbb80); + @override + final CoinsManagerTheme coinsManagerTheme = const CoinsManagerTheme(); + @override + final DexPageTheme dexPageTheme = const DexPageTheme( + activeOrderFormTabColor: Color.fromRGBO(34, 121, 241, 1), + inactiveOrderFormTabColor: Color.fromRGBO(69, 96, 120, 1), + activeOrderFormTab: Color.fromRGBO(34, 121, 241, 1), + inactiveOrderFormTab: Color.fromRGBO(69, 96, 120, 1), + formPlateGradient: LinearGradient( + colors: [ + Color.fromRGBO(134, 213, 255, 1), + Color.fromRGBO(178, 107, 255, 1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + frontPlate: Color.fromRGBO(255, 255, 255, 1), + frontPlateInner: Color.fromRGBO(245, 245, 245, 1), + frontPlateBorder: Color.fromRGBO(208, 214, 237, 1), + activeText: Color.fromRGBO(34, 121, 241, 1), + inactiveText: Color.fromRGBO(69, 96, 120, 1), + blueText: Color.fromRGBO(34, 121, 241, 1), + smallButton: Color.fromRGBO(243, 245, 246, 1), + smallButtonText: Color.fromRGBO(69, 96, 120, 1), + pagePlateDivider: Color.fromRGBO(208, 214, 237, 1), + coinPlateDivider: Color.fromRGBO(208, 214, 237, 1), + formPlateDivider: Color.fromRGBO(208, 214, 237, 1), + emptyPlace: Color.fromRGBO(245, 249, 255, 1), + tokenName: Color.fromRGBO(69, 96, 120, 1), + expandMore: Color.fromRGBO(69, 96, 120, 1), + ); + @override + final Color asksColor = const Color(0xffe52167); + @override + final Color bidsColor = const Color(0xFF00C3AA); + @override + final Color targetColor = Colors.orange; + @override + final double dexFormWidth = 480; + @override + final double dexInputWidth = 320; + @override + final Color specificButtonBorderColor = + const Color.fromRGBO(237, 237, 237, 1); + @override + final Color specificButtonBackgroundColor = + const Color.fromRGBO(251, 251, 251, 1); + @override + final Color balanceColor = const Color.fromRGBO(106, 139, 235, 1); + @override + final Color subBalanceColor = const Color.fromRGBO(124, 136, 171, 1); + @override + final Color subCardBackgroundColor = const Color.fromRGBO(245, 249, 255, 1); + @override + final Color lightButtonColor = const Color.fromRGBO(137, 182, 255, 0.12); + @override + final Color filterItemBorderColor = const Color.fromRGBO(239, 239, 239, 1); + @override + final Color warningColor = const Color.fromRGBO(229, 33, 103, 1); + @override + final Color progressBarColor = const Color.fromRGBO(69, 96, 120, 0.33); + @override + final Color progressBarPassedColor = const Color.fromRGBO(137, 147, 236, 1); + @override + final Color progressBarNotPassedColor = + const Color.fromRGBO(194, 203, 210, 1); + @override + final Color dexSubTitleColor = const Color.fromRGBO(134, 148, 161, 1); + @override + final Color tabBarShadowColor = const Color.fromRGBO(0, 0, 0, 0.08); + @override + final Color smartchainLabelBorderColor = const Color.fromRGBO(32, 22, 49, 1); + @override + final Color mainMenuSelectedItemBackgroundColor = + const Color.fromRGBO(146, 187, 255, 0.12); + @override + final Color selectedMenuBackgroundColor = + const Color.fromRGBO(146, 187, 255, 0.12); + @override + final Color searchFieldMobile = const Color.fromRGBO(239, 240, 242, 1); + @override + final Color walletEditButtonsBackgroundColor = + const Color.fromRGBO(248, 248, 248, 1); + @override + final Color swapButtonColor = const Color.fromRGBO(64, 146, 219, 1); + @override + final Color suspendedBannerBackgroundColor = + theme.currentGlobal.colorScheme.onSurface; + @override + final bridgeFormHeader = const TextStyle( + fontSize: 11, fontWeight: FontWeight.w500, letterSpacing: 3.5); + @override + final Color keyPadColor = theme.global.light.colorScheme.onSurface; + @override + final Color keyPadTextColor = const Color.fromRGBO(129, 151, 182, 1); + @override + final Color dialogBarrierColor = const Color.fromRGBO(3, 26, 43, 0.36); + @override + final Color dexCoinProtocolColor = const Color.fromRGBO(168, 177, 185, 1); + @override + final Color noTransactionsTextColor = const Color.fromRGBO(196, 196, 196, 1); +} diff --git a/app_theme/lib/src/light/theme_global_light.dart b/app_theme/lib/src/light/theme_global_light.dart new file mode 100644 index 0000000000..8e985dacff --- /dev/null +++ b/app_theme/lib/src/light/theme_global_light.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; + +ThemeData get themeGlobalLight { + const Color inputBackgroundColor = Color.fromRGBO(243, 245, 246, 1); + const Color textColor = Color.fromRGBO(69, 96, 120, 1); + + SnackBarThemeData snackBarThemeLight() => const SnackBarThemeData( + elevation: 12.0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(4))), + actionTextColor: Colors.green, + behavior: SnackBarBehavior.floating, + ); + + OutlineInputBorder outlineBorderLight(Color lightAccentColor) => + OutlineInputBorder( + borderSide: BorderSide(color: lightAccentColor), + borderRadius: BorderRadius.circular(18), + ); + + final ColorScheme colorScheme = const ColorScheme.light().copyWith( + primary: const Color.fromRGBO(90, 104, 230, 1), + secondary: const Color.fromRGBO(73, 134, 234, 1), + tertiary: const Color.fromARGB(255, 192, 225, 255), + surface: const Color.fromRGBO(255, 255, 255, 1), + onSurface: const Color.fromRGBO(251, 251, 251, 1), + error: const Color.fromRGBO(229, 33, 103, 1), + ); + + final TextTheme textTheme = TextTheme( + headlineMedium: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700, color: textColor), + headlineSmall: const TextStyle( + fontSize: 40, fontWeight: FontWeight.w700, color: textColor), + titleLarge: const TextStyle( + fontSize: 26.0, color: textColor, fontWeight: FontWeight.w700), + titleSmall: const TextStyle(fontSize: 18.0, color: textColor), + bodyMedium: const TextStyle( + fontSize: 16.0, color: textColor, fontWeight: FontWeight.w300), + labelLarge: const TextStyle(fontSize: 16.0, color: textColor), + bodyLarge: TextStyle(fontSize: 14.0, color: textColor.withOpacity(0.5)), + bodySmall: TextStyle( + fontSize: 12.0, + color: textColor.withOpacity(0.8), + fontWeight: FontWeight.w400, + ), + ); + + return ThemeData( + useMaterial3: false, + fontFamily: 'Manrope', + scaffoldBackgroundColor: colorScheme.onSurface, + cardColor: colorScheme.surface, + cardTheme: CardTheme( + color: colorScheme.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(18)), + ), + ), + colorScheme: colorScheme, + primaryColor: colorScheme.primary, + dividerColor: const Color.fromRGBO(208, 214, 237, 1), + appBarTheme: AppBarTheme(color: colorScheme.surface), + iconTheme: IconThemeData(color: colorScheme.primary), + progressIndicatorTheme: + ProgressIndicatorThemeData(color: colorScheme.primary), + dialogBackgroundColor: const Color.fromRGBO(255, 255, 255, 1), + dialogTheme: const DialogTheme( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.all( + Radius.circular(16), + ), + ), + ), + canvasColor: colorScheme.surface, + hintColor: const Color.fromRGBO(183, 187, 191, 1), + snackBarTheme: snackBarThemeLight(), + textSelectionTheme: TextSelectionThemeData( + cursorColor: const Color.fromRGBO(57, 161, 238, 1), + selectionColor: const Color.fromRGBO(57, 161, 238, 1).withOpacity(0.3), + selectionHandleColor: const Color.fromRGBO(57, 161, 238, 1), + ), + inputDecorationTheme: InputDecorationTheme( + enabledBorder: outlineBorderLight(Colors.transparent), + disabledBorder: outlineBorderLight(Colors.transparent), + border: outlineBorderLight(Colors.transparent), + focusedBorder: outlineBorderLight(Colors.transparent), + errorBorder: outlineBorderLight(colorScheme.error), + fillColor: inputBackgroundColor, + focusColor: inputBackgroundColor, + hoverColor: Colors.transparent, + errorStyle: TextStyle(color: colorScheme.error), + filled: true, + contentPadding: const EdgeInsets.symmetric(vertical: 12, horizontal: 22), + hintStyle: TextStyle( + color: textColor.withOpacity(0.58), + ), + labelStyle: TextStyle( + color: textColor.withOpacity(0.58), + ), + prefixIconColor: textColor.withOpacity(0.58), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ButtonStyle( + backgroundColor: WidgetStateProperty.resolveWith( + (Set states) { + if (states.contains(WidgetState.disabled)) return Colors.grey; + return colorScheme.primary; + }, + ), + ), + ), + checkboxTheme: CheckboxThemeData( + checkColor: WidgetStateProperty.all(Colors.white), + fillColor: WidgetStateProperty.all(colorScheme.primary), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(2)), + ), + floatingActionButtonTheme: FloatingActionButtonThemeData( + backgroundColor: colorScheme.primary, + ), + textTheme: textTheme, + scrollbarTheme: ScrollbarThemeData( + thumbColor: + WidgetStateProperty.all(colorScheme.primary.withOpacity(0.8)), + ), + bottomNavigationBarTheme: BottomNavigationBarThemeData( + // remove icons shift + type: BottomNavigationBarType.fixed, + backgroundColor: colorScheme.surface, + selectedItemColor: const Color.fromRGBO(34, 121, 241, 1), + unselectedItemColor: textColor, + unselectedLabelStyle: + const TextStyle(fontSize: 12, fontWeight: FontWeight.w600), + selectedLabelStyle: + const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + segmentedButtonTheme: SegmentedButtonThemeData( + style: SegmentedButton.styleFrom( + backgroundColor: const Color.fromRGBO(243, 245, 246, 1), + surfaceTintColor: Colors.purple, + selectedBackgroundColor: colorScheme.primary, + foregroundColor: textColor.withOpacity(0.7), + selectedForegroundColor: Colors.white, + side: const BorderSide(color: Color.fromRGBO(208, 214, 237, 1)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 4), + ), + ), + tabBarTheme: TabBarTheme( + labelColor: textColor, + indicator: UnderlineTabIndicator( + borderSide: BorderSide( + width: 2.0, + color: colorScheme.primary, + ), + // Match the card's border radius + insets: const EdgeInsets.symmetric( + horizontal: 18), + ), + ), + ); +} diff --git a/app_theme/lib/src/new_theme/extensions/color_scheme_extension.dart b/app_theme/lib/src/new_theme/extensions/color_scheme_extension.dart new file mode 100644 index 0000000000..08254f5161 --- /dev/null +++ b/app_theme/lib/src/new_theme/extensions/color_scheme_extension.dart @@ -0,0 +1,71 @@ +import 'package:flutter/material.dart'; + +class ColorSchemeExtension extends ThemeExtension { + const ColorSchemeExtension({ + required this.primary, + required this.p50, + required this.p40, + required this.p10, + required this.secondary, + required this.s70, + required this.s50, + required this.s40, + required this.s30, + required this.s20, + required this.s10, + required this.surf, + required this.surfContHighest, + required this.surfContHigh, + required this.surfCont, + required this.surfContLow, + required this.surfContLowest, + required this.error, + required this.e50, + required this.e20, + required this.e10, + required this.green, + required this.g20, + required this.g10, + required this.orange, + required this.yellow, + required this.purple, + }); + final Color primary; + final Color p50; + final Color p40; + final Color p10; + final Color secondary; + final Color s70; + final Color s50; + final Color s40; + final Color s30; + final Color s20; + final Color s10; + final Color surf; + final Color surfContHighest; + final Color surfContHigh; + final Color surfCont; + final Color surfContLow; + final Color surfContLowest; + final Color error; + final Color e50; + final Color e20; + final Color e10; + final Color green; + final Color g20; + final Color g10; + final Color orange; + final Color yellow; + final Color purple; + + @override + ThemeExtension copyWith() { + return this; + } + + @override + ThemeExtension lerp( + covariant ThemeExtension? other, double t) { + return this; + } +} diff --git a/app_theme/lib/src/new_theme/extensions/text_theme_extension.dart b/app_theme/lib/src/new_theme/extensions/text_theme_extension.dart new file mode 100644 index 0000000000..f474d0a3dd --- /dev/null +++ b/app_theme/lib/src/new_theme/extensions/text_theme_extension.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +class TextThemeExtension extends ThemeExtension { + TextThemeExtension({ + required Color textColor, + }) : heading1 = TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + color: textColor, + ), + heading2 = TextStyle( + fontSize: 24, + fontWeight: FontWeight.w700, + color: textColor, + ), + bodyM = TextStyle( + fontSize: 16, color: textColor, fontWeight: FontWeight.w500), + bodyMBold = TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + color: textColor, + ), + bodyS = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: textColor, + ), + bodySBold = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: textColor, + ), + bodyXS = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: textColor, + ), + bodyXSBold = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: textColor, + ), + bodyXXS = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w500, + color: textColor, + ), + bodyXXSBold = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: textColor, + ); + final TextStyle heading1; + final TextStyle heading2; + final TextStyle bodyM; + final TextStyle bodyMBold; + final TextStyle bodyS; + final TextStyle bodySBold; + final TextStyle bodyXS; + final TextStyle bodyXSBold; + final TextStyle bodyXXS; + final TextStyle bodyXXSBold; + + @override + ThemeExtension copyWith() { + return this; + } + + @override + ThemeExtension lerp( + covariant ThemeExtension? other, double t) { + return this; + } +} diff --git a/app_theme/lib/src/new_theme/new_theme_dark.dart b/app_theme/lib/src/new_theme/new_theme_dark.dart new file mode 100644 index 0000000000..2219384286 --- /dev/null +++ b/app_theme/lib/src/new_theme/new_theme_dark.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; + +import '../../app_theme.dart'; + +const ColorSchemeExtension _colorSchemeExtension = ColorSchemeExtension( + primary: Color.fromRGBO(61, 119, 233, 1), + p50: Color.fromRGBO(31, 60, 117, 1), + p40: Color.fromRGBO(24, 48, 93, 1), + p10: Color.fromRGBO(6, 12, 23, 1), + secondary: Color.fromRGBO(173, 175, 196, 1), + s70: Color.fromRGBO(121, 123, 137, 1), + s50: Color.fromRGBO(87, 88, 98, 1), + s40: Color.fromRGBO(69, 70, 78, 1), + s30: Color.fromRGBO(52, 53, 59, 1), + s20: Color.fromRGBO(35, 35, 39, 1), + s10: Color.fromRGBO(17, 18, 20, 1), + e10: Color.fromRGBO(21, 6, 10, 1), + e20: Color.fromRGBO(42, 11, 21, 1), + e50: Color.fromRGBO(105, 29, 52, 1), + error: Color.fromRGBO(210, 57, 104, 1), + g10: Color.fromRGBO(9, 19, 17, 1), + g20: Color.fromRGBO(18, 38, 34, 1), + green: Color.fromRGBO(88, 192, 171, 1), + surf: Color.fromRGBO(255, 255, 255, 1), + surfCont: Color.fromRGBO(33, 35, 54, 1), + surfContHigh: Color.fromRGBO(43, 45, 64, 1), + surfContHighest: Color.fromRGBO(53, 55, 74, 1), + surfContLow: Color.fromRGBO(23, 25, 38, 1), + surfContLowest: Color.fromRGBO(18, 20, 31, 1), + orange: Color.fromRGBO(237, 170, 70, 1), + yellow: Color.fromRGBO(230, 188, 65, 1), + purple: Color.fromRGBO(123, 73, 221, 1), +); + +final ColorScheme _colorScheme = theme.global.dark.colorScheme.copyWith( + primary: _colorSchemeExtension.primary, + secondary: _colorSchemeExtension.secondary, +); +final TextTheme _textTheme = theme.global.dark.textTheme.copyWith(); +final TextThemeExtension _textThemeExtension = TextThemeExtension( + textColor: _colorSchemeExtension.secondary, +); + +final ThemeData newThemeDataDark = theme.global.dark.copyWith( + colorScheme: _colorScheme, + textTheme: _textTheme, + inputDecorationTheme: theme.global.dark.inputDecorationTheme.copyWith( + hintStyle: _textThemeExtension.bodySBold + .copyWith(color: _colorSchemeExtension.s50), + labelStyle: _textThemeExtension.bodyXSBold + .copyWith(color: _colorSchemeExtension.primary), + errorStyle: + _textThemeExtension.bodyS.copyWith(color: _colorSchemeExtension.error), + enabledBorder: _outlineBorderLight(_colorSchemeExtension.secondary), + disabledBorder: _outlineBorderLight(_colorSchemeExtension.secondary), + focusedBorder: _outlineBorderLight(_colorSchemeExtension.primary), + errorBorder: _outlineBorderLight(_colorSchemeExtension.error), + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + ), + extensions: [_colorSchemeExtension, _textThemeExtension], +); + +OutlineInputBorder _outlineBorderLight(Color accentColor) => OutlineInputBorder( + borderSide: BorderSide(color: accentColor, width: 2), + borderRadius: BorderRadius.circular(18), + ); diff --git a/app_theme/lib/src/new_theme/new_theme_light.dart b/app_theme/lib/src/new_theme/new_theme_light.dart new file mode 100644 index 0000000000..b5a6015549 --- /dev/null +++ b/app_theme/lib/src/new_theme/new_theme_light.dart @@ -0,0 +1,66 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +const ColorSchemeExtension _colorSchemeExtension = ColorSchemeExtension( + primary: Color.fromRGBO(61, 119, 233, 1), + p50: Color.fromRGBO(158, 187, 244, 1), + p40: Color.fromRGBO(177, 201, 246, 1), + p10: Color.fromRGBO(236, 241, 253, 1), + secondary: Color.fromRGBO(69, 96, 120, 1), + s70: Color.fromRGBO(125, 144, 161, 1), + s50: Color.fromRGBO(162, 175, 187, 1), + s40: Color.fromRGBO(181, 191, 201, 1), + s30: Color.fromRGBO(199, 207, 214, 1), + s20: Color.fromRGBO(218, 223, 228, 1), + s10: Color.fromRGBO(236, 239, 241, 1), + e10: Color.fromRGBO(250, 235, 240, 1), + e20: Color.fromRGBO(246, 215, 225, 1), + e50: Color.fromRGBO(233, 156, 179, 1), + error: Color.fromRGBO(210, 57, 104, 1), + g10: Color.fromRGBO(238, 249, 247, 1), + g20: Color.fromRGBO(222, 242, 238, 1), + green: Color.fromRGBO(88, 192, 171, 1), + surf: Color.fromRGBO(255, 255, 255, 1), + surfCont: Color.fromRGBO(255, 255, 255, 1), + surfContHigh: Color.fromRGBO(245, 245, 245, 1), + surfContHighest: Color.fromRGBO(235, 235, 235, 1), + surfContLow: Color.fromRGBO(253, 253, 253, 1), + surfContLowest: Color.fromRGBO(245, 245, 245, 1), + orange: Color.fromRGBO(237, 170, 70, 1), + yellow: Color.fromRGBO(230, 188, 65, 1), + purple: Color.fromRGBO(123, 73, 221, 1), +); + +final ColorScheme _colorScheme = theme.global.light.colorScheme.copyWith( + primary: _colorSchemeExtension.primary, + secondary: _colorSchemeExtension.secondary, + error: _colorSchemeExtension.error, +); +final TextTheme _textTheme = theme.global.light.textTheme.copyWith(); +final TextThemeExtension _textThemeExtension = TextThemeExtension( + textColor: _colorSchemeExtension.secondary, +); +final ThemeData newThemeDataLight = theme.global.light.copyWith( + colorScheme: _colorScheme, + textTheme: _textTheme, + inputDecorationTheme: theme.global.light.inputDecorationTheme.copyWith( + hintStyle: _textThemeExtension.bodySBold + .copyWith(color: _colorSchemeExtension.s50), + labelStyle: _textThemeExtension.bodyXSBold + .copyWith(color: _colorSchemeExtension.primary), + errorStyle: + _textThemeExtension.bodyS.copyWith(color: _colorSchemeExtension.error), + enabledBorder: _outlineBorderLight(_colorSchemeExtension.secondary), + disabledBorder: _outlineBorderLight(_colorSchemeExtension.secondary), + focusedBorder: _outlineBorderLight(_colorSchemeExtension.primary), + errorBorder: _outlineBorderLight(_colorSchemeExtension.error), + fillColor: Colors.transparent, + hoverColor: Colors.transparent, + ), + extensions: [_colorSchemeExtension, _textThemeExtension], +); + +OutlineInputBorder _outlineBorderLight(Color accentColor) => OutlineInputBorder( + borderSide: BorderSide(color: accentColor, width: 2), + borderRadius: BorderRadius.circular(18), + ); diff --git a/app_theme/lib/src/theme_global.dart b/app_theme/lib/src/theme_global.dart new file mode 100644 index 0000000000..137cc6e96f --- /dev/null +++ b/app_theme/lib/src/theme_global.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +import 'dark/theme_global_dark.dart'; +import 'light/theme_global_light.dart'; + +class ThemeDataGlobal { + final ThemeData light = themeGlobalLight; + final ThemeData dark = themeGlobalDark; +} diff --git a/app_theme/pubspec.lock b/app_theme/pubspec.lock new file mode 100644 index 0000000000..5b0b8ab2cf --- /dev/null +++ b/app_theme/pubspec.lock @@ -0,0 +1,64 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + meta: + dependency: transitive + description: + name: meta + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + url: "https://pub.dev" + source: hosted + version: "1.12.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.99" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" +sdks: + dart: ">=3.3.0-0 <4.0.0" + flutter: ">=2.5.0" diff --git a/app_theme/pubspec.yaml b/app_theme/pubspec.yaml new file mode 100644 index 0000000000..e84ef9bfb0 --- /dev/null +++ b/app_theme/pubspec.yaml @@ -0,0 +1,13 @@ +name: app_theme +description: App theme. +version: 0.0.1 +homepage: + +environment: + sdk: ">=2.16.0 <3.0.0" + flutter: ">=2.5.0" + +dependencies: + flutter: + sdk: flutter + plugin_platform_interface: 2.1.8 # flutter.dev diff --git a/assets/app_icon/logo_icon.png b/assets/app_icon/logo_icon.png new file mode 100644 index 0000000000..445e2ca862 Binary files /dev/null and b/assets/app_icon/logo_icon.png differ diff --git a/assets/blockchain_icons/svg/32px/avalanche.svg b/assets/blockchain_icons/svg/32px/avalanche.svg new file mode 100644 index 0000000000..01c356dabe --- /dev/null +++ b/assets/blockchain_icons/svg/32px/avalanche.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/blockchain_icons/svg/32px/bsc.svg b/assets/blockchain_icons/svg/32px/bsc.svg new file mode 100644 index 0000000000..1515ae2bd9 --- /dev/null +++ b/assets/blockchain_icons/svg/32px/bsc.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/blockchain_icons/svg/32px/eth.svg b/assets/blockchain_icons/svg/32px/eth.svg new file mode 100644 index 0000000000..ce9c92fd5a --- /dev/null +++ b/assets/blockchain_icons/svg/32px/eth.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/blockchain_icons/svg/32px/fantom.svg b/assets/blockchain_icons/svg/32px/fantom.svg new file mode 100644 index 0000000000..26b59af458 --- /dev/null +++ b/assets/blockchain_icons/svg/32px/fantom.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/blockchain_icons/svg/32px/polygon.svg b/assets/blockchain_icons/svg/32px/polygon.svg new file mode 100644 index 0000000000..914250849f --- /dev/null +++ b/assets/blockchain_icons/svg/32px/polygon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/config/.gitkeep b/assets/config/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/assets/custom_icons/16px/cross.svg b/assets/custom_icons/16px/cross.svg new file mode 100644 index 0000000000..e875ef5f4e --- /dev/null +++ b/assets/custom_icons/16px/cross.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/custom_icons/16px/search.svg b/assets/custom_icons/16px/search.svg new file mode 100644 index 0000000000..c970c326f9 --- /dev/null +++ b/assets/custom_icons/16px/search.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/custom_icons/arrow_down.svg b/assets/custom_icons/arrow_down.svg new file mode 100644 index 0000000000..1b37a70ef3 --- /dev/null +++ b/assets/custom_icons/arrow_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/custom_icons/arrow_up.svg b/assets/custom_icons/arrow_up.svg new file mode 100644 index 0000000000..fd92749bc1 --- /dev/null +++ b/assets/custom_icons/arrow_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/custom_icons/filter.svg b/assets/custom_icons/filter.svg new file mode 100644 index 0000000000..c4b954f2ab --- /dev/null +++ b/assets/custom_icons/filter.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/custom_icons/receive.svg b/assets/custom_icons/receive.svg new file mode 100644 index 0000000000..c5cecc380d --- /dev/null +++ b/assets/custom_icons/receive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/custom_icons/send.svg b/assets/custom_icons/send.svg new file mode 100644 index 0000000000..fead6ca0cb --- /dev/null +++ b/assets/custom_icons/send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/fallback_fonts/roboto/v20/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf b/assets/fallback_fonts/roboto/v20/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf new file mode 100644 index 0000000000..2b6392ffe8 Binary files /dev/null and b/assets/fallback_fonts/roboto/v20/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf differ diff --git a/assets/fiat/fiat_icons_square/aed.webp b/assets/fiat/fiat_icons_square/aed.webp new file mode 100644 index 0000000000..11ded81d8a Binary files /dev/null and b/assets/fiat/fiat_icons_square/aed.webp differ diff --git a/assets/fiat/fiat_icons_square/afn.webp b/assets/fiat/fiat_icons_square/afn.webp new file mode 100644 index 0000000000..8a434a960a Binary files /dev/null and b/assets/fiat/fiat_icons_square/afn.webp differ diff --git a/assets/fiat/fiat_icons_square/all.webp b/assets/fiat/fiat_icons_square/all.webp new file mode 100644 index 0000000000..68e20529ac Binary files /dev/null and b/assets/fiat/fiat_icons_square/all.webp differ diff --git a/assets/fiat/fiat_icons_square/amd.webp b/assets/fiat/fiat_icons_square/amd.webp new file mode 100644 index 0000000000..34a3de5700 Binary files /dev/null and b/assets/fiat/fiat_icons_square/amd.webp differ diff --git a/assets/fiat/fiat_icons_square/ang.webp b/assets/fiat/fiat_icons_square/ang.webp new file mode 100644 index 0000000000..5ce23fa494 Binary files /dev/null and b/assets/fiat/fiat_icons_square/ang.webp differ diff --git a/assets/fiat/fiat_icons_square/aoa.webp b/assets/fiat/fiat_icons_square/aoa.webp new file mode 100644 index 0000000000..8004a2398f Binary files /dev/null and b/assets/fiat/fiat_icons_square/aoa.webp differ diff --git a/assets/fiat/fiat_icons_square/aq.webp b/assets/fiat/fiat_icons_square/aq.webp new file mode 100644 index 0000000000..755bfb3dd8 Binary files /dev/null and b/assets/fiat/fiat_icons_square/aq.webp differ diff --git a/assets/fiat/fiat_icons_square/arab.webp b/assets/fiat/fiat_icons_square/arab.webp new file mode 100644 index 0000000000..cdc6108024 Binary files /dev/null and b/assets/fiat/fiat_icons_square/arab.webp differ diff --git a/assets/fiat/fiat_icons_square/ars.webp b/assets/fiat/fiat_icons_square/ars.webp new file mode 100644 index 0000000000..30032250a0 Binary files /dev/null and b/assets/fiat/fiat_icons_square/ars.webp differ diff --git a/assets/fiat/fiat_icons_square/aud.webp b/assets/fiat/fiat_icons_square/aud.webp new file mode 100644 index 0000000000..aa05dc1579 Binary files /dev/null and b/assets/fiat/fiat_icons_square/aud.webp differ diff --git a/assets/fiat/fiat_icons_square/awg.webp b/assets/fiat/fiat_icons_square/awg.webp new file mode 100644 index 0000000000..adfbe34ea3 Binary files /dev/null and b/assets/fiat/fiat_icons_square/awg.webp differ diff --git a/assets/fiat/fiat_icons_square/azn.webp b/assets/fiat/fiat_icons_square/azn.webp new file mode 100644 index 0000000000..db3cd7f643 Binary files /dev/null and b/assets/fiat/fiat_icons_square/azn.webp differ diff --git a/assets/fiat/fiat_icons_square/bam.webp b/assets/fiat/fiat_icons_square/bam.webp new file mode 100644 index 0000000000..ec6161263e Binary files /dev/null and b/assets/fiat/fiat_icons_square/bam.webp differ diff --git a/assets/fiat/fiat_icons_square/bbd.webp b/assets/fiat/fiat_icons_square/bbd.webp new file mode 100644 index 0000000000..255099db61 Binary files /dev/null and b/assets/fiat/fiat_icons_square/bbd.webp differ diff --git a/assets/fiat/fiat_icons_square/bdt.webp b/assets/fiat/fiat_icons_square/bdt.webp new file mode 100644 index 0000000000..b34f56e6a1 Binary files /dev/null and b/assets/fiat/fiat_icons_square/bdt.webp differ diff --git a/assets/fiat/fiat_icons_square/bgn.webp b/assets/fiat/fiat_icons_square/bgn.webp new file mode 100644 index 0000000000..15007a49b2 Binary files /dev/null and b/assets/fiat/fiat_icons_square/bgn.webp differ diff --git a/assets/fiat/fiat_icons_square/bhd.webp b/assets/fiat/fiat_icons_square/bhd.webp new file mode 100644 index 0000000000..dfbe0960b2 Binary files /dev/null and b/assets/fiat/fiat_icons_square/bhd.webp differ diff --git a/assets/fiat/fiat_icons_square/bif.webp b/assets/fiat/fiat_icons_square/bif.webp new file mode 100644 index 0000000000..de08ec74f5 Binary files /dev/null and b/assets/fiat/fiat_icons_square/bif.webp differ diff --git a/assets/fiat/fiat_icons_square/bmd.webp b/assets/fiat/fiat_icons_square/bmd.webp new file mode 100644 index 0000000000..5eaf842d5d Binary files /dev/null and b/assets/fiat/fiat_icons_square/bmd.webp differ diff --git a/assets/fiat/fiat_icons_square/bnd.webp b/assets/fiat/fiat_icons_square/bnd.webp new file mode 100644 index 0000000000..b54704b945 Binary files /dev/null and b/assets/fiat/fiat_icons_square/bnd.webp differ diff --git a/assets/fiat/fiat_icons_square/bob.webp b/assets/fiat/fiat_icons_square/bob.webp new file mode 100644 index 0000000000..70948b51e1 Binary files /dev/null and b/assets/fiat/fiat_icons_square/bob.webp differ diff --git a/assets/fiat/fiat_icons_square/brl.webp b/assets/fiat/fiat_icons_square/brl.webp new file mode 100644 index 0000000000..0439052193 Binary files /dev/null and b/assets/fiat/fiat_icons_square/brl.webp differ diff --git a/assets/fiat/fiat_icons_square/bsd.webp b/assets/fiat/fiat_icons_square/bsd.webp new file mode 100644 index 0000000000..232817c0ff Binary files /dev/null and b/assets/fiat/fiat_icons_square/bsd.webp differ diff --git a/assets/fiat/fiat_icons_square/btn.webp b/assets/fiat/fiat_icons_square/btn.webp new file mode 100644 index 0000000000..a03099552a Binary files /dev/null and b/assets/fiat/fiat_icons_square/btn.webp differ diff --git a/assets/fiat/fiat_icons_square/bv.webp b/assets/fiat/fiat_icons_square/bv.webp new file mode 100644 index 0000000000..b69b0a5af0 Binary files /dev/null and b/assets/fiat/fiat_icons_square/bv.webp differ diff --git a/assets/fiat/fiat_icons_square/bwp.webp b/assets/fiat/fiat_icons_square/bwp.webp new file mode 100644 index 0000000000..edb8bce317 Binary files /dev/null and b/assets/fiat/fiat_icons_square/bwp.webp differ diff --git a/assets/fiat/fiat_icons_square/byn.webp b/assets/fiat/fiat_icons_square/byn.webp new file mode 100644 index 0000000000..b660d795bf Binary files /dev/null and b/assets/fiat/fiat_icons_square/byn.webp differ diff --git a/assets/fiat/fiat_icons_square/bzd.webp b/assets/fiat/fiat_icons_square/bzd.webp new file mode 100644 index 0000000000..a88c6a91e8 Binary files /dev/null and b/assets/fiat/fiat_icons_square/bzd.webp differ diff --git a/assets/fiat/fiat_icons_square/cad.webp b/assets/fiat/fiat_icons_square/cad.webp new file mode 100644 index 0000000000..db4a74f406 Binary files /dev/null and b/assets/fiat/fiat_icons_square/cad.webp differ diff --git a/assets/fiat/fiat_icons_square/cdf.webp b/assets/fiat/fiat_icons_square/cdf.webp new file mode 100644 index 0000000000..9ae7074db2 Binary files /dev/null and b/assets/fiat/fiat_icons_square/cdf.webp differ diff --git a/assets/fiat/fiat_icons_square/cefta.webp b/assets/fiat/fiat_icons_square/cefta.webp new file mode 100644 index 0000000000..224d670d22 Binary files /dev/null and b/assets/fiat/fiat_icons_square/cefta.webp differ diff --git a/assets/fiat/fiat_icons_square/chf.webp b/assets/fiat/fiat_icons_square/chf.webp new file mode 100644 index 0000000000..bbd86bacfc Binary files /dev/null and b/assets/fiat/fiat_icons_square/chf.webp differ diff --git a/assets/fiat/fiat_icons_square/ckd.webp b/assets/fiat/fiat_icons_square/ckd.webp new file mode 100644 index 0000000000..701925d8bc Binary files /dev/null and b/assets/fiat/fiat_icons_square/ckd.webp differ diff --git a/assets/fiat/fiat_icons_square/clp.webp b/assets/fiat/fiat_icons_square/clp.webp new file mode 100644 index 0000000000..adae9f3aca Binary files /dev/null and b/assets/fiat/fiat_icons_square/clp.webp differ diff --git a/assets/fiat/fiat_icons_square/cny.webp b/assets/fiat/fiat_icons_square/cny.webp new file mode 100644 index 0000000000..b3478715f0 Binary files /dev/null and b/assets/fiat/fiat_icons_square/cny.webp differ diff --git a/assets/fiat/fiat_icons_square/cop.webp b/assets/fiat/fiat_icons_square/cop.webp new file mode 100644 index 0000000000..3cafd35651 Binary files /dev/null and b/assets/fiat/fiat_icons_square/cop.webp differ diff --git a/assets/fiat/fiat_icons_square/cp.webp b/assets/fiat/fiat_icons_square/cp.webp new file mode 100644 index 0000000000..f43ecbee29 Binary files /dev/null and b/assets/fiat/fiat_icons_square/cp.webp differ diff --git a/assets/fiat/fiat_icons_square/crc.webp b/assets/fiat/fiat_icons_square/crc.webp new file mode 100644 index 0000000000..704a06d2ec Binary files /dev/null and b/assets/fiat/fiat_icons_square/crc.webp differ diff --git a/assets/fiat/fiat_icons_square/cuc.webp b/assets/fiat/fiat_icons_square/cuc.webp new file mode 100644 index 0000000000..619c71cc79 Binary files /dev/null and b/assets/fiat/fiat_icons_square/cuc.webp differ diff --git a/assets/fiat/fiat_icons_square/cve.webp b/assets/fiat/fiat_icons_square/cve.webp new file mode 100644 index 0000000000..c6e4634055 Binary files /dev/null and b/assets/fiat/fiat_icons_square/cve.webp differ diff --git a/assets/fiat/fiat_icons_square/czk.webp b/assets/fiat/fiat_icons_square/czk.webp new file mode 100644 index 0000000000..5d14c9cde3 Binary files /dev/null and b/assets/fiat/fiat_icons_square/czk.webp differ diff --git a/assets/fiat/fiat_icons_square/dg.webp b/assets/fiat/fiat_icons_square/dg.webp new file mode 100644 index 0000000000..a136215df7 Binary files /dev/null and b/assets/fiat/fiat_icons_square/dg.webp differ diff --git a/assets/fiat/fiat_icons_square/djf.webp b/assets/fiat/fiat_icons_square/djf.webp new file mode 100644 index 0000000000..45ad8876ba Binary files /dev/null and b/assets/fiat/fiat_icons_square/djf.webp differ diff --git a/assets/fiat/fiat_icons_square/dkk.webp b/assets/fiat/fiat_icons_square/dkk.webp new file mode 100644 index 0000000000..760103df9d Binary files /dev/null and b/assets/fiat/fiat_icons_square/dkk.webp differ diff --git a/assets/fiat/fiat_icons_square/dop.webp b/assets/fiat/fiat_icons_square/dop.webp new file mode 100644 index 0000000000..329fb8cfa6 Binary files /dev/null and b/assets/fiat/fiat_icons_square/dop.webp differ diff --git a/assets/fiat/fiat_icons_square/dzd.webp b/assets/fiat/fiat_icons_square/dzd.webp new file mode 100644 index 0000000000..374476758c Binary files /dev/null and b/assets/fiat/fiat_icons_square/dzd.webp differ diff --git a/assets/fiat/fiat_icons_square/eac.webp b/assets/fiat/fiat_icons_square/eac.webp new file mode 100644 index 0000000000..4e519bd751 Binary files /dev/null and b/assets/fiat/fiat_icons_square/eac.webp differ diff --git a/assets/fiat/fiat_icons_square/egp.webp b/assets/fiat/fiat_icons_square/egp.webp new file mode 100644 index 0000000000..b10e832964 Binary files /dev/null and b/assets/fiat/fiat_icons_square/egp.webp differ diff --git a/assets/fiat/fiat_icons_square/ern.webp b/assets/fiat/fiat_icons_square/ern.webp new file mode 100644 index 0000000000..6abdfd8350 Binary files /dev/null and b/assets/fiat/fiat_icons_square/ern.webp differ diff --git a/assets/fiat/fiat_icons_square/es-ct.webp b/assets/fiat/fiat_icons_square/es-ct.webp new file mode 100644 index 0000000000..de23756e6e Binary files /dev/null and b/assets/fiat/fiat_icons_square/es-ct.webp differ diff --git a/assets/fiat/fiat_icons_square/es-ga.webp b/assets/fiat/fiat_icons_square/es-ga.webp new file mode 100644 index 0000000000..7fd73a18b6 Binary files /dev/null and b/assets/fiat/fiat_icons_square/es-ga.webp differ diff --git a/assets/fiat/fiat_icons_square/es-pv.webp b/assets/fiat/fiat_icons_square/es-pv.webp new file mode 100644 index 0000000000..f02234ffef Binary files /dev/null and b/assets/fiat/fiat_icons_square/es-pv.webp differ diff --git a/assets/fiat/fiat_icons_square/etb.webp b/assets/fiat/fiat_icons_square/etb.webp new file mode 100644 index 0000000000..a4ea7f5c0b Binary files /dev/null and b/assets/fiat/fiat_icons_square/etb.webp differ diff --git a/assets/fiat/fiat_icons_square/eur.webp b/assets/fiat/fiat_icons_square/eur.webp new file mode 100644 index 0000000000..308ade766e Binary files /dev/null and b/assets/fiat/fiat_icons_square/eur.webp differ diff --git a/assets/fiat/fiat_icons_square/fjd.webp b/assets/fiat/fiat_icons_square/fjd.webp new file mode 100644 index 0000000000..7e2b1be6e1 Binary files /dev/null and b/assets/fiat/fiat_icons_square/fjd.webp differ diff --git a/assets/fiat/fiat_icons_square/fkp.webp b/assets/fiat/fiat_icons_square/fkp.webp new file mode 100644 index 0000000000..e0b06e2b90 Binary files /dev/null and b/assets/fiat/fiat_icons_square/fkp.webp differ diff --git a/assets/fiat/fiat_icons_square/gb-eng.webp b/assets/fiat/fiat_icons_square/gb-eng.webp new file mode 100644 index 0000000000..495efa0b69 Binary files /dev/null and b/assets/fiat/fiat_icons_square/gb-eng.webp differ diff --git a/assets/fiat/fiat_icons_square/gb-nir.webp b/assets/fiat/fiat_icons_square/gb-nir.webp new file mode 100644 index 0000000000..8281dfa077 Binary files /dev/null and b/assets/fiat/fiat_icons_square/gb-nir.webp differ diff --git a/assets/fiat/fiat_icons_square/gb-sct.webp b/assets/fiat/fiat_icons_square/gb-sct.webp new file mode 100644 index 0000000000..8ec5063624 Binary files /dev/null and b/assets/fiat/fiat_icons_square/gb-sct.webp differ diff --git a/assets/fiat/fiat_icons_square/gb-wls.webp b/assets/fiat/fiat_icons_square/gb-wls.webp new file mode 100644 index 0000000000..d8c0ff2484 Binary files /dev/null and b/assets/fiat/fiat_icons_square/gb-wls.webp differ diff --git a/assets/fiat/fiat_icons_square/gbp.webp b/assets/fiat/fiat_icons_square/gbp.webp new file mode 100644 index 0000000000..849fff2fb7 Binary files /dev/null and b/assets/fiat/fiat_icons_square/gbp.webp differ diff --git a/assets/fiat/fiat_icons_square/gel.webp b/assets/fiat/fiat_icons_square/gel.webp new file mode 100644 index 0000000000..6a9e88a47d Binary files /dev/null and b/assets/fiat/fiat_icons_square/gel.webp differ diff --git a/assets/fiat/fiat_icons_square/ghs.webp b/assets/fiat/fiat_icons_square/ghs.webp new file mode 100644 index 0000000000..a8a83801cf Binary files /dev/null and b/assets/fiat/fiat_icons_square/ghs.webp differ diff --git a/assets/fiat/fiat_icons_square/gip.webp b/assets/fiat/fiat_icons_square/gip.webp new file mode 100644 index 0000000000..fd015481f5 Binary files /dev/null and b/assets/fiat/fiat_icons_square/gip.webp differ diff --git a/assets/fiat/fiat_icons_square/gmd.webp b/assets/fiat/fiat_icons_square/gmd.webp new file mode 100644 index 0000000000..13bfcb8c9e Binary files /dev/null and b/assets/fiat/fiat_icons_square/gmd.webp differ diff --git a/assets/fiat/fiat_icons_square/gnf.webp b/assets/fiat/fiat_icons_square/gnf.webp new file mode 100644 index 0000000000..49cbd58409 Binary files /dev/null and b/assets/fiat/fiat_icons_square/gnf.webp differ diff --git a/assets/fiat/fiat_icons_square/gtq.webp b/assets/fiat/fiat_icons_square/gtq.webp new file mode 100644 index 0000000000..aa315e4e0f Binary files /dev/null and b/assets/fiat/fiat_icons_square/gtq.webp differ diff --git a/assets/fiat/fiat_icons_square/gyd.webp b/assets/fiat/fiat_icons_square/gyd.webp new file mode 100644 index 0000000000..562c6924d6 Binary files /dev/null and b/assets/fiat/fiat_icons_square/gyd.webp differ diff --git a/assets/fiat/fiat_icons_square/hkd.webp b/assets/fiat/fiat_icons_square/hkd.webp new file mode 100644 index 0000000000..402d661c3d Binary files /dev/null and b/assets/fiat/fiat_icons_square/hkd.webp differ diff --git a/assets/fiat/fiat_icons_square/hnl.webp b/assets/fiat/fiat_icons_square/hnl.webp new file mode 100644 index 0000000000..5e19a76207 Binary files /dev/null and b/assets/fiat/fiat_icons_square/hnl.webp differ diff --git a/assets/fiat/fiat_icons_square/htg.webp b/assets/fiat/fiat_icons_square/htg.webp new file mode 100644 index 0000000000..e2a12bafcf Binary files /dev/null and b/assets/fiat/fiat_icons_square/htg.webp differ diff --git a/assets/fiat/fiat_icons_square/huf.webp b/assets/fiat/fiat_icons_square/huf.webp new file mode 100644 index 0000000000..538596c576 Binary files /dev/null and b/assets/fiat/fiat_icons_square/huf.webp differ diff --git a/assets/fiat/fiat_icons_square/ic.webp b/assets/fiat/fiat_icons_square/ic.webp new file mode 100644 index 0000000000..cae2e918ae Binary files /dev/null and b/assets/fiat/fiat_icons_square/ic.webp differ diff --git a/assets/fiat/fiat_icons_square/idr.webp b/assets/fiat/fiat_icons_square/idr.webp new file mode 100644 index 0000000000..eafb00f313 Binary files /dev/null and b/assets/fiat/fiat_icons_square/idr.webp differ diff --git a/assets/fiat/fiat_icons_square/ils.webp b/assets/fiat/fiat_icons_square/ils.webp new file mode 100644 index 0000000000..ff19ae7061 Binary files /dev/null and b/assets/fiat/fiat_icons_square/ils.webp differ diff --git a/assets/fiat/fiat_icons_square/inr.webp b/assets/fiat/fiat_icons_square/inr.webp new file mode 100644 index 0000000000..f007ff6e6f Binary files /dev/null and b/assets/fiat/fiat_icons_square/inr.webp differ diff --git a/assets/fiat/fiat_icons_square/iqd.webp b/assets/fiat/fiat_icons_square/iqd.webp new file mode 100644 index 0000000000..4eb36a14ed Binary files /dev/null and b/assets/fiat/fiat_icons_square/iqd.webp differ diff --git a/assets/fiat/fiat_icons_square/irr.webp b/assets/fiat/fiat_icons_square/irr.webp new file mode 100644 index 0000000000..8e8b660732 Binary files /dev/null and b/assets/fiat/fiat_icons_square/irr.webp differ diff --git a/assets/fiat/fiat_icons_square/isk.webp b/assets/fiat/fiat_icons_square/isk.webp new file mode 100644 index 0000000000..89c9a8258e Binary files /dev/null and b/assets/fiat/fiat_icons_square/isk.webp differ diff --git a/assets/fiat/fiat_icons_square/jmd.webp b/assets/fiat/fiat_icons_square/jmd.webp new file mode 100644 index 0000000000..0978d45726 Binary files /dev/null and b/assets/fiat/fiat_icons_square/jmd.webp differ diff --git a/assets/fiat/fiat_icons_square/jod.webp b/assets/fiat/fiat_icons_square/jod.webp new file mode 100644 index 0000000000..a826bb76fc Binary files /dev/null and b/assets/fiat/fiat_icons_square/jod.webp differ diff --git a/assets/fiat/fiat_icons_square/jpy.webp b/assets/fiat/fiat_icons_square/jpy.webp new file mode 100644 index 0000000000..6cc5dfcf90 Binary files /dev/null and b/assets/fiat/fiat_icons_square/jpy.webp differ diff --git a/assets/fiat/fiat_icons_square/kes.webp b/assets/fiat/fiat_icons_square/kes.webp new file mode 100644 index 0000000000..bfc5556603 Binary files /dev/null and b/assets/fiat/fiat_icons_square/kes.webp differ diff --git a/assets/fiat/fiat_icons_square/kgs.webp b/assets/fiat/fiat_icons_square/kgs.webp new file mode 100644 index 0000000000..2e9a8a4083 Binary files /dev/null and b/assets/fiat/fiat_icons_square/kgs.webp differ diff --git a/assets/fiat/fiat_icons_square/khr.webp b/assets/fiat/fiat_icons_square/khr.webp new file mode 100644 index 0000000000..60ac616e21 Binary files /dev/null and b/assets/fiat/fiat_icons_square/khr.webp differ diff --git a/assets/fiat/fiat_icons_square/kmf.webp b/assets/fiat/fiat_icons_square/kmf.webp new file mode 100644 index 0000000000..b3aa4b8574 Binary files /dev/null and b/assets/fiat/fiat_icons_square/kmf.webp differ diff --git a/assets/fiat/fiat_icons_square/kpw.webp b/assets/fiat/fiat_icons_square/kpw.webp new file mode 100644 index 0000000000..348bdc4881 Binary files /dev/null and b/assets/fiat/fiat_icons_square/kpw.webp differ diff --git a/assets/fiat/fiat_icons_square/krw.webp b/assets/fiat/fiat_icons_square/krw.webp new file mode 100644 index 0000000000..0e17bfc9ba Binary files /dev/null and b/assets/fiat/fiat_icons_square/krw.webp differ diff --git a/assets/fiat/fiat_icons_square/kwd.webp b/assets/fiat/fiat_icons_square/kwd.webp new file mode 100644 index 0000000000..9d2dfdb8a0 Binary files /dev/null and b/assets/fiat/fiat_icons_square/kwd.webp differ diff --git a/assets/fiat/fiat_icons_square/kyd.webp b/assets/fiat/fiat_icons_square/kyd.webp new file mode 100644 index 0000000000..a7d819712f Binary files /dev/null and b/assets/fiat/fiat_icons_square/kyd.webp differ diff --git a/assets/fiat/fiat_icons_square/kzt.webp b/assets/fiat/fiat_icons_square/kzt.webp new file mode 100644 index 0000000000..8b65dba1af Binary files /dev/null and b/assets/fiat/fiat_icons_square/kzt.webp differ diff --git a/assets/fiat/fiat_icons_square/lak.webp b/assets/fiat/fiat_icons_square/lak.webp new file mode 100644 index 0000000000..54d05e823e Binary files /dev/null and b/assets/fiat/fiat_icons_square/lak.webp differ diff --git a/assets/fiat/fiat_icons_square/lbp.webp b/assets/fiat/fiat_icons_square/lbp.webp new file mode 100644 index 0000000000..1f81b9b755 Binary files /dev/null and b/assets/fiat/fiat_icons_square/lbp.webp differ diff --git a/assets/fiat/fiat_icons_square/lkr.webp b/assets/fiat/fiat_icons_square/lkr.webp new file mode 100644 index 0000000000..d4c76e05ae Binary files /dev/null and b/assets/fiat/fiat_icons_square/lkr.webp differ diff --git a/assets/fiat/fiat_icons_square/lrd.webp b/assets/fiat/fiat_icons_square/lrd.webp new file mode 100644 index 0000000000..e40b7e9821 Binary files /dev/null and b/assets/fiat/fiat_icons_square/lrd.webp differ diff --git a/assets/fiat/fiat_icons_square/lsl.webp b/assets/fiat/fiat_icons_square/lsl.webp new file mode 100644 index 0000000000..b58f652e3c Binary files /dev/null and b/assets/fiat/fiat_icons_square/lsl.webp differ diff --git a/assets/fiat/fiat_icons_square/lyd.webp b/assets/fiat/fiat_icons_square/lyd.webp new file mode 100644 index 0000000000..8c9357d7f0 Binary files /dev/null and b/assets/fiat/fiat_icons_square/lyd.webp differ diff --git a/assets/fiat/fiat_icons_square/mad.webp b/assets/fiat/fiat_icons_square/mad.webp new file mode 100644 index 0000000000..cd780c0388 Binary files /dev/null and b/assets/fiat/fiat_icons_square/mad.webp differ diff --git a/assets/fiat/fiat_icons_square/mdl.webp b/assets/fiat/fiat_icons_square/mdl.webp new file mode 100644 index 0000000000..6f119d36ce Binary files /dev/null and b/assets/fiat/fiat_icons_square/mdl.webp differ diff --git a/assets/fiat/fiat_icons_square/mga.webp b/assets/fiat/fiat_icons_square/mga.webp new file mode 100644 index 0000000000..5d968ec26c Binary files /dev/null and b/assets/fiat/fiat_icons_square/mga.webp differ diff --git a/assets/fiat/fiat_icons_square/mkd.webp b/assets/fiat/fiat_icons_square/mkd.webp new file mode 100644 index 0000000000..1feb6e0c8f Binary files /dev/null and b/assets/fiat/fiat_icons_square/mkd.webp differ diff --git a/assets/fiat/fiat_icons_square/mmk.webp b/assets/fiat/fiat_icons_square/mmk.webp new file mode 100644 index 0000000000..f8419a6b8f Binary files /dev/null and b/assets/fiat/fiat_icons_square/mmk.webp differ diff --git a/assets/fiat/fiat_icons_square/mnt.webp b/assets/fiat/fiat_icons_square/mnt.webp new file mode 100644 index 0000000000..87f9d1768a Binary files /dev/null and b/assets/fiat/fiat_icons_square/mnt.webp differ diff --git a/assets/fiat/fiat_icons_square/mop.webp b/assets/fiat/fiat_icons_square/mop.webp new file mode 100644 index 0000000000..f7fbcda032 Binary files /dev/null and b/assets/fiat/fiat_icons_square/mop.webp differ diff --git a/assets/fiat/fiat_icons_square/mru.webp b/assets/fiat/fiat_icons_square/mru.webp new file mode 100644 index 0000000000..9d2187504a Binary files /dev/null and b/assets/fiat/fiat_icons_square/mru.webp differ diff --git a/assets/fiat/fiat_icons_square/mur.webp b/assets/fiat/fiat_icons_square/mur.webp new file mode 100644 index 0000000000..e3087019a3 Binary files /dev/null and b/assets/fiat/fiat_icons_square/mur.webp differ diff --git a/assets/fiat/fiat_icons_square/mvr.webp b/assets/fiat/fiat_icons_square/mvr.webp new file mode 100644 index 0000000000..3967944964 Binary files /dev/null and b/assets/fiat/fiat_icons_square/mvr.webp differ diff --git a/assets/fiat/fiat_icons_square/mwk.webp b/assets/fiat/fiat_icons_square/mwk.webp new file mode 100644 index 0000000000..43c47e0bd4 Binary files /dev/null and b/assets/fiat/fiat_icons_square/mwk.webp differ diff --git a/assets/fiat/fiat_icons_square/mxn.webp b/assets/fiat/fiat_icons_square/mxn.webp new file mode 100644 index 0000000000..327036d462 Binary files /dev/null and b/assets/fiat/fiat_icons_square/mxn.webp differ diff --git a/assets/fiat/fiat_icons_square/myr.webp b/assets/fiat/fiat_icons_square/myr.webp new file mode 100644 index 0000000000..86308afdd1 Binary files /dev/null and b/assets/fiat/fiat_icons_square/myr.webp differ diff --git a/assets/fiat/fiat_icons_square/mzn.webp b/assets/fiat/fiat_icons_square/mzn.webp new file mode 100644 index 0000000000..ec6a2660ae Binary files /dev/null and b/assets/fiat/fiat_icons_square/mzn.webp differ diff --git a/assets/fiat/fiat_icons_square/nad.webp b/assets/fiat/fiat_icons_square/nad.webp new file mode 100644 index 0000000000..e85921cee1 Binary files /dev/null and b/assets/fiat/fiat_icons_square/nad.webp differ diff --git a/assets/fiat/fiat_icons_square/ngn.webp b/assets/fiat/fiat_icons_square/ngn.webp new file mode 100644 index 0000000000..69da53a31f Binary files /dev/null and b/assets/fiat/fiat_icons_square/ngn.webp differ diff --git a/assets/fiat/fiat_icons_square/nio.webp b/assets/fiat/fiat_icons_square/nio.webp new file mode 100644 index 0000000000..641a2409d4 Binary files /dev/null and b/assets/fiat/fiat_icons_square/nio.webp differ diff --git a/assets/fiat/fiat_icons_square/nok.webp b/assets/fiat/fiat_icons_square/nok.webp new file mode 100644 index 0000000000..11f9f28e37 Binary files /dev/null and b/assets/fiat/fiat_icons_square/nok.webp differ diff --git a/assets/fiat/fiat_icons_square/npr.webp b/assets/fiat/fiat_icons_square/npr.webp new file mode 100644 index 0000000000..9701c534fe Binary files /dev/null and b/assets/fiat/fiat_icons_square/npr.webp differ diff --git a/assets/fiat/fiat_icons_square/nzd.webp b/assets/fiat/fiat_icons_square/nzd.webp new file mode 100644 index 0000000000..70517c1674 Binary files /dev/null and b/assets/fiat/fiat_icons_square/nzd.webp differ diff --git a/assets/fiat/fiat_icons_square/omr.webp b/assets/fiat/fiat_icons_square/omr.webp new file mode 100644 index 0000000000..7f1a5c1052 Binary files /dev/null and b/assets/fiat/fiat_icons_square/omr.webp differ diff --git a/assets/fiat/fiat_icons_square/pab.webp b/assets/fiat/fiat_icons_square/pab.webp new file mode 100644 index 0000000000..8f3e8a6380 Binary files /dev/null and b/assets/fiat/fiat_icons_square/pab.webp differ diff --git a/assets/fiat/fiat_icons_square/pc.webp b/assets/fiat/fiat_icons_square/pc.webp new file mode 100644 index 0000000000..f81e448c4c Binary files /dev/null and b/assets/fiat/fiat_icons_square/pc.webp differ diff --git a/assets/fiat/fiat_icons_square/pen.webp b/assets/fiat/fiat_icons_square/pen.webp new file mode 100644 index 0000000000..ab4acdb566 Binary files /dev/null and b/assets/fiat/fiat_icons_square/pen.webp differ diff --git a/assets/fiat/fiat_icons_square/pgk.webp b/assets/fiat/fiat_icons_square/pgk.webp new file mode 100644 index 0000000000..c8797106b9 Binary files /dev/null and b/assets/fiat/fiat_icons_square/pgk.webp differ diff --git a/assets/fiat/fiat_icons_square/php.webp b/assets/fiat/fiat_icons_square/php.webp new file mode 100644 index 0000000000..e347109abd Binary files /dev/null and b/assets/fiat/fiat_icons_square/php.webp differ diff --git a/assets/fiat/fiat_icons_square/pkr.webp b/assets/fiat/fiat_icons_square/pkr.webp new file mode 100644 index 0000000000..85518a6377 Binary files /dev/null and b/assets/fiat/fiat_icons_square/pkr.webp differ diff --git a/assets/fiat/fiat_icons_square/pln.webp b/assets/fiat/fiat_icons_square/pln.webp new file mode 100644 index 0000000000..c260e62f45 Binary files /dev/null and b/assets/fiat/fiat_icons_square/pln.webp differ diff --git a/assets/fiat/fiat_icons_square/pyg.webp b/assets/fiat/fiat_icons_square/pyg.webp new file mode 100644 index 0000000000..f9e4005e48 Binary files /dev/null and b/assets/fiat/fiat_icons_square/pyg.webp differ diff --git a/assets/fiat/fiat_icons_square/qar.webp b/assets/fiat/fiat_icons_square/qar.webp new file mode 100644 index 0000000000..ee41cc5ed2 Binary files /dev/null and b/assets/fiat/fiat_icons_square/qar.webp differ diff --git a/assets/fiat/fiat_icons_square/ron.webp b/assets/fiat/fiat_icons_square/ron.webp new file mode 100644 index 0000000000..0b21285344 Binary files /dev/null and b/assets/fiat/fiat_icons_square/ron.webp differ diff --git a/assets/fiat/fiat_icons_square/rsd.webp b/assets/fiat/fiat_icons_square/rsd.webp new file mode 100644 index 0000000000..8c47fd6482 Binary files /dev/null and b/assets/fiat/fiat_icons_square/rsd.webp differ diff --git a/assets/fiat/fiat_icons_square/rub.webp b/assets/fiat/fiat_icons_square/rub.webp new file mode 100644 index 0000000000..6292eac085 Binary files /dev/null and b/assets/fiat/fiat_icons_square/rub.webp differ diff --git a/assets/fiat/fiat_icons_square/rwf.webp b/assets/fiat/fiat_icons_square/rwf.webp new file mode 100644 index 0000000000..a7f778c2a6 Binary files /dev/null and b/assets/fiat/fiat_icons_square/rwf.webp differ diff --git a/assets/fiat/fiat_icons_square/sar.webp b/assets/fiat/fiat_icons_square/sar.webp new file mode 100644 index 0000000000..bc3595b160 Binary files /dev/null and b/assets/fiat/fiat_icons_square/sar.webp differ diff --git a/assets/fiat/fiat_icons_square/sbd.webp b/assets/fiat/fiat_icons_square/sbd.webp new file mode 100644 index 0000000000..b3068d239e Binary files /dev/null and b/assets/fiat/fiat_icons_square/sbd.webp differ diff --git a/assets/fiat/fiat_icons_square/scr.webp b/assets/fiat/fiat_icons_square/scr.webp new file mode 100644 index 0000000000..af95c5bd02 Binary files /dev/null and b/assets/fiat/fiat_icons_square/scr.webp differ diff --git a/assets/fiat/fiat_icons_square/sdg.webp b/assets/fiat/fiat_icons_square/sdg.webp new file mode 100644 index 0000000000..2a065ece3b Binary files /dev/null and b/assets/fiat/fiat_icons_square/sdg.webp differ diff --git a/assets/fiat/fiat_icons_square/sek.webp b/assets/fiat/fiat_icons_square/sek.webp new file mode 100644 index 0000000000..6fc9fc8b65 Binary files /dev/null and b/assets/fiat/fiat_icons_square/sek.webp differ diff --git a/assets/fiat/fiat_icons_square/sgd.webp b/assets/fiat/fiat_icons_square/sgd.webp new file mode 100644 index 0000000000..3a7f0436d5 Binary files /dev/null and b/assets/fiat/fiat_icons_square/sgd.webp differ diff --git a/assets/fiat/fiat_icons_square/sh-ac.webp b/assets/fiat/fiat_icons_square/sh-ac.webp new file mode 100644 index 0000000000..3a2ada6d8e Binary files /dev/null and b/assets/fiat/fiat_icons_square/sh-ac.webp differ diff --git a/assets/fiat/fiat_icons_square/sh-hl.webp b/assets/fiat/fiat_icons_square/sh-hl.webp new file mode 100644 index 0000000000..a73d29be47 Binary files /dev/null and b/assets/fiat/fiat_icons_square/sh-hl.webp differ diff --git a/assets/fiat/fiat_icons_square/sh-ta.webp b/assets/fiat/fiat_icons_square/sh-ta.webp new file mode 100644 index 0000000000..9f61500af1 Binary files /dev/null and b/assets/fiat/fiat_icons_square/sh-ta.webp differ diff --git a/assets/fiat/fiat_icons_square/shp.webp b/assets/fiat/fiat_icons_square/shp.webp new file mode 100644 index 0000000000..740ec8476d Binary files /dev/null and b/assets/fiat/fiat_icons_square/shp.webp differ diff --git a/assets/fiat/fiat_icons_square/sll.webp b/assets/fiat/fiat_icons_square/sll.webp new file mode 100644 index 0000000000..9a8b121bef Binary files /dev/null and b/assets/fiat/fiat_icons_square/sll.webp differ diff --git a/assets/fiat/fiat_icons_square/sos.webp b/assets/fiat/fiat_icons_square/sos.webp new file mode 100644 index 0000000000..dd21807388 Binary files /dev/null and b/assets/fiat/fiat_icons_square/sos.webp differ diff --git a/assets/fiat/fiat_icons_square/srd.webp b/assets/fiat/fiat_icons_square/srd.webp new file mode 100644 index 0000000000..a32ff3f5b4 Binary files /dev/null and b/assets/fiat/fiat_icons_square/srd.webp differ diff --git a/assets/fiat/fiat_icons_square/ssp.webp b/assets/fiat/fiat_icons_square/ssp.webp new file mode 100644 index 0000000000..f967f292c1 Binary files /dev/null and b/assets/fiat/fiat_icons_square/ssp.webp differ diff --git a/assets/fiat/fiat_icons_square/stn.webp b/assets/fiat/fiat_icons_square/stn.webp new file mode 100644 index 0000000000..0ca4e7a429 Binary files /dev/null and b/assets/fiat/fiat_icons_square/stn.webp differ diff --git a/assets/fiat/fiat_icons_square/syp.webp b/assets/fiat/fiat_icons_square/syp.webp new file mode 100644 index 0000000000..7eeda5ca0e Binary files /dev/null and b/assets/fiat/fiat_icons_square/syp.webp differ diff --git a/assets/fiat/fiat_icons_square/szl.webp b/assets/fiat/fiat_icons_square/szl.webp new file mode 100644 index 0000000000..0801abd5c7 Binary files /dev/null and b/assets/fiat/fiat_icons_square/szl.webp differ diff --git a/assets/fiat/fiat_icons_square/thb.webp b/assets/fiat/fiat_icons_square/thb.webp new file mode 100644 index 0000000000..a5b31afaae Binary files /dev/null and b/assets/fiat/fiat_icons_square/thb.webp differ diff --git a/assets/fiat/fiat_icons_square/tjs.webp b/assets/fiat/fiat_icons_square/tjs.webp new file mode 100644 index 0000000000..4c5b0db2aa Binary files /dev/null and b/assets/fiat/fiat_icons_square/tjs.webp differ diff --git a/assets/fiat/fiat_icons_square/tmt.webp b/assets/fiat/fiat_icons_square/tmt.webp new file mode 100644 index 0000000000..5d0c6dfc51 Binary files /dev/null and b/assets/fiat/fiat_icons_square/tmt.webp differ diff --git a/assets/fiat/fiat_icons_square/tnd.webp b/assets/fiat/fiat_icons_square/tnd.webp new file mode 100644 index 0000000000..68481d495c Binary files /dev/null and b/assets/fiat/fiat_icons_square/tnd.webp differ diff --git a/assets/fiat/fiat_icons_square/top.webp b/assets/fiat/fiat_icons_square/top.webp new file mode 100644 index 0000000000..2e9b93c318 Binary files /dev/null and b/assets/fiat/fiat_icons_square/top.webp differ diff --git a/assets/fiat/fiat_icons_square/try.webp b/assets/fiat/fiat_icons_square/try.webp new file mode 100644 index 0000000000..7d05285e35 Binary files /dev/null and b/assets/fiat/fiat_icons_square/try.webp differ diff --git a/assets/fiat/fiat_icons_square/ttd.webp b/assets/fiat/fiat_icons_square/ttd.webp new file mode 100644 index 0000000000..9532da11fd Binary files /dev/null and b/assets/fiat/fiat_icons_square/ttd.webp differ diff --git a/assets/fiat/fiat_icons_square/twd.webp b/assets/fiat/fiat_icons_square/twd.webp new file mode 100644 index 0000000000..ce136bc641 Binary files /dev/null and b/assets/fiat/fiat_icons_square/twd.webp differ diff --git a/assets/fiat/fiat_icons_square/tzs.webp b/assets/fiat/fiat_icons_square/tzs.webp new file mode 100644 index 0000000000..bc979ef1f8 Binary files /dev/null and b/assets/fiat/fiat_icons_square/tzs.webp differ diff --git a/assets/fiat/fiat_icons_square/uah.webp b/assets/fiat/fiat_icons_square/uah.webp new file mode 100644 index 0000000000..8f457d3d8e Binary files /dev/null and b/assets/fiat/fiat_icons_square/uah.webp differ diff --git a/assets/fiat/fiat_icons_square/ugx.webp b/assets/fiat/fiat_icons_square/ugx.webp new file mode 100644 index 0000000000..cab29d2b5e Binary files /dev/null and b/assets/fiat/fiat_icons_square/ugx.webp differ diff --git a/assets/fiat/fiat_icons_square/un.webp b/assets/fiat/fiat_icons_square/un.webp new file mode 100644 index 0000000000..36ba75a364 Binary files /dev/null and b/assets/fiat/fiat_icons_square/un.webp differ diff --git a/assets/fiat/fiat_icons_square/usd.webp b/assets/fiat/fiat_icons_square/usd.webp new file mode 100644 index 0000000000..fa3e79f384 Binary files /dev/null and b/assets/fiat/fiat_icons_square/usd.webp differ diff --git a/assets/fiat/fiat_icons_square/uyu.webp b/assets/fiat/fiat_icons_square/uyu.webp new file mode 100644 index 0000000000..7b3a3508a0 Binary files /dev/null and b/assets/fiat/fiat_icons_square/uyu.webp differ diff --git a/assets/fiat/fiat_icons_square/uzs.webp b/assets/fiat/fiat_icons_square/uzs.webp new file mode 100644 index 0000000000..2b50e9f326 Binary files /dev/null and b/assets/fiat/fiat_icons_square/uzs.webp differ diff --git a/assets/fiat/fiat_icons_square/ves.webp b/assets/fiat/fiat_icons_square/ves.webp new file mode 100644 index 0000000000..229aa0f350 Binary files /dev/null and b/assets/fiat/fiat_icons_square/ves.webp differ diff --git a/assets/fiat/fiat_icons_square/vnd.webp b/assets/fiat/fiat_icons_square/vnd.webp new file mode 100644 index 0000000000..2cf59a503c Binary files /dev/null and b/assets/fiat/fiat_icons_square/vnd.webp differ diff --git a/assets/fiat/fiat_icons_square/vuv.webp b/assets/fiat/fiat_icons_square/vuv.webp new file mode 100644 index 0000000000..f5b4028ded Binary files /dev/null and b/assets/fiat/fiat_icons_square/vuv.webp differ diff --git a/assets/fiat/fiat_icons_square/wst.webp b/assets/fiat/fiat_icons_square/wst.webp new file mode 100644 index 0000000000..df0edfb63d Binary files /dev/null and b/assets/fiat/fiat_icons_square/wst.webp differ diff --git a/assets/fiat/fiat_icons_square/xaf.webp b/assets/fiat/fiat_icons_square/xaf.webp new file mode 100644 index 0000000000..aa4101f65a Binary files /dev/null and b/assets/fiat/fiat_icons_square/xaf.webp differ diff --git a/assets/fiat/fiat_icons_square/xcd.webp b/assets/fiat/fiat_icons_square/xcd.webp new file mode 100644 index 0000000000..f147a9d5ea Binary files /dev/null and b/assets/fiat/fiat_icons_square/xcd.webp differ diff --git a/assets/fiat/fiat_icons_square/xof.webp b/assets/fiat/fiat_icons_square/xof.webp new file mode 100644 index 0000000000..2e5a4a0886 Binary files /dev/null and b/assets/fiat/fiat_icons_square/xof.webp differ diff --git a/assets/fiat/fiat_icons_square/xpf.webp b/assets/fiat/fiat_icons_square/xpf.webp new file mode 100644 index 0000000000..38d37781c7 Binary files /dev/null and b/assets/fiat/fiat_icons_square/xpf.webp differ diff --git a/assets/fiat/fiat_icons_square/xx.webp b/assets/fiat/fiat_icons_square/xx.webp new file mode 100644 index 0000000000..10a80ababd Binary files /dev/null and b/assets/fiat/fiat_icons_square/xx.webp differ diff --git a/assets/fiat/fiat_icons_square/yer.webp b/assets/fiat/fiat_icons_square/yer.webp new file mode 100644 index 0000000000..61859d5f32 Binary files /dev/null and b/assets/fiat/fiat_icons_square/yer.webp differ diff --git a/assets/fiat/fiat_icons_square/zar.webp b/assets/fiat/fiat_icons_square/zar.webp new file mode 100644 index 0000000000..88415501bc Binary files /dev/null and b/assets/fiat/fiat_icons_square/zar.webp differ diff --git a/assets/fiat/fiat_icons_square/zmw.webp b/assets/fiat/fiat_icons_square/zmw.webp new file mode 100644 index 0000000000..0fd7f07fe6 Binary files /dev/null and b/assets/fiat/fiat_icons_square/zmw.webp differ diff --git a/assets/fiat/fiat_icons_square/zwl.webp b/assets/fiat/fiat_icons_square/zwl.webp new file mode 100644 index 0000000000..f12bde35bd Binary files /dev/null and b/assets/fiat/fiat_icons_square/zwl.webp differ diff --git a/assets/fiat/providers/banxa_icon.svg b/assets/fiat/providers/banxa_icon.svg new file mode 100644 index 0000000000..2004d75827 --- /dev/null +++ b/assets/fiat/providers/banxa_icon.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/fiat/providers/ramp_icon.svg b/assets/fiat/providers/ramp_icon.svg new file mode 100644 index 0000000000..de3cd635db --- /dev/null +++ b/assets/fiat/providers/ramp_icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/flags/ar.svg b/assets/flags/ar.svg new file mode 100644 index 0000000000..d3a949857a --- /dev/null +++ b/assets/flags/ar.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/flags/ch.svg b/assets/flags/ch.svg new file mode 100644 index 0000000000..bd204d37f9 --- /dev/null +++ b/assets/flags/ch.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/flags/en.svg b/assets/flags/en.svg new file mode 100644 index 0000000000..a36920a94d --- /dev/null +++ b/assets/flags/en.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/assets/flags/fr.svg b/assets/flags/fr.svg new file mode 100644 index 0000000000..e5c14b74b1 --- /dev/null +++ b/assets/flags/fr.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/assets/fonts/Manrope-Bold.ttf b/assets/fonts/Manrope-Bold.ttf new file mode 100644 index 0000000000..8bbf0bd1fe Binary files /dev/null and b/assets/fonts/Manrope-Bold.ttf differ diff --git a/assets/fonts/Manrope-ExtraBold.ttf b/assets/fonts/Manrope-ExtraBold.ttf new file mode 100644 index 0000000000..3f68dffc7f Binary files /dev/null and b/assets/fonts/Manrope-ExtraBold.ttf differ diff --git a/assets/fonts/Manrope-ExtraLight.ttf b/assets/fonts/Manrope-ExtraLight.ttf new file mode 100644 index 0000000000..9d21d775df Binary files /dev/null and b/assets/fonts/Manrope-ExtraLight.ttf differ diff --git a/assets/fonts/Manrope-Light.ttf b/assets/fonts/Manrope-Light.ttf new file mode 100644 index 0000000000..f255257a81 Binary files /dev/null and b/assets/fonts/Manrope-Light.ttf differ diff --git a/assets/fonts/Manrope-Medium.ttf b/assets/fonts/Manrope-Medium.ttf new file mode 100644 index 0000000000..c73d7741b1 Binary files /dev/null and b/assets/fonts/Manrope-Medium.ttf differ diff --git a/assets/fonts/Manrope-Regular.ttf b/assets/fonts/Manrope-Regular.ttf new file mode 100644 index 0000000000..c02b01bea3 Binary files /dev/null and b/assets/fonts/Manrope-Regular.ttf differ diff --git a/assets/fonts/Manrope-SemiBold.ttf b/assets/fonts/Manrope-SemiBold.ttf new file mode 100644 index 0000000000..30ee031048 Binary files /dev/null and b/assets/fonts/Manrope-SemiBold.ttf differ diff --git a/assets/logo/alpha_warning.png b/assets/logo/alpha_warning.png new file mode 100644 index 0000000000..04804dfd15 Binary files /dev/null and b/assets/logo/alpha_warning.png differ diff --git a/assets/logo/dark_theme.png b/assets/logo/dark_theme.png new file mode 100644 index 0000000000..b11ee48c69 Binary files /dev/null and b/assets/logo/dark_theme.png differ diff --git a/assets/logo/default_nft.png b/assets/logo/default_nft.png new file mode 100644 index 0000000000..6461ade84b Binary files /dev/null and b/assets/logo/default_nft.png differ diff --git a/assets/logo/komodian_thanks.png b/assets/logo/komodian_thanks.png new file mode 100644 index 0000000000..b01bed0004 Binary files /dev/null and b/assets/logo/komodian_thanks.png differ diff --git a/assets/logo/light_theme.png b/assets/logo/light_theme.png new file mode 100644 index 0000000000..2de4e872de Binary files /dev/null and b/assets/logo/light_theme.png differ diff --git a/assets/logo/lines.svg b/assets/logo/lines.svg new file mode 100644 index 0000000000..127028de84 --- /dev/null +++ b/assets/logo/lines.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/logo/lines_dark.svg b/assets/logo/lines_dark.svg new file mode 100644 index 0000000000..9a4c9a25de --- /dev/null +++ b/assets/logo/lines_dark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/logo/logo.svg b/assets/logo/logo.svg new file mode 100644 index 0000000000..acff7ec991 --- /dev/null +++ b/assets/logo/logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/logo/logo_dark.svg b/assets/logo/logo_dark.svg new file mode 100644 index 0000000000..818a337815 --- /dev/null +++ b/assets/logo/logo_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/logo/update_logo.png b/assets/logo/update_logo.png new file mode 100644 index 0000000000..d550af6962 Binary files /dev/null and b/assets/logo/update_logo.png differ diff --git a/assets/nav_icons/desktop/dark/bridge.svg b/assets/nav_icons/desktop/dark/bridge.svg new file mode 100644 index 0000000000..0d9a2e8505 --- /dev/null +++ b/assets/nav_icons/desktop/dark/bridge.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/nav_icons/desktop/dark/bridge_active.svg b/assets/nav_icons/desktop/dark/bridge_active.svg new file mode 100644 index 0000000000..238895baa4 --- /dev/null +++ b/assets/nav_icons/desktop/dark/bridge_active.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/nav_icons/desktop/dark/dex.svg b/assets/nav_icons/desktop/dark/dex.svg new file mode 100644 index 0000000000..328a219e12 --- /dev/null +++ b/assets/nav_icons/desktop/dark/dex.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/nav_icons/desktop/dark/dex_active.svg b/assets/nav_icons/desktop/dark/dex_active.svg new file mode 100644 index 0000000000..5e5211e97c --- /dev/null +++ b/assets/nav_icons/desktop/dark/dex_active.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/nav_icons/desktop/dark/fiat.svg b/assets/nav_icons/desktop/dark/fiat.svg new file mode 100644 index 0000000000..7855fa0ed0 --- /dev/null +++ b/assets/nav_icons/desktop/dark/fiat.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/nav_icons/desktop/dark/fiat_active.svg b/assets/nav_icons/desktop/dark/fiat_active.svg new file mode 100644 index 0000000000..8ad27a4b43 --- /dev/null +++ b/assets/nav_icons/desktop/dark/fiat_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/nav_icons/desktop/dark/marketMakerBot.svg b/assets/nav_icons/desktop/dark/marketMakerBot.svg new file mode 100644 index 0000000000..9e20331ea8 --- /dev/null +++ b/assets/nav_icons/desktop/dark/marketMakerBot.svg @@ -0,0 +1,36 @@ + + + + + + diff --git a/assets/nav_icons/desktop/dark/marketMakerBot_active.svg b/assets/nav_icons/desktop/dark/marketMakerBot_active.svg new file mode 100644 index 0000000000..f85ef52af1 --- /dev/null +++ b/assets/nav_icons/desktop/dark/marketMakerBot_active.svg @@ -0,0 +1,36 @@ + + + + + + diff --git a/assets/nav_icons/desktop/dark/menu.svg b/assets/nav_icons/desktop/dark/menu.svg new file mode 100644 index 0000000000..96dea98d93 --- /dev/null +++ b/assets/nav_icons/desktop/dark/menu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/desktop/dark/menu_active.svg b/assets/nav_icons/desktop/dark/menu_active.svg new file mode 100644 index 0000000000..46a34e486c --- /dev/null +++ b/assets/nav_icons/desktop/dark/menu_active.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/desktop/dark/nft.svg b/assets/nav_icons/desktop/dark/nft.svg new file mode 100644 index 0000000000..ab15ec4008 --- /dev/null +++ b/assets/nav_icons/desktop/dark/nft.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/desktop/dark/nft_active.svg b/assets/nav_icons/desktop/dark/nft_active.svg new file mode 100644 index 0000000000..3b6dae9a36 --- /dev/null +++ b/assets/nav_icons/desktop/dark/nft_active.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/desktop/dark/settings.svg b/assets/nav_icons/desktop/dark/settings.svg new file mode 100644 index 0000000000..b80f9085db --- /dev/null +++ b/assets/nav_icons/desktop/dark/settings.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/nav_icons/desktop/dark/settings_active.svg b/assets/nav_icons/desktop/dark/settings_active.svg new file mode 100644 index 0000000000..7e88d09f36 --- /dev/null +++ b/assets/nav_icons/desktop/dark/settings_active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/nav_icons/desktop/dark/support.svg b/assets/nav_icons/desktop/dark/support.svg new file mode 100644 index 0000000000..d2475dcecb --- /dev/null +++ b/assets/nav_icons/desktop/dark/support.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/nav_icons/desktop/dark/support_active.svg b/assets/nav_icons/desktop/dark/support_active.svg new file mode 100644 index 0000000000..f4efa9d002 --- /dev/null +++ b/assets/nav_icons/desktop/dark/support_active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/nav_icons/desktop/dark/wallet.svg b/assets/nav_icons/desktop/dark/wallet.svg new file mode 100644 index 0000000000..8c3a797fdb --- /dev/null +++ b/assets/nav_icons/desktop/dark/wallet.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/nav_icons/desktop/dark/wallet_active.svg b/assets/nav_icons/desktop/dark/wallet_active.svg new file mode 100644 index 0000000000..1d9871af0a --- /dev/null +++ b/assets/nav_icons/desktop/dark/wallet_active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/nav_icons/desktop/light/bridge.svg b/assets/nav_icons/desktop/light/bridge.svg new file mode 100644 index 0000000000..f14c28d45c --- /dev/null +++ b/assets/nav_icons/desktop/light/bridge.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/nav_icons/desktop/light/bridge_active.svg b/assets/nav_icons/desktop/light/bridge_active.svg new file mode 100644 index 0000000000..238895baa4 --- /dev/null +++ b/assets/nav_icons/desktop/light/bridge_active.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/nav_icons/desktop/light/dex.svg b/assets/nav_icons/desktop/light/dex.svg new file mode 100644 index 0000000000..e4e4e3247c --- /dev/null +++ b/assets/nav_icons/desktop/light/dex.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/nav_icons/desktop/light/dex_active.svg b/assets/nav_icons/desktop/light/dex_active.svg new file mode 100644 index 0000000000..5e5211e97c --- /dev/null +++ b/assets/nav_icons/desktop/light/dex_active.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/nav_icons/desktop/light/fiat.svg b/assets/nav_icons/desktop/light/fiat.svg new file mode 100644 index 0000000000..c36fed2bc1 --- /dev/null +++ b/assets/nav_icons/desktop/light/fiat.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/nav_icons/desktop/light/fiat_active.svg b/assets/nav_icons/desktop/light/fiat_active.svg new file mode 100644 index 0000000000..8ad27a4b43 --- /dev/null +++ b/assets/nav_icons/desktop/light/fiat_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/nav_icons/desktop/light/marketMakerBot.svg b/assets/nav_icons/desktop/light/marketMakerBot.svg new file mode 100644 index 0000000000..743192c136 --- /dev/null +++ b/assets/nav_icons/desktop/light/marketMakerBot.svg @@ -0,0 +1,36 @@ + + + + + + diff --git a/assets/nav_icons/desktop/light/marketMakerBot_active.svg b/assets/nav_icons/desktop/light/marketMakerBot_active.svg new file mode 100644 index 0000000000..f85ef52af1 --- /dev/null +++ b/assets/nav_icons/desktop/light/marketMakerBot_active.svg @@ -0,0 +1,36 @@ + + + + + + diff --git a/assets/nav_icons/desktop/light/menu_active.svg b/assets/nav_icons/desktop/light/menu_active.svg new file mode 100644 index 0000000000..46a34e486c --- /dev/null +++ b/assets/nav_icons/desktop/light/menu_active.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/desktop/light/nft.svg b/assets/nav_icons/desktop/light/nft.svg new file mode 100644 index 0000000000..ec471b633a --- /dev/null +++ b/assets/nav_icons/desktop/light/nft.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/desktop/light/nft_active.svg b/assets/nav_icons/desktop/light/nft_active.svg new file mode 100644 index 0000000000..3b6dae9a36 --- /dev/null +++ b/assets/nav_icons/desktop/light/nft_active.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/desktop/light/settings.svg b/assets/nav_icons/desktop/light/settings.svg new file mode 100644 index 0000000000..194d9ff66b --- /dev/null +++ b/assets/nav_icons/desktop/light/settings.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/nav_icons/desktop/light/settings_active.svg b/assets/nav_icons/desktop/light/settings_active.svg new file mode 100644 index 0000000000..7e88d09f36 --- /dev/null +++ b/assets/nav_icons/desktop/light/settings_active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/nav_icons/desktop/light/support.svg b/assets/nav_icons/desktop/light/support.svg new file mode 100644 index 0000000000..8e2457f561 --- /dev/null +++ b/assets/nav_icons/desktop/light/support.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/nav_icons/desktop/light/support_active.svg b/assets/nav_icons/desktop/light/support_active.svg new file mode 100644 index 0000000000..f4efa9d002 --- /dev/null +++ b/assets/nav_icons/desktop/light/support_active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/nav_icons/desktop/light/wallet.svg b/assets/nav_icons/desktop/light/wallet.svg new file mode 100644 index 0000000000..d6fedc6b1d --- /dev/null +++ b/assets/nav_icons/desktop/light/wallet.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/nav_icons/desktop/light/wallet_active.svg b/assets/nav_icons/desktop/light/wallet_active.svg new file mode 100644 index 0000000000..1d9871af0a --- /dev/null +++ b/assets/nav_icons/desktop/light/wallet_active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/nav_icons/mobile/bridge.svg b/assets/nav_icons/mobile/bridge.svg new file mode 100644 index 0000000000..0d9a2e8505 --- /dev/null +++ b/assets/nav_icons/mobile/bridge.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/nav_icons/mobile/bridge_active.svg b/assets/nav_icons/mobile/bridge_active.svg new file mode 100644 index 0000000000..238895baa4 --- /dev/null +++ b/assets/nav_icons/mobile/bridge_active.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/nav_icons/mobile/dex.svg b/assets/nav_icons/mobile/dex.svg new file mode 100644 index 0000000000..328a219e12 --- /dev/null +++ b/assets/nav_icons/mobile/dex.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/nav_icons/mobile/dex_active.svg b/assets/nav_icons/mobile/dex_active.svg new file mode 100644 index 0000000000..5e5211e97c --- /dev/null +++ b/assets/nav_icons/mobile/dex_active.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/assets/nav_icons/mobile/fiat.svg b/assets/nav_icons/mobile/fiat.svg new file mode 100644 index 0000000000..7855fa0ed0 --- /dev/null +++ b/assets/nav_icons/mobile/fiat.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/nav_icons/mobile/fiat_active.svg b/assets/nav_icons/mobile/fiat_active.svg new file mode 100644 index 0000000000..8ad27a4b43 --- /dev/null +++ b/assets/nav_icons/mobile/fiat_active.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/nav_icons/mobile/marketMakerBot.svg b/assets/nav_icons/mobile/marketMakerBot.svg new file mode 100644 index 0000000000..9e20331ea8 --- /dev/null +++ b/assets/nav_icons/mobile/marketMakerBot.svg @@ -0,0 +1,36 @@ + + + + + + diff --git a/assets/nav_icons/mobile/marketMakerBot_active.svg b/assets/nav_icons/mobile/marketMakerBot_active.svg new file mode 100644 index 0000000000..f85ef52af1 --- /dev/null +++ b/assets/nav_icons/mobile/marketMakerBot_active.svg @@ -0,0 +1,36 @@ + + + + + + diff --git a/assets/nav_icons/mobile/menu.svg b/assets/nav_icons/mobile/menu.svg new file mode 100644 index 0000000000..96dea98d93 --- /dev/null +++ b/assets/nav_icons/mobile/menu.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/mobile/menu_active.svg b/assets/nav_icons/mobile/menu_active.svg new file mode 100644 index 0000000000..46a34e486c --- /dev/null +++ b/assets/nav_icons/mobile/menu_active.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/mobile/nft.svg b/assets/nav_icons/mobile/nft.svg new file mode 100644 index 0000000000..ab15ec4008 --- /dev/null +++ b/assets/nav_icons/mobile/nft.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/mobile/nft_active.svg b/assets/nav_icons/mobile/nft_active.svg new file mode 100644 index 0000000000..3b6dae9a36 --- /dev/null +++ b/assets/nav_icons/mobile/nft_active.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/nav_icons/mobile/settings.svg b/assets/nav_icons/mobile/settings.svg new file mode 100644 index 0000000000..b80f9085db --- /dev/null +++ b/assets/nav_icons/mobile/settings.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/nav_icons/mobile/settings_active.svg b/assets/nav_icons/mobile/settings_active.svg new file mode 100644 index 0000000000..7e88d09f36 --- /dev/null +++ b/assets/nav_icons/mobile/settings_active.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/assets/nav_icons/mobile/support.svg b/assets/nav_icons/mobile/support.svg new file mode 100644 index 0000000000..d2475dcecb --- /dev/null +++ b/assets/nav_icons/mobile/support.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/nav_icons/mobile/support_active.svg b/assets/nav_icons/mobile/support_active.svg new file mode 100644 index 0000000000..f4efa9d002 --- /dev/null +++ b/assets/nav_icons/mobile/support_active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/nav_icons/mobile/wallet.svg b/assets/nav_icons/mobile/wallet.svg new file mode 100644 index 0000000000..8c3a797fdb --- /dev/null +++ b/assets/nav_icons/mobile/wallet.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/nav_icons/mobile/wallet_active.svg b/assets/nav_icons/mobile/wallet_active.svg new file mode 100644 index 0000000000..1d9871af0a --- /dev/null +++ b/assets/nav_icons/mobile/wallet_active.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/others/bitrefill_logo.svg b/assets/others/bitrefill_logo.svg new file mode 100644 index 0000000000..d3ac029f9b --- /dev/null +++ b/assets/others/bitrefill_logo.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/others/bridge_swap_arrow_down.svg b/assets/others/bridge_swap_arrow_down.svg new file mode 100644 index 0000000000..592e1f5378 --- /dev/null +++ b/assets/others/bridge_swap_arrow_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/others/chevron_down.svg b/assets/others/chevron_down.svg new file mode 100644 index 0000000000..253fc606ef --- /dev/null +++ b/assets/others/chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/others/chevron_left.svg b/assets/others/chevron_left.svg new file mode 100644 index 0000000000..26ef648f37 --- /dev/null +++ b/assets/others/chevron_left.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/others/chevron_left_mobile.svg b/assets/others/chevron_left_mobile.svg new file mode 100644 index 0000000000..ed5d412f2a --- /dev/null +++ b/assets/others/chevron_left_mobile.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/others/chevron_up.svg b/assets/others/chevron_up.svg new file mode 100644 index 0000000000..293412ede8 --- /dev/null +++ b/assets/others/chevron_up.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/others/denied.svg b/assets/others/denied.svg new file mode 100644 index 0000000000..053bfb2bb4 --- /dev/null +++ b/assets/others/denied.svg @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/others/dex_chevron_down.svg b/assets/others/dex_chevron_down.svg new file mode 100644 index 0000000000..b4ffee5868 --- /dev/null +++ b/assets/others/dex_chevron_down.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/others/dex_swap.svg b/assets/others/dex_swap.svg new file mode 100644 index 0000000000..21628b535e --- /dev/null +++ b/assets/others/dex_swap.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/others/discord_icon.png b/assets/others/discord_icon.png new file mode 100644 index 0000000000..e14d79892d Binary files /dev/null and b/assets/others/discord_icon.png differ diff --git a/assets/others/discord_icon.svg b/assets/others/discord_icon.svg new file mode 100644 index 0000000000..80d112100b --- /dev/null +++ b/assets/others/discord_icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/others/feedback_text.svg b/assets/others/feedback_text.svg new file mode 100644 index 0000000000..43e7c3892f --- /dev/null +++ b/assets/others/feedback_text.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/others/ledger_logo.svg b/assets/others/ledger_logo.svg new file mode 100644 index 0000000000..b5572f1783 --- /dev/null +++ b/assets/others/ledger_logo.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/others/ledger_logo_light.svg b/assets/others/ledger_logo_light.svg new file mode 100644 index 0000000000..d308864322 --- /dev/null +++ b/assets/others/ledger_logo_light.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/assets/others/receive.svg b/assets/others/receive.svg new file mode 100644 index 0000000000..b0d1e19806 --- /dev/null +++ b/assets/others/receive.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/others/rewardBackgroundImage.png b/assets/others/rewardBackgroundImage.png new file mode 100644 index 0000000000..d6e413471d Binary files /dev/null and b/assets/others/rewardBackgroundImage.png differ diff --git a/assets/others/round_question_mark.svg b/assets/others/round_question_mark.svg new file mode 100644 index 0000000000..7e56c0810a --- /dev/null +++ b/assets/others/round_question_mark.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/others/seed_success.svg b/assets/others/seed_success.svg new file mode 100644 index 0000000000..b77ba51831 --- /dev/null +++ b/assets/others/seed_success.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/assets/others/send.svg b/assets/others/send.svg new file mode 100644 index 0000000000..701b9405a1 --- /dev/null +++ b/assets/others/send.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/others/swap.svg b/assets/others/swap.svg new file mode 100644 index 0000000000..70dc40ab52 --- /dev/null +++ b/assets/others/swap.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/others/tick.png b/assets/others/tick.png new file mode 100644 index 0000000000..587db5d86e Binary files /dev/null and b/assets/others/tick.png differ diff --git a/assets/others/tick.svg b/assets/others/tick.svg new file mode 100644 index 0000000000..d2e4749bae --- /dev/null +++ b/assets/others/tick.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/assets/others/trezor_logo.svg b/assets/others/trezor_logo.svg new file mode 100644 index 0000000000..8678653840 --- /dev/null +++ b/assets/others/trezor_logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/others/trezor_logo_light.svg b/assets/others/trezor_logo_light.svg new file mode 100644 index 0000000000..f230dccd65 --- /dev/null +++ b/assets/others/trezor_logo_light.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/packages/flutter_inappwebview_web/assets/web/web_support.js b/assets/packages/flutter_inappwebview_web/assets/web/web_support.js new file mode 100644 index 0000000000..9997396268 --- /dev/null +++ b/assets/packages/flutter_inappwebview_web/assets/web/web_support.js @@ -0,0 +1,589 @@ +window.flutter_inappwebview = { + webViews: {}, + createFlutterInAppWebView: function (viewId, iframeId) { + var webView = { + viewId: viewId, + iframeId: iframeId, + iframe: null, + iframeContainer: null, + windowAutoincrementId: 0, + windows: {}, + isFullscreen: false, + documentTitle: null, + functionMap: {}, + settings: {}, + disableContextMenuHandler: function (event) { + event.preventDefault(); + event.stopPropagation(); + return false; + }, + prepare: function (settings) { + webView.settings = settings; + var iframe = document.getElementById(iframeId); + var iframeContainer = document.getElementById(iframeId + '-container'); + + document.addEventListener('fullscreenchange', function (event) { + // document.fullscreenElement will point to the element that + // is in fullscreen mode if there is one. If there isn't one, + // the value of the property is null. + if (document.fullscreenElement && document.fullscreenElement.id == iframeId) { + webView.isFullscreen = true; + window.flutter_inappwebview.nativeCommunication('onEnterFullscreen', viewId); + } else if (!document.fullscreenElement && webView.isFullscreen) { + webView.isFullscreen = false; + window.flutter_inappwebview.nativeCommunication('onExitFullscreen', viewId); + } else { + webView.isFullscreen = false; + } + }); + + if (iframe != null) { + webView.iframe = iframe; + webView.iframeContainer = iframeContainer; + iframe.addEventListener('load', function (event) { + webView.windowAutoincrementId = 0; + webView.windows = {}; + + var url = iframe.src; + try { + url = iframe.contentWindow.location.href; + } catch (e) { + console.log(e); + } + window.flutter_inappwebview.nativeCommunication('onLoadStart', viewId, [url]); + + try { + var oldLogs = { + 'log': iframe.contentWindow.console.log, + 'debug': iframe.contentWindow.console.debug, + 'error': iframe.contentWindow.console.error, + 'info': iframe.contentWindow.console.info, + 'warn': iframe.contentWindow.console.warn + }; + for (var k in oldLogs) { + (function (oldLog) { + iframe.contentWindow.console[oldLog] = function () { + var message = ''; + for (var i in arguments) { + if (message == '') { + message += arguments[i]; + } else { + message += ' ' + arguments[i]; + } + } + oldLogs[oldLog].call(iframe.contentWindow.console, ...arguments); + window.flutter_inappwebview.nativeCommunication('onConsoleMessage', viewId, [oldLog, message]); + } + })(k); + } + } catch (e) { + console.log(e); + } + + try { + var originalPushState = iframe.contentWindow.history.pushState; + iframe.contentWindow.history.pushState = function (state, unused, url) { + originalPushState.call(iframe.contentWindow.history, state, unused, url); + var iframeUrl = iframe.src; + try { + iframeUrl = iframe.contentWindow.location.href; + } catch (e) { + console.log(e); + } + window.flutter_inappwebview.nativeCommunication('onUpdateVisitedHistory', viewId, [iframeUrl]); + }; + + var originalReplaceState = iframe.contentWindow.history.replaceState; + iframe.contentWindow.history.replaceState = function (state, unused, url) { + originalReplaceState.call(iframe.contentWindow.history, state, unused, url); + var iframeUrl = iframe.src; + try { + iframeUrl = iframe.contentWindow.location.href; + } catch (e) { + console.log(e); + } + window.flutter_inappwebview.nativeCommunication('onUpdateVisitedHistory', viewId, [iframeUrl]); + }; + + var originalOpen = iframe.contentWindow.open; + iframe.contentWindow.open = function (url, target, windowFeatures) { + var newWindow = originalOpen.call(iframe.contentWindow, ...arguments); + var windowId = webView.windowAutoincrementId; + webView.windowAutoincrementId++; + webView.windows[windowId] = newWindow; + window.flutter_inappwebview.nativeCommunication('onCreateWindow', viewId, [windowId, url, target, windowFeatures]).then(function () { }, function (handledByClient) { + if (handledByClient) { + newWindow.close(); + } + }); + return newWindow; + }; + + var originalPrint = iframe.contentWindow.print; + iframe.contentWindow.print = function () { + var iframeUrl = iframe.src; + try { + iframeUrl = iframe.contentWindow.location.href; + } catch (e) { + console.log(e); + } + window.flutter_inappwebview.nativeCommunication('onPrintRequest', viewId, [iframeUrl]); + originalPrint.call(iframe.contentWindow); + }; + + webView.functionMap = { + "window.open": iframe.contentWindow.open, + "window.print": iframe.contentWindow.print, + "window.history.pushState": iframe.contentWindow.history.pushState, + "window.history.replaceState": iframe.contentWindow.history.replaceState, + } + + var initialTitle = iframe.contentDocument.title; + var titleEl = iframe.contentDocument.querySelector('title'); + webView.documentTitle = initialTitle; + window.flutter_inappwebview.nativeCommunication('onTitleChanged', viewId, [initialTitle]); + if (titleEl != null) { + new MutationObserver(function (mutations) { + var title = mutations[0].target.innerText; + if (title != webView.documentTitle) { + webView.documentTitle = title; + window.flutter_inappwebview.nativeCommunication('onTitleChanged', viewId, [title]); + } + }).observe( + titleEl, + { subtree: true, characterData: true, childList: true } + ); + } + + var oldPixelRatio = iframe.contentWindow.devicePixelRatio; + iframe.contentWindow.addEventListener('resize', function (e) { + var newPixelRatio = iframe.contentWindow.devicePixelRatio; + if (newPixelRatio !== oldPixelRatio) { + window.flutter_inappwebview.nativeCommunication('onZoomScaleChanged', viewId, [oldPixelRatio, newPixelRatio]); + oldPixelRatio = newPixelRatio; + } + }); + + iframe.contentWindow.addEventListener('popstate', function (event) { + var iframeUrl = iframe.src; + try { + iframeUrl = iframe.contentWindow.location.href; + } catch (e) { + console.log(e); + } + window.flutter_inappwebview.nativeCommunication('onUpdateVisitedHistory', viewId, [iframeUrl]); + }); + + iframe.contentWindow.addEventListener('scroll', function (event) { + var x = 0; + var y = 0; + try { + x = iframe.contentWindow.scrollX; + y = iframe.contentWindow.scrollY; + } catch (e) { + console.log(e); + } + window.flutter_inappwebview.nativeCommunication('onScrollChanged', viewId, [x, y]); + }); + + iframe.contentWindow.addEventListener('focus', function (event) { + window.flutter_inappwebview.nativeCommunication('onWindowFocus', viewId); + }); + + iframe.contentWindow.addEventListener('blur', function (event) { + window.flutter_inappwebview.nativeCommunication('onWindowBlur', viewId); + }); + } catch (e) { + console.log(e); + } + + try { + + if (!webView.settings.javaScriptCanOpenWindowsAutomatically) { + iframe.contentWindow.open = function () { + throw new Error('JavaScript cannot open windows automatically'); + }; + } + + if (!webView.settings.verticalScrollBarEnabled && !webView.settings.horizontalScrollBarEnabled) { + var style = iframe.contentDocument.createElement('style'); + style.id = "settings.verticalScrollBarEnabled-settings.horizontalScrollBarEnabled"; + style.innerHTML = "body::-webkit-scrollbar { width: 0px; height: 0px; }"; + iframe.contentDocument.head.append(style); + } + + if (webView.settings.disableVerticalScroll) { + var style = iframe.contentDocument.createElement('style'); + style.id = "settings.disableVerticalScroll"; + style.innerHTML = "body { overflow-y: hidden; }"; + iframe.contentDocument.head.append(style); + } + + if (webView.settings.disableHorizontalScroll) { + var style = iframe.contentDocument.createElement('style'); + style.id = "settings.disableHorizontalScroll"; + style.innerHTML = "body { overflow-x: hidden; }"; + iframe.contentDocument.head.append(style); + } + + if (webView.settings.disableContextMenu) { + iframe.contentWindow.addEventListener('contextmenu', webView.disableContextMenuHandler); + } + } catch (e) { + console.log(e); + } + + window.flutter_inappwebview.nativeCommunication('onLoadStop', viewId, [url]); + }); + } + }, + setSettings: function (newSettings) { + var iframe = webView.iframe; + try { + if (webView.settings.javaScriptCanOpenWindowsAutomatically != newSettings.javaScriptCanOpenWindowsAutomatically) { + if (!newSettings.javaScriptCanOpenWindowsAutomatically) { + iframe.contentWindow.open = function () { + throw new Error('JavaScript cannot open windows automatically'); + }; + } else { + iframe.contentWindow.open = webView.functionMap["window.open"]; + } + } + + if (webView.settings.verticalScrollBarEnabled != newSettings.verticalScrollBarEnabled && + webView.settings.horizontalScrollBarEnabled != newSettings.horizontalScrollBarEnabled) { + if (!newSettings.verticalScrollBarEnabled && !newSettings.horizontalScrollBarEnabled) { + var style = iframe.contentDocument.createElement('style'); + style.id = "settings.verticalScrollBarEnabled-settings.horizontalScrollBarEnabled"; + style.innerHTML = "body::-webkit-scrollbar { width: 0px; height: 0px; }"; + iframe.contentDocument.head.append(style); + } else { + var styleElement = iframe.contentDocument.getElementById("settings.verticalScrollBarEnabled-settings.horizontalScrollBarEnabled"); + if (styleElement) { styleElement.remove() } + } + } + + if (webView.settings.disableVerticalScroll != newSettings.disableVerticalScroll) { + if (newSettings.disableVerticalScroll) { + var style = iframe.contentDocument.createElement('style'); + style.id = "settings.disableVerticalScroll"; + style.innerHTML = "body { overflow-y: hidden; }"; + iframe.contentDocument.head.append(style); + } else { + var styleElement = iframe.contentDocument.getElementById("settings.disableVerticalScroll"); + if (styleElement) { styleElement.remove() } + } + } + + if (webView.settings.disableHorizontalScroll != newSettings.disableHorizontalScroll) { + if (newSettings.disableHorizontalScroll) { + var style = iframe.contentDocument.createElement('style'); + style.id = "settings.disableHorizontalScroll"; + style.innerHTML = "body { overflow-x: hidden; }"; + iframe.contentDocument.head.append(style); + } else { + var styleElement = iframe.contentDocument.getElementById("settings.disableHorizontalScroll"); + if (styleElement) { styleElement.remove() } + } + } + + if (webView.settings.disableContextMenu != newSettings.disableContextMenu) { + if (newSettings.disableContextMenu) { + iframe.contentWindow.addEventListener('contextmenu', webView.disableContextMenuHandler); + } else { + iframe.contentWindow.removeEventListener('contextmenu', webView.disableContextMenuHandler); + } + } + } catch (e) { + console.log(e); + } + + webView.settings = newSettings; + }, + reload: function () { + var iframe = webView.iframe; + if (iframe != null && iframe.contentWindow != null) { + try { + iframe.contentWindow.location.reload(); + } catch (e) { + console.log(e); + iframe.contentWindow.location.href = iframe.src; + } + } + }, + goBack: function () { + var iframe = webView.iframe; + if (iframe != null) { + try { + iframe.contentWindow.history.back(); + } catch (e) { + console.log(e); + } + } + }, + goForward: function () { + var iframe = webView.iframe; + if (iframe != null) { + try { + iframe.contentWindow.history.forward(); + } catch (e) { + console.log(e); + } + } + }, + goBackOrForward: function (steps) { + var iframe = webView.iframe; + if (iframe != null) { + try { + iframe.contentWindow.history.go(steps); + } catch (e) { + console.log(e); + } + } + }, + evaluateJavascript: function (source) { + var iframe = webView.iframe; + var result = null; + if (iframe != null) { + try { + result = JSON.stringify(iframe.contentWindow.eval(source)); + } catch (e) { } + } + return result; + }, + stopLoading: function (steps) { + var iframe = webView.iframe; + if (iframe != null) { + try { + iframe.contentWindow.stop(); + } catch (e) { + console.log(e); + } + } + }, + getUrl: function () { + var iframe = webView.iframe; + var url = iframe.src; + try { + url = iframe.contentWindow.location.href; + } catch (e) { + console.log(e); + } + return url; + }, + getTitle: function () { + var iframe = webView.iframe; + var title = null; + try { + title = iframe.contentDocument.title; + } catch (e) { + console.log(e); + } + return title; + }, + injectJavascriptFileFromUrl: function (urlFile, scriptHtmlTagAttributes) { + var iframe = webView.iframe; + try { + var d = iframe.contentDocument; + var script = d.createElement('script'); + for (var key of Object.keys(scriptHtmlTagAttributes)) { + if (scriptHtmlTagAttributes[key] != null) { + script[key] = scriptHtmlTagAttributes[key]; + } + } + if (script.id != null) { + script.onload = function () { + window.flutter_inappwebview.nativeCommunication('onInjectedScriptLoaded', webView.viewId, [script.id]); + } + script.onerror = function () { + window.flutter_inappwebview.nativeCommunication('onInjectedScriptError', webView.viewId, [script.id]); + } + } + script.src = urlFile; + if (d.body != null) { + d.body.appendChild(script); + } + } catch (e) { + console.log(e); + } + }, + injectCSSCode: function (source) { + var iframe = webView.iframe; + try { + var d = iframe.contentDocument; + var style = d.createElement('style'); + style.innerHTML = source; + if (d.head != null) { + d.head.appendChild(style); + } + } catch (e) { + console.log(e); + } + }, + injectCSSFileFromUrl: function (urlFile, cssLinkHtmlTagAttributes) { + var iframe = webView.iframe; + try { + var d = iframe.contentDocument; + var link = d.createElement('link'); + for (var key of Object.keys(cssLinkHtmlTagAttributes)) { + if (cssLinkHtmlTagAttributes[key] != null) { + link[key] = cssLinkHtmlTagAttributes[key]; + } + } + link.type = 'text/css'; + var alternateStylesheet = ""; + if (cssLinkHtmlTagAttributes.alternateStylesheet) { + alternateStylesheet = "alternate "; + } + link.rel = alternateStylesheet + "stylesheet"; + link.href = urlFile; + if (d.head != null) { + d.head.appendChild(link); + } + } catch (e) { + console.log(e); + } + }, + scrollTo: function (x, y, animated) { + var iframe = webView.iframe; + try { + if (animated) { + iframe.contentWindow.scrollTo({ top: y, left: x, behavior: 'smooth' }); + } else { + iframe.contentWindow.scrollTo(x, y); + } + } catch (e) { + console.log(e); + } + }, + scrollBy: function (x, y, animated) { + var iframe = webView.iframe; + try { + if (animated) { + iframe.contentWindow.scrollBy({ top: y, left: x, behavior: 'smooth' }); + } else { + iframe.contentWindow.scrollBy(x, y); + } + } catch (e) { + console.log(e); + } + }, + printCurrentPage: function () { + var iframe = webView.iframe; + try { + iframe.contentWindow.print(); + } catch (e) { + console.log(e); + } + }, + getContentHeight: function () { + var iframe = webView.iframe; + try { + return iframe.contentDocument.documentElement.scrollHeight; + } catch (e) { + console.log(e); + } + return null; + }, + getContentWidth: function () { + var iframe = webView.iframe; + try { + return iframe.contentDocument.documentElement.scrollWidth; + } catch (e) { + console.log(e); + } + return null; + }, + getSelectedText: function () { + var iframe = webView.iframe; + try { + var txt; + var w = iframe.contentWindow; + if (w.getSelection) { + txt = w.getSelection().toString(); + } else if (w.document.getSelection) { + txt = w.document.getSelection().toString(); + } else if (w.document.selection) { + txt = w.document.selection.createRange().text; + } + return txt; + } catch (e) { + console.log(e); + } + return null; + }, + getScrollX: function () { + var iframe = webView.iframe; + try { + return iframe.contentWindow.scrollX; + } catch (e) { + console.log(e); + } + return null; + }, + getScrollY: function () { + var iframe = webView.iframe; + try { + return iframe.contentWindow.scrollY; + } catch (e) { + console.log(e); + } + return null; + }, + isSecureContext: function () { + var iframe = webView.iframe; + try { + return iframe.contentWindow.isSecureContext; + } catch (e) { + console.log(e); + } + return false; + }, + canScrollVertically: function () { + var iframe = webView.iframe; + try { + return iframe.contentDocument.body.scrollHeight > iframe.contentWindow.innerHeight; + } catch (e) { + console.log(e); + } + return false; + }, + canScrollHorizontally: function () { + var iframe = webView.iframe; + try { + return iframe.contentDocument.body.scrollWidth > iframe.contentWindow.innerWidth; + } catch (e) { + console.log(e); + } + return false; + }, + getSize: function () { + var iframeContainer = webView.iframeContainer; + var width = 0.0; + var height = 0.0; + if (iframeContainer.style.width != null && iframeContainer.style.width != '' && iframeContainer.style.width.indexOf('px') > 0) { + width = parseFloat(iframeContainer.style.width); + } + if (width == null || width == 0.0) { + width = iframeContainer.getBoundingClientRect().width; + } + if (iframeContainer.style.height != null && iframeContainer.style.height != '' && iframeContainer.style.height.indexOf('px') > 0) { + height = parseFloat(iframeContainer.style.height); + } + if (height == null || height == 0.0) { + height = iframeContainer.getBoundingClientRect().height; + } + + return { + width: width, + height: height + }; + } + }; + + return webView; + }, + getCookieExpirationDate: function (timestamp) { + return (new Date(timestamp)).toUTCString(); + } +}; \ No newline at end of file diff --git a/assets/translations/en.json b/assets/translations/en.json new file mode 100644 index 0000000000..9e5ad838aa --- /dev/null +++ b/assets/translations/en.json @@ -0,0 +1,592 @@ +{ + "plsActivateKmd": "Please activate KMD", + "rewardClaiming": "Rewards claim in progress", + "noKmdAddress": "No KMD address found", + "dex": "DEX", + "asset": "Assets", + "price": "Price", + "volume": "Volume", + "history": "History", + "active": "Active", + "change24h": "Change 24h", + "change24hRevert": "24h %", + "viewOnExplorer": "View on Explorer", + "getRewards": "Get Rewards", + "rewardBoxTitle": "Komodo Active User Reward", + "sendToAddress": "Only send {} to this address", + "network": "Network", + "rewardBoxSubTitle": "All You need to know", + "rewardBoxReadMore": "Read more", + "claimSuccess": "Claim success", + "noRewards": "There are no rewards available", + "kmdAmount": "Amount, KMD", + "kmdReward": "Reward, KMD", + "kmdRewardSpan1": "Market data (prices, charts, etc.) marked with this icon originates from third party sources", + "timeLeft": "Time Left", + "status": "Status", + "complete": "Complete", + "claim": "Claim", + "noTransactionsTitle": "No transactions available", + "noTransactionsDescription": "Click the receive button to view your address and deposit funds", + "noClaimableRewards": "No claimable rewards", + "amountToSend": "Amount to send", + "enterAmountToSend": "Enter {} send amount", + "inferiorSendAmount": "{} send amount must be > 0", + "date": "Date", + "confirmations": "Confirmations", + "blockHeight": "Block Height", + "from": "From", + "to": "To", + "fromDate": "Date from", + "toDate": "Date to", + "amount": "Amount", + "close": "Close", + "fee": "Fee", + "done": "Done", + "fees": "Fees", + "recipientAddress": "Recipient address", + "transactionHash": "Transaction Hash", + "hash": "Hash", + "fullHash": "Full Hash", + "coinAddress": "Enter {} address", + "youSend": "You are sending", + "invalidAddress": "Invalid {} address", + "customFeeCoin": "Custom fee [{}]", + "customFeeOptional": "Custom fee (optional)", + "optional": "Optional", + "showMore": "Show more", + "settings": "Settings", + "somethingWrong": "Something went wrong!", + "transactionComplete": "Transaction complete!", + "transactionDenied": "Denied", + "coinDisableSpan1": "You can't disable {} while it has a swap in progress", + "confirmSending": "Confirm sending", + "confirmSend": "Confirm send", + "confirm": "Confirm", + "confirmed": "Confirmed", + "ok": "OK", + "cancel": "Cancel", + "next": "Next", + "continueText": "Continue", + "accept": "Accept", + "create": "Create", + "import": "Import", + "enterDataToSend": "Enter data to send", + "address": "Address: ", + "request": "Request", + "disable": "Disable", + "usdPrice": "USD Price", + "portfolio": "Portfolio", + "editList": "Edit list", + "withBalance": "Hide 0 balance assets", + "balance": "Balance", + "transactions": "Transactions", + "send": "Send", + "receive": "Receive", + "faucet": "Faucet", + "reward": "Reward", + "loadingSwap": "Loading Swaps...", + "swapDaily": "Swaps Last 24h", + "swapMonthly": "Swaps Last 30d", + "swapAllTime": "Swaps All Time", + "seed": "Seed", + "wallet": "Wallet", + "logIn": "Log In", + "logOut": "Log out", + "logOutGo": "Go", + "delete": "Delete", + "forget": "Clear", + "seedPhrase": "Seed phrase:", + "assetNumber": { + "zero": "{} Assets", + "one": "{} Asset", + "two": "{} Assets", + "many": "{} Assets", + "few": "{} Assets", + "other": "{} Assets" + }, + "clipBoard": "Copied to the clipboard", + "walletsManagerCreateWalletButton": "Create wallet", + "walletsManagerImportWalletButton": "Import wallet", + "walletsManagerStepBuilderCreationWalletError": "Failed to create wallet, please try again later", + "walletCreationTitle": "Create wallet", + "walletImportTitle": "Import wallet", + "walletImportByFileTitle": "Seed file has been imported", + "walletImportCreatePasswordTitle": "Create a password for \"{}\" wallet", + "walletImportByFileDescription": "Enter the password for your seed file to decrypt it. This password will be used to log in to your wallet", + "walletLogInTitle": "Log in", + "walletCreationNameHint": "Wallet name", + "walletCreationPasswordHint": "Wallet password", + "walletCreationConfirmPasswordHint": "Confirm password", + "walletCreationConfirmPassword": "Confirm password", + "walletCreationUploadFile": "Upload seed file", + "walletCreationEmptySeedError": "Seed should not be empty", + "walletCreationExistNameError": "Wallet name exists", + "walletCreationNameLengthError": "Name length should be between 1 and 40", + "walletCreationFormatPasswordError": "Password must contain at least 12 characters, with at least one lower-case, one upper-case and one special symbol.", + "walletCreationConfirmPasswordError": "Your passwords do not match. Please try again.", + "invalidPasswordError": "Invalid password", + "importSeedEnterSeedPhraseHint": "Enter seed", + "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.", + "passphraseCheckingEnterWord": "Enter the {} word of your seed phrase:", + "passphraseCheckingEnterWordHint": "Enter the {} word", + "back": "Back", + "settingsMenuGeneral": "General", + "settingsMenuLanguage": "Change language", + "settingsMenuSecurity": "Security", + "settingsMenuAbout": "About", + "seedPhraseSettingControlsViewSeed": "View seed phrase", + "seedPhraseSettingControlsDownloadSeed": "Download seed file", + "debugSettingsResetActivatedCoins": "Reset activated coins", + "debugSettingsDownloadButton": "Download logs", + "or": "Or", + "passwordTitle": "Password", + "passwordUpdateCreate": "Create the password", + "enterThePassword": "Enter your password", + "changeThePassword": "Change your password", + "changePasswordSpan1": "You can change your password for this wallet at any time (it is used to encrypt your seed phrase)", + "updatePassword": "Update password", + "passwordHasChanged": "Password has changed", + "confirmationForShowingSeedPhraseTitle": "Enter wallet password", + "saveAndRemember": "Save & remember", + "seedPhraseShowingTitle": "Your seed phrase", + "seedPhraseShowingWarning": "Make sure no one spies on your seed phrase!", + "seedPhraseShowingShowPhrase": "Show my seed phrase", + "seedPhraseShowingCopySeed": "Copy seed", + "seedPhraseShowingSavedPhraseButton": "I saved my seed phrase", + "seedAccessSpan1": "Your seed phrase is how you unlock your assets. Be sure to save this securely, and do not share it with anyone!", + "backupSeedNotificationTitle": "Backup your seed phrase", + "backupSeedNotificationDescription": "Confirm your seed phrase in the settings to make the first transaction", + "backupSeedNotificationButton": "Backup", + "swapConfirmationTitle": "Confirm exchange", + "swapConfirmationYouReceive": "You will receive:", + "swapConfirmationYouSending": "You are sending:", + "tradingDetailsTitleFailed": "Swap failed", + "tradingDetailsTitleCompleted": "Swap completed", + "tradingDetailsTitleInProgress": "Swap in progress", + "tradingDetailsTitleOrderMatching": "Order matching", + "tradingDetailsTotalSpentTime": "Total time {} min {} sec", + "tradingDetailsTotalSpentTimeWithHours": "Total time {} h {} min {} sec", + "swapRecoverButtonTitle": "You need unlock your funds", + "swapRecoverButtonText": "Unlock Funds", + "swapRecoverButtonErrorMessage": "Something wrong, please try again", + "swapRecoverButtonSuccessMessage": "Recover swap successful", + "swapProgressStatusFailed": "Failed swap", + "swapDetailsStepStatusFailed": "Failed", + "disclaimerAcceptEulaCheckbox": "EULA", + "disclaimerAcceptTermsAndConditionsCheckbox": "TERMS and CONDITIONS", + "disclaimerAcceptDescription": "By clicking the button below, you confirm that you have read and accept the \"EULA\" and \"TOC\"", + "swapDetailsStepStatusInProcess": "In progress", + "swapDetailsStepStatusTimeSpent": "Time spent {}", + "milliseconds": "ms", + "seconds": "s", + "minutes": "m", + "hours": "h", + "coinAddressDetailsNotificationTitle": "Confirm your seed phrase", + "coinAddressDetailsNotificationDescription": "In order to receive funds to your wallet, confirm the following phrase", + "swapFeeDetailsPaidFromBalance": "Paid from balance:", + "swapFeeDetailsSendCoinTxFee": "send {} tx fee", + "swapFeeDetailsReceiveCoinTxFee": "receive {} tx fee", + "swapFeeDetailsTradingFee": "trading fee", + "swapFeeDetailsSendTradingFeeTxFee": "send trading fee tx fee", + "swapFeeDetailsNone": "None", + "swapFeeDetailsPaidFromReceivedVolume": "Paid from received volume", + "logoutPopupTitle": "Confirm log out?", + "logoutPopupDescription": "Are you sure you want to logout? Your opened orders will no longer be available to match with other users and any trades in progress may not be completed", + "transactionDetailsTitle": "Transaction completed", + "customSeedWarningText": "Custom seed phrases are generally less secure and easier to crack than a generated BIP39 compliant seed phrase. To confirm you understand and are aware of the risk, type \"I\u00A0Understand\" in the box below.", + "customSeedIUnderstand": "i understand", + "walletCreationBip39SeedError": "BIP39 seed validation failed, try again or select 'Allow custom seed'", + "walletPageNoSuchAsset": "No assets match search criteria", + "swapCoin": "Swap", + "fiatBalance": "Fiat balance", + "yourBalance": "Your balance", + "all": "All", + "taker": "Taker", + "maker": "Maker", + "successful": "Successful", + "success": "Success", + "failed": "Failed", + "exchangeCoin": "Exchange coin", + "search": "Search", + "searchAssets": "Search assets", + "searchCoin": "Search coin", + "filters": "Filters", + "sellAsset": "Sell asset", + "buyAsset": "Buy asset", + "assetName": "Asset name", + "protocol": "Protocol", + "resetAll": "Reset All", + "reset": "Reset", + "clearFilter": "Clear Filter", + "addAssets": "Add assets", + "removeAssets": "Remove assets", + "selectedAssetsCount": "Selected {} assets", + "clickAssetToAddHint": "No assets selected", + "clickAssetToRemoveHint": "Select assets to remove", + "defaultCoinDisableWarning": "You can't disable {}, it is a default coin", + "supportFrequentlyQuestionSpan": "Frequently asked questions", + "support": "Support", + "supportInfoTitle1": "Do you store my private keys?", + "supportInfoContent1": "No! Komodo Wallet is non-custodial. We never store any sensitive data, private keys, seed phrases, or your PIN. This data is only stored locally, and is encrypted with your wallet password. It never leaves your device unless you export it. You are in full control of your assets.", + "supportInfoTitle2": "How is trading with Komodo Wallet different from trading on other DEXs?", + "supportInfoContent2": "Most DEXs only allow you to trade assets that are based on a single blockchain network, use proxy tokens, and only allow placing a single order with the same funds. Komodo Wallet enables you to trade across different blockchain networks without proxy tokens, and use the same funds to place multiple orders for different pairs. For example, you can sell 0.1 BTC for KMD, QTUM, or VRSC — the first order that fills automatically cancels all other orders.", + "supportInfoTitle3": "How long does each atomic swap take?", + "supportInfoContent3": "Several factors determine the processing time for each swap. The block time of the traded assets depends on each network (i.e. a BTC swap with 10 minute block time will typically be slower than a KMD swap with 1 minute block time). Depending on your security preferences, you can increase or lower the number of confirmations required, which will also affect the speed of your swap. For example, you can configure Komodo Wallet to consider a KMD transaction as final after just 3 confirmations which makes the swap time faster compared to waiting for a notarization.", + "supportInfoTitle4": "Do I need to be online for the duration of the swap?", + "supportInfoContent4": "Yes. You must remain connected to the internet and have your app running to successfully complete each atomic swap (very short breaks in connectivity are usually fine). If you go offline, there is risk of trade cancellation if you are a maker, or risk of loss of fees if you are a taker (remaining funds will be refunded after a few hours). The atomic swap protocol requires both participants to stay online so that Komodo Wallet can monitor the involved blockchains for the process to stay atomic. If you go offline, so will your orders, and any swaps that are in progress will fail, leading to potential loss of trade / transaction fees, and a wait time for the swap HTLC to timeout and issue a refund. It may also negatively affect your wallet's reputation score for future trade matching. When you come back online, your orders will begin to broadcast again at the price you set before you went offline. If there has been significant price movement in the meantime, you might unintentionally offer someone a bargain! For this reason, we recommend cancelling orders before closing Komodo Wallet, or reviewing and revising your prices when restarting Komodo Wallet.", + "supportInfoTitle5": "How are the fees in Komodo Wallet calculated?", + "supportInfoContent5": "There are two fee categories to consider when trading with Komodo Wallet. Komodo Wallet charges approximately 0.13% (1/777 of trading volume but not lower than 0.0001) as the trading fee for taker orders, and maker orders have zero trade fee! Both makers and takers will need to pay normal network transaction fees to the involved blockchains when making atomic swap transactions. Network transaction fees can vary greatly depending on your selected trading pair, and will include gas fees paid in the parent coin where applicable (e.g. a BTC-BEP20 to BTC-ERC20 trade will require BNB and ETH to pay the gas fee).", + "supportInfoTitle6": "Do you provide user support?", + "supportInfoContent6": "Yes! Komodo Wallet offers support through the Komodo Discord server. The team and the community are always happy to help!", + "supportInfoTitle7": "Who is behind Komodo Wallet?", + "supportInfoContent7": "Komodo Wallet is developed by the Komodo team. Komodo is one of the most established blockchain projects working on innovative solutions like atomic swaps, Delayed Proof of Work, and an interoperable multi-chain architecture.", + "supportInfoTitle8": "Is it possible to develop my own white-label exchange on Komodo Wallet?", + "supportInfoContent8": "Absolutely! You can read our developer documentation at https://developers.komodoplatform.com for more details or contact us with your partnership inquiries. Have a specific technical question? The Komodo Wallet developer community is always ready to help!", + "supportInfoTitle9": "Which devices can I use Komodo Wallet on?", + "supportInfoContent9": "Komodo Wallet is available for mobile on both Android and iPhone, and for desktop on Windows, Mac, and Linux operating systems.", + "supportInfoTitle10": "Compliance Info", + "supportInfoContent10": "Due to regulatory and legal restrictions the citizens of certain jurisdictions including, but not limited to, the United States of America, Canada, Hong Kong, Israel, Singapore, Sudan, Austria, Iran (and any other state, country or other jurisdiction that is embargoed by the United States of America or the European Union) are not allowed to use this application.", + "supportDiscordButton": "Komodo #general-support DISCORD", + "supportAskSpan": "If you have any questions, suggestions or technical problems with the Komodo Wallet app, you can report it to get support from our team.", + "fiat": "Buy / Sell", + "bridge": "Bridge", + "apply": "Apply", + "makerOrder": "Maker order", + "takerOrder": "Taker order", + "buyPrice": "Buy price", + "inProgress": "In Progress", + "orders": "Orders", + "swap": "Swap", + "percentFilled": "{}% filled", + "orderType": "Order type", + "recover": "Recover", + "cancelAll": "Cancel all", + "type": "Type", + "sell": "Sell", + "buy": "Buy", + "changingWalletPassword": "Changing your wallet password", + "changingWalletPasswordDescription": "This password will be used for logging in to your wallet", + "dark": "Dark", + "darkMode": "Dark mode", + "light": "Light", + "lightMode": "Light mode", + "defaultText": "Default", + "clear": "Clear", + "remove": "Remove", + "newText": "new", + "whatsNew": "What's new?", + "remindLater": "Remind me later", + "updateNow": "Update now", + "updatePopupTitle": "New update! Would you like to\nupdate to latest version?", + "activationFailedMessage": "Activation failed", + "retryButtonText": "Retry", + "reloadButtonText": "Reload", + "feedbackFormTitle": "Your feedback is important to us", + "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", + "email": "Email", + "emailValidatorError": "Please enter a valid email address", + "feedbackValidatorEmptyError": "Please enter your feedback", + "feedbackValidatorMaxLengthError": "Please limit your feedback to {} characters or less", + "yourFeedback": "Your feedback", + "sendFeedback": "Send feedback", + "sendFeedbackError": "Something wrong :( Please try again later. Your feedback is very important for us", + "addMoreFeedback": "Add more feedback", + "closePopup": "Close pop-up", + "exchange": "Exchange", + "connectSomething": "Connect {}", + "hardwareWallet": "Hardware wallet", + "komodoWalletSeed": "Komodo Wallet seed", + "metamask": "Metamask", + "comingSoon": "Coming soon", + "walletsTypeListTitle": "Start using Komodo Wallet", + "seedPhraseMakeSureBody": "Write down a piece of paper and put the seed phrase in a safe place. This is the only access to your wallet if you lose your device or password access to your wallet.", + "seedPhraseSuccessTitle": "Seed phrase has been confirmed", + "seedPhraseSuccessBody": "You can trade, send and receive any coins. We hope you put your seed phrase in the safe place, because if you lose access to your seed phrase you can lose your funds. Be careful.", + "seedPhraseGotIt": "I got it", + "viewSeedPhrase": "View seed phrase", + "backupSeedPhrase": "Backup seed phrase", + "seedOr": "Or", + "seedDownload": "Download seed file", + "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", + "tryAgain": "Oops! Something went wrong. \nPlease try again. \nIf it didn't help - contact us.", + "customFeesWarning": "Only use custom fees if you know what you are doing!", + "fiatExchange": "Exchange", + "bridgeExchange": "Exchange", + "noTxSupportHidden": "Please use button below to open block explorer", + "deleteWalletTitle": "Are you sure you want to delete \"{}\" wallet?", + "deleteWalletInfo": "The wallet will be deleted from your device's cache. You always can import your seed phrase again.", + "trezorEnterPinTitle": "Enter PIN for your Trezor", + "trezorEnterPinHint": "The PIN layout is displayed on your Trezor", + "trezorInProgressTitle": "Looking for your Trezor...", + "trezorInProgressHint": "Please do not disable your Trezor", + "trezorErrorBusy": "Device is busy.\n' Make sure that Trezor Suite is not running in background.", + "trezorErrorInvalidPin": "Invalid PIN code", + "trezorSelectTitle": "Connect a hardware wallet", + "trezorSelectSubTitle": "Select a hardware wallet you'd like to use with Komodo Wallet", + "mixedCaseError": "If you are using non mixed case address, please try to convert to mixed case one.", + "invalidAddressChecksum": "Invalid address checksum", + "notEnoughBalance": "Not enough balance", + "pleaseInputData": "Please input data", + "customFeeHigherAmount": "Custom fee can't be higher than the amount", + "noSenderAddress": "Sender address is not specified.", + "confirmOnTrezor": "Confirm on Trezor", + "alphaVersionWarningTitle": "Notification about alpha-testing program", + "alphaVersionWarningDescription": "Komodo Wallet Web is currently available for alpha testing. As with any early-stage software, there are relevant risks for testers. It’s recommended not to use the alpha version of this application to hold or trade large amounts of funds. The application is currently (during the alpha development phase) hosted on the Google Firebase cloud service. By clicking the \"Accept\" button, you confirm that you understand this disclaimer and accept the possible risks.", + "sendToAnalytics": "Send anonymous data to analytics", + "backToWallet": "Back to wallet", + "backToDex": "Back to DEX", + "backToBridge": "Back to Bridge", + "scanToGetAddress": "Scan to get address", + "listIsEmpty": "This list is empty", + "setMax": "Set Max", + "setMin": "Set Min", + "timeout": "Timeout", + "notEnoughBalanceForGasError": "Not enough balance to pay gas.", + "notEnoughFundsError": "Not enough funds to perform a trade", + "dexErrorMessage": "Something went wrong!", + "seedConfirmInitialText": "Enter the seed phrase", + "seedConfirmIncorrectText": "Incorrect seed phrase", + "usedSamePassword": "This password matches your current one. Please create a different password.", + "passwordNotAccepted": "Password not accepted", + "confirmNewPassword": "Confirm new password", + "enterNewPassword": "Enter new password", + "currentPassword": "Current password", + "walletNotFound": "Wallet not found!", + "passwordIsEmpty": "Password is empty", + "dexBalanceNotSufficientError": "{} balance is not sufficient. {} {} required", + "dexEnterPriceError": "Please enter price", + "dexZeroPriceError": "Price must be greater than 0", + "dexSelectBuyCoinError": "Please select Buy Coin", + "dexSelectSellCoinError": "Please select Sell Coin", + "dexCoinSuspendedError": "{} is suspended, can't perform trade", + "dexEnterBuyAmountError": "Please enter Buy Amount", + "dexEnterSellAmountError": "Please enter Sell Amount", + "dexZeroBuyAmountError": "Buy amount should be greater than 0", + "dexZeroSellAmountError": "Sell amount should be greater than 0", + "dexMaxSellAmountError": "Max sell amount is {} {}", + "dexMaxOrderVolume": "Max volume for order at this price is {} {}", + "dexMinSellAmountError": "Minimum sell amount is {} {}", + "dexMaxOrderVolumeError": "Max order volume is {} {}", + "dexInsufficientFundsError": "Insufficient funds: {} {} available.", + "dexTradingWithSelfError": "Trading with self is not allowed.", + "bridgeSelectSendProtocolError": "Please select 'FROM' protocol", + "bridgeSelectFromProtocolError": "Please select 'FROM' protocol first.", + "bridgeSelectTokenFirstError": "Please select Token first", + "bridgeEnterSendAmountError": "Please enter send amount", + "bridgeZeroSendAmountError": "Send amount should be greater than 0", + "bridgeMaxSendAmountError": "Max send amount is {} {}", + "bridgeMinOrderAmountError": "Minimum order amount is {} {}", + "bridgeMaxOrderAmountError": "Maximum order amount is {} {}", + "bridgeInsufficientBalanceError": "Insufficient {} balance: {} required, {} available.", + "lowTradeVolumeError": "Trade volume is too low. Min {} {} required.", + "bridgeSelectReceiveCoinError": "Please select 'TO' protocol", + "withdrawNoParentCoinError": "Please activate {}", + "withdrawTopUpBalanceError": "Please top up {} balance", + "withdrawNotEnoughBalanceForGasError": "{} balance not sufficient to pay transaction fees", + "withdrawNotSufficientBalanceError": "Not enough {} to withdraw: available {}, required {} or more", + "withdrawZeroBalanceError": "You have zero {} balance", + "withdrawAmountTooLowError": "{} {} too low, you need > {} {} to send", + "withdrawNoSuchCoinError": "Invalid selection, {} does not exist", + "txHistoryFetchError": "Error fetching tx history from the endpoint. Unsupported type: {}", + "txHistoryNoTransactions": "Transactions are not available", + "memo": "Memo", + "gasPriceGwei": "Gas price [Gwei]", + "gasLimit": "Gas limit", + "memoOptional": "Memo (optional)", + "convert": "Convert", + "youClaimed": "You claimed", + "successClaim": "Success claim", + "rewardProcessingShort": "Processing", + "rewardProcessingLong": "Transaction is in progress", + "rewardLessThanTenLong": "UTXO amount less than 10 KMD", + "rewardOneHourNotPassedShort": "<1 hour", + "rewardOneHourNotPassedLong": "One hour not passed yet", + "comparedToCexTitle": "Compared to CEX", + "comparedToCexInfo": "Markets data (prices, charts, etc.) marked with this icon originates from third party sources.", + "makeOrder": "Make Order", + "nothingFound": "Nothing found", + "half": "Half", + "max": "Max", + "reactivating": "Reactivating", + "weFailedCoinActivate": "We failed to activate {} :(", + "failedActivate": "Failed to activate", + "pleaseTryActivateAssets": "Please try to activate your assets again.", + "makerOrderDetails": "Maker Order details", + "cancelled": "Cancelled", + "fulfilled": "Fulfilled", + "cancelledInsufficientBalance": "Cancelled - insufficient balance", + "orderId": "Order ID", + "details": "Details", + "orderBook": "Order book", + "orderBookFailedLoadError": "Failed to load Orderbook", + "orderBookNoAsks": "No asks found", + "orderBookNoBids": "No bids found", + "orderBookEmpty": "Orderbook is empty", + "freshAddress": "Fresh address", + "userActionRequired": "User action required", + "unknown": "Unknown", + "unableToActiveCoin": "Unable to activate {}", + "feedback": "Feedback", + "selectAToken": "Select a token", + "selectToken": "Select token", + "rate": "Rate", + "totalFees": "Total fees", + "selectProtocol": "Select protocol", + "showSwapData": "Show swap data", + "importSwaps": "Import Swaps", + "changeTheme": "Change theme", + "available": "Available", + "availableForSwaps": "Available for swaps", + "swapNow": "Swap Now", + "passphrase": "Passphrase", + "enterPassphraseHiddenWalletTitle": "Enter passphrase from hidden wallet", + "enterPassphraseHiddenWalletDescription": "Passphrase is required for hidden wallet", + "skip": "Skip", + "activateToSeeFunds": "Activate to see your funds", + "allowCustomFee": "Allow custom seed", + "cancelOrder": "Cancel Order", + "version": "version", + "copyToClipboard": "Copy to clipboard", + "createdAt": "Created at", + "coin": "Coin", + "token": "Token", + "matching": "Matching", + "matched": "Matched", + "ongoing": "Ongoing", + "manageAnalytics": "Manage analytics", + "logs": "Logs", + "resetActivatedCoinsTitle": "Reset Activated Coins", + "seedConfirmTitle": "Let's double check your seed phrase", + "seedConfirmDescription": "Your seed phrase is the only way to access Your funds. That's why we want to ensure you saved it safely. Please input your seed phrase into text filed below.", + "standardWallet": "Standard wallet", + "noPassphrase": "No passphrase", + "passphraseRequired": "Passphrase is required", + "hiddenWallet": "Hidden wallet", + "accessHiddenWallet": "Access hidden wallet", + "passphraseIsEmpty": "Passphrase is empty", + "selectWalletType": "Select wallet type", + "trezorNoAddresses": "Please generate an address", + "trezorImportFailed": "Failed to import {}", + "faucetFailureTitle": "Failure", + "faucetLoadingTitle": "Loading...", + "faucetInitialTitle": "Starting...", + "faucetUnknownErrorMessage": "Service is unavailable. We bring you our apologies. Please try again later", + "faucetLinkToTransaction": "Link to the transaction", + "nfts": "NFTs", + "nft": "NFT", + "blockchain": "Blockchain", + "nItems": "{} items", + "nNetworks": "{} networks", + "receiveNft": "Receive NFT", + "yourCollectibles": "Your collectibles", + "transactionsHistory": "Transactions history", + "transactionsEmptyTitle": "No transactions", + "transactionsEmptyDescription": "Try to send or receive the first NFT to this wallet", + "transactionsNoLoginCAT": "There's nothing here yet, please connect your wallet", + "loadingError": "Loading Error", + "tryAgainButton": "Try again", + "contractAddress": "Contract address", + "tokenID": "Token ID", + "tokenStandard": "Token standard", + "tokensAmount": "Tokens amount", + "noCollectibles": "No NFTs found.", + "tryReceiveNft": "Make sure {} is enabled, then refresh the list to scan for your NFTs", + "networkFee": "Network fee", + "titleUnknown": "Title unknown", + "maxCount": "Max {}", + "minCount": "Min {}", + "successfullySent": "Successfully sent", + "transactionId": "Transaction ID", + "transactionFee": "Transaction Fee", + "collectibles": "Collectibles", + "sendingProcess": "Sending process", + "ercStandardDisclaimer": "Send only ERC721 and ERC1155 standard tokens on this address", + "nftMainLoggedOut": "There's nothing here yet, please connect your wallet", + "confirmLogoutOnAnotherTab": "You are already logged in to this wallet. Do you want to log out from another session?", + "refreshList": "Refresh {} NFT list", + "unableRetrieveNftData": "Waiting for {} NFT data sync", + "tryCheckInternetConnection": "Try checking your internet connection", + "resetWalletTitle": "Reset Wallet", + "resetWalletContent": "Do you want to reset activated coins of {} wallet?\n\nResetting the activated coins will revert your wallet to its initial state. You will need to re-enable the coins to include them in your wallet's balance and trade them.", + "resetCompleteTitle": "Reset Complete", + "resetCompleteContent": "Wallet {} has been reset successfully.", + "noWalletsAvailable": "No wallets available", + "selectWalletToReset": "Select a wallet to reset", + "qrScannerTitle": "QR Scanner", + "qrScannerErrorControllerUninitialized": "The controller was used before being initialized", + "qrScannerErrorPermissionDenied": "Camera permission denied", + "qrScannerErrorGenericError": "An error occurred", + "qrScannerErrorTitle": "ERROR", + "spend": "Spend", + "viewInvoice": "View Invoice", + "systemTimeWarning": "System Time Incorrect: Your system time is more than 60 seconds off from the network time. Please update your system time before starting any swaps.", + "errorCode": "Error code", + "errorDetails": "Error details", + "errorMessage": "Error message", + "followTrezorInstructions": "Follow the instructions on your Trezor screen", + "orderFailedTryAgain": "Order failed, please try again later or contact support", + "noOptionsToPurchase": "No options to purchase {} with {}. Please try a different pair.", + "youReceive": "You Receive", + "selectFiat": "Select fiat", + "selectCoin": "Select coin", + "bestOffer": "Best offer", + "komodoWallet": "Komodo Wallet", + "loadingNfts": "Thank you for your patience! We're currently loading your NFTs and carefully running them through our spam protection system to ensure a safe and smooth experience for you.", + "coinAssets": "Coin Assets", + "bundled": "Bundled", + "updated": "Updated", + "notUpdated": "Not Updated", + "api": "API", + "floodLogs": "Flood Logs", + "addressNotFound": "Address not found", + "enterAmount": "Enter amount", + "submitting": "Submitting", + "buyNow": "Buy Now", + "fiatCantCompleteOrder": "Cannot complete order. Please try again later or contact support", + "fiatPriceCanChange": "Price is subject to change depending on the selected provider", + "fiatConnectWallet": "Please connect your wallet to purchase coins", + "pleaseWait": "Please wait", + "bitrefillPaymentSuccessfull": "Bitrefill payment succssfull", + "bitrefillPaymentSuccessfullInstruction": "You should receive an email from Bitrefill shortly.\n\nPlease check your email for further information.\n\nInvoice ID: {}\n", + "tradingBot": "Trading Bot", + "margin": "Margin", + "updateInterval": "Update interval", + "expertMode": "Expert mode", + "enableTradingBot": "Enable Trading Bot", + "makeMarket": "Make Market", + "custom": "Custom", + "edit": "Edit", + "offer": "Offer", + "asking": "Asking", + "mmBotRestart": "Restart Bot", + "mmBotStart": "Start Bot", + "mmBotStop": "Stop Bot", + "mmBotStatusRunning": "Bot Status: Active", + "mmBotStatusStopped": "Bot Status: Stopped", + "mmBotStatusStarting": "Bot Status: Starting", + "mmBotStatusStopping": "Bot Status: Stopping", + "mmBotTradeVolumeRequired": "Trade volume is required", + "postitiveNumberRequired": "Positive number is required", + "mustBeLessThan": "Must be less than {}", + "mmBotMinimumTradeVolume": "Minimum trade volume is {}", + "mmBotVolumePerTrade": "Volume of available {} balance used per trade", + "mmBotFirstTradePreview": "Preview of the first order", + "mmBotFirstTradeEstimate": "First trade estimate", + "mmBotFirstOrderVolume": "This is an estimate of the first order only. Following orders will be placed automatically using the configured volume of the available {} balance.", + "important": "Important", + "trend": "Trend", + "growth": "Growth", + "portfolioGrowth": "Portfolio Growth", + "performance": "Performance", + "portfolioPerformance": "Portfolio Performance", + "allTimeInvestment": "All-time Investment", + "allTimeProfit": "All-time Profit", + "profitAndLoss": "Profit & Loss" +} \ No newline at end of file diff --git a/assets/ui_icons/account.svg b/assets/ui_icons/account.svg new file mode 100644 index 0000000000..0d49bcabdc --- /dev/null +++ b/assets/ui_icons/account.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui_icons/arrows.svg b/assets/ui_icons/arrows.svg new file mode 100644 index 0000000000..2c85f864fe --- /dev/null +++ b/assets/ui_icons/arrows.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui_icons/atomic_dex.svg b/assets/ui_icons/atomic_dex.svg new file mode 100644 index 0000000000..b6f367039d --- /dev/null +++ b/assets/ui_icons/atomic_dex.svg @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/assets/ui_icons/bar_chart.svg b/assets/ui_icons/bar_chart.svg new file mode 100644 index 0000000000..cd67970a5c --- /dev/null +++ b/assets/ui_icons/bar_chart.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui_icons/datepicker.png b/assets/ui_icons/datepicker.png new file mode 100644 index 0000000000..039b3426b1 Binary files /dev/null and b/assets/ui_icons/datepicker.png differ diff --git a/assets/ui_icons/error.svg b/assets/ui_icons/error.svg new file mode 100644 index 0000000000..0cbde1c2d3 --- /dev/null +++ b/assets/ui_icons/error.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ui_icons/eyes.svg b/assets/ui_icons/eyes.svg new file mode 100644 index 0000000000..64eb1defdb --- /dev/null +++ b/assets/ui_icons/eyes.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/ui_icons/feedback.svg b/assets/ui_icons/feedback.svg new file mode 100644 index 0000000000..6a2374f399 --- /dev/null +++ b/assets/ui_icons/feedback.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/assets/ui_icons/filters.svg b/assets/ui_icons/filters.svg new file mode 100644 index 0000000000..c2bdd6e691 --- /dev/null +++ b/assets/ui_icons/filters.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/assets/ui_icons/folder_with_atom.png b/assets/ui_icons/folder_with_atom.png new file mode 100644 index 0000000000..425b70a02c Binary files /dev/null and b/assets/ui_icons/folder_with_atom.png differ diff --git a/assets/ui_icons/folder_without_atom.png b/assets/ui_icons/folder_without_atom.png new file mode 100644 index 0000000000..6d267074b0 Binary files /dev/null and b/assets/ui_icons/folder_without_atom.png differ diff --git a/assets/ui_icons/hardware_wallet.svg b/assets/ui_icons/hardware_wallet.svg new file mode 100644 index 0000000000..97fc589230 --- /dev/null +++ b/assets/ui_icons/hardware_wallet.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/ui_icons/hardware_wallet_dark.svg b/assets/ui_icons/hardware_wallet_dark.svg new file mode 100644 index 0000000000..1a30c60ca4 --- /dev/null +++ b/assets/ui_icons/hardware_wallet_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/ui_icons/hw_wallet_icon.svg b/assets/ui_icons/hw_wallet_icon.svg new file mode 100644 index 0000000000..84bd6ffde1 --- /dev/null +++ b/assets/ui_icons/hw_wallet_icon.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/assets/ui_icons/hw_wallet_icon_light.svg b/assets/ui_icons/hw_wallet_icon_light.svg new file mode 100644 index 0000000000..bac517f46d --- /dev/null +++ b/assets/ui_icons/hw_wallet_icon_light.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/ui_icons/keplr.svg b/assets/ui_icons/keplr.svg new file mode 100644 index 0000000000..1a69d0b96c --- /dev/null +++ b/assets/ui_icons/keplr.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui_icons/metamask.svg b/assets/ui_icons/metamask.svg new file mode 100644 index 0000000000..3e4b844347 --- /dev/null +++ b/assets/ui_icons/metamask.svg @@ -0,0 +1,60 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/ui_icons/rewards.svg b/assets/ui_icons/rewards.svg new file mode 100644 index 0000000000..e734adedb6 --- /dev/null +++ b/assets/ui_icons/rewards.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/ui_icons/seed_backed_up.svg b/assets/ui_icons/seed_backed_up.svg new file mode 100644 index 0000000000..d7f1d78c93 --- /dev/null +++ b/assets/ui_icons/seed_backed_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui_icons/seed_not_backed_up.svg b/assets/ui_icons/seed_not_backed_up.svg new file mode 100644 index 0000000000..a289c068e7 --- /dev/null +++ b/assets/ui_icons/seed_not_backed_up.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/assets/ui_icons/segwit_dark.svg b/assets/ui_icons/segwit_dark.svg new file mode 100644 index 0000000000..1047cf1a99 --- /dev/null +++ b/assets/ui_icons/segwit_dark.svg @@ -0,0 +1,54 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + diff --git a/assets/ui_icons/segwit_light.svg b/assets/ui_icons/segwit_light.svg new file mode 100644 index 0000000000..667bf4e46d --- /dev/null +++ b/assets/ui_icons/segwit_light.svg @@ -0,0 +1,54 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + + + + diff --git a/assets/ui_icons/success.svg b/assets/ui_icons/success.svg new file mode 100644 index 0000000000..0e51dc7dac --- /dev/null +++ b/assets/ui_icons/success.svg @@ -0,0 +1,4 @@ + + + + diff --git a/assets/ui_icons/success_swap.svg b/assets/ui_icons/success_swap.svg new file mode 100644 index 0000000000..181a2f4dd2 --- /dev/null +++ b/assets/ui_icons/success_swap.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/ui_icons/two_komodians.png b/assets/ui_icons/two_komodians.png new file mode 100644 index 0000000000..7a1a793dd3 Binary files /dev/null and b/assets/ui_icons/two_komodians.png differ diff --git a/assets/web_pages/bitrefill_widget.html b/assets/web_pages/bitrefill_widget.html new file mode 100644 index 0000000000..fec0784344 --- /dev/null +++ b/assets/web_pages/bitrefill_widget.html @@ -0,0 +1,49 @@ + + + + + Bitrefill + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/web_pages/bitrefill_widget.js b/assets/web_pages/bitrefill_widget.js new file mode 100644 index 0000000000..66905186b4 --- /dev/null +++ b/assets/web_pages/bitrefill_widget.js @@ -0,0 +1,116 @@ +const url = getBitrefillUrlFromParameters(); +document.getElementById('bitrefill-iframe').src = url; +window.onmessage = onBitrefillMessage; + +/** + * Get the Bitrefill widget URL from the parameters in the URL + * + * @returns {string} Bitrefill widget URL + */ +function getBitrefillUrlFromParameters() { + // Extract parameters from the URL + const urlParams = new URLSearchParams(window.location.search); + + const ref = urlParams.get('ref'); + const theme = urlParams.get('theme') || 'dark'; + const language = urlParams.get('language') || 'en'; + const company_name = urlParams.get('company_name') || 'Komodo Wallet'; + + /* Optional parameters */ + const payment_methods = urlParams.get('payment_methods'); + const refund_address = urlParams.get('refund_address'); + // Enable showPaymentInfo to display the recipient address, amount and QR code in the widget + // This is useful for the user to verify the payment details before making the payment + // This is disabled by default to reduce the amount of information displayed to the user + // and to avoid any confusion with the payment process + const show_payment_info = urlParams.get('show_payment_info') || 'false'; + + // Use the parameters to set the iframe's src + let bitrefillUrl = `https://embed.bitrefill.com/?ref=${ref} + &theme=${theme}&language=${language}&companyName=${company_name} + &showPaymentInfo=${show_payment_info}`; + + if (payment_methods) { + console.log(payment_methods); + bitrefillUrl += `&paymentMethods=${payment_methods}`; + } + if (refund_address) { + bitrefillUrl += `&refundAddress=${refund_address}`; + } + + return bitrefillUrl; +} + +/** + * Handle messages from Bitrefill widget iframe. + * Send payment and invoice events to the parent window. + * Show a banner to the user when the payment is complete. + * + * @param {MessageEvent} bitrefillEvent + */ +function onBitrefillMessage(bitrefillEvent) { + const data = JSON.parse(bitrefillEvent.data); + const strData = JSON.stringify(data); + const { + event, + invoiceId, + paymentUri + } = data; + + switch (event) { + case 'invoice_created': + postMessageToParent(strData); + showEmailWarningBanner(); + break; + case 'payment_intent': + postMessageToParent(strData); + returnToWallet(); + break; + default: + break; + } +} + +/** + * Post a message to the parent window + * + * @param {string} message + */ +function postMessageToParent(message) { + // flutter_inappwebview + console.log(message); + + // universal_url opener + if (window.opener) { + return window.opener.postMessage(message, "*"); + } + + // desktop_webview_window - https://github.com/MixinNetwork/flutter-plugins/blob/main/packages/desktop_webview_window/example/test_web_pages/test.html + if (window.webkit) { + return window.webkit.messageHandlers.test.postMessage(message); + } + + // Windows WebView2 (desktop_webview_window) - https://learn.microsoft.com/en-us/microsoft-edge/webview2/how-to/communicate-btwn-web-native + if (window.chrome && window.chrome.webview) { + return window.chrome.webview.postMessage(message); + } + + console.error('No valid postMessage target found'); +} + +/** + * Close the widget and show a banner to the user to navigate back to the wallet + */ +function returnToWallet() { + // window.close(); + + // In some cases the window doesn't close (i.e. Desktop platforms) + // In that case, we show a banner to the user to navigate back to the wallet + document.getElementById('bitrefill-payment-banner').style.display = 'block'; + document.getElementById('bitrefill-email-banner').style.display = 'none'; +} + +function showEmailWarningBanner() { + document.getElementById('bitrefill-email-banner').style.display = 'block'; + document.getElementById('bitrefill-payment-banner').style.display = 'none'; +} \ No newline at end of file diff --git a/assets/web_pages/checkout_status_redirect.html b/assets/web_pages/checkout_status_redirect.html new file mode 100644 index 0000000000..d9aa2631c1 --- /dev/null +++ b/assets/web_pages/checkout_status_redirect.html @@ -0,0 +1,48 @@ + + + + Komodo Payment Redirect + + + + + \ No newline at end of file diff --git a/deploy_host.yml b/deploy_host.yml new file mode 100644 index 0000000000..b098d11544 --- /dev/null +++ b/deploy_host.yml @@ -0,0 +1,62 @@ +--- +all: + children: + airdex: + children: + demo_node: + hosts: "node.dragonhound.info" + vars: + ansible_user: gitci + allowed_ip: "95.216.214.144" + allowed_ports: + - 1000 + - 1001 + - 1002 + - 1003 + - 1004 + - 1005 + - 1006 + - 1007 + - 1008 + - 1009 + - 1010 + - 1011 + - 1012 + - 1013 + - 1014 + - 1015 + - 1016 + - 1017 + - 1018 + - 1019 + - 1020 + - 1021 + - 1022 + - 1023 + - 1024 + - 1025 + - 1026 + - 1027 + - 1028 + - 1029 + - 1030 + - 1031 + - 1032 + - 1033 + - 1034 + - 1035 + - 1036 + - 1037 + - 1038 + - 1039 + - 1040 + - 1041 + - 1042 + - 1043 + - 1044 + - 1045 + - 1046 + - 1047 + - 1048 + - 1049 + - 1050 diff --git a/docs/BUILD_CONFIG.md b/docs/BUILD_CONFIG.md new file mode 100644 index 0000000000..effac03839 --- /dev/null +++ b/docs/BUILD_CONFIG.md @@ -0,0 +1,77 @@ +# Build Config + +## Overview + +Coin configs and asset files are automatically downloaded as part of the flutter build pipeline, based on the settings configured in [build_config.json](/app_build/build_config.json). + +There are two sections of note in [build_config.json](/app_build/build_config.json), `api` and `coins`. + +- `api` contains the configuration for fetching the API binaries, and the checksums used to validate the downloaded files. +- `coins` contains the configuration for fetching the coin assets, including where to download the files to and where to download the files from. + +The config is read by the build step for every invocation of `flutter run` or `flutter build`, so no further actions are required to update the coin assets or API binaries. + +NOTE: The build step will fail on the first run if the coin assets are not present in the specified folders. Run the same command again and the build should succeed. + +## API + +The build step will use the settings in [build_config.json](/app_build/build_config.json) to download the API binaries from the specified URLs and validate the checksums. + +By default, the build step will + +- Download the API binaries from the commit in the branch of the API repository specified in `build_config.json`. +- Skip downloading the API binaries for a platform if the file already exists and the checksum is valid. +- Fail the build if no download `source_urls` returned a file with a valid checksum. + +### Configuration + +In [build_config.json](/app_build/build_config.json) update `api_commit_hash` and `branch` to the latest commit hash in the desired branch name. Use the `fetch_at_build_enabled` parameter to dictate whether the API binary downloads and checksum validation should run for every build. Example: + +```json + "api": { + "api_commit_hash": "f956070bc4c33723f753ed6ecaf2dc32a6f44972", + "branch": "master", + "fetch_at_build_enabled": true, + ... + } +``` + +## Coins + +The build step will check [build_config.json](/app_build/build_config.json) for the [coins](https://github.com/KomodoPlatform/coins) repository GitHub API URL, branch and commit hash, and it will then download the mapped files and folders from the repository to the specified local files and folders. + +By default, the build step will + +- Download the coin assets from the latest commit in the branch of the coins repository specified in `build_config.json` +- Skip assets if the file already exists. + +### Configuration + +In [build_config.json](/app_build/build_config.json) update `bundled_coins_repo_commit` and `coins_repo_branch` to the latest commit hash in the desired branch name. Example: + +```json + "coins": { + "bundled_coins_repo_commit": "f956070bc4c33723f753ed6ecaf2dc32a6f44972", + "coins_repo_branch": "master", + ... + } +``` + +To update or add to the files and folders synced from the [coins](https://github.com/KomodoPlatform/coins), modify the `mapped_files` and `mapped_folders` sections respectively. Example: + +NOTE: The coins repository path on the right should be relative to the root of the coins repository. The local path, on the left, should be relative to the root of this repository. + +```json +{ + "coins": { + ..., + "mapped_files": { + "assets/config/coins_config.json": "utils/coins_config.json", + "assets/config/coins.json": "coins" + }, + "mapped_folders": { + "assets/coin_icons/png/": "icons" + } + } +} +``` diff --git a/docs/BUILD_RELEASE.md b/docs/BUILD_RELEASE.md new file mode 100644 index 0000000000..b5fc3401ff --- /dev/null +++ b/docs/BUILD_RELEASE.md @@ -0,0 +1,72 @@ +# Build Release version of the App + +### Environment setup + +Before building the app, make sure you have all the necessary tools installed. Follow the instructions in the [Environment Setup](./PROJECT_SETUP.md) document. Alternatively, you can use the Docker image as described here: (TODO!). + +### Firebase Analytics Setup + +Optionally, you can enable Firebase Analytics for the app. To do so, follow the instructions in the [Firebase Analytics Setup](./FIREBASE_SETUP.md) document. + +## Build for Web + +```bash +flutter build web --csp --no-web-resources-cdn +``` + +The release version of the app will be located in `build/web` folder. Specifying the `--release` flag is not necessary, as it is the default behavior. + +## Native builds + +Run `flutter build {TARGET}` command with one of the following targets: + +- `apk` - builds Android APK (output to `build/app/outputs/flutter-apk` folder) +- `appbundle` - builds Android bundle (output to `build/app/outputs/bundle/release` folder) +- `ios` - builds for iOS (output to `build/ios/iphoneos` folder) +- `macos` - builds for macOS (output to `build/macos/Build/Products/Release` folder) +- `linux` - builds for Linux (output to `build/linux/x64/release/bundle` folder) +- `windows` - builds for Windows (output to `build/windows/runner/Release` folder) + +Example: + +```bash +flutter build apk +``` + +## Docker builds + +### Build for Web + +```bash +sh .docker/build.sh web release +``` + +Alternatively, you can run the docker build commands directly: + +```bash +# Build the supporting images +docker build -f .docker/kdf-android.dockerfile . -t komodo/kdf-android --build-arg KDF_BRANCH=main +docker build -f .docker/android-sdk.dockerfile . -t komodo/android-sdk:34 +docker build -f .docker/komodo-wallet-android.dockerfile . -t komodo/komodo-wallet +# Build the app +mkdir -p build +docker run --rm -v ./build:/app/build komodo/komodo-wallet:latest bash -c "flutter pub get && flutter build web --release || flutter build web --release" +``` + +### Build for Android + +```bash +sh .docker/build.sh android release +``` + +Alternatively, you can run the docker build commands directly: + +```bash +# Build the supporting images +docker build -f .docker/kdf-android.dockerfile . -t komodo/kdf-android --build-arg KDF_BRANCH=main +docker build -f .docker/android-sdk.dockerfile . -t komodo/android-sdk:34 +docker build -f .docker/komodo-wallet-android.dockerfile . -t komodo/komodo-wallet +# Build the app +mkdir -p build +docker run --rm -v ./build:/app/build komodo/komodo-wallet:latest bash -c "flutter pub get && flutter build apk --release || flutter build apk --release" +``` diff --git a/docs/BUILD_RUN_APP.md b/docs/BUILD_RUN_APP.md new file mode 100644 index 0000000000..7a3cbdb468 --- /dev/null +++ b/docs/BUILD_RUN_APP.md @@ -0,0 +1,260 @@ +# Build and run the App + +Before proceeding, make sure that you have set up a working environment for your host platform according to the [guide](PROJECT_SETUP.md#host-platform-setup). + +There are two main commands, `flutter run` to run the app or `flutter build` to build for the specified platform. + +When using `flutter run` you can specify the mode in which the app will run. By default, it uses the debug mode. + +```bash +# debug mode: +flutter run +# release mode: +flutter run --release +# profile mode: +flutter run --profile +``` + +To build the app, use `flutter build` followed by the build target. + +As an example, for the mobile platforms: + +```bash +# Android APK: +flutter build apk +# Android app bundle: +flutter build appbundle +# iOS IPA: +flutter build ios +``` + +---- + +## Target platforms + +### Web + +Google Chrome is required, and the `chrome` binary must be accessible by the flutter command (e.g. via the system path) + +```bash +flutter clean +flutter pub get +``` + +Run in debug mode: + +```bash +flutter run -d chrome +``` + +Run in release mode: + +```bash +flutter run -d chrome --release +``` + +Running on web-server (useful for testing/debugging in different browsers): + +```bash +flutter run -d web-server --web-port=8080 +``` + +## Desktop + +#### macOS desktop + +In order to build for macOS, you need to use a macOS host. + +Before you begin: + + 1. Open `macos/Runner.xcworkspace` in XCode + 2. Set Product -> Destination -> Destination Architectures to 'Show Both' + +```bash +flutter clean +flutter pub get +``` + +Debug mode + +```bash +flutter run -d macos +``` + +If you encounter build errors, try to follow any instructions in the error message. +In many cases, simply running the app from XCode before trying `flutter run -d macos` again will resolve the error. + +- Open `macos/Runner.xcworkspace` in XCode +- Product -> Run + +Release mode + +```bash +flutter run -d macos --release +``` + +Build + +```bash +flutter build macos +``` + +#### Windows desktop + +In order to build for Windows, you need to use a Windows host. + +Run `flutter config --enable-windows-desktop` to enable Windows desktop support. + +If you are using Windows 10, please ensure that [Microsoft WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2?form=MA13LH) is installed for Webview support. Windows 11 ships with it, but Windows 10 users might need to install it. + +Before building for Windows, run `flutter doctor` to check if all the dependencies are installed. If not, follow the instructions in the error message. + +```bash +flutter doctor +``` + +```bash +flutter clean +flutter pub get +``` + +Debug mode + +```bash +flutter run -d windows +``` + +Release mode + +```bash +flutter run -d windows --release +``` + +Build + +```bash +flutter build windows +``` + +#### Linux desktop + +In order to build for Linux, you need to use a Linux host with support for [libwebkit2gtk-4.1](https://packages.ubuntu.com/search?keywords=webkit2gtk), i.e. Ubuntu 22.04 (jammy) or later. + +Run `flutter config --enable-linux-desktop` to enable Linux desktop support. + +Before building for Linux, run `flutter doctor` to check if all the dependencies are installed. If not, follow the instructions in the error message. + +```bash +flutter doctor +``` + +The Linux dependencies, [according to flutter.dev](https://docs.flutter.dev/get-started/install/linux#additional-linux-requirements) are as follow: + +> For Linux desktop development, you need the following in addition to the Flutter SDK: +> +> - Clang +> - CMake +> - GTK development headers +> - Ninja build +> - pkg-config +> - liblzma-dev (This might be necessary) +> - libstdc++-12-dev +> - webkit2gtk-4.1 (Webview support) + +To install on Ubuntu 22.04 or later, run: + +```bash +sudo apt-get install clang cmake ninja-build pkg-config libgtk-3-dev liblzma-dev libstdc++-12-dev webkit2gtk-4.1 +``` + +```bash +flutter clean +flutter pub get +``` + +Debug mode + +```bash +flutter run -d linux +``` + +Release mode + +```bash +flutter run -d linux --release +``` + +Build + +```bash +flutter build linux +``` + +### Mobile + +Building an app for Android and iOS requires you to download their respective IDEs and enable developer mode to build directly to the device. + +However, iOS tooling only works on macOS host. + +#### Android + +For Android, after installing the IDE and initial tools using the setup wizard, run the app with `flutter run`. +Flutter will attempt to build the app, and any missing Android SDK dependency will be downloaded. + +Running the app on an Android emulator has been tested on Apple Silicon Macs only; for other host platforms, a physical device might be required. + +1. `flutter clean` +2. `flutter pub get` +3. Activate developer mode and USB debugging on your device +4. Connect your device to your computer with a USB cable +5. Ensure Flutter is aware of your device by running `flutter devices` +6. Copy your device ID +7. Run in debug mode with `flutter run -d ` +8. Follow instructions on your device + +Release mode: + +``` +flutter run -d --release +``` + +Build APK: + +``` +flutter build apk +``` + +Build App Bundle: + +``` +flutter build appbundle +``` + +#### iOS + +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. + +1. `flutter clean` +2. `flutter pub get` +3. Connect your device to your Mac with a USB cable +4. Ensure Flutter is aware of your device by running `flutter devices` +5. Copy your device ID +6. Run in debug mode with `flutter run -d ` +7. Follow the instructions in the error message (if any) + In many cases it's worth trying to run the app from XCode first, then run `flutter run -d ` again + - Open `ios/Runner.xcworkspace` in XCode + - Product -> Run +8. Follow the instructions on your device to trust the developer + +Run in release mode: + +``` +flutter run -d --release +``` + +Build: + +``` +flutter build ios +``` diff --git a/docs/CLONE_REPOSITORY.md b/docs/CLONE_REPOSITORY.md new file mode 100644 index 0000000000..7f41737ad6 --- /dev/null +++ b/docs/CLONE_REPOSITORY.md @@ -0,0 +1,21 @@ +# Clone repository + +There are two options, cloning via HTTPS or via SSH. HTTPS is recommended. + +If using HTTPS, run +``` +git clone https://github.com/KomodoPlatform/komodowallet.git +``` +Alternatively, instruct the IDE to clone: `https://github.com/KomodoPlatform/komodowallet.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). + + +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: + +``` +cd ~ +git clone git@github.com:KomodoPlatform/komodowallet.git +``` \ No newline at end of file diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000..88c216746b --- /dev/null +++ b/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,34 @@ +# Code of Conduct + +## Our Values + - Creating a welcoming and inclusive environment for all participants + - Treating each other with respect and dignity + - Resolving conflicts in a constructive and respectful manner + - Maintaining a high standard of professionalism and ethics + + +## Expected Behavior + - Be respectful and professional in all communications and interactions + - Refrain from discriminatory or harassing behavior, including but not limited to: discrimination on the basis of race, ethnicity, gender identity, sexual orientation, age, religion, disability, or any other protected characteristic + - Listen to others with an open mind and be willing to learn from diverse perspectives and experiences + - Be collaborative and constructive in your contributions + - Respect the intellectual property rights of others + - Protect the privacy and confidentiality of others + + +## Unacceptable Behavior + - Discrimination, harassment, or bullying of any kind + - Offensive or derogatory comments, language, or images + - Intimidation or threats + - Physical or verbal abuse + - Unauthorized access or use of confidential or sensitive information + - Any other behavior that creates an intimidating, hostile, or offensive environment for others + +## Reporting +If you witness or experience any behavior that violates this code of conduct, please report it immediately to yurii-khi@komodoplatform.com. All reports will be taken seriously and will be handled in a confidential and respectful manner. + +## Consequences +Participants who violate this code of conduct may face consequences, up to and including termination of their involvement in the project, as deemed appropriate by the project leaders. We reserve the right to remove any content, comments, or contributions that violate this code of conduct. + +## Conclusion +We believe that our community is stronger when everyone feels safe, respected, and valued. We are committed to creating a positive and inclusive environment for all participants, and we ask that everyone in our community help us to achieve this goal. \ No newline at end of file diff --git a/docs/CONTRIBUTION_GUIDE.md b/docs/CONTRIBUTION_GUIDE.md new file mode 100644 index 0000000000..ea590e301a --- /dev/null +++ b/docs/CONTRIBUTION_GUIDE.md @@ -0,0 +1,65 @@ +# Contribution guide + +## 1. Get to know the project + - Read the [README](/README.md) file + - Read the [Code of Conduct](CODE_OF_CONDUCT.md) file + - Read the [Contribution guide](CONTRIBUTION_GUIDE.md) (this file) + + +## 2. Set up your local environment + - Read project [Setup guide](PROJECT_SETUP.md) + +## 3. Find an issue to work on + - Check the issues, assigned to you on this page: https://github.com/issues/assigned + - Pay attention to the priority labels, the higher the priority, the more important the issue is + +## 4. Create a new branch + - Create a new local branch from the `dev` branch (`master` for hotfixes), name it according to [branch naming conventions](GITFLOW_BRANCHING.md#branch-naming-conventions) + +## 5. Work on the issue + - Read the issue description and comments + - Make sure that you understand the issue, requirements, security details, etc. + - Move related issue to the "In progress" column on the project board + - Be sure to follow the project's coding style and best practices + - Commit your changes in small, logical units with [clear and descriptive commit messages](https://cbea.ms/git-commit/) + - Upload your work branch to the remote, even if it's not finished yet. Update it with new commits as you work on the issue + +## 6. Before creating or updating a PR (checklist) + - [ ] Sync your work branch with the latest changes from the target branch (`dev` or `master`), resolve merge conflicts if any + - [ ] (Re)read original issue and comments, make sure that changes are solving the issue or adding the feature + - [ ] Run [integration tests](INTEGRATION_TESTING.md) locally + - [ ] Consider adding integration tests for your changes + - [ ] Test your changes manually + - [ ] Desktop/mobile view + - [ ] Dark/light mode + - [ ] Different browsers (for web build): Chrome, Firefox, Safari, Edge, Brave + - [ ] Different build modes: debug, release, profile + - [ ] Make sure that `flutter analyze` and `flutter format` are passing + +## 7. Create a PR (checklist) + - [ ] Sync your work branch with the latest changes from the target branch (again :), push it to the remote + - [ ] Make sure that you're opening a PR from your work branch to the proper target branch (`dev` or `master`) + - [ ] Provide a clear and concise title for your PR + - [ ] Avoid using generic titles like "Fix" or "Update" + - [ ] Avoid using the issue number in the title + - [ ] Use the imperative mood in the title (e.g. "Fix bug" and not "Fixed bug") + - [ ] Describe the changes you've made and how they address the issue or feature request + - [ ] Reference any related issues using the appropriate keywords (e.g., "Closes #123" or "Fixes #456") + - [ ] Provide test instructions if applicable to help QA engineers test your changes + - [ ] Request a code review from one or more maintainers or other contributors + - [ ] Move related issue to the "Review" column on the project board + - [ ] After code review is done, request testing from QA team + - [ ] Move related issue to the "Testing" column on the project board + - [ ] When QA team approves the changes, merge the PR, move related issue to the "Done" column on the project board + +## 8. Maintain your PR + - Once your PR is created, you should maintain it until it's merged + - Check the PR on daily basis for comments, changes requests, questions, etc. + - Address any comments or questions from the code review, or from QA testing + - Make sure that your PR is up to date with the target branch (`dev` or `master`), resolve merge conflicts proactively + - After merging, delete your work branch + +## 9. 🎉 Celebrate! + - Congratulations! You've just contributed to the project! + - Thank you for your time and effort! + \ No newline at end of file diff --git a/docs/FIREBASE_SETUP.md b/docs/FIREBASE_SETUP.md new file mode 100644 index 0000000000..c7ce656342 --- /dev/null +++ b/docs/FIREBASE_SETUP.md @@ -0,0 +1,17 @@ +# Firebase setup (local builds) + +To generate the configuration files for Firebase, follow the steps below: +- Create a Firebase account and add a new project. +- Add a new web app to the project. + +- Install firebase CLI: `curl -sL https://firebase.tools | bash` +- Install flutterfire CLI: `dart pub global activate flutterfire_cli` +- Login to Firebase: `firebase login` +- Generate config files: `flutterfire configure` +- Disable github tracking of config files: +``` +git update-index --assume-unchanged android/app/google-services.json +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 +``` diff --git a/docs/FLUTTER_VERSION.md b/docs/FLUTTER_VERSION.md new file mode 100644 index 0000000000..dd84b43b4a --- /dev/null +++ b/docs/FLUTTER_VERSION.md @@ -0,0 +1,17 @@ +# Flutter version + +This project aims to keep the Flutter version up-to-date with the latest stable release. See the section below for the latest version officially supported by this project. + +## Current version + +3.22.3 + +## Pin Flutter version + +``` +cd ~/flutter +git checkout 3.22.3 +flutter doctor +``` + +See also: [Multiple flutter versions](MULTIPLE_FLUTTER_VERSIONS.md) diff --git a/docs/GITFLOW_BRANCHING.md b/docs/GITFLOW_BRANCHING.md new file mode 100644 index 0000000000..3970ac2de8 --- /dev/null +++ b/docs/GITFLOW_BRANCHING.md @@ -0,0 +1,55 @@ +# Gitflow and branching strategy + +1. `dev` branch is created from `master` +2. A release branch is created from `dev` just before release (e.g. `release-0.4.2`) +3. Feature branches are created from `dev` +4. When a feature is complete it is merged into the `dev` branch +5. When the release branch is done it is merged into `master` and `dev` +6. If an issue in `master` is detected a hotfix branch is created from `master` +7. Once the hotfix is complete it is merged to both `dev` and `master` + +[More...](https://www.atlassian.com/git/tutorials/comparing-workflows/gitflow-workflow) + +## Branch naming conventions + ### 1. Use short, clear, descriptive name + + | ❌ Bad | ✅ Good | + | ------------------------------------ | --------------------- | + | `patch-002` | `fix-logout-crash` | + | `build-for-ios-macos-universal_html` | `test-universal-html` | + + ### 2. Use prefixes to indicate branch type + - Feature branch (adding, improving, refactoring, or removing a feature): + - `add-` + - `improve-` + - `remove-` + - Bug fix branch (regular bug fixes): + - `fix-` + - Hotfix branch (hotfixes based on `master` branch): + - `hotfix-` + - Release branch: + - `release-RELEASE.VERSION.NUMBER` (e.g. `release-0.4.2`) + - Sync branch (for resolving merging conflicts between release and dev branch after merging it to master): + - `sync-` + - Test branch (testing, temp branches, etc): + - `test-` + ### 3. Avoid special characters (`/`, `>`, https://github.com/KomodoPlatform/komodowallet/issues/907) + + | ❌ Bad | ✅ Good | + | ----------------- | ---------------- | + | `sync/0.4.2->dev` | `sync-0.4.2-dev` | + + ### 4. Prefer hyphen separator (kebab-case) over underscore (snake_case) + + | ❌ Bad | ✅ Good | + | ------------------ | ------------------ | + | `add_green_button` | `add-green-button` | + + ### 5. Avoid using issue IDs, SHA, etc. Release version is only allowed in release branches + + | ❌ Bad | ✅ Good | + | --------------------- | --------------------- | + | `dropdown-item-0.4.2` | `release-0.4.3` | + | `wasm-3f70b911b-demo` | `test-wasm-api` | + | `issue-514` | `improve-seed-import` | + \ No newline at end of file diff --git a/docs/INSTALL_FLUTTER.md b/docs/INSTALL_FLUTTER.md new file mode 100644 index 0000000000..cc308acc73 --- /dev/null +++ b/docs/INSTALL_FLUTTER.md @@ -0,0 +1,51 @@ +# Installing Flutter SDK + +Komodo Wallet requires a specific Flutter version to build and run. The required version can be seen +on [FLUTTER_VERSION.md](FLUTTER_VERSION.md). + +While it should be possible to go a few bugfixes versions over that version without issues, +it's generally intended to use that exact version. + +There are two main ways to get an older copy of Flutter. + +The first way is by cloning the official repository and then pinning to an older version. + +1. Clone Flutter with + ``` + cd ~ + git clone https://github.com/flutter/flutter.git + ``` +2. [Pin Flutter version](FLUTTER_VERSION.md#pin-flutter-version) + + +The second way is via downloading the desired version from the SDK Archives. +Here are [Windows](https://docs.flutter.dev/release/archive?tab=windows), [Mac](https://docs.flutter.dev/release/archive?tab=macos) +and [Linux](https://docs.flutter.dev/release/archive?tab=linux) download links. +Remember to extract the file into a convenient place, such as `~/flutter`. + +Choose the option that is more convenient for you at the time. + +If you opt for the SDK Archive, you easily change to use the [Pin Flutter version](FLUTTER_VERSION.md#pin-flutter-version) later if you prefer. + +Add the flutter binaries subfolder `flutter/bin` to your system PATH. This process differs for each OS: + +For macOS: + ``` + nano ~/.zshrc + export PATH="$PATH:$HOME/flutter/bin" + ``` +For Linux: + ``` + vim ~/.bashrc + export PATH="$PATH:$HOME/flutter/bin" + ``` +For Windows, follow the instructions below (from [flutter.dev](https://docs.flutter.dev/get-started/install/windows#update-your-path)):: + + - From the Start search bar, enter `env` and select **Edit environment variables for your account**. + - Under **User variables** check if there is an entry called **Path**: + - If the entry exists, append the full path to flutter\bin using ; as a separator from existing values. + - If the entry doesn't exist, create a new user variable named Path with the full path to flutter\bin as its value. + +You might need to logout and re-login (or source the shell configuration file, if applicable) to make changes apply. + +On macOS and Linux it should also be possible to confirm it's been added to the PATH correctly by running `which flutter`. diff --git a/docs/INTEGRATION_TESTING.md b/docs/INTEGRATION_TESTING.md new file mode 100644 index 0000000000..8760e8dfd0 --- /dev/null +++ b/docs/INTEGRATION_TESTING.md @@ -0,0 +1,105 @@ +# Integration Testing + +## 1. General info + +- Integration testing implemented using Flutter built-in [integration_test](https://github.com/flutter/flutter/tree/main/packages/integration_test) package. +- New tests should be added, if possible, for every new issue as a part of corresponding PR, to provide coverage for specific bug/feature. This way we'll, hopefully, expand test coverage in a natural manner. +- Coverage (and structure, if needed) should be updated in table below. + +## 2. How to run tests + +### 2.1. Web/Chrome/Safari/Firefox + +[https://github.com/flutter/flutter/wiki/Running-Flutter-Driver-tests-with-Web](https://github.com/flutter/flutter/wiki/Running-Flutter-Driver-tests-with-Web) + +#### 2.1.1. Download and unpack web drivers + ##### Chrome: + + + ##### Safari: + Configure Safari to Enable WebDriver Support. + + Safari’s WebDriver support for developers is turned off by default. + + Run once: + ```bash + safaridriver --enable + ``` + Note: If you’re upgrading from a previous macOS release, you may need to use sudo. + + ##### Firefox: + - Install and check the version of Firefox. + + - Download the Gecko driver for that version from the releases + + Note that this section is experimental, at this point we don't have automated tests running on Firefox. + +#### 2.1.2. Launch the WebDriver + - for Google Chrome + ```bash + ./chromedriver --port=4444 --silent --enable-chrome-logs --log-path=console.log + ``` + - or Firefox + ```bash + ./geckodriver --port=4444 + ``` + - or Safari + ```bash + /usr/bin/safaridriver --port=4444 + ``` +#### 2.1.3. Run test. From the root of the project, run the following command: + + ```bash + dart run_integration_tests.dart + ``` + +To see tests run scripts help message: + + ```bash + dart run_integration_tests.dart -h + ``` + +Tests script runs tests in profile mode, accepts browser dimension adjustment argument and -d (display) arg to set headless mode. (see below for details) +Or, to run single test: + +Change `/testname_test.dart` to actual test file, located in ./test_integration directory. +Currently available test groups: + - `dex_tests/dex_tests.dart` + - `wallets_manager_tests/wallets_manager_tests.dart` + - `wallets_tests/wallets_tests.dart` + - `misc_tests/misc_tests.dart` + - `no_login_tests/no_login_tests.dart` + + and run + + ```bash + dart run_integration_tests.dart -b '1600,1040' -d 'no-headless' -t 'wallets_tests/wallets_tests.dart' + ``` + + Each test in test groups can be run separately in exact same fashion. + +#### 2.1.4. To simulate different screen dimensions, you can use the --browserDimension or -b argument, -d or --display argument to configure headless run: + + ```bash + dart run_integration_tests.dart -b '360,640' + ``` + + ```bash + dart run_integration_tests.dart --browserDimension='1100,1600' + ``` + + ```bash + dart run_integration_tests.dart -b '1600,1040' -d 'headless' + ``` + +#### 2.1.5. To run tests in different browsers, you can specify the --browser-name or -n argument: + + ```bash + dart run_integration_tests.dart -n 'safari' + ``` + + ```bash + dart run_integration_tests.dart --browser-name=firefox + ``` + + By default, the Chrome browser is used to run tests \ No newline at end of file diff --git a/docs/ISSUE.md b/docs/ISSUE.md new file mode 100644 index 0000000000..da5a7a1017 --- /dev/null +++ b/docs/ISSUE.md @@ -0,0 +1,21 @@ +# Issue + +## Issue type, templates + +### Dev + +### Bug report + +### Feature request + +### Note, placeholder + +## Priorities + +`P0` label indicates critical bug, regression or failure, usually in production or in a release candidate. It means the assigned developer should **stop working on all current tasks** and focus on `P0` until it's fixed/resolved. Normally there should be zero open `P0` bugs. + +`P1` indicates crucial bug or feature, **blocking current sprint release**. No new tasks should be started until `P1` resolved. + +`P2` indicates important problem that we want to resolve as soon as possible. + +`P3` indicates a time-permitted task: minor/rare bug, non-critical request, research, documentation update, ongoing discussion etc. \ No newline at end of file diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md new file mode 100644 index 0000000000..e0e6cb7cf7 --- /dev/null +++ b/docs/LOCALIZATION.md @@ -0,0 +1,175 @@ +# Localization + +### Add new language to app (step 1) + +In `${FLUTTER_PROJECT}/assets/translations`, add the `en.json` template file. For example: + +`en.json` + +```json +{ + "helloWorld": "Hello World!" +} +``` + +`{}` is used to place arguments and `{name}` for named arguments. + +*Important: After any update of `.json` file you need to run this command for generate `LocaleKeys` file.* + +```bash +flutter pub run easy_localization:generate -S ./assets/translations -s en.json -f keys +``` + +### Step 2 + +To add a new language translation in the app you need to add an `fr.json` file in the same directory for French translation of the same message: +`fr.json` + +```json +{ + "helloWorld": "Bonjour le Monde" +} +``` + +then run this command + +```bash +flutter pub run easy_localization:generate -S ./assets/translations -s en.json -f keys +``` + +and update constants.dart file to: + +```dart +const List localeList = [Locale('en'), Locale('fr')]; +``` + +### Step 3 + +Translate text +lets suppose we start with this code + +`home.dart` + +```dart + + .. + + Widget build(BuildContext context) { + return Row( + children: [ + Text( + 'Hello World' + ), + Text( + 'Welcome' + ), + ] + ); + } + +``` + +and we want to translate `Hello World` text to french when people device localization is french language +`home.dart` + +```dart +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + + ... + + Widget build(BuildContext context) { + return Row( + children: [ + Text( + LocaleKeys.helloWorld.tr() + ), + Text( + 'Welcome' + ), + ] + ); + } +``` + +### How to translate string with variable + +How to translate sentence with the variables in it, say `10` and `KMD` in `Max amount is 10 KMD, please select other amount`? + +```dart + Text('Max amount is $amount $kmd, please select other amount') // amount & kmd is variable +``` + +Process: + +- Add declaration in json file +`en.json` + +```json +{ + "maxMount": "Max amount is {amount} {coinAbbr}, please select other amount" +} +``` + +- update your code like the following snippet + +```dart + Text(LocaleKeys.maxMount.tr(namedArgs:{amount:10, coinAbbr:'KMD'})) +``` + +or you can just use arguments like the following example + +- Add declaration in json file +`en.json` + +```json +{ + "maxMount": "Max amount is {} {}, please select other amount" +} +``` + +- update your code like the following snippet + +```dart + Text(LocaleKeys.maxMount.tr(args:[10,'KMD'])) +``` + +how to deal with Plurals? + +update the value of required key in json file like following + +`en.json` + +```json +{ + "money": { + "zero": "You have no money", + "one": "You have {} dollar", + "two": "You have {} dollars", + "many": "You have {} dollars", + "few": "You have {} dollars", + "other": "You have {} dollars" + }, + "money_args": { + "zero": "{} has no money", + "one": "{} has {} dollar", + "two": "{} has {} dollars", + "many": "{} has {} dollars", + "few": "{} has {} dollars", + "other": "{} has {} dollars" + } +} +``` + +- update your code like the following snippet + +```dart + Text(LocaleKeys.money.plural(10.23)), + Text(LocaleKeys.money_args.plural(10.23,args:['John', '10.23'])), +``` + +please note that after adding or removing any items in the translation json file, you need to run the following command + +```bash +flutter pub run easy_localization:generate -S ./assets/translations -s en.json -f keys +``` \ No newline at end of file diff --git a/docs/MANUAL_TESTING_DEBUGGING.md b/docs/MANUAL_TESTING_DEBUGGING.md new file mode 100644 index 0000000000..70d01c39bb --- /dev/null +++ b/docs/MANUAL_TESTING_DEBUGGING.md @@ -0,0 +1,128 @@ +# Manual testing and debugging + +## Debug login + +In order to simplify login during debug session `Debug login` button provided in gui (debug mode only). + +Please create `assets/debug_data.json` file with wallet credentials to use it. + +File structure example bellow: + +```json +{ + "wallet": { + "name": "wasmtest", + "password": "debugpassword", + "seed": "test seed phrase please change with your actual debug seed", + "activated_coins": ["RICK", "MORTY"], + "automateLogin": true + }, + "swaps": { + "import": [] + } +} +``` + +## Manual testing + +[Manual testing plan](https://docs.google.com/spreadsheets/d/1EiFwI00VJFj5lRm-x-ybRoV8r17EW3GnhzTBR628XjM/edit#gid=0) + +## Debugging web version on desktop + +## HTTP + +```bash +flutter run -d chrome --web-hostname=0.0.0.0 --web-port=7777 +``` + +## HTTPS + +### Generate self-signed certificate with openssl + +```bash +openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -sha256 -days 3650 -nodes -subj "/C=XX/ST=StateName/L=CityName/O=CompanyName/OU=CompanySectionName/CN=CommonNameOrHostname" +``` + +### Run flutter with self-signed certificate + +```bash +flutter run -d chrome --web-hostname=0.0.0.0 --web-port=7777 --web-tls-cert-key-path=key.pem --web-tls-cert-path=cert.pem +``` + +Or as a standalone web server for use with any browser: + +```bash +flutter run -d web-server --web-hostname=0.0.0.0 --web-port=7777 --web-tls-cert-key-path=key.pem --web-tls-cert-path=cert.pem +``` + +## Debugging web version on physical mobile devices + +Since app behavior in mobile browser on physical device may differ from its behavior in Chrome Desktop mobile emulator, sometimes it is necessary to run local app build on a physical mobile phone. + +### Mac + iPhone + +1. On your mac: + 1.2. Plug in your iPhone to Mac with cable + 1.3. Go to System Preferences -> Sharing + 1.4. Uncheck 'Internet Sharing' checkbox on the left side, if checked + 1.5. Check 'iPhone USB' checkbox on the right + 1.6. Check 'Internet Sharing' checkbox on the left again + 1.7. At the top of the window you'll see message, similar to 'Computers on your local network can access your computer at: %yourMacName%.local'. You can press 'Edit' button and change `%yourMacName%` with shorter value. + 1.8. Run `flutter run -d web-server --web-hostname 0.0.0.0 --web-port 53875` in project directory. You can use different port if needed. +2. On your iPhone: + 2.1. Open Safari + 2.2. Switch to 'Private' mode (to avoid caching) + 2.3. Enter `%yourMacName%.local:53875` in the address bar (`%yourMacName%.local` is the value from 1.7, port is from 1.8) + 2.4. You should see app running in your mobile browser + +### More platforms TBD + +## Useful for testing + + 1. Server for static files on node.js: + + ```js + const express = require('express'); + const path = require('path'); + var app = express(); + + app.use(express.static(path.join(__dirname, '/build/web'))); + app.get('/', (req, res) => { + res.sendFile(path.join(__dirname, '/build/web/index.html')); + }); + + app.listen(53875); + ``` + + 2. Change `updateCheckerEndpoint` in `lib/app_config/constants.dart` to use your custom version checker endpoint + 3. Decrease time for checking of version, see `init` method in `update_bloc.dart` + +### To create a recoverable swap + +At the time of writing used branch [gen-recoverable-swap](https://github.com/KomodoPlatform/atomicDEX-API/pull/1428) + + 1. Setup atomicDex-API, [see](https://github.com/KomodoPlatform/atomicDEX-API/tree/dev#building-from-source) + 2. Setup dev environment, [see](https://github.com/KomodoPlatform/atomicDEX-API/blob/dev/docs/DEV_ENVIRONMENT.md#running-native-tests) + 3. Run command below + + ```bash + BOB_PASSPHRASE="seedphrase1" ALICE_PASSPHRASE="seedphrase2" TAKER_FAIL_AT="taker_payment_refund" MAKER_FAIL_AT="taker_payment_spend" cargo test --package mm2_main --lib mm2::lp_swap::lp_swap_tests::gen_recoverable_swap -- --exact --ignored --nocapture + ``` + + 4. In the end of test you should see in the console JSON-files with swaps data + + ```bash + Maker swap path /Users/ivan/projects/atomicDEX-API/mm2src/mm2_main/DB/030e5e283d0405ae3d01c6d6fd1e7a060aa61fde/SWAPS/MY/336dc9dd-4a1c-4da8-8a63-a2881067ae0c.json + Taker swap path /Users/ivan/projects/atomicDEX-API/mm2src/mm2_main/DB/21605444b36ec72780bdf52a5ffbc18288893664/SWAPS/MY/336dc9dd-4a1c-4da8-8a63-a2881067ae0c.json + ``` + + 5. Copy swap with for your seedphrase to 'assets/debug_data.json', see [Debug Login](#debug-login) + 6. Run Komodo Wallet in debug mode and click 'Debug Login' button in the top right corner + 7. Imported swaps should appear in history on the DEX page + + Explanation for env variables: + + 1. ALICE_PASSPHRASE uses for taker + 2. BOB_PASSPHRASE uses for maker + 3. TAKER_FAIL_AT values see [here](https://github.com/KomodoPlatform/atomicDEX-API/pull/1428/files#diff-3b58e25a3c557aa8a502011591e9a7d56441fd147c2ab072e108902a06ef3076R481) + 4. MAKER_FAIL_AT values see [here](https://github.com/KomodoPlatform/atomicDEX-API/pull/1428/files#diff-608240539630bec8eb43b211b0b74ec3580b34dda66e339bac21c04b1db6da43R1861) diff --git a/docs/MULTIPLE_FLUTTER_VERSIONS.md b/docs/MULTIPLE_FLUTTER_VERSIONS.md new file mode 100644 index 0000000000..16fcca984e --- /dev/null +++ b/docs/MULTIPLE_FLUTTER_VERSIONS.md @@ -0,0 +1,47 @@ +# Handle multiple Flutter versions + +## macOS + +### 1. Clone new Flutter instance alongside with the existing one: +``` +cd ~ +git clone https://github.com/flutter/flutter.git flutter_web +cd ./flutter_web +git checkout 3.3.9 +``` + +### pen (or create) `.zshrc` file in your home directory: +``` +nano ~/.zshrc +``` +Add line: +``` +alias flutter_web="$HOME/flutter_web/bin/flutter" +``` +Save and close. + +### 3. Check if newly installed Flutter version is accessible from terminal: +``` +cd ~ +flutter_web doctor +``` + + +### 4. Add new Flutter version to VSCode: + + - Settings (⌘,) -> Extensions -> Dart -> SDK -> Flutter Sdk Paths -> Add Item -> `~/flutter_web` + - ⌘⇧P -> Developer: Reload window + - ⌘⇧P -> Flutter: Change SDK + + +### 5. Add to Android Studio + + - Settings (⌘,) -> Languages & Frameworks -> Flutter -> SDK -> Flutter SDK Path -> `~/flutter_web` + +---- + +## Windows TBD + +---- + +## Linux TBD \ No newline at end of file diff --git a/docs/PROJECT_SETUP.md b/docs/PROJECT_SETUP.md new file mode 100644 index 0000000000..79e2a2295b --- /dev/null +++ b/docs/PROJECT_SETUP.md @@ -0,0 +1,53 @@ +# Project setup + +Komodo Wallet is a cross-platform application, meaning it can be built for multiple target platforms using the same code base. It is important to note that some target platforms may only be accessible from specific host platforms. Below is a list of all supported host platforms and their corresponding target platforms: + +| Host Platform | Target Platform | +| ------------- | -------------------------------- | +| macOS | Web, macOS, iOS, iPadOS, Android | +| Windows | Web, Windows, Android | +| Linux | Web, Linux, Android | + +## Host Platform Setup + + 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) - Flamingo | 2024.1.2 + - install and enable `Dart` and `Flutter` plugins + - SDK Manager -> SDK Tools: + - [x] Android SDK Build-Tools 35 + - [x] NDK (Side by side) 27.1 + - [x] Android command line tools (latest) + - [x] CMake 3.30.3 (latest) + - [xCode](https://developer.apple.com/xcode/) - 15.4 (macOS only) + - [Visual Studio](https://visualstudio.microsoft.com/vs/community/) - Community 17.11.3 (Windows only) + - `Desktop development with C++` workload required + + 3. Run `flutter doctor` and make sure all checks (except version) pass + 4. [Clone project repository](CLONE_REPOSITORY.md) + 5. Install [nodejs and npm](https://nodejs.org/en/download). Make sure `npm` is in your system PATH and you can run `npm run build` from the project root folder. Node LTS (v18, v20) is required. + + > In case of an error, try to run `npm i`. + + 6. 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) + 7. [Build release version](BUILD_RELEASE.md) + +## Dev Container setup (Web and Android builds only) + +1. Install [Docker](https://www.docker.com/get-started) for your operating system. + - 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 +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` diff --git a/docs/UNIT_TESTING.md b/docs/UNIT_TESTING.md new file mode 100644 index 0000000000..5ba9b0e3fd --- /dev/null +++ b/docs/UNIT_TESTING.md @@ -0,0 +1,7 @@ +# Unit and Widget testing + +To run unit test: + + ```bash + flutter test test_units/main.dart + ``` diff --git a/docs/UPDATE_API_MODULE.md b/docs/UPDATE_API_MODULE.md new file mode 100644 index 0000000000..24aacaec68 --- /dev/null +++ b/docs/UPDATE_API_MODULE.md @@ -0,0 +1,62 @@ +# API module + +## Current version + +Current API module version is `b0fd99e` (`v2.0.0-beta`) + +### Prerequisites + +```bash +# Install Dart dependencies +dart pub get + +# Use Node version 18 +nvm use 18 +``` + +### Usage + +The script will check the `.api_last_updated_[PLATFORM_NAME]` file for every platform listed in `platforms`, and if the last updated version is different from the current API `version`, it will update the API module, `.api_last_updated_[PLATFORM_NAME]` file, and [documentation](#current-version). + +By default, the script will update the API module for all supported platforms to the version specified in [build_config.json](../app_build/build_config.json). + +### Configuration + +In [build_config.json](../app_build/build_config.json), update the API version to the latest commit hash from the [atomicDEX-API](https://github.com/KomodoPlatform/atomicDEX-API) repository. Example: + +```json + "api": { + "api_commit_hash": "fa74561", + ... + } +``` + +To add a new platform to the update script, add a new item to the `platforms` list in [build_config.json](../app_build/build_config.json). + +```json + "api": { + ... + "platforms": { + "linux": { + "keywords": ["linux", "x86_64"], + "path": "linux" + }, + ... + } + } +``` + +- `keywords` is a list of keywords that will be used to find the platform-specific API module zip file on the API CI upload server (`base_url`). +- `path` is the path to the API module directory in the project. + +### Error handling + +In case of errors, please check our [Project setup](PROJECT_SETUP.md) section and verify your environment. + +One possible solution is to run: + +```bash +npm i +``` + +By updating the documentation, users can now rely on the Dart-based build process for fetching and updating the API module, simplifying the workflow and removing the dependency on the Python script. diff --git a/firebase.json b/firebase.json new file mode 100644 index 0000000000..c2d711b738 --- /dev/null +++ b/firebase.json @@ -0,0 +1,17 @@ +{ + "hosting": { + "site": "walletrc", + "public": "build/web", + "ignore": [ + "firebase.json", + "**/.*", + "**/node_modules/**" + ], + "rewrites": [ + { + "source": "**", + "destination": "/index.html" + } + ] + } +} diff --git a/flutter_preview.yml b/flutter_preview.yml new file mode 100644 index 0000000000..3d0fae4009 --- /dev/null +++ b/flutter_preview.yml @@ -0,0 +1,4 @@ +--- +- hosts: airdex + roles: + - nginx diff --git a/ios/.gitignore b/ios/.gitignore new file mode 100644 index 0000000000..0f1df0fdd4 --- /dev/null +++ b/ios/.gitignore @@ -0,0 +1,32 @@ +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +**/*sync/ +.sconsign.dblite +.tags* +**/.vagrant/ +**/DerivedData/ +Icon? +**/Pods/ +**/.symlinks/ +profile +xcuserdata +**/.generated/ +Flutter/App.framework +Flutter/Flutter.framework +Flutter/Flutter.podspec +Flutter/Generated.xcconfig +Flutter/app.flx +Flutter/app.zip +Flutter/flutter_assets/ +Flutter/flutter_export_environment.sh +ServiceDefinitions.json +Runner/GeneratedPluginRegistrant.* + +# Exceptions to above rules. +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist new file mode 100644 index 0000000000..4f8d4d2456 --- /dev/null +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -0,0 +1,26 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + App + CFBundleIdentifier + io.flutter.flutter.app + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + App + CFBundlePackageType + FMWK + CFBundleShortVersionString + 1.0 + CFBundleSignature + ???? + CFBundleVersion + 1.0 + MinimumOSVersion + 11.0 + + diff --git a/ios/Flutter/Debug.xcconfig b/ios/Flutter/Debug.xcconfig new file mode 100644 index 0000000000..c242b3d2d4 --- /dev/null +++ b/ios/Flutter/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Flutter/Release.xcconfig b/ios/Flutter/Release.xcconfig new file mode 100644 index 0000000000..6f07b31e9e --- /dev/null +++ b/ios/Flutter/Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "Generated.xcconfig" diff --git a/ios/Podfile b/ios/Podfile new file mode 100644 index 0000000000..88359b225f --- /dev/null +++ b/ios/Podfile @@ -0,0 +1,41 @@ +# Uncomment this line to define a global platform for your project +# platform :ios, '11.0' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_ios_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__)) +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_ios_build_settings(target) + end +end diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000000..faa7a9e1af --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,308 @@ +PODS: + - DKImagePickerController/Core (4.3.4): + - DKImagePickerController/ImageDataManager + - DKImagePickerController/Resource + - DKImagePickerController/ImageDataManager (4.3.4) + - DKImagePickerController/PhotoGallery (4.3.4): + - DKImagePickerController/Core + - DKPhotoGallery + - DKImagePickerController/Resource (4.3.4) + - DKPhotoGallery (0.0.17): + - DKPhotoGallery/Core (= 0.0.17) + - DKPhotoGallery/Model (= 0.0.17) + - DKPhotoGallery/Preview (= 0.0.17) + - DKPhotoGallery/Resource (= 0.0.17) + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Core (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Preview + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Model (0.0.17): + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Preview (0.0.17): + - DKPhotoGallery/Model + - DKPhotoGallery/Resource + - SDWebImage + - SwiftyGif + - DKPhotoGallery/Resource (0.0.17): + - SDWebImage + - SwiftyGif + - file_picker (0.0.1): + - DKImagePickerController/PhotoGallery + - Flutter + - Firebase/Analytics (10.10.0): + - Firebase/Core + - Firebase/Core (10.10.0): + - Firebase/CoreOnly + - FirebaseAnalytics (~> 10.10.0) + - Firebase/CoreOnly (10.10.0): + - FirebaseCore (= 10.10.0) + - firebase_analytics (10.4.3): + - Firebase/Analytics (= 10.10.0) + - firebase_core + - Flutter + - firebase_core (2.14.0): + - Firebase/CoreOnly (= 10.10.0) + - Flutter + - FirebaseAnalytics (10.10.0): + - FirebaseAnalytics/AdIdSupport (= 10.10.0) + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseAnalytics/AdIdSupport (10.10.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleAppMeasurement (= 10.10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - FirebaseCore (10.10.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/Logger (~> 7.8) + - FirebaseCoreInternal (10.11.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseInstallations (10.11.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - PromisesObjC (~> 2.1) + - Flutter (1.0.0) + - flutter_inappwebview_ios (0.0.1): + - Flutter + - flutter_inappwebview_ios/Core (= 0.0.1) + - OrderedSet (~> 5.0) + - flutter_inappwebview_ios/Core (0.0.1): + - Flutter + - OrderedSet (~> 5.0) + - GoogleAppMeasurement (10.10.0): + - GoogleAppMeasurement/AdIdSupport (= 10.10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement/AdIdSupport (10.10.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 10.10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleAppMeasurement/WithoutAdIdSupport (10.10.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30910.0, >= 2.30908.0) + - GoogleDataTransport (9.2.3): + - GoogleUtilities/Environment (~> 7.7) + - nanopb (< 2.30910.0, >= 2.30908.0) + - PromisesObjC (< 3.0, >= 1.2) + - GoogleMLKit/BarcodeScanning (4.0.0): + - GoogleMLKit/MLKitCore + - MLKitBarcodeScanning (~> 3.0.0) + - GoogleMLKit/MLKitCore (4.0.0): + - MLKitCommon (~> 9.0.0) + - GoogleToolboxForMac/DebugUtils (2.3.2): + - GoogleToolboxForMac/Defines (= 2.3.2) + - GoogleToolboxForMac/Defines (2.3.2) + - GoogleToolboxForMac/Logger (2.3.2): + - GoogleToolboxForMac/Defines (= 2.3.2) + - "GoogleToolboxForMac/NSData+zlib (2.3.2)": + - GoogleToolboxForMac/Defines (= 2.3.2) + - "GoogleToolboxForMac/NSDictionary+URLArguments (2.3.2)": + - GoogleToolboxForMac/DebugUtils (= 2.3.2) + - GoogleToolboxForMac/Defines (= 2.3.2) + - "GoogleToolboxForMac/NSString+URLArguments (= 2.3.2)" + - "GoogleToolboxForMac/NSString+URLArguments (2.3.2)" + - GoogleUtilities/AppDelegateSwizzler (7.11.1): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Environment (7.11.1): + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.11.1): + - GoogleUtilities/Environment + - GoogleUtilities/MethodSwizzler (7.11.1): + - GoogleUtilities/Logger + - GoogleUtilities/Network (7.11.1): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.11.1)" + - GoogleUtilities/Reachability (7.11.1): + - GoogleUtilities/Logger + - GoogleUtilities/UserDefaults (7.11.1): + - GoogleUtilities/Logger + - GoogleUtilitiesComponents (1.1.0): + - GoogleUtilities/Logger + - GTMSessionFetcher/Core (2.3.0) + - integration_test (0.0.1): + - Flutter + - MLImage (1.0.0-beta4) + - MLKitBarcodeScanning (3.0.0): + - MLKitCommon (~> 9.0) + - MLKitVision (~> 5.0) + - MLKitCommon (9.0.0): + - GoogleDataTransport (~> 9.0) + - GoogleToolboxForMac/Logger (~> 2.1) + - "GoogleToolboxForMac/NSData+zlib (~> 2.1)" + - "GoogleToolboxForMac/NSDictionary+URLArguments (~> 2.1)" + - GoogleUtilities/UserDefaults (~> 7.0) + - GoogleUtilitiesComponents (~> 1.0) + - GTMSessionFetcher/Core (< 3.0, >= 1.1) + - MLKitVision (5.0.0): + - GoogleToolboxForMac/Logger (~> 2.1) + - "GoogleToolboxForMac/NSData+zlib (~> 2.1)" + - GTMSessionFetcher/Core (< 3.0, >= 1.1) + - MLImage (= 1.0.0-beta4) + - MLKitCommon (~> 9.0) + - mobile_scanner (3.2.0): + - Flutter + - GoogleMLKit/BarcodeScanning (~> 4.0.0) + - nanopb (2.30909.0): + - nanopb/decode (= 2.30909.0) + - nanopb/encode (= 2.30909.0) + - nanopb/decode (2.30909.0) + - nanopb/encode (2.30909.0) + - OrderedSet (5.0.0) + - package_info_plus (0.4.5): + - Flutter + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - PromisesObjC (2.2.0) + - SDWebImage (5.16.0): + - SDWebImage/Core (= 5.16.0) + - SDWebImage/Core (5.16.0) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - SwiftyGif (5.4.4) + - url_launcher_ios (0.0.1): + - Flutter + - video_player_avfoundation (0.0.1): + - Flutter + +DEPENDENCIES: + - file_picker (from `.symlinks/plugins/file_picker/ios`) + - firebase_analytics (from `.symlinks/plugins/firebase_analytics/ios`) + - firebase_core (from `.symlinks/plugins/firebase_core/ios`) + - Flutter (from `Flutter`) + - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) + - integration_test (from `.symlinks/plugins/integration_test/ios`) + - mobile_scanner (from `.symlinks/plugins/mobile_scanner/ios`) + - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - video_player_avfoundation (from `.symlinks/plugins/video_player_avfoundation/ios`) + +SPEC REPOS: + trunk: + - DKImagePickerController + - DKPhotoGallery + - Firebase + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - GoogleAppMeasurement + - GoogleDataTransport + - GoogleMLKit + - GoogleToolboxForMac + - GoogleUtilities + - GoogleUtilitiesComponents + - GTMSessionFetcher + - MLImage + - MLKitBarcodeScanning + - MLKitCommon + - MLKitVision + - nanopb + - OrderedSet + - PromisesObjC + - SDWebImage + - SwiftyGif + +EXTERNAL SOURCES: + file_picker: + :path: ".symlinks/plugins/file_picker/ios" + firebase_analytics: + :path: ".symlinks/plugins/firebase_analytics/ios" + firebase_core: + :path: ".symlinks/plugins/firebase_core/ios" + Flutter: + :path: Flutter + flutter_inappwebview_ios: + :path: ".symlinks/plugins/flutter_inappwebview_ios/ios" + integration_test: + :path: ".symlinks/plugins/integration_test/ios" + mobile_scanner: + :path: ".symlinks/plugins/mobile_scanner/ios" + package_info_plus: + :path: ".symlinks/plugins/package_info_plus/ios" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + video_player_avfoundation: + :path: ".symlinks/plugins/video_player_avfoundation/ios" + +SPEC CHECKSUMS: + DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac + DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179 + file_picker: ce3938a0df3cc1ef404671531facef740d03f920 + Firebase: facd334e557a979bd03a0b58d90fd56b52b8aba0 + firebase_analytics: 1a5ad75876257318ba5fdc6bf7aae73b6e98d0cf + firebase_core: 85b6664038311940ad60584eaabc73103c61f5de + FirebaseAnalytics: 7bc7de519111dae802f5bc0c9c083918f8b8870d + FirebaseCore: d027ff503d37edb78db98429b11f580a24a7df2a + FirebaseCoreInternal: 9e46c82a14a3b3a25be4e1e151ce6d21536b89c0 + FirebaseInstallations: 2a2c6859354cbec0a228a863d4daf6de7c74ced4 + Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854 + flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 + GoogleAppMeasurement: bbbfd4bcb2b40ae9b772c3b0823a58c1e3d618f9 + GoogleDataTransport: f0308f5905a745f94fb91fea9c6cbaf3831cb1bd + GoogleMLKit: 2bd0dc6253c4d4f227aad460f69215a504b2980e + GoogleToolboxForMac: 8bef7c7c5cf7291c687cf5354f39f9db6399ad34 + GoogleUtilities: 9aa0ad5a7bc171f8bae016300bfcfa3fb8425749 + GoogleUtilitiesComponents: 679b2c881db3b615a2777504623df6122dd20afe + GTMSessionFetcher: 3a63d75eecd6aa32c2fc79f578064e1214dfdec2 + integration_test: 13825b8a9334a850581300559b8839134b124670 + MLImage: 7bb7c4264164ade9bf64f679b40fb29c8f33ee9b + MLKitBarcodeScanning: 04e264482c5f3810cb89ebc134ef6b61e67db505 + MLKitCommon: c1b791c3e667091918d91bda4bba69a91011e390 + MLKitVision: 8baa5f46ee3352614169b85250574fde38c36f49 + mobile_scanner: 47056db0c04027ea5f41a716385542da28574662 + nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 + OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c + package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7 + path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 + PromisesObjC: 09985d6d70fbe7878040aa746d78236e6946d2ef + SDWebImage: 2aea163b50bfcb569a2726b6a754c54a4506fcf6 + share_plus: 599aa54e4ea31d4b4c0e9c911bcc26c55e791028 + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 + SwiftyGif: 93a1cc87bf3a51916001cf8f3d63835fb64c819f + url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4 + video_player_avfoundation: 81e49bb3d9fb63dccf9fa0f6d877dc3ddbeac126 + +PODFILE CHECKSUM: ef19549a9bc3046e7bb7d2fab4d021637c0c58a3 + +COCOAPODS: 1.12.1 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..52fdfe96da --- /dev/null +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,620 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXBuildFile section */ + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 49484A33FCF0585DB40EBAD9 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C74478EE63B90E2A48A7AB3C /* GoogleService-Info.plist */; }; + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; + C2B3782B4B651E97F3AF9B7A /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6DB340A008F6FECB3B82619D /* Pods_Runner.framework */; }; + D63143E32701FFB500374C78 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D63143E22701FFB500374C78 /* CoreFoundation.framework */; }; + D63143E52702003500374C78 /* libc++.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D63143E42701FFD100374C78 /* libc++.tbd */; }; + D63143E62702004B00374C78 /* libmm2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D63143E12701FF6700374C78 /* libmm2.a */; }; + D63143E92702008000374C78 /* libSystem.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D63143E82702007200374C78 /* libSystem.tbd */; }; + D63143EA2702008B00374C78 /* libSystem.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D63143E82702007200374C78 /* libSystem.tbd */; }; + D63143EB2702009900374C78 /* libresolv.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D63143E72702005D00374C78 /* libresolv.tbd */; }; + D63143ED270200B100374C78 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D63143EC270200B100374C78 /* SystemConfiguration.framework */; }; + D6C50BDA2702024E0095EE3C /* mm2.m in Sources */ = {isa = PBXBuildFile; fileRef = D6C50BD82702024E0095EE3C /* mm2.m */; }; +/* End PBXBuildFile section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 9705A1C41CF9048500538489 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 1E4F53BBF74C393F00500C43 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 335A3372CF627D5A148D7C1F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; + 6DB340A008F6FECB3B82619D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; + 91B045D447C7C6266906543C /* 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 = ""; }; + 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; + 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; + 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 97C146FB1CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/Main.storyboard; sourceTree = ""; }; + 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + C74478EE63B90E2A48A7AB3C /* GoogleService-Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Runner/GoogleService-Info.plist"; sourceTree = ""; }; + D63143E12701FF6700374C78 /* libmm2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libmm2.a; sourceTree = ""; }; + D63143E22701FFB500374C78 /* CoreFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreFoundation.framework; path = System/Library/Frameworks/CoreFoundation.framework; sourceTree = SDKROOT; }; + D63143E42701FFD100374C78 /* libc++.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = "libc++.tbd"; path = "usr/lib/libc++.tbd"; sourceTree = SDKROOT; }; + D63143E72702005D00374C78 /* libresolv.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libresolv.tbd; path = usr/lib/libresolv.tbd; sourceTree = SDKROOT; }; + D63143E82702007200374C78 /* libSystem.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libSystem.tbd; path = usr/lib/libSystem.tbd; sourceTree = SDKROOT; }; + D63143EC270200B100374C78 /* SystemConfiguration.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SystemConfiguration.framework; path = System/Library/Frameworks/SystemConfiguration.framework; sourceTree = SDKROOT; }; + D6C50BD82702024E0095EE3C /* mm2.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = mm2.m; sourceTree = ""; }; + D6C50BD92702024E0095EE3C /* mm2.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = mm2.h; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXFrameworksBuildPhase section */ + 97C146EB1CF9000F007C117D /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D63143E52702003500374C78 /* libc++.tbd in Frameworks */, + D63143EA2702008B00374C78 /* libSystem.tbd in Frameworks */, + D63143E32701FFB500374C78 /* CoreFoundation.framework in Frameworks */, + D63143EB2702009900374C78 /* libresolv.tbd in Frameworks */, + D63143E62702004B00374C78 /* libmm2.a in Frameworks */, + D63143E92702008000374C78 /* libSystem.tbd in Frameworks */, + D63143ED270200B100374C78 /* SystemConfiguration.framework in Frameworks */, + C2B3782B4B651E97F3AF9B7A /* Pods_Runner.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 9740EEB11CF90186004384FC /* Flutter */ = { + isa = PBXGroup; + children = ( + 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 9740EEB31CF90195004384FC /* Generated.xcconfig */, + ); + name = Flutter; + sourceTree = ""; + }; + 97C146E51CF9000F007C117D = { + isa = PBXGroup; + children = ( + 9740EEB11CF90186004384FC /* Flutter */, + 97C146F01CF9000F007C117D /* Runner */, + 97C146EF1CF9000F007C117D /* Products */, + CA91CBABE9CCB6E6B6240E93 /* Pods */, + 97C8F770FEFFE1477DCEA6C5 /* Frameworks */, + C74478EE63B90E2A48A7AB3C /* GoogleService-Info.plist */, + ); + sourceTree = ""; + }; + 97C146EF1CF9000F007C117D /* Products */ = { + isa = PBXGroup; + children = ( + 97C146EE1CF9000F007C117D /* Runner.app */, + ); + name = Products; + sourceTree = ""; + }; + 97C146F01CF9000F007C117D /* Runner */ = { + isa = PBXGroup; + children = ( + D6C50BD92702024E0095EE3C /* mm2.h */, + D6C50BD82702024E0095EE3C /* mm2.m */, + 97C146FA1CF9000F007C117D /* Main.storyboard */, + 97C146FD1CF9000F007C117D /* Assets.xcassets */, + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */, + 97C147021CF9000F007C117D /* Info.plist */, + 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */, + 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */, + 74858FAE1ED2DC5600515810 /* AppDelegate.swift */, + 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + 97C8F770FEFFE1477DCEA6C5 /* Frameworks */ = { + isa = PBXGroup; + children = ( + D63143EC270200B100374C78 /* SystemConfiguration.framework */, + D63143E82702007200374C78 /* libSystem.tbd */, + D63143E72702005D00374C78 /* libresolv.tbd */, + D63143E42701FFD100374C78 /* libc++.tbd */, + D63143E22701FFB500374C78 /* CoreFoundation.framework */, + D63143E12701FF6700374C78 /* libmm2.a */, + 6DB340A008F6FECB3B82619D /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; + CA91CBABE9CCB6E6B6240E93 /* Pods */ = { + isa = PBXGroup; + children = ( + 91B045D447C7C6266906543C /* Pods-Runner.debug.xcconfig */, + 335A3372CF627D5A148D7C1F /* Pods-Runner.release.xcconfig */, + 1E4F53BBF74C393F00500C43 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 97C146ED1CF9000F007C117D /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + 4F319252857CDB102A7867B7 /* [CP] Check Pods Manifest.lock */, + 9740EEB61CF901F6004384FC /* Run Script */, + 97C146EA1CF9000F007C117D /* Sources */, + 97C146EB1CF9000F007C117D /* Frameworks */, + 97C146EC1CF9000F007C117D /* Resources */, + 9705A1C41CF9048500538489 /* Embed Frameworks */, + 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + FC78CCE93902D5D826DCE20C /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = Runner; + productName = Runner; + productReference = 97C146EE1CF9000F007C117D /* Runner.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 97C146E61CF9000F007C117D /* Project object */ = { + isa = PBXProject; + attributes = { + LastUpgradeCheck = 1300; + ORGANIZATIONNAME = ""; + TargetAttributes = { + 97C146ED1CF9000F007C117D = { + CreatedOnToolsVersion = 7.3.1; + LastSwiftMigration = 1100; + }; + }; + }; + buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 9.3"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 97C146E51CF9000F007C117D; + productRefGroup = 97C146EF1CF9000F007C117D /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 97C146ED1CF9000F007C117D /* Runner */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 97C146EC1CF9000F007C117D /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */, + 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */, + 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */, + 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */, + 49484A33FCF0585DB40EBAD9 /* GoogleService-Info.plist in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${TARGET_BUILD_DIR}/${INFOPLIST_PATH}", + ); + name = "Thin Binary"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; + }; + 4F319252857CDB102A7867B7 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + 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 = "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; + }; + 9740EEB61CF901F6004384FC /* Run Script */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Run Script"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; + }; + FC78CCE93902D5D826DCE20C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 97C146EA1CF9000F007C117D /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D6C50BDA2702024E0095EE3C /* mm2.m in Sources */, + 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, + 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXVariantGroup section */ + 97C146FA1CF9000F007C117D /* Main.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C146FB1CF9000F007C117D /* Base */, + ); + name = Main.storyboard; + sourceTree = ""; + }; + 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */ = { + isa = PBXVariantGroup; + children = ( + 97C147001CF9000F007C117D /* Base */, + ); + name = LaunchScreen.storyboard; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 249021D3217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Profile; + }; + 249021D4217E4FDB00AE95B9 /* Profile */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G3VBBBMD8T; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + "$(PROJECT_DIR)", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.komodoplatform.atomicdex; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Profile; + }; + 97C147031CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + 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_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 97C147041CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_NONNULL = YES; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x"; + 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_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + 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_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + GCC_C_LANGUAGE_STANDARD = gnu99; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = iphoneos; + SUPPORTED_PLATFORMS = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 97C147061CF9000F007C117D /* Debug */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G3VBBBMD8T; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + "$(PROJECT_DIR)", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.komodoplatform.atomicdex; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Debug; + }; + 97C147071CF9000F007C117D /* Release */ = { + isa = XCBuildConfiguration; + baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CLANG_ENABLE_MODULES = YES; + CURRENT_PROJECT_VERSION = "$(FLUTTER_BUILD_NUMBER)"; + DEVELOPMENT_TEAM = G3VBBBMD8T; + ENABLE_BITCODE = NO; + FRAMEWORK_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + ); + INFOPLIST_FILE = Runner/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Flutter", + "$(PROJECT_DIR)", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.komodoplatform.atomicdex; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + VERSIONING_SYSTEM = "apple-generic"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147031CF9000F007C117D /* Debug */, + 97C147041CF9000F007C117D /* Release */, + 249021D3217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 97C147061CF9000F007C117D /* Debug */, + 97C147071CF9000F007C117D /* Release */, + 249021D4217E4FDB00AE95B9 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 97C146E61CF9000F007C117D /* Project object */; +} diff --git a/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..919434a625 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..fc6bf80748 --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..af0309c4dc --- /dev/null +++ b/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..c87d15a335 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..fc6bf80748 --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 0000000000..af0309c4dc --- /dev/null +++ b/ios/Runner.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + PreviewsEnabled + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift new file mode 100644 index 0000000000..0fddb44650 --- /dev/null +++ b/ios/Runner/AppDelegate.swift @@ -0,0 +1,154 @@ +import UIKit +import Flutter +import Foundation +import CoreLocation +import os.log +import UserNotifications +import AVFoundation + +var mm2StartArgs: String? +var shouldRestartMM2: Bool = true; +var eventSink: FlutterEventSink? + +func flutterLog(_ log: String) { + eventSink?("{\"type\": \"log\", \"message\": \"\(log)\"}") +} + +func mm2Callback(line: UnsafePointer?) { + if let lineStr = line { + let logMessage = String(cString: lineStr) + flutterLog(logMessage) + } +} + +func performMM2Start() -> Int32 { + flutterLog("START MM2 --------------------------------") + let error = Int32(mm2_main(mm2StartArgs, mm2Callback)) + flutterLog("START MM2 RESULT: \(error) ---------------") + return error +} + +func performMM2Stop() -> Int32 { + flutterLog("STOP MM2 --------------------------------"); + let error = Int32(mm2_stop()); + flutterLog("STOP MM2 RESULT: \(error) ---------------"); + return error; +} + +func performMM2Restart() -> Int32 { + let _ = performMM2Stop() + var ticker: Int = 0 + // wait until mm2 stopped, but continue after 3s anyway + while(mm2_main_status() != 0 && ticker < 30) { + usleep(100000) // 0.1s + ticker += 1 + } + + let error = performMM2Start() + ticker = 0 + // wait until mm2 started, but continue after 10s anyway + while(mm2_main_status() != 3 && ticker < 100) { + usleep(100000) // 0.1s + ticker += 1 + } + + return error; +} + +@UIApplicationMain +@objc class AppDelegate: FlutterAppDelegate, FlutterStreamHandler { + var intentURI: String? + + + override func application(_ application: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:] ) -> Bool { + self.intentURI = url.absoluteString + return true + } + + override func application (_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + guard let vc = window?.rootViewController as? FlutterViewController else { + fatalError ("rootViewController is not type FlutterViewController")} + let vcbm = vc as! FlutterBinaryMessenger + + let channelMain = FlutterMethodChannel (name: "komodo-web-dex", binaryMessenger: vcbm) + let eventChannel = FlutterEventChannel (name: "komodo-web-dex/event", binaryMessenger: vcbm) + eventChannel.setStreamHandler (self) + + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .badge, .sound]) { success, error in + if success { + print("Notifications allowed!") + } else if let error = error { + print(error.localizedDescription) + } + } + + UIDevice.current.isBatteryMonitoringEnabled = true + + channelMain.setMethodCallHandler ({(call: FlutterMethodCall, result: FlutterResult) -> Void in + if call.method == "start" { + guard let arg = (call.arguments as! Dictionary)["params"] else { result(0); return } + mm2StartArgs = arg; + let error: Int32 = performMM2Start(); + + result(error) + } else if call.method == "status" { + let ret = Int32(mm2_main_status()); + result(ret) + } else if call.method == "stop" { + mm2StartArgs = nil; + let error: Int32 = performMM2Stop(); + + result(error) + } else if call.method == "restart" { + let error: Int32 = performMM2Restart(); + + result(error) + } else {result (FlutterMethodNotImplemented)}}) + GeneratedPluginRegistrant.register(with: self) + return super.application(application, didFinishLaunchingWithOptions: launchOptions) + } + + @objc func onDidReceiveData(_ notification:Notification) { + if let data = notification.userInfo as? [String: String] + { + flutterLog(data["log"]!) + } + + } + + public func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + NotificationCenter.default.addObserver(self, selector: #selector(onDidReceiveData(_:)), name: .didReceiveData, object: nil) + return nil + } + + public func onCancel(withArguments arguments: Any?) -> FlutterError? { + NotificationCenter.default.removeObserver(self) + eventSink = nil + return nil + } + + public override func applicationWillResignActive(_ application: UIApplication) { + let blurEffect = UIBlurEffect(style: UIBlurEffect.Style.dark) + let blurEffectView = UIVisualEffectView(effect: blurEffect) + blurEffectView.frame = window!.frame + blurEffectView.tag = 61007 + + self.window?.addSubview(blurEffectView) + } + + public override func applicationDidBecomeActive(_ application: UIApplication) { + signal(SIGPIPE, SIG_IGN); + self.window?.viewWithTag(61007)?.removeFromSuperview() + + eventSink?("{\"type\": \"app_did_become_active\"}") + } + + override func applicationWillEnterForeground(_ application: UIApplication) { + signal(SIGPIPE, SIG_IGN); + } +} + +extension Notification.Name { + static let didReceiveData = Notification.Name("didReceiveData") +} diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..e9c9b88ef7 --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images" : [ + { + "filename" : "Icon-App-1024x1024@1x.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} 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 new file mode 100644 index 0000000000..7b1f3db446 Binary files /dev/null and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppLogo.imageset/AppLogo.png b/ios/Runner/Assets.xcassets/AppLogo.imageset/AppLogo.png new file mode 100644 index 0000000000..a6614de14c Binary files /dev/null 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 new file mode 100644 index 0000000000..7fec80eb9c Binary files /dev/null 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 new file mode 100644 index 0000000000..77f1ae1dc6 --- /dev/null +++ b/ios/Runner/Assets.xcassets/AppLogo.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "AppLogo.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "AppLogo@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Assets.xcassets/Contents.json b/ios/Runner/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/ios/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/Runner/Base.lproj/LaunchScreen.storyboard b/ios/Runner/Base.lproj/LaunchScreen.storyboard new file mode 100644 index 0000000000..8f6ee0b5fe --- /dev/null +++ b/ios/Runner/Base.lproj/LaunchScreen.storyboard @@ -0,0 +1,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/Base.lproj/Main.storyboard b/ios/Runner/Base.lproj/Main.storyboard new file mode 100644 index 0000000000..bbb83caaec --- /dev/null +++ b/ios/Runner/Base.lproj/Main.storyboard @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/GoogleService-Info.plist b/ios/Runner/GoogleService-Info.plist new file mode 100644 index 0000000000..bef7488865 --- /dev/null +++ b/ios/Runner/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + THIS_IS_AUTOGENERATED + GCM_SENDER_ID + THIS_IS_AUTOGENERATED + PLIST_VERSION + 1 + BUNDLE_ID + THIS_IS_AUTOGENERATED + PROJECT_ID + THIS_IS_AUTOGENERATED + STORAGE_BUCKET + THIS_IS_AUTOGENERATED + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + THIS_IS_AUTOGENERATED + + \ No newline at end of file diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist new file mode 100644 index 0000000000..2f37ec4f57 --- /dev/null +++ b/ios/Runner/Info.plist @@ -0,0 +1,56 @@ + + + + + CADisableMinimumFrameDurationOnPhone + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Komodo Wallet + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleSignature + ???? + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSRequiresIPhoneOS + + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + NSCameraUsageDescription + This app needs camera access to scan QR codes + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIMainStoryboardFile + Main + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UIViewControllerBasedStatusBarAppearance + + + diff --git a/ios/Runner/Runner-Bridging-Header.h b/ios/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..bee0ae5ae7 --- /dev/null +++ b/ios/Runner/Runner-Bridging-Header.h @@ -0,0 +1,2 @@ +#import "GeneratedPluginRegistrant.h" +#import "mm2.h" diff --git a/ios/Runner/mm2.h b/ios/Runner/mm2.h new file mode 100644 index 0000000000..a132efafa8 --- /dev/null +++ b/ios/Runner/mm2.h @@ -0,0 +1,36 @@ +#ifndef mm2_h +#define mm2_h + +#include + +char* writeable_dir (void); + +void start_mm2 (const char* mm2_conf); + +/// Checks if the MM2 singleton thread is currently running or not. +/// 0 .. not running. +/// 1 .. running, but no context yet. +/// 2 .. context, but no RPC yet. +/// 3 .. RPC is up. +int8_t mm2_main_status (void); + +/// Defined in "common/for_c.rs". +uint8_t is_loopback_ip (const char* ip); +/// Defined in "mm2_lib.rs". +int8_t mm2_main (const char* conf, void (*log_cb) (const char* line)); + +/// Defined in "mm2_lib.rs". +/// 0 .. MM2 has been stopped successfully. +/// 1 .. not running. +/// 2 .. error stopping an MM2 instance. +int8_t mm2_stop (void); + +void lsof (void); + +/// Measurement of application metrics: network traffic, CPU usage, etc. +const char* metrics (void); + +/// Corresponds to the `applicationDocumentsDirectory` used in Dart. +const char* documentDirectory (void); + +#endif /* mm2_h */ diff --git a/ios/Runner/mm2.m b/ios/Runner/mm2.m new file mode 100644 index 0000000000..d2469813e0 --- /dev/null +++ b/ios/Runner/mm2.m @@ -0,0 +1,235 @@ +#include "mm2.h" + +#import +#import +#import +#import +#import +#import // os_log +#import // NSException + +#include +#include + +#include // task_info, mach_task_self + +#include // strcpy +#include +#include +#include + +// Note that the network interface traffic is not the same as the application traffic. +// Might still be useful with picking some trends in how the application is using the network, +// and for troubleshooting. +void network (NSMutableDictionary* ret) { + // https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/getifaddrs.3.html + struct ifaddrs *addrs = NULL; + int rc = getifaddrs (&addrs); + if (rc != 0) return; + + for (struct ifaddrs *addr = addrs; addr != NULL; addr = addr->ifa_next) { + if (addr->ifa_addr->sa_family != AF_LINK) continue; + + // Known aliases: “en0” is wi-fi, “pdp_ip0” is mobile. + // AG: “lo0” on my iPhone 5s seems to be measuring the Wi-Fi traffic. + const char* name = addr->ifa_name; + + struct if_data *stats = (struct if_data*) addr->ifa_data; + if (name == NULL || stats == NULL) continue; + if (stats->ifi_ipackets == 0 || stats->ifi_opackets == 0) continue; + + int8_t log = 0; + if (log == 1) os_log (OS_LOG_DEFAULT, + "network] if %{public}s ipackets %lld ibytes %lld opackets %lld obytes %lld", + name, + (int64_t) stats->ifi_ipackets, + (int64_t) stats->ifi_ibytes, + (int64_t) stats->ifi_opackets, + (int64_t) stats->ifi_obytes); + + NSDictionary* readings = @{ + @"ipackets": @((int64_t) stats->ifi_ipackets), + @"ibytes": @((int64_t) stats->ifi_ibytes), + @"opackets": @((int64_t) stats->ifi_opackets), + @"obytes": @((int64_t) stats->ifi_obytes)}; + NSString* key = [[NSString alloc] initWithUTF8String:name]; + [ret setObject:readings forKey:key];} + + freeifaddrs (addrs);} + +// Results in a `EXC_CRASH (SIGABRT)` crash log. +void throw_example (void) { + @throw [NSException exceptionWithName:@"exceptionName" reason:@"throw_example" userInfo:nil];} + +const char* documentDirectory (void) { + NSFileManager* sharedFM = [NSFileManager defaultManager]; + NSArray* urls = [sharedFM URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask]; + //for (NSURL* url in urls) os_log (OS_LOG_DEFAULT, "documentDirectory] supp dir: %{public}s\n", url.fileSystemRepresentation); + if (urls.count < 1) {os_log (OS_LOG_DEFAULT, "documentDirectory] Can't get a NSApplicationSupportDirectory"); return NULL;} + const char* wr_dir = urls[0].fileSystemRepresentation; + return wr_dir; +} + +// “in_use” stops at 256. +void file_example (void) { + const char* documents = documentDirectory(); + NSString* dir = [[NSString alloc] initWithUTF8String:documents]; + NSArray* files = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dir error:NULL]; + static int32_t total = 0; + [files enumerateObjectsUsingBlock:^ (id obj, NSUInteger idx, BOOL *stop) { + NSString* filename = (NSString*) obj; + os_log (OS_LOG_DEFAULT, "file_example] filename: %{public}s", filename.UTF8String); + + NSString* path = [NSString stringWithFormat:@"%@/%@", dir, filename]; + int fd = open (path.UTF8String, O_RDWR); + if (fd > 0) ++total;}]; + + int32_t in_use = 0; + for (int fd = 0; fd < (int) FD_SETSIZE; ++fd) if (fcntl (fd, F_GETFD, 0) != -1) ++in_use; + + os_log (OS_LOG_DEFAULT, "file_example] leaked %d; in_use %d / %d", total, in_use, (int32_t) FD_SETSIZE);} + +// On iPhone 5s the app stopped at “phys_footprint 646 MiB; rs 19 MiB”. +// It didn't get to a memory allocation failure but was killed by Jetsam instead +// (“JetsamEvent-2020-04-03-175018.ips” was generated in the iTunes crash logs directory). +void leak_example (void) { + static int8_t* leaks[9999]; // Preserve the pointers for GC + static int32_t next_leak = 0; + int32_t size = 9 * 1024 * 1024; + os_log (OS_LOG_DEFAULT, "leak_example] Leaking %d MiB…", size / 1024 / 1024); + int8_t* leak = malloc (size); + if (leak == NULL) {os_log (OS_LOG_DEFAULT, "leak_example] Allocation failed"); return;} + leaks[next_leak++] = leak; + // Fill with random junk to workaround memory compression + for (int ix = 0; ix < size; ++ix) leak[ix] = (int8_t) rand(); + os_log (OS_LOG_DEFAULT, "leak_example] Leak %d, allocated %d MiB", next_leak, size / 1024 / 1024);} + +int32_t fds_simple (void) { + int32_t fds = 0; + for (int fd = 0; fd < (int) FD_SETSIZE; ++fd) if (fcntl (fd, F_GETFD, 0) != -1) ++fds; + return fds;} + +int32_t fds (void) { + // fds_simple is likely to generate a number of interrupts + // (FD_SETSIZE of 1024 would likely mean 1024 interrupts). + // We should actually check it: maybe it will help us with reproducing the high number of `wakeups`. + // But for production use we want to reduce the number of `fcntl` invocations. + + // We'll skip the first portion of file descriptors because most of the time we have them opened anyway. + int fd = 66; + int32_t fds = 66; + int32_t gap = 0; + + while (fd < (int) FD_SETSIZE && fd < 333) { + if (fcntl (fd, F_GETFD, 0) != -1) { // If file descriptor exists + gap = 0; + if (fd < 220) { + // We will count the files by ten, hoping that iOS traditionally fills the gaps. + fd += 10; + fds += 10; + } else { + // Unless we're close to the limit, where we want more precision. + ++fd; ++fds;} + continue;} + // Sample with increasing step while inside the gap. + int step = 1 + gap / 3; + fd += step; + gap += step;} + + return fds;} + +const char* metrics (void) { + //file_example(); + //leak_example(); + + mach_port_t self = mach_task_self(); + if (self == MACH_PORT_NULL || self == MACH_PORT_DEAD) return "{}"; + + // cf. https://forums.developer.apple.com/thread/105088#357415 + int32_t footprint = 0, rs = 0; + task_vm_info_data_t vmInfo; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + kern_return_t rc = task_info (self, TASK_VM_INFO, (task_info_t) &vmInfo, &count); + if (rc == KERN_SUCCESS) { + footprint = (int32_t) vmInfo.phys_footprint / (1024 * 1024); + rs = (int32_t) vmInfo.resident_size / (1024 * 1024);} + + // iOS applications are in danger of being killed if the number of iterrupts is too high, + // so it might be interesting to maintain some statistics on the number of interrupts. + int64_t wakeups = 0; + task_power_info_data_t powInfo; + count = TASK_POWER_INFO_COUNT; + rc = task_info (self, TASK_POWER_INFO, (task_info_t) &powInfo, &count); + if (rc == KERN_SUCCESS) wakeups = (int64_t) powInfo.task_interrupt_wakeups; + + int32_t files = fds(); + + NSMutableDictionary* ret = [NSMutableDictionary new]; + + //os_log (OS_LOG_DEFAULT, + // "metrics] phys_footprint %d MiB; rs %d MiB; wakeups %lld; files %d", footprint, rs, wakeups, files); + ret[@"footprint"] = @(footprint); + ret[@"rs"] = @(rs); + ret[@"wakeups"] = @(wakeups); + ret[@"files"] = @(files); + + network (ret); + + NSError *err; + NSData *js = [NSJSONSerialization dataWithJSONObject:ret options:0 error: &err]; + if (js == NULL) {os_log (OS_LOG_DEFAULT, "metrics] !json: %@", err); return "{}";} + NSString *jss = [[NSString alloc] initWithData:js encoding:NSUTF8StringEncoding]; + const char *cs = [jss UTF8String]; + return cs;} + +void lsof (void) +{ + // AG: For now `os_log` allows me to see the information in the logs, + // but in the future we might want to return the information to Flutter + // in order to gather statistics on the use of file descriptors in the app, etc. + + int flags; + int fd; + char buf[MAXPATHLEN+1] ; + int n = 1 ; + + for (fd = 0; fd < (int) FD_SETSIZE; fd++) { + errno = 0; + flags = fcntl(fd, F_GETFD, 0); + if (flags == -1 && errno) { + if (errno != EBADF) { + return ; + } + else + continue; + } + if (fcntl(fd , F_GETPATH, buf ) >= 0) + { + printf("File Descriptor %d number %d in use for: %s\n", fd, n, buf); + os_log (OS_LOG_DEFAULT, "lsof] File Descriptor %d number %d in use for: %{public}s", fd, n, buf); + } + else + { + //[...] + + struct sockaddr_in addr; + socklen_t addr_size = sizeof(struct sockaddr); + int res = getpeername(fd, (struct sockaddr*)&addr, &addr_size); + if (res >= 0) + { + char clientip[20]; + strcpy(clientip, inet_ntoa(addr.sin_addr)); + uint16_t port = \ + (uint16_t)((((uint16_t)(addr.sin_port) & 0xff00) >> 8) | \ + (((uint16_t)(addr.sin_port) & 0x00ff) << 8)); + printf("File Descriptor %d, %s:%d \n", fd, clientip, port); + os_log (OS_LOG_DEFAULT, "lsof] File Descriptor %d, %{public}s:%d", fd, clientip, port); + } + else { + printf("File Descriptor %d number %d couldn't get path or socket\n", fd, n); + os_log (OS_LOG_DEFAULT, "lsof] File Descriptor %d number %d couldn't get path or socket", fd, n); + } + } + ++n ; + } +} diff --git a/ios/build/Pods.build/Release-iphonesimulator/Flutter.build/dgph b/ios/build/Pods.build/Release-iphonesimulator/Flutter.build/dgph new file mode 100644 index 0000000000..e5b31cc6e3 Binary files /dev/null and b/ios/build/Pods.build/Release-iphonesimulator/Flutter.build/dgph 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 new file mode 100644 index 0000000000..e5b31cc6e3 Binary files /dev/null and b/ios/build/Pods.build/Release-iphonesimulator/Pods-Runner.build/dgph 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 new file mode 100644 index 0000000000..989ccb51d0 Binary files /dev/null and b/ios/build/Pods.build/Release-iphonesimulator/integration_test.build/dgph 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 new file mode 100644 index 0000000000..e5b31cc6e3 Binary files /dev/null and b/ios/build/Pods.build/Release-iphonesimulator/path_provider.build/dgph 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 new file mode 100644 index 0000000000..e5b31cc6e3 Binary files /dev/null and b/ios/build/Pods.build/Release-iphonesimulator/shared_preferences.build/dgph 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 new file mode 100644 index 0000000000..e5b31cc6e3 Binary files /dev/null and b/ios/build/Pods.build/Release-iphonesimulator/url_launcher.build/dgph differ diff --git a/ios/firebase_app_id_file.json b/ios/firebase_app_id_file.json new file mode 100644 index 0000000000..84783c766d --- /dev/null +++ b/ios/firebase_app_id_file.json @@ -0,0 +1,7 @@ +{ + "file_generated_by": "FlutterFire CLI", + "purpose": "FirebaseAppID & ProjectID for this Firebase app in this directory", + "GOOGLE_APP_ID": "", + "FIREBASE_PROJECT_ID": "", + "GCM_SENDER_ID": "" +} \ No newline at end of file diff --git a/lib/3p_api/faucet/faucet.dart b/lib/3p_api/faucet/faucet.dart new file mode 100644 index 0000000000..e5576106df --- /dev/null +++ b/lib/3p_api/faucet/faucet.dart @@ -0,0 +1,17 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:web_dex/3p_api/faucet/faucet_response.dart'; + +Future callFaucet(String coin, String address) async { + try { + final response = await http.get( + Uri.parse('https://faucet.komodo.earth/faucet/$coin/$address'), + ); + + final Map json = jsonDecode(response.body); + return FaucetResponse.fromJson(json); + } catch (e) { + return FaucetResponse.error(e.toString()); + } +} diff --git a/lib/3p_api/faucet/faucet_response.dart b/lib/3p_api/faucet/faucet_response.dart new file mode 100644 index 0000000000..8fac33ce47 --- /dev/null +++ b/lib/3p_api/faucet/faucet_response.dart @@ -0,0 +1,59 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/utils/extensions/string_extensions.dart'; + +enum FaucetStatus { + success, + denied, + error, + loading; + + static FaucetStatus byNameOrError(String name) { + final isContains = FaucetStatus.values.map((e) => e.name).contains(name); + if (isContains) { + return FaucetStatus.values.byName(name); + } + return FaucetStatus.error; + } + + String get title => name.toCapitalize(); +} + +class FaucetResponse { + FaucetResponse({ + required this.status, + required this.address, + required this.message, + required this.coin, + }); + + factory FaucetResponse.fromJson(Map json) { + final result = json["result"]; + final status = FaucetStatus.byNameOrError(json["status"]); + + if (result != null && result is Map) { + return FaucetResponse( + status: status, + message: result["message"] ?? '', + address: result["address"] ?? '', + coin: result["coin"] ?? '', + ); + } else { + return FaucetResponse.error(LocaleKeys.faucetUnknownErrorMessage.tr()); + } + } + + factory FaucetResponse.error(String errorMessage) { + return FaucetResponse( + status: FaucetStatus.error, + message: errorMessage, + address: '', + coin: '', + ); + } + + final FaucetStatus status; + final String message; + final String coin; + final String address; +} diff --git a/lib/app_config/app_config.dart b/lib/app_config/app_config.dart new file mode 100644 index 0000000000..9b00f2e1d9 --- /dev/null +++ b/lib/app_config/app_config.dart @@ -0,0 +1,133 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; + +const String mmRpcVersion = '2.0'; +// issue https://github.com/flutter/flutter/issues/19462#issuecomment-478284020 +final GlobalKey scaffoldKey = GlobalKey(); +const double maxScreenWidth = 1273; +const double mainLayoutPadding = 29; +const double appBarHeight = 70; +const String allWalletsStorageKey = 'all-wallets'; +const String defaultDexCoin = 'KMD'; +const List localeList = [Locale('en')]; +const String assetsPath = 'assets'; + +// Temporary feature flag to allow merging of the PR +// TODO: Remove this flag after the feature is finalized +const bool isBitrefillIntegrationEnabled = false; + +const Duration kPerformanceLogInterval = Duration(minutes: 1); + +// This information is here because it is not contextual and is branded. +// Names of their own are not localized. Also, the application is initialized before +// the localization package is initialized. +String get appTitle => "Komodo Wallet | Non-Custodial Multi-Coin Wallet & DEX"; +String get appShortTitle => "Komodo Wallet"; + +// We're using a hardcoded seed for the hidden login instead +// of generating it on the fly. This will allow us to access +// previously connected Trezor wallet accounts data and speed up +// the reactivation of its coins. +String get seedForHiddenLogin => 'hidden-login'; + +Map priorityCoinsAbbrMap = { + 'KMD': 30, + 'BTC-segwit': 20, + 'ETH': 20, + 'LTC-segwit': 20, + 'USDT-ERC20': 20, + 'BNB': 11, + 'ETC': 11, + 'DOGE': 11, + 'DASH': 11, + 'MATIC': 10, + 'FTM': 10, + 'AVAX': 10, + 'HT': 10, + 'MOVR': 10, +}; + +const List excludedAssetList = [ + 'ADEXBSCT', + 'ADEXBSC', + 'BRC', + 'WID', + 'EPC', + 'CFUN', + 'ENT', + 'PLY', + 'ILNSW-PLG20', + 'FENIX', + 'AWR', + 'BOT', + 'ARRR', + 'ZOMBIE', + 'SMTF-v2', + 'SFUSD', + 'VOTE2023', + 'RICK', + 'MORTY', + + // NFT v2 coins: https://github.com/KomodoPlatform/coins/pull/1061 + // NFT upgrade is not merged yet, and the coins will likely be used in the + // background, so users do not need to see them. + 'NFT_ETH', + 'NFT_AVAX', + 'NFT_BNB', + 'NFT_FTM', + 'NFT_MATIC', +]; + +const List excludedAssetListTrezor = [ + // https://github.com/KomodoPlatform/atomicDEX-API/issues/1510 + 'BCH', + // https://github.com/KomodoPlatform/coins/pull/619/files + // Can't use modified config directly, since it includes features, + // not implemented on webdex side yet (e.g. 0.4.2 doesn't have segwit) + 'VAL', +]; + +// Assets in wallet-only mode on app level, +// global wallet-only assets are defined in coins config files. +const List appWalletOnlyAssetList = [ + 'BET', + 'BOTS', + 'CRYPTO', + 'DEX', + 'HODL', + 'JUMBLR', + 'MGW', + 'MSHARK', + 'PANGEA', + 'REVS', + 'SUPERNET', +]; + +List get enabledByDefaultCoins => [ + 'BTC-segwit', + 'KMD', + 'LTC-segwit', + 'ETH', + 'MATIC', + 'BNB', + 'AVAX', + 'FTM', + if (kDebugMode || kProfileMode) 'DOC', + if (kDebugMode || kProfileMode) 'MARTY', + ]; + +List get enabledByDefaultTrezorCoins => [ + 'BTC', + 'KMD', + 'LTC', + ]; + +List get coinsWithFaucet => ['RICK', 'MORTY', 'DOC', 'MARTY']; + +const String logsDbName = 'logs'; +const String appFolder = 'KomodoWallet'; + +Future get applicationDocumentsDirectory async => kIsWeb + ? appFolder + : '${(await getApplicationDocumentsDirectory()).path}/$appFolder'; diff --git a/lib/app_config/coins_config_parser.dart b/lib/app_config/coins_config_parser.dart new file mode 100644 index 0000000000..eea3ec0c73 --- /dev/null +++ b/lib/app_config/coins_config_parser.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/model/coin.dart'; + +final CoinConfigParser coinConfigParser = CoinConfigParser(); + +class CoinConfigParser { + List? _globalConfigCache; + + Future> getGlobalCoinsJson() async { + final List globalConfig = + _globalConfigCache ?? await _readGlobalConfig(); + final List filtered = _removeDelisted(globalConfig); + + return filtered; + } + + Future> _readGlobalConfig() async { + final String globalConfig = + await rootBundle.loadString('$assetsPath/config/coins.json'); + final List globalCoinsJson = jsonDecode(globalConfig); + + _globalConfigCache = globalCoinsJson; + return globalCoinsJson; + } + + /// Checks if the specified asset [path] exists. + /// Returns `true` if the asset exists, otherwise `false`. + Future doesAssetExist(String path) async { + try { + await rootBundle.load(path); + return true; + } catch (e) { + return false; + } + } + + /// Checks if the local coin configs exist. + /// Returns `true` if the local coin configs exist, otherwise `false`. + Future hasLocalConfigs({ + String coinsPath = '$assetsPath/config/coins.json', + String coinsConfigPath = '$assetsPath/config/coins_config.json', + }) async { + try { + final bool coinsFileExists = await doesAssetExist(coinsPath); + final bool coinsConfigFileExists = await doesAssetExist(coinsConfigPath); + return coinsFileExists && coinsConfigFileExists; + } catch (e) { + return false; + } + } + + Future> getUnifiedCoinsJson() async { + final Map json = await _readLocalConfig(); + final Map modifiedJson = _modifyLocalJson(json); + + return modifiedJson; + } + + Map _modifyLocalJson(Map source) { + final Map modifiedJson = {}; + + source.forEach((abbr, dynamic coinItem) { + if (_needSkipCoin(coinItem)) return; + + dynamic electrum = coinItem['electrum']; + // Web doesn't support SSL and TCP protocols, so we need to remove + // electrum servers with these protocols. + if (kIsWeb) { + removeElectrumsWithoutWss(electrum); + } + + coinItem['abbr'] = abbr; + coinItem['priority'] = priorityCoinsAbbrMap[abbr] ?? 0; + coinItem['active'] = enabledByDefaultCoins.contains(abbr); + modifiedJson[abbr] = coinItem; + }); + + return modifiedJson; + } + + /// Remove electrum servers without WSS protocol from [electrums]. + /// If [electrums] is a list, it will be modified in place. + /// Leaving as in-place modification for performance reasons. + void removeElectrumsWithoutWss(dynamic electrums) { + if (electrums is List) { + for (final e in electrums) { + if (e['protocol'] == 'WSS') { + e['ws_url'] = e['url']; + } + } + + electrums.removeWhere((dynamic e) => e['ws_url'] == null); + } + } + + Future> _readLocalConfig() async { + final String localConfig = + await rootBundle.loadString('$assetsPath/config/coins_config.json'); + final Map json = jsonDecode(localConfig); + + return json; + } + + bool _needSkipCoin(Map jsonCoin) { + final dynamic electrum = jsonCoin['electrum']; + if (excludedAssetList.contains(jsonCoin['coin'])) { + return true; + } + if (getCoinType(jsonCoin['type'], jsonCoin['coin']) == null) { + return true; + } + + return electrum is List && + electrum.every((dynamic e) => + e['ws_url'] == null && !_isProtocolSupported(e['protocol'])); + } + + /// Returns true if [protocol] is supported on the current platform. + /// On web, only WSS is supported. + /// On other (native) platforms, only SSL and TCP are supported. + bool _isProtocolSupported(String? protocol) { + if (protocol == null) { + return false; + } + + String uppercaseProtocol = protocol.toUpperCase(); + + if (kIsWeb) { + return uppercaseProtocol == 'WSS'; + } + + return uppercaseProtocol == 'SSL' || uppercaseProtocol == 'TCP'; + } + + List _removeDelisted(List all) { + final List filtered = List.from(all); + filtered.removeWhere( + (dynamic item) => excludedAssetList.contains(item['coin']), + ); + return filtered; + } +} diff --git a/lib/app_config/package_information.dart b/lib/app_config/package_information.dart new file mode 100644 index 0000000000..a664326e0d --- /dev/null +++ b/lib/app_config/package_information.dart @@ -0,0 +1,14 @@ +import 'package:package_info_plus/package_info_plus.dart'; + +PackageInformation packageInformation = PackageInformation(); + +class PackageInformation { + String? packageVersion; + String? packageName; + + Future init() async { + final PackageInfo packageInfo = await PackageInfo.fromPlatform(); + packageVersion = packageInfo.version; + packageName = packageInfo.packageName; + } +} diff --git a/lib/bloc/analytics/analytics_bloc.dart b/lib/bloc/analytics/analytics_bloc.dart new file mode 100644 index 0000000000..7723ff4268 --- /dev/null +++ b/lib/bloc/analytics/analytics_bloc.dart @@ -0,0 +1,63 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/analytics/analytics_repo.dart'; +import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/model/stored_settings.dart'; + +import 'analytics_event.dart'; +import 'analytics_state.dart'; + +class AnalyticsBloc extends Bloc { + AnalyticsBloc({ + required AnalyticsRepo analytics, + required StoredSettings storedData, + required SettingsRepository repository, + }) : _analytics = analytics, + _storedData = storedData, + _settingsRepo = repository, + super(AnalyticsState.fromSettings(storedData.analytics)) { + on(_onActivate); + on(_onDeactivate); + on(_onSendData); + } + + final AnalyticsRepo _analytics; + final StoredSettings _storedData; + final SettingsRepository _settingsRepo; + + Future _onActivate( + AnalyticsActivateEvent event, + Emitter emit, + ) async { + await _analytics.activate(); + emit(state.copyWith(isSendDataAllowed: true)); + await _settingsRepo.updateSettings( + _storedData.copyWith( + analytics: _storedData.analytics.copyWith(isSendAllowed: true), + ), + ); + } + + Future _onDeactivate( + AnalyticsDeactivateEvent event, + Emitter emit, + ) async { + await _analytics.deactivate(); + emit(state.copyWith(isSendDataAllowed: false)); + await _settingsRepo.updateSettings( + _storedData.copyWith( + analytics: _storedData.analytics.copyWith(isSendAllowed: false), + ), + ); + } + + Future _onSendData( + AnalyticsSendDataEvent event, + Emitter emitter, + ) async { + if (state.isSendDataAllowed) { + await _analytics.sendData(event.data); + } + } +} diff --git a/lib/bloc/analytics/analytics_event.dart b/lib/bloc/analytics/analytics_event.dart new file mode 100644 index 0000000000..130797402f --- /dev/null +++ b/lib/bloc/analytics/analytics_event.dart @@ -0,0 +1,18 @@ +import 'package:web_dex/bloc/analytics/analytics_repo.dart'; + +abstract class AnalyticsEvent { + const AnalyticsEvent(); +} + +class AnalyticsActivateEvent extends AnalyticsEvent { + const AnalyticsActivateEvent(); +} + +class AnalyticsDeactivateEvent extends AnalyticsEvent { + const AnalyticsDeactivateEvent(); +} + +class AnalyticsSendDataEvent extends AnalyticsEvent { + const AnalyticsSendDataEvent(this.data); + final AnalyticsEventData data; +} diff --git a/lib/bloc/analytics/analytics_repo.dart b/lib/bloc/analytics/analytics_repo.dart new file mode 100644 index 0000000000..eb6cc83511 --- /dev/null +++ b/lib/bloc/analytics/analytics_repo.dart @@ -0,0 +1,80 @@ +import 'package:firebase_analytics/firebase_analytics.dart'; +import 'package:firebase_core/firebase_core.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'; + +abstract class AnalyticsEventData { + late String name; + Map get parameters; +} + +abstract class AnalyticsRepo { + Future sendData(AnalyticsEventData data); + Future activate(); + Future deactivate(); +} + +class FirebaseAnalyticsRepo implements AnalyticsRepo { + FirebaseAnalyticsRepo(AnalyticsSettings settings) { + _initialize(settings); + } + + late FirebaseAnalytics _instance; + + bool _isInitialized = false; + + Future _initialize(AnalyticsSettings settings) async { + try { + if (!settings.isSendAllowed) return; + + await Firebase.initializeApp( + options: DefaultFirebaseOptions.currentPlatform, + ); + _instance = FirebaseAnalytics.instance; + + _isInitialized = true; + if (_isInitialized && settings.isSendAllowed) { + await activate(); + } else { + await deactivate(); + } + } catch (e) { + _isInitialized = false; + } + } + + @override + Future sendData(AnalyticsEventData event) async { + if (!_isInitialized) { + return; + } + + try { + await _instance.logEvent(name: event.name, parameters: event.parameters); + } catch (e, s) { + log( + e.toString(), + path: 'analytics -> FirebaseAnalyticsService -> logEvent', + trace: s, + isError: true, + ); + } + } + + @override + Future activate() async { + if (!_isInitialized) { + return; + } + await _instance.setAnalyticsCollectionEnabled(true); + } + + @override + Future deactivate() async { + if (!_isInitialized) { + return; + } + await _instance.setAnalyticsCollectionEnabled(false); + } +} diff --git a/lib/bloc/analytics/analytics_state.dart b/lib/bloc/analytics/analytics_state.dart new file mode 100644 index 0000000000..40c184ddf1 --- /dev/null +++ b/lib/bloc/analytics/analytics_state.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/model/settings/analytics_settings.dart'; + +class AnalyticsState extends Equatable { + const AnalyticsState({ + required this.isSendDataAllowed, + }); + static AnalyticsState initial() => + const AnalyticsState(isSendDataAllowed: false); + + static AnalyticsState fromSettings(AnalyticsSettings settings) => + AnalyticsState(isSendDataAllowed: settings.isSendAllowed); + + AnalyticsState copyWith({bool? isSendDataAllowed}) { + return AnalyticsState( + isSendDataAllowed: isSendDataAllowed ?? this.isSendDataAllowed, + ); + } + + final bool isSendDataAllowed; + + @override + List get props => [isSendDataAllowed]; +} diff --git a/lib/bloc/app_bloc_observer.dart b/lib/bloc/app_bloc_observer.dart new file mode 100644 index 0000000000..7692f64a0d --- /dev/null +++ b/lib/bloc/app_bloc_observer.dart @@ -0,0 +1,37 @@ +import 'package:bloc/bloc.dart'; +import 'package:flutter/foundation.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class AppBlocObserver extends BlocObserver { + @override + void onChange(BlocBase bloc, Change change) { + if (kDebugMode) { + // print(change); + } + + super.onChange(bloc, change); + } + + @override + void onTransition(Bloc bloc, Transition transition) { + if (kDebugMode) { + // print(transition); + } + + super.onTransition(bloc, transition); + } + + @override + void onError(BlocBase bloc, Object error, StackTrace stackTrace) { + log('${bloc.runtimeType}: $error', trace: stackTrace, isError: true); + + // ignore: avoid_print + print('\x1B[31mAppBlocObserver -> onError\x1B[0m'); + // ignore: avoid_print + print('\x1B[31m${bloc.runtimeType}: $error\x1B[0m'); + // ignore: avoid_print + print('\x1B[31mTrace: $stackTrace\x1B[0m'); + + super.onError(bloc, error, stackTrace); + } +} diff --git a/lib/bloc/app_bloc_root.dart b/lib/bloc/app_bloc_root.dart new file mode 100644 index 0000000000..1487b5215c --- /dev/null +++ b/lib/bloc/app_bloc_root.dart @@ -0,0 +1,353 @@ +import 'dart:async'; + +import 'package:app_theme/app_theme.dart'; +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:http/http.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_coin_updates/komodo_coin_updates.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/bloc/analytics/analytics_repo.dart'; +import 'package:web_dex/bloc/assets_overview/bloc/asset_overview_bloc.dart'; +import 'package:web_dex/bloc/assets_overview/investment_repository.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/bitrefill/bloc/bitrefill_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_repository.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/generator.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; +import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart'; +import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; +import 'package:web_dex/bloc/cex_market_data/price_chart/price_chart_bloc.dart'; +import 'package:web_dex/bloc/cex_market_data/price_chart/price_chart_event.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/dex_repository.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_repository.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/market_maker_bot_order_list_repository.dart'; +import 'package:web_dex/bloc/nfts/nft_main_bloc.dart'; +import 'package:web_dex/bloc/nfts/nft_main_repo.dart'; +import 'package:web_dex/bloc/runtime_coin_updates/coin_config_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_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/transaction_history/transaction_history_bloc.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; +import 'package:web_dex/bloc/trezor_bloc/trezor_repo.dart'; +import 'package:web_dex/bloc/trezor_connection_bloc/trezor_connection_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/main.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/model/stored_settings.dart'; +import 'package:web_dex/router/navigators/app_router_delegate.dart'; +import 'package:web_dex/router/navigators/back_dispatcher.dart'; +import 'package:web_dex/router/parsers/root_route_parser.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/services/orders_service/my_orders_service.dart'; +import 'package:web_dex/shared/utils/debug_utils.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/coin_icon.dart'; + +class AppBlocRoot extends StatelessWidget { + const AppBlocRoot({ + Key? key, + required this.storedPrefs, + required this.runtimeUpdateConfig, + }); + + final StoredSettings storedPrefs; + final RuntimeUpdateConfig runtimeUpdateConfig; + + // TODO: Refactor to clean up the bloat in this main file + void _clearCachesIfPerformanceModeChanged( + PerformanceMode? performanceMode, + ProfitLossRepository profitLossRepo, + PortfolioGrowthRepository portfolioGrowthRepo, + ) async { + final sharedPrefs = await SharedPreferences.getInstance(); + + final storedLastPerformanceMode = + sharedPrefs.getString('last_performance_mode'); + + if (storedLastPerformanceMode != performanceMode?.name) { + profitLossRepo.clearCache().ignore(); + portfolioGrowthRepo.clearCache().ignore(); + } + if (performanceMode == null) { + sharedPrefs.remove('last_performance_mode').ignore(); + } else { + sharedPrefs + .setString('last_performance_mode', performanceMode.name) + .ignore(); + } + } + + @override + Widget build(BuildContext context) { + final performanceMode = appDemoPerformanceMode; + + final transactionsRepo = performanceMode != null + ? MockTransactionHistoryRepo( + api: mm2Api, + client: Client(), + performanceMode: performanceMode, + demoDataGenerator: DemoDataCache.withDefaults(), + ) + : TransactionHistoryRepo(api: mm2Api, client: Client()); + + + 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, + ); + + final portfolioGrowthRepo = PortfolioGrowthRepository.withDefaults( + transactionHistoryRepo: transactionsRepo, + cexRepository: binanceRepository, + demoMode: performanceMode, + ); + + _clearCachesIfPerformanceModeChanged( + performanceMode, + profitLossRepo, + portfolioGrowthRepo, + ); + + return MultiRepositoryProvider( + providers: [RepositoryProvider(create: (_) => NftsRepo(api: mm2Api.nft))], + child: MultiBlocProvider( + providers: [ + BlocProvider( + create: (context) => PriceChartBloc(binanceRepository) + ..add( + const PriceChartStarted( + symbols: ['KMD'], + period: Duration(days: 30), + ), + ), + ), + BlocProvider( + create: (context) => AssetOverviewBloc( + investmentRepository: InvestmentRepository( + profitLossRepository: profitLossRepo, + ), + profitLossRepository: profitLossRepo, + ), + ), + BlocProvider( + create: (context) => ProfitLossBloc( + profitLossRepository: profitLossRepo, + ), + ), + BlocProvider( + create: (BuildContext ctx) => PortfolioGrowthBloc( + portfolioGrowthRepository: portfolioGrowthRepo, + ), + ), + BlocProvider( + create: (BuildContext ctx) => TransactionHistoryBloc( + repo: transactionsRepo, + ), + ), + BlocProvider( + create: (context) => + SettingsBloc(storedPrefs, SettingsRepository()), + ), + BlocProvider( + // lazy: false, + create: (context) => AnalyticsBloc( + analytics: FirebaseAnalyticsRepo(storedPrefs.analytics), + storedData: storedPrefs, + repository: SettingsRepository(), + ), + ), + BlocProvider( + create: (context) => TakerBloc( + authRepo: authRepo, + dexRepository: dexRepository, + coinsRepository: coinsBloc, + ), + ), + BlocProvider( + create: (context) => BridgeBloc( + authRepository: authRepo, + dexRepository: dexRepository, + bridgeRepository: BridgeRepository.instance, + coinsRepository: coinsBloc, + ), + ), + BlocProvider( + create: (_) => TrezorConnectionBloc( + trezorRepo: trezorRepo, + authRepo: authRepo, + walletRepo: currentWalletBloc, + ), + lazy: false, + ), + BlocProvider( + lazy: false, + create: (context) => NftMainBloc( + repo: context.read(), + authRepo: authRepo, + isLoggedIn: + context.read().state.mode == AuthorizeMode.logIn, + ), + ), + if (isBitrefillIntegrationEnabled) + BlocProvider( + create: (context) => + BitrefillBloc()..add(const BitrefillLoadRequested()), + ), + BlocProvider( + create: (context) => MarketMakerBotBloc( + MarketMakerBotRepository( + mm2Api, + SettingsRepository(), + ), + MarketMakerBotOrderListRepository( + myOrdersService, + SettingsRepository(), + ), + ), + ), + BlocProvider( + create: (_) => SystemHealthBloc(), + ), + BlocProvider( + lazy: false, + create: (_) => CoinConfigBloc( + coinsConfigRepo: CoinConfigRepository.withDefaults( + runtimeUpdateConfig, + ), + ) + ..add(CoinConfigLoadRequested()) + ..add(CoinConfigUpdateSubscribeRequested()), + ), + ], + child: _MyAppView(), + ), + ); + } +} + +class _MyAppView extends StatefulWidget { + @override + State<_MyAppView> createState() => _MyAppViewState(); +} + +class _MyAppViewState extends State<_MyAppView> { + final AppRouterDelegate _routerDelegate = AppRouterDelegate(); + final RootRouteInformationParser _routeInformationParser = + RootRouteInformationParser(); + late final AirDexBackButtonDispatcher _airDexBackButtonDispatcher; + + @override + void initState() { + _airDexBackButtonDispatcher = AirDexBackButtonDispatcher(_routerDelegate); + routingState.selectedMenu = MainMenuValue.dex; + + if (kDebugMode) initDebugData(context.read()); + + unawaited(_hideAppLoader()); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return MaterialApp.router( + onGenerateTitle: (_) => appTitle, + themeMode: context + .select((SettingsBloc settingsBloc) => settingsBloc.state.themeMode), + darkTheme: theme.global.dark, + theme: theme.global.light, + routerDelegate: _routerDelegate, + locale: context.locale, + localizationsDelegates: context.localizationDelegates, + supportedLocales: context.supportedLocales, + routeInformationParser: _routeInformationParser, + backButtonDispatcher: _airDexBackButtonDispatcher, + ); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + + _precacheCoinIcons().ignore(); + } + + /// Hides the native app launch loader. Currently only implemented for web. + // TODO: Consider using ab abstract class with separate implementations for + // web and native to avoid web-code in code concerning all platforms. + Future _hideAppLoader() async { + if (kIsWeb) { + html.document.getElementById('main-content')?.style.display = 'block'; + + final loadingElement = html.document.getElementById('loading'); + + if (loadingElement == null) return; + + // Trigger the zoom out animation. + loadingElement.classes.add('init_done'); + + // Await 200ms so the user can see the animation. + await Future.delayed(const Duration(milliseconds: 200)); + + // Remove the loading indicator. + loadingElement.remove(); + } + } + + Completer? _currentPrecacheOperation; + + Future _precacheCoinIcons() async { + if (_currentPrecacheOperation != null && + !_currentPrecacheOperation!.isCompleted) { + _currentPrecacheOperation! + .completeError('New request to precache icons started.'); + } + + _currentPrecacheOperation = Completer(); + + try { + final coins = (await coinsRepo.getKnownCoins()).map((coin) => coin.abbr); + + await for (final abbr in Stream.fromIterable(coins)) { + // TODO: Test if necessary to complete prematurely with error if build + // context is stale. Alternatively, we can check if the context is + // not mounted and return early with error. + // ignore: use_build_context_synchronously + // if (context.findRenderObject() == null) { + // _currentPrecacheOperation!.completeError('Build context is stale.'); + // return; + // } + + // ignore: use_build_context_synchronously + await CoinIcon.precacheCoinIcon(context, abbr) + .onError((_, __) => debugPrint('Error precaching coin icon $abbr')); + } + + _currentPrecacheOperation!.complete(); + } 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 new file mode 100644 index 0000000000..8b96403366 --- /dev/null +++ b/lib/bloc/assets_overview/bloc/asset_overview_bloc.dart @@ -0,0 +1,251 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/assets_overview/investment_repository.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/models/fiat_value.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss.dart'; +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; + +part 'asset_overview_event.dart'; +part 'asset_overview_state.dart'; + +class AssetOverviewBloc extends Bloc { + AssetOverviewBloc({ + required this.profitLossRepository, + required this.investmentRepository, + }) : super(const AssetOverviewInitial()) { + on(_onLoad); + on(_onClear); + on(_onLoadPortfolio); + on(_onSubscribe); + on(_onSubscribePortfolio); + on(_onUnsubscribe); + on(_onUnsubscribePortfolio); + } + + final ProfitLossRepository profitLossRepository; + final InvestmentRepository investmentRepository; + + Timer? _updateTimer; + + Future _onLoad( + AssetOverviewLoadRequested event, + Emitter emit, + ) async { + emit(const AssetOverviewLoadInProgress()); + + try { + final profitLosses = await profitLossRepository.getProfitLoss( + event.coin.abbr, + 'USDT', + event.walletId, + ); + + 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 + final investmentReturnPercentage = + (profitAmount / totalInvestment.value) * 100; + + emit( + AssetOverviewLoadSuccess( + totalInvestment: totalInvestment, + // TODO! Get current coin value + // totalValue: profitAmount, + totalValue: totalInvestment, + profitAmount: FiatValue.usd(profitAmount), + investmentReturnPercentage: investmentReturnPercentage, + ), + ); + } catch (e) { + logger.log('Failed to load asset overview: $e', isError: true); + if (state is! AssetOverviewLoadSuccess) { + emit(AssetOverviewLoadFailure(error: e.toString())); + } + } + } + + Future _onClear( + AssetOverviewClearRequested event, + Emitter emit, + ) async { + emit(const AssetOverviewInitial()); + _updateTimer?.cancel(); + _updateTimer = null; + } + + Future _onLoadPortfolio( + PortfolioAssetsOverviewLoadRequested event, + Emitter emit, + ) async { + // nothing listens to this. The UI just resets to default values, i.e. 0 + // emit(const AssetOverviewLoadInProgress()); + + try { + final profitLossesFutures = event.coins.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. + try { + return await profitLossRepository.getProfitLoss( + coin.abbr, + 'USDT', + event.walletId, + ); + } catch (e) { + return Future.value([]); + } + }); + + final profitLosses = await Future.wait(profitLossesFutures); + + final totalInvestment = + await investmentRepository.calculateTotalInvestment( + event.walletId, + event.coins, + ); + + final profitAmount = profitLosses.fold(0.0, (sum, item) { + return sum + (item.lastOrNull?.profitLoss ?? 0.0); + }); + + final double portfolioInvestmentReturnPercentage = + _calculateInvestmentReturnPercentage(profitAmount, totalInvestment); + // Total profit / total purchase amount + final assetPortionPercentages = + _calculateAssetPortionPercentages(profitLosses, profitAmount); + + emit( + PortfolioAssetsOverviewLoadSuccess( + selectedAssetIds: event.coins.map((coin) => coin.abbr).toList(), + assetPortionPercentages: assetPortionPercentages, + totalInvestment: totalInvestment, + totalValue: FiatValue.usd(profitAmount), + profitAmount: FiatValue.usd(profitAmount), + profitIncreasePercentage: portfolioInvestmentReturnPercentage, + ), + ); + } catch (e) { + logger.log('Failed to load portfolio assets overview: $e', isError: true); + if (state is! PortfolioAssetsOverviewLoadSuccess) { + emit(AssetOverviewLoadFailure(error: e.toString())); + } + } + } + + double _calculateInvestmentReturnPercentage( + double profitAmount, + FiatValue totalInvestment, + ) { + if (totalInvestment.value == 0) return 0.0; + + final portfolioInvestmentReturnPercentage = + (profitAmount / totalInvestment.value) * 100; + return portfolioInvestmentReturnPercentage; + } + + Future _onSubscribe( + AssetOverviewSubscriptionRequested event, + Emitter emit, + ) async { + add( + AssetOverviewLoadRequested( + coin: event.coin, + walletId: event.walletId, + ), + ); + + _updateTimer?.cancel(); + _updateTimer = Timer.periodic(event.updateFrequency, (_) { + add( + AssetOverviewLoadRequested( + coin: event.coin, + walletId: event.walletId, + ), + ); + }); + } + + Future _onSubscribePortfolio( + PortfolioAssetsOverviewSubscriptionRequested event, + Emitter emit, + ) async { + add( + PortfolioAssetsOverviewLoadRequested( + coins: event.coins, + walletId: event.walletId, + ), + ); + + _updateTimer?.cancel(); + _updateTimer = Timer.periodic(event.updateFrequency, (_) { + add( + PortfolioAssetsOverviewLoadRequested( + coins: event.coins, + walletId: event.walletId, + ), + ); + }); + } + + Future _onUnsubscribe( + AssetOverviewUnsubscriptionRequested event, + Emitter emit, + ) async { + _updateTimer?.cancel(); + _updateTimer = null; + } + + Future _onUnsubscribePortfolio( + PortfolioAssetsOverviewUnsubscriptionRequested event, + Emitter emit, + ) async { + _updateTimer?.cancel(); + _updateTimer = null; + } + + double _calculatePercentageIncrease(List profitLosses) { + if (profitLosses.length < 2) return 0.0; + + final oldestValue = profitLosses.first.fiatPrice.value; + final newestValue = profitLosses.last.fiatPrice.value; + + if (oldestValue < 0 && newestValue >= 0) { + final double totalChange = newestValue + oldestValue.abs(); + return (totalChange / oldestValue.abs()) * 100; + } else { + return ((newestValue - oldestValue) / oldestValue.abs()) * 100; + } + } + + Map _calculateAssetPortionPercentages( + List> profitLosses, + double totalProfit, + ) { + final Map assetPortionPercentages = {}; + + for (final coinProfitLosses in profitLosses) { + if (coinProfitLosses.isNotEmpty) { + final coinId = coinProfitLosses.first.coin; + final coinTotalProfit = _calculatePercentageIncrease(coinProfitLosses); + assetPortionPercentages[coinId] = (coinTotalProfit / totalProfit) * 100; + } + } + + return assetPortionPercentages; + } + + @override + Future close() { + _updateTimer?.cancel(); + return super.close(); + } +} diff --git a/lib/bloc/assets_overview/bloc/asset_overview_event.dart b/lib/bloc/assets_overview/bloc/asset_overview_event.dart new file mode 100644 index 0000000000..626a9f3341 --- /dev/null +++ b/lib/bloc/assets_overview/bloc/asset_overview_event.dart @@ -0,0 +1,73 @@ +part of 'asset_overview_bloc.dart'; + +abstract class AssetOverviewEvent extends Equatable { + const AssetOverviewEvent(); + + @override + List get props => []; +} + +class AssetOverviewClearRequested extends AssetOverviewEvent { + const AssetOverviewClearRequested(); +} + +class AssetOverviewLoadRequested extends AssetOverviewEvent { + const AssetOverviewLoadRequested({ + required this.coin, + required this.walletId, + }); + + final Coin coin; + final String walletId; + + @override + List get props => [coin, walletId]; +} + +class PortfolioAssetsOverviewLoadRequested extends AssetOverviewEvent { + const PortfolioAssetsOverviewLoadRequested({ + required this.coins, + required this.walletId, + }); + + final List coins; + final String walletId; + + @override + List get props => [coins, walletId]; +} + +class AssetOverviewSubscriptionRequested extends AssetOverviewEvent { + const AssetOverviewSubscriptionRequested({ + required this.coin, + required this.walletId, + required this.updateFrequency, + }); + + final Coin coin; + final String walletId; + final Duration updateFrequency; + + @override + List get props => [coin, walletId, updateFrequency]; +} + +class PortfolioAssetsOverviewSubscriptionRequested extends AssetOverviewEvent { + const PortfolioAssetsOverviewSubscriptionRequested({ + required this.coins, + required this.walletId, + required this.updateFrequency, + }); + + final List coins; + final String walletId; + final Duration updateFrequency; + + @override + List get props => [coins, walletId, updateFrequency]; +} + +class AssetOverviewUnsubscriptionRequested extends AssetOverviewEvent {} + +class PortfolioAssetsOverviewUnsubscriptionRequested + extends AssetOverviewEvent {} diff --git a/lib/bloc/assets_overview/bloc/asset_overview_state.dart b/lib/bloc/assets_overview/bloc/asset_overview_state.dart new file mode 100644 index 0000000000..dc0b1d02b4 --- /dev/null +++ b/lib/bloc/assets_overview/bloc/asset_overview_state.dart @@ -0,0 +1,77 @@ +part of 'asset_overview_bloc.dart'; + +abstract class AssetOverviewState extends Equatable { + const AssetOverviewState(); + + @override + List get props => []; +} + +class AssetOverviewInitial extends AssetOverviewState { + const AssetOverviewInitial(); +} + +class AssetOverviewLoadInProgress extends AssetOverviewState { + const AssetOverviewLoadInProgress(); +} + +class AssetOverviewLoadSuccess extends AssetOverviewState { + const AssetOverviewLoadSuccess({ + required this.totalValue, + required this.totalInvestment, + required this.profitAmount, + required this.investmentReturnPercentage, + }); + + final EntityWithValue totalValue; + final EntityWithValue totalInvestment; + final EntityWithValue profitAmount; + final double investmentReturnPercentage; + + @override + List get props => [ + totalValue, + totalInvestment, + profitAmount, + investmentReturnPercentage, + ]; +} + +class AssetOverviewLoadFailure extends AssetOverviewState { + const AssetOverviewLoadFailure({required this.error}); + + final String error; + + @override + List get props => [error]; +} + +class PortfolioAssetsOverviewLoadSuccess extends AssetOverviewState { + const PortfolioAssetsOverviewLoadSuccess({ + required this.selectedAssetIds, + required this.assetPortionPercentages, + required this.totalInvestment, + required this.totalValue, + required this.profitAmount, + required this.profitIncreasePercentage, + }); + + final List selectedAssetIds; + final Map assetPortionPercentages; + final EntityWithValue totalInvestment; + final EntityWithValue totalValue; + final EntityWithValue profitAmount; + final double profitIncreasePercentage; + + int get assetsCount => selectedAssetIds.length; + + @override + List get props => [ + selectedAssetIds, + assetPortionPercentages, + totalInvestment, + totalValue, + profitAmount, + profitIncreasePercentage, + ]; +} diff --git a/lib/bloc/assets_overview/investment_repository.dart b/lib/bloc/assets_overview/investment_repository.dart new file mode 100644 index 0000000000..825c4fa1f0 --- /dev/null +++ b/lib/bloc/assets_overview/investment_repository.dart @@ -0,0 +1,65 @@ +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; + + final ProfitLossRepository _profitLossRepository; + + // TODO: Create a balance repository to fetch the current balance for a coin + // and also calculate its fiat value + + /// Calculates the total investment for all coins. + /// [walletId] is the wallet ID associated with the profit/loss data. + /// [coins] is the list of coins to calculate the investment for. + /// + /// Returns the [FiatValue] of the total investment. + Future calculateTotalInvestment( + String walletId, + List coins, + ) async { + final fetchCoinProfitFutures = coins.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. + try { + final profitLoss = await _profitLossRepository.getProfitLoss( + coin.abbr, + 'USDT', + walletId, + ); + + if (profitLoss.isEmpty) { + return FiatValue.usd(0); + } + + final purchases = profitLoss.where((item) => item.myBalanceChange > 0); + final totalPurchased = FiatValue.usd( + purchases.fold( + 0.0, + (sum, item) => sum + (item.myBalanceChange * item.fiatPrice.value), + ), + ); + + return totalPurchased; + } catch (e) { + logger.log('Failed to calculate total investment: $e', isError: true); + return FiatValue.usd(0); + } + }); + + final coinInvestments = await Future.wait(fetchCoinProfitFutures); + + final totalInvestment = coinInvestments.fold( + 0.0, + (sum, item) => sum + item.value, + ); + + return FiatValue.usd(totalInvestment); + } +} diff --git a/lib/bloc/auth_bloc/auth_bloc.dart b/lib/bloc/auth_bloc/auth_bloc.dart new file mode 100644 index 0000000000..8ccd4a7f00 --- /dev/null +++ b/lib/bloc/auth_bloc/auth_bloc.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/services/auth_checker/auth_checker.dart'; +import 'package:web_dex/services/auth_checker/get_auth_checker.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +import 'auth_bloc_event.dart'; +import 'auth_bloc_state.dart'; +import 'auth_repository.dart'; + +class AuthBloc extends Bloc { + AuthBloc({required AuthRepository authRepo}) + : _authRepo = authRepo, + super(AuthBlocState.initial()) { + on(_onAuthChanged); + on(_onLogout); + on(_onReLogIn); + _authorizationSubscription = _authRepo.authMode.listen((event) { + add(AuthChangedEvent(mode: event)); + }); + } + late StreamSubscription _authorizationSubscription; + final AuthRepository _authRepo; + final AuthChecker _authChecker = getAuthChecker(); + + Stream get outAuthorizeMode => _authRepo.authMode; + + @override + Future close() async { + _authorizationSubscription.cancel(); + super.close(); + } + + Future isLoginAllowed(Wallet newWallet) async { + final String walletEncryptedSeed = newWallet.config.seedPhrase; + final bool isLoginAllowed = + await _authChecker.askConfirmLoginIfNeeded(walletEncryptedSeed); + return isLoginAllowed; + } + + Future _onLogout( + AuthLogOutEvent event, + Emitter emit, + ) async { + log( + 'Logging out from a wallet', + path: 'auth_bloc => _logOut', + ); + + await _logOut(); + await _authRepo.logIn(AuthorizeMode.noLogin); + + log( + 'Logged out from a wallet', + path: 'auth_bloc => _logOut', + ); + } + + Future _onReLogIn( + AuthReLogInEvent event, + Emitter emit, + ) async { + log( + 're-login from a wallet', + path: 'auth_bloc => _reLogin', + ); + + await _logOut(); + await _logIn(event.seed, event.wallet); + + log( + 're-logged in from a wallet', + path: 'auth_bloc => _reLogin', + ); + } + + Future _onAuthChanged( + AuthChangedEvent event, Emitter emit) async { + emit(AuthBlocState(mode: event.mode)); + } + + Future _logOut() async { + await _authRepo.logOut(); + final currentWallet = currentWalletBloc.wallet; + if (currentWallet != null && + currentWallet.config.type == WalletType.iguana) { + _authChecker.removeSession(currentWallet.config.seedPhrase); + } + currentWalletBloc.wallet = null; + } + + Future _logIn( + String seed, + Wallet wallet, + ) async { + await _authRepo.logIn(AuthorizeMode.logIn, seed); + currentWalletBloc.wallet = wallet; + if (wallet.config.type == WalletType.iguana) { + _authChecker.addSession(wallet.config.seedPhrase); + } + } +} diff --git a/lib/bloc/auth_bloc/auth_bloc_event.dart b/lib/bloc/auth_bloc/auth_bloc_event.dart new file mode 100644 index 0000000000..b1b8c64f39 --- /dev/null +++ b/lib/bloc/auth_bloc/auth_bloc_event.dart @@ -0,0 +1,22 @@ +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/wallet.dart'; + +abstract class AuthBlocEvent { + const AuthBlocEvent(); +} + +class AuthChangedEvent extends AuthBlocEvent { + const AuthChangedEvent({required this.mode}); + final AuthorizeMode mode; +} + +class AuthLogOutEvent extends AuthBlocEvent { + const AuthLogOutEvent(); +} + +class AuthReLogInEvent extends AuthBlocEvent { + const AuthReLogInEvent({required this.seed, required this.wallet}); + + final String seed; + final Wallet wallet; +} diff --git a/lib/bloc/auth_bloc/auth_bloc_state.dart b/lib/bloc/auth_bloc/auth_bloc_state.dart new file mode 100644 index 0000000000..c935dd53ed --- /dev/null +++ b/lib/bloc/auth_bloc/auth_bloc_state.dart @@ -0,0 +1,12 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/model/authorize_mode.dart'; + +class AuthBlocState extends Equatable { + const AuthBlocState({required this.mode}); + + factory AuthBlocState.initial() => + const AuthBlocState(mode: AuthorizeMode.noLogin); + final AuthorizeMode mode; + @override + List get props => [mode]; +} diff --git a/lib/bloc/auth_bloc/auth_repository.dart b/lib/bloc/auth_bloc/auth_repository.dart new file mode 100644 index 0000000000..cb62dfa571 --- /dev/null +++ b/lib/bloc/auth_bloc/auth_repository.dart @@ -0,0 +1,38 @@ +import 'dart:async'; + +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class AuthRepository { + AuthRepository(); + final StreamController _authController = + StreamController.broadcast(); + Stream get authMode => _authController.stream; + Future logIn(AuthorizeMode mode, [String? seed]) async { + await _startMM2(seed); + await waitMM2StatusChange(MM2Status.rpcIsUp, mm2, waitingTime: 60000); + setAuthMode(mode); + } + + void setAuthMode(AuthorizeMode mode) { + _authController.sink.add(mode); + } + + Future logOut() async { + await mm2.stop(); + await waitMM2StatusChange(MM2Status.isNotRunningYet, mm2); + setAuthMode(AuthorizeMode.noLogin); + } + + Future _startMM2(String? seed) async { + try { + await mm2.start(seed); + } catch (e) { + log('mm2 start error: ${e.toString()}'); + rethrow; + } + } +} + +final AuthRepository authRepo = AuthRepository(); diff --git a/lib/bloc/bitrefill/bloc/bitrefill_bloc.dart b/lib/bloc/bitrefill/bloc/bitrefill_bloc.dart new file mode 100644 index 0000000000..01c8a53f4d --- /dev/null +++ b/lib/bloc/bitrefill/bloc/bitrefill_bloc.dart @@ -0,0 +1,79 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/bitrefill/data/bitrefill_repository.dart'; +import 'package:web_dex/bloc/bitrefill/models/bitrefill_payment_intent_event.dart'; +import 'package:web_dex/model/coin.dart'; + +part 'bitrefill_event.dart'; +part 'bitrefill_state.dart'; + +class BitrefillBloc extends Bloc { + BitrefillBloc() + : _bitrefillRepository = BitrefillRepository(), + super(BitrefillInitial()) { + on(_onBitrefillLoadRequested); + on(_onBitrefillLaunchRequested); + on(_onBitrefillPaymentIntentReceived); + on(_onBitrefillPaymentCompleted); + } + + final BitrefillRepository _bitrefillRepository; + StreamSubscription? _paymentIntentSubscription; + + Future _onBitrefillLoadRequested( + BitrefillLoadRequested event, + Emitter emit, + ) async { + emit(const BitrefillLoadInProgress()); + final String url = _bitrefillRepository.embeddedBitrefillUrl( + coinAbbr: event.coin?.abbr, + refundAddress: event.coin?.address, + ); + + final List supportedCoins = + _bitrefillRepository.bitrefillSupportedCoins; + emit(BitrefillLoadSuccess(url, supportedCoins)); + } + + void _onBitrefillLaunchRequested( + BitrefillEvent event, + Emitter emit, + ) { + _paymentIntentSubscription?.cancel(); + _paymentIntentSubscription = _bitrefillRepository + .watchPaymentIntent() + .listen((BitrefillPaymentIntentEvent event) { + add(BitrefillPaymentIntentReceived(event)); + }); + } + + void _onBitrefillPaymentIntentReceived( + BitrefillPaymentIntentReceived event, + Emitter emit, + ) { + emit(BitrefillPaymentInProgress(event.paymentIntentRecieved)); + } + + void _onBitrefillPaymentCompleted( + BitrefillPaymentCompleted event, + Emitter emit, + ) { + if (state is! BitrefillPaymentInProgress) { + return; + } + + final String invoiceId = (state as BitrefillPaymentInProgress) + .paymentIntent + .invoiceId + .toString(); + emit(BitrefillPaymentSuccess(invoiceId: invoiceId)); + } + + @override + Future close() { + _paymentIntentSubscription?.cancel(); + return super.close(); + } +} diff --git a/lib/bloc/bitrefill/bloc/bitrefill_event.dart b/lib/bloc/bitrefill/bloc/bitrefill_event.dart new file mode 100644 index 0000000000..e8f0e9d13a --- /dev/null +++ b/lib/bloc/bitrefill/bloc/bitrefill_event.dart @@ -0,0 +1,45 @@ +part of 'bitrefill_bloc.dart'; + +sealed class BitrefillEvent extends Equatable { + const BitrefillEvent(); + + @override + List get props => []; +} + +/// Request to load the bitrefill state with the url and supported coins +/// from the bitrefill provider. +final class BitrefillLoadRequested extends BitrefillEvent { + const BitrefillLoadRequested({this.coin}); + + final Coin? coin; + + @override + List get props => []; +} + +/// Request to open the Bitrefill widget to make a purchase +final class BitrefillLaunchRequested extends BitrefillEvent { + const BitrefillLaunchRequested(); + + @override + List get props => []; +} + +/// Event that is fired when the Bitrefill payment intent is received +final class BitrefillPaymentIntentReceived extends BitrefillEvent { + const BitrefillPaymentIntentReceived(this.paymentIntentRecieved); + + final BitrefillPaymentIntentEvent paymentIntentRecieved; + + @override + List get props => [paymentIntentRecieved]; +} + +/// Payment was completed successfully in the Withdrawal page +final class BitrefillPaymentCompleted extends BitrefillEvent { + const BitrefillPaymentCompleted(); + + @override + List get props => []; +} diff --git a/lib/bloc/bitrefill/bloc/bitrefill_state.dart b/lib/bloc/bitrefill/bloc/bitrefill_state.dart new file mode 100644 index 0000000000..aa0d83cca2 --- /dev/null +++ b/lib/bloc/bitrefill/bloc/bitrefill_state.dart @@ -0,0 +1,79 @@ +part of 'bitrefill_bloc.dart'; + +sealed class BitrefillState extends Equatable { + const BitrefillState(); + + @override + List get props => []; +} + +final class BitrefillInitial extends BitrefillState {} + +/// Payment intent was recieved from Bitrefill and is in progress +final class BitrefillPaymentInProgress extends BitrefillState { + const BitrefillPaymentInProgress(this.paymentIntent); + + /// This contains the payment details required to make the payment. + final BitrefillPaymentIntentEvent paymentIntent; + + @override + List get props => [paymentIntent]; +} + +/// The payment was successful from Komodo Wallet to the Bitrefill address. +final class BitrefillPaymentSuccess extends BitrefillState { + const BitrefillPaymentSuccess({ + required this.invoiceId, + }); + + /// The Bitrefill invoice ID. This is used to track the payment. + final String invoiceId; + + @override + List get props => []; +} + +/// The payment failed from Komodo Wallet to the Bitrefill address. +final class BitrefillPaymentFailure extends BitrefillState { + const BitrefillPaymentFailure(this.message); + + /// The error message or message to be displayed to the user. + final String message; + + @override + List get props => [message]; +} + +/// Bitrefill state loading is in progress to get the url and supported coins. +final class BitrefillLoadInProgress extends BitrefillState { + const BitrefillLoadInProgress(); + + @override + List get props => []; +} + +/// Bitrefill state was successfully loaded with the url and supported coins +/// from the bitrefill provider. +final class BitrefillLoadSuccess extends BitrefillState { + const BitrefillLoadSuccess(this.url, this.supportedCoins); + + /// The Bitrefill url to load the Embedded Bitrefill widget. + final String url; + + /// The list of coins supported as payment methods by Bitrefill. + final List supportedCoins; + + @override + List get props => [url, supportedCoins]; +} + +/// Bitrefill state failed to load with the given [message]. +final class BitrefillLoadFailure extends BitrefillState { + const BitrefillLoadFailure(this.message); + + /// The error message or message to be displayed to the user. + final String message; + + @override + List get props => [message]; +} diff --git a/lib/bloc/bitrefill/data/bitrefill_provider.dart b/lib/bloc/bitrefill/data/bitrefill_provider.dart new file mode 100644 index 0000000000..a7de500690 --- /dev/null +++ b/lib/bloc/bitrefill/data/bitrefill_provider.dart @@ -0,0 +1,71 @@ +import 'package:flutter/foundation.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:web_dex/bloc/bitrefill/models/embedded_bitrefill_url.dart'; + +class BitrefillProvider { + /// A map of supported coin abbreviations to their corresponding Bitrefill + /// coin names. The keys are the coin abbreviations used in the app, and the + /// values are the coin names used in the Bitrefill widget. + Map get supportedCoinAbbrMap => { + 'BTC': 'bitcoin', + 'BTC-segwit': 'bitcoin', + 'DASH': 'dash', + 'DOGE': 'dogecoin', + 'ETH': 'ethereum', + 'LTC': 'litecoin', + 'LTC-segwit': 'litecoin', + 'USDT-ERC20': 'usdt_erc20', + 'USDT-TRC20': 'usdt_trc20', + 'USDT-PLG20': 'usdt_polygon', + 'USDC-ERC20': 'usdc_erc20', + 'USDC-PLG20': 'usdc_polygon', + }; + + /// A list of supported Bitrefill coin abbreviations for payments. + List get supportedCoinAbbrs => supportedCoinAbbrMap.keys.toList(); + + // TODO: replace with actual Bitrefill referral code / partnership code + final String referralCode = '2i8u2o27'; + final String theme = 'dark'; + + /// Returns the URL of the Bitrefill widget page. + /// + /// If the app is running on the web, the URL will be the same as the current + /// page's origin with the path to the widget's HTML file appended. + /// + /// If the app is running on a mobile or desktop platform, the URL will be + /// the URL of the asset on the deployed web app. + /// + /// This is necessary because the Bitrefill widget's content security policy + /// does not allow the widget to be embedded in an iframe, and the widget + /// must be embedded in an iframe to work with the app's webview. + /// + /// The widget's HTML file is located at `assets/web_pages/bitrefill_widget.html`. + String embeddedBitrefillUrl({String? coinAbbr, String? refundAddress}) { + final String baseUrl = baseEmbeddedBitrefillUrl(); + final String? coinName = + coinAbbr != null ? supportedCoinAbbrMap[coinAbbr] : null; + final EmbeddedBitrefillUrl embeddedBitrefillUrl = EmbeddedBitrefillUrl( + baseUrl: baseUrl, + paymentMethods: coinName != null ? [coinName] : null, + refundAddress: refundAddress, + referralCode: referralCode, + theme: theme, + ); + + return embeddedBitrefillUrl.toString(); + } + + /// Returns the URL of the Bitrefill widget page without any query parameters. + String baseEmbeddedBitrefillUrl() { + if (kIsWeb) { + final String baseUrl = + '${html.window.location.origin}/assets/assets/web_pages/bitrefill_widget.html'; + return baseUrl; + } + + return kDebugMode + ? 'http://localhost:42069/assets/web_pages/bitrefill_widget.html' + : 'https://app.komodoplatform.com/assets/assets/web_pages/bitrefill_widget.html'; + } +} diff --git a/lib/bloc/bitrefill/data/bitrefill_purchase_watcher.dart b/lib/bloc/bitrefill/data/bitrefill_purchase_watcher.dart new file mode 100644 index 0000000000..21435b3645 --- /dev/null +++ b/lib/bloc/bitrefill/data/bitrefill_purchase_watcher.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:universal_html/html.dart' as html; +import 'package:web_dex/bloc/bitrefill/models/bitrefill_payment_intent_event.dart'; + +class BitrefillPurchaseWatcher { + bool _isDisposed = false; + + /// Watches for the payment intent event from the Bitrefill checkout page + /// using a [scheduleMicrotask] to listen for events asynchronously. + /// + /// NB: This will only work if the Bitrefill page was opened from the app - + /// similar to [RampPurchaseWatcher]. JavaScript's `window.opener.postMessage` + /// is used to send the payment intent data to the app. + /// I.e. If the user copies the checkout URL and opens it in a new tab, + /// we will not receive events. + Stream watchPaymentIntent() { + _assertNotDisposed(); + + final StreamController controller = + StreamController(); + scheduleMicrotask(() async { + final Stream> stream = watchBitrefillPaymentIntent() + .takeWhile((Map element) => !controller.isClosed); + try { + await for (final Map event in stream) { + final BitrefillPaymentIntentEvent paymentIntentEvent = + BitrefillPaymentIntentEvent.fromJson(event); + controller.add(paymentIntentEvent); + } + } catch (e) { + controller.addError(e); + } finally { + _cleanup(); + } + }); + + return controller.stream; + } + + void _cleanup() { + _isDisposed = true; + // Close any other resources if necessary + } + + void _assertNotDisposed() { + if (_isDisposed) { + throw Exception('RampOrderWatcher has already been disposed'); + } + } + + /// Watches for the payment intent event from the Bitrefill checkout page. + /// + /// NB: This will only work if the Bitrefill page was opened from the app - + /// similar to [RampPurchaseWatcher]. JavaScript's `window.opener.postMessage` + /// is used to send the payment intent data to the app. + /// I.e. If the user copies the checkout URL and opens it in a new tab, + /// we will not receive events. + Stream> watchBitrefillPaymentIntent() async* { + final StreamController> paymentIntentsController = + StreamController>(); + + void handlerFunction(html.Event event) { + if (paymentIntentsController.isClosed) { + return; + } + final html.MessageEvent messageEvent = event as html.MessageEvent; + if (messageEvent.data is String) { + try { + // TODO(Francois): convert to a model here (payment intent or invoice created atm) + final Map dataJson = + jsonDecode(messageEvent.data as String) as Map; + paymentIntentsController.add(dataJson); + } catch (e) { + paymentIntentsController.addError(e); + } + } + } + + try { + html.window.addEventListener('message', handlerFunction); + + yield* paymentIntentsController.stream; + } catch (e) { + paymentIntentsController.addError(e); + } finally { + html.window.removeEventListener('message', handlerFunction); + + if (!paymentIntentsController.isClosed) { + await paymentIntentsController.close(); + } + } + } +} diff --git a/lib/bloc/bitrefill/data/bitrefill_repository.dart b/lib/bloc/bitrefill/data/bitrefill_repository.dart new file mode 100644 index 0000000000..487e7f600a --- /dev/null +++ b/lib/bloc/bitrefill/data/bitrefill_repository.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:web_dex/bloc/bitrefill/data/bitrefill_provider.dart'; +import 'package:web_dex/bloc/bitrefill/data/bitrefill_purchase_watcher.dart'; +import 'package:web_dex/bloc/bitrefill/models/bitrefill_payment_intent_event.dart'; + +class BitrefillRepository { + final BitrefillPurchaseWatcher _bitrefillPurchaseWatcher = + BitrefillPurchaseWatcher(); + final BitrefillProvider _bitrefillProvider = BitrefillProvider(); + + Stream watchPaymentIntent() { + return _bitrefillPurchaseWatcher.watchPaymentIntent(); + } + + /// Returns the supported coins for Bitrefill. + List get bitrefillSupportedCoins => + _bitrefillProvider.supportedCoinAbbrs; + + /// Returns the embedded Bitrefill url. + String embeddedBitrefillUrl({String? coinAbbr, String? refundAddress}) { + return _bitrefillProvider.embeddedBitrefillUrl( + coinAbbr: coinAbbr, + refundAddress: refundAddress, + ); + } +} diff --git a/lib/bloc/bitrefill/models/bitrefill_event.dart b/lib/bloc/bitrefill/models/bitrefill_event.dart new file mode 100644 index 0000000000..f2adec47df --- /dev/null +++ b/lib/bloc/bitrefill/models/bitrefill_event.dart @@ -0,0 +1,6 @@ +abstract class BitrefillWidgetEvent { + // ignore: avoid_unused_constructor_parameters + factory BitrefillWidgetEvent.fromJson(Map json) { + throw UnimplementedError('BitrefillEvent.fromJson is not implemented'); + } +} diff --git a/lib/bloc/bitrefill/models/bitrefill_event_factory.dart b/lib/bloc/bitrefill/models/bitrefill_event_factory.dart new file mode 100644 index 0000000000..ec00eb1c4e --- /dev/null +++ b/lib/bloc/bitrefill/models/bitrefill_event_factory.dart @@ -0,0 +1,21 @@ +import 'package:web_dex/bloc/bitrefill/models/bitrefill_event.dart'; +import 'package:web_dex/bloc/bitrefill/models/bitrefill_invoice_created_event.dart'; +import 'package:web_dex/bloc/bitrefill/models/bitrefill_payment_intent_event.dart'; + +/// A factory class that creates [BitrefillWidgetEvent] objects from JSON maps. +/// The event type is expected to be a string with the key 'event'. +class BitrefillEventFactory { + /// Creates a [BitrefillWidgetEvent] from a JSON map using the event type + /// specified in the map. The event type is expected to be a string with the + /// key 'event'. + static BitrefillWidgetEvent createEvent(Map json) { + switch (json['event']) { + case 'payment_intent': + return BitrefillPaymentIntentEvent.fromJson(json); + case 'invoice_created': + return BitrefillInvoiceCreatedEvent.fromJson(json); + default: + throw Exception('Unknown event type: ${json['event']}'); + } + } +} diff --git a/lib/bloc/bitrefill/models/bitrefill_invoice_created_event.dart b/lib/bloc/bitrefill/models/bitrefill_invoice_created_event.dart new file mode 100644 index 0000000000..1e7304abe1 --- /dev/null +++ b/lib/bloc/bitrefill/models/bitrefill_invoice_created_event.dart @@ -0,0 +1,74 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/bitrefill/models/bitrefill_event.dart'; + +/// An event that is dispatched when a Bitrefill invoice is created. +/// This happens before a payment request is made (i.e. before the user clicks "Pay"). +class BitrefillInvoiceCreatedEvent extends Equatable + implements BitrefillWidgetEvent { + const BitrefillInvoiceCreatedEvent({ + this.event, + this.invoiceId, + this.paymentUri, + this.paymentMethod, + this.paymentAmount, + this.paymentCurrency, + this.paymentAddress, + }); + + factory BitrefillInvoiceCreatedEvent.fromJson(Map json) { + return BitrefillInvoiceCreatedEvent( + event: json['event'] as String?, + invoiceId: json['invoiceId'] as String?, + paymentUri: json['paymentUri'] as String?, + paymentMethod: json['paymentMethod'] as String?, + paymentAmount: (json['paymentAmount'] as num?)?.toDouble(), + paymentCurrency: json['paymentCurrency'] as String?, + paymentAddress: json['paymentAddress'] as String?, + ); + } + + /// The event type. E.g. `invoice_created` + final String? event; + + /// The Bitrefill invoice ID. This is used to track the payment. + final String? invoiceId; + + /// The payment URI containing the payment method, address and amount. + /// E.g. `bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1` + final String? paymentUri; + + /// The payment method. E.g. `bitcoin` + final String? paymentMethod; + + /// The payment amount. E.g. `0.1` + final double? paymentAmount; + + /// The payment currency. E.g. `BTC` + final String? paymentCurrency; + + /// The payment address. E.g. `1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa` + final String? paymentAddress; + + Map toJson() => { + 'event': event, + 'invoiceId': invoiceId, + 'paymentUri': paymentUri, + 'paymentMethod': paymentMethod, + 'paymentAmount': paymentAmount, + 'paymentCurrency': paymentCurrency, + 'paymentAddress': paymentAddress, + }; + + @override + List get props { + return [ + event, + invoiceId, + paymentUri, + paymentMethod, + paymentAmount, + paymentCurrency, + paymentAddress, + ]; + } +} diff --git a/lib/bloc/bitrefill/models/bitrefill_payment_intent_event.dart b/lib/bloc/bitrefill/models/bitrefill_payment_intent_event.dart new file mode 100644 index 0000000000..ff45f788cd --- /dev/null +++ b/lib/bloc/bitrefill/models/bitrefill_payment_intent_event.dart @@ -0,0 +1,73 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/bitrefill/models/bitrefill_event.dart'; + +/// The event that is dispatched when a Bitrefill payment intent is created (i.e. user clicks "Pay"). +class BitrefillPaymentIntentEvent extends Equatable + implements BitrefillWidgetEvent { + const BitrefillPaymentIntentEvent({ + this.event, + this.invoiceId, + this.paymentUri, + this.paymentMethod, + this.paymentAmount, + this.paymentCurrency, + this.paymentAddress, + }); + + factory BitrefillPaymentIntentEvent.fromJson(Map json) { + return BitrefillPaymentIntentEvent( + event: json['event'] as String?, + invoiceId: json['invoiceId'] as String?, + paymentUri: json['paymentUri'] as String?, + paymentMethod: json['paymentMethod'] as String?, + paymentAmount: (json['paymentAmount'] as num?)?.toDouble(), + paymentCurrency: json['paymentCurrency'] as String?, + paymentAddress: json['paymentAddress'] as String?, + ); + } + + /// The event type. E.g. `invoice_created` + final String? event; + + /// The Bitrefill invoice ID. This is used to track the payment. + final String? invoiceId; + + /// The payment URI containing the payment method, address and amount. + /// E.g. `bitcoin:1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa?amount=0.1` + final String? paymentUri; + + /// The payment method. E.g. `bitcoin` + final String? paymentMethod; + + /// The payment amount. E.g. `0.1` + final double? paymentAmount; + + /// The payment currency. E.g. `BTC` + final String? paymentCurrency; + + /// The payment address. E.g. `1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa` + final String? paymentAddress; + + Map toJson() => { + 'event': event, + 'invoiceId': invoiceId, + 'paymentUri': paymentUri, + 'paymentMethod': paymentMethod, + 'paymentAmount': paymentAmount, + 'paymentCurrency': paymentCurrency, + 'paymentAddress': paymentAddress, + }; + + @override + List get props { + return [ + event, + invoiceId, + paymentUri, + paymentMethod, + paymentAmount, + paymentCurrency, + paymentAddress, + ]; + } +} diff --git a/lib/bloc/bitrefill/models/embedded_bitrefill_url.dart b/lib/bloc/bitrefill/models/embedded_bitrefill_url.dart new file mode 100644 index 0000000000..3c664fed25 --- /dev/null +++ b/lib/bloc/bitrefill/models/embedded_bitrefill_url.dart @@ -0,0 +1,79 @@ +/// Represents the URL to open the Bitrefill widget in an embedded web view. +/// This includes query parameters like the [referralCode], [theme], +/// [paymentMethods], and [refundAddress]. +/// See https://www.bitrefill.com/playground/documentation/url-params for more info. +class EmbeddedBitrefillUrl { + EmbeddedBitrefillUrl({ + required this.baseUrl, + required this.referralCode, + this.theme = 'dark', + this.language = 'en', + this.companyName = 'Komodo Platform', + this.showPaymentInfo = false, + this.refundAddress, + this.paymentMethods, + }); + + /// The base URL to the embedded Bitrefill widget, excluding query parameters. + final String baseUrl; + + /// The business referral code to use for the Bitrefill widget. + final String referralCode; + + /// The theme to use when opening the Bitrefill widget. + /// This can be 'auto', 'light', 'dark', 'crimson', 'aquamarine', or 'retro'. + /// The default is 'dark'. + final String theme; + + /// The language to use when opening the Bitrefill widget. + /// This can be 'en', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'ja', 'ko', or 'zh-Hans'. + /// The default is 'en'. + final String language; + + /// The company name to use when opening the Bitrefill widget. + /// This defaults to 'Komodo Platform'. + final String companyName; + + /// Whether to show payment info when opening the Bitrefill widget. + /// The default is false. + final bool showPaymentInfo; + + /// The refund address to use when opening the Bitrefill widget. + final String? refundAddress; + + /// The payment methods to use when opening the Bitrefill widget. + /// This limits the payment methods that are available to the user. + /// If only one payment method is available, the payment method + /// selection page will be skipped. + /// The default is null, which means all payment methods are available. + final List? paymentMethods; + + @override + String toString() { + final Map query = { + 'ref': referralCode, + 'theme': theme, + 'language': language, + 'company_name': companyName, + }; + + if (paymentMethods != null) { + query['payment_methods'] = paymentMethods!.join(','); + } + + if (refundAddress != null) { + query['refund_address'] = refundAddress!; + } + + final Uri baseUri = Uri.parse(baseUrl); + final Uri uri = Uri( + scheme: baseUri.scheme, + host: baseUri.host, + path: baseUri.path, + port: baseUri.port, + queryParameters: query, + ); + + return uri.toString(); + } +} diff --git a/lib/bloc/bridge_form/bridge_bloc.dart b/lib/bloc/bridge_form/bridge_bloc.dart new file mode 100644 index 0000000000..582a251e70 --- /dev/null +++ b/lib/bloc/bridge_form/bridge_bloc.dart @@ -0,0 +1,625 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.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'; +import 'package:web_dex/bloc/bridge_form/bridge_validator.dart'; +import 'package:web_dex/bloc/dex_repository.dart'; +import 'package:web_dex/bloc/transformers.dart'; +import 'package:web_dex/blocs/coins_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/sell/sell_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/sell/sell_response.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/available_balance_state.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/model/typedef.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class BridgeBloc extends Bloc { + BridgeBloc({ + required BridgeRepository bridgeRepository, + required DexRepository dexRepository, + required CoinsBloc coinsRepository, + required AuthRepository authRepository, + }) : _bridgeRepository = bridgeRepository, + _dexRepository = dexRepository, + _coinsRepository = coinsRepository, + super(BridgeState.initial()) { + on(_onInit); + on(_onReInit); + on(_onLogout); + on(_onTickerChanged); + on(_onUpdateTickers); + on(_onShowTickerDropdown); + on(_onShowSourceDropdown); + on(_onShowTargetDropdown); + on(_onUpdateSellCoins); + on(_onSetSellCoin); + on(_onUpdateBestOrders); + on(_onSelectBestOrder); + on(_onSetError); + on(_onClearErrors); + on(_onUpdateMaxSellAmount); + on(_onAmountButtonClick); + on(_onSellAmountChange, transformer: debounce()); + on(_onSetSellAmount); + on(_onUpdateFees); + on(_onGetMinSellAmount); + on(_onSetPreimage); + on(_onSetInProgress); + on(_onSubmitClick); + on(_onBackClick); + on(_onSetWalletIsReady); + on(_onStartSwap); + on(_onClear); + on(_verifyOrderVolume); + + _validator = BridgeValidator( + bloc: this, + coinsRepository: coinsRepository, + dexRepository: dexRepository, + ); + + _authorizationSubscription = authRepository.authMode.listen((event) { + _isLoggedIn = event == AuthorizeMode.logIn; + if (!_isLoggedIn) add(const BridgeLogout()); + }); + } + + final BridgeRepository _bridgeRepository; + final DexRepository _dexRepository; + final CoinsBloc _coinsRepository; + + bool _activatingAssets = false; + bool _waitingForWallet = true; + bool _isLoggedIn = false; + late StreamSubscription _authorizationSubscription; + late BridgeValidator _validator; + Timer? _maxSellAmountTimer; + Timer? _preimageTimer; + + void _onInit( + BridgeInit event, + Emitter emit, + ) { + if (state.selectedTicker != null) return; + final Coin? defaultTickerCoin = _coinsRepository.getCoin(event.ticker); + + emit(state.copyWith( + selectedTicker: () => defaultTickerCoin?.abbr, + )); + + add(const BridgeUpdateTickers()); + } + + Future _onReInit( + BridgeReInit event, + Emitter emit, + ) async { + _isLoggedIn = true; + + emit(state.copyWith( + error: () => null, + autovalidate: () => false, + )); + + add(const BridgeUpdateMaxSellAmount(true)); + + await _autoActivateCoin(state.sellCoin?.abbr); + await _autoActivateCoin(state.bestOrder?.coin); + + add(const BridgeGetMinSellAmount()); + _subscribeFees(); + } + + void _onLogout( + BridgeLogout event, + Emitter emit, + ) { + _isLoggedIn = false; + + 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, + )); + } + + Future _onUpdateTickers( + BridgeUpdateTickers event, + Emitter emit, + ) async { + final CoinsByTicker tickers = + await _bridgeRepository.getAvailableTickers(_coinsRepository); + + emit(state.copyWith( + tickers: () => tickers, + )); + + add(const BridgeUpdateSellCoins()); + } + + void _onShowTickerDropdown( + BridgeShowTickerDropdown event, + Emitter emit, + ) { + 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, + )); + } + + void _onShowTargetDropdown( + BridgeShowTargetDropdown event, + Emitter emit, + ) { + 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); + + 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, + )); + + _autoActivateCoin(event.coin.abbr); + _subscribeMaxSellAmount(); + + add(const BridgeGetMinSellAmount()); + add(const BridgeUpdateBestOrders()); + } + + Future _onUpdateBestOrders( + BridgeUpdateBestOrders event, + Emitter emit, + ) async { + if (!event.silent) { + emit(state.copyWith(bestOrders: () => null)); + } + + final sellCoin = state.sellCoin; + if (sellCoin == null) return; + + final bestOrders = await _dexRepository.getBestOrders(BestOrdersRequest( + coin: sellCoin.abbr, + action: 'sell', + type: BestOrdersRequestType.number, + number: 1, + )); + + emit(state.copyWith( + bestOrders: () => bestOrders, + )); + } + + void _onSelectBestOrder( + BridgeSelectBestOrder event, + Emitter emit, + ) async { + 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, + )); + + if (!state.autovalidate) add(const BridgeVerifyOrderVolume()); + + await _autoActivateCoin(event.order?.coin); + if (state.autovalidate) _validator.validateForm(); + _subscribeFees(); + } + + void _onSetError( + BridgeSetError event, + Emitter emit, + ) { + emit(state.copyWith( + error: () => event.error, + )); + } + + void _onClearErrors( + BridgeClearErrors event, + Emitter emit, + ) { + emit(state.copyWith( + error: () => null, + )); + } + + void _subscribeFees() { + _preimageTimer?.cancel(); + if (!_validator.canRequestPreimage) return; + + add(const BridgeUpdateFees()); + _preimageTimer = Timer.periodic(const Duration(seconds: 20), (_) { + add(const BridgeUpdateFees()); + }); + } + + void _subscribeMaxSellAmount() { + _maxSellAmountTimer?.cancel(); + + add(const BridgeUpdateMaxSellAmount(true)); + _maxSellAmountTimer = Timer.periodic(const Duration(seconds: 10), (_) { + add(const BridgeUpdateMaxSellAmount()); + }); + } + + void _onAmountButtonClick( + BridgeAmountButtonClick event, + Emitter emit, + ) { + final Rational? maxSellAmount = state.maxSellAmount; + if (maxSellAmount == null) return; + final Rational sellAmount = + getFractionOfAmount(maxSellAmount, event.fraction); + add(BridgeSetSellAmount(sellAmount)); + } + + void _onSellAmountChange( + BridgeSellAmountChange event, + Emitter emit, + ) { + final Rational? amount = + event.value.isNotEmpty ? Rational.parse(event.value) : null; + + if (amount == state.sellAmount) return; + + add(BridgeSetSellAmount(amount)); + } + + void _onSetSellAmount( + BridgeSetSellAmount event, + Emitter emit, + ) { + emit(state.copyWith( + sellAmount: () => event.amount, + buyAmount: () => calculateBuyAmount( + selectedOrder: state.bestOrder, + sellAmount: event.amount, + ), + )); + + if (state.autovalidate) { + _validator.validateForm(); + } else { + add(const BridgeVerifyOrderVolume()); + } + _subscribeFees(); + } + + Future _onUpdateMaxSellAmount( + BridgeUpdateMaxSellAmount event, + Emitter emit, + ) async { + if (state.sellCoin == null) { + _maxSellAmountTimer?.cancel(); + emit(state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + )); + return; + } + + if (state.availableBalanceState == AvailableBalanceState.initial || + event.setLoadingStatus) { + emit(state.copyWith( + availableBalanceState: () => AvailableBalanceState.loading, + )); + } + + if (!_isLoggedIn) { + emit(state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + )); + } else { + Rational? maxSellAmount = + await _dexRepository.getMaxTakerVolume(state.sellCoin!.abbr); + if (maxSellAmount != null) { + emit(state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: () => AvailableBalanceState.success, + )); + } else { + maxSellAmount = await _frequentlyGetMaxTakerVolume(); + emit(state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: maxSellAmount == null + ? () => AvailableBalanceState.failure + : () => AvailableBalanceState.success, + )); + } + } + } + + void _onUpdateFees( + BridgeUpdateFees event, + Emitter emit, + ) async { + emit(state.copyWith( + preimageData: () => null, + )); + + if (!_validator.canRequestPreimage) { + _preimageTimer?.cancel(); + return; + } + + final preimageData = await _getFeesData(); + add(BridgeSetPreimage(preimageData)); + } + + Future _onGetMinSellAmount( + BridgeGetMinSellAmount event, + Emitter emit, + ) async { + if (state.sellCoin == null) return; + if (!_isLoggedIn) { + emit(state.copyWith( + minSellAmount: () => null, + )); + return; + } + + final Rational? minSellAmount = + await _dexRepository.getMinTradingVolume(state.sellCoin!.abbr); + + emit(state.copyWith( + minSellAmount: () => minSellAmount, + )); + } + + 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 _onSubmitClick( + BridgeSubmitClick event, + Emitter emit, + ) async { + 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, + )); + } + + void _onBackClick( + BridgeBackClick event, + Emitter emit, + ) { + emit(state.copyWith( + step: () => BridgeStep.form, + error: () => null, + )); + } + + void _onSetWalletIsReady( + BridgeSetWalletIsReady event, + Emitter emit, + ) { + _waitingForWallet = !event.isReady; + } + + void _onStartSwap( + BridgeStartSwap event, + Emitter emit, + ) async { + 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, + )); + + if (response.error != null) { + add(BridgeSetError(DexFormError(error: response.error!.message))); + } + + final String? uuid = response.result?.uuid; + + emit(state.copyWith( + inProgress: uuid == null ? () => false : null, + swapUuid: () => uuid, + )); + } + + void _verifyOrderVolume( + BridgeVerifyOrderVolume event, + Emitter emit, + ) { + _validator.verifyOrderVolume(); + } + + void _onClear( + BridgeClear event, + Emitter emit, + ) { + emit(BridgeState.initial()); + } + + Future _frequentlyGetMaxTakerVolume() async { + int attempts = 5; + Rational? maxSellAmount; + while (attempts > 0) { + maxSellAmount = + await _dexRepository.getMaxTakerVolume(state.sellCoin!.abbr); + if (maxSellAmount != null) { + return maxSellAmount; + } + attempts -= 1; + await Future.delayed(const Duration(seconds: 2)); + } + return null; + } + + Future _autoActivateCoin(String? abbr) async { + if (abbr == null) return; + + _activatingAssets = true; + final List activationErrors = + await activateCoinIfNeeded(abbr); + _activatingAssets = false; + + if (activationErrors.isNotEmpty) { + add(BridgeSetError(activationErrors.first)); + } + } + + List prepareTargetsList(Map> bestOrders) { + final List list = []; + + final Coin? sellCoin = state.sellCoin; + if (sellCoin == null) return list; + + bestOrders.forEach((key, value) => list.addAll(value)); + + 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; + + if (item.isSuspended) return true; + if (item.walletOnly) return true; + + return false; + }, + ); + + list.sort((a, b) => a.coin.compareTo(b.coin)); + + return list; + } + + Future> _getFeesData() async { + try { + return await _dexRepository.getTradePreimage( + state.sellCoin!.abbr, + state.bestOrder!.coin, + state.bestOrder!.price, + 'sell', + state.sellAmount, + ); + } catch (e, s) { + log(e.toString(), + trace: s, path: 'bridge_bloc::_getFeesData', isError: true); + return DataFromService(error: TextError(error: 'Failed to request fees')); + } + } + + @override + Future close() { + _authorizationSubscription.cancel(); + _maxSellAmountTimer?.cancel(); + _preimageTimer?.cancel(); + + return super.close(); + } +} diff --git a/lib/bloc/bridge_form/bridge_event.dart b/lib/bloc/bridge_form/bridge_event.dart new file mode 100644 index 0000000000..f0908aaccc --- /dev/null +++ b/lib/bloc/bridge_form/bridge_event.dart @@ -0,0 +1,158 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/model/trade_preimage.dart'; + +abstract class BridgeEvent { + const BridgeEvent(); +} + +class BridgeInit extends BridgeEvent { + const BridgeInit({ + required this.ticker, + }); + + final String ticker; +} + +class BridgeTickerChanged extends BridgeEvent { + const BridgeTickerChanged(this.ticker); + + final String? ticker; +} + +class BridgeUpdateTickers extends BridgeEvent { + const BridgeUpdateTickers(); +} + +class BridgeShowTickerDropdown extends BridgeEvent { + const BridgeShowTickerDropdown(this.show); + + final bool show; +} + +class BridgeShowSourceDropdown extends BridgeEvent { + const BridgeShowSourceDropdown(this.show); + + final bool show; +} + +class BridgeShowTargetDropdown extends BridgeEvent { + const BridgeShowTargetDropdown(this.show); + + final bool show; +} + +class BridgeUpdateSellCoins extends BridgeEvent { + const BridgeUpdateSellCoins(); +} + +class BridgeSetSellCoin extends BridgeEvent { + const BridgeSetSellCoin(this.coin); + + final Coin coin; +} + +class BridgeUpdateBestOrders extends BridgeEvent { + const BridgeUpdateBestOrders({this.silent = false}); + + final bool silent; +} + +class BridgeSelectBestOrder extends BridgeEvent { + const BridgeSelectBestOrder(this.order); + + final BestOrder? order; +} + +class BridgeSetError extends BridgeEvent { + const BridgeSetError(this.error); + + final DexFormError error; +} + +class BridgeClearErrors extends BridgeEvent { + const BridgeClearErrors(); +} + +class BridgeUpdateMaxSellAmount extends BridgeEvent { + const BridgeUpdateMaxSellAmount([this.setLoadingStatus = false]); + + final bool setLoadingStatus; +} + +class BridgeReInit extends BridgeEvent { + const BridgeReInit(); +} + +class BridgeLogout extends BridgeEvent { + const BridgeLogout(); +} + +// 'max', 'half' buttons +class BridgeAmountButtonClick extends BridgeEvent { + BridgeAmountButtonClick(this.fraction); + + final double fraction; +} + +class BridgeSellAmountChange extends BridgeEvent { + BridgeSellAmountChange(this.value); + + final String value; +} + +class BridgeSetSellAmount extends BridgeEvent { + BridgeSetSellAmount(this.amount); + + final Rational? amount; +} + +class BridgeGetMinSellAmount extends BridgeEvent { + const BridgeGetMinSellAmount(); +} + +class BridgeUpdateFees extends BridgeEvent { + const BridgeUpdateFees(); +} + +class BridgeSetPreimage extends BridgeEvent { + const BridgeSetPreimage(this.preimageData); + + final DataFromService? preimageData; +} + +class BridgeSetInProgress extends BridgeEvent { + const BridgeSetInProgress(this.inProgress); + + final bool inProgress; +} + +class BridgeSubmitClick extends BridgeEvent { + const BridgeSubmitClick(); +} + +class BridgeSetWalletIsReady extends BridgeEvent { + const BridgeSetWalletIsReady(this.isReady); + + final bool isReady; +} + +class BridgeBackClick extends BridgeEvent { + const BridgeBackClick(); +} + +class BridgeStartSwap extends BridgeEvent { + const BridgeStartSwap(); +} + +class BridgeClear extends BridgeEvent { + const BridgeClear(); +} + +class BridgeVerifyOrderVolume extends BridgeEvent { + const BridgeVerifyOrderVolume(); +} diff --git a/lib/bloc/bridge_form/bridge_repository.dart b/lib/bloc/bridge_form/bridge_repository.dart new file mode 100644 index 0000000000..517fdbda1e --- /dev/null +++ b/lib/bloc/bridge_form/bridge_repository.dart @@ -0,0 +1,135 @@ +import 'dart:async'; + +import 'package:web_dex/blocs/coins_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_response.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_utils.dart'; +import 'package:web_dex/model/typedef.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class BridgeRepository { + BridgeRepository._(); + + static final BridgeRepository _instance = BridgeRepository._(); + static BridgeRepository get instance => _instance; + + Future getSellCoins(CoinsByTicker? tickers) async { + if (tickers == null) return null; + + final List? depths = await _getDepths(tickers); + if (depths == null) return null; + + final CoinsByTicker sellCoins = + tickers.entries.fold({}, (previousValue, entry) { + final List coins = previousValue[entry.key] ?? []; + final List tickerDepths = depths + .where((depth) => + (abbr2Ticker(depth.source.abbr) == entry.key) && + (abbr2Ticker(depth.target.abbr) == entry.key)) + .toList(); + + if (tickerDepths.isEmpty) return previousValue; + + for (OrderBookDepth depth in tickerDepths) { + if (depth.asks != 0) { + if (!isCoinInList(depth.target, coins)) coins.add(depth.target); + } + if (depth.bids != 0) { + if (!isCoinInList(depth.source, coins)) coins.add(depth.source); + } + } + + previousValue[entry.key] = coins; + + return previousValue; + }); + + return sellCoins; + } + + Future getAvailableTickers(CoinsBloc coinsRepo) async { + List coins = List.from(coinsRepo.knownCoins); + + coins = removeWalletOnly(coins); + coins = removeSuspended(coins); + + final CoinsByTicker coinsByTicker = convertToCoinsByTicker(coins); + final CoinsByTicker multiProtocolCoins = + removeSingleProtocol(coinsByTicker); + + final List? orderBookDepths = + await _getDepths(multiProtocolCoins); + + if (orderBookDepths == null || orderBookDepths.isEmpty) { + return multiProtocolCoins; + } else { + return removeTokensWithEmptyOrderbook( + multiProtocolCoins, orderBookDepths); + } + } + + Future?> _getDepths(CoinsByTicker coinsByTicker) async { + final List> depthsPairs = _getDepthsPairs(coinsByTicker); + + List? orderBookDepths = + await _getNotEmptyDepths(depthsPairs); + if (orderBookDepths?.isEmpty ?? true) { + orderBookDepths = await _frequentRequestDepth(depthsPairs); + } + + return orderBookDepths; + } + + Future?> _frequentRequestDepth( + List> depthsPairs) async { + int attempts = 5; + List? orderBookDepthsLocal; + + if (depthsPairs.isEmpty) { + return null; + } + while (attempts > 0) { + orderBookDepthsLocal = await _getNotEmptyDepths(depthsPairs); + + if (orderBookDepthsLocal?.isNotEmpty ?? false) { + return orderBookDepthsLocal; + } + attempts -= 1; + await Future.delayed(const Duration(milliseconds: 600)); + } + return null; + } + + Future?> _getNotEmptyDepths( + List> pairs) async { + final OrderBookDepthResponse? depthResponse = + await mm2Api.getOrderBookDepth(pairs); + + return depthResponse?.list + .where((d) => d.bids != 0 || d.asks != 0) + .toList(); + } + + List> _getDepthsPairs(CoinsByTicker coins) { + return coins.values.fold>>( + [], + (previousValue, entry) { + previousValue.addAll(_createPairs(entry)); + return previousValue; + }, + ); + } + + List> _createPairs(List group) { + final List cloneGroup = List.from(group); + final List> pairs = []; + while (cloneGroup.isNotEmpty) { + final Coin coin = cloneGroup.removeLast(); + for (Coin item in cloneGroup) { + pairs.add([item.abbr, coin.abbr]); + } + } + return pairs; + } +} diff --git a/lib/bloc/bridge_form/bridge_state.dart b/lib/bloc/bridge_form/bridge_state.dart new file mode 100644 index 0000000000..5b2a9053bf --- /dev/null +++ b/lib/bloc/bridge_form/bridge_state.dart @@ -0,0 +1,142 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/available_balance_state.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/model/typedef.dart'; + +class BridgeState { + BridgeState({ + required this.error, + required this.selectedTicker, + required this.tickers, + required this.showTickerDropdown, + required this.showSourceDropdown, + required this.showTargetDropdown, + required this.sellCoin, + required this.sellAmount, + required this.buyAmount, + required this.sellCoins, + required this.bestOrder, + required this.bestOrders, + required this.maxSellAmount, + required this.minSellAmount, + required this.availableBalanceState, + required this.preimageData, + required this.inProgress, + required this.step, + required this.swapUuid, + required this.autovalidate, + }); + + final DexFormError? error; + final String? selectedTicker; + final CoinsByTicker? tickers; + final bool showTickerDropdown; + final bool showSourceDropdown; + final bool showTargetDropdown; + final Coin? sellCoin; + final Rational? sellAmount; + final Rational? buyAmount; + final CoinsByTicker? sellCoins; + final BestOrder? bestOrder; + final BestOrders? bestOrders; + final Rational? maxSellAmount; + final Rational? minSellAmount; + final AvailableBalanceState availableBalanceState; + final DataFromService? preimageData; + final bool inProgress; + final BridgeStep step; + final String? swapUuid; + final bool autovalidate; + + static BridgeState initial() { + return BridgeState( + error: null, + selectedTicker: null, + tickers: null, + showTickerDropdown: false, + showSourceDropdown: false, + showTargetDropdown: false, + sellCoin: null, + sellAmount: null, + buyAmount: null, + sellCoins: null, + bestOrder: null, + bestOrders: null, + maxSellAmount: null, + minSellAmount: null, + availableBalanceState: AvailableBalanceState.unavailable, + preimageData: null, + inProgress: false, + step: BridgeStep.form, + swapUuid: null, + autovalidate: false, + ); + } + + BridgeState copyWith({ + DexFormError? Function()? error, + String? Function()? selectedTicker, + CoinsByTicker? Function()? tickers, + bool Function()? showTickerDropdown, + bool Function()? showSourceDropdown, + bool Function()? showTargetDropdown, + Coin? Function()? sellCoin, + Rational? Function()? sellAmount, + Rational? Function()? buyAmount, + CoinsByTicker? Function()? sellCoins, + BestOrder? Function()? bestOrder, + BestOrders? Function()? bestOrders, + Rational? Function()? maxSellAmount, + Rational? Function()? minSellAmount, + AvailableBalanceState Function()? availableBalanceState, + DataFromService? Function()? preimageData, + bool Function()? inProgress, + BridgeStep Function()? step, + String? Function()? swapUuid, + bool Function()? autovalidate, + }) { + return BridgeState( + error: error == null ? this.error : error(), + selectedTicker: + selectedTicker == null ? this.selectedTicker : selectedTicker(), + tickers: tickers == null ? this.tickers : tickers(), + showTickerDropdown: showTickerDropdown == null + ? this.showTickerDropdown + : showTickerDropdown(), + showSourceDropdown: showSourceDropdown == null + ? this.showSourceDropdown + : showSourceDropdown(), + showTargetDropdown: showTargetDropdown == null + ? this.showTargetDropdown + : showTargetDropdown(), + sellCoin: sellCoin == null ? this.sellCoin : sellCoin(), + sellAmount: sellAmount == null ? this.sellAmount : sellAmount(), + buyAmount: buyAmount == null ? this.buyAmount : buyAmount(), + sellCoins: sellCoins == null ? this.sellCoins : sellCoins(), + bestOrder: bestOrder == null ? this.bestOrder : bestOrder(), + bestOrders: bestOrders == null ? this.bestOrders : bestOrders(), + maxSellAmount: + maxSellAmount == null ? this.maxSellAmount : maxSellAmount(), + minSellAmount: + minSellAmount == null ? this.minSellAmount : minSellAmount(), + availableBalanceState: availableBalanceState == null + ? this.availableBalanceState + : availableBalanceState(), + preimageData: preimageData == null ? this.preimageData : preimageData(), + inProgress: inProgress == null ? this.inProgress : inProgress(), + step: step == null ? this.step : step(), + swapUuid: swapUuid == null ? this.swapUuid : swapUuid(), + autovalidate: autovalidate == null ? this.autovalidate : autovalidate(), + ); + } +} + +enum BridgeStep { + form, + confirm; +} diff --git a/lib/bloc/bridge_form/bridge_validator.dart b/lib/bloc/bridge_form/bridge_validator.dart new file mode 100644 index 0000000000..c57db4e245 --- /dev/null +++ b/lib/bloc/bridge_form/bridge_validator.dart @@ -0,0 +1,408 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:rational/rational.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/dex_repository.dart'; +import 'package:web_dex/blocs/coins_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'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_errors.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/views/dex/simple/form/error_list/dex_form_error_with_action.dart'; + +class BridgeValidator { + BridgeValidator({ + required BridgeBloc bloc, + required CoinsBloc coinsRepository, + required DexRepository dexRepository, + }) : _bloc = bloc, + _coinsRepo = coinsRepository, + _dexRepo = dexRepository, + _add = bloc.add; + + final BridgeBloc _bloc; + final CoinsBloc _coinsRepo; + final DexRepository _dexRepo; + + final Function(BridgeEvent) _add; + BridgeState get _state => _bloc.state; + + Future validate() async { + final bool isFormValid = validateForm(); + if (!isFormValid) return false; + + final bool tradingWithSelf = _checkTradeWithSelf(); + if (tradingWithSelf) return false; + + final bool isPreimageValid = await _validatePreimage(); + if (!isPreimageValid) return false; + + return true; + } + + Future _validatePreimage() async { + _add(const BridgeClearErrors()); + + final preimageData = await _getPreimageData(); + final preimageError = _parsePreimageError(preimageData); + + if (preimageError != null) { + _add(BridgeSetError(preimageError)); + return false; + } + + _add(BridgeSetPreimage(preimageData)); + return true; + } + + DexFormError? _parsePreimageError( + DataFromService preimageData) { + final BaseError? error = preimageData.error; + + if (error is TradePreimageNotSufficientBalanceError) { + return _insufficientBalanceError( + Rational.parse(error.required), error.coin); + } else if (error is TradePreimageNotSufficientBaseCoinBalanceError) { + return _insufficientBalanceError( + Rational.parse(error.required), error.coin); + } else if (error is TradePreimageTransportError) { + return DexFormError( + error: LocaleKeys.notEnoughBalanceForGasError.tr(), + ); + } else if (error is TradePreimageVolumeTooLowError) { + return DexFormError( + error: LocaleKeys.lowTradeVolumeError + .tr(args: [formatAmt(double.parse(error.threshold)), error.coin]), + ); + } else if (error != null) { + return DexFormError( + error: error.message, + ); + } else if (preimageData.data == null) { + return DexFormError( + error: LocaleKeys.somethingWrong.tr(), + ); + } + + return null; + } + + DataFromService? get _cachedPreimage { + final preimageData = _state.preimageData; + if (preimageData == null) return null; + + final request = preimageData.data?.request; + if (_state.sellCoin?.abbr != request?.base) return null; + if (_state.bestOrder?.coin != request?.rel) return null; + if (_state.bestOrder?.price != request?.price) return null; + if (_state.sellAmount != request?.volume) return null; + + return preimageData; + } + + Future> _getPreimageData() async { + final cached = _cachedPreimage; + if (cached != null) return cached; + + try { + return await _dexRepo.getTradePreimage( + _state.sellCoin!.abbr, + _state.bestOrder!.coin, + _state.bestOrder!.price, + 'sell', + _state.sellAmount, + ); + } catch (e, s) { + log(e.toString(), + trace: s, path: 'bridge_validator::_getPreimageData', isError: true); + return DataFromService( + error: TextError(error: 'Failed to request trade preimage')); + } + } + + bool validateForm() { + _add(const BridgeClearErrors()); + + if (!_isSellCoinSelected) { + _add(BridgeSetError(_selectSourceProtocolError())); + return false; + } + + if (!_isOrderSelected) { + _add(BridgeSetError(_selectTargetProtocolError())); + return false; + } + + if (!_validateCoinAndParent(_state.sellCoin!.abbr)) return false; + if (!_validateCoinAndParent(_state.bestOrder!.coin)) return false; + + if (!_validateAmount()) return false; + + return true; + } + + bool _validateAmount() { + if (!_validateMinAmount()) return false; + if (!_validateMaxAmount()) return false; + + return true; + } + + bool _validateMaxAmount() { + final Rational? availableBalance = _state.maxSellAmount; + if (availableBalance == null) return true; // validated on preimage side + + final Rational? maxOrderVolume = _state.bestOrder?.maxVolume; + if (maxOrderVolume == null) { + _add(BridgeSetError(_selectTargetProtocolError())); + return false; + } + + final Rational? sellAmount = _state.sellAmount; + if (sellAmount == null || sellAmount == Rational.zero) { + _add(BridgeSetError(_enterSellAmountError())); + return false; + } + + if (maxOrderVolume <= availableBalance && sellAmount > maxOrderVolume) { + _add(BridgeSetError(_setOrderMaxError(maxOrderVolume))); + return false; + } + + if (availableBalance < maxOrderVolume && sellAmount > availableBalance) { + final Rational minAmount = maxRational([ + _state.minSellAmount ?? Rational.zero, + _state.bestOrder!.minVolume + ])!; + + if (availableBalance < minAmount) { + _add(BridgeSetError( + _insufficientBalanceError(minAmount, _state.sellCoin!.abbr), + )); + } else { + _add(BridgeSetError( + _setMaxError(availableBalance), + )); + } + + return false; + } + + return true; + } + + bool _validateMinAmount() { + final Rational minTradingVolume = _state.minSellAmount ?? Rational.zero; + final Rational minOrderVolume = + _state.bestOrder?.minVolume ?? Rational.zero; + + final Rational minAmount = + maxRational([minTradingVolume, minOrderVolume]) ?? Rational.zero; + final Rational sellAmount = _state.sellAmount ?? Rational.zero; + + if (sellAmount < minAmount) { + final Rational available = _state.maxSellAmount ?? Rational.zero; + if (available < minAmount) { + _add(BridgeSetError( + _insufficientBalanceError(minAmount, _state.sellCoin!.abbr), + )); + } else { + _add(BridgeSetError(_setMinError(minAmount))); + } + + return false; + } + + return true; + } + + bool _validateCoinAndParent(String abbr) { + final Coin? coin = _coinsRepo.getKnownCoin(abbr); + + if (coin == null) { + _add(BridgeSetError(_unknownCoinError(abbr))); + return false; + } + + if (coin.enabledType == null) { + _add(BridgeSetError(_coinNotActiveError(coin.abbr))); + return false; + } + + if (coin.isSuspended) { + _add(BridgeSetError(_coinSuspendedError(coin.abbr))); + return false; + } + + final Coin? parent = coin.parentCoin; + if (parent != null) { + if (parent.enabledType == null) { + _add(BridgeSetError(_coinNotActiveError(parent.abbr))); + return false; + } + + if (parent.isSuspended) { + _add(BridgeSetError(_coinSuspendedError(parent.abbr))); + return false; + } + } + + return true; + } + + bool _checkTradeWithSelf() { + _add(const BridgeClearErrors()); + + final BestOrder? selectedOrder = _state.bestOrder; + if (selectedOrder == null) return false; + + final selectedOrderAddress = selectedOrder.address; + final coin = _coinsRepo.getCoin(selectedOrder.coin); + final ownAddress = coin?.address; + + if (selectedOrderAddress == ownAddress) { + _add(BridgeSetError(_tradingWithSelfError())); + return true; + } + return false; + } + + void verifyOrderVolume() { + final Coin? sellCoin = _state.sellCoin; + final BestOrder? selectedOrder = _state.bestOrder; + final Rational? sellAmount = _state.sellAmount; + + if (sellCoin == null) return; + if (selectedOrder == null) return; + if (sellAmount == null) return; + + _add(const BridgeClearErrors()); + if (sellAmount > selectedOrder.maxVolume) { + _add(BridgeSetError(_setOrderMaxError(selectedOrder.maxVolume))); + return; + } + } + + DexFormError _unknownCoinError(String abbr) => + DexFormError(error: 'Unknown coin $abbr.'); + + DexFormError _coinSuspendedError(String abbr) { + return DexFormError(error: '$abbr suspended.'); + } + + DexFormError _coinNotActiveError(String abbr) { + return DexFormError(error: '$abbr is not active.'); + } + + DexFormError _selectSourceProtocolError() => + DexFormError(error: LocaleKeys.bridgeSelectSendProtocolError.tr()); + DexFormError _selectTargetProtocolError() => + DexFormError(error: LocaleKeys.bridgeSelectReceiveCoinError.tr()); + DexFormError _enterSellAmountError() => + DexFormError(error: LocaleKeys.dexEnterSellAmountError.tr()); + + DexFormError _setOrderMaxError(Rational maxAmount) { + return DexFormError( + error: LocaleKeys.dexMaxOrderVolume + .tr(args: [formatDexAmt(maxAmount), _state.sellCoin!.abbr]), + type: DexFormErrorType.largerMaxSellVolume, + action: DexFormErrorAction( + text: LocaleKeys.setMax.tr(), + callback: () async { + _add(BridgeSetSellAmount(maxAmount)); + }, + ), + ); + } + + DexFormError _insufficientBalanceError(Rational required, String abbr) { + return DexFormError( + error: LocaleKeys.dexBalanceNotSufficientError + .tr(args: [abbr, formatDexAmt(required), abbr]), + ); + } + + DexFormError _setMaxError(Rational available) { + return DexFormError( + error: LocaleKeys.dexInsufficientFundsError.tr( + args: [formatDexAmt(available), _state.sellCoin!.abbr], + ), + type: DexFormErrorType.largerMaxSellVolume, + action: DexFormErrorAction( + text: LocaleKeys.setMax.tr(), + callback: () async { + _add(BridgeSetSellAmount(available)); + }, + ), + ); + } + + DexFormError _setMinError(Rational minAmount) { + return DexFormError( + type: DexFormErrorType.lessMinVolume, + error: LocaleKeys.dexMinSellAmountError + .tr(args: [formatDexAmt(minAmount), _state.sellCoin!.abbr]), + action: DexFormErrorAction( + text: LocaleKeys.setMin.tr(), + callback: () async { + _add(BridgeSetSellAmount(minAmount)); + }), + ); + } + + DexFormError _tradingWithSelfError() { + return DexFormError( + error: LocaleKeys.dexTradingWithSelfError.tr(), + ); + } + + bool get _isSellCoinSelected => _state.sellCoin != null; + + bool get _isOrderSelected => _state.bestOrder != null; + + bool get canRequestPreimage { + final Coin? sellCoin = _state.sellCoin; + if (sellCoin == null) return false; + if (sellCoin.enabledType == null) return false; + if (sellCoin.isSuspended) return false; + + final Rational? sellAmount = _state.sellAmount; + if (sellAmount == null) return false; + if (sellAmount == Rational.zero) return false; + final Rational? minSellAmount = _state.minSellAmount; + if (minSellAmount != null && sellAmount < minSellAmount) return false; + final Rational? maxSellAmount = _state.maxSellAmount; + if (maxSellAmount != null && sellAmount > maxSellAmount) return false; + + final Coin? parentSell = sellCoin.parentCoin; + if (parentSell != null) { + if (parentSell.enabledType == null) return false; + if (parentSell.isSuspended) return false; + if (parentSell.balance == 0.00) return false; + } + + final BestOrder? bestOrder = _state.bestOrder; + if (bestOrder == null) return false; + final Coin? buyCoin = _coinsRepo.getCoin(bestOrder.coin); + if (buyCoin == null) return false; + if (buyCoin.enabledType == null) return false; + + final Coin? parentBuy = buyCoin.parentCoin; + if (parentBuy != null) { + if (parentBuy.enabledType == null) return false; + if (parentBuy.isSuspended) return false; + if (parentBuy.balance == 0.00) return false; + } + + return true; + } +} diff --git a/lib/bloc/cex_market_data/cex_market_data.dart b/lib/bloc/cex_market_data/cex_market_data.dart new file mode 100644 index 0000000000..ce5747ab7f --- /dev/null +++ b/lib/bloc/cex_market_data/cex_market_data.dart @@ -0,0 +1,9 @@ +import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; + +class CexMarketData { + static Future ensureInitialized() async { + await ProfitLossRepository.ensureInitialized(); + await PortfolioGrowthRepository.ensureInitialized(); + } +} diff --git a/lib/bloc/cex_market_data/charts.dart b/lib/bloc/cex_market_data/charts.dart new file mode 100644 index 0000000000..08b3105a5f --- /dev/null +++ b/lib/bloc/cex_market_data/charts.dart @@ -0,0 +1,353 @@ +import 'dart:math'; + +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; + +typedef ChartData = List>; + +/// The type of merge to perform when combining two charts +enum MergeType { + /// Merges two charts together, adding the y values of points with the + /// matching x values. Adds any points that are not present in the first + /// chart to the resulting chart. + fullOuterJoin, + + /// Merges two charts together, adding the y values of points with the + /// nearest x value. Ensures that the resulting chart has the same x + /// values as the first chart. If the second chart has x values that are + /// not present in the first chart, the nearest x value in the first chart + /// is used to calculate the y value. + leftJoin, +} + +extension ChartExtension on ChartData { + /// Calculate the percentage increase between the first and last points + /// in the chart. + /// Returns 0.0 if the chart has less than 2 points. + /// The x values are assumed to be in ascending order. + double get percentageIncrease { + if (length < 2) { + return 0.0; + } + + final double initialValue = first.y; + final double finalValue = last.y; + + // Handle the case where the initial value is zero to avoid division by zero + if (initialValue == 0) { + return finalValue == 0 ? 0.0 : double.infinity; + } + + double percentageChange = + ((finalValue - initialValue) / initialValue.abs()) * 100; + return percentageChange; + } + + /// Calculate the increase between the first and last points in the chart. + /// Returns 0.0 if the chart has less than 2 points. + /// The x values are assumed to be in ascending order. + double get increase { + if (length < 2) { + return 0.0; + } + + final oldestValue = first.y; + final newestValue = last.y; + + assert(first.x < last.x); + + return newestValue - oldestValue; + } + + /// Filter the chart data to a specific period of time. + /// [period] The duration of time to filter the chart data to. + /// Returns a new chart with the filtered data. + ChartData filterToPeriod(Duration period) { + final startDate = DateTime.now().subtract(period); + return where( + (element) => DateTime.fromMillisecondsSinceEpoch(element.x.floor()) + .isAfter(startDate), + ).toList(); + } + + /// Filter the chart data to a specific period of time. + ChartData filterDomain({DateTime? startAt, DateTime? endAt}) { + if (startAt == null && endAt == null) { + return this; + } + + if (startAt != null && endAt != null) { + return where( + (element) { + final date = DateTime.fromMillisecondsSinceEpoch(element.x.floor()); + return date.isAfter(startAt) && date.isBefore(endAt); + }, + ).toList(); + } + + if (startAt != null) { + return where( + (element) { + final date = DateTime.fromMillisecondsSinceEpoch(element.x.floor()); + return date.isAfter(startAt); + }, + ).toList(); + } + + return where( + (element) { + final date = DateTime.fromMillisecondsSinceEpoch(element.x.floor()); + return date.isBefore(endAt!); + }, + ).toList(); + } +} + +/// A class for manipulating chart data +class Charts { + /// Merges two or more charts together. The [mergeType] determines how the + /// charts are combined, whether one graph is added to another (left join) or + /// meshed together (full outer join). + /// + /// [charts] The charts to merge, with the first chart being the base chart + /// [mergeType] The type of merge to perform + /// + /// Returns a new chart with the combined values of the charts + /// + /// Example usage: + /// ```dart + /// final ChartData combinedChart = Charts.merge([chart1, chart2]); + /// ``` + static ChartData merge( + Iterable charts, { + MergeType mergeType = MergeType.fullOuterJoin, + }) { + if (charts.isEmpty) { + return []; + } + + ChartData combinedChart = charts.first; + for (int i = 1; i < charts.length; i++) { + if (charts.elementAt(i).isEmpty) { + continue; + } + + switch (mergeType) { + case MergeType.fullOuterJoin: + combinedChart = + _fullOuterJoinMerge(combinedChart, charts.elementAt(i)); + break; + case MergeType.leftJoin: + combinedChart = _leftJoinMerge(combinedChart, charts.elementAt(i)); + break; + } + } + + return combinedChart; + } + + /// Interpolates a chart to a target length by adding points between the + /// existing points. The new points are calculated by linear interpolation + /// between the existing points. + /// + /// [points] The chart to interpolate to a length of + /// [targetLength] data points. + /// + /// Returns a new chart with the interpolated values + /// + /// Example usage: + /// ```dart + /// final ChartData interpolatedChart = Charts.interpolate(chart, 100); + /// ``` + static ChartData interpolate(ChartData points, int targetLength) { + if (points.isEmpty || points.length >= targetLength) { + return points; + } + + ChartData result = []; + int originalLength = points.length; + + for (int i = 0; i < targetLength - 1; i++) { + double ratio = i / (targetLength - 1); + int leftIndex = (ratio * (originalLength - 1)).floor(); + int rightIndex = leftIndex + 1; + double t = (ratio * (originalLength - 1)) - leftIndex; + + if (rightIndex < originalLength) { + double interpolatedX = + points[leftIndex].x * (1 - t) + points[rightIndex].x * t; + double interpolatedY = + points[leftIndex].y * (1 - t) + points[rightIndex].y * t; + result.add(Point(interpolatedX, interpolatedY)); + } else { + result.add(points[leftIndex]); + } + } + + result.add(points.last); + + return result; + } + + /// Merges two charts together, adding the y values of points with the + /// matching x values. Adds any points that are not present in the first + /// chart to the resulting chart. + static ChartData _fullOuterJoinMerge( + ChartData chart1, + ChartData chart2, + ) { + final ChartData combinedChart = [...chart1]; + for (final point in chart2) { + final existingPointIndex = + combinedChart.indexWhere((p) => p.x == point.x); + if (existingPointIndex > -1) { + combinedChart[existingPointIndex] = Point( + combinedChart[existingPointIndex].x, + combinedChart[existingPointIndex].y + point.y, + ); + } else { + // use the last point in the combined chart to calculate the next point + final nearestIndex = chart1.indexWhere((p) => p.x >= point.x); + if (nearestIndex > -1) { + combinedChart.add( + Point( + point.x, + chart1[nearestIndex].y + point.y, + ), + ); + } else { + combinedChart.add(point); + } + } + } + + return combinedChart; + } + + /// Uses the time-axis (x-values) of an OHLC chart as the basis of a chart + /// into which the list of transactions are merged using the left join + /// strategy. The date of the first transaction is used to filter the + /// OHLC values. + /// + /// NOTE: this function is specific to the Portfolio Growth chart + /// + /// [transactions] The transactions to merge + /// [spotValues] The OHLC values to merge with + /// + /// Returns a new chart with the combined values + + /// + /// Example usage: + /// ```dart + /// final ChartData portfolioBalance = + /// Charts.mergeTransactionsWithPortfolioOHLC(transactions, spotValues); + /// ``` + static ChartData mergeTransactionsWithPortfolioOHLC( + List transactions, + ChartData spotValues, + ) { + if (transactions.isEmpty) { + return List.empty(); + } + + final int firstTransactionDate = transactions.first.timestamp; + final ChartData ohlcFromFirstTransaction = spotValues + .where((Point spot) => (spot.x / 1000) >= firstTransactionDate) + .toList(); + + double runningTotal = 0; + int transactionIndex = 0; + final ChartData portfolioBalance = >[]; + + Transaction currentTransaction() => transactions[transactionIndex]; + + for (final Point spot in ohlcFromFirstTransaction) { + if (transactionIndex < transactions.length) { + bool transactionPassed = + currentTransaction().timestamp <= (spot.x ~/ 1000); + while (transactionPassed) { + final double changeAmount = + double.parse(currentTransaction().myBalanceChange); + runningTotal += changeAmount; + transactionIndex++; + + // The below code shifts all entries by the change amount to avoid + // negative values. + // This is a workaround for the issue where the balance can go + // negative while the transaction history is loaded from the API. + // This is specific to the portfolio growth chart, so + const double threshold = 0.000001; // Adjust this value as needed + if (runningTotal.abs() < threshold) { + runningTotal = 0; + } + if (runningTotal < 0) { + runningTotal += changeAmount.abs(); + // offset all entries by the change amount to avoid negative values + for (int i = 0; i < portfolioBalance.length; i++) { + portfolioBalance[i] = Point( + portfolioBalance[i].x, + portfolioBalance[i].y + changeAmount.abs(), + ); + } + } + // end of extremely bad code, on with the questionable code + + if (transactionIndex >= transactions.length) { + break; + } + + transactionPassed = currentTransaction().timestamp < (spot.x ~/ 1000); + } + } + + portfolioBalance.add( + Point( + spot.x, + runningTotal * spot.y, + ), + ); + } + return portfolioBalance; + } + + /// Merges two charts together, adding the y values of points with the + /// nearest x value. Ensures that the resulting chart has the same x + /// values as the first chart. If the second chart has x values that are + /// not present in the first chart, the nearest x value in the first chart + /// is used to calculate the y value. + /// + /// [baseChart] The chart to merge into + /// [chartToMerge] The chart to merge with + /// + /// Returns a new chart with the combined values + static ChartData _leftJoinMerge( + ChartData baseChart, + ChartData chartToMerge, + ) { + final List> mergedChart = >[]; + int mergeIndex = 0; + double cumulativeChange = 0; + double lastMergeY = 0; + + for (final Point basePoint in baseChart) { + while (mergeIndex < chartToMerge.length && + chartToMerge[mergeIndex].x <= basePoint.x) { + // Calculate the difference between the current price and the previous + // price and add it to the cumulative change. This way, the + // [chartToMerge] is merged in without any gaps or spikes. + cumulativeChange += chartToMerge[mergeIndex].y - lastMergeY; + lastMergeY = chartToMerge[mergeIndex].y; + mergeIndex++; + } + + // Add the cumulative change to the base chart value + mergedChart.add( + Point( + basePoint.x, + basePoint.y + cumulativeChange, + ), + ); + } + + return mergedChart; + } +} diff --git a/lib/bloc/cex_market_data/mockup/generate_demo_data.dart b/lib/bloc/cex_market_data/mockup/generate_demo_data.dart new file mode 100644 index 0000000000..a50c7015fa --- /dev/null +++ b/lib/bloc/cex_market_data/mockup/generate_demo_data.dart @@ -0,0 +1,209 @@ +import 'dart:math'; + +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:uuid/uuid.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; +import 'package:web_dex/model/withdraw_details/fee_details.dart'; + +// similar to generator implementation to allow for const constructor +final _ohlcvCache = >{}; + +/// Generates semi-random transaction data for demo purposes. The transactions +/// are generated based on the historical OHLCV 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 BinanceRepository _ohlcRepo; + final int randomSeed; + final List coinPairs; + final Map transactionsPerMode; + final Map overallReturn; + final Map> buyProbabilities; + final Map> tradeAmountFactors; + final double initialBalance; + + const DemoDataGenerator( + this._ohlcRepo, { + 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.transactionsPerMode = const { + PerformanceMode.good: 28, + PerformanceMode.mediocre: 52, + PerformanceMode.veryBad: 34, + }, + this.overallReturn = const { + PerformanceMode.good: 2.0, + PerformanceMode.mediocre: 1.0, + PerformanceMode.veryBad: 0.05, + }, + this.buyProbabilities = const { + PerformanceMode.good: [0.9, 0.7, 0.5, 0.2], + PerformanceMode.mediocre: [0.6, 0.5, 0.5, 0.4], + PerformanceMode.veryBad: [0.7, 0.5, 0.3, 0.1], + }, + this.tradeAmountFactors = const { + PerformanceMode.good: [0.25, 0.2, 0.15, 0.1], + PerformanceMode.mediocre: [0.01, 0.01, 0.01, 0.01], + PerformanceMode.veryBad: [0.1, 0.15, 0.2, 0.25], + }, + this.randomSeed = 42, + }); + + Future> generateTransactions( + String coinId, + PerformanceMode mode, + ) async { + if (_ohlcvCache.isEmpty) { + _ohlcvCache.addAll(await fetchOhlcData()); + } + + // 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))) { + return []; + } + final ohlcvData = _ohlcvCache[CexCoinPair.usdtPrice(cacheKey)]!; + + final numTransactions = transactionsPerMode[mode]!; + final random = Random(randomSeed); + final buyProbalities = buyProbabilities[mode]!; + final tradeAmounts = tradeAmountFactors[mode]!; + double totalBalance = initialBalance / ohlcvData.last.close; + double targetFinalBalance = + (initialBalance * overallReturn[mode]!) / ohlcvData.first.close; + + 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 quarter = (i * 4 ~/ numTransactions).clamp(0, 3); + final bool isBuy = random.nextDouble() < buyProbalities[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); + transactions.add(transaction); + + if (isSameDay) { + final transaction = fromTradeAmount( + coinId, + -tradeAmount, + !isBuy, + ohlcv.closeTime + 100, + ); + transactions.add(transaction); + } + + totalBalance += double.parse(transaction.myBalanceChange); + if (totalBalance <= 0) { + totalBalance = targetFinalBalance; + break; + } + } + + List adjustedTransactions = _adjustTransactionsToTargetBalance( + targetFinalBalance, + totalBalance, + transactions, + ); + + return adjustedTransactions; + } + + List _adjustTransactionsToTargetBalance( + double targetFinalBalance, + double totalBalance, + List transactions, + ) { + double adjustmentFactor = targetFinalBalance / totalBalance; + final adjustedTransactions = []; + for (var transaction in transactions) { + adjustedTransactions.add( + transaction.copyWith( + myBalanceChange: + (double.parse(transaction.myBalanceChange) * adjustmentFactor) + .toString(), + receivedByMe: + (double.parse(transaction.receivedByMe) * adjustmentFactor) + .toString(), + spentByMe: (double.parse(transaction.spentByMe) * adjustmentFactor) + .toString(), + totalAmount: + (double.parse(transaction.totalAmount) * adjustmentFactor) + .toString(), + ), + ); + } + return adjustedTransactions; + } + + Future>> fetchOhlcData() async { + final ohlcvData = >{}; + for (final CexCoinPair coin in coinPairs) { + const interval = GraphInterval.oneDay; + final startAt = DateTime.now().subtract(const Duration(days: 365)); + + final data = + await _ohlcRepo.getCoinOhlc(coin, interval, startAt: startAt); + + final twoWeeksAgo = DateTime.now().subtract(const Duration(days: 14)); + data.ohlc.addAll( + await _ohlcRepo + .getCoinOhlc(coin, GraphInterval.oneHour, startAt: twoWeeksAgo) + .then((value) => value.ohlc), + ); + + ohlcvData[coin] = data.ohlc; + } + return ohlcvData; + } +} + +Transaction fromTradeAmount( + String coinId, + double tradeAmount, + bool isBuy, + int closeTimestamp, +) { + const uuid = Uuid(); + final random = Random(42); + + return Transaction( + blockHeight: random.nextInt(100000) + 100000, + coin: coinId, + confirmations: random.nextInt(3) + 1, + feeDetails: FeeDetails( + type: "fixed", + coin: "USDT", + amount: "1.0", + totalFee: "1.0", + ), + from: ["address1"], + internalId: uuid.v4(), + myBalanceChange: isBuy ? tradeAmount.toString() : (-tradeAmount).toString(), + receivedByMe: !isBuy ? tradeAmount.toString() : '0', + spentByMe: isBuy ? tradeAmount.toString() : '0', + timestamp: closeTimestamp ~/ 1000, + to: ["address2"], + totalAmount: tradeAmount.toString(), + txHash: uuid.v4(), + txHex: "hexstring", + memo: "memo", + ); +} diff --git a/lib/bloc/cex_market_data/mockup/generator.dart b/lib/bloc/cex_market_data/mockup/generator.dart new file mode 100644 index 0000000000..a1e8c50884 --- /dev/null +++ b/lib/bloc/cex_market_data/mockup/generator.dart @@ -0,0 +1,54 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:flutter/services.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 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; + +final _supportedCoinsCache = >{}; +final _transactionsCache = >>{}; + +class DemoDataCache { + final DemoDataGenerator _generator; + + DemoDataCache(this._generator); + DemoDataCache.withDefaults() + : _generator = DemoDataGenerator( + BinanceRepository(binanceProvider: const BinanceProvider()), + ); + + 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 Map data = json.decode(response); + final result = (data['profit'] as Map).keys.toList(); + _supportedCoinsCache[cacheKey] = result; + return result; + } + + Future> loadTransactionsDemoData( + PerformanceMode performanceMode, + String coin, + ) async { + final cacheKey = coin; + if (_transactionsCache.containsKey(cacheKey) && + _transactionsCache[cacheKey]!.containsKey(performanceMode)) { + return _transactionsCache[cacheKey]![performanceMode]!; + } + + final result = + await _generator.generateTransactions(cacheKey, performanceMode); + + _transactionsCache.putIfAbsent(cacheKey, () => {}); + _transactionsCache[cacheKey]![performanceMode] = result; + + return 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 new file mode 100644 index 0000000000..80bfda5be6 --- /dev/null +++ b/lib/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart @@ -0,0 +1,37 @@ +import 'package:http/http.dart'; +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'; +import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; +import 'package:web_dex/bloc/cex_market_data/models/graph_cache.dart'; +import 'package:web_dex/bloc/cex_market_data/models/graph_type.dart'; +import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; + +class MockPortfolioGrowthRepository extends PortfolioGrowthRepository { + final PerformanceMode performanceMode; + + MockPortfolioGrowthRepository({ + required super.cexRepository, + required super.transactionHistoryRepo, + required super.cacheProvider, + required this.performanceMode, + }); + + MockPortfolioGrowthRepository.withDefaults({required this.performanceMode}) + : super( + cexRepository: BinanceRepository( + binanceProvider: const BinanceProvider(), + ), + transactionHistoryRepo: MockTransactionHistoryRepo( + api: mm2Api, + client: Client(), + performanceMode: performanceMode, + demoDataGenerator: DemoDataCache.withDefaults(), + ), + cacheProvider: HiveLazyBoxProvider( + name: GraphType.balanceGrowth.tableName, + ), + ); +} diff --git a/lib/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart b/lib/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart new file mode 100644 index 0000000000..825ec6df1d --- /dev/null +++ b/lib/bloc/cex_market_data/mockup/mock_transaction_history_repository.dart @@ -0,0 +1,27 @@ +import 'package:http/http.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/generator.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; +import 'package:web_dex/model/coin.dart'; + +class MockTransactionHistoryRepo extends TransactionHistoryRepo { + final PerformanceMode performanceMode; + final DemoDataCache demoDataGenerator; + + MockTransactionHistoryRepo({ + required Mm2Api api, + required Client client, + required this.performanceMode, + required this.demoDataGenerator, + }) : super(api: api, client: client); + + @override + Future> fetchTransactions(Coin coin) async { + return demoDataGenerator.loadTransactionsDemoData( + performanceMode, + coin.abbr, + ); + } +} diff --git a/lib/bloc/cex_market_data/mockup/performance_mode.dart b/lib/bloc/cex_market_data/mockup/performance_mode.dart new file mode 100644 index 0000000000..539a8117fa --- /dev/null +++ b/lib/bloc/cex_market_data/mockup/performance_mode.dart @@ -0,0 +1,5 @@ +enum PerformanceMode { + good, + mediocre, + veryBad, +} diff --git a/lib/bloc/cex_market_data/models/adapters/adapters.dart b/lib/bloc/cex_market_data/models/adapters/adapters.dart new file mode 100644 index 0000000000..f891c42a91 --- /dev/null +++ b/lib/bloc/cex_market_data/models/adapters/adapters.dart @@ -0,0 +1,2 @@ +export 'graph_cache_adapter.dart'; +export 'point_adapter.dart'; diff --git a/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart b/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart new file mode 100644 index 0000000000..c9faeebaf1 --- /dev/null +++ b/lib/bloc/cex_market_data/models/adapters/graph_cache_adapter.dart @@ -0,0 +1,54 @@ +import 'dart:math'; + +import 'package:hive/hive.dart'; +import 'package:web_dex/bloc/cex_market_data/models/graph_cache.dart'; +import 'package:web_dex/bloc/cex_market_data/models/graph_type.dart'; + +class GraphCacheAdapter extends TypeAdapter { + @override + final int typeId = 17; + + @override + GraphCache read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return GraphCache( + coinId: fields[0] as String, + fiatCoinId: fields[1] as String, + lastUpdated: fields[2] as DateTime, + graph: (fields[3] as List).cast>(), + graphType: GraphType.fromName(fields[4] as String), + walletId: fields[5] as String, + ); + } + + @override + void write(BinaryWriter writer, GraphCache obj) { + writer + ..writeByte(6) + ..writeByte(0) + ..write(obj.coinId) + ..writeByte(1) + ..write(obj.fiatCoinId) + ..writeByte(2) + ..write(obj.lastUpdated) + ..writeByte(3) + ..write(obj.graph) + ..writeByte(4) + ..write(obj.graphType.name) + ..writeByte(5) + ..write(obj.walletId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is GraphCacheAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/bloc/cex_market_data/models/adapters/point_adapter.dart b/lib/bloc/cex_market_data/models/adapters/point_adapter.dart new file mode 100644 index 0000000000..e5c9318cf0 --- /dev/null +++ b/lib/bloc/cex_market_data/models/adapters/point_adapter.dart @@ -0,0 +1,40 @@ +import 'dart:math'; + +import 'package:hive/hive.dart'; + +class PointAdapter extends TypeAdapter> { + @override + final int typeId = 18; + + @override + Point read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Point( + fields[0] as double, + fields[1] as double, + ); + } + + @override + void write(BinaryWriter writer, Point obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.x) + ..writeByte(1) + ..write(obj.y); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is PointAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/bloc/cex_market_data/models/graph_cache.dart b/lib/bloc/cex_market_data/models/graph_cache.dart new file mode 100644 index 0000000000..4029d3d4b5 --- /dev/null +++ b/lib/bloc/cex_market_data/models/graph_cache.dart @@ -0,0 +1,97 @@ +import 'package:equatable/equatable.dart'; +import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; +import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/models/graph_type.dart'; + +/// Cache for the portfolio growth graph data. +class GraphCache extends Equatable implements ObjectWithPrimaryKey { + /// Create a new instance of the cache. + const GraphCache({ + required this.coinId, + required this.fiatCoinId, + required this.lastUpdated, + required this.graph, + required this.graphType, + required this.walletId, + }); + + factory GraphCache.fromJson(Map json) { + return GraphCache( + coinId: json['coinId'], + fiatCoinId: json['fiatCoinId'], + lastUpdated: DateTime.parse(json['lastUpdated']), + graph: List.from(json['portfolioGrowthGraphs']), + graphType: json['graphType'], + walletId: json['walletId'], + ); + } + + static String getPrimaryKey( + String coinId, + String fiatCoinId, + GraphType graphType, + String walletId, + ) => + '$coinId-$fiatCoinId-${graphType.name}-$walletId'; + + /// The komodo coin abbreviation from the coins repository (e.g. BTC, etc.). + final String coinId; + + /// The fiat coin abbreviation (e.g. USDT, etc.). + final String fiatCoinId; + + /// The timestamp of the last update. + final DateTime lastUpdated; + + /// The portfolio growth graph data. + final ChartData graph; + + /// The type of the graph. + final GraphType graphType; + + /// The wallet ID. + final String walletId; + + Map toJson() { + return { + 'coinId': coinId, + 'fiatCoinId': fiatCoinId, + 'lastUpdated': lastUpdated.toIso8601String(), + 'portfolioGrowthGraphs': graph, + 'graphType': graphType, + 'walletId': walletId, + }; + } + + GraphCache copyWith({ + String? coinId, + String? fiatCoinId, + DateTime? lastUpdated, + ChartData? portfolioGrowthGraphs, + GraphType? graphType, + String? walletId, + }) { + return GraphCache( + coinId: coinId ?? this.coinId, + fiatCoinId: fiatCoinId ?? this.fiatCoinId, + lastUpdated: lastUpdated ?? this.lastUpdated, + graph: portfolioGrowthGraphs ?? graph, + graphType: graphType ?? this.graphType, + walletId: walletId ?? this.walletId, + ); + } + + @override + List get props => [ + coinId, + fiatCoinId, + lastUpdated, + graph, + graphType, + walletId, + ]; + + @override + String get primaryKey => + getPrimaryKey(coinId, fiatCoinId, graphType, walletId); +} diff --git a/lib/bloc/cex_market_data/models/graph_type.dart b/lib/bloc/cex_market_data/models/graph_type.dart new file mode 100644 index 0000000000..bae69ae46b --- /dev/null +++ b/lib/bloc/cex_market_data/models/graph_type.dart @@ -0,0 +1,69 @@ +/// Enum for the different types of graphs that can be displayed in +/// the portfolio growth screen. +enum GraphType { + /// The profit/loss graph for an individual coin/asset. + profitLoss, + + /// The balance growth graph for an individual coin/asset. + balanceGrowth, + + /// The profit/loss graph for the entire portfolio. + portfolioProfitLoss, + + /// The balance growth graph for the entire portfolio. + portfolioGrowth; + + static GraphType fromName(String key) { + switch (key) { + case 'profitLossGraph': + return GraphType.profitLoss; + case 'balanceGrowthGraph': + return GraphType.balanceGrowth; + case 'portfolioProfitLossGraph': + return GraphType.portfolioProfitLoss; + case 'portfolioGrowthGraph': + return GraphType.portfolioGrowth; + default: + throw ArgumentError('Invalid key: $key'); + } + } + + String get title { + switch (this) { + case GraphType.profitLoss: + return 'Profit/Loss'; + case GraphType.balanceGrowth: + return 'Balance Growth'; + case GraphType.portfolioProfitLoss: + return 'Portfolio Profit/Loss'; + case GraphType.portfolioGrowth: + return 'Portfolio Growth'; + } + } + + String get name { + switch (this) { + case GraphType.profitLoss: + return 'profitLossGraph'; + case GraphType.balanceGrowth: + return 'balanceGrowthGraph'; + case GraphType.portfolioProfitLoss: + return 'portfolioProfitLossGraph'; + case GraphType.portfolioGrowth: + return 'portfolioGrowthGraph'; + } + } + + String get tableName { + switch (this) { + case GraphType.profitLoss: + return 'profit_loss'; + case GraphType.balanceGrowth: + return 'balance_growth'; + case GraphType.portfolioProfitLoss: + return 'portfolio_profit_loss'; + case GraphType.portfolioGrowth: + return 'portfolio_growth'; + } + } +} diff --git a/lib/bloc/cex_market_data/models/models.dart b/lib/bloc/cex_market_data/models/models.dart new file mode 100644 index 0000000000..2e4f711be7 --- /dev/null +++ b/lib/bloc/cex_market_data/models/models.dart @@ -0,0 +1,2 @@ +export 'adapters/adapters.dart'; +export 'graph_cache.dart'; 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 new file mode 100644 index 0000000000..6a8f8ddfaf --- /dev/null +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart @@ -0,0 +1,215 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart'; +import 'package:web_dex/blocs/blocs.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/utils.dart'; + +part 'portfolio_growth_event.dart'; +part 'portfolio_growth_state.dart'; + +class PortfolioGrowthBloc + extends Bloc { + PortfolioGrowthBloc({ + required this.portfolioGrowthRepository, + }) : 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). + on( + _onLoadPortfolioGrowth, + transformer: restartable(), + ); + on( + _onPortfolioGrowthPeriodChanged, + transformer: restartable(), + ); + on(_onClearPortfolioGrowth); + } + + final PortfolioGrowthRepository portfolioGrowthRepository; + + void _onClearPortfolioGrowth( + PortfolioGrowthClearRequested event, + Emitter emit, + ) { + emit(const PortfolioGrowthInitial()); + } + + void _onPortfolioGrowthPeriodChanged( + PortfolioGrowthPeriodChanged event, + Emitter emit, + ) { + if (state is GrowthChartLoadFailure) { + emit( + GrowthChartLoadFailure( + error: (state as GrowthChartLoadFailure).error, + selectedPeriod: event.selectedPeriod, + ), + ); + } + + add( + PortfolioGrowthLoadRequested( + coins: event.coins, + selectedPeriod: event.selectedPeriod, + fiatCoinId: 'USDT', + updateFrequency: event.updateFrequency, + walletId: event.walletId, + ), + ); + } + + Future _onLoadPortfolioGrowth( + PortfolioGrowthLoadRequested event, + Emitter emit, + ) async { + List coins = await _removeUnsupportedCoins(event); + // 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) { + return emit( + PortfolioGrowthChartUnsupported(selectedPeriod: event.selectedPeriod), + ); + } + + await _loadChart(coins, event, useCache: true) + .then(emit.call) + .catchError((e, _) { + if (state is! PortfolioGrowthChartLoadSuccess) { + emit( + GrowthChartLoadFailure( + error: TextError(error: e.toString()), + selectedPeriod: event.selectedPeriod, + ), + ); + } + }); + + // Only remove inactivate/activating coins after an attempt to load the + // cached chart, as the cached chart may contain inactive coins. + coins = _removeInactiveCoins(coins); + if (coins.isNotEmpty) { + await _loadChart(coins, event, useCache: false) + .then(emit.call) + .catchError((_, __) { + // Ignore un-cached errors, as a transaction loading exception should not + // make the graph disappear with a load failure emit, as the cached data + // is already displayed. The periodic updates will still try to fetch the + // data and update the graph. + }); + } + + await emit.forEach( + Stream.periodic(event.updateFrequency) + .asyncMap((_) async => await _fetchPortfolioGrowthChart(event)), + onData: (data) => + _handlePortfolioGrowthUpdate(data, event.selectedPeriod), + onError: (e, _) { + log( + 'Failed to load portfolio growth: $e', + isError: true, + ); + return GrowthChartLoadFailure( + error: TextError(error: e.toString()), + selectedPeriod: event.selectedPeriod, + ); + }, + ); + } + + Future> _removeUnsupportedCoins( + PortfolioGrowthLoadRequested event, + ) async { + final List coins = List.from(event.coins); + await coins.removeWhereAsync( + (Coin coin) async { + final isCoinSupported = await portfolioGrowthRepository + .isCoinChartSupported(coin.abbr, event.fiatCoinId); + return !isCoinSupported; + }, + ); + return coins; + } + + List _removeInactiveCoins(List coins) { + final List coinsCopy = List.from(coins) + ..removeWhere((coin) { + final updatedCoin = coinsBlocRepository.getCoin(coin.abbr)!; + return updatedCoin.isActivating || !updatedCoin.isActive; + }); + return coinsCopy; + } + + Future _loadChart( + List coins, + PortfolioGrowthLoadRequested event, { + required bool useCache, + }) async { + final chart = await portfolioGrowthRepository.getPortfolioGrowthChart( + coins, + fiatCoinId: event.fiatCoinId, + walletId: event.walletId, + useCache: useCache, + ); + + if (useCache && chart.isEmpty) { + return state; + } + + return PortfolioGrowthChartLoadSuccess( + portfolioGrowth: chart, + percentageIncrease: chart.percentageIncrease, + selectedPeriod: event.selectedPeriod, + ); + } + + Future _fetchPortfolioGrowthChart( + PortfolioGrowthLoadRequested event, + ) async { + // Do not let transaction loading exceptions stop the periodic updates + final coins = _removeInactiveCoins(await _removeUnsupportedCoins(event)); + try { + return await portfolioGrowthRepository.getPortfolioGrowthChart( + coins, + fiatCoinId: event.fiatCoinId, + walletId: event.walletId, + useCache: false, + ); + } catch (e, s) { + log( + 'Empty growth chart on periodic update: $e', + isError: true, + trace: s, + path: 'PortfolioGrowthBloc', + ); + return ChartData.empty(); + } + } + + PortfolioGrowthState _handlePortfolioGrowthUpdate( + ChartData growthChart, + Duration selectedPeriod, + ) { + if (growthChart.isEmpty && state is PortfolioGrowthChartLoadSuccess) { + return state; + } + + final percentageIncrease = growthChart.percentageIncrease; + + // TODO? Include the center value in the bloc state instead of + // calculating it in the UI + + return PortfolioGrowthChartLoadSuccess( + portfolioGrowth: growthChart, + percentageIncrease: percentageIncrease, + selectedPeriod: selectedPeriod, + ); + } +} 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 new file mode 100644 index 0000000000..0aa750b24a --- /dev/null +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_event.dart @@ -0,0 +1,54 @@ +part of 'portfolio_growth_bloc.dart'; + +sealed class PortfolioGrowthEvent extends Equatable { + const PortfolioGrowthEvent(); + + @override + List get props => []; +} + +class PortfolioGrowthClearRequested extends PortfolioGrowthEvent { + const PortfolioGrowthClearRequested(); +} + +class PortfolioGrowthLoadRequested extends PortfolioGrowthEvent { + const PortfolioGrowthLoadRequested({ + required this.coins, + 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, + ]; +} + +class PortfolioGrowthPeriodChanged extends PortfolioGrowthEvent { + const PortfolioGrowthPeriodChanged({ + 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]; +} 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 new file mode 100644 index 0000000000..1451946526 --- /dev/null +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_repository.dart @@ -0,0 +1,354 @@ +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_persistence_layer/komodo_persistence_layer.dart'; +import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/mock_portfolio_growth_repository.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; +import 'package:web_dex/bloc/cex_market_data/models/graph_type.dart'; +import 'package:web_dex/bloc/cex_market_data/models/models.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; +import 'package:web_dex/model/coin.dart'; + +/// A repository for fetching the growth chart for the portfolio and coins. +class PortfolioGrowthRepository { + /// Create a new instance of the repository with the provided dependencies. + PortfolioGrowthRepository({ + required cex.CexRepository cexRepository, + required TransactionHistoryRepo transactionHistoryRepo, + required PersistenceProvider cacheProvider, + }) : _transactionHistoryRepository = transactionHistoryRepo, + _cexRepository = cexRepository, + _graphCache = cacheProvider; + + /// 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, + PerformanceMode? demoMode, + }) { + if (demoMode != null) { + return MockPortfolioGrowthRepository.withDefaults( + performanceMode: demoMode, + ); + } + + return PortfolioGrowthRepository( + cexRepository: cexRepository, + transactionHistoryRepo: transactionHistoryRepo, + cacheProvider: HiveLazyBoxProvider( + name: GraphType.balanceGrowth.tableName, + ), + ); + } + + /// 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; + + /// The graph cache provider to store the portfolio growth graph data. + final PersistenceProvider _graphCache; + + static Future ensureInitialized() async { + Hive + ..registerAdapter(GraphCacheAdapter()) + ..registerAdapter(PointAdapter()); + } + + /// Get the growth chart for a coin based on the transactions + /// and the spot price of the coin in the fiat currency. + /// + /// NOTE: On a cache miss, an [Exception] is thrown. The assumption is that + /// the function is called with useCache set to false to fetch the + /// transactions again. + /// NOTE: If the transactions are empty, an empty chart is stored in the + /// cache. This is to avoid fetching transactions again for each invocation. + /// + /// [coinId] is the coin to get the growth chart for. + /// [fiatCoinId] is the fiat currency to convert the coin to. + /// [walletId] is the id of the current wallet of the user. + /// [startAt] is the start time of the chart. + /// [endAt] is the end time of the chart. + /// [useCache] is a flag to indicate whether to use the cache when fetching + /// the chart. If set to `true`, the chart is fetched from the cache if it + /// exists, otherwise an [Exception] is thrown. + /// + /// Returns the growth [ChartData] for the coin ([List] of [Point]). + Future getCoinGrowthChart( + String coinId, { + // avoid the possibility of accidentally swapping the order of these + // required parameters by using named parameters + required String fiatCoinId, + required String walletId, + DateTime? startAt, + DateTime? endAt, + bool useCache = true, + bool ignoreTransactionFetchErrors = true, + }) async { + if (useCache) { + final String compoundKey = GraphCache.getPrimaryKey( + coinId, + fiatCoinId, + GraphType.balanceGrowth, + walletId, + ); + final GraphCache? cachedGraph = await _graphCache.get(compoundKey); + final cacheExists = cachedGraph != null; + if (cacheExists) { + return cachedGraph.graph; + } else { + throw Exception('Cache miss for $compoundKey'); + } + } + + // TODO: Refactor referenced coinsBloc method to a repository. + // NB: Even though the class is called [CoinsBloc], it is not a Bloc. + final Coin coin = coinsBlocRepository.getCoin(coinId)!; + final List transactions = await _transactionHistoryRepository + .fetchCompletedTransactions(coin) + .then((value) => value.toList()) + .catchError((Object e) { + if (ignoreTransactionFetchErrors) { + return List.empty(); + } else { + throw e; + } + }); + + if (transactions.isEmpty) { + // Insert an empty chart into the cache to avoid fetching transactions + // again for each invocation. The assumption is that this function is + // called later with useCache set to false to fetch the transactions again + await _graphCache.insert( + GraphCache( + coinId: coinId, + fiatCoinId: fiatCoinId, + lastUpdated: DateTime.now(), + graph: List.empty(), + graphType: GraphType.balanceGrowth, + walletId: walletId, + ), + ); + return List.empty(); + } + + // Continue to cache an empty chart rather than trying to fetch transactions + // again for each invocation. + startAt ??= transactions.first.timestampDate; + endAt ??= DateTime.now(); + + final String baseCoinId = coin.abbr.split('-').first; + final cex.GraphInterval interval = _getOhlcInterval( + startAt, + endDate: endAt, + ); + + cex.CoinOhlc 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()) { + ohlcData = cex.CoinOhlc.fromConstantPrice( + startAt: startAt, + endAt: endAt, + intervalSeconds: interval.toSeconds(), + ); + } else { + ohlcData = await _cexRepository.getCoinOhlc( + cex.CexCoinPair(baseCoinTicker: baseCoinId, relCoinTicker: fiatCoinId), + interval, + startAt: startAt, + endAt: endAt, + ); + } + + final List> portfolowGrowthChart = + _mergeTransactionsWithOhlc(ohlcData, transactions); + + await _graphCache.insert( + GraphCache( + coinId: coin.abbr, + fiatCoinId: fiatCoinId, + lastUpdated: DateTime.now(), + graph: portfolowGrowthChart, + graphType: GraphType.balanceGrowth, + walletId: walletId, + ), + ); + + return portfolowGrowthChart; + } + + /// Get the growth chart for the portfolio based on the transactions + /// and the spot price of the coins in the fiat currency provided. + /// + /// [coins] is the list of coins in the portfolio. + /// [fiatCoinId] is the fiat currency to convert the portfolio to. + /// [walletId] is the wallet id of the portfolio. + /// [useCache] is a flag to indicate whether to use the cache. + /// [startAt] and [endAt] will filter the final chart to the specified range, + /// and cache the filtered chart. + /// [ignoreTransactionFetchErrors] is a flag to ignore transaction fetch errors + /// and return an empty chart instead. + /// + /// Returns the growth [ChartData] for the portfolio ([List] of [Point]). + /// + /// Example usage: + /// ```dart + /// final chartData = + /// await getPortfolioGrowthChart(coins, fiatCurrency: 'usdt'); + /// ``` + Future getPortfolioGrowthChart( + List coins, { + required String fiatCoinId, + required String walletId, + bool useCache = true, + DateTime? startAt, + DateTime? endAt, + bool ignoreTransactionFetchErrors = true, + }) async { + if (coins.isEmpty) { + assert(coins.isNotEmpty, 'The list of coins should not empty.'); + return ChartData.empty(); + } + + final chartDataFutures = coins.map((coin) async { + try { + return await getCoinGrowthChart( + coin.abbr, + fiatCoinId: fiatCoinId, + useCache: useCache, + walletId: walletId, + ignoreTransactionFetchErrors: ignoreTransactionFetchErrors, + ); + } on TransactionFetchException { + if (ignoreTransactionFetchErrors) { + return Future.value(ChartData.empty()); + } else { + rethrow; + } + } on Exception { + return Future.value(ChartData.empty()); + } + }); + final charts = await Future.wait(chartDataFutures); + + charts.removeWhere((element) => element.isEmpty); + if (charts.isEmpty) { + return ChartData.empty(); + } + + final mergedChart = Charts.merge(charts, mergeType: MergeType.leftJoin); + // Add the current USD balance to the end of the chart to ensure that the + // chart matches the current prices and ends at the current time. + final double totalUsdBalance = + coins.fold(0, (prev, coin) => prev + (coin.usdBalance ?? 0)); + if (totalUsdBalance <= 0) { + return mergedChart; + } + + final currentDate = DateTime.now(); + mergedChart.add( + Point( + currentDate.millisecondsSinceEpoch.toDouble(), + totalUsdBalance, + ), + ); + + return mergedChart.filterDomain(startAt: startAt, endAt: endAt); + } + + ChartData _mergeTransactionsWithOhlc( + cex.CoinOhlc ohlcData, + List transactions, + ) { + if (transactions.isEmpty || ohlcData.ohlc.isEmpty) { + return List.empty(); + } + + final ChartData spotValues = ohlcData.ohlc.map((cex.Ohlc ohlc) { + return Point( + ohlc.closeTime.toDouble(), + ohlc.close, + ); + }).toList(); + + final portfolowGrowthChart = + Charts.mergeTransactionsWithPortfolioOHLC(transactions, spotValues); + + return portfolowGrowthChart; + } + + /// 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( + String coinId, + String fiatCoinId, { + bool allowFiatAsBase = true, + }) async { + final Coin coin = coinsBlocRepository.getCoin(coinId)!; + + final supportedCoins = await _cexRepository.getCoinList(); + final coinTicker = coin.abbr.split('-').firstOrNull?.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 + if (allowFiatAsBase && coinTicker == fiatCoinId.toUpperCase()) { + return true; + } + + final coinPair = CexCoinPair( + baseCoinTicker: coinTicker, + relCoinTicker: fiatCoinId.toUpperCase(), + ); + final isCoinSupported = coinPair.isCoinSupported(supportedCoins); + return !coin.isTestCoin && isCoinSupported; + } + + /// Get the OHLC interval for the chart based on the number of transactions + /// and the time span of the transactions. + /// The interval is chosen based on the number of data points + /// and the time span of the transactions. + /// + /// [startDate] is the start date of the transactions. + /// [endDate] is the end date of the transactions. + /// [targetLength] is the number of data points to be displayed on the chart. + /// + /// Returns the OHLC interval. + /// + /// Example usage: + /// ```dart + /// final interval + /// = _getOhlcInterval(transactions, targetLength: 500); + /// ``` + cex.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, + ); + return intervalValue.key; + } + + Future clearCache() => _graphCache.deleteAll(); +} 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 new file mode 100644 index 0000000000..bd53543cb4 --- /dev/null +++ b/lib/bloc/cex_market_data/portfolio_growth/portfolio_growth_state.dart @@ -0,0 +1,50 @@ +part of 'portfolio_growth_bloc.dart'; + +sealed class PortfolioGrowthState extends Equatable { + const PortfolioGrowthState({required this.selectedPeriod}); + + final Duration selectedPeriod; + + @override + List get props => [selectedPeriod]; +} + +final class PortfolioGrowthInitial extends PortfolioGrowthState { + const PortfolioGrowthInitial() + : super(selectedPeriod: const Duration(hours: 1)); +} + +final class PortfolioGrowthChartLoadSuccess extends PortfolioGrowthState { + const PortfolioGrowthChartLoadSuccess({ + required this.portfolioGrowth, + required this.percentageIncrease, + required super.selectedPeriod, + }); + + final ChartData portfolioGrowth; + final double percentageIncrease; + + @override + List get props => [ + portfolioGrowth, + percentageIncrease, + selectedPeriod, + ]; +} + +final class GrowthChartLoadFailure extends PortfolioGrowthState { + const GrowthChartLoadFailure({ + required this.error, + required super.selectedPeriod, + }); + + final BaseError error; + + @override + List get props => [error, selectedPeriod]; +} + +final class PortfolioGrowthChartUnsupported extends PortfolioGrowthState { + const PortfolioGrowthChartUnsupported({required Duration selectedPeriod}) + : super(selectedPeriod: selectedPeriod); +} diff --git a/lib/bloc/cex_market_data/price_chart/models/price_chart_data.dart b/lib/bloc/cex_market_data/price_chart/models/price_chart_data.dart new file mode 100644 index 0000000000..969f3f265a --- /dev/null +++ b/lib/bloc/cex_market_data/price_chart/models/price_chart_data.dart @@ -0,0 +1,41 @@ +// TODO? The names of these classes don't seem to be very descriptive and the +// hierachy may be confusing. Consider renaming them if this is conirmed. + +// TODO? Make all classes in this file generic classes with type parameters +// so that they can be re-used for other charts. +class CoinPriceInfo { + final String ticker; + final String name; + final String id; + + final double selectedPeriodIncreasePercentage; + + CoinPriceInfo({ + required this.ticker, + required this.selectedPeriodIncreasePercentage, + required this.id, + required this.name, + }); +} + +class PriceChartSeriesPoint { + final double usdValue; + final double unixTimestamp; + + PriceChartSeriesPoint({ + required this.usdValue, + required this.unixTimestamp, + }); +} + +class PriceChartDataSeries { + PriceChartDataSeries({ + required this.info, + required this.data, + }); + final CoinPriceInfo info; + + // TODO: Better approach to use class or Map? Latter allows us to cut out + // the point class. E.g. Map<{x type}, {y type}>. + final List data; +} diff --git a/lib/bloc/cex_market_data/price_chart/models/price_chart_interval.dart b/lib/bloc/cex_market_data/price_chart/models/price_chart_interval.dart new file mode 100644 index 0000000000..6e1f04929b --- /dev/null +++ b/lib/bloc/cex_market_data/price_chart/models/price_chart_interval.dart @@ -0,0 +1,44 @@ +// TODO! Renmove confusion between PriceChartInterval and missing feature of +// price chart period selection + +enum PriceChartPeriod { + oneHour, + oneDay, + oneWeek, + oneMonth, + oneYear; + + String get name { + switch (this) { + case PriceChartPeriod.oneHour: + return '1H'; + case PriceChartPeriod.oneDay: + return '1D'; + case PriceChartPeriod.oneWeek: + return '1W'; + case PriceChartPeriod.oneMonth: + return '1M'; + case PriceChartPeriod.oneYear: + return '1Y'; + default: + throw Exception('Unknown interval'); + } + } + + String get intervalString { + switch (this) { + case PriceChartPeriod.oneHour: + return '1h'; + case PriceChartPeriod.oneDay: + return '1d'; + case PriceChartPeriod.oneWeek: + return '1w'; + case PriceChartPeriod.oneMonth: + return '1M'; + case PriceChartPeriod.oneYear: + return '1y'; + default: + throw Exception('Unknown interval'); + } + } +} diff --git a/lib/bloc/cex_market_data/price_chart/models/time_period.dart b/lib/bloc/cex_market_data/price_chart/models/time_period.dart new file mode 100644 index 0000000000..e09a2c4fe6 --- /dev/null +++ b/lib/bloc/cex_market_data/price_chart/models/time_period.dart @@ -0,0 +1,44 @@ +// TODO! Renmove confusion between PriceChartInterval and missing feature of +// price chart period selection + +enum TimePeriod { + oneHour, + oneDay, + oneWeek, + oneMonth, + oneYear; + + String get name { + switch (this) { + case TimePeriod.oneHour: + return '1H'; + case TimePeriod.oneDay: + return '1D'; + case TimePeriod.oneWeek: + return '1W'; + case TimePeriod.oneMonth: + return '1M'; + case TimePeriod.oneYear: + return '1Y'; + default: + throw Exception('Unknown interval'); + } + } + + Duration get duration { + switch (this) { + case TimePeriod.oneHour: + return const Duration(hours: 1); + case TimePeriod.oneDay: + return const Duration(days: 1); + case TimePeriod.oneWeek: + return const Duration(days: 7); + case TimePeriod.oneMonth: + return const Duration(days: 30); + case TimePeriod.oneYear: + return const Duration(days: 365); + default: + throw Exception('Unknown interval'); + } + } +} 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 new file mode 100644 index 0000000000..b20a7f89d3 --- /dev/null +++ b/lib/bloc/cex_market_data/price_chart/price_chart_bloc.dart @@ -0,0 +1,194 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +import 'models/price_chart_data.dart'; +import 'price_chart_event.dart'; +import 'price_chart_state.dart'; + +class PriceChartBloc extends Bloc { + PriceChartBloc(this.cexPriceRepository) : super(const PriceChartState()) { + on(_onStarted); + on(_onIntervalChanged); + on(_onSymbolChanged); + } + + final BinanceRepository cexPriceRepository; + final KomodoPriceRepository _komodoPriceRepository = KomodoPriceRepository( + cexPriceProvider: KomodoPriceProvider(), + ); + + void _onStarted( + PriceChartStarted event, + Emitter emit, + ) async { + emit(state.copyWith(status: PriceChartStatus.loading)); + try { + final coinPrices = await _komodoPriceRepository.getKomodoPrices(); + + Map fetchedCexCoins = state.availableCoins; + if (state.availableCoins.isEmpty) { + final coins = (await cexPriceRepository.getCoinList()) + .where((coin) => coin.currencies.contains('USDT')) + .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(); + + fetchedCexCoins = { + for (var coin in await Future.wait(coins)) coin.ticker: coin, + }; + } + + final List> futures = + event.symbols.map((symbol) async { + try { + final CoinOhlc ohlcData = await cexPriceRepository.getCoinOhlc( + CexCoinPair.usdtPrice(symbol), + _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, + 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(), + ); + }).toList(), + ); + } catch (e) { + log("Error fetching OHLC data for $symbol: $e"); + return null; + } + }).toList(); + + final data = await Future.wait(futures); + + emit( + state.copyWith( + status: PriceChartStatus.success, + data: data + .where((series) => series != null) + .cast() + .toList(), + selectedPeriod: event.period, + availableCoins: fetchedCexCoins, + ), + ); + } catch (e) { + emit( + state.copyWith( + status: PriceChartStatus.failure, + error: e.toString(), + ), + ); + } + } + + double? _calculatePercentageChange(Ohlc? first, Ohlc? 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; + } + + return ((lastTypicalPrice - firstTypicalPrice) / firstTypicalPrice) * 100; + } + + void _onIntervalChanged( + PriceChartPeriodChanged event, + Emitter emit, + ) { + final currentState = state; + if (currentState.status != PriceChartStatus.success) { + return; + } + emit( + state.copyWith( + selectedPeriod: event.period, + ), + ); + add( + PriceChartStarted( + symbols: currentState.data.map((e) => e.info.id).toList(), + period: event.period, + ), + ); + } + + void _onSymbolChanged( + PriceChartCoinsSelected event, + Emitter emit, + ) { + add( + PriceChartStarted( + symbols: event.symbols, + period: state.selectedPeriod, + ), + ); + } + + GraphInterval _dividePeriodToInterval(Duration period) { + if (period.inDays >= 365) { + return GraphInterval.oneWeek; + } + if (period.inDays >= 30) { + return GraphInterval.oneDay; + } + if (period.inDays >= 7) { + return GraphInterval.sixHours; + } + if (period.inDays >= 1) { + return GraphInterval.oneHour; + } + if (period.inHours >= 1) { + return GraphInterval.oneMinute; + } + + throw Exception('Unknown interval'); + } +} diff --git a/lib/bloc/cex_market_data/price_chart/price_chart_event.dart b/lib/bloc/cex_market_data/price_chart/price_chart_event.dart new file mode 100644 index 0000000000..1297fdd873 --- /dev/null +++ b/lib/bloc/cex_market_data/price_chart/price_chart_event.dart @@ -0,0 +1,36 @@ +import 'package:equatable/equatable.dart'; + +sealed class PriceChartEvent extends Equatable { + const PriceChartEvent(); + + @override + List get props => []; +} + +final class PriceChartStarted extends PriceChartEvent { + final List symbols; + final Duration period; + + const PriceChartStarted({required this.symbols, required this.period}); + + @override + List get props => [symbols, period]; +} + +final class PriceChartPeriodChanged extends PriceChartEvent { + final Duration period; + + const PriceChartPeriodChanged(this.period); + + @override + List get props => [period]; +} + +final class PriceChartCoinsSelected extends PriceChartEvent { + final List symbols; + + const PriceChartCoinsSelected(this.symbols); + + @override + List get props => [symbols]; +} diff --git a/lib/bloc/cex_market_data/price_chart/price_chart_state.dart b/lib/bloc/cex_market_data/price_chart/price_chart_state.dart new file mode 100644 index 0000000000..a20add72ec --- /dev/null +++ b/lib/bloc/cex_market_data/price_chart/price_chart_state.dart @@ -0,0 +1,50 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/cex_market_data/price_chart/models/price_chart_data.dart'; + +enum PriceChartStatus { initial, loading, success, failure } + +final class PriceChartState extends Equatable { + final PriceChartStatus status; + final List data; + final String? error; + + final Map availableCoins; + + //! + final Duration selectedPeriod; + + bool get hasData => data.isNotEmpty; + + const PriceChartState({ + this.status = PriceChartStatus.initial, + this.data = const [], + this.availableCoins = const {}, + this.error, + this.selectedPeriod = const Duration(days: 1), + }); + + PriceChartState copyWith({ + PriceChartStatus? status, + List? data, + String? error, + Duration? selectedPeriod, + Map? availableCoins, + }) { + return PriceChartState( + status: status ?? this.status, + data: data ?? this.data, + error: error ?? this.error, + selectedPeriod: selectedPeriod ?? this.selectedPeriod, + availableCoins: availableCoins ?? this.availableCoins, + ); + } + + @override + List get props => [ + status, + data, + error, + selectedPeriod, + availableCoins, + ]; +} 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 new file mode 100644 index 0000000000..21d70919c4 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart @@ -0,0 +1,47 @@ +import 'package:http/http.dart'; +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'; +import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; + +class MockProfitLossRepository extends ProfitLossRepository { + final PerformanceMode performanceMode; + + MockProfitLossRepository({ + required this.performanceMode, + required super.transactionHistoryRepo, + required super.cexRepository, + required super.profitLossCacheProvider, + required super.profitLossCalculator, + }); + + factory MockProfitLossRepository.withDefaults({ + required PerformanceMode performanceMode, + String cacheTableName = 'mock_profit_loss', + }) { + return MockProfitLossRepository( + profitLossCacheProvider: + HiveLazyBoxProvider(name: cacheTableName), + cexRepository: BinanceRepository( + binanceProvider: const BinanceProvider(), + ), + performanceMode: performanceMode, + transactionHistoryRepo: MockTransactionHistoryRepo( + api: mm2Api, + client: Client(), + performanceMode: performanceMode, + demoDataGenerator: DemoDataCache.withDefaults(), + ), + profitLossCalculator: RealisedProfitLossCalculator( + BinanceRepository( + binanceProvider: const BinanceProvider(), + ), + ), + ); + } +} diff --git a/lib/bloc/cex_market_data/profit_loss/extensions/extensions.dart b/lib/bloc/cex_market_data/profit_loss/extensions/extensions.dart new file mode 100644 index 0000000000..15dcd60739 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/extensions/extensions.dart @@ -0,0 +1 @@ +export 'profit_loss_transaction_extension.dart'; diff --git a/lib/bloc/cex_market_data/profit_loss/extensions/profit_loss_transaction_extension.dart b/lib/bloc/cex_market_data/profit_loss/extensions/profit_loss_transaction_extension.dart new file mode 100644 index 0000000000..80aac5e031 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/extensions/profit_loss_transaction_extension.dart @@ -0,0 +1,30 @@ +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; + +extension ProfitLossTransactionExtension on Transaction { + /// The total amount of the coin transferred in the transaction as a double. + /// This is the absolute value of the [totalAmount]. + double get totalAmountAsDouble => double.parse(totalAmount).abs(); + + /// The amount of the coin received in the transaction as a double. + /// This is the [receivedByMe] as a double. + double get amountReceived => double.parse(receivedByMe); + + /// The amount of the coin spent in the transaction as a double. + /// This is the [spentByMe] as a double. + double get amountSpent => double.parse(spentByMe); + + /// The net change in the coin balance as a double. + /// This is the [myBalanceChange] as a double. + double get balanceChange => double.parse(myBalanceChange); + + /// The timestamp of the transaction as a [DateTime] at midnight. + DateTime get timeStampMidnight => + DateTime(timestampDate.year, timestampDate.month, timestampDate.day); + + /// Returns true if the transaction is a deposit. I.e. the user receives the + /// coin and does not spend any of it. This is true if the transaction is + /// on the receiving end of a transaction, as the sender pays transaction fees + /// for UTXO coins and the receiver does not. + bool get isDeposit => + amountReceived > 0 && amountSpent == 0 && balanceChange > 0; +} diff --git a/lib/bloc/cex_market_data/profit_loss/models/adapters/adapters.dart b/lib/bloc/cex_market_data/profit_loss/models/adapters/adapters.dart new file mode 100644 index 0000000000..06d388f505 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/models/adapters/adapters.dart @@ -0,0 +1,3 @@ +export 'fiat_value_adapter.dart'; +export 'profit_loss_adapter.dart'; +export 'profit_loss_cache_adapter.dart'; diff --git a/lib/bloc/cex_market_data/profit_loss/models/adapters/fiat_value_adapter.dart b/lib/bloc/cex_market_data/profit_loss/models/adapters/fiat_value_adapter.dart new file mode 100644 index 0000000000..91bacca876 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/models/adapters/fiat_value_adapter.dart @@ -0,0 +1,40 @@ +import 'package:hive/hive.dart'; + +import '../fiat_value.dart'; + +class FiatValueAdapter extends TypeAdapter { + @override + final int typeId = 16; + + @override + FiatValue read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return FiatValue( + currency: fields[0] as String, + value: fields[1] as double, + ); + } + + @override + void write(BinaryWriter writer, FiatValue obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.currency) + ..writeByte(1) + ..write(obj.value); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is FiatValueAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart new file mode 100644 index 0000000000..16ae889139 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_adapter.dart @@ -0,0 +1,65 @@ +import 'package:hive/hive.dart'; + +import '../fiat_value.dart'; +import '../profit_loss.dart'; + +class ProfitLossAdapter extends TypeAdapter { + @override + final int typeId = 15; + + @override + ProfitLoss read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ProfitLoss( + profitLoss: fields[0] as double, + coin: fields[1] as String, + fiatPrice: fields[2] as FiatValue, + internalId: fields[3] as String, + myBalanceChange: fields[4] as double, + receivedAmountFiatPrice: fields[5] as double, + spentAmountFiatPrice: fields[6] as double, + timestamp: fields[7] as DateTime, + totalAmount: fields[8] as double, + txHash: fields[9] as String, + ); + } + + @override + void write(BinaryWriter writer, ProfitLoss obj) { + writer + ..writeByte(10) + ..writeByte(0) + ..write(obj.profitLoss) + ..writeByte(1) + ..write(obj.coin) + ..writeByte(2) + ..write(obj.fiatPrice) + ..writeByte(3) + ..write(obj.internalId) + ..writeByte(4) + ..write(obj.myBalanceChange) + ..writeByte(5) + ..write(obj.receivedAmountFiatPrice) + ..writeByte(6) + ..write(obj.spentAmountFiatPrice) + ..writeByte(7) + ..write(obj.timestamp) + ..writeByte(8) + ..write(obj.totalAmount) + ..writeByte(9) + ..write(obj.txHash); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProfitLossAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart new file mode 100644 index 0000000000..4444b50efc --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/models/adapters/profit_loss_cache_adapter.dart @@ -0,0 +1,50 @@ +import 'package:hive/hive.dart'; + +import '../profit_loss_cache.dart'; + +class ProfitLossCacheAdapter extends TypeAdapter { + @override + final int typeId = 14; + + @override + ProfitLossCache read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + + return ProfitLossCache( + coinId: fields[0] as String, + fiatCoinId: fields[1] as String, + lastUpdated: fields[2] as DateTime, + profitLosses: (fields[3] as List).cast(), + walletId: fields[4] as String, + ); + } + + @override + void write(BinaryWriter writer, ProfitLossCache obj) { + writer + ..writeByte(5) + ..writeByte(0) + ..write(obj.coinId) + ..writeByte(1) + ..write(obj.fiatCoinId) + ..writeByte(2) + ..write(obj.lastUpdated) + ..writeByte(3) + ..write(obj.profitLosses) + ..writeByte(4) + ..write(obj.walletId); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProfitLossCacheAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/lib/bloc/cex_market_data/profit_loss/models/fiat_value.dart b/lib/bloc/cex_market_data/profit_loss/models/fiat_value.dart new file mode 100644 index 0000000000..93a5279776 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/models/fiat_value.dart @@ -0,0 +1,98 @@ +// TODO! Move this class to a shared types package. + +import 'package:equatable/equatable.dart'; +// import 'package:intl/intl.dart'; + +abstract class EntityWithValue extends Equatable { + double get value; + + //TODO! NumberFormat? get valueFormatter; + + @override + List get props => [value]; +} + +class FiatValue extends EntityWithValue { + FiatValue({ + required this.currency, + required this.value, + }); + + FiatValue.fromJson(Map json) + : this( + currency: json['currency'] ?? '', + value: (json['value'] as num).toDouble(), + ); + + FiatValue.usd(double value) : this(currency: 'USD', value: value); + + final String currency; + @override + final double value; + + Map toJson() { + return { + 'currency': currency, + 'value': value, + }; + } + + void _validateCurrencyPair(FiatValue other) { + if (currency != other.currency) { + throw ArgumentError('Cannot compare two different currencies'); + } + } + + operator +(FiatValue other) { + _validateCurrencyPair(other); + + return FiatValue(currency: currency, value: value + other.value); + } + + operator -(FiatValue other) { + _validateCurrencyPair(other); + + return FiatValue(currency: currency, value: value - other.value); + } + + // Multiply a fiat value by a scalar, return value in the same currency + operator *(double scalar) { + return FiatValue(currency: currency, value: value * scalar); + } + + // Divide a fiat value by a scalar, return value in the same currency + operator /(double scalar) { + return FiatValue(currency: currency, value: value / scalar); + } + + @override + List get props => [currency, value]; +} + +class CoinValue extends EntityWithValue { + CoinValue({ + required this.coinId, + required this.value, + }); + + factory CoinValue.fromJson(Map json) { + return CoinValue( + coinId: json['coinId'] ?? '', + value: (json['value'] as num).toDouble(), + ); + } + + final String coinId; + @override + final double value; + + Map toJson() { + return { + 'coinId': coinId, + 'value': value, + }; + } + + @override + List get props => [coinId, value]; +} diff --git a/lib/bloc/cex_market_data/profit_loss/models/models.dart b/lib/bloc/cex_market_data/profit_loss/models/models.dart new file mode 100644 index 0000000000..99c305683d --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/models/models.dart @@ -0,0 +1,5 @@ +export 'adapters/adapters.dart'; +export 'fiat_value.dart'; +export 'profit_loss.dart'; +export 'profit_loss_cache.dart'; +export 'price_stamped_transaction.dart'; diff --git a/lib/bloc/cex_market_data/profit_loss/models/price_stamped_transaction.dart b/lib/bloc/cex_market_data/profit_loss/models/price_stamped_transaction.dart new file mode 100644 index 0000000000..4541990d8b --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/models/price_stamped_transaction.dart @@ -0,0 +1,41 @@ +import 'package:web_dex/bloc/cex_market_data/profit_loss/models/fiat_value.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; + +class PriceStampedTransaction extends Transaction { + final FiatValue fiatValue; + + PriceStampedTransaction({ + required Transaction transaction, + required this.fiatValue, + }) : super( + blockHeight: transaction.blockHeight, + coin: transaction.coin, + confirmations: transaction.confirmations, + feeDetails: transaction.feeDetails, + from: transaction.from, + internalId: transaction.internalId, + myBalanceChange: transaction.myBalanceChange, + receivedByMe: transaction.receivedByMe, + spentByMe: transaction.spentByMe, + timestamp: transaction.timestamp, + to: transaction.to, + totalAmount: transaction.totalAmount, + txHash: transaction.txHash, + txHex: transaction.txHex, + memo: transaction.memo, + ); +} + +class UsdPriceStampedTransaction extends PriceStampedTransaction { + double get priceUsd => fiatValue.value; + double get totalAmountUsd => + (double.parse(totalAmount) * fiatValue.value).abs(); + double get balanceChangeUsd => + double.parse(myBalanceChange) * fiatValue.value; + + UsdPriceStampedTransaction(Transaction transaction, double priceUsd) + : super( + transaction: transaction, + fiatValue: FiatValue.usd(priceUsd), + ); +} diff --git a/lib/bloc/cex_market_data/profit_loss/models/profit_loss.dart b/lib/bloc/cex_market_data/profit_loss/models/profit_loss.dart new file mode 100644 index 0000000000..f6c3dec714 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/models/profit_loss.dart @@ -0,0 +1,149 @@ +import 'package:equatable/equatable.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/extensions/profit_loss_transaction_extension.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; + +/// Represents a profit/loss for a specific coin. +class ProfitLoss extends Equatable { + /// The running total profit/loss in the coin for the user calculated from + /// their transaction history. The last profit/loss in the list is the current + /// profit/loss. + final double profitLoss; + + /// The komodo coin abbreviation from the coins repository + /// (e.g. BTC, KMD, etc.). + final String coin; + + /// The fiat price of the [coin] at or near the time of the transaction. This + /// is currently derived from OHLC data from the CEX API. + final FiatValue fiatPrice; + + /// The internal komodo ID of the transaction. This is kept to reference back + /// to the transaction. + final String internalId; + + /// The net change in the coin balance as a result of the transaction. + final double myBalanceChange; + + /// The fiat price of the coin amount received in the transaction. This is + /// the amount received multiplied by the fiat price of the coin at or near + /// the time of the transaction. + final double receivedAmountFiatPrice; + + /// The fiat price of the coin amount spent in the transaction. This is + /// the amount spent multiplied by the fiat price of the coin at or near + /// the time of the transaction. + final double spentAmountFiatPrice; + + /// The timestamp of the transaction in seconds since epoch. + final DateTime timestamp; + + /// The total amount of the coin transferred in the transaction. + final double totalAmount; + + /// The transaction hash. This is kept to reference back to the transaction. + final String txHash; + + /// Creates a new [ProfitLoss] instance. + const ProfitLoss({ + required this.profitLoss, + required this.coin, + required this.fiatPrice, + required this.internalId, + required this.myBalanceChange, + required this.receivedAmountFiatPrice, + required this.spentAmountFiatPrice, + required this.timestamp, + required this.totalAmount, + required this.txHash, + }); + + factory ProfitLoss.fromJson(Map json) { + return ProfitLoss( + profitLoss: (json['profit_loss'] as double?) ?? 0.0, + coin: json['coin'] ?? '', + fiatPrice: FiatValue.fromJson(json['fiat_value'] as Map), + internalId: json['internal_id'] as String, + myBalanceChange: json['my_balance_change'] as double, + receivedAmountFiatPrice: json['received_by_me'] as double, + spentAmountFiatPrice: json['spent_by_me'] as double, + timestamp: DateTime.parse(json['timestamp']), + totalAmount: json['total_amount'] as double, + txHash: json['tx_hash'] as String, + ); + } + + factory ProfitLoss.fromTransaction( + Transaction transaction, + FiatValue fiatPrice, + double runningProfitLoss, + ) { + return ProfitLoss( + profitLoss: runningProfitLoss, + coin: transaction.coin, + fiatPrice: fiatPrice, + internalId: transaction.internalId, + myBalanceChange: transaction.balanceChange, + receivedAmountFiatPrice: transaction.amountReceived * fiatPrice.value, + spentAmountFiatPrice: transaction.amountSpent * fiatPrice.value, + timestamp: transaction.timestampDate, + totalAmount: transaction.totalAmountAsDouble, + txHash: transaction.txHash, + ); + } + + Map toJson() { + return { + 'profit_loss': profitLoss, + 'coin': coin, + 'fiat_value': fiatPrice.toJson(), + 'internal_id': internalId, + 'my_balance_change': myBalanceChange, + 'received_by_me': receivedAmountFiatPrice, + 'spent_by_me': spentAmountFiatPrice, + 'timestamp': timestamp, + 'total_amount': totalAmount, + 'tx_hash': txHash, + }; + } + + ProfitLoss copyWith({ + double? profitLoss, + String? coin, + FiatValue? fiatPrice, + String? internalId, + double? myBalanceChange, + double? receivedByMe, + double? spentByMe, + DateTime? timestamp, + double? totalAmount, + String? txHash, + }) { + return ProfitLoss( + profitLoss: profitLoss ?? this.profitLoss, + coin: coin ?? this.coin, + fiatPrice: fiatPrice ?? this.fiatPrice, + internalId: internalId ?? this.internalId, + myBalanceChange: myBalanceChange ?? this.myBalanceChange, + receivedAmountFiatPrice: receivedByMe ?? receivedAmountFiatPrice, + spentAmountFiatPrice: spentByMe ?? spentAmountFiatPrice, + timestamp: timestamp ?? this.timestamp, + totalAmount: totalAmount ?? this.totalAmount, + txHash: txHash ?? this.txHash, + ); + } + + @override + List get props => [ + profitLoss, + coin, + fiatPrice, + internalId, + myBalanceChange, + receivedAmountFiatPrice, + spentAmountFiatPrice, + timestamp, + totalAmount, + txHash, + ]; +} diff --git a/lib/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart b/lib/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart new file mode 100644 index 0000000000..e9e9d2ed78 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/models/profit_loss_cache.dart @@ -0,0 +1,47 @@ +import 'package:equatable/equatable.dart'; +import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/models/profit_loss.dart'; + +/// Cache for profit/loss data. +/// +/// This class is used to store profit/loss data in a Hive box. +class ProfitLossCache extends Equatable + implements ObjectWithPrimaryKey { + const ProfitLossCache({ + required this.coinId, + required this.fiatCoinId, + required this.lastUpdated, + required this.profitLosses, + required this.walletId, + }); + + /// The komodo coin abbreviation from the coins repository (e.g. BTC, KMD, etc.). + final String coinId; + + /// The id of the stable coin that [coinId] is converted to (e.g. USDT, USD, etc.). + /// This can be any coinId, but the intention is to use a stable coin to + /// represent the fiat value of the coin in the profit/loss calculation. + final String fiatCoinId; + + /// The wallet ID associated with the profit/loss data. + final String walletId; + + /// The timestamp of the last update in seconds since epoch. (e.g. [DateTime.now().millisecondsSinceEpoch ~/ 1000]) + final DateTime lastUpdated; + + /// The list of [ProfitLoss] data. + final List profitLosses; + + @override + get primaryKey => getPrimaryKey(coinId, fiatCoinId, walletId); + + static String getPrimaryKey( + String coinId, + String fiatCurrency, + String walletId, + ) => + '$coinId-$fiatCurrency-$walletId'; + + @override + List get props => [coinId, fiatCoinId, lastUpdated, profitLosses]; +} 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 new file mode 100644 index 0000000000..4bc82319ed --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart @@ -0,0 +1,227 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:bloc/bloc.dart'; +import 'package:bloc_concurrency/bloc_concurrency.dart'; +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_repository.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/utils.dart' as logger; + +part 'profit_loss_event.dart'; +part 'profit_loss_state.dart'; + +class ProfitLossBloc extends Bloc { + ProfitLossBloc({ + required ProfitLossRepository profitLossRepository, + }) : _profitLossRepository = profitLossRepository, + 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). + on( + _onLoadPortfolioProfitLoss, + transformer: restartable(), + ); + + on(_onPortfolioPeriodChanged); + on(_onClearPortfolioProfitLoss); + } + + final ProfitLossRepository _profitLossRepository; + + void _onClearPortfolioProfitLoss( + ProfitLossPortfolioChartClearRequested event, + Emitter emit, + ) { + emit(const ProfitLossInitial()); + } + + Future _onLoadPortfolioProfitLoss( + ProfitLossPortfolioChartLoadRequested event, + Emitter emit, + ) async { + List coins = await _removeUnsupportedCons(event); + // 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) { + return emit( + PortfolioProfitLossChartUnsupported( + selectedPeriod: event.selectedPeriod, + ), + ); + } + + await _getProfitLossChart(event, coins, useCache: true) + .then(emit.call) + .catchError((e, _) { + logger.log('Failed to load portfolio profit/loss: $e', isError: true); + if (state is! PortfolioProfitLossChartLoadSuccess) { + emit( + ProfitLossLoadFailure( + error: TextError(error: 'Failed to load portfolio profit/loss: $e'), + selectedPeriod: event.selectedPeriod, + ), + ); + } + }); + + // Fetch the un-cached version of the chart to update the cache. + coins = await _removeUnsupportedCons(event, allowInactiveCoins: false); + if (coins.isNotEmpty) { + await _getProfitLossChart(event, coins, useCache: false) + .then(emit.call) + .catchError((e, _) { + // Ignore un-cached errors, as a transaction loading exception should not + // make the graph disappear with a load failure emit, as the cached data + // is already displayed. The periodic updates will still try to fetch the + // data and update the graph. + }); + } + + await emit.forEach( + Stream.periodic(event.updateFrequency).asyncMap((_) async { + return _getSortedProfitLossChartForCoins( + event, + useCache: false, + ); + }), + onData: (profitLossChart) { + if (profitLossChart.isEmpty) { + return state; + } + + final unCachedProfitIncrease = profitLossChart.increase; + final unCachedPercentageIncrease = profitLossChart.percentageIncrease; + return PortfolioProfitLossChartLoadSuccess( + profitLossChart: profitLossChart, + totalValue: unCachedProfitIncrease, + percentageIncrease: unCachedPercentageIncrease, + coins: event.coins, + fiatCurrency: event.fiatCoinId, + selectedPeriod: event.selectedPeriod, + walletId: event.walletId, + ); + }, + onError: (e, s) { + logger.log('Failed to load portfolio profit/loss: $e', isError: true); + return ProfitLossLoadFailure( + error: TextError(error: 'Failed to load portfolio profit/loss: $e'), + selectedPeriod: event.selectedPeriod, + ); + }, + ); + } + + Future _getProfitLossChart( + ProfitLossPortfolioChartLoadRequested event, + List coins, { + required bool useCache, + }) async { + final filteredChart = await _getSortedProfitLossChartForCoins( + event, + useCache: useCache, + ); + final unCachedProfitIncrease = filteredChart.increase; + final unCachedPercentageIncrease = filteredChart.percentageIncrease; + return PortfolioProfitLossChartLoadSuccess( + profitLossChart: filteredChart, + totalValue: unCachedProfitIncrease, + percentageIncrease: unCachedPercentageIncrease, + coins: coins, + fiatCurrency: event.fiatCoinId, + selectedPeriod: event.selectedPeriod, + walletId: event.walletId, + ); + } + + Future> _removeUnsupportedCons( + ProfitLossPortfolioChartLoadRequested event, { + bool allowInactiveCoins = true, + }) async { + final List coins = List.from(event.coins); + await coins.removeWhereAsync( + (Coin coin) async { + final isCoinSupported = + await _profitLossRepository.isCoinChartSupported( + coin.abbr, + event.fiatCoinId, + allowInactiveCoins: allowInactiveCoins, + ); + return coin.isTestCoin || !isCoinSupported; + }, + ); + return coins; + } + + Future _onPortfolioPeriodChanged( + ProfitLossPortfolioPeriodChanged event, + Emitter emit, + ) async { + final eventState = state; + if (eventState is! PortfolioProfitLossChartLoadSuccess) { + emit( + PortfolioProfitLossChartLoadInProgress( + selectedPeriod: event.selectedPeriod, + ), + ); + } + + assert( + eventState is PortfolioProfitLossChartLoadSuccess, + 'Selected period can only be changed when ' + 'the state is PortfolioProfitLossChartLoadSuccess', + ); + + final successState = eventState as PortfolioProfitLossChartLoadSuccess; + add( + ProfitLossPortfolioChartLoadRequested( + coins: successState.coins, + fiatCoinId: successState.fiatCurrency, + selectedPeriod: event.selectedPeriod, + walletId: successState.walletId, + ), + ); + } + + Future _getSortedProfitLossChartForCoins( + ProfitLossPortfolioChartLoadRequested event, { + bool useCache = true, + }) async { + final chartsList = await Future.wait( + event.coins.map((coin) async { + // Catch any errors and return an empty chart to prevent a single coin + // from breaking the entire portfolio chart. + try { + final profitLosses = await _profitLossRepository.getProfitLoss( + coin.abbr, + event.fiatCoinId, + event.walletId, + useCache: useCache, + ); + + profitLosses.removeRange( + 0, + profitLosses.indexOf( + profitLosses.firstWhere((element) => element.profitLoss != 0), + ), + ); + + return profitLosses.toChartData(); + } catch (e) { + logger.log( + 'Failed to load cached profit/loss for coin ${coin.abbr}: $e', + isError: true, + ); + return ChartData.empty(); + } + }), + ); + + chartsList.removeWhere((element) => element.isEmpty); + return Charts.merge(chartsList)..sort((a, b) => a.x.compareTo(b.x)); + } +} 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 new file mode 100644 index 0000000000..5b7b3d0eb9 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart @@ -0,0 +1,217 @@ +import 'package:komodo_cex_market_data/komodo_cex_market_data.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'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; + +class ProfitLossCalculator { + final CexRepository _cexRepository; + + ProfitLossCalculator(this._cexRepository); + + /// Get the running profit/loss for a coin based on the transactions. + /// ProfitLoss = Proceeds - CostBasis + /// CostBasis = Sum of the fiat price of the coin amount received (bought) + /// Proceeds = Sum of the fiat price of the coin amount spent (sold) + /// + /// [transactions] is the list of transactions. + /// [coinId] is the id of the coin, generally the coin ticker. Eg: 'BTC'. + /// [fiatCoinId] is id of the fiat currency tether to convert the [coinId] to. + /// E.g. 'USDT'. This can be any supported coin id, but the idea is to convert + /// the coin to a fiat currency to calculate the profit/loss in fiat. + /// [cexRepository] is the repository to fetch the fiat price of the coin. + /// + /// Returns the list of [ProfitLoss] for the coin. + Future> getProfitFromTransactions( + List transactions, { + required String coinId, + required String fiatCoinId, + }) async { + if (transactions.isEmpty) { + return []; + } + + transactions.sort((a, b) => a.timestampDate.compareTo(b.timestampDate)); + + final todayAtMidnight = _getDateAtMidnight(DateTime.now()); + final transactionDates = _getTransactionDates(transactions); + final coinUsdPrices = + await _getTimestampedUsdPrices(coinId, transactionDates); + final currentPrice = coinUsdPrices[todayAtMidnight]!; + final priceStampedTransactions = + _priceStampTransactions(transactions, coinUsdPrices); + + return _calculateProfitLosses(priceStampedTransactions, currentPrice); + } + + List _priceStampTransactions( + List transactions, + Map usdPrices, + ) { + return transactions.map((transaction) { + final usdPrice = + usdPrices[_getDateAtMidnight(transaction.timestampDate)]!; + return UsdPriceStampedTransaction(transaction, usdPrice); + }).toList(); + } + + List _getTransactionDates(List transactions) { + return transactions.map((tx) => tx.timestampDate).toList() + ..add(DateTime.now()); + } + + 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 await _cexRepository.getCoinFiatPrices(cleanCoinId, dates); + } + + List _calculateProfitLosses( + List transactions, + double currentPrice, + ) { + var state = _ProfitLossState(); + final profitLosses = []; + + for (var transaction in transactions) { + if (transaction.totalAmountAsDouble == 0) continue; + + if (transaction.isReceived) { + state = _processBuyTransaction(state, transaction); + } else { + state = _processSellTransaction(state, transaction); + } + + final runningProfitLoss = _calculateProfitLoss(state, currentPrice); + profitLosses.add( + ProfitLoss.fromTransaction( + transaction, + transaction.fiatValue, + runningProfitLoss, + ), + ); + } + + return profitLosses; + } + + _ProfitLossState _processBuyTransaction( + _ProfitLossState state, + UsdPriceStampedTransaction transaction, + ) { + final newHolding = + (holdings: transaction.balanceChange, price: transaction.priceUsd); + return _ProfitLossState( + holdings: [...state.holdings, newHolding], + realizedProfitLoss: state.realizedProfitLoss, + totalInvestment: state.totalInvestment + transaction.balanceChangeUsd, + currentHoldings: state.currentHoldings + transaction.balanceChange, + ); + } + + _ProfitLossState _processSellTransaction( + _ProfitLossState state, + UsdPriceStampedTransaction transaction, + ) { + if (state.currentHoldings < transaction.balanceChange) { + throw Exception('Attempting to sell more than currently held'); + } + + // Balance change is negative for sales, so we use the abs value to + // calculate the cost basis (formula assumes positive "total" value). + var remainingToSell = transaction.balanceChange.abs(); + var costBasis = 0.0; + var newHoldings = + List<({double holdings, double price})>.from(state.holdings); + + while (remainingToSell > 0) { + final oldestBuy = newHoldings.first.holdings; + if (oldestBuy <= remainingToSell) { + newHoldings.removeAt(0); + costBasis += oldestBuy * state.holdings.first.price; + remainingToSell -= oldestBuy; + } else { + newHoldings[0] = ( + holdings: newHoldings[0].holdings - remainingToSell, + price: newHoldings[0].price + ); + costBasis += remainingToSell * state.holdings.first.price; + remainingToSell = 0; + } + } + + final double saleProceeds = transaction.balanceChangeUsd.abs(); + final double newRealizedProfitLoss = + state.realizedProfitLoss + (saleProceeds - costBasis); + + // Balance change is negative for a sale, so subtract the abs value ( + // or add the positive value) to get the new holdings. + final double newCurrentHoldings = + state.currentHoldings - transaction.balanceChange.abs(); + final double newTotalInvestment = state.totalInvestment - costBasis; + + return _ProfitLossState( + holdings: newHoldings, + realizedProfitLoss: newRealizedProfitLoss, + totalInvestment: newTotalInvestment, + currentHoldings: newCurrentHoldings, + ); + } + + double _calculateProfitLoss( + _ProfitLossState state, + double currentPrice, + ) { + final currentValue = state.currentHoldings * currentPrice; + final unrealizedProfitLoss = currentValue - state.totalInvestment; + return state.realizedProfitLoss + unrealizedProfitLoss; + } +} + +class RealisedProfitLossCalculator extends ProfitLossCalculator { + RealisedProfitLossCalculator(CexRepository cexRepository) + : super(cexRepository); + + @override + double _calculateProfitLoss( + _ProfitLossState state, + double currentPrice, + ) { + return state.realizedProfitLoss; + } +} + +class UnRealisedProfitLossCalculator extends ProfitLossCalculator { + UnRealisedProfitLossCalculator(CexRepository cexRepository) + : super(cexRepository); + + @override + double _calculateProfitLoss( + _ProfitLossState state, + double currentPrice, + ) { + final currentValue = state.currentHoldings * currentPrice; + final unrealizedProfitLoss = currentValue - state.totalInvestment; + return unrealizedProfitLoss; + } +} + +class _ProfitLossState { + final List<({double holdings, double price})> holdings; + final double realizedProfitLoss; + final double totalInvestment; + final double currentHoldings; + + _ProfitLossState({ + List<({double holdings, double price})>? holdings, + this.realizedProfitLoss = 0.0, + this.totalInvestment = 0.0, + this.currentHoldings = 0.0, + }) : holdings = holdings ?? []; +} 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 new file mode 100644 index 0000000000..14ff2ff6da --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_event.dart @@ -0,0 +1,50 @@ +part of 'profit_loss_bloc.dart'; + +abstract class ProfitLossEvent extends Equatable { + const ProfitLossEvent(); + + @override + List get props => []; +} + +class ProfitLossPortfolioChartClearRequested extends ProfitLossEvent { + const ProfitLossPortfolioChartClearRequested(); +} + +class ProfitLossPortfolioChartLoadRequested extends ProfitLossEvent { + const ProfitLossPortfolioChartLoadRequested({ + required this.coins, + 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 + List get props => [ + coins, + 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]; +} 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 new file mode 100644 index 0000000000..ac11e20391 --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_repository.dart @@ -0,0 +1,200 @@ +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_persistence_layer/komodo_persistence_layer.dart'; +import 'package:web_dex/bloc/cex_market_data/charts.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/demo_profit_loss_repository.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/models/adapters/adapters.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/models/profit_loss_cache.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_calculator.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class ProfitLossRepository { + ProfitLossRepository({ + required PersistenceProvider + profitLossCacheProvider, + required cex.CexRepository cexRepository, + required TransactionHistoryRepo transactionHistoryRepo, + required ProfitLossCalculator profitLossCalculator, + }) : _transactionHistoryRepo = transactionHistoryRepo, + _cexRepository = cexRepository, + _profitLossCacheProvider = profitLossCacheProvider, + _profitLossCalculator = profitLossCalculator; + + final PersistenceProvider _profitLossCacheProvider; + final cex.CexRepository _cexRepository; + final TransactionHistoryRepo _transactionHistoryRepo; + final ProfitLossCalculator _profitLossCalculator; + + static Future ensureInitialized() async { + Hive..registerAdapter(FiatValueAdapter()) + ..registerAdapter(ProfitLossAdapter()) + ..registerAdapter(ProfitLossCacheAdapter()); + } + + Future clearCache() async { + await _profitLossCacheProvider.deleteAll(); + } + + /// Return a new instance of [ProfitLossRepository] with default values. + /// + /// If [demoMode] is provided, it will return a [MockProfitLossRepository]. + factory ProfitLossRepository.withDefaults({ + String cacheTableName = 'profit_loss', + required TransactionHistoryRepo transactionHistoryRepo, + required cex.CexRepository cexRepository, + PerformanceMode? demoMode, + }) { + if (demoMode != null) { + return MockProfitLossRepository.withDefaults( + performanceMode: demoMode, + cacheTableName: 'mock_${cacheTableName}_${demoMode.name}', + ); + } + + return ProfitLossRepository( + transactionHistoryRepo: transactionHistoryRepo, + profitLossCacheProvider: + HiveLazyBoxProvider(name: cacheTableName), + cexRepository: cexRepository, + profitLossCalculator: RealisedProfitLossCalculator(cexRepository), + ); + } + + /// 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( + String coinId, + String fiatCoinId, { + bool allowFiatAsBase = false, + bool allowInactiveCoins = false, + }) async { + if (!allowInactiveCoins) { + final coin = coinsBlocRepository.getCoin(coinId)!; + if (coin.isActivating || !coin.isActive) { + return false; + } + } + + final supportedCoins = await _cexRepository.getCoinList(); + final coinTicker = abbr2Ticker(coinId).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 + if (allowFiatAsBase && coinId == fiatCoinId.toUpperCase()) { + return true; + } + + final coinPair = CexCoinPair( + baseCoinTicker: coinTicker, + relCoinTicker: fiatCoinId.toUpperCase(), + ); + return coinPair.isCoinSupported(supportedCoins); + } + + /// Get the profit/loss data for a coin based on the transactions + /// and the spot price of the coin in the fiat currency. + /// + /// [coinId] is the id of the coin. E.g. 'BTC'. This is generally the coin + /// ticker from the komodo coins repository. + /// [fiatCoinId] is id of the stablecoin to convert the [coinId] to get the + /// fiat-equivalent price of the coin. This can be any supported coin id, but + /// the idea is to convert the coin to a fiat currency to calculate the + /// profit/loss in fiat. + /// [walletId] is the wallet ID associated with the profit/loss data. + /// + /// Returns the list of [ProfitLoss] for the coin. + Future> getProfitLoss( + String coinId, + String fiatCoinId, + String walletId, { + bool useCache = true, + }) async { + if (useCache) { + final String compoundKey = ProfitLossCache.getPrimaryKey( + coinId, + fiatCoinId, + walletId, + ); + final ProfitLossCache? profitLossCache = + await _profitLossCacheProvider.get(compoundKey); + final bool cacheExists = profitLossCache != null; + + if (cacheExists) { + return profitLossCache.profitLosses; + } + } + + final isCoinSupported = await isCoinChartSupported( + coinId, + fiatCoinId, + ); + if (!isCoinSupported) { + return []; + } + + final transactions = + await _transactionHistoryRepo.fetchCompletedTransactions( + // TODO: Refactor referenced coinsBloc method to a repository. + // NB: Even though the class is called [CoinsBloc], it is not a Bloc. + coinsBlocRepository.getCoin(coinId)!, + ); + + if (transactions.isEmpty) { + await _profitLossCacheProvider.insert( + ProfitLossCache( + coinId: coinId, + profitLosses: List.empty(), + fiatCoinId: fiatCoinId, + lastUpdated: DateTime.now(), + walletId: walletId, + ), + ); + return []; + } + + final List profitLosses = + await _profitLossCalculator.getProfitFromTransactions( + transactions, + coinId: coinId, + fiatCoinId: fiatCoinId, + ); + + await _profitLossCacheProvider.insert( + ProfitLossCache( + coinId: coinId, + profitLosses: profitLosses, + fiatCoinId: fiatCoinId, + lastUpdated: DateTime.now(), + walletId: walletId, + ), + ); + + return profitLosses; + } +} + +extension ProfitLossExtension on List { + ChartData toChartData() { + return map((ProfitLoss profitLoss) { + return Point( + profitLoss.timestamp.millisecondsSinceEpoch.toDouble(), + profitLoss.profitLoss, + ); + }).toList(); + } +} diff --git a/lib/bloc/cex_market_data/profit_loss/profit_loss_state.dart b/lib/bloc/cex_market_data/profit_loss/profit_loss_state.dart new file mode 100644 index 0000000000..e4c384c17b --- /dev/null +++ b/lib/bloc/cex_market_data/profit_loss/profit_loss_state.dart @@ -0,0 +1,64 @@ +part of 'profit_loss_bloc.dart'; + +sealed class ProfitLossState extends Equatable { + const ProfitLossState({required this.selectedPeriod}); + + final Duration selectedPeriod; + + @override + List get props => [selectedPeriod]; +} + +final class ProfitLossInitial extends ProfitLossState { + const ProfitLossInitial() : super(selectedPeriod: const Duration(hours: 1)); +} + +final class PortfolioProfitLossChartLoadInProgress extends ProfitLossState { + const PortfolioProfitLossChartLoadInProgress({required super.selectedPeriod}); +} + +final class PortfolioProfitLossChartLoadSuccess extends ProfitLossState { + const PortfolioProfitLossChartLoadSuccess({ + required this.profitLossChart, + required this.totalValue, + required this.percentageIncrease, + required this.coins, + required this.fiatCurrency, + required this.walletId, + required super.selectedPeriod, + }); + + final List> profitLossChart; + final double totalValue; + final double percentageIncrease; + final List coins; + final String fiatCurrency; + final String walletId; + + @override + List get props => [ + profitLossChart, + totalValue, + percentageIncrease, + coins, + fiatCurrency, + selectedPeriod, + walletId, + ]; +} + +final class ProfitLossLoadFailure extends ProfitLossState { + const ProfitLossLoadFailure({ + required this.error, + required super.selectedPeriod, + }); + + final BaseError error; + + @override + List get props => [error, selectedPeriod]; +} + +final class PortfolioProfitLossChartUnsupported extends ProfitLossState { + const PortfolioProfitLossChartUnsupported({required super.selectedPeriod}); +} diff --git a/lib/bloc/coins_bloc/coins_repo.dart b/lib/bloc/coins_bloc/coins_repo.dart new file mode 100644 index 0000000000..6733723417 --- /dev/null +++ b/lib/bloc/coins_bloc/coins_repo.dart @@ -0,0 +1,332 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:komodo_coin_updates/komodo_coin_updates.dart' as coin_updates; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/app_config/coins_config_parser.dart'; +import 'package:web_dex/bloc/runtime_coin_updates/runtime_update_config_provider.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/convert_address/convert_address_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/electrum/electrum_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/enable/enable_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/enable_tendermint/enable_tendermint_token.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/enable_tendermint/enable_tendermint_with_assets.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/text_error.dart'; + +final CoinsRepo coinsRepo = CoinsRepo( + api: mm2Api, +); + +class CoinsRepo { + CoinsRepo({ + required Mm2Api api, + }) : _api = api; + final Mm2Api _api; + coin_updates.CoinConfigRepository? _coinRepo; + + List? _cachedKnownCoins; + + // TODO: Consider refactoring to a Map + Future> getKnownCoins() async { + if (_cachedKnownCoins != null) return _cachedKnownCoins!; + + _coinRepo ??= coin_updates.CoinConfigRepository.withDefaults( + await RuntimeUpdateConfigProvider().getRuntimeUpdateConfig(), + ); + // If the bundled config files don't exist, then download the latest configs + // and load them from the storage provider. + final bool bundledConfigsExist = await coinConfigParser.hasLocalConfigs(); + if (!bundledConfigsExist) { + await _coinRepo!.updateCoinConfig(excludedAssets: excludedAssetList); + } + + final bool hasUpdatedConfigs = await _coinRepo!.coinConfigExists(); + if (!bundledConfigsExist || hasUpdatedConfigs) { + final coins = await _getKnownCoinsFromStorage(); + if (coins.isNotEmpty) { + _cachedKnownCoins = coins; + return coins; + } + } + + final coins = _cachedKnownCoins ?? await _getKnownCoinsFromConfig(); + return [...coins]; + } + + /// Get the list of [coin_updates.Coin]s with the minimal fields from `coins.json`. + /// If the local coin configs exist, and there are no updates in storage, then + /// the coins from the bundled configs are loaded. + /// Otherwise, the coins from storage are loaded. + Future> getKnownGlobalCoins() async { + _coinRepo ??= coin_updates.CoinConfigRepository.withDefaults( + await RuntimeUpdateConfigProvider().getRuntimeUpdateConfig(), + ); + + final bool bundledConfigsExist = await coinConfigParser.hasLocalConfigs(); + if (!bundledConfigsExist) { + await _coinRepo!.updateCoinConfig(excludedAssets: excludedAssetList); + } + + final bool hasUpdatedConfigs = await _coinRepo!.coinConfigExists(); + if (!bundledConfigsExist || hasUpdatedConfigs) { + final coins = + await _coinRepo!.getCoins(excludedAssets: excludedAssetList); + if (coins != null && coins.isNotEmpty) { + return coins + .where((coin) => !excludedAssetList.contains(coin.coin)) + .toList(); + } + } + + final globalCoins = await coinConfigParser.getGlobalCoinsJson(); + return globalCoins + .map((coin) => coin_updates.Coin.fromJson(coin as Map)) + .toList(); + } + + /// Loads the known [coin_updates.Coin]s from the storage provider, maps it + /// to the existing [Coin] model with the parent coin assigned and + /// orphans removed. + Future> _getKnownCoinsFromStorage() async { + final List coins = + (await _coinRepo!.getCoinConfigs(excludedAssets: excludedAssetList))! + .values + .where((coin) => getCoinType(coin.type ?? '', coin.coin) != null) + .where((coin) => !_shouldSkipCoin(coin)) + .map(_mapCoinConfigToCoin) + .toList(); + + for (Coin coin in coins) { + coin.parentCoin = _getParentCoin(coin, coins); + } + + _removeOrphans(coins); + + final List unmodifiableCoins = List.unmodifiable(coins); + _cachedKnownCoins = unmodifiableCoins; + return unmodifiableCoins; + } + + /// Maps the komodo_coin_updates package Coin class [coin] + /// to the app Coin class. + Coin _mapCoinConfigToCoin(coin_updates.CoinConfig coin) { + final coinJson = coin.toJson(); + coinJson['abbr'] = coin.coin; + coinJson['priority'] = priorityCoinsAbbrMap[coin.coin] ?? 0; + coinJson['active'] = enabledByDefaultCoins.contains(coin.coin); + if (kIsWeb) { + coinConfigParser.removeElectrumsWithoutWss(coinJson['electrum']); + } + final newCoin = Coin.fromJson(coinJson, coinJson); + return newCoin; + } + + /// Checks if the coin should be skipped according to the following rules: + /// - If the coin is in the excluded asset list. + /// - If the coin type is not supported or empty. + /// - If the electrum servers are not supported on the current platform + /// (WSS on web, SSL and TCP on native platforms). + bool _shouldSkipCoin(coin_updates.CoinConfig coin) { + if (excludedAssetList.contains(coin.coin)) { + return true; + } + + if (getCoinType(coin.type, coin.coin) == null) { + return true; + } + + if (coin.electrum != null && coin.electrum?.isNotEmpty == true) { + return coin.electrum! + .every((e) => !_isConnectionTypeSupported(e.protocol ?? '')); + } + + return false; + } + + /// Returns true if [networkProtocol] is supported on the current platform. + /// On web, only WSS is supported. + /// On other (native) platforms, only SSL and TCP are supported. + bool _isConnectionTypeSupported(String networkProtocol) { + String uppercaseProtocol = networkProtocol.toUpperCase(); + + if (kIsWeb) { + return uppercaseProtocol == 'WSS'; + } + + return uppercaseProtocol == 'SSL' || uppercaseProtocol == 'TCP'; + } + + Future> _getKnownCoinsFromConfig() async { + final List globalCoinsJson = + await coinConfigParser.getGlobalCoinsJson(); + final Map appCoinsJson = + await coinConfigParser.getUnifiedCoinsJson(); + + final List appItems = appCoinsJson.values.toList(); + + _removeUnknown(appItems, globalCoinsJson); + + final List coins = appItems.map((dynamic appItem) { + final dynamic globalItem = + _getGlobalItemByAbbr(appItem['coin'], globalCoinsJson); + + return Coin.fromJson(appItem, globalItem); + }).toList(); + + for (Coin coin in coins) { + coin.parentCoin = _getParentCoin(coin, coins); + } + + _removeOrphans(coins); + + final List unmodifiableCoins = List.unmodifiable(coins); + _cachedKnownCoins = unmodifiableCoins; + return unmodifiableCoins; + } + + // 'Orphans' are coins that have 'parent' coin in config, + // but 'parent' coin wasn't found. + void _removeOrphans(List coins) { + final List original = List.from(coins); + + coins.removeWhere((coin) { + final String? platform = coin.protocolData?.platform; + if (platform == null) return false; + + final parentCoin = + original.firstWhereOrNull((coin) => coin.abbr == platform); + + return parentCoin == null; + }); + } + + void _removeUnknown( + List appItems, + List globalItems, + ) { + appItems.removeWhere((dynamic appItem) { + return _getGlobalItemByAbbr(appItem['coin'], globalItems) == null; + }); + } + + dynamic _getGlobalItemByAbbr(String abbr, List globalItems) { + return globalItems.firstWhereOrNull((dynamic item) => abbr == item['coin']); + } + + Coin? _getParentCoin(Coin? coin, List coins) { + final String? parentCoinAbbr = coin?.protocolData?.platform; + if (parentCoinAbbr == null) return null; + + return coins.firstWhereOrNull( + (item) => item.abbr.toUpperCase() == parentCoinAbbr.toUpperCase()); + } + + Future> getEnabledCoins(List knownCoins) async { + final enabledCoins = await _api.getEnabledCoins(knownCoins); + return enabledCoins ?? []; + } + + Future getBalanceInfo(String abbr) async { + return await _api.getMaxMakerVol(abbr); + } + + Future deactivateCoin(Coin coin) async { + await _api.disableCoin(coin.abbr); + } + + Future?> validateCoinAddress( + Coin coin, String address) async { + return await _api.validateAddress(coin.abbr, address); + } + + Future?> withdraw(WithdrawRequest request) async { + return await _api.withdraw(request); + } + + Future sendRawTransaction( + SendRawTransactionRequest request) async { + final response = await _api.sendRawTransaction(request); + if (response == null) { + return SendRawTransactionResponse( + txHash: null, + error: TextError(error: LocaleKeys.somethingWrong.tr()), + ); + } + + return SendRawTransactionResponse.fromJson(response); + } + + Future activateCoins(List coins) async { + final List ethWithTokensRequests = []; + final List erc20Requests = []; + final List electrumCoinRequests = []; + final List tendermintRequests = []; + final List tendermintTokenRequests = []; + final List bchWithTokens = []; + final List slpTokens = []; + + for (Coin coin in coins) { + if (coin.type == CoinType.cosmos || coin.type == CoinType.iris) { + if (coin.isIrisToken) { + tendermintTokenRequests + .add(EnableTendermintTokenRequest(ticker: coin.abbr)); + } else { + tendermintRequests.add(EnableTendermintWithAssetsRequest( + ticker: coin.abbr, + rpcUrls: coin.rpcUrls, + )); + } + } else if (coin.type == CoinType.slp) { + slpTokens.add(EnableSlp(ticker: coin.abbr)); + } else if (coin.protocolType == 'BCH') { + bchWithTokens.add(EnableBchWithTokens( + ticker: coin.abbr, servers: coin.electrum, urls: coin.bchdUrls)); + } else if (coin.electrum.isNotEmpty) { + electrumCoinRequests.add(ElectrumReq( + coin: coin.abbr, + servers: coin.electrum, + swapContractAddress: coin.swapContractAddress, + fallbackSwapContract: coin.swapContractAddress, + )); + } else { + if (coin.protocolType == 'ETH') { + ethWithTokensRequests.add(EnableEthWithTokensRequest( + coin: coin.abbr, + swapContractAddress: coin.swapContractAddress, + fallbackSwapContract: coin.fallbackSwapContract, + nodes: coin.nodes, + )); + } else { + erc20Requests.add(EnableErc20Request(ticker: coin.abbr)); + } + } + } + await _api.enableCoins( + ethWithTokensRequests: ethWithTokensRequests, + erc20Requests: erc20Requests, + electrumCoinRequests: electrumCoinRequests, + tendermintRequests: tendermintRequests, + tendermintTokenRequests: tendermintTokenRequests, + bchWithTokens: bchWithTokens, + slpTokens: slpTokens, + ); + } + + Future convertLegacyAddress(Coin coin, String address) async { + final request = ConvertAddressRequest( + coin: coin.abbr, + from: address, + isErc: coin.isErcType, + ); + return await _api.convertLegacyAddress(request); + } +} diff --git a/lib/bloc/coins_manager/coins_manager_bloc.dart b/lib/bloc/coins_manager/coins_manager_bloc.dart new file mode 100644 index 0000000000..9ce11b82bb --- /dev/null +++ b/lib/bloc/coins_manager/coins_manager_bloc.dart @@ -0,0 +1,241 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart' show Bloc, Emitter; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/blocs/coins_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/router/state/wallet_state.dart'; + +import 'coins_manager_event.dart'; +import 'coins_manager_state.dart'; + +class CoinsManagerBloc extends Bloc { + CoinsManagerBloc({ + required CoinsBloc coinsRepo, + required CoinsManagerAction action, + }) : _coinsRepo = coinsRepo, + super( + CoinsManagerState.initial( + action: action, + coins: _getOriginalCoinList(coinsRepo, action), + ), + ) { + on(_onCoinsUpdate); + on(_onCoinTypeSelect); + on(_onCoinsSwitch); + on(_onCoinSelect); + on(_onSelectAll); + on(_onSelectedTypesReset); + on(_onSearchUpdate); + + _enabledCoinsListener = _coinsRepo.outWalletCoins + .listen((_) => add(const CoinsManagerCoinsUpdate())); + } + final CoinsBloc _coinsRepo; + late StreamSubscription> _enabledCoinsListener; + + @override + Future close() { + _enabledCoinsListener.cancel(); + return super.close(); + } + + List mergeCoinLists(List originalList, List newList) { + Map coinMap = {}; + + for (Coin coin in originalList) { + coinMap[coin.abbr] = coin; + } + + for (Coin coin in newList) { + coinMap[coin.abbr] = coin; + } + + final list = coinMap.values.toList(); + list.sort((a, b) => a.abbr.compareTo(b.abbr)); + + return list; + } + + void _onCoinsUpdate( + CoinsManagerCoinsUpdate event, + Emitter emit, + ) { + final List filters = []; + + List list = mergeCoinLists( + _getOriginalCoinList(_coinsRepo, state.action), state.coins); + + if (state.searchPhrase.isNotEmpty) { + filters.add(_filterByPhrase); + } + if (state.selectedCoinTypes.isNotEmpty) { + filters.add(_filterByType); + } + + for (var filter in filters) { + list = filter(list); + } + + emit(state.copyWith(coins: list)); + } + + void _onCoinTypeSelect( + CoinsManagerCoinTypeSelect event, + Emitter emit, + ) { + final List newTypes = state.selectedCoinTypes.contains(event.type) + ? state.selectedCoinTypes.where((type) => type != event.type).toList() + : [...state.selectedCoinTypes, event.type]; + + emit(state.copyWith(selectedCoinTypes: newTypes)); + + add(const CoinsManagerCoinsUpdate()); + } + + Future _onCoinsSwitch( + CoinsManagerCoinsSwitch event, + Emitter emit, + ) async { + final List selectedCoins = [...state.selectedCoins]; + emit(state.copyWith(isSwitching: true)); + + final Future switchingFuture = state.action == CoinsManagerAction.add + ? _coinsRepo.activateCoins(selectedCoins) + : _coinsRepo.deactivateCoins(selectedCoins); + + emit(state.copyWith(selectedCoins: [], isSwitching: false)); + await switchingFuture; + } + + void _onCoinSelect( + CoinsManagerCoinSelect event, + Emitter emit, + ) { + final coin = event.coin; + final List selectedCoins = List.from(state.selectedCoins); + if (selectedCoins.contains(coin)) { + selectedCoins.remove(coin); + + if (state.action == CoinsManagerAction.add) { + _coinsRepo.deactivateCoins([event.coin]); + } else { + _coinsRepo.activateCoins([event.coin]); + } + } else { + selectedCoins.add(coin); + + if (state.action == CoinsManagerAction.add) { + _coinsRepo.activateCoins([event.coin]); + } else { + _coinsRepo.deactivateCoins([event.coin]); + } + } + emit(state.copyWith(selectedCoins: selectedCoins)); + } + + FutureOr _onSelectAll( + CoinsManagerSelectAllTap event, + Emitter emit, + ) { + final selectedCoins = + state.isSelectedAllCoinsEnabled ? [] : state.coins; + emit(state.copyWith(selectedCoins: selectedCoins)); + } + + FutureOr _onSelectedTypesReset( + CoinsManagerSelectedTypesReset event, + Emitter emit, + ) { + emit(state.copyWith(selectedCoinTypes: [])); + add(const CoinsManagerCoinsUpdate()); + } + + FutureOr _onSearchUpdate( + CoinsManagerSearchUpdate event, + Emitter emit, + ) { + emit(state.copyWith(searchPhrase: event.text)); + add(const CoinsManagerCoinsUpdate()); + } + + List _filterByPhrase(List coins) { + final String filter = state.searchPhrase.toLowerCase(); + final List filtered = filter.isEmpty + ? coins + : coins + .where( + (Coin coin) => + compareCoinByPhrase(coin, filter) || + state.selectedCoins.indexWhere( + (selectedCoin) => selectedCoin.abbr == coin.abbr, + ) != + -1, + ) + .toList(); + + filtered + .sort((a, b) => a.abbr.toLowerCase().compareTo(b.abbr.toLowerCase())); + return filtered; + } + + List _filterByType(List coins) { + return coins + .where( + (coin) => + state.selectedCoinTypes.contains(coin.type) || + state.selectedCoins.indexWhere( + (selectedCoin) => selectedCoin.abbr == coin.abbr, + ) != + -1, + ) + .toList(); + } +} + +List _getOriginalCoinList( + CoinsBloc coinsRepo, + CoinsManagerAction action, +) { + final WalletType? walletType = currentWalletBloc.wallet?.config.type; + if (walletType == null) return []; + + switch (action) { + case CoinsManagerAction.add: + return _getDeactivatedCoins(coinsRepo, walletType); + case CoinsManagerAction.remove: + return _getActivatedCoins(coinsRepo); + case CoinsManagerAction.none: + return []; + } +} + +List _getActivatedCoins(CoinsBloc coinsRepo) { + return coinsRepo.walletCoins.where((coin) => !coin.isActivating).toList(); +} + +List _getDeactivatedCoins(CoinsBloc coinsRepo, WalletType walletType) { + final Map disabledCoinsMap = Map.from(coinsRepo.knownCoinsMap) + ..removeWhere( + (key, coin) => + coinsRepo.walletCoinsMap.containsKey(key) || coin.isActivating, + ); + + switch (walletType) { + case WalletType.iguana: + return disabledCoinsMap.values.toList(); + case WalletType.trezor: + return (disabledCoinsMap + ..removeWhere((_, coin) => !coin.hasTrezorSupport)) + .values + .toList(); + case WalletType.metamask: + case WalletType.keplr: + return []; + } +} + +typedef FilterFunction = List Function(List); diff --git a/lib/bloc/coins_manager/coins_manager_event.dart b/lib/bloc/coins_manager/coins_manager_event.dart new file mode 100644 index 0000000000..fe9a77225e --- /dev/null +++ b/lib/bloc/coins_manager/coins_manager_event.dart @@ -0,0 +1,37 @@ +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; + +abstract class CoinsManagerEvent { + const CoinsManagerEvent(); +} + +class CoinsManagerCoinsUpdate extends CoinsManagerEvent { + const CoinsManagerCoinsUpdate(); +} + +class CoinsManagerCoinTypeSelect extends CoinsManagerEvent { + const CoinsManagerCoinTypeSelect({required this.type}); + final CoinType type; +} + +class CoinsManagerCoinsSwitch extends CoinsManagerEvent { + const CoinsManagerCoinsSwitch(); +} + +class CoinsManagerCoinSelect extends CoinsManagerEvent { + const CoinsManagerCoinSelect({required this.coin}); + final Coin coin; +} + +class CoinsManagerSelectAllTap extends CoinsManagerEvent { + const CoinsManagerSelectAllTap(); +} + +class CoinsManagerSelectedTypesReset extends CoinsManagerEvent { + const CoinsManagerSelectedTypesReset(); +} + +class CoinsManagerSearchUpdate extends CoinsManagerEvent { + const CoinsManagerSearchUpdate({required this.text}); + final String text; +} diff --git a/lib/bloc/coins_manager/coins_manager_repo.dart b/lib/bloc/coins_manager/coins_manager_repo.dart new file mode 100644 index 0000000000..e375dd5356 --- /dev/null +++ b/lib/bloc/coins_manager/coins_manager_repo.dart @@ -0,0 +1 @@ +class CoinsManagerRepo {} diff --git a/lib/bloc/coins_manager/coins_manager_state.dart b/lib/bloc/coins_manager/coins_manager_state.dart new file mode 100644 index 0000000000..104fcbe3ea --- /dev/null +++ b/lib/bloc/coins_manager/coins_manager_state.dart @@ -0,0 +1,68 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/router/state/wallet_state.dart'; + +class CoinsManagerState extends Equatable { + const CoinsManagerState({ + required this.action, + required this.searchPhrase, + required this.selectedCoinTypes, + required this.coins, + required this.selectedCoins, + required this.isSwitching, + }); + final CoinsManagerAction action; + final String searchPhrase; + final List selectedCoinTypes; + final List coins; + final List selectedCoins; + final bool isSwitching; + + static CoinsManagerState initial({ + required CoinsManagerAction action, + required List coins, + }) { + return CoinsManagerState( + action: action, + searchPhrase: '', + selectedCoinTypes: const [], + coins: coins, + selectedCoins: const [], + isSwitching: false, + ); + } + + CoinsManagerState copyWith({ + CoinsManagerAction? action, + String? searchPhrase, + List? selectedCoinTypes, + List? coins, + List? selectedCoins, + bool? isSwitching, + }) => + CoinsManagerState( + action: action ?? this.action, + coins: coins ?? this.coins, + searchPhrase: searchPhrase ?? this.searchPhrase, + selectedCoinTypes: selectedCoinTypes ?? this.selectedCoinTypes, + selectedCoins: selectedCoins ?? this.selectedCoins, + isSwitching: isSwitching ?? this.isSwitching, + ); + + bool get isSelectedAllCoinsEnabled { + if (selectedCoins.isEmpty) return false; + + return coins.every((coin) => selectedCoins.contains(coin)); + } + + @override + List get props => [ + action, + searchPhrase, + selectedCoinTypes, + coins, + selectedCoins, + isSwitching, + ]; +} diff --git a/lib/bloc/dex_repository.dart b/lib/bloc/dex_repository.dart new file mode 100644 index 0000000000..82bc7e44e4 --- /dev/null +++ b/lib/bloc/dex_repository.dart @@ -0,0 +1,142 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/min_trading_vol/min_trading_vol.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/min_trading_vol/min_trading_vol_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_swap_status/my_swap_status_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/sell/sell_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/sell/sell_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_errors.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_response.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/services/mappers/trade_preimage_mappers.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +final dexRepository = DexRepository(); + +class DexRepository { + Future sell(SellRequest request) async { + try { + final Map response = await mm2Api.sell(request); + return SellResponse.fromJson(response); + } catch (e) { + return SellResponse(error: TextError.fromString(e.toString())); + } + } + + Future> getTradePreimage( + String base, String rel, Rational price, String swapMethod, + [Rational? volume, bool max = false]) async { + final request = TradePreimageRequest( + base: base, + rel: rel, + price: price, + volume: volume, + swapMethod: swapMethod, + max: max, + ); + final ApiResponse> response = await mm2Api.getTradePreimage(request); + + final Map? error = response.error; + final TradePreimageResponseResult? result = response.result; + if (error != null) { + return DataFromService( + error: tradePreimageErrorFactory.getError(error, response.request), + ); + } + if (result == null) { + return DataFromService(error: TextError(error: 'Something wrong')); + } + try { + return DataFromService( + data: mapTradePreimageResponseResultToTradePreimage( + result, response.request)); + } catch (e, s) { + log( + e.toString(), + path: + 'swaps_service => getTradePreimage => mapTradePreimageResponseToTradePreimage', + trace: s, + isError: true, + ); + return DataFromService(error: TextError(error: 'Something wrong')); + } + } + + Future getMaxTakerVolume(String coinAbbr) async { + final MaxTakerVolResponse? response = + await mm2Api.getMaxTakerVolume(MaxTakerVolRequest(coin: coinAbbr)); + if (response == null) { + return null; + } + + return fract2rat(response.result.toJson()); + } + + Future getMinTradingVolume(String coinAbbr) async { + final MinTradingVolResponse? response = + await mm2Api.getMinTradingVol(MinTradingVolRequest(coin: coinAbbr)); + if (response == null) { + return null; + } + + return fract2rat(response.result.toJson()); + } + + Future?> getRecentSwaps(MyRecentSwapsRequest request) async { + return null; + } + + Future getBestOrders(BestOrdersRequest request) async { + Map? response; + try { + response = await mm2Api.getBestOrders(request); + } catch (e) { + return BestOrders(error: TextError.fromString(e.toString())); + } + + final isErrorResponse = + (response?['error'] as String?)?.isNotEmpty ?? false; + final hasResult = + (response?['result'] as Map?)?.isNotEmpty ?? false; + + if (isErrorResponse) { + return BestOrders(error: TextError(error: response!['error']!)); + } + + if (!hasResult) { + return BestOrders(error: TextError(error: 'Orders not found!')); + } + + try { + return BestOrders.fromJson(response!); + } catch (e, s) { + log('Error parsing best_orders response: $e', trace: s, isError: true); + + return BestOrders( + error: TextError( + error: 'Something went wrong! Unexpected response format.')); + } + } + + Future getSwapStatus(String swapUuid) async { + final response = + await mm2Api.getSwapStatus(MySwapStatusReq(uuid: swapUuid)); + + if (response['error'] != null) { + throw TextError(error: response['error']); + } + + return Swap.fromJson(response['result']); + } +} diff --git a/lib/bloc/dex_tab_bar/dex_tab_bar_bloc.dart b/lib/bloc/dex_tab_bar/dex_tab_bar_bloc.dart new file mode 100644 index 0000000000..ac228d5239 --- /dev/null +++ b/lib/bloc/dex_tab_bar/dex_tab_bar_bloc.dart @@ -0,0 +1,57 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/views/market_maker_bot/tab_type_enum.dart'; + +part 'dex_tab_bar_event.dart'; +part 'dex_tab_bar_state.dart'; + +class DexTabBarBloc extends Bloc { + DexTabBarBloc(super.initialState, AuthRepository authRepo) { + on(_onTabChanged); + on(_onFilterChanged); + + _authorizationSubscription = authRepo.authMode.listen((event) { + if (event == AuthorizeMode.noLogin) { + add(const TabChanged(0)); + } + }); + } + + @override + Future close() { + _authorizationSubscription.cancel(); + return super.close(); + } + + late StreamSubscription _authorizationSubscription; + int get tabIndex => state.tabIndex; + + int get ordersCount => tradingEntitiesBloc.myOrders.length; + + int get inProgressCount => + tradingEntitiesBloc.swaps.where((swap) => !swap.isCompleted).length; + + int get completedCount => + tradingEntitiesBloc.swaps.where((swap) => swap.isCompleted).length; + + FutureOr _onTabChanged(TabChanged event, Emitter emit) { + emit(state.copyWith(tabIndex: event.tabIndex)); + } + + void _onFilterChanged(FilterChanged event, Emitter emit) { + emit( + state.copyWith( + filters: { + ...state.filters, + event.tabType: event.filter, + }, + ), + ); + } +} diff --git a/lib/bloc/dex_tab_bar/dex_tab_bar_event.dart b/lib/bloc/dex_tab_bar/dex_tab_bar_event.dart new file mode 100644 index 0000000000..56a7ec9154 --- /dev/null +++ b/lib/bloc/dex_tab_bar/dex_tab_bar_event.dart @@ -0,0 +1,25 @@ +part of 'dex_tab_bar_bloc.dart'; + +abstract class DexTabBarEvent extends Equatable { + const DexTabBarEvent(); + + @override + List get props => []; +} + +class TabChanged extends DexTabBarEvent { + const TabChanged(this.tabIndex); + final int tabIndex; + @override + List get props => [tabIndex]; +} + +class FilterChanged extends DexTabBarEvent { + const FilterChanged({required this.tabType, required this.filter}); + + final TabTypeEnum tabType; + final TradingEntitiesFilter? filter; + + @override + List get props => [tabType, filter]; +} diff --git a/lib/bloc/dex_tab_bar/dex_tab_bar_state.dart b/lib/bloc/dex_tab_bar/dex_tab_bar_state.dart new file mode 100644 index 0000000000..9cfd6caa0b --- /dev/null +++ b/lib/bloc/dex_tab_bar/dex_tab_bar_state.dart @@ -0,0 +1,22 @@ +part of 'dex_tab_bar_bloc.dart'; + +class DexTabBarState extends Equatable { + const DexTabBarState({required this.tabIndex, this.filters = const {}}); + factory DexTabBarState.initial() => const DexTabBarState(tabIndex: 0); + + final int tabIndex; + final Map filters; + + @override + List get props => [tabIndex, filters]; + + DexTabBarState copyWith({ + int? tabIndex, + Map? filters, + }) { + return DexTabBarState( + tabIndex: tabIndex ?? this.tabIndex, + filters: filters ?? this.filters, + ); + } +} diff --git a/lib/bloc/feedback_form/feedback_form_bloc.dart b/lib/bloc/feedback_form/feedback_form_bloc.dart new file mode 100644 index 0000000000..8ac1808b9b --- /dev/null +++ b/lib/bloc/feedback_form/feedback_form_bloc.dart @@ -0,0 +1,85 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_event.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_repo.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/feedback_data.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class FeedbackFormBloc extends Bloc { + FeedbackFormBloc({required FeedbackFormRepo feedbackFormRepo}) + : _feedbackFormRepo = feedbackFormRepo, + super(const FeedbackFormInitialState()) { + on(_onSubmitted); + on(_onReset); + } + final FeedbackFormRepo _feedbackFormRepo; + + Future _onSubmitted( + FeedbackFormSubmitted event, Emitter emit) async { + if (state is FeedbackFormSendingState) return; + final BaseError? emailError = _validateEmail(event.email); + final BaseError? messageError = _validateMessage(event.message); + if (emailError != null || messageError != null) { + emit(FeedbackFormFailureState( + emailError: emailError, + messageError: messageError, + )); + return; + } + + emit(const FeedbackFormSendingState()); + try { + final bool isSuccess = await _feedbackFormRepo.send( + FeedbackData( + email: event.email, + message: event.message, + ), + ); + if (isSuccess) { + emit(const FeedbackFormSuccessState()); + } else { + emit(FeedbackFormFailureState( + sendingError: TextError(error: LocaleKeys.sendFeedbackError.tr()), + )); + } + } catch (e, s) { + emit(FeedbackFormFailureState( + sendingError: TextError(error: LocaleKeys.sendFeedbackError.tr()), + )); + log(e.toString(), + path: 'feedback_form_bloc -> error -> _onSubmitted', + trace: s, + isError: true); + } + } + + void _onReset(FeedbackFormReset event, Emitter emit) { + emit(const FeedbackFormInitialState()); + } + + BaseError? _validateEmail(String email) { + if (!emailRegExp.hasMatch(email)) { + return TextError(error: LocaleKeys.emailValidatorError.tr()); + } + + return null; + } + + BaseError? _validateMessage(String message) { + if (message.isEmpty) { + return TextError(error: LocaleKeys.feedbackValidatorEmptyError.tr()); + } + if (message.length > 500) { + return TextError( + error: LocaleKeys.feedbackValidatorMaxLengthError.tr(args: ['500'])); + } + return null; + } +} diff --git a/lib/bloc/feedback_form/feedback_form_event.dart b/lib/bloc/feedback_form/feedback_form_event.dart new file mode 100644 index 0000000000..dde1acd15c --- /dev/null +++ b/lib/bloc/feedback_form/feedback_form_event.dart @@ -0,0 +1,16 @@ +abstract class FeedbackFormEvent { + const FeedbackFormEvent(); +} + +class FeedbackFormSubmitted extends FeedbackFormEvent { + const FeedbackFormSubmitted({ + required this.email, + required this.message, + }); + final String email; + final String message; +} + +class FeedbackFormReset extends FeedbackFormEvent { + const FeedbackFormReset(); +} diff --git a/lib/bloc/feedback_form/feedback_form_repo.dart b/lib/bloc/feedback_form/feedback_form_repo.dart new file mode 100644 index 0000000000..d84d4dbf34 --- /dev/null +++ b/lib/bloc/feedback_form/feedback_form_repo.dart @@ -0,0 +1,33 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:web_dex/model/feedback_data.dart'; +import 'package:web_dex/model/feedback_request.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class FeedbackFormRepo { + Future send(FeedbackData feedback) async { + try { + final Map headers = { + 'Content-type': 'application/json', + 'Accept': 'application/json', + }; + + final String body = json.encode( + FeedbackRequest(email: feedback.email, message: feedback.message), + ); + + await http.post( + feedbackUrl, + headers: headers, + body: body, + ); + return true; + } catch (e, s) { + log('Sending feedback error: ${e.toString()}', + path: 'feedback_service => send', trace: s, isError: true); + return false; + } + } +} diff --git a/lib/bloc/feedback_form/feedback_form_state.dart b/lib/bloc/feedback_form/feedback_form_state.dart new file mode 100644 index 0000000000..75c6bf8c42 --- /dev/null +++ b/lib/bloc/feedback_form/feedback_form_state.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +abstract class FeedbackFormState extends Equatable { + const FeedbackFormState(); + @override + List get props => []; +} + +class FeedbackFormInitialState extends FeedbackFormState { + const FeedbackFormInitialState(); +} + +class FeedbackFormSuccessState extends FeedbackFormState { + const FeedbackFormSuccessState(); +} + +class FeedbackFormFailureState extends FeedbackFormState { + const FeedbackFormFailureState( + {this.emailError, this.messageError, this.sendingError}); + final BaseError? emailError; + final BaseError? messageError; + final BaseError? sendingError; + + @override + List get props => [emailError, messageError, sendingError]; +} + +class FeedbackFormSendingState extends FeedbackFormState { + const FeedbackFormSendingState(); +} diff --git a/lib/bloc/fiat/banxa_fiat_provider.dart b/lib/bloc/fiat/banxa_fiat_provider.dart new file mode 100644 index 0000000000..7533d4acba --- /dev/null +++ b/lib/bloc/fiat/banxa_fiat_provider.dart @@ -0,0 +1,289 @@ +import 'dart:convert'; + +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/fiat/base_fiat_provider.dart'; +import 'package:web_dex/bloc/fiat/fiat_order_status.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class BanxaFiatProvider extends BaseFiatProvider { + final String providerId = "Banxa"; + final String apiEndpoint = "/api/v1/banxa"; + + BanxaFiatProvider(); + + @override + String getProviderId() { + return providerId; + } + + @override + String get providerIconPath => '$assetsPath/fiat/providers/banxa_icon.svg'; + + FiatOrderStatus _parseStatusFromResponse(Map response) { + final statusString = response['data']?['order']?['status'] as String?; + + return _parseOrderStatus(statusString ?? ''); + } + + Future _getPaymentMethods( + String source, + Currency target, { + String? sourceAmount, + }) => + apiRequest( + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/api/payment-methods', + 'source': source, + 'target': target.symbol + }, + ); + + Future _getPricesWithPaymentMethod( + String source, + Currency target, + String sourceAmount, + Map paymentMethod, + ) => + apiRequest( + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/api/prices', + 'source': source, + 'target': target.symbol, + 'source_amount': sourceAmount, + 'payment_method_id': paymentMethod['id'].toString(), + }, + ); + + Future _createOrder(Map payload) => + apiRequest('POST', apiEndpoint, + queryParams: { + 'endpoint': '/api/orders', + }, + body: payload); + + Future _getOrder(String orderId) => + apiRequest('GET', apiEndpoint, queryParams: { + 'endpoint': '/api/orders', + 'order_id': orderId, + }); + + Future _getFiats() => apiRequest( + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/api/fiats', + 'orderType': 'buy', + }, + ); + + Future _getCoins() => apiRequest( + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/api/coins', + 'orderType': 'buy', + }, + ); + + FiatOrderStatus _parseOrderStatus(String status) { + // The case statements are references to Banxa's order statuses. See the + // docs link here for more info: https://docs.banxa.com/docs/order-status + switch (status) { + case 'complete': + return FiatOrderStatus.success; + + case 'cancelled': + case 'declined': + case 'expired': + case 'refunded': + return FiatOrderStatus.failed; + + case 'extraVerification': + case 'pendingPayment': + case 'waitingPayment': + return FiatOrderStatus.pending; + + case 'paymentReceived': + case 'inProgress': + case 'coinTransferred': + return FiatOrderStatus.inProgress; + + default: + throw Exception('Unknown status: $status'); + } + } + + // These will be in BLOC: + @override + Stream watchOrderStatus(String orderId) async* { + FiatOrderStatus? lastStatus; + + // TODO: At the moment we're polling the API for order status. We can + // further optimise this by listening for the status redirect page post + // message, but adds the challenge that we add further web-only code that + // needs to be re-implemented for mobile/desktop. + while (true) { + final response = await _getOrder(orderId) + .catchError((e) => Future.error('Error fetching order: $e')); + + log('Fiat order status response:\n${jsonEncode(response)}'); + + final status = _parseStatusFromResponse(response); + + final isCompleted = + status == FiatOrderStatus.success || status == FiatOrderStatus.failed; + + if (status != lastStatus) { + lastStatus = status; + + yield status; + } + + if (isCompleted) break; + + await Future.delayed(const Duration(seconds: 5)); + } + } + + @override + Future> getFiatList() async { + final response = await _getFiats(); + final data = response['data']['fiats'] as List; + return data + .map((item) => Currency( + item['fiat_code'] as String, + item['fiat_name'] as String, + isFiat: true, + )) + .toList(); + } + + @override + Future> getCoinList() async { + final response = await _getCoins(); + final data = response['data']['coins'] as List; + + List currencyList = []; + for (final item in data) { + final coinCode = item['coin_code'] as String; + final coinName = item['coin_name'] as String; + final blockchains = item['blockchains'] as List; + + for (final blockchain in blockchains) { + currencyList.add( + Currency( + coinCode, + coinName, + chainType: getCoinType(blockchain['code'] as String), + isFiat: false, + ), + ); + } + } + + return currencyList; + } + + @override + Future>> getPaymentMethodsList( + String source, + Currency target, + String sourceAmount, + ) async { + try { + final response = + await _getPaymentMethods(source, target, sourceAmount: sourceAmount); + List> paymentMethods = + List>.from(response['data']['payment_methods']); + + List>> priceFutures = []; + for (final paymentMethod in paymentMethods) { + final futurePrice = getPaymentMethodPrice( + source, + target, + sourceAmount, + paymentMethod, + ); + priceFutures.add(futurePrice); + } + + // Wait for all futures to complete + List> prices = await Future.wait(priceFutures); + + // Combine price information with payment methods + for (int i = 0; i < paymentMethods.length; i++) { + paymentMethods[i]['price_info'] = prices[i]; + } + + return paymentMethods; + } catch (e) { + return []; + } + } + + @override + Future> getPaymentMethodPrice( + String source, + Currency target, + String sourceAmount, + Map paymentMethod, + ) async { + try { + final response = await _getPricesWithPaymentMethod( + source, + target, + sourceAmount, + paymentMethod, + ); + return Map.from(response['data']['prices'][0]); + } catch (e) { + return {}; + } + } + + @override + Future> buyCoin( + String accountReference, + String source, + Currency target, + String walletAddress, + String paymentMethodId, + String sourceAmount, + String returnUrlOnSuccess, + ) async { + final payload = { + 'account_reference': accountReference, + 'source': source, + 'target': target.symbol, + "wallet_address": walletAddress, + 'payment_method_id': paymentMethodId, + 'source_amount': sourceAmount, + 'return_url_on_success': returnUrlOnSuccess, + }; + + log('Fiat buy coin order payload:'); + log(jsonEncode(payload)); + final response = await _createOrder(payload); + log('Fiat buy coin order response:'); + log(jsonEncode(response)); + + return Map.from(response); + } + + @override + String? getCoinChainId(Currency currency) { + switch (currency.chainType) { + case CoinType.bep20: + return 'BNB'; // It's BSC usually, different for this provider + default: + break; + } + + return super.getCoinChainId(currency); + } +} diff --git a/lib/bloc/fiat/base_fiat_provider.dart b/lib/bloc/fiat/base_fiat_provider.dart new file mode 100644 index 0000000000..3b3b4f064d --- /dev/null +++ b/lib/bloc/fiat/base_fiat_provider.dart @@ -0,0 +1,274 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:web_dex/bloc/fiat/fiat_order_status.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/coin_utils.dart'; + +const String domain = "https://fiat-ramps-proxy.komodo.earth"; + +class Currency { + final String symbol; + final String name; + final CoinType? chainType; + final bool isFiat; + + Currency(this.symbol, this.name, {this.chainType, required this.isFiat}); + + String getAbbr() { + if (chainType == null) return symbol; + + final t = chainType; + if (t == null || + t == CoinType.utxo || + (t == CoinType.cosmos && symbol == 'ATOM') || + (t == CoinType.cosmos && symbol == 'ATOM') || + (t == CoinType.erc20 && symbol == 'ETH') || + (t == CoinType.bep20 && symbol == 'BNB') || + (t == CoinType.avx20 && symbol == 'AVAX') || + (t == CoinType.etc && symbol == 'ETC') || + (t == CoinType.ftm20 && symbol == 'FTM') || + (t == CoinType.hrc20 && symbol == 'ONE') || + (t == CoinType.plg20 && symbol == 'MATIC') || + (t == CoinType.mvr20 && symbol == 'MOVR')) return symbol; + + return '$symbol-${getCoinTypeName(chainType!).replaceAll('-', '')}'; + } + + /// Returns the short name of the coin including the chain type (if any). + String formatNameShort() { + return '$name${chainType != null ? ' (${getCoinTypeName(chainType!)})' : ''}'; + } +} + +abstract class BaseFiatProvider { + String getProviderId(); + + String get providerIconPath; + + Stream watchOrderStatus(String orderId); + + Future> getFiatList(); + + Future> getCoinList(); + + Future>> getPaymentMethodsList( + String source, + Currency target, + String sourceAmount, + ); + + Future> getPaymentMethodPrice( + String source, + Currency target, + String sourceAmount, + Map paymentMethod, + ); + + Future> buyCoin( + String accountReference, + String source, + Currency target, + String walletAddress, + String paymentMethodId, + String sourceAmount, + String returnUrlOnSuccess, + ); + + @protected + + /// Makes an API request to the fiat provider. Uses the test mode if the app + /// is in debug mode. + Future apiRequest( + String method, + String endpoint, { + Map? queryParams, + Map? body, + }) async { + final domainUri = Uri.parse(domain); + Uri url; + + // Remove the leading '/' if it exists in /api/fiats kind of an endpoint + if (endpoint.startsWith('/')) { + endpoint = endpoint.substring(1); + } + + // Add `is_test_mode` query param to all requests if we are in debug mode + final passedQueryParams = {} + ..addAll(queryParams ?? {}) + ..addAll({ + 'is_test_mode': kDebugMode ? 'true' : 'false', + }); + + url = Uri( + scheme: domainUri.scheme, + host: domainUri.host, + path: endpoint, + query: Uri(queryParameters: passedQueryParams).query, + ); + + final headers = {'Content-Type': 'application/json'}; + + http.Response response; + try { + if (method == 'GET') { + response = await http.get( + url, + headers: headers, + ); + } else { + response = await http.post( + url, + headers: headers, + body: json.encode(body), + ); + } + + if (response.statusCode >= 200 && response.statusCode < 300) { + return json.decode(response.body); + } else { + return Future.error( + json.decode(response.body), + ); + } + } catch (e) { + return Future.error("Network error: $e"); + } + } + + String? getCoinChainId(Currency currency) { + switch (currency.chainType) { + // These exist in the current fiat provider coin lists: + case CoinType.utxo: + // BTC, BCH, DOGE, LTC + return currency.symbol; + case CoinType.erc20: + return 'ETH'; + case CoinType.bep20: + return 'BSC'; // It is BNB for some providers like Banxa + case CoinType.cosmos: + return 'ATOM'; + case CoinType.avx20: + return 'AVAX'; + case CoinType.etc: + return 'ETC'; + case CoinType.ftm20: + return 'FTM'; + case CoinType.hrc20: + return 'HARMONY'; + case CoinType.plg20: + return 'MATIC'; + case CoinType.mvr20: + return 'MOVR'; + default: + return null; + } + + // These are not offered yet by the providers: + /* + case CoinType.qrc20: + return 'QRC-20'; + case CoinType.smartChain: + return 'Smart Chain'; + case CoinType.hco20: + return 'HCO-20'; + case CoinType.sbch: + return 'SmartBCH'; + case CoinType.ubiq: + return 'Ubiq'; + case CoinType.krc20: + return 'KRC-20'; + case CoinType.iris: + return 'Iris'; + case CoinType.slp: + return 'SLP'; + */ + + // These exist in coin config but not in CoinType structure yet: + // ARBITRUM + + // These chain IDs are not supported yet by Komodo Wallet: + // ADA / CARDANO + // AVAX-X + // ALGO + // ARWEAVE + // ASTR + // BAJU + // BNC + // BOBA + // BSV + // BSX + // CELO + // CRO + // DINGO + // DOT + // EGLD + // ELROND + // EOS + // FIL + // FLOW + // FLR + // GOERLI + // GLMR + // HBAR + // KDA + // KINT + // KSM + // KUSAMA + // LOOPRING + // MCK + // METIS + // MOB + // NEAR + // POLKADOT + // RON + // SEPOLIA + // SOL + // SOLANA + // STARKNET + // TERNOA + // TERRA + // TEZOS + // TRON + // WAX + // XCH + // XDAI + // XLM + // XPRT + // XRP + // XTZ + // ZILLIQA + } + + CoinType? getCoinType(String chain) { + switch (chain) { + case "BTC": + case "BCH": + case "DOGE": + case "LTC": + return CoinType.utxo; + case "ETH": + return CoinType.erc20; + case "BSC": + case "BNB": + return CoinType.bep20; + case "ATOM": + return CoinType.cosmos; + case "AVAX": + return CoinType.avx20; + case "ETC": + return CoinType.etc; + case "FTM": + return CoinType.ftm20; + case "HARMONY": + return CoinType.hrc20; + case "MATIC": + return CoinType.plg20; + case "MOVR": + return CoinType.mvr20; + default: + return null; + } + } +} diff --git a/lib/bloc/fiat/fiat_order_status.dart b/lib/bloc/fiat/fiat_order_status.dart new file mode 100644 index 0000000000..6980e8f712 --- /dev/null +++ b/lib/bloc/fiat/fiat_order_status.dart @@ -0,0 +1,17 @@ +// TODO: Differentiate between different error and in-progress statuses +enum FiatOrderStatus { + /// User has not yet started the payment process + pending, + + /// Payment has been submitted and is being processed + inProgress, + + /// Payment has been completed successfully + success, + + /// Payment has been cancelled, declined, expired or refunded + failed; + + bool get isTerminal => + this == FiatOrderStatus.success || this == FiatOrderStatus.failed; +} diff --git a/lib/bloc/fiat/fiat_repository.dart b/lib/bloc/fiat/fiat_repository.dart new file mode 100644 index 0000000000..0c65fb8971 --- /dev/null +++ b/lib/bloc/fiat/fiat_repository.dart @@ -0,0 +1,304 @@ +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/fiat/banxa_fiat_provider.dart'; +import 'package:web_dex/bloc/fiat/base_fiat_provider.dart'; +import 'package:web_dex/bloc/fiat/fiat_order_status.dart'; +import 'package:web_dex/bloc/fiat/ramp/ramp_fiat_provider.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +final fiatRepository = + FiatRepository([BanxaFiatProvider(), RampFiatProvider()]); + +class FiatRepository { + final List fiatProviders; + FiatRepository(this.fiatProviders); + + String? _paymentMethodFiat; + Currency? _paymentMethodsCoin; + List>? _paymentMethodsList; + + BaseFiatProvider? _getPaymentMethodProvider( + Map paymentMethod, + ) { + return _getProvider(paymentMethod['provider_id'].toString()); + } + + BaseFiatProvider? _getProvider( + String providerId, + ) { + for (final provider in fiatProviders) { + if (provider.getProviderId() == providerId) { + return provider; + } + } + return null; + } + + Stream watchOrderStatus( + Map paymentMethod, + String orderId, + ) async* { + final provider = _getPaymentMethodProvider(paymentMethod); + if (provider == null) yield* Stream.error('Provider not found'); + + yield* provider!.watchOrderStatus(orderId); + } + + Future> _getListFromProviders( + Future> Function(BaseFiatProvider) getList, + bool isCoin) async { + final futures = fiatProviders.map(getList); + final results = await Future.wait(futures); + + final currencyMap = {}; + + Set? knownCoinAbbreviations; + + if (isCoin) { + final knownCoins = await coinsRepo.getKnownCoins(); + knownCoinAbbreviations = knownCoins.map((coin) => coin.abbr).toSet(); + } + + for (final currencyList in results) { + for (final currency in currencyList) { + // Skip unsupported chains and coins + if (isCoin && + (currency.chainType == null || + !knownCoinAbbreviations!.contains(currency.getAbbr()))) { + continue; + } + + // Fill the map and replace missing image ones + currencyMap.putIfAbsent(currency.getAbbr(), () => currency); + } + } + + return currencyMap.values.toList() + ..sort((a, b) => a.symbol.compareTo(b.symbol)); + } + + Future> getFiatList() async { + return (await _getListFromProviders( + (provider) => provider.getFiatList(), false)) + ..sort((a, b) => currencySorter(a.getAbbr(), b.getAbbr())); + } + + // Order fiat list by common currencies first (fixed order), then the + // remaining are sorted alphabetically + int currencySorter(String a, String b) { + const List commonCurrencies = ['USD', 'EUR', 'GBP']; + + if (commonCurrencies.contains(a) && commonCurrencies.contains(b)) { + return commonCurrencies.indexOf(a).compareTo(commonCurrencies.indexOf(b)); + } else if (commonCurrencies.contains(a)) { + return -1; + } else if (commonCurrencies.contains(b)) { + return 1; + } else { + return a.compareTo(b); + } + } + + Future> getCoinList() async { + return _getListFromProviders((provider) => provider.getCoinList(), true); + } + + String? _calculateCoinAmount(String fiatAmount, String spotPriceIncludingFee, + {int decimalPoints = 8}) { + if (fiatAmount.isEmpty || spotPriceIncludingFee.isEmpty) { + return null; + } + + try { + final fiat = double.parse(fiatAmount); + final spotPrice = double.parse(spotPriceIncludingFee); + if (spotPrice == 0) return null; + + final coinAmount = fiat / spotPrice; + return coinAmount.toStringAsFixed(decimalPoints); + } catch (e) { + return null; + } + } + + String _calculateSpotPriceIncludingFee(Map paymentMethod) { + // Use the previous coin and fiat amounts to estimate the spot price + // including fee. + final coinAmount = + double.parse(paymentMethod['price_info']['coin_amount'] as String); + final fiatAmount = + double.parse(paymentMethod['price_info']['fiat_amount'] as String); + final spotPriceIncludingFee = fiatAmount / coinAmount; + return spotPriceIncludingFee.toString(); + } + + int? _getDecimalPoints(String amount) { + final decimalPointIndex = amount.indexOf('.'); + if (decimalPointIndex == -1) { + return null; + } + return amount.substring(decimalPointIndex + 1).length; + } + + List>? _getPaymentListEstimate( + List> paymentMethodsList, + String sourceAmount, + Currency target, + String source, + ) { + if (target != _paymentMethodsCoin || source != _paymentMethodFiat) { + _paymentMethodsCoin = null; + _paymentMethodFiat = null; + _paymentMethodsList = null; + return null; + } + + try { + return paymentMethodsList.map((method) { + String? spotPriceIncludingFee; + spotPriceIncludingFee = _calculateSpotPriceIncludingFee(method); + int decimalAmount = + _getDecimalPoints(method['price_info']['coin_amount']) ?? 8; + + final coinAmount = _calculateCoinAmount( + sourceAmount, + spotPriceIncludingFee, + decimalPoints: decimalAmount, + ); + + return { + ...method, + "price_info": { + ...method['price_info'], + "coin_amount": coinAmount, + "fiat_amount": sourceAmount, + }.map((key, value) => MapEntry(key as String, value)), + }; + }).toList(); + } catch (e) { + log('Fiat payment list estimation failed', + isError: true, trace: StackTrace.current, path: 'fiat_repository'); + return null; + } + } + + Stream>> getPaymentMethodsList( + String source, + Currency target, + String sourceAmount, + ) async* { + if (_paymentMethodsList != null) { + // Try to estimate the payment list based on the cached one + // This is to display temporary values while the new list is being fetched + // This is not a perfect solution + _paymentMethodsList = _getPaymentListEstimate( + _paymentMethodsList!, sourceAmount, target, source); + if (_paymentMethodsList != null) { + _paymentMethodsCoin = target; + _paymentMethodFiat = source; + yield _paymentMethodsList!; + } + } + + final futures = fiatProviders.map((provider) async { + final paymentMethods = + await provider.getPaymentMethodsList(source, target, sourceAmount); + return paymentMethods + .map((method) => { + ...method, + 'provider_id': provider.getProviderId(), + 'provider_icon_asset_path': provider.providerIconPath, + }) + .toList(); + }); + + final results = await Future.wait(futures); + _paymentMethodsList = results.expand((x) => x).toList(); + _paymentMethodsList = _addRelativePercentField(_paymentMethodsList!); + + _paymentMethodsCoin = target; + _paymentMethodFiat = source; + yield _paymentMethodsList!; + } + + Future> getPaymentMethodPrice( + String source, + Currency target, + String sourceAmount, + Map buyPaymentMethod, + ) async { + final provider = _getPaymentMethodProvider(buyPaymentMethod); + if (provider == null) return Future.error("Provider not found"); + + return await provider.getPaymentMethodPrice( + source, + target, + sourceAmount, + buyPaymentMethod, + ); + } + + Future> buyCoin( + String accountReference, + String source, + Currency target, + String walletAddress, + Map paymentMethod, + String sourceAmount, + String returnUrlOnSuccess, + ) async { + final provider = _getPaymentMethodProvider(paymentMethod); + if (provider == null) return Future.error("Provider not found"); + + return await provider.buyCoin( + accountReference, + source, + target, + walletAddress, + paymentMethod['id'].toString(), + sourceAmount, + returnUrlOnSuccess, + ); + } + + List> _addRelativePercentField( + List> paymentMethodsList) { + // Add a relative percent value to each payment method + // based on the payment method with the highest `coin_amount` + try { + final coinAmounts = _paymentMethodsList! + .map((method) => double.parse(method['price_info']['coin_amount'])) + .toList(); + final maxCoinAmount = coinAmounts.reduce((a, b) => a > b ? a : b); + return _paymentMethodsList!.map((method) { + final coinAmount = double.parse(method['price_info']['coin_amount']); + if (coinAmount == 0) { + return method; + } + if (coinAmount == maxCoinAmount) { + return { + ...method, + 'relative_percent': null, + }; + } + + final relativeValue = + (coinAmount - maxCoinAmount) / (maxCoinAmount).abs(); + + return { + ...method, + 'relative_percent': relativeValue, //0 to -1 + }; + }).toList() + ..sort((a, b) { + if (a['relative_percent'] == null) return -1; + if (b['relative_percent'] == null) return 1; + return (b['relative_percent'] as double) + .compareTo(a['relative_percent'] as double); + }); + } catch (e) { + log('Failed to add relative percent field to payment methods list', + isError: true, trace: StackTrace.current, path: 'fiat_repository'); + return paymentMethodsList; + } + } +} diff --git a/lib/bloc/fiat/ramp/ramp_fiat_provider.dart b/lib/bloc/fiat/ramp/ramp_fiat_provider.dart new file mode 100644 index 0000000000..ec362509e6 --- /dev/null +++ b/lib/bloc/fiat/ramp/ramp_fiat_provider.dart @@ -0,0 +1,304 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:flutter/foundation.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/fiat/base_fiat_provider.dart'; +import 'package:web_dex/bloc/fiat/fiat_order_status.dart'; +import 'package:web_dex/bloc/fiat/ramp/ramp_purchase_watcher.dart'; + +const komodoLogoUrl = 'https://komodoplatform.com/assets/img/logo-dark.png'; + +class RampFiatProvider extends BaseFiatProvider { + final String providerId = "Ramp"; + final String apiEndpoint = "/api/v1/ramp"; + + String get orderDomain => + kDebugMode ? 'https://app.demo.ramp.network' : 'https://app.ramp.network'; + String get hostId => kDebugMode + ? '3uvh7c9nj9hxz97wam8kohzqkogtx4om5uhd6d9c' + : 'dc8v2qap3ks2mpezf4p2znxuzy5f684oxy7cgstc'; + + @override + String get providerIconPath => '$assetsPath/fiat/providers/ramp_icon.svg'; + + RampFiatProvider(); + + @override + String getProviderId() { + return providerId; + } + + String getFullCoinCode(Currency target) { + return '${getCoinChainId(target)}_${target.symbol}'; + } + + Future _getPaymentMethods( + String source, + Currency target, { + String? sourceAmount, + }) => + apiRequest( + 'POST', + apiEndpoint, + queryParams: { + 'endpoint': '/onramp/quote/all', + }, + body: { + 'fiatCurrency': source, + 'cryptoAssetSymbol': getFullCoinCode(target), + "fiatValue": double.tryParse(sourceAmount!), + }, + ); + + Future _getPricesWithPaymentMethod( + String source, + Currency target, + String sourceAmount, + Map paymentMethod, + ) => + apiRequest( + 'POST', + apiEndpoint, + queryParams: { + 'endpoint': '/onramp/quote/all', + }, + body: { + 'fiatCurrency': source, + 'cryptoAssetSymbol': getFullCoinCode(target), + 'fiatValue': double.tryParse(sourceAmount), + }, + ); + + Future _getFiats() => apiRequest( + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/currencies', + }, + ); + + Future _getCoins({String? currencyCode}) => apiRequest( + 'GET', + apiEndpoint, + queryParams: { + 'endpoint': '/assets', + if (currencyCode != null) 'currencyCode': currencyCode, + }, + ); + + @override + Stream watchOrderStatus([String? orderId]) { + assert( + orderId == null || orderId.isEmpty == true, + 'Ramp Order ID is only available after the user starts the checkout.', + ); + + final rampOrderWatcher = RampPurchaseWatcher(); + + return rampOrderWatcher.watchOrderStatus(); + } + + @override + Future> getFiatList() async { + final response = await _getFiats(); + final data = response as List; + return data + .where((item) => item['onrampAvailable'] as bool) + .map((item) => Currency( + item['fiatCurrency'] as String, + item['name'] as String, + isFiat: true, + )) + .toList(); + } + + @override + Future> getCoinList() async { + final response = await _getCoins(); + final data = response['assets'] as List; + return data + .map((item) { + return Currency(item['symbol'] as String, item['name'] as String, + chainType: getCoinType(item['chain'] as String), isFiat: false); + }) + .where((item) => item.chainType != null) + .toList(); + } + + // Turns `APPLE_PAY` to `Apple Pay` + String _formatMethodName(String methodName) { + return methodName + .split('_') + .map((str) => str[0].toUpperCase() + str.substring(1).toLowerCase()) + .join(' '); + } + + @override + Future>> getPaymentMethodsList( + String source, + Currency target, + String sourceAmount, + ) async { + try { + List> paymentMethodsList = []; + + final paymentMethodsFuture = + _getPaymentMethods(source, target, sourceAmount: sourceAmount); + final coinsFuture = _getCoins(currencyCode: source); + + final results = await Future.wait([paymentMethodsFuture, coinsFuture]); + + final paymentMethods = results[0]; + final coins = results[1] as Map; + + final asset = paymentMethods['asset']; + + final globalMinPurchaseAmount = coins['minPurchaseAmount']; + final globalMaxPurchaseAmount = coins['maxPurchaseAmount']; + final assetMinPurchaseAmount = + asset == null ? null : asset['minPurchaseAmount']; + final assetMaxPurchaseAmount = + asset == null ? null : asset['maxPurchaseAmount']; + + if (asset != null) { + paymentMethods.forEach((key, value) { + if (key != "asset") { + final method = { + "id": key, + "name": _formatMethodName(key), + "transaction_fees": [ + { + "fees": [ + { + "amount": + value["baseRampFee"] / double.tryParse(sourceAmount) + }, + ], + } + ], + "transaction_limits": [ + { + "fiat_code": source, + "min": (assetMinPurchaseAmount != null && + assetMinPurchaseAmount != -1 + ? assetMinPurchaseAmount + : globalMinPurchaseAmount) + .toString(), + "max": (assetMaxPurchaseAmount != null && + assetMaxPurchaseAmount != -1 + ? assetMaxPurchaseAmount + : globalMaxPurchaseAmount) + .toString(), + } + ], + "price_info": { + 'coin_amount': + getCryptoAmount(value['cryptoAmount'], asset['decimals']), + "fiat_amount": value['fiatValue'].toString(), + } + }; + paymentMethodsList.add(method); + } + }); + } + return paymentMethodsList; + } catch (e) { + debugPrint(e.toString()); + + return []; + } + } + + double _getPaymentMethodFee(Map paymentMethod) { + return paymentMethod['transaction_fees'][0]['fees'][0]['amount']; + } + + double _getFeeAdjustedPrice( + Map paymentMethod, + double price, + ) { + return price / (1 - _getPaymentMethodFee(paymentMethod)); + } + + String getCryptoAmount(String cryptoAmount, int decimals) { + final amount = double.parse(cryptoAmount); + return (amount / pow(10, decimals)).toString(); + } + + @override + Future> getPaymentMethodPrice( + String source, + Currency target, + String sourceAmount, + Map paymentMethod, + ) async { + final response = await _getPricesWithPaymentMethod( + source, + target, + sourceAmount, + paymentMethod, + ); + final asset = response['asset']; + final prices = asset['price']; + if (!prices.containsKey(source)) { + return Future.error( + 'Price information not available for the currency: $source', + ); + } + + final priceInfo = { + 'fiat_code': source, + 'coin_code': target.symbol, + 'spot_price_including_fee': + _getFeeAdjustedPrice(paymentMethod, prices[source]).toString(), + 'coin_amount': getCryptoAmount( + response[paymentMethod['id']]['cryptoAmount'], asset['decimals']), + }; + + return Map.from(priceInfo); + } + + @override + Future> buyCoin( + String accountReference, + String source, + Currency target, + String walletAddress, + String paymentMethodId, + String sourceAmount, + String returnUrlOnSuccess, + ) async { + final payload = { + 'hostApiKey': hostId, + 'hostAppName': appShortTitle, + 'hostLogoUrl': komodoLogoUrl, + "userAddress": walletAddress, + "finalUrl": returnUrlOnSuccess, + "defaultFlow": 'ONRAMP', + "enabledFlows": '[ONRAMP]', + "fiatCurrency": source, + "fiatValue": sourceAmount, + "defaultAsset": getFullCoinCode(target), + // if(coinsBloc.walletCoins.isNotEmpty) + // "swapAsset": coinsBloc.walletCoins.map((e) => e.abbr).toList().toString(), + // "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.toString())}'; + }).join('&'); + + final checkoutUrl = '$orderDomain?$queryString'; + + final orderInfo = { + 'data': { + 'order': { + 'checkout_url': checkoutUrl, + }, + }, + }; + + return Map.from(orderInfo); + } +} diff --git a/lib/bloc/fiat/ramp/ramp_purchase_watcher.dart b/lib/bloc/fiat/ramp/ramp_purchase_watcher.dart new file mode 100644 index 0000000000..03690c7fe8 --- /dev/null +++ b/lib/bloc/fiat/ramp/ramp_purchase_watcher.dart @@ -0,0 +1,258 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:http/http.dart' as http; +import 'package:web_dex/bloc/fiat/fiat_order_status.dart'; + +class RampPurchaseWatcher { + FiatOrderStatus? _lastStatus; + bool _isDisposed = false; + + /// Watches the status of new Ramp purchases. + /// + /// NB: Will only work if the Ramp checkout tab was opened by the app. I.e. + /// the user copies the checkout URL and opens it in a new tab, we will not + /// be able to track the status of that purchase. Implementing a microservice + /// that can receive webhooks is a possible solution. + /// + /// [watchFirstPurchaseOnly] - if true, will only listen for status updates + /// for the first purchase. If false, will we will no longer listen for + /// status updates of the first purchase and will start listening for status + /// updates of the new purchase. The ramp purchase is created in one of the + /// last checkout steps, so if the user creates the purchase and goes back to + /// the first step, Ramp will create a new purchase. + Stream watchOrderStatus({ + bool watchFirstPurchaseOnly = false, + }) { + _assertNotDisposed(); + + RampPurchaseDetails? purchaseDetails; + + final controller = StreamController(); + + scheduleMicrotask(() async { + StreamSubscription? subscription; + + final stream = watchNewRampOrdersCreated().takeWhile((purchase) => + !controller.isClosed && + (purchaseDetails == null || !watchFirstPurchaseOnly)); + try { + subscription = stream.listen( + (newPurchaseJson) => purchaseDetails = + RampPurchaseDetails.tryFromMessage(newPurchaseJson), + cancelOnError: false, + ); + + while (!controller.isClosed) { + if (purchaseDetails != null) { + final status = await _getPurchaseStatus(purchaseDetails!); + if (status != _lastStatus) { + _lastStatus = status; + controller.add(status); + } + + if (status.isTerminal || controller.isClosed) break; + } + await Future.delayed(const Duration(seconds: 10)); + } + } catch (e) { + controller.addError(e); + debugPrint('RampOrderWatcher: Error: $e'); + } finally { + subscription?.cancel().ignore(); + _cleanup(); + } + }); + + return controller.stream; + } + + Stream> watchNewRampOrdersCreated() async* { + _assertNotDisposed(); + + final purchaseStartedController = StreamController>(); + + void handlerFunction(html.Event event) { + if (purchaseStartedController.isClosed) return; + final messageEvent = event as html.MessageEvent; + if (messageEvent.data is Map) { + try { + final dataJson = (messageEvent.data as Map).cast(); + + if (_isRampNewPurchaseMessage(dataJson)) { + purchaseStartedController.add(dataJson); + } + } catch (e) { + purchaseStartedController.addError(e); + } + } + } + + final handler = handlerFunction; + + try { + html.window.addEventListener('message', handler); + + yield* purchaseStartedController.stream; + } catch (e) { + purchaseStartedController.addError(e); + } finally { + html.window.removeEventListener('message', handler); + + if (!purchaseStartedController.isClosed) { + await purchaseStartedController.close(); + } + } + } + + /// Checks if the JS message is a new Ramp purchase message. + bool _isRampNewPurchaseMessage(Map data) { + return data.containsKey('type') && data['type'] == 'PURCHASE_CREATED'; + } + + void _cleanup() { + _isDisposed = true; + // Close any other resources if necessary + } + + void _assertNotDisposed() { + if (_isDisposed) { + throw Exception('RampOrderWatcher has already been disposed'); + } + } + + static Future _getPurchaseStatus( + RampPurchaseDetails purchase) async { + final response = await http.get(purchase.purchaseUrl); + + if (response.statusCode != 200) { + throw Exception('Could not get Ramp purchase status'); + } + + final data = json.decode(response.body) as _JsonMap; + final rampStatus = data['status'] as String; + final status = _mapRampStatusToFiatOrderStatus(rampStatus); + if (status != null) { + return status; + } else { + throw Exception('Could not parse Ramp status: $rampStatus'); + } + } + + static FiatOrderStatus? _mapRampStatusToFiatOrderStatus(String rampStatus) { + // See here for all possible statuses: + // https://docs.ramp.network/sdk-reference#on-ramp-purchase-status + switch (rampStatus) { + case 'INITIALIZED': + case 'PAYMENT_STARTED': + case 'PAYMENT_IN_PROGRESS': + return FiatOrderStatus.pending; + + case 'FIAT_SENT': + case 'FIAT_RECEIVED': + case 'RELEASING': + return FiatOrderStatus.inProgress; + case 'PAYMENT_EXECUTED': + case 'RELEASED': + return FiatOrderStatus.success; + case 'PAYMENT_FAILED': + case 'EXPIRED': + case 'CANCELLED': + return FiatOrderStatus.failed; + default: + return null; + } + } +} + +typedef _JsonMap = Map; + +class RampPurchaseDetails { + RampPurchaseDetails({ + required this.orderId, + required this.apiUrl, + required this.purchaseViewToken, + }); + + final String orderId; + final String apiUrl; + final String purchaseViewToken; + + Uri get purchaseUrl => + Uri.parse('$apiUrl/host-api/purchase/$orderId?secret=$purchaseViewToken'); + + static RampPurchaseDetails? tryFromMessage(Map message) { + if (!message.containsKey('type') || message['type'] != 'PURCHASE_CREATED') { + return null; + } + + try { + final payload = message['payload'] as Map; + final Map purchase = + Map.from(payload['purchase'] as Map); + final String purchaseViewToken = payload['purchaseViewToken'] as String; + final String apiUrl = payload['apiUrl'] as String; + final String orderId = purchase['id'] as String; + + return RampPurchaseDetails( + orderId: orderId, + apiUrl: apiUrl, + purchaseViewToken: purchaseViewToken, + ); + } catch (e) { + debugPrint('RampOrderWatcher: Error parsing RampPurchaseDetails: $e'); + return null; + } + } + +//==== RampPurchase MESSAGE FORMAT: +// { +// type: 'PURCHASE_CREATED', +// payload: { +// purchase: RampPurchase, +// purchaseViewToken: string, +// apiUrl: string +// }, +// widgetInstanceId: string, +// } + +//==== RampPurchase MESSAGE EXAMPLE: +// { +// "type": "PURCHASE_CREATED", +// "payload": { +// "purchase": { +// "endTime": "2023-11-26T13:24:20.177Z", +// "cryptoAmount": "110724180593676737247", +// "fiatCurrency": "GBP", +// "fiatValue": 100, +// "assetExchangeRateEur": 1, +// "fiatExchangeRateEur": 1.1505242363683013, +// "baseRampFee": 3.753282987574591, +// "networkFee": 0.00869169, +// "appliedFee": 3.761974677574591, +// "createdAt": "2023-11-23T13:24:20.271Z", +// "updatedAt": "2023-11-23T13:24:21.040Z", +// "id": "s73gxbn6jotrvqj", +// "asset": { +// "address": "0x5248dDdC7857987A2EfD81522AFBA1fCb017A4b7", +// "symbol": "MATIC_TEST", +// "apiV3Symbol": "TEST", +// "name": "Test Token on Polygon Mumbai", +// "decimals": 18, +// "type": "MATIC_ERC20", +// "apiV3Type": "ERC20", +// "chain": "MATIC" +// }, +// "receiverAddress": "0xbbabc29087c7ef37a59da76896d7740a43dcb371", +// "assetExchangeRate": 0.869169, +// "purchaseViewToken": "56grvvsvu3mae27t", +// "status": "INITIALIZED", +// "paymentMethodType": "CARD_PAYMENT" +// }, +// "purchaseViewToken": "56grvvsvu3mae27t", +// "apiUrl": "https://api.demo.ramp.network/api" +// }, +// "widgetInstanceId": "KNWgVtLoPwMM0v2sllOeE" +// } +} 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 new file mode 100644 index 0000000000..be04e62b2c --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart @@ -0,0 +1,172 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_repository.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_status.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/market_maker_bot_order_list_repository.dart'; +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'; + +part 'market_maker_bot_event.dart'; +part 'market_maker_bot_state.dart'; + +/// BLoC responsible for starting, stopping and updating the market maker bot. +/// The bot is started with the parameters defined in the settings. +/// All active orders are cancelled when the bot is stopped or updated. +class MarketMakerBotBloc + extends Bloc { + MarketMakerBotBloc( + MarketMakerBotRepository marketMaketBotRepository, + MarketMakerBotOrderListRepository orderRepository, + ) : _botRepository = marketMaketBotRepository, + _orderRepository = orderRepository, + super(const MarketMakerBotState.initial()) { + on(_onStartRequested); + on(_onStopRequested); + on(_onOrderUpdateRequested); + on(_onOrderCancelRequested); + } + + final MarketMakerBotRepository _botRepository; + final MarketMakerBotOrderListRepository _orderRepository; + + void _onStartRequested( + MarketMakerBotStartRequested event, + Emitter emit, + ) async { + if (state.isRunning || state.isUpdating) { + return; + } + + emit(const MarketMakerBotState.starting()); + try { + await _botRepository.start(botId: event.botId); + emit(const MarketMakerBotState.running()); + } catch (e) { + final isAlreadyStarted = + e is RpcException && e.error.errorType == RpcErrorType.alreadyStarted; + if (isAlreadyStarted) { + emit(const MarketMakerBotState.running()); + return; + } + emit(const MarketMakerBotState.stopped().copyWith(error: e.toString())); + } + } + + void _onStopRequested( + MarketMakerBotStopRequested event, + Emitter emit, + ) async { + try { + emit(const MarketMakerBotState.stopping()); + await _botRepository.stop(botId: event.botId); + await _waitForOrdersToBeCancelled( + timeout: const Duration(minutes: 1), + fatalTimeout: false, + ); + emit(const MarketMakerBotState.stopped()); + } catch (e) { + emit( + const MarketMakerBotState.stopped() + .copyWith(error: 'Failed to stop the bot'), + ); + } + } + + void _onOrderUpdateRequested( + MarketMakerBotOrderUpdateRequested event, + Emitter emit, + ) async { + emit(const MarketMakerBotState.stopping()); + + try { + // Add the trade pair to stored settings immediately to provide feedback + // and updates to the user. + _botRepository.addTradePairToStoredSettings(event.tradePair); + + // 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); + await for (final botStatus in botStatusStream) { + emit(state.copyWith(status: botStatus)); + } + } catch (e) { + final isAlreadyStarted = + e is RpcException && e.error.errorType == RpcErrorType.alreadyStarted; + if (isAlreadyStarted) { + emit(const MarketMakerBotState.running()); + return; + } + + final stoppingState = + const MarketMakerBotState.stopping().copyWith(error: e.toString()); + emit(stoppingState); + await _botRepository.stop(botId: event.botId); + emit(stoppingState.copyWith(status: MarketMakerBotStatus.stopped)); + } + } + + void _onOrderCancelRequested( + MarketMakerBotOrderCancelRequested event, + Emitter emit, + ) async { + emit(const MarketMakerBotState.stopping()); + + try { + await _orderRepository.cancelOrders(event.tradePairs.toList()); + + final botStatusStream = _botRepository.cancelOrders( + event.tradePairs, + botId: event.botId, + ); + await for (final botStatus in botStatusStream) { + emit(state.copyWith(status: botStatus)); + } + + // 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()); + } catch (e) { + final isAlreadyStarted = + e is RpcException && e.error.errorType == RpcErrorType.alreadyStarted; + if (isAlreadyStarted) { + emit(const MarketMakerBotState.running()); + return; + } + + final stoppingState = + const MarketMakerBotState.stopping().copyWith(error: e.toString()); + emit(stoppingState); + await _botRepository.stop(botId: event.botId); + emit(stoppingState.copyWith(status: MarketMakerBotStatus.stopped)); + } + } + + /// Waits for all orders to be cancelled. + /// + /// Throws a [TimeoutException] if the orders are not cancelled in time if + /// [fatalTimeout] is `true`. Otherwise, the function returns without throwing + Future _waitForOrdersToBeCancelled({ + Duration timeout = const Duration(seconds: 30), + bool fatalTimeout = true, + }) async { + final start = DateTime.now(); + final orders = await _orderRepository.getTradePairs(); + while (orders.any((order) => order.order != null)) { + if (DateTime.now().difference(start) > timeout) { + if (fatalTimeout) { + throw TimeoutException('Failed to cancel orders in time'); + } + return; + } + await Future.delayed(const Duration(milliseconds: 100)); + } + } +} diff --git a/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_event.dart b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_event.dart new file mode 100644 index 0000000000..5a20579eca --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_event.dart @@ -0,0 +1,46 @@ +part of 'market_maker_bot_bloc.dart'; + +sealed class MarketMakerBotEvent extends Equatable { + const MarketMakerBotEvent({this.botId = 0}); + + /// The ID of the current bot configuration. + final int botId; + + @override + List get props => [botId]; +} + +/// Event to start the market maker bot with the current settings obtained from +/// [SettingsRepository]. If the bot is already running, the event is ignored. +class MarketMakerBotStartRequested extends MarketMakerBotEvent { + const MarketMakerBotStartRequested(); +} + +/// Event to stop the market maker bot. +class MarketMakerBotStopRequested extends MarketMakerBotEvent { + const MarketMakerBotStopRequested(); +} + +/// Event to update the market maker bot orders. All active orders are cancelled +/// and new orders are created based on the current market maker bot settings +/// obtained from [SettingsRepository]. +class MarketMakerBotOrderUpdateRequested extends MarketMakerBotEvent { + const MarketMakerBotOrderUpdateRequested(this.tradePair); + + final TradeCoinPairConfig tradePair; + + @override + List get props => [botId, tradePair]; +} + +/// Event to cancel a market maker bot order. All active orders are cancelled +/// and new orders are created based on the current market maker bot settings +/// obtained from [SettingsRepository]. +class MarketMakerBotOrderCancelRequested extends MarketMakerBotEvent { + const MarketMakerBotOrderCancelRequested(this.tradePairs); + + final Iterable tradePairs; + + @override + List get props => [botId, tradePairs]; +} diff --git a/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_method.dart b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_method.dart new file mode 100644 index 0000000000..228da5d64e --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_method.dart @@ -0,0 +1,13 @@ +enum MarketMakerBotMethod { + start, + stop; + + String get value { + switch (this) { + case MarketMakerBotMethod.start: + return 'start_simple_market_maker_bot'; + case MarketMakerBotMethod.stop: + return 'stop_simple_market_maker_bot'; + } + } +} diff --git a/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_repository.dart b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_repository.dart new file mode 100644 index 0000000000..dad9b6bf81 --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_repository.dart @@ -0,0 +1,264 @@ +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_method.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_status.dart'; +import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_parameters.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_request.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/shared/utils/utils.dart'; + +class MarketMakerBotRepository { + MarketMakerBotRepository(this._mm2Api, this._settingsRepository); + + /// The MM2 RPC API provider used to start/stop the market maker bot. + final Mm2Api _mm2Api; + + /// The settings repository used to read/fetch the market maker bot settings. + /// This BLoC does not write to the settings repository. + final SettingsRepository _settingsRepository; + + /// Starts the market maker bot with the given parameters. + /// Throws an [ArgumentError] if the request fails. + Future start({ + required int botId, + int retries = 10, + int delay = 2000, + }) async { + final requestParams = await loadStoredConfig(); + final request = MarketMakerBotRequest( + id: botId, + method: MarketMakerBotMethod.start.value, + params: requestParams, + ); + + if (requestParams.tradeCoinPairs?.isEmpty == true) { + throw ArgumentError('No trade pairs configured'); + } + + await _startStopBotWithExponentialBackoff( + request, + retries: retries, + delay: delay, + ); + } + + /// Stops the market maker bot with the given ID. + /// Throws an [Exception] if the request fails. + Future stop({ + required int botId, + int retries = 10, + int delay = 2000, + }) async { + try { + MarketMakerBotRequest request = MarketMakerBotRequest( + id: botId, + method: MarketMakerBotMethod.stop.value, + ); + await _startStopBotWithExponentialBackoff( + request, + retries: retries, + delay: delay, + ); + } catch (e) { + if (e is RpcException && + (e.error.errorType == RpcErrorType.alreadyStopped || + e.error.errorType == RpcErrorType.alreadyStopping)) { + return; + } + rethrow; + } + } + + /// Updates the market maker bot with the given parameters. + /// Throws an [Exception] if the request fails. + Stream updateOrder( + TradeCoinPairConfig tradePair, { + required int botId, + int retries = 10, + int delay = 2000, + }) async* { + yield MarketMakerBotStatus.stopping; + await stop(botId: botId, retries: retries, delay: delay); + // This would be correct, if the bot stopped completely before responding + // to the stop request + // yield MarketMakerBotStatus.stopped; + + final requestParams = await loadStoredConfig(); + if (requestParams.tradeCoinPairs?.containsKey(tradePair.name) == false) { + requestParams.tradeCoinPairs?.addEntries([ + MapEntry(tradePair.name, tradePair), + ]); + } + + if (requestParams.tradeCoinPairs?.isEmpty == true) { + yield MarketMakerBotStatus.stopped; + } else { + yield MarketMakerBotStatus.starting; + final request = MarketMakerBotRequest( + id: botId, + method: MarketMakerBotMethod.start.value, + params: requestParams, + ); + await _startStopBotWithExponentialBackoff( + request, + retries: retries, + delay: delay, + ); + yield MarketMakerBotStatus.running; + } + } + + /// Cancels the market maker bot order with the given parameters. + /// Throws an [Exception] if the request fails. + Stream cancelOrders( + Iterable tradeCoinPairConfig, { + required int botId, + int retries = 10, + int delay = 2000, + }) async* { + yield MarketMakerBotStatus.stopping; + await stop(botId: botId, retries: retries, delay: delay); + // This would be correct, if the bot stopped completely before responding + // to the stop request + // yield MarketMakerBotStatus.stopped; + + final requestParams = await loadStoredConfig(); + for (final tradePair in tradeCoinPairConfig) { + requestParams.tradeCoinPairs?.remove(tradePair.name); + } + + if (requestParams.tradeCoinPairs?.isEmpty == true) { + yield MarketMakerBotStatus.stopped; + } else { + // yield MarketMakerBotStatus.starting; + final request = MarketMakerBotRequest( + id: botId, + method: MarketMakerBotMethod.start.value, + params: requestParams, + ); + await _startStopBotWithExponentialBackoff( + request, + retries: retries, + delay: delay, + ); + yield MarketMakerBotStatus.running; + } + } + + /// Loads the market maker bot parameters from the settings repository. + /// The parameters are used to start the market maker bot. + Future loadStoredConfig() async { + final settings = await _settingsRepository.loadSettings(); + final mmSettings = settings.marketMakerBotSettings; + final tradePairs = { + for (final tradePair in mmSettings.tradeCoinPairConfigs) + tradePair.name: tradePair, + }; + return MarketMakerBotParameters( + botRefreshRate: mmSettings.botRefreshRate, + priceUrl: mmSettings.priceUrl, + tradeCoinPairs: tradePairs, + ); + } + + /// Starts the market maker bot with the given parameters. Retries the request + /// if it fails. The number of retries and the initial delay between retries + /// can be configured. The delay between retries is doubled after each retry. + /// Throws an [Exception] if the request fails after all retries. + Future _startStopBotWithExponentialBackoff( + MarketMakerBotRequest request, { + required int retries, + required int delay, + }) async { + final isStartRequest = request.method == MarketMakerBotMethod.start.value; + final isTradePairsEmpty = request.params?.tradeCoinPairs?.isEmpty == true; + if (isStartRequest && isTradePairsEmpty) { + throw ArgumentError('No trade pairs configured'); + } + + while (retries > 0) { + try { + await _mm2Api.startStopMarketMakerBot(request); + break; + } catch (e, s) { + if (retries <= 0) { + rethrow; + } + + if (e is RpcException) { + if (request.method == MarketMakerBotMethod.start.value && + e.error.errorType == RpcErrorType.alreadyStarted) { + log('Market maker bot already started', isError: true); + return; + } else if (request.method == MarketMakerBotMethod.stop.value && + e.error.errorType == RpcErrorType.alreadyStopped || + e.error.errorType == RpcErrorType.alreadyStopping) { + log('Market maker bot already stopped', isError: true); + return; + } + } + + log( + 'Failed to start market maker bot. Retrying in $delay ms', + isError: true, + trace: s, + path: 'MarketMakerBotBloc', + ); + await Future.delayed(Duration(milliseconds: delay)); + retries--; + delay *= 2; + } + } + } + + /// Adds the given trade pair to the stored market maker bot settings. + /// The settings are updated in the settings repository. + /// Throws an [Exception] if the settings cannot be updated. + /// + /// The [tradePair] to added to the existing settings. + Future addTradePairToStoredSettings( + TradeCoinPairConfig tradePair, + ) async { + final allSettings = await _settingsRepository.loadSettings(); + final settings = allSettings.marketMakerBotSettings; + final tradePairs = + List.from(settings.tradeCoinPairConfigs); + + // remove any existing pairs + tradePairs.removeWhere( + (element) => + element.baseCoinId == tradePair.baseCoinId && + element.relCoinId == tradePair.relCoinId, + ); + + tradePairs.add(tradePair); + final newSettings = settings.copyWith(tradeCoinPairConfigs: tradePairs); + await _settingsRepository.updateSettings( + allSettings.copyWith(marketMakerBotSettings: newSettings), + ); + } + + /// Removes the given trade pair from the stored market maker bot settings. + /// The settings are updated in the settings repository. + /// Throws an [Exception] if the settings cannot be updated. + /// + /// The [tradePair] to remove from the existing settings. + Future removeTradePairsFromStoredSettings( + List tradePairsToRemove, + ) async { + final allSettings = await _settingsRepository.loadSettings(); + final settings = allSettings.marketMakerBotSettings; + final tradePairs = + List.from(settings.tradeCoinPairConfigs); + + for (final pair in tradePairsToRemove) { + tradePairs.removeWhere((e) => e.name == pair.name); + } + final newSettings = settings.copyWith(tradeCoinPairConfigs: tradePairs); + await _settingsRepository.updateSettings( + allSettings.copyWith(marketMakerBotSettings: newSettings), + ); + } +} diff --git a/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_state.dart b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_state.dart new file mode 100644 index 0000000000..2212864d6b --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_state.dart @@ -0,0 +1,56 @@ +part of 'market_maker_bot_bloc.dart'; + +/// Represents the state of the market maker bot. +final class MarketMakerBotState extends Equatable { + /// Whether the bot is starting, stopping, running or stopped. + final MarketMakerBotStatus status; + + /// The error message if the bot failed to start or stop. + /// TODO: change to enum error type. + final String? errorMessage; + + const MarketMakerBotState({required this.status, this.errorMessage}); + + /// The initial state of the bot. Defaults [status] to stopped + /// and [errorMessage] to null. + const MarketMakerBotState.initial() + : this(status: MarketMakerBotStatus.stopped); + + /// The bot is starting. Defaults [status] to starting + /// and [errorMessage] to null. + const MarketMakerBotState.starting() + : this(status: MarketMakerBotStatus.starting); + + /// The bot is stopping. Defaults [status] to stopping + /// and [errorMessage] to null. + const MarketMakerBotState.stopping() + : this(status: MarketMakerBotStatus.stopping); + + /// The bot is running. Defaults [status] to running + /// and [errorMessage] to null. + const MarketMakerBotState.running() + : this(status: MarketMakerBotStatus.running); + + /// The bot is stopped. Defaults [status] to stopped + /// and [errorMessage] to null. + const MarketMakerBotState.stopped() + : this(status: MarketMakerBotStatus.stopped); + + bool get isRunning => status == MarketMakerBotStatus.running; + bool get isUpdating => + status == MarketMakerBotStatus.starting || + status == MarketMakerBotStatus.stopping; + + MarketMakerBotState copyWith({ + MarketMakerBotStatus? status, + String? error, + }) { + return MarketMakerBotState( + status: status ?? this.status, + errorMessage: error, + ); + } + + @override + List get props => [status, errorMessage]; +} diff --git a/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_status.dart b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_status.dart new file mode 100644 index 0000000000..566b1922ea --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_bot/market_maker_bot_status.dart @@ -0,0 +1,14 @@ +/// Represents the status of the market maker bot. +enum MarketMakerBotStatus { + /// The bot is starting: loading configuration and creating orders. + starting, + + /// The bot is stopping: cancelling orders created by the bot. + stopping, + + /// The bot is running: orders are created and being monitored and updated. + running, + + /// The bot is stopped: no orders are created or monitored. + stopped, +} 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 new file mode 100644 index 0000000000..45e7fee7ca --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_bot_order_list_repository.dart @@ -0,0 +1,61 @@ +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/trade_pair.dart'; +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/model/my_orders/my_order.dart'; +import 'package:web_dex/services/orders_service/my_orders_service.dart'; + +class MarketMakerBotOrderListRepository { + final MyOrdersService _ordersService; + final SettingsRepository _settingsRepository; + + const MarketMakerBotOrderListRepository( + this._ordersService, + this._settingsRepository, + ); + + Future cancelOrders(List tradePairs) async { + final orders = await _ordersService.getOrders(); + final ordersToCancel = orders + ?.where( + (order) => + tradePairs.any( + (tradePair) => + order.base == tradePair.baseCoinId && + order.rel == tradePair.relCoinId, + ) && + order.orderType == TradeSide.maker, + ) + .toList(); + + if (ordersToCancel?.isEmpty == true) { + return; + } + + for (final order in ordersToCancel!) { + await _ordersService.cancelOrder(order.uuid); + } + } + + 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 tradePairs = configs + .map( + (e) => TradePair( + e, + makerOrders + ?.where( + (order) => + order.base == e.baseCoinId && order.rel == e.relCoinId, + ) + .firstOrNull, + ), + ) + .toList(); + + return tradePairs; + } +} diff --git a/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_bloc.dart b/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_bloc.dart new file mode 100644 index 0000000000..81a8e7014b --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_bloc.dart @@ -0,0 +1,177 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/market_maker_bot_order_list_repository.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/trade_pair.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_order_list_header.dart'; + +part 'market_maker_order_list_event.dart'; +part 'market_maker_order_list_state.dart'; + +class MarketMakerOrderListBloc + extends Bloc { + MarketMakerOrderListBloc( + MarketMakerBotOrderListRepository orderListRepository, + ) : _orderListRepository = orderListRepository, + super(MarketMakerOrderListState.initial()) { + on(_onOrderListRequested); + on(_onOrderListSortChanged); + on(_onOrderListFilterChanged); + } + + final MarketMakerBotOrderListRepository _orderListRepository; + + void _onOrderListRequested( + MarketMakerOrderListRequested event, + Emitter emit, + ) async { + emit(state.copyWith(status: MarketMakerOrderListStatus.loading)); + + try { + List orders = await _orderListRepository.getTradePairs(); + _sortOrders(orders, state.sortData); + if (state.filterData != null) { + orders = _applyFilters(state.filterData!, orders); + } + emit( + state.copyWith( + makerBotOrders: orders, + status: MarketMakerOrderListStatus.success, + ), + ); + + return emit.forEach( + Stream.periodic(event.updateInterval) + .asyncMap((_) => _orderListRepository.getTradePairs()), + onData: (orders) { + _sortOrders(orders, state.sortData); + if (state.filterData != null) { + orders = _applyFilters(state.filterData!, orders); + } + return state.copyWith( + makerBotOrders: orders, + status: MarketMakerOrderListStatus.success, + ); + }, + ); + } catch (e, s) { + log( + 'Failed to load market maker orders: $e', + trace: s, + isError: true, + path: 'MarketMakerOrderListBloc', + ); + emit(state.copyWith(status: MarketMakerOrderListStatus.failure)); + } + } + + void _onOrderListSortChanged( + MarketMakerOrderListSortChanged event, + Emitter emit, + ) { + List sortedOrders = state.makerBotOrders; + final sortData = event.sortData; + + _sortOrders(sortedOrders, sortData); + if (state.filterData != null) { + sortedOrders = _applyFilters(state.filterData!, sortedOrders); + } + + emit(state.copyWith(makerBotOrders: sortedOrders, sortData: sortData)); + } + + void _onOrderListFilterChanged( + MarketMakerOrderListFilterChanged event, + Emitter emit, + ) { + List filteredOrders = state.makerBotOrders; + final filterData = event.filterData; + + _sortOrders(filteredOrders, state.sortData); + if (filterData != null) { + filteredOrders = _applyFilters(filterData, filteredOrders); + } + + emit( + state.copyWith( + makerBotOrders: filteredOrders, + filterData: filterData, + ), + ); + } + + void _sortOrders( + List sortedOrders, + SortData sortData, + ) { + // Retrieve the sorting function based on the sort type. + var sortingFunction = sortFunctions[sortData.sortType]; + if (sortingFunction != null) { + sortedOrders.sort((a, b) { + // Apply the sorting function. + var result = sortingFunction(a, b); + // Reverse the result if sortDirection is descending. + return sortData.sortDirection == SortDirection.decrease + ? -result + : result; + }); + } + } +} + +// Define a map that associates each sort type with a sorting function. +final sortFunctions = + { + MarketMakerBotOrderListType.date: (a, b) => + a.order?.createdAt.compareTo(b.order?.createdAt ?? 0) ?? 0, + MarketMakerBotOrderListType.margin: (a, b) => + double.tryParse(a.config.spread) + ?.compareTo(double.tryParse(b.config.spread) ?? 0) ?? + 0, + MarketMakerBotOrderListType.receive: (a, b) => + a.config.relCoinId.compareTo(b.config.relCoinId), + MarketMakerBotOrderListType.send: (a, b) => + a.config.baseCoinId.compareTo(b.config.baseCoinId), + MarketMakerBotOrderListType.updateInterval: (a, b) => + a.config.priceElapsedValidity + ?.compareTo(b.config.priceElapsedValidity ?? 0) ?? + 0, + MarketMakerBotOrderListType.price: (a, b) => + (a.order?.price ?? 0).compareTo(b.order?.price ?? 0), +}; + +List _applyFilters( + TradingEntitiesFilter filters, + List orders, +) { + return orders.where((order) { + final String? sellCoin = filters.sellCoin; + final String? buyCoin = filters.buyCoin; + final int? startDate = filters.startDate?.millisecondsSinceEpoch; + final int? endDate = filters.endDate?.millisecondsSinceEpoch; + final List? shownSides = filters.shownSides; + + if (sellCoin != null && order.config.baseCoinId != sellCoin) return false; + if (buyCoin != null && order.config.relCoinId != buyCoin) return false; + + if (order.order != null) { + if (startDate != null && order.order!.createdAt < startDate / 1000) { + return false; + } + if (endDate != null && + order.order!.createdAt > (endDate + millisecondsIn24H) / 1000) { + return false; + } + if ((shownSides != null && shownSides.isNotEmpty) && + !shownSides.contains(order.order!.orderType)) return false; + } + + return true; + }).toList(); +} diff --git a/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_event.dart b/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_event.dart new file mode 100644 index 0000000000..5ba7defbeb --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_event.dart @@ -0,0 +1,35 @@ +part of 'market_maker_order_list_bloc.dart'; + +sealed class MarketMakerOrderListEvent extends Equatable { + const MarketMakerOrderListEvent(); + + @override + List get props => []; +} + +class MarketMakerOrderListRequested extends MarketMakerOrderListEvent { + const MarketMakerOrderListRequested(this.updateInterval); + + final Duration updateInterval; + + @override + List get props => [updateInterval]; +} + +class MarketMakerOrderListSortChanged extends MarketMakerOrderListEvent { + const MarketMakerOrderListSortChanged(this.sortData); + + final SortData sortData; + + @override + List get props => [sortData]; +} + +class MarketMakerOrderListFilterChanged extends MarketMakerOrderListEvent { + const MarketMakerOrderListFilterChanged(this.filterData); + + final TradingEntitiesFilter? filterData; + + @override + List get props => [filterData]; +} diff --git a/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_state.dart b/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_state.dart new file mode 100644 index 0000000000..10f9895d3f --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_state.dart @@ -0,0 +1,55 @@ +part of 'market_maker_order_list_bloc.dart'; + +enum MarketMakerOrderListStatus { initial, loading, success, failure } + +class MarketMakerOrderListState extends Equatable { + /// List of maker orders managed by the market maker bot. + /// The list is sorted by the selected sort type. + final List makerBotOrders; + + /// Status of the market maker order list. + final MarketMakerOrderListStatus status; + + /// Sorting data for the market maker order list. + final SortData sortData; + + /// Filter data for the market maker order list. + final TradingEntitiesFilter? filterData; + + const MarketMakerOrderListState({ + this.makerBotOrders = const [], + required this.status, + required this.sortData, + this.filterData, + }); + + MarketMakerOrderListState.initial() + : this( + status: MarketMakerOrderListStatus.initial, + sortData: initialSortState(), + ); + + MarketMakerOrderListState copyWith({ + List? makerBotOrders, + MarketMakerOrderListStatus? status, + SortData? sortData, + TradingEntitiesFilter? filterData, + }) { + return MarketMakerOrderListState( + makerBotOrders: makerBotOrders ?? this.makerBotOrders, + status: status ?? this.status, + sortData: sortData ?? this.sortData, + filterData: filterData ?? this.filterData, + ); + } + + static SortData initialSortState() { + return const SortData( + sortDirection: SortDirection.increase, + sortType: MarketMakerBotOrderListType.send, + ); + } + + @override + List get props => [makerBotOrders, status, sortData, filterData]; +} diff --git a/lib/bloc/market_maker_bot/market_maker_order_list/trade_pair.dart b/lib/bloc/market_maker_bot/market_maker_order_list/trade_pair.dart new file mode 100644 index 0000000000..6584509c36 --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_order_list/trade_pair.dart @@ -0,0 +1,31 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; + +class TradePair { + TradePair(this.config, this.order); + + final TradeCoinPairConfig config; + final MyOrder? order; + + MyOrder get orderPreview => MyOrder( + base: config.baseCoinId, + rel: config.relCoinId, + baseAmount: Rational.zero, + relAmount: Rational.zero, + cancelable: false, + createdAt: DateTime.now().millisecondsSinceEpoch, + uuid: '', + orderType: TradeSide.maker, + ); + + TradePair copyWith({ + TradeCoinPairConfig? config, + MyOrder? order, + }) { + return TradePair( + config ?? this.config, + order ?? this.order, + ); + } +} 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 new file mode 100644 index 0000000000..a400cc54ca --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart @@ -0,0 +1,519 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:formz/formz.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/bloc/dex_repository.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/trade_pair.dart'; +import 'package:web_dex/blocs/coins_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.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/market_maker_bot/trade_volume.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_errors.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/forms/coin_select_input.dart'; +import 'package:web_dex/model/forms/coin_trade_amount_input.dart'; +import 'package:web_dex/model/forms/trade_margin_input.dart'; +import 'package:web_dex/model/forms/trade_volume_input.dart'; +import 'package:web_dex/model/forms/update_interval_input.dart'; +import 'package:web_dex/model/orderbook/order.dart'; +import 'package:web_dex/model/trade_preimage.dart'; + +part 'market_maker_trade_form_event.dart'; +part 'market_maker_trade_form_state.dart'; + +class MarketMakerTradeFormBloc + extends Bloc { + /// The market maker trade form bloc is used to manage the state of the trade + /// form. The trade form is used to create a trade pair for the market maker + /// bot. + /// + /// The [DexRepository] is used to get the trade preimage, which is used + /// to pre-emptively check if a trade will be successful. + /// + /// The [CoinsBloc] is used to activate coins that are not active when + /// they are selected in the trade form. + MarketMakerTradeFormBloc({ + required DexRepository dexRepo, + required CoinsBloc coinsRepo, + }) : _dexRepository = dexRepo, + _coinsRepo = coinsRepo, + super(MarketMakerTradeFormState.initial()) { + on(_onSellCoinChanged); + on(_onBuyCoinChanged); + on(_onTradeVolumeChanged); + on(_onSwapCoinsRequested); + on(_onTradeMarginChanged); + on(_onUpdateIntervalChanged); + on(_onClearForm); + on(_onEditOrder); + on(_onOrderbookSelected); + on(_onPreviewConfirmation); + on( + _onPreviewConfirmationCancelled, + ); + } + + /// The dex repository is used to get the trade preimage, which is used + /// to pre-emptively check if a trade will be successful + final DexRepository _dexRepository; + + /// The coins repository is used to activate coins that are not active + /// when they are selected in the trade form + final CoinsBloc _coinsRepo; + + Future _onSellCoinChanged( + MarketMakerTradeFormSellCoinChanged event, + Emitter emit, + ) async { + final identicalBuyAndSellCoins = state.buyCoin.value == event.sellCoin; + final sellCoinBalance = event.sellCoin?.balance ?? 0; + final newSellAmount = CoinTradeAmountInput.dirty( + (state.maximumTradeVolume.value * sellCoinBalance).toString(), + ); + + emit( + state.copyWith( + sellCoin: CoinSelectInput.dirty(event.sellCoin), + sellAmount: newSellAmount, + buyCoin: identicalBuyAndSellCoins + ? const CoinSelectInput.dirty(null, -1) + : state.buyCoin, + 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()), + ), + ); + } + + await _autoActivateCoin(event.sellCoin); + + 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)); + } + } + } + + Future _onBuyCoinChanged( + MarketMakerTradeFormBuyCoinChanged event, + Emitter emit, + ) async { + // Update the buy and sell coins first before calculating the buy amount + // since the priceFromUsdWithMargin is dependent on the buy coin. + // An alternative approach would be to calculate the new price with margin + // 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( + state.copyWith( + buyCoin: CoinSelectInput.dirty(event.buyCoin, -1), + sellCoin: areBuyAndSellCoinsIdentical + ? const CoinSelectInput.dirty(null, -1) + : state.sellCoin, + status: MarketMakerTradeFormStatus.success, + ), + ); + + 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( + state.sellAmount.value, + state.priceFromUsdWithMargin, + ); + + emit( + state.copyWith( + buyAmount: newBuyAmount > 0 + ? CoinTradeAmountInput.dirty(newBuyAmount.toString()) + : const CoinTradeAmountInput.dirty(), + status: MarketMakerTradeFormStatus.success, + ), + ); + + final preImage = await _getPreimageData(state); + final preImageError = await _getPreImageError(preImage.error, state); + if (preImageError != MarketMakerTradeFormError.none) { + emit(state.copyWith(preImageError: preImageError)); + } + } + + Future _onTradeVolumeChanged( + MarketMakerTradeFormTradeVolumeChanged event, + Emitter emit, + ) async { + final sellCoinBalance = state.sellCoin.value?.balance ?? 0; + final newSellAmount = CoinTradeAmountInput.dirty( + (event.maximumTradeVolume * sellCoinBalance).toString(), + 0, + state.sellCoin.value!.balance, + ); + final newBuyAmount = _getBuyAmountFromSellAmount( + newSellAmount.value, + state.priceFromUsdWithMargin, + ); + emit( + state.copyWith( + sellAmount: newSellAmount, + buyAmount: CoinTradeAmountInput.dirty(newBuyAmount.toString()), + minimumTradeVolume: TradeVolumeInput.dirty(event.minimumTradeVolume), + maximumTradeVolume: TradeVolumeInput.dirty(event.maximumTradeVolume), + ), + ); + + final preImage = await _getPreimageData(state); + final preImageError = await _getPreImageError(preImage.error, state); + final newSellAmountFromPreImage = await _getMaxSellAmountFromPreImage( + preImage.error, + newSellAmount, + state.sellCoin, + ); + if (preImageError != MarketMakerTradeFormError.none) { + emit( + state.copyWith( + preImageError: preImageError, + sellAmount: + CoinTradeAmountInput.dirty(newSellAmountFromPreImage.toString()), + ), + ); + } + } + + Future _onSwapCoinsRequested( + MarketMakerTradeFormSwapCoinsRequested event, + Emitter emit, + ) async { + final newSellAmount = + state.maximumTradeVolume.value * (state.buyCoin.value?.balance ?? 0); + emit( + state.copyWith( + sellCoin: CoinSelectInput.dirty(state.buyCoin.value), + sellAmount: CoinTradeAmountInput.dirty(newSellAmount.toString()), + buyCoin: CoinSelectInput.dirty(state.sellCoin.value, -1, -1), + buyAmount: const CoinTradeAmountInput.dirty('0', -1), + ), + ); + + if (state.buyCoin.value != null) { + final newBuyAmount = _getBuyAmountFromSellAmount( + newSellAmount.toString(), + state.priceFromUsdWithMargin, + ); + emit( + state.copyWith( + buyAmount: CoinTradeAmountInput.dirty(newBuyAmount.toString()), + ), + ); + } + } + + Future _onTradeMarginChanged( + MarketMakerTradeFormTradeMarginChanged event, + Emitter emit, + ) async { + emit( + state.copyWith( + tradeMargin: TradeMarginInput.dirty(event.tradeMargin), + ), + ); + + if (state.buyCoin.value != null) { + final newBuyAmount = _getBuyAmountFromSellAmount( + state.sellAmount.value, + state.priceFromUsdWithMargin, + ); + emit( + state.copyWith( + buyAmount: CoinTradeAmountInput.dirty(newBuyAmount.toString()), + ), + ); + } + } + + Future _onUpdateIntervalChanged( + MarketMakerTradeFormUpdateIntervalChanged event, + Emitter emit, + ) async { + emit( + state.copyWith( + updateInterval: UpdateIntervalInput.dirty(event.updateInterval), + ), + ); + } + + Future _onClearForm( + MarketMakerTradeFormClearRequested event, + Emitter emit, + ) async { + emit(MarketMakerTradeFormState.initial()); + } + + Future _onEditOrder( + MarketMakerTradeFormEditOrderRequested event, + Emitter emit, + ) async { + final sellCoin = CoinSelectInput.dirty( + _coinsRepo.getCoin(event.tradePair.config.baseCoinId), + ); + final buyCoin = CoinSelectInput.dirty( + _coinsRepo.getCoin(event.tradePair.config.relCoinId), + ); + final maxTradeVolume = event.tradePair.config.maxVolume?.value ?? 0.9; + final minTradeVolume = event.tradePair.config.minVolume?.value ?? 0.01; + final coinBalance = sellCoin.value?.balance ?? 0; + final sellAmountFromVolume = maxTradeVolume * coinBalance; + final sellAmount = CoinTradeAmountInput.dirty( + sellAmountFromVolume.toString(), + 0, + sellCoin.value?.balance ?? 0, + ); + final tradeMargin = TradeMarginInput.dirty( + event.tradePair.config.margin.toStringAsFixed(2), + ); + final updateInterval = UpdateIntervalInput.dirty( + event.tradePair.config.updateInterval.seconds.toString(), + ); + + emit( + MarketMakerTradeFormState.initial().copyWith( + sellCoin: sellCoin, + sellAmount: sellAmount, + minimumTradeVolume: TradeVolumeInput.dirty(minTradeVolume), + maximumTradeVolume: TradeVolumeInput.dirty(maxTradeVolume), + buyCoin: buyCoin, + buyAmount: const CoinTradeAmountInput.dirty('0'), + tradeMargin: tradeMargin, + updateInterval: updateInterval, + ), + ); + + final newBuyAmount = _getBuyAmountFromSellAmount( + sellAmount.value, + state.priceFromUsdWithMargin, + ); + emit( + state.copyWith( + buyAmount: CoinTradeAmountInput.dirty(newBuyAmount.toString()), + ), + ); + } + + Future _onOrderbookSelected( + MarketMakerTradeFormAskOrderbookSelected event, + Emitter emit, + ) async { + final askPrice = event.order.price.toDouble(); + final coinPrice = state.priceFromUsd ?? state.priceFromAmount; + final numerator = (askPrice - coinPrice) * 100; + final denomiator = (askPrice + coinPrice) / 2; + final margin = numerator / denomiator; + + emit( + state.copyWith( + tradeMargin: TradeMarginInput.dirty(margin.toStringAsFixed(2)), + ), + ); + } + + Future _onPreviewConfirmation( + MarketMakerConfirmationPreviewRequested event, + Emitter emit, + ) async { + emit( + state.copyWith( + stage: MarketMakerTradeFormStage.confirmationRequired, + status: MarketMakerTradeFormStatus.loading, + ), + ); + + if (state.sellCoin.value == null || state.buyCoin.value == null) { + emit( + state.copyWith( + stage: MarketMakerTradeFormStage.initial, + status: MarketMakerTradeFormStatus.error, + preImageError: MarketMakerTradeFormError.insufficientBalanceBase, + ), + ); + return; + } + + final preImage = await _getPreimageData(state); + final preImageError = await _getPreImageError(preImage.error, state); + if (preImageError == MarketMakerTradeFormError.none) { + return emit( + state.copyWith( + tradePreImage: preImage.data, + status: MarketMakerTradeFormStatus.success, + ), + ); + } + + double newSellAmount = state.sellAmount.valueAsRational.toDouble(); + final bool isInsufficientBaseBalance = + preImageError == MarketMakerTradeFormError.insufficientBalanceBase; + if (isInsufficientBaseBalance) { + newSellAmount = await _getMaxSellAmountFromPreImage( + preImage.error, + state.sellAmount, + state.sellCoin, + ); + } + + emit( + state.copyWith( + tradePreImage: preImage.data, + preImageError: isInsufficientBaseBalance ? null : preImageError, + sellAmount: isInsufficientBaseBalance + ? CoinTradeAmountInput.dirty(newSellAmount.toString()) + : state.sellAmount, + status: isInsufficientBaseBalance + ? MarketMakerTradeFormStatus.success + : MarketMakerTradeFormStatus.error, + ), + ); + return; + } + + Future _onPreviewConfirmationCancelled( + MarketMakerConfirmationPreviewCancelRequested event, + Emitter emit, + ) async { + emit( + state.copyWith( + stage: MarketMakerTradeFormStage.initial, + status: MarketMakerTradeFormStatus.success, + ), + ); + } + + double _getBuyAmountFromSellAmount( + String sellAmount, + double? priceFromUsdWithMargin, + ) { + final double sellAmountValue = double.tryParse(sellAmount) ?? 0; + + if (priceFromUsdWithMargin != null) { + final currentPrice = priceFromUsdWithMargin; + final double newBuyAmount = sellAmountValue * currentPrice; + return newBuyAmount; + } + + return 0; + } + + /// Check for preimage errors, return the matching error state and include the + /// new sell amount if the error is due to insufficient balance. + Future _getMaxSellAmountFromPreImage( + BaseError? preImageError, + CoinTradeAmountInput sellAmount, + CoinSelectInput sellCoin, + ) async { + if (preImageError is TradePreimageNotSufficientBalanceError) { + final sellAmountValue = double.tryParse(sellAmount.value) ?? 0; + if (sellCoin.value?.abbr != preImageError.coin) { + return sellAmountValue; + } + + final requiredAmount = double.tryParse(preImageError.required) ?? 0; + final sellCoinBalance = sellCoin.value?.balance ?? 0; + final newSellAmount = + sellAmountValue - (requiredAmount - sellCoinBalance); + return newSellAmount; + } + + return sellAmount.valueAsRational.toDouble(); + } + + /// Check for preimage errors, return the matching error state and include the + /// new sell amount if the error is due to insufficient balance. + Future _getPreImageError( + BaseError? preImageError, + MarketMakerTradeFormState formStateSnapshot, + ) async { + if (preImageError is TradePreimageNotSufficientBalanceError) { + if (formStateSnapshot.sellCoin.value?.abbr != preImageError.coin) { + return MarketMakerTradeFormError.insufficientBalanceRel; + } + + return MarketMakerTradeFormError.insufficientBalanceBase; + } else if (preImageError + is TradePreimageNotSufficientBaseCoinBalanceError) { + // if Rel coin has a parent, e.g. 1INCH-AVX-20, then the error is + // due to insufficient balance of the parent coin + return MarketMakerTradeFormError.insufficientBalanceRelParent; + } else if (preImageError is TradePreimageTransportError) { + return MarketMakerTradeFormError.insufficientTradeAmount; + } else { + return MarketMakerTradeFormError.none; + } + } + + Future> _getPreimageData( + MarketMakerTradeFormState state, + ) async { + try { + final base = state.sellCoin.value?.abbr; + final rel = state.buyCoin.value?.abbr; + final coinPrice = state.priceFromUsd ?? state.priceFromAmount; + final price = Rational.parse(coinPrice.toString()); + if (state.sellAmount.value.isEmpty) { + throw ArgumentError('Sell amount must be set'); + } + final Rational volume = Rational.parse(state.sellAmount.value); + + if (base == null || rel == null) { + throw ArgumentError('Base and rel coins must be set'); + } + + final preimageData = await _dexRepository.getTradePreimage( + base, + rel, + price, + 'setprice', + volume, + ); + + return preimageData; + } catch (e) { + return DataFromService( + error: TradePreimagePriceTooLowError( + price: '0', + threshold: '0', + error: e.toString(), + ), + ); + } + } + + /// Activate the coin if it is not active. If the coin is a child coin, + /// activate the parent coin as well. + /// Throws an error if the coin cannot be activated. + Future _autoActivateCoin(Coin? coin) async { + if (coin == null) { + return; + } + + if (!coin.isActive) { + await _coinsRepo.activateCoins([coin]); + } else { + final Coin? parentCoin = coin.parentCoin; + if (parentCoin != null && !parentCoin.isActive) { + await _coinsRepo.activateCoins([parentCoin]); + } + } + } +} diff --git a/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_event.dart b/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_event.dart new file mode 100644 index 0000000000..4782242824 --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_event.dart @@ -0,0 +1,95 @@ +part of 'market_maker_trade_form_bloc.dart'; + +sealed class MarketMakerTradeFormEvent extends Equatable { + const MarketMakerTradeFormEvent(); + + @override + List get props => []; +} + +class MarketMakerTradeFormSellCoinChanged extends MarketMakerTradeFormEvent { + const MarketMakerTradeFormSellCoinChanged(this.sellCoin); + + final Coin? sellCoin; + + @override + List get props => [sellCoin]; +} + +class MarketMakerTradeFormBuyCoinChanged extends MarketMakerTradeFormEvent { + const MarketMakerTradeFormBuyCoinChanged(this.buyCoin); + + final Coin? buyCoin; + + @override + List get props => [buyCoin]; +} + +class MarketMakerTradeFormTradeVolumeChanged extends MarketMakerTradeFormEvent { + const MarketMakerTradeFormTradeVolumeChanged({ + required this.minimumTradeVolume, + required this.maximumTradeVolume, + }); + + final double minimumTradeVolume; + final double maximumTradeVolume; + + @override + List get props => [minimumTradeVolume, maximumTradeVolume]; +} + +class MarketMakerTradeFormTradeMarginChanged extends MarketMakerTradeFormEvent { + const MarketMakerTradeFormTradeMarginChanged(this.tradeMargin); + + final String tradeMargin; + + @override + List get props => [tradeMargin]; +} + +class MarketMakerTradeFormUpdateIntervalChanged + extends MarketMakerTradeFormEvent { + const MarketMakerTradeFormUpdateIntervalChanged(this.updateInterval); + + final String updateInterval; + + @override + List get props => [updateInterval]; +} + +class MarketMakerTradeFormClearRequested extends MarketMakerTradeFormEvent { + const MarketMakerTradeFormClearRequested(); +} + +class MarketMakerTradeFormSwapCoinsRequested extends MarketMakerTradeFormEvent { + const MarketMakerTradeFormSwapCoinsRequested(); +} + +class MarketMakerTradeFormEditOrderRequested extends MarketMakerTradeFormEvent { + const MarketMakerTradeFormEditOrderRequested(this.tradePair); + + final TradePair tradePair; + + @override + List get props => [tradePair]; +} + +class MarketMakerTradeFormAskOrderbookSelected + extends MarketMakerTradeFormEvent { + const MarketMakerTradeFormAskOrderbookSelected(this.order); + + final Order order; + + @override + List get props => [order]; +} + +class MarketMakerConfirmationPreviewRequested + extends MarketMakerTradeFormEvent { + const MarketMakerConfirmationPreviewRequested(); +} + +class MarketMakerConfirmationPreviewCancelRequested + extends MarketMakerTradeFormEvent { + const MarketMakerConfirmationPreviewCancelRequested(); +} 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 new file mode 100644 index 0000000000..473bbcf270 --- /dev/null +++ b/lib/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_state.dart @@ -0,0 +1,234 @@ +part of 'market_maker_trade_form_bloc.dart'; + +enum MarketMakerTradeFormError { + insufficientBalanceBase, + insufficientBalanceRel, + insufficientBalanceRelParent, + insufficientTradeAmount, + none, +} + +enum MarketMakerTradeFormStatus { initial, loading, success, error } + +// Usually this would be a dedicated tab contoller/ui flow bloc, but because +// there is only two stages (initial and confirmationRequired), and for the +// sake of simplicity, we are using the form state to manage the form stages. +enum MarketMakerTradeFormStage { + initial, + confirmationRequired, +} + +/// The state of the market maker trade form. The state is a formz mixin +/// which allows the form to be validated and checked for errors. +class MarketMakerTradeFormState extends Equatable with FormzMixin { + const MarketMakerTradeFormState({ + required this.sellCoin, + required this.buyCoin, + required this.minimumTradeVolume, + required this.maximumTradeVolume, + required this.sellAmount, + required this.buyAmount, + required this.tradeMargin, + required this.updateInterval, + required this.status, + required this.stage, + this.tradePreImageError, + this.tradePreImage, + }); + + MarketMakerTradeFormState.initial() + : sellCoin = const CoinSelectInput.pure(), + buyCoin = const CoinSelectInput.pure(), + minimumTradeVolume = const TradeVolumeInput.pure(0.01), + maximumTradeVolume = const TradeVolumeInput.pure(0.9), + sellAmount = const CoinTradeAmountInput.pure(), + buyAmount = const CoinTradeAmountInput.pure(), + tradeMargin = const TradeMarginInput.pure(), + updateInterval = const UpdateIntervalInput.pure(), + status = MarketMakerTradeFormStatus.initial, + stage = MarketMakerTradeFormStage.initial, + tradePreImageError = null, + tradePreImage = null; + + /// The coin being sold in the trade pair (base coin). + final CoinSelectInput sellCoin; + + /// The coin being bought in the trade pair (rel coin). + final CoinSelectInput buyCoin; + + /// The minimum volume to use per trade. E.g. The minimum trade volume in USD. + final TradeVolumeInput minimumTradeVolume; + + /// The maximum volume to use per trade. + /// E.g. The maximum trade volume in percentage. + final TradeVolumeInput maximumTradeVolume; + + /// The amount of the base coin being sold. + final CoinTradeAmountInput sellAmount; + + /// The amount of the rel coin being bought. + final CoinTradeAmountInput buyAmount; + + /// The trade margin percentage over the usd market price (cex rate). + final TradeMarginInput tradeMargin; + + /// The interval at which the market maker bot should update the trade pair. + /// The interval is in seconds. + final UpdateIntervalInput updateInterval; + + /// Whether the form is in the initial, in progress, success or error state. + final MarketMakerTradeFormStatus status; + + /// The error state of the form. + final MarketMakerTradeFormError? tradePreImageError; + + /// The current stage of the form (confirmation or initial). + final MarketMakerTradeFormStage stage; + + /// The preimage of the trade pair, used to calculate the trade pair fees. + final TradePreimage? tradePreImage; + + /// The price of the trade pair derived from the USD price of the coins. + /// Price = baseCoinUsdPrice / relCoinUsdPrice. + double? get priceFromUsd { + final baseUsdPrice = sellCoin.value?.usdPrice?.price; + final relUsdPrice = buyCoin.value?.usdPrice?.price; + final price = relUsdPrice != null && baseUsdPrice != null + ? baseUsdPrice / relUsdPrice + : null; + + return price; + } + + /// The price of the trade pair derived from the USD price of the coins + /// with the trade margin applied. The trade margin is a percentage over + /// the usd market price (cex rate). + double? get priceFromUsdWithMargin { + final price = priceFromUsd; + final spreadPercentage = double.tryParse(tradeMargin.value) ?? 0; + if (price != null) { + return price * (1 + (spreadPercentage / 100)); + } + return price; + } + + /// The price of the trade pair derived from the USD price of the coins + /// with the trade margin applied. The trade margin is a percentage over + /// the usd market price (cex rate). + Rational? get priceFromUsdWithMarginRational { + final price = priceFromUsdWithMargin; + return price != null ? Rational.parse(price.toString()) : null; + } + + /// The price of the trade pair derived from the amount of the coins. + /// Price = buyAmount / sellAmount. + double get priceFromAmount { + final sellAmount = double.tryParse(this.sellAmount.value) ?? 0; + final buyAmount = double.tryParse(this.buyAmount.value) ?? 0; + return sellAmount != 0 ? buyAmount / sellAmount : 0; + } + + /// The margin percentage derived from the amount of the coins. + /// Margin = (priceFromAmount / priceFromUsd - 1) * 100. + double get marginFromAmounts { + double newMargin = tradeMargin.valueAsDouble; + if (sellAmount.value.isEmpty) { + return newMargin; + } + + final currentPrice = priceFromUsd; + if (currentPrice == null || currentPrice == 0) { + return newMargin; + } + + final amountPrice = priceFromAmount; + if (currentPrice == amountPrice) { + return newMargin; + } + + newMargin = (amountPrice / currentPrice - 1) * 100; + return newMargin; + } + + MarketMakerTradeFormState copyWith({ + CoinSelectInput? sellCoin, + CoinSelectInput? buyCoin, + TradeVolumeInput? minimumTradeVolume, + TradeVolumeInput? maximumTradeVolume, + CoinTradeAmountInput? sellAmount, + CoinTradeAmountInput? buyAmount, + TradeMarginInput? tradeMargin, + UpdateIntervalInput? updateInterval, + MarketMakerTradeFormStatus? status, + MarketMakerTradeFormError? preImageError, + MarketMakerTradeFormStage? stage, + TradePreimage? tradePreImage, + }) { + return MarketMakerTradeFormState( + sellCoin: sellCoin ?? this.sellCoin, + buyCoin: buyCoin ?? this.buyCoin, + minimumTradeVolume: minimumTradeVolume ?? this.minimumTradeVolume, + maximumTradeVolume: maximumTradeVolume ?? this.maximumTradeVolume, + sellAmount: sellAmount ?? this.sellAmount, + buyAmount: buyAmount ?? this.buyAmount, + tradeMargin: tradeMargin ?? this.tradeMargin, + updateInterval: updateInterval ?? this.updateInterval, + status: status ?? this.status, + tradePreImageError: preImageError, + stage: stage ?? this.stage, + tradePreImage: tradePreImage ?? this.tradePreImage, + ); + } + + /// Converts the form state to a [TradeCoinPairConfig] object to be used + /// in the market maker bot parameters. + TradeCoinPairConfig toTradePairConfig() { + final baseCoinId = sellCoin.value?.abbr ?? ''; + final relCoinId = buyCoin.value?.abbr ?? ''; + final spreadPercentage = double.parse(tradeMargin.value); + final spread = 1 + (spreadPercentage / 100); + + return TradeCoinPairConfig( + name: TradeCoinPairConfig.getSimpleName(baseCoinId, relCoinId), + baseCoinId: baseCoinId, + relCoinId: relCoinId, + spread: spread.toString(), + priceElapsedValidity: updateInterval.interval.seconds, + maxVolume: TradeVolume.percentage(maximumTradeVolume.value), + minVolume: TradeVolume.percentage(minimumTradeVolume.value), + ); + } + + @override + List> get inputs => [ + sellCoin, + buyCoin, + minimumTradeVolume, + maximumTradeVolume, + tradeMargin, + updateInterval, + ]; + + @override + bool get isValid { + return super.isValid && + tradePreImageError == null && + status != MarketMakerTradeFormStatus.error; + } + + @override + List get props => [ + sellCoin, + buyCoin, + minimumTradeVolume, + maximumTradeVolume, + sellAmount, + buyAmount, + tradeMargin, + updateInterval, + tradePreImageError, + stage, + status, + tradePreImage, + ]; +} diff --git a/lib/bloc/nft_receive/bloc/nft_receive_bloc.dart b/lib/bloc/nft_receive/bloc/nft_receive_bloc.dart new file mode 100644 index 0000000000..eba799889f --- /dev/null +++ b/lib/bloc/nft_receive/bloc/nft_receive_bloc.dart @@ -0,0 +1,86 @@ +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:web_dex/blocs/coins_bloc.dart'; +import 'package:web_dex/blocs/current_wallet_bloc.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +part 'nft_receive_event.dart'; +part 'nft_receive_state.dart'; + +class NftReceiveBloc extends Bloc { + NftReceiveBloc({ + required CoinsBloc coinsRepo, + required CurrentWalletBloc currentWalletBloc, + }) : _coinsRepo = coinsRepo, + _currentWalletBloc = currentWalletBloc, + super(NftReceiveInitial()) { + on(_onInitial); + on(_onRefresh); + on(_onChangeAddress); + } + + final CoinsBloc _coinsRepo; + final CurrentWalletBloc _currentWalletBloc; + NftBlockchains? chain; + + Future _onInitial(NftReceiveEventInitial event, Emitter emit) async { + if (state is! NftReceiveAddress) { + chain = event.chain; + final abbr = event.chain.coinAbbr(); + var coin = _coinsRepo.getCoin(abbr); + + if (coin != null) { + final walletConfig = _currentWalletBloc.wallet?.config; + if (walletConfig?.hasBackup == false && !coin.isTestCoin) { + return emit( + NftReceiveHasBackup(), + ); + } + + if (coin.address?.isEmpty ?? true) { + final activationErrors = await activateCoinIfNeeded(coin.abbr); + if (activationErrors.isNotEmpty) { + return emit( + NftReceiveFailure( + message: activationErrors.first.error, + ), + ); + } + coin = _coinsRepo.getCoin(abbr)!; + } + + return emit( + NftReceiveAddress( + coin: coin, + address: coin.defaultAddress, + ), + ); + } + + return emit(const NftReceiveFailure()); + } + } + + Future _onRefresh(NftReceiveEventRefresh event, Emitter emit) async { + final localChain = chain; + if (localChain != null) { + emit(NftReceiveEventInitial(chain: localChain)); + add(NftReceiveEventInitial(chain: localChain)); + } else { + return emit(const NftReceiveFailure()); + } + } + + void _onChangeAddress(NftReceiveEventChangedAddress event, Emitter emit) { + final state = this.state; + if (state is NftReceiveAddress) { + return emit( + state.copyWith( + address: event.address, + ), + ); + } + } +} diff --git a/lib/bloc/nft_receive/bloc/nft_receive_event.dart b/lib/bloc/nft_receive/bloc/nft_receive_event.dart new file mode 100644 index 0000000000..a2125838a8 --- /dev/null +++ b/lib/bloc/nft_receive/bloc/nft_receive_event.dart @@ -0,0 +1,31 @@ +part of 'nft_receive_bloc.dart'; + +abstract class NftReceiveEvent extends Equatable { + const NftReceiveEvent(); + + @override + List get props => []; +} + +class NftReceiveEventInitial extends NftReceiveEvent { + final NftBlockchains chain; + const NftReceiveEventInitial({required this.chain}); + + @override + List get props => [chain]; +} + +class NftReceiveEventRefresh extends NftReceiveEvent { + const NftReceiveEventRefresh(); + + @override + List get props => []; +} + +class NftReceiveEventChangedAddress extends NftReceiveEvent { + final String? address; + const NftReceiveEventChangedAddress({required this.address}); + + @override + List get props => [address ?? '']; +} diff --git a/lib/bloc/nft_receive/bloc/nft_receive_state.dart b/lib/bloc/nft_receive/bloc/nft_receive_state.dart new file mode 100644 index 0000000000..cef166f4b4 --- /dev/null +++ b/lib/bloc/nft_receive/bloc/nft_receive_state.dart @@ -0,0 +1,41 @@ +part of 'nft_receive_bloc.dart'; + +abstract class NftReceiveState extends Equatable { + const NftReceiveState(); + + @override + List get props => []; +} + +class NftReceiveInitial extends NftReceiveState {} + +class NftReceiveHasBackup extends NftReceiveState {} + +class NftReceiveAddress extends NftReceiveState { + final Coin coin; + final String? address; + + const NftReceiveAddress({ + required this.coin, + required this.address, + }); + + NftReceiveAddress copyWith({ + Coin? coin, + String? address, + }) { + return NftReceiveAddress( + coin: coin ?? this.coin, + address: address ?? this.address, + ); + } + + @override + List get props => [address ?? '', coin]; +} + +class NftReceiveFailure extends NftReceiveState { + final String? message; + + const NftReceiveFailure({this.message}); +} diff --git a/lib/bloc/nft_transactions/bloc/nft_transactions_bloc.dart b/lib/bloc/nft_transactions/bloc/nft_transactions_bloc.dart new file mode 100644 index 0000000000..8a5ff73ac1 --- /dev/null +++ b/lib/bloc/nft_transactions/bloc/nft_transactions_bloc.dart @@ -0,0 +1,318 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/nft_transactions/bloc/nft_transactions_filters.dart'; +import 'package:web_dex/bloc/nft_transactions/nft_txn_repository.dart'; +import 'package:web_dex/bloc/nfts/nft_main_repo.dart'; +import 'package:web_dex/blocs/coins_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/shared/utils/utils.dart' as utils; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +part 'nft_transactions_event.dart'; +part 'nft_transactions_state.dart'; + +class NftTransactionsBloc extends Bloc { + NftTransactionsBloc({ + required NftTxnRepository nftTxnRepository, + required AuthRepository authRepo, + required CoinsBloc coinsBloc, + required bool isLoggedIn, + required NftsRepo nftsRepository, + }) : _nftTxnRepository = nftTxnRepository, + _authRepo = authRepo, + _coinsBloc = coinsBloc, + _nftsRepository = nftsRepository, + _isLoggedIn = isLoggedIn, + super(NftTransactionsInitial()) { + on(_onReceiveTransactions); + on(_onReceiveDetails); + on(_onSearchChanged); + on(_onStatusesChanged); + on(_onBlockchainChanged); + on(_startDateChanged); + on(_endDateChanged); + on(_cleanFilters); + on(_changeFullFilter); + on(_noLogin); + + _authorizationSubscription = _authRepo.authMode.listen((event) { + final bool prevLoginState = _isLoggedIn; + _isLoggedIn = event == AuthorizeMode.logIn; + + if (_isLoggedIn && prevLoginState) { + if (_isLoggedIn) { + return add(const NftTxnReceiveEvent()); + } else { + return add(const NftTxnEventNoLogin()); + } + } + }); + } + + final NftTxnRepository _nftTxnRepository; + final NftsRepo _nftsRepository; + final AuthRepository _authRepo; + final CoinsBloc _coinsBloc; + final List _transactions = []; + + bool _isLoggedIn = false; + late final StreamSubscription _authorizationSubscription; + PersistentBottomSheetController? _bottomSheetController; + set bottomSheetController(PersistentBottomSheetController controller) => + _bottomSheetController = controller; + + @override + Future close() async { + await _authorizationSubscription.cancel(); + + if (_bottomSheetController != null) { + _bottomSheetController?.close(); + // Wait util bottom sheet will be closed + await Future.delayed(const Duration(milliseconds: 500)); + } + + return super.close(); + } + + Future _onReceiveTransactions( + NftTxnReceiveEvent event, Emitter emitter) async { + if (!_isLoggedIn) return; + emitter(state.copyWith(status: NftTxnStatus.loading)); + try { + await _nftsRepository.updateNft(NftBlockchains.values); + final response = await _nftTxnRepository.getNftTransactions(); + final transactions = response.transactions + ..sort((a, b) => a.blockTimestamp.isAfter(b.blockTimestamp) ? -1 : 1); + _transactions.clear(); + _transactions.addAll(transactions); + for (var tx in _transactions) { + if (tx.containsAdditionalInfo) { + tx.setDetailsStatus(NftTxnDetailsStatus.success); + } + } + + return emitter( + state.copyWith( + filteredTransactions: _transactions, + status: NftTxnStatus.success, + ), + ); + } on BaseError catch (e) { + return emitter( + state.copyWith( + filteredTransactions: [], + status: NftTxnStatus.failure, + errorMessage: e.message, + ), + ); + } catch (e) { + return emitter( + state.copyWith( + filteredTransactions: [], + status: NftTxnStatus.failure, + ), + ); + } + } + + Future _onReceiveDetails( + NftTxReceiveDetailsEvent event, Emitter emitter) async { + if (!_isLoggedIn) return; + + final tx = event.tx; + if (tx.containsAdditionalInfo) return; + + final int index = _transactions + .indexWhere((element) => element.getTxKey() == event.tx.getTxKey()); + if (tx.detailsFetchStatus != NftTxnDetailsStatus.initial) return; + + try { + final response = + await _nftTxnRepository.getNftTxDetailsByHash(tx: event.tx); + _transactions[index].confirmations = response.confirmations; + _transactions[index].feeDetails = response.feeDetails; + _transactions[index].setDetailsStatus(NftTxnDetailsStatus.success); + // todo: @DmitriiP: improve because we use for loop [O(n)] each time + return emitFilteredData(emitter); + } on BaseError catch (e) { + if (_transactions[index].detailsFetchStatus == + NftTxnDetailsStatus.initial) { + final status = + await retryReceiveDetails(index, event.tx.transactionHash); + _transactions[index].setDetailsStatus(status); + emitFilteredData(emitter); + } + return emitter( + state.copyWith(errorMessage: e.message), + ); + } catch (e) { + if (_transactions[index].detailsFetchStatus == + NftTxnDetailsStatus.initial) { + final status = + await retryReceiveDetails(index, event.tx.transactionHash); + _transactions[index].setDetailsStatus(status); + } + return emitFilteredData(emitter); + } + } + + Future retryReceiveDetails( + int index, String txHash) async { + int attempt = 5; + NftTxnDetailsStatus status = NftTxnDetailsStatus.failure; + while (attempt > 0) { + try { + await Future.delayed(const Duration(seconds: 2)); + attempt--; + final response = await _nftTxnRepository.getNftTxDetailsByHash( + tx: _transactions[index]); + _transactions[index].confirmations = response.confirmations; + _transactions[index].feeDetails = response.feeDetails; + status = NftTxnDetailsStatus.success; + attempt--; + if (status == NftTxnDetailsStatus.success) break; + } catch (_) {} + } + return status; + } + + void _noLogin(NftTxnEventNoLogin event, Emitter emitter) { + return emitter( + state.copyWith( + status: NftTxnStatus.noLogin, + ), + ); + } + + void _onSearchChanged( + NftTxnEventSearchChanged event, Emitter emitter) { + emitter( + state.copyWith( + filters: state.filters.copyWith(searchLine: event.searchLine), + ), + ); + emitFilteredData(emitter); + } + + void _onStatusesChanged( + NftTxnEventStatusesChanged event, Emitter emitter) { + emitter( + state.copyWith( + filters: state.filters.copyWith(statuses: event.statuses), + ), + ); + emitFilteredData(emitter); + } + + void _onBlockchainChanged( + NftTxnEventBlockchainChanged event, Emitter emitter) { + emitter( + state.copyWith( + filters: state.filters.copyWith(blockchain: event.blockchains), + ), + ); + emitFilteredData(emitter); + } + + void _startDateChanged( + NftTxnEventStartDateChanged event, Emitter emitter) { + emitter( + state.copyWith( + filters: state.filters.copyWith(dateFrom: event.dateFrom), + ), + ); + emitFilteredData(emitter); + } + + void _endDateChanged( + NftTxnEventEndDateChanged event, Emitter emitter) { + emitter( + state.copyWith( + filters: state.filters.copyWith(dateTo: event.dateTo), + ), + ); + emitFilteredData(emitter); + } + + void _changeFullFilter( + NftTxnEventFullFilterChanged event, Emitter emitter) { + emitter(state.copyWith(filters: event.filter)); + + emitFilteredData(emitter); + } + + void emitFilteredData(Emitter emitter) { + final filtered = _applyFilter(); + return emitter( + state.copyWith(filteredTransactions: filtered), + ); + } + + void _cleanFilters(NftTxnClearFilters event, Emitter emitter) { + return emitter( + state.copyWith( + filters: const NftTransactionsFilter(), + filteredTransactions: _transactions, + ), + ); + } + + List _applyFilter() { + final filters = state.filters; + final filteredTransactions = _transactions.where((transaction) { + final includeTokenName = transaction.tokenName + ?.toLowerCase() + .contains(filters.searchLine.toLowerCase()) ?? + false; + final includeTokenCollection = transaction.collectionName + ?.toLowerCase() + .contains(filters.searchLine.toLowerCase()) ?? + false; + if (filters.searchLine.isNotEmpty && + !(includeTokenName || includeTokenCollection)) { + return false; + } + if (filters.statuses.isNotEmpty && + !filters.statuses.contains(transaction.status)) { + return false; + } + if (filters.blockchain.isNotEmpty && + !filters.blockchain.map((e) => e).contains(transaction.chain)) { + return false; + } + if (filters.dateFrom != null && + transaction.blockTimestamp.isBefore(filters.dateFrom!)) { + return false; + } + if (filters.dateTo != null && + transaction.blockTimestamp.isAfter(filters.dateTo!)) { + return false; + } + return true; + }).toList(); + return filteredTransactions; + } + + Future viewNftOnExplorer(NftTransaction transaction) async { + final abbr = transaction.chain.coinAbbr(); + + final activationErrors = await activateCoinIfNeeded(abbr); + var coin = _coinsBloc.getCoin(abbr); + if (coin != null) { + if (activationErrors.isEmpty) { + utils.viewHashOnExplorer( + coin, + transaction.transactionHash, + utils.HashExplorerType.tx, + ); + } + } + } +} diff --git a/lib/bloc/nft_transactions/bloc/nft_transactions_event.dart b/lib/bloc/nft_transactions/bloc/nft_transactions_event.dart new file mode 100644 index 0000000000..293eee0490 --- /dev/null +++ b/lib/bloc/nft_transactions/bloc/nft_transactions_event.dart @@ -0,0 +1,84 @@ +part of 'nft_transactions_bloc.dart'; + +abstract class NftTxnEvent extends Equatable { + const NftTxnEvent(); + + @override + List get props => []; +} + +class NftTxnReceiveEvent extends NftTxnEvent { + final bool withAdditionalData; + const NftTxnReceiveEvent([this.withAdditionalData = false]); +} + +class NftTxReceiveDetailsEvent extends NftTxnEvent { + const NftTxReceiveDetailsEvent(this.tx); + + final NftTransaction tx; + + @override + List get props => [tx]; +} + +class NftTxnEventNoLogin extends NftTxnEvent { + const NftTxnEventNoLogin(); +} + +class NftTxnEventSearchChanged extends NftTxnEvent { + const NftTxnEventSearchChanged(this.searchLine); + + final String searchLine; + + @override + List get props => [searchLine]; +} + +class NftTxnEventStatusesChanged extends NftTxnEvent { + const NftTxnEventStatusesChanged(this.statuses); + + final List statuses; + + @override + List get props => [statuses]; +} + +class NftTxnEventBlockchainChanged extends NftTxnEvent { + const NftTxnEventBlockchainChanged(this.blockchains); + + final List blockchains; + + @override + List get props => [blockchains]; +} + +class NftTxnEventStartDateChanged extends NftTxnEvent { + const NftTxnEventStartDateChanged(this.dateFrom); + + final DateTime? dateFrom; + + @override + List get props => [dateFrom ?? DateTime(2010)]; +} + +class NftTxnEventEndDateChanged extends NftTxnEvent { + const NftTxnEventEndDateChanged(this.dateTo); + + final DateTime? dateTo; + + @override + List get props => [dateTo ?? DateTime(2010)]; +} + +class NftTxnEventFullFilterChanged extends NftTxnEvent { + const NftTxnEventFullFilterChanged(this.filter); + + final NftTransactionsFilter filter; + + @override + List get props => [filter]; +} + +class NftTxnClearFilters extends NftTxnEvent { + const NftTxnClearFilters(); +} diff --git a/lib/bloc/nft_transactions/bloc/nft_transactions_filters.dart b/lib/bloc/nft_transactions/bloc/nft_transactions_filters.dart new file mode 100644 index 0000000000..b6ae95b5cf --- /dev/null +++ b/lib/bloc/nft_transactions/bloc/nft_transactions_filters.dart @@ -0,0 +1,70 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; +import 'package:web_dex/model/nft.dart'; + +class NftTransactionsFilter extends Equatable { + const NftTransactionsFilter({ + this.statuses = const [], + this.blockchain = const [], + this.dateFrom, + this.dateTo, + this.searchLine = '', + }); + + factory NftTransactionsFilter.from(NftTransactionsFilter? data) { + if (data == null) return const NftTransactionsFilter(); + + return NftTransactionsFilter( + statuses: data.statuses, + blockchain: data.blockchain, + dateFrom: data.dateFrom, + dateTo: data.dateTo, + searchLine: data.searchLine, + ); + } + + NftTransactionsFilter copyWith({ + List? statuses, + List? blockchain, + DateTime? dateFrom, + DateTime? dateTo, + String? searchLine, + }) { + return NftTransactionsFilter( + statuses: statuses ?? this.statuses, + blockchain: blockchain ?? this.blockchain, + dateFrom: dateFrom ?? this.dateFrom, + dateTo: dateTo ?? this.dateTo, + searchLine: searchLine ?? this.searchLine, + ); + } + + bool get isEmpty => + statuses.isEmpty && + blockchain.isEmpty && + dateFrom == null && + dateTo == null && + searchLine.isEmpty; + + int get count => + statuses.length + + blockchain.length + + (searchLine.isNotEmpty ? 1 : 0) + + (dateFrom != null ? 1 : 0) + + (dateTo != null ? 1 : 0); + + final List statuses; + final List blockchain; + final DateTime? dateFrom; + final DateTime? dateTo; + final String searchLine; + + @override + List get props => [ + statuses, + blockchain, + dateFrom, + dateTo, + searchLine, + ]; +} diff --git a/lib/bloc/nft_transactions/bloc/nft_transactions_state.dart b/lib/bloc/nft_transactions/bloc/nft_transactions_state.dart new file mode 100644 index 0000000000..3069069ee6 --- /dev/null +++ b/lib/bloc/nft_transactions/bloc/nft_transactions_state.dart @@ -0,0 +1,37 @@ +part of 'nft_transactions_bloc.dart'; + +enum NftTxnStatus { + loading, + noLogin, + success, + failure, +} + +class NftTxnState { + final List filteredTransactions; + final NftTransactionsFilter filters; + final NftTxnStatus status; + final String? errorMessage; + NftTxnState({ + this.filteredTransactions = const [], + this.filters = const NftTransactionsFilter(), + this.status = NftTxnStatus.noLogin, + this.errorMessage, + }); + + NftTxnState copyWith({ + List? filteredTransactions, + NftTransactionsFilter? filters, + NftTxnStatus? status, + String? errorMessage, + }) { + return NftTxnState( + filteredTransactions: filteredTransactions ?? this.filteredTransactions, + filters: filters ?? this.filters, + status: status ?? this.status, + errorMessage: errorMessage ?? this.errorMessage, + ); + } +} + +class NftTransactionsInitial extends NftTxnState {} diff --git a/lib/bloc/nft_transactions/nft_txn_repository.dart b/lib/bloc/nft_transactions/nft_txn_repository.dart new file mode 100644 index 0000000000..69895f1f0b --- /dev/null +++ b/lib/bloc/nft_transactions/nft_txn_repository.dart @@ -0,0 +1,102 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api_nft.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/errors.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_request.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/model/withdraw_details/fee_details.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class NftTxnRepository { + final Mm2ApiNft _api; + final CoinsRepo _coinsRepo; + final Map _abbrToUsdPrices = {}; + + NftTxnRepository({required Mm2ApiNft api, required CoinsRepo coinsRepo}) + : _api = api, + _coinsRepo = coinsRepo; + Map get abbrToUsdPrices => _abbrToUsdPrices; + + Future getNftTransactions( + [List? chains]) async { + final List allChains = + (chains ?? NftBlockchains.values).map((e) => e.toApiRequest()).toList(); + await getUsdPricesOfCoins( + (chains ?? NftBlockchains.values).map((e) => e.coinAbbr())); + final request = NftTransactionsRequest( + chains: allChains, + max: true, + ); + + try { + final json = await _api.getNftTxs(request, false); + if (json['error'] != null) { + log( + json['error'], + path: 'nft_main_repo => getNfts', + isError: true, + ); + throw ApiError(message: json['error']); + } + + if (json['result'] == null) { + throw ApiError(message: LocaleKeys.somethingWrong.tr()); + } + try { + final NftTxsResponse nftTransactionsResponse = + NftTxsResponse.fromJson(json); + + return nftTransactionsResponse; + } catch (e) { + throw ParsingApiJsonError(message: e.toString()); + } + } on TransportError catch (_) { + rethrow; + } on ApiError catch (_) { + rethrow; + } on ParsingApiJsonError catch (_) { + rethrow; + } catch (e) { + rethrow; + } + } + + Future getNftTxDetailsByHash({ + required NftTransaction tx, + }) async { + try { + final request = NftTxDetailsRequest( + chain: tx.chain.toApiRequest(), txHash: tx.transactionHash); + final json = await _api.getNftTxDetails(request); + try { + tx.confirmations = json['confirmations'] ?? 0; + tx.feeDetails = json['fee_details'] != null + ? FeeDetails.fromJson(json['fee_details']) + : FeeDetails.empty(); + tx.feeDetails?.setCoinUsdPrice(_abbrToUsdPrices[tx.chain.coinAbbr()]); + + return tx; + } catch (e) { + throw ParsingApiJsonError(message: e.toString()); + } + } on TransportError catch (_) { + rethrow; + } on ApiError catch (_) { + rethrow; + } on ParsingApiJsonError catch (_) { + rethrow; + } catch (e) { + rethrow; + } + } + + Future getUsdPricesOfCoins(Iterable coinAbbr) async { + final coins = await _coinsRepo.getKnownCoins(); + for (var abbr in coinAbbr) { + final coin = coins.firstWhere((c) => c.abbr == abbr); + _abbrToUsdPrices[abbr] = coin.usdPrice?.price; + } + } +} diff --git a/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart b/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart new file mode 100644 index 0000000000..9786eacd24 --- /dev/null +++ b/lib/bloc/nft_withdraw/nft_withdraw_bloc.dart @@ -0,0 +1,242 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_repo.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/blocs/coins_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/errors.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_response.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/model/text_error.dart'; + +part 'nft_withdraw_event.dart'; +part 'nft_withdraw_state.dart'; + +class NftWithdrawBloc extends Bloc { + NftWithdrawBloc({ + required NftWithdrawRepo repo, + required NftToken nft, + required CoinsBloc coinsBloc, + }) : _repo = repo, + _coinsBloc = coinsBloc, + super(NftWithdrawFillState.initial(nft)) { + on(_onAddressChanged); + on(_onAmountChanged); + on(_onSend); + on(_onConfirmSend); + on(_onShowFillForm); + on(_onInit); + on(_onConvertAddress); + } + + final NftWithdrawRepo _repo; + final CoinsBloc _coinsBloc; + + Future _onSend( + NftWithdrawSendEvent event, + Emitter emit, + ) async { + final state = this.state; + if (state is! NftWithdrawFillState) return; + if (state.isSending) return; + + emit(state.copyWith( + isSending: () => true, + addressError: () => null, + amountError: () => null, + sendError: () => null, + )); + final NftToken nft = state.nft; + final String address = state.address; + final int? amount = state.amount; + + await _activateParentCoinIfNeeded(nft); + + final BaseError? addressError = + await _validateAddress(nft.parentCoin, address); + final BaseError? amountError = + _validateAmount(amount, int.parse(nft.amount), nft.contractType); + if (addressError != null || amountError != null) { + emit(state.copyWith( + isSending: () => false, + addressError: () => addressError, + amountError: () => amountError, + )); + return; + } + + try { + final WithdrawNftResponse response = + await _repo.withdraw(nft: nft, address: address, amount: amount); + + final NftTransactionDetails result = response.result; + + emit(NftWithdrawConfirmState( + nft: state.nft, + isSending: false, + txDetails: result, + sendError: null, + )); + } on ApiError catch (e) { + emit(state.copyWith(sendError: () => e, isSending: () => false)); + } on TransportError catch (e) { + emit(state.copyWith(sendError: () => e, isSending: () => false)); + } on ParsingApiJsonError catch (e) { + if (kDebugMode) { + print(e.message); + } + emit(state.copyWith(isSending: () => false)); + } + } + + Future _onConfirmSend( + NftWithdrawConfirmSendEvent event, Emitter emit) async { + final state = this.state; + if (state is! NftWithdrawConfirmState) return; + + emit(state.copyWith( + isSending: () => true, + sendError: () => null, + )); + final txDetails = state.txDetails; + + final SendRawTransactionResponse response = + await _repo.confirmSend(txDetails.coin, txDetails.txHex); + final BaseError? responseError = response.error; + final String? txHash = response.txHash; + if (txHash == null) { + emit(state.copyWith( + isSending: () => false, + sendError: () => + responseError ?? TextError(error: LocaleKeys.somethingWrong), + )); + } else { + emit(NftWithdrawSuccessState( + txHash: txHash, + nft: state.nft, + timestamp: txDetails.timestamp, + to: txDetails.to.first, + )); + } + } + + void _onAddressChanged( + NftWithdrawAddressChanged event, Emitter emit) { + final state = this.state; + if (state is! NftWithdrawFillState) return; + emit(state.copyWith( + address: () => event.address, + addressError: () => null, + sendError: () => null, + )); + } + + void _onAmountChanged( + NftWithdrawAmountChanged event, + Emitter emit, + ) { + final state = this.state; + if (state is! NftWithdrawFillState) return; + + emit(state.copyWith( + amount: () => event.amount, + amountError: () => null, + sendError: () => null, + )); + } + + Future _validateAddress( + Coin coin, + String address, + ) async { + if (address.isEmpty) { + return TextError(error: LocaleKeys.invalidAddress.tr(args: [coin.abbr])); + } + try { + final validateResponse = await _repo.validateAddress(coin, address); + final isNonMixed = _isErcNonMixedCase(validateResponse.reason ?? ''); + + if (isNonMixed) { + return MixedCaseAddressError(); + } + + return validateResponse.isValid + ? null + : TextError(error: LocaleKeys.invalidAddress.tr(args: [coin.abbr])); + } on ApiError catch (e) { + return e; + } catch (e) { + return TextError(error: e.toString()); + } + } + + BaseError? _validateAmount( + int? amount, + int totalAmount, + NftContractType contractType, + ) { + if (contractType != NftContractType.erc1155) return null; + if (amount == null || amount < 1) { + return TextError(error: LocaleKeys.minCount.tr(args: ['1'])); + } + if (amount > totalAmount) { + return TextError( + error: LocaleKeys.maxCount.tr(args: [totalAmount.toString()])); + } + return null; + } + + FutureOr _onShowFillForm( + NftWithdrawShowFillStep event, Emitter emit) { + final state = this.state; + + if (state is NftWithdrawConfirmState) { + emit(NftWithdrawFillState( + address: state.txDetails.to.first, + amount: int.tryParse(state.txDetails.amount), + isSending: false, + nft: state.nft, + )); + } else { + emit(NftWithdrawFillState.initial(state.nft)); + } + } + + void _onInit(NftWithdrawInit event, Emitter emit) { + if (isClosed) return; + emit(NftWithdrawFillState.initial(state.nft)); + } + + bool _isErcNonMixedCase(String error) { + return error.contains(LocaleKeys.invalidAddressChecksum.tr()); + } + + Future _onConvertAddress( + NftWithdrawConvertAddress event, Emitter emit) async { + final state = this.state; + if (state is! NftWithdrawFillState) return; + + final result = await coinsRepo.convertLegacyAddress( + state.nft.parentCoin, + state.address, + ); + if (result == null) return; + + add(NftWithdrawAddressChanged(result)); + } + + Future _activateParentCoinIfNeeded(NftToken nft) async { + final parentCoin = state.nft.parentCoin; + + if (!parentCoin.isActive) { + await _coinsBloc.activateCoins([parentCoin]); + } + } +} diff --git a/lib/bloc/nft_withdraw/nft_withdraw_event.dart b/lib/bloc/nft_withdraw/nft_withdraw_event.dart new file mode 100644 index 0000000000..d569ff6bbc --- /dev/null +++ b/lib/bloc/nft_withdraw/nft_withdraw_event.dart @@ -0,0 +1,35 @@ +part of 'nft_withdraw_bloc.dart'; + +abstract class NftWithdrawEvent { + const NftWithdrawEvent(); +} + +class NftWithdrawAddressChanged extends NftWithdrawEvent { + const NftWithdrawAddressChanged(this.address); + final String address; +} + +class NftWithdrawAmountChanged extends NftWithdrawEvent { + const NftWithdrawAmountChanged(this.amount); + final int? amount; +} + +class NftWithdrawSendEvent extends NftWithdrawEvent { + const NftWithdrawSendEvent(); +} + +class NftWithdrawConfirmSendEvent extends NftWithdrawEvent { + const NftWithdrawConfirmSendEvent(); +} + +class NftWithdrawShowFillStep extends NftWithdrawEvent { + const NftWithdrawShowFillStep(); +} + +class NftWithdrawInit extends NftWithdrawEvent { + const NftWithdrawInit(); +} + +class NftWithdrawConvertAddress extends NftWithdrawEvent { + const NftWithdrawConvertAddress(); +} diff --git a/lib/bloc/nft_withdraw/nft_withdraw_repo.dart b/lib/bloc/nft_withdraw/nft_withdraw_repo.dart new file mode 100644 index 0000000000..ca8c09089c --- /dev/null +++ b/lib/bloc/nft_withdraw/nft_withdraw_repo.dart @@ -0,0 +1,85 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api_nft.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/errors.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/validateaddress/validateaddress_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_errors.dart'; +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/shared/utils/utils.dart'; + +class NftWithdrawRepo { + const NftWithdrawRepo({required Mm2ApiNft api}) : _api = api; + + final Mm2ApiNft _api; + Future withdraw({ + required NftToken nft, + required String address, + int? amount, + }) async { + final request = WithdrawNftRequest( + type: nft.contractType, + chain: nft.chain, + toAddress: address, + tokenAddress: nft.tokenAddress, + tokenId: nft.tokenId, + amount: amount, + ); + final Map json = await _api.withdraw(request); + if (json['error'] != null) { + log(json['error'] ?? 'unknown error', + path: 'nft_main_repo => getNfts', isError: true); + final BaseError error = + withdrawErrorFactory.getError(json, nft.parentCoin.abbr); + throw ApiError(message: error.message); + } + + if (json['result'] == null) { + throw ApiError(message: LocaleKeys.somethingWrong.tr()); + } + + try { + final WithdrawNftResponse response = WithdrawNftResponse.fromJson(json); + + return response; + } catch (e) { + throw ParsingApiJsonError(message: e.toString()); + } + } + + Future confirmSend( + String coin, String txHex) async { + try { + final request = SendRawTransactionRequest(coin: coin, txHex: txHex); + final response = await coinsRepo.sendRawTransaction(request); + return response; + } catch (e) { + return SendRawTransactionResponse( + txHash: null, + error: TextError(error: LocaleKeys.somethingWrong.tr())); + } + } + + Future validateAddress( + Coin coin, + String address, + ) async { + try { + final Map? responseRaw = + await coinsRepo.validateCoinAddress(coin, address); + if (responseRaw == null) { + throw ApiError(message: LocaleKeys.somethingWrong.tr()); + } + return ValidateAddressResponse.fromJson(responseRaw); + } catch (e) { + throw ApiError(message: e.toString()); + } + } +} diff --git a/lib/bloc/nft_withdraw/nft_withdraw_state.dart b/lib/bloc/nft_withdraw/nft_withdraw_state.dart new file mode 100644 index 0000000000..b5735dd163 --- /dev/null +++ b/lib/bloc/nft_withdraw/nft_withdraw_state.dart @@ -0,0 +1,95 @@ +part of 'nft_withdraw_bloc.dart'; + +abstract class NftWithdrawState { + const NftWithdrawState({required this.nft}); + final NftToken nft; +} + +class NftWithdrawFillState extends NftWithdrawState { + final String address; + final int? amount; + final bool isSending; + final BaseError? sendError; + final BaseError? addressError; + final BaseError? amountError; + + const NftWithdrawFillState({ + required super.nft, + required this.address, + required this.amount, + required this.isSending, + this.sendError, + this.addressError, + this.amountError, + }); + + static NftWithdrawFillState initial(NftToken nft) => NftWithdrawFillState( + nft: nft, + address: '', + amount: 1, + isSending: false, + amountError: null, + addressError: null, + sendError: null, + ); + + NftWithdrawFillState copyWith({ + NftToken Function()? nft, + String Function()? address, + int? Function()? amount, + NftTransactionDetails? Function()? txDetails, + bool Function()? isSending, + BaseError? Function()? sendError, + BaseError? Function()? addressError, + BaseError? Function()? amountError, + }) { + return NftWithdrawFillState( + nft: nft == null ? this.nft : nft(), + address: address == null ? this.address : address(), + amount: amount == null ? this.amount : amount(), + isSending: isSending == null ? this.isSending : isSending(), + addressError: addressError == null ? this.addressError : addressError(), + amountError: amountError == null ? this.amountError : amountError(), + sendError: sendError == null ? this.sendError : sendError(), + ); + } +} + +class NftWithdrawConfirmState extends NftWithdrawState { + final NftTransactionDetails txDetails; + final bool isSending; + final BaseError? sendError; + + const NftWithdrawConfirmState({ + required this.txDetails, + required this.isSending, + this.sendError, + required super.nft, + }); + + NftWithdrawConfirmState copyWith({ + NftTransactionDetails Function()? txDetails, + bool Function()? isSending, + BaseError? Function()? sendError, + NftToken Function()? nft, + }) { + return NftWithdrawConfirmState( + txDetails: txDetails == null ? this.txDetails : txDetails(), + isSending: isSending == null ? this.isSending : isSending(), + sendError: sendError == null ? this.sendError : sendError(), + nft: nft == null ? this.nft : nft(), + ); + } +} + +class NftWithdrawSuccessState extends NftWithdrawState { + const NftWithdrawSuccessState({ + required this.txHash, + required super.nft, + required this.timestamp, + required this.to, + }); + final String txHash; + final int timestamp; + final String to; +} diff --git a/lib/bloc/nfts/nft_main_bloc.dart b/lib/bloc/nfts/nft_main_bloc.dart new file mode 100644 index 0000000000..d19bc2730a --- /dev/null +++ b/lib/bloc/nfts/nft_main_bloc.dart @@ -0,0 +1,211 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/nfts/nft_main_repo.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/model/text_error.dart'; + +part 'nft_main_event.dart'; +part 'nft_main_state.dart'; + +class NftMainBloc extends Bloc { + NftMainBloc({ + required NftsRepo repo, + required AuthRepository authRepo, + required bool isLoggedIn, + }) : _repo = repo, + _isLoggedIn = isLoggedIn, + super(NftMainState.initial()) { + on(_onUpdateChainNfts); + on(_onChangeTab); + on(_onReset); + on(_onRefreshForChain); + on(_onStartUpdate); + on(_onStopUpdate); + + _authorizationSubscription = authRepo.authMode.listen((event) { + _isLoggedIn = event == AuthorizeMode.logIn; + + if (_isLoggedIn) { + add(const UpdateChainNftsEvent()); + } else { + add(const ResetNftPageEvent()); + } + }); + } + + final NftsRepo _repo; + late StreamSubscription _authorizationSubscription; + Timer? _updateTimer; + bool _isLoggedIn = false; + bool get isLoggedIn => _isLoggedIn; + + Future _onChangeTab( + ChangeNftTabEvent event, + Emitter emit, + ) async { + emit(state.copyWith(selectedChain: () => event.chain)); + if (!_isLoggedIn || !state.isInitialized) return; + + try { + final List nftList = await _repo.getNfts([event.chain]); + + final (newNftS, newNftCount) = + _recalculateNftsForChain(nftList, event.chain); + emit(state.copyWith( + nfts: () => newNftS, + nftCount: () => newNftCount, + error: () => null, + )); + } on BaseError catch (e) { + emit(state.copyWith(error: () => e)); + } catch (e) { + emit(state.copyWith(error: () => TextError(error: e.toString()))); + } + } + + Future _onUpdateChainNfts( + UpdateChainNftsEvent event, + Emitter emit, + ) async { + if (!_isLoggedIn) { + return; + } + + try { + final Map> nfts = await _getAllNfts(); + var (counts, sortedChains) = _calculateNftCount(nfts); + + emit(state.copyWith( + nftCount: () => counts, + nfts: () => nfts, + sortedChains: () => sortedChains, + selectedChain: state.isInitialized ? null : () => sortedChains.first, + isInitialized: () => true, + error: () => null, + )); + } on BaseError catch (e) { + emit(state.copyWith(error: () => e)); + } catch (e) { + emit(state.copyWith(error: () => TextError(error: e.toString()))); + } finally { + emit(state.copyWith(isInitialized: () => true)); + } + } + + void _onReset(ResetNftPageEvent event, Emitter emit) { + emit(NftMainState.initial()); + } + + Future _onRefreshForChain( + RefreshNFTsForChainEvent event, Emitter emit) async { + if (!_isLoggedIn || !state.isInitialized) return; + final updatingChains = _addUpdatingChains(event.chain); + emit(state.copyWith(updatingChains: () => updatingChains)); + + try { + final List nftList = await _repo.getNfts([event.chain]); + + final (newNftS, newNftCount) = + _recalculateNftsForChain(nftList, event.chain); + emit(state.copyWith( + nfts: () => newNftS, + nftCount: () => newNftCount, + error: () => null, + )); + } on BaseError catch (e) { + emit(state.copyWith(error: () => e)); + } catch (e) { + emit(state.copyWith(error: () => TextError(error: e.toString()))); + } finally { + final updatingChains = _removeUpdatingChains(event.chain); + emit(state.copyWith(updatingChains: () => updatingChains)); + } + } + + void _onStopUpdate(StopUpdateNftEvent event, Emitter emit) { + _stopUpdate(); + } + + void _onStartUpdate(StartUpdateNftsEvent event, Emitter emit) { + _stopUpdate(); + _updateTimer = Timer.periodic(const Duration(minutes: 1), (timer) { + add(const UpdateChainNftsEvent()); + }); + } + + Future>> _getAllNfts() async { + const chains = NftBlockchains.values; + await _repo.updateNft(chains); + final List list = await _repo.getNfts(chains); + + final Map> nfts = + list.fold>>( + >{}, + (prev, element) { + List chainList = prev[element.chain] ?? []; + chainList.add(element); + prev[element.chain] = chainList; + + return prev; + }, + ); + + return nfts; + } + + (Map, List) _calculateNftCount( + Map> nfts) { + final Map countMap = {}; + + for (NftBlockchains chain in NftBlockchains.values) { + final count = nfts[chain]?.length ?? 0; + countMap[chain] = count; + } + + final sorted = countMap.entries.toList()..sort((a, b) => b.value - a.value); + final List sortedTabs = sorted.map((e) => e.key).toList(); + + return (countMap, sortedTabs); + } + + void _stopUpdate() { + _updateTimer?.cancel(); + _updateTimer = null; + } + + ( + Map?>, + Map + ) _recalculateNftsForChain(List newNftList, NftBlockchains chain) { + final Map nftCount = {...state.nftCount}; + final Map?> nfts = {...state.nfts}; + nfts[chain] = newNftList; + nftCount[chain] = newNftList.length; + + return (nfts, nftCount); + } + + Map _addUpdatingChains(NftBlockchains chain) { + final Map updatingChains = {...state.updatingChains}; + updatingChains[chain] = true; + return updatingChains; + } + + Map _removeUpdatingChains(NftBlockchains chain) { + final Map updatingChains = {...state.updatingChains}; + updatingChains[chain] = false; + return updatingChains; + } + + @override + Future close() { + _authorizationSubscription.cancel(); + _stopUpdate(); + return super.close(); + } +} diff --git a/lib/bloc/nfts/nft_main_event.dart b/lib/bloc/nfts/nft_main_event.dart new file mode 100644 index 0000000000..658217bab2 --- /dev/null +++ b/lib/bloc/nfts/nft_main_event.dart @@ -0,0 +1,31 @@ +part of 'nft_main_bloc.dart'; + +abstract class NftMainEvent { + const NftMainEvent(); +} + +class UpdateChainNftsEvent extends NftMainEvent { + const UpdateChainNftsEvent(); +} + +class StopUpdateNftEvent extends NftMainEvent { + const StopUpdateNftEvent(); +} + +class StartUpdateNftsEvent extends NftMainEvent { + const StartUpdateNftsEvent(); +} + +class ResetNftPageEvent extends NftMainEvent { + const ResetNftPageEvent(); +} + +class ChangeNftTabEvent extends NftMainEvent { + const ChangeNftTabEvent(this.chain); + final NftBlockchains chain; +} + +class RefreshNFTsForChainEvent extends NftMainEvent { + const RefreshNFTsForChainEvent(this.chain); + final NftBlockchains chain; +} diff --git a/lib/bloc/nfts/nft_main_repo.dart b/lib/bloc/nfts/nft_main_repo.dart new file mode 100644 index 0000000000..f2699543fa --- /dev/null +++ b/lib/bloc/nfts/nft_main_repo.dart @@ -0,0 +1,64 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api_nft.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/errors.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/nft/get_nft_list/get_nft_list_res.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class NftsRepo { + NftsRepo({ + required Mm2ApiNft api, + }) : _api = api; + final Mm2ApiNft _api; + + Future updateNft(List chains) async { + // Only runs on active nft chains + final json = await _api.updateNftList(chains); + if (json['error'] != null) { + log(json['error'], path: 'nft_main_repo => updateNft', isError: true); + throw ApiError(message: json['error']); + } + } + + Future> getNfts(List chains) async { + // Only runs on active nft chains + final json = await _api.getNftList(chains); + final jsonError = json['error']; + if (jsonError != null) { + log( + jsonError, + path: 'nft_main_repo => getNfts', + isError: true, + ); + if (jsonError is String && + jsonError.toLowerCase().startsWith('transport')) { + throw TransportError(message: jsonError); + } else { + throw ApiError(message: jsonError); + } + } + + if (json['result'] == null) { + throw ApiError(message: LocaleKeys.somethingWrong.tr()); + } + try { + final response = GetNftListResponse.fromJson(json); + final nfts = response.result.nfts; + final coins = await coinsRepo.getKnownCoins(); + for (NftToken nft in nfts) { + final coin = coins.firstWhere((c) => c.type == nft.coinType); + final parentCoin = coin.parentCoin ?? coin; + nft.parentCoin = parentCoin; + } + return response.result.nfts; + } on StateError catch (e) { + throw TextError(error: e.toString()); + } catch (e) { + throw ParsingApiJsonError( + message: 'nft_main_repo -> getNfts: ${e.toString()}'); + } + } +} diff --git a/lib/bloc/nfts/nft_main_state.dart b/lib/bloc/nfts/nft_main_state.dart new file mode 100644 index 0000000000..d9b77c811e --- /dev/null +++ b/lib/bloc/nfts/nft_main_state.dart @@ -0,0 +1,65 @@ +part of 'nft_main_bloc.dart'; + +class NftMainState extends Equatable { + const NftMainState({ + required this.nfts, + required this.selectedChain, + required this.nftCount, + required this.sortedChains, + required this.isInitialized, + required this.updatingChains, + this.error, + }); + + static NftMainState initial() => const NftMainState( + nfts: {}, + isInitialized: false, + updatingChains: {}, + selectedChain: NftBlockchains.eth, + nftCount: {}, + sortedChains: [], + error: null, + ); + + final Map?> nfts; + final NftBlockchains selectedChain; + final bool isInitialized; + final Map updatingChains; + final Map nftCount; + final List sortedChains; + final BaseError? error; + + @override + List get props => [ + nfts, + selectedChain, + nftCount, + sortedChains, + error, + updatingChains, + isInitialized, + ]; + + NftMainState copyWith({ + Map?> Function()? nfts, + NftBlockchains Function()? selectedChain, + bool Function()? isInitialized, + Map Function()? nftCount, + List Function()? sortedChains, + BaseError? Function()? error, + Map Function()? updatingChains, + }) { + return NftMainState( + nfts: nfts != null ? nfts() : this.nfts, + selectedChain: + selectedChain != null ? selectedChain() : this.selectedChain, + nftCount: nftCount != null ? nftCount() : this.nftCount, + sortedChains: sortedChains != null ? sortedChains() : this.sortedChains, + isInitialized: + isInitialized != null ? isInitialized() : this.isInitialized, + error: error != null ? error() : this.error, + updatingChains: + updatingChains != null ? updatingChains() : this.updatingChains, + ); + } +} diff --git a/lib/bloc/runtime_coin_updates/coin_config_bloc.dart b/lib/bloc/runtime_coin_updates/coin_config_bloc.dart new file mode 100644 index 0000000000..09d6a894d0 --- /dev/null +++ b/lib/bloc/runtime_coin_updates/coin_config_bloc.dart @@ -0,0 +1,145 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:bloc/bloc.dart'; +import 'package:equatable/equatable.dart'; +import 'package:flutter/foundation.dart'; +import 'package:komodo_coin_updates/komodo_coin_updates.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/runtime_coin_updates/runtime_update_config_provider.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +part 'coin_config_event.dart'; +part 'coin_config_state.dart'; + +/// A BLoC that manages the coin config state. +/// The BLoC fetches the coin configs from the repository and stores them +/// in the storage provider. +/// The BLoC emits the coin configs to the UI. +class CoinConfigBloc extends Bloc { + CoinConfigBloc({ + required this.coinsConfigRepo, + }) : super(const CoinConfigState()) { + on(_onLoadRequested); + on(_onUpdateRequested); + on(_onPeriodicUpdateRequested); + on(_onUnsubscribeRequested); + } + + /// The repository that fetches the coins and coin configs. + final CoinConfigRepository coinsConfigRepo; + + /// Full, platform-dependent, path to the app folder. + String? _appFolderPath; + Timer? _updateCoinConfigTimer; + final _updateTime = const Duration(hours: 1); + + Future _onLoadRequested( + CoinConfigLoadRequested event, + Emitter emit, + ) async { + String? activeFetchedCommitHash; + + emit(const CoinConfigLoadInProgress()); + + try { + activeFetchedCommitHash = (state is CoinConfigLoadSuccess) + ? (state as CoinConfigLoadSuccess).updatedCommitHash + : await coinsConfigRepo.getCurrentCommit(); + + _appFolderPath ??= await applicationDocumentsDirectory; + await compute(updateCoinConfigs, _appFolderPath!); + } catch (e) { + emit(CoinConfigLoadFailure(error: e.toString())); + log('Failed to update coin config: $e', isError: true); + return; + } + + final List coins = (await coinsConfigRepo.getCoins())!; + emit( + CoinConfigLoadSuccess( + coins: coins, + updatedCommitHash: activeFetchedCommitHash, + ), + ); + } + + String? get stateActiveFetchedCommitHash { + if (state is CoinConfigLoadSuccess) { + return (state as CoinConfigLoadSuccess).updatedCommitHash; + } + return null; + } + + Future _onUpdateRequested( + CoinConfigUpdateRequested event, + Emitter emit, + ) async { + String? currentCommit = stateActiveFetchedCommitHash; + + emit(const CoinConfigLoadInProgress()); + + try { + _appFolderPath ??= await applicationDocumentsDirectory; + await compute(updateCoinConfigs, _appFolderPath!); + } catch (e) { + emit(CoinConfigLoadFailure(error: e.toString())); + log('Failed to update coin config: $e', isError: true); + return; + } + + final List coins = (await coinsConfigRepo.getCoins())!; + emit( + CoinConfigLoadSuccess( + coins: coins, + updatedCommitHash: currentCommit, + ), + ); + } + + Future _onPeriodicUpdateRequested( + CoinConfigUpdateSubscribeRequested event, + Emitter emit, + ) async { + _updateCoinConfigTimer = Timer.periodic(_updateTime, (timer) async { + add(CoinConfigUpdateRequested()); + }); + } + + void _onUnsubscribeRequested( + CoinConfigUpdateUnsubscribeRequested event, + Emitter emit, + ) { + _updateCoinConfigTimer?.cancel(); + _updateCoinConfigTimer = null; + } +} + +Future updateCoinConfigs(String appFolderPath) async { + final RuntimeUpdateConfigProvider runtimeUpdateConfigProvider = + RuntimeUpdateConfigProvider(); + final CoinConfigRepository repo = CoinConfigRepository.withDefaults( + await runtimeUpdateConfigProvider.getRuntimeUpdateConfig(), + ); + // On native platforms, Isolates run in a separate process, so we need to + // ensure that the Hive Box is initialized in the isolate. + if (!kIsWeb) { + final isMainThread = Isolate.current.debugName == 'main'; + if (!isMainThread) { + KomodoCoinUpdater.ensureInitializedIsolate(appFolderPath); + } + } + + final bool isUpdated = await repo.isLatestCommit(); + + Stopwatch stopwatch = Stopwatch()..start(); + + if (!isUpdated) { + await repo.updateCoinConfig( + excludedAssets: excludedAssetList, + ); + } + + log('Coin config updated in ${stopwatch.elapsedMilliseconds}ms'); + stopwatch.stop(); +} diff --git a/lib/bloc/runtime_coin_updates/coin_config_event.dart b/lib/bloc/runtime_coin_updates/coin_config_event.dart new file mode 100644 index 0000000000..1f6e108638 --- /dev/null +++ b/lib/bloc/runtime_coin_updates/coin_config_event.dart @@ -0,0 +1,24 @@ +part of 'coin_config_bloc.dart'; + +sealed class CoinConfigEvent extends Equatable { + const CoinConfigEvent(); + + @override + List get props => []; +} + +/// Request for the coin configs to be loaded from disk. +/// Emits [CoinConfigLoadInProgress] followed by [CoinConfigLoadSuccess] or +/// [CoinConfigLoadFailure]. +final class CoinConfigLoadRequested extends CoinConfigEvent {} + +/// Request for the coin configs to be updated from the repository. +/// Emits [CoinConfigLoadInProgress] followed by [CoinConfigLoadSuccess] or +/// [CoinConfigLoadFailure]. +final class CoinConfigUpdateRequested extends CoinConfigEvent {} + +/// Request for periodic updates of the coin configs. +final class CoinConfigUpdateSubscribeRequested extends CoinConfigEvent {} + +/// Request to stop periodic updates of the coin configs. +final class CoinConfigUpdateUnsubscribeRequested extends CoinConfigEvent {} diff --git a/lib/bloc/runtime_coin_updates/coin_config_state.dart b/lib/bloc/runtime_coin_updates/coin_config_state.dart new file mode 100644 index 0000000000..b3a9997f3c --- /dev/null +++ b/lib/bloc/runtime_coin_updates/coin_config_state.dart @@ -0,0 +1,52 @@ +part of 'coin_config_bloc.dart'; + +class CoinConfigState extends Equatable { + const CoinConfigState(); + + @override + List get props => []; +} + +class CoinConfigInitial extends CoinConfigState { + const CoinConfigInitial(); + + @override + List get props => []; +} + +/// The coin config is currently being loaded from disk or network. +class CoinConfigLoadInProgress extends CoinConfigState { + const CoinConfigLoadInProgress(); + + @override + List get props => []; +} + +/// The coin config has been successfully loaded. +/// [coins] is a list of [Coin] objects. +class CoinConfigLoadSuccess extends CoinConfigState { + const CoinConfigLoadSuccess({ + required this.coins, + this.updatedCommitHash, + }); + + final List coins; + + final String? updatedCommitHash; + + @override + List get props => [coins, updatedCommitHash]; +} + +/// The coin config failed to load. +/// [error] is the error message. +class CoinConfigLoadFailure extends CoinConfigState { + const CoinConfigLoadFailure({ + required this.error, + }); + + final String error; + + @override + List get props => [error]; +} diff --git a/lib/bloc/runtime_coin_updates/runtime_update_config_provider.dart b/lib/bloc/runtime_coin_updates/runtime_update_config_provider.dart new file mode 100644 index 0000000000..fa7b64eca5 --- /dev/null +++ b/lib/bloc/runtime_coin_updates/runtime_update_config_provider.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:flutter/services.dart'; +import 'package:komodo_coin_updates/komodo_coin_updates.dart'; + +class RuntimeUpdateConfigProvider { + RuntimeUpdateConfigProvider({ + this.configFilePath = 'app_build/build_config.json', + }); + + final String configFilePath; + + /// Fetches the runtime update config from the repository. + /// Returns a [RuntimeUpdateConfig] object. + /// Throws an [Exception] if the request fails. + Future getRuntimeUpdateConfig() async { + final config = jsonDecode(await rootBundle.loadString(configFilePath)) + as Map; + return RuntimeUpdateConfig.fromJson(config['coins']); + } +} diff --git a/lib/bloc/security_settings/security_settings_bloc.dart b/lib/bloc/security_settings/security_settings_bloc.dart new file mode 100644 index 0000000000..07f78a8d5d --- /dev/null +++ b/lib/bloc/security_settings/security_settings_bloc.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:bloc/bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_event.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_state.dart'; +import 'package:web_dex/blocs/blocs.dart'; + +class SecuritySettingsBloc + extends Bloc { + SecuritySettingsBloc(SecuritySettingsState state) : super(state) { + on(_onReset); + on(_onShowSeed); + on(_onSeedConfirm); + on(_onSeedConfirmed); + on(_onShowSeedWords); + on(_onPasswordUpdate); + on(_onSeedCopied); + } + + void _onReset( + ResetEvent event, + Emitter emit, + ) { + emit(SecuritySettingsState.initialState()); + } + + void _onShowSeed( + ShowSeedEvent event, + Emitter emit, + ) { + final newState = state.copyWith( + step: SecuritySettingsStep.seedShow, + showSeedWords: false, + ); + emit(newState); + } + + void _onShowSeedWords( + ShowSeedWordsEvent event, + Emitter emit, + ) { + final newState = state.copyWith( + step: SecuritySettingsStep.seedShow, + showSeedWords: event.isShow, + isSeedSaved: state.isSeedSaved || event.isShow, + ); + emit(newState); + } + + void _onPasswordUpdate( + PasswordUpdateEvent event, + Emitter emit, + ) { + final newState = state.copyWith( + step: SecuritySettingsStep.passwordUpdate, + showSeedWords: false, + ); + emit(newState); + } + + void _onSeedConfirm( + SeedConfirmEvent event, + Emitter emit, + ) { + final newState = state.copyWith( + step: SecuritySettingsStep.seedConfirm, + showSeedWords: false, + ); + emit(newState); + } + + Future _onSeedConfirmed( + SeedConfirmedEvent event, + Emitter emit, + ) async { + await currentWalletBloc.confirmBackup(); + final newState = state.copyWith( + step: SecuritySettingsStep.seedSuccess, + showSeedWords: false, + ); + emit(newState); + } + + FutureOr _onSeedCopied( + ShowSeedCopiedEvent event, Emitter emit) { + emit(state.copyWith(isSeedSaved: true)); + } +} diff --git a/lib/bloc/security_settings/security_settings_event.dart b/lib/bloc/security_settings/security_settings_event.dart new file mode 100644 index 0000000000..54365cdbab --- /dev/null +++ b/lib/bloc/security_settings/security_settings_event.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; + +abstract class SecuritySettingsEvent extends Equatable { + const SecuritySettingsEvent(); + + @override + List get props => []; +} + +class ResetEvent extends SecuritySettingsEvent { + const ResetEvent(); +} + +class ShowSeedEvent extends SecuritySettingsEvent { + const ShowSeedEvent(); +} + +class SeedConfirmEvent extends SecuritySettingsEvent { + const SeedConfirmEvent(); +} + +class SeedConfirmedEvent extends SecuritySettingsEvent { + const SeedConfirmedEvent(); +} + +class ShowSeedWordsEvent extends SecuritySettingsEvent { + const ShowSeedWordsEvent(this.isShow); + final bool isShow; +} + +class ShowSeedCopiedEvent extends SecuritySettingsEvent { + const ShowSeedCopiedEvent(); +} + +class PasswordUpdateEvent extends SecuritySettingsEvent { + const PasswordUpdateEvent(); +} diff --git a/lib/bloc/security_settings/security_settings_state.dart b/lib/bloc/security_settings/security_settings_state.dart new file mode 100644 index 0000000000..949d4e4751 --- /dev/null +++ b/lib/bloc/security_settings/security_settings_state.dart @@ -0,0 +1,44 @@ +import 'package:equatable/equatable.dart'; + +enum SecuritySettingsStep { + securityMain, + seedShow, + seedConfirm, + seedSuccess, + passwordUpdate, +} + +class SecuritySettingsState extends Equatable { + const SecuritySettingsState({ + required this.step, + required this.showSeedWords, + required this.isSeedSaved, + }); + + factory SecuritySettingsState.initialState() { + return const SecuritySettingsState( + step: SecuritySettingsStep.securityMain, + showSeedWords: false, + isSeedSaved: false, + ); + } + + final SecuritySettingsStep step; + final bool showSeedWords; + final bool isSeedSaved; + + @override + List get props => [step, showSeedWords, isSeedSaved]; + + SecuritySettingsState copyWith({ + SecuritySettingsStep? step, + bool? showSeedWords, + bool? isSeedSaved, + }) { + return SecuritySettingsState( + step: step ?? this.step, + showSeedWords: showSeedWords ?? this.showSeedWords, + isSeedSaved: isSeedSaved ?? this.isSeedSaved, + ); + } +} diff --git a/lib/bloc/settings/settings_bloc.dart b/lib/bloc/settings/settings_bloc.dart new file mode 100644 index 0000000000..cd0fc32fc1 --- /dev/null +++ b/lib/bloc/settings/settings_bloc.dart @@ -0,0 +1,48 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:bloc/bloc.dart'; +import 'package:web_dex/bloc/settings/settings_event.dart'; +import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/bloc/settings/settings_state.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/stored_settings.dart'; +import 'package:web_dex/platform/platform.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class SettingsBloc extends Bloc { + SettingsBloc(StoredSettings stored, SettingsRepository repository) + : _settingsRepo = repository, + super(SettingsState.fromStored(stored)) { + _storedSettings = stored; + theme.mode = state.themeMode; + + on(_onThemeModeChanged); + on(_onMarketMakerBotSettingsChanged); + } + + late StoredSettings _storedSettings; + final SettingsRepository _settingsRepo; + + Future _onThemeModeChanged( + ThemeModeChanged event, + Emitter emitter, + ) async { + if (materialPageContext == null) return; + final newMode = event.mode; + theme.mode = newMode; + await _settingsRepo.updateSettings(_storedSettings.copyWith(mode: newMode)); + changeHtmlTheme(newMode.index); + emitter(state.copyWith(mode: newMode)); + + rebuildAll(null); + } + + Future _onMarketMakerBotSettingsChanged( + MarketMakerBotSettingsChanged event, + Emitter emitter, + ) async { + await _settingsRepo.updateSettings( + _storedSettings.copyWith(marketMakerBotSettings: event.settings), + ); + emitter(state.copyWith(marketMakerBotSettings: event.settings)); + } +} diff --git a/lib/bloc/settings/settings_event.dart b/lib/bloc/settings/settings_event.dart new file mode 100644 index 0000000000..a5a153a391 --- /dev/null +++ b/lib/bloc/settings/settings_event.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/model/settings/market_maker_bot_settings.dart'; + +abstract class SettingsEvent extends Equatable { + const SettingsEvent(); + + @override + List get props => []; +} + +class ThemeModeChanged extends SettingsEvent { + const ThemeModeChanged({required this.mode}); + final ThemeMode mode; +} + +class MarketMakerBotSettingsChanged extends SettingsEvent { + const MarketMakerBotSettingsChanged(this.settings); + + final MarketMakerBotSettings settings; + + @override + List get props => [settings]; +} diff --git a/lib/bloc/settings/settings_repository.dart b/lib/bloc/settings/settings_repository.dart new file mode 100644 index 0000000000..104983d401 --- /dev/null +++ b/lib/bloc/settings/settings_repository.dart @@ -0,0 +1,31 @@ +import 'dart:convert'; + +import 'package:web_dex/model/stored_settings.dart'; +import 'package:web_dex/services/storage/base_storage.dart'; +import 'package:web_dex/services/storage/get_storage.dart'; +import 'package:web_dex/shared/constants.dart'; + +class SettingsRepository { + SettingsRepository({BaseStorage? storage}) + : _storage = storage ?? getStorage(); + + final BaseStorage _storage; + + Future loadSettings() async { + final dynamic storedAppPrefs = await _storage.read(storedSettingsKey); + + return StoredSettings.fromJson(storedAppPrefs); + } + + Future updateSettings(StoredSettings settings) async { + final String encodedData = jsonEncode(settings.toJson()); + await _storage.write(storedSettingsKey, encodedData); + } + + static Future loadStoredSettings() async { + final storage = getStorage(); + final dynamic storedAppPrefs = await storage.read(storedSettingsKey); + + return StoredSettings.fromJson(storedAppPrefs); + } +} diff --git a/lib/bloc/settings/settings_state.dart b/lib/bloc/settings/settings_state.dart new file mode 100644 index 0000000000..084a37cb4e --- /dev/null +++ b/lib/bloc/settings/settings_state.dart @@ -0,0 +1,37 @@ +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/model/settings/market_maker_bot_settings.dart'; +import 'package:web_dex/model/stored_settings.dart'; + +class SettingsState extends Equatable { + const SettingsState({ + required this.themeMode, + required this.mmBotSettings, + }); + + factory SettingsState.fromStored(StoredSettings stored) { + return SettingsState( + themeMode: stored.mode, + mmBotSettings: stored.marketMakerBotSettings, + ); + } + + final ThemeMode themeMode; + final MarketMakerBotSettings mmBotSettings; + + @override + List get props => [ + themeMode, + mmBotSettings, + ]; + + SettingsState copyWith({ + ThemeMode? mode, + MarketMakerBotSettings? marketMakerBotSettings, + }) { + return SettingsState( + themeMode: mode ?? themeMode, + mmBotSettings: marketMakerBotSettings ?? mmBotSettings, + ); + } +} diff --git a/lib/bloc/system_health/system_health_bloc.dart b/lib/bloc/system_health/system_health_bloc.dart new file mode 100644 index 0000000000..6287e7e98d --- /dev/null +++ b/lib/bloc/system_health/system_health_bloc.dart @@ -0,0 +1,40 @@ +import 'dart:async'; +import 'package:bloc/bloc.dart'; +import 'system_health_event.dart'; +import 'system_health_state.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class SystemHealthBloc extends Bloc { + SystemHealthBloc() : super(SystemHealthInitial()) { + on(_onCheckSystemClock); + _startPeriodicCheck(); + } + + Timer? _timer; + + void _startPeriodicCheck() { + add(CheckSystemClock()); + + _timer = Timer.periodic(const Duration(seconds: 30), (timer) { + add(CheckSystemClock()); + }); + } + + Future _onCheckSystemClock( + CheckSystemClock event, + Emitter emit, + ) async { + emit(SystemHealthLoadInProgress()); + try { + emit(SystemHealthLoadSuccess(await systemClockIsValid())); + } catch (_) { + emit(SystemHealthLoadFailure()); + } + } + + @override + Future close() { + _timer?.cancel(); + return super.close(); + } +} diff --git a/lib/bloc/system_health/system_health_event.dart b/lib/bloc/system_health/system_health_event.dart new file mode 100644 index 0000000000..3caba2ca8e --- /dev/null +++ b/lib/bloc/system_health/system_health_event.dart @@ -0,0 +1,3 @@ +abstract class SystemHealthEvent {} + +class CheckSystemClock extends SystemHealthEvent {} diff --git a/lib/bloc/system_health/system_health_state.dart b/lib/bloc/system_health/system_health_state.dart new file mode 100644 index 0000000000..60857c9b6d --- /dev/null +++ b/lib/bloc/system_health/system_health_state.dart @@ -0,0 +1,13 @@ +abstract class SystemHealthState {} + +class SystemHealthInitial extends SystemHealthState {} + +class SystemHealthLoadInProgress extends SystemHealthState {} + +class SystemHealthLoadSuccess extends SystemHealthState { + final bool isValid; + + SystemHealthLoadSuccess(this.isValid); +} + +class SystemHealthLoadFailure extends SystemHealthState {} diff --git a/lib/bloc/taker_form/taker_bloc.dart b/lib/bloc/taker_form/taker_bloc.dart new file mode 100644 index 0000000000..f1b8878cae --- /dev/null +++ b/lib/bloc/taker_form/taker_bloc.dart @@ -0,0 +1,543 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/dex_repository.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/taker_form/taker_validator.dart'; +import 'package:web_dex/bloc/transformers.dart'; +import 'package:web_dex/blocs/coins_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/sell/sell_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/sell/sell_response.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/available_balance_state.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class TakerBloc extends Bloc { + TakerBloc({ + required DexRepository dexRepository, + required CoinsBloc coinsRepository, + required AuthRepository authRepo, + }) : _dexRepo = dexRepository, + _coinsRepo = coinsRepository, + super(TakerState.initial()) { + _validator = TakerValidator( + bloc: this, + coinsRepo: _coinsRepo, + dexRepo: _dexRepo, + ); + + on(_onSetDefaults); + on(_onCoinSelectorClick); + on(_onOrderSelectorClick); + on(_onCoinSelectorOpen); + on(_onOrderSelectorOpen); + on(_onSetSellCoin); + on(_onSelectOrder); + on(_onAddError); + on(_onClearErrors); + on(_onUpdateBestOrders); + on(_onClear); + on(_onSellAmountChange, transformer: debounce()); + on(_onSetSellAmount); + on(_onUpdateMaxSellAmount); + on(_onGetMinSellAmount); + on(_onAmountButtonClick); + on(_onUpdateFees); + on(_onSetPreimage); + on(_onFormSubmitClick); + on(_onBackButtonClick); + on(_onStartSwap); + on(_onSetInProgress); + on(_onReInit); + on(_onVerifyOrderVolume); + on(_onSetWalletReady); + + _authorizationSubscription = authRepo.authMode.listen((event) { + if (event == AuthorizeMode.noLogin && state.step == TakerStep.confirm) { + add(TakerBackButtonClick()); + } + final bool prevLoginState = _isLoggedIn; + _isLoggedIn = event == AuthorizeMode.logIn; + + if (prevLoginState != _isLoggedIn) { + add(const TakerUpdateMaxSellAmount(true)); + add(TakerGetMinSellAmount()); + } + }); + } + + final DexRepository _dexRepo; + final CoinsBloc _coinsRepo; + Timer? _maxSellAmountTimer; + bool _activatingAssets = false; + bool _waitingForWallet = true; + bool _isLoggedIn = false; + late TakerValidator _validator; + 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, + )); + + if (response.error != null) { + add(TakerAddError(DexFormError(error: response.error!.message))); + } + + final String? uuid = response.result?.uuid; + + emit(state.copyWith( + inProgress: uuid == null ? () => false : null, + swapUuid: () => uuid, + )); + } + + void _onBackButtonClick( + TakerBackButtonClick event, + Emitter emit, + ) { + emit(state.copyWith( + step: () => TakerStep.form, + errors: () => [], + )); + } + + Future _onFormSubmitClick( + TakerFormSubmitClick event, + Emitter emit, + ) async { + 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, + )); + } + + void _onAmountButtonClick( + TakerAmountButtonClick event, + Emitter emit, + ) { + final Rational? maxSellAmount = state.maxSellAmount; + if (maxSellAmount == null) return; + + final Rational sellAmount = + getFractionOfAmount(maxSellAmount, event.fraction); + + add(TakerSetSellAmount(sellAmount)); + } + + void _onSellAmountChange( + TakerSellAmountChange event, + Emitter emit, + ) { + final Rational? amount = + event.value.isNotEmpty ? Rational.parse(event.value) : null; + + if (amount == state.sellAmount) return; + + add(TakerSetSellAmount(amount)); + } + + void _onSetSellAmount( + TakerSetSellAmount event, + Emitter emit, + ) { + emit(state.copyWith( + sellAmount: () => event.amount, + buyAmount: () => calculateBuyAmount( + selectedOrder: state.selectedOrder, + sellAmount: event.amount, + ), + )); + + if (state.autovalidate) { + _validator.validateForm(); + } else { + add(TakerVerifyOrderVolume()); + } + add(TakerUpdateFees()); + } + + void _onAddError( + TakerAddError event, + Emitter emit, + ) { + final List errorsList = List.from(state.errors); + errorsList.add(event.error); + + emit(state.copyWith( + errors: () => errorsList, + )); + } + + void _onClearErrors( + TakerClearErrors event, + Emitter emit, + ) { + emit(state.copyWith( + errors: () => [], + )); + } + + Future _onSelectOrder( + TakerSelectOrder event, + Emitter emit, + ) async { + 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, + ), + tradePreimage: () => null, + errors: () => [], + autovalidate: switchingCoin ? () => false : null, + )); + + if (!state.autovalidate) add(TakerVerifyOrderVolume()); + + await _autoActivateCoin(state.selectedOrder?.coin); + if (state.autovalidate) _validator.validateForm(); + add(TakerUpdateFees()); + } + + Future _onSetDefaults( + TakerSetDefaults event, + Emitter emit, + ) async { + if (state.sellCoin == null) await _setDefaultSellCoin(); + } + + Future _setDefaultSellCoin() async { + final Coin? defaultCoin = _coinsRepo.getCoin(defaultDexCoin); + add(TakerSetSellCoin(defaultCoin)); + } + + Future _onSetSellCoin( + TakerSetSellCoin event, + Emitter emit, + ) async { + 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)); + + await _autoActivateCoin(state.sellCoin?.abbr); + _subscribeMaxSellAmount(); + add(TakerGetMinSellAmount()); + } + + Future _onUpdateBestOrders( + TakerUpdateBestOrders event, + Emitter emit, + ) async { + final Coin? coin = state.sellCoin; + + emit(state.copyWith( + bestOrders: () => null, + )); + + if (coin == null) return; + + final BestOrders bestOrders = await _dexRepo.getBestOrders( + BestOrdersRequest( + coin: coin.abbr, + type: BestOrdersRequestType.number, + number: 1, + action: 'sell', + ), + ); + + emit(state.copyWith(bestOrders: () => bestOrders)); + + final buyCoin = event.autoSelectOrderAbbr; + if (buyCoin != null) { + final orders = bestOrders.result?[buyCoin]; + if (orders != null) { + add(TakerSelectOrder(orders.first)); + } + } + } + + void _onCoinSelectorClick( + TakerCoinSelectorClick event, + Emitter emit, + ) { + emit(state.copyWith( + showCoinSelector: () => !state.showCoinSelector, + showOrderSelector: () => false, + )); + } + + Future _onOrderSelectorClick( + TakerOrderSelectorClick event, + Emitter emit, + ) async { + if (state.sellCoin == null) { + _validator.validateForm(); + return; + } + + emit(state.copyWith( + showOrderSelector: () => !state.showOrderSelector, + showCoinSelector: () => false, + bestOrders: _haveBestOrders ? () => state.bestOrders : () => null, + )); + + if (state.showOrderSelector && !_haveBestOrders) { + add(TakerUpdateBestOrders()); + } + } + + bool get _haveBestOrders { + return state.bestOrders != null && + state.bestOrders!.result != null && + state.bestOrders!.result!.isNotEmpty; + } + + void _onCoinSelectorOpen( + TakerCoinSelectorOpen event, + Emitter emit, + ) { + emit(state.copyWith( + showCoinSelector: () => event.isOpen, + )); + } + + void _onOrderSelectorOpen( + TakerOrderSelectorOpen event, + Emitter emit, + ) { + emit(state.copyWith( + showOrderSelector: () => event.isOpen, + )); + } + + void _onClear( + TakerClear event, + Emitter emit, + ) { + _maxSellAmountTimer?.cancel(); + + emit(TakerState.initial().copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable, + )); + } + + void _subscribeMaxSellAmount() { + _maxSellAmountTimer?.cancel(); + + add(const TakerUpdateMaxSellAmount()); + _maxSellAmountTimer = Timer.periodic(const Duration(seconds: 10), (_) { + add(const TakerUpdateMaxSellAmount()); + }); + } + + Future _onUpdateMaxSellAmount( + TakerUpdateMaxSellAmount event, + Emitter emitter, + ) async { + if (state.sellCoin == null) { + _maxSellAmountTimer?.cancel(); + return; + } + if (state.availableBalanceState == AvailableBalanceState.initial || + event.setLoadingStatus) { + emitter(state.copyWith( + availableBalanceState: () => AvailableBalanceState.loading)); + } + + if (!_isLoggedIn) { + emitter(state.copyWith( + availableBalanceState: () => AvailableBalanceState.unavailable)); + } else { + Rational? maxSellAmount = + await _dexRepo.getMaxTakerVolume(state.sellCoin!.abbr); + if (maxSellAmount != null) { + emitter(state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: () => AvailableBalanceState.success, + )); + } else { + maxSellAmount = await _frequentlyGetMaxTakerVolume(); + emitter(state.copyWith( + maxSellAmount: () => maxSellAmount, + availableBalanceState: maxSellAmount == null + ? () => AvailableBalanceState.failure + : () => AvailableBalanceState.success, + )); + } + } + } + + Future _frequentlyGetMaxTakerVolume() async { + int attempts = 5; + Rational? maxSellAmount; + while (attempts > 0) { + maxSellAmount = await _dexRepo.getMaxTakerVolume(state.sellCoin!.abbr); + if (maxSellAmount != null) { + return maxSellAmount; + } + attempts -= 1; + await Future.delayed(const Duration(seconds: 2)); + } + return null; + } + + Future _onGetMinSellAmount( + TakerGetMinSellAmount event, + Emitter emit, + ) async { + if (state.sellCoin == null) return; + if (!_isLoggedIn) { + emit(state.copyWith( + minSellAmount: () => null, + )); + return; + } + + final Rational? minSellAmount = + await _dexRepo.getMinTradingVolume(state.sellCoin!.abbr); + + emit(state.copyWith( + minSellAmount: () => minSellAmount, + )); + } + + Future _onUpdateFees( + TakerUpdateFees event, + Emitter emit, + ) async { + emit(state.copyWith( + tradePreimage: () => null, + )); + + if (!_validator.canRequestPreimage) return; + + final preimageData = await _getFeesData(); + add(TakerSetPreimage(preimageData.data)); + } + + void _onSetPreimage( + TakerSetPreimage event, + Emitter emit, + ) { + emit(state.copyWith(tradePreimage: () => event.tradePreimage)); + } + + Future> _getFeesData() async { + try { + return await _dexRepo.getTradePreimage( + state.sellCoin!.abbr, + state.selectedOrder!.coin, + state.selectedOrder!.price, + 'sell', + state.sellAmount, + ); + } catch (e, s) { + log(e.toString(), + trace: s, path: 'taker_bloc::_getFeesData', isError: true); + return DataFromService(error: TextError(error: 'Failed to request fees')); + } + } + + Future _autoActivateCoin(String? abbr) async { + if (abbr == null) return; + + _activatingAssets = true; + final List activationErrors = + await activateCoinIfNeeded(abbr); + _activatingAssets = false; + + if (activationErrors.isNotEmpty) { + add(TakerAddError(activationErrors.first)); + } + } + + void _onSetInProgress( + TakerSetInProgress event, + Emitter emit, + ) { + emit(state.copyWith( + inProgress: () => event.value, + )); + } + + void _onSetWalletReady( + TakerSetWalletIsReady event, + Emitter _, + ) { + _waitingForWallet = !event.ready; + } + + void _onVerifyOrderVolume( + TakerVerifyOrderVolume event, + Emitter emit, + ) { + _validator.verifyOrderVolume(); + } + + Future _onReInit(TakerReInit event, Emitter emit) async { + emit(state.copyWith( + errors: () => [], + autovalidate: () => false, + )); + await _autoActivateCoin(state.sellCoin?.abbr); + await _autoActivateCoin(state.selectedOrder?.coin); + } + + @override + Future close() { + _maxSellAmountTimer?.cancel(); + _authorizationSubscription.cancel(); + + return super.close(); + } +} diff --git a/lib/bloc/taker_form/taker_event.dart b/lib/bloc/taker_form/taker_event.dart new file mode 100644 index 0000000000..f80d65abcb --- /dev/null +++ b/lib/bloc/taker_form/taker_event.dart @@ -0,0 +1,112 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.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'; + +abstract class TakerEvent { + const TakerEvent(); +} + +class TakerCoinSelectorOpen extends TakerEvent { + TakerCoinSelectorOpen(this.isOpen); + + final bool isOpen; +} + +class TakerOrderSelectorOpen extends TakerEvent { + TakerOrderSelectorOpen(this.isOpen); + + final bool isOpen; +} + +class TakerCoinSelectorClick extends TakerEvent {} + +class TakerOrderSelectorClick extends TakerEvent {} + +class TakerSetSellCoin extends TakerEvent { + TakerSetSellCoin(this.coin, {this.autoSelectOrderAbbr}); + + final Coin? coin; + final String? autoSelectOrderAbbr; +} + +class TakerSelectOrder extends TakerEvent { + TakerSelectOrder(this.order); + + final BestOrder? order; +} + +class TakerSetDefaults extends TakerEvent {} + +class TakerAddError extends TakerEvent { + TakerAddError(this.error); + + final DexFormError error; +} + +class TakerClearErrors extends TakerEvent {} + +class TakerUpdateBestOrders extends TakerEvent { + TakerUpdateBestOrders({this.autoSelectOrderAbbr}); + + final String? autoSelectOrderAbbr; +} + +class TakerClear extends TakerEvent {} + +class TakerSellAmountChange extends TakerEvent { + TakerSellAmountChange(this.value); + + final String value; +} + +class TakerSetSellAmount extends TakerEvent { + TakerSetSellAmount(this.amount); + + final Rational? amount; +} + +class TakerUpdateMaxSellAmount extends TakerEvent { + const TakerUpdateMaxSellAmount([this.setLoadingStatus = false]); + final bool setLoadingStatus; +} + +class TakerGetMinSellAmount extends TakerEvent {} + +// 'max', 'half' buttons +class TakerAmountButtonClick extends TakerEvent { + TakerAmountButtonClick(this.fraction); + + final double fraction; +} + +class TakerUpdateFees extends TakerEvent {} + +class TakerSetPreimage extends TakerEvent { + TakerSetPreimage(this.tradePreimage); + + final TradePreimage? tradePreimage; +} + +class TakerFormSubmitClick extends TakerEvent {} + +class TakerBackButtonClick extends TakerEvent {} + +class TakerStartSwap extends TakerEvent {} + +class TakerReInit extends TakerEvent {} + +class TakerSetInProgress extends TakerEvent { + TakerSetInProgress(this.value); + + final bool value; +} + +class TakerSetWalletIsReady extends TakerEvent { + TakerSetWalletIsReady(this.ready); + + final bool ready; +} + +class TakerVerifyOrderVolume extends TakerEvent {} diff --git a/lib/bloc/taker_form/taker_state.dart b/lib/bloc/taker_form/taker_state.dart new file mode 100644 index 0000000000..b9fb3197b7 --- /dev/null +++ b/lib/bloc/taker_form/taker_state.dart @@ -0,0 +1,118 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/available_balance_state.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'; + +class TakerState { + TakerState({ + required this.step, + required this.inProgress, + this.sellCoin, + this.selectedOrder, + this.bestOrders, + required this.showCoinSelector, + required this.showOrderSelector, + this.sellAmount, + this.buyAmount, + required this.errors, + this.tradePreimage, + this.maxSellAmount, + this.minSellAmount, + required this.autovalidate, + this.swapUuid, + required this.availableBalanceState, + }); + + factory TakerState.initial() { + return TakerState( + step: TakerStep.form, + inProgress: false, + sellCoin: null, + selectedOrder: null, + bestOrders: null, + showCoinSelector: false, + showOrderSelector: false, + errors: [], + tradePreimage: null, + maxSellAmount: null, + minSellAmount: null, + autovalidate: false, + swapUuid: null, + availableBalanceState: AvailableBalanceState.initial, + ); + } + + TakerStep step; + bool inProgress; + Coin? sellCoin; + BestOrder? selectedOrder; + BestOrders? bestOrders; + bool showCoinSelector; + bool showOrderSelector; + Rational? sellAmount; + Rational? buyAmount; + List errors; + TradePreimage? tradePreimage; + Rational? maxSellAmount; + Rational? minSellAmount; + bool autovalidate; + String? swapUuid; + AvailableBalanceState availableBalanceState; + + // Function arguments needed to handle nullable props + // https://bloclibrary.dev/#/fluttertodostutorial + // https://stackoverflow.com/questions/68009392/dart-custom-copywith-method-with-nullable-properties + TakerState copyWith({ + TakerStep Function()? step, + bool Function()? inProgress, + Coin? Function()? sellCoin, + BestOrder? Function()? selectedOrder, + BestOrders? Function()? bestOrders, + bool Function()? showCoinSelector, + bool Function()? showOrderSelector, + Rational? Function()? sellAmount, + Rational? Function()? buyAmount, + List Function()? errors, + TradePreimage? Function()? tradePreimage, + Rational? Function()? maxSellAmount, + Rational? Function()? minSellAmount, + bool Function()? autovalidate, + String? Function()? swapUuid, + AvailableBalanceState Function()? availableBalanceState, + }) { + return TakerState( + step: step == null ? this.step : step(), + inProgress: inProgress == null ? this.inProgress : inProgress(), + sellCoin: sellCoin == null ? this.sellCoin : sellCoin(), + selectedOrder: + selectedOrder == null ? this.selectedOrder : selectedOrder(), + bestOrders: bestOrders == null ? this.bestOrders : bestOrders(), + showCoinSelector: + showCoinSelector == null ? this.showCoinSelector : showCoinSelector(), + showOrderSelector: showOrderSelector == null + ? this.showOrderSelector + : showOrderSelector(), + sellAmount: sellAmount == null ? this.sellAmount : sellAmount(), + buyAmount: buyAmount == null ? this.buyAmount : buyAmount(), + errors: errors == null ? this.errors : errors(), + tradePreimage: + tradePreimage == null ? this.tradePreimage : tradePreimage(), + maxSellAmount: + maxSellAmount == null ? this.maxSellAmount : maxSellAmount(), + minSellAmount: + minSellAmount == null ? this.minSellAmount : minSellAmount(), + autovalidate: autovalidate == null ? this.autovalidate : autovalidate(), + swapUuid: swapUuid == null ? this.swapUuid : swapUuid(), + availableBalanceState: availableBalanceState == null + ? this.availableBalanceState + : availableBalanceState(), + ); + } +} + +enum TakerStep { + form, + confirm, +} diff --git a/lib/bloc/taker_form/taker_validator.dart b/lib/bloc/taker_form/taker_validator.dart new file mode 100644 index 0000000000..a69c41bb6c --- /dev/null +++ b/lib/bloc/taker_form/taker_validator.dart @@ -0,0 +1,394 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/bloc/dex_repository.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/blocs/coins_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'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_errors.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/views/dex/simple/form/error_list/dex_form_error_with_action.dart'; + +class TakerValidator { + TakerValidator({ + required TakerBloc bloc, + required CoinsBloc coinsRepo, + required DexRepository dexRepo, + }) : _bloc = bloc, + _coinsRepo = coinsRepo, + _dexRepo = dexRepo, + add = bloc.add; + + final TakerBloc _bloc; + final CoinsBloc _coinsRepo; + final DexRepository _dexRepo; + + final Function(TakerEvent) add; + TakerState get state => _bloc.state; + + Future validate() async { + final bool isFormValid = validateForm(); + if (!isFormValid) return false; + + final bool tradingWithSelf = _checkTradeWithSelf(); + if (tradingWithSelf) return false; + + final bool isPreimageValid = await _validatePreimage(); + if (!isPreimageValid) return false; + + return true; + } + + Future _validatePreimage() async { + add(TakerClearErrors()); + + final preimageData = await _getPreimageData(); + final preimageError = _parsePreimageError(preimageData); + + if (preimageError != null) { + add(TakerAddError(preimageError)); + return false; + } + + add(TakerSetPreimage(preimageData.data)); + return true; + } + + DexFormError? _parsePreimageError( + DataFromService preimageData) { + final BaseError? error = preimageData.error; + + if (error is TradePreimageNotSufficientBalanceError) { + return _insufficientBalanceError( + Rational.parse(error.required), error.coin); + } else if (error is TradePreimageNotSufficientBaseCoinBalanceError) { + return _insufficientBalanceError( + Rational.parse(error.required), error.coin); + } else if (error is TradePreimageTransportError) { + return DexFormError( + error: LocaleKeys.notEnoughBalanceForGasError.tr(), + ); + } else if (error is TradePreimageVolumeTooLowError) { + return DexFormError( + error: LocaleKeys.lowTradeVolumeError + .tr(args: [formatAmt(double.parse(error.threshold)), error.coin]), + ); + } else if (error != null) { + return DexFormError( + error: error.message, + ); + } else if (preimageData.data == null) { + return DexFormError( + error: LocaleKeys.somethingWrong.tr(), + ); + } + + return null; + } + + bool validateForm() { + add(TakerClearErrors()); + + if (!_isSellCoinSelected) { + add(TakerAddError(_selectSellCoinError())); + return false; + } + + if (!_isOrderSelected) { + add(TakerAddError(_selectOrderError())); + return false; + } + + if (!_validateCoinAndParent(state.sellCoin!.abbr)) return false; + if (!_validateCoinAndParent(state.selectedOrder!.coin)) return false; + + if (!_validateAmount()) return false; + + return true; + } + + bool _validateAmount() { + if (!_validateMinAmount()) return false; + if (!_validateMaxAmount()) return false; + + return true; + } + + bool _checkTradeWithSelf() { + add(TakerClearErrors()); + + if (state.selectedOrder == null) return false; + final BestOrder selectedOrder = state.selectedOrder!; + + final selectedOrderAddress = selectedOrder.address; + final coin = _coinsRepo.getCoin(selectedOrder.coin); + final ownAddress = coin?.address; + + if (selectedOrderAddress == ownAddress) { + add(TakerAddError(_tradingWithSelfError())); + return true; + } + return false; + } + + bool _validateMaxAmount() { + final Rational? availableBalance = state.maxSellAmount; + if (availableBalance == null) return true; // validated on preimage side + + final Rational? maxOrderVolume = state.selectedOrder?.maxVolume; + if (maxOrderVolume == null) { + add(TakerAddError(_selectOrderError())); + return false; + } + + final Rational? sellAmount = state.sellAmount; + if (sellAmount == null || sellAmount == Rational.zero) { + add(TakerAddError(_enterSellAmountError())); + return false; + } + + if (maxOrderVolume <= availableBalance && sellAmount > maxOrderVolume) { + add(TakerAddError(_setOrderMaxError(maxOrderVolume))); + return false; + } + + if (availableBalance < maxOrderVolume && sellAmount > availableBalance) { + final Rational minAmount = maxRational([ + state.minSellAmount ?? Rational.zero, + state.selectedOrder!.minVolume + ])!; + + if (availableBalance < minAmount) { + add(TakerAddError( + _insufficientBalanceError(minAmount, state.sellCoin!.abbr), + )); + } else { + add(TakerAddError( + _setMaxError(availableBalance), + )); + } + + return false; + } + + return true; + } + + bool _validateMinAmount() { + final Rational minTradingVolume = state.minSellAmount ?? Rational.zero; + final Rational minOrderVolume = + state.selectedOrder?.minVolume ?? Rational.zero; + + final Rational minAmount = + maxRational([minTradingVolume, minOrderVolume]) ?? Rational.zero; + final Rational sellAmount = state.sellAmount ?? Rational.zero; + + if (sellAmount < minAmount) { + final Rational available = state.maxSellAmount ?? Rational.zero; + if (available < minAmount) { + add(TakerAddError( + _insufficientBalanceError(minAmount, state.sellCoin!.abbr), + )); + } else { + add(TakerAddError(_setMinError(minAmount))); + } + + return false; + } + + return true; + } + + bool _validateCoinAndParent(String abbr) { + final Coin? coin = _coinsRepo.getKnownCoin(abbr); + + if (coin == null) { + add(TakerAddError(_unknownCoinError(abbr))); + return false; + } + + if (coin.enabledType == null) { + add(TakerAddError(_coinNotActiveError(coin.abbr))); + return false; + } + + if (coin.isSuspended) { + add(TakerAddError(_coinSuspendedError(coin.abbr))); + return false; + } + + final Coin? parent = coin.parentCoin; + if (parent != null) { + if (parent.enabledType == null) { + add(TakerAddError(_coinNotActiveError(parent.abbr))); + return false; + } + + if (parent.isSuspended) { + add(TakerAddError(_coinSuspendedError(parent.abbr))); + return false; + } + } + + return true; + } + + bool get _isSellCoinSelected => state.sellCoin != null; + + bool get _isOrderSelected => state.selectedOrder != null; + + bool get canRequestPreimage { + final Coin? sellCoin = state.sellCoin; + if (sellCoin == null) return false; + if (sellCoin.enabledType == null) return false; + if (sellCoin.isSuspended) return false; + + final Rational? sellAmount = state.sellAmount; + if (sellAmount == null) return false; + if (sellAmount == Rational.zero) return false; + final Rational? minSellAmount = state.minSellAmount; + if (minSellAmount != null && sellAmount < minSellAmount) return false; + final Rational? maxSellAmount = state.maxSellAmount; + if (maxSellAmount != null && sellAmount > maxSellAmount) return false; + + final Coin? parentSell = sellCoin.parentCoin; + if (parentSell != null) { + if (parentSell.enabledType == null) return false; + if (parentSell.isSuspended) return false; + if (parentSell.balance == 0.00) return false; + } + + final BestOrder? selectedOrder = state.selectedOrder; + if (selectedOrder == null) return false; + final Coin? buyCoin = _coinsRepo.getCoin(selectedOrder.coin); + if (buyCoin == null) return false; + if (buyCoin.enabledType == null) return false; + + final Coin? parentBuy = buyCoin.parentCoin; + if (parentBuy != null) { + if (parentBuy.enabledType == null) return false; + if (parentBuy.isSuspended) return false; + if (parentBuy.balance == 0.00) return false; + } + + return true; + } + + void verifyOrderVolume() { + final Coin? sellCoin = state.sellCoin; + final BestOrder? selectedOrder = state.selectedOrder; + final Rational? sellAmount = state.sellAmount; + + if (sellCoin == null) return; + if (selectedOrder == null) return; + if (sellAmount == null) return; + + add(TakerClearErrors()); + if (sellAmount > selectedOrder.maxVolume) { + add(TakerAddError(_setOrderMaxError(selectedOrder.maxVolume))); + return; + } + } + + Future> _getPreimageData() async { + try { + return await _dexRepo.getTradePreimage( + state.sellCoin!.abbr, + state.selectedOrder!.coin, + state.selectedOrder!.price, + 'sell', + state.sellAmount, + ); + } catch (e, s) { + log(e.toString(), + trace: s, path: 'taker_validator::_getPreimageData', isError: true); + return DataFromService( + error: TextError(error: 'Failed to request trade preimage')); + } + } + + DexFormError _unknownCoinError(String abbr) => + DexFormError(error: 'Unknown coin $abbr.'); + + DexFormError _coinSuspendedError(String abbr) { + return DexFormError(error: '$abbr suspended.'); + } + + DexFormError _coinNotActiveError(String abbr) { + return DexFormError(error: '$abbr is not active.'); + } + + DexFormError _selectSellCoinError() => + DexFormError(error: LocaleKeys.dexSelectSellCoinError.tr()); + + DexFormError _selectOrderError() => + DexFormError(error: LocaleKeys.dexSelectBuyCoinError.tr()); + + DexFormError _enterSellAmountError() => + DexFormError(error: LocaleKeys.dexEnterSellAmountError.tr()); + + DexFormError _insufficientBalanceError(Rational required, String abbr) { + return DexFormError( + error: LocaleKeys.dexBalanceNotSufficientError + .tr(args: [abbr, formatDexAmt(required), abbr]), + ); + } + + DexFormError _setOrderMaxError(Rational maxAmount) { + return DexFormError( + error: LocaleKeys.dexMaxOrderVolume + .tr(args: [formatDexAmt(maxAmount), state.sellCoin!.abbr]), + type: DexFormErrorType.largerMaxSellVolume, + action: DexFormErrorAction( + text: LocaleKeys.setMax.tr(), + callback: () async { + add(TakerSetSellAmount(maxAmount)); + }, + ), + ); + } + + DexFormError _setMaxError(Rational available) { + return DexFormError( + error: LocaleKeys.dexInsufficientFundsError.tr( + args: [formatDexAmt(available), state.sellCoin!.abbr], + ), + type: DexFormErrorType.largerMaxSellVolume, + action: DexFormErrorAction( + text: LocaleKeys.setMax.tr(), + callback: () async { + add(TakerSetSellAmount(available)); + }, + ), + ); + } + + DexFormError _setMinError(Rational minAmount) { + return DexFormError( + type: DexFormErrorType.lessMinVolume, + error: LocaleKeys.dexMinSellAmountError + .tr(args: [formatDexAmt(minAmount), state.sellCoin!.abbr]), + action: DexFormErrorAction( + text: LocaleKeys.setMin.tr(), + callback: () async { + add(TakerSetSellAmount(minAmount)); + }), + ); + } + + DexFormError _tradingWithSelfError() { + return DexFormError( + error: LocaleKeys.dexTradingWithSelfError.tr(), + ); + } +} diff --git a/lib/bloc/trading_kind/trading_kind.dart b/lib/bloc/trading_kind/trading_kind.dart new file mode 100644 index 0000000000..c1b87d2345 --- /dev/null +++ b/lib/bloc/trading_kind/trading_kind.dart @@ -0,0 +1 @@ +enum TradingKind { taker, maker } diff --git a/lib/bloc/trading_kind/trading_kind_bloc.dart b/lib/bloc/trading_kind/trading_kind_bloc.dart new file mode 100644 index 0000000000..1f7de2e4b9 --- /dev/null +++ b/lib/bloc/trading_kind/trading_kind_bloc.dart @@ -0,0 +1,20 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/trading_kind/trading_kind.dart'; +import 'package:web_dex/bloc/trading_kind/trading_kind_event.dart'; +import 'package:web_dex/bloc/trading_kind/trading_kind_state.dart'; + +export 'package:web_dex/bloc/trading_kind/trading_kind.dart'; +export 'package:web_dex/bloc/trading_kind/trading_kind_event.dart'; +export 'package:web_dex/bloc/trading_kind/trading_kind_state.dart'; + +class TradingKindBloc extends Bloc { + TradingKindBloc(super.initialState) { + on(_onKindChanged); + } + + void setKind(TradingKind kind) => add(KindChanged(kind)); + + void _onKindChanged(KindChanged event, Emitter emit) { + emit(state.copyWith(kind: event.kind)); + } +} diff --git a/lib/bloc/trading_kind/trading_kind_event.dart b/lib/bloc/trading_kind/trading_kind_event.dart new file mode 100644 index 0000000000..fbf876fb4c --- /dev/null +++ b/lib/bloc/trading_kind/trading_kind_event.dart @@ -0,0 +1,16 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/trading_kind/trading_kind.dart'; + +abstract class TradingKindEvent extends Equatable { + const TradingKindEvent(); + + @override + List get props => []; +} + +class KindChanged extends TradingKindEvent { + const KindChanged(this.kind); + final TradingKind kind; + @override + List get props => [kind]; +} diff --git a/lib/bloc/trading_kind/trading_kind_state.dart b/lib/bloc/trading_kind/trading_kind_state.dart new file mode 100644 index 0000000000..fe4a6c0dac --- /dev/null +++ b/lib/bloc/trading_kind/trading_kind_state.dart @@ -0,0 +1,19 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/trading_kind/trading_kind.dart'; + +class TradingKindState extends Equatable { + const TradingKindState({required this.kind}); + factory TradingKindState.initial() => + const TradingKindState(kind: TradingKind.taker); + + final TradingKind kind; + bool get isMaker => kind == TradingKind.maker; + bool get isTaker => kind == TradingKind.taker; + + @override + List get props => [kind]; + + TradingKindState copyWith({TradingKind? kind}) { + return TradingKindState(kind: kind ?? this.kind); + } +} diff --git a/lib/bloc/transaction_history/transaction_history_bloc.dart b/lib/bloc/transaction_history/transaction_history_bloc.dart new file mode 100644 index 0000000000..33899e8e95 --- /dev/null +++ b/lib/bloc/transaction_history/transaction_history_bloc.dart @@ -0,0 +1,140 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_event.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_repo.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/my_tx_history_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class TransactionHistoryBloc + extends Bloc { + TransactionHistoryBloc({ + required TransactionHistoryRepo repo, + }) : _repo = repo, + super(TransactionHistoryInitialState()) { + on(_onSubscribe); + on(_onUnsubscribe); + on(_onUpdated); + on(_onFailure); + } + + final TransactionHistoryRepo _repo; + Timer? _updateTransactionsTimer; + final _updateTime = const Duration(seconds: 10); + + Future _onSubscribe( + TransactionHistorySubscribe event, + Emitter emit, + ) async { + if (!hasTxHistorySupport(event.coin)) { + return; + } + emit(TransactionHistoryInitialState()); + await _update(event.coin); + _stopTimers(); + _updateTransactionsTimer = Timer.periodic(_updateTime, (_) async { + await _update(event.coin); + }); + } + + void _onUnsubscribe( + TransactionHistoryUnsubscribe event, + Emitter emit, + ) { + _stopTimers(); + } + + void _onUpdated( + TransactionHistoryUpdated event, + Emitter emit, + ) { + if (event.isInProgress) { + emit(TransactionHistoryInProgressState(transactions: event.transactions)); + return; + } + emit(TransactionHistoryLoadedState(transactions: event.transactions)); + } + + void _onFailure( + TransactionHistoryFailure event, + Emitter emit, + ) { + emit(TransactionHistoryFailureState(error: event.error)); + } + + Future _update(Coin coin) async { + final DataFromService + transactionsResponse = await _repo.fetch(coin); + if (isClosed) { + return; + } + final TransactionHistoryResponseResult? result = transactionsResponse.data; + + final BaseError? responseError = transactionsResponse.error; + if (responseError != null) { + add(TransactionHistoryFailure(error: responseError)); + return; + } else if (result == null) { + add( + TransactionHistoryFailure( + error: TextError(error: LocaleKeys.somethingWrong.tr()), + ), + ); + return; + } + + final List transactions = List.from(result.transactions); + transactions.sort(_sortTransactions); + _flagTransactions(transactions, coin); + + add( + TransactionHistoryUpdated( + transactions: transactions, + isInProgress: result.syncStatus.state == SyncStatusState.inProgress, + ), + ); + } + + @override + Future close() { + _stopTimers(); + + return super.close(); + } + + void _stopTimers() { + _updateTransactionsTimer?.cancel(); + _updateTransactionsTimer = null; + } +} + +int _sortTransactions(Transaction tx1, Transaction tx2) { + if (tx2.timestamp == 0) { + return 1; + } else if (tx1.timestamp == 0) { + return -1; + } + return tx2.timestamp.compareTo(tx1.timestamp); +} + +void _flagTransactions(List transactions, Coin coin) { + // First response to https://trezor.io/support/a/address-poisoning-attacks, + // need to be refactored. + // ref: https://github.com/KomodoPlatform/komodowallet/issues/1091 + + if (!coin.isErcType) return; + + for (final Transaction tx in List.from(transactions)) { + if (double.tryParse(tx.totalAmount) == 0.0) { + transactions.remove(tx); + } + } +} diff --git a/lib/bloc/transaction_history/transaction_history_event.dart b/lib/bloc/transaction_history/transaction_history_event.dart new file mode 100644 index 0000000000..921c1d2a02 --- /dev/null +++ b/lib/bloc/transaction_history/transaction_history_event.dart @@ -0,0 +1,31 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; +import 'package:web_dex/model/coin.dart'; + +abstract class TransactionHistoryEvent { + const TransactionHistoryEvent(); +} + +class TransactionHistorySubscribe extends TransactionHistoryEvent { + const TransactionHistorySubscribe({required this.coin}); + final Coin coin; +} + +class TransactionHistoryUnsubscribe extends TransactionHistoryEvent { + const TransactionHistoryUnsubscribe({required this.coin}); + final Coin coin; +} + +class TransactionHistoryUpdated extends TransactionHistoryEvent { + const TransactionHistoryUpdated({ + required this.transactions, + required this.isInProgress, + }); + final List transactions; + final bool isInProgress; +} + +class TransactionHistoryFailure extends TransactionHistoryEvent { + TransactionHistoryFailure({required this.error}); + final BaseError error; +} diff --git a/lib/bloc/transaction_history/transaction_history_repo.dart b/lib/bloc/transaction_history/transaction_history_repo.dart new file mode 100644 index 0000000000..9c951a124d --- /dev/null +++ b/lib/bloc/transaction_history/transaction_history_repo.dart @@ -0,0 +1,196 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:http/http.dart' show Client, Response; +import 'package:http/http.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/my_tx_history_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/my_tx_history_response.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/my_tx_history/transaction.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class TransactionHistoryRepo { + TransactionHistoryRepo({required Mm2Api api, required Client client}) + : _api = api, + _client = client; + final Mm2Api _api; + final Client _client; + + Future> fetch( + Coin coin) async { + if (_checkV2RequestSupport(coin)) { + return await fetchTransactionHistoryV2(MyTxHistoryV2Request( + coin: coin.abbr, + type: coin.enabledType ?? WalletType.iguana, + )); + } + return coin.isErcType + ? await fetchErcTransactionHistory(coin) + : await fetchTransactionHistory( + MyTxHistoryRequest( + coin: coin.abbr, + max: true, + ), + ); + } + + Future> fetchTransactions(Coin coin) async { + final historyResponse = await fetch(coin); + final TransactionHistoryResponseResult? result = historyResponse.data; + + final BaseError? responseError = historyResponse.error; + // TODO: add custom exceptions here? + if (responseError != null) { + throw TransactionFetchException('Transaction fetch error: ${responseError.message}'); + } else if (result == null) { + throw TransactionFetchException('Transaction fetch result is null'); + } + + return result.transactions; + } + + /// Fetches transactions for the provided [coin] where the transaction + /// timestamp is not 0 (transaction is completed and/or confirmed). + Future> fetchCompletedTransactions(Coin coin) async { + final List transactions = await fetchTransactions(coin) + ..sort( + (a, b) => a.timestamp.compareTo(b.timestamp), + ); + transactions.removeWhere((transaction) => transaction.timestamp <= 0); + return transactions; + } + + Future> + fetchTransactionHistoryV2(MyTxHistoryV2Request request) async { + final Map? response = + await _api.getTransactionsHistoryV2(request); + if (response == null) { + return DataFromService( + data: null, + error: TextError(error: LocaleKeys.somethingWrong.tr()), + ); + } + + if (response['error'] != null) { + log(response['error'], + path: 'transaction_history_service => fetchTransactionHistoryV2', + isError: true); + return DataFromService( + data: null, + error: TextError(error: response['error']), + ); + } + + final MyTxHistoryResponse transactionHistory = + MyTxHistoryResponse.fromJson(response); + + return DataFromService( + data: transactionHistory.result, + ); + } + + Future> + fetchTransactionHistory(MyTxHistoryRequest request) async { + final Map? response = + await _api.getTransactionsHistory(request); + if (response == null) { + return DataFromService( + data: null, + error: TextError(error: LocaleKeys.somethingWrong.tr()), + ); + } + + if (response['error'] != null) { + log(response['error'], + path: 'transaction_history_service => fetchTransactionHistory', + isError: true); + return DataFromService( + data: null, + error: TextError(error: response['error']), + ); + } + + final MyTxHistoryResponse transactionHistory = + MyTxHistoryResponse.fromJson(response); + + return DataFromService( + data: transactionHistory.result, + ); + } + + Future> + fetchErcTransactionHistory(Coin coin) async { + final String? url = getErcTransactionHistoryUrl(coin); + if (url == null) { + return DataFromService( + data: null, + error: TextError( + error: LocaleKeys.txHistoryFetchError.tr(args: [coin.typeName]), + ), + ); + } + + try { + final Response response = await _client.get(Uri.parse(url)); + final String body = response.body; + final String result = + body.isNotEmpty ? body : '{"result": {"transactions": []}}'; + final MyTxHistoryResponse transactionHistory = + MyTxHistoryResponse.fromJson(json.decode(result)); + + return DataFromService( + data: _fixTestCoinsNaming(transactionHistory.result, coin), + error: null); + } catch (e, s) { + final String errorString = e.toString(); + log(errorString, + path: 'transaction_history_service => fetchErcTransactionHistory', + trace: s, + isError: true); + return DataFromService( + data: null, + error: TextError( + error: errorString, + ), + ); + } + } + + TransactionHistoryResponseResult _fixTestCoinsNaming( + TransactionHistoryResponseResult result, + Coin originalCoin, + ) { + if (!originalCoin.isTestCoin) return result; + + final String? parentCoin = originalCoin.protocolData?.platform; + final String feeCoin = parentCoin ?? originalCoin.abbr; + + for (Transaction tx in result.transactions) { + tx.coin = originalCoin.abbr; + tx.feeDetails.coin = feeCoin; + } + + return result; + } + + bool _checkV2RequestSupport(Coin coin) => + coin.enabledType == WalletType.trezor || + coin.protocolType == 'BCH' || + coin.type == CoinType.slp || + coin.type == CoinType.iris || + coin.type == CoinType.cosmos; +} + +class TransactionFetchException implements Exception { + TransactionFetchException(this.message); + final String message; +} diff --git a/lib/bloc/transaction_history/transaction_history_state.dart b/lib/bloc/transaction_history/transaction_history_state.dart new file mode 100644 index 0000000000..11e3a68777 --- /dev/null +++ b/lib/bloc/transaction_history/transaction_history_state.dart @@ -0,0 +1,34 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; + +abstract class TransactionHistoryState extends Equatable {} + +class TransactionHistoryInitialState extends TransactionHistoryState { + @override + List get props => []; +} + +class TransactionHistoryInProgressState extends TransactionHistoryState { + TransactionHistoryInProgressState({required this.transactions}); + final List transactions; + + @override + List get props => [transactions]; +} + +class TransactionHistoryLoadedState extends TransactionHistoryState { + TransactionHistoryLoadedState({required this.transactions}); + final List transactions; + + @override + List get props => [transactions]; +} + +class TransactionHistoryFailureState extends TransactionHistoryState { + TransactionHistoryFailureState({required this.error}); + final BaseError error; + + @override + List get props => [error]; +} diff --git a/lib/bloc/transformers.dart b/lib/bloc/transformers.dart new file mode 100644 index 0000000000..bcebd7dd0e --- /dev/null +++ b/lib/bloc/transformers.dart @@ -0,0 +1,33 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; + +EventTransformer debounce([int ms = 300]) { + return (events, mapper) { + final duration = Duration(milliseconds: ms); + final Stream debounced = debounceStream(events, duration); + final Stream> mapped = debounced.map(mapper); + + return flattenStream(mapped); + }; +} + +Stream flattenStream(Stream> source) async* { + await for (var stream in source) { + yield* stream; + } +} + +Stream debounceStream(Stream source, Duration duration) { + final controller = StreamController.broadcast(); + Timer? timer; + + source.listen((T event) async { + timer?.cancel(); + timer = Timer(duration, () { + controller.sink.add(event); + }); + }); + + return controller.stream; +} diff --git a/lib/bloc/trezor_bloc/trezor_repo.dart b/lib/bloc/trezor_bloc/trezor_repo.dart new file mode 100644 index 0000000000..3fb742c3f3 --- /dev/null +++ b/lib/bloc/trezor_bloc/trezor_repo.dart @@ -0,0 +1,131 @@ +import 'dart:async'; + +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api_trezor.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/balance/trezor_balance_status/trezor_balance_status_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/balance/trezor_balance_status/trezor_balance_status_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo/trezor_enable_utxo_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo/trezor_enable_utxo_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo_status/trezor_enable_utxo_status_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo_status/trezor_enable_utxo_status_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor_cancel/init_trezor_cancel_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/trezor_passphrase/trezor_passphrase_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/trezor_pin/trezor_pin_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_cancel/trezor_withdraw_cancel_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_status/trezor_withdraw_status_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_status/trezor_withdraw_status_response.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/hw_wallet/trezor_connection_status.dart'; +import 'package:web_dex/model/hw_wallet/trezor_task.dart'; + +class TrezorRepo { + TrezorRepo({ + required Mm2ApiTrezor api, + }) : _api = api; + + final Mm2ApiTrezor _api; + + Future init() async { + return await _api.init(InitTrezorReq()); + } + + final StreamController _connectionStatusController = + StreamController.broadcast(); + Stream get connectionStatusStream => + _connectionStatusController.stream; + Timer? _connectionStatusTimer; + + Future initStatus(int taskId) async { + return await _api.initStatus(InitTrezorStatusReq(taskId: taskId)); + } + + Future sendPin(String pin, TrezorTask trezorTask) async { + await _api.pin(TrezorPinRequest( + pin: pin, + task: trezorTask, + )); + } + + Future sendPassphrase(String passphrase, TrezorTask trezorTask) async { + await _api.passphrase(TrezorPassphraseRequest( + passphrase: passphrase, + task: trezorTask, + )); + } + + Future initCancel(int taskId) async { + await _api.initCancel(InitTrezorCancelReq(taskId: taskId)); + } + + Future initBalance(Coin coin) async { + return await _api.balanceInit(TrezorBalanceInitRequest(coin: coin)); + } + + Future getBalanceStatus(int taskId) async { + return await _api.balanceStatus(TrezorBalanceStatusRequest(taskId: taskId)); + } + + Future enableUtxo(Coin coin) async { + return await _api.enableUtxo(TrezorEnableUtxoReq(coin: coin)); + } + + Future getEnableUtxoStatus(int taskId) async { + return await _api + .enableUtxoStatus(TrezorEnableUtxoStatusReq(taskId: taskId)); + } + + Future initNewAddress(String coin) async { + return await _api.initNewAddress(coin); + } + + Future getNewAddressStatus(int taskId) async { + return await _api.getNewAddressStatus(taskId); + } + + Future cancelGetNewAddress(int taskId) async { + await _api.cancelGetNewAddress(taskId); + } + + Future withdraw(TrezorWithdrawRequest request) async { + return await _api.withdraw(request); + } + + Future getWithdrawStatus(int taskId) async { + return _api.withdrawStatus(TrezorWithdrawStatusRequest(taskId: taskId)); + } + + Future cancelWithdraw(int taskId) async { + await _api.withdrawCancel(TrezorWithdrawCancelRequest(taskId: taskId)); + } + + void subscribeOnConnectionStatus(String pubKey) { + if (_connectionStatusTimer != null) return; + _connectionStatusTimer = + Timer.periodic(const Duration(seconds: 1), (timer) async { + final TrezorConnectionStatus status = + await _api.getConnectionStatus(pubKey); + _connectionStatusController.sink.add(status); + if (status == TrezorConnectionStatus.unreachable) { + _connectionStatusTimer?.cancel(); + _connectionStatusTimer = null; + } + }); + } + + void unsubscribeFromConnectionStatus() { + if (_connectionStatusTimer == null) return; + _connectionStatusTimer?.cancel(); + _connectionStatusTimer = null; + } +} + +final TrezorRepo trezorRepo = TrezorRepo(api: mm2Api.trezor); diff --git a/lib/bloc/trezor_connection_bloc/trezor_connection_bloc.dart b/lib/bloc/trezor_connection_bloc/trezor_connection_bloc.dart new file mode 100644 index 0000000000..9a135ad81d --- /dev/null +++ b/lib/bloc/trezor_connection_bloc/trezor_connection_bloc.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/trezor_bloc/trezor_repo.dart'; +import 'package:web_dex/bloc/trezor_connection_bloc/trezor_connection_event.dart'; +import 'package:web_dex/bloc/trezor_connection_bloc/trezor_connection_state.dart'; +import 'package:web_dex/blocs/current_wallet_bloc.dart'; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/hw_wallet/trezor_connection_status.dart'; +import 'package:web_dex/model/wallet.dart'; + +class TrezorConnectionBloc + extends Bloc { + TrezorConnectionBloc({ + required TrezorRepo trezorRepo, + required AuthRepository authRepo, + required CurrentWalletBloc walletRepo, + }) : _authRepo = authRepo, + _walletRepo = walletRepo, + super(TrezorConnectionState.initial()) { + _trezorConnectionStatusListener = trezorRepo.connectionStatusStream + .listen(_onTrezorConnectionStatusChanged); + _authModeListener = _authRepo.authMode.listen(_onAuthModeChanged); + + on(_onConnectionStatusChange); + } + + void _onTrezorConnectionStatusChanged(TrezorConnectionStatus status) { + add(TrezorConnectionStatusChange(status: status)); + } + + void _onAuthModeChanged(AuthorizeMode mode) { + if (mode == AuthorizeMode.logIn) { + final Wallet? currentWallet = _walletRepo.wallet; + if (currentWallet == null) return; + if (currentWallet.config.type != WalletType.trezor) return; + + final String? pubKey = currentWallet.config.pubKey; + if (pubKey == null) return; + + trezorRepo.subscribeOnConnectionStatus(pubKey); + } else { + trezorRepo.unsubscribeFromConnectionStatus(); + } + } + + final AuthRepository _authRepo; + final CurrentWalletBloc _walletRepo; + late StreamSubscription + _trezorConnectionStatusListener; + late StreamSubscription _authModeListener; + + Future _onConnectionStatusChange(TrezorConnectionStatusChange event, + Emitter emit) async { + final status = event.status; + emit(TrezorConnectionState(status: status)); + + switch (status) { + case TrezorConnectionStatus.unreachable: + final MM2Status mm2Status = await mm2.status(); + if (mm2Status == MM2Status.rpcIsUp) await authRepo.logOut(); + await _authRepo.logIn(AuthorizeMode.noLogin); + return; + case TrezorConnectionStatus.unknown: + case TrezorConnectionStatus.connected: + return; + } + } + + @override + Future close() { + _trezorConnectionStatusListener.cancel(); + _authModeListener.cancel(); + return super.close(); + } +} diff --git a/lib/bloc/trezor_connection_bloc/trezor_connection_event.dart b/lib/bloc/trezor_connection_bloc/trezor_connection_event.dart new file mode 100644 index 0000000000..9c4864be01 --- /dev/null +++ b/lib/bloc/trezor_connection_bloc/trezor_connection_event.dart @@ -0,0 +1,10 @@ +import 'package:web_dex/model/hw_wallet/trezor_connection_status.dart'; + +abstract class TrezorConnectionEvent { + const TrezorConnectionEvent(); +} + +class TrezorConnectionStatusChange extends TrezorConnectionEvent { + const TrezorConnectionStatusChange({required this.status}); + final TrezorConnectionStatus status; +} diff --git a/lib/bloc/trezor_connection_bloc/trezor_connection_state.dart b/lib/bloc/trezor_connection_bloc/trezor_connection_state.dart new file mode 100644 index 0000000000..26410a4459 --- /dev/null +++ b/lib/bloc/trezor_connection_bloc/trezor_connection_state.dart @@ -0,0 +1,13 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/model/hw_wallet/trezor_connection_status.dart'; + +class TrezorConnectionState extends Equatable { + const TrezorConnectionState({required this.status}); + final TrezorConnectionStatus status; + + static TrezorConnectionState initial() => + const TrezorConnectionState(status: TrezorConnectionStatus.unknown); + + @override + List get props => [status]; +} diff --git a/lib/bloc/trezor_init_bloc/trezor_init_bloc.dart b/lib/bloc/trezor_init_bloc/trezor_init_bloc.dart new file mode 100644 index 0000000000..1dfbb7430f --- /dev/null +++ b/lib/bloc/trezor_init_bloc/trezor_init_bloc.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/trezor_bloc/trezor_repo.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_event.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_state.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_response.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/hw_wallet/init_trezor.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status_error.dart'; +import 'package:web_dex/model/hw_wallet/trezor_task.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class TrezorInitBloc extends Bloc { + TrezorInitBloc({ + required AuthRepository authRepo, + required TrezorRepo trezorRepo, + }) : _trezorRepo = trezorRepo, + _authRepo = authRepo, + super(TrezorInitState.initial()) { + on(_onSubscribeStatus); + on(_onInit); + on(_onReset); + on(_onUpdateStatus); + on(_onInitSuccess); + on(_onSendPin); + on(_onSendPassphrase); + on(_onAuthModeChange); + + _authorizationSubscription = _authRepo.authMode.listen((event) { + add(TrezorInitUpdateAuthMode(event)); + }); + } + + late StreamSubscription _authorizationSubscription; + final TrezorRepo _trezorRepo; + final AuthRepository _authRepo; + Timer? _statusTimer; + + void _unsubscribeStatus() { + _statusTimer?.cancel(); + _statusTimer = null; + } + + void _checkAndHandleSuccess(InitTrezorStatusData status) { + final InitTrezorStatus trezorStatus = status.trezorStatus; + final TrezorDeviceDetails? deviceDetails = status.details.deviceDetails; + + if (trezorStatus == InitTrezorStatus.ok && deviceDetails != null) { + add(TrezorInitSuccess(deviceDetails)); + } + } + + Future _onInit(TrezorInit event, Emitter emit) async { + if (state.inProgress) return; + emit(state.copyWith(inProgress: () => true)); + await _loginToHiddenMode(); + + final InitTrezorRes response = await _trezorRepo.init(); + final String? responseError = response.error; + final InitTrezorResult? responseResult = response.result; + + if (responseError != null) { + emit(state.copyWith( + error: () => TextError(error: responseError), + inProgress: () => false, + )); + await _logoutFromHiddenMode(); + return; + } + if (responseResult == null) { + emit(state.copyWith( + error: () => TextError(error: LocaleKeys.somethingWrong.tr()), + inProgress: () => false, + )); + await _logoutFromHiddenMode(); + return; + } + + add(const TrezorInitSubscribeStatus()); + emit(state.copyWith( + taskId: () => responseResult.taskId, + inProgress: () => false, + )); + } + + Future _onSubscribeStatus( + TrezorInitSubscribeStatus event, Emitter emit) async { + add(const TrezorInitUpdateStatus()); + _statusTimer = Timer.periodic(const Duration(milliseconds: 1000), (timer) { + add(const TrezorInitUpdateStatus()); + }); + } + + FutureOr _onUpdateStatus( + TrezorInitUpdateStatus event, Emitter emit) async { + final int? taskId = state.taskId; + if (taskId == null) return; + + final InitTrezorStatusRes response = await _trezorRepo.initStatus(taskId); + + if (response.errorType == 'NoSuchTask') { + _unsubscribeStatus(); + emit(state.copyWith(taskId: () => null)); + await _logoutFromHiddenMode(); + return; + } + + final String? responseError = response.error; + + if (responseError != null) { + emit(state.copyWith(error: () => TextError(error: responseError))); + await _logoutFromHiddenMode(); + return; + } + + final InitTrezorStatusData? initTrezorStatus = response.result; + if (initTrezorStatus == null) { + emit(state.copyWith( + error: () => + TextError(error: 'Something went wrong. Empty init status.'))); + + await _logoutFromHiddenMode(); + return; + } + + _checkAndHandleSuccess(initTrezorStatus); + if (_checkAndHandleInvalidPin(initTrezorStatus)) { + emit(state.copyWith(taskId: () => null)); + _unsubscribeStatus(); + } + + emit(state.copyWith(status: () => initTrezorStatus)); + } + + Future _onInitSuccess( + TrezorInitSuccess event, Emitter emit) async { + _unsubscribeStatus(); + final deviceDetails = event.details; + + final String name = deviceDetails.name ?? 'My Trezor'; + final Wallet? wallet = await walletsBloc.importTrezorWallet( + name: name, + pubKey: deviceDetails.pubKey, + ); + + if (wallet == null) { + emit(state.copyWith( + error: () => TextError( + error: LocaleKeys.trezorImportFailed.tr(args: [name])))); + + await _logoutFromHiddenMode(); + return; + } + + await coinsBloc.deactivateWalletCoins(); + currentWalletBloc.wallet = wallet; + routingState.selectedMenu = MainMenuValue.wallet; + _authRepo.setAuthMode(AuthorizeMode.logIn); + _trezorRepo.subscribeOnConnectionStatus(deviceDetails.pubKey); + rebuildAll(null); + } + + Future _onSendPin( + TrezorInitSendPin event, Emitter emit) async { + final int? taskId = state.taskId; + + if (taskId == null) return; + await _trezorRepo.sendPin( + event.pin, + TrezorTask( + taskId: taskId, + type: TrezorTaskType.initTrezor, + ), + ); + } + + Future _onSendPassphrase( + TrezorInitSendPassphrase event, Emitter emit) async { + final int? taskId = state.taskId; + + if (taskId == null) return; + + await _trezorRepo.sendPassphrase( + event.passphrase, + TrezorTask( + taskId: taskId, + type: TrezorTaskType.initTrezor, + ), + ); + } + + FutureOr _onReset( + TrezorInitReset event, Emitter emit) async { + _unsubscribeStatus(); + final taskId = state.taskId; + + if (taskId != null) { + await _trezorRepo.initCancel(taskId); + } + _logoutFromHiddenMode(); + emit(state.copyWith( + taskId: () => null, + status: () => null, + error: () => null, + )); + } + + FutureOr _onAuthModeChange( + TrezorInitUpdateAuthMode event, Emitter emit) { + emit(state.copyWith(authMode: () => event.authMode)); + } + + Future _loginToHiddenMode() async { + final MM2Status mm2Status = await mm2.status(); + + if (state.authMode == AuthorizeMode.hiddenLogin && + mm2Status == MM2Status.rpcIsUp) return; + + if (mm2Status == MM2Status.rpcIsUp) await _authRepo.logOut(); + await _authRepo.logIn(AuthorizeMode.hiddenLogin, seedForHiddenLogin); + } + + Future _logoutFromHiddenMode() async { + final MM2Status mm2Status = await mm2.status(); + + if (state.authMode != AuthorizeMode.hiddenLogin && + mm2Status == MM2Status.rpcIsUp) return; + + if (mm2Status == MM2Status.rpcIsUp) await _authRepo.logOut(); + await _authRepo.logIn(AuthorizeMode.noLogin); + } + + bool _checkAndHandleInvalidPin(InitTrezorStatusData status) { + if (status.trezorStatus != InitTrezorStatus.error) return false; + if (status.details.errorDetails == null) return false; + if (status.details.errorDetails!.errorData != + TrezorStatusErrorData.invalidPin) return false; + + return true; + } + + @override + Future close() { + _unsubscribeStatus(); + _authorizationSubscription.cancel(); + _logoutFromHiddenMode(); + return super.close(); + } +} diff --git a/lib/bloc/trezor_init_bloc/trezor_init_event.dart b/lib/bloc/trezor_init_bloc/trezor_init_event.dart new file mode 100644 index 0000000000..b508173bc5 --- /dev/null +++ b/lib/bloc/trezor_init_bloc/trezor_init_event.dart @@ -0,0 +1,43 @@ +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/hw_wallet/init_trezor.dart'; + +abstract class TrezorInitEvent { + const TrezorInitEvent(); +} + +class TrezorInitUpdateAuthMode extends TrezorInitEvent { + const TrezorInitUpdateAuthMode(this.authMode); + final AuthorizeMode authMode; +} + +class TrezorInit extends TrezorInitEvent { + const TrezorInit(); +} + +class TrezorInitReset extends TrezorInitEvent { + const TrezorInitReset(); +} + +class TrezorInitSubscribeStatus extends TrezorInitEvent { + const TrezorInitSubscribeStatus(); +} + +class TrezorInitUpdateStatus extends TrezorInitEvent { + const TrezorInitUpdateStatus(); +} + +class TrezorInitSuccess extends TrezorInitEvent { + const TrezorInitSuccess(this.details); + + final TrezorDeviceDetails details; +} + +class TrezorInitSendPin extends TrezorInitEvent { + const TrezorInitSendPin(this.pin); + final String pin; +} + +class TrezorInitSendPassphrase extends TrezorInitEvent { + const TrezorInitSendPassphrase(this.passphrase); + final String passphrase; +} diff --git a/lib/bloc/trezor_init_bloc/trezor_init_state.dart b/lib/bloc/trezor_init_bloc/trezor_init_state.dart new file mode 100644 index 0000000000..5a27a72f1d --- /dev/null +++ b/lib/bloc/trezor_init_bloc/trezor_init_state.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/hw_wallet/init_trezor.dart'; +import 'package:web_dex/model/text_error.dart'; + +class TrezorInitState extends Equatable { + const TrezorInitState({ + required this.taskId, + required this.authMode, + required this.status, + this.error, + required this.inProgress, + }); + static TrezorInitState initial() => const TrezorInitState( + taskId: null, + authMode: null, + error: null, + status: null, + inProgress: false, + ); + + TrezorInitState copyWith({ + int? Function()? taskId, + AuthorizeMode? Function()? authMode, + TextError? Function()? error, + InitTrezorStatusData? Function()? status, + bool Function()? inProgress, + }) => + TrezorInitState( + taskId: taskId != null ? taskId() : this.taskId, + authMode: authMode != null ? authMode() : this.authMode, + status: status != null ? status() : this.status, + error: error != null ? error() : this.error, + inProgress: inProgress != null ? inProgress() : this.inProgress, + ); + + final int? taskId; + final AuthorizeMode? authMode; + final InitTrezorStatusData? status; + final TextError? error; + final bool inProgress; + + @override + List get props => [taskId, authMode, status, error, inProgress]; +} diff --git a/lib/bloc/wallets_bloc/wallets_repo.dart b/lib/bloc/wallets_bloc/wallets_repo.dart new file mode 100644 index 0000000000..b7c6674e1e --- /dev/null +++ b/lib/bloc/wallets_bloc/wallets_repo.dart @@ -0,0 +1,45 @@ +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/services/storage/base_storage.dart'; +import 'package:web_dex/services/storage/get_storage.dart'; + +class WalletsRepo { + WalletsRepo({required BaseStorage storage}) : _storage = storage; + final BaseStorage _storage; + + Future> getAll() async { + final List> json = + (await _storage.read(allWalletsStorageKey) as List?) + ?.cast>() ?? + >[]; + final List wallets = + json.map((Map w) => Wallet.fromJson(w)).toList(); + + return wallets; + } + + Future save(Wallet wallet) async { + final wallets = await getAll(); + final int walletIndex = wallets.indexWhere((w) => w.id == wallet.id); + + if (walletIndex == -1) { + wallets.add(wallet); + } else { + wallets[walletIndex] = wallet; + } + + return _write(wallets); + } + + Future delete(Wallet wallet) async { + final wallets = await getAll(); + wallets.removeWhere((w) => w.id == wallet.id); + return _write(wallets); + } + + Future _write(List wallets) { + return _storage.write(allWalletsStorageKey, wallets); + } +} + +final WalletsRepo walletsRepo = WalletsRepo(storage: getStorage()); diff --git a/lib/bloc/withdraw_form/withdraw_form_bloc.dart b/lib/bloc/withdraw_form/withdraw_form_bloc.dart new file mode 100644 index 0000000000..a0dc502416 --- /dev/null +++ b/lib/bloc/withdraw_form/withdraw_form_bloc.dart @@ -0,0 +1,613 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_event.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_state.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_step.dart'; +import 'package:web_dex/blocs/coins_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/send_raw_transaction/send_raw_transaction_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/validateaddress/validateaddress_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/fee/fee_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/fee_type.dart'; +import 'package:web_dex/model/hw_wallet/trezor_progress_status.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/utils/utils.dart'; + +export 'package:web_dex/bloc/withdraw_form/withdraw_form_event.dart'; +export 'package:web_dex/bloc/withdraw_form/withdraw_form_state.dart'; +export 'package:web_dex/bloc/withdraw_form/withdraw_form_step.dart'; + +class WithdrawFormBloc extends Bloc { + WithdrawFormBloc( + {required Coin coin, required CoinsBloc coinsBloc, required this.goBack}) + : _coinsRepo = coinsBloc, + super(WithdrawFormState.initial(coin, coinsBloc)) { + on(_onAddressChanged); + on(_onAmountChanged); + on(_onCustomFeeChanged); + on(_onCustomEvmFeeChanged); + on(_onSenderAddressChanged); + on(_onMaxTapped); + on(_onSubmitted); + on(_onWithdrawSuccess); + on(_onWithdrawFailed); + on(_onSendRawTransaction); + on(_onCustomFeeEnabled); + on(_onCustomFeeDisabled); + on(_onConvertMixedCaseAddress); + on(_onStepReverted); + on(_onWithdrawFormReset); + on(_onTrezorProgressUpdated); + on(_onMemoUpdated); + } + + // will use actual CoinsRepo when implemented + final CoinsBloc _coinsRepo; + final VoidCallback goBack; + + // Event handlers + void _onAddressChanged( + WithdrawFormAddressChanged event, + Emitter emitter, + ) { + emitter(state.copyWith(address: event.address)); + } + + void _onAmountChanged( + WithdrawFormAmountChanged event, + Emitter emitter, + ) { + emitter(state.copyWith(amount: event.amount, isMaxAmount: false)); + } + + void _onCustomFeeEnabled( + WithdrawFormCustomFeeEnabled event, + Emitter emitter, + ) { + emitter(state.copyWith( + isCustomFeeEnabled: true, customFee: FeeRequest(type: _customFeeType))); + } + + void _onCustomFeeDisabled( + WithdrawFormCustomFeeDisabled event, + Emitter emitter, + ) { + emitter(state.copyWith( + isCustomFeeEnabled: false, + customFee: FeeRequest(type: _customFeeType), + gasLimitError: TextError.empty(), + gasPriceError: TextError.empty(), + utxoCustomFeeError: TextError.empty(), + )); + } + + void _onCustomFeeChanged( + WithdrawFormCustomFeeChanged event, + Emitter emitter, + ) { + emitter(state.copyWith( + customFee: FeeRequest( + type: feeType.utxoFixed, + amount: event.amount, + ), + )); + } + + void _onCustomEvmFeeChanged( + WithdrawFormCustomEvmFeeChanged event, + Emitter emitter, + ) { + emitter(state.copyWith( + customFee: FeeRequest( + type: feeType.ethGas, + gas: event.gas, + gasPrice: event.gasPrice, + ), + )); + } + + void _onSenderAddressChanged( + WithdrawFormSenderAddressChanged event, + Emitter emitter, + ) { + emitter( + state.copyWith( + selectedSenderAddress: event.address, + amount: state.isMaxAmount + ? doubleToString( + state.coin.getHdAddress(event.address)?.balance.spendable ?? + 0.0) + : state.amount, + ), + ); + } + + void _onMaxTapped( + WithdrawFormMaxTapped event, + Emitter emitter, + ) { + emitter(state.copyWith( + amount: event.isEnabled ? doubleToString(state.senderAddressBalance) : '', + isMaxAmount: event.isEnabled, + )); + } + + Future _onSubmitted( + WithdrawFormSubmitted event, + Emitter emitter, + ) async { + if (state.isSending) return; + emitter(state.copyWith( + isSending: true, + trezorProgressStatus: null, + sendError: TextError.empty(), + amountError: TextError.empty(), + addressError: TextError.empty(), + gasLimitError: TextError.empty(), + gasPriceError: TextError.empty(), + utxoCustomFeeError: TextError.empty(), + )); + + bool isValid = await _validateEnterForm(emitter); + if (!isValid) { + return; + } + + isValid = await _additionalValidate(emitter); + if (!isValid) { + emitter(state.copyWith(isSending: false)); + return; + } + + final withdrawResponse = state.coin.enabledType == WalletType.trezor + ? await _coinsRepo.trezor.withdraw( + TrezorWithdrawRequest( + coin: state.coin, + from: state.selectedSenderAddress, + to: state.address, + amount: state.isMaxAmount + ? state.senderAddressBalance + : double.parse(state.amount), + max: state.isMaxAmount, + fee: state.isCustomFeeEnabled ? state.customFee : null, + ), + onProgressUpdated: (TrezorProgressStatus? status) { + add(WithdrawFormTrezorStatusUpdated(status: status)); + }, + ) + : await _coinsRepo.withdraw(WithdrawRequest( + to: state.address, + coin: state.coin.abbr, + max: state.isMaxAmount, + amount: state.isMaxAmount ? null : state.amount, + memo: state.memo, + fee: state.isCustomFeeEnabled + ? state.customFee + : state.coin.type == CoinType.cosmos || + state.coin.type == CoinType.iris + ? FeeRequest( + type: feeType.cosmosGas, + gasLimit: 150000, + gasPrice: 0.05, + ) + : null, + )); + + final BaseError? error = withdrawResponse.error; + final WithdrawDetails? result = withdrawResponse.result; + + if (error != null) { + add(WithdrawFormWithdrawFailed(error: error)); + log('WithdrawFormBloc: withdraw error: ${error.message}', isError: true); + return; + } + + if (result == null) { + emitter(state.copyWith( + sendError: TextError(error: LocaleKeys.somethingWrong.tr()), + isSending: false, + )); + return; + } + + add(WithdrawFormWithdrawSuccessful(details: result)); + } + + void _onWithdrawSuccess( + WithdrawFormWithdrawSuccessful event, + Emitter emitter, + ) { + emitter(state.copyWith( + isSending: false, + withdrawDetails: event.details, + step: WithdrawFormStep.confirm, + )); + } + + void _onWithdrawFailed( + WithdrawFormWithdrawFailed event, + Emitter emitter, + ) { + final error = event.error; + + emitter(state.copyWith( + sendError: error, + isSending: false, + step: WithdrawFormStep.failed, + )); + } + + void _onTrezorProgressUpdated( + WithdrawFormTrezorStatusUpdated event, + Emitter emitter, + ) { + String? message; + + switch (event.status) { + case TrezorProgressStatus.waitingForUserToConfirmSigning: + message = LocaleKeys.confirmOnTrezor.tr(); + break; + default: + } + + if (state.trezorProgressStatus != message) { + emitter(state.copyWith(trezorProgressStatus: message)); + } + } + + Future _onConvertMixedCaseAddress( + WithdrawFormConvertAddress event, + Emitter emitter, + ) async { + final result = await coinsRepo.convertLegacyAddress( + state.coin, + state.address, + ); + + add(WithdrawFormAddressChanged(address: result ?? '')); + } + + Future _onSendRawTransaction( + WithdrawFormSendRawTx event, + Emitter emitter, + ) async { + if (state.isSending) return; + emitter(state.copyWith(isSending: true, sendError: TextError.empty())); + final BaseError? parentCoinError = _checkParentCoinErrors( + coin: state.coin, + fee: state.withdrawDetails.feeValue, + ); + if (parentCoinError != null) { + emitter(state.copyWith( + isSending: false, + sendError: parentCoinError, + )); + return; + } + + final response = await _coinsRepo.sendRawTransaction( + SendRawTransactionRequest( + coin: state.withdrawDetails.coin, + txHex: state.withdrawDetails.txHex, + ), + ); + + final BaseError? responseError = response.error; + final String? txHash = response.txHash; + + if (responseError != null) { + log( + 'WithdrawFormBloc: sendRawTransaction error: ${responseError.message}', + isError: true, + ); + emitter(state.copyWith( + isSending: false, + sendError: responseError, + step: WithdrawFormStep.failed, + )); + return; + } + + if (txHash == null) { + emitter(state.copyWith( + isSending: false, + sendError: TextError(error: LocaleKeys.somethingWrong.tr()), + step: WithdrawFormStep.failed, + )); + return; + } + await _coinsRepo.updateBalances(); + emitter(state.copyWith(step: WithdrawFormStep.success)); + } + + void _onStepReverted( + WithdrawFormStepReverted event, + Emitter emitter, + ) { + if (event.step == WithdrawFormStep.confirm) { + emitter( + state.copyWith( + step: WithdrawFormStep.fill, + withdrawDetails: WithdrawDetails.empty(), + ), + ); + } + } + + void _onMemoUpdated( + WithdrawFormMemoUpdated event, + Emitter emitter, + ) { + emitter(state.copyWith(memo: event.text)); + } + + void _onWithdrawFormReset( + WithdrawFormReset event, + Emitter emitter, + ) { + emitter(WithdrawFormState.initial(state.coin, _coinsRepo)); + } + + String get _customFeeType => + state.coin.type == CoinType.smartChain || state.coin.type == CoinType.utxo + ? feeType.utxoFixed + : feeType.ethGas; + + // Validators + Future _additionalValidate(Emitter emitter) async { + final BaseError? parentCoinError = _checkParentCoinErrors(coin: state.coin); + if (parentCoinError != null) { + emitter(state.copyWith(sendError: parentCoinError)); + return false; + } + return true; + } + + Future _validateEnterForm(Emitter emitter) async { + final bool isAddressValid = await _validateAddress(emitter); + final bool isAmountValid = _validateAmount(emitter); + final bool isCustomFeeValid = _validateCustomFee(emitter); + + return isAddressValid && isAmountValid && isCustomFeeValid; + } + + Future _validateAddress(Emitter emitter) async { + final String address = state.address; + if (address.isEmpty) { + emitter(state.copyWith( + isSending: false, + addressError: TextError( + error: LocaleKeys.invalidAddress.tr(args: [state.coin.abbr])), + )); + return false; + } + if (state.coin.enabledType == WalletType.trezor && + state.selectedSenderAddress.isEmpty) { + emitter(state.copyWith( + isSending: false, + addressError: TextError(error: LocaleKeys.noSenderAddress.tr()), + )); + return false; + } + + final Map? validateRawResponse = + await coinsRepo.validateCoinAddress( + state.coin, + state.address, + ); + if (validateRawResponse == null) { + emitter(state.copyWith( + isSending: false, + addressError: TextError( + error: LocaleKeys.invalidAddress.tr(args: [state.coin.abbr])), + )); + return false; + } + + final ValidateAddressResponse validateResponse = + ValidateAddressResponse.fromJson(validateRawResponse); + + final reason = validateResponse.reason ?? ''; + final isNonMixed = _isErcNonMixedCase(reason); + final isValid = validateResponse.isValid; + + if (isNonMixed) { + emitter(state.copyWith( + isSending: false, + addressError: MixedCaseAddressError(), + )); + return false; + } else if (!isValid) { + emitter(state.copyWith( + isSending: false, + addressError: TextError( + error: LocaleKeys.invalidAddress.tr(args: [state.coin.abbr])), + )); + return false; + } + + emitter(state.copyWith( + addressError: TextError.empty(), + amountError: state.amountError, + )); + return true; + } + + bool _isErcNonMixedCase(String error) { + if (!state.coin.isErcType) return false; + if (!error.contains(LocaleKeys.invalidAddressChecksum.tr())) return false; + return true; + } + + bool _validateAmount(Emitter emitter) { + if (state.amount.isEmpty) { + emitter(state.copyWith( + isSending: false, + amountError: TextError( + error: LocaleKeys.enterAmountToSend.tr(args: [state.coin.abbr]), + ))); + return false; + } + final double? parsedValue = double.tryParse(state.amount); + + if (parsedValue == null) { + emitter(state.copyWith( + isSending: false, + amountError: TextError( + error: LocaleKeys.enterAmountToSend.tr(args: [state.coin.abbr]), + ))); + return false; + } + + if (parsedValue == 0) { + emitter(state.copyWith( + isSending: false, + amountError: TextError( + error: LocaleKeys.inferiorSendAmount.tr(args: [state.coin.abbr]), + ))); + return false; + } + + final double formattedBalance = + double.parse(doubleToString(state.senderAddressBalance)); + + if (parsedValue > formattedBalance) { + emitter(state.copyWith( + isSending: false, + amountError: TextError( + error: LocaleKeys.notEnoughBalance.tr(), + ))); + return false; + } + + if (state.isCustomFeeEnabled && + !state.isMaxAmount && + state.customFee.type == feeType.utxoFixed) { + final double feeValue = + double.tryParse(state.customFee.amount ?? '0.0') ?? 0.0; + if ((parsedValue + feeValue) > formattedBalance) { + emitter(state.copyWith( + isSending: false, + amountError: TextError( + error: LocaleKeys.notEnoughBalance.tr(), + ))); + return false; + } + } + + return true; + } + + bool _validateCustomFee(Emitter emitter) { + final customFee = state.customFee; + if (!state.isCustomFeeEnabled) { + return true; + } + if (customFee.type == feeType.utxoFixed) { + return _validateUtxoCustomFee(emitter); + } + if (customFee.type == feeType.ethGas) { + return _validateEvmCustomFee(emitter); + } + return true; + } + + bool _validateUtxoCustomFee(Emitter emitter) { + final value = state.customFee.amount; + final double? feeAmount = _valueToAmount(value); + if (feeAmount == null || feeAmount < 0) { + emitter(state.copyWith( + isSending: false, + utxoCustomFeeError: + TextError(error: LocaleKeys.pleaseInputData.tr()))); + return false; + } + final double amountToSend = state.amountToSendDouble; + + if (feeAmount > amountToSend) { + emitter(state.copyWith( + isSending: false, + utxoCustomFeeError: + TextError(error: LocaleKeys.customFeeHigherAmount.tr()))); + return false; + } + + return true; + } + + bool _validateEvmCustomFee(Emitter emitter) { + final bool isGasLimitValid = _gasLimitValidator(emitter); + final bool isGasPriceValid = _gasPriceValidator(emitter); + return isGasLimitValid && isGasPriceValid; + } + + BaseError? _checkParentCoinErrors({required Coin? coin, String? fee}) { + final Coin? parentCoin = coin?.parentCoin; + if (parentCoin == null) return null; + + if (!parentCoin.isActive) { + return TextError( + error: + LocaleKeys.withdrawNoParentCoinError.tr(args: [parentCoin.abbr])); + } + + final double balance = parentCoin.balance; + + if (balance == 0) { + return TextError( + error: LocaleKeys.withdrawTopUpBalanceError.tr(args: [parentCoin.abbr]), + ); + } else if (fee != null && parentCoin.balance < double.parse(fee)) { + return TextError( + error: LocaleKeys.withdrawNotEnoughBalanceForGasError + .tr(args: [parentCoin.abbr]), + ); + } + + return null; + } + + bool _gasLimitValidator(Emitter emitter) { + final value = state.customFee.gas.toString(); + final double? feeAmount = _valueToAmount(value); + if (feeAmount == null || feeAmount < 0) { + emitter(state.copyWith( + isSending: false, + gasLimitError: TextError(error: LocaleKeys.pleaseInputData.tr()))); + return false; + } + return true; + } + + bool _gasPriceValidator(Emitter emitter) { + final value = state.customFee.gasPrice; + final double? feeAmount = _valueToAmount(value); + if (feeAmount == null || feeAmount < 0) { + emitter(state.copyWith( + isSending: false, + gasPriceError: TextError(error: LocaleKeys.pleaseInputData.tr()))); + return false; + } + return true; + } + + double? _valueToAmount(String? value) { + if (value == null) return null; + value = value.replaceAll(',', '.'); + return double.tryParse(value); + } +} + +class MixedCaseAddressError extends BaseError { + @override + String get message => LocaleKeys.mixedCaseError.tr(); +} diff --git a/lib/bloc/withdraw_form/withdraw_form_event.dart b/lib/bloc/withdraw_form/withdraw_form_event.dart new file mode 100644 index 0000000000..4a8982ae8b --- /dev/null +++ b/lib/bloc/withdraw_form/withdraw_form_event.dart @@ -0,0 +1,131 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_step.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/hw_wallet/trezor_progress_status.dart'; +import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; + +abstract class WithdrawFormEvent extends Equatable { + const WithdrawFormEvent(); + + @override + List get props => []; +} + +class WithdrawFormAddressChanged extends WithdrawFormEvent { + const WithdrawFormAddressChanged({required this.address}); + final String address; + + @override + List get props => [address]; +} + +class WithdrawFormAmountChanged extends WithdrawFormEvent { + const WithdrawFormAmountChanged({required this.amount}); + final String amount; + + @override + List get props => [amount]; +} + +class WithdrawFormCustomFeeChanged extends WithdrawFormEvent { + const WithdrawFormCustomFeeChanged({required this.amount}); + final String amount; + + @override + List get props => [amount]; +} + +class WithdrawFormCustomEvmFeeChanged extends WithdrawFormEvent { + const WithdrawFormCustomEvmFeeChanged({this.gasPrice, this.gas}); + final String? gasPrice; + final int? gas; + + @override + List get props => [gasPrice, gas]; +} + +class WithdrawFormSenderAddressChanged extends WithdrawFormEvent { + const WithdrawFormSenderAddressChanged({required this.address}); + final String address; + + @override + List get props => [address]; +} + +class WithdrawFormMaxTapped extends WithdrawFormEvent { + const WithdrawFormMaxTapped({required this.isEnabled}); + final bool isEnabled; + + @override + List get props => [isEnabled]; +} + +class WithdrawFormWithdrawSuccessful extends WithdrawFormEvent { + const WithdrawFormWithdrawSuccessful({required this.details}); + final WithdrawDetails details; + + @override + List get props => [details]; +} + +class WithdrawFormWithdrawFailed extends WithdrawFormEvent { + const WithdrawFormWithdrawFailed({required this.error}); + final BaseError error; + + @override + List get props => [error]; +} + +class WithdrawFormTrezorStatusUpdated extends WithdrawFormEvent { + const WithdrawFormTrezorStatusUpdated({required this.status}); + final TrezorProgressStatus? status; + + @override + List get props => [status]; +} + +class WithdrawFormSendRawTx extends WithdrawFormEvent { + const WithdrawFormSendRawTx(); + + @override + List get props => []; +} + +class WithdrawFormCustomFeeDisabled extends WithdrawFormEvent { + const WithdrawFormCustomFeeDisabled(); + + @override + List get props => []; +} + +class WithdrawFormCustomFeeEnabled extends WithdrawFormEvent { + const WithdrawFormCustomFeeEnabled(); + + @override + List get props => []; +} + +class WithdrawFormConvertAddress extends WithdrawFormEvent { + const WithdrawFormConvertAddress(); + + @override + List get props => []; +} + +class WithdrawFormSubmitted extends WithdrawFormEvent { + const WithdrawFormSubmitted(); +} + +class WithdrawFormReset extends WithdrawFormEvent { + const WithdrawFormReset(); +} + +class WithdrawFormStepReverted extends WithdrawFormEvent { + const WithdrawFormStepReverted({required this.step}); + final WithdrawFormStep step; +} + +class WithdrawFormMemoUpdated extends WithdrawFormEvent { + const WithdrawFormMemoUpdated({required this.text}); + final String? text; +} diff --git a/lib/bloc/withdraw_form/withdraw_form_state.dart b/lib/bloc/withdraw_form/withdraw_form_state.dart new file mode 100644 index 0000000000..4c8ee32be4 --- /dev/null +++ b/lib/bloc/withdraw_form/withdraw_form_state.dart @@ -0,0 +1,207 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_step.dart'; +import 'package:web_dex/blocs/coins_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/fee/fee_request.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/hd_account/hd_account.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/utils/utils.dart'; + +class WithdrawFormState extends Equatable { + const WithdrawFormState({ + required this.coin, + required this.step, + required this.address, + required this.amount, + required this.senderAddresses, + required this.selectedSenderAddress, + required bool isMaxAmount, + required this.customFee, + required this.withdrawDetails, + required this.isSending, + required this.trezorProgressStatus, + required this.sendError, + required this.addressError, + required this.amountError, + required this.utxoCustomFeeError, + required this.gasLimitError, + required this.gasPriceError, + required this.isCustomFeeEnabled, + required this.memo, + required CoinsBloc coinsBloc, + }) : _isMaxAmount = isMaxAmount, + _coinsRepo = coinsBloc; + + static WithdrawFormState initial(Coin coin, CoinsBloc coinsBloc) { + final List initSenderAddresses = coin.nonEmptyHdAddresses(); + final String selectedSenderAddress = + initSenderAddresses.isNotEmpty ? initSenderAddresses.first.address : ''; + + return WithdrawFormState( + coin: coin, + step: WithdrawFormStep.fill, + address: '', + amount: '', + senderAddresses: initSenderAddresses, + selectedSenderAddress: selectedSenderAddress, + isMaxAmount: false, + customFee: FeeRequest(type: ''), + withdrawDetails: WithdrawDetails.empty(), + isSending: false, + isCustomFeeEnabled: false, + trezorProgressStatus: null, + sendError: TextError.empty(), + addressError: TextError.empty(), + amountError: TextError.empty(), + utxoCustomFeeError: TextError.empty(), + gasLimitError: TextError.empty(), + gasPriceError: TextError.empty(), + memo: null, + coinsBloc: coinsBloc, + ); + } + + WithdrawFormState copyWith({ + Coin? coin, + String? address, + String? amount, + WithdrawFormStep? step, + FeeRequest? customFee, + List? senderAddresses, + String? selectedSenderAddress, + bool? isMaxAmount, + BaseError? sendError, + BaseError? addressError, + BaseError? amountError, + BaseError? utxoCustomFeeError, + BaseError? gasLimitError, + BaseError? gasPriceError, + WithdrawDetails? withdrawDetails, + bool? isSending, + bool? isCustomFeeEnabled, + String? trezorProgressStatus, + String? memo, + CoinsBloc? coinsBloc, + }) { + return WithdrawFormState( + coin: coin ?? this.coin, + address: address ?? this.address, + amount: amount ?? this.amount, + step: step ?? this.step, + customFee: customFee ?? this.customFee, + isMaxAmount: isMaxAmount ?? this.isMaxAmount, + senderAddresses: senderAddresses ?? this.senderAddresses, + selectedSenderAddress: + selectedSenderAddress ?? this.selectedSenderAddress, + sendError: sendError ?? this.sendError, + withdrawDetails: withdrawDetails ?? this.withdrawDetails, + isSending: isSending ?? this.isSending, + addressError: addressError ?? this.addressError, + amountError: amountError ?? this.amountError, + gasLimitError: gasLimitError ?? this.gasLimitError, + gasPriceError: gasPriceError ?? this.gasPriceError, + utxoCustomFeeError: utxoCustomFeeError ?? this.utxoCustomFeeError, + isCustomFeeEnabled: isCustomFeeEnabled ?? this.isCustomFeeEnabled, + trezorProgressStatus: trezorProgressStatus, + memo: memo ?? this.memo, + coinsBloc: coinsBloc ?? _coinsRepo, + ); + } + + final Coin coin; + final String address; + final String amount; + final WithdrawFormStep step; + final List senderAddresses; + final String selectedSenderAddress; + final FeeRequest customFee; + final WithdrawDetails withdrawDetails; + final bool isSending; + final bool isCustomFeeEnabled; + final String? trezorProgressStatus; + final BaseError sendError; + final BaseError addressError; + final BaseError amountError; + final BaseError utxoCustomFeeError; + final BaseError gasLimitError; + final BaseError gasPriceError; + final bool _isMaxAmount; + final String? memo; + final CoinsBloc _coinsRepo; + + @override + List get props => [ + coin, + address, + amount, + step, + senderAddresses, + selectedSenderAddress, + isMaxAmount, + customFee, + withdrawDetails, + isSending, + isCustomFeeEnabled, + trezorProgressStatus, + sendError, + addressError, + amountError, + utxoCustomFeeError, + gasLimitError, + gasPriceError, + memo, + ]; + + bool get isMaxAmount => + _isMaxAmount || amount == doubleToString(senderAddressBalance); + double get amountToSendDouble => double.tryParse(amount) ?? 0; + String get amountToSendString { + if (isMaxAmount && coin.abbr == withdrawDetails.feeCoin) { + return doubleToString( + amountToSendDouble - double.parse(withdrawDetails.feeValue), + ); + } + return amount; + } + + double get senderAddressBalance { + switch (coin.enabledType) { + case WalletType.trezor: + return coin.getHdAddress(selectedSenderAddress)?.balance.spendable ?? + 0.0; + default: + return coin.sendableBalance; + } + } + + bool get hasAddressError => addressError.message.isNotEmpty; + bool get hasAmountError => amountError.message.isNotEmpty; + bool get hasSendError => sendError.message.isNotEmpty; + bool get hasGasLimitError => gasLimitError.message.isNotEmpty; + bool get hasGasPriceError => gasPriceError.message.isNotEmpty; + bool get hasUtxoFeeError => utxoCustomFeeError.message.isNotEmpty; + + double? get usdAmountPrice => _coinsRepo.getUsdPriceByAmount( + amount, + coin.abbr, + ); + + double? get usdFeePrice => _coinsRepo.getUsdPriceByAmount( + withdrawDetails.feeValue, + withdrawDetails.feeCoin, + ); + + bool get isFeePriceExpensive { + final usdFeePrice = this.usdFeePrice; + final usdAmountPrice = this.usdAmountPrice; + + if (usdFeePrice == null || usdAmountPrice == null || usdAmountPrice == 0) { + return false; + } + + return usdFeePrice / usdAmountPrice >= 0.05; + } +} diff --git a/lib/bloc/withdraw_form/withdraw_form_step.dart b/lib/bloc/withdraw_form/withdraw_form_step.dart new file mode 100644 index 0000000000..24d0f50dca --- /dev/null +++ b/lib/bloc/withdraw_form/withdraw_form_step.dart @@ -0,0 +1,22 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +enum WithdrawFormStep { + failed, + fill, + confirm, + success; + + String get title { + switch (this) { + case WithdrawFormStep.fill: + return LocaleKeys.enterDataToSend.tr(); + case WithdrawFormStep.confirm: + return LocaleKeys.confirmSending.tr(); + case WithdrawFormStep.success: + return LocaleKeys.transactionComplete.tr(); + case WithdrawFormStep.failed: + return LocaleKeys.transactionDenied.tr(); + } + } +} diff --git a/lib/blocs/bloc_base.dart b/lib/blocs/bloc_base.dart new file mode 100644 index 0000000000..13392f7eaf --- /dev/null +++ b/lib/blocs/bloc_base.dart @@ -0,0 +1,3 @@ +abstract class BlocBase { + void dispose(); +} diff --git a/lib/blocs/blocs.dart b/lib/blocs/blocs.dart new file mode 100644 index 0000000000..6fe3a54411 --- /dev/null +++ b/lib/blocs/blocs.dart @@ -0,0 +1,58 @@ +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/wallets_bloc/wallets_repo.dart'; +import 'package:web_dex/blocs/coins_bloc.dart'; +import 'package:web_dex/blocs/current_wallet_bloc.dart'; +import 'package:web_dex/blocs/dropdown_dismiss_bloc.dart'; +import 'package:web_dex/blocs/maker_form_bloc.dart'; +import 'package:web_dex/blocs/orderbook_bloc.dart'; +import 'package:web_dex/blocs/trading_entities_bloc.dart'; +import 'package:web_dex/blocs/wallets_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/services/cex_service/cex_service.dart'; +import 'package:web_dex/services/file_loader/get_file_loader.dart'; +import 'package:web_dex/shared/utils/encryption_tool.dart'; + +// todo(yurii): recommended bloc arch refactoring order: + +/// [AlphaVersionWarningService] can be converted to Bloc +/// and [AlphaVersionWarningService.isShown] might be stored in [StoredSettings] + +// 1) +CexService cexService = CexService(); +// 2) +TradingEntitiesBloc tradingEntitiesBloc = TradingEntitiesBloc(); +// 3) +WalletsBloc walletsBloc = WalletsBloc( + walletsRepo: walletsRepo, + encryptionTool: EncryptionTool(), +); +// 4) +CurrentWalletBloc currentWalletBloc = CurrentWalletBloc( + fileLoader: fileLoader, + authRepo: authRepo, + walletsRepo: walletsRepo, + encryptionTool: EncryptionTool(), +); + +/// Returns a global singleton instance of [CurrentWalletBloc]. +/// +/// NB! Even though the class is called [CoinsBloc], it is not a Bloc. +CoinsBloc coinsBloc = CoinsBloc( + api: mm2Api, + currentWalletBloc: currentWalletBloc, + authRepo: authRepo, + coinsRepo: coinsRepo, +); + +/// Returns the same instance of [CoinsBloc] as [coinsBloc]. The purpose of this +/// is to identify which methods of [CoinsBloc] need to be refacored into a +/// the existing [CoinsRepository] or a new repository. +/// +/// NB! Even though the class is called [CoinsBloc], it is not a Bloc. +CoinsBloc get coinsBlocRepository => coinsBloc; + +MakerFormBloc makerFormBloc = MakerFormBloc(api: mm2Api); +OrderbookBloc orderbookBloc = OrderbookBloc(api: mm2Api); + +DropdownDismissBloc globalCancelBloc = DropdownDismissBloc(); diff --git a/lib/blocs/coins_bloc.dart b/lib/blocs/coins_bloc.dart new file mode 100644 index 0000000000..2ed9bf8fbd --- /dev/null +++ b/lib/blocs/coins_bloc.dart @@ -0,0 +1,467 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/trezor_bloc/trezor_repo.dart'; +import 'package:web_dex/blocs/bloc_base.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/blocs/current_wallet_bloc.dart'; +import 'package:web_dex/blocs/trezor_coins_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/bloc_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_errors.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.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/text_error.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; +import 'package:web_dex/services/cex_service/cex_service.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class CoinsBloc implements BlocBase { + CoinsBloc({ + required Mm2Api api, + required CurrentWalletBloc currentWalletBloc, + required AuthRepository authRepo, + required CoinsRepo coinsRepo, + }) : _coinsRepo = coinsRepo, + _currentWalletBloc = currentWalletBloc { + trezor = TrezorCoinsBloc( + trezorRepo: trezorRepo, + walletRepo: currentWalletBloc, + ); + + _authorizationSubscription = authRepo.authMode.listen((event) async { + switch (event) { + case AuthorizeMode.noLogin: + _isLoggedIn = false; + await _onLogout(); + break; + case AuthorizeMode.logIn: + _isLoggedIn = true; + await _onLogIn(); + break; + case AuthorizeMode.hiddenLogin: + break; + } + }); + _updateBalancesTimer = Timer.periodic(const Duration(seconds: 10), (timer) { + if (loginActivationFinished) { + updateBalances(); + } + }); + + _loadKnownCoins(); + } + + Map> addressCache = + {}; // { acc: { abbr: address }}, used in Fiat Page + + late StreamSubscription _authorizationSubscription; + late TrezorCoinsBloc trezor; + final CoinsRepo _coinsRepo; + + final CurrentWalletBloc _currentWalletBloc; + late StreamSubscription> _pricesSubscription; + + bool _isLoggedIn = false; + bool get isLoggedIn => _isLoggedIn; + + late Timer _updateBalancesTimer; + + final StreamController> _knownCoinsController = + StreamController>.broadcast(); + Sink> get _inKnownCoins => _knownCoinsController.sink; + Stream> get outKnownCoins => _knownCoinsController.stream; + + List _knownCoins = []; + + List get knownCoins => _knownCoins; + + Map _knownCoinsMap = {}; + Map get knownCoinsMap => _knownCoinsMap; + + final StreamController> _walletCoinsController = + StreamController>.broadcast(); + Sink> get _inWalletCoins => _walletCoinsController.sink; + Stream> get outWalletCoins => _walletCoinsController.stream; + + List _walletCoins = []; + List get walletCoins => _walletCoins; + set walletCoins(List coins) { + _walletCoins = coins; + _walletCoinsMap = Map.fromEntries( + coins.map((coin) => MapEntry(coin.abbr.toUpperCase(), coin)), + ); + _inWalletCoins.add(_walletCoins); + } + + Map _walletCoinsMap = {}; + Map get walletCoinsMap => _walletCoinsMap; + + final StreamController _loginActivationFinishedController = + StreamController.broadcast(); + Sink get _inLoginActivationFinished => + _loginActivationFinishedController.sink; + Stream get outLoginActivationFinished => + _loginActivationFinishedController.stream; + + bool _loginActivationFinished = false; + bool get loginActivationFinished => _loginActivationFinished; + set loginActivationFinished(bool value) { + _loginActivationFinished = value; + _inLoginActivationFinished.add(_loginActivationFinished); + } + + Future _activateLoginWalletCoins() async { + final Wallet? currentWallet = _currentWalletBloc.wallet; + if (currentWallet == null || !_isLoggedIn) { + return; + } + + final List coins = currentWallet.config.activatedCoins + .map((abbr) => getCoin(abbr)) + .whereType() + .where((coin) => !coin.isActive) + .toList(); + + await activateCoins(coins, skipUpdateBalance: true); + await updateBalances(); + await reActivateSuspended(attempts: 2); + + loginActivationFinished = true; + } + + Future _onLogIn() async { + await _activateLoginWalletCoins(); + await updateBalances(); + } + + Coin? getCoin(String abbr) { + return getWalletCoin(abbr) ?? getKnownCoin(abbr); + } + + Future _loadKnownCoins() async { + _knownCoins = await _coinsRepo.getKnownCoins(); + _knownCoinsMap = Map.fromEntries( + _knownCoins.map((coin) => MapEntry(coin.abbr.toUpperCase(), coin)), + ); + _inKnownCoins.add(_knownCoins); + } + + Coin? getWalletCoin(String abbr) { + return _walletCoinsMap[abbr.toUpperCase()]; + } + + Coin? getKnownCoin(String abbr) { + return _knownCoinsMap[abbr.toUpperCase()]; + } + + Future updateBalances() async { + switch (_currentWalletBloc.wallet?.config.type) { + case WalletType.trezor: + await _updateTrezorBalances(); + break; + case WalletType.iguana: + await _updateIguanaBalances(); + break; + case WalletType.metamask: + case WalletType.keplr: + case null: + await _updateIguanaBalances(); + break; + } + } + + Future _updateTrezorBalances() async { + final coins = _walletCoins.where((coin) => coin.isActive).toList(); + for (Coin coin in coins) { + coin.accounts = await trezor.getAccounts(coin); + } + _updateCoins(); + } + + Future _updateIguanaBalances() async { + bool changed = false; + final coins = _walletCoins.where((coin) => coin.isActive).toList(); + + final newBalances = await Future.wait( + coins.map((coin) => _coinsRepo.getBalanceInfo(coin.abbr))); + + for (int i = 0; i < coins.length; i++) { + if (newBalances[i] != null) { + final newBalance = double.parse(newBalances[i]!.balance.decimal); + final newSendableBalance = double.parse(newBalances[i]!.volume.decimal); + + if (newBalance != coins[i].balance || + newSendableBalance != coins[i].sendableBalance) { + changed = true; + coins[i].balance = newBalance; + coins[i].sendableBalance = newSendableBalance; + } + } + } + + if (changed) { + _updateCoins(); + } + } + + void _updateCoinsCexPrices(Map prices) { + bool changed = false; + for (Coin coin in _knownCoins) { + final CexPrice? usdPrice = prices[abbr2Ticker(coin.abbr)]; + + changed = changed || usdPrice != coin.usdPrice; + coin.usdPrice = usdPrice; + + final Coin? enabledCoin = getWalletCoin(coin.abbr); + enabledCoin?.usdPrice = usdPrice; + + _inKnownCoins.add(_knownCoins); + } + if (changed) { + _updateCoins(); + } + + log('CEX prices updated', path: 'coins_bloc => updateCoinsCexPrices'); + } + + Future _activateCoin(Coin coin, + {bool skipUpdateBalance = false}) async { + if (!_isLoggedIn || coin.isActivating || coin.isActive) return; + + coin.state = CoinState.activating; + await _addCoinToWallet(coin); + _updateCoins(); + + switch (currentWalletBloc.wallet?.config.type) { + case WalletType.iguana: + await _activateIguanaCoin(coin, skipUpdateBalance: skipUpdateBalance); + break; + case WalletType.trezor: + await _activateTrezorCoin(coin); + break; + case WalletType.metamask: + case WalletType.keplr: + case null: + break; + } + _updateCoins(); + } + + Future _activateIguanaCoin(Coin coin, + {bool skipUpdateBalance = false}) async { + log('Enabling a ${coin.name}', path: 'coins_bloc => enable'); + await _activateParentOf(coin, skipUpdateBalance: skipUpdateBalance); + await _coinsRepo.activateCoins([coin]); + await _syncIguanaCoinState(coin); + + if (!skipUpdateBalance) await updateBalances(); + log('${coin.name} has enabled', path: 'coins_bloc => enable'); + } + + Future _activateTrezorCoin(Coin coin) async { + await trezor.activateCoin(coin); + } + + Future _activateParentOf(Coin coin, + {bool skipUpdateBalance = false}) async { + final Coin? parentCoin = coin.parentCoin; + if (parentCoin == null) return; + + if (parentCoin.isInactive) { + await activateCoins([parentCoin], skipUpdateBalance: skipUpdateBalance); + } + + await pauseWhile( + () => parentCoin.isActivating, + timeout: const Duration(seconds: 100), + ); + } + + Future _onLogout() async { + final List coins = [...walletCoins]; + for (Coin coin in coins) { + switch (coin.enabledType) { + case WalletType.iguana: + await _deactivateApiCoin(coin); + break; + case WalletType.trezor: + case WalletType.metamask: + case WalletType.keplr: + case null: + break; + } + coin.reset(); + } + walletCoins = []; + loginActivationFinished = false; + } + + Future deactivateWalletCoins() async { + await deactivateCoins(walletCoins); + } + + Future deactivateCoins(List coins) async { + await Future.wait(coins.map(deactivateCoin)); + } + + Future deactivateCoin(Coin coin) async { + log('Disabling a ${coin.name}', path: 'coins_bloc => disable'); + await _removeCoinFromWallet(coin); + _updateCoins(); + await _deactivateApiCoin(coin); + _updateCoins(); + + log( + '${coin.name} has been disabled', + path: 'coins_bloc => disable', + ); + } + + Future _deactivateApiCoin(Coin coin) async { + if (coin.isSuspended || coin.isActivating) return; + await _coinsRepo.deactivateCoin(coin); + } + + Future _removeCoinFromWallet(Coin coin) async { + coin.reset(); + _walletCoins.removeWhere((enabledCoin) => enabledCoin.abbr == coin.abbr); + _walletCoinsMap.remove(coin.abbr.toUpperCase()); + await _currentWalletBloc.removeCoin(coin.abbr); + } + + double? getUsdPriceByAmount(String amount, String coinAbbr) { + final Coin? coin = getCoin(coinAbbr); + final double? parsedAmount = double.tryParse(amount); + final double? usdPrice = coin?.usdPrice?.price; + + if (coin == null || usdPrice == null || parsedAmount == null) { + return null; + } + return parsedAmount * usdPrice; + } + + Future> withdraw( + WithdrawRequest request) async { + final Map? response = await _coinsRepo.withdraw(request); + + if (response == null) { + log('Withdraw error: response is null', isError: true); + return BlocResponse( + result: null, + error: TextError(error: LocaleKeys.somethingWrong.tr()), + ); + } + + if (response['error'] != null) { + log('Withdraw error: ${response['error']}', isError: true); + return BlocResponse( + result: null, + error: withdrawErrorFactory.getError(response, request.params.coin), + ); + } + + final WithdrawDetails withdrawDetails = + WithdrawDetails.fromJson(response['result']); + + return BlocResponse( + result: withdrawDetails, + error: null, + ); + } + + Future sendRawTransaction( + SendRawTransactionRequest request) async { + final SendRawTransactionResponse response = + await _coinsRepo.sendRawTransaction(request); + + return response; + } + + Future activateCoins(List coins, + {bool skipUpdateBalance = false}) async { + final List> enableFutures = coins + .map( + (coin) => _activateCoin(coin, skipUpdateBalance: skipUpdateBalance)) + .toList(); + await Future.wait(enableFutures); + } + + Future _addCoinToWallet(Coin coin) async { + if (getWalletCoin(coin.abbr) != null) return; + + coin.enabledType = _currentWalletBloc.wallet?.config.type; + _walletCoins.add(coin); + _walletCoinsMap[coin.abbr.toUpperCase()] = coin; + await _currentWalletBloc.addCoin(coin); + } + + Future _syncIguanaCoinState(Coin coin) async { + final List apiCoins = await _coinsRepo.getEnabledCoins([coin]); + final Coin? apiCoin = + apiCoins.firstWhereOrNull((coin) => coin.abbr == coin.abbr); + + if (apiCoin != null) { + // enabled on gui side, but not on api side - suspend + coin.state = CoinState.active; + } else { + // enabled on both sides - unsuspend + coin.state = CoinState.suspended; + } + + for (Coin apiCoin in apiCoins) { + if (getWalletCoin(apiCoin.abbr) == null) { + // enabled on api side, but not on gui side - enable on gui side + _walletCoins.add(apiCoin); + _walletCoinsMap[apiCoin.abbr.toUpperCase()] = apiCoin; + } + } + _updateCoins(); + } + + Future reactivateAll() async { + for (Coin coin in _walletCoins) { + coin.state = CoinState.inactive; + } + + await activateCoins(_walletCoins); + } + + Future reActivateSuspended({int attempts = 1}) async { + for (int i = 0; i < attempts; i++) { + final List suspended = + _walletCoins.where((coin) => coin.isSuspended).toList(); + if (suspended.isEmpty) return; + + await activateCoins(suspended); + } + } + + void subscribeOnPrice(CexService cexService) { + _pricesSubscription = cexService.pricesStream + .listen((prices) => _updateCoinsCexPrices(prices)); + } + + void _updateCoins() { + walletCoins = _walletCoins; + } + + @override + void dispose() { + _walletCoinsController.close(); + _knownCoinsController.close(); + _updateBalancesTimer.cancel(); + _authorizationSubscription.cancel(); + _pricesSubscription.cancel(); + } +} diff --git a/lib/blocs/current_wallet_bloc.dart b/lib/blocs/current_wallet_bloc.dart new file mode 100644 index 0000000000..6e1de765ac --- /dev/null +++ b/lib/blocs/current_wallet_bloc.dart @@ -0,0 +1,118 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/wallets_bloc/wallets_repo.dart'; +import 'package:web_dex/blocs/bloc_base.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; +import 'package:web_dex/shared/utils/encryption_tool.dart'; + +class CurrentWalletBloc implements BlocBase { + CurrentWalletBloc({ + required EncryptionTool encryptionTool, + required FileLoader fileLoader, + required WalletsRepo walletsRepo, + required AuthRepository authRepo, + }) : _encryptionTool = encryptionTool, + _fileLoader = fileLoader, + _walletsRepo = walletsRepo; + + final EncryptionTool _encryptionTool; + final FileLoader _fileLoader; + final WalletsRepo _walletsRepo; + late StreamSubscription _authModeListener; + + final StreamController _walletController = + StreamController.broadcast(); + Sink get _inWallet => _walletController.sink; + Stream get outWallet => _walletController.stream; + + Wallet? _wallet; + Wallet? get wallet => _wallet; + set wallet(Wallet? wallet) { + _wallet = wallet; + _inWallet.add(_wallet); + } + + @override + void dispose() { + _walletController.close(); + _authModeListener.cancel(); + } + + Future updatePassword( + String oldPassword, String password, Wallet wallet) async { + final walletCopy = wallet.copy(); + + final String? decryptedSeed = await _encryptionTool.decryptData( + oldPassword, walletCopy.config.seedPhrase); + final String encryptedSeed = + await _encryptionTool.encryptData(password, decryptedSeed!); + walletCopy.config.seedPhrase = encryptedSeed; + final bool isSaved = await _walletsRepo.save(walletCopy); + + if (isSaved) { + this.wallet = walletCopy; + return true; + } else { + return false; + } + } + + Future addCoin(Coin coin) async { + final String coinAbbr = coin.abbr; + final Wallet? wallet = this.wallet; + if (wallet == null) { + return false; + } + if (wallet.config.activatedCoins.contains(coinAbbr)) { + return false; + } + wallet.config.activatedCoins.add(coinAbbr); + + final bool isSuccess = await _walletsRepo.save(wallet); + return isSuccess; + } + + Future removeCoin(String coinAbbr) async { + final Wallet? wallet = this.wallet; + if (wallet == null) { + return false; + } + + wallet.config.activatedCoins.remove(coinAbbr); + final bool isSuccess = await _walletsRepo.save(wallet); + this.wallet = wallet; + return isSuccess; + } + + Future downloadCurrentWallet(String password) async { + final Wallet? wallet = this.wallet; + if (wallet == null) return; + + final String data = jsonEncode(wallet.config); + final String encryptedData = + await _encryptionTool.encryptData(password, data); + + _fileLoader.save( + fileName: wallet.name, + data: encryptedData, + type: LoadFileType.text, + ); + + await confirmBackup(); + this.wallet = wallet; + } + + Future confirmBackup() async { + final Wallet? wallet = this.wallet; + if (wallet == null || wallet.config.hasBackup) return; + + wallet.config.hasBackup = true; + await _walletsRepo.save(wallet); + this.wallet = wallet; + } +} diff --git a/lib/blocs/dropdown_dismiss_bloc.dart b/lib/blocs/dropdown_dismiss_bloc.dart new file mode 100644 index 0000000000..77a0f5f086 --- /dev/null +++ b/lib/blocs/dropdown_dismiss_bloc.dart @@ -0,0 +1,42 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/taker_form/taker_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_event.dart'; + +import 'blocs.dart'; + +class DropdownDismissBloc { + final dropdownDismissController = StreamController.broadcast(); + StreamSink get _inDropdownDismiss => dropdownDismissController.sink; + Stream get outDropdownDismiss => dropdownDismissController.stream; + + void runDropdownDismiss({BuildContext? context}) { + if (context != null) { + // Taker form + context.read().add(TakerCoinSelectorOpen(false)); + context.read().add(TakerOrderSelectorOpen(false)); + + // Maker form + makerFormBloc.showSellCoinSelect = false; + makerFormBloc.showBuyCoinSelect = false; + + // Bridge form + context.read().add(const BridgeShowTickerDropdown(false)); + context.read().add(const BridgeShowSourceDropdown(false)); + context.read().add(const BridgeShowTargetDropdown(false)); + } + + // In case there's need to make it available in a stream for future use + _inDropdownDismiss.add(true); + Future.delayed(const Duration(seconds: 1)) + .then((_) => _inDropdownDismiss.add(false)); + } + + void dispose() { + dropdownDismissController.close(); + } +} diff --git a/lib/blocs/kmd_rewards_bloc.dart b/lib/blocs/kmd_rewards_bloc.dart new file mode 100644 index 0000000000..a717663b35 --- /dev/null +++ b/lib/blocs/kmd_rewards_bloc.dart @@ -0,0 +1,100 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/bloc_base.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/bloc_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_reward_item.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_rewards_info_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/withdraw_request.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; + +KmdRewardsBloc kmdRewardsBloc = KmdRewardsBloc(); + +class KmdRewardsBloc implements BlocBase { + bool _claimInProgress = false; + + Future> claim(BuildContext context) async { + if (_claimInProgress) { + return BlocResponse( + error: TextError(error: LocaleKeys.rewardClaiming.tr()), + ); + } + + _claimInProgress = true; + final withdraw = await _withdraw(); + final WithdrawDetails? withdrawDetails = withdraw.result; + + if (withdrawDetails == null || withdraw.error != null) { + final BaseError error = + withdraw.error ?? TextError(error: LocaleKeys.somethingWrong.tr()); + _claimInProgress = false; + return BlocResponse( + error: error, + ); + } + + final tx = await coinsBloc.sendRawTransaction(SendRawTransactionRequest( + coin: 'KMD', + txHex: withdrawDetails.txHex, + )); + if (tx.error != null) { + final BaseError error = + tx.error ?? TextError(error: LocaleKeys.somethingWrong.tr()); + _claimInProgress = false; + return BlocResponse( + error: error, + ); + } + _claimInProgress = false; + return BlocResponse(result: withdrawDetails.myBalanceChange); + } + + @override + void dispose() {} + + Future> getInfo() async { + final Map? response = + await mm2Api.getRewardsInfo(KmdRewardsInfoRequest()); + if (response != null && response['result'] != null) { + return response['result'] + .map( + (dynamic reward) => KmdRewardItem.fromJson(reward)) + .toList(); + } + return []; + } + + Future getTotal(BuildContext context) async { + final withdraw = await _withdraw(); + final String? myBalanceChange = withdraw.result?.myBalanceChange; + if (myBalanceChange == null || withdraw.error != null) { + return null; + } + + return double.tryParse(myBalanceChange); + } + + Future> _withdraw() async { + final Coin? kmdCoin = coinsBloc.getWalletCoin('KMD'); + if (kmdCoin == null) { + return BlocResponse( + error: TextError(error: LocaleKeys.plsActivateKmd.tr())); + } + if (kmdCoin.address == null) { + return BlocResponse( + error: TextError(error: LocaleKeys.noKmdAddress.tr())); + } + + return await coinsBloc.withdraw(WithdrawRequest( + coin: 'KMD', + max: true, + to: kmdCoin.address!, + )); + } +} diff --git a/lib/blocs/maker_form_bloc.dart b/lib/blocs/maker_form_bloc.dart new file mode 100644 index 0000000000..967ac51688 --- /dev/null +++ b/lib/blocs/maker_form_bloc.dart @@ -0,0 +1,671 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/dex_repository.dart'; +import 'package:web_dex/blocs/bloc_base.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/setprice/setprice_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_errors.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/available_balance_state.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/data_from_service.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/views/dex/simple/form/error_list/dex_form_error_with_action.dart'; + +class MakerFormBloc implements BlocBase { + MakerFormBloc({required this.api}); + + void onChangeAuthStatus(AuthorizeMode event) { + final bool prevLoginState = _isLoggedIn; + _isLoggedIn = event == AuthorizeMode.logIn; + + if (prevLoginState != _isLoggedIn) { + sellCoin = sellCoin; + } + } + + final Mm2Api api; + + String currentEntityUuid = ''; + bool _isLoggedIn = false; + + bool _showConfirmation = false; + final StreamController _showConfirmationCtrl = + StreamController.broadcast(); + Sink get _inShowConfirmation => _showConfirmationCtrl.sink; + Stream get outShowConfirmation => _showConfirmationCtrl.stream; + bool get showConfirmation => _showConfirmation; + set showConfirmation(bool value) { + _showConfirmation = value; + _inShowConfirmation.add(_showConfirmation); + } + + bool _showSellCoinSelect = false; + final StreamController _showSellCoinSelectCtrl = + StreamController.broadcast(); + Sink get _inShowSellCoinSelect => _showSellCoinSelectCtrl.sink; + Stream get outShowSellCoinSelect => _showSellCoinSelectCtrl.stream; + bool get showSellCoinSelect => _showSellCoinSelect; + set showSellCoinSelect(bool value) { + _showSellCoinSelect = value; + _inShowSellCoinSelect.add(_showSellCoinSelect); + if (_showSellCoinSelect) showBuyCoinSelect = false; + } + + bool _showBuyCoinSelect = false; + final StreamController _showBuyCoinSelectCtrl = + StreamController.broadcast(); + Sink get _inShowBuyCoinSelect => _showBuyCoinSelectCtrl.sink; + Stream get outShowBuyCoinSelect => _showBuyCoinSelectCtrl.stream; + bool get showBuyCoinSelect => _showBuyCoinSelect; + set showBuyCoinSelect(bool value) { + _showBuyCoinSelect = value; + _inShowBuyCoinSelect.add(_showBuyCoinSelect); + if (_showBuyCoinSelect) showSellCoinSelect = false; + } + + bool _inProgress = false; + final StreamController _inProgressCtrl = StreamController.broadcast(); + Sink get _inInProgress => _inProgressCtrl.sink; + Stream get outInProgress => _inProgressCtrl.stream; + bool get inProgress => _inProgress; + set inProgress(bool value) { + _inProgress = value; + _inInProgress.add(_inProgress); + } + + bool _isMaxActive = false; + final StreamController _isMaxActiveCtrl = StreamController.broadcast(); + Sink get _inIsMaxActive => _isMaxActiveCtrl.sink; + Stream get outIsMaxActive => _isMaxActiveCtrl.stream; + bool get isMaxActive => _isMaxActive; + set isMaxActive(bool value) { + _isMaxActive = value; + _inIsMaxActive.add(_isMaxActive); + } + + Coin? _sellCoin; + final StreamController _sellCoinCtrl = StreamController.broadcast(); + Sink get _inSellCoin => _sellCoinCtrl.sink; + Stream get outSellCoin => _sellCoinCtrl.stream; + Coin? get sellCoin => _sellCoin; + set sellCoin(Coin? coin) { + if (coin?.abbr != sellCoin?.abbr) { + setSellAmount(null); + setBuyAmount(null); + setPriceValue(null); + maxSellAmount = null; + availableBalanceState = AvailableBalanceState.initial; + } + + _sellCoin = coin; + _inSellCoin.add(_sellCoin); + if (coin == buyCoin) buyCoin = null; + + _autoActivate(sellCoin) + .then((_) => _updateMaxSellAmountListener()) + .then((_) => _updatePreimage()) + .then((_) => _reValidate()); + } + + Coin? _buyCoin; + final StreamController _buyCoinCtrl = StreamController.broadcast(); + Sink get _inBuyCoin => _buyCoinCtrl.sink; + Stream get outBuyCoin => _buyCoinCtrl.stream; + Coin? get buyCoin => _buyCoin; + set buyCoin(Coin? coin) { + if (coin?.abbr != buyCoin?.abbr) { + setBuyAmount(null); + setPriceValue(null); + } + + _buyCoin = coin; + _inBuyCoin.add(_buyCoin); + if (coin == sellCoin && coin != null) sellCoin = null; + + _autoActivate(buyCoin) + .then((_) => _updatePreimage()) + .then((_) => _reValidate()); + } + + Rational? _sellAmount; + final StreamController _sellAmountCtrl = + StreamController.broadcast(); + Sink get _inSellAmount => _sellAmountCtrl.sink; + Stream get outSellAmount => _sellAmountCtrl.stream; + Rational? get sellAmount => _sellAmount; + set sellAmount(Rational? amount) { + _sellAmount = amount; + _inSellAmount.add(_sellAmount); + + _updatePreimage().then((_) => _reValidate()); + } + + Rational? _buyAmount; + final StreamController _buyAmountCtrl = + StreamController.broadcast(); + Sink get _inBuyAmount => _buyAmountCtrl.sink; + Stream get outBuyAmount => _buyAmountCtrl.stream; + Rational? get buyAmount => _buyAmount; + set buyAmount(Rational? amount) { + _buyAmount = amount; + _inBuyAmount.add(_buyAmount); + + _updatePreimage().then((_) => _reValidate()); + } + + Rational? _price; + final StreamController _priceCtrl = + StreamController.broadcast(); + Sink get _inPrice => _priceCtrl.sink; + Stream get outPrice => _priceCtrl.stream; + Rational? get price => _price; + set price(Rational? price) { + _price = price; + _inPrice.add(_price); + + _updatePreimage().then((_) => _reValidate()); + } + + Rational? _maxSellAmount; + final StreamController _maxSellAmountCtrl = + StreamController.broadcast(); + Sink get _inMaxSellAmount => _maxSellAmountCtrl.sink; + Stream get outMaxSellAmount => _maxSellAmountCtrl.stream; + Rational? get maxSellAmount => _maxSellAmount; + set maxSellAmount(Rational? amount) { + _maxSellAmount = amount; + _inMaxSellAmount.add(_maxSellAmount); + } + + AvailableBalanceState _availableBalanceState = + AvailableBalanceState.unavailable; + final StreamController _availableBalanceStateCtrl = + StreamController.broadcast(); + Sink get _inAvailableBalanceState => + _availableBalanceStateCtrl.sink; + Stream get outAvailableBalanceState => + _availableBalanceStateCtrl.stream; + AvailableBalanceState get availableBalanceState => _availableBalanceState; + set availableBalanceState(AvailableBalanceState state) { + _availableBalanceState = state; + _inAvailableBalanceState.add(_availableBalanceState); + } + + TradePreimage? _preimage; + final StreamController _preimageCtrl = + StreamController.broadcast(); + Sink get _inPreimage => _preimageCtrl.sink; + Stream get outPreimage => _preimageCtrl.stream; + TradePreimage? get preimage => _preimage; + set preimage(TradePreimage? tradePreimage) { + _preimage = tradePreimage; + _inPreimage.add(_preimage); + } + + final List _formErrors = []; + final StreamController> _formErrorsCtrl = + StreamController.broadcast(); + Sink> get _inFormErrors => _formErrorsCtrl.sink; + Stream> get outFormErrors => _formErrorsCtrl.stream; + List getFormErrors() => _formErrors; + void _setFormErrors(List? errors) { + errors ??= []; + _formErrors.clear(); + _formErrors.addAll(errors); + _inFormErrors.add(_formErrors); + } + + @override + void dispose() { + _inProgressCtrl.close(); + _showConfirmationCtrl.close(); + _sellCoinCtrl.close(); + _buyCoinCtrl.close(); + _sellAmountCtrl.close(); + _buyAmountCtrl.close(); + _priceCtrl.close(); + _isMaxActiveCtrl.close(); + _showSellCoinSelectCtrl.close(); + _showBuyCoinSelectCtrl.close(); + _formErrorsCtrl.close(); + _availableBalanceStateCtrl.close(); + } + + Timer? _maxSellAmountTimer; + void _updateMaxSellAmountListener() { + _maxSellAmountTimer?.cancel(); + maxSellAmount = null; + availableBalanceState = AvailableBalanceState.loading; + isMaxActive = false; + + _updateMaxSellAmount(); + _maxSellAmountTimer = Timer.periodic(const Duration(seconds: 10), (_) { + _updateMaxSellAmount(); + }); + } + + void _updateMaxSellAmount() { + final Coin? coin = sellCoin; + if (availableBalanceState == AvailableBalanceState.initial) { + availableBalanceState = AvailableBalanceState.loading; + } + + if (!_isLoggedIn) { + availableBalanceState = AvailableBalanceState.unavailable; + } else { + if (coin == null) { + maxSellAmount = null; + availableBalanceState = AvailableBalanceState.unavailable; + } else if (!coin.isActive) { + maxSellAmount = null; + availableBalanceState = AvailableBalanceState.loading; + } else { + maxSellAmount = Rational.parse(coin.balance.toString()); + availableBalanceState = AvailableBalanceState.success; + } + } + } + + Future setMaxSellAmount() async { + if (sellAmount == maxSellAmount) return; + + sellAmount = maxSellAmount; + isMaxActive = maxSellAmount != null; + _onSellAmountUpdated(); + } + + Future setHalfSellAmount() async { + if (maxSellAmount == null) return; + + final Rational halfAmount = maxSellAmount! / Rational.fromInt(2); + if (sellAmount == halfAmount) return; + + sellAmount = halfAmount; + isMaxActive = false; + _onSellAmountUpdated(); + } + + Future validate() async { + _setFormErrors(null); + inProgress = true; + + if (!(await _validateFormFields())) { + inProgress = false; + return false; + } + + if (!(await _validatePreimage())) { + inProgress = false; + return false; + } + + inProgress = false; + return true; + } + + Future _validateFormFields() async { + final DexFormError? sellItemError = await _validateSellFields(); + if (sellItemError != null) { + _setFormErrors([sellItemError]); + return false; + } + + final DexFormError? buyItemError = await _validateBuyFields(); + if (buyItemError != null) { + _setFormErrors([buyItemError]); + return false; + } + + final DexFormError? priceItemError = await _validatePriceField(); + if (priceItemError != null) { + _setFormErrors([priceItemError]); + return false; + } + + return true; + } + + Future _validatePreimage() async { + inProgress = true; + final tradePreimageData = await _getPreimageData(); + preimage = tradePreimageData?.data; + inProgress = false; + + if (tradePreimageData == null) return false; + + final BaseError? error = tradePreimageData.error; + if (error == null) return true; + + if (error is TradePreimageNotSufficientBalanceError) { + _setFormErrors([ + DexFormError( + error: LocaleKeys.dexBalanceNotSufficientError.tr(args: [ + error.coin, + formatAmt(double.parse(error.required)), + error.coin, + ]), + ) + ]); + } else if (error is TradePreimageNotSufficientBaseCoinBalanceError) { + _setFormErrors([ + DexFormError( + error: LocaleKeys.dexBalanceNotSufficientError.tr(args: [ + error.coin, + formatAmt(double.parse(error.required)), + error.coin, + ]), + ) + ]); + } else if (error is TradePreimageTransportError) { + _setFormErrors([ + DexFormError( + error: LocaleKeys.notEnoughBalanceForGasError.tr(), + ) + ]); + } else { + _setFormErrors([ + DexFormError( + error: error.message, + ) + ]); + } + + return false; + } + + Future _validatePriceField() async { + final Rational? price = this.price; + + if (price == null) { + return DexFormError(error: LocaleKeys.dexEnterPriceError.tr()); + } else if (price == Rational.zero) { + return DexFormError(error: LocaleKeys.dexZeroPriceError.tr()); + } + + return null; + } + + Future _validateBuyFields() async { + final Coin? buyCoin = this.buyCoin; + + if (buyCoin == null) { + return DexFormError(error: LocaleKeys.dexSelectBuyCoinError.tr()); + } else if (buyCoin.isSuspended) { + return DexFormError( + error: LocaleKeys.dexCoinSuspendedError.tr(args: [buyCoin.abbr])); + } else { + final Coin? parentCoin = buyCoin.parentCoin; + if (parentCoin != null && parentCoin.isSuspended) { + return DexFormError( + error: + LocaleKeys.dexCoinSuspendedError.tr(args: [parentCoin.abbr])); + } + } + + final Rational? buyAmount = this.buyAmount; + if (buyAmount == null) { + return DexFormError(error: LocaleKeys.dexEnterBuyAmountError.tr()); + } else { + if (buyAmount.toDouble() == 0) { + return DexFormError(error: LocaleKeys.dexZeroBuyAmountError.tr()); + } + } + + return null; + } + + Future _validateSellFields() async { + final Coin? sellCoin = this.sellCoin; + + if (sellCoin == null) { + return DexFormError(error: LocaleKeys.dexSelectSellCoinError.tr()); + } else if (sellCoin.isSuspended) { + return DexFormError( + error: LocaleKeys.dexCoinSuspendedError.tr(args: [sellCoin.abbr])); + } + + final Coin? parentCoin = sellCoin.parentCoin; + if (parentCoin != null && parentCoin.isSuspended) { + return DexFormError( + error: LocaleKeys.dexCoinSuspendedError.tr(args: [parentCoin.abbr])); + } + + final Rational? sellAmount = this.sellAmount; + + if (sellAmount == null) { + return DexFormError(error: LocaleKeys.dexEnterSellAmountError.tr()); + } else { + if (sellAmount == Rational.zero) { + return DexFormError(error: LocaleKeys.dexZeroSellAmountError.tr()); + } else { + final Rational maxAmount = maxSellAmount ?? Rational.zero; + if (maxAmount == Rational.zero) { + return DexFormError(error: LocaleKeys.notEnoughFundsError.tr()); + } else if (sellAmount > maxAmount) { + return DexFormError( + error: LocaleKeys.dexMaxSellAmountError + .tr(args: [formatAmt(maxAmount.toDouble()), sellCoin.abbr]), + type: DexFormErrorType.largerMaxSellVolume, + action: DexFormErrorAction( + text: LocaleKeys.setMax.tr(), + callback: () async { + await setMaxSellAmount(); + }), + ); + } + } + } + + return null; + } + + Future _autoActivate(Coin? coin) async { + if (coin == null) return; + inProgress = true; + final List activationErrors = + await activateCoinIfNeeded(coin.abbr); + inProgress = false; + if (activationErrors.isNotEmpty) { + _setFormErrors(activationErrors); + } + } + + Future makeOrder() async { + final Map? response = await api.setprice(SetPriceRequest( + base: sellCoin!.abbr, + rel: buyCoin!.abbr, + volume: sellAmount!, + price: price!, + max: isMaxActive, + )); + + if (response == null) { + return TextError(error: LocaleKeys.somethingWrong.tr()); + } + + if (response['error'] != null) { + return TextError(error: response['error']); + } + + currentEntityUuid = response['result']['uuid']; + + return null; + } + + void clear() { + sellCoin = null; + sellAmount = null; + buyCoin = null; + buyAmount = null; + price = null; + inProgress = false; + showBuyCoinSelect = false; + showSellCoinSelect = false; + showConfirmation = false; + isMaxActive = false; + availableBalanceState = AvailableBalanceState.unavailable; + _setFormErrors(null); + } + + void setSellAmount(String? amountStr) { + amountStr ??= ''; + Rational? amount; + + if (amountStr.isEmpty) { + amount = null; + } else { + amount = Rational.parse(amountStr); + } + + isMaxActive = false; + + if (amount == sellAmount) return; + sellAmount = amount; + + _onSellAmountUpdated(); + } + + void setBuyAmount(String? amountStr) { + amountStr ??= ''; + Rational? amount; + + if (amountStr.isEmpty) { + amount = null; + } else { + amount = Rational.parse(amountStr); + } + + if (amount == buyAmount) return; + buyAmount = amount; + _onBuyAmountUpdated(); + } + + void setPriceValue(String? priceStr) { + priceStr ??= ''; + Rational? priceValue; + + if (priceStr.isEmpty) { + priceValue = null; + } else { + priceValue = Rational.parse(priceStr); + } + + if (priceValue == price) return; + price = priceValue; + _onPriceUpdated(); + } + + void _onSellAmountUpdated() { + final res = processBuyAmountAndPrice(sellAmount, price, buyAmount); + if (res != null) { + buyAmount = res.$1; + price = res.$2; + } + } + + void _onBuyAmountUpdated() { + if (buyAmount == null) return; + if (price == null && sellAmount == null) return; + try { + price = buyAmount! / sellAmount!; + } catch (_) { + price = null; + } + } + + void _onPriceUpdated() { + if (price == null) return; + if (sellAmount == null && buyAmount == null) return; + if (sellAmount != null) { + buyAmount = sellAmount! * price!; + } else if (buyAmount != null) { + try { + sellAmount = buyAmount! / price!; + } catch (_) { + sellAmount = null; + } + } + } + + bool _fetchingPreimageData = false; + Future?> _getPreimageData() async { + await pauseWhile(() => _fetchingPreimageData); + + final String? base = sellCoin?.abbr; + final String? rel = buyCoin?.abbr; + final Rational? price = this.price; + final Rational? volume = sellAmount; + + if (base == null || rel == null || price == null || volume == null) { + return null; + } + + _fetchingPreimageData = true; + final preimageData = await dexRepository.getTradePreimage( + base, + rel, + price, + 'setprice', + volume, + isMaxActive, + ); + _fetchingPreimageData = false; + + return preimageData; + } + + Timer? _preimageDebounceTimer; + Future _updatePreimage() async { + _preimageDebounceTimer?.cancel(); + + _preimageDebounceTimer = Timer(const Duration(milliseconds: 300), () async { + final tradePreimageData = await _getPreimageData(); + preimage = tradePreimageData?.data; + }); + } + + int? _updateTimer; + Future _reValidate() async { + if (_updateTimer != null) return; + _updateTimer = DateTime.now().millisecondsSinceEpoch; + + while (inProgress && + DateTime.now().millisecondsSinceEpoch - _updateTimer! < 3000) { + await Future.delayed(const Duration(milliseconds: 50)); + } + + await Future.delayed(const Duration(milliseconds: 200)); + _updateTimer = null; + + if (getFormErrors().isNotEmpty) { + _setFormErrors(null); + await _validateFormFields(); + } + } + + Future reInitForm() async { + if (sellCoin != null) sellCoin = coinsBloc.getKnownCoin(sellCoin!.abbr); + if (buyCoin != null) buyCoin = coinsBloc.getKnownCoin(buyCoin!.abbr); + } + + void setDefaultSellCoin() { + if (sellCoin != null) return; + + final Coin? defaultSellCoin = coinsBloc.getCoin(defaultDexCoin); + if (defaultSellCoin == null) return; + + sellCoin = defaultSellCoin; + } +} diff --git a/lib/blocs/orderbook_bloc.dart b/lib/blocs/orderbook_bloc.dart new file mode 100644 index 0000000000..c5427de616 --- /dev/null +++ b/lib/blocs/orderbook_bloc.dart @@ -0,0 +1,104 @@ +import 'dart:async'; + +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'; + +class OrderbookBloc implements BlocBase { + OrderbookBloc({required Mm2Api api}) { + _api = api; + + _timer = Timer.periodic( + const Duration(seconds: 3), + (_) async => await _updateOrderbooks(), + ); + } + + late Mm2Api _api; + Timer? _timer; + + // keys are 'base/rel' Strings + final Map _subscriptions = {}; + + @override + void dispose() { + _timer?.cancel(); + _subscriptions.forEach((pair, subs) => subs.controller.close()); + } + + OrderbookResponse? getInitialData(String base, String rel) { + final String pair = '$base/$rel'; + final OrderbookSubscription? subscription = _subscriptions[pair]; + + return subscription?.initialData; + } + + 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 sink = controller.sink; + final stream = controller.stream; + + _subscriptions[pair] = OrderbookSubscription( + initialData: null, + controller: controller, + sink: sink, + stream: stream, + ); + + _fetchOrderbook(pair); + return _subscriptions[pair]!.stream; + } + } + + Future _updateOrderbooks() async { + final List pairs = List.from(_subscriptions.keys); + + for (String pair in pairs) { + final OrderbookSubscription? subscription = _subscriptions[pair]; + if (subscription == null) { + continue; + } + if (!subscription.controller.hasListener) { + continue; + } + + await _fetchOrderbook(pair); + } + } + + Future _fetchOrderbook(String pair) async { + final OrderbookSubscription? subscription = _subscriptions[pair]; + if (subscription == null) return; + + 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); + } +} + +class OrderbookSubscription { + OrderbookSubscription({ + required this.initialData, + required this.controller, + required this.sink, + required this.stream, + }); + + OrderbookResponse? initialData; + final StreamController controller; + final Sink sink; + final Stream stream; +} diff --git a/lib/blocs/startup_bloc.dart b/lib/blocs/startup_bloc.dart new file mode 100644 index 0000000000..a2e84dd645 --- /dev/null +++ b/lib/blocs/startup_bloc.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/blocs/bloc_base.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/services/coins_service/coins_service.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +StartUpBloc startUpBloc = StartUpBloc(); + +class StartUpBloc implements BlocBase { + bool _running = false; + + @override + void dispose() { + _runningController.close(); + } + + final StreamController _runningController = + StreamController.broadcast(); + Sink get _inRunning => _runningController.sink; + Stream get outRunning => _runningController.stream; + + bool get running => _running; + set running(bool value) { + _running = value; + _inRunning.add(_running); + } + + Future run() async { + if (mm2 is MM2WithInit) await (mm2 as MM2WithInit).init(); + + final wasAlreadyRunning = running; + + authRepo.authMode.listen((event) { + makerFormBloc.onChangeAuthStatus(event); + }); + coinsService.init(); + coinsBloc.subscribeOnPrice(cexService); + running = true; + tradingEntitiesBloc.runUpdate(); + routingState.selectedMenu = MainMenuValue.dex; + if (!wasAlreadyRunning) await authRepo.logIn(AuthorizeMode.noLogin); + + log('Application has started'); + } +} diff --git a/lib/blocs/trading_entities_bloc.dart b/lib/blocs/trading_entities_bloc.dart new file mode 100644 index 0000000000..2f09d96773 --- /dev/null +++ b/lib/blocs/trading_entities_bloc.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/blocs/bloc_base.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/cancel_order/cancel_order_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_response.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/services/orders_service/my_orders_service.dart'; +import 'package:web_dex/services/swaps_service/swaps_service.dart'; + +class TradingEntitiesBloc implements BlocBase { + TradingEntitiesBloc() { + _authModeListener = authRepo.authMode.listen((mode) => _authMode = mode); + } + + AuthorizeMode? _authMode; + StreamSubscription? _authModeListener; + List _myOrders = []; + List _swaps = []; + Timer? timer; + + final StreamController> _myOrdersController = + StreamController>.broadcast(); + Sink> get _inMyOrders => _myOrdersController.sink; + Stream> get outMyOrders => _myOrdersController.stream; + List get myOrders => _myOrders; + set myOrders(List orderList) { + orderList.sort((first, second) => second.createdAt - first.createdAt); + _myOrders = orderList; + _inMyOrders.add(_myOrders); + } + + final StreamController> _swapsController = + StreamController>.broadcast(); + Sink> get _inSwaps => _swapsController.sink; + Stream> get outSwaps => _swapsController.stream; + List get swaps => _swaps; + set swaps(List swapList) { + swapList.sort( + (first, second) => second.myInfo.startedAt - first.myInfo.startedAt); + _swaps = swapList; + _inSwaps.add(_swaps); + } + + Future fetch() async { + myOrders = await myOrdersService.getOrders() ?? []; + swaps = await swapsService.getRecentSwaps(MyRecentSwapsRequest()) ?? []; + } + + @override + void dispose() { + _authModeListener?.cancel(); + } + + bool get _shouldFetchDexUpdates { + if (_authMode == AuthorizeMode.noLogin) return false; + if (_authMode == AuthorizeMode.hiddenLogin) return false; + if (currentWalletBloc.wallet?.isHW == true) return false; + + return true; + } + + void runUpdate() { + bool updateInProgress = false; + + timer = Timer.periodic(const Duration(seconds: 1), (_) async { + if (!_shouldFetchDexUpdates) return; + if (updateInProgress) return; + + updateInProgress = true; + await fetch(); + updateInProgress = false; + }); + } + + Future recoverFundsOfSwap(String uuid) async { + return swapsService.recoverFundsOfSwap(uuid); + } + + Future cancelOrder(String uuid) async { + final Map response = + await mm2Api.cancelOrder(CancelOrderRequest(uuid: uuid)); + return response['error']; + } + + bool isCoinBusy(String coin) { + return (_swaps + .where((swap) => !swap.isCompleted) + .where((swap) => swap.sellCoin == coin || swap.buyCoin == coin) + .toList() + .length + + _myOrders + .where((order) => order.base == coin || order.rel == coin) + .toList() + .length) > + 0; + } + + double getPriceFromAmount(Rational sellAmount, Rational buyAmount) { + final sellDoubleAmount = sellAmount.toDouble(); + final buyDoubleAmount = buyAmount.toDouble(); + + if (sellDoubleAmount == 0 || buyDoubleAmount == 0) return 0; + return buyDoubleAmount / sellDoubleAmount; + } + + String getTypeString(bool isTaker) => + isTaker ? LocaleKeys.takerOrder.tr() : LocaleKeys.makerOrder.tr(); + + Swap? getSwap(String uuid) => + swaps.firstWhereOrNull((swap) => swap.uuid == uuid); + + double getProgressFillSwap(MyOrder order) { + final List swaps = (order.startedSwaps ?? []) + .map((id) => getSwap(id)) + .whereType() + .toList(); + final double swapFill = swaps.fold( + 0, + (previousValue, swap) => + previousValue + swap.myInfo.myAmount.toDouble()); + return swapFill / order.baseAmount.toDouble(); + } + + Future cancelAllOrders() async { + final futures = myOrders.map((o) => cancelOrder(o.uuid)); + Future.wait(futures); + } +} diff --git a/lib/blocs/trezor_coins_bloc.dart b/lib/blocs/trezor_coins_bloc.dart new file mode 100644 index 0000000000..07a292d0c4 --- /dev/null +++ b/lib/blocs/trezor_coins_bloc.dart @@ -0,0 +1,248 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/bloc/trezor_bloc/trezor_repo.dart'; +import 'package:web_dex/blocs/current_wallet_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/bloc_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_request.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/hd_account/hd_account.dart'; +import 'package:web_dex/model/hw_wallet/init_trezor.dart'; +import 'package:web_dex/model/hw_wallet/trezor_progress_status.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status.dart'; +import 'package:web_dex/model/hw_wallet/trezor_task.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/utils/utils.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/show_trezor_passphrase_dialog.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/show_trezor_pin_dialog.dart'; + +class TrezorCoinsBloc { + TrezorCoinsBloc({ + required TrezorRepo trezorRepo, + required CurrentWalletBloc walletRepo, + }) : _trezorRepo = trezorRepo, + _walletRepo = walletRepo; + + final TrezorRepo _trezorRepo; + final CurrentWalletBloc _walletRepo; + bool get _loggedInTrezor => + _walletRepo.wallet?.config.type == WalletType.trezor; + Timer? _initNewAddressStatusTimer; + + Future?> getAccounts(Coin coin) async { + final TrezorBalanceInitResponse initResponse = + await _trezorRepo.initBalance(coin); + final int? taskId = initResponse.result?.taskId; + if (taskId == null) return null; + + final int started = nowMs; + // todo(yurii): change timeout to some reasonable value (10000?) + while (nowMs - started < 100000) { + final statusResponse = await _trezorRepo.getBalanceStatus(taskId); + final InitTrezorStatus? status = statusResponse.result?.status; + + if (status == InitTrezorStatus.error) return null; + + if (status == InitTrezorStatus.ok) { + return statusResponse.result?.balanceDetails?.accounts; + } + + await Future.delayed(const Duration(milliseconds: 500)); + } + + return null; + } + + Future activateCoin(Coin coin) async { + switch (coin.type) { + case CoinType.utxo: + case CoinType.smartChain: + await _enableUtxo(coin); + break; + default: + {} + } + } + + Future _enableUtxo(Coin coin) async { + final enableResponse = await _trezorRepo.enableUtxo(coin); + final taskId = enableResponse.result?.taskId; + if (taskId == null) return; + + while (_loggedInTrezor) { + final statusResponse = await _trezorRepo.getEnableUtxoStatus(taskId); + final InitTrezorStatus? status = statusResponse.result?.status; + + switch (status) { + case InitTrezorStatus.error: + coin.state = CoinState.suspended; + return; + + case InitTrezorStatus.userActionRequired: + final TrezorUserAction? action = statusResponse.result?.actionDetails; + if (action == TrezorUserAction.enterTrezorPin) { + await showTrezorPinDialog(TrezorTask( + taskId: taskId, + type: TrezorTaskType.enableUtxo, + )); + } else if (action == TrezorUserAction.enterTrezorPassphrase) { + await showTrezorPassphraseDialog(TrezorTask( + taskId: taskId, + type: TrezorTaskType.enableUtxo, + )); + } + break; + + case InitTrezorStatus.ok: + final details = statusResponse.result?.details; + if (details != null) { + coin.accounts = details.accounts; + coin.state = CoinState.active; + } + return; + + default: + } + + await Future.delayed(const Duration(milliseconds: 500)); + } + } + + Future initNewAddress(Coin coin) async { + final TrezorGetNewAddressInitResponse response = + await _trezorRepo.initNewAddress(coin.abbr); + final result = response.result; + + return result?.taskId; + } + + Future getNewAddressStatus( + int taskId, Coin coin) async { + final GetNewAddressResponse response = + await _trezorRepo.getNewAddressStatus(taskId); + final GetNewAddressStatus? status = response.result?.status; + final GetNewAddressResultDetails? details = response.result?.details; + if (status == GetNewAddressStatus.ok && + details is GetNewAddressResultOkDetails) { + coin.accounts = await getAccounts(coin); + } + return response; + } + + void subscribeOnNewAddressStatus( + int taskId, + Coin coin, + Function(GetNewAddressResponse) callback, + ) { + _initNewAddressStatusTimer = + Timer.periodic(const Duration(seconds: 1), (timer) async { + final GetNewAddressResponse initNewAddressStatus = + await getNewAddressStatus(taskId, coin); + callback(initNewAddressStatus); + }); + } + + void unsubscribeFromNewAddressStatus() { + _initNewAddressStatusTimer?.cancel(); + _initNewAddressStatusTimer = null; + } + + Future cancelGetNewAddress(int taskId) async { + await _trezorRepo.cancelGetNewAddress(taskId); + } + + Future> withdraw( + TrezorWithdrawRequest request, { + required void Function(TrezorProgressStatus?) onProgressUpdated, + }) async { + final withdrawResponse = await _trezorRepo.withdraw(request); + + if (withdrawResponse.error != null) { + return BlocResponse( + result: null, + error: TextError(error: withdrawResponse.error!), + ); + } + + final int? taskId = withdrawResponse.result?.taskId; + if (taskId == null) { + return BlocResponse( + result: null, + error: TextError(error: LocaleKeys.somethingWrong.tr()), + ); + } + + final int started = nowMs; + while (nowMs - started < 1000 * 60 * 3) { + final statusResponse = await _trezorRepo.getWithdrawStatus(taskId); + + if (statusResponse.error != null) { + return BlocResponse( + result: null, + error: TextError(error: statusResponse.error!), + ); + } + + final InitTrezorStatus? status = statusResponse.result?.status; + + switch (status) { + case InitTrezorStatus.error: + return BlocResponse( + result: null, + error: TextError( + error: statusResponse.result?.errorDetails?.error ?? + LocaleKeys.somethingWrong.tr()), + ); + + case InitTrezorStatus.inProgress: + final TrezorProgressStatus? progressDetails = + statusResponse.result?.progressDetails; + + onProgressUpdated(progressDetails); + break; + + case InitTrezorStatus.userActionRequired: + final TrezorUserAction? action = statusResponse.result?.actionDetails; + if (action == TrezorUserAction.enterTrezorPin) { + await showTrezorPinDialog(TrezorTask( + taskId: taskId, + type: TrezorTaskType.withdraw, + )); + } else if (action == TrezorUserAction.enterTrezorPassphrase) { + await showTrezorPassphraseDialog(TrezorTask( + taskId: taskId, + type: TrezorTaskType.enableUtxo, + )); + } + break; + + case InitTrezorStatus.ok: + return BlocResponse( + result: statusResponse.result?.details, + error: null, + ); + + default: + } + + await Future.delayed(const Duration(milliseconds: 500)); + } + + await _withdrawCancel(taskId); + return BlocResponse( + result: null, + error: TextError(error: LocaleKeys.timeout.tr()), + ); + } + + Future _withdrawCancel(int taskId) async { + await _trezorRepo.cancelWithdraw(taskId); + } +} diff --git a/lib/blocs/update_bloc.dart b/lib/blocs/update_bloc.dart new file mode 100644 index 0000000000..cfef6e655b --- /dev/null +++ b/lib/blocs/update_bloc.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:web_dex/blocs/bloc_base.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/platform/platform.dart'; +import 'package:web_dex/services/app_update_service/app_update_service.dart'; +import 'package:web_dex/shared/widgets/update_popup.dart'; + +final updateBloc = UpdateBloc(); + +class UpdateBloc extends BlocBase { + late Timer _checkerTime; + bool _isPopupShown = false; + + @override + void dispose() { + _checkerTime.cancel(); + } + + Future _checkForUpdates() async { + final currentVersion = await _getCurrentAppVersion(); + final versionInfo = await appUpdateService.getUpdateInfo(); + if (versionInfo == null) return; + final bool isNewVersion = + _isVersionGreaterThan(versionInfo.version, currentVersion); + + if (!isNewVersion || _isPopupShown) return; + + PopupDispatcher( + barrierDismissible: false, + contentPadding: isMobile + ? const EdgeInsets.all(15.0) + : const EdgeInsets.fromLTRB(26, 15, 26, 42), + popupContent: UpdatePopUp( + versionInfo: versionInfo, + onAccept: () { + _isPopupShown = false; + }, + onCancel: () { + _isPopupShown = false; + _checkerTime.cancel(); + }, + )).show(); + _isPopupShown = true; + } + + Future _getCurrentAppVersion() async { + final PackageInfo packageInfo = await PackageInfo.fromPlatform(); + return packageInfo.version; + } + + bool _isVersionGreaterThan(String newVersion, String currentVersion) { + if (newVersion == currentVersion) return false; + + final int currentV = int.parse(currentVersion.replaceAll('.', '')); + final int newV = int.parse(newVersion.replaceAll('.', '')); + + return newV > currentV; + } + + Future init() async { + await _checkForUpdates(); + _checkerTime = Timer.periodic( + const Duration(minutes: 5), + (_) async => await _checkForUpdates(), + ); + } + + Future update() async { + if (kIsWeb) { + reloadPage(); + } + } +} + +enum UpdateStatus { upToDate, available, recommended, required } + +class UpdateVersionInfo { + const UpdateVersionInfo({ + required this.status, + required this.version, + required this.changelog, + required this.downloadUrl, + }); + final String version; + final String changelog; + final String downloadUrl; + final UpdateStatus status; +} diff --git a/lib/blocs/wallets_bloc.dart b/lib/blocs/wallets_bloc.dart new file mode 100644 index 0000000000..10b06aae26 --- /dev/null +++ b/lib/blocs/wallets_bloc.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:uuid/uuid.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/wallets_bloc/wallets_repo.dart'; +import 'package:web_dex/blocs/bloc_base.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/utils/encryption_tool.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class WalletsBloc implements BlocBase { + WalletsBloc({ + required WalletsRepo walletsRepo, + required EncryptionTool encryptionTool, + }) : _walletsRepo = walletsRepo, + _encryptionTool = encryptionTool; + + final WalletsRepo _walletsRepo; + final EncryptionTool _encryptionTool; + + List _wallets = []; + List get wallets => _wallets; + set wallets(List newWallets) { + _wallets = newWallets; + _inWallets.add(_wallets); + } + + final StreamController> _walletsController = + StreamController>.broadcast(); + Sink> get _inWallets => _walletsController.sink; + Stream> get outWallets => _walletsController.stream; + + @override + void dispose() { + _walletsController.close(); + } + + Future createNewWallet({ + required String name, + required String password, + required String seed, + }) async { + try { + bool isWalletCreationSuccessfully = false; + + final String encryptedSeed = + await _encryptionTool.encryptData(password, seed); + + final Wallet newWallet = Wallet( + id: const Uuid().v1(), + name: name, + config: WalletConfig( + type: WalletType.iguana, + seedPhrase: encryptedSeed, + activatedCoins: enabledByDefaultCoins, + hasBackup: false, + ), + ); + log('Creating a new wallet ${newWallet.id}', + path: 'wallet_bloc => createNewWallet'); + + isWalletCreationSuccessfully = await _addWallet(newWallet); + + if (isWalletCreationSuccessfully) { + log('The wallet ${newWallet.id} has created', + path: 'wallet_bloc => createNewWallet'); + return newWallet; + } else { + return null; + } + } catch (_) { + return null; + } + } + + Future importWallet({ + required String name, + required String password, + required WalletConfig walletConfig, + WalletType type = WalletType.iguana, + }) async { + log('Importing a wallet $name', path: 'wallet_bloc => importWallet'); + try { + bool isWalletCreationSuccessfully = false; + + final String encryptedSeed = + await _encryptionTool.encryptData(password, walletConfig.seedPhrase); + final Wallet newWallet = Wallet( + id: const Uuid().v1(), + name: name, + config: WalletConfig( + type: type, + seedPhrase: encryptedSeed, + activatedCoins: walletConfig.activatedCoins, + hasBackup: true, + ), + ); + + isWalletCreationSuccessfully = await _addWallet(newWallet); + + if (isWalletCreationSuccessfully) { + log('The Wallet $name has imported', + path: 'wallet_bloc => importWallet'); + return newWallet; + } else { + return null; + } + } catch (_) { + return null; + } + } + + Future importTrezorWallet({ + required String name, + required String pubKey, + }) async { + try { + final Wallet? existedWallet = + wallets.firstWhereOrNull((w) => w.config.pubKey == pubKey); + if (existedWallet != null) return existedWallet; + + final Wallet newWallet = Wallet( + id: const Uuid().v1(), + name: name, + config: WalletConfig( + type: WalletType.trezor, + seedPhrase: '', + activatedCoins: enabledByDefaultTrezorCoins, + hasBackup: true, + pubKey: pubKey, + ), + ); + + final bool isWalletImportSuccessfully = await _addWallet(newWallet); + + if (isWalletImportSuccessfully) { + log('The Wallet $name has imported', + path: 'wallet_bloc => importWallet'); + return newWallet; + } else { + return null; + } + } catch (_) { + return null; + } + } + + Future fetchSavedWallets() async { + wallets = await _walletsRepo.getAll(); + } + + Future deleteWallet(Wallet wallet) async { + log( + 'Deleting a wallet ${wallet.id}', + path: 'wallet_bloc => deleteWallet', + ); + + final bool isDeletingSuccess = await _walletsRepo.delete(wallet); + if (isDeletingSuccess) { + final newWallets = _wallets.where((w) => w.id != wallet.id).toList(); + wallets = newWallets; + log( + 'The wallet ${wallet.id} has deleted', + path: 'wallet_bloc => deleteWallet', + ); + } + + return isDeletingSuccess; + } + + Future _addWallet(Wallet wallet) async { + final bool isSavingSuccess = await _walletsRepo.save(wallet); + if (isSavingSuccess) { + final List newWallets = [..._wallets]; + newWallets.add(wallet); + wallets = newWallets; + } + + return isSavingSuccess; + } + + String? validateWalletName(String name) { + if (wallets.firstWhereOrNull((w) => w.name == name) != null) { + return LocaleKeys.walletCreationExistNameError.tr(); + } else if (name.isEmpty || name.length > 40) { + return LocaleKeys.walletCreationNameLengthError.tr(); + } + return null; + } + + Future resetSpecificWallet(Wallet wallet) async { + WalletConfig updatedConfig = wallet.config.copy() + ..activatedCoins = enabledByDefaultCoins; + + Wallet updatedWallet = Wallet( + id: wallet.id, + name: wallet.name, + config: updatedConfig, + ); + + await _walletsRepo.save(updatedWallet); + } +} diff --git a/lib/common/app_assets.dart b/lib/common/app_assets.dart new file mode 100644 index 0000000000..5eac505177 --- /dev/null +++ b/lib/common/app_assets.dart @@ -0,0 +1,95 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/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'; + +class Assets { + static const seedSuccess = '$assetsPath/others/seed_success.svg'; + static const dexSwapCoins = '$assetsPath/others/dex_swap.svg'; + static const dexChevronDown = '$assetsPath/others/dex_chevron_down.svg'; + static const dexChevronUp = '$assetsPath/others/dex_chevron_down.svg'; + static const chevronLeftMobile = '$assetsPath/others/chevron_left_mobile.svg'; + static const chevronDown = '$assetsPath/others/chevron_down.svg'; + static const chevronUp = '$assetsPath/others/chevron_up.svg'; + static const assetTick = '$assetsPath/others/tick.svg'; + static const assetsDenied = '$assetsPath/others/denied.svg'; + static const discord = '$assetsPath/others/discord_icon.svg'; + static const seedBackedUp = '$assetsPath/ui_icons/seed_backed_up.svg'; + static const seedNotBackedUp = '$assetsPath/ui_icons/seed_not_backed_up.svg'; +} + +enum ColorFilterEnum { + expandMode, + headerIconColor, +} + +class DexSvgImage extends StatelessWidget { + final String path; + final ColorFilterEnum? colorFilter; + final double? size; + const DexSvgImage( + {super.key, required this.path, this.colorFilter, this.size}); + + @override + Widget build(BuildContext context) { + return SvgPicture.asset( + path, + colorFilter: _getColorFilter(), + width: size, + ); + } + + ColorFilter? _getColorFilter() { + switch (colorFilter) { + case ColorFilterEnum.expandMode: + return ColorFilter.mode(dexPageColors.expandMore, BlendMode.srcIn); + case ColorFilterEnum.headerIconColor: + return ColorFilter.mode(theme.custom.headerIconColor, BlendMode.srcIn); + default: + return null; + } + } +} + +class RewardBackground extends StatelessWidget { + const RewardBackground({super.key}); + + @override + Widget build(BuildContext context) { + return Image.asset( + '$assetsPath/others/rewardBackgroundImage.png', + filterQuality: FilterQuality.high, + ); + } +} + +class NavIcon extends StatelessWidget { + const NavIcon({ + required this.item, + required this.isActive, + super.key, + }); + + final MainMenuValue item; + final bool isActive; + + @override + Widget build(BuildContext context) { + final String iconPath = '/${item.name.split('.').last}'; + final String screenPath = isMobile ? '/mobile' : '/desktop'; + final String themePath = isMobile + ? '' + : theme.mode == ThemeMode.dark + ? '/dark' + : '/light'; + final String activeSuffix = isActive ? '_active' : ''; + + return SvgPicture.asset( + '$assetsPath/nav_icons$screenPath$themePath$iconPath$activeSuffix.svg', + width: isTablet ? 30 : 20, + height: isTablet ? 30 : 20, + ); + } +} diff --git a/lib/common/screen.dart b/lib/common/screen.dart new file mode 100644 index 0000000000..a837be5cd6 --- /dev/null +++ b/lib/common/screen.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/common/screen_type.dart'; + +export 'package:web_dex/common/screen_type.dart'; + +bool get isMobile => screenType == ScreenType.mobile; +bool get isTablet => screenType == ScreenType.tablet; +bool get isDesktop => screenType == ScreenType.desktop; +bool get isWideScreen => windowWidth > maxScreenWidth + mainLayoutPadding; + +bool get isNotMobile => !isMobile; +bool get isNotTablet => !isTablet; +bool get isNotDesktop => !isDesktop; + +ScreenType _screenType = ScreenType.mobile; +ScreenType get screenType => _screenType; + +double get screenWidth => _width; +double _width = 0; + +double get screenHeight => _height; +double _height = 0; + +void updateScreenType(BuildContext context) { + final size = MediaQuery.of(context).size; + _width = size.width; + _height = size.height; + + if (_width < 768) { + _screenType = ScreenType.mobile; + } else if (_width < 1024) { + _screenType = ScreenType.tablet; + } else { + _screenType = ScreenType.desktop; + } +} + +/// Storing top context in global variable [materialPageContext] +/// allows us to use [isMobile], [isTablet], and [isDesktop] getters +/// without passing local context every single time. +/// +// ignore: deprecated_member_use +/// [MediaQueryData.fromWindow] is deprecated and was replaced with +/// [MediaQueryData.fromView] on Flutter 3.10 due to the upcoming multi-window +/// support. + +BuildContext? materialPageContext; +double get windowWidth => materialPageContext != null + ? MediaQuery.of(materialPageContext!).size.width + : MediaQueryData.fromView(View.of(materialPageContext!)).size.width; diff --git a/lib/common/screen_type.dart b/lib/common/screen_type.dart new file mode 100644 index 0000000000..9921bd4e33 --- /dev/null +++ b/lib/common/screen_type.dart @@ -0,0 +1 @@ +enum ScreenType { mobile, tablet, desktop } diff --git a/lib/dispatchers/popup_dispatcher.dart b/lib/dispatchers/popup_dispatcher.dart new file mode 100644 index 0000000000..363a6e1811 --- /dev/null +++ b/lib/dispatchers/popup_dispatcher.dart @@ -0,0 +1,117 @@ +import 'dart:async'; + +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:web_dex/router/state/routing_state.dart'; + +class PopupDispatcher { + PopupDispatcher({ + this.context, + this.popupContent, + this.width, + this.insetPadding, + this.contentPadding, + this.barrierColor, + this.borderColor, + this.maxWidth = 640, + this.barrierDismissible = true, + this.onDismiss, + }); + + final BuildContext? context; + final Widget? popupContent; + final double? width; + final double maxWidth; + final bool barrierDismissible; + final EdgeInsets? insetPadding; + final EdgeInsets? contentPadding; + final Color? barrierColor; + final Color? borderColor; + final VoidCallback? onDismiss; + + bool _isShown = false; + bool get isShown => _isShown; + + StreamSubscription? _popStreamSubscription; + + Future show() async { + if (_currentContext == null) return; + + if (_isShown) close(); + _isShown = true; + final borderColor = this.borderColor; + _setupDismissibleLogic(); + + await showDialog( + barrierDismissible: barrierDismissible, + context: _currentContext!, + barrierColor: theme.custom.dialogBarrierColor, + builder: (BuildContext dialogContext) { + return SimpleDialog( + insetPadding: insetPadding ?? + EdgeInsets.symmetric( + horizontal: isMobile ? 16 : 24, + vertical: isMobile ? 40 : 24, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + side: borderColor != null + ? BorderSide(color: borderColor) + : BorderSide.none, + ), + contentPadding: contentPadding ?? + EdgeInsets.symmetric( + horizontal: isMobile ? 16 : 30, + vertical: isMobile ? 26 : 30, + ), + children: [ + Container( + width: width, + constraints: BoxConstraints(maxWidth: maxWidth), + child: popupContent, + ) + ], + ); + }, + ); + _isShown = false; + _resetBrowserNavigationToDefault(); + if (onDismiss != null) onDismiss!(); + } + + void close() { + _resetBrowserNavigationToDefault(); + if (_currentContext == null) return; + if (_isShown) Navigator.of(_currentContext!).pop(); + } + + void _setupDismissibleLogic() { + routingState.isBrowserNavigationBlocked = true; + if (barrierDismissible) { + if (kIsWeb) { + _onPopStateSubscriptionWeb(); + } + } + } + + void _onPopStateSubscriptionWeb() { + _popStreamSubscription = html.window.onPopState.listen((_) { + final navigator = Navigator.of(_currentContext!, rootNavigator: true); + if (navigator.canPop()) { + _resetBrowserNavigationToDefault(); + navigator.pop(); + } + }); + } + + void _resetBrowserNavigationToDefault() { + routingState.isBrowserNavigationBlocked = false; + _popStreamSubscription?.cancel(); + } + + BuildContext? get _currentContext => context ?? scaffoldKey.currentContext; +} diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart new file mode 100644 index 0000000000..221d298d69 --- /dev/null +++ b/lib/firebase_options.dart @@ -0,0 +1,81 @@ +// File generated by FlutterFire CLI. +// ignore_for_file: lines_longer_than_80_chars, avoid_classes_with_only_static_members +import 'package:firebase_core/firebase_core.dart' show FirebaseOptions; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, TargetPlatform; + +/// Default [FirebaseOptions] for use with your Firebase apps. +/// +/// Example: +/// ```dart +/// import 'firebase_options.dart'; +/// // ... +/// await Firebase.initializeApp( +/// options: DefaultFirebaseOptions.currentPlatform, +/// ); +/// ``` +class DefaultFirebaseOptions { + static FirebaseOptions get currentPlatform { + if (kIsWeb) { + return web; + } + switch (defaultTargetPlatform) { + case TargetPlatform.android: + return android; + case TargetPlatform.iOS: + return ios; + case TargetPlatform.macOS: + return macos; + case TargetPlatform.windows: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for windows - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + case TargetPlatform.linux: + throw UnsupportedError( + 'DefaultFirebaseOptions have not been configured for linux - ' + 'you can reconfigure this by running the FlutterFire CLI again.', + ); + default: + throw UnsupportedError( + 'DefaultFirebaseOptions are not supported for this platform.', + ); + } + } + + static const FirebaseOptions web = FirebaseOptions( + apiKey: '', + appId: '', + messagingSenderId: '', + projectId: '', + authDomain: '', + storageBucket: '', + measurementId: '', + ); + + static const FirebaseOptions android = FirebaseOptions( + apiKey: '', + appId: '', + messagingSenderId: '', + projectId: '', + storageBucket: '', + ); + + static const FirebaseOptions ios = FirebaseOptions( + apiKey: '', + appId: '', + messagingSenderId: '', + projectId: '', + storageBucket: '', + iosBundleId: '', + ); + + static const FirebaseOptions macos = FirebaseOptions( + apiKey: '', + appId: '', + messagingSenderId: '', + projectId: '', + storageBucket: '', + iosBundleId: '', + ); +} diff --git a/lib/generated/codegen_loader.g.dart b/lib/generated/codegen_loader.g.dart new file mode 100644 index 0000000000..a4e32f3193 --- /dev/null +++ b/lib/generated/codegen_loader.g.dart @@ -0,0 +1,623 @@ +// DO NOT EDIT. This is code generated via package:easy_localization/generate.dart + +abstract class LocaleKeys { + static const plsActivateKmd = 'plsActivateKmd'; + static const rewardClaiming = 'rewardClaiming'; + static const noKmdAddress = 'noKmdAddress'; + static const dex = 'dex'; + static const asset = 'asset'; + static const price = 'price'; + static const volume = 'volume'; + static const history = 'history'; + static const active = 'active'; + static const change24h = 'change24h'; + static const change24hRevert = 'change24hRevert'; + static const viewOnExplorer = 'viewOnExplorer'; + static const getRewards = 'getRewards'; + static const rewardBoxTitle = 'rewardBoxTitle'; + static const sendToAddress = 'sendToAddress'; + static const network = 'network'; + static const rewardBoxSubTitle = 'rewardBoxSubTitle'; + static const rewardBoxReadMore = 'rewardBoxReadMore'; + static const claimSuccess = 'claimSuccess'; + static const noRewards = 'noRewards'; + static const kmdAmount = 'kmdAmount'; + static const kmdReward = 'kmdReward'; + static const kmdRewardSpan1 = 'kmdRewardSpan1'; + static const timeLeft = 'timeLeft'; + static const status = 'status'; + static const complete = 'complete'; + static const claim = 'claim'; + static const noTransactionsTitle = 'noTransactionsTitle'; + static const noTransactionsDescription = 'noTransactionsDescription'; + static const noClaimableRewards = 'noClaimableRewards'; + static const amountToSend = 'amountToSend'; + static const enterAmountToSend = 'enterAmountToSend'; + static const inferiorSendAmount = 'inferiorSendAmount'; + static const date = 'date'; + static const confirmations = 'confirmations'; + static const blockHeight = 'blockHeight'; + static const from = 'from'; + static const to = 'to'; + static const fromDate = 'fromDate'; + static const toDate = 'toDate'; + static const amount = 'amount'; + static const close = 'close'; + static const fee = 'fee'; + static const done = 'done'; + static const fees = 'fees'; + static const recipientAddress = 'recipientAddress'; + static const transactionHash = 'transactionHash'; + static const hash = 'hash'; + static const fullHash = 'fullHash'; + static const coinAddress = 'coinAddress'; + static const youSend = 'youSend'; + static const invalidAddress = 'invalidAddress'; + static const customFeeCoin = 'customFeeCoin'; + static const customFeeOptional = 'customFeeOptional'; + static const optional = 'optional'; + static const showMore = 'showMore'; + static const settings = 'settings'; + static const somethingWrong = 'somethingWrong'; + static const transactionComplete = 'transactionComplete'; + static const transactionDenied = 'transactionDenied'; + static const coinDisableSpan1 = 'coinDisableSpan1'; + static const confirmSending = 'confirmSending'; + static const confirmSend = 'confirmSend'; + static const confirm = 'confirm'; + static const confirmed = 'confirmed'; + static const ok = 'ok'; + static const cancel = 'cancel'; + static const next = 'next'; + static const continueText = 'continueText'; + static const accept = 'accept'; + static const create = 'create'; + static const import = 'import'; + static const enterDataToSend = 'enterDataToSend'; + static const address = 'address'; + static const request = 'request'; + static const disable = 'disable'; + static const usdPrice = 'usdPrice'; + static const portfolio = 'portfolio'; + static const editList = 'editList'; + static const withBalance = 'withBalance'; + static const balance = 'balance'; + static const transactions = 'transactions'; + static const send = 'send'; + static const receive = 'receive'; + static const faucet = 'faucet'; + static const reward = 'reward'; + static const loadingSwap = 'loadingSwap'; + static const swapDaily = 'swapDaily'; + static const swapMonthly = 'swapMonthly'; + static const swapAllTime = 'swapAllTime'; + static const seed = 'seed'; + static const wallet = 'wallet'; + static const logIn = 'logIn'; + static const logOut = 'logOut'; + static const logOutGo = 'logOutGo'; + static const delete = 'delete'; + static const forget = 'forget'; + static const seedPhrase = 'seedPhrase'; + static const assetNumber = 'assetNumber'; + static const clipBoard = 'clipBoard'; + static const walletsManagerCreateWalletButton = + 'walletsManagerCreateWalletButton'; + static const walletsManagerImportWalletButton = + 'walletsManagerImportWalletButton'; + static const walletsManagerStepBuilderCreationWalletError = + 'walletsManagerStepBuilderCreationWalletError'; + static const walletCreationTitle = 'walletCreationTitle'; + static const walletImportTitle = 'walletImportTitle'; + static const walletImportByFileTitle = 'walletImportByFileTitle'; + static const walletImportCreatePasswordTitle = + 'walletImportCreatePasswordTitle'; + static const walletImportByFileDescription = 'walletImportByFileDescription'; + static const walletLogInTitle = 'walletLogInTitle'; + static const walletCreationNameHint = 'walletCreationNameHint'; + static const walletCreationPasswordHint = 'walletCreationPasswordHint'; + static const walletCreationConfirmPasswordHint = + 'walletCreationConfirmPasswordHint'; + static const walletCreationConfirmPassword = 'walletCreationConfirmPassword'; + static const walletCreationUploadFile = 'walletCreationUploadFile'; + static const walletCreationEmptySeedError = 'walletCreationEmptySeedError'; + static const walletCreationExistNameError = 'walletCreationExistNameError'; + static const walletCreationNameLengthError = 'walletCreationNameLengthError'; + static const walletCreationFormatPasswordError = + 'walletCreationFormatPasswordError'; + static const walletCreationConfirmPasswordError = + 'walletCreationConfirmPasswordError'; + static const invalidPasswordError = 'invalidPasswordError'; + static const importSeedEnterSeedPhraseHint = 'importSeedEnterSeedPhraseHint'; + static const passphraseCheckingTitle = 'passphraseCheckingTitle'; + static const passphraseCheckingDescription = 'passphraseCheckingDescription'; + static const passphraseCheckingEnterWord = 'passphraseCheckingEnterWord'; + static const passphraseCheckingEnterWordHint = + 'passphraseCheckingEnterWordHint'; + static const back = 'back'; + static const settingsMenuGeneral = 'settingsMenuGeneral'; + static const settingsMenuLanguage = 'settingsMenuLanguage'; + static const settingsMenuSecurity = 'settingsMenuSecurity'; + static const settingsMenuAbout = 'settingsMenuAbout'; + static const seedPhraseSettingControlsViewSeed = + 'seedPhraseSettingControlsViewSeed'; + static const seedPhraseSettingControlsDownloadSeed = + 'seedPhraseSettingControlsDownloadSeed'; + static const debugSettingsResetActivatedCoins = + 'debugSettingsResetActivatedCoins'; + static const debugSettingsDownloadButton = 'debugSettingsDownloadButton'; + static const or = 'or'; + static const passwordTitle = 'passwordTitle'; + static const passwordUpdateCreate = 'passwordUpdateCreate'; + static const enterThePassword = 'enterThePassword'; + static const changeThePassword = 'changeThePassword'; + static const changePasswordSpan1 = 'changePasswordSpan1'; + static const updatePassword = 'updatePassword'; + static const passwordHasChanged = 'passwordHasChanged'; + static const confirmationForShowingSeedPhraseTitle = + 'confirmationForShowingSeedPhraseTitle'; + static const saveAndRemember = 'saveAndRemember'; + static const seedPhraseShowingTitle = 'seedPhraseShowingTitle'; + static const seedPhraseShowingWarning = 'seedPhraseShowingWarning'; + static const seedPhraseShowingShowPhrase = 'seedPhraseShowingShowPhrase'; + static const seedPhraseShowingCopySeed = 'seedPhraseShowingCopySeed'; + static const seedPhraseShowingSavedPhraseButton = + 'seedPhraseShowingSavedPhraseButton'; + static const seedAccessSpan1 = 'seedAccessSpan1'; + static const backupSeedNotificationTitle = 'backupSeedNotificationTitle'; + static const backupSeedNotificationDescription = + 'backupSeedNotificationDescription'; + static const backupSeedNotificationButton = 'backupSeedNotificationButton'; + static const swapConfirmationTitle = 'swapConfirmationTitle'; + static const swapConfirmationYouReceive = 'swapConfirmationYouReceive'; + static const swapConfirmationYouSending = 'swapConfirmationYouSending'; + static const tradingDetailsTitleFailed = 'tradingDetailsTitleFailed'; + static const tradingDetailsTitleCompleted = 'tradingDetailsTitleCompleted'; + static const tradingDetailsTitleInProgress = 'tradingDetailsTitleInProgress'; + static const tradingDetailsTitleOrderMatching = + 'tradingDetailsTitleOrderMatching'; + static const tradingDetailsTotalSpentTime = 'tradingDetailsTotalSpentTime'; + static const tradingDetailsTotalSpentTimeWithHours = + 'tradingDetailsTotalSpentTimeWithHours'; + static const swapRecoverButtonTitle = 'swapRecoverButtonTitle'; + static const swapRecoverButtonText = 'swapRecoverButtonText'; + static const swapRecoverButtonErrorMessage = 'swapRecoverButtonErrorMessage'; + static const swapRecoverButtonSuccessMessage = + 'swapRecoverButtonSuccessMessage'; + static const swapProgressStatusFailed = 'swapProgressStatusFailed'; + static const swapDetailsStepStatusFailed = 'swapDetailsStepStatusFailed'; + static const disclaimerAcceptEulaCheckbox = 'disclaimerAcceptEulaCheckbox'; + static const disclaimerAcceptTermsAndConditionsCheckbox = + 'disclaimerAcceptTermsAndConditionsCheckbox'; + static const disclaimerAcceptDescription = 'disclaimerAcceptDescription'; + static const swapDetailsStepStatusInProcess = + 'swapDetailsStepStatusInProcess'; + static const swapDetailsStepStatusTimeSpent = + 'swapDetailsStepStatusTimeSpent'; + static const milliseconds = 'milliseconds'; + static const seconds = 'seconds'; + static const minutes = 'minutes'; + static const hours = 'hours'; + static const coinAddressDetailsNotificationTitle = + 'coinAddressDetailsNotificationTitle'; + static const coinAddressDetailsNotificationDescription = + 'coinAddressDetailsNotificationDescription'; + static const swapFeeDetailsPaidFromBalance = 'swapFeeDetailsPaidFromBalance'; + static const swapFeeDetailsSendCoinTxFee = 'swapFeeDetailsSendCoinTxFee'; + static const swapFeeDetailsReceiveCoinTxFee = + 'swapFeeDetailsReceiveCoinTxFee'; + static const swapFeeDetailsTradingFee = 'swapFeeDetailsTradingFee'; + static const swapFeeDetailsSendTradingFeeTxFee = + 'swapFeeDetailsSendTradingFeeTxFee'; + static const swapFeeDetailsNone = 'swapFeeDetailsNone'; + static const swapFeeDetailsPaidFromReceivedVolume = + 'swapFeeDetailsPaidFromReceivedVolume'; + static const logoutPopupTitle = 'logoutPopupTitle'; + static const logoutPopupDescription = 'logoutPopupDescription'; + static const transactionDetailsTitle = 'transactionDetailsTitle'; + static const customSeedWarningText = 'customSeedWarningText'; + static const customSeedIUnderstand = 'customSeedIUnderstand'; + static const walletCreationBip39SeedError = 'walletCreationBip39SeedError'; + static const walletPageNoSuchAsset = 'walletPageNoSuchAsset'; + static const swapCoin = 'swapCoin'; + static const fiatBalance = 'fiatBalance'; + static const yourBalance = 'yourBalance'; + static const all = 'all'; + static const taker = 'taker'; + static const maker = 'maker'; + static const successful = 'successful'; + static const success = 'success'; + static const failed = 'failed'; + static const exchangeCoin = 'exchangeCoin'; + static const search = 'search'; + static const searchAssets = 'searchAssets'; + static const searchCoin = 'searchCoin'; + static const filters = 'filters'; + static const sellAsset = 'sellAsset'; + static const buyAsset = 'buyAsset'; + static const assetName = 'assetName'; + static const protocol = 'protocol'; + static const resetAll = 'resetAll'; + static const reset = 'reset'; + static const clearFilter = 'clearFilter'; + static const addAssets = 'addAssets'; + static const removeAssets = 'removeAssets'; + static const selectedAssetsCount = 'selectedAssetsCount'; + static const clickAssetToAddHint = 'clickAssetToAddHint'; + static const clickAssetToRemoveHint = 'clickAssetToRemoveHint'; + static const defaultCoinDisableWarning = 'defaultCoinDisableWarning'; + static const supportFrequentlyQuestionSpan = 'supportFrequentlyQuestionSpan'; + static const support = 'support'; + static const supportInfoTitle1 = 'supportInfoTitle1'; + static const supportInfoContent1 = 'supportInfoContent1'; + static const supportInfoTitle2 = 'supportInfoTitle2'; + static const supportInfoContent2 = 'supportInfoContent2'; + static const supportInfoTitle3 = 'supportInfoTitle3'; + static const supportInfoContent3 = 'supportInfoContent3'; + static const supportInfoTitle4 = 'supportInfoTitle4'; + static const supportInfoContent4 = 'supportInfoContent4'; + static const supportInfoTitle5 = 'supportInfoTitle5'; + static const supportInfoContent5 = 'supportInfoContent5'; + static const supportInfoTitle6 = 'supportInfoTitle6'; + static const supportInfoContent6 = 'supportInfoContent6'; + static const supportInfoTitle7 = 'supportInfoTitle7'; + static const supportInfoContent7 = 'supportInfoContent7'; + static const supportInfoTitle8 = 'supportInfoTitle8'; + static const supportInfoContent8 = 'supportInfoContent8'; + static const supportInfoTitle9 = 'supportInfoTitle9'; + static const supportInfoContent9 = 'supportInfoContent9'; + static const supportInfoTitle10 = 'supportInfoTitle10'; + static const supportInfoContent10 = 'supportInfoContent10'; + static const supportDiscordButton = 'supportDiscordButton'; + static const supportAskSpan = 'supportAskSpan'; + static const fiat = 'fiat'; + static const bridge = 'bridge'; + static const apply = 'apply'; + static const makerOrder = 'makerOrder'; + static const takerOrder = 'takerOrder'; + 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'; + static const cancelAll = 'cancelAll'; + static const type = 'type'; + static const sell = 'sell'; + static const buy = 'buy'; + static const changingWalletPassword = 'changingWalletPassword'; + static const changingWalletPasswordDescription = + 'changingWalletPasswordDescription'; + static const dark = 'dark'; + static const darkMode = 'darkMode'; + static const light = 'light'; + static const lightMode = 'lightMode'; + static const defaultText = 'defaultText'; + static const clear = 'clear'; + static const remove = 'remove'; + static const newText = 'newText'; + static const whatsNew = 'whatsNew'; + static const remindLater = 'remindLater'; + static const updateNow = 'updateNow'; + static const updatePopupTitle = 'updatePopupTitle'; + static const activationFailedMessage = 'activationFailedMessage'; + static const retryButtonText = 'retryButtonText'; + static const reloadButtonText = 'reloadButtonText'; + static const feedbackFormTitle = 'feedbackFormTitle'; + static const feedbackFormDescription = 'feedbackFormDescription'; + static const feedbackFormThanksTitle = 'feedbackFormThanksTitle'; + static const feedbackFormThanksDescription = 'feedbackFormThanksDescription'; + static const email = 'email'; + static const emailValidatorError = 'emailValidatorError'; + static const feedbackValidatorEmptyError = 'feedbackValidatorEmptyError'; + static const feedbackValidatorMaxLengthError = + 'feedbackValidatorMaxLengthError'; + static const yourFeedback = 'yourFeedback'; + static const sendFeedback = 'sendFeedback'; + static const sendFeedbackError = 'sendFeedbackError'; + static const addMoreFeedback = 'addMoreFeedback'; + static const closePopup = 'closePopup'; + static const exchange = 'exchange'; + static const connectSomething = 'connectSomething'; + static const hardwareWallet = 'hardwareWallet'; + static const komodoWalletSeed = 'komodoWalletSeed'; + static const metamask = 'metamask'; + static const comingSoon = 'comingSoon'; + static const walletsTypeListTitle = 'walletsTypeListTitle'; + static const seedPhraseMakeSureBody = 'seedPhraseMakeSureBody'; + static const seedPhraseSuccessTitle = 'seedPhraseSuccessTitle'; + static const seedPhraseSuccessBody = 'seedPhraseSuccessBody'; + static const seedPhraseGotIt = 'seedPhraseGotIt'; + static const viewSeedPhrase = 'viewSeedPhrase'; + 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'; + static const tryAgain = 'tryAgain'; + static const customFeesWarning = 'customFeesWarning'; + static const fiatExchange = 'fiatExchange'; + static const bridgeExchange = 'bridgeExchange'; + static const noTxSupportHidden = 'noTxSupportHidden'; + static const deleteWalletTitle = 'deleteWalletTitle'; + static const deleteWalletInfo = 'deleteWalletInfo'; + static const trezorEnterPinTitle = 'trezorEnterPinTitle'; + static const trezorEnterPinHint = 'trezorEnterPinHint'; + static const trezorInProgressTitle = 'trezorInProgressTitle'; + static const trezorInProgressHint = 'trezorInProgressHint'; + static const trezorErrorBusy = 'trezorErrorBusy'; + static const trezorErrorInvalidPin = 'trezorErrorInvalidPin'; + static const trezorSelectTitle = 'trezorSelectTitle'; + static const trezorSelectSubTitle = 'trezorSelectSubTitle'; + static const mixedCaseError = 'mixedCaseError'; + static const invalidAddressChecksum = 'invalidAddressChecksum'; + static const notEnoughBalance = 'notEnoughBalance'; + static const pleaseInputData = 'pleaseInputData'; + static const customFeeHigherAmount = 'customFeeHigherAmount'; + static const noSenderAddress = 'noSenderAddress'; + static const confirmOnTrezor = 'confirmOnTrezor'; + static const alphaVersionWarningTitle = 'alphaVersionWarningTitle'; + static const alphaVersionWarningDescription = + 'alphaVersionWarningDescription'; + static const sendToAnalytics = 'sendToAnalytics'; + static const backToWallet = 'backToWallet'; + static const backToDex = 'backToDex'; + static const backToBridge = 'backToBridge'; + static const scanToGetAddress = 'scanToGetAddress'; + static const listIsEmpty = 'listIsEmpty'; + static const setMax = 'setMax'; + static const setMin = 'setMin'; + static const timeout = 'timeout'; + static const notEnoughBalanceForGasError = 'notEnoughBalanceForGasError'; + static const notEnoughFundsError = 'notEnoughFundsError'; + static const dexErrorMessage = 'dexErrorMessage'; + static const seedConfirmInitialText = 'seedConfirmInitialText'; + static const seedConfirmIncorrectText = 'seedConfirmIncorrectText'; + static const usedSamePassword = 'usedSamePassword'; + static const passwordNotAccepted = 'passwordNotAccepted'; + static const confirmNewPassword = 'confirmNewPassword'; + static const enterNewPassword = 'enterNewPassword'; + static const currentPassword = 'currentPassword'; + static const walletNotFound = 'walletNotFound'; + static const passwordIsEmpty = 'passwordIsEmpty'; + static const dexBalanceNotSufficientError = 'dexBalanceNotSufficientError'; + static const dexEnterPriceError = 'dexEnterPriceError'; + static const dexZeroPriceError = 'dexZeroPriceError'; + static const dexSelectBuyCoinError = 'dexSelectBuyCoinError'; + static const dexSelectSellCoinError = 'dexSelectSellCoinError'; + static const dexCoinSuspendedError = 'dexCoinSuspendedError'; + static const dexEnterBuyAmountError = 'dexEnterBuyAmountError'; + static const dexEnterSellAmountError = 'dexEnterSellAmountError'; + static const dexZeroBuyAmountError = 'dexZeroBuyAmountError'; + static const dexZeroSellAmountError = 'dexZeroSellAmountError'; + static const dexMaxSellAmountError = 'dexMaxSellAmountError'; + static const dexMaxOrderVolume = 'dexMaxOrderVolume'; + static const dexMinSellAmountError = 'dexMinSellAmountError'; + static const dexMaxOrderVolumeError = 'dexMaxOrderVolumeError'; + static const dexInsufficientFundsError = 'dexInsufficientFundsError'; + static const dexTradingWithSelfError = 'dexTradingWithSelfError'; + static const bridgeSelectSendProtocolError = 'bridgeSelectSendProtocolError'; + static const bridgeSelectFromProtocolError = 'bridgeSelectFromProtocolError'; + static const bridgeSelectTokenFirstError = 'bridgeSelectTokenFirstError'; + static const bridgeEnterSendAmountError = 'bridgeEnterSendAmountError'; + static const bridgeZeroSendAmountError = 'bridgeZeroSendAmountError'; + static const bridgeMaxSendAmountError = 'bridgeMaxSendAmountError'; + static const bridgeMinOrderAmountError = 'bridgeMinOrderAmountError'; + static const bridgeMaxOrderAmountError = 'bridgeMaxOrderAmountError'; + static const bridgeInsufficientBalanceError = + 'bridgeInsufficientBalanceError'; + static const lowTradeVolumeError = 'lowTradeVolumeError'; + static const bridgeSelectReceiveCoinError = 'bridgeSelectReceiveCoinError'; + static const withdrawNoParentCoinError = 'withdrawNoParentCoinError'; + static const withdrawTopUpBalanceError = 'withdrawTopUpBalanceError'; + static const withdrawNotEnoughBalanceForGasError = + 'withdrawNotEnoughBalanceForGasError'; + static const withdrawNotSufficientBalanceError = + 'withdrawNotSufficientBalanceError'; + static const withdrawZeroBalanceError = 'withdrawZeroBalanceError'; + static const withdrawAmountTooLowError = 'withdrawAmountTooLowError'; + static const withdrawNoSuchCoinError = 'withdrawNoSuchCoinError'; + static const txHistoryFetchError = 'txHistoryFetchError'; + static const txHistoryNoTransactions = 'txHistoryNoTransactions'; + static const memo = 'memo'; + static const gasPriceGwei = 'gasPriceGwei'; + static const gasLimit = 'gasLimit'; + static const memoOptional = 'memoOptional'; + static const convert = 'convert'; + static const youClaimed = 'youClaimed'; + static const successClaim = 'successClaim'; + static const rewardProcessingShort = 'rewardProcessingShort'; + static const rewardProcessingLong = 'rewardProcessingLong'; + static const rewardLessThanTenLong = 'rewardLessThanTenLong'; + static const rewardOneHourNotPassedShort = 'rewardOneHourNotPassedShort'; + static const rewardOneHourNotPassedLong = 'rewardOneHourNotPassedLong'; + static const comparedToCexTitle = 'comparedToCexTitle'; + static const comparedToCexInfo = 'comparedToCexInfo'; + static const makeOrder = 'makeOrder'; + static const nothingFound = 'nothingFound'; + static const half = 'half'; + static const max = 'max'; + static const reactivating = 'reactivating'; + static const weFailedCoinActivate = 'weFailedCoinActivate'; + static const failedActivate = 'failedActivate'; + static const pleaseTryActivateAssets = 'pleaseTryActivateAssets'; + static const makerOrderDetails = 'makerOrderDetails'; + static const cancelled = 'cancelled'; + static const fulfilled = 'fulfilled'; + static const cancelledInsufficientBalance = 'cancelledInsufficientBalance'; + static const orderId = 'orderId'; + static const details = 'details'; + static const orderBook = 'orderBook'; + static const orderBookFailedLoadError = 'orderBookFailedLoadError'; + static const orderBookNoAsks = 'orderBookNoAsks'; + static const orderBookNoBids = 'orderBookNoBids'; + static const orderBookEmpty = 'orderBookEmpty'; + static const freshAddress = 'freshAddress'; + static const userActionRequired = 'userActionRequired'; + static const unknown = 'unknown'; + static const unableToActiveCoin = 'unableToActiveCoin'; + static const feedback = 'feedback'; + static const selectAToken = 'selectAToken'; + static const selectToken = 'selectToken'; + static const rate = 'rate'; + static const totalFees = 'totalFees'; + static const selectProtocol = 'selectProtocol'; + static const showSwapData = 'showSwapData'; + static const importSwaps = 'importSwaps'; + static const changeTheme = 'changeTheme'; + static const available = 'available'; + static const availableForSwaps = 'availableForSwaps'; + static const swapNow = 'swapNow'; + static const passphrase = 'passphrase'; + static const enterPassphraseHiddenWalletTitle = + 'enterPassphraseHiddenWalletTitle'; + static const enterPassphraseHiddenWalletDescription = + 'enterPassphraseHiddenWalletDescription'; + static const skip = 'skip'; + static const activateToSeeFunds = 'activateToSeeFunds'; + static const allowCustomFee = 'allowCustomFee'; + static const cancelOrder = 'cancelOrder'; + static const version = 'version'; + static const copyToClipboard = 'copyToClipboard'; + static const createdAt = 'createdAt'; + static const coin = 'coin'; + static const token = 'token'; + static const matching = 'matching'; + static const matched = 'matched'; + static const ongoing = 'ongoing'; + static const manageAnalytics = 'manageAnalytics'; + static const logs = 'logs'; + static const resetActivatedCoinsTitle = 'resetActivatedCoinsTitle'; + static const seedConfirmTitle = 'seedConfirmTitle'; + static const seedConfirmDescription = 'seedConfirmDescription'; + static const standardWallet = 'standardWallet'; + static const noPassphrase = 'noPassphrase'; + static const passphraseRequired = 'passphraseRequired'; + static const hiddenWallet = 'hiddenWallet'; + static const accessHiddenWallet = 'accessHiddenWallet'; + static const passphraseIsEmpty = 'passphraseIsEmpty'; + static const selectWalletType = 'selectWalletType'; + static const trezorNoAddresses = 'trezorNoAddresses'; + static const trezorImportFailed = 'trezorImportFailed'; + static const faucetFailureTitle = 'faucetFailureTitle'; + static const faucetLoadingTitle = 'faucetLoadingTitle'; + static const faucetInitialTitle = 'faucetInitialTitle'; + static const faucetUnknownErrorMessage = 'faucetUnknownErrorMessage'; + static const faucetLinkToTransaction = 'faucetLinkToTransaction'; + static const nfts = 'nfts'; + static const nft = 'nft'; + static const blockchain = 'blockchain'; + static const nItems = 'nItems'; + static const nNetworks = 'nNetworks'; + static const receiveNft = 'receiveNft'; + static const yourCollectibles = 'yourCollectibles'; + static const transactionsHistory = 'transactionsHistory'; + static const transactionsEmptyTitle = 'transactionsEmptyTitle'; + static const transactionsEmptyDescription = 'transactionsEmptyDescription'; + static const transactionsNoLoginCAT = 'transactionsNoLoginCAT'; + static const loadingError = 'loadingError'; + static const tryAgainButton = 'tryAgainButton'; + static const contractAddress = 'contractAddress'; + static const tokenID = 'tokenID'; + static const tokenStandard = 'tokenStandard'; + static const tokensAmount = 'tokensAmount'; + static const noCollectibles = 'noCollectibles'; + static const tryReceiveNft = 'tryReceiveNft'; + static const networkFee = 'networkFee'; + static const titleUnknown = 'titleUnknown'; + static const maxCount = 'maxCount'; + static const minCount = 'minCount'; + static const successfullySent = 'successfullySent'; + static const transactionId = 'transactionId'; + static const transactionFee = 'transactionFee'; + static const collectibles = 'collectibles'; + static const sendingProcess = 'sendingProcess'; + static const ercStandardDisclaimer = 'ercStandardDisclaimer'; + static const nftMainLoggedOut = 'nftMainLoggedOut'; + static const confirmLogoutOnAnotherTab = 'confirmLogoutOnAnotherTab'; + static const refreshList = 'refreshList'; + static const unableRetrieveNftData = 'unableRetrieveNftData'; + static const tryCheckInternetConnection = 'tryCheckInternetConnection'; + static const resetWalletTitle = 'resetWalletTitle'; + static const resetWalletContent = 'resetWalletContent'; + static const resetCompleteTitle = 'resetCompleteTitle'; + static const resetCompleteContent = 'resetCompleteContent'; + static const noWalletsAvailable = 'noWalletsAvailable'; + static const selectWalletToReset = 'selectWalletToReset'; + static const qrScannerTitle = 'qrScannerTitle'; + static const qrScannerErrorControllerUninitialized = + 'qrScannerErrorControllerUninitialized'; + static const qrScannerErrorPermissionDenied = + 'qrScannerErrorPermissionDenied'; + static const qrScannerErrorGenericError = 'qrScannerErrorGenericError'; + static const qrScannerErrorTitle = 'qrScannerErrorTitle'; + static const spend = 'spend'; + static const viewInvoice = 'viewInvoice'; + static const systemTimeWarning = 'systemTimeWarning'; + static const errorCode = 'errorCode'; + static const errorDetails = 'errorDetails'; + static const errorMessage = 'errorMessage'; + static const followTrezorInstructions = 'followTrezorInstructions'; + static const orderFailedTryAgain = 'orderFailedTryAgain'; + static const noOptionsToPurchase = 'noOptionsToPurchase'; + static const youReceive = 'youReceive'; + static const selectFiat = 'selectFiat'; + static const selectCoin = 'selectCoin'; + static const bestOffer = 'bestOffer'; + static const loadingNfts = 'loadingNfts'; + static const komodoWallet = 'komodoWallet'; + static const coinAssets = 'coinAssets'; + static const bundled = 'bundled'; + static const updated = 'updated'; + static const notUpdated = 'notUpdated'; + static const api = 'api'; + static const floodLogs = 'floodLogs'; + static const addressNotFound = 'addressNotFound'; + static const enterAmount = 'enterAmount'; + static const submitting = 'submitting'; + static const buyNow = 'buyNow'; + static const fiatCantCompleteOrder = 'fiatCantCompleteOrder'; + static const fiatPriceCanChange = 'fiatPriceCanChange'; + static const fiatConnectWallet = 'fiatConnectWallet'; + static const pleaseWait = 'pleaseWait'; + static const bitrefillPaymentSuccessfull = 'bitrefillPaymentSuccessfull'; + static const bitrefillPaymentSuccessfullInstruction = + 'bitrefillPaymentSuccessfullInstruction'; + static const tradingBot = 'tradingBot'; + static const margin = 'margin'; + static const updateInterval = 'updateInterval'; + static const expertMode = 'expertMode'; + static const enableTradingBot = 'enableTradingBot'; + static const makeMarket = 'makeMarket'; + static const custom = 'custom'; + static const edit = 'edit'; + static const offer = 'offer'; + static const asking = 'asking'; + static const mmBotRestart = 'mmBotRestart'; + static const mmBotStart = 'mmBotStart'; + static const mmBotStop = 'mmBotStop'; + static const mmBotStatusRunning = 'mmBotStatusRunning'; + static const mmBotStatusStopped = 'mmBotStatusStopped'; + static const mmBotStatusStarting = 'mmBotStatusStarting'; + static const mmBotStatusStopping = 'mmBotStatusStopping'; + static const mmBotTradeVolumeRequired = 'mmBotTradeVolumeRequired'; + static const postitiveNumberRequired = 'postitiveNumberRequired'; + static const mustBeLessThan = 'mustBeLessThan'; + static const mmBotMinimumTradeVolume = 'mmBotMinimumTradeVolume'; + static const mmBotVolumePerTrade = 'mmBotVolumePerTrade'; + static const mmBotFirstTradePreview = 'mmBotFirstTradePreview'; + static const mmBotFirstTradeEstimate = 'mmBotFirstTradeEstimate'; + static const mmBotFirstOrderVolume = 'mmBotFirstOrderVolume'; + static const important = 'important'; + static const trend = 'trend'; + static const growth = 'growth'; + static const portfolioGrowth = 'portfolioGrowth'; + static const performance = 'performance'; + static const portfolioPerformance = 'portfolioPerformance'; + static const allTimeInvestment = 'allTimeInvestment'; + static const allTimeProfit = 'allTimeProfit'; + static const profitAndLoss = 'profitAndLoss'; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000000..52410cfbe1 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,99 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_web_plugins/url_strategy.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_coin_updates/komodo_coin_updates.dart'; +import 'package:universal_html/html.dart' as html; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/app_config/package_information.dart'; +import 'package:web_dex/bloc/app_bloc_observer.dart'; +import 'package:web_dex/bloc/app_bloc_root.dart' deferred as app_bloc_root; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/cex_market_data/cex_market_data.dart'; +import 'package:web_dex/bloc/cex_market_data/mockup/performance_mode.dart'; +import 'package:web_dex/bloc/runtime_coin_updates/runtime_update_config_provider.dart'; +import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/blocs/startup_bloc.dart'; +import 'package:web_dex/model/stored_settings.dart'; +import 'package:web_dex/performance_analytics/performance_analytics.dart'; +import 'package:web_dex/services/logger/get_logger.dart'; +import 'package:web_dex/shared/utils/platform_tuner.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +part 'services/initializer/app_bootstrapper.dart'; + +PerformanceMode? _appDemoPerformanceMode; + +PerformanceMode? get appDemoPerformanceMode => + _appDemoPerformanceMode ?? _getPerformanceModeFromUrl(); + +Future main() async { + usePathUrlStrategy(); + + WidgetsFlutterBinding.ensureInitialized(); + + await AppBootstrapper.instance.ensureInitialized(); + + Bloc.observer = AppBlocObserver(); + + PerformanceAnalytics.init(); + + runApp( + EasyLocalization( + supportedLocales: localeList, + fallbackLocale: localeList.first, + useFallbackTranslations: true, + useOnlyLangCode: true, + path: '$assetsPath/translations', + child: MyApp(), + ), + ); +} + +PerformanceMode? _getPerformanceModeFromUrl() { + String? maybeEnvPerformanceMode; + + maybeEnvPerformanceMode = const bool.hasEnvironment('DEMO_MODE_PERFORMANCE') + ? const String.fromEnvironment('DEMO_MODE_PERFORMANCE') + : null; + + if (kIsWeb) { + final url = html.window.location.href; + final uri = Uri.parse(url); + maybeEnvPerformanceMode = + uri.queryParameters['demo_mode_performance'] ?? maybeEnvPerformanceMode; + } + + switch (maybeEnvPerformanceMode) { + case 'good': + return PerformanceMode.good; + case 'mediocre': + return PerformanceMode.mediocre; + case 'very_bad': + return PerformanceMode.veryBad; + default: + return null; + } +} + +class MyApp extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (_) => AuthBloc(authRepo: authRepo), + ), + ], + child: app_bloc_root.AppBlocRoot( + storedPrefs: _storedSettings!, + runtimeUpdateConfig: _runtimeUpdateConfig!, + ), + ); + } +} diff --git a/lib/mm2/mm2.dart b/lib/mm2/mm2.dart new file mode 100644 index 0000000000..bcf35ce63e --- /dev/null +++ b/lib/mm2/mm2.dart @@ -0,0 +1,170 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/mm2/mm2_android.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/get_my_peer_id/get_my_peer_id_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/version/version_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/version/version_response.dart'; +import 'package:web_dex/mm2/mm2_ios.dart'; +import 'package:web_dex/mm2/mm2_linux.dart'; +import 'package:web_dex/mm2/mm2_macos.dart'; +import 'package:web_dex/mm2/mm2_web.dart'; +import 'package:web_dex/mm2/mm2_windows.dart'; +import 'package:web_dex/shared/utils/password.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +final MM2 mm2 = _createMM2(); + +abstract class MM2 { + const MM2(); + static late String _rpcPassword; + + Future start(String? passphrase); + + Future stop(); + + Future version() async { + final dynamic responseStr = await call(VersionRequest()); + final Map responseJson = jsonDecode(responseStr); + final VersionResponse response = VersionResponse.fromJson(responseJson); + + return response.result; + } + + Future isLive() async { + try { + final String response = await call(GetMyPeerIdRequest()); + final Map responseJson = jsonDecode(response); + + return responseJson['result']?.isNotEmpty ?? false; + } catch (e, s) { + log( + 'Get my peer id error: ${e.toString()}', + path: 'mm2 => isLive', + trace: s, + isError: true, + ); + return false; + } + } + + Future status(); + + Future call(dynamic reqStr); + + static String prepareRequest(dynamic req) { + final String reqStr = jsonEncode(_assertPass(req)); + return reqStr; + } + + static Future> generateStartParams({ + required String gui, + required String? passphrase, + required String? userHome, + required String? dbDir, + }) async { + String newRpcPassword = generatePassword(); + + if (!validateRPCPassword(newRpcPassword)) { + log( + 'If you\'re seeing this, there\'s a bug in the rpcPassword generation code.', + path: 'auth_bloc => _startMM2', + ); + throw Exception('invalid rpc password'); + } + _rpcPassword = newRpcPassword; + + // Use the repository to load the known global coins, so that we can load + // from the bundled configs OR the storage provider after updates are + // downloaded from GitHub. + final List coins = (await coinsRepo.getKnownGlobalCoins()) + .map((e) => e.toJson() as dynamic) + .toList(); + + // Load the stored settings to get the message service config. + final storedSettings = await SettingsRepository.loadStoredSettings(); + final messageServiceConfig = + storedSettings.marketMakerBotSettings.messageServiceConfig; + + return { + 'mm2': 1, + 'allow_weak_password': false, + 'rpc_password': _rpcPassword, + 'netid': 8762, + 'coins': coins, + 'gui': gui, + if (dbDir != null) 'dbdir': dbDir, + if (userHome != null) 'userhome': userHome, + if (passphrase != null) 'passphrase': passphrase, + if (messageServiceConfig != null) + 'message_service_cfg': messageServiceConfig.toJson(), + }; + } + + static dynamic _assertPass(dynamic req) { + if (req is List) { + for (dynamic element in req) { + element.userpass = _rpcPassword; + } + } else { + if (req is Map) { + req['userpass'] = _rpcPassword; + } else { + req.userpass = _rpcPassword; + } + } + + return req; + } +} + +MM2 _createMM2() { + if (kIsWeb) { + return MM2Web(); + } else if (Platform.isMacOS) { + return MM2MacOs(); + } else if (Platform.isIOS) { + return MM2iOS(); + } else if (Platform.isWindows) { + return MM2Windows(); + } else if (Platform.isLinux) { + return MM2Linux(); + } else if (Platform.isAndroid) { + return MM2Android(); + } + + throw UnimplementedError(); +} + +// 0 - MM2 is not running yet. +// 1 - MM2 is running, but no context yet. +// 2 - MM2 is running, but no RPC yet. +// 3 - MM2's RPC is up. +enum MM2Status { + isNotRunningYet, + runningWithoutContext, + runningWithoutRPC, + rpcIsUp; + + static MM2Status fromInt(int status) { + switch (status) { + case 0: + return isNotRunningYet; + case 1: + return runningWithoutContext; + case 2: + return runningWithoutRPC; + case 3: + return rpcIsUp; + default: + return isNotRunningYet; + } + } +} + +abstract class MM2WithInit { + Future init(); +} diff --git a/lib/mm2/mm2_android.dart b/lib/mm2/mm2_android.dart new file mode 100644 index 0000000000..ef6a57cb5a --- /dev/null +++ b/lib/mm2/mm2_android.dart @@ -0,0 +1,66 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/mm2/rpc.dart'; +import 'package:web_dex/mm2/rpc_native.dart'; +import 'package:web_dex/services/logger/get_logger.dart'; +import 'package:web_dex/services/native_channel.dart'; + +class MM2Android extends MM2 implements MM2WithInit { + final RPC _rpc = RPCNative(); + + @override + Future start(String? passphrase) async { + await stop(); + final Directory dir = await getApplicationDocumentsDirectory(); + final String filesPath = '${dir.path}/'; + final Map params = await MM2.generateStartParams( + passphrase: passphrase, + gui: 'web_dex Android', + userHome: filesPath, + dbDir: filesPath, + ); + + final int errorCode = await nativeChannel.invokeMethod( + 'start', {'params': jsonEncode(params)}); + + if (kDebugMode) { + print('MM2 start response:$errorCode'); + } + // todo: handle 'already running' case + } + + @override + Future stop() async { + // todo: consider using FFI instead of RPC here + await mm2Api.stop(); + } + + @override + Future status() async { + return MM2Status.fromInt( + await nativeChannel.invokeMethod('status')); + } + + @override + Future call(dynamic reqStr) async { + return await _rpc.call(MM2.prepareRequest(reqStr)); + } + + @override + Future init() async { + await _subscribeOnLogs(); + } + + Future _subscribeOnLogs() async { + nativeEventChannel.receiveBroadcastStream().listen((log) async { + if (log is String) { + await logger.write(log); + } + }); + } +} diff --git a/lib/mm2/mm2_api/mm2_api.dart b/lib/mm2/mm2_api/mm2_api.dart new file mode 100644 index 0000000000..6cf19583bf --- /dev/null +++ b/lib/mm2/mm2_api/mm2_api.dart @@ -0,0 +1,920 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api_nft.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api_trezor.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/active_swaps/active_swaps_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/cancel_order/cancel_order_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/convert_address/convert_address_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/electrum/electrum_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/enable/enable_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/enable_tendermint/enable_tendermint_token.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/enable_tendermint/enable_tendermint_with_assets.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/get_enabled_coins/get_enabled_coins_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/import_swaps/import_swaps_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/import_swaps/import_swaps_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_rewards_info_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/min_trading_vol/min_trading_vol.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/min_trading_vol/min_trading_vol_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_orders/my_orders_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_orders/my_orders_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_swap_status/my_swap_status_req.dart'; +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'; +import 'package:web_dex/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/rpc_error.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/sell/sell_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/setprice/setprice_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/stop/stop_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_response.dart'; +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/coin.dart'; +import 'package:web_dex/model/orderbook/orderbook.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +final Mm2Api mm2Api = Mm2Api(mm2: mm2); + +class Mm2Api { + Mm2Api({ + required MM2 mm2, + }) : _mm2 = mm2 { + trezor = Mm2ApiTrezor(_call); + nft = Mm2ApiNft(_call); + } + + final MM2 _mm2; + late Mm2ApiTrezor trezor; + late Mm2ApiNft nft; + VersionResponse? _versionResponse; + + Future?> getEnabledCoins(List knownCoins) async { + dynamic response; + try { + response = await _call(GetEnabledCoinsReq()); + } catch (e) { + log( + 'Error getting enabled coins: ${e.toString()}', + path: 'api => getEnabledCoins => _call', + isError: true, + ); + return null; + } + + dynamic resultJson; + try { + resultJson = jsonDecode(response)['result']; + } catch (e, s) { + log( + 'Error parsing of enabled coins response: ${e.toString()}', + path: 'api => getEnabledCoins => jsonDecode', + trace: s, + isError: true, + ); + return null; + } + + final List list = []; + if (resultJson is List) { + for (dynamic item in resultJson) { + final Coin? coin = knownCoins.firstWhereOrNull( + (Coin known) => known.abbr == item['ticker'], + ); + + if (coin != null) { + coin.address = item['address']; + list.add(coin); + } + } + } + + return list; + } + + Future enableCoins({ + required List? ethWithTokensRequests, + required List? electrumCoinRequests, + required List? erc20Requests, + required List? tendermintRequests, + required List? tendermintTokenRequests, + required List? bchWithTokens, + required List? slpTokens, + }) async { + if (ethWithTokensRequests != null && ethWithTokensRequests.isNotEmpty) { + await _enableEthWithTokensCoins(ethWithTokensRequests); + } + if (erc20Requests != null && erc20Requests.isNotEmpty) { + await _enableErc20Coins(erc20Requests); + } + if (electrumCoinRequests != null && electrumCoinRequests.isNotEmpty) { + await _enableElectrumCoins(electrumCoinRequests); + } + if (tendermintRequests != null && tendermintRequests.isNotEmpty) { + await _enableTendermintWithAssets(tendermintRequests); + } + if (tendermintTokenRequests != null && tendermintTokenRequests.isNotEmpty) { + await _enableTendermintTokens(tendermintTokenRequests, null); + } + if (bchWithTokens != null && bchWithTokens.isNotEmpty) { + await _enableBchWithTokens(bchWithTokens); + } + if (slpTokens != null && slpTokens.isNotEmpty) { + await _enableSlpTokens(slpTokens); + } + } + + Future _enableEthWithTokensCoins( + List coinRequests, + ) async { + dynamic response; + try { + response = await _call(coinRequests); + log( + response, + path: 'api => _enableEthWithTokensCoins', + ); + } catch (e, s) { + log( + 'Error enabling coins: ${e.toString()}', + path: 'api => _enableEthWithTokensCoins => _call', + trace: s, + isError: true, + ); + return; + } + + dynamic json; + try { + json = jsonDecode(response); + } catch (e, s) { + log( + 'Error parsing of enable coins response: ${e.toString()}', + path: 'api => _enableEthWithTokensCoins => jsonDecode', + trace: s, + isError: true, + ); + return; + } + + if (json is List) { + for (var item in json) { + if (item['error'] != null) { + log( + item['error'], + path: 'api => _enableEthWithTokensCoins:', + isError: true, + ); + } + } + + return; + } else if (json is Map && json['error'] != null) { + log( + json['error'], + path: 'api => _enableEthWithTokensCoins:', + isError: true, + ); + return; + } + } + + Future _enableErc20Coins(List coinRequests) async { + dynamic response; + try { + response = await _call(coinRequests); + log( + response, + path: 'api => _enableErc20Coins', + ); + } catch (e, s) { + log( + 'Error enabling coins: ${e.toString()}', + path: 'api => _enableErc20Coins => _call', + trace: s, + isError: true, + ); + return; + } + + List json; + try { + json = jsonDecode(response); + } catch (e, s) { + log( + 'Error parsing of enable coins response: ${e.toString()}', + path: 'api => _enableEthWithTokensCoins => jsonDecode', + trace: s, + isError: true, + ); + return; + } + for (dynamic item in json) { + if (item['error'] != null) { + log( + item['error'], + path: 'api => _enableEthWithTokensCoins:', + isError: true, + ); + } + } + } + + Future _enableElectrumCoins(List electrumRequests) async { + try { + final dynamic response = await _call(electrumRequests); + log( + response, + path: 'api => _enableElectrumCoins => _call', + ); + } catch (e, s) { + log( + 'Error enabling electrum coins: ${e.toString()}', + path: 'api => _enableElectrumCoins => _call', + trace: s, + isError: true, + ); + return; + } + } + + Future _enableTendermintWithAssets( + List request, + ) async { + try { + final dynamic response = await _call(request); + log( + response, + path: 'api => _enableTendermintWithAssets => _call', + ); + } catch (e, s) { + log( + 'Error enabling tendermint coins: ${e.toString()}', + path: 'api => _enableTendermintWithAssets => _call', + trace: s, + isError: true, + ); + return; + } + } + + Future _enableTendermintTokens( + List request, + EnableTendermintWithAssetsRequest? tendermintWithAssetsRequest, + ) async { + try { + if (tendermintWithAssetsRequest != null) { + await _call(tendermintWithAssetsRequest); + } + final dynamic response = await _call(request); + log( + response, + path: 'api => _enableTendermintToken => _call', + ); + } catch (e, s) { + log( + 'Error enabling tendermint tokens: ${e.toString()}', + path: 'api => _enableTendermintToken => _call', + trace: s, + isError: true, + ); + return; + } + } + + Future _enableSlpTokens( + List requests, + ) async { + try { + final dynamic response = await _call(requests); + log( + response, + path: 'api => _enableSlpTokens => _call', + ); + } catch (e, s) { + log( + 'Error enabling bch coins: ${e.toString()}', + path: 'api => _enableSlpTokens => _call', + trace: s, + isError: true, + ); + return; + } + } + + Future _enableBchWithTokens( + List requests, + ) async { + try { + final dynamic response = await _call(requests); + log( + response, + path: 'api => _enableBchWithTokens => _call', + ); + } catch (e, s) { + log( + 'Error enabling bch coins: ${e.toString()}', + path: 'api => _enableBchWithTokens => _call', + trace: s, + isError: true, + ); + return; + } + } + + Future disableCoin(String coin) async { + try { + await _call(DisableCoinReq(coin: coin)); + } catch (e, s) { + log( + 'Error disabling $coin: ${e.toString()}', + path: 'api=> disableCoin => _call', + trace: s, + isError: true, + ); + return; + } + } + + Future getMaxMakerVol(String abbr) async { + dynamic response; + try { + response = await _call(MaxMakerVolRequest(coin: abbr)); + } catch (e, s) { + log( + 'Error getting max maker vol $abbr: ${e.toString()}', + path: 'api => getMaxMakerVol => _call', + trace: s, + isError: true, + ); + return null; + } + + Map json; + try { + json = jsonDecode(response); + } catch (e, s) { + log( + 'Error parsing of max maker vol $abbr response: ${e.toString()}', + path: 'api => getMaxMakerVol => jsonDecode', + trace: s, + isError: true, + ); + return null; + } + + final error = json['error']; + if (error != null) { + log( + 'Error parsing of max maker vol $abbr response: ${error.toString()}', + path: 'api => getMaxMakerVol => error', + isError: true, + ); + return null; + } + + return MaxMakerVolResponse.fromJson(json['result']); + } + + Future?> getActiveSwaps( + ActiveSwapsRequest request, + ) async { + try { + final String response = await _call(request); + return jsonDecode(response); + } catch (e, s) { + log( + 'Error getting active swaps: ${e.toString()}', + path: 'api => getActiveSwaps', + trace: s, + isError: true, + ); + return {'error': 'something went wrong'}; + } + } + + Future?> validateAddress( + String coinAbbr, + String address, + ) async { + try { + final dynamic response = await _call( + ValidateAddressRequest(coin: coinAbbr, address: address), + ); + final Map json = jsonDecode(response); + + return json; + } catch (e, s) { + log( + 'Error validating address $coinAbbr: ${e.toString()}', + path: 'api => validateAddress', + trace: s, + isError: true, + ); + return null; + } + } + + Future?> withdraw(WithdrawRequest request) async { + try { + final dynamic response = await _call(request); + final Map json = jsonDecode(response); + + return json; + } catch (e, s) { + log( + 'Error withdrawing ${request.params.coin}: ${e.toString()}', + path: 'api => withdraw', + trace: s, + isError: true, + ); + return null; + } + } + + Future?> sendRawTransaction( + SendRawTransactionRequest request, + ) async { + try { + final dynamic response = await _call(request); + final Map json = jsonDecode(response); + + return json; + } catch (e, s) { + log( + 'Error sending raw transaction ${request.coin}: ${e.toString()}', + path: 'api => sendRawTransaction', + trace: s, + isError: true, + ); + return null; + } + } + + Future?> getTransactionsHistory( + MyTxHistoryRequest request, + ) async { + try { + final dynamic response = await _call(request); + final Map json = jsonDecode(response); + + return json; + } catch (e, s) { + log( + 'Error sending raw transaction ${request.coin}: ${e.toString()}', + path: 'api => getTransactions', + trace: s, + isError: true, + ); + return null; + } + } + + Future?> getTransactionsHistoryV2( + MyTxHistoryV2Request request, + ) async { + try { + final dynamic response = await _call(request); + final Map json = jsonDecode(response); + + return json; + } catch (e, s) { + log( + 'Error sending raw transaction ${request.params.coin}: ${e.toString()}', + path: 'api => getTransactions', + trace: s, + isError: true, + ); + return null; + } + } + + Future?> getRewardsInfo( + KmdRewardsInfoRequest request, + ) async { + try { + final dynamic response = await _call(request); + final Map json = jsonDecode(response); + + return json; + } catch (e, s) { + log( + 'Error getting rewards info: ${e.toString()}', + path: 'api => getRewardsInfo', + trace: s, + isError: true, + ); + return null; + } + } + + Future?> getBestOrders(BestOrdersRequest request) async { + try { + final String response = await _call(request); + return jsonDecode(response); + } catch (e, s) { + log( + 'Error getting best orders ${request.coin}: ${e.toString()}', + path: 'api => getBestOrders', + trace: s, + isError: true, + ); + return {'error': e}; + } + } + + Future> sell(SellRequest request) async { + try { + final String response = await _call(request); + return jsonDecode(response); + } catch (e, s) { + log( + 'Error sell ${request.base}/${request.rel}: ${e.toString()}', + path: 'api => sell', + trace: s, + isError: true, + ); + return {'error': e}; + } + } + + Future?> setprice(SetPriceRequest request) async { + try { + final String response = await _call(request); + return jsonDecode(response); + } catch (e, s) { + log( + 'Error setprice ${request.base}/${request.rel}: ${e.toString()}', + path: 'api => setprice', + trace: s, + isError: true, + ); + return {'error': e}; + } + } + + Future> cancelOrder(CancelOrderRequest request) async { + try { + final String response = await _call(request); + return jsonDecode(response); + } catch (e, s) { + log( + 'Error cancelOrder ${request.uuid}: ${e.toString()}', + path: 'api => cancelOrder', + trace: s, + isError: true, + ); + return {'error': e}; + } + } + + Future> getSwapStatus(MySwapStatusReq request) async { + try { + final String response = await _call(request); + return jsonDecode(response); + } catch (e, s) { + log( + 'Error sell getting swap status ${request.uuid}: ${e.toString()}', + path: 'api => getSwapStatus', + trace: s, + isError: true, + ); + return {'error': 'something went wrong'}; + } + } + + Future getMyOrders() async { + try { + final MyOrdersRequest request = MyOrdersRequest(); + final String response = await _call(request); + final Map json = jsonDecode(response); + if (json['error'] != null) { + return null; + } + return MyOrdersResponse.fromJson(json); + } catch (e, s) { + log( + 'Error getting my orders: ${e.toString()}', + path: 'api => getMyOrders', + trace: s, + isError: true, + ); + return null; + } + } + + Future getRawSwapData(MyRecentSwapsRequest request) async { + return await _call(request); + } + + Future getMyRecentSwaps( + MyRecentSwapsRequest request, + ) async { + try { + final String response = await _call(request); + final Map json = jsonDecode(response); + if (json['error'] != null) { + return null; + } + return MyRecentSwapsResponse.fromJson(json); + } catch (e, s) { + log( + 'Error getting my recent swaps: ${e.toString()}', + path: 'api => getMyRecentSwaps', + trace: s, + isError: true, + ); + return null; + } + } + + Future getOrderStatus(String uuid) async { + try { + final OrderStatusRequest request = OrderStatusRequest(uuid: uuid); + final String response = await _call(request); + final Map json = jsonDecode(response); + if (json['error'] != null) { + return null; + } + return OrderStatusResponse.fromJson(json); + } catch (e, s) { + log( + 'Error getting order status $uuid: ${e.toString()}', + path: 'api => getOrderStatus', + trace: s, + isError: true, + ); + return null; + } + } + + Future importSwaps(ImportSwapsRequest request) async { + try { + final String response = await _call(request); + final Map json = jsonDecode(response); + if (json['error'] != null) { + return null; + } + return ImportSwapsResponse.fromJson(json); + } catch (e, s) { + log( + 'Error import swaps : ${e.toString()}', + path: 'api => importSwaps', + trace: s, + isError: true, + ); + return null; + } + } + + Future recoverFundsOfSwap( + RecoverFundsOfSwapRequest request, + ) async { + try { + final String response = await _call(request); + final Map json = jsonDecode(response); + if (json['error'] != null) { + log( + 'Error recovering funds of swap ${request.uuid}: ${json['error']}', + path: 'api => recoverFundsOfSwap', + isError: true, + ); + return null; + } + return RecoverFundsOfSwapResponse.fromJson(json); + } catch (e, s) { + log( + 'Error recovering funds of swap ${request.uuid}: ${e.toString()}', + path: 'api => recoverFundsOfSwap', + trace: s, + isError: true, + ); + return null; + } + } + + Future getMaxTakerVolume( + MaxTakerVolRequest request, + ) async { + try { + final String response = await _call(request); + final Map json = jsonDecode(response); + if (json['error'] != null) { + return null; + } + return MaxTakerVolResponse.fromJson(json); + } catch (e, s) { + log( + 'Error getting max taker volume ${request.coin}: ${e.toString()}', + path: 'api => getMaxTakerVolume', + trace: s, + isError: true, + ); + return null; + } + } + + Future getMinTradingVol( + MinTradingVolRequest request, + ) async { + try { + final String response = await _call(request); + final Map json = jsonDecode(response); + if (json['error'] != null) { + return null; + } + return MinTradingVolResponse.fromJson(json); + } catch (e, s) { + log( + 'Error getting min trading volume ${request.coin}: ${e.toString()}', + path: 'api => getMinTradingVol', + trace: s, + isError: true, + ); + return null; + } + } + + Future getOrderbook(OrderbookRequest request) async { + try { + final String response = await _call(request); + final Map json = jsonDecode(response); + + if (json['error'] != null) { + return OrderbookResponse( + request: request, + error: json['error'], + ); + } + + return OrderbookResponse( + request: request, + result: Orderbook.fromJson(json), + ); + } catch (e, s) { + log( + 'Error getting orderbook ${request.base}/${request.rel}: ${e.toString()}', + path: 'api => getOrderbook', + trace: s, + isError: true, + ); + + return OrderbookResponse( + request: request, + error: e.toString(), + ); + } + } + + Future getOrderBookDepth( + List> pairs, + ) async { + final request = OrderBookDepthReq(pairs: pairs); + try { + final String response = await _call(request); + final Map json = jsonDecode(response); + if (json['error'] != null) { + return null; + } + return OrderBookDepthResponse.fromJson(json); + } catch (e, s) { + log( + 'Error getting orderbook depth $request: ${e.toString()}', + path: 'api => getOrderBookDepth', + trace: s, + ); + } + return null; + } + + Future< + ApiResponse>> getTradePreimage( + TradePreimageRequest request, + ) async { + try { + final String response = await _call(request); + final Map responseJson = await jsonDecode(response); + if (responseJson['error'] != null) { + return ApiResponse(request: request, error: responseJson); + } + return ApiResponse( + request: request, + result: TradePreimageResponse.fromJson(responseJson).result, + ); + } catch (e, s) { + log( + 'Error getting trade preimage ${request.base}/${request.rel}: ${e.toString()}', + path: 'api => getTradePreimage', + trace: s, + isError: true, + ); + return ApiResponse( + request: request, + ); + } + } + + /// Start or stop the market maker bot. + /// The [MarketMakerBotRequest.method] field determines whether the start + /// or stop method is called. + /// + /// The [MarketMakerBotRequest] is sent to the MM2 RPC API. + /// + /// The response, or exceptions, are logged. + /// + /// Throws [Exception] if an error occurs. + Future startStopMarketMakerBot( + MarketMakerBotRequest marketMakerBotRequest, + ) async { + try { + final dynamic response = await _call(marketMakerBotRequest.toJson()); + log( + response, + path: 'api => ${marketMakerBotRequest.method} => _call', + ); + + if (response is String) { + final Map responseJson = jsonDecode(response); + if (responseJson['error'] != null) { + throw RpcException(RpcError.fromJson(responseJson)); + } + } + } catch (e, s) { + log( + 'Error starting or stopping simple market maker bot: ${e.toString()}', + path: 'api => start_simple_market_maker_bot => _call', + trace: s, + isError: true, + ); + rethrow; + } + } + + Future version() async { + _versionResponse ??= await _getMm2Version(); + + return _versionResponse?.result; + } + + Future _getMm2Version() async { + try { + final String versionResult = await mm2.version(); + return VersionResponse(result: versionResult); + } catch (e) { + rethrow; + } + } + + Future convertLegacyAddress(ConvertAddressRequest request) async { + try { + final String response = await _call(request); + final Map responseJson = jsonDecode(response); + return responseJson['result']?['address']; + } catch (e, s) { + log( + 'Convert address error: ${e.toString()}', + path: 'api => convertLegacyAddress', + trace: s, + isError: true, + ); + return null; + } + } + + Future stop() async { + await _call(StopReq()); + } + + Future _call(dynamic req) async { + final MM2Status mm2Status = await _mm2.status(); + if (mm2Status != MM2Status.rpcIsUp) { + return '{"error": "Error, mm2 status: $mm2Status"}'; + } + + final dynamic response = await _mm2.call(req); + + return response; + } +} diff --git a/lib/mm2/mm2_api/mm2_api_nft.dart b/lib/mm2/mm2_api/mm2_api_nft.dart new file mode 100644 index 0000000000..4bf166ab77 --- /dev/null +++ b/lib/mm2/mm2_api/mm2_api_nft.dart @@ -0,0 +1,238 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/errors.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/nft/get_nft_list/get_nft_list_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/nft/update_nft/update_nft_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/nft/refresh_nft_metadata/refresh_nft_metadata_req.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_request.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_request.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class Mm2ApiNft { + Mm2ApiNft(this.call); + + final Future Function(dynamic) call; + + Future> updateNftList( + List chains) async { + try { + final List nftChains = await getActiveNftChains(chains); + if (nftChains.isEmpty) { + return { + "error": + "Please ensure an NFT chain is activated and patiently await while your NFTs are loaded." + }; + } + final UpdateNftRequest request = UpdateNftRequest(chains: nftChains); + final dynamic rawResponse = await call(request); + + final Map json = jsonDecode(rawResponse); + log( + request.toJson().toString(), + path: 'UpdateNftRequest', + ); + log( + rawResponse, + path: 'UpdateNftResponse', + ); + return json; + } catch (e, s) { + log( + 'Error updating nfts: ${e.toString()}', + path: 'UpdateNftResponse', + trace: s, + isError: true, + ); + throw TransportError(message: e.toString()); + } + } + + Future> refreshNftMetadata( + {required String chain, + required String tokenAddress, + required String tokenId}) async { + try { + final RefreshNftMetadataRequest request = RefreshNftMetadataRequest( + chain: chain, tokenAddress: tokenAddress, tokenId: tokenId); + final dynamic rawResponse = await call(request); + + final Map json = jsonDecode(rawResponse); + + return json; + } catch (e) { + log(e.toString(), + path: 'Mm2ApiNft => RefreshNftMetadataRequest', isError: true); + throw TransportError(message: e.toString()); + } + } + + Future> getNftList(List chains) async { + try { + final List nftChains = await getActiveNftChains(chains); + if (nftChains.isEmpty) { + return { + "error": + "Please ensure the NFT chain is activated and patiently await " + "while your NFTs are loaded." + }; + } + final GetNftListRequest request = GetNftListRequest(chains: nftChains); + + final dynamic rawResponse = await call(request); + final Map json = jsonDecode(rawResponse); + + log( + request.toJson().toString(), + path: 'getActiveNftChains', + ); + log( + rawResponse, + path: 'UpdateNftResponse', + ); + + return json; + } catch (e) { + log(e.toString(), path: 'Mm2ApiNft => getNftList', isError: true); + throw TransportError(message: e.toString()); + } + } + + Future> withdraw(WithdrawNftRequest request) async { + try { + final dynamic rawResponse = await call(request); + final Map json = jsonDecode(rawResponse); + + return json; + } catch (e) { + log(e.toString(), path: 'Mm2ApiNft => withdraw', isError: true); + throw TransportError(message: e.toString()); + } + } + + Future> getNftTxs( + NftTransactionsRequest request, bool withAdditionalInfo) async { + try { + final String rawResponse = await call(request); + final Map json = jsonDecode(rawResponse); + if (withAdditionalInfo) { + final jsonUpdated = await const ProxyApiNft().addDetailsToTx(json); + return jsonUpdated; + } + return json; + } catch (e) { + log(e.toString(), path: 'Mm2ApiNft => getNftTransactions', isError: true); + throw TransportError(message: e.toString()); + } + } + + Future> getNftTxDetails( + NftTxDetailsRequest request) async { + try { + final additionalTxInfo = await const ProxyApiNft() + .getTxDetailsByHash(request.chain, request.txHash); + return additionalTxInfo; + } catch (e) { + log(e.toString(), path: 'Mm2ApiNft => getNftTxDetails', isError: true); + throw TransportError(message: e.toString()); + } + } + + Future> getActiveNftChains(List chains) async { + final List knownCoins = await coinsRepo.getKnownCoins(); + // log(knownCoins.toString(), path: 'Mm2ApiNft => knownCoins', isError: true); + final List apiCoins = await coinsRepo.getEnabledCoins(knownCoins); + // log(apiCoins.toString(), path: 'Mm2ApiNft => apiCoins', isError: true); + final List enabledCoins = apiCoins.map((c) => c.abbr).toList(); + log(enabledCoins.toString(), + path: 'Mm2ApiNft => enabledCoins', isError: true); + final List nftCoins = chains.map((c) => c.coinAbbr()).toList(); + log(nftCoins.toString(), path: 'Mm2ApiNft => nftCoins', isError: true); + final List activeChains = chains + .map((c) => c) + .toList() + .where((c) => enabledCoins.contains(c.coinAbbr())) + .toList(); + log(activeChains.toString(), + path: 'Mm2ApiNft => activeChains', isError: true); + final List nftChains = + activeChains.map((c) => c.toApiRequest()).toList(); + log(nftChains.toString(), path: 'Mm2ApiNft => nftChains', isError: true); + return nftChains; + } +} + +class ProxyApiNft { + static const _errorBaseMessage = 'ProxyApiNft API: '; + const ProxyApiNft(); + Future> addDetailsToTx(Map json) async { + final transactions = List.from(json['result']['transfer_history']); + final listOfAdditionalData = transactions + .map((tx) => { + 'blockchain': convertChainForProxy(tx['chain']), + 'tx_hash': tx['transaction_hash'], + }) + .toList(); + + final response = await Client().post( + Uri.parse(txByHashUrl), + body: jsonEncode(listOfAdditionalData), + ); + final Map jsonBody = jsonDecode(response.body); + json['result']['transfer_history'] = transactions.map((element) { + final String? txHash = element['transaction_hash']; + final tx = jsonBody[txHash]; + if (tx != null) { + element['confirmations'] = tx['confirmations']; + element['fee_details'] = tx['fee_details']; + } + return element; + }).toList(); + + return json; + } + + Future> getTxDetailsByHash( + String blockchain, + String txHash, + ) async { + final listOfAdditionalData = [ + { + 'blockchain': convertChainForProxy(blockchain), + 'tx_hash': txHash, + } + ]; + final body = jsonEncode(listOfAdditionalData); + try { + final response = await Client().post( + Uri.parse(txByHashUrl), + body: body, + ); + final Map jsonBody = jsonDecode(response.body); + return jsonBody[txHash]; + } catch (e) { + throw Exception(_errorBaseMessage + e.toString()); + } + } + + String convertChainForProxy(String chain) { + switch (chain) { + case 'AVALANCHE': + return 'avx'; + case 'BSC': + return 'bnb'; + case 'ETH': + return 'eth'; + case 'FANTOM': + return 'ftm'; + case 'POLYGON': + return 'plg'; + } + + throw UnimplementedError(); + } +} diff --git a/lib/mm2/mm2_api/mm2_api_trezor.dart b/lib/mm2/mm2_api/mm2_api_trezor.dart new file mode 100644 index 0000000000..9073fb2915 --- /dev/null +++ b/lib/mm2/mm2_api/mm2_api_trezor.dart @@ -0,0 +1,191 @@ +import 'dart:convert'; + +import 'package:web_dex/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/balance/trezor_balance_status/trezor_balance_status_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/balance/trezor_balance_status/trezor_balance_status_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo/trezor_enable_utxo_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo/trezor_enable_utxo_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo_status/trezor_enable_utxo_status_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo_status/trezor_enable_utxo_status_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor_cancel/init_trezor_cancel_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/trezor_connection_status/trezor_connection_status_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/trezor_passphrase/trezor_passphrase_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/trezor_pin/trezor_pin_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_cancel/trezor_withdraw_cancel_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_status/trezor_withdraw_status_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_status/trezor_withdraw_status_response.dart'; +import 'package:web_dex/model/hw_wallet/trezor_connection_status.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class Mm2ApiTrezor { + Mm2ApiTrezor(this.call); + + final Future Function(dynamic) call; + + Future init(InitTrezorReq request) async { + try { + final String response = await call(request); + return InitTrezorRes.fromJson(jsonDecode(response)); + } catch (e) { + return InitTrezorRes( + error: e.toString(), + ); + } + } + + Future initStatus(InitTrezorStatusReq request) async { + try { + final String response = await call(request); + return InitTrezorStatusRes.fromJson(jsonDecode(response)); + } catch (e) { + return InitTrezorStatusRes(error: e.toString()); + } + } + + Future initCancel(InitTrezorCancelReq request) async { + try { + await call(request); + } catch (e) { + log(e.toString(), path: 'api => initTrezorCancel', isError: true); + } + } + + Future pin(TrezorPinRequest request) async { + try { + await call(request); + } catch (e) { + log(e.toString(), path: 'api => trezorPin', isError: true); + } + } + + Future passphrase(TrezorPassphraseRequest request) async { + try { + await call(request); + } catch (e) { + log(e.toString(), path: 'api => trezorPassphrase', isError: true); + } + } + + Future enableUtxo( + TrezorEnableUtxoReq request) async { + try { + final String response = await call(request); + return TrezorEnableUtxoResponse.fromJson(jsonDecode(response)); + } catch (e) { + return TrezorEnableUtxoResponse(error: e.toString()); + } + } + + Future enableUtxoStatus( + TrezorEnableUtxoStatusReq request) async { + try { + final String response = await call(request); + return TrezorEnableUtxoStatusResponse.fromJson(jsonDecode(response)); + } catch (e) { + return TrezorEnableUtxoStatusResponse(error: e.toString()); + } + } + + Future balanceInit( + TrezorBalanceInitRequest request) async { + try { + final String response = await call(request); + return TrezorBalanceInitResponse.fromJson(jsonDecode(response)); + } catch (e) { + return TrezorBalanceInitResponse(error: e.toString()); + } + } + + Future balanceStatus( + TrezorBalanceStatusRequest request) async { + try { + final String response = await call(request); + return TrezorBalanceStatusResponse.fromJson(jsonDecode(response)); + } catch (e) { + return TrezorBalanceStatusResponse(error: e.toString()); + } + } + + Future initNewAddress(String coin) async { + try { + final String response = + await call(TrezorGetNewAddressInitReq(coin: coin)); + return TrezorGetNewAddressInitResponse.fromJson(jsonDecode(response)); + } catch (e) { + return TrezorGetNewAddressInitResponse(error: e.toString()); + } + } + + Future getNewAddressStatus(int taskId) async { + try { + final String response = + await call(TrezorGetNewAddressStatusReq(taskId: taskId)); + return GetNewAddressResponse.fromJson(jsonDecode(response)); + } catch (e) { + return GetNewAddressResponse(error: e.toString()); + } + } + + Future cancelGetNewAddress(int taskId) async { + try { + await call(TrezorGetNewAddressCancelReq(taskId: taskId)); + } catch (e) { + log(e.toString(), path: 'api_trezor => getNewAddressCancel'); + } + } + + Future withdraw(TrezorWithdrawRequest request) async { + try { + final String response = await call(request); + return TrezorWithdrawResponse.fromJson(jsonDecode(response)); + } catch (e) { + return TrezorWithdrawResponse(error: e.toString()); + } + } + + Future withdrawStatus( + TrezorWithdrawStatusRequest request) async { + try { + final String response = await call(request); + return TrezorWithdrawStatusResponse.fromJson(jsonDecode(response)); + } catch (e) { + return TrezorWithdrawStatusResponse(error: e.toString()); + } + } + + Future withdrawCancel(TrezorWithdrawCancelRequest request) async { + try { + await call(request); + } catch (e) { + log(e.toString(), path: 'api => withdrawCancel', isError: true); + } + } + + Future getConnectionStatus(String pubKey) async { + try { + final String response = + await call(TrezorConnectionStatusRequest(pubKey: pubKey)); + final Map responseJson = jsonDecode(response); + final String? status = responseJson['result']?['status']; + if (status == null) return TrezorConnectionStatus.unknown; + return TrezorConnectionStatus.fromString(status); + } catch (e, s) { + log( + 'Error getting trezor status: ${e.toString()}', + path: 'api => trezorConnectionStatus', + trace: s, + isError: true, + ); + return TrezorConnectionStatus.unknown; + } + } +} diff --git a/lib/mm2/mm2_api/rpc/active_swaps/active_swaps_request.dart b/lib/mm2/mm2_api/rpc/active_swaps/active_swaps_request.dart new file mode 100644 index 0000000000..a9353cdcea --- /dev/null +++ b/lib/mm2/mm2_api/rpc/active_swaps/active_swaps_request.dart @@ -0,0 +1,13 @@ +class ActiveSwapsRequest { + ActiveSwapsRequest({this.method = 'active_swaps'}); + + final String method; + late String userpass; + final bool includeStatus = true; + + Map toJson() => { + 'userpass': userpass, + 'method': method, + 'include_status': true + }; +} diff --git a/lib/mm2/mm2_api/rpc/base.dart b/lib/mm2/mm2_api/rpc/base.dart new file mode 100644 index 0000000000..435fb81679 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/base.dart @@ -0,0 +1,44 @@ +abstract class BaseRequest { + final String method = ''; + late String userpass; + + Map toJson(); +} + +abstract class BaseRequestWithParams { + BaseRequestWithParams(this.params); + + final T params; +} + +abstract class BaseResponse { + BaseResponse({required this.result}); + + final String mmrpc = ''; + final T result; +} + +abstract class BaseError { + const BaseError(); + String get message; +} + +abstract class ErrorNeedSetExtraData { + void setExtraData(T data); +} + +abstract mixin class ErrorWithDetails { + String get details; +} + +abstract class ErrorFactory { + ErrorFactory(); + BaseError getError(Map json, T data); +} + +class ApiResponse { + ApiResponse({required this.request, this.result, this.error}); + final Req request; + final Res? result; + final E? error; +} diff --git a/lib/mm2/mm2_api/rpc/best_orders/best_orders.dart b/lib/mm2/mm2_api/rpc/best_orders/best_orders.dart new file mode 100644 index 0000000000..cade27c162 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/best_orders/best_orders.dart @@ -0,0 +1,72 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/orderbook/order.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class BestOrders { + BestOrders({this.result, this.error}); + + factory BestOrders.fromJson(Map json) { + final Map> ordersMap = >{}; + for (var key in json['result']['orders'].keys) { + final List bestOrders = []; + for (var result in json['result']['orders'][key]) { + bestOrders.add(BestOrder.fromJson(result)); + } + ordersMap.putIfAbsent(key, () => bestOrders); + } + return BestOrders(result: ordersMap); + } + + Map>? result; + BaseError? error; +} + +class BestOrder { + const BestOrder({ + required this.price, + required this.maxVolume, + required this.minVolume, + required this.coin, + required this.address, + required this.uuid, + }); + + factory BestOrder.fromOrder(Order order, String? coin) { + return BestOrder( + price: order.price, + maxVolume: order.maxVolume, + minVolume: order.minVolume ?? Rational.zero, + coin: coin ?? order.base, + address: order.address ?? '', + uuid: order.uuid ?? '', + ); + } + + factory BestOrder.fromJson(Map json) { + return BestOrder( + price: fract2rat(json['price']['fraction']) ?? + Rational.parse(json['price']['decimal']), + maxVolume: fract2rat(json['base_max_volume']['fraction']) ?? + Rational.parse(json['base_max_volume']['decimal']), + minVolume: fract2rat(json['base_min_volume']['fraction']) ?? + Rational.parse(json['base_min_volume']['decimal']), + coin: json['coin'], + address: json['address']['address_data'], + uuid: json['uuid'], + ); + } + + final Rational price; + final Rational maxVolume; + final Rational minVolume; + final String coin; + final String address; + + @override + String toString() { + return 'BestOrder($coin, $price)'; + } + + final String uuid; +} diff --git a/lib/mm2/mm2_api/rpc/best_orders/best_orders_request.dart b/lib/mm2/mm2_api/rpc/best_orders/best_orders_request.dart new file mode 100644 index 0000000000..ed0d82b569 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/best_orders/best_orders_request.dart @@ -0,0 +1,65 @@ +import 'package:rational/rational.dart'; + +class BestOrdersRequest { + BestOrdersRequest({ + this.method = 'best_orders', + required this.coin, + required this.action, + this.type = BestOrdersRequestType.volume, + this.volume, + this.number, + }) : assert((type == BestOrdersRequestType.number && number != null) || + (type == BestOrdersRequestType.volume && volume != null)); + + late String userpass; + final String method; + final String coin; + final String action; + final BestOrdersRequestType type; + final Rational? volume; + final int? number; + + Map toJson() => { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'coin': coin, + 'action': 'sell', + 'request_by': { + 'type': _typeJson, + 'value': _valueJson + } + }, + }; + + dynamic get _valueJson { + switch (type) { + case BestOrdersRequestType.number: + // ignore: unnecessary_this + final int? number = this.number; + if (number == null) return null; + + return number; + case BestOrdersRequestType.volume: + final Rational? volume = this.volume; + if (volume == null) return null; + + return { + 'numer': volume.numerator.toString(), + 'denom': volume.denominator.toString(), + }; + } + } + + String get _typeJson { + switch (type) { + case BestOrdersRequestType.number: + return 'number'; + case BestOrdersRequestType.volume: + return 'volume'; + } + } +} + +enum BestOrdersRequestType { volume, number } diff --git a/lib/mm2/mm2_api/rpc/bloc_response.dart b/lib/mm2/mm2_api/rpc/bloc_response.dart new file mode 100644 index 0000000000..5d25577a85 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/bloc_response.dart @@ -0,0 +1,6 @@ +// @deprecated +class BlocResponse { + BlocResponse({this.result, this.error}); + R? result; + E? error; +} diff --git a/lib/mm2/mm2_api/rpc/cancel_order/cancel_order_request.dart b/lib/mm2/mm2_api/rpc/cancel_order/cancel_order_request.dart new file mode 100644 index 0000000000..fe2c517958 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/cancel_order/cancel_order_request.dart @@ -0,0 +1,19 @@ +class CancelOrderRequest { + CancelOrderRequest({ + this.method = 'cancel_order', + this.userpass, + required this.uuid, + }); + + final String method; + String? userpass; + final String uuid; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'uuid': uuid, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/convert_address/convert_address_request.dart b/lib/mm2/mm2_api/rpc/convert_address/convert_address_request.dart new file mode 100644 index 0000000000..47202ee021 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/convert_address/convert_address_request.dart @@ -0,0 +1,32 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class ConvertAddressRequest implements BaseRequest { + ConvertAddressRequest({ + required this.from, + required this.coin, + required this.isErc, + }); + + @override + final String method = 'convertaddress'; + @override + late String userpass; + + final String from; + final String coin; + final bool isErc; + + @override + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'from': from, + 'coin': coin, + 'to_address_format': { + 'format': isErc ? 'mixedcase' : 'cashaddress', + if (coin == 'BCH') 'network': 'bitcoincash', + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart b/lib/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart new file mode 100644 index 0000000000..0bdffea904 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/disable_coin/disable_coin_req.dart @@ -0,0 +1,17 @@ +class DisableCoinReq { + DisableCoinReq({ + required this.coin, + }); + + static const String method = 'disable_coin'; + final String coin; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'coin': coin, + 'userpass': userpass, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/electrum/electrum_req.dart b/lib/mm2/mm2_api/rpc/electrum/electrum_req.dart new file mode 100644 index 0000000000..5753968e23 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/electrum/electrum_req.dart @@ -0,0 +1,34 @@ +import 'package:web_dex/model/electrum.dart'; + +class ElectrumReq { + ElectrumReq({ + this.mm2 = 1, + required this.coin, + required this.servers, + this.swapContractAddress, + this.fallbackSwapContract, + }); + + static const String method = 'electrum'; + final int mm2; + final String coin; + final List servers; + final String? swapContractAddress; + final String? fallbackSwapContract; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'coin': coin, + 'servers': servers.map((server) => server.toJson()).toList(), + 'userpass': userpass, + 'mm2': mm2, + 'tx_history': true, + if (swapContractAddress != null) + 'swap_contract_address': swapContractAddress, + if (fallbackSwapContract != null) + 'swap_contract_address': swapContractAddress, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/enable/enable_req.dart b/lib/mm2/mm2_api/rpc/enable/enable_req.dart new file mode 100644 index 0000000000..3be375b9b5 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/enable/enable_req.dart @@ -0,0 +1,123 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/electrum.dart'; + +class EnableEthWithTokensRequest implements BaseRequest { + EnableEthWithTokensRequest({ + required this.coin, + required this.nodes, + required this.swapContractAddress, + required this.fallbackSwapContract, + this.tokens = const [], + }); + + final String coin; + final List nodes; + final String? swapContractAddress; + final String? fallbackSwapContract; + final List tokens; + + @override + final String method = 'enable_eth_with_tokens'; + @override + late String userpass; + + @override + Map toJson() { + return { + 'userpass': userpass, + 'mmrpc': '2.0', + 'method': method, + 'params': { + 'ticker': coin, + 'nodes': nodes.map>((n) => n.toJson()).toList(), + 'swap_contract_address': swapContractAddress, + if (fallbackSwapContract != null) + 'fallback_swap_contract': fallbackSwapContract, + 'erc20_tokens_requests': + tokens.map((t) => {'ticker': t}).toList(), + }, + 'id': 0, + }; + } +} + +class EnableErc20Request implements BaseRequest { + EnableErc20Request({required this.ticker}); + final String ticker; + @override + late String userpass; + @override + final String method = 'enable_erc20'; + + @override + Map toJson() { + return { + 'mmrpc': '2.0', + 'userpass': userpass, + 'method': method, + 'params': { + 'ticker': ticker, + 'activation_params': {}, + }, + 'id': 0 + }; + } +} + +class EnableBchWithTokens implements BaseRequest { + EnableBchWithTokens({ + required this.ticker, + required this.urls, + required this.servers, + }); + final String ticker; + final List urls; + final List servers; + @override + late String userpass; + @override + String get method => 'enable_bch_with_tokens'; + + @override + Map toJson() { + return { + 'mmrpc': '2.0', + 'userpass': userpass, + 'method': method, + 'params': { + 'ticker': ticker, + 'allow_slp_unsafe_conf': false, + 'bchd_urls': urls, + 'mode': { + 'rpc': 'Electrum', + 'rpc_data': { + 'servers': servers.map((server) => server.toJson()).toList(), + } + }, + 'tx_history': true, + 'slp_tokens_requests': [], + } + }; + } +} + +class EnableSlp implements BaseRequest { + EnableSlp({required this.ticker}); + + final String ticker; + @override + late String userpass; + @override + String get method => 'enable_slp'; + + @override + Map toJson() { + return { + 'mmrpc': '2.0', + 'userpass': userpass, + 'method': method, + 'params': {'ticker': ticker, 'activation_params': {}} + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/enable_tendermint/enable_tendermint_token.dart b/lib/mm2/mm2_api/rpc/enable_tendermint/enable_tendermint_token.dart new file mode 100644 index 0000000000..db6e851df8 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/enable_tendermint/enable_tendermint_token.dart @@ -0,0 +1,40 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class EnableTendermintTokenRequest + implements + BaseRequest, + BaseRequestWithParams { + EnableTendermintTokenRequest({required String ticker}) + : params = EnableTendermintTokenRequestParams( + ticker: ticker, + ); + @override + late String userpass; + @override + final method = 'enable_tendermint_token'; + @override + final EnableTendermintTokenRequestParams params; + + @override + Map toJson() { + return { + 'mmrpc': '2.0', + 'method': method, + 'userpass': userpass, + 'params': params.toJson(), + }; + } +} + +class EnableTendermintTokenRequestParams { + EnableTendermintTokenRequestParams({required this.ticker}); + final String ticker; + + Map toJson() { + return { + 'ticker': ticker, + 'tx_history': true, + 'activation_params': {}, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/enable_tendermint/enable_tendermint_with_assets.dart b/lib/mm2/mm2_api/rpc/enable_tendermint/enable_tendermint_with_assets.dart new file mode 100644 index 0000000000..254bf2b77b --- /dev/null +++ b/lib/mm2/mm2_api/rpc/enable_tendermint/enable_tendermint_with_assets.dart @@ -0,0 +1,55 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/coin.dart'; + +class EnableTendermintWithAssetsRequest + implements + BaseRequest, + BaseRequestWithParams { + EnableTendermintWithAssetsRequest({ + required String ticker, + required List rpcUrls, + List tokensParams = const [], + }) : params = EnableTendermintWithAssetsRequestParams( + ticker: ticker, tokensParams: tokensParams, rpcUrls: rpcUrls); + @override + late String userpass; + @override + final method = 'enable_tendermint_with_assets'; + @override + final EnableTendermintWithAssetsRequestParams params; + + @override + Map toJson() { + return { + 'mmrpc': '2.0', + 'method': method, + 'userpass': userpass, + 'params': params.toJson(), + }; + } +} + +class EnableTendermintWithAssetsRequestParams { + EnableTendermintWithAssetsRequestParams({ + required this.ticker, + required this.tokensParams, + required this.rpcUrls, + }); + final String ticker; + final List rpcUrls; + final List tokensParams; + + Map toJson() { + return { + 'tokens_params': tokensParams, + 'rpc_urls': rpcUrls.map((e) => e.url).toList(), + 'ticker': ticker, + 'tx_history': true, + }; + } +} + +class TendermintTokenParamsItem { + const TendermintTokenParamsItem({required this.ticker}); + final String ticker; +} diff --git a/lib/mm2/mm2_api/rpc/errors.dart b/lib/mm2/mm2_api/rpc/errors.dart new file mode 100644 index 0000000000..c9752ec240 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/errors.dart @@ -0,0 +1,20 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class ApiError implements BaseError { + const ApiError({required this.message}); + + @override + final String message; +} + +class TransportError implements BaseError { + const TransportError({required this.message}); + @override + final String message; +} + +class ParsingApiJsonError implements BaseError { + const ParsingApiJsonError({required this.message}); + @override + final String message; +} diff --git a/lib/mm2/mm2_api/rpc/get_enabled_coins/get_enabled_coins_req.dart b/lib/mm2/mm2_api/rpc/get_enabled_coins/get_enabled_coins_req.dart new file mode 100644 index 0000000000..026d3243ae --- /dev/null +++ b/lib/mm2/mm2_api/rpc/get_enabled_coins/get_enabled_coins_req.dart @@ -0,0 +1,11 @@ +class GetEnabledCoinsReq { + static const String method = 'get_enabled_coins'; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/get_my_peer_id/get_my_peer_id_request.dart b/lib/mm2/mm2_api/rpc/get_my_peer_id/get_my_peer_id_request.dart new file mode 100644 index 0000000000..ca54126285 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/get_my_peer_id/get_my_peer_id_request.dart @@ -0,0 +1,18 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class GetMyPeerIdRequest implements BaseRequest { + GetMyPeerIdRequest(); + + @override + final String method = 'get_my_peer_id'; + @override + late String userpass; + + @override + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/import_swaps/import_swaps_request.dart b/lib/mm2/mm2_api/rpc/import_swaps/import_swaps_request.dart new file mode 100644 index 0000000000..38722acbdd --- /dev/null +++ b/lib/mm2/mm2_api/rpc/import_swaps/import_swaps_request.dart @@ -0,0 +1,17 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class ImportSwapsRequest implements BaseRequest { + ImportSwapsRequest({this.swaps = const []}); + @override + late String userpass; + @override + final String method = 'import_swaps'; + final List swaps; + + @override + Map toJson() => { + 'userpass': userpass, + 'method': method, + 'swaps': swaps, + }; +} diff --git a/lib/mm2/mm2_api/rpc/import_swaps/import_swaps_response.dart b/lib/mm2/mm2_api/rpc/import_swaps/import_swaps_response.dart new file mode 100644 index 0000000000..7584baf3a6 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/import_swaps/import_swaps_response.dart @@ -0,0 +1,31 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class ImportSwapsResponseResult { + ImportSwapsResponseResult({ + required this.imported, + required this.skipped, + }); + + factory ImportSwapsResponseResult.fromJson(Map json) => + ImportSwapsResponseResult( + imported: List.from(json['imported'] ?? []), + skipped: Map.from(json['skipped'] ?? {}), + ); + + final List imported; + final Map skipped; +} + +class ImportSwapsResponse implements BaseResponse { + ImportSwapsResponse({required this.result}); + + factory ImportSwapsResponse.fromJson(Map json) => + ImportSwapsResponse( + result: ImportSwapsResponseResult.fromJson(json['result']), + ); + + @override + final String mmrpc = ''; + @override + final ImportSwapsResponseResult result; +} diff --git a/lib/mm2/mm2_api/rpc/kmd_rewards_info/kmd_reward_item.dart b/lib/mm2/mm2_api/rpc/kmd_rewards_info/kmd_reward_item.dart new file mode 100644 index 0000000000..47e51ba2e9 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/kmd_rewards_info/kmd_reward_item.dart @@ -0,0 +1,87 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class KmdRewardItem { + KmdRewardItem({ + required this.txHash, + required this.height, + required this.outputIndex, + required this.amount, + required this.lockTime, + required this.reward, + required this.accrueStartAt, + required this.accrueStopAt, + String? error, + }) { + _error = error != null && error.isNotEmpty ? _getError(error) : null; + } + + factory KmdRewardItem.fromJson(Map json) { + final double? reward = json['accrued_rewards']?['Accrued'] != null + ? double.tryParse(json['accrued_rewards']['Accrued']) + : null; + final String? error = json['accrued_rewards']?['NotAccruedReason']; + + return KmdRewardItem( + txHash: json['tx_hash'], + height: json['height'], + outputIndex: json['output_index'], + amount: json['amount'], + lockTime: json['locktime'], + reward: reward, + accrueStartAt: json['accrue_start_at'], + accrueStopAt: json['accrue_stop_at'], + error: error, + ); + } + + final String txHash; + final String amount; + final int? outputIndex; + final int? lockTime; + final double? reward; + final int? height; + final int? accrueStartAt; + final int? accrueStopAt; + RewardItemError? _error; + + Duration? get timeLeft { + if (accrueStopAt == null) { + return null; + } + return Duration( + milliseconds: + accrueStopAt! * 1000 - DateTime.now().millisecondsSinceEpoch); + } + + RewardItemError? get error { + return _error; + } +} + +class RewardItemError { + RewardItemError({required this.short, required this.long}); + + final String short; + final String long; +} + +RewardItemError _getError(String errorKey) { + switch (errorKey) { + case 'UtxoAmountLessThanTen': + return RewardItemError( + short: '<10 KMD', + long: LocaleKeys.rewardLessThanTenLong.tr(), + ); + case 'TransactionInMempool': + return RewardItemError( + short: LocaleKeys.rewardProcessingShort.tr().toLowerCase(), + long: LocaleKeys.rewardProcessingLong.tr()); + case 'OneHourNotPassedYet': + return RewardItemError( + short: LocaleKeys.rewardOneHourNotPassedShort.tr(), + long: LocaleKeys.rewardOneHourNotPassedLong.tr()); + default: + return RewardItemError(short: '?', long: ''); + } +} diff --git a/lib/mm2/mm2_api/rpc/kmd_rewards_info/kmd_rewards_info_request.dart b/lib/mm2/mm2_api/rpc/kmd_rewards_info/kmd_rewards_info_request.dart new file mode 100644 index 0000000000..436d0bbe5a --- /dev/null +++ b/lib/mm2/mm2_api/rpc/kmd_rewards_info/kmd_rewards_info_request.dart @@ -0,0 +1,16 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class KmdRewardsInfoRequest implements BaseRequest { + @override + final String method = 'kmd_rewards_info'; + @override + late String userpass; + + @override + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_parameters.dart b/lib/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_parameters.dart new file mode 100644 index 0000000000..f4f3b57e13 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_parameters.dart @@ -0,0 +1,58 @@ +import 'package:equatable/equatable.dart'; + +import 'trade_coin_pair_config.dart'; + +/// The parameters for the market maker bot. These are sent as part of the +/// market_maker_bot_start RPC call to the KDF API. +class MarketMakerBotParameters extends Equatable { + const MarketMakerBotParameters({ + this.priceUrl, + this.botRefreshRate, + this.tradeCoinPairs, + }); + + /// The full URL to the price API endpoint. + final String? priceUrl; + + /// The rate at which the bot should refresh its data in seconds. + final int? botRefreshRate; + + /// The configuration for each trading pair. The key is the trading pair name. + /// The value is the configuration for that trading pair. + /// + /// For example, the key could be 'BTC-ETH' and the value could be the + /// configuration for the BTC-ETH trading pair. + final Map? tradeCoinPairs; + + factory MarketMakerBotParameters.fromJson(Map json) => + MarketMakerBotParameters( + priceUrl: json['price_url'] as String?, + botRefreshRate: json['bot_refresh_rate'] as int?, + tradeCoinPairs: json['cfg'] == null + ? null + : (json['cfg'] as Map).map( + (k, e) => MapEntry(k, TradeCoinPairConfig.fromJson(e)), + ), + ); + + Map toJson() => { + 'price_url': priceUrl, + 'bot_refresh_rate': botRefreshRate, + 'cfg': tradeCoinPairs?.map((k, e) => MapEntry(k, e.toJson())) ?? {}, + }..removeWhere((_, value) => value == null); + + MarketMakerBotParameters copyWith({ + String? priceUrl, + int? botRefreshRate, + Map? cfg, + }) { + return MarketMakerBotParameters( + priceUrl: priceUrl ?? this.priceUrl, + botRefreshRate: botRefreshRate ?? this.botRefreshRate, + tradeCoinPairs: cfg ?? tradeCoinPairs, + ); + } + + @override + List get props => [priceUrl, botRefreshRate, tradeCoinPairs]; +} diff --git a/lib/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_request.dart b/lib/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_request.dart new file mode 100644 index 0000000000..13597dcbc5 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/market_maker_bot/market_maker_bot_request.dart @@ -0,0 +1,56 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +import 'market_maker_bot_parameters.dart'; + +/// The request object to start or stop a market maker bot. +class MarketMakerBotRequest implements BaseRequest { + MarketMakerBotRequest({ + this.userpass = '', + this.mmrpc = "2.0", + this.method = 'start_simple_market_maker_bot', + this.params = const MarketMakerBotParameters(), + required this.id, + }); + + /// The RPC user password populated by the MM2 API before sending, + /// so this field should be left null or empty. + @override + String userpass; + + /// The MM2 RPC version. Defaults to "2.0". + final String mmrpc; + + /// The name of the MM2 RPC method to call. Defaults to "start_simple_market_maker_bot". + @override + final String method; + + /// The parameters to pass to the MM2 RPC method. This includes the coin + /// pairs to trade, the price URL, and the bot refresh rate. Defaults to null. + final MarketMakerBotParameters? params; + + /// The ID of the market maker bot to start. Defaults to 0. + final int id; + + @override + Map toJson() => { + 'userpass': userpass, + 'mmrpc': mmrpc, + 'method': method, + 'params': params?.toJson() ?? {}, + 'id': id, + }; + + MarketMakerBotRequest copyWith({ + String? mmrpc, + String? method, + MarketMakerBotParameters? params, + int? id, + }) { + return MarketMakerBotRequest( + mmrpc: mmrpc ?? this.mmrpc, + method: method ?? this.method, + params: params ?? this.params, + id: id ?? this.id, + ); + } +} diff --git a/lib/mm2/mm2_api/rpc/market_maker_bot/message_service_config/chat_registry.dart b/lib/mm2/mm2_api/rpc/market_maker_bot/message_service_config/chat_registry.dart new file mode 100644 index 0000000000..1ec035f285 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/market_maker_bot/message_service_config/chat_registry.dart @@ -0,0 +1,47 @@ +import 'package:equatable/equatable.dart'; + +/// Represents the chat registry configuration - the chat IDs for the +/// default chat, the maker bot chat, and the swap events chat. +class ChatRegistry extends Equatable { + const ChatRegistry({ + this.defaultId, + this.makerBotId, + this.swapEventsId, + }); + + factory ChatRegistry.fromJson(Map json) => ChatRegistry( + defaultId: json['default'] as String?, + makerBotId: json['maker_bot'] as String?, + swapEventsId: json['swap_events'] as String?, + ); + + /// The default chat ID. + final String? defaultId; + + /// The maker bot chat ID. + final String? makerBotId; + + /// The swap events chat ID. + final String? swapEventsId; + + Map toJson() => { + 'default': defaultId, + 'maker_bot': makerBotId, + 'swap_events': swapEventsId, + }; + + ChatRegistry copyWith({ + String? defaultId, + String? makerBotId, + String? swapEventsId, + }) { + return ChatRegistry( + defaultId: defaultId ?? this.defaultId, + makerBotId: makerBotId ?? this.makerBotId, + swapEventsId: swapEventsId ?? this.swapEventsId, + ); + } + + @override + List get props => [defaultId, makerBotId, swapEventsId]; +} diff --git a/lib/mm2/mm2_api/rpc/market_maker_bot/message_service_config/message_service_config.dart b/lib/mm2/mm2_api/rpc/market_maker_bot/message_service_config/message_service_config.dart new file mode 100644 index 0000000000..e522d71ee0 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/market_maker_bot/message_service_config/message_service_config.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; + +import 'telegram_service_config.dart'; + +class MessageServiceConfig extends Equatable { + final TelegramServiceConfig? telegram; + + const MessageServiceConfig({this.telegram}); + + factory MessageServiceConfig.initial() { + return const MessageServiceConfig(telegram: null); + } + + factory MessageServiceConfig.fromJson(Map json) { + return MessageServiceConfig( + telegram: json['telegram'] == null + ? null + : TelegramServiceConfig.fromJson( + json['telegram'] as Map, + ), + ); + } + + Map toJson() => { + 'telegram': telegram?.toJson(), + }..removeWhere((key, value) => value == null); + + MessageServiceConfig copyWith({ + TelegramServiceConfig? telegram, + }) { + return MessageServiceConfig( + telegram: telegram ?? this.telegram, + ); + } + + @override + List get props => [telegram]; +} diff --git a/lib/mm2/mm2_api/rpc/market_maker_bot/message_service_config/telegram_service_config.dart b/lib/mm2/mm2_api/rpc/market_maker_bot/message_service_config/telegram_service_config.dart new file mode 100644 index 0000000000..972f8520a7 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/market_maker_bot/message_service_config/telegram_service_config.dart @@ -0,0 +1,38 @@ +import 'package:equatable/equatable.dart'; + +import 'chat_registry.dart'; + +class TelegramServiceConfig extends Equatable { + final String? apiKey; + final ChatRegistry? chatRegistry; + + const TelegramServiceConfig({this.apiKey, this.chatRegistry}); + + factory TelegramServiceConfig.fromJson(Map json) => + TelegramServiceConfig( + apiKey: json['api_key'] as String?, + chatRegistry: json['chat_registry'] == null + ? null + : ChatRegistry.fromJson( + json['chat_registry'] as Map, + ), + ); + + Map toJson() => { + 'api_key': apiKey, + 'chat_registry': chatRegistry?.toJson(), + }; + + TelegramServiceConfig copyWith({ + String? apiKey, + ChatRegistry? chatRegistry, + }) { + return TelegramServiceConfig( + apiKey: apiKey ?? this.apiKey, + chatRegistry: chatRegistry ?? this.chatRegistry, + ); + } + + @override + List get props => [apiKey, chatRegistry]; +} diff --git a/lib/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart b/lib/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart new file mode 100644 index 0000000000..cfcc4b1c25 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart @@ -0,0 +1,251 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/views/market_maker_bot/trade_bot_update_interval.dart'; + +import 'trade_volume.dart'; + +/// Represents the settings for a trading pair. +class TradeCoinPairConfig extends Equatable { + const TradeCoinPairConfig({ + required this.name, + required this.baseCoinId, + required this.relCoinId, + this.maxBalancePerTrade, + this.minVolume, + this.maxVolume, + this.minBasePriceUsd, + this.minRelPriceUsd, + this.minPairPrice, + required this.spread, + this.baseConfs, + this.baseNota, + this.relConfs, + this.relNota, + this.enable = true, + this.priceElapsedValidity, + this.checkLastBidirectionalTradeThreshHold, + }); + + /// The name of the trading pair + final String name; + + /// The id of the coin to sell in the trade. Usually the ticker symbol. + /// E.g. 'BTC-segwit' or 'ETH' + final String baseCoinId; + + /// The id of the coin to buy in the trade. Usually the ticker symbol. + final String relCoinId; + + /// Whether to trade the entire balance + final bool? maxBalancePerTrade; + + /// The maximum volume to trade expressed in terms of percentage of the total + /// balance of the [baseCoinId]. For example, a value of 0.5 represents 50% + /// of the total balance of [baseCoinId]. + final TradeVolume? maxVolume; + + /// The minimum volume to trade expressed in terms of percentage of the total + /// balance of the [baseCoinId]. For example, a value of 0.5 represents 50% + /// of the total balance of [baseCoinId]. + final TradeVolume? minVolume; + + /// The minimum USD price of the base coin to accept in trade + final double? minBasePriceUsd; + + /// The minimum USD price of the rel coin to accept in trade + final double? minRelPriceUsd; + + /// The minimum USD price of the pair to accept in trade + final double? minPairPrice; + + /// The spread to use in trade as a decimal value representing the percentage. + /// For example, a spread of 1.04 represents a 4% spread. + final String spread; + + /// The number of confirmations required for the base coin + final int? baseConfs; + + /// Whether the base coin requires a notarization + final bool? baseNota; + + /// The number of confirmations required for the rel coin + final int? relConfs; + + /// Whether the rel coin requires a notarization + final bool? relNota; + + /// Whether to enable the trading pair. Defaults to false. + /// The trading pair will be ignored if true + final bool enable; + + /// Will cancel current orders for this pair and not submit a new order if + /// last price update time has been longer than this value in seconds. + /// Defaults to 5 minutes. + final int? priceElapsedValidity; + + /// Will readjust the calculated cex price if a precedent trade exists for + /// the pair (or reversed pair), applied via a VWAP logic. This is a trading + /// strategy to adjust the price of one pair to the VWAP price, encouraging + /// trades in the opposite direction to address temporary liquidity imbalances + /// + /// NOTE: This requires two trades to be made in the pair (or reversed pair). + /// + /// Defaults to false. + /// + /// ## Trade Analysis: + /// - The bot evaluates the last 1000 trades for both base/rel and rel/base + /// pairs (up to 2000 total). + /// + /// ## VWAP Calculation: + /// - For each pair, the VWAP is computed by taking the sum of the product of + /// each trade's price and volume (sum(price * volume)) and dividing it by the + /// total volume (sum(volume)). + /// - When calculating the VWAP for the reverse pair (rel/base), the bot + /// considers its own base asset as the reference, and it gets the price for + /// the base asset. + /// - Combines/sums the separate VWAPs for base/rel and rel/base trades into + /// a total VWAP. + /// + /// ## Price Comparison: + /// - Compares total VWAP to the bot's calculated price + /// (price from price service * spread). + /// - If VWAP > calculated price, uses VWAP for order price. + /// + /// ## Liquidity Adjustment: + /// - By setting the price of one pair to the VWAP price, the bot adjusts + /// market maker orders above the market rate for one direction to encourage + /// trades in the opposite direction, addressing temporary liquidity + /// imbalances until equilibrium is restored. + final bool? checkLastBidirectionalTradeThreshHold; + + /// Returns [baseCoinId] and [relCoinId] in the format 'BASE/REL'. + /// E.g. 'BTC/ETH' + String get simpleName => getSimpleName(baseCoinId, relCoinId); + + /// Returns the margin as a percentage value + double get margin => (double.parse(spread) - 1) * 100; + + /// Converts the update interval for the trade bot to [TradeBotUpdateInterval] + TradeBotUpdateInterval get updateInterval => + TradeBotUpdateInterval.fromString( + priceElapsedValidity?.toString() ?? '300', + ); + + /// Returns [baseCoinId] and [relCoinId] in the format 'BASE/REL'. + /// E.g. 'BTC/ETH' + static String getSimpleName(String baseCoinId, String relCoinId) => + '$baseCoinId/$relCoinId'.toUpperCase(); + + factory TradeCoinPairConfig.fromJson(Map json) { + return TradeCoinPairConfig( + name: json['name'] as String, + baseCoinId: json['base'] as String, + relCoinId: json['rel'] as String, + maxBalancePerTrade: json['max'] as bool?, + minVolume: json['min_volume'] != null + ? TradeVolume.fromJson(json['min_volume'] as Map) + : null, + maxVolume: json['max_volume'] != null + ? TradeVolume.fromJson(json['max_volume'] as Map) + : null, + minBasePriceUsd: json['min_base_price'] as double?, + minRelPriceUsd: json['min_rel_price'] as double?, + minPairPrice: json['min_pair_price'] as double?, + spread: json['spread'] as String, + baseConfs: json['base_confs'] as int?, + baseNota: json['base_nota'] as bool?, + relConfs: json['rel_confs'] as int?, + relNota: json['rel_nota'] as bool?, + enable: json['enable'] as bool, + priceElapsedValidity: json['price_elapsed_validity'] as int?, + checkLastBidirectionalTradeThreshHold: + json['check_last_bidirectional_trade_thresh_hold'] as bool?, + ); + } + + /// Converts the object to a JSON serializable map. NOTE: removes null values + Map toJson() { + return { + 'name': name, + 'base': baseCoinId, + 'rel': relCoinId, + 'max': maxBalancePerTrade, + 'min_volume': minVolume?.toJson(), + 'max_volume': maxVolume?.toJson(), + 'min_base_price': minBasePriceUsd, + 'min_rel_price': minRelPriceUsd, + 'min_pair_price': minPairPrice, + 'spread': spread, + 'base_confs': baseConfs, + 'base_nota': baseNota, + 'rel_confs': relConfs, + 'rel_nota': relNota, + 'enable': enable, + 'price_elapsed_validity': priceElapsedValidity, + 'check_last_bidirectional_trade_thresh_hold': + checkLastBidirectionalTradeThreshHold, + }..removeWhere((key, value) => value == null || value == {}); + } + + TradeCoinPairConfig copyWith({ + String? name, + String? baseCoinId, + String? relCoinId, + bool? maxBalancePerTrade, + TradeVolume? minVolume, + TradeVolume? maxVolume, + double? minBasePriceUsd, + double? minRelPriceUsd, + double? minPairPriceUsd, + String? spread, + int? baseConfs, + bool? baseNota, + int? relConfs, + bool? relNota, + bool? enable, + int? priceElapsedValidity, + bool? checkLastBidirectionalTradeThreshHold, + }) { + return TradeCoinPairConfig( + name: name ?? this.name, + baseCoinId: baseCoinId ?? this.baseCoinId, + relCoinId: relCoinId ?? this.relCoinId, + maxBalancePerTrade: maxBalancePerTrade ?? this.maxBalancePerTrade, + minVolume: minVolume ?? this.minVolume, + maxVolume: maxVolume ?? this.maxVolume, + minBasePriceUsd: minBasePriceUsd ?? this.minBasePriceUsd, + minRelPriceUsd: minRelPriceUsd ?? this.minRelPriceUsd, + minPairPrice: minPairPriceUsd ?? minPairPrice, + spread: spread ?? this.spread, + baseConfs: baseConfs ?? this.baseConfs, + baseNota: baseNota ?? this.baseNota, + relConfs: relConfs ?? this.relConfs, + relNota: relNota ?? this.relNota, + enable: enable ?? this.enable, + priceElapsedValidity: priceElapsedValidity ?? this.priceElapsedValidity, + checkLastBidirectionalTradeThreshHold: + checkLastBidirectionalTradeThreshHold ?? + this.checkLastBidirectionalTradeThreshHold, + ); + } + + @override + List get props => [ + name, + baseCoinId, + relCoinId, + maxBalancePerTrade, + minVolume, + maxVolume, + minBasePriceUsd, + minRelPriceUsd, + minPairPrice, + spread, + baseConfs, + baseNota, + relConfs, + relNota, + enable, + priceElapsedValidity, + checkLastBidirectionalTradeThreshHold, + ]; +} diff --git a/lib/mm2/mm2_api/rpc/market_maker_bot/trade_volume.dart b/lib/mm2/mm2_api/rpc/market_maker_bot/trade_volume.dart new file mode 100644 index 0000000000..5c4f40196a --- /dev/null +++ b/lib/mm2/mm2_api/rpc/market_maker_bot/trade_volume.dart @@ -0,0 +1,55 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/views/market_maker_bot/trade_volume_type.dart'; + +/// The trade volume for the market maker bot. +class TradeVolume extends Equatable { + const TradeVolume({this.type = TradeVolumeType.usd, required this.value}); + + /// Creates a trade volume with the [type] set to [TradeVolumeType.percentage] + /// with the given [value]. + factory TradeVolume.percentage(double value) => + TradeVolume(type: TradeVolumeType.percentage, value: value); + + /// The value of the trade volume limit. + final double value; + + /// The type of the trade volume limit. E.g. percentage or usd. + final TradeVolumeType type; + + factory TradeVolume.fromJson(Map json) { + final percentage = double.tryParse(json['percentage'] as String? ?? ''); + final usd = double.tryParse(json['usd'] as String? ?? ''); + + if (percentage != null && usd != null) { + throw ArgumentError( + 'TradeVolumeLimit cannot have both percentage and usd', + ); + } + + return TradeVolume( + type: + percentage != null ? TradeVolumeType.percentage : TradeVolumeType.usd, + // null check is done above, so value is not null + value: (percentage ?? usd)!, + ); + } + + Map toJson() => { + 'percentage': + type == TradeVolumeType.percentage ? value.toString() : null, + 'usd': type == TradeVolumeType.usd ? value.toString() : null, + }..removeWhere((_, value) => value == null); + + TradeVolume copyWith({ + double? value, + TradeVolumeType? type, + }) { + return TradeVolume( + value: value ?? this.value, + type: type ?? this.type, + ); + } + + @override + List get props => [value, type]; +} diff --git a/lib/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_req.dart b/lib/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_req.dart new file mode 100644 index 0000000000..5b1423db50 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_req.dart @@ -0,0 +1,20 @@ +class MaxMakerVolRequest { + MaxMakerVolRequest({ + required this.coin, + }); + + static const String method = 'max_maker_vol'; + final String coin; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'mmrpc': '2.0', + 'userpass': userpass, + 'params': { + 'coin': coin, + }, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_response.dart b/lib/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_response.dart new file mode 100644 index 0000000000..463e8db80e --- /dev/null +++ b/lib/mm2/mm2_api/rpc/max_maker_vol/max_maker_vol_response.dart @@ -0,0 +1,30 @@ +class MaxMakerVolResponse { + MaxMakerVolResponse({ + required this.volume, + required this.balance, + }); + + final MaxMakerVolResponseValue volume; + final MaxMakerVolResponseValue balance; + + factory MaxMakerVolResponse.fromJson(Map json) => + MaxMakerVolResponse( + volume: MaxMakerVolResponseValue.fromJson(json['volume']), + balance: MaxMakerVolResponseValue.fromJson(json['balance']), + ); +} + +class MaxMakerVolResponseValue { + MaxMakerVolResponseValue({ + required this.decimal, + }); + + final String decimal; + + factory MaxMakerVolResponseValue.fromJson(Map json) => + MaxMakerVolResponseValue(decimal: json['decimal']); + + Map toJson() => { + 'decimal': decimal, + }; +} diff --git a/lib/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_request.dart b/lib/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_request.dart new file mode 100644 index 0000000000..aa2983f38d --- /dev/null +++ b/lib/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_request.dart @@ -0,0 +1,19 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class MaxTakerVolRequest implements BaseRequest { + MaxTakerVolRequest({ + required this.coin, + }); + @override + late String userpass; + @override + final String method = 'max_taker_vol'; + final String coin; + + @override + Map toJson() => { + 'method': method, + 'userpass': userpass, + 'coin': coin, + }; +} diff --git a/lib/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_response.dart b/lib/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_response.dart new file mode 100644 index 0000000000..b8185d8e49 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_response.dart @@ -0,0 +1,29 @@ +class MaxTakerVolResponse { + MaxTakerVolResponse({ + required this.coin, + required this.result, + }); + + factory MaxTakerVolResponse.fromJson(Map json) => + MaxTakerVolResponse( + coin: json['coin'] ?? '', + result: MaxTakerVolumeResponseResult.fromJson(json['result'])); + final String coin; + final MaxTakerVolumeResponseResult result; +} + +class MaxTakerVolumeResponseResult { + MaxTakerVolumeResponseResult({ + required this.numer, + required this.denom, + }); + factory MaxTakerVolumeResponseResult.fromJson(Map json) => + MaxTakerVolumeResponseResult(denom: json['denom'], numer: json['numer']); + final String denom; + final String numer; + + Map toJson() => { + 'denom': denom, + 'numer': numer, + }; +} diff --git a/lib/mm2/mm2_api/rpc/min_trading_vol/min_trading_vol.dart b/lib/mm2/mm2_api/rpc/min_trading_vol/min_trading_vol.dart new file mode 100644 index 0000000000..0a5adcfab3 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/min_trading_vol/min_trading_vol.dart @@ -0,0 +1,20 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class MinTradingVolRequest implements BaseRequest { + MinTradingVolRequest({ + required this.coin, + }); + + @override + late String userpass; + @override + final String method = 'min_trading_vol'; + final String coin; + + @override + Map toJson() => { + 'method': method, + 'userpass': userpass, + 'coin': coin, + }; +} diff --git a/lib/mm2/mm2_api/rpc/min_trading_vol/min_trading_vol_response.dart b/lib/mm2/mm2_api/rpc/min_trading_vol/min_trading_vol_response.dart new file mode 100644 index 0000000000..a09376adf9 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/min_trading_vol/min_trading_vol_response.dart @@ -0,0 +1,37 @@ +class MinTradingVolResponse { + MinTradingVolResponse({ + required this.coin, + required this.result, + }); + + factory MinTradingVolResponse.fromJson(Map json) => + MinTradingVolResponse( + coin: json['coin'] ?? '', + result: MinTradingVolResponseResult.fromJson(json['result']), + ); + + final String coin; + final MinTradingVolResponseResult result; +} + +class MinTradingVolResponseResult { + MinTradingVolResponseResult({ + required this.numer, + required this.denom, + }); + + factory MinTradingVolResponseResult.fromJson(Map json) { + return MinTradingVolResponseResult( + denom: json['min_trading_vol_fraction']['denom'], + numer: json['min_trading_vol_fraction']['numer'], + ); + } + + final String denom; + final String numer; + + Map toJson() => { + 'denom': denom, + 'numer': numer, + }; +} diff --git a/lib/mm2/mm2_api/rpc/my_orders/my_orders_request.dart b/lib/mm2/mm2_api/rpc/my_orders/my_orders_request.dart new file mode 100644 index 0000000000..bcd9875846 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/my_orders/my_orders_request.dart @@ -0,0 +1,14 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class MyOrdersRequest implements BaseRequest { + @override + final String method = 'my_orders'; + @override + late String userpass; + + @override + Map toJson() => { + 'method': method, + 'userpass': userpass, + }; +} diff --git a/lib/mm2/mm2_api/rpc/my_orders/my_orders_response.dart b/lib/mm2/mm2_api/rpc/my_orders/my_orders_response.dart new file mode 100644 index 0000000000..92594fc4b2 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/my_orders/my_orders_response.dart @@ -0,0 +1,48 @@ +import 'package:web_dex/model/my_orders/maker_order.dart'; +import 'package:web_dex/model/my_orders/taker_order.dart'; + +class MyOrdersResponse { + MyOrdersResponse({ + required this.result, + }); + + factory MyOrdersResponse.fromJson(Map json) => + MyOrdersResponse( + result: MyOrdersResponseResult.fromJson(json['result']), + ); + + MyOrdersResponseResult result; + + Map toJson() => { + 'result': result.toJson(), + }; +} + +class MyOrdersResponseResult { + MyOrdersResponseResult({ + required this.makerOrders, + required this.takerOrders, + }); + + factory MyOrdersResponseResult.fromJson(Map json) => + MyOrdersResponseResult( + makerOrders: Map.from(json['maker_orders']).map( + (dynamic k, dynamic v) => + MapEntry(k, MakerOrder.fromJson(v))), + takerOrders: Map.from(json['taker_orders']).map( + (dynamic k, dynamic v) => + MapEntry(k, TakerOrder.fromJson(v))), + ); + + Map makerOrders; + Map takerOrders; + + Map toJson() => { + 'maker_orders': Map.from(makerOrders) + .map((dynamic k, dynamic v) => + MapEntry(k, v.toJson())), + 'taker_orders': Map.from(takerOrders) + .map((dynamic k, dynamic v) => + MapEntry(k, v.toJson())), + }; +} diff --git a/lib/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_request.dart b/lib/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_request.dart new file mode 100644 index 0000000000..bb22333dc8 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_request.dart @@ -0,0 +1,39 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class MyRecentSwapsRequest implements BaseRequest { + MyRecentSwapsRequest({ + this.fromUuid, + this.pageNumber, + this.myCoin, + this.otherCoin, + this.fromTimestamp, + this.toTimestamp, + this.limit = 10000, + }); + + final int? limit; + final String? fromUuid; + final int? pageNumber; + final String? myCoin; + final String? otherCoin; + final int? fromTimestamp; + final int? toTimestamp; + + @override + late String userpass; + @override + final String method = 'my_recent_swaps'; + + @override + Map toJson() => { + 'userpass': userpass, + 'method': method, + 'from_uuid': fromUuid, + if (limit != null) 'limit': limit, + if (pageNumber != null) 'page_number': pageNumber, + 'my_coin': myCoin, + 'other_coin': otherCoin, + 'from_timestamp': fromTimestamp, + 'to_timestamp': toTimestamp, + }; +} diff --git a/lib/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_response.dart b/lib/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_response.dart new file mode 100644 index 0000000000..019d229622 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_response.dart @@ -0,0 +1,63 @@ +import 'package:web_dex/model/swap.dart'; + +class MyRecentSwapsResponse { + MyRecentSwapsResponse({ + required this.result, + }); + + factory MyRecentSwapsResponse.fromJson(Map json) => + MyRecentSwapsResponse( + result: MyRecentSwapsResponseResult.fromJson(json['result']), + ); + + MyRecentSwapsResponseResult result; + + Map get toJson => { + 'result': result.toJson, + }; +} + +class MyRecentSwapsResponseResult { + MyRecentSwapsResponseResult({ + required this.fromUuid, + required this.limit, + required this.skipped, + required this.swaps, + required this.total, + required this.pageNumber, + required this.foundRecords, + required this.totalPages, + }); + + factory MyRecentSwapsResponseResult.fromJson(Map json) => + MyRecentSwapsResponseResult( + fromUuid: json['from_uuid'], + limit: json['limit'] ?? 0, + skipped: json['skipped'] ?? 0, + swaps: List.from((json['swaps'] ?? []) + .where((dynamic x) => x != null) + .map((dynamic x) => Swap.fromJson(x))), + total: json['total'] ?? 0, + foundRecords: json['found_records'] ?? 0, + pageNumber: json['page_number'] ?? 0, + totalPages: json['total_pages'] ?? 0, + ); + + String? fromUuid; + int limit; + int skipped; + List swaps; + int total; + int pageNumber; + int totalPages; + int foundRecords; + + Map get toJson => { + 'from_uuid': fromUuid, + 'limit': limit, + 'skipped': skipped, + 'swaps': List.from( + swaps.map>((Swap x) => x.toJson())), + 'total': total, + }; +} diff --git a/lib/mm2/mm2_api/rpc/my_swap_status/my_swap_status_req.dart b/lib/mm2/mm2_api/rpc/my_swap_status/my_swap_status_req.dart new file mode 100644 index 0000000000..7949d4efc2 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/my_swap_status/my_swap_status_req.dart @@ -0,0 +1,13 @@ +class MySwapStatusReq { + MySwapStatusReq({required this.uuid}); + + final String method = 'my_swap_status'; + late String userpass; + final String uuid; + + Map toJson() => { + 'method': method, + 'params': {'uuid': uuid}, + 'userpass': userpass, + }; +} diff --git a/lib/mm2/mm2_api/rpc/my_tx_history/my_tx_history_request.dart b/lib/mm2/mm2_api/rpc/my_tx_history/my_tx_history_request.dart new file mode 100644 index 0000000000..19eda6da73 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/my_tx_history/my_tx_history_request.dart @@ -0,0 +1,33 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class MyTxHistoryRequest implements BaseRequest { + MyTxHistoryRequest({ + required this.coin, + required this.max, + this.limit, + this.fromId, + this.pageNumber, + }); + + @override + final String method = 'my_tx_history'; + @override + late String userpass; + + final String coin; + final bool max; + final String? fromId; + final int? limit; + final int? pageNumber; + + @override + Map toJson() => { + 'method': method, + 'userpass': userpass, + 'coin': coin, + 'max': max, + if (fromId != null) 'from_id': fromId, + if (pageNumber != null) 'page_number': pageNumber, + if (limit != null) 'limit': limit, + }; +} diff --git a/lib/mm2/mm2_api/rpc/my_tx_history/my_tx_history_response.dart b/lib/mm2/mm2_api/rpc/my_tx_history/my_tx_history_response.dart new file mode 100644 index 0000000000..bdbcb16dac --- /dev/null +++ b/lib/mm2/mm2_api/rpc/my_tx_history/my_tx_history_response.dart @@ -0,0 +1,114 @@ +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; + +class MyTxHistoryResponse { + MyTxHistoryResponse({ + required this.result, + }); + + factory MyTxHistoryResponse.fromJson(Map json) => + MyTxHistoryResponse( + result: TransactionHistoryResponseResult.fromJson( + json['result'] ?? {}), + ); + + TransactionHistoryResponseResult result; +} + +class TransactionHistoryResponseResult { + TransactionHistoryResponseResult({ + required this.fromId, + required this.currentBlock, + required this.syncStatus, + required this.limit, + required this.skipped, + required this.total, + required this.transactions, + }); + + factory TransactionHistoryResponseResult.fromJson( + Map json) => + TransactionHistoryResponseResult( + fromId: json['from_id'] ?? '', + limit: json['limit'] ?? 0, + skipped: json['skipped'] ?? 0, + total: json['total'] ?? 0, + currentBlock: json['current_block'] ?? 0, + syncStatus: json['sync_status'] == null + ? SyncStatus() + : SyncStatus.fromJson(json['sync_status']), + transactions: json['transactions'] is List + ? List.from(json['transactions'] + .map((dynamic x) => Transaction.fromJson(x))) + : [], + ); + + final String fromId; + final int currentBlock; + final SyncStatus syncStatus; + final int limit; + final int skipped; + final int total; + final List transactions; +} + +class SyncStatus { + SyncStatus({ + this.state, + this.additionalInfo, + }); + + factory SyncStatus.fromJson(Map json) => SyncStatus( + additionalInfo: json['additional_info'] == null + ? null + : AdditionalInfo.fromJson(json['additional_info']), + state: _convertSyncStatusState(json['state'])); + + AdditionalInfo? additionalInfo; + SyncStatusState? state; +} + +class AdditionalInfo { + AdditionalInfo({ + required this.code, + required this.message, + required this.transactionsLeft, + required this.blocksLeft, + }); + + factory AdditionalInfo.fromJson(Map json) => AdditionalInfo( + code: json['code'] ?? 0, + message: json['message'] ?? '', + transactionsLeft: json['transactions_left'] ?? 0, + blocksLeft: json['blocks_left'] ?? 0, + ); + + int code; + String message; + int transactionsLeft; + int blocksLeft; + + Map toJson() => { + 'code': code, + 'message': message, + 'transactions_left': transactionsLeft, + 'blocks_left': blocksLeft, + }; +} + +SyncStatusState? _convertSyncStatusState(String? state) { + switch (state) { + case 'NotEnabled': + return SyncStatusState.notEnabled; + case 'NotStarted': + return SyncStatusState.notStarted; + case 'InProgress': + return SyncStatusState.inProgress; + case 'Error': + return SyncStatusState.error; + case 'Finished': + return SyncStatusState.finished; + } + return null; +} + +enum SyncStatusState { notEnabled, notStarted, inProgress, error, finished } diff --git a/lib/mm2/mm2_api/rpc/my_tx_history/my_tx_history_v2_request.dart b/lib/mm2/mm2_api/rpc/my_tx_history/my_tx_history_v2_request.dart new file mode 100644 index 0000000000..dbd19a3a19 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/my_tx_history/my_tx_history_v2_request.dart @@ -0,0 +1,47 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/wallet.dart'; + +class MyTxHistoryV2Request + implements BaseRequest, BaseRequestWithParams { + MyTxHistoryV2Request({required String coin, required WalletType type}) + : params = MyTxHistoryV2ParamsRequest(coin: coin, type: type); + @override + late String userpass; + + @override + final String method = 'my_tx_history'; + + @override + final MyTxHistoryV2ParamsRequest params; + + @override + Map toJson() => { + 'userpass': userpass, + 'mmrpc': '2.0', + 'method': method, + 'params': params.toJson(), + }; +} + +class MyTxHistoryV2ParamsRequest { + const MyTxHistoryV2ParamsRequest({required this.coin, required this.type}); + final String coin; + final WalletType type; + + Map toJson() { + if (type == WalletType.trezor) { + return { + 'coin': coin, + 'limit': 10000, + 'target': { + 'type': 'account_id', + 'account_id': 0, + } + }; + } + return { + 'coin': coin, + 'limit': 10000 // https://github.com/KomodoPlatform/WebDEX/issues/795 + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/my_tx_history/transaction.dart b/lib/mm2/mm2_api/rpc/my_tx_history/transaction.dart new file mode 100644 index 0000000000..639b01712f --- /dev/null +++ b/lib/mm2/mm2_api/rpc/my_tx_history/transaction.dart @@ -0,0 +1,137 @@ +import 'package:intl/intl.dart'; +import 'package:web_dex/model/withdraw_details/fee_details.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class Transaction { + Transaction({ + required this.blockHeight, + required this.coin, + required this.confirmations, + required this.feeDetails, + required this.from, + required this.internalId, + required this.myBalanceChange, + required this.receivedByMe, + required this.spentByMe, + required this.timestamp, + required this.to, + required this.totalAmount, + required this.txHash, + required this.txHex, + required this.memo, + }); + + factory Transaction.fromJson(Map json) { + return Transaction( + blockHeight: assertInt(json['block_height']) ?? 0, + coin: _parseCoin(json['coin']) ?? '', + confirmations: json['confirmations'] ?? 0, + feeDetails: FeeDetails.fromJson(json['fee_details']), + from: json['from'] != null + ? List.from(json['from'].map((dynamic x) => x)) + : [], + internalId: json['internal_id'] ?? '', + myBalanceChange: assertString(json['my_balance_change']) ?? '0.0', + receivedByMe: assertString(json['received_by_me']) ?? '0.0', + spentByMe: assertString(json['spent_by_me']) ?? '0.0', + timestamp: json['timestamp'] ?? 0, + to: json['to'] != null + ? List.from(json['to'].map((dynamic x) => x)) + : [], + totalAmount: assertString(json['total_amount']) ?? '', + txHash: json['tx_hash'] ?? '', + txHex: json['tx_hex'] ?? '', + memo: json['memo'], + ); + } + + String coin; + final int blockHeight; + final int confirmations; + final FeeDetails feeDetails; + final List from; + final String internalId; + final String myBalanceChange; + final String receivedByMe; + final String spentByMe; + final int timestamp; + final List to; + final String totalAmount; + final String txHash; + final String txHex; + final String? memo; + + String get formattedTime { + if (timestamp == 0 && confirmations == 0) { + return 'unconfirmed'; + } else if (timestamp == 0 && confirmations > 0) { + return 'confirmed'; + } else { + return DateFormat('dd MMM yyyy HH:mm') + .format(DateTime.fromMillisecondsSinceEpoch(timestamp * 1000)); + } + } + + /// Timestamp as a [DateTime] object. + DateTime get timestampDate => + DateTime.fromMillisecondsSinceEpoch(timestampMilliseconds); + + /// Timestamp in milliseconds since epoch (UTC). + /// Unix timestamp, but in milliseconds since epoch. + int get timestampMilliseconds => timestamp * 1000; + + String get toAddress { + final List toAddress = List.from(to); + if (toAddress.length > 1) { + toAddress.removeWhere((String toItem) => toItem == from[0]); + } + return toAddress.isNotEmpty ? toAddress[0] : ''; + } + + bool get isReceived => double.parse(myBalanceChange) > 0; + + Transaction copyWith({ + int? blockHeight, + String? coin, + int? confirmations, + FeeDetails? feeDetails, + List? from, + String? internalId, + String? myBalanceChange, + String? receivedByMe, + String? spentByMe, + int? timestamp, + List? to, + String? totalAmount, + String? txHash, + String? txHex, + String? memo, + }) { + return Transaction( + blockHeight: blockHeight ?? this.blockHeight, + coin: coin ?? this.coin, + confirmations: confirmations ?? this.confirmations, + feeDetails: feeDetails ?? this.feeDetails, + from: from ?? this.from, + internalId: internalId ?? this.internalId, + myBalanceChange: myBalanceChange ?? this.myBalanceChange, + receivedByMe: receivedByMe ?? this.receivedByMe, + spentByMe: spentByMe ?? this.spentByMe, + timestamp: timestamp ?? this.timestamp, + to: to ?? this.to, + totalAmount: totalAmount ?? this.totalAmount, + txHash: txHash ?? this.txHash, + txHex: txHex ?? this.txHex, + memo: memo ?? this.memo, + ); + } +} + +String? _parseCoin(String value) { + if (value == + 'IBC/27394FB092D2ECCD56123C74F36E4C1F926001CEADA9CA97EA622B25F41E5EB2') { + return 'ATOM-IBC_IRIS'; + } else { + return value; + } +} diff --git a/lib/mm2/mm2_api/rpc/nft/get_nft_list/get_nft_list_req.dart b/lib/mm2/mm2_api/rpc/nft/get_nft_list/get_nft_list_req.dart new file mode 100644 index 0000000000..64410af78d --- /dev/null +++ b/lib/mm2/mm2_api/rpc/nft/get_nft_list/get_nft_list_req.dart @@ -0,0 +1,29 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class GetNftListRequest implements BaseRequest { + GetNftListRequest({ + required this.chains, + }); + + final List chains; + @override + late String userpass; + + @override + final String method = 'get_nft_list'; + + @override + Map toJson() { + return { + "userpass": userpass, + "method": method, + "mmrpc": "2.0", + "params": { + "chains": chains, + "max": true, + "protect_from_spam": true, + "filters": {"exclude_spam": true, "exclude_phishing": true} + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/nft/get_nft_list/get_nft_list_res.dart b/lib/mm2/mm2_api/rpc/nft/get_nft_list/get_nft_list_res.dart new file mode 100644 index 0000000000..de526927f1 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/nft/get_nft_list/get_nft_list_res.dart @@ -0,0 +1,23 @@ +import 'package:web_dex/model/nft.dart'; + +class GetNftListResponse { + const GetNftListResponse({required this.result}); + final GetNftListResponseResult result; + + static GetNftListResponse fromJson(Map json) { + return GetNftListResponse( + result: GetNftListResponseResult.fromJson(json['result'])); + } +} + +class GetNftListResponseResult { + const GetNftListResponseResult({required this.nfts}); + static GetNftListResponseResult fromJson(Map json) { + final dynamic nftsJson = json['nfts']; + final List nftList = nftsJson is List ? nftsJson : []; + return GetNftListResponseResult( + nfts: nftList.map(NftToken.fromJson).toList()); + } + + final List nfts; +} diff --git a/lib/mm2/mm2_api/rpc/nft/refresh_nft_metadata/refresh_nft_metadata_req.dart b/lib/mm2/mm2_api/rpc/nft/refresh_nft_metadata/refresh_nft_metadata_req.dart new file mode 100644 index 0000000000..b533998c2e --- /dev/null +++ b/lib/mm2/mm2_api/rpc/nft/refresh_nft_metadata/refresh_nft_metadata_req.dart @@ -0,0 +1,36 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/shared/constants.dart'; + +class RefreshNftMetadataRequest implements BaseRequest { + RefreshNftMetadataRequest({ + required this.chain, + required this.tokenAddress, + required this.tokenId, + }); + + final String chain; + final String tokenAddress; + final String tokenId; + @override + late String userpass; + + @override + final String method = 'refresh_nft_metadata'; + + @override + Map toJson() { + return { + "userpass": userpass, + "method": method, + "mmrpc": "2.0", + "params": { + "token_address": tokenAddress, + "token_id": tokenId, + "chain": chain, + "url": moralisProxyUrl, + "url_antispam": nftAntiSpamUrl, + "proxy_auth": false, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/nft/update_nft/update_nft_req.dart b/lib/mm2/mm2_api/rpc/nft/update_nft/update_nft_req.dart new file mode 100644 index 0000000000..f17adf29f7 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/nft/update_nft/update_nft_req.dart @@ -0,0 +1,30 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/shared/constants.dart'; + +class UpdateNftRequest implements BaseRequest { + UpdateNftRequest({ + required this.chains, + }); + + final List chains; + @override + late String userpass; + + @override + final String method = 'update_nft'; + + @override + Map toJson() { + return { + "userpass": userpass, + "method": method, + "mmrpc": "2.0", + "params": { + "chains": chains, + "url": moralisProxyUrl, + "url_antispam": nftAntiSpamUrl, + "proxy_auth": false, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_request.dart b/lib/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_request.dart new file mode 100644 index 0000000000..17af6a96cd --- /dev/null +++ b/lib/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_request.dart @@ -0,0 +1,47 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/nft.dart'; + +class WithdrawNftRequest implements BaseRequest { + WithdrawNftRequest({ + required this.type, + required this.chain, + required this.toAddress, + required this.tokenAddress, + required this.tokenId, + this.amount, + this.max, + }); + final NftContractType type; + final NftBlockchains chain; + final String toAddress; + final String tokenAddress; + final String tokenId; + final int? amount; + final bool? max; + @override + late String userpass; + + @override + String get method => 'withdraw_nft'; + + @override + Map toJson() { + return { + 'mmrpc': '2.0', + 'userpass': userpass, + 'method': method, + 'params': { + 'type': type.toWithdrawRequest(), + 'withdraw_data': { + 'chain': chain.toApiRequest(), + "to": toAddress, + "token_address": tokenAddress, + "token_id": tokenId, + if (max != null && type == NftContractType.erc1155) 'max': max, + if (amount != null && type == NftContractType.erc1155) + 'amount': amount, + }, + }, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_response.dart b/lib/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_response.dart new file mode 100644 index 0000000000..da23a6808d --- /dev/null +++ b/lib/mm2/mm2_api/rpc/nft/withdraw/withdraw_nft_response.dart @@ -0,0 +1,13 @@ +import 'package:web_dex/model/nft.dart'; + +class WithdrawNftResponse { + WithdrawNftResponse({ + required this.result, + }); + final NftTransactionDetails result; + + factory WithdrawNftResponse.fromJson(Map json) { + return WithdrawNftResponse( + result: NftTransactionDetails.fromJson(json['result'])); + } +} diff --git a/lib/mm2/mm2_api/rpc/order_status/cancellation_reason.dart b/lib/mm2/mm2_api/rpc/order_status/cancellation_reason.dart new file mode 100644 index 0000000000..ca81ac934c --- /dev/null +++ b/lib/mm2/mm2_api/rpc/order_status/cancellation_reason.dart @@ -0,0 +1,14 @@ +enum MakerOrderCancellationReason { + fulfilled, + insufficientBalance, + cancelled, + none, +} + +enum TakerOrderCancellationReason { + fulfilled, + toMaker, + timedOut, + cancelled, + none, +} diff --git a/lib/mm2/mm2_api/rpc/order_status/order_status_request.dart b/lib/mm2/mm2_api/rpc/order_status/order_status_request.dart new file mode 100644 index 0000000000..6c7ca45788 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/order_status/order_status_request.dart @@ -0,0 +1,17 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class OrderStatusRequest implements BaseRequest { + OrderStatusRequest({required this.uuid}); + final String uuid; + @override + late String userpass; + @override + final String method = 'order_status'; + + @override + Map toJson() => { + 'userpass': userpass, + 'method': method, + 'uuid': uuid, + }; +} diff --git a/lib/mm2/mm2_api/rpc/order_status/order_status_response.dart b/lib/mm2/mm2_api/rpc/order_status/order_status_response.dart new file mode 100644 index 0000000000..980f0112f2 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/order_status/order_status_response.dart @@ -0,0 +1,27 @@ +import 'package:web_dex/model/my_orders/maker_order.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/my_orders/taker_order.dart'; + +class OrderStatusResponse { + OrderStatusResponse({ + required this.type, + required this.order, + required this.cancellationReason, + }); + + factory OrderStatusResponse.fromJson(Map json) { + final TradeSide type = + json['type'] == 'Taker' ? TradeSide.taker : TradeSide.maker; + return OrderStatusResponse( + type: type, + order: type == TradeSide.taker + ? TakerOrder.fromJson(json['order']) + : MakerOrder.fromJson(json['order']), + cancellationReason: json['cancellation_reason'], + ); + } + + final TradeSide type; + final dynamic order; // TakerOrder or MakerOrder + final String? cancellationReason; +} diff --git a/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart b/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart new file mode 100644 index 0000000000..d742cb063e --- /dev/null +++ b/lib/mm2/mm2_api/rpc/orderbook/orderbook_request.dart @@ -0,0 +1,23 @@ +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 new file mode 100644 index 0000000000..55fa0e5118 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/orderbook/orderbook_response.dart @@ -0,0 +1,15 @@ +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/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_req.dart b/lib/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_req.dart new file mode 100644 index 0000000000..c50c861947 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_req.dart @@ -0,0 +1,26 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class OrderBookDepthReq implements BaseRequest { + OrderBookDepthReq({this.pairs = const []}); + + @override + late String userpass; + @override + final String method = 'orderbook_depth'; + + final List pairs; + + @override + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'pairs': pairs, + }; + } + + @override + String toString() { + return 'OrderBookDepthReq(${pairs.length})'; + } +} diff --git a/lib/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_response.dart b/lib/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_response.dart new file mode 100644 index 0000000000..3b990d4290 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_response.dart @@ -0,0 +1,51 @@ +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; + +class OrderBookDepthResponse { + OrderBookDepthResponse(this.list); + + factory OrderBookDepthResponse.fromJson(Map json) { + final List list = []; + final List result = json['result']; + + for (int i = 0; i < result.length; i++) { + final Map item = result[i]; + final pair = OrderBookDepth.fromJson(item); + if (pair != null) list.add(pair); + } + list.sort((a, b) => a.source.abbr.compareTo(b.source.abbr)); + return OrderBookDepthResponse(list); + } + + List list; +} + +class OrderBookDepth { + OrderBookDepth(this.source, this.target, this.asks, this.bids); + + Coin source; + Coin target; + int asks; + int bids; + + static OrderBookDepth? fromJson(Map map) { + final List pair = map['pair']; + final Map depth = map['depth']; + + final String sourceName = (pair[0] ?? '').replaceAll('"', ''); + final String targetName = (pair[1] ?? '').replaceAll('"', ''); + + final Coin? source = coinsBloc.getCoin(sourceName); + final Coin? target = coinsBloc.getCoin(targetName); + + if (source == null || target == null) return null; + + return OrderBookDepth( + source, target, depth['asks'] ?? 0, depth['bids'] ?? 0); + } + + @override + String toString() { + return 'OrderBookDepth($source, $target, $asks, $bids)'; + } +} diff --git a/lib/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_request.dart b/lib/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_request.dart new file mode 100644 index 0000000000..ea89bd149f --- /dev/null +++ b/lib/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_request.dart @@ -0,0 +1,20 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class RecoverFundsOfSwapRequest implements BaseRequest { + RecoverFundsOfSwapRequest({required this.uuid}); + + final String uuid; + @override + late String userpass; + @override + final String method = 'recover_funds_of_swap'; + + @override + Map toJson() => { + 'userpass': userpass, + 'method': method, + 'params': { + 'uuid': uuid, + }, + }; +} diff --git a/lib/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_response.dart b/lib/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_response.dart new file mode 100644 index 0000000000..f430b28eda --- /dev/null +++ b/lib/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_response.dart @@ -0,0 +1,43 @@ +class RecoverFundsOfSwapResponse { + RecoverFundsOfSwapResponse({ + required this.result, + }); + factory RecoverFundsOfSwapResponse.fromJson(Map json) => + RecoverFundsOfSwapResponse( + result: RecoverFundsOfSwapResponseResult.fromJson(json['result'])); + final RecoverFundsOfSwapResponseResult result; + + Map toJson() => { + 'result': result.toJson(), + }; +} + +class RecoverFundsOfSwapResponseResult { + RecoverFundsOfSwapResponseResult({ + required this.action, + required this.coin, + required this.txHash, + required this.txHex, + }); + + factory RecoverFundsOfSwapResponseResult.fromJson( + Map json) => + RecoverFundsOfSwapResponseResult( + action: json['action'], + coin: json['coin'], + txHash: json['tx_hash'], + txHex: json['tx_hex'], + ); + + final String action; // SpentOtherPayment or RefundedMyPayment + final String coin; + final String txHash; + final String txHex; + + Map toJson() => { + 'action': action, + 'coin': coin, + 'tx_hash': txHash, + 'tx_hex': txHex, + }; +} diff --git a/lib/mm2/mm2_api/rpc/rpc_error.dart b/lib/mm2/mm2_api/rpc/rpc_error.dart new file mode 100644 index 0000000000..043ee8aaad --- /dev/null +++ b/lib/mm2/mm2_api/rpc/rpc_error.dart @@ -0,0 +1,100 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/rpc_error_type.dart'; + +class RpcException implements Exception { + final RpcError error; + + const RpcException(this.error); + + @override + String toString() { + return 'RpcException: ${error.error}'; + } +} + +class RpcError extends Equatable { + final String? mmrpc; + final String? error; + final String? errorPath; + final String? errorTrace; + final RpcErrorType? errorType; + final String? errorData; + final int? id; + + const RpcError({ + this.mmrpc, + this.error, + this.errorPath, + this.errorTrace, + this.errorType, + this.errorData, + this.id, + }); + + factory RpcError.fromJson(Map json) => RpcError( + mmrpc: json['mmrpc'] as String?, + error: json['error'] as String?, + errorPath: json['error_path'] as String?, + errorTrace: json['error_trace'] as String?, + errorType: RpcErrorType.fromString(json['error_type'] as String? ?? ''), + errorData: json['error_data'] as String?, + id: json['id'] as int?, + ); + + Map toJson() => { + 'mmrpc': mmrpc, + 'error': error, + 'error_path': errorPath, + 'error_trace': errorTrace, + 'error_type': errorType?.toString(), + 'error_data': errorData, + 'id': id, + }; + + RpcError copyWith({ + String? mmrpc, + String? error, + String? errorPath, + String? errorTrace, + RpcErrorType? errorType, + String? errorData, + int? id, + }) { + return RpcError( + mmrpc: mmrpc ?? this.mmrpc, + error: error ?? this.error, + errorPath: errorPath ?? this.errorPath, + errorTrace: errorTrace ?? this.errorTrace, + errorType: errorType ?? this.errorType, + errorData: errorData ?? this.errorData, + id: id ?? this.id, + ); + } + + @override + String toString() { + return ''' +RpcError: { + mmrpc: $mmrpc, + error: $error, + errorPath: $errorPath, + errorTrace: $errorTrace, + errorType: $errorType, + errorData: $errorData, + id: $id +}'''; + } + + @override + List get props { + return [ + mmrpc, + error, + errorPath, + errorTrace, + errorType, + errorData, + id, + ]; + } +} diff --git a/lib/mm2/mm2_api/rpc/rpc_error_type.dart b/lib/mm2/mm2_api/rpc/rpc_error_type.dart new file mode 100644 index 0000000000..e1b133096a --- /dev/null +++ b/lib/mm2/mm2_api/rpc/rpc_error_type.dart @@ -0,0 +1,40 @@ +enum RpcErrorType { + alreadyStarted, + alreadyStopped, + alreadyStopping, + cannotStartFromStopping, + invalidRequest; + + @override + String toString() { + switch (this) { + case RpcErrorType.alreadyStarted: + return 'AlreadyStarted'; + case RpcErrorType.alreadyStopped: + return 'AlreadyStopped'; + case RpcErrorType.alreadyStopping: + return 'AlreadyStopping'; + case RpcErrorType.cannotStartFromStopping: + return 'CannotStartFromStopping'; + case RpcErrorType.invalidRequest: + return 'InvalidRequest'; + } + } + + static RpcErrorType fromString(String value) { + switch (value) { + case 'AlreadyStarted': + return RpcErrorType.alreadyStarted; + case 'AlreadyStopped': + return RpcErrorType.alreadyStopped; + case 'AlreadyStopping': + return RpcErrorType.alreadyStopping; + case 'CannotStartFromStopping': + return RpcErrorType.cannotStartFromStopping; + case 'InvalidRequest': + return RpcErrorType.invalidRequest; + default: + throw ArgumentError('Invalid value: $value'); + } + } +} diff --git a/lib/mm2/mm2_api/rpc/sell/sell_request.dart b/lib/mm2/mm2_api/rpc/sell/sell_request.dart new file mode 100644 index 0000000000..2553021f33 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/sell/sell_request.dart @@ -0,0 +1,71 @@ +import 'package:rational/rational.dart'; + +class SellRequest { + SellRequest({ + this.method = 'sell', + required this.base, + required this.rel, + required this.volume, + required this.price, + required this.orderType, + }); + + factory SellRequest.fromJson(Map json) { + final String typeStr = + json['order_type'] != null && json['order_type']['type'] != null + ? json['order_type']['type'] + : null; + + SellBuyOrderType orderType; + switch (typeStr) { + case 'FillOrKill': + orderType = SellBuyOrderType.fillOrKill; + break; + default: + orderType = SellBuyOrderType.goodTillCancelled; + } + + return SellRequest( + method: json['method'], + base: json['base'], + rel: json['rel'], + volume: json['volume'], + price: json['price'], + orderType: orderType, + ); + } + + late String userpass; + final String method; + final String base; + final String rel; + final SellBuyOrderType orderType; + bool? baseNota; + int? baseConfs; + bool? relNota; + int? relConfs; + final Rational price; + final Rational volume; + + Map toJson() => { + 'userpass': userpass, + 'method': method, + 'base': base, + 'rel': rel, + 'volume': { + 'numer': volume.numerator.toString(), + 'denom': volume.denominator.toString() + }, + 'price': { + 'numer': price.numerator.toString(), + 'denom': price.denominator.toString() + }, + 'order_type': { + 'type': orderType == SellBuyOrderType.fillOrKill + ? 'FillOrKill' + : 'GoodTillCancelled' + }, + }; +} + +enum SellBuyOrderType { goodTillCancelled, fillOrKill } diff --git a/lib/mm2/mm2_api/rpc/sell/sell_response.dart b/lib/mm2/mm2_api/rpc/sell/sell_response.dart new file mode 100644 index 0000000000..4e4e79559f --- /dev/null +++ b/lib/mm2/mm2_api/rpc/sell/sell_response.dart @@ -0,0 +1,27 @@ +import 'package:web_dex/model/text_error.dart'; + +class SellResponse { + SellResponse({this.error, this.result}); + + factory SellResponse.fromJson(Map json) { + return SellResponse( + error: TextError.fromString(json['error']), + result: SellResponseResult.fromJson(json['result']), + ); + } + + final TextError? error; + final SellResponseResult? result; +} + +class SellResponseResult { + SellResponseResult({required this.uuid}); + + static SellResponseResult? fromJson(Map? json) { + if (json == null) return null; + + return SellResponseResult(uuid: json['uuid']); + } + + final String uuid; +} diff --git a/lib/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_request.dart b/lib/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_request.dart new file mode 100644 index 0000000000..d3e2573753 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_request.dart @@ -0,0 +1,34 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class SendRawTransactionRequest implements BaseRequest { + SendRawTransactionRequest({ + required this.coin, + required this.txHex, + }); + + factory SendRawTransactionRequest.fromJson(Map json) { + return SendRawTransactionRequest( + coin: json['coin'], + txHex: json['tx_hex'], + ); + } + + @override + final String method = 'send_raw_transaction'; + + String coin; + String txHex; + + @override + late String userpass; + + @override + Map toJson() { + return { + 'method': method, + 'coin': coin, + 'tx_hex': txHex, + 'userpass': userpass, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_response.dart b/lib/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_response.dart new file mode 100644 index 0000000000..db9f5d7d4d --- /dev/null +++ b/lib/mm2/mm2_api/rpc/send_raw_transaction/send_raw_transaction_response.dart @@ -0,0 +1,19 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/text_error.dart'; + +class SendRawTransactionResponse { + SendRawTransactionResponse({ + required this.txHash, + this.error, + }); + + factory SendRawTransactionResponse.fromJson(Map json) { + final dynamic error = json['error']; + return SendRawTransactionResponse( + txHash: json['tx_hash'], + error: error is String ? TextError(error: error) : null, + ); + } + final String? txHash; + final BaseError? error; +} diff --git a/lib/mm2/mm2_api/rpc/setprice/setprice_request.dart b/lib/mm2/mm2_api/rpc/setprice/setprice_request.dart new file mode 100644 index 0000000000..daea3aa917 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/setprice/setprice_request.dart @@ -0,0 +1,40 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class SetPriceRequest { + SetPriceRequest({ + this.method = 'setprice', + required this.base, + required this.rel, + required this.volume, + required this.price, + this.minVolume, + this.max = false, + this.cancelPrevious = false, + this.userpass, + }); + + final String method; + String? userpass; + final String base; + final String rel; + final Rational volume; + final Rational price; + final Rational? minVolume; + final bool max; + final bool cancelPrevious; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'base': base, + 'rel': rel, + 'volume': rat2fract(volume), + 'price': rat2fract(price), + if (minVolume != null) 'minVolume': rat2fract(minVolume!), + 'max': max, + 'cancel_previous': cancelPrevious, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/stop/stop_req.dart b/lib/mm2/mm2_api/rpc/stop/stop_req.dart new file mode 100644 index 0000000000..f6d502decf --- /dev/null +++ b/lib/mm2/mm2_api/rpc/stop/stop_req.dart @@ -0,0 +1,14 @@ +class StopReq { + StopReq(); + + static const String method = 'stop'; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mm2': 1, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trade_preimage/trade_preimage_errors.dart b/lib/mm2/mm2_api/rpc/trade_preimage/trade_preimage_errors.dart new file mode 100644 index 0000000000..0713ea0a09 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trade_preimage/trade_preimage_errors.dart @@ -0,0 +1,247 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_request.dart'; +import 'package:web_dex/model/text_error.dart'; + +class TradePreimageNotSufficientBalanceError implements BaseError { + TradePreimageNotSufficientBalanceError({ + required this.coin, + required this.available, + required this.required, + required this.lockedBySwaps, + required this.error, + }); + + factory TradePreimageNotSufficientBalanceError.fromJson( + Map json) { + return TradePreimageNotSufficientBalanceError( + coin: json['error_data']['coin'], + available: json['error_data']['available'], + required: json['error_data']['required'], + lockedBySwaps: json['error_data']['locked_by_swaps'], + error: json['error'], + ); + } + static String type = 'NotSufficientBalance'; + final String coin; + final String available; + final String required; + final String? lockedBySwaps; + final String error; + + @override + String get message => error; +} + +class TradePreimageNotSufficientBaseCoinBalanceError implements BaseError { + TradePreimageNotSufficientBaseCoinBalanceError({ + required this.coin, + required this.available, + required this.required, + required this.lockedBySwaps, + required this.error, + }); + + factory TradePreimageNotSufficientBaseCoinBalanceError.fromJson( + Map json) { + return TradePreimageNotSufficientBaseCoinBalanceError( + coin: json['error_data']['coin'], + available: json['error_data']['available'], + required: json['error_data']['required'], + lockedBySwaps: json['error_data']['locked_by_swaps'], + error: json['error'], + ); + } + static String type = 'NotSufficientBaseCoinBalance'; + final String coin; + final String available; + final String required; + final String lockedBySwaps; + final String error; + + @override + String get message => error; +} + +class TradePreimageVolumeTooLowError implements BaseError { + TradePreimageVolumeTooLowError({ + required this.coin, + required this.volume, + required this.threshold, + required this.error, + }); + factory TradePreimageVolumeTooLowError.fromJson(Map json) => + TradePreimageVolumeTooLowError( + coin: json['error_data']['coin'], + volume: json['error_data']['volume'], + threshold: json['error_data']['threshold'], + error: json['error'], + ); + + static String type = 'VolumeTooLow'; + final String coin; + final String volume; + final String threshold; + final String error; + @override + String get message => error; +} + +class TradePreimageNoSuchCoinError implements BaseError { + TradePreimageNoSuchCoinError({required this.coin, required this.error}); + + factory TradePreimageNoSuchCoinError.fromJson(Map json) => + TradePreimageNoSuchCoinError( + coin: json['error_data']['coin'], + error: json['error'], + ); + + static String type = 'NoSuchCoin'; + final String coin; + final String error; + + @override + String get message => error; +} + +class TradePreimageCoinIsWalletOnlyError implements BaseError { + TradePreimageCoinIsWalletOnlyError({required this.coin, required this.error}); + + factory TradePreimageCoinIsWalletOnlyError.fromJson( + Map json) => + TradePreimageCoinIsWalletOnlyError( + coin: json['error_data']['coin'], + error: json['error'], + ); + static String type = 'CoinIsWalletOnly'; + final String coin; + final String error; + + @override + String get message => error; +} + +class TradePreimageBaseEqualRelError implements BaseError { + TradePreimageBaseEqualRelError({required this.error}); + + factory TradePreimageBaseEqualRelError.fromJson(Map json) => + TradePreimageBaseEqualRelError( + error: json['error'], + ); + + static String type = 'BaseEqualRel'; + final String error; + + @override + String get message => error; +} + +class TradePreimageInvalidParamError implements BaseError { + TradePreimageInvalidParamError({ + required this.param, + required this.reason, + required this.error, + }); + + factory TradePreimageInvalidParamError.fromJson(Map json) => + TradePreimageInvalidParamError( + param: json['error_data']['param'], + reason: json['error_data']['reason'], + error: json['error'], + ); + + static String type = 'InvalidParam'; + final String param; + final String reason; + final String error; + + @override + String get message => error; +} + +class TradePreimagePriceTooLowError implements BaseError { + TradePreimagePriceTooLowError({ + required this.price, + required this.threshold, + required this.error, + }); + + factory TradePreimagePriceTooLowError.fromJson(Map json) => + TradePreimagePriceTooLowError( + price: json['error_data']['price'], + threshold: json['error_data']['threshold'], + error: json['error'], + ); + + static String type = 'PriceTooLow'; + final String price; + final String threshold; + final String error; + + @override + String get message => error; +} + +class TradePreimageTransportError implements BaseError { + TradePreimageTransportError({required this.error}); + factory TradePreimageTransportError.fromJson(Map json) => + TradePreimageTransportError( + error: json['error'], + ); + static String type = 'Transport'; + final String error; + + @override + String get message => error; +} + +class TradePreimageInternalError implements BaseError { + TradePreimageInternalError({required this.error}); + factory TradePreimageInternalError.fromJson(Map json) => + TradePreimageInternalError( + error: json['error'], + ); + + static String type = 'InternalError'; + final String error; + + @override + String get message => error; +} + +class TradePreimageErrorFactory implements ErrorFactory { + Map)> errors = { + TradePreimageNotSufficientBalanceError.type: (json) => + TradePreimageNotSufficientBalanceError.fromJson(json), + TradePreimageNotSufficientBaseCoinBalanceError.type: (json) => + TradePreimageNotSufficientBaseCoinBalanceError.fromJson(json), + TradePreimageVolumeTooLowError.type: (json) => + TradePreimageVolumeTooLowError.fromJson(json), + TradePreimageNoSuchCoinError.type: (json) => + TradePreimageNoSuchCoinError.fromJson(json), + TradePreimageCoinIsWalletOnlyError.type: (json) => + TradePreimageCoinIsWalletOnlyError.fromJson(json), + TradePreimageBaseEqualRelError.type: (json) => + TradePreimageBaseEqualRelError.fromJson(json), + TradePreimageInvalidParamError.type: (json) => + TradePreimageInvalidParamError.fromJson(json), + TradePreimagePriceTooLowError.type: (json) => + TradePreimagePriceTooLowError.fromJson(json), + TradePreimageTransportError.type: (json) => + TradePreimageTransportError.fromJson(json) + }; + + @override + BaseError getError(Map json, TradePreimageRequest request) { + final BaseError Function(Map)? errorFactory = + errors[json['error_type']]; + + if (errorFactory == null) { + return TextError(error: 'Something went wrong!'); + } + final BaseError error = errorFactory(json); + return error; + } +} + +TradePreimageErrorFactory tradePreimageErrorFactory = + TradePreimageErrorFactory(); diff --git a/lib/mm2/mm2_api/rpc/trade_preimage/trade_preimage_request.dart b/lib/mm2/mm2_api/rpc/trade_preimage/trade_preimage_request.dart new file mode 100644 index 0000000000..5f0f657974 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trade_preimage/trade_preimage_request.dart @@ -0,0 +1,61 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class TradePreimageRequest implements BaseRequest { + TradePreimageRequest({ + required this.base, + required this.rel, + required this.swapMethod, + required this.price, + this.volume, + this.max = false, + }) { + if (volume == null) { + assert(max == true && swapMethod == 'setprice'); + } + if (max == true) { + assert(swapMethod == 'setprice'); + } + } + + final String mmrpc = '2.0'; + final String base; + final String rel; + final String swapMethod; // 'buy', 'sell' or 'setprice' + final Rational price; + final Rational? volume; + final bool max; + + @override + final String method = 'trade_preimage'; + @override + late String userpass; + + @override + Map toJson() { + final Rational? volume = this.volume; + + final Map json = { + 'userpass': userpass, + 'method': method, + 'mmrpc': mmrpc, + 'params': { + 'base': base, + 'rel': rel, + 'swap_method': swapMethod, + if (volume != null) + 'volume': { + 'numer': volume.numerator.toString(), + 'denom': volume.denominator.toString(), + }, + 'price': { + 'numer': price.numerator.toString(), + 'denom': price.denominator.toString(), + }, + 'max': max, + }, + }; + + return json; + } +} diff --git a/lib/mm2/mm2_api/rpc/trade_preimage/trade_preimage_response.dart b/lib/mm2/mm2_api/rpc/trade_preimage/trade_preimage_response.dart new file mode 100644 index 0000000000..b4b91af6dd --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trade_preimage/trade_preimage_response.dart @@ -0,0 +1,61 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/trade_preimage_extended_fee_info.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class TradePreimageResponse + implements BaseResponse { + TradePreimageResponse({required this.result, required this.mmrpc}); + factory TradePreimageResponse.fromJson(Map json) => + TradePreimageResponse( + result: TradePreimageResponseResult.fromJson(json['result']), + mmrpc: json['mmrpc']); + @override + final TradePreimageResponseResult result; + @override + final String mmrpc; +} + +class TradePreimageResponseResult { + TradePreimageResponseResult({ + required this.baseCoinFee, + required this.relCoinFee, + required this.volume, + required this.volumeRat, + required this.volumeFraction, + required this.takerFee, + required this.feeToSendTakerFee, + required this.totalFees, + }); + factory TradePreimageResponseResult.fromJson(Map json) => + TradePreimageResponseResult( + baseCoinFee: + TradePreimageExtendedFeeInfo.fromJson(json['base_coin_fee']), + relCoinFee: TradePreimageExtendedFeeInfo.fromJson(json['rel_coin_fee']), + volume: json['volume'], + volumeRat: json['volume_rat'] != null + ? List>.from(json['volume_rat']) + : [], + volumeFraction: json['volume_fraction'] != null + ? fract2rat(json['volume_fraction']) + : null, + takerFee: json['taker_fee'] != null + ? TradePreimageExtendedFeeInfo.fromJson(json['taker_fee']) + : null, + feeToSendTakerFee: json['fee_to_send_taker_fee'] != null + ? TradePreimageExtendedFeeInfo.fromJson( + json['fee_to_send_taker_fee']) + : null, + totalFees: (json['total_fees'] as List) + .map((dynamic json) => TradePreimageExtendedFeeInfo.fromJson(json)) + .toList(), + ); + final TradePreimageExtendedFeeInfo baseCoinFee; + final TradePreimageExtendedFeeInfo relCoinFee; + final String? volume; + final List> volumeRat; + final Rational? volumeFraction; + final TradePreimageExtendedFeeInfo? takerFee; + final TradePreimageExtendedFeeInfo? feeToSendTakerFee; + final List totalFees; +} diff --git a/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_request.dart b/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_request.dart new file mode 100644 index 0000000000..eaedcec1e3 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_request.dart @@ -0,0 +1,20 @@ +import 'package:web_dex/model/coin.dart'; + +class TrezorBalanceInitRequest { + TrezorBalanceInitRequest({ + required this.coin, + }); + + static const String method = 'task::account_balance::init'; + late String userpass; + final Coin coin; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': {'coin': coin.abbr, 'account_index': 0} + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_response.dart b/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_response.dart new file mode 100644 index 0000000000..72501d56b6 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_init/trezor_balance_init_response.dart @@ -0,0 +1,27 @@ +class TrezorBalanceInitResponse { + TrezorBalanceInitResponse({this.result, this.error}); + + factory TrezorBalanceInitResponse.fromJson(Map json) { + return TrezorBalanceInitResponse( + result: TrezorBalanceInitResult.fromJson(json['result']), + error: json['error'], + ); + } + + final TrezorBalanceInitResult? result; + final dynamic error; +} + +class TrezorBalanceInitResult { + TrezorBalanceInitResult({required this.taskId}); + + static TrezorBalanceInitResult? fromJson(Map? json) { + if (json == null) return null; + + return TrezorBalanceInitResult( + taskId: json['task_id'], + ); + } + + final int taskId; +} diff --git a/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_status/trezor_balance_status_request.dart b/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_status/trezor_balance_status_request.dart new file mode 100644 index 0000000000..1baced120d --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_status/trezor_balance_status_request.dart @@ -0,0 +1,18 @@ +class TrezorBalanceStatusRequest { + TrezorBalanceStatusRequest({required this.taskId}); + + static const String method = 'task::account_balance::status'; + late String userpass; + final int taskId; + + Map toJson() { + return { + 'userpass': userpass, + 'mmrpc': '2.0', + 'method': method, + 'params': { + 'task_id': taskId, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_status/trezor_balance_status_response.dart b/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_status/trezor_balance_status_response.dart new file mode 100644 index 0000000000..96186d99a1 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/balance/trezor_balance_status/trezor_balance_status_response.dart @@ -0,0 +1,70 @@ +import 'package:web_dex/model/hd_account/hd_account.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status.dart'; + +class TrezorBalanceStatusResponse { + TrezorBalanceStatusResponse({this.result, this.error}); + + static TrezorBalanceStatusResponse fromJson(Map json) { + return TrezorBalanceStatusResponse( + result: TrezorBalanceStatusResult.fromJson(json['result'])); + } + + final TrezorBalanceStatusResult? result; + final dynamic error; +} + +class TrezorBalanceStatusResult { + TrezorBalanceStatusResult({ + required this.status, + required this.balanceDetails, + }); + + static TrezorBalanceStatusResult? fromJson(Map? json) { + if (json == null) return null; + + final status = InitTrezorStatus.fromJson(json['status']); + return TrezorBalanceStatusResult( + status: status, + balanceDetails: status == InitTrezorStatus.ok + ? TrezorBalanceDetails.fromJson(json['details']) + : null, + ); + } + + final InitTrezorStatus status; + final TrezorBalanceDetails? balanceDetails; +} + +class TrezorBalanceDetails { + TrezorBalanceDetails({ + required this.totalBalance, + required this.accounts, + }); + + static TrezorBalanceDetails? fromJson(Map? json) { + if (json == null) return null; + + return TrezorBalanceDetails( + totalBalance: HdBalance.fromJson(json['total_balance']), + // Current api version (89c2b7050) only supports single (index == 0) + // HD account for every asset. + // But since trezor enable_utxo rpc returns list of accounts + // (with a single element in it), and also there is a possibility of + // adding multiple accounts support, we'll store list of accounts in our + // model, although trezor balance rpc returns data for first account only. + accounts: [ + HdAccount( + accountIndex: json['account_index'], + // Since we only support single account, its balance is the same + // as total asset balance + totalBalance: HdBalance.fromJson(json['total_balance']), + addresses: json['addresses'] + .map((dynamic item) => HdAddress.fromJson(item)) + .toList(), + ) + ]); + } + + final HdBalance? totalBalance; + final List accounts; +} diff --git a/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo/trezor_enable_utxo_request.dart b/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo/trezor_enable_utxo_request.dart new file mode 100644 index 0000000000..cde67a4749 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo/trezor_enable_utxo_request.dart @@ -0,0 +1,31 @@ +import 'package:web_dex/model/coin.dart'; + +class TrezorEnableUtxoReq { + TrezorEnableUtxoReq({required this.coin}); + + static const String method = 'task::enable_utxo::init'; + late String userpass; + final Coin coin; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'ticker': coin.abbr, + 'activation_params': { + 'tx_history': true, + 'mode': { + 'rpc': 'Electrum', + 'rpc_data': { + 'servers': coin.electrum, + }, + }, + 'scan_policy': 'scan_if_new_wallet', + 'priv_key_policy': 'Trezor' + } + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo/trezor_enable_utxo_response.dart b/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo/trezor_enable_utxo_response.dart new file mode 100644 index 0000000000..8979c2895c --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo/trezor_enable_utxo_response.dart @@ -0,0 +1,27 @@ +class TrezorEnableUtxoResponse { + TrezorEnableUtxoResponse({this.result, this.error}); + + factory TrezorEnableUtxoResponse.fromJson(Map json) { + return TrezorEnableUtxoResponse( + result: TrezorEnableUtxoResult.fromJson(json['result']), + error: json['error'], + ); + } + + final TrezorEnableUtxoResult? result; + final dynamic error; +} + +class TrezorEnableUtxoResult { + TrezorEnableUtxoResult({required this.taskId}); + + static TrezorEnableUtxoResult? fromJson(Map? json) { + if (json == null) return null; + + return TrezorEnableUtxoResult( + taskId: json['task_id'], + ); + } + + final int taskId; +} diff --git a/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo_status/trezor_enable_utxo_status_request.dart b/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo_status/trezor_enable_utxo_status_request.dart new file mode 100644 index 0000000000..7da90056f0 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo_status/trezor_enable_utxo_status_request.dart @@ -0,0 +1,18 @@ +class TrezorEnableUtxoStatusReq { + TrezorEnableUtxoStatusReq({required this.taskId}); + + static const String method = 'task::enable_utxo::status'; + late String userpass; + final int taskId; + + Map toJson() { + return { + 'userpass': userpass, + 'mmrpc': '2.0', + 'method': method, + 'params': { + 'task_id': taskId, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo_status/trezor_enable_utxo_status_response.dart b/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo_status/trezor_enable_utxo_status_response.dart new file mode 100644 index 0000000000..3e066bc00e --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/enable_utxo/trezor_enable_utxo_status/trezor_enable_utxo_status_response.dart @@ -0,0 +1,59 @@ +import 'package:web_dex/model/hd_account/hd_account.dart'; +import 'package:web_dex/model/hw_wallet/init_trezor.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status.dart'; + +class TrezorEnableUtxoStatusResponse { + TrezorEnableUtxoStatusResponse({this.result, this.error}); + + static TrezorEnableUtxoStatusResponse fromJson(Map json) { + return TrezorEnableUtxoStatusResponse( + result: TrezorEnableUtxoStatusResult.fromJson(json['result'])); + } + + final TrezorEnableUtxoStatusResult? result; + final dynamic error; +} + +class TrezorEnableUtxoStatusResult { + TrezorEnableUtxoStatusResult({ + required this.status, + this.details, + this.actionDetails, + }); + + static TrezorEnableUtxoStatusResult? fromJson(Map? json) { + if (json == null) return null; + + final InitTrezorStatus status = InitTrezorStatus.fromJson(json['status']); + return TrezorEnableUtxoStatusResult( + status: status, + details: status == InitTrezorStatus.ok + ? TrezorEnableDetails.fromJson(json['details']) + : null, + actionDetails: status == InitTrezorStatus.userActionRequired + ? TrezorUserAction.fromJson(json['details']) + : null); + } + + final InitTrezorStatus status; + final TrezorEnableDetails? details; + final TrezorUserAction? actionDetails; +} + +class TrezorEnableDetails { + TrezorEnableDetails({ + required this.accounts, + }); + + static TrezorEnableDetails? fromJson(Map? json) { + final Map? jsonData = json?['wallet_balance']; + if (jsonData == null) return null; + + return TrezorEnableDetails( + accounts: jsonData['accounts'] + .map((dynamic item) => HdAccount.fromJson(item)) + .toList()); + } + + final List accounts; +} diff --git a/lib/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_request.dart b/lib/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_request.dart new file mode 100644 index 0000000000..1455c10276 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_request.dart @@ -0,0 +1,59 @@ +class TrezorGetNewAddressInitReq { + TrezorGetNewAddressInitReq({required this.coin}); + + static const String method = 'task::get_new_address::init'; + late String userpass; + final String coin; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'coin': coin, + 'account_id': 0, + 'chain': 'External', + 'gap_limit': 20, + } + }; + } +} + +class TrezorGetNewAddressStatusReq { + TrezorGetNewAddressStatusReq({required this.taskId}); + + static const String method = 'task::get_new_address::status'; + final int taskId; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'task_id': taskId, + } + }; + } +} + +class TrezorGetNewAddressCancelReq { + TrezorGetNewAddressCancelReq({required this.taskId}); + + static const String method = 'task::get_new_address::cancel'; + final int taskId; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'task_id': taskId, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_response.dart b/lib/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_response.dart new file mode 100644 index 0000000000..08301540dc --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_response.dart @@ -0,0 +1,134 @@ +import 'package:web_dex/model/hd_account/hd_account.dart'; + +class TrezorGetNewAddressInitResponse { + TrezorGetNewAddressInitResponse({this.result, this.error}); + + factory TrezorGetNewAddressInitResponse.fromJson(Map json) { + return TrezorGetNewAddressInitResponse( + result: TrezorGetNewAddressInitResult.fromJson(json['result']), + error: json['error'], + ); + } + + final TrezorGetNewAddressInitResult? result; + final dynamic error; +} + +class TrezorGetNewAddressInitResult { + TrezorGetNewAddressInitResult({required this.taskId}); + + static TrezorGetNewAddressInitResult? fromJson(Map? json) { + if (json == null) return null; + + return TrezorGetNewAddressInitResult( + taskId: json['task_id'], + ); + } + + final int taskId; +} + +class GetNewAddressResponse { + GetNewAddressResponse({ + this.result, + this.error, + }); + + factory GetNewAddressResponse.fromJson(Map json) { + return GetNewAddressResponse( + result: GetNewAddressResult.fromJson(json['result']), + error: json['error'], + ); + } + + final String? error; + final GetNewAddressResult? result; +} + +class GetNewAddressResult { + GetNewAddressResult({required this.status, required this.details}); + + static GetNewAddressResult? fromJson(Map? json) { + if (json == null) return null; + final GetNewAddressStatus status = + GetNewAddressStatus.fromString(json['status']); + final GetNewAddressResultDetails? details = + _getDetails(status, json['details']); + return GetNewAddressResult( + status: status, + details: details, + ); + } + + final GetNewAddressStatus status; + final GetNewAddressResultDetails? details; +} + +GetNewAddressResultDetails? _getDetails( + GetNewAddressStatus status, + dynamic json, +) { + if (json == null) return null; + + switch (status) { + case GetNewAddressStatus.ok: + final Map? newAddressJson = json['new_address']; + if (newAddressJson == null) return null; + + return GetNewAddressResultOkDetails( + newAddress: HdAddress.fromJson(newAddressJson)); + case GetNewAddressStatus.inProgress: + if (json is! Map) return null; + final confirmAddressJson = json['ConfirmAddress']; + if (confirmAddressJson != null) { + return GetNewAddressResultConfirmAddressDetails( + expectedAddress: confirmAddressJson['expected_address']); + } + + final requestingAccountBalanceJson = json['RequestingAccountBalance']; + if (requestingAccountBalanceJson != null) { + return const GetNewAddressResultRequestingAccountBalanceDetails(); + } + return null; + case GetNewAddressStatus.unknown: + return null; + } +} + +enum GetNewAddressStatus { + ok, + inProgress, + unknown; + + factory GetNewAddressStatus.fromString(String status) { + switch (status) { + case 'Ok': + return GetNewAddressStatus.ok; + case 'InProgress': + return GetNewAddressStatus.inProgress; + } + return GetNewAddressStatus.unknown; + } +} + +abstract class GetNewAddressResultDetails { + const GetNewAddressResultDetails(); +} + +class GetNewAddressResultConfirmAddressDetails + extends GetNewAddressResultDetails { + const GetNewAddressResultConfirmAddressDetails( + {required this.expectedAddress}); + + final String expectedAddress; +} + +class GetNewAddressResultRequestingAccountBalanceDetails + extends GetNewAddressResultDetails { + const GetNewAddressResultRequestingAccountBalanceDetails(); +} + +class GetNewAddressResultOkDetails extends GetNewAddressResultDetails { + const GetNewAddressResultOkDetails({required this.newAddress}); + final HdAddress newAddress; +} diff --git a/lib/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_request.dart b/lib/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_request.dart new file mode 100644 index 0000000000..27fd31431a --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_request.dart @@ -0,0 +1,18 @@ +class InitTrezorReq { + InitTrezorReq({this.devicePubkey}); + + static const String method = 'task::init_trezor::init'; + final String? devicePubkey; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + if (devicePubkey != null) 'device_pubkey': devicePubkey, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_response.dart b/lib/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_response.dart new file mode 100644 index 0000000000..073b740a60 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/init/init_trezor/init_trezor_response.dart @@ -0,0 +1,20 @@ +import 'package:web_dex/model/hw_wallet/init_trezor.dart'; + +class InitTrezorRes { + InitTrezorRes({ + this.result, + this.error, + this.id, + }); + + factory InitTrezorRes.fromJson(Map json) { + return InitTrezorRes( + result: InitTrezorResult.fromJson(json['result']), + error: json['error'], + id: json['id']); + } + + final InitTrezorResult? result; + final String? error; + final String? id; +} diff --git a/lib/mm2/mm2_api/rpc/trezor/init/init_trezor_cancel/init_trezor_cancel_request.dart b/lib/mm2/mm2_api/rpc/trezor/init/init_trezor_cancel/init_trezor_cancel_request.dart new file mode 100644 index 0000000000..473c1d4d52 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/init/init_trezor_cancel/init_trezor_cancel_request.dart @@ -0,0 +1,18 @@ +class InitTrezorCancelReq { + InitTrezorCancelReq({required this.taskId}); + + static const String method = 'task::init_trezor::cancel'; + final int taskId; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'task_id': taskId, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_request.dart b/lib/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_request.dart new file mode 100644 index 0000000000..f5d43a198f --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_request.dart @@ -0,0 +1,18 @@ +class InitTrezorStatusReq { + InitTrezorStatusReq({required this.taskId}); + + static const String method = 'task::init_trezor::status'; + final int taskId; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'task_id': taskId, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_response.dart b/lib/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_response.dart new file mode 100644 index 0000000000..a6010a1cb5 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/init/init_trezor_status/init_trezor_status_response.dart @@ -0,0 +1,23 @@ +import 'package:web_dex/model/hw_wallet/init_trezor.dart'; + +class InitTrezorStatusRes { + InitTrezorStatusRes({ + this.result, + this.error, + this.errorType, + this.id, + }); + + factory InitTrezorStatusRes.fromJson(Map json) { + return InitTrezorStatusRes( + result: InitTrezorStatusData.fromJson(json['result']), + error: json['error'], + errorType: json['error_type'], + id: json['id']); + } + + final InitTrezorStatusData? result; + final String? error; + final String? errorType; + final String? id; +} diff --git a/lib/mm2/mm2_api/rpc/trezor/trezor_connection_status/trezor_connection_status_request.dart b/lib/mm2/mm2_api/rpc/trezor/trezor_connection_status/trezor_connection_status_request.dart new file mode 100644 index 0000000000..f0f1ee789c --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/trezor_connection_status/trezor_connection_status_request.dart @@ -0,0 +1,20 @@ +class TrezorConnectionStatusRequest { + TrezorConnectionStatusRequest({ + required this.pubKey, + }); + + static const String method = 'trezor_connection_status'; + late String userpass; + final String pubKey; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'device_pubkey': pubKey, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/trezor_passphrase/trezor_passphrase_request.dart b/lib/mm2/mm2_api/rpc/trezor/trezor_passphrase/trezor_passphrase_request.dart new file mode 100644 index 0000000000..c9c4677864 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/trezor_passphrase/trezor_passphrase_request.dart @@ -0,0 +1,27 @@ +import 'package:web_dex/model/hw_wallet/trezor_task.dart'; + +class TrezorPassphraseRequest { + TrezorPassphraseRequest({required this.passphrase, required this.task}); + + final String passphrase; + final TrezorTask task; + late String userpass; + + String get method => 'task::${task.type.name}::user_action'; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'task_id': task.taskId, + 'user_action': { + 'action_type': 'TrezorPassphrase', + 'passphrase': passphrase, + } + }, + 'id': null + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/trezor_pin/trezor_pin_request.dart b/lib/mm2/mm2_api/rpc/trezor/trezor_pin/trezor_pin_request.dart new file mode 100644 index 0000000000..ee976bd51f --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/trezor_pin/trezor_pin_request.dart @@ -0,0 +1,27 @@ +import 'package:web_dex/model/hw_wallet/trezor_task.dart'; + +class TrezorPinRequest { + TrezorPinRequest({required this.pin, required this.task}); + + final String pin; + final TrezorTask task; + late String userpass; + + String get method => 'task::${task.type.name}::user_action'; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'task_id': task.taskId, + 'user_action': { + 'action_type': 'TrezorPin', + 'pin': pin, + } + }, + 'id': null + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_request.dart b/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_request.dart new file mode 100644 index 0000000000..da17977a17 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_request.dart @@ -0,0 +1,40 @@ +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/fee/fee_request.dart'; +import 'package:web_dex/model/coin.dart'; + +class TrezorWithdrawRequest { + TrezorWithdrawRequest({ + required this.coin, + required this.from, + required this.to, + required this.amount, + this.max = false, + this.fee, + }); + + static const String method = 'task::withdraw::init'; + late String userpass; + final Coin coin; + final String to; + final String from; + final double amount; + final bool max; + final FeeRequest? fee; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'coin': coin.abbr, + 'from': { + 'derivation_path': coin.getDerivationPath(from), + }, + 'to': to, + 'amount': amount, + 'max': max, + if (fee != null) 'fee': fee!.toJson(), + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_response.dart b/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_response.dart new file mode 100644 index 0000000000..97975fb058 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw/trezor_withdraw_response.dart @@ -0,0 +1,25 @@ +class TrezorWithdrawResponse { + TrezorWithdrawResponse({this.result, this.error}); + + factory TrezorWithdrawResponse.fromJson(Map json) { + return TrezorWithdrawResponse( + result: TrezorWithdrawResult.fromJson(json['result']), + error: json['error'], + ); + } + + String? error; + TrezorWithdrawResult? result; +} + +class TrezorWithdrawResult { + TrezorWithdrawResult({required this.taskId}); + + static TrezorWithdrawResult? fromJson(Map? json) { + if (json == null) return null; + + return TrezorWithdrawResult(taskId: json['task_id']); + } + + final int taskId; +} diff --git a/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_cancel/trezor_withdraw_cancel_request.dart b/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_cancel/trezor_withdraw_cancel_request.dart new file mode 100644 index 0000000000..fe5849415d --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_cancel/trezor_withdraw_cancel_request.dart @@ -0,0 +1,18 @@ +class TrezorWithdrawCancelRequest { + TrezorWithdrawCancelRequest({required this.taskId}); + + static const String method = 'task::withdraw::cancel'; + final int taskId; + late String userpass; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'task_id': taskId, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_status/trezor_withdraw_status_request.dart b/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_status/trezor_withdraw_status_request.dart new file mode 100644 index 0000000000..89497ede44 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_status/trezor_withdraw_status_request.dart @@ -0,0 +1,18 @@ +class TrezorWithdrawStatusRequest { + TrezorWithdrawStatusRequest({required this.taskId}); + + static const String method = 'task::withdraw::status'; + late String userpass; + final int taskId; + + Map toJson() { + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'task_id': taskId, + } + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_status/trezor_withdraw_status_response.dart b/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_status/trezor_withdraw_status_response.dart new file mode 100644 index 0000000000..66fc533a0f --- /dev/null +++ b/lib/mm2/mm2_api/rpc/trezor/withdraw/trezor_withdraw_status/trezor_withdraw_status_response.dart @@ -0,0 +1,56 @@ +import 'package:web_dex/model/hw_wallet/init_trezor.dart'; +import 'package:web_dex/model/hw_wallet/trezor_progress_status.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status_error.dart'; +import 'package:web_dex/model/withdraw_details/withdraw_details.dart'; + +class TrezorWithdrawStatusResponse { + TrezorWithdrawStatusResponse({this.result, this.error}); + + factory TrezorWithdrawStatusResponse.fromJson(Map json) { + return TrezorWithdrawStatusResponse( + result: TrezorWithdrawStatusResult.fromJson(json['result']), + error: json['error']); + } + + final TrezorWithdrawStatusResult? result; + final String? error; +} + +class TrezorWithdrawStatusResult { + TrezorWithdrawStatusResult({ + required this.status, + this.details, + this.progressDetails, + this.actionDetails, + this.errorDetails, + }); + + final InitTrezorStatus status; + final WithdrawDetails? details; + final TrezorProgressStatus? progressDetails; + final TrezorUserAction? actionDetails; + final TrezorStatusError? errorDetails; + + static TrezorWithdrawStatusResult? fromJson(Map? json) { + if (json == null) return null; + + final InitTrezorStatus status = InitTrezorStatus.fromJson(json['status']); + + return TrezorWithdrawStatusResult( + status: status, + details: status == InitTrezorStatus.ok + ? WithdrawDetails.fromTrezorJson(json['details']) + : null, + progressDetails: status == InitTrezorStatus.inProgress + ? TrezorProgressStatus.fromJson(json['details']) + : null, + actionDetails: status == InitTrezorStatus.userActionRequired + ? TrezorUserAction.fromJson(json['details']) + : null, + errorDetails: status == InitTrezorStatus.error + ? TrezorStatusError.fromJson(json['details']) + : null, + ); + } +} diff --git a/lib/mm2/mm2_api/rpc/validateaddress/validateaddress_request.dart b/lib/mm2/mm2_api/rpc/validateaddress/validateaddress_request.dart new file mode 100644 index 0000000000..604430e015 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/validateaddress/validateaddress_request.dart @@ -0,0 +1,26 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class ValidateAddressRequest implements BaseRequest { + ValidateAddressRequest({ + required this.coin, + required this.address, + }); + + @override + final String method = 'validateaddress'; + String address; + String coin; + + @override + late String userpass; + + @override + Map toJson() { + return { + 'method': method, + 'coin': coin, + 'userpass': userpass, + 'address': address, + }; + } +} diff --git a/lib/mm2/mm2_api/rpc/validateaddress/validateaddress_response.dart b/lib/mm2/mm2_api/rpc/validateaddress/validateaddress_response.dart new file mode 100644 index 0000000000..a31b973dc6 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/validateaddress/validateaddress_response.dart @@ -0,0 +1,11 @@ +class ValidateAddressResponse { + ValidateAddressResponse({required this.isValid, this.reason}); + + factory ValidateAddressResponse.fromJson(Map response) { + return ValidateAddressResponse( + isValid: response['result']?['is_valid'] ?? false, + reason: response['result']?['reason']); + } + final bool isValid; + final String? reason; +} diff --git a/lib/mm2/mm2_api/rpc/version/version_request.dart b/lib/mm2/mm2_api/rpc/version/version_request.dart new file mode 100644 index 0000000000..252d49b706 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/version/version_request.dart @@ -0,0 +1,15 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class VersionRequest implements BaseRequest { + @override + late String userpass; + + @override + String method = 'version'; + + @override + Map toJson() => { + 'method': method, + 'userpass': userpass, + }; +} diff --git a/lib/mm2/mm2_api/rpc/version/version_response.dart b/lib/mm2/mm2_api/rpc/version/version_response.dart new file mode 100644 index 0000000000..ba5f224f17 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/version/version_response.dart @@ -0,0 +1,9 @@ +class VersionResponse { + const VersionResponse({required this.result}); + factory VersionResponse.fromJson(Map response) { + return VersionResponse( + result: response['result'] ?? '', + ); + } + final String result; +} diff --git a/lib/mm2/mm2_api/rpc/withdraw/fee/fee_request.dart b/lib/mm2/mm2_api/rpc/withdraw/fee/fee_request.dart new file mode 100644 index 0000000000..75743a49a1 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/withdraw/fee/fee_request.dart @@ -0,0 +1,49 @@ +class FeeRequest { + FeeRequest({ + required this.type, + this.amount, + this.gasPrice, + this.gasLimit, + this.gas, + }); + factory FeeRequest.fromJson(Map json) => FeeRequest( + type: json['type'], + amount: json['amount'], + gasPrice: json['gas_price'], + gasLimit: json['gas_limit'], + gas: json['gas'], + ); + + /// type of transaction fee. + /// Possible values:[UtxoFixed, UtxoPerKbyte, EthGas, CosmosGas, Qrc20Gas] + String type; + + /// fee amount in coin units, + /// used only when type is [UtxoFixed] (fixed amount not depending on tx size) + /// or [UtxoPerKbyte] (amount per Kbyte). + String? amount; + + /// used only when fee type is [EthGas], [QrcGas] or [CosmosGas]. + /// Sets the gas price in `gwei` units + dynamic gasPrice; + + /// used only when fee type is [EthGas]. Sets the gas limit for transaction + int? gas; + + /// used only when fee type is [CosmosGas] or [QrcGas]. + /// Sets the gas limit for transaction + int? gasLimit; + + String getGasLimitAmount() => gas == null ? '' : gas.toString(); + dynamic getGasPrice() => gasPrice == null ? '' : gasPrice!; + String getGasLimit() => gasLimit == null ? '' : gasLimit.toString(); + String getFeeAmount() => amount == null ? '' : amount!; + + Map toJson() => { + 'type': type, + 'amount': amount, + 'gas_price': gasPrice, + 'gas': gas, + 'gas_limit': gasLimit, + }; +} diff --git a/lib/mm2/mm2_api/rpc/withdraw/withdraw_errors.dart b/lib/mm2/mm2_api/rpc/withdraw/withdraw_errors.dart new file mode 100644 index 0000000000..1f02ed4dc9 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/withdraw/withdraw_errors.dart @@ -0,0 +1,269 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.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'; + +abstract class ErrorNeedsSetCoinAbbr { + void setCoinAbbr(String coinAbbr); +} + +class WithdrawNotSufficientBalanceError implements BaseError { + WithdrawNotSufficientBalanceError({ + required String coin, + required String availableAmount, + required String requiredAmount, + }) : _coin = coin, + _availableAmount = availableAmount, + _requiredAmount = requiredAmount; + factory WithdrawNotSufficientBalanceError.fromJson( + Map json) { + return WithdrawNotSufficientBalanceError( + coin: json['error_data']['coin'], + availableAmount: json['error_data']['available'], + requiredAmount: json['error_data']['required'], + ); + } + + String _coin; + String _availableAmount; + String _requiredAmount; + + static const String type = 'NotSufficientBalance'; + + @override + String get message { + return LocaleKeys.withdrawNotSufficientBalanceError + .tr(args: [_coin, _availableAmount, _requiredAmount]); + } +} + +class WithdrawZeroBalanceToWithdrawMaxError + implements BaseError, ErrorNeedsSetCoinAbbr { + WithdrawZeroBalanceToWithdrawMaxError(); + factory WithdrawZeroBalanceToWithdrawMaxError.fromJson( + Map json) => + WithdrawZeroBalanceToWithdrawMaxError(); + + late String _coin; + + static const String type = 'ZeroBalanceToWithdrawMax'; + + @override + String get message { + return LocaleKeys.withdrawZeroBalanceError.tr(args: [_coin]); + } + + @override + void setCoinAbbr(String coinAbbr) { + _coin = coinAbbr; + } +} + +class WithdrawAmountTooLowError implements BaseError, ErrorNeedsSetCoinAbbr { + WithdrawAmountTooLowError({ + required String amount, + required String threshold, + }) : _amount = amount, + _threshold = threshold; + + factory WithdrawAmountTooLowError.fromJson(Map json) => + WithdrawAmountTooLowError( + amount: json['error_data']['amount'], + threshold: json['error_data']['threshold'], + ); + + static const String type = 'AmountTooLow'; + late String _coin; + String _amount; + String _threshold; + + @override + String get message { + return LocaleKeys.withdrawAmountTooLowError + .tr(args: [_amount, _coin, _threshold, _coin]); + } + + @override + void setCoinAbbr(String coinAbbr) { + _coin = coinAbbr; + } +} + +class WithdrawInvalidAddressError implements BaseError { + WithdrawInvalidAddressError({ + required String error, + }) : _error = error; + + factory WithdrawInvalidAddressError.fromJson(Map json) => + WithdrawInvalidAddressError( + error: json['error'], + ); + + static const String type = 'InvalidAddress'; + String _error; + + @override + String get message { + return _error; + } +} + +class WithdrawInvalidFeePolicyError implements BaseError { + WithdrawInvalidFeePolicyError({ + required String error, + }) : _error = error; + factory WithdrawInvalidFeePolicyError.fromJson(Map json) => + WithdrawInvalidFeePolicyError( + error: json['error'], + ); + + String _error; + static const String type = 'InvalidFeePolicy'; + + @override + String get message { + return _error; + } +} + +class WithdrawNoSuchCoinError implements BaseError { + WithdrawNoSuchCoinError({required String coin}) : _coin = coin; + + factory WithdrawNoSuchCoinError.fromJson(Map json) => + WithdrawNoSuchCoinError( + coin: json['error_data']['coin'], + ); + + String _coin; + + static const String type = 'NoSuchCoin'; + + @override + String get message { + return LocaleKeys.withdrawNoSuchCoinError.tr(args: [_coin]); + } +} + +class WithdrawTransportError + with ErrorWithDetails + implements BaseError, ErrorNeedsSetCoinAbbr { + WithdrawTransportError({ + required String error, + }) : _error = error; + + factory WithdrawTransportError.fromJson(Map json) { + return WithdrawTransportError( + error: json['error'] ?? '', + ); + } + + String _error; + late String _feeCoin; + + static const String type = 'Transport'; + + @override + String get message { + if (isGasPaymentError && _feeCoin.isNotEmpty) { + return '${LocaleKeys.withdrawNotEnoughBalanceForGasError.tr(args: [ + _feeCoin + ])}.'; + } + + if (_error.isNotEmpty && + _error.contains('insufficient funds for transfer') && + _feeCoin.isNotEmpty) { + return LocaleKeys.withdrawNotEnoughBalanceForGasError + .tr(args: [_feeCoin]); + } + + return LocaleKeys.somethingWrong.tr(); + } + + bool get isGasPaymentError { + return _error.isNotEmpty && + (_error.contains('gas required exceeds allowance') || + _error.contains('insufficient funds for transfer')); + } + + @override + String get details { + if (isGasPaymentError) { + return ''; + } + return _error; + } + + @override + void setCoinAbbr(String coinAbbr) { + final Coin? coin = coinsBloc.getCoin(coinAbbr); + if (coin == null) { + return; + } + final String? platform = coin.protocolData?.platform; + + _feeCoin = platform ?? coinAbbr; + } +} + +class WithdrawInternalError with ErrorWithDetails implements BaseError { + WithdrawInternalError({ + required String error, + }) : _error = error; + + factory WithdrawInternalError.fromJson(Map json) => + WithdrawInternalError( + error: json['error'], + ); + + String _error; + + static const String type = 'InternalError'; + + @override + String get message { + return LocaleKeys.somethingWrong.tr(); + } + + @override + String get details { + return _error; + } +} + +class WithdrawErrorFactory implements ErrorFactory { + @override + BaseError getError(Map json, String coinAbbr) { + final BaseError error = _parseError(json); + if (error is ErrorNeedsSetCoinAbbr) { + (error as ErrorNeedsSetCoinAbbr).setCoinAbbr(coinAbbr); + } + return error; + } + + BaseError _parseError(Map json) { + switch (json['error_type']) { + case WithdrawNotSufficientBalanceError.type: + return WithdrawNotSufficientBalanceError.fromJson(json); + case WithdrawZeroBalanceToWithdrawMaxError.type: + return WithdrawZeroBalanceToWithdrawMaxError.fromJson(json); + case WithdrawAmountTooLowError.type: + return WithdrawAmountTooLowError.fromJson(json); + case WithdrawInvalidAddressError.type: + return WithdrawInvalidAddressError.fromJson(json); + case WithdrawInvalidFeePolicyError.type: + return WithdrawInvalidFeePolicyError.fromJson(json); + case WithdrawNoSuchCoinError.type: + return WithdrawNoSuchCoinError.fromJson(json); + case WithdrawTransportError.type: + return WithdrawTransportError.fromJson(json); + case WithdrawInternalError.type: + return WithdrawInternalError.fromJson(json); + } + return TextError(error: LocaleKeys.somethingWrong.tr()); + } +} + +WithdrawErrorFactory withdrawErrorFactory = WithdrawErrorFactory(); diff --git a/lib/mm2/mm2_api/rpc/withdraw/withdraw_request.dart b/lib/mm2/mm2_api/rpc/withdraw/withdraw_request.dart new file mode 100644 index 0000000000..3871809456 --- /dev/null +++ b/lib/mm2/mm2_api/rpc/withdraw/withdraw_request.dart @@ -0,0 +1,66 @@ +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/withdraw/fee/fee_request.dart'; + +class WithdrawRequestParams { + WithdrawRequestParams({ + required this.coin, + required this.to, + this.amount, + this.max, + this.memo, + this.fee, + }); + + String? amount; + String coin; + String to; + bool? max; + String? memo; + FeeRequest? fee; +} + +class WithdrawRequest + implements BaseRequest, BaseRequestWithParams { + WithdrawRequest({ + String? amount, + required String to, + required String coin, + required bool max, + String? memo, + FeeRequest? fee, + }) : params = WithdrawRequestParams( + amount: amount, + to: to, + coin: coin, + max: max, + fee: fee, + memo: memo, + ); + + @override + final String method = 'withdraw'; + @override + final WithdrawRequestParams params; + @override + late String userpass; + + @override + Map toJson() { + final FeeRequest? fee = params.fee; + + return { + 'method': method, + 'userpass': userpass, + 'mmrpc': mmRpcVersion, + 'params': { + 'to': params.to, + 'max': params.max ?? false, + 'coin': params.coin, + if (params.memo != null) 'memo': params.memo, + if (params.amount != null) 'amount': params.amount, + if (fee != null) 'fee': fee.toJson(), + }, + }; + } +} diff --git a/lib/mm2/mm2_ios.dart b/lib/mm2/mm2_ios.dart new file mode 100644 index 0000000000..73039a43a2 --- /dev/null +++ b/lib/mm2/mm2_ios.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/mm2/rpc.dart'; +import 'package:web_dex/mm2/rpc_native.dart'; +import 'package:web_dex/services/logger/get_logger.dart'; +import 'package:web_dex/services/native_channel.dart'; + +class MM2iOS extends MM2 implements MM2WithInit { + final RPC _rpc = RPCNative(); + + @override + Future start(String? passphrase) async { + final Directory dir = await getApplicationDocumentsDirectory(); + final String filesPath = '${dir.path}/'; + final Map params = await MM2.generateStartParams( + passphrase: passphrase, + gui: 'web_dex iOs', + userHome: filesPath, + dbDir: filesPath, + ); + + final int errorCode = await nativeChannel.invokeMethod( + 'start', {'params': jsonEncode(params)}); + + await logger.write('MM2 start response: $errorCode'); + } + + @override + Future stop() async { + final int errorCode = await nativeChannel.invokeMethod('stop'); + + await logger.write('MM2 sop response: $errorCode'); + } + + @override + Future status() async { + return MM2Status.fromInt( + await nativeChannel.invokeMethod('status')); + } + + @override + Future call(dynamic reqStr) async { + return await _rpc.call(MM2.prepareRequest(reqStr)); + } + + @override + Future init() async { + await _subscribeOnEvents(); + } + + Future _subscribeOnEvents() async { + nativeEventChannel.receiveBroadcastStream().listen((event) async { + Map eventJson; + try { + eventJson = jsonDecode(event); + } catch (e) { + logger.write('Error decoding MM2 event: $e'); + return; + } + + if (eventJson['type'] == 'log') { + await logger.write(eventJson['message']); + } else if (eventJson['type'] == 'app_did_become_active') { + if (!await isLive()) await _restartMM2AndCoins(); + } + }); + } + + Future _restartMM2AndCoins() async { + await nativeChannel.invokeMethod('restart'); + await coinsBloc.reactivateAll(); + } +} diff --git a/lib/mm2/mm2_linux.dart b/lib/mm2/mm2_linux.dart new file mode 100644 index 0000000000..c038150e51 --- /dev/null +++ b/lib/mm2/mm2_linux.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/rpc.dart'; +import 'package:web_dex/mm2/rpc_native.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class MM2Linux extends MM2 { + final RPC _rpc = RPCNative(); + Process? _mm2Process; + + @override + Future start(String? passphrase) async { + await _killMM2Process(); + + final String mainPath = _mainPath; + final Map params = await MM2.generateStartParams( + passphrase: passphrase, + gui: 'web_dex linux', + userHome: mainPath, + dbDir: '$mainPath/DB', + ); + + final mm2Config = await _createMM2ConfigFile(mainPath, params); + + if (mm2Config == null) { + return; + } + + _mm2Process = await Process.start( + _exePath, + [], + environment: { + 'MM_CONF_PATH': mm2Config.path, + }, + ); + + await waitMM2StatusChange(MM2Status.rpcIsUp, mm2, waitingTime: 60000); + mm2Config.deleteSync(recursive: true); + + _mm2Process?.stdout.transform(utf8.decoder).listen((data) async { + log( + data, + path: 'mm2 log', + isError: false, + ); + }); + _mm2Process?.stderr.transform(utf8.decoder).listen((event) async { + log( + event, + path: 'mm2 error log', + isError: true, + ); + }); + } + + @override + Future stop() async { + await mm2Api.stop(); + await _killMM2Process(); + } + + @override + Future status() async { + try { + final status = await version(); + return status.isNotEmpty ? MM2Status.rpcIsUp : MM2Status.isNotRunningYet; + } catch (_) { + return MM2Status.isNotRunningYet; + } + } + + Future _createMM2ConfigFile( + String dir, Map params) async { + try { + final File mm2Config = File('$dir/MM2.json')..createSync(recursive: true); + await mm2Config.writeAsString(jsonEncode(params)); + return mm2Config; + } catch (e) { + log('mm2 linux error mm2 config file not created: ${e.toString()}'); + return null; + } + } + + String get _mainPath => Platform.resolvedExecutable + .substring(0, Platform.resolvedExecutable.lastIndexOf("/")); + + String get _exePath => path.join(_mainPath, 'mm2'); + + Future _killMM2Process() async { + _mm2Process?.kill(); + await Process.run('pkill', ['-f', 'mm2']); + } + + @override + Future call(dynamic reqStr) async { + return await _rpc.call(MM2.prepareRequest(reqStr)); + } +} diff --git a/lib/mm2/mm2_macos.dart b/lib/mm2/mm2_macos.dart new file mode 100644 index 0000000000..cd6873b1f5 --- /dev/null +++ b/lib/mm2/mm2_macos.dart @@ -0,0 +1,64 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/mm2/rpc.dart'; +import 'package:web_dex/mm2/rpc_native.dart'; +import 'package:web_dex/services/logger/get_logger.dart'; +import 'package:web_dex/services/native_channel.dart'; + +class MM2MacOs extends MM2 implements MM2WithInit { + final RPC _rpc = RPCNative(); + + @override + Future start(String? passphrase) async { + final Directory dir = await getApplicationDocumentsDirectory(); + final String filesPath = '${dir.path}/'; + final Map params = await MM2.generateStartParams( + passphrase: passphrase, + gui: 'web_dex macOs', + userHome: filesPath, + dbDir: filesPath, + ); + + final int errorCode = await nativeChannel.invokeMethod( + 'start', {'params': jsonEncode(params)}); + + if (kDebugMode) { + print('MM2 start response:$errorCode'); + } + } + + @override + Future stop() async { + final int errorCode = await nativeChannel.invokeMethod('stop'); + + await logger.write('MM2 sop response: $errorCode'); + } + + @override + Future status() async { + return MM2Status.fromInt( + await nativeChannel.invokeMethod('status')); + } + + @override + Future call(dynamic reqStr) async { + return await _rpc.call(MM2.prepareRequest(reqStr)); + } + + @override + Future init() async { + await _subscribeOnLogs(); + } + + Future _subscribeOnLogs() async { + nativeEventChannel.receiveBroadcastStream().listen((log) async { + if (log is String) { + await logger.write(log); + } + }); + } +} diff --git a/lib/mm2/mm2_web.dart b/lib/mm2/mm2_web.dart new file mode 100644 index 0000000000..a6753981ad --- /dev/null +++ b/lib/mm2/mm2_web.dart @@ -0,0 +1,77 @@ +import 'dart:convert'; + +// ignore: unnecessary_import +import 'package:universal_html/js.dart'; +import 'package:universal_html/js_util.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/mm2/rpc_web.dart'; +import 'package:web_dex/platform/platform.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class MM2Web extends MM2 implements MM2WithInit { + final RPCWeb _rpc = const RPCWeb(); + + @override + Future init() async { + // TODO! Test for breaking changes to mm2 initialisation accross reloads + while (isBusyPreloading == true) { + await Future.delayed(const Duration(milliseconds: 10)); + // TODO: Safe guard for max retries + } + + if (isPreloaded == false) { + await promiseToFuture(initWasm()); + } + } + + /// TODO: Document + bool? get isBusyPreloading => context['is_mm2_preload_busy'] as bool?; + + /// TODO: Document + bool? get isPreloaded => context['is_mm2_preloaded'] as bool?; + + @override + Future start(String? passphrase) async { + final Map params = await MM2.generateStartParams( + passphrase: passphrase, + gui: 'web_dex web', + dbDir: null, + userHome: null, + ); + + await promiseToFuture( + wasmRunMm2( + jsonEncode(params), + allowInterop Function(int level, String message)>( + _handleLog, + ), + ), + ); + } + + @override + Future stop() async { + // todo: consider using FFI instead of RPC here + await mm2Api.stop(); + } + + @override + Future version() async { + return wasmVersion(); + } + + @override + Future status() async { + return MM2Status.fromInt(wasmMm2Status()); + } + + @override + Future call(dynamic reqStr) async { + return await _rpc.call(MM2.prepareRequest(reqStr)); + } + + Future _handleLog(int level, String message) async { + log(message, path: 'mm2 log'); + } +} diff --git a/lib/mm2/mm2_windows.dart b/lib/mm2/mm2_windows.dart new file mode 100644 index 0000000000..3cd0e6e4a4 --- /dev/null +++ b/lib/mm2/mm2_windows.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:path/path.dart' as path; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/rpc.dart'; +import 'package:web_dex/mm2/rpc_native.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class MM2Windows extends MM2 { + final RPC _rpc = RPCNative(); + Process? _mm2Process; + + @override + Future start(String? passphrase) async { + await _killMM2Process(); + + final String mainPath = _mainPath; + final Map params = await MM2.generateStartParams( + gui: 'web_dex windows', + passphrase: passphrase, + userHome: mainPath, + dbDir: '$mainPath/DB', + ); + + final mm2Config = await _createMM2ConfigFile(mainPath, params); + + if (mm2Config == null) { + return; + } + + _mm2Process = await Process.start( + _exePath, + [], + environment: { + 'MM_CONF_PATH': mm2Config.path, + }, + ); + + _mm2Process?.stdout.transform(utf8.decoder).listen((data) async { + log( + data, + path: 'mm2 log', + isError: false, + ); + }); + _mm2Process?.stderr.transform(utf8.decoder).listen((event) async { + log( + event, + path: 'mm2 error log', + isError: true, + ); + }); + _mm2Process?.exitCode.then((exitCode) async { + log('mm2 exit code: $exitCode'); + }); + + await waitMM2StatusChange(MM2Status.rpcIsUp, mm2, waitingTime: 60000); + mm2Config.deleteSync(recursive: true); + } + + @override + Future status() async { + try { + final status = await version(); + return status.isNotEmpty ? MM2Status.rpcIsUp : MM2Status.isNotRunningYet; + } catch (_) { + return MM2Status.isNotRunningYet; + } + } + + @override + Future stop() async { + await mm2Api.stop(); + await _killMM2Process(); + } + + Future _createMM2ConfigFile( + String dir, Map params) async { + try { + final File mm2Config = File('$dir/MM2.json')..createSync(recursive: true); + await mm2Config.writeAsString(jsonEncode(params)); + return mm2Config; + } catch (e) { + log('mm2 windows error mm2 config file not created: ${e.toString()}'); + return null; + } + } + + String get _mainPath => Platform.resolvedExecutable + .substring(0, Platform.resolvedExecutable.lastIndexOf("\\")); + + String get _exePath => + path.join(path.dirname(Platform.resolvedExecutable), 'mm2.exe'); + + Future _killMM2Process() async { + _mm2Process?.kill(); + await Process.run('taskkill', ['/F', '/IM', 'mm2.exe', '/T']); + } + + @override + Future call(dynamic reqStr) async { + return await _rpc.call(MM2.prepareRequest(reqStr)); + } +} diff --git a/lib/mm2/nft_api_interface.dart b/lib/mm2/nft_api_interface.dart new file mode 100644 index 0000000000..0e7397f6b2 --- /dev/null +++ b/lib/mm2/nft_api_interface.dart @@ -0,0 +1,6 @@ +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_request.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; + +abstract class NftApi { + Future getNftTransactions(NftTransactionsRequest request); +} diff --git a/lib/mm2/rpc.dart b/lib/mm2/rpc.dart new file mode 100644 index 0000000000..064c2379b4 --- /dev/null +++ b/lib/mm2/rpc.dart @@ -0,0 +1,4 @@ +abstract class RPC { + const RPC(); + Future call(String reqStr); +} diff --git a/lib/mm2/rpc/nft_transaction/nft_transactions_request.dart b/lib/mm2/rpc/nft_transaction/nft_transactions_request.dart new file mode 100644 index 0000000000..4476ded99e --- /dev/null +++ b/lib/mm2/rpc/nft_transaction/nft_transactions_request.dart @@ -0,0 +1,37 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class NftTransactionsRequest implements BaseRequest { + NftTransactionsRequest({ + required this.chains, + required this.max, + }); + @override + late String userpass; + @override + final String method = 'get_nft_transfers'; + final List chains; + final bool max; + + @override + Map toJson() => { + 'method': method, + 'userpass': userpass, + 'mmrpc': '2.0', + 'params': { + 'chains': chains, + 'max': max, + "protect_from_spam": true, + "filters": {"exclude_spam": true, "exclude_phishing": true} + }, + }; +} + +class NftTxDetailsRequest { + final String chain; + final String txHash; + + NftTxDetailsRequest({ + required this.chain, + required this.txHash, + }); +} diff --git a/lib/mm2/rpc/nft_transaction/nft_transactions_response.dart b/lib/mm2/rpc/nft_transaction/nft_transactions_response.dart new file mode 100644 index 0000000000..766e46216d --- /dev/null +++ b/lib/mm2/rpc/nft_transaction/nft_transactions_response.dart @@ -0,0 +1,192 @@ +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/model/withdraw_details/fee_details.dart'; + +class NftTxsResponse { + NftTxsResponse({ + required this.transactions, + this.errorMessage, + }); + + factory NftTxsResponse.fromJson(Map json) { + final List transferHistory = json['result']['transfer_history']; + final result = + transferHistory.map((e) => NftTransaction.fromJson(e)).toList(); + return NftTxsResponse( + transactions: result, + ); + } + + final List transactions; + final String? errorMessage; +} + +enum NftTxnDetailsStatus { + initial, + success, + failure, +} + +class NftTransaction { + NftTransaction({ + required this.chain, + required this.blockNumber, + this.confirmations, + this.feeDetails, + required this.blockTimestamp, + required this.blockHash, + required this.transactionHash, + required this.transactionIndex, + required this.logIndex, + required this.value, + required this.contractType, + required this.transactionType, + required this.tokenAddress, + required this.tokenId, + required this.fromAddress, + required this.toAddress, + required this.amount, + required this.verified, + this.operator, + this.collectionName, + this.image, + this.tokenName, + this.status, + required this.possibleSpam, + }); + + final NftBlockchains chain; + final int blockNumber; + int? confirmations; + FeeDetails? feeDetails; + final DateTime blockTimestamp; + final String? blockHash; + final String transactionHash; + final int? transactionIndex; + final int? logIndex; + final String? value; + final String contractType; + final String? transactionType; + final String tokenAddress; + final String tokenId; + final String? collectionName; + final String? image; + final String? tokenName; + final String fromAddress; + final String toAddress; + final String amount; + final NftTransactionStatuses? status; + final bool verified; + final String? operator; + final bool possibleSpam; + NftTxnDetailsStatus _detailsFetchStatus = NftTxnDetailsStatus.initial; + + String getTxKey() { + return '$tokenId-$transactionHash'; + } + + factory NftTransaction.fromJson(Map json) { + return NftTransaction( + chain: NftBlockchains.fromApiResponse(json['chain'] as String), + blockNumber: json['block_number'], + blockTimestamp: + DateTime.fromMillisecondsSinceEpoch(json['block_timestamp'] * 1000), + blockHash: json['block_hash'], + confirmations: json['confirmations'], + feeDetails: json['fee_details'] != null + ? FeeDetails.fromJson(json['fee_details']) + : null, + transactionHash: json['transaction_hash'], + transactionIndex: json['transaction_index'], + logIndex: json['log_index'], + value: json['value'], + contractType: json['contract_type'], + transactionType: json['transaction_type'], + tokenAddress: json['token_address'], + tokenId: json['token_id'], + fromAddress: json['from_address'], + toAddress: json['to_address'], + status: NftTransactionStatuses.fromApi(json['status']), + collectionName: json['collection_name'] as String?, + image: json['image_url'] as String?, + tokenName: json['token_name'] as String?, + amount: json['amount'], + verified: json['verified'] == 1, + operator: json['operator'], + possibleSpam: json['possible_spam'], + ); + } + + NftTransaction copyWithProxyInfo(Map json) { + final confirmations = json['confirmations'] ?? 0; + final feeDetails = json['fee_details'] != null + ? FeeDetails.fromJson(json['fee_details']) + : FeeDetails.empty(); + return NftTransaction( + chain: chain, + blockNumber: blockNumber, + blockTimestamp: blockTimestamp, + blockHash: blockHash, + confirmations: confirmations, + feeDetails: feeDetails, + transactionHash: transactionHash, + transactionIndex: transactionIndex, + logIndex: logIndex, + value: value, + contractType: contractType, + transactionType: transactionType, + tokenAddress: tokenAddress, + tokenId: tokenId, + fromAddress: fromAddress, + toAddress: toAddress, + status: status, + collectionName: collectionName, + image: image, + tokenName: tokenName, + amount: amount, + verified: verified, + operator: operator, + possibleSpam: possibleSpam, + ); + } + + bool get containsAdditionalInfo => + feeDetails != null && confirmations != null; + + String get name => tokenName ?? tokenId; + String? get imageUrl { + if (image == null) return null; + // Image.network does not support ipfs + return image?.replaceFirst('ipfs://', 'https://ipfs.io/ipfs/'); + } + + void setDetailsStatus(NftTxnDetailsStatus value) { + _detailsFetchStatus = value; + } + + NftTxnDetailsStatus get detailsFetchStatus => _detailsFetchStatus; +} + +enum NftTransactionStatuses { + receive, + send; + + @override + String toString() { + switch (this) { + case NftTransactionStatuses.receive: + return 'Receive'; + case NftTransactionStatuses.send: + return 'Send'; + } + } + + static NftTransactionStatuses? fromApi(String? status) { + switch (status) { + case 'Receive': + return NftTransactionStatuses.receive; + case 'Send': + return NftTransactionStatuses.send; + } + return null; + } +} diff --git a/lib/mm2/rpc_native.dart b/lib/mm2/rpc_native.dart new file mode 100644 index 0000000000..70d56fc979 --- /dev/null +++ b/lib/mm2/rpc_native.dart @@ -0,0 +1,16 @@ +import 'package:http/http.dart'; +import 'package:web_dex/mm2/rpc.dart'; + +class RPCNative extends RPC { + RPCNative(); + + final Uri _url = Uri.parse('http://localhost:7783'); + final Client client = Client(); + + @override + Future call(String reqStr) async { + // todo: implement error handling + final Response response = await client.post(_url, body: reqStr); + return response.body; + } +} diff --git a/lib/mm2/rpc_web.dart b/lib/mm2/rpc_web.dart new file mode 100644 index 0000000000..39113924a2 --- /dev/null +++ b/lib/mm2/rpc_web.dart @@ -0,0 +1,13 @@ +import 'package:universal_html/js_util.dart'; +import 'package:web_dex/mm2/rpc.dart'; +import 'package:web_dex/platform/platform.dart'; + +class RPCWeb extends RPC { + const RPCWeb(); + + @override + Future call(String reqStr) async { + final dynamic response = await promiseToFuture(wasmRpc(reqStr)); + return response; + } +} diff --git a/lib/model/authorize_mode.dart b/lib/model/authorize_mode.dart new file mode 100644 index 0000000000..fbba460b0b --- /dev/null +++ b/lib/model/authorize_mode.dart @@ -0,0 +1 @@ +enum AuthorizeMode { noLogin, logIn, hiddenLogin } diff --git a/lib/model/available_balance_state.dart b/lib/model/available_balance_state.dart new file mode 100644 index 0000000000..9056ae99d6 --- /dev/null +++ b/lib/model/available_balance_state.dart @@ -0,0 +1,7 @@ +enum AvailableBalanceState { + initial, + loading, + success, + failure, + unavailable, +} diff --git a/lib/model/cex_price.dart b/lib/model/cex_price.dart new file mode 100644 index 0000000000..8130765226 --- /dev/null +++ b/lib/model/cex_price.dart @@ -0,0 +1,55 @@ +import 'package:collection/collection.dart'; +import 'package:equatable/equatable.dart'; + +class CexPrice extends Equatable { + const CexPrice({ + required this.ticker, + required this.price, + this.lastUpdated, + this.priceProvider, + this.change24h, + this.changeProvider, + this.volume24h, + this.volumeProvider, + }); + + final String ticker; + final double price; + final DateTime? lastUpdated; + final CexDataProvider? priceProvider; + final double? volume24h; + final CexDataProvider? volumeProvider; + final double? change24h; + final CexDataProvider? changeProvider; + + @override + String toString() { + return 'CexPrice(ticker: $ticker, price: $price)'; + } + + @override + List get props => [ + ticker, + price, + lastUpdated, + priceProvider, + volume24h, + volumeProvider, + change24h, + changeProvider, + ]; +} + +enum CexDataProvider { + binance, + coingecko, + coinpaprika, + nomics, + unknown, +} + +CexDataProvider cexDataProvider(String string) { + return CexDataProvider.values + .firstWhereOrNull((e) => e.toString().split('.').last == string) ?? + CexDataProvider.unknown; +} diff --git a/lib/model/coin.dart b/lib/model/coin.dart new file mode 100644 index 0000000000..bb836d59f8 --- /dev/null +++ b/lib/model/coin.dart @@ -0,0 +1,587 @@ +import 'package:collection/collection.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/model/cex_price.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/coin_utils.dart'; +import 'package:web_dex/model/electrum.dart'; +import 'package:web_dex/model/hd_account/hd_account.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class Coin { + Coin({ + required this.type, + required this.abbr, + required this.name, + required this.explorerUrl, + required this.explorerTxUrl, + required this.explorerAddressUrl, + required this.protocolType, + required this.protocolData, + required this.isTestCoin, + required this.coingeckoId, + required this.fallbackSwapContract, + required this.electrum, + required this.nodes, + required this.rpcUrls, + required this.bchdUrls, + required this.priority, + required this.state, + this.decimals = 8, + this.parentCoin, + this.trezorCoin, + this.derivationPath, + this.accounts, + this.usdPrice, + this.coinpaprikaId, + this.activeByDefault = false, + required String? swapContractAddress, + required bool walletOnly, + required this.mode, + }) : _swapContractAddress = swapContractAddress, + _walletOnly = walletOnly; + + factory Coin.fromJson( + Map json, + Map globalCoinJson, + ) { + final List electrumList = _getElectrumFromJson(json); + final List nodesList = _getNodesFromJson(json); + final List bchdUrls = _getBchdUrlsFromJson(json); + final List rpcUrls = _getRpcUrlsFromJson(json); + final String explorerUrl = _getExplorerFromJson(json); + final String explorerTxUrl = _getExplorerTxUrlFromJson(json); + final String explorerAddressUrl = _getExplorerAddressUrlFromJson(json); + + final String? jsonType = json['type']; + final String coinAbbr = json['abbr']; + final CoinType? type = getCoinType(jsonType, coinAbbr); + if (type == null) { + throw ArgumentError.value(jsonType, 'json[\'type\']'); + } + // The code below is commented out because of the latest changes + // to coins config to include "offline" coins so that the user can + // see the coins fail to activate instead of disappearing from the + // We should still figure out if there is a new criteria instead of + // blindly parsing the JSON as-is. + // if (type != CoinType.slp) { + // assert( + // electrumList.isNotEmpty || + // nodesList.isNotEmpty || + // rpcUrls.isNotEmpty || + // bchdUrls.isNotEmpty, + // 'The ${json['abbr']} doesn\'t have electrum, nodes and rpc_urls', + // ); + // } + + return Coin( + type: type, + abbr: coinAbbr, + coingeckoId: json['coingecko_id'], + coinpaprikaId: json['coinpaprika_id'], + name: json['name'], + electrum: electrumList, + nodes: nodesList, + rpcUrls: rpcUrls, + bchdUrls: bchdUrls, + swapContractAddress: json['swap_contract_address'], + fallbackSwapContract: json['fallback_swap_contract'], + activeByDefault: json['active'] ?? false, + explorerUrl: explorerUrl, + explorerTxUrl: explorerTxUrl, + explorerAddressUrl: explorerAddressUrl, + protocolType: _getProtocolType(globalCoinJson), + protocolData: _parseProtocolData(globalCoinJson), + isTestCoin: json['is_testnet'] ?? false, + walletOnly: json['wallet_only'] ?? false, + trezorCoin: globalCoinJson['trezor_coin'], + derivationPath: globalCoinJson['derivation_path'], + decimals: json['decimals'] ?? 8, + priority: json['priority'], + mode: _getCoinMode(json), + state: CoinState.inactive, + ); + } + + final String abbr; + final String name; + final String? coingeckoId; + final String? coinpaprikaId; + final List electrum; + final List nodes; + final List bchdUrls; + final List rpcUrls; + final CoinType type; + final bool activeByDefault; + final String protocolType; + final ProtocolData? protocolData; + final String explorerUrl; + final String explorerTxUrl; + final String explorerAddressUrl; + final String? trezorCoin; + final String? derivationPath; + final int decimals; + CexPrice? usdPrice; + final bool isTestCoin; + String? address; + List? accounts; + double _balance = 0; + String? _swapContractAddress; + String? fallbackSwapContract; + WalletType? enabledType; + bool _walletOnly; + final int priority; + Coin? parentCoin; + final CoinMode mode; + CoinState state; + + bool get walletOnly => _walletOnly || appWalletOnlyAssetList.contains(abbr); + + Map toJson() { + return { + 'coin': abbr, + 'name': name, + 'coingecko_id': coingeckoId, + 'coinpaprika_id': coinpaprikaId, + 'electrum': electrum.map((Electrum e) => e.toJson()).toList(), + 'nodes': nodes.map((CoinNode n) => n.toJson()).toList(), + 'rpc_urls': rpcUrls.map((CoinNode n) => n.toJson()).toList(), + 'bchd_urls': bchdUrls, + 'type': getCoinTypeName(type), + 'active': activeByDefault, + 'protocol': { + 'type': protocolType, + 'protocol_data': protocolData?.toJson(), + }, + 'is_testnet': isTestCoin, + 'wallet_only': walletOnly, + 'trezor_coin': trezorCoin, + 'derivation_path': derivationPath, + 'decimals': decimals, + 'priority': priority, + 'mode': mode.toString(), + 'state': state.toString(), + 'swap_contract_address': _swapContractAddress, + 'fallback_swap_contract': fallbackSwapContract, + }; + } + + String? get swapContractAddress => + _swapContractAddress ?? parentCoin?.swapContractAddress; + bool get isSuspended => state == CoinState.suspended; + bool get isActive => state == CoinState.active; + bool get isActivating => state == CoinState.activating; + bool get isInactive => state == CoinState.inactive; + + double sendableBalance = 0; + + double get balance { + switch (enabledType) { + case WalletType.trezor: + return _totalHdBalance ?? 0.0; + default: + return _balance; + } + } + + set balance(double value) { + switch (enabledType) { + case WalletType.trezor: + log('Warning: Trying to set $abbr balance,' + ' while it was activated in ${enabledType!.name} mode. Ignoring.'); + break; + default: + _balance = value; + } + } + + double? get _totalHdBalance { + if (accounts == null) return null; + + double? totalBalance; + for (HdAccount account in accounts!) { + double accountBalance = 0.0; + for (HdAddress address in account.addresses) { + accountBalance += address.balance.spendable; + } + totalBalance = (totalBalance ?? 0.0) + accountBalance; + } + + return totalBalance; + } + + double? get usdBalance { + if (usdPrice == null) return null; + if (balance == 0) return 0; + + return balance.toDouble() * (usdPrice?.price.toDouble() ?? 0.00); + } + + String get getFormattedUsdBalance => + usdBalance == null ? '\$0.00' : '\$${formatAmt(usdBalance!)}'; + + String get typeName => getCoinTypeName(type); + String get typeNameWithTestnet => typeName + (isTestCoin ? ' (TESTNET)' : ''); + + bool get isIrisToken => protocolType == 'TENDERMINTTOKEN'; + + bool get need0xPrefixForTxHash => isErcType; + + bool get isErcType => protocolType == 'ERC20' || protocolType == 'ETH'; + + bool get isTxMemoSupported => + type == CoinType.iris || type == CoinType.cosmos; + + String? get defaultAddress { + switch (enabledType) { + case WalletType.trezor: + return _defaultTrezorAddress; + default: + return address; + } + } + + bool get isCustomFeeSupported { + return type != CoinType.iris && type != CoinType.cosmos; + } + + bool get hasFaucet => coinsWithFaucet.contains(abbr); + + bool get hasTrezorSupport { + if (trezorCoin == null) return false; + if (excludedAssetListTrezor.contains(abbr)) return false; + if (checkSegwitByAbbr(abbr)) return false; + if (type == CoinType.utxo) return true; + if (type == CoinType.smartChain) return true; + + return false; + } + + String? get _defaultTrezorAddress { + if (enabledType != WalletType.trezor) return null; + if (accounts == null) return null; + if (accounts!.isEmpty) return null; + if (accounts!.first.addresses.isEmpty) return null; + + return accounts!.first.addresses.first.address; + } + + List nonEmptyHdAddresses() { + final List? allAddresses = accounts?.first.addresses; + if (allAddresses == null) return []; + + final List nonEmpty = List.from(allAddresses); + nonEmpty.removeWhere((hdAddress) => hdAddress.balance.spendable <= 0); + return nonEmpty; + } + + String? getDerivationPath(String address) { + final HdAddress? hdAddress = getHdAddress(address); + return hdAddress?.derivationPath; + } + + HdAddress? getHdAddress(String? address) { + if (address == null) return null; + if (enabledType == WalletType.iguana) return null; + if (accounts == null || accounts!.isEmpty) return null; + + final List addresses = accounts!.first.addresses; + if (address.isEmpty) return null; + + return addresses.firstWhereOrNull( + (HdAddress hdAddress) => hdAddress.address == address); + } + + static bool checkSegwitByAbbr(String abbr) => abbr.contains('-segwit'); + static String normalizeAbbr(String abbr) => abbr.replaceAll('-segwit', ''); + + @override + String toString() { + return 'Coin($abbr);'; + } + + void reset() { + balance = 0; + enabledType = null; + accounts = null; + state = CoinState.inactive; + } + + Coin dummyCopyWithoutProtocolData() { + return Coin( + type: type, + abbr: abbr, + name: name, + explorerUrl: explorerUrl, + explorerTxUrl: explorerTxUrl, + explorerAddressUrl: explorerAddressUrl, + protocolType: protocolType, + isTestCoin: isTestCoin, + coingeckoId: coingeckoId, + fallbackSwapContract: fallbackSwapContract, + electrum: electrum, + nodes: nodes, + rpcUrls: rpcUrls, + bchdUrls: bchdUrls, + priority: priority, + state: state, + swapContractAddress: swapContractAddress, + walletOnly: walletOnly, + mode: mode, + usdPrice: usdPrice, + parentCoin: parentCoin, + trezorCoin: trezorCoin, + derivationPath: derivationPath, + accounts: accounts, + coinpaprikaId: coinpaprikaId, + activeByDefault: activeByDefault, + protocolData: null, + ); + } +} + +String _getExplorerFromJson(Map json) { + return json['explorer_url'] ?? ''; +} + +String _getExplorerAddressUrlFromJson(Map json) { + final url = json['explorer_address_url']; + if (url == null || url.isEmpty) { + return 'address/'; + } + return url; +} + +String _getExplorerTxUrlFromJson(Map json) { + final String? url = json['explorer_tx_url']; + if (url == null || url.isEmpty) { + return 'tx/'; + } + return url; +} + +List _getNodesFromJson(Map json) { + final dynamic nodes = json['nodes']; + if (nodes is List) { + return nodes.map((dynamic n) => CoinNode.fromJson(n)).toList(); + } + + return []; +} + +List _getRpcUrlsFromJson(Map json) { + final dynamic rpcUrls = json['rpc_urls']; + if (rpcUrls is List) { + return rpcUrls.map((dynamic n) => CoinNode.fromJson(n)).toList(); + } + + return []; +} + +List _getBchdUrlsFromJson(Map json) { + final dynamic urls = json['bchd_urls']; + if (urls is List) { + return List.from(urls); + } + + return []; +} + +List _getElectrumFromJson(Map json) { + final dynamic electrum = json['electrum']; + if (electrum is List) { + return electrum + .map((dynamic item) => Electrum.fromJson(item)) + .toList(); + } + + return []; +} + +String _getProtocolType(Map coin) { + return coin['protocol']['type']; +} + +ProtocolData? _parseProtocolData(Map json) { + final Map? protocolData = json['protocol']['protocol_data']; + + if (protocolData == null || + protocolData['platform'] == null || + (protocolData['contract_address'] == null && + protocolData['platform'] != 'BCH' && + protocolData['platform'] != 'tBCH' && + protocolData['platform'] != 'IRIS')) return null; + return ProtocolData.fromJson(protocolData); +} + +CoinType? getCoinType(String? jsonType, String coinAbbr) { + // anchor: protocols support + for (CoinType value in CoinType.values) { + switch (value) { + case CoinType.utxo: + if (jsonType == 'UTXO') { + return value; + } else { + continue; + } + case CoinType.smartChain: + if (jsonType == 'Smart Chain') { + return value; + } else { + continue; + } + case CoinType.erc20: + if (jsonType == 'ERC-20') { + return value; + } else { + continue; + } + case CoinType.bep20: + if (jsonType == 'BEP-20') { + return value; + } else { + continue; + } + case CoinType.qrc20: + if (jsonType == 'QRC-20') { + return value; + } else { + continue; + } + case CoinType.ftm20: + if (jsonType == 'FTM-20') { + return value; + } else { + continue; + } + case CoinType.etc: + if (jsonType == 'Ethereum Classic') { + return value; + } else { + continue; + } + case CoinType.avx20: + if (jsonType == 'AVX-20') { + return value; + } else { + continue; + } + case CoinType.mvr20: + if (jsonType == 'Moonriver') { + return value; + } else { + continue; + } + case CoinType.hco20: + if (jsonType == 'HecoChain') { + return value; + } else { + continue; + } + case CoinType.plg20: + if (jsonType == 'Matic') { + return value; + } else { + continue; + } + case CoinType.sbch: + if (jsonType == 'SmartBCH') { + return value; + } else { + continue; + } + case CoinType.ubiq: + if (jsonType == 'Ubiq') { + return value; + } else { + continue; + } + case CoinType.hrc20: + if (jsonType == 'HRC-20') { + return value; + } else { + continue; + } + case CoinType.krc20: + if (jsonType == 'KRC-20') { + return value; + } else { + continue; + } + case CoinType.cosmos: + if (jsonType == 'TENDERMINT' && coinAbbr != 'IRIS') { + return value; + } else { + continue; + } + case CoinType.iris: + if (jsonType == 'TENDERMINTTOKEN' || coinAbbr == 'IRIS') { + return value; + } else { + continue; + } + case CoinType.slp: + if (jsonType == 'SLP') { + return value; + } else { + continue; + } + } + } + return null; +} + +CoinMode _getCoinMode(Map json) { + if ((json['abbr'] as String).contains('-segwit')) { + return CoinMode.segwit; + } + return CoinMode.standard; +} + +class ProtocolData { + ProtocolData({ + required this.platform, + required this.contractAddress, + }); + + factory ProtocolData.fromJson(Map json) => ProtocolData( + platform: json['platform'], + contractAddress: json['contract_address'] ?? '', + ); + + String platform; + String contractAddress; + + Map toJson() { + return { + 'platform': platform, + 'contract_address': contractAddress, + }; + } +} + +class CoinNode { + const CoinNode({required this.url, required this.guiAuth}); + static CoinNode fromJson(Map json) => CoinNode( + url: json['url'], + guiAuth: (json['gui_auth'] ?? json['komodo_proxy']) ?? false); + final bool guiAuth; + final String url; + + Map toJson() => { + 'url': url, + 'gui_auth': guiAuth, + 'komodo_proxy': guiAuth, + }; +} + +enum CoinMode { segwit, standard, hw } + +enum CoinState { + inactive, + activating, + active, + suspended, + hidden, +} diff --git a/lib/model/coin_type.dart b/lib/model/coin_type.dart new file mode 100644 index 0000000000..8dd1f359f0 --- /dev/null +++ b/lib/model/coin_type.dart @@ -0,0 +1,21 @@ +// anchor: protocols support +enum CoinType { + utxo, + smartChain, + etc, + erc20, + bep20, + qrc20, + ftm20, + avx20, + hrc20, + mvr20, + hco20, + plg20, + sbch, + ubiq, + krc20, + cosmos, + iris, + slp, +} diff --git a/lib/model/coin_utils.dart b/lib/model/coin_utils.dart new file mode 100644 index 0000000000..0cbdf9e67e --- /dev/null +++ b/lib/model/coin_utils.dart @@ -0,0 +1,202 @@ +import 'package:collection/collection.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/orderbook_depth/orderbook_depth_response.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/typedef.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +List sortFiatBalance(List coins) { + final List list = List.from(coins); + list.sort((a, b) { + final double usdBalanceA = a.usdBalance ?? 0.00; + final double usdBalanceB = b.usdBalance ?? 0.00; + if (usdBalanceA > usdBalanceB) return -1; + if (usdBalanceA < usdBalanceB) return 1; + + if (a.balance > b.balance) return -1; + if (a.balance < b.balance) return 1; + + final bool isAEnabled = a.isActive; + final bool isBEnabled = b.isActive; + if (isAEnabled && !isBEnabled) return -1; + if (isBEnabled && !isAEnabled) return 1; + + return a.abbr.compareTo(b.abbr); + }); + return list; +} + +List removeWalletOnly(List coins) { + final List list = List.from(coins); + + list.removeWhere((Coin coin) => coin.walletOnly); + + return list; +} + +List removeSuspended(List coins) { + if (!coinsBloc.isLoggedIn) return coins; + final List list = List.from(coins); + + list.removeWhere((Coin coin) => coin.isSuspended); + + return list; +} + +Map> removeSingleProtocol(Map> group) { + final Map> copy = Map>.from(group); + copy.removeWhere((key, value) => value.length == 1); + return copy; +} + +CoinsByTicker removeTokensWithEmptyOrderbook( + CoinsByTicker tokenGroups, List depths) { + final CoinsByTicker copy = CoinsByTicker.from(tokenGroups); + + copy.removeWhere((key, value) { + return value.every((coin) { + final depth = depths.firstWhereOrNull((depth) { + final String source = depth.source.abbr; + final String target = depth.target.abbr; + + return (source == coin.abbr || target == coin.abbr) && + (abbr2Ticker(source) == abbr2Ticker(target)); + }); + + return depth == null; + }); + }); + + return copy; +} + +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; + }, + ); +} + +bool isCoinInList(Coin coin, List list) { + return list.firstWhereOrNull((element) => element.abbr == coin.abbr) != null; +} + +Iterable filterCoinsByPhrase(Iterable coins, String phrase) { + if (phrase.isEmpty) return coins; + return coins.where((Coin coin) => compareCoinByPhrase(coin, phrase)); +} + +bool compareCoinByPhrase(Coin coin, String phrase) { + final String compareName = coin.name.toLowerCase(); + final String compareAbbr = abbr2Ticker(coin.abbr).toLowerCase(); + final lowerCasePhrase = phrase.toLowerCase(); + + if (lowerCasePhrase.isEmpty) return false; + return compareName.contains(lowerCasePhrase) || + compareAbbr.contains(lowerCasePhrase); +} + +String getCoinTypeName(CoinType type) { + switch (type) { + case CoinType.erc20: + return 'ERC-20'; + case CoinType.bep20: + return 'BEP-20'; + case CoinType.qrc20: + return 'QRC-20'; + case CoinType.utxo: + return 'Native'; + case CoinType.smartChain: + return 'Smart Chain'; + case CoinType.ftm20: + return 'FTM-20'; + case CoinType.etc: + return 'ETC'; + case CoinType.avx20: + return 'AVX-20'; + case CoinType.hrc20: + return 'HRC-20'; + case CoinType.mvr20: + return 'MVR-20'; + case CoinType.hco20: + return 'HCO-20'; + case CoinType.plg20: + return 'PLG-20'; + case CoinType.sbch: + return 'SmartBCH'; + case CoinType.ubiq: + return 'Ubiq'; + case CoinType.krc20: + return 'KRC-20'; + case CoinType.cosmos: + return 'Cosmos'; + case CoinType.iris: + return 'Iris'; + case CoinType.slp: + return 'SLP'; + } +} + +String getCoinTypeNameLong(CoinType type) { + switch (type) { + case CoinType.erc20: + return 'Ethereum (ERC-20)'; + case CoinType.bep20: + return 'Binance (BEP-20)'; + case CoinType.qrc20: + return 'QTUM (QRC-20)'; + case CoinType.utxo: + return 'Native'; + case CoinType.smartChain: + return 'Smart Chain'; + case CoinType.ftm20: + return 'Fantom'; + case CoinType.etc: + return 'Ethereum Classic'; + case CoinType.avx20: + return 'Avalanche'; + case CoinType.hrc20: + return 'Harmony (HRC-20)'; + case CoinType.mvr20: + return 'Moonriver'; + case CoinType.hco20: + return 'HecoChain'; + case CoinType.plg20: + return 'Polygon'; + case CoinType.sbch: + return 'SmartBCH'; + case CoinType.ubiq: + return 'Ubiq'; + case CoinType.krc20: + return 'Kucoin Chain'; + case CoinType.cosmos: + return 'Cosmos'; + case CoinType.iris: + return 'Iris'; + case CoinType.slp: + return 'SLP'; + } +} + +Iterable sortByPriority(Iterable list) { + final sortedList = List.from(list); + sortedList.sort((a, b) { + final int priorityA = a.priority; + final int priorityB = b.priority; + + return priorityB - priorityA; + }); + return sortedList; +} diff --git a/lib/model/data_from_service.dart b/lib/model/data_from_service.dart new file mode 100644 index 0000000000..8427cd3d06 --- /dev/null +++ b/lib/model/data_from_service.dart @@ -0,0 +1,6 @@ +class DataFromService { + DataFromService({this.data, this.error}) + : assert(data != null || error != null); + final R? data; + final E? error; +} diff --git a/lib/model/dex_form_error.dart b/lib/model/dex_form_error.dart new file mode 100644 index 0000000000..f45c088efc --- /dev/null +++ b/lib/model/dex_form_error.dart @@ -0,0 +1,30 @@ +import 'package:uuid/uuid.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/views/dex/simple/form/error_list/dex_form_error_with_action.dart'; + +class DexFormError implements TextError { + DexFormError({ + required this.error, + this.type = DexFormErrorType.simple, + this.isWarning = false, + this.action, + }) : id = const Uuid().v4(); + + final DexFormErrorType type; + final bool isWarning; + final String id; + final DexFormErrorAction? action; + + @override + final String error; + + @override + String get message => error; +} + +enum DexFormErrorType { + simple, + largerMaxSellVolume, + largerMaxBuyVolume, + lessMinVolume, +} diff --git a/lib/model/dex_list_type.dart b/lib/model/dex_list_type.dart new file mode 100644 index 0000000000..6d10eef5bb --- /dev/null +++ b/lib/model/dex_list_type.dart @@ -0,0 +1,43 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/bloc/dex_tab_bar/dex_tab_bar_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/market_maker_bot/tab_type_enum.dart'; + +/// The order in this enum is important. +/// When you rearrange the elements, the order of the tabs must change. +/// Remember to change the initial tab +enum DexListType implements TabTypeEnum { + swap, + inProgress, + orders, + history; + + @override + String name(DexTabBarBloc bloc) { + switch (this) { + case swap: + return LocaleKeys.swap.tr(); + case orders: + return '${LocaleKeys.orders.tr()}${isMobile ? '' : ' (${bloc.ordersCount})'}'; + case inProgress: + return '${LocaleKeys.inProgress.tr()}${isMobile ? '' : ' (${bloc.inProgressCount})'}'; + case history: + return '${LocaleKeys.history.tr()}${isMobile ? '' : ' (${bloc.completedCount})'}'; + } + } + + @override + String get key { + switch (this) { + case swap: + return 'dex-swap-tab'; + case orders: + return 'dex-orders-tab'; + case inProgress: + return 'dex-in-progress-tab'; + case history: + return 'dex-history-tab'; + } + } +} diff --git a/lib/model/electrum.dart b/lib/model/electrum.dart new file mode 100644 index 0000000000..79ef283c9b --- /dev/null +++ b/lib/model/electrum.dart @@ -0,0 +1,29 @@ +import 'package:flutter/foundation.dart'; + +class Electrum { + Electrum({ + required this.url, + required this.protocol, + required this.disableCertVerification, + }); + + factory Electrum.fromJson(Map json) { + return Electrum( + url: kIsWeb ? json['ws_url'] : json['url'], + protocol: kIsWeb ? 'WSS' : (json['protocol'] ?? 'TCP'), + disableCertVerification: json['disable_cert_verification'] ?? false, + ); + } + + final String url; + final String protocol; + final bool disableCertVerification; + + Map toJson() { + return { + 'url': url, + 'protocol': protocol, + 'disable_cert_verification': disableCertVerification, + }; + } +} diff --git a/lib/model/fee_type.dart b/lib/model/fee_type.dart new file mode 100644 index 0000000000..1f31e88943 --- /dev/null +++ b/lib/model/fee_type.dart @@ -0,0 +1,8 @@ +class FeeType { + final String utxoFixed = 'UtxoFixed'; + final String utxoPerKbyte = 'UtxoPerKbyte'; + final String ethGas = 'EthGas'; + final String cosmosGas = 'CosmosGas'; +} + +FeeType feeType = FeeType(); diff --git a/lib/model/feedback_data.dart b/lib/model/feedback_data.dart new file mode 100644 index 0000000000..ce5a6bef56 --- /dev/null +++ b/lib/model/feedback_data.dart @@ -0,0 +1,8 @@ +class FeedbackData { + FeedbackData({ + required this.email, + required this.message, + }); + final String email; + final String message; +} diff --git a/lib/model/feedback_request.dart b/lib/model/feedback_request.dart new file mode 100644 index 0000000000..9f67d4ea30 --- /dev/null +++ b/lib/model/feedback_request.dart @@ -0,0 +1,14 @@ +class FeedbackRequest { + const FeedbackRequest({ + required this.email, + required this.message, + }); + + final String email; + final String message; + + Map toJson() => { + 'email': email, + 'message': message, + }; +} diff --git a/lib/model/first_uri_segment.dart b/lib/model/first_uri_segment.dart new file mode 100644 index 0000000000..fa79f6c690 --- /dev/null +++ b/lib/model/first_uri_segment.dart @@ -0,0 +1,16 @@ +class FirstUriSegment { + FirstUriSegment(); + + final String wallet = 'wallet'; + final String fiat = 'fiat'; + final String dex = 'dex'; + final String bridge = 'bridge'; + final String nfts = 'nfts'; + final String walletManager = 'wallet-manager'; + final String marketMakerBot = 'trading-bot'; + final String orders = 'orders'; + final String seed = 'seed'; + final String settings = 'settings'; +} + +final FirstUriSegment firstUriSegment = FirstUriSegment(); diff --git a/lib/model/forms/coin_select_input.dart b/lib/model/forms/coin_select_input.dart new file mode 100644 index 0000000000..f70ebbaa5e --- /dev/null +++ b/lib/model/forms/coin_select_input.dart @@ -0,0 +1,61 @@ +import 'package:formz/formz.dart'; +import 'package:web_dex/model/coin.dart'; + +/// Validation errors for the coin selection form field. +enum CoinSelectValidationError { + /// Input is empty + empty, + + /// The coin has not been activated in the users wallet yet + inactive, + + /// Selected coin does not have enough available balance + insufficientBalance, + + /// The parent coin is suspended, so this coin cannot be used. + /// E.g. If BNB is suspended, then all BEP2 tokens are suspended. + parentSuspended, + + // The available balance is not enough to cover the gas fee + insufficientGasBalance, +} + +/// Formz input for selecting a coin. +class CoinSelectInput extends FormzInput { + const CoinSelectInput.pure({this.minBalance = 0, this.minGasBalance = 0}) + : super.pure(null); + const CoinSelectInput.dirty([ + Coin? value, + this.minBalance = 0, + this.minGasBalance = 0, + ]) : super.dirty(value); + + final double minBalance; + final double minGasBalance; + + @override + CoinSelectValidationError? validator(Coin? value) { + if (value == null) { + return CoinSelectValidationError.empty; + } + + if (!value.isActive) { + return CoinSelectValidationError.inactive; + } + + if (value.balance <= minBalance) { + return CoinSelectValidationError.insufficientBalance; + } + + final parentCoin = value.parentCoin; + if (parentCoin != null && parentCoin.isSuspended) { + return CoinSelectValidationError.parentSuspended; + } + + if (parentCoin != null && parentCoin.balance < minGasBalance) { + return CoinSelectValidationError.insufficientBalance; + } + + return null; + } +} diff --git a/lib/model/forms/coin_trade_amount_input.dart b/lib/model/forms/coin_trade_amount_input.dart new file mode 100644 index 0000000000..edc90a4e2e --- /dev/null +++ b/lib/model/forms/coin_trade_amount_input.dart @@ -0,0 +1,59 @@ +import 'package:formz/formz.dart'; +import 'package:rational/rational.dart'; + +enum AmountValidationError { + /// Input is empty + empty, + + /// Not a valid number + invalid, + + /// Number is greater than the available balance + moreThanMaximum, + + /// Number is less than the minimum required amount. Defaults to 0. + lessThanMinimum, +} + +class CoinTradeAmountInput extends FormzInput { + const CoinTradeAmountInput.pure([ + String value = '0', + this.minAmount = 0, + this.maxAmount = double.infinity, + ]) : super.pure(value); + const CoinTradeAmountInput.dirty([ + String value = '', + this.minAmount = 0, + this.maxAmount = double.infinity, + ]) : super.dirty(value); + + final double minAmount; + final double maxAmount; + + Rational get valueAsRational { + final amount = double.tryParse(value) ?? 0; + return Rational.parse(amount.toString()); + } + + @override + AmountValidationError? validator(String value) { + if (value.isEmpty) { + return AmountValidationError.empty; + } + + final amount = double.tryParse(value); + if (amount == null) { + return AmountValidationError.invalid; + } + + if (amount < minAmount) { + return AmountValidationError.lessThanMinimum; + } + + if (amount > maxAmount) { + return AmountValidationError.moreThanMaximum; + } + + return null; + } +} diff --git a/lib/model/forms/trade_margin_input.dart b/lib/model/forms/trade_margin_input.dart new file mode 100644 index 0000000000..348f3ef5de --- /dev/null +++ b/lib/model/forms/trade_margin_input.dart @@ -0,0 +1,49 @@ +import 'package:formz/formz.dart'; + +enum TradeMarginValidationError { + /// Input is empty + empty, + + /// Not a valid number + invalidNumber, + + /// Number is negative + lessThanMinimum, + + /// Number is greater than 100 + greaterThanMaximum, +} + +class TradeMarginInput extends FormzInput { + final double min; + final double max; + + const TradeMarginInput.pure({this.min = 0, this.max = 1000}) + : super.pure('3'); + const TradeMarginInput.dirty(String value, {this.min = 0, this.max = 1000}) + : super.dirty(value); + + double get valueAsDouble => double.tryParse(value) ?? 0; + + @override + TradeMarginValidationError? validator(String value) { + if (value.isEmpty) { + return TradeMarginValidationError.empty; + } + + final margin = double.tryParse(value); + if (margin == null) { + return TradeMarginValidationError.invalidNumber; + } + + if (margin <= min) { + return TradeMarginValidationError.lessThanMinimum; + } + + if (margin > max) { + return TradeMarginValidationError.greaterThanMaximum; + } + + return null; + } +} diff --git a/lib/model/forms/trade_volume_input.dart b/lib/model/forms/trade_volume_input.dart new file mode 100644 index 0000000000..7cba65e619 --- /dev/null +++ b/lib/model/forms/trade_volume_input.dart @@ -0,0 +1,20 @@ +import 'package:formz/formz.dart'; + +enum TradeVolumeValidationError { + /// The percentage is invalid + invalidPercentage, +} + +/// Formz input for the trade volume limit. +class TradeVolumeInput extends FormzInput { + const TradeVolumeInput.pure(double value) : super.pure(value); + const TradeVolumeInput.dirty(double value) : super.dirty(value); + + @override + TradeVolumeValidationError? validator(double value) { + if (value < 0.0 || value > 1.0) { + return TradeVolumeValidationError.invalidPercentage; + } + return null; + } +} diff --git a/lib/model/forms/update_interval_input.dart b/lib/model/forms/update_interval_input.dart new file mode 100644 index 0000000000..b6a8bbe50a --- /dev/null +++ b/lib/model/forms/update_interval_input.dart @@ -0,0 +1,47 @@ +import 'package:formz/formz.dart'; +import 'package:web_dex/views/market_maker_bot/trade_bot_update_interval.dart'; + +enum UpdateIntervalValidationError { + /// Input is empty + empty, + + /// Not a valid number + invalid, + + /// Number is negative + negative, + + /// Number is too low + tooLow, +} + +class UpdateIntervalInput + extends FormzInput { + const UpdateIntervalInput.pure() : super.pure('300'); + const UpdateIntervalInput.dirty([String value = '300']) : super.dirty(value); + + TradeBotUpdateInterval get interval => + TradeBotUpdateInterval.fromString(value); + + @override + UpdateIntervalValidationError? validator(String value) { + if (value.isEmpty) { + return UpdateIntervalValidationError.empty; + } + + final interval = int.tryParse(value); + if (interval == null) { + return UpdateIntervalValidationError.invalid; + } + + if (interval < 0) { + return UpdateIntervalValidationError.negative; + } + + if (interval < 60) { + return UpdateIntervalValidationError.tooLow; + } + + return null; + } +} diff --git a/lib/model/hd_account/hd_account.dart b/lib/model/hd_account/hd_account.dart new file mode 100644 index 0000000000..fa181d95c5 --- /dev/null +++ b/lib/model/hd_account/hd_account.dart @@ -0,0 +1,64 @@ +class HdAccount { + HdAccount({ + required this.accountIndex, + required this.addresses, + this.derivationPath, + this.totalBalance, + }); + + factory HdAccount.fromJson(Map json) { + return HdAccount( + accountIndex: json['account_index'], + derivationPath: json['derivation_path'], + totalBalance: HdBalance.fromJson(json['total_balance']), + addresses: json['addresses'] + .map((dynamic item) => HdAddress.fromJson(item)) + .toList(), + ); + } + + final int accountIndex; + final String? derivationPath; + final HdBalance? totalBalance; + final List addresses; +} + +class HdAddress { + HdAddress({ + required this.address, + required this.derivationPath, + required this.chain, + required this.balance, + }); + + factory HdAddress.fromJson(Map json) { + return HdAddress( + address: json['address'], + derivationPath: json['derivation_path'], + chain: json['chain'], + balance: HdBalance.fromJson(json['balance']), + ); + } + + final String address; + final String derivationPath; + final String chain; + final HdBalance balance; +} + +class HdBalance { + HdBalance({ + required this.spendable, + required this.unspendable, + }); + + factory HdBalance.fromJson(Map json) { + return HdBalance( + spendable: double.parse(json['spendable'] ?? '0'), + unspendable: double.parse(json['unspendable'] ?? '0'), + ); + } + + double spendable; + double unspendable; +} diff --git a/lib/model/hw_wallet/hw_wallet.dart b/lib/model/hw_wallet/hw_wallet.dart new file mode 100644 index 0000000000..5e251681bd --- /dev/null +++ b/lib/model/hw_wallet/hw_wallet.dart @@ -0,0 +1,4 @@ +enum WalletBrand { + trezor, + ledger, +} diff --git a/lib/model/hw_wallet/init_trezor.dart b/lib/model/hw_wallet/init_trezor.dart new file mode 100644 index 0000000000..d1e222224a --- /dev/null +++ b/lib/model/hw_wallet/init_trezor.dart @@ -0,0 +1,110 @@ +import 'package:web_dex/model/hw_wallet/trezor_progress_status.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status_error.dart'; + +class InitTrezorResult { + InitTrezorResult({required this.taskId}); + + static InitTrezorResult? fromJson(Map? json) { + if (json == null) return null; + return InitTrezorResult(taskId: json['task_id']); + } + + final int taskId; +} + +class InitTrezorStatusData { + InitTrezorStatusData({ + required this.trezorStatus, + required this.details, + }); + + static InitTrezorStatusData? fromJson(Map? json) { + if (json == null) return null; + + final status = InitTrezorStatus.fromJson(json['status']); + return InitTrezorStatusData( + trezorStatus: status, + details: TrezorStatusDetails.fromJson( + json['details'], + status, + )); + } + + final InitTrezorStatus trezorStatus; + final TrezorStatusDetails details; +} + +class TrezorStatusDetails { + TrezorStatusDetails({ + this.progressDetails, + this.details, + this.errorDetails, + this.actionDetails, + this.deviceDetails, + }); + + factory TrezorStatusDetails.fromJson(dynamic json, InitTrezorStatus status) { + switch (status) { + case InitTrezorStatus.inProgress: + return TrezorStatusDetails( + progressDetails: TrezorProgressStatus.fromJson(json), + ); + case InitTrezorStatus.userActionRequired: + return TrezorStatusDetails( + actionDetails: TrezorUserAction.fromJson(json), + ); + case InitTrezorStatus.ok: + return TrezorStatusDetails( + deviceDetails: TrezorDeviceDetails.fromJson(json)); + case InitTrezorStatus.error: + return TrezorStatusDetails( + errorDetails: TrezorStatusError.fromJson(json)); + default: + return TrezorStatusDetails(details: json); + } + } + + final dynamic details; + final TrezorProgressStatus? progressDetails; + final TrezorStatusError? errorDetails; + final TrezorUserAction? actionDetails; + final TrezorDeviceDetails? deviceDetails; +} + +class TrezorDeviceDetails { + TrezorDeviceDetails({ + required this.pubKey, + this.name, + this.deviceId, + }); + + static TrezorDeviceDetails fromJson(Map json) { + return TrezorDeviceDetails( + pubKey: json['device_pubkey'], + name: json['device_name'], + deviceId: json['device_id'], + ); + } + + final String pubKey; + final String? name; + final String? deviceId; +} + +enum TrezorUserAction { + enterTrezorPin, + enterTrezorPassphrase, + unknown; + + static TrezorUserAction fromJson(String json) { + switch (json) { + case 'EnterTrezorPin': + return TrezorUserAction.enterTrezorPin; + case 'EnterTrezorPassphrase': + return TrezorUserAction.enterTrezorPassphrase; + default: + return TrezorUserAction.unknown; + } + } +} diff --git a/lib/model/hw_wallet/trezor_connection_status.dart b/lib/model/hw_wallet/trezor_connection_status.dart new file mode 100644 index 0000000000..3e26787618 --- /dev/null +++ b/lib/model/hw_wallet/trezor_connection_status.dart @@ -0,0 +1,12 @@ +enum TrezorConnectionStatus { + connected, + unreachable, + unknown; + + factory TrezorConnectionStatus.fromString(String status) { + if (status == 'Connected') return TrezorConnectionStatus.connected; + if (status == 'Unreachable') return TrezorConnectionStatus.unreachable; + + return TrezorConnectionStatus.unknown; + } +} diff --git a/lib/model/hw_wallet/trezor_progress_status.dart b/lib/model/hw_wallet/trezor_progress_status.dart new file mode 100644 index 0000000000..58c0bdca37 --- /dev/null +++ b/lib/model/hw_wallet/trezor_progress_status.dart @@ -0,0 +1,25 @@ +enum TrezorProgressStatus { + initializing, + waitingForTrezorToConnect, + waitingForUserToConfirmPubkey, + waitingForUserToConfirmSigning, + followHwDeviceInstructions, + unknown; + + static TrezorProgressStatus fromJson(String json) { + switch (json) { + case 'Initializing': + return TrezorProgressStatus.initializing; + case 'WaitingForTrezorToConnect': + return TrezorProgressStatus.waitingForTrezorToConnect; + case 'WaitingForUserToConfirmPubkey': + return TrezorProgressStatus.waitingForUserToConfirmPubkey; + case 'WaitingForUserToConfirmSigning': + return TrezorProgressStatus.waitingForUserToConfirmSigning; + case 'FollowHwDeviceInstructions': + return TrezorProgressStatus.followHwDeviceInstructions; + default: + return TrezorProgressStatus.unknown; + } + } +} diff --git a/lib/model/hw_wallet/trezor_status.dart b/lib/model/hw_wallet/trezor_status.dart new file mode 100644 index 0000000000..800a43d267 --- /dev/null +++ b/lib/model/hw_wallet/trezor_status.dart @@ -0,0 +1,22 @@ +enum InitTrezorStatus { + ok, + inProgress, + userActionRequired, + error, + unknown; + + static InitTrezorStatus fromJson(String json) { + switch (json) { + case 'Ok': + return InitTrezorStatus.ok; + case 'InProgress': + return InitTrezorStatus.inProgress; + case 'UserActionRequired': + return InitTrezorStatus.userActionRequired; + case 'Error': + return InitTrezorStatus.error; + default: + return InitTrezorStatus.unknown; + } + } +} diff --git a/lib/model/hw_wallet/trezor_status_error.dart b/lib/model/hw_wallet/trezor_status_error.dart new file mode 100644 index 0000000000..e591aed270 --- /dev/null +++ b/lib/model/hw_wallet/trezor_status_error.dart @@ -0,0 +1,62 @@ +class TrezorStatusError { + TrezorStatusError({ + required this.error, + required this.errorType, + required this.errorData, + }); + + factory TrezorStatusError.fromJson(Map json) { + return TrezorStatusError( + error: json['error'], + errorType: TrezorStatusErrorType.fromJson(json['error_type']), + errorData: TrezorStatusErrorData.fromJson(json['error_data']), + ); + } + + final String error; + final TrezorStatusErrorType errorType; + final TrezorStatusErrorData errorData; +} + +enum TrezorStatusErrorData { + noTrezorDeviceAvailable, + foundMultipleDevices, + foundUnexpectedDevice, + invalidPin, + unknown; + + static TrezorStatusErrorData fromJson(dynamic json) { + switch (json) { + case 'NoTrezorDeviceAvailable': + return TrezorStatusErrorData.noTrezorDeviceAvailable; + case 'FoundMultipleDevices': + return TrezorStatusErrorData.foundMultipleDevices; + case 'FoundUnexpectedDevice': + return TrezorStatusErrorData.foundUnexpectedDevice; + case 'InvalidPin': + return TrezorStatusErrorData.invalidPin; + default: + return TrezorStatusErrorData.unknown; + } + } +} + +enum TrezorStatusErrorType { + hwError, + hwContextInitializingAlready, + internal, + unknown; + + static TrezorStatusErrorType fromJson(String? json) { + switch (json) { + case 'HwError': + return TrezorStatusErrorType.hwError; + case 'HwContextInitializingAlready': + return TrezorStatusErrorType.hwContextInitializingAlready; + case 'Internal': + return TrezorStatusErrorType.internal; + default: + return TrezorStatusErrorType.unknown; + } + } +} diff --git a/lib/model/hw_wallet/trezor_task.dart b/lib/model/hw_wallet/trezor_task.dart new file mode 100644 index 0000000000..44e5ded455 --- /dev/null +++ b/lib/model/hw_wallet/trezor_task.dart @@ -0,0 +1,29 @@ +class TrezorTask { + TrezorTask({ + required this.taskId, + required this.type, + }); + + final int taskId; + final TrezorTaskType type; +} + +enum TrezorTaskType { + initTrezor, + enableUtxo, + withdraw, + accountBalance; + + String get name { + switch (this) { + case TrezorTaskType.initTrezor: + return 'init_trezor'; + case TrezorTaskType.enableUtxo: + return 'enable_utxo'; + case TrezorTaskType.withdraw: + return 'withdraw'; + case TrezorTaskType.accountBalance: + return 'account_balance'; + } + } +} diff --git a/lib/model/main_menu_value.dart b/lib/model/main_menu_value.dart new file mode 100644 index 0000000000..662ee2a572 --- /dev/null +++ b/lib/model/main_menu_value.dart @@ -0,0 +1,76 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +enum MainMenuValue { + wallet, + fiat, + dex, + bridge, + marketMakerBot, + nft, + settings, + support, + none; + + String get title { + switch (this) { + case MainMenuValue.wallet: + return LocaleKeys.wallet.tr(); + case MainMenuValue.fiat: + return LocaleKeys.fiat.tr(); + case MainMenuValue.dex: + return LocaleKeys.swap.tr(); + case MainMenuValue.bridge: + return LocaleKeys.bridge.tr(); + case MainMenuValue.marketMakerBot: + return LocaleKeys.tradingBot.tr(); + case MainMenuValue.nft: + return LocaleKeys.nfts.tr(); + case MainMenuValue.settings: + return LocaleKeys.settings.tr(); + case MainMenuValue.support: + return LocaleKeys.support.tr(); + case MainMenuValue.none: + return ''; + } + } + + bool get isNew { + switch (this) { + case MainMenuValue.wallet: + case MainMenuValue.dex: + case MainMenuValue.settings: + case MainMenuValue.support: + case MainMenuValue.none: + case MainMenuValue.bridge: + return false; + case MainMenuValue.fiat: + case MainMenuValue.marketMakerBot: + case MainMenuValue.nft: + return true; + } + } + + int get currentIndex { + switch (this) { + case MainMenuValue.wallet: + return 0; + case MainMenuValue.fiat: + return 1; + case MainMenuValue.dex: + return 2; + case MainMenuValue.bridge: + return 3; + case MainMenuValue.nft: + return 4; + case MainMenuValue.settings: + return 5; + case MainMenuValue.marketMakerBot: + return 6; + case MainMenuValue.support: + return 6; + case MainMenuValue.none: + return 0; + } + } +} diff --git a/lib/model/my_orders/maker_order.dart b/lib/model/my_orders/maker_order.dart new file mode 100644 index 0000000000..b1df8eb0be --- /dev/null +++ b/lib/model/my_orders/maker_order.dart @@ -0,0 +1,77 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/model/my_orders/matches.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class MakerOrder { + MakerOrder({ + required this.base, + required this.createdAt, + required this.availableAmount, + required this.cancellable, + required this.matches, + required this.maxBaseVol, + required this.minBaseVol, + required this.price, + required this.rel, + required this.startedSwaps, + required this.uuid, + }); + + factory MakerOrder.fromJson(Map json) { + final Rational maxBaseVol = fract2rat(json['max_base_vol_fraction']) ?? + Rational.parse(json['max_base_vol'] ?? '0'); + final Rational price = fract2rat(json['price_fraction']) ?? + Rational.parse(json['price'] ?? '0'); + final Rational availableAmount = + fract2rat(json['available_amount_fraction']) ?? + Rational.parse(json['available_amount'] ?? '0'); + + return MakerOrder( + base: json['base'] ?? '', + createdAt: json['created_at'] ?? 0, + availableAmount: availableAmount, + cancellable: json['cancellable'] ?? false, + matches: Map.from(json['matches'] ?? {}) + .map((dynamic k, dynamic v) => + MapEntry(k, Matches.fromJson(v))), + maxBaseVol: maxBaseVol, + minBaseVol: json['min_base_vol'] ?? '', + price: price, + rel: json['rel'] ?? '', + startedSwaps: List.from( + (json['started_swaps'] ?? []).map((dynamic x) => x)), + uuid: json['uuid'] ?? '', + ); + } + + String base; + int createdAt; + Rational availableAmount; + bool cancellable; + Map matches; + Rational maxBaseVol; + String minBaseVol; + Rational price; + String rel; + List startedSwaps; + String uuid; + + Map toJson() => { + 'base': base, + 'created_at': createdAt, + 'available_amount': availableAmount.toDouble().toString(), + 'available_amount_fraction': rat2fract(availableAmount), + 'cancellable': cancellable, + 'matches': Map.from(matches).map( + (dynamic k, dynamic v) => MapEntry(k, v.toJson())), + 'max_base_vol': maxBaseVol.toDouble().toString(), + 'max_base_vol_fraction': rat2fract(maxBaseVol), + 'min_base_vol': minBaseVol, + 'price': price.toDouble().toString(), + 'price_fraction': rat2fract(price), + 'rel': rel, + 'started_swaps': + List.from(startedSwaps.map((dynamic x) => x)), + 'uuid': uuid, + }; +} diff --git a/lib/model/my_orders/match_connect.dart b/lib/model/my_orders/match_connect.dart new file mode 100644 index 0000000000..a9ec585211 --- /dev/null +++ b/lib/model/my_orders/match_connect.dart @@ -0,0 +1,31 @@ +class MatchConnect { + MatchConnect({ + required this.destPubKey, + required this.makerOrderUuid, + required this.method, + required this.senderPubkey, + required this.takerOrderUuid, + }); + + factory MatchConnect.fromJson(Map json) => MatchConnect( + destPubKey: json['dest_pub_key'] ?? '', + makerOrderUuid: json['maker_order_uuid'] ?? '', + method: json['method'] ?? '', + senderPubkey: json['sender_pubkey'] ?? '', + takerOrderUuid: json['taker_order_uuid'] ?? '', + ); + + String destPubKey; + String makerOrderUuid; + String method; + String senderPubkey; + String takerOrderUuid; + + Map toJson() => { + 'dest_pub_key': destPubKey, + 'maker_order_uuid': makerOrderUuid, + 'method': method, + 'sender_pubkey': senderPubkey, + 'taker_order_uuid': takerOrderUuid, + }; +} diff --git a/lib/model/my_orders/match_request.dart b/lib/model/my_orders/match_request.dart new file mode 100644 index 0000000000..5710756aa1 --- /dev/null +++ b/lib/model/my_orders/match_request.dart @@ -0,0 +1,67 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class MatchRequest { + MatchRequest({ + this.action = '', + this.base = '', + required this.baseAmount, + this.destPubKey = '', + this.method = '', + this.rel = '', + required this.relAmount, + this.senderPubkey = '', + this.uuid = '', + this.makerOrderUuid = '', + this.takerOrderUuid = '', + }); + + factory MatchRequest.fromJson(Map json) { + final Rational baseAmount = fract2rat(json['base_amount_fraction']) ?? + Rational.parse(json['base_amount'] ?? '0'); + final Rational relAmount = fract2rat(json['rel_amount_fraction']) ?? + Rational.parse(json['rel_amount'] ?? '0'); + + return MatchRequest( + action: json['action'] ?? '', + base: json['base'] ?? '', + baseAmount: baseAmount, + destPubKey: json['dest_pub_key'] ?? '', + method: json['method'] ?? '', + rel: json['rel'] ?? '', + relAmount: relAmount, + senderPubkey: json['sender_pubkey'] ?? '', + uuid: json['uuid'] ?? '', + makerOrderUuid: json['maker_order_uuid'] ?? '', + takerOrderUuid: json['taker_order_uuid'] ?? '', + ); + } + + String action; + String base; + Rational baseAmount; + String destPubKey; + String method; + String rel; + Rational relAmount; + String senderPubkey; + String uuid; + String makerOrderUuid; + String takerOrderUuid; + + Map toJson() => { + 'action': action, + 'base': base, + 'base_amount': baseAmount.toDouble().toString(), + 'base_amount_fraction': rat2fract(baseAmount), + 'dest_pub_key': destPubKey, + 'method': method, + 'rel': rel, + 'rel_amount': relAmount.toDouble().toString(), + 'rel_amount_fraction': rat2fract(relAmount), + 'sender_pubkey': senderPubkey, + 'uuid': uuid, + 'maker_order_uuid': makerOrderUuid, + 'taker_order_uuid': takerOrderUuid, + }; +} diff --git a/lib/model/my_orders/matches.dart b/lib/model/my_orders/matches.dart new file mode 100644 index 0000000000..b69e3d3e42 --- /dev/null +++ b/lib/model/my_orders/matches.dart @@ -0,0 +1,42 @@ +import 'package:web_dex/model/my_orders/match_connect.dart'; +import 'package:web_dex/model/my_orders/match_request.dart'; + +class Matches { + Matches({ + required this.connect, + required this.connected, + required this.lastUpdated, + required this.request, + required this.reserved, + }); + + factory Matches.fromJson(Map json) => Matches( + connect: json['connect'] == null + ? null + : MatchConnect.fromJson(json['connect']), + connected: json['connected'] == null + ? null + : MatchConnect.fromJson(json['connected']), + lastUpdated: json['last_updated'] ?? 0, + request: json['request'] == null + ? null + : MatchRequest.fromJson(json['request']), + reserved: json['reserved'] == null + ? null + : MatchRequest.fromJson(json['reserved']), + ); + + MatchConnect? connect; + MatchConnect? connected; + int lastUpdated; + MatchRequest? request; + MatchRequest? reserved; + + Map toJson() => { + 'connect': connect?.toJson(), + 'connected': connected?.toJson(), + 'last_updated': lastUpdated, + 'request': request?.toJson(), + 'reserved': reserved?.toJson(), + }; +} diff --git a/lib/model/my_orders/my_order.dart b/lib/model/my_orders/my_order.dart new file mode 100644 index 0000000000..471f803b02 --- /dev/null +++ b/lib/model/my_orders/my_order.dart @@ -0,0 +1,43 @@ +import 'package:rational/rational.dart'; + +class MyOrder { + MyOrder({ + required this.base, + required this.orderType, + required this.rel, + required this.relAmount, + required this.uuid, + required this.baseAmount, + required this.createdAt, + required this.cancelable, + this.startedSwaps, + this.baseAmountAvailable, + this.relAmountAvailable, + this.minVolume, + }); + + String base; + Rational baseAmount; + Rational? baseAmountAvailable; + String rel; + TradeSide orderType; + Rational relAmount; + Rational? relAmountAvailable; + String uuid; + int createdAt; + bool cancelable; + double? minVolume; + List? startedSwaps; + int get orderMatchingTime { + final resetTimeInSeconds = 30 - + DateTime.now() + .subtract(Duration(milliseconds: createdAt * 1000)) + .second; + + return resetTimeInSeconds < 0 ? 0 : resetTimeInSeconds; + } + + double get price => baseAmount.toDouble() / relAmount.toDouble(); +} + +enum TradeSide { maker, taker } diff --git a/lib/model/my_orders/taker_order.dart b/lib/model/my_orders/taker_order.dart new file mode 100644 index 0000000000..840d68a037 --- /dev/null +++ b/lib/model/my_orders/taker_order.dart @@ -0,0 +1,47 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/model/my_orders/match_request.dart'; +import 'package:web_dex/model/my_orders/matches.dart'; + +class TakerOrder { + TakerOrder({ + required this.createdAt, + required this.cancellable, + required this.matches, + required this.request, + }); + + factory TakerOrder.fromJson(Map json) { + return TakerOrder( + createdAt: json['created_at'] ?? 0, + cancellable: json['cancellable'] ?? false, + matches: json['matches'] == null + ? null + : Map.from(json['matches']).map( + (String k, dynamic v) => + MapEntry(k, Matches.fromJson(v))), + request: json['request'] == null + ? MatchRequest(baseAmount: Rational.zero, relAmount: Rational.zero) + : MatchRequest.fromJson(json['request']), + ); + } + + int createdAt; + bool cancellable; + Map? matches; + MatchRequest request; + + Map toJson() { + final Map? matches = this.matches; + + return { + 'created_at': createdAt, + 'cancellable': cancellable, + 'matches': matches == null + ? null + : Map.from(matches).map( + (String k, Matches v) => + MapEntry(k, v.toJson())), + 'request': request.toJson() + }; + } +} diff --git a/lib/model/nft.dart b/lib/model/nft.dart new file mode 100644 index 0000000000..66cadcb47c --- /dev/null +++ b/lib/model/nft.dart @@ -0,0 +1,320 @@ +import 'dart:convert'; + +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/withdraw_details/fee_details.dart'; + +class NftToken { + NftToken({ + required this.chain, + required this.tokenAddress, + required this.tokenId, + required this.amount, + required this.ownerOf, + required this.tokenHash, + required this.blockNumber, + required this.blockNumberMinted, + required this.contractType, + required this.collectionName, + required this.symbol, + required this.metaData, + required this.lastTokenUriSync, + required this.lastMetadataSync, + required this.minterAddress, + required this.possibleSpam, + required this.uriMeta, + required this.tokenUri, + }); + + final NftBlockchains chain; + final String tokenAddress; + final String tokenId; + final String amount; + final String ownerOf; + final int blockNumber; + final bool possibleSpam; + final NftUriMeta uriMeta; + final NftMetaData? metaData; + final NftContractType contractType; + final String? tokenHash; + final int? blockNumberMinted; + final String? collectionName; + final String? symbol; + final String? lastTokenUriSync; + final String? lastMetadataSync; + final String? minterAddress; + final String? tokenUri; + late final Coin parentCoin; + + static NftToken fromJson(dynamic json) { + return NftToken( + chain: NftBlockchains.fromApiResponse(json['chain']), + tokenAddress: json['token_address'], + tokenId: json['token_id'], + amount: json['amount'], + ownerOf: json['owner_of'], + tokenHash: json['token_hash'], + blockNumber: json['block_number'], + blockNumberMinted: json['block_number_minted'], + contractType: NftContractType.fromApiResponse(json['contract_type']), + collectionName: json['name'], + symbol: json['symbol'], + metaData: json['metadata'] != null + ? NftMetaData.fromJson(jsonDecode(json['metadata'])) + : null, + lastTokenUriSync: json['last_token_uri_sync'], + lastMetadataSync: json['last_metadata_sync'], + minterAddress: json['minter_address'], + possibleSpam: json['possible_spam'] ?? false, + uriMeta: NftUriMeta.fromJson(json['uri_meta']), + tokenUri: json['token_uri'], + ); + } + + String get name => metaData?.name ?? uriMeta.tokenName ?? tokenId; + 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 + return image.replaceFirst('ipfs://', 'https://ipfs.io/ipfs/'); + } + + String get uuid => + '${chain.toString()}:$tokenAddress:$tokenId'.hashCode.toString(); + + CoinType get coinType { + switch (chain) { + case NftBlockchains.eth: + return CoinType.erc20; + case NftBlockchains.bsc: + return CoinType.bep20; + case NftBlockchains.avalanche: + return CoinType.avx20; + case NftBlockchains.polygon: + return CoinType.plg20; + case NftBlockchains.fantom: + return CoinType.ftm20; + } + } +} + +class NftUriMeta { + const NftUriMeta({ + required this.tokenName, + required this.description, + required this.image, + required this.attributes, + required this.animationUrl, + required this.imageUrl, + required this.imageDetails, + required this.externalUrl, + }); + + static NftUriMeta fromJson(Map json) { + return NftUriMeta( + animationUrl: json['animation_url'], + attributes: json['attributes'], + description: json['description'], + image: json['image'], + imageUrl: json['image_url'], + tokenName: json['name'], + imageDetails: json['image_details'], + externalUrl: json['external_url'], + ); + } + + final String? tokenName; + final String? description; + final String? image; + final String? imageUrl; + final dynamic attributes; + final String? animationUrl; + final Map? imageDetails; + final String? externalUrl; +} + +class NftMetaData { + const NftMetaData({ + required this.name, + required this.image, + required this.description, + }); + final String? name; + final String? image; + final String? description; + + static NftMetaData fromJson(Map json) { + return NftMetaData( + name: json['name'], + image: json['image'], + description: json['description'], + ); + } +} + +// Order is important +enum NftBlockchains { + eth, + polygon, + bsc, + avalanche, + fantom; + + @override + String toString() { + switch (this) { + case NftBlockchains.eth: + return 'ETH'; + case NftBlockchains.bsc: + return 'BNB'; + case NftBlockchains.avalanche: + return 'Avalanche'; + case NftBlockchains.polygon: + return 'Polygon'; + case NftBlockchains.fantom: + return 'Fantom'; + } + } + + static NftBlockchains? fromString(String chain) { + switch (chain) { + case 'ETH': + return NftBlockchains.eth; + case 'BSC': + return NftBlockchains.bsc; + case 'AVALANCHE': + return NftBlockchains.avalanche; + case 'POLYGON': + return NftBlockchains.polygon; + case 'FANTOM': + return NftBlockchains.fantom; + default: + return null; + } + } + + static NftBlockchains fromApiResponse(String type) { + switch (type) { + case 'AVALANCHE': + return NftBlockchains.avalanche; + case 'BSC': + return NftBlockchains.bsc; + case 'ETH': + return NftBlockchains.eth; + case 'FANTOM': + return NftBlockchains.fantom; + case 'POLYGON': + return NftBlockchains.polygon; + } + + throw UnimplementedError(); + } + + String toApiRequest() { + switch (this) { + case NftBlockchains.eth: + return 'ETH'; + case NftBlockchains.bsc: + return 'BSC'; + case NftBlockchains.avalanche: + return 'AVALANCHE'; + case NftBlockchains.polygon: + return 'POLYGON'; + case NftBlockchains.fantom: + return 'FANTOM'; + } + } + + String coinAbbr() { + switch (this) { + case NftBlockchains.eth: + return 'ETH'; + case NftBlockchains.bsc: + return 'BNB'; + case NftBlockchains.avalanche: + return 'AVAX'; + case NftBlockchains.polygon: + return 'MATIC'; + case NftBlockchains.fantom: + return 'FTM'; + } + } +} + +enum NftContractType { + erc1155, + erc721; + + static NftContractType fromApiResponse(String type) { + switch (type) { + case 'ERC721': + return NftContractType.erc721; + case 'ERC1155': + return NftContractType.erc1155; + } + throw Exception('There is no contract type'); + } + + String toWithdrawRequest() { + switch (this) { + case NftContractType.erc1155: + return 'withdraw_erc1155'; + case NftContractType.erc721: + return 'withdraw_erc721'; + } + } +} + +class NftTransactionDetails { + NftTransactionDetails({ + required this.txHex, + required this.txHash, + required this.from, + required this.to, + required this.contractType, + required this.tokenAddress, + required this.tokenId, + required this.amount, + required this.feeDetails, + required this.coin, + required this.blockHeight, + required this.timestamp, + required this.internalId, + required this.transactionType, + }); + + static NftTransactionDetails fromJson(Map json) { + return NftTransactionDetails( + txHex: json['tx_hex'], + txHash: json['tx_hash'], + coin: json['coin'], + internalId: json['internal_id'] ?? 0, + blockHeight: json['block_height'] ?? 0, + timestamp: json['timestamp'], + from: List.from(json['from']), + to: List.from(json['to']), + feeDetails: FeeDetails.fromJson(json['fee_details']), + contractType: NftContractType.fromApiResponse(json['contract_type']), + transactionType: json['transaction_type'], + tokenAddress: json['token_address'], + tokenId: json['token_id'], + amount: json['amount'], + ); + } + + final String txHex; + final String txHash; + final List from; + final List to; + final NftContractType contractType; + final String tokenAddress; + final String tokenId; + final String amount; + final FeeDetails feeDetails; + final String coin; + final int blockHeight; + final int timestamp; + final int internalId; + final String transactionType; +} diff --git a/lib/model/orderbook/order.dart b/lib/model/orderbook/order.dart new file mode 100644 index 0000000000..f80b63c0dc --- /dev/null +++ b/lib/model/orderbook/order.dart @@ -0,0 +1,60 @@ +import 'package:rational/rational.dart'; +import 'package:uuid/uuid.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class Order { + Order({ + required this.base, + required this.rel, + required this.direction, + required this.price, + required this.maxVolume, + this.address, + this.uuid, + this.pubkey, + this.minVolume, + this.minVolumeRel, + }); + + factory Order.fromJson( + Map json, { + required OrderDirection direction, + required String otherCoin, + }) { + return Order( + base: json['coin'], + rel: otherCoin, + direction: direction, + address: json['address'], + uuid: json['uuid'], + pubkey: json['pubkey'], + price: fract2rat(json['price_fraction']) ?? Rational.parse(json['price']), + maxVolume: fract2rat(json['base_max_volume_fraction']) ?? + Rational.parse(json['base_max_volume']), + minVolume: fract2rat(json['base_min_volume_fraction']) ?? + Rational.parse(json['base_min_volume']), + minVolumeRel: fract2rat(json['rel_min_volume_fraction']) ?? + Rational.parse(json['rel_min_volume']), + ); + } + + final String base; + final String rel; + final OrderDirection direction; + final Rational maxVolume; + final Rational price; + final String? address; + final String? uuid; + final String? pubkey; + final Rational? minVolume; + final Rational? minVolumeRel; + + bool get isBid => direction == OrderDirection.bid; + bool get isAsk => direction == OrderDirection.ask; +} + +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(); diff --git a/lib/model/orderbook/orderbook.dart b/lib/model/orderbook/orderbook.dart new file mode 100644 index 0000000000..386ba0dcfb --- /dev/null +++ b/lib/model/orderbook/orderbook.dart @@ -0,0 +1,57 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/model/orderbook/order.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class Orderbook { + Orderbook({ + required this.base, + required this.rel, + required this.bidsBaseVolTotal, + required this.bidsRelVolTotal, + required this.asksBaseVolTotal, + required this.asksRelVolTotal, + required this.bids, + required this.asks, + required this.timestamp, + }); + + factory Orderbook.fromJson(Map json) { + return Orderbook( + base: json['base'], + rel: json['rel'], + asks: json['asks'] + .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'], + )) + .toList(), + bidsBaseVolTotal: fract2rat(json['total_bids_base_vol_fraction']) ?? + Rational.parse(json['total_bids_base_vol']), + bidsRelVolTotal: fract2rat(json['total_bids_rel_vol_fraction']) ?? + Rational.parse(json['total_bids_rel_vol']), + asksBaseVolTotal: fract2rat(json['total_asks_base_vol_fraction']) ?? + Rational.parse(json['total_asks_base_vol']), + asksRelVolTotal: fract2rat(json['total_asks_rel_vol_fraction']) ?? + Rational.parse(json['total_asks_rel_vol']), + timestamp: json['timestamp'], + ); + } + + final String base; + final String rel; + final List bids; + final List asks; + final Rational bidsBaseVolTotal; + final Rational bidsRelVolTotal; + final Rational asksBaseVolTotal; + final Rational asksRelVolTotal; + final int timestamp; +} diff --git a/lib/model/orderbook_model.dart b/lib/model/orderbook_model.dart new file mode 100644 index 0000000000..b9dcbfd5c2 --- /dev/null +++ b/lib/model/orderbook_model.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/orderbook/orderbook_response.dart'; +import 'package:web_dex/model/coin.dart'; + +class OrderbookModel { + OrderbookModel({ + required Coin? base, + required Coin? rel, + }) { + _base = base; + _rel = rel; + _updateListener(); + } + + Coin? _base; + Coin? get base => _base; + set base(Coin? value) { + _base = value; + _updateListener(); + } + + Coin? _rel; + Coin? get rel => _rel; + set rel(Coin? value) { + _rel = value; + _updateListener(); + } + + 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) { + _response = value; + _inResponse.add(_response); + } + + bool get isComplete => base?.abbr != null && rel?.abbr != null; + + void dispose() { + _orderbookListener?.cancel(); + _responseCtrl.close(); + } + + void reload() { + _updateListener(); + } + + void _updateListener() { + _orderbookListener?.cancel(); + response = null; + if (base == null || rel == null) return; + + final stream = orderbookBloc.getOrderbookStream(base!.abbr, rel!.abbr); + _orderbookListener = stream.listen((resp) => response = resp); + } + + @override + String toString() { + return 'OrderbookModel(base:${base?.abbr}, rel:${rel?.abbr} isComplete:$isComplete);'; + } +} diff --git a/lib/model/settings/analytics_settings.dart b/lib/model/settings/analytics_settings.dart new file mode 100644 index 0000000000..3d3db28245 --- /dev/null +++ b/lib/model/settings/analytics_settings.dart @@ -0,0 +1,29 @@ +class AnalyticsSettings { + const AnalyticsSettings({required this.isSendAllowed}); + + static AnalyticsSettings initial() { + return const AnalyticsSettings(isSendAllowed: false); + } + + final bool isSendAllowed; + + AnalyticsSettings copyWith({bool? isSendAllowed}) { + return AnalyticsSettings( + isSendAllowed: isSendAllowed ?? this.isSendAllowed, + ); + } + + static AnalyticsSettings fromJson(Map? json) { + if (json == null) { + return AnalyticsSettings.initial(); + } + + return AnalyticsSettings( + isSendAllowed: json['send_allowed'] ?? false, + ); + } + + Map toJson() => { + 'send_allowed': isSendAllowed, + }; +} diff --git a/lib/model/settings/market_maker_bot_settings.dart b/lib/model/settings/market_maker_bot_settings.dart new file mode 100644 index 0000000000..63137ecb59 --- /dev/null +++ b/lib/model/settings/market_maker_bot_settings.dart @@ -0,0 +1,106 @@ +import 'package:equatable/equatable.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/market_maker_bot/message_service_config/message_service_config.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/market_maker_bot/trade_coin_pair_config.dart'; +import 'package:web_dex/shared/constants.dart'; + +/// Settings for the KDF Simple Market Maker Bot. +class MarketMakerBotSettings extends Equatable { + const MarketMakerBotSettings({ + required this.isMMBotEnabled, + required this.priceUrl, + required this.botRefreshRate, + required this.tradeCoinPairConfigs, + this.messageServiceConfig, + }); + + /// Initial (default) settings for the Market Maker Bot. + /// + /// The Market Maker Bot is disabled by default and all other settings are + /// empty or zero. + factory MarketMakerBotSettings.initial() { + return MarketMakerBotSettings( + isMMBotEnabled: false, + priceUrl: pricesUrlV3.toString(), + botRefreshRate: 60, + tradeCoinPairConfigs: const [], + messageServiceConfig: null, + ); + } + + /// Creates a Market Maker Bot settings object from a JSON map. + /// Returns the initial settings if the JSON map is null or does not contain + /// the required `is_market_maker_bot_enabled` key. + factory MarketMakerBotSettings.fromJson(Map? json) { + if (json == null || !json.containsKey('is_market_maker_bot_enabled')) { + return MarketMakerBotSettings.initial(); + } + + return MarketMakerBotSettings( + isMMBotEnabled: json['is_market_maker_bot_enabled'] as bool, + priceUrl: json['price_url'] as String, + botRefreshRate: json['bot_refresh_rate'] as int, + tradeCoinPairConfigs: (json['trade_coin_pair_configs'] as List) + .map((e) => TradeCoinPairConfig.fromJson(e as Map)) + .toList(), + messageServiceConfig: json['message_service_config'] == null + ? null + : MessageServiceConfig.fromJson( + json['message_service_config'] as Map, + ), + ); + } + + /// Whether the Market Maker Bot is enabled (menu item is shown or not). + final bool isMMBotEnabled; + + /// The URL to fetch the price data from. + final String priceUrl; + + /// The refresh rate of the bot in seconds. + final int botRefreshRate; + + /// The list of trade coin pair configurations. + final List tradeCoinPairConfigs; + + /// The message service configuration. + /// + /// This is used to enable Telegram notifications for the bot. + final MessageServiceConfig? messageServiceConfig; + + Map toJson() { + return { + 'is_market_maker_bot_enabled': isMMBotEnabled, + 'price_url': priceUrl, + 'bot_refresh_rate': botRefreshRate, + 'trade_coin_pair_configs': + tradeCoinPairConfigs.map((e) => e.toJson()).toList(), + if (messageServiceConfig != null) + 'message_service_config': messageServiceConfig?.toJson(), + }; + } + + MarketMakerBotSettings copyWith({ + bool? isMMBotEnabled, + String? priceUrl, + int? botRefreshRate, + List? tradeCoinPairConfigs, + MessageServiceConfig? messageServiceConfig, + }) { + return MarketMakerBotSettings( + isMMBotEnabled: isMMBotEnabled ?? this.isMMBotEnabled, + priceUrl: priceUrl ?? this.priceUrl, + botRefreshRate: botRefreshRate ?? this.botRefreshRate, + tradeCoinPairConfigs: tradeCoinPairConfigs ?? this.tradeCoinPairConfigs, + messageServiceConfig: messageServiceConfig ?? this.messageServiceConfig, + ); + } + + @override + List get props => [ + isMMBotEnabled, + priceUrl, + botRefreshRate, + tradeCoinPairConfigs, + messageServiceConfig, + ]; +} diff --git a/lib/model/settings_menu_value.dart b/lib/model/settings_menu_value.dart new file mode 100644 index 0000000000..2ba25b8db8 --- /dev/null +++ b/lib/model/settings_menu_value.dart @@ -0,0 +1,40 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +enum SettingsMenuValue { + general, + security, + support, + feedback, + none; + + String get title { + switch (this) { + case SettingsMenuValue.general: + return LocaleKeys.settingsMenuGeneral.tr(); + case SettingsMenuValue.security: + return LocaleKeys.settingsMenuSecurity.tr(); + case SettingsMenuValue.support: + return LocaleKeys.support.tr(); + case SettingsMenuValue.feedback: + return LocaleKeys.feedback.tr(); + case SettingsMenuValue.none: + return ''; + } + } + + String get name { + switch (this) { + case SettingsMenuValue.general: + return 'general'; + case SettingsMenuValue.security: + return 'security'; + case SettingsMenuValue.support: + return 'support'; + case SettingsMenuValue.feedback: + return 'feedback'; + case SettingsMenuValue.none: + return 'none'; + } + } +} diff --git a/lib/model/stored_settings.dart b/lib/model/stored_settings.dart new file mode 100644 index 0000000000..7aa7671812 --- /dev/null +++ b/lib/model/stored_settings.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/settings/analytics_settings.dart'; +import 'package:web_dex/model/settings/market_maker_bot_settings.dart'; +import 'package:web_dex/shared/constants.dart'; + +class StoredSettings { + StoredSettings({ + required this.mode, + required this.analytics, + required this.marketMakerBotSettings, + }); + + final ThemeMode mode; + final AnalyticsSettings analytics; + final MarketMakerBotSettings marketMakerBotSettings; + + static StoredSettings initial() { + return StoredSettings( + mode: ThemeMode.dark, + analytics: AnalyticsSettings.initial(), + marketMakerBotSettings: MarketMakerBotSettings.initial(), + ); + } + + factory StoredSettings.fromJson(Map? json) { + if (json == null) return StoredSettings.initial(); + + return StoredSettings( + mode: ThemeMode.values[json['themeModeIndex']], + analytics: AnalyticsSettings.fromJson(json[storedAnalyticsSettingsKey]), + marketMakerBotSettings: MarketMakerBotSettings.fromJson( + json[storedMarketMakerSettingsKey], + ), + ); + } + + Map toJson() { + return { + 'themeModeIndex': mode.index, + storedAnalyticsSettingsKey: analytics.toJson(), + storedMarketMakerSettingsKey: marketMakerBotSettings.toJson(), + }; + } + + StoredSettings copyWith({ + ThemeMode? mode, + AnalyticsSettings? analytics, + MarketMakerBotSettings? marketMakerBotSettings, + }) { + return StoredSettings( + mode: mode ?? this.mode, + analytics: analytics ?? this.analytics, + marketMakerBotSettings: + marketMakerBotSettings ?? this.marketMakerBotSettings, + ); + } +} diff --git a/lib/model/swap.dart b/lib/model/swap.dart new file mode 100644 index 0000000000..e37ff1f2c9 --- /dev/null +++ b/lib/model/swap.dart @@ -0,0 +1,451 @@ +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:equatable/equatable.dart'; + +class Swap extends Equatable { + const Swap({ + required this.type, + required this.uuid, + required this.myOrderUuid, + required this.events, + required this.makerAmount, + required this.makerCoin, + required this.takerAmount, + required this.takerCoin, + required this.gui, + required this.mmVersion, + required this.successEvents, + required this.errorEvents, + required this.myInfo, + required this.recoverable, + }); + + factory Swap.fromJson(Map json) { + final Rational makerAmount = fract2rat(json['maker_amount_fraction']) ?? + Rational.parse(json['maker_amount'] ?? '0'); + final Rational takerAmount = fract2rat(json['taker_amount_fraction']) ?? + Rational.parse(json['taker_amount'] ?? '0'); + final TradeSide type = + json['type'] == 'Taker' ? TradeSide.taker : TradeSide.maker; + return Swap( + type: type, + uuid: json['uuid'], + myOrderUuid: json['my_order_uuid'] ?? '', + events: List>.from(json['events']) + .map((e) => SwapEventItem.fromJson(e)) + .toList(), + makerAmount: makerAmount, + makerCoin: json['maker_coin'] ?? '', + takerAmount: takerAmount, + takerCoin: json['taker_coin'] ?? '', + gui: json['gui'] ?? '', + mmVersion: json['mm_version'] ?? '', + successEvents: List.castFrom(json['success_events']), + errorEvents: List.castFrom(json['error_events']), + myInfo: SwapMyInfo.fromJson(json['my_info']), + recoverable: json['recoverable'] ?? false, + ); + } + + final TradeSide type; + final String uuid; + final String myOrderUuid; + final List events; + final Rational makerAmount; + final String makerCoin; + final Rational takerAmount; + final String takerCoin; + final String gui; + final String mmVersion; + final List successEvents; + final List errorEvents; + final SwapMyInfo myInfo; + final bool recoverable; + + Map toJson() { + final data = {}; + + data['type'] = type; + data['uuid'] = uuid; + data['my_order_uuid'] = myOrderUuid; + data['events'] = events.map((e) => e.toJson()).toList(); + data['maker_amount'] = makerAmount.toDouble().toString(); + data['maker_amount_fraction'] = rat2fract(makerAmount); + data['maker_coin'] = makerCoin; + data['taker_amount'] = takerAmount.toDouble().toString(); + data['taker_amount_fraction'] = rat2fract(takerAmount); + data['taker_coin'] = takerCoin; + data['gui'] = gui; + data['mm_version'] = mmVersion; + data['success_events'] = successEvents; + data['error_events'] = errorEvents; + data['my_info'] = myInfo.toJson(); + data['recoverable'] = recoverable; + + return data; + } + + bool get isCompleted => events.any( + (e) => + e.event.type == successEvents.last || + errorEvents.contains(e.event.type), + ); + + bool get isFailed => + events.firstWhereOrNull( + (event) => errorEvents.contains(event.event.type), + ) != + null; + bool get isSuccessful => isCompleted && !isFailed; + SwapStatus get status { + bool started = false, negotiated = false; + for (SwapEventItem ev in events) { + if (errorEvents.contains(ev.event.type)) return SwapStatus.failed; + if (ev.event.type == 'Finished') return SwapStatus.successful; + if (ev.event.type == 'Started') started = true; + if (ev.event.type == 'Negotiated') negotiated = true; + } + if (negotiated) return SwapStatus.ongoing; + if (started) return SwapStatus.matched; + return SwapStatus.matching; + } + + bool get isTaker => type == TradeSide.taker; + + String get sellCoin => isTaker ? takerCoin : makerCoin; + + Rational get sellAmount => isTaker ? takerAmount : makerAmount; + + String get buyCoin => isTaker ? makerCoin : takerCoin; + + Rational get buyAmount => isTaker ? makerAmount : takerAmount; + + bool get isTheSameTicker => abbr2Ticker(takerCoin) == abbr2Ticker(makerCoin); + + static int get statusSteps => 3; + + int get statusStep { + switch (status) { + case SwapStatus.matching: + return 0; + case SwapStatus.matched: + return 1; + case SwapStatus.ongoing: + return 2; + case SwapStatus.successful: + case SwapStatus.failed: + return 0; + case SwapStatus.negotiated: + return 0; + default: + } + return 0; + } + + static String getSwapStatusString(SwapStatus status) { + switch (status) { + case SwapStatus.matching: + return LocaleKeys.matching.tr(); + case SwapStatus.matched: + return LocaleKeys.matched.tr(); + case SwapStatus.ongoing: + return LocaleKeys.ongoing.tr(); + case SwapStatus.successful: + return LocaleKeys.successful.tr(); + case SwapStatus.failed: + return LocaleKeys.failed.tr(); + default: + return ''; + } + } + + @override + List get props => [ + type, + uuid, + myOrderUuid, + events, + makerAmount, + makerCoin, + takerAmount, + takerCoin, + gui, + mmVersion, + successEvents, + errorEvents, + myInfo, + recoverable, + ]; +} + +class SwapEventItem extends Equatable { + const SwapEventItem({ + required this.timestamp, + required this.event, + }); + factory SwapEventItem.fromJson(Map json) => SwapEventItem( + timestamp: json['timestamp'], + event: SwapEvent.fromJson(json['event']), + ); + final int timestamp; + final SwapEvent event; + + String get eventDateTime => DateFormat('d MMMM y, H:m') + .format(DateTime.fromMillisecondsSinceEpoch(timestamp)); + + Map toJson() { + final data = {}; + data['timestamp'] = timestamp; + data['event'] = event.toJson(); + return data; + } + + @override + List get props => [timestamp, event]; +} + +class SwapEvent extends Equatable { + const SwapEvent({ + required this.type, + required this.data, + }); + + factory SwapEvent.fromJson(Map json) { + return SwapEvent( + type: json['type'], + data: (json['data'] != null && json['type'] != "WatcherMessageSent") + ? SwapEventData.fromJson(json['data']) + : null, + ); + } + + final String type; + final SwapEventData? data; + + Map toJson() { + final data = {}; + data['type'] = type; + data['data'] = this.data?.toJson(); + return data; + } + + @override + List get props => [type, data]; +} + +class SwapEventData extends Equatable { + const SwapEventData({ + required this.takerCoin, + required this.makerCoin, + required this.maker, + required this.myPersistentPub, + required this.lockDuration, + required this.makerAmount, + required this.takerAmount, + required this.makerPaymentConfirmations, + required this.makerPaymentRequiresNota, + required this.takerPaymentConfirmations, + required this.takerPaymentRequiresNota, + required this.takerPaymentLock, + required this.uuid, + required this.startedAt, + required this.makerPaymentWait, + required this.makerCoinStartBlock, + required this.takerCoinStartBlock, + required this.feeToSendTakerFee, + required this.takerPaymentTradeFee, + required this.makerPaymentSpendTradeFee, + required this.txHash, + }); + + factory SwapEventData.fromJson(Map json) => SwapEventData( + takerCoin: json['taker_coin'], + makerCoin: json['maker_coin'], + maker: json['maker'], + myPersistentPub: json['my_persistent_pub'], + lockDuration: json['lock_duration'], + makerAmount: double.tryParse(json['maker_amount'] ?? ''), + takerAmount: double.tryParse(json['taker_amount'] ?? ''), + makerPaymentConfirmations: json['maker_payment_confirmations'], + makerPaymentRequiresNota: json['maker_payment_requires_nota'], + takerPaymentConfirmations: json['taker_payment_confirmations'], + takerPaymentRequiresNota: json['taker_payment_requires_nota'], + takerPaymentLock: json['taker_payment_lock'], + uuid: json['uuid'], + startedAt: json['started_at'], + makerPaymentWait: json['maker_payment_wait'], + makerCoinStartBlock: json['maker_coin_start_block'], + takerCoinStartBlock: json['taker_coin_start_block'], + feeToSendTakerFee: json['fee_to_send_taker_fee'] != null + ? TradeFee.fromJson(json['fee_to_send_taker_fee']) + : null, + takerPaymentTradeFee: json['taker_payment_trade_fee'] != null + ? TradeFee.fromJson(json['taker_payment_trade_fee']) + : null, + makerPaymentSpendTradeFee: json['maker_payment_spend_trade_fee'] != null + ? TradeFee.fromJson(json['maker_payment_spend_trade_fee']) + : null, + txHash: json['tx_hash'] ?? json['transaction']?['tx_hash'], + ); + + final String? takerCoin; + final String? makerCoin; + final String? maker; + final String? myPersistentPub; + final int? lockDuration; + final double? makerAmount; + final double? takerAmount; + final int? makerPaymentConfirmations; + final bool? makerPaymentRequiresNota; + final int? takerPaymentConfirmations; + final bool? takerPaymentRequiresNota; + final int? takerPaymentLock; + final String? uuid; + final int? startedAt; + final int? makerPaymentWait; + final int? makerCoinStartBlock; + final int? takerCoinStartBlock; + final TradeFee? feeToSendTakerFee; + final TradeFee? takerPaymentTradeFee; + final TradeFee? makerPaymentSpendTradeFee; + final String? txHash; + + Map toJson() { + final data = {}; + data['taker_coin'] = takerCoin; + data['maker_coin'] = makerCoin; + data['maker'] = maker; + data['my_persistent_pub'] = myPersistentPub; + data['lock_duration'] = lockDuration; + data['maker_amount'] = makerAmount; + data['taker_amount'] = takerAmount; + data['maker_payment_confirmations'] = makerPaymentConfirmations; + data['maker_payment_requires_nota'] = makerPaymentRequiresNota; + data['taker_payment_confirmations'] = takerPaymentConfirmations; + data['taker_payment_requires_nota'] = takerPaymentRequiresNota; + data['taker_payment_lock'] = takerPaymentLock; + data['uuid'] = uuid; + data['started_at'] = startedAt; + data['maker_payment_wait'] = makerPaymentWait; + data['maker_coin_start_block'] = makerCoinStartBlock; + data['taker_coin_start_block'] = takerCoinStartBlock; + data['fee_to_send_taker_fee'] = feeToSendTakerFee?.toJson(); + data['taker_payment_trade_fee'] = takerPaymentTradeFee?.toJson(); + data['maker_payment_spend_trade_fee'] = makerPaymentSpendTradeFee?.toJson(); + return data; + } + + @override + List get props => [ + takerCoin, + makerCoin, + maker, + myPersistentPub, + lockDuration, + makerAmount, + takerAmount, + makerPaymentConfirmations, + makerPaymentRequiresNota, + takerPaymentConfirmations, + takerPaymentRequiresNota, + takerPaymentLock, + uuid, + startedAt, + makerPaymentWait, + makerCoinStartBlock, + takerCoinStartBlock, + feeToSendTakerFee, + takerPaymentTradeFee, + makerPaymentSpendTradeFee, + txHash, + ]; +} + +enum SwapStatus { + successful, + negotiated, + ongoing, + matched, + matching, + failed, +} + +class TradeFee extends Equatable { + const TradeFee({ + required this.coin, + required this.amount, + required this.paidFromTradingVol, + }); + + factory TradeFee.fromJson(Map json) { + return TradeFee( + coin: json['coin'], + amount: double.tryParse(json['amount'] ?? ''), + paidFromTradingVol: json['paid_from_trading_vol'], + ); + } + + final String coin; + final double? amount; + final bool paidFromTradingVol; + + Map toJson() { + final data = {}; + data['coin'] = coin; + data['amount'] = amount; + data['paid_from_trading_vol'] = paidFromTradingVol; + return data; + } + + @override + List get props => [coin, amount, paidFromTradingVol]; +} + +class SwapMyInfo extends Equatable { + const SwapMyInfo({ + required this.myCoin, + required this.otherCoin, + required this.myAmount, + required this.otherAmount, + required this.startedAt, + }); + + factory SwapMyInfo.fromJson(Map json) { + return SwapMyInfo( + myCoin: json['my_coin'], + otherCoin: json['other_coin'], + myAmount: double.parse(json['my_amount']), + otherAmount: double.parse(json['other_amount']), + startedAt: json['started_at'], + ); + } + + final String myCoin; + final String otherCoin; + final double myAmount; + final double otherAmount; + final int startedAt; + + Map toJson() { + final data = {}; + data['my_coin'] = myCoin; + data['other_coin'] = otherCoin; + data['my_amount'] = myAmount; + data['other_amount'] = otherAmount; + data['started_at'] = startedAt; + return data; + } + + @override + List get props => [ + myCoin, + otherCoin, + myAmount, + otherAmount, + startedAt, + ]; +} diff --git a/lib/model/text_error.dart b/lib/model/text_error.dart new file mode 100644 index 0000000000..a75adfedad --- /dev/null +++ b/lib/model/text_error.dart @@ -0,0 +1,20 @@ +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class TextError implements BaseError { + TextError({required this.error}); + static TextError empty() { + return TextError(error: ''); + } + + static TextError? fromString(String? text) { + if (text == null) return null; + + return TextError(error: text); + } + + static const String type = 'TextError'; + final String error; + + @override + String get message => error; +} diff --git a/lib/model/trade_preimage.dart b/lib/model/trade_preimage.dart new file mode 100644 index 0000000000..0c8119d9a1 --- /dev/null +++ b/lib/model/trade_preimage.dart @@ -0,0 +1,25 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_request.dart'; +import 'package:web_dex/model/trade_preimage_extended_fee_info.dart'; + +class TradePreimage { + TradePreimage({ + required this.baseCoinFee, + required this.relCoinFee, + required this.volume, + required this.volumeFract, + required this.takerFee, + required this.totalFees, + required this.feeToSendTakerFee, + required this.request, + }); + + final TradePreimageExtendedFeeInfo baseCoinFee; + final TradePreimageExtendedFeeInfo relCoinFee; + final String? volume; + final Rational? volumeFract; + final TradePreimageExtendedFeeInfo? takerFee; + final TradePreimageExtendedFeeInfo? feeToSendTakerFee; + final List totalFees; + final TradePreimageRequest request; +} diff --git a/lib/model/trade_preimage_extended_fee_info.dart b/lib/model/trade_preimage_extended_fee_info.dart new file mode 100644 index 0000000000..803ffc3bf5 --- /dev/null +++ b/lib/model/trade_preimage_extended_fee_info.dart @@ -0,0 +1,23 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class TradePreimageExtendedFeeInfo { + TradePreimageExtendedFeeInfo({ + required this.coin, + required this.amount, + required this.amountRational, + required this.paidFromTradingVol, + }); + factory TradePreimageExtendedFeeInfo.fromJson(Map json) => + TradePreimageExtendedFeeInfo( + coin: json['coin'], + amount: json['amount'], + amountRational: fract2rat(json['amount_fraction']) ?? Rational.zero, + paidFromTradingVol: json['paid_from_trading_vol'] ?? false, + ); + + final String coin; + final String amount; + final Rational amountRational; + final bool paidFromTradingVol; +} diff --git a/lib/model/trading_entities_filter.dart b/lib/model/trading_entities_filter.dart new file mode 100644 index 0000000000..7e1e848af1 --- /dev/null +++ b/lib/model/trading_entities_filter.dart @@ -0,0 +1,46 @@ +import 'package:web_dex/model/my_orders/my_order.dart'; + +enum TradingStatus { + successful, + failed, +} + +class TradingEntitiesFilter { + TradingEntitiesFilter({ + this.sellCoin, + this.buyCoin, + this.startDate, + this.endDate, + this.statuses, + this.shownSides, + }); + + factory TradingEntitiesFilter.from(TradingEntitiesFilter? data) { + if (data == null) return TradingEntitiesFilter(); + + return TradingEntitiesFilter( + buyCoin: data.buyCoin, + endDate: data.endDate, + sellCoin: data.sellCoin, + shownSides: data.shownSides, + startDate: data.startDate, + statuses: data.statuses, + ); + } + + bool get isEmpty { + return (sellCoin?.isEmpty ?? true) && + (buyCoin?.isEmpty ?? true) && + startDate == null && + endDate == null && + (statuses?.isEmpty ?? true) && + (shownSides?.isEmpty ?? true); + } + + String? sellCoin; + String? buyCoin; + DateTime? startDate; + DateTime? endDate; + List? statuses; + List? shownSides; +} diff --git a/lib/model/typedef.dart b/lib/model/typedef.dart new file mode 100644 index 0000000000..f65f30ea56 --- /dev/null +++ b/lib/model/typedef.dart @@ -0,0 +1,8 @@ +import 'package:web_dex/model/coin.dart'; + +/// [Ticker] is a part of coin abbr without protocol sufix, +/// e.g. `KMD` for `KMD-BEP20` +/// See also: [abbr2Ticker] helper +typedef Ticker = String; +typedef Coins = List; +typedef CoinsByTicker = Map; diff --git a/lib/model/wallet.dart b/lib/model/wallet.dart new file mode 100644 index 0000000000..8e817ac382 --- /dev/null +++ b/lib/model/wallet.dart @@ -0,0 +1,105 @@ +import 'package:web_dex/shared/utils/encryption_tool.dart'; + +class Wallet { + Wallet({ + required this.id, + required this.name, + required this.config, + }); + + factory Wallet.fromJson(Map json) => Wallet( + id: json['id'] ?? '', + name: json['name'] ?? '', + config: WalletConfig.fromJson(json['config']), + ); + + String id; + String name; + WalletConfig config; + + bool get isHW => config.type != WalletType.iguana; + + Future getSeed(String password) async => + await EncryptionTool().decryptData(password, config.seedPhrase) ?? ''; + + Map toJson() => { + 'id': id, + 'name': name, + 'config': config.toJson(), + }; + + Wallet copy() { + return Wallet( + id: id, + name: name, + config: config.copy(), + ); + } +} + +class WalletConfig { + WalletConfig({ + required this.seedPhrase, + this.pubKey, + required this.activatedCoins, + required this.hasBackup, + this.type = WalletType.iguana, + }); + + factory WalletConfig.fromJson(Map json) { + return WalletConfig( + type: WalletType.fromJson(json['type'] ?? WalletType.iguana.name), + seedPhrase: json['seed_phrase'], + pubKey: json['pub_key'], + activatedCoins: + List.from(json['activated_coins'] ?? []).toList(), + hasBackup: json['has_backup'] ?? false, + ); + } + + Map toJson() { + return { + 'type': type.name, + 'seed_phrase': seedPhrase, + 'pub_key': pubKey, + 'activated_coins': activatedCoins, + 'has_backup': hasBackup, + }; + } + + String seedPhrase; + String? pubKey; + List activatedCoins; + bool hasBackup; + WalletType type; + + WalletConfig copy() { + return WalletConfig( + activatedCoins: [...activatedCoins], + hasBackup: hasBackup, + type: type, + seedPhrase: seedPhrase, + pubKey: pubKey, + ); + } +} + +enum WalletType { + iguana, + trezor, + metamask, + keplr; + + factory WalletType.fromJson(String json) { + switch (json) { + case 'trezor': + return WalletType.trezor; + case 'metamask': + return WalletType.metamask; + case 'keplr': + return WalletType.keplr; + default: + return WalletType.iguana; + } + } +} diff --git a/lib/model/wallets_manager_models.dart b/lib/model/wallets_manager_models.dart new file mode 100644 index 0000000000..e2a7dfe6ae --- /dev/null +++ b/lib/model/wallets_manager_models.dart @@ -0,0 +1,11 @@ +enum WalletsManagerAction { + create, + import, + none, +} + +enum WalletsManagerExistWalletAction { + logIn, + delete, + none, +} diff --git a/lib/model/withdraw_details/fee_details.dart b/lib/model/withdraw_details/fee_details.dart new file mode 100644 index 0000000000..ef7a81ebbe --- /dev/null +++ b/lib/model/withdraw_details/fee_details.dart @@ -0,0 +1,69 @@ +import 'package:web_dex/shared/utils/utils.dart'; + +class FeeDetails { + FeeDetails({ + required this.type, + required this.coin, + this.amount, + this.totalFee, + this.gasPrice, + this.gas, + this.gasLimit, + this.minerFee, + this.totalGasFee, + }); + factory FeeDetails.fromJson(Map json) { + return FeeDetails( + type: json['type'] ?? '', + coin: json['coin'] ?? '', + gas: json['gas'], + gasLimit: json['gas_limit'], + minerFee: assertString(json['miner_fee']), + totalGasFee: assertString(json['total_gas_fee']), + gasPrice: assertString(json['gas_price']), + totalFee: assertString(json['total_fee']), + amount: assertString(json['amount']), + ); + } + + static FeeDetails empty() => FeeDetails( + type: '', + coin: '', + gas: null, + gasLimit: null, + minerFee: null, + totalGasFee: null, + gasPrice: null, + totalFee: null, + amount: null, + ); + + String type; + String coin; + String? amount; + int? gas; + String? gasPrice; + int? gasLimit; + String? minerFee; + String? totalGasFee; + String? totalFee; + double? _coinUsdPrice; + + String? get feeValue { + if (type == 'Qrc20') { + try { + return '${double.parse(totalGasFee!) + double.parse(minerFee!)}'; + } catch (_) { + return null; + } + } + + return amount ?? totalFee; + } + + void setCoinUsdPrice(double? value) { + _coinUsdPrice = value; + } + + double? get coinUsdPrice => _coinUsdPrice; +} diff --git a/lib/model/withdraw_details/withdraw_details.dart b/lib/model/withdraw_details/withdraw_details.dart new file mode 100644 index 0000000000..313732a28d --- /dev/null +++ b/lib/model/withdraw_details/withdraw_details.dart @@ -0,0 +1,100 @@ +import 'package:web_dex/model/withdraw_details/fee_details.dart'; + +class WithdrawDetails { + WithdrawDetails({ + required this.txHex, + required this.txHash, + required this.from, + required this.to, + required this.totalAmount, + required this.spentByMe, + required this.receivedByMe, + required this.myBalanceChange, + required this.blockHeight, + required this.timestamp, + required this.feeDetails, + required this.coin, + required this.internalId, + }); + factory WithdrawDetails.fromJson(Map json) { + final String totalAmount = json['total_amount'].toString(); + final String spentByMe = json['spent_by_me'].toString(); + final String receivedByMe = json['received_by_me']; + final String myBalanceChange = json['my_balance_change'].toString(); + + return WithdrawDetails( + txHex: json['tx_hex'], + txHash: json['tx_hash'], + from: List.from(json['from']), + to: List.from(json['to']), + totalAmount: totalAmount, + spentByMe: spentByMe, + receivedByMe: receivedByMe, + myBalanceChange: myBalanceChange, + blockHeight: json['block_height'] ?? 0, + timestamp: json['timestamp'], + feeDetails: FeeDetails.fromJson(json['fee_details']), + coin: json['coin'], + internalId: json['internal_id'] ?? '', + ); + } + + static WithdrawDetails empty() => WithdrawDetails( + txHex: '', + txHash: '', + from: [], + to: [], + totalAmount: '', + spentByMe: '', + receivedByMe: '', + myBalanceChange: '', + blockHeight: 0, + timestamp: 0, + feeDetails: FeeDetails.empty(), + coin: '', + internalId: '', + ); + + final String txHex; + final String txHash; + final List from; + final List to; + final String totalAmount; + final String spentByMe; + final String receivedByMe; + final String myBalanceChange; + final int blockHeight; + final int timestamp; + final FeeDetails feeDetails; + final String coin; + final String internalId; + + String get toAddress { + final List toAddress = List.from(to); + if (toAddress.length > 1) { + toAddress.removeWhere((String toItem) => toItem == from[0]); + } + return toAddress.isNotEmpty ? toAddress[0] : ''; + } + + String get feeCoin => feeDetails.coin; + String get feeValue => feeDetails.amount ?? feeDetails.totalFee ?? '0.0'; + + static WithdrawDetails fromTrezorJson(Map json) { + return WithdrawDetails( + txHex: json['tx_hex'], + txHash: json['tx_hash'], + totalAmount: json['total_amount'].toString(), + coin: json['coin'], + myBalanceChange: json['my_balance_change'].toString(), + receivedByMe: json['received_by_me'].toString(), + spentByMe: json['spent_by_me'].toString(), + internalId: json['internal_id'] ?? '', + blockHeight: json['block_height'] ?? 0, + timestamp: json['timestamp'], + from: List.from(json['from']), + to: List.from(json['to']), + feeDetails: FeeDetails.fromJson(json['fee_details']), + ); + } +} diff --git a/lib/performance_analytics/performance_analytics.dart b/lib/performance_analytics/performance_analytics.dart new file mode 100644 index 0000000000..27d8c2f76e --- /dev/null +++ b/lib/performance_analytics/performance_analytics.dart @@ -0,0 +1,80 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:web_dex/app_config/app_config.dart'; + +PerformanceAnalytics get performance => PerformanceAnalytics._instance; + +class PerformanceAnalytics { + PerformanceAnalytics._(); + + static final PerformanceAnalytics _instance = PerformanceAnalytics._(); + + Timer? _summaryTimer; + + bool get _isInitialized => _summaryTimer != null; + + static void init() { + if (_instance._isInitialized) { + throw Exception('PerformanceAnalytics already initialized'); + } + if (!kDebugMode) return; + + _instance._start(); + + print('PerformanceAnalytics initialized'); + } + + void _start() { + _summaryTimer = Timer.periodic( + kPerformanceLogInterval, + (timer) { + final summary = _metricsSummary(); + print(summary); + }, + ); + } + + String _metricsSummary() { + final summary = StringBuffer(); + summary.writeln('=-' * 20); + + summary.writeln('Performance summary:'); + // summary.writeln(' - Total time spent writing logs: $_totalLogTime'); + summary.writeln(' - Total log events: $_logEventsCount'); + // summary.writeln( + // ' - Average time spent writing logs: ' + // '${_totalLogTime.inMilliseconds ~/ _logEventsCount}ms', + // ); + summary.writeln('=-' * 20); + + return summary.toString(); + } + + // Duration get _totalLogTime => Duration( + // milliseconds: _totalMillisecondsWaitingOnLogs, + // ); + + // int _totalMillisecondsWaitingOnLogs = 0; + int _logEventsCount = 0; + + void logTimeWritingLogs(int milliSeconds) { + if (!_isInitialized) return; + + if (milliSeconds < 0) { + throw Exception('Log execution time milliSeconds must be >= 0'); + } + + // _totalMillisecondsWaitingOnLogs += milliSeconds; + _logEventsCount++; + } + + static void stop() { + _instance._summaryTimer?.cancel(); + _instance._summaryTimer = null; + + print('PerformanceAnalytics stopped'); + } +} diff --git a/lib/platform/platform.dart b/lib/platform/platform.dart new file mode 100644 index 0000000000..8ae69d4300 --- /dev/null +++ b/lib/platform/platform.dart @@ -0,0 +1,2 @@ +export 'package:web_dex/platform/platform_native.dart' + if (dart.library.html) 'package:web_dex/platform/platform_web.dart'; diff --git a/lib/platform/platform_native.dart b/lib/platform/platform_native.dart new file mode 100644 index 0000000000..cb75f3ea45 --- /dev/null +++ b/lib/platform/platform_native.dart @@ -0,0 +1,21 @@ +void reloadPage() {} + +bool canLogin(String? _) => true; + +dynamic initWasm() async {} + +Future wasmRunMm2( + String params, + void Function(int, String) handleLog, +) async {} +dynamic wasmMm2Status() async {} +dynamic wasmRpc(String request) async {} + +String wasmVersion() => ''; + +void changeTheme(int themeModeIndex) {} +void changeHtmlTheme(int themeIndex) {} + +Future zipEncode(String fileName, String fileContent) async { + return null; +} diff --git a/lib/platform/platform_web.dart b/lib/platform/platform_web.dart new file mode 100644 index 0000000000..332dd37f77 --- /dev/null +++ b/lib/platform/platform_web.dart @@ -0,0 +1,31 @@ +@JS() +library wasmlib; + +import 'package:js/js.dart'; + +@JS('init_wasm') +external dynamic initWasm(); + +@JS('run_mm2') +external Future wasmRunMm2( + String params, + void Function(int, String) handleLog, +); + +@JS('mm2_status') +external dynamic wasmMm2Status(); + +@JS('mm2_version') +external String wasmVersion(); + +@JS('rpc_request') +external dynamic wasmRpc(String request); + +@JS('reload_page') +external void reloadPage(); + +@JS('changeTheme') +external void changeHtmlTheme(int themeIndex); + +@JS('zip_encode') +external Future zipEncode(String fileName, String fileContent); diff --git a/lib/release_options.dart b/lib/release_options.dart new file mode 100644 index 0000000000..9cf84fc138 --- /dev/null +++ b/lib/release_options.dart @@ -0,0 +1 @@ +const bool showLanguageSwitcher = false; diff --git a/lib/router/navigators/app_router_delegate.dart b/lib/router/navigators/app_router_delegate.dart new file mode 100644 index 0000000000..11568af7bc --- /dev/null +++ b/lib/router/navigators/app_router_delegate.dart @@ -0,0 +1,223 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/model/settings_menu_value.dart'; +import 'package:web_dex/router/routes.dart'; +import 'package:web_dex/router/state/bridge_section_state.dart'; +import 'package:web_dex/router/state/dex_state.dart'; +import 'package:web_dex/router/state/fiat_state.dart'; +import 'package:web_dex/router/state/market_maker_bot_state.dart'; +import 'package:web_dex/router/state/nfts_state.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/main_layout/main_layout.dart'; + +class AppRouterDelegate extends RouterDelegate + with ChangeNotifier, PopNavigatorRouterDelegateMixin { + AppRouterDelegate() : navigatorKey = GlobalKey() { + routingState.addListener(notifyListeners); + } + + @override + final GlobalKey navigatorKey; + + @override + Widget build(BuildContext context) { + updateScreenType(context); + + return Navigator( + key: navigatorKey, + pages: [ + MaterialPage( + key: const ValueKey('MainPage'), + child: Builder( + builder: (context) { + materialPageContext = context; + return GestureDetector( + onTap: () => + globalCancelBloc.runDropdownDismiss(context: context), + child: MainLayout(), + ); + }, + ), + ), + ], + onPopPage: (route, dynamic result) => route.didPop(result), + ); + } + + @override + Future setNewRoutePath(AppRoutePath configuration) async { + final configurationToSet = routingState.isBrowserNavigationBlocked + ? currentConfiguration + : configuration; + + if (configurationToSet is WalletRoutePath) { + _setNewWalletRoutePath(configurationToSet); + } else if (configurationToSet is FiatRoutePath) { + _setNewFiatRoutePath(configurationToSet); + } else if (configurationToSet is DexRoutePath) { + _setNewDexRoutePath(configurationToSet); + } else if (configurationToSet is BridgeRoutePath) { + _setNewBridgeRoutePath(configurationToSet); + } else if (configurationToSet is NftRoutePath) { + _setNewNftsRoutePath(configurationToSet); + } else if (configurationToSet is SettingsRoutePath) { + _setNewSettingsRoutePath(configurationToSet); + } else if (configurationToSet is MarketMakerBotRoutePath) { + _setNewMarketMakerBotRoutePath(configurationToSet); + } else { + routingState.reset(); + } + } + + void _setNewWalletRoutePath(WalletRoutePath path) { + routingState.selectedMenu = MainMenuValue.wallet; + if (path.abbr.isNotEmpty) { + routingState.walletState.selectedCoin = path.abbr.toUpperCase(); + } else if (path.action.isNotEmpty) { + routingState.walletState.action = path.action; + } else { + routingState.resetDataForPageContent(); + } + } + + void _setNewBridgeRoutePath(BridgeRoutePath path) { + routingState.selectedMenu = MainMenuValue.bridge; + routingState.bridgeState.action = path.action; + routingState.bridgeState.uuid = path.uuid; + } + + void _setNewNftsRoutePath(NftRoutePath path) { + routingState.selectedMenu = MainMenuValue.nft; + routingState.nftsState.uuid = path.uuid; + routingState.nftsState.pageState = path.pageState; + } + + void _setNewFiatRoutePath(FiatRoutePath path) { + routingState.selectedMenu = MainMenuValue.fiat; + routingState.fiatState.action = path.action; + routingState.fiatState.uuid = path.uuid; + } + + void _setNewDexRoutePath(DexRoutePath path) { + routingState.selectedMenu = MainMenuValue.dex; + routingState.dexState.action = path.action; + routingState.dexState.uuid = path.uuid; + } + + void _setNewMarketMakerBotRoutePath(MarketMakerBotRoutePath path) { + routingState.selectedMenu = MainMenuValue.marketMakerBot; + routingState.marketMakerState.action = path.action; + routingState.marketMakerState.uuid = path.uuid; + } + + void _setNewSettingsRoutePath(SettingsRoutePath path) { + routingState.selectedMenu = MainMenuValue.settings; + routingState.settingsState.selectedMenu = path.selectedMenu; + } + + Map get _menuConfiguration { + return { + MainMenuValue.wallet: _currentWalletConfiguration, + MainMenuValue.fiat: _currentFiatConfiguration, + MainMenuValue.dex: _currentDexConfiguration, + MainMenuValue.bridge: _currentBridgeConfiguration, + MainMenuValue.marketMakerBot: _currentMarketMakerBotConfiguration, + MainMenuValue.nft: _currentNftConfiguration, + MainMenuValue.settings: _currentSettingsConfiguration, + MainMenuValue.support: _currentSettingsConfiguration, + }; + } + + @override + AppRoutePath? get currentConfiguration { + return _menuConfiguration.containsKey(routingState.selectedMenu) + ? _menuConfiguration[routingState.selectedMenu] + : null; + } + + AppRoutePath get _currentWalletConfiguration { + if (routingState.walletState.selectedCoin.isNotEmpty) { + return WalletRoutePath.coinDetails(routingState.walletState.selectedCoin); + } else if (routingState.walletState.action.isNotEmpty) { + return WalletRoutePath.action(routingState.walletState.action); + } + return WalletRoutePath.wallet(); + } + + AppRoutePath get _currentFiatConfiguration { + if (routingState.fiatState.action == FiatAction.tradingDetails) { + return FiatRoutePath.swapDetails( + routingState.fiatState.action, + routingState.fiatState.uuid, + ); + } + + return FiatRoutePath.fiat(); + } + + AppRoutePath get _currentDexConfiguration { + if (routingState.dexState.action == DexAction.tradingDetails) { + return DexRoutePath.swapDetails( + routingState.dexState.action, + routingState.dexState.uuid, + ); + } + + return DexRoutePath.dex(); + } + + AppRoutePath get _currentMarketMakerBotConfiguration { + if (routingState.marketMakerState.action == + MarketMakerBotAction.tradingDetails) { + return MarketMakerBotRoutePath.swapDetails( + routingState.marketMakerState.action, + routingState.marketMakerState.uuid, + ); + } + + return MarketMakerBotRoutePath.marketMakerBot(); + } + + AppRoutePath get _currentBridgeConfiguration { + if (routingState.bridgeState.action == BridgeAction.tradingDetails) { + return BridgeRoutePath.swapDetails( + routingState.bridgeState.action, + routingState.bridgeState.uuid, + ); + } + + return BridgeRoutePath.bridge(); + } + + AppRoutePath get _currentNftConfiguration { + switch (routingState.nftsState.pageState) { + case NFTSelectedState.send: + return NftRoutePath.nftDetails(routingState.nftsState.uuid, true); + case NFTSelectedState.details: + return NftRoutePath.nftDetails(routingState.nftsState.uuid, false); + case NFTSelectedState.receive: + return NftRoutePath.nftReceive(); + case NFTSelectedState.transactions: + return NftRoutePath.nftTransactions(); + case NFTSelectedState.none: + return NftRoutePath.nfts(); + } + } + + AppRoutePath get _currentSettingsConfiguration { + switch (routingState.settingsState.selectedMenu) { + case SettingsMenuValue.general: + return SettingsRoutePath.general(); + case SettingsMenuValue.security: + return SettingsRoutePath.security(); + case SettingsMenuValue.support: + return SettingsRoutePath.support(); + case SettingsMenuValue.feedback: + return SettingsRoutePath.feedback(); + case SettingsMenuValue.none: + return SettingsRoutePath.root(); + } + } +} diff --git a/lib/router/navigators/back_dispatcher.dart b/lib/router/navigators/back_dispatcher.dart new file mode 100644 index 0000000000..2e803e8665 --- /dev/null +++ b/lib/router/navigators/back_dispatcher.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/router/navigators/app_router_delegate.dart'; + +class AirDexBackButtonDispatcher extends RootBackButtonDispatcher { + AirDexBackButtonDispatcher(this._routerDelegate) : super(); + + final AppRouterDelegate _routerDelegate; + + @override + Future didPopRoute() { + return _routerDelegate.popRoute(); + } +} diff --git a/lib/router/navigators/main_layout/main_layout_router.dart b/lib/router/navigators/main_layout/main_layout_router.dart new file mode 100644 index 0000000000..d1fe561e2d --- /dev/null +++ b/lib/router/navigators/main_layout/main_layout_router.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/router/navigators/main_layout/main_layout_router_delegate.dart'; + +class MainLayoutRouter extends StatefulWidget { + @override + State createState() => _MainLayoutRouterState(); +} + +class _MainLayoutRouterState extends State { + final MainLayoutRouterDelegate _routerDelegate = MainLayoutRouterDelegate(); + + @override + Widget build(BuildContext context) { + return Router( + routerDelegate: _routerDelegate, + ); + } +} diff --git a/lib/router/navigators/main_layout/main_layout_router_delegate.dart b/lib/router/navigators/main_layout/main_layout_router_delegate.dart new file mode 100644 index 0000000000..e77f3bcf36 --- /dev/null +++ b/lib/router/navigators/main_layout/main_layout_router_delegate.dart @@ -0,0 +1,102 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/router/navigators/page_content/page_content_router.dart'; +import 'package:web_dex/router/navigators/page_menu/page_menu_router.dart'; +import 'package:web_dex/router/routes.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/common/main_menu/main_menu_desktop.dart'; + +class MainLayoutRouterDelegate extends RouterDelegate + with ChangeNotifier, PopNavigatorRouterDelegateMixin { + @override + final GlobalKey navigatorKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.topCenter, + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: maxScreenWidth, + ), + child: Builder(builder: (context) { + switch (screenType) { + case ScreenType.mobile: + return _MobileLayout(); + case ScreenType.tablet: + return _TabletLayout(); + case ScreenType.desktop: + return _DesktopLayout(); + } + }), + ), + ); + } + + @override + Future setNewRoutePath(AppRoutePath configuration) async {} +} + +class _DesktopLayout extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + flex: 2, + child: MainMenuDesktop(), + ), + Flexible( + flex: 9, + child: Container( + padding: isWideScreen + ? const EdgeInsets.fromLTRB( + 3, mainLayoutPadding, 0, mainLayoutPadding) + : const EdgeInsets.fromLTRB( + 3, + mainLayoutPadding, + mainLayoutPadding, + mainLayoutPadding, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: PageContentRouter()), + ], + ), + ), + ), + ], + ); + } +} + +class _TabletLayout extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: PageContentRouter()), + ], + ); + } +} + +class _MobileLayout extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: routingState.isPageContentShown + ? PageContentRouter() + : PageMenuRouter(), + ), + ], + ); + } +} diff --git a/lib/router/navigators/page_content/page_content_router.dart b/lib/router/navigators/page_content/page_content_router.dart new file mode 100644 index 0000000000..0e5e180718 --- /dev/null +++ b/lib/router/navigators/page_content/page_content_router.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/router/navigators/page_content/page_content_router_delegate.dart'; + +class PageContentRouter extends StatefulWidget { + @override + State createState() => _PageContentRouterState(); +} + +class _PageContentRouterState extends State { + final PageContentRouterDelegate _routerDelegate = PageContentRouterDelegate(); + + @override + Widget build(BuildContext context) { + return Router( + routerDelegate: _routerDelegate, + ); + } +} diff --git a/lib/router/navigators/page_content/page_content_router_delegate.dart b/lib/router/navigators/page_content/page_content_router_delegate.dart new file mode 100644 index 0000000000..50e3151e32 --- /dev/null +++ b/lib/router/navigators/page_content/page_content_router_delegate.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/router/routes.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/bridge/bridge_page.dart'; +import 'package:web_dex/views/dex/dex_page.dart'; +import 'package:web_dex/views/fiat/fiat_page.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_page.dart'; +import 'package:web_dex/views/nfts/nft_page.dart'; +import 'package:web_dex/views/settings/settings_page.dart'; +import 'package:web_dex/views/settings/widgets/support_page/support_page.dart'; +import 'package:web_dex/views/wallet/wallet_page/wallet_page.dart'; + +class PageContentRouterDelegate extends RouterDelegate + with ChangeNotifier, PopNavigatorRouterDelegateMixin { + @override + final GlobalKey navigatorKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + switch (routingState.selectedMenu) { + case MainMenuValue.wallet: + return WalletPage( + coinAbbr: routingState.walletState.selectedCoin, + action: routingState.walletState.coinsManagerAction, + ); + case MainMenuValue.fiat: + return const FiatPage(); + case MainMenuValue.dex: + return DexPage(); + case MainMenuValue.bridge: + return const BridgePage(); + case MainMenuValue.marketMakerBot: + return const MarketMakerBotPage(); + case MainMenuValue.nft: + return NftPage( + key: const Key('nft-page'), + pageState: routingState.nftsState.pageState, + uuid: routingState.nftsState.uuid, + ); + case MainMenuValue.settings: + return SettingsPage( + selectedMenu: routingState.settingsState.selectedMenu); + case MainMenuValue.support: + return SupportPage(); + default: + return WalletPage( + coinAbbr: routingState.walletState.selectedCoin, + action: routingState.walletState.coinsManagerAction, + ); + } + } + + @override + Future setNewRoutePath(AppRoutePath configuration) async {} +} diff --git a/lib/router/navigators/page_menu/page_menu_router.dart b/lib/router/navigators/page_menu/page_menu_router.dart new file mode 100644 index 0000000000..2aaf026b08 --- /dev/null +++ b/lib/router/navigators/page_menu/page_menu_router.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/router/navigators/page_menu/page_menu_router_delegate.dart'; + +class PageMenuRouter extends StatefulWidget { + @override + State createState() => _PageMenuRouterState(); +} + +class _PageMenuRouterState extends State { + final PageMenuRouterDelegate _routerDelegate = PageMenuRouterDelegate(); + + @override + Widget build(BuildContext context) { + return Router( + routerDelegate: _routerDelegate, + ); + } +} diff --git a/lib/router/navigators/page_menu/page_menu_router_delegate.dart b/lib/router/navigators/page_menu/page_menu_router_delegate.dart new file mode 100644 index 0000000000..dfadebf338 --- /dev/null +++ b/lib/router/navigators/page_menu/page_menu_router_delegate.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/router/routes.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/bridge/bridge_page.dart'; +import 'package:web_dex/views/dex/dex_page.dart'; +import 'package:web_dex/views/fiat/fiat_page.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_page.dart'; +import 'package:web_dex/views/nfts/nft_page.dart'; +import 'package:web_dex/views/settings/settings_page.dart'; +import 'package:web_dex/views/settings/widgets/support_page/support_page.dart'; +import 'package:web_dex/views/wallet/wallet_page/wallet_page.dart'; + +class PageMenuRouterDelegate extends RouterDelegate + with ChangeNotifier, PopNavigatorRouterDelegateMixin { + @override + final GlobalKey navigatorKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + const empty = SizedBox(); + switch (routingState.selectedMenu) { + case MainMenuValue.wallet: + return WalletPage( + coinAbbr: routingState.walletState.selectedCoin, + action: routingState.walletState.coinsManagerAction, + ); + case MainMenuValue.fiat: + return isMobile ? const FiatPage() : empty; + case MainMenuValue.dex: + return isMobile ? DexPage() : empty; + case MainMenuValue.bridge: + return isMobile ? const BridgePage() : empty; + case MainMenuValue.marketMakerBot: + return isMobile ? const MarketMakerBotPage() : empty; + case MainMenuValue.nft: + return isMobile + ? NftPage( + key: const Key('nft-page'), + pageState: routingState.nftsState.pageState, + uuid: routingState.nftsState.uuid, + ) + : empty; + case MainMenuValue.settings: + return isMobile + ? SettingsPage( + selectedMenu: routingState.settingsState.selectedMenu, + ) + : empty; + case MainMenuValue.support: + return isMobile + ? WalletPage( + coinAbbr: routingState.walletState.selectedCoin, + action: routingState.walletState.coinsManagerAction, + ) + : SupportPage(); + default: + return WalletPage( + coinAbbr: routingState.walletState.selectedCoin, + action: routingState.walletState.coinsManagerAction, + ); + } + } + + @override + Future setNewRoutePath(AppRoutePath configuration) async {} +} diff --git a/lib/router/parsers/base_route_parser.dart b/lib/router/parsers/base_route_parser.dart new file mode 100644 index 0000000000..6616689baa --- /dev/null +++ b/lib/router/parsers/base_route_parser.dart @@ -0,0 +1,5 @@ +import 'package:web_dex/router/routes.dart'; + +abstract class BaseRouteParser { + AppRoutePath getRoutePath(Uri uri); +} diff --git a/lib/router/parsers/bridge_route_parser.dart b/lib/router/parsers/bridge_route_parser.dart new file mode 100644 index 0000000000..68b0dc2865 --- /dev/null +++ b/lib/router/parsers/bridge_route_parser.dart @@ -0,0 +1,22 @@ +import 'package:web_dex/router/parsers/base_route_parser.dart'; +import 'package:web_dex/router/routes.dart'; +import 'package:web_dex/router/state/bridge_section_state.dart'; + +class _BridgeRouteParser implements BaseRouteParser { + const _BridgeRouteParser(); + + @override + AppRoutePath getRoutePath(Uri uri) { + if (uri.pathSegments.length == 3) { + if (uri.pathSegments[1] == 'trading_details' && + uri.pathSegments[2].isNotEmpty) { + return BridgeRoutePath.swapDetails( + BridgeAction.tradingDetails, uri.pathSegments[2]); + } + } + + return BridgeRoutePath.bridge(); + } +} + +const bridgeRouteParser = _BridgeRouteParser(); diff --git a/lib/router/parsers/dex_route_parser.dart b/lib/router/parsers/dex_route_parser.dart new file mode 100644 index 0000000000..8beeb65c47 --- /dev/null +++ b/lib/router/parsers/dex_route_parser.dart @@ -0,0 +1,21 @@ +import 'package:web_dex/router/parsers/base_route_parser.dart'; +import 'package:web_dex/router/routes.dart'; +import 'package:web_dex/router/state/dex_state.dart'; + +class _DexRouteParser implements BaseRouteParser { + const _DexRouteParser(); + @override + AppRoutePath getRoutePath(Uri uri) { + if (uri.pathSegments.length == 3) { + if (uri.pathSegments[1] == 'trading_details' && + uri.pathSegments[2].isNotEmpty) { + return DexRoutePath.swapDetails( + DexAction.tradingDetails, uri.pathSegments[2]); + } + } + + return DexRoutePath.dex(); + } +} + +const dexRouteParser = _DexRouteParser(); diff --git a/lib/router/parsers/fiat_route_parser.dart b/lib/router/parsers/fiat_route_parser.dart new file mode 100644 index 0000000000..7971b6d5c5 --- /dev/null +++ b/lib/router/parsers/fiat_route_parser.dart @@ -0,0 +1,22 @@ +import 'package:web_dex/router/parsers/base_route_parser.dart'; +import 'package:web_dex/router/routes.dart'; +import 'package:web_dex/router/state/fiat_state.dart'; + +class _FiatRouteParser implements BaseRouteParser { + const _FiatRouteParser(); + + @override + AppRoutePath getRoutePath(Uri uri) { + if (uri.pathSegments.length == 3) { + if (uri.pathSegments[1] == 'trading_details' && + uri.pathSegments[2].isNotEmpty) { + return FiatRoutePath.swapDetails( + FiatAction.tradingDetails, uri.pathSegments[2]); + } + } + + return FiatRoutePath.fiat(); + } +} + +const fiatRouteParser = _FiatRouteParser(); diff --git a/lib/router/parsers/nft_route_parser.dart b/lib/router/parsers/nft_route_parser.dart new file mode 100644 index 0000000000..9a51245730 --- /dev/null +++ b/lib/router/parsers/nft_route_parser.dart @@ -0,0 +1,23 @@ +import 'package:web_dex/router/parsers/base_route_parser.dart'; +import 'package:web_dex/router/routes.dart'; + +class _NFTsRouteParser implements BaseRouteParser { + const _NFTsRouteParser(); + + @override + AppRoutePath getRoutePath(Uri uri) { + if (uri.pathSegments.length == 2) { + if (uri.pathSegments[1] == 'receive') { + return NftRoutePath.nftReceive(); + } else if (uri.pathSegments[1] == 'transactions') { + return NftRoutePath.nftTransactions(); + } else if (uri.pathSegments[1].isNotEmpty) { + return NftRoutePath.nftDetails(uri.pathSegments[1], false); + } + } + + return NftRoutePath.nfts(); + } +} + +const nftRouteParser = _NFTsRouteParser(); diff --git a/lib/router/parsers/root_route_parser.dart b/lib/router/parsers/root_route_parser.dart new file mode 100644 index 0000000000..56b59321a3 --- /dev/null +++ b/lib/router/parsers/root_route_parser.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/first_uri_segment.dart'; +import 'package:web_dex/router/parsers/base_route_parser.dart'; +import 'package:web_dex/router/parsers/bridge_route_parser.dart'; +import 'package:web_dex/router/parsers/dex_route_parser.dart'; +import 'package:web_dex/router/parsers/fiat_route_parser.dart'; +import 'package:web_dex/router/parsers/nft_route_parser.dart'; +import 'package:web_dex/router/parsers/settings_route_parser.dart'; +import 'package:web_dex/router/parsers/wallet_route_parser.dart'; +import 'package:web_dex/router/routes.dart'; + +class RootRouteInformationParser extends RouteInformationParser { + final Map _parsers = { + firstUriSegment.wallet: walletRouteParser, + firstUriSegment.fiat: fiatRouteParser, + firstUriSegment.dex: dexRouteParser, + firstUriSegment.bridge: bridgeRouteParser, + firstUriSegment.nfts: nftRouteParser, + firstUriSegment.settings: settingsRouteParser, + }; + + @override + Future parseRouteInformation( + RouteInformation routeInformation) async { + final uri = Uri.parse(routeInformation.uri.path); + final BaseRouteParser parser = _getRoutParser(uri); + + return parser.getRoutePath(uri); + } + + @override + RouteInformation restoreRouteInformation(AppRoutePath configuration) { + return RouteInformation(uri: Uri.parse(configuration.location)); + } + + BaseRouteParser _getRoutParser(Uri uri) { + if (uri.pathSegments.isEmpty) return dexRouteParser; + return _parsers[uri.pathSegments.first] ?? dexRouteParser; + } +} diff --git a/lib/router/parsers/settings_route_parser.dart b/lib/router/parsers/settings_route_parser.dart new file mode 100644 index 0000000000..e862d3a2ae --- /dev/null +++ b/lib/router/parsers/settings_route_parser.dart @@ -0,0 +1,29 @@ +import 'package:web_dex/router/parsers/base_route_parser.dart'; +import 'package:web_dex/router/routes.dart'; + +class _SettingsRouteParser implements BaseRouteParser { + const _SettingsRouteParser(); + + @override + AppRoutePath getRoutePath(Uri uri) { + if (uri.pathSegments.length < 2) { + return SettingsRoutePath.root(); + } + + if (uri.pathSegments[1] == 'general') { + return SettingsRoutePath.general(); + } + + if (uri.pathSegments[1] == 'security') { + return SettingsRoutePath.security(); + } + + if (uri.pathSegments[1] == 'feedback') { + return SettingsRoutePath.feedback(); + } + + return SettingsRoutePath.root(); + } +} + +const settingsRouteParser = _SettingsRouteParser(); diff --git a/lib/router/parsers/wallet_route_parser.dart b/lib/router/parsers/wallet_route_parser.dart new file mode 100644 index 0000000000..167707fdd3 --- /dev/null +++ b/lib/router/parsers/wallet_route_parser.dart @@ -0,0 +1,27 @@ +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/router/parsers/base_route_parser.dart'; +import 'package:web_dex/router/routes.dart'; + +class _WalletRouteParser implements BaseRouteParser { + const _WalletRouteParser(); + + @override + AppRoutePath getRoutePath(Uri uri) { + if (uri.pathSegments.length < 2) { + return WalletRoutePath.wallet(); + } + if (uri.pathSegments[1] == 'add-assets' || + uri.pathSegments[1] == 'remove-assets') { + return WalletRoutePath.action(uri.pathSegments[1]); + } + + final Coin? coin = coinsBloc.getWalletCoin(uri.pathSegments[1]); + + return coin == null + ? WalletRoutePath.wallet() + : WalletRoutePath.coinDetails(coin.abbr); + } +} + +const walletRouteParser = _WalletRouteParser(); diff --git a/lib/router/routes.dart b/lib/router/routes.dart new file mode 100644 index 0000000000..cb4d3050ff --- /dev/null +++ b/lib/router/routes.dart @@ -0,0 +1,126 @@ +import 'package:web_dex/model/first_uri_segment.dart'; +import 'package:web_dex/model/settings_menu_value.dart'; +import 'package:web_dex/router/state/bridge_section_state.dart'; +import 'package:web_dex/router/state/dex_state.dart'; +import 'package:web_dex/router/state/fiat_state.dart'; +import 'package:web_dex/router/state/market_maker_bot_state.dart'; +import 'package:web_dex/router/state/nfts_state.dart'; + +abstract class AppRoutePath { + final String location = ''; +} + +class WalletRoutePath implements AppRoutePath { + WalletRoutePath.wallet() : location = '/${firstUriSegment.wallet}'; + + WalletRoutePath.coinDetails(this.abbr) + : location = '/${firstUriSegment.wallet}/${abbr.toLowerCase()}'; + WalletRoutePath.action(this.action) + : location = '/${firstUriSegment.wallet}/$action'; + + String abbr = ''; + String action = ''; + + @override + final String location; +} + +class FiatRoutePath implements AppRoutePath { + FiatRoutePath.fiat() + : location = '/${firstUriSegment.fiat}', + uuid = ''; + FiatRoutePath.swapDetails(this.action, this.uuid) + : location = '/${firstUriSegment.fiat}/trading_details/$uuid'; + + @override + final String location; + final String uuid; + FiatAction action = FiatAction.none; +} + +class DexRoutePath implements AppRoutePath { + DexRoutePath.dex() + : location = '/${firstUriSegment.dex}', + uuid = ''; + DexRoutePath.swapDetails(this.action, this.uuid) + : location = '/${firstUriSegment.dex}/trading_details/$uuid'; + + @override + final String location; + final String uuid; + DexAction action = DexAction.none; +} + +class BridgeRoutePath implements AppRoutePath { + BridgeRoutePath.bridge() + : location = '/${firstUriSegment.bridge}', + uuid = ''; + BridgeRoutePath.swapDetails(this.action, this.uuid) + : location = '/${firstUriSegment.bridge}/trading_details/$uuid'; + + @override + final String location; + final String uuid; + BridgeAction action = BridgeAction.none; +} + +class NftRoutePath implements AppRoutePath { + NftRoutePath.nfts() + : location = '/${firstUriSegment.nfts}', + uuid = '', + pageState = NFTSelectedState.none; + NftRoutePath.nftDetails(this.uuid, bool isSend) + : location = '/${firstUriSegment.nfts}/$uuid', + pageState = isSend ? NFTSelectedState.send : NFTSelectedState.details; + NftRoutePath.nftReceive() + : location = '/${firstUriSegment.nfts}/receive', + uuid = '', + pageState = NFTSelectedState.receive; + NftRoutePath.nftTransactions() + : location = '/${firstUriSegment.nfts}/transactions', + pageState = NFTSelectedState.transactions, + uuid = ''; + + @override + final String location; + final String uuid; + final NFTSelectedState pageState; +} + +class MarketMakerBotRoutePath implements AppRoutePath { + MarketMakerBotRoutePath.marketMakerBot() + : location = '/${firstUriSegment.marketMakerBot}', + uuid = ''; + MarketMakerBotRoutePath.swapDetails(this.action, this.uuid) + : location = '/${firstUriSegment.marketMakerBot}/trading_details/$uuid'; + + @override + final String location; + final String uuid; + MarketMakerBotAction action = MarketMakerBotAction.none; +} + +class SettingsRoutePath implements AppRoutePath { + SettingsRoutePath.root() + : location = '/${firstUriSegment.settings}', + selectedMenu = SettingsMenuValue.none; + SettingsRoutePath.general() + : location = '/${firstUriSegment.settings}/general', + selectedMenu = SettingsMenuValue.general; + SettingsRoutePath.security() + : location = '/${firstUriSegment.settings}/security', + selectedMenu = SettingsMenuValue.security; + SettingsRoutePath.passwordUpdate() + : location = '/${firstUriSegment.settings}/security/passwordUpdate', + selectedMenu = SettingsMenuValue.security; + SettingsRoutePath.support() + : location = '/${firstUriSegment.settings}/support', + selectedMenu = SettingsMenuValue.support; + SettingsRoutePath.feedback() + : location = '/${firstUriSegment.settings}/feedback', + selectedMenu = SettingsMenuValue.feedback; + + @override + final String location; + final SettingsMenuValue selectedMenu; +} diff --git a/lib/router/state/bridge_section_state.dart b/lib/router/state/bridge_section_state.dart new file mode 100644 index 0000000000..cd4bbaec3c --- /dev/null +++ b/lib/router/state/bridge_section_state.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/router/state/menu_state_interface.dart'; + +class BridgeSectionState extends ChangeNotifier implements IResettableOnLogout { + BridgeSectionState() + : _action = BridgeAction.none, + _uuid = ''; + + BridgeAction _action; + String _uuid; + + set action(BridgeAction action) { + if (_action == action) { + return; + } + _action = action; + notifyListeners(); + } + + void setDetailsAction(String uuid) { + _uuid = uuid; + _action = BridgeAction.tradingDetails; + notifyListeners(); + } + + BridgeAction get action => _action; + + set uuid(String uuid) { + _uuid = uuid; + notifyListeners(); + } + + String get uuid => _uuid; + + @override + void reset() { + action = BridgeAction.none; + } + + @override + void resetOnLogOut() { + action = BridgeAction.none; + } +} + +enum BridgeAction { + tradingDetails, + none, +} diff --git a/lib/router/state/dex_state.dart b/lib/router/state/dex_state.dart new file mode 100644 index 0000000000..a31aa70f0e --- /dev/null +++ b/lib/router/state/dex_state.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/router/state/menu_state_interface.dart'; + +class DexState extends ChangeNotifier implements IResettableOnLogout { + DexState() + : _action = DexAction.none, + _uuid = ''; + + DexAction _action; + String _uuid; + + set action(DexAction action) { + if (_action == action) { + return; + } + + _action = action; + notifyListeners(); + } + + void setDetailsAction(String uuid) { + _uuid = uuid; + _action = DexAction.tradingDetails; + notifyListeners(); + } + + DexAction get action => _action; + + bool get isTradingDetails => _action == DexAction.tradingDetails; + + set uuid(String uuid) { + _uuid = uuid; + notifyListeners(); + } + + String get uuid => _uuid; + + @override + void reset() { + action = DexAction.none; + } + + @override + void resetOnLogOut() { + action = DexAction.none; + } +} + +enum DexAction { + tradingDetails, + none, +} diff --git a/lib/router/state/fiat_state.dart b/lib/router/state/fiat_state.dart new file mode 100644 index 0000000000..938e1ab2f1 --- /dev/null +++ b/lib/router/state/fiat_state.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/router/state/menu_state_interface.dart'; + +class FiatState extends ChangeNotifier implements IResettableOnLogout { + FiatState() + : _action = FiatAction.none, + _uuid = ''; + + FiatAction _action; + String _uuid; + + set action(FiatAction action) { + if (_action == action) { + return; + } + + _action = action; + notifyListeners(); + } + + void setDetailsAction(String uuid) { + _uuid = uuid; + _action = FiatAction.tradingDetails; + notifyListeners(); + } + + FiatAction get action => _action; + + bool get isTradingDetails => _action == FiatAction.tradingDetails; + + set uuid(String uuid) { + _uuid = uuid; + notifyListeners(); + } + + String get uuid => _uuid; + + @override + void reset() { + action = FiatAction.none; + } + + @override + void resetOnLogOut() { + action = FiatAction.none; + } +} + +enum FiatAction { + tradingDetails, + none, +} diff --git a/lib/router/state/main_menu_state.dart b/lib/router/state/main_menu_state.dart new file mode 100644 index 0000000000..176e53f85d --- /dev/null +++ b/lib/router/state/main_menu_state.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/main_menu_value.dart'; + +class MainMenuState extends ChangeNotifier { + MainMenuState() : _selectedMenu = MainMenuValue.none; + + MainMenuValue _selectedMenu; + + MainMenuValue get selectedMenu => _selectedMenu; + + set selectedMenu(MainMenuValue menu) { + _selectedMenu = menu; + notifyListeners(); + } + + void reset() { + selectedMenu = MainMenuValue.wallet; + } +} diff --git a/lib/router/state/market_maker_bot_state.dart b/lib/router/state/market_maker_bot_state.dart new file mode 100644 index 0000000000..9bfc9afb10 --- /dev/null +++ b/lib/router/state/market_maker_bot_state.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/router/state/menu_state_interface.dart'; + +class MarketMakerBotState extends ChangeNotifier + implements IResettableOnLogout { + MarketMakerBotState() + : _action = MarketMakerBotAction.none, + _uuid = ''; + + MarketMakerBotAction _action; + String _uuid; + + set action(MarketMakerBotAction action) { + if (_action == action) { + return; + } + + _action = action; + notifyListeners(); + } + + void setDetailsAction(String uuid) { + _uuid = uuid; + _action = MarketMakerBotAction.tradingDetails; + notifyListeners(); + } + + MarketMakerBotAction get action => _action; + + bool get isTradingDetails => _action == MarketMakerBotAction.tradingDetails; + + set uuid(String uuid) { + _uuid = uuid; + notifyListeners(); + } + + String get uuid => _uuid; + + @override + void reset() { + action = MarketMakerBotAction.none; + } + + @override + void resetOnLogOut() { + action = MarketMakerBotAction.none; + } +} + +enum MarketMakerBotAction { + tradingDetails, + none, +} diff --git a/lib/router/state/menu_state_interface.dart b/lib/router/state/menu_state_interface.dart new file mode 100644 index 0000000000..4464b96ede --- /dev/null +++ b/lib/router/state/menu_state_interface.dart @@ -0,0 +1,4 @@ +abstract class IResettableOnLogout { + void reset(); + void resetOnLogOut(); +} diff --git a/lib/router/state/nfts_state.dart b/lib/router/state/nfts_state.dart new file mode 100644 index 0000000000..1c5ea33b66 --- /dev/null +++ b/lib/router/state/nfts_state.dart @@ -0,0 +1,73 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/router/state/menu_state_interface.dart'; + +class NFTsState extends ChangeNotifier implements IResettableOnLogout { + NFTsState() + : _selectedNftIndex = '', + _uuid = '', + _pageState = NFTSelectedState.none; + + String _selectedNftIndex; + String _uuid; + NFTSelectedState _pageState; + + String get selectedNftIndex => _selectedNftIndex; + set selectedNftIndex(String nftIndex) { + if (_selectedNftIndex == nftIndex) { + return; + } + _selectedNftIndex = nftIndex; + notifyListeners(); + } + + String get uuid => _uuid; + set uuid(String uuid) { + _uuid = uuid; + notifyListeners(); + } + + NFTSelectedState get pageState => _pageState; + set pageState(NFTSelectedState action) { + if (_pageState == action) { + return; + } + _pageState = action; + notifyListeners(); + } + + void setDetailsAction(String uuid, bool isSend) { + _uuid = uuid; + _pageState = isSend ? NFTSelectedState.send : NFTSelectedState.details; + notifyListeners(); + } + + void setReceiveAction() { + _pageState = NFTSelectedState.receive; + notifyListeners(); + } + + void setTransactionsAction() { + _pageState = NFTSelectedState.transactions; + notifyListeners(); + } + + @override + void reset() { + uuid = ''; + pageState = NFTSelectedState.none; + } + + @override + void resetOnLogOut() { + uuid = ''; + pageState = NFTSelectedState.none; + } +} + +enum NFTSelectedState { + details, + send, + receive, + transactions, + none, +} diff --git a/lib/router/state/routing_state.dart b/lib/router/state/routing_state.dart new file mode 100644 index 0000000000..c35b2e784f --- /dev/null +++ b/lib/router/state/routing_state.dart @@ -0,0 +1,84 @@ +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/model/settings_menu_value.dart'; +import 'package:web_dex/router/state/bridge_section_state.dart'; +import 'package:web_dex/router/state/dex_state.dart'; +import 'package:web_dex/router/state/fiat_state.dart'; +import 'package:web_dex/router/state/main_menu_state.dart'; +import 'package:web_dex/router/state/market_maker_bot_state.dart'; +import 'package:web_dex/router/state/nfts_state.dart'; +import 'package:web_dex/router/state/settings_section_state.dart'; +import 'package:web_dex/router/state/wallet_state.dart'; + +class RoutingState { + final WalletState walletState = WalletState(); + final FiatState fiatState = FiatState(); + final DexState dexState = DexState(); + final BridgeSectionState bridgeState = BridgeSectionState(); + final MarketMakerBotState marketMakerState = MarketMakerBotState(); + final NFTsState nftsState = NFTsState(); + final SettingsSectionState settingsState = SettingsSectionState(); + final MainMenuState _mainMenu = MainMenuState(); + + MainMenuValue get selectedMenu => _mainMenu.selectedMenu; + bool isBrowserNavigationBlocked = false; + + set selectedMenu(MainMenuValue menu) { + if (_shouldCallResetWhenMenuChanged(menu)) { + reset(); + } + _mainMenu.selectedMenu = menu; + if (menu == MainMenuValue.settings && !isMobile) { + settingsState.selectedMenu = SettingsMenuValue.general; + } + } + + bool get isPageContentShown { + return walletState.selectedCoin.isNotEmpty || walletState.action.isNotEmpty; + } + + void resetDataForPageContent() { + walletState.reset(); + } + + void reset() { + walletState.reset(); + fiatState.reset(); + dexState.reset(); + bridgeState.reset(); + marketMakerState.reset(); + nftsState.reset(); + settingsState.reset(); + } + + void addListener(void Function() notifyListeners) { + _mainMenu.addListener(notifyListeners); + walletState.addListener(notifyListeners); + fiatState.addListener(notifyListeners); + dexState.addListener(notifyListeners); + bridgeState.addListener(notifyListeners); + marketMakerState.addListener(notifyListeners); + nftsState.addListener(notifyListeners); + settingsState.addListener(notifyListeners); + } + + bool _shouldCallResetWhenMenuChanged(MainMenuValue menu) { + if (_mainMenu.selectedMenu != menu) return true; + if (_mainMenu.selectedMenu == menu && + menu == MainMenuValue.settings && + !isMobile) return false; + return true; + } + + void resetOnLogOut() { + walletState.resetOnLogOut(); + fiatState.resetOnLogOut(); + dexState.resetOnLogOut(); + bridgeState.resetOnLogOut(); + marketMakerState.resetOnLogOut(); + nftsState.resetOnLogOut(); + settingsState.resetOnLogOut(); + } +} + +RoutingState routingState = RoutingState(); diff --git a/lib/router/state/settings_section_state.dart b/lib/router/state/settings_section_state.dart new file mode 100644 index 0000000000..a0683a75c1 --- /dev/null +++ b/lib/router/state/settings_section_state.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/model/settings_menu_value.dart'; +import 'package:web_dex/router/state/menu_state_interface.dart'; +import 'package:web_dex/router/state/routing_state.dart'; + +class SettingsSectionState extends ChangeNotifier + implements IResettableOnLogout { + SettingsMenuValue _selectedMenu = SettingsMenuValue.none; + + set selectedMenu(SettingsMenuValue menu) { + if (_selectedMenu == menu) { + return; + } + + final isSecurity = menu == SettingsMenuValue.security; + final showSecurity = currentWalletBloc.wallet?.isHW == false; + if (isSecurity && !showSecurity) return; + + _selectedMenu = menu; + notifyListeners(); + } + + SettingsMenuValue get selectedMenu { + return _selectedMenu; + } + + bool get isNone { + return _selectedMenu == SettingsMenuValue.none; + } + + @override + void reset() { + _selectedMenu = SettingsMenuValue.none; + } + + Future openSecurity() async { + routingState.selectedMenu = MainMenuValue.settings; + selectedMenu = SettingsMenuValue.security; + } + + @override + void resetOnLogOut() { + if (selectedMenu == SettingsMenuValue.security) { + selectedMenu = SettingsMenuValue.general; + } + } +} diff --git a/lib/router/state/wallet_state.dart b/lib/router/state/wallet_state.dart new file mode 100644 index 0000000000..eac36a9cbf --- /dev/null +++ b/lib/router/state/wallet_state.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/router/state/menu_state_interface.dart'; + +class WalletState extends ChangeNotifier implements IResettableOnLogout { + WalletState() + : _selectedCoin = '', + _action = ''; + + String _selectedCoin; + String _action; + + String get selectedCoin => _selectedCoin; + set selectedCoin(String abbrCoin) { + if (_selectedCoin == abbrCoin) { + return; + } + + _selectedCoin = abbrCoin; + action = ''; + notifyListeners(); + } + + set action(String action) { + if (_action == action) { + return; + } + _action = action; + _selectedCoin = ''; + notifyListeners(); + } + + String get action => _action; + + CoinsManagerAction get coinsManagerAction { + return coinsManagerRouteAction.toEnum(_action); + } + + @override + void reset() { + selectedCoin = ''; + action = ''; + } + + @override + void resetOnLogOut() { + selectedCoin = ''; + action = ''; + } +} + +class CoinsManagerRouteAction { + final String addAssets = 'add-assets'; + final String removeAssets = 'remove-assets'; + final String none = ''; + + CoinsManagerAction toEnum(String action) { + if (action == addAssets) return CoinsManagerAction.add; + if (action == removeAssets) return CoinsManagerAction.remove; + return CoinsManagerAction.none; + } +} + +final CoinsManagerRouteAction coinsManagerRouteAction = + CoinsManagerRouteAction(); + +enum CoinsManagerAction { + add, + remove, + none, +} diff --git a/lib/services/alpha_version_alert_service/alpha_version_alert_service.dart b/lib/services/alpha_version_alert_service/alpha_version_alert_service.dart new file mode 100644 index 0000000000..a20ee67494 --- /dev/null +++ b/lib/services/alpha_version_alert_service/alpha_version_alert_service.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/app_config/package_information.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/services/storage/base_storage.dart'; +import 'package:web_dex/services/storage/get_storage.dart'; +import 'package:web_dex/shared/widgets/alpha_version_warning.dart'; + +final _serviceStorageKey = + 'alpha_alert_shown_${packageInformation.packageVersion}'; + +class AlphaVersionWarningService { + AlphaVersionWarningService() : _storage = getStorage(); + + final BaseStorage _storage; + Future run() async { + final isShown = await _checkShowingMessageEarlier(); + if (isShown) return; + + PopupDispatcher( + barrierDismissible: false, + maxWidth: 320, + contentPadding: isMobile + ? const EdgeInsets.symmetric(horizontal: 16, vertical: 26) + : const EdgeInsets.all(40.0), + popupContent: AlphaVersionWarning(onAccept: _onAccept), + ).show(); + } + + Future _checkShowingMessageEarlier() async { + return await _storage.read(_serviceStorageKey) ?? false; + } + + Future _onAccept() async { + await _storage.write(_serviceStorageKey, true); + } +} diff --git a/lib/services/app_update_service/app_update_service.dart b/lib/services/app_update_service/app_update_service.dart new file mode 100644 index 0000000000..b066d22144 --- /dev/null +++ b/lib/services/app_update_service/app_update_service.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:web_dex/blocs/update_bloc.dart'; +import 'package:web_dex/shared/constants.dart'; + +const AppUpdateService appUpdateService = AppUpdateService(); + +class AppUpdateService { + const AppUpdateService(); + + Future getUpdateInfo() async { + try { + final http.Response response = await http.post( + Uri.parse(updateCheckerEndpoint), + ); + final Map json = jsonDecode(response.body); + + return UpdateVersionInfo( + status: _getStatus(json['status'] ?? ''), + version: json['new_version'] ?? '', + changelog: json['changelog'] ?? '', + downloadUrl: json['download_url'] ?? '', + ); + } catch (e) { + return null; + } + } + + UpdateStatus _getStatus(String status) { + switch (status) { + case 'upToDate': + return UpdateStatus.upToDate; + + case 'available': + return UpdateStatus.available; + + case 'recommended': + return UpdateStatus.recommended; + + case 'required': + return UpdateStatus.required; + default: + return UpdateStatus.upToDate; + } + } +} diff --git a/lib/services/auth_checker/auth_checker.dart b/lib/services/auth_checker/auth_checker.dart new file mode 100644 index 0000000000..a89b839bab --- /dev/null +++ b/lib/services/auth_checker/auth_checker.dart @@ -0,0 +1,5 @@ +abstract class AuthChecker { + Future askConfirmLoginIfNeeded(String walletEncryptedSeed); + void addSession(String walletEncryptedSeed); + void removeSession(String walletEncryptedSeed); +} diff --git a/lib/services/auth_checker/get_auth_checker.dart b/lib/services/auth_checker/get_auth_checker.dart new file mode 100644 index 0000000000..3da80344e8 --- /dev/null +++ b/lib/services/auth_checker/get_auth_checker.dart @@ -0,0 +1,10 @@ +import 'package:flutter/foundation.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/services/auth_checker/auth_checker.dart'; +import 'package:web_dex/services/auth_checker/mock_auth_checker.dart'; +import 'package:web_dex/services/auth_checker/web_auth_checker.dart'; + +final AuthChecker _authChecker = + kIsWeb ? WebAuthChecker(authRepo: authRepo) : MockAuthChecker(); + +AuthChecker getAuthChecker() => _authChecker; diff --git a/lib/services/auth_checker/mock_auth_checker.dart b/lib/services/auth_checker/mock_auth_checker.dart new file mode 100644 index 0000000000..a4a062d3a9 --- /dev/null +++ b/lib/services/auth_checker/mock_auth_checker.dart @@ -0,0 +1,14 @@ +import 'package:web_dex/services/auth_checker/auth_checker.dart'; + +class MockAuthChecker implements AuthChecker { + @override + Future askConfirmLoginIfNeeded(String? walletEncryptedSeed) async { + return true; + } + + @override + void removeSession(String? walletEncryptedSeed) {} + + @override + void addSession(String walletEncryptedSeed) {} +} diff --git a/lib/services/auth_checker/web_auth_checker.dart b/lib/services/auth_checker/web_auth_checker.dart new file mode 100644 index 0000000000..c25cb294b2 --- /dev/null +++ b/lib/services/auth_checker/web_auth_checker.dart @@ -0,0 +1,84 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:universal_html/html.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/services/auth_checker/auth_checker.dart'; + +const _appCloseCommandKey = 'web_dex_command'; + +class WebAuthChecker implements AuthChecker { + WebAuthChecker({required AuthRepository authRepo}) : _authRepo = authRepo { + _initListeners(); + } + + String? _currentSeed; + final AuthRepository _authRepo; + + @override + Future askConfirmLoginIfNeeded(String encryptedSeed) async { + final String localStorageValue = window.localStorage[encryptedSeed] ?? '0'; + final isLoggedIn = int.tryParse(localStorageValue) ?? 0; + if (isLoggedIn == 0) { + return true; + } + + final confirmAnswer = + window.confirm(LocaleKeys.confirmLogoutOnAnotherTab.tr()); + if (confirmAnswer) { + window.localStorage[_appCloseCommandKey] = encryptedSeed; + window.localStorage.remove(_appCloseCommandKey); + + _currentSeed = encryptedSeed; + return true; + } + + return false; + } + + @override + void removeSession(String encryptedSeed) { + if (_currentSeed == encryptedSeed) { + window.localStorage.remove(encryptedSeed); + _currentSeed = null; + } + } + + @override + void addSession(String encryptedSeed) { + window.localStorage.addAll({encryptedSeed: '1'}); + _currentSeed = encryptedSeed; + } + + void _initListeners() { + window.addEventListener( + 'storage', + _onStorageListener, + ); + + window.addEventListener( + 'beforeunload', + _onBeforeUnloadListener, + ); + } + + Future _onStorageListener(Event event) async { + if (event is! StorageEvent) return; + + if (event.key != _appCloseCommandKey) { + return; + } + + if (event.newValue != null && event.newValue == _currentSeed) { + _currentSeed = null; + await _authRepo.logOut(); + } + } + + void _onBeforeUnloadListener(Event event) { + if (event is! BeforeUnloadEvent) return; + final currentSeed = _currentSeed; + if (currentSeed != null) { + removeSession(currentSeed); + } + } +} diff --git a/lib/services/cex_service/cex_service.dart b/lib/services/cex_service/cex_service.dart new file mode 100644 index 0000000000..eea5c027ad --- /dev/null +++ b/lib/services/cex_service/cex_service.dart @@ -0,0 +1,154 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/cex_price.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class CexService { + CexService() { + updatePrices(); + _pricesTimer = Timer.periodic(const Duration(minutes: 1), (_) { + updatePrices(); + }); + } + + late Timer _pricesTimer; + final StreamController> _pricesController = + StreamController>.broadcast(); + Stream> get pricesStream => _pricesController.stream; + + Future updatePrices() async { + final prices = await fetchCurrentPrices(); + if (prices == null) return; + + _pricesController.sink.add(prices); + } + + Future?> fetchCurrentPrices() async { + final Map? prices = + await _updateFromMain() ?? await _updateFromFallback(); + + return prices; + } + + Future fetchPrice(String ticker) async { + final Map? prices = await fetchCurrentPrices(); + if (prices == null || !prices.containsKey(ticker)) return null; + + return prices[ticker]!; + } + + void dispose() { + _pricesTimer.cancel(); + _pricesController.close(); + } + + Future?> _updateFromMain() async { + http.Response res; + String body; + try { + res = await http.get(pricesUrlV3); + body = res.body; + } catch (e, s) { + log( + 'Error updating price from main: ${e.toString()}', + path: 'cex_services => _updateFromMain => http.get', + trace: s, + isError: true, + ); + return null; + } + + Map? json; + try { + json = jsonDecode(body); + } catch (e, s) { + log( + 'Error parsing of update price from main response: ${e.toString()}', + path: 'cex_services => _updateFromMain => jsonDecode', + trace: s, + isError: true, + ); + } + + if (json == null) return null; + final Map prices = {}; + json.forEach((String priceTicker, dynamic pricesData) { + prices[priceTicker] = CexPrice( + ticker: priceTicker, + price: double.tryParse(pricesData['last_price'] ?? '') ?? 0, + lastUpdated: DateTime.fromMillisecondsSinceEpoch( + pricesData['last_updated_timestamp'] * 1000, + ), + priceProvider: cexDataProvider(pricesData['price_provider']), + change24h: double.tryParse(pricesData['change_24h'] ?? ''), + changeProvider: cexDataProvider(pricesData['change_24h_provider']), + volume24h: double.tryParse(pricesData['volume24h'] ?? ''), + volumeProvider: cexDataProvider(pricesData['volume_provider']), + ); + }); + return prices; + } + + Future?> _updateFromFallback() async { + final List ids = coinsBloc.walletCoinsMap.values + .map((c) => c.coingeckoId ?? '') + .toList(); + ids.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; + try { + res = await http.get(fallbackUri); + body = res.body; + } catch (e, s) { + log( + 'Error updating price from fallback: ${e.toString()}', + path: 'cex_services => _updateFromFallback => http.get', + trace: s, + isError: true, + ); + return null; + } + + Map? json; + try { + json = jsonDecode(body); + } catch (e, s) { + log( + 'Error parsing of update price from fallback response: ${e.toString()}', + path: 'cex_services => _updateFromFallback => jsonDecode', + trace: s, + isError: true, + ); + } + + if (json == null) return null; + Map prices = {}; + json.forEach((String coingeckoId, dynamic pricesData) { + if (coingeckoId == 'test-coin') return; + + // Coins with the same coingeckoId supposedly have same usd price + // (e.g. KMD == KMD-BEP20) + final Iterable samePriceCoins = + coinsBloc.knownCoins.where((coin) => coin.coingeckoId == coingeckoId); + + for (Coin coin in samePriceCoins) { + prices[coin.abbr] = CexPrice( + ticker: coin.abbr, + price: double.parse(pricesData['usd'].toString()), + ); + } + }); + + return prices; + } +} diff --git a/lib/services/coins_service/coins_service.dart b/lib/services/coins_service/coins_service.dart new file mode 100644 index 0000000000..e97a963aba --- /dev/null +++ b/lib/services/coins_service/coins_service.dart @@ -0,0 +1,17 @@ +import 'dart:async'; + +import 'package:web_dex/blocs/blocs.dart'; + +final coinsService = CoinsService(); + +class CoinsService { + void init() { + Timer.periodic(const Duration(seconds: 30), (timer) async { + await _reEnableSuspended(); + }); + } + + Future _reEnableSuspended() async { + await coinsBloc.reActivateSuspended(); + } +} diff --git a/lib/services/file_loader/file_loader.dart b/lib/services/file_loader/file_loader.dart new file mode 100644 index 0000000000..c2542ae6e2 --- /dev/null +++ b/lib/services/file_loader/file_loader.dart @@ -0,0 +1,48 @@ +import 'package:file_picker/file_picker.dart'; + +abstract class FileLoader { + const FileLoader(); + Future save({ + required String fileName, + required String data, + required LoadFileType type, + }); + Future upload({ + required Function(String name, String? content) onUpload, + required Function(String) onError, + LoadFileType? fileType, + }); +} + +enum LoadFileType { + compressed, + text; + + FileType get fileType { + switch (this) { + case LoadFileType.compressed: + case LoadFileType.text: + return FileType.custom; + } + } + + String get mimeType { + switch (this) { + case LoadFileType.compressed: + return 'application/zip'; + case LoadFileType.text: + return 'text/plain'; + default: + return '*/*'; + } + } + + String get extension { + switch (this) { + case LoadFileType.compressed: + return 'zip'; + case LoadFileType.text: + return 'txt'; + } + } +} diff --git a/lib/services/file_loader/file_loader_native_desktop.dart b/lib/services/file_loader/file_loader_native_desktop.dart new file mode 100644 index 0000000000..09530a02c5 --- /dev/null +++ b/lib/services/file_loader/file_loader_native_desktop.dart @@ -0,0 +1,74 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; + +class FileLoaderNativeDesktop implements FileLoader { + const FileLoaderNativeDesktop(); + + @override + Future save({ + required String fileName, + required String data, + required LoadFileType type, + }) async { + switch (type) { + case LoadFileType.text: + _saveAsTextFile(fileName, data); + return; + case LoadFileType.compressed: + _saveAsCompressedFile(fileName, data); + return; + } + } + + @override + Future upload({ + required Function(String name, String content) onUpload, + required Function(String) onError, + LoadFileType? fileType, + }) async { + try { + final result = await FilePicker.platform.pickFiles(); + if (result == null || result.files.isEmpty) { + return; + } + final file = result.files.first; + final path = file.path; + if (path == null) return; + + final selectedFile = File(path); + final data = await selectedFile.readAsString(); + + onUpload(file.name, data); + } catch (e) { + onError(e.toString()); + } + } + + Future _saveAsTextFile(String fileName, String data) async { + final String? fileFullPath = + await FilePicker.platform.saveFile(fileName: '$fileName.txt'); + if (fileFullPath == null) return; + final File file = File(fileFullPath)..createSync(recursive: true); + await file.writeAsString(data); + } + + Future _saveAsCompressedFile( + String fileName, + String data, + ) async { + final String? fileFullPath = + await FilePicker.platform.saveFile(fileName: '$fileName.zip'); + if (fileFullPath == null) return; + + final List fileBytes = utf8.encode(data); + + // Using ZLibCodec for compression + final compressedBytes = ZLibEncoder().convert(fileBytes); + + final File compressedFile = File(fileFullPath); + await compressedFile.writeAsBytes(compressedBytes); + } +} diff --git a/lib/services/file_loader/file_loader_web.dart b/lib/services/file_loader/file_loader_web.dart new file mode 100644 index 0000000000..bc99d23bcc --- /dev/null +++ b/lib/services/file_loader/file_loader_web.dart @@ -0,0 +1,98 @@ +import 'dart:convert'; + +import 'package:universal_html/html.dart'; +import 'package:universal_html/js_util.dart'; +import 'package:web_dex/platform/platform.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; + +class FileLoaderWeb implements FileLoader { + const FileLoaderWeb(); + @override + Future save({ + required String fileName, + required String data, + required LoadFileType type, + }) async { + switch (type) { + case LoadFileType.text: + await _saveAsTextFile(filename: fileName, data: data); + return; + case LoadFileType.compressed: + await _saveAsCompressedFile(fileName: fileName, data: data); + return; + } + } + + Future _saveAsTextFile({ + required String filename, + required String data, + }) async { + final AnchorElement anchor = AnchorElement(); + anchor.href = + '${Uri.dataFromString(data, mimeType: 'text/plain', encoding: utf8)}'; + anchor.download = filename; + anchor.style.display = 'none'; + anchor.click(); + } + + Future _saveAsCompressedFile({ + required String fileName, + required String data, + }) async { + final String? compressedData = + await promiseToFuture(zipEncode('$fileName.txt', data)); + + if (compressedData == null) return; + + final anchor = AnchorElement(); + anchor.href = 'data:application/zip;base64,$compressedData'; + anchor.download = '$fileName.zip'; + anchor.click(); + } + + @override + Future upload({ + required Function(String name, String? content) onUpload, + required Function(String) onError, + LoadFileType? fileType, + }) async { + final FileUploadInputElement uploadInput = FileUploadInputElement(); + if (fileType != null) { + uploadInput.setAttribute('accept', _getMimeType(fileType)); + } + uploadInput.click(); + uploadInput.onChange.listen((Event event) { + final List? files = uploadInput.files; + if (files == null) { + return; + } + if (files.length == 1) { + final file = files[0]; + + final FileReader reader = FileReader(); + + reader.onLoadEnd.listen((_) { + final result = reader.result; + if (result is String) { + onUpload(file.name, result); + } + }); + reader.onError.listen( + (ProgressEvent _) {}, + onError: (Object error) => onError(error.toString()), + ); + + reader.readAsText(file); + } + }); + } + + String _getMimeType(LoadFileType type) { + switch (type) { + case LoadFileType.compressed: + return 'application/zip'; + case LoadFileType.text: + return 'text/plain'; + } + } +} diff --git a/lib/services/file_loader/get_file_loader.dart b/lib/services/file_loader/get_file_loader.dart new file mode 100644 index 0000000000..1adae1161f --- /dev/null +++ b/lib/services/file_loader/get_file_loader.dart @@ -0,0 +1,26 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; +import 'package:web_dex/services/file_loader/file_loader_native_desktop.dart'; +import 'package:web_dex/services/file_loader/file_loader_web.dart'; + +import 'mobile/file_loader_native_android.dart'; +import 'mobile/file_loader_native_ios.dart'; + +final FileLoader fileLoader = _getFileLoader(); +FileLoader _getFileLoader() { + if (kIsWeb) { + return const FileLoaderWeb(); + } + if (Platform.isMacOS || Platform.isWindows || Platform.isLinux) { + return const FileLoaderNativeDesktop(); + } + if (Platform.isAndroid) { + return const FileLoaderNativeAndroid(); + } + if (Platform.isIOS) { + return const FileLoaderNativeIOS(); + } + throw UnimplementedError(); +} diff --git a/lib/services/file_loader/mobile/file_loader_native_android.dart b/lib/services/file_loader/mobile/file_loader_native_android.dart new file mode 100644 index 0000000000..c69605b84a --- /dev/null +++ b/lib/services/file_loader/mobile/file_loader_native_android.dart @@ -0,0 +1,82 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; + +class FileLoaderNativeAndroid implements FileLoader { + const FileLoaderNativeAndroid(); + + @override + Future save({ + required String fileName, + required String data, + LoadFileType type = LoadFileType.text, + }) async { + switch (type) { + case LoadFileType.text: + await _saveAsTextFile(fileName: fileName, data: data); + break; + case LoadFileType.compressed: + await _saveAsCompressedFile(fileName: fileName, data: data); + break; + } + } + + Future _saveAsTextFile({ + required String fileName, + required String data, + }) async { + final String? fileFullPath = await FilePicker.platform.saveFile( + fileName: '$fileName.txt', + ); + if (fileFullPath == null) return; + + final File file = File(fileFullPath)..createSync(recursive: true); + await file.writeAsString(data); + } + + Future _saveAsCompressedFile({ + required String fileName, + required String data, + }) async { + final String? fileFullPath = await FilePicker.platform.saveFile( + fileName: '$fileName.zip', + ); + if (fileFullPath == null) return; + + final List fileBytes = utf8.encode(data); + + // Using ZLibCodec for compression + final compressedBytes = ZLibEncoder().convert(fileBytes); + + final File compressedFile = File(fileFullPath); + await compressedFile.writeAsBytes(compressedBytes); + } + + @override + Future upload({ + required Function(String name, String content) onUpload, + required Function(String) onError, + LoadFileType? fileType, + }) async { + try { + final result = await FilePicker.platform.pickFiles( + type: fileType == null ? FileType.any : fileType.fileType, + allowedExtensions: fileType != null ? [fileType.extension] : null, + ); + if (result == null || result.files.isEmpty) return; + + final file = result.files.first; + final path = file.path; + if (path == null) return; + + final selectedFile = File(path); + final data = await selectedFile.readAsString(); + + onUpload(file.name, data); + } catch (e) { + onError(e.toString()); + } + } +} diff --git a/lib/services/file_loader/mobile/file_loader_native_ios.dart b/lib/services/file_loader/mobile/file_loader_native_ios.dart new file mode 100644 index 0000000000..b434410454 --- /dev/null +++ b/lib/services/file_loader/mobile/file_loader_native_ios.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:file_picker/file_picker.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:path/path.dart' as path; + +class FileLoaderNativeIOS implements FileLoader { + const FileLoaderNativeIOS(); + + @override + Future save({ + required String fileName, + required String data, + LoadFileType type = LoadFileType.text, + }) async { + switch (type) { + case LoadFileType.text: + await _saveAsTextFile(fileName: fileName, data: data); + break; + case LoadFileType.compressed: + await _saveAsCompressedFile(fileName: fileName, data: data); + break; + } + } + + Future _saveAsTextFile({ + required String fileName, + required String data, + }) async { + final directory = await getApplicationDocumentsDirectory(); + final filePath = path.join(directory.path, '$fileName.txt'); + final File file = File(filePath); + await file.writeAsString(data); + + await Share.shareXFiles([XFile(file.path)]); + } + + Future _saveAsCompressedFile({ + required String fileName, + required String data, + }) async { + final directory = await getApplicationDocumentsDirectory(); + final filePath = path.join(directory.path, '$fileName.zip'); + + final List fileBytes = utf8.encode(data); + + // Using ZLibCodec for compression + final compressedBytes = ZLibEncoder().convert(fileBytes); + + final File compressedFile = File(filePath); + await compressedFile.writeAsBytes(compressedBytes); + + await Share.shareXFiles([XFile(compressedFile.path)]); + } + + @override + Future upload({ + required Function(String name, String content) onUpload, + required Function(String) onError, + LoadFileType? fileType, + }) async { + try { + final result = await FilePicker.platform.pickFiles( + type: fileType == null ? FileType.any : fileType.fileType, + allowedExtensions: fileType != null ? [fileType.extension] : null, + ); + if (result == null || result.files.isEmpty) return; + + final file = result.files.first; + final path = file.path; + if (path == null) return; + + final selectedFile = File(path); + final data = await selectedFile.readAsString(); + + onUpload(file.name, data); + } catch (e) { + onError(e.toString()); + } + } +} diff --git a/lib/services/initializer/app_bootstrapper.dart b/lib/services/initializer/app_bootstrapper.dart new file mode 100644 index 0000000000..bb405a0216 --- /dev/null +++ b/lib/services/initializer/app_bootstrapper.dart @@ -0,0 +1,48 @@ +part of 'package:web_dex/main.dart'; + +final class AppBootstrapper { + AppBootstrapper._(); + + static AppBootstrapper get instance => _instance; + + static final _instance = AppBootstrapper._(); + + bool _isInitialized = false; + + Future ensureInitialized() async { + if (_isInitialized) return; + + final timer = Stopwatch()..start(); + await logger.init(); + + log('AppBootstrapper: Log initialized in ${timer.elapsedMilliseconds}ms'); + timer.reset(); + + await _warmUpInitializers.awaitAll(); + log('AppBootstrapper: Warm-up initializers completed in ${timer.elapsedMilliseconds}ms'); + timer.stop(); + + _isInitialized = true; + } + + /// A list of futures that should be completed before the app starts + /// ([runApp]) which do not depend on each other. + final List> _warmUpInitializers = [ + app_bloc_root.loadLibrary(), + packageInformation.init(), + EasyLocalization.ensureInitialized(), + CexMarketData.ensureInitialized(), + PlatformTuner.setWindowTitleAndSize(), + startUpBloc.run(), + SettingsRepository.loadStoredSettings() + .then((stored) => _storedSettings = stored), + RuntimeUpdateConfigProvider() + .getRuntimeUpdateConfig() + .then((config) => _runtimeUpdateConfig = config), + KomodoCoinUpdater.ensureInitialized(appFolder) + .then((_) => sparklineRepository.init()), + ]; +} + +StoredSettings? _storedSettings; +RuntimeUpdateConfig? _runtimeUpdateConfig; diff --git a/lib/services/logger/common.dart b/lib/services/logger/common.dart new file mode 100644 index 0000000000..a4ae982a50 --- /dev/null +++ b/lib/services/logger/common.dart @@ -0,0 +1,22 @@ +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; +import 'package:web_dex/services/logger/log_message.dart'; + +String formatLogs(Iterable logs) { + final logIterable = logs.map((e) => jsonEncode(e)); + + final buffer = StringBuffer('[\n'); + + buffer.writeAll(logIterable, ',\n'); + + buffer.write('\n]'); + + return buffer.toString(); +} + +Future formatLogsExport(Iterable logs) async { + final result = await compute, String>(formatLogs, logs); + + return result; +} diff --git a/lib/services/logger/get_logger.dart b/lib/services/logger/get_logger.dart new file mode 100644 index 0000000000..130dd27585 --- /dev/null +++ b/lib/services/logger/get_logger.dart @@ -0,0 +1,23 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:web_dex/services/logger/logger.dart'; +import 'package:web_dex/services/logger/mock_logger.dart'; +import 'package:web_dex/services/logger/universal_logger.dart'; +import 'package:web_dex/services/platform_info/plaftorm_info.dart'; + +final LoggerInterface logger = _getLogger(); +LoggerInterface _getLogger() { + final platformInfo = PlatformInfo.getInstance(); + + if (kIsWeb || + Platform.isWindows || + Platform.isMacOS || + Platform.isLinux || + Platform.isAndroid || + Platform.isIOS) { + return UniversalLogger(platformInfo: platformInfo); + } + + return const MockLogger(); +} diff --git a/lib/services/logger/log_message.dart b/lib/services/logger/log_message.dart new file mode 100644 index 0000000000..e0e8e33fe3 --- /dev/null +++ b/lib/services/logger/log_message.dart @@ -0,0 +1,64 @@ +class LogMessage { + final String appVersion; + + /// MM2 version + /// + /// Nullable because log can be called before the API is started. + final String? mm2Version; + + /// App locale + /// + /// Nullable because log can be called before the locale is not yet set. + final String? appLocale; + + final String platform; + final String osLanguage; + final String screenSize; + final int timestamp; + final String message; + final String? path; + final String date; + + const LogMessage({ + required this.appVersion, + required this.mm2Version, + required this.appLocale, + required this.platform, + required this.osLanguage, + required this.screenSize, + required this.timestamp, + required this.message, + required this.date, + this.path, + }); + + Map toJson() { + return { + 'message': message, + 'path': path, + 'app_version': appVersion, + 'mm2_version': mm2Version, + 'app_language': appLocale, + 'platform': platform, + 'os_language': osLanguage, + 'screen_size': screenSize, + 'timestamp': timestamp, + 'date': date, + }; + } + + factory LogMessage.fromJson(Map json) { + return LogMessage( + appVersion: json['app_version'] as String, + mm2Version: json['mm2_version'] as String?, + appLocale: json['app_language'] as String?, + platform: json['platform'] as String, + osLanguage: json['os_language'] as String, + screenSize: json['screen_size'] as String, + timestamp: json['timestamp'] as int, + message: json['message'] as String, + path: json['path'] as String?, + date: json['date'] as String, + ); + } +} diff --git a/lib/services/logger/logger.dart b/lib/services/logger/logger.dart new file mode 100644 index 0000000000..8f143ee590 --- /dev/null +++ b/lib/services/logger/logger.dart @@ -0,0 +1,5 @@ +abstract class LoggerInterface { + Future init(); + Future write(String logMessage, [String? path]); + Future getLogFile(); +} diff --git a/lib/services/logger/logger_metadata_mixin.dart b/lib/services/logger/logger_metadata_mixin.dart new file mode 100644 index 0000000000..2fb9e4c0b4 --- /dev/null +++ b/lib/services/logger/logger_metadata_mixin.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/services/storage/base_storage.dart'; +import 'package:web_dex/services/storage/get_storage.dart'; + +mixin LoggerMetadataMixin { + String? _apiVersion; + String? _locale; + + BaseStorage get environmentStorage => getStorage(); + + /// Get the current locale from the environment storage. + /// NB! If the locale is changed after the app is initialized, the change + /// will not be reflected in the logger metadata until the app is restarted. + FutureOr localeName() { + if (_locale != null) return _locale; + + return Future( + () async => _locale = + await environmentStorage.read('locale').catchError((_) => null), + ); + } + + FutureOr apiVersion() { + if (_apiVersion != null) return _apiVersion; + + return Future( + () async => _apiVersion = await mm2Api.version().catchError((_) => null), + ); + } +} diff --git a/lib/services/logger/mock_logger.dart b/lib/services/logger/mock_logger.dart new file mode 100644 index 0000000000..3e04a62877 --- /dev/null +++ b/lib/services/logger/mock_logger.dart @@ -0,0 +1,22 @@ +// ignore_for_file: avoid_print + +import 'package:web_dex/services/logger/logger.dart'; + +class MockLogger implements LoggerInterface { + const MockLogger(); + + @override + Future write(String logMessage, [String? path]) async { + print('path: $path, $logMessage'); + } + + @override + Future getLogFile() async { + print('downloaded'); + } + + @override + Future init() async { + print('initialized'); + } +} diff --git a/lib/services/logger/universal_logger.dart b/lib/services/logger/universal_logger.dart new file mode 100644 index 0000000000..541866eeb9 --- /dev/null +++ b/lib/services/logger/universal_logger.dart @@ -0,0 +1,96 @@ +import 'dart:async'; + +import 'package:dragon_logs/dragon_logs.dart'; +import 'package:intl/intl.dart'; +import 'package:web_dex/app_config/package_information.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; +import 'package:web_dex/services/file_loader/get_file_loader.dart'; +import 'package:web_dex/services/logger/log_message.dart'; +import 'package:web_dex/services/logger/logger.dart'; +import 'package:web_dex/services/logger/logger_metadata_mixin.dart'; +import 'package:web_dex/services/platform_info/plaftorm_info.dart'; +import 'package:web_dex/shared/utils/utils.dart' as initialised_logger show log; + +class UniversalLogger with LoggerMetadataMixin implements LoggerInterface { + UniversalLogger({required this.platformInfo}); + + bool _isInitialized = false; + bool _isBusyInit = false; + + final PlatformInfo platformInfo; + + @override + Future init() async { + if (_isInitialized || _isBusyInit) return; + + final timer = Stopwatch()..start(); + + try { + await DragonLogs.init(); + + DragonLogs.setSessionMetadata({ + 'appVersion': packageInformation.packageVersion, + 'mm2Version': await apiVersion(), + 'appLanguage': await localeName(), + 'platform': platformInfo.platform, + 'osLanguage': platformInfo.osLanguage, + 'screenSize': platformInfo.screenSize, + }); + + initialised_logger + .log('Logger initialized in ${timer.elapsedMilliseconds}ms'); + + _isInitialized = true; + } catch (e) { + // ignore: avoid_print + print( + 'Failed to initialize app logging. Downloaded logs ' + 'may be incomplete.\n${e.toString()}', + ); + } finally { + timer.stop(); + _isBusyInit = false; + } + } + + @override + Future write(String message, [String? path]) async { + final date = DateTime.now(); + + final LogMessage logMessage = LogMessage( + path: path, + appVersion: packageInformation.packageVersion ?? '', + mm2Version: await apiVersion(), + appLocale: await localeName(), + platform: platformInfo.platform, + osLanguage: platformInfo.osLanguage, + screenSize: platformInfo.screenSize ?? '', + timestamp: date.millisecondsSinceEpoch, + message: message, + date: date.toString(), + ); + + // Convert to JSON but exclude fields which are already set in the session + // metadata and non-null. + final Map json = logMessage.toJson() + ..removeWhere( + (key, value) => + DragonLogs.sessionMetadata!.containsKey(key) || value == null, + ); + + return log(json.toString()); + } + + @override + Future getLogFile() async { + final String date = + DateFormat('dd.MM.yyyy_HH-mm-ss').format(DateTime.now()); + final String filename = 'komodo_wallet_log_$date'; + + await fileLoader.save( + fileName: filename, + data: await DragonLogs.exportLogsString(), + type: LoadFileType.compressed, + ); + } +} diff --git a/lib/services/mappers/my_orders_mappers.dart b/lib/services/mappers/my_orders_mappers.dart new file mode 100644 index 0000000000..bc07cec22b --- /dev/null +++ b/lib/services/mappers/my_orders_mappers.dart @@ -0,0 +1,46 @@ +import 'package:web_dex/mm2/mm2_api/rpc/my_orders/my_orders_response.dart'; +import 'package:web_dex/model/my_orders/maker_order.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/my_orders/taker_order.dart'; + +MyOrder mapMyOrderResponseTakerOrderToOrder(TakerOrder order, String uuid) => + MyOrder( + cancelable: order.cancellable, + base: order.request.base, + rel: order.request.rel, + orderType: TradeSide.taker, + createdAt: order.createdAt ~/ 1000, + baseAmount: order.request.baseAmount, + relAmount: order.request.relAmount, + uuid: uuid, + ); + +MyOrder mapMyOrderResponseMakerOrderToOrder(MakerOrder order, String uuid) => + MyOrder( + cancelable: order.cancellable, + baseAmount: order.maxBaseVol, + baseAmountAvailable: order.availableAmount, + minVolume: double.tryParse(order.minBaseVol), + base: order.base, + rel: order.rel, + orderType: TradeSide.maker, + startedSwaps: order.startedSwaps, + createdAt: order.createdAt ~/ 1000, + relAmount: order.price * order.maxBaseVol, + relAmountAvailable: order.price * order.availableAmount, + uuid: uuid, + ); + +List mapMyOrdersResponseToOrders(MyOrdersResponse myOrders) { + final List takerOrders = myOrders.result.takerOrders.entries + .map((entry) => + mapMyOrderResponseTakerOrderToOrder(entry.value, entry.key)) + .toList(); + + final List makerOrders = myOrders.result.makerOrders.entries + .map((MapEntry entry) => + mapMyOrderResponseMakerOrderToOrder(entry.value, entry.key)) + .toList(); + + return [...takerOrders, ...makerOrders]; +} diff --git a/lib/services/mappers/trade_preimage_mappers.dart b/lib/services/mappers/trade_preimage_mappers.dart new file mode 100644 index 0000000000..883bd9a9f9 --- /dev/null +++ b/lib/services/mappers/trade_preimage_mappers.dart @@ -0,0 +1,17 @@ +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trade_preimage/trade_preimage_response.dart'; +import 'package:web_dex/model/trade_preimage.dart'; + +TradePreimage mapTradePreimageResponseResultToTradePreimage( + TradePreimageResponseResult result, TradePreimageRequest request) { + return TradePreimage( + baseCoinFee: result.baseCoinFee, + relCoinFee: result.relCoinFee, + takerFee: result.takerFee, + feeToSendTakerFee: result.feeToSendTakerFee, + totalFees: result.totalFees, + volume: result.volume, + volumeFract: result.volumeFraction, + request: request, + ); +} diff --git a/lib/services/native_channel.dart b/lib/services/native_channel.dart new file mode 100644 index 0000000000..67c37becbb --- /dev/null +++ b/lib/services/native_channel.dart @@ -0,0 +1,4 @@ +import 'package:flutter/services.dart'; + +const MethodChannel nativeChannel = MethodChannel('komodo-web-dex'); +const EventChannel nativeEventChannel = EventChannel('komodo-web-dex/event'); diff --git a/lib/services/orders_service/my_orders_service.dart b/lib/services/orders_service/my_orders_service.dart new file mode 100644 index 0000000000..6966c632cf --- /dev/null +++ b/lib/services/orders_service/my_orders_service.dart @@ -0,0 +1,103 @@ +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/cancel_order/cancel_order_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_orders/my_orders_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/order_status/cancellation_reason.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/order_status/order_status_response.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/my_orders/taker_order.dart'; +import 'package:web_dex/services/mappers/my_orders_mappers.dart'; + +MyOrdersService myOrdersService = MyOrdersService(); + +class MyOrdersService { + Future?> getOrders() async { + final MyOrdersResponse? response = await mm2Api.getMyOrders(); + + if (response == null) { + return null; + } + + return mapMyOrdersResponseToOrders(response); + } + + Future getStatus(String uuid) async { + try { + final OrderStatusResponse? response = await mm2Api.getOrderStatus(uuid); + if (response == null) { + return null; + } + final dynamic order = response.order; + if (order is TakerOrder) { + return OrderStatus( + takerOrderStatus: TakerOrderStatus( + order: mapMyOrderResponseTakerOrderToOrder(order, uuid), + cancellationReason: _getTakerOrderCancellationReason( + response.cancellationReason ?? ''), + ), + ); + } else { + return OrderStatus( + makerOrderStatus: MakerOrderStatus( + order: mapMyOrderResponseMakerOrderToOrder(order, uuid), + cancellationReason: _getMakerOrderCancellationReason( + response.cancellationReason ?? ''), + ), + ); + } + } catch (_) { + return null; + } + } + + Future cancelOrder(String uuid) async { + final Map response = + await mm2Api.cancelOrder(CancelOrderRequest(uuid: uuid)); + return response['error']; + } + + TakerOrderCancellationReason _getTakerOrderCancellationReason(String reason) { + switch (reason) { + case 'Cancelled': + return TakerOrderCancellationReason.cancelled; + case 'Fulfilled': + return TakerOrderCancellationReason.fulfilled; + case 'TimedOut': + return TakerOrderCancellationReason.timedOut; + case 'ToMaker': + return TakerOrderCancellationReason.toMaker; + default: + return TakerOrderCancellationReason.none; + } + } + + MakerOrderCancellationReason _getMakerOrderCancellationReason(String reason) { + switch (reason) { + case 'Cancelled': + return MakerOrderCancellationReason.cancelled; + case 'Fulfilled': + return MakerOrderCancellationReason.fulfilled; + case 'InsufficientBalance': + return MakerOrderCancellationReason.insufficientBalance; + default: + return MakerOrderCancellationReason.none; + } + } +} + +class OrderStatus { + OrderStatus({this.takerOrderStatus, this.makerOrderStatus}); + final TakerOrderStatus? takerOrderStatus; + final MakerOrderStatus? makerOrderStatus; +} + +class TakerOrderStatus { + TakerOrderStatus({required this.order, required this.cancellationReason}); + final TakerOrderCancellationReason cancellationReason; + final MyOrder order; +} + +class MakerOrderStatus { + MakerOrderStatus({required this.order, required this.cancellationReason}); + final MakerOrderCancellationReason cancellationReason; + final MyOrder order; +} diff --git a/lib/services/platform_info/native_platform_info.dart b/lib/services/platform_info/native_platform_info.dart new file mode 100644 index 0000000000..998a0b277d --- /dev/null +++ b/lib/services/platform_info/native_platform_info.dart @@ -0,0 +1,24 @@ +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:flutter/widgets.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/services/platform_info/plaftorm_info.dart'; + +class NativePlatformInfo extends PlatformInfo with MemoizedPlatformInfoMixin { + @override + String computeOsLanguage() => + ui.PlatformDispatcher.instance.locale.toLanguageTag(); + + @override + String computePlatform() => + '${Platform.operatingSystem} ${Platform.operatingSystemVersion}'; + + @override + String? computeScreenSize() { + final currentContext = scaffoldKey.currentContext; + final size = + currentContext == null ? null : MediaQuery.of(currentContext).size; + return size == null ? '' : '${size.width}:${size.height}'; + } +} diff --git a/lib/services/platform_info/plaftorm_info.dart b/lib/services/platform_info/plaftorm_info.dart new file mode 100644 index 0000000000..bf77a45bf7 --- /dev/null +++ b/lib/services/platform_info/plaftorm_info.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:web_dex/services/platform_info/native_platform_info.dart'; +import 'package:web_dex/services/platform_info/web_platform_info.dart'; + +abstract class PlatformInfo { + String get osLanguage; + String get platform; + String? get screenSize; + + static PlatformInfo getInstance() { + if (kIsWeb) { + return WebPlatformInfo(); + } else { + return NativePlatformInfo(); + } + } +} + +mixin MemoizedPlatformInfoMixin { + String? _osLanguage; + String? _platform; + String? _screenSize; + + String get osLanguage => _osLanguage ??= computeOsLanguage(); + String get platform => _platform ??= computePlatform(); + String? get screenSize => _screenSize ??= computeScreenSize(); + + String computeOsLanguage(); + String computePlatform(); + String? computeScreenSize(); +} diff --git a/lib/services/platform_info/web_platform_info.dart b/lib/services/platform_info/web_platform_info.dart new file mode 100644 index 0000000000..4055d7263f --- /dev/null +++ b/lib/services/platform_info/web_platform_info.dart @@ -0,0 +1,21 @@ +import 'package:universal_html/html.dart'; +import 'package:web_dex/services/platform_info/plaftorm_info.dart'; +import 'package:web_dex/shared/utils/browser_helpers.dart'; + +class WebPlatformInfo extends PlatformInfo with MemoizedPlatformInfoMixin { + BrowserInfo? _browserInfo; + + BrowserInfo get browserInfo => _browserInfo ??= BrowserInfoParser.get(); + + @override + // Exclude for mav compilation because it shows string is nullable + // ignore: unnecessary_non_null_assertion + String computeOsLanguage() => window.navigator.language!; + + @override + String computePlatform() => + '${browserInfo.os} ${browserInfo.browserName} ${browserInfo.browserVersion}'; + + @override + String? computeScreenSize() => browserInfo.screenSize; +} diff --git a/lib/services/storage/app_storage.dart b/lib/services/storage/app_storage.dart new file mode 100644 index 0000000000..bfe43b1409 --- /dev/null +++ b/lib/services/storage/app_storage.dart @@ -0,0 +1,81 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:web_dex/services/storage/base_storage.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class AppStorage implements BaseStorage { + SharedPreferences? _prefs; + + @override + Future write(String key, dynamic data) async { + try { + await _writeToSharedPrefs(key, data); + return true; + } catch (_) { + return false; + } + } + + @override + Future read(String key) async { + final SharedPreferences prefs = await _getPreferences(); + await prefs.reload(); + try { + final dynamic value = prefs.get(key); + if (value is String) { + try { + return jsonDecode(value); + } catch (_) { + return value; + } + } else { + return value; + } + } catch (e, s) { + log( + e.toString(), + path: 'web_storage => read', + trace: s, + isError: true, + ); + return null; + } + } + + @override + Future delete(String key) async { + final SharedPreferences prefs = await _getPreferences(); + return prefs.remove(key); + } + + Future _writeToSharedPrefs(String key, dynamic data) async { + final SharedPreferences prefs = await _getPreferences(); + + switch (data.runtimeType) { + case bool: + await prefs.setBool(key, data); + break; + case double: + await prefs.setDouble(key, data); + break; + case int: + await prefs.setInt(key, data); + break; + case String: + await prefs.setString(key, data); + break; + default: + await prefs.setString(key, jsonEncode(data)); + } + } + + Future _getPreferences() async { + if (_prefs != null) { + return Future.value(_prefs); + } + _prefs = await SharedPreferences.getInstance(); + + return Future.value(_prefs); + } +} diff --git a/lib/services/storage/base_storage.dart b/lib/services/storage/base_storage.dart new file mode 100644 index 0000000000..59a130d596 --- /dev/null +++ b/lib/services/storage/base_storage.dart @@ -0,0 +1,5 @@ +abstract class BaseStorage { + Future write(String key, dynamic data); + Future read(String key); + Future delete(String key); +} diff --git a/lib/services/storage/get_storage.dart b/lib/services/storage/get_storage.dart new file mode 100644 index 0000000000..c3d667efcb --- /dev/null +++ b/lib/services/storage/get_storage.dart @@ -0,0 +1,16 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:web_dex/services/storage/app_storage.dart'; +import 'package:web_dex/services/storage/base_storage.dart'; +import 'package:web_dex/services/storage/mock_storage.dart'; + +final BaseStorage _storage = kIsWeb || + Platform.isWindows || + !Platform.environment.containsKey('FLUTTER_TEST') + ? AppStorage() + : MockStorage(); + +BaseStorage getStorage() { + return _storage; +} diff --git a/lib/services/storage/mock_storage.dart b/lib/services/storage/mock_storage.dart new file mode 100644 index 0000000000..efdd23ba8d --- /dev/null +++ b/lib/services/storage/mock_storage.dart @@ -0,0 +1,18 @@ +import 'package:web_dex/services/storage/base_storage.dart'; + +class MockStorage implements BaseStorage { + @override + Future delete(String key) { + return Future.value(true); + } + + @override + Future read(String key) { + return Future.value(key); + } + + @override + Future write(String key, dynamic data) { + return Future.value(true); + } +} diff --git a/lib/services/swaps_service/swaps_service.dart b/lib/services/swaps_service/swaps_service.dart new file mode 100644 index 0000000000..2ba6e2fecb --- /dev/null +++ b/lib/services/swaps_service/swaps_service.dart @@ -0,0 +1,48 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/max_taker_vol/max_taker_vol_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_request.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_response.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +SwapsService swapsService = SwapsService(); + +class SwapsService { + Future?> getRecentSwaps(MyRecentSwapsRequest request) async { + final MyRecentSwapsResponse? response = + await mm2Api.getMyRecentSwaps(request); + if (response == null) { + return null; + } + + return response.result.swaps; + } + + Future recoverFundsOfSwap(String uuid) async { + final RecoverFundsOfSwapRequest request = + RecoverFundsOfSwapRequest(uuid: uuid); + final RecoverFundsOfSwapResponse? response = + await mm2Api.recoverFundsOfSwap(request); + if (response != null) { + log( + response.toJson().toString(), + path: 'swaps_service => recoverFundsOfSwap', + ); + } + return response; + } + + Future getMaxTakerVolume(String coinAbbr) async { + final MaxTakerVolResponse? response = + await mm2Api.getMaxTakerVolume(MaxTakerVolRequest(coin: coinAbbr)); + if (response == null) { + return null; + } + + return fract2rat(response.result.toJson()); + } +} diff --git a/lib/shared/constants.dart b/lib/shared/constants.dart new file mode 100644 index 0000000000..f29902f768 --- /dev/null +++ b/lib/shared/constants.dart @@ -0,0 +1,44 @@ +RegExp numberRegExp = RegExp('^\$|^(0|([1-9][0-9]{0,12}))([.,]{1}[0-9]{0,8})?'); +RegExp emailRegExp = RegExp( + r"^[a-zA-Z0-9.!#$%&'*+\/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$", +); +const int decimalRange = 8; + +// stored app preferences +const String storedSettingsKey = '_atomicDexStoredSettings'; +const String storedAnalyticsSettingsKey = 'analytics_settings'; +const String storedMarketMakerSettingsKey = 'market_maker_settings'; + +// anchor: protocols support +const String ercTxHistoryUrl = 'https://etherscan-proxy.komodo.earth/api'; +const String ethUrl = '$ercTxHistoryUrl/v1/eth_tx_history'; +const String ercUrl = '$ercTxHistoryUrl/v2/erc_tx_history'; +const String bnbUrl = '$ercTxHistoryUrl/v1/bnb_tx_history'; +const String bepUrl = '$ercTxHistoryUrl/v2/bep_tx_history'; +const String ftmUrl = '$ercTxHistoryUrl/v1/ftm_tx_history'; +const String ftmTokenUrl = '$ercTxHistoryUrl/v2/ftm_tx_history'; +const String etcUrl = '$ercTxHistoryUrl/v1/etc_tx_history'; +const String avaxUrl = '$ercTxHistoryUrl/v1/avx_tx_history'; +const String avaxTokenUrl = '$ercTxHistoryUrl/v2/avx_tx_history'; +const String mvrUrl = '$ercTxHistoryUrl/v1/moonriver_tx_history'; +const String mvrTokenUrl = '$ercTxHistoryUrl/v2/moonriver_tx_history'; +const String hecoUrl = '$ercTxHistoryUrl/v1/heco_tx_history'; +const String hecoTokenUrl = '$ercTxHistoryUrl/v2/heco_tx_history'; +const String maticUrl = '$ercTxHistoryUrl/v1/plg_tx_history'; +const String maticTokenUrl = '$ercTxHistoryUrl/v2/plg_tx_history'; +const String kcsUrl = '$ercTxHistoryUrl/v1/kcs_tx_history'; +const String kcsTokenUrl = '$ercTxHistoryUrl/v2/kcs_tx_history'; +const String txByHashUrl = '$ercTxHistoryUrl/v1/transactions_by_hash'; + +const String updateCheckerEndpoint = 'https://komodo.earth/adexwebversion'; +final Uri feedbackUrl = Uri.parse('https://komodo.earth:8181/webform/'); +final Uri pricesUrlV3 = Uri.parse( + 'https://defi-stats.komodo.earth/api/v3/prices/tickers_v2?expire_at=60', +); + +const int millisecondsIn24H = 86400000; + +const bool isTestMode = + bool.fromEnvironment('testing_mode', defaultValue: false); +const String moralisProxyUrl = 'https://moralis-proxy.komodo.earth'; +const String nftAntiSpamUrl = 'https://nft.antispam.dragonhound.info'; diff --git a/lib/shared/ui/app_button.dart b/lib/shared/ui/app_button.dart new file mode 100644 index 0000000000..1e707163b5 --- /dev/null +++ b/lib/shared/ui/app_button.dart @@ -0,0 +1,82 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class AppDefaultButton extends StatefulWidget { + const AppDefaultButton({ + Key? key, + required this.text, + this.width = 150, + this.height = 45, + this.padding = const EdgeInsets.symmetric(vertical: 10), + this.textStyle = const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + required this.onPressed, + }) : super(key: key); + + final String text; + final TextStyle? textStyle; + final double width; + final double height; + final Function onPressed; + final EdgeInsets padding; + + @override + State createState() => _AppButton(); +} + +class _AppButton extends State { + bool hover = false; + bool hasFocus = false; + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) => setState(() => hover = true), + child: MouseRegion( + onHover: (_) => setState(() => hover = true), + onExit: (_) => setState(() => hover = false), + child: Container( + decoration: BoxDecoration( + color: hover + ? theme.custom.buttonColorDefaultHover + : Theme.of(context).colorScheme.tertiary, + border: Border.all( + color: hasFocus + ? theme.custom.buttonColorDefaultHover + : Colors.transparent), + borderRadius: BorderRadius.circular(20), + ), + child: ElevatedButton( + onFocusChange: (value) { + setState(() { + hasFocus = value; + }); + }, + key: Key('coin-details-${(widget.text).toLowerCase()}'), + style: ElevatedButton.styleFrom( + padding: widget.padding, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20.0), + ), + minimumSize: Size(widget.width, widget.height), + maximumSize: Size(double.infinity, widget.height), + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + ), + onPressed: () { + widget.onPressed(); + }, + child: Text( + widget.text, + textAlign: TextAlign.center, + style: widget.textStyle!.copyWith( + color: hover + ? theme.custom.buttonTextColorDefaultHover + : Theme.of(context).textTheme.labelLarge?.color, + ), + ), + ), + ))); + } +} diff --git a/lib/shared/ui/borderless_search_field.dart b/lib/shared/ui/borderless_search_field.dart new file mode 100644 index 0000000000..fdf17d916b --- /dev/null +++ b/lib/shared/ui/borderless_search_field.dart @@ -0,0 +1,35 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class BorderLessSearchField extends StatelessWidget { + const BorderLessSearchField( + {Key? key, required this.onChanged, this.height = 44}) + : super(key: key); + final Function(String) onChanged; + final double height; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + + return SizedBox( + height: height, + child: TextField( + key: const Key('search-field'), + onChanged: onChanged, + decoration: InputDecoration( + hintText: LocaleKeys.search.tr(), + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(height * 0.5)), + prefixIcon: Icon( + Icons.search, + size: height * 0.6, + color: Theme.of(context).textTheme.bodyMedium?.color, + )), + style: themeData.textTheme.bodyMedium?.copyWith(fontSize: 14), + ), + ); + } +} diff --git a/lib/shared/ui/clock_warning_banner.dart b/lib/shared/ui/clock_warning_banner.dart new file mode 100644 index 0000000000..027019f27e --- /dev/null +++ b/lib/shared/ui/clock_warning_banner.dart @@ -0,0 +1,47 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; +import 'package:web_dex/bloc/system_health/system_health_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class ClockWarningBanner extends StatelessWidget { + const ClockWarningBanner({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, systemHealthState) { + if (systemHealthState is SystemHealthLoadSuccess && + !systemHealthState.isValid) { + return _buildWarningBanner(); + } + 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/ui/custom_numeric_text_form_field.dart b/lib/shared/ui/custom_numeric_text_form_field.dart new file mode 100644 index 0000000000..e921a218a2 --- /dev/null +++ b/lib/shared/ui/custom_numeric_text_form_field.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class CustomNumericTextFormField extends StatelessWidget { + const CustomNumericTextFormField({ + Key? key, + required this.controller, + required this.validator, + required this.hintText, + required this.filteringRegExp, + this.style, + this.hintTextStyle, + this.errorMaxLines, + this.onChanged, + this.focusNode, + this.onFocus, + this.suffixIcon, + this.validationMode = InputValidationMode.eager, + }) : super(key: key); + + final TextEditingController controller; + final String? Function(String?)? validator; + final String hintText; + final TextStyle? style; + final TextStyle? hintTextStyle; + final String filteringRegExp; + final int? errorMaxLines; + final InputValidationMode validationMode; + final void Function(String)? onChanged; + + final FocusNode? focusNode; + final void Function(FocusNode)? onFocus; + + final Widget? suffixIcon; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + return UiTextFormField( + controller: controller, + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(filteringRegExp)), + DecimalTextInputFormatter(decimalRange: decimalRange), + ], + textInputAction: TextInputAction.done, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: style ?? themeData.textTheme.bodyMedium, + validationMode: validationMode, + validator: validator, + onChanged: onChanged, + focusNode: focusNode, + hintTextStyle: hintTextStyle, + hintText: hintText, + errorMaxLines: errorMaxLines, + suffixIcon: suffixIcon, + ); + } +} diff --git a/lib/shared/ui/custom_tooltip.dart b/lib/shared/ui/custom_tooltip.dart new file mode 100644 index 0000000000..1045750be8 --- /dev/null +++ b/lib/shared/ui/custom_tooltip.dart @@ -0,0 +1,175 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class CustomTooltip extends StatefulWidget { + const CustomTooltip({ + Key? key, + required this.child, + required this.tooltip, + this.scrollController, + this.maxWidth = 400, + this.padding = const EdgeInsets.all(8), + this.boxShadow = const [ + BoxShadow( + color: Colors.black12, + blurRadius: 5, + spreadRadius: 1, + offset: Offset(0, 2), + ) + ], + this.color, + }) : super(key: key); + final double maxWidth; + final EdgeInsets padding; + final Color? color; + final List boxShadow; + final Widget child; + final Widget? tooltip; + final ScrollController? scrollController; + + @override + State createState() => _CustomTooltipState(); +} + +class _CustomTooltipState extends State { + final GlobalKey _childKey = GlobalKey(); + final GlobalKey _tooltipKey = GlobalKey(); + bool _tooltipHasHover = false; + bool _childHasHover = false; + late OverlayEntry _tooltipWrapper; + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _tooltipWrapper = _buildTooltipWrapper(); + widget.scrollController?.addListener(_scrollListener); + }); + + super.initState(); + } + + @override + void dispose() { + super.dispose(); + widget.scrollController?.removeListener(_scrollListener); + if (_tooltipWrapper.mounted) { + _tooltipWrapper.remove(); + } + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.passthrough, + clipBehavior: Clip.none, + children: [ + InkWell( + splashColor: Colors.transparent, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + focusColor: Colors.transparent, + mouseCursor: MouseCursor.uncontrolled, + key: _childKey, + onTap: _switch, + onHover: (hasHover) async { + setState(() => _childHasHover = hasHover); + + if (_childHasHover) { + _show(); + } else { + await Future.delayed(const Duration(milliseconds: 300)); + if (!_tooltipHasHover) _hide(); + } + }, + child: widget.child, + ), + ], + ); + } + + OverlayEntry _buildTooltipWrapper() { + final RenderBox childObject = + _childKey.currentContext?.findRenderObject() as RenderBox; + final childOffset = childObject.localToGlobal(Offset.zero); + final bottom = MediaQueryData.fromView(View.of(_childKey.currentContext!)) + .size + .height - + childOffset.dy; + final left = childOffset.dx + childObject.size.width; + + return OverlayEntry( + builder: (context) { + return Stack( + children: [ + GestureDetector(onTapDown: (details) => _hide()), + Positioned( + bottom: bottom, + left: left, + width: widget.maxWidth, + child: Material( + child: InkWell( + splashColor: Colors.transparent, + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + focusColor: Colors.transparent, + mouseCursor: MouseCursor.uncontrolled, + onTap: () {}, + onHover: (hasHover) async { + setState(() => _tooltipHasHover = hasHover); + if (_tooltipHasHover) return; + + await Future.delayed( + const Duration(milliseconds: 300)); + if (!_childHasHover) _hide(); + }, + child: Container( + key: _tooltipKey, + constraints: BoxConstraints( + maxWidth: widget.maxWidth, + ), + decoration: BoxDecoration( + color: + widget.color ?? Theme.of(context).colorScheme.surface, + boxShadow: widget.boxShadow, + ), + padding: widget.padding, + child: widget.tooltip, + ), + ), + ), + ), + ], + ); + }, + ); + } + + void _show() { + if (widget.tooltip == null) return; + if (_tooltipWrapper.mounted) return; + + setState(() => _tooltipWrapper = _buildTooltipWrapper()); + Overlay.of(context).insert(_tooltipWrapper); + } + + void _hide() { + if (!_tooltipWrapper.mounted) return; + + _tooltipWrapper.remove(); + } + + void _switch() { + if (_tooltipWrapper.mounted) { + _hide(); + } else { + _show(); + } + } + + void _scrollListener() { + if (_tooltipWrapper.mounted) { + _tooltipWrapper.remove(); + } + } +} diff --git a/lib/shared/ui/fading_edge_scroll_view.dart b/lib/shared/ui/fading_edge_scroll_view.dart new file mode 100644 index 0000000000..8203b5f1ea --- /dev/null +++ b/lib/shared/ui/fading_edge_scroll_view.dart @@ -0,0 +1,348 @@ +import 'package:flutter/material.dart'; + +/// Flutter widget for displaying fading edge at start/end of scroll views +class FadingEdgeScrollView extends StatefulWidget { + /// child widget + final Widget child; + + /// scroll controller of child widget + /// + /// Look for more documentation at [ScrollView.scrollController] + final ScrollController scrollController; + + /// Whether the scroll view scrolls in the reading direction. + /// + /// Look for more documentation at [ScrollView.reverse] + final bool reverse; + + /// The axis along which child view scrolls + /// + /// Look for more documentation at [ScrollView.scrollDirection] + final Axis scrollDirection; + + /// what part of screen on start half should be covered by fading edge gradient + /// [gradientFractionOnStart] must be 0 <= [gradientFractionOnStart] <= 1 + /// 0 means no gradient, + /// 1 means gradients on start half of widget fully covers it + final double gradientFractionOnStart; + + /// what part of screen on end half should be covered by fading edge gradient + /// [gradientFractionOnEnd] must be 0 <= [gradientFractionOnEnd] <= 1 + /// 0 means no gradient, + /// 1 means gradients on start half of widget fully covers it + final double gradientFractionOnEnd; + + /// set to true if you want scrollController passed to widget to be disposed when widget's state is disposed + final bool shouldDisposeScrollController; + + const FadingEdgeScrollView._internal({ + Key? key, + required this.child, + required this.scrollController, + required this.reverse, + required this.scrollDirection, + required this.gradientFractionOnStart, + required this.gradientFractionOnEnd, + required this.shouldDisposeScrollController, + }) : assert(gradientFractionOnStart >= 0 && gradientFractionOnStart <= 1), + assert(gradientFractionOnEnd >= 0 && gradientFractionOnEnd <= 1), + super(key: key); + + /// Constructor for creating [FadingEdgeScrollView] with [ScrollView] as child + /// child must have [ScrollView.controller] set + factory FadingEdgeScrollView.fromScrollView({ + Key? key, + required ScrollView child, + double gradientFractionOnStart = 0.1, + double gradientFractionOnEnd = 0.1, + bool shouldDisposeScrollController = false, + }) { + final controller = child.controller; + if (controller == null) { + throw Exception("Child must have controller set"); + } + + return FadingEdgeScrollView._internal( + key: key, + scrollController: controller, + scrollDirection: child.scrollDirection, + reverse: child.reverse, + gradientFractionOnStart: gradientFractionOnStart, + gradientFractionOnEnd: gradientFractionOnEnd, + shouldDisposeScrollController: shouldDisposeScrollController, + child: child, + ); + } + + /// Constructor for creating [FadingEdgeScrollView] with [SingleChildScrollView] as child + /// child must have [SingleChildScrollView.controller] set + factory FadingEdgeScrollView.fromSingleChildScrollView({ + Key? key, + required SingleChildScrollView child, + double gradientFractionOnStart = 0.1, + double gradientFractionOnEnd = 0.1, + bool shouldDisposeScrollController = false, + }) { + final controller = child.controller; + if (controller == null) { + throw Exception("Child must have controller set"); + } + + return FadingEdgeScrollView._internal( + key: key, + scrollController: controller, + scrollDirection: child.scrollDirection, + reverse: child.reverse, + gradientFractionOnStart: gradientFractionOnStart, + gradientFractionOnEnd: gradientFractionOnEnd, + shouldDisposeScrollController: shouldDisposeScrollController, + child: child, + ); + } + + /// Constructor for creating [FadingEdgeScrollView] with [PageView] as child + /// child must have [PageView.controller] set + factory FadingEdgeScrollView.fromPageView({ + Key? key, + required PageView child, + double gradientFractionOnStart = 0.1, + double gradientFractionOnEnd = 0.1, + bool shouldDisposeScrollController = false, + }) { + assert( + child.controller != null, + "PageView constructor's controller must be set. \nSee more at: " + "https://docs.flutter.dev/release/breaking-changes/pageview-controller", + ); + return FadingEdgeScrollView._internal( + key: key, + scrollController: child.controller!, + scrollDirection: child.scrollDirection, + reverse: child.reverse, + gradientFractionOnStart: gradientFractionOnStart, + gradientFractionOnEnd: gradientFractionOnEnd, + shouldDisposeScrollController: shouldDisposeScrollController, + child: child, + ); + } + + /// Constructor for creating [FadingEdgeScrollView] with [AnimatedList] as child + /// child must have [AnimatedList.controller] set + factory FadingEdgeScrollView.fromAnimatedList({ + Key? key, + required AnimatedList child, + double gradientFractionOnStart = 0.1, + double gradientFractionOnEnd = 0.1, + bool shouldDisposeScrollController = false, + }) { + final controller = child.controller; + if (controller == null) { + throw Exception("Child must have controller set"); + } + + return FadingEdgeScrollView._internal( + key: key, + scrollController: controller, + scrollDirection: child.scrollDirection, + reverse: child.reverse, + gradientFractionOnStart: gradientFractionOnStart, + gradientFractionOnEnd: gradientFractionOnEnd, + shouldDisposeScrollController: shouldDisposeScrollController, + child: child, + ); + } + + /// Constructor for creating [FadingEdgeScrollView] with [ScrollView] as child + /// child must have [ScrollView.controller] set + factory FadingEdgeScrollView.fromListWheelScrollView({ + Key? key, + required ListWheelScrollView child, + double gradientFractionOnStart = 0.1, + double gradientFractionOnEnd = 0.1, + bool shouldDisposeScrollController = false, + }) { + final controller = child.controller; + if (controller == null) { + throw Exception("Child must have controller set"); + } + + return FadingEdgeScrollView._internal( + key: key, + scrollController: controller, + scrollDirection: Axis.vertical, + reverse: false, + gradientFractionOnStart: gradientFractionOnStart, + gradientFractionOnEnd: gradientFractionOnEnd, + shouldDisposeScrollController: shouldDisposeScrollController, + child: child, + ); + } + + @override + // ignore: library_private_types_in_public_api + _FadingEdgeScrollViewState createState() => _FadingEdgeScrollViewState(); +} + +class _FadingEdgeScrollViewState extends State + with WidgetsBindingObserver { + late ScrollController _controller; + bool? _isScrolledToStart; + bool? _isScrolledToEnd; + + @override + void initState() { + super.initState(); + + _controller = widget.scrollController; + _isScrolledToStart = _controller.initialScrollOffset == 0; + _controller.addListener(_onScroll); + + WidgetsBinding.instance.let((it) { + it.addPostFrameCallback(_postFrameCallback); + it.addObserver(this); + }); + } + + bool get _controllerIsReady => + _controller.hasClients && _controller.position.hasContentDimensions; + + void _postFrameCallback(Duration _) { + if (!mounted) { + return; + } + + if (_isScrolledToEnd == null && + _controllerIsReady && + _controller.position.maxScrollExtent == 0) { + setState(() { + _isScrolledToEnd = true; + }); + } + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + _controller.removeListener(_onScroll); + if (widget.shouldDisposeScrollController) { + _controller.dispose(); + } + } + + void _onScroll() { + if (!_controllerIsReady) { + return; + } + final offset = _controller.offset; + final minOffset = _controller.position.minScrollExtent; + final maxOffset = _controller.position.maxScrollExtent; + + final isScrolledToEnd = offset >= maxOffset; + final isScrolledToStart = offset <= minOffset; + + if (isScrolledToEnd != _isScrolledToEnd || + isScrolledToStart != _isScrolledToStart) { + setState(() { + _isScrolledToEnd = isScrolledToEnd; + _isScrolledToStart = isScrolledToStart; + }); + } + } + + @override + void didChangeMetrics() { + super.didChangeMetrics(); + setState(() { + // Add the shading or remove it when the screen resize (web/desktop) or mobile is rotated + if (!_controllerIsReady) { + return; + } + final offset = _controller.offset; + final maxOffset = _controller.position.maxScrollExtent; + if (maxOffset == 0 && offset == 0) { + // Not scrollable + _isScrolledToStart = true; + _isScrolledToEnd = true; + } else if (maxOffset == offset) { + // Scrollable but at end + _isScrolledToStart = false; + _isScrolledToEnd = true; + } else if (maxOffset > 0 && offset == 0) { + // Scrollable but at start + _isScrolledToStart = true; + _isScrolledToEnd = false; + } else { + // Scroll in progress/not at either end + _isScrolledToStart = false; + _isScrolledToEnd = false; + } + }); + } + + @override + Widget build(BuildContext context) { + if (_isScrolledToStart == null && _controllerIsReady) { + final offset = _controller.offset; + final minOffset = _controller.position.minScrollExtent; + final maxOffset = _controller.position.maxScrollExtent; + + _isScrolledToEnd = offset >= maxOffset; + _isScrolledToStart = offset <= minOffset; + } + + return ShaderMask( + shaderCallback: (bounds) => LinearGradient( + begin: _gradientStart, + end: _gradientEnd, + stops: [ + 0, + widget.gradientFractionOnStart * 0.5, + 1 - widget.gradientFractionOnEnd * 0.5, + 1, + ], + colors: _getColors( + widget.gradientFractionOnStart > 0 && !(_isScrolledToStart ?? true), + widget.gradientFractionOnEnd > 0 && !(_isScrolledToEnd ?? false)), + ).createShader( + bounds.shift(Offset(-bounds.left, -bounds.top)), + textDirection: Directionality.of(context), + ), + blendMode: BlendMode.dstIn, + child: widget.child, + ); + } + + AlignmentGeometry get _gradientStart => + widget.scrollDirection == Axis.vertical + ? _verticalStart + : _horizontalStart; + + AlignmentGeometry get _gradientEnd => + widget.scrollDirection == Axis.vertical ? _verticalEnd : _horizontalEnd; + + Alignment get _verticalStart => + widget.reverse ? Alignment.bottomCenter : Alignment.topCenter; + + Alignment get _verticalEnd => + widget.reverse ? Alignment.topCenter : Alignment.bottomCenter; + + AlignmentDirectional get _horizontalStart => widget.reverse + ? AlignmentDirectional.centerEnd + : AlignmentDirectional.centerStart; + + AlignmentDirectional get _horizontalEnd => widget.reverse + ? AlignmentDirectional.centerStart + : AlignmentDirectional.centerEnd; + + List _getColors(bool isStartEnabled, bool isEndEnabled) => [ + (isStartEnabled ? Colors.transparent : Colors.white), + Colors.white, + Colors.white, + (isEndEnabled ? Colors.transparent : Colors.white) + ]; +} + +extension _Let on T { + U let(U Function(T) block) => block(this); +} diff --git a/lib/shared/ui/gap.dart b/lib/shared/ui/gap.dart new file mode 100644 index 0000000000..b2114ff8a7 --- /dev/null +++ b/lib/shared/ui/gap.dart @@ -0,0 +1,361 @@ +/// NB! The following code is copied from the gap package verbatim to avoid +/// the unnecessary overhead on the OPSEC team. +/// +/// The original package also has a gap widget for slivers, but it is not +/// included here. +/// +/// Credit: https://github.com/letsar/gap/blob/master/lib/src/rendering/gap.dart +/// +/// ============================================================================ + +import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; + +/// A widget that takes a fixed amount of space in the direction of its parent. +/// +/// It only works in the following cases: +/// - It is a descendant of a [Row], [Column], or [Flex], +/// and the path from the [Gap] widget to its enclosing [Row], [Column], or +/// [Flex] must contain only [StatelessWidget]s or [StatefulWidget]s (not other +/// kinds of widgets, like [RenderObjectWidget]s). +/// - It is a descendant of a [Scrollable]. +/// +/// See also: +/// +/// * [MaxGap], a gap that can take, at most, the amount of space specified. +/// * [SliverGap], the sliver version of this widget. +class Gap extends StatelessWidget { + /// Creates a widget that takes a fixed [mainAxisExtent] of space in the + /// direction of its parent. + /// + /// The [mainAxisExtent] must not be null and must be positive. + /// The [crossAxisExtent] must be either null or positive. + const Gap( + this.mainAxisExtent, { + Key? key, + this.crossAxisExtent, + this.color, + }) : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity), + assert(crossAxisExtent == null || crossAxisExtent >= 0), + super(key: key); + + /// Creates a widget that takes a fixed [mainAxisExtent] of space in the + /// direction of its parent and expands in the cross axis direction. + /// + /// The [mainAxisExtent] must not be null and must be positive. + const Gap.expand( + double mainAxisExtent, { + Key? key, + Color? color, + }) : this( + mainAxisExtent, + key: key, + crossAxisExtent: double.infinity, + color: color, + ); + + /// The amount of space this widget takes in the direction of its parent. + /// + /// For example: + /// - If the parent is a [Column] this is the height of this widget. + /// - If the parent is a [Row] this is the width of this widget. + /// + /// Must not be null and must be positive. + final double mainAxisExtent; + + /// The amount of space this widget takes in the opposite direction of the + /// parent. + /// + /// For example: + /// - If the parent is a [Column] this is the width of this widget. + /// - If the parent is a [Row] this is the height of this widget. + /// + /// Must be positive or null. If it's null (the default) the cross axis extent + /// will be the same as the constraints of the parent in the opposite + /// direction. + final double? crossAxisExtent; + + /// The color used to fill the gap. + final Color? color; + + @override + Widget build(BuildContext context) { + final scrollableState = Scrollable.maybeOf(context); + final AxisDirection? axisDirection = scrollableState?.axisDirection; + final Axis? fallbackDirection = + axisDirection == null ? null : axisDirectionToAxis(axisDirection); + + return _RawGap( + mainAxisExtent, + crossAxisExtent: crossAxisExtent, + color: color, + fallbackDirection: fallbackDirection, + ); + } +} + +/// A widget that takes, at most, an amount of space in a [Row], [Column], +/// or [Flex] widget. +/// +/// A [MaxGap] widget must be a descendant of a [Row], [Column], or [Flex], +/// and the path from the [MaxGap] widget to its enclosing [Row], [Column], or +/// [Flex] must contain only [StatelessWidget]s or [StatefulWidget]s (not other +/// kinds of widgets, like [RenderObjectWidget]s). +/// +/// See also: +/// +/// * [Gap], the unflexible version of this widget. +class MaxGap extends StatelessWidget { + /// Creates a widget that takes, at most, the specified [mainAxisExtent] of + /// space in a [Row], [Column], or [Flex] widget. + /// + /// The [mainAxisExtent] must not be null and must be positive. + /// The [crossAxisExtent] must be either null or positive. + const MaxGap( + this.mainAxisExtent, { + Key? key, + this.crossAxisExtent, + this.color, + }) : super(key: key); + + /// Creates a widget that takes, at most, the specified [mainAxisExtent] of + /// space in a [Row], [Column], or [Flex] widget and expands in the cross axis + /// direction. + /// + /// The [mainAxisExtent] must not be null and must be positive. + /// The [crossAxisExtent] must be either null or positive. + const MaxGap.expand( + double mainAxisExtent, { + Key? key, + Color? color, + }) : this( + mainAxisExtent, + key: key, + crossAxisExtent: double.infinity, + color: color, + ); + + /// The amount of space this widget takes in the direction of the parent. + /// + /// If the parent is a [Column] this is the height of this widget. + /// If the parent is a [Row] this is the width of this widget. + /// + /// Must not be null and must be positive. + final double mainAxisExtent; + + /// The amount of space this widget takes in the opposite direction of the + /// parent. + /// + /// If the parent is a [Column] this is the width of this widget. + /// If the parent is a [Row] this is the height of this widget. + /// + /// Must be positive or null. If it's null (the default) the cross axis extent + /// will be the same as the constraints of the parent in the opposite + /// direction. + final double? crossAxisExtent; + + /// The color used to fill the gap. + final Color? color; + + @override + Widget build(BuildContext context) { + return Flexible( + child: _RawGap( + mainAxisExtent, + crossAxisExtent: crossAxisExtent, + color: color, + ), + ); + } +} + +class _RawGap extends LeafRenderObjectWidget { + const _RawGap( + this.mainAxisExtent, { + Key? key, + this.crossAxisExtent, + this.color, + this.fallbackDirection, + }) : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity), + assert(crossAxisExtent == null || crossAxisExtent >= 0), + super(key: key); + + final double mainAxisExtent; + + final double? crossAxisExtent; + + final Color? color; + + final Axis? fallbackDirection; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderGap( + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent ?? 0, + color: color, + fallbackDirection: fallbackDirection, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderGap renderObject) { + renderObject + ..mainAxisExtent = mainAxisExtent + ..crossAxisExtent = crossAxisExtent ?? 0 + ..color = color + ..fallbackDirection = fallbackDirection; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('mainAxisExtent', mainAxisExtent)); + properties.add( + DoubleProperty('crossAxisExtent', crossAxisExtent, defaultValue: 0)); + properties.add(ColorProperty('color', color)); + properties.add(EnumProperty('fallbackDirection', fallbackDirection)); + } +} + +class RenderGap extends RenderBox { + RenderGap({ + required double mainAxisExtent, + double? crossAxisExtent, + Axis? fallbackDirection, + Color? color, + }) : _mainAxisExtent = mainAxisExtent, + _crossAxisExtent = crossAxisExtent, + _color = color, + _fallbackDirection = fallbackDirection; + + double get mainAxisExtent => _mainAxisExtent; + double _mainAxisExtent; + set mainAxisExtent(double value) { + if (_mainAxisExtent != value) { + _mainAxisExtent = value; + markNeedsLayout(); + } + } + + double? get crossAxisExtent => _crossAxisExtent; + double? _crossAxisExtent; + set crossAxisExtent(double? value) { + if (_crossAxisExtent != value) { + _crossAxisExtent = value; + markNeedsLayout(); + } + } + + Axis? get fallbackDirection => _fallbackDirection; + Axis? _fallbackDirection; + set fallbackDirection(Axis? value) { + if (_fallbackDirection != value) { + _fallbackDirection = value; + markNeedsLayout(); + } + } + + Axis? get _direction { + final parentNode = parent; + if (parentNode is RenderFlex) { + return parentNode.direction; + } else { + return fallbackDirection; + } + } + + Color? get color => _color; + Color? _color; + set color(Color? value) { + if (_color != value) { + _color = value; + markNeedsPaint(); + } + } + + @override + double computeMinIntrinsicWidth(double height) { + return _computeIntrinsicExtent( + Axis.horizontal, + () => super.computeMinIntrinsicWidth(height), + )!; + } + + @override + double computeMaxIntrinsicWidth(double height) { + return _computeIntrinsicExtent( + Axis.horizontal, + () => super.computeMaxIntrinsicWidth(height), + )!; + } + + @override + double computeMinIntrinsicHeight(double width) { + return _computeIntrinsicExtent( + Axis.vertical, + () => super.computeMinIntrinsicHeight(width), + )!; + } + + @override + double computeMaxIntrinsicHeight(double width) { + return _computeIntrinsicExtent( + Axis.vertical, + () => super.computeMaxIntrinsicHeight(width), + )!; + } + + double? _computeIntrinsicExtent(Axis axis, double Function() compute) { + final Axis? direction = _direction; + if (direction == axis) { + return _mainAxisExtent; + } else { + if (_crossAxisExtent!.isFinite) { + return _crossAxisExtent; + } else { + return compute(); + } + } + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final Axis? direction = _direction; + + if (direction != null) { + if (direction == Axis.horizontal) { + return constraints.constrain(Size(mainAxisExtent, crossAxisExtent!)); + } else { + return constraints.constrain(Size(crossAxisExtent!, mainAxisExtent)); + } + } else { + throw FlutterError( + 'A Gap widget must be placed directly inside a Flex widget ' + 'or its fallbackDirection must not be null', + ); + } + } + + @override + void performLayout() { + size = computeDryLayout(constraints); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (color != null) { + final Paint paint = Paint()..color = color!; + context.canvas.drawRect(offset & size, paint); + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('mainAxisExtent', mainAxisExtent)); + properties.add(DoubleProperty('crossAxisExtent', crossAxisExtent)); + properties.add(ColorProperty('color', color)); + properties.add(EnumProperty('fallbackDirection', fallbackDirection)); + } +} diff --git a/lib/shared/ui/gradient_border.dart b/lib/shared/ui/gradient_border.dart new file mode 100644 index 0000000000..adfb383cca --- /dev/null +++ b/lib/shared/ui/gradient_border.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class GradientBorder extends StatelessWidget { + final Widget child; + final LinearGradient gradient; + final double width; + final BorderRadius borderRadius; + final Color innerColor; + + const GradientBorder({ + Key? key, + required this.child, + required this.gradient, + required this.innerColor, + this.width = 1.0, + this.borderRadius = const BorderRadius.all(Radius.circular(18)), + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.all(width), + decoration: BoxDecoration( + borderRadius: borderRadius, + gradient: gradient, + ), + child: Container( + decoration: BoxDecoration( + color: innerColor, + borderRadius: borderRadius, + ), + child: child, + ), + ); + } +} diff --git a/lib/shared/ui/ui_flat_button.dart b/lib/shared/ui/ui_flat_button.dart new file mode 100644 index 0000000000..ea2d7c52db --- /dev/null +++ b/lib/shared/ui/ui_flat_button.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +class UiFlatButton extends StatelessWidget { + const UiFlatButton({ + Key? key, + this.text = '', + this.width = double.infinity, + this.height = 48.0, + this.backgroundColor, + this.textStyle, + this.shadow = false, + required this.onPressed, + }) : super(key: key); + + final String text; + final TextStyle? textStyle; + final bool shadow; + final double width; + final double height; + final Gradient? backgroundColor; + final void Function()? onPressed; + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints.tightFor(width: width, height: height), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18)), + boxShadow: shadow + ? [ + BoxShadow( + offset: const Offset(0, 0), + color: Theme.of(context) + .textTheme + .bodyMedium + ?.color + ?.withAlpha(20) ?? + Colors.transparent, + spreadRadius: 3, + blurRadius: 5, + ) + ] + : null), + child: TextButton( + onPressed: onPressed, + style: ButtonStyle( + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18.0), + )), + backgroundColor: shadow + ? WidgetStateProperty.all(Theme.of(context).cardColor) + : null, + ), + child: Text( + text, + style: textStyle ?? + Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + ), + ); + } +} diff --git a/lib/shared/ui/ui_gradient_icon.dart b/lib/shared/ui/ui_gradient_icon.dart new file mode 100644 index 0000000000..a978faba10 --- /dev/null +++ b/lib/shared/ui/ui_gradient_icon.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +class UiGradientIcon extends StatelessWidget { + const UiGradientIcon({ + Key? key, + required this.icon, + this.size = 24, + this.color, + }) : super(key: key); + + final IconData icon; + final double size; + final Color? color; + + @override + Widget build(BuildContext context) { + return SizedBox( + key: const Key('return-button'), + width: size, + height: size, + child: Icon( + icon, + size: size, + color: color ?? Theme.of(context).colorScheme.primary, + ), + ); + } +} diff --git a/lib/shared/ui/ui_light_button.dart b/lib/shared/ui/ui_light_button.dart new file mode 100644 index 0000000000..c181f58d83 --- /dev/null +++ b/lib/shared/ui/ui_light_button.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; + +class UiLightButton extends StatelessWidget { + const UiLightButton({ + Key? key, + this.text = '', + this.width = double.infinity, + this.height = 48.0, + this.prefix, + this.backgroundColor, + this.border, + this.textStyle, + required this.onPressed, + }) : super(key: key); + + final String text; + final TextStyle? textStyle; + final double width; + final double height; + final Widget? prefix; + final Color? backgroundColor; + final BoxBorder? border; + final void Function()? onPressed; + + @override + Widget build(BuildContext context) { + final TextStyle? style = Theme.of(context) + .textTheme + .labelLarge + ?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 14, + ) + .merge(textStyle); + + return Container( + constraints: BoxConstraints.tightFor(width: width, height: height), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18)), + border: border, + ), + child: TextButton( + onPressed: onPressed, + style: ButtonStyle( + padding: WidgetStateProperty.all(EdgeInsets.zero), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18.0), + )), + backgroundColor: WidgetStateProperty.all( + backgroundColor ?? Theme.of(context).colorScheme.surface), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (prefix != null) prefix!, + Text(text, style: style), + ], + ), + ), + ); + } +} diff --git a/lib/shared/ui/ui_list_header_with_sortings.dart b/lib/shared/ui/ui_list_header_with_sortings.dart new file mode 100644 index 0000000000..69ff1c32f3 --- /dev/null +++ b/lib/shared/ui/ui_list_header_with_sortings.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class UiListHeaderWithSorting extends StatelessWidget { + const UiListHeaderWithSorting({ + Key? key, + required this.items, + required this.sortData, + required this.onSortChange, + }) : super(key: key); + final List items; + final SortData sortData; + + final void Function(SortData) onSortChange; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 20, 12, 5), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: 1, + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Row( + children: items + .map((item) => Expanded( + flex: item.flex, + child: item.isEmpty + ? SizedBox(width: item.width) + : SizedBox( + width: item.width, + child: UiSortListButton( + text: item.text, + value: item.value, + sortData: sortData, + onClick: onSortChange, + ), + ), + )) + .toList(), + ), + ); + } +} + +class SortHeaderItemData { + SortHeaderItemData({ + required this.text, + required this.value, + this.flex = 1, + this.isEmpty = false, + this.width, + }); + + final String text; + final T value; + final int flex; + final bool isEmpty; + final double? width; +} diff --git a/lib/shared/ui/ui_primary_button.dart b/lib/shared/ui/ui_primary_button.dart new file mode 100644 index 0000000000..22a1f98015 --- /dev/null +++ b/lib/shared/ui/ui_primary_button.dart @@ -0,0 +1,160 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class UiPrimaryButton extends StatelessWidget { + const UiPrimaryButton({ + Key? key, + this.buttonKey, + this.text = '', + this.width = double.infinity, + this.height = 48.0, + this.backgroundColor, + this.textStyle, + this.prefix, + this.border, + required this.onPressed, + this.focusNode, + this.shadowColor, + this.child, + }) : super(key: key); + + final String text; + final TextStyle? textStyle; + final double width; + final double height; + final Color? backgroundColor; + final Widget? prefix; + final Key? buttonKey; + final BoxBorder? border; + final void Function()? onPressed; + final FocusNode? focusNode; + final Color? shadowColor; + final Widget? child; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: onPressed == null, + child: Opacity( + opacity: onPressed == null ? 0.4 : 1, + child: Container( + constraints: BoxConstraints.tightFor(width: width, height: height), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18)), + border: border, + ), + child: _Button( + focusNode: focusNode, + onPressed: onPressed, + buttonKey: buttonKey, + shadowColor: shadowColor, + backgroundColor: backgroundColor, + text: text, + textStyle: textStyle, + prefix: prefix, + child: child, + ), + ), + ), + ); + } +} + +class _Button extends StatefulWidget { + final FocusNode? focusNode; + final void Function()? onPressed; + final Key? buttonKey; + final Color? shadowColor; + final Color? backgroundColor; + final Widget? child; + final String text; + final TextStyle? textStyle; + final Widget? prefix; + const _Button({ + Key? key, + this.focusNode, + this.onPressed, + this.buttonKey, + this.shadowColor, + this.backgroundColor, + this.child, + required this.text, + this.textStyle, + this.prefix, + }) : super(key: key); + + @override + State<_Button> createState() => _ButtonState(); +} + +class _ButtonState extends State<_Button> { + bool _hasFocus = false; + + _ButtonState(); + @override + Widget build(BuildContext context) { + return ElevatedButton( + focusNode: widget.focusNode, + onFocusChange: (value) { + setState(() { + _hasFocus = value; + }); + }, + onPressed: widget.onPressed ?? () {}, + key: widget.buttonKey, + style: ElevatedButton.styleFrom( + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(18)), + ), + shadowColor: _hasFocus + ? widget.shadowColor ?? Theme.of(context).colorScheme.primary + : Colors.transparent, + elevation: 1, + backgroundColor: _backgroundColor, + foregroundColor: + ThemeData.estimateBrightnessForColor(_backgroundColor) == + Brightness.dark + ? theme.global.light.colorScheme.onSurface + : Theme.of(context).colorScheme.secondary, + ), + child: widget.child ?? + _ButtonChild( + text: widget.text, + textStyle: widget.textStyle, + prefix: widget.prefix, + ), + ); + } + + Color get _backgroundColor { + return widget.backgroundColor ?? Theme.of(context).colorScheme.primary; + } +} + +class _ButtonChild extends StatelessWidget { + final Widget? prefix; + final String text; + final TextStyle? textStyle; + const _ButtonChild({ + Key? key, + required this.text, + this.prefix, + this.textStyle, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final textStyle = Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 14, + color: theme.custom.defaultGradientButtonTextColor, + ); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (prefix != null) prefix!, + Text(text, style: textStyle ?? textStyle), + ], + ); + } +} diff --git a/lib/shared/ui/ui_simple_border_button.dart b/lib/shared/ui/ui_simple_border_button.dart new file mode 100644 index 0000000000..733207eebf --- /dev/null +++ b/lib/shared/ui/ui_simple_border_button.dart @@ -0,0 +1,77 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class UiSimpleBorderButton extends StatelessWidget { + const UiSimpleBorderButton({ + Key? key, + this.onPressed, + this.inProgress = false, + this.style, + this.padding, + required this.child, + }) : super(key: key); + + final VoidCallback? onPressed; + final Widget child; + final bool inProgress; + final TextStyle? style; + final EdgeInsets? padding; + + @override + Widget build(BuildContext context) { + final Color? color = Theme.of(context).textTheme.bodyMedium?.color; + final TextStyle effectiveStyle = TextStyle( + fontSize: 12, + color: color, + ).merge(style); + final EdgeInsets effectivePadding = + padding ?? const EdgeInsets.fromLTRB(15, 1, 15, 1); + const double borderRadius = 16; + + return Stack( + children: [ + Opacity( + opacity: inProgress ? 0 : 1, + child: Container( + decoration: BoxDecoration( + color: theme.custom.specificButtonBackgroundColor, + border: Border.all( + width: 1, + color: theme.custom.specificButtonBorderColor, + ), + borderRadius: BorderRadius.circular(borderRadius)), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(borderRadius), + child: Padding( + padding: effectivePadding, + child: DefaultTextStyle( + style: effectiveStyle, + child: child, + ), + ), + ), + ), + ), + ), + if (inProgress) + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: Center( + child: UiSpinner( + width: effectiveStyle.fontSize! * 1.2, + height: effectiveStyle.fontSize! * 1.2, + strokeWidth: effectiveStyle.fontSize! * 0.12, + ), + ), + ) + ], + ); + } +} diff --git a/lib/shared/ui/ui_tab_bar/ui_tab.dart b/lib/shared/ui/ui_tab_bar/ui_tab.dart new file mode 100644 index 0000000000..bd1b676ccc --- /dev/null +++ b/lib/shared/ui/ui_tab_bar/ui_tab.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +class UiTab extends StatelessWidget { + const UiTab({ + Key? key, + required this.text, + required this.isSelected, + this.onClick, + }) : super(key: key); + + final String text; + final bool isSelected; + final Function? onClick; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + gradient: isSelected + ? LinearGradient( + colors: [ + Theme.of(context).colorScheme.primary, + Theme.of(context).colorScheme.secondary, + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ) + : null, + borderRadius: BorderRadius.circular(36.0), + ), + child: ElevatedButton( + onPressed: onClick == null ? null : () => onClick!(), + style: ElevatedButton.styleFrom( + shadowColor: Colors.transparent, + padding: EdgeInsets.zero, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(36.0), + ), + ).copyWith( + backgroundColor: WidgetStateProperty.all(Colors.transparent), + ), + child: Center( + child: Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).primaryTextTheme.bodyMedium?.copyWith( + // Use the same color scheme as in `dex_text_button.dart` + // for now + color: isSelected + ? Theme.of(context).primaryTextTheme.labelLarge?.color + : Theme.of(context).textTheme.labelLarge?.color, + ), + ), + ), + ), + ); + } +} diff --git a/lib/shared/ui/ui_tab_bar/ui_tab_bar.dart b/lib/shared/ui/ui_tab_bar/ui_tab_bar.dart new file mode 100644 index 0000000000..ec67355792 --- /dev/null +++ b/lib/shared/ui/ui_tab_bar/ui_tab_bar.dart @@ -0,0 +1,160 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/shared/ui/gradient_border.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab.dart'; + +class UiTabBar extends StatefulWidget { + const UiTabBar({ + Key? key, + required this.currentTabIndex, + required this.tabs, + }) : super(key: key); + + final int currentTabIndex; + final List tabs; + + @override + State createState() => _UiTabBarState(); +} + +class _UiTabBarState extends State { + final GlobalKey _switcherKey = GlobalKey(); + final int _tabsOnMobile = 3; + + @override + Widget build(BuildContext context) { + return GradientBorder( + borderRadius: const BorderRadius.all(Radius.circular(36)), + innerColor: dexPageColors.frontPlate, + gradient: dexPageColors.formPlateGradient, + child: Container( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + padding: const EdgeInsets.all(2), + child: SizedBox( + height: 36, + child: FocusTraversalGroup( + child: Row(children: _buildTabs()), + )), + ), + ); + } + + List _buildTabs() { + final List children = []; + + for (int i = 0; i < widget.tabs.length; i++) { + children.add(Flexible(child: widget.tabs[i])); + + // We need a way to fit all tabs + // in mobile screens with limited width + if (_isLastNotHiddenTabMobile(i)) { + children.add(Padding( + padding: const EdgeInsets.only(left: 1.0), + child: _buildMobileDropdown(), + )); + + break; + } + } + + return children; + } + + bool _isLastNotHiddenTabMobile(int i) { + return i == _tabsOnMobile - 1 && + isMobile && + widget.tabs.length > _tabsOnMobile; + } + + Widget _buildMobileDropdown() { + final bool isSelected = [3].contains(widget.currentTabIndex); + return UiDropdown( + borderRadius: BorderRadius.circular(50), + switcher: Container( + key: _switcherKey, + width: 28, + height: 28, + decoration: BoxDecoration( + color: isSelected ? Theme.of(context).colorScheme.primary : null, + shape: BoxShape.circle, + border: Border.all(color: const Color.fromRGBO(158, 213, 244, 1))), + child: Center( + child: Icon( + Icons.more_horiz, + color: isSelected + ? Colors.white + : const Color.fromRGBO(158, 213, 244, 1), + ), + ), + ), + dropdown: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + boxShadow: [ + BoxShadow( + offset: const Offset(0, 1), + blurRadius: 8, + color: theme.custom.tabBarShadowColor) + ], + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(10), + ), + child: Column(children: _buildDropdownTabs()), + ), + ), + ); + } + + List _buildDropdownTabs() { + final List childrenMobile = []; + + for (int i = _tabsOnMobile; i < widget.tabs.length; i++) { + childrenMobile.add(_buildDropdownItem(widget.tabs[i])); + } + + return childrenMobile; + } + + Widget _buildDropdownItem(UiTab tab) { + return InkWell( + borderRadius: BorderRadius.circular(10), + onTap: tab.onClick == null + ? null + : () { + tab.onClick!(); + _clickOnDropDownSwitcher(); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 17, vertical: 9), + child: Text( + tab.text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + ); + } + + void _clickOnDropDownSwitcher() { + final RenderBox? box = + _switcherKey.currentContext?.findRenderObject() as RenderBox?; + final Offset? position = box?.localToGlobal(Offset.zero); + if (box != null && position != null) { + WidgetsBinding.instance.handlePointerEvent(PointerDownEvent( + pointer: 0, + position: position, + )); + WidgetsBinding.instance.handlePointerEvent(PointerUpEvent( + pointer: 0, + position: position, + )); + } + } +} diff --git a/lib/shared/utils/balances_formatter.dart b/lib/shared/utils/balances_formatter.dart new file mode 100644 index 0000000000..479187c4c6 --- /dev/null +++ b/lib/shared/utils/balances_formatter.dart @@ -0,0 +1,34 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/model/coin.dart'; + +/// Calculates the fiat amount equivalent of the given [amount] of a [Coin] in USD. +/// +/// The fiat amount is calculated by multiplying the [amount] with the USD price of the [Coin]. +/// If the USD price is not available (`null`), it is treated as 0.00, and the resulting fiat amount will be 0.00. +/// +/// Parameters: +/// - [coin] (Coin): The Coin for which the fiat amount needs to be calculated. +/// - [amount] (Rational): The amount of the Coin to be converted to fiat. +/// +/// Return Value: +/// - (double): The equivalent fiat amount in USD based on the [amount] and the USD price of the [coin]. +/// +/// Example Usage: +/// ```dart +/// Coin bitcoin = Coin('BTC', usdPrice: Price(50000.0)); +/// Rational amount = Rational.fromDouble(2.5); +/// double fiatAmount = getFiatAmount(bitcoin, amount); +/// print(fiatAmount); // Output: 125000.0 (USD) +/// ``` +/// ```dart +/// Coin ethereum = Coin('ETH', usdPrice: Price(3000.0)); +/// Rational amount = Rational.fromInt(10); +/// double fiatAmount = getFiatAmount(ethereum, amount); +/// print(fiatAmount); // Output: 30000.0 (USD) +/// ``` +/// 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(); +} diff --git a/lib/shared/utils/browser_helpers.dart b/lib/shared/utils/browser_helpers.dart new file mode 100644 index 0000000000..a4a3854f5f --- /dev/null +++ b/lib/shared/utils/browser_helpers.dart @@ -0,0 +1,100 @@ +import 'package:universal_html/html.dart'; + +class BrowserInfo { + final String browserName; + final String browserVersion; + final String os; + final String screenSize; + + BrowserInfo({ + required this.browserName, + required this.browserVersion, + required this.os, + required this.screenSize, + }); +} + +class BrowserInfoParser { + static BrowserInfo? _cached; + + static BrowserInfo get() { + final cached = _cached; + if (cached == null) { + final String ua = window.navigator.userAgent.toLowerCase(); + final info = BrowserInfo( + browserName: _getBrowserName(ua), + browserVersion: _getBrowserVersion(ua), + os: _getOs(ua), + screenSize: _getScreenSize(), + ); + _cached = info; + + return info; + } else { + return BrowserInfo( + browserName: cached.browserName, + browserVersion: cached.browserVersion, + os: cached.os, + screenSize: _getScreenSize(), + ); + } + } + + static String _getOs(ua) { + if (ua.contains('windows')) { + return 'windows'; + } else if (ua.contains('android')) { + return 'android'; + } else if (ua.contains('macintosh')) { + return 'mac'; + } else if (ua.contains('iphone') || ua.contains('ipad')) { + return 'ios'; + } else if (ua.contains('linux')) { + return 'linux'; + } + return 'unknown'; + } + + static String _getBrowserName(String ua) { + if (ua.contains('edg/')) { + return 'edge'; + } else if (ua.contains('opr/')) { + return 'opera'; + } else if (ua.contains('chrome')) { + return 'chrome'; + } else if (ua.contains('safari')) { + return 'safari'; + } else if (ua.contains('firefox')) { + return 'firefox'; + } else if (ua.contains('brave')) { + return 'brave'; + } + return 'unknown'; + } + + static String _getBrowserVersion(String ua) { + String? browserVersion; + if (ua.contains('edg/')) { + browserVersion = RegExp('edg/([^s|;]*)').firstMatch(ua)?.group(1); + } else if (ua.contains('opr/')) { + browserVersion = RegExp('opr/([^s|;]*)').firstMatch(ua)?.group(1); + } else if (ua.contains('chrome')) { + browserVersion = RegExp('chrome/([^s|;]*)').firstMatch(ua)?.group(1); + } else if (ua.contains('safari')) { + browserVersion = RegExp('version/([^s|;]*)').firstMatch(ua)?.group(1); + } else if (ua.contains('firefox')) { + browserVersion = RegExp('firefox/([^s|;]*)').firstMatch(ua)?.group(1); + } else if (ua.contains('brave')) { + browserVersion = RegExp('brave/([^s|;]*)').firstMatch(ua)?.group(1); + } + return browserVersion ?? 'unknown'; + } + + static String _getScreenSize() { + final BodyElement? body = document.body; + final width = document.documentElement?.clientWidth ?? body?.clientWidth; + final height = document.documentElement?.clientHeight ?? body?.clientHeight; + + return '${width ?? ''}:${height ?? ''}'; + } +} diff --git a/lib/shared/utils/debug_utils.dart b/lib/shared/utils/debug_utils.dart new file mode 100644 index 0000000000..6625728a6b --- /dev/null +++ b/lib/shared/utils/debug_utils.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:flutter/services.dart'; +import 'package:uuid/uuid.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc_event.dart'; +import 'package:web_dex/bloc/wallets_bloc/wallets_repo.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/import_swaps/import_swaps_request.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/utils/encryption_tool.dart'; + +Future initDebugData(AuthBloc authBloc) async { + try { + final String testWalletStr = + await rootBundle.loadString('assets/debug_data.json'); + final Map debugDataJson = jsonDecode(testWalletStr); + final Map? newWalletJson = debugDataJson['wallet']; + if (newWalletJson == null) { + return; + } + + final Wallet? debugWallet = await _createDebugWallet( + newWalletJson, + hasBackup: true, + ); + if (debugWallet == null) { + return; + } + if (newWalletJson['automateLogin'] == true) { + authBloc.add( + AuthReLogInEvent(seed: newWalletJson['seed'], wallet: debugWallet)); + } + } catch (e) { + return; + } +} + +Future _createDebugWallet( + Map walletJson, { + bool hasBackup = false, +}) async { + final wallets = await walletsRepo.getAll(); + final Wallet? existedDebugWallet = + wallets.firstWhereOrNull((w) => w.name == walletJson['name']); + if (existedDebugWallet != null) return existedDebugWallet; + + final EncryptionTool encryptionTool = EncryptionTool(); + final String name = walletJson['name']; + final String seed = walletJson['seed']; + final String password = walletJson['password']; + final List activatedCoins = + List.from(walletJson['activated_coins'] ?? []); + + final String encryptedSeed = await encryptionTool.encryptData(password, seed); + + final Wallet wallet = Wallet( + id: const Uuid().v1(), + name: name, + config: WalletConfig( + seedPhrase: encryptedSeed, + activatedCoins: activatedCoins, + hasBackup: hasBackup, + ), + ); + final bool isSuccess = await walletsRepo.save(wallet); + return isSuccess ? wallet : null; +} + +Future importSwapsData(List swapsJson) async { + final ImportSwapsRequest request = ImportSwapsRequest(swaps: swapsJson); + await mm2Api.importSwaps(request); +} + +Future?> loadDebugSwaps() async { + final String? testDataStr; + try { + testDataStr = await rootBundle.loadString('assets/debug_data.json'); + } catch (e) { + return null; + } + + final Map debugDataJson = jsonDecode(testDataStr); + + if (debugDataJson['swaps'] == null) return null; + return debugDataJson['swaps']['import']; +} diff --git a/lib/shared/utils/encryption_tool.dart b/lib/shared/utils/encryption_tool.dart new file mode 100644 index 0000000000..6918e4a965 --- /dev/null +++ b/lib/shared/utils/encryption_tool.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:encrypt/encrypt.dart'; + +class EncryptionTool { + /// Encrypts the provided [data] using AES encryption with the given [password]. + /// + /// Parameters: + /// - [password] (String): The password used for encryption key derivation. + /// - [data] (String): The data to be encrypted. + /// + /// Return Value: + /// - Future: A JSON-encoded string containing the encrypted data and IVs. + /// + /// Example Usage: + /// ```dart + /// String password = 'securepassword'; + /// String data = 'confidential information'; + /// + /// String encryptedResult = await encryptData(password, data); + /// print(encryptedResult); // Output: JSON-encoded string with encrypted data and IVs + /// ``` + /// unit tests [testEncryptDataTool] + Future encryptData(String password, String data) async { + final iv1 = IV.fromLength(16); + final iv2 = IV.fromLength(16); + final secretKey = await _pbkdf2Key(password, iv2.bytes); + + final encrypter = Encrypter(AES(secretKey, mode: AESMode.cbc)); + final Encrypted encrypted = encrypter.encrypt(data, iv: iv1); + + final String result = jsonEncode({ + '0': base64.encode(encrypted.bytes), + '1': base64.encode(iv1.bytes), + '2': base64.encode(iv2.bytes), + }); + + return result; + } + + /// Decrypts the provided [encryptedData] using AES decryption with the given [password]. + /// The method attempts to decode the [encryptedData] as a JSON-encoded string + /// containing encrypted data and initialization vectors (IVs). + /// Parameters: + /// - [password] (String): The password used for decryption key derivation. + /// - [encryptedData] (String): The JSON-encoded string containing encrypted data and IVs. + /// + /// Return Value: + /// - Future: The decrypted data, or `null` if decryption fails. + /// + /// Example Usage: + /// ```dart + /// String password = 'securepassword'; + /// String encryptedData = '{"0":"...", "1":"...", "2":"..."}'; + /// + /// String? decryptedResult = await decryptData(password, encryptedData); + /// print(decryptedResult); // Output: Decrypted data or null if decryption fails + /// ``` + /// unit tests [testEncryptDataTool] + Future decryptData(String password, String encryptedData) async { + try { + final Map json = jsonDecode(encryptedData); + final Uint8List data = Uint8List.fromList(base64.decode(json['0'])); + final IV iv1 = IV.fromBase64(json['1']); + final IV iv2 = IV.fromBase64(json['2']); + + final secretKey = await _pbkdf2Key(password, iv2.bytes); + + final encrypter = Encrypter(AES(secretKey, mode: AESMode.cbc)); + final String decrypted = encrypter.decrypt(Encrypted(data), iv: iv1); + + return decrypted; + } catch (_) { + return _decryptLegacy(password, encryptedData); + } + } + + String? _decryptLegacy(String password, String encryptedData) { + try { + final String length32Key = md5.convert(utf8.encode(password)).toString(); + final key = Key.fromUtf8(length32Key); + final IV iv = IV.allZerosOfLength(16); + + final Encrypter encrypter = Encrypter(AES(key)); + final Encrypted encrypted = Encrypted.fromBase64(encryptedData); + final decryptedData = encrypter.decrypt(encrypted, iv: iv); + + return decryptedData; + } catch (_) { + return null; + } + } + + Future _pbkdf2Key(String password, Uint8List salt) async { + return Key.fromUtf8(password).stretch(16, iterationCount: 1000, salt: salt); + } +} diff --git a/lib/shared/utils/extensions/async_extensions.dart b/lib/shared/utils/extensions/async_extensions.dart new file mode 100644 index 0000000000..b6d8a0fa53 --- /dev/null +++ b/lib/shared/utils/extensions/async_extensions.dart @@ -0,0 +1,23 @@ +// Extension to wait all futures in a list + +extension WaitAllFutures on List> { + /// Wait all futures in a list. + /// + /// See Dart docs on error handling in lists of futures: [Future.wait] + Future> awaitAll() => Future.wait(this); +} + +extension AsyncRemoveWhere on List { + Future removeWhereAsync(Future Function(T element) test) async { + final List> futures = map(test).toList(); + final List results = await Future.wait(futures); + + final List newList = [ + for (int i = 0; i < length; i++) + if (!results[i]) this[i], + ]; + + clear(); + addAll(newList); + } +} diff --git a/lib/shared/utils/extensions/string_extensions.dart b/lib/shared/utils/extensions/string_extensions.dart new file mode 100644 index 0000000000..d3e18086cd --- /dev/null +++ b/lib/shared/utils/extensions/string_extensions.dart @@ -0,0 +1,6 @@ +extension StringExtension on String { + String toCapitalize() { + if (isEmpty) return this; + return "${this[0].toUpperCase()}${substring(1).toLowerCase()}"; + } +} diff --git a/lib/shared/utils/formatters.dart b/lib/shared/utils/formatters.dart new file mode 100644 index 0000000000..26ce9e4212 --- /dev/null +++ b/lib/shared/utils/formatters.dart @@ -0,0 +1,336 @@ +import 'dart:math' as math; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/shared/constants.dart'; + +final List currencyInputFormatters = [ + DecimalTextInputFormatter(decimalRange: decimalRange), + FilteringTextInputFormatter.allow(numberRegExp) +]; + +class DurationLocalization { + final String milliseconds; + final String seconds; + final String minutes; + final String hours; + + DurationLocalization({ + required this.milliseconds, + required this.seconds, + required this.minutes, + required this.hours, + }); +} + +/// unit test: [testDurationFormat] +String durationFormat( + Duration duration, DurationLocalization durationLocalization) { + final int hh = duration.inHours; + final int mm = duration.inMinutes.remainder(60); + final int ss = duration.inSeconds.remainder(60); + final int ms = duration.inMilliseconds; + + if (ms < 1000) return '$ms${durationLocalization.milliseconds}'; + + StringBuffer output = StringBuffer(); + if (hh > 0) { + output.write('$hh${durationLocalization.hours} '); + } + if (mm > 0 || output.isNotEmpty) { + output.write('$mm${durationLocalization.minutes} '); + } + output.write('$ss${durationLocalization.seconds}'); + + return output.toString().trim(); +} + +/// unit test: [testNumberWithoutExponent] +String getNumberWithoutExponent(String value) { + try { + return Rational.parse(value).toDecimalString(); + } catch (_) { + return value; + } +} + +/// unit tests: [testTruncateDecimal] +/// +/// Suggestion: @DmitriiP: +/// +// if (decimalRange < 0) { +// return value; +// } + +// final String withoutExponent = getNumberWithoutExponent(value); +// int dotIndex = withoutExponent.indexOf("."); +// int endIndex = dotIndex + decimalRange + 1; +// endIndex = math.min(endIndex, withoutExponent.length); + +// return withoutExponent.substring(0, endIndex); +String truncateDecimal(String value, int decimalRange) { + if (decimalRange < 0) { + return value; + } + final String withoutExponent = getNumberWithoutExponent(value); + final List temp = withoutExponent.split('.'); + if (temp.length == 1) { + return value; + } + if (decimalRange == 0) { + return temp[0]; + } + final String truncatedDecimals = temp[1].length < decimalRange + ? temp[1] + : temp[1].substring(0, decimalRange); + + return '${temp[0]}.$truncatedDecimals'; +} + +/// unit test: [testDecimalTextInputFormatter] +class DecimalTextInputFormatter extends TextInputFormatter { + DecimalTextInputFormatter({this.decimalRange = 0}) : assert(decimalRange > 0); + final int decimalRange; + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (oldValue.text == newValue.text) { + return newValue; + } + TextSelection newSelection = newValue.selection; + String truncated = newValue.text.replaceAll(',', '.'); + + final String value = newValue.text; + + if (value.contains('.') && + value.substring(value.indexOf('.') + 1).length > decimalRange) { + truncated = oldValue.text.isNotEmpty + ? oldValue.text + : truncateDecimal(newValue.text, decimalRange); + newSelection = oldValue.selection; + } else if (value == '.' || value == ',') { + truncated = '0.'; + + newSelection = newValue.selection.copyWith( + baseOffset: math.min(truncated.length, truncated.length + 1), + extentOffset: math.min(truncated.length, truncated.length + 1), + ); + } + + return TextEditingValue( + text: truncated, + selection: newSelection, + composing: TextRange.empty, + ); + } +} + +const _maxTimestampMillisecond = 8640000000000000; +const _minTimestampMillisecond = -8639999999999999; + +/// unit tests: [testFormattedDate] +String getFormattedDate(int timestamp, [bool isUtc = false]) { + final timestampMilliseconds = timestamp * 1000; + if (timestampMilliseconds < _minTimestampMillisecond || + timestampMilliseconds > _maxTimestampMillisecond) { + return 'Date is out of the range'; + } + final dateTime = + DateTime.fromMillisecondsSinceEpoch(timestampMilliseconds, isUtc: isUtc); + if (dateTime.year < 0) { + return '${DateFormat('dd MMM yyyy, HH:mm', 'en_US').format(dateTime)} BC'; + } + return DateFormat('dd MMM yyyy, HH:mm', 'en_US').format(dateTime); +} + +/// unit tests: [testCutLeadingZeros] +String cutTrailingZeros(String str) { + String loop(String input) { + if (input.length == 1) return input; + if (!input.contains('.')) return input; + + if (input[input.length - 1] == '0' || input[input.length - 1] == '.') { + input = input.substring(0, input.length - 1); + return loop(input); + } else { + return input; + } + } + + return loop(str); +} + +/// unit tests: [testFormatDexAmount] +String formatDexAmt(dynamic amount) { + if (amount == null) return ''; + + switch (amount.runtimeType) { + case double: + case Rational: + return cutTrailingZeros(amount.toStringAsFixed(8) ?? ''); + case String: + return cutTrailingZeros(double.parse(amount).toStringAsFixed(8)); + case int: + return cutTrailingZeros(amount.toDouble().toStringAsFixed(2)); + default: + return amount.toString(); + } +} + +const maxDigits = 12; +const fractionDigits = 2; +const significantDigits = 2; + +/// 1e-[maxDigits] +const minNumber = 1e-12; + +/// 1e+[maxDigits] +const maxNumber = 1e+12; + +/// unit test: [testFormatAmount] +/// We show 11 digits after dot if value in e+ notation +/// We show only 2 digit after zeros if we have small value greater then minNumber +String formatAmt(double value) { + if (!value.isFinite) return 'infinity'; + if (value == 0) return '0.00'; + + final sign = value < 0 ? '-' : ''; + final valueAbs = value.abs(); + + if (valueAbs > maxNumber) { + return sign + valueAbs.toStringAsPrecision(maxDigits); + } + + if (valueAbs < minNumber) { + final valueString = '$valueAbs'; + final precisionString = valueAbs.toStringAsPrecision(maxDigits); + if (valueString.length < precisionString.length) { + return sign + valueString; + } + return sign + precisionString; + } + + final leadingZeros = getLeadingZeros(valueAbs); + + if (leadingZeros > 0) { + String result = valueAbs.toStringAsFixed(leadingZeros + significantDigits); + while (result.endsWith('0') && result.length > 4) { + result = result.substring(0, result.length - 1); + } + return sign + result; + } + + final String rounded = valueAbs.toStringAsFixed(fractionDigits); + + if (rounded.length <= (maxDigits + 1)) { + return sign + rounded; + } + return sign + valueAbs.toStringAsPrecision(maxDigits); +} + +const tenBillion = 1e+10; +const billion = 1e+9; +const lowAmount = 1e-8; +const thousand = 1000; +const one = 1; + +final hugeFormatter = NumberFormat.compactLong(); +final billionFormatter = NumberFormat.decimalPattern(); +final thousandFormatter = NumberFormat("###,###,###,###", "en_US"); +final oneFormatter = NumberFormat("###,###,###,###.00", "en_US"); + +/// unit tests: [testToStringAmount] +/// Main idea is to keep length of value less then 13 symbols +/// include dots, commas, space and e-notation +/// +/// Reference is https://www.binance.com/en/markets/overview +/// +/// Use this sparingly in UIs as it can clutter the UI with too much information. +String toStringAmount(double amount, [int? digits]) { + switch (amount) { + case >= tenBillion: + final billionsAmount = amount / billion; + final newFormat = billionsAmount.toStringAsFixed(2); + final billionCount = newFormat.split('.').first.length; + if (billionCount >= 2) { + return hugeFormatter.format(amount); + } + return billionFormatter.format(amount.round()); + case >= thousand: + return thousandFormatter.format(amount); + case >= one: + return oneFormatter.format(amount); + case >= lowAmount: + String pattern = "0.00######"; + if (digits != null) { + pattern = "0.00${List.filled(digits - 2, "#").join()}"; + } + return NumberFormat(pattern, "en_US").format(amount); + } + return amount.toStringAsPrecision(4); +} + +/// Calculates the number of leading zeros required for the decimal representation of [value]. +/// Parameters: +/// - [value] (double): The value for which the number of leading zeros needs to be calculated. +/// +/// Return Value: +/// - (int): The number of leading zeros required for the decimal representation of [value]. +/// +/// Example Usage: +/// ```dart +/// double input = 0.01234; +/// int leadingZeros = getLeadingZeros(input); +/// print(leadingZeros); // Output: 2 (approximately) +/// ``` +/// unit test: [testLeadingZeros] +int getLeadingZeros(double value) => + ((1 / math.ln10) * math.log(1 / value)).floor(); + +void formatAmountInput(TextEditingController controller, Rational? value) { + final String currentText = controller.text; + if (currentText.isNotEmpty && Rational.parse(currentText) == value) return; + + final newText = + value == null ? '' : cutTrailingZeros(value.toStringAsFixed(8)); + controller.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), + composing: TextRange.empty, + ); +} + +/// Truncates a given [text] by removing middle characters, retaining start and end characters. +/// Parameters: +/// - [text] (String): The input text to be truncated. +/// - [startSymbolsCount] (int?): The number of characters to retain at the beginning of the [text]. +/// - [endCount] (int): The number of characters to retain at the end of the [text]. Default is 7. +/// +/// Return Value: +/// - (String): The truncated text with start and end characters retained and middle characters replaced by '...'. +/// +/// Example Usage: +/// ```dart +/// String input1 = '0x8f76543210abcdef'; +/// String result1 = truncateMiddleSymbols(input1); +/// print(result1); // Output: "0x8f76...cdef" +/// ``` +/// ```dart +/// String input2 = '1234567890'; +/// String result2 = truncateMiddleSymbols(input2, 2, 3); +/// print(result2); // Output: "12...890" +/// ``` +/// unit tests: [testTruncateHash] +String truncateMiddleSymbols(String text, + [int? startSymbolsCount, int endCount = 7]) { + int startCount = startSymbolsCount ?? (text.startsWith('0x') ? 6 : 4); + if (text.length <= startCount + endCount + 3) return text; + final String firstPart = text.substring(0, startCount); + final String secondPart = text.substring(text.length - endCount, text.length); + return '$firstPart...$secondPart'; +} diff --git a/lib/shared/utils/math.dart b/lib/shared/utils/math.dart new file mode 100644 index 0000000000..5fa895f44a --- /dev/null +++ b/lib/shared/utils/math.dart @@ -0,0 +1,20 @@ +import 'dart:math'; + +int decimalPlacesForSignificantFigures( + double number, + int significantFigures, +) { + if (number == 0) { + // For zero, the number of decimal places is simply the number of significant figures minus 1 + return significantFigures - 1; + } + + // Calculate the order of magnitude of the number + int orderOfMagnitude = log((number.abs()) / ln10).floor(); + + // Calculate the number of decimal places required + int decimalPlaces = significantFigures - 1 - orderOfMagnitude; + + // Ensure we don't return a negative number of decimal places + return decimalPlaces > 0 ? decimalPlaces : 0; +} diff --git a/lib/shared/utils/password.dart b/lib/shared/utils/password.dart new file mode 100644 index 0000000000..9d15540f4d --- /dev/null +++ b/lib/shared/utils/password.dart @@ -0,0 +1,81 @@ +import 'dart:math'; + +String generatePassword() { + final List passwords = []; + + final rng = Random(); + + const String lowerCase = 'abcdefghijklmnopqrstuvwxyz'; + const String upperCase = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; + const String digit = '0123456789'; + const String punctuation = '*.!@#\$%^(){}:;\',.?/~`_+\\-=|'; + + final string = [lowerCase, upperCase, digit, punctuation]; + + final length = rng.nextInt(24) + 8; + + final List tab = []; + + while (true) { + // This loop make sure the new RPC password will contains all the requirement + // characters type in password, it generate automatically the position. + tab.clear(); + for (var x = 0; x < length; x++) { + tab.add(string[rng.nextInt(4)]); + } + + if (tab.contains(lowerCase) && + tab.contains(upperCase) && + tab.contains(digit) && + tab.contains(punctuation)) break; + } + + for (int i = 0; i < tab.length; i++) { + // Here we constitute new RPC password, and check the repetition. + final chars = tab[i]; + final character = chars[rng.nextInt(chars.length)]; + final count = passwords.where((c) => c == character).toList().length; + if (count < 2) { + passwords.add(character); + } else { + tab.add(chars); + } + } + + return passwords.join(''); +} + +/// unit tests: [testValidateRPCPassword] +bool validateRPCPassword(String src) { + if (src.isEmpty) return false; + + // Password can't contain word 'password' + if (src.toLowerCase().contains('password')) return false; + + // Password must contain one digit, one lowercase letter, one uppercase letter, + // one special character and its length must be between 8 and 32 characters + final RegExp exp = RegExp( + r'^(?:(?=.*[0-9])(?=.*[a-z])(?=.*[A-Z])(?=.*[^A-Za-z0-9])).{8,32}$'); + if (!src.contains(exp)) return false; + + // Password can't contain same character three time in a row, + // so some code below to check that: + + // MRC: Divide the password into all possible 3 character blocks + final pieces = []; + for (int start = 0, end = 3; end <= src.length; start += 1, end += 1) { + pieces.add(src.substring(start, end)); + } + + // If, for any block, all 3 character are the same, block doesn't fit criteria + for (String p in pieces) { + final src = p[0]; + int count = 1; + if (p[1] == src) count += 1; + if (p[2] == src) count += 1; + + if (count == 3) return false; + } + + return true; +} diff --git a/lib/shared/utils/platform_tuner.dart b/lib/shared/utils/platform_tuner.dart new file mode 100644 index 0000000000..d73376e40a --- /dev/null +++ b/lib/shared/utils/platform_tuner.dart @@ -0,0 +1,54 @@ +import 'dart:ui'; + +import 'package:flutter/foundation.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:window_size/window_size.dart'; + +abstract class PlatformTuner { + static const minDesktopSize = Size(360, 650); + static const defaultDesktopSize = Size(1040, 820); + static const maxDesktopSize = Size.infinite; + + static bool get isNativeDesktop { + if (kIsWeb) return false; + + return defaultTargetPlatform == TargetPlatform.macOS || + defaultTargetPlatform == TargetPlatform.windows || + defaultTargetPlatform == TargetPlatform.linux; + } + + static Future setWindowTitleAndSize() async { + if (!isNativeDesktop) return; + + setWindowTitle(appTitle); + await _setWindowSizeDesktop(); + } + + static Future _setWindowSizeDesktop() async { + final info = await getWindowInfo(); + final double scaleFactor = info.screen?.scaleFactor ?? info.scaleFactor; + + if (defaultTargetPlatform == TargetPlatform.linux && scaleFactor != 1.0) { + // yurii (09.05.23): there is a bug in the window_size package + // that prevents the window from being resized correctly on Linux + // when the scale factor is not 1. + // setWindowMinSize and setWindowMaxSize are also affected. + return; + } + + // https://github.com/google/flutter-desktop-embedding/issues/917 + final double appliedScaleFactor = + defaultTargetPlatform == TargetPlatform.windows ? scaleFactor : 1.0; + + final Offset center = info.screen?.frame.center ?? info.frame.center; + final defaultWindowSize = Rect.fromCenter( + center: center, + width: defaultDesktopSize.width * appliedScaleFactor, + height: defaultDesktopSize.height * appliedScaleFactor, + ); + + setWindowMinSize(minDesktopSize); + setWindowMaxSize(maxDesktopSize); + setWindowFrame(defaultWindowSize); + } +} diff --git a/lib/shared/utils/prominent_colors.dart b/lib/shared/utils/prominent_colors.dart new file mode 100644 index 0000000000..769c316f3a --- /dev/null +++ b/lib/shared/utils/prominent_colors.dart @@ -0,0 +1,480 @@ +// Generated file with prominent colors for images +// The script extracts the most prominent color from each image in a directory +// and saves the results in this Dart file as a map. + +import 'dart:ui'; + +import 'package:web_dex/shared/utils/utils.dart'; + +Color? getCoinColorFromId(String id) { + final abbr = abbr2Ticker(id); + return getCoinColor(abbr); +} + +Color? getCoinColor(String ticker) { + final imagePath = _getCoinIconFileName(ticker); + final color = _prominentColors[imagePath]; + + return color != null + ? Color(int.parse(color.substring(1), radix: 16) | 0xFF000000) + : null; +} + +String _getCoinIconFileName(String abbr) { + final fileName = abbr2Ticker(abbr) + .toLowerCase() + // Remove any underscore suffixes _{SUFFIX}. E.g. doc_old -> doc + .replaceAll(RegExp(r'_.*'), ''); + return fileName; +} + +final Map _prominentColors = { + "runes": "#d7a316", + "labs": "#0d0d0d", + "osmo": "#770fb9", + "tft": "#d038ec", + "bttc": "#050505", + "shib": "#fb9f0c", + "cy": "#040404", + "dodo": "#0a0a04", + "psf": "#040505", + "dash": "#048be6", + "egld": "#1d4cb4", + "uni": "#f80577", + "arpa": "#b6bec3", + "woo": "#24242c", + "rsr": "#040404", + "neng": "#dbdacd", + "xec": "#076cbc", + "cfx": "#ebf6f6", + "vite": "#047cfc", + "slp": "#0cc38c", + "loom": "#46bbfb", + "snx": "#5eccf9", + "zet": "#2b7c91", + "chf": "#c03128", + "zec": "#ebb043", + "mkr": "#1aa899", + "tel": "#14c6fb", + "jkrw": "#154fa0", + "sys": "#0480c4", + "cake": "#d28a4b", + "waf": "#ebcbb1", + "wcn": "#e39b04", + "trac": "#3fca9f", + "busd": "#f3bc0c", + "jchf": "#da0626", + "minds": "#5a5959", + "nyc": "#1a56b4", + "krw": "#dbd0cf", + "agix": "#6715fa", + "qtum": "#2e98ce", + "avaxt": "#e74041", + "huf": "#c43c4d", + "cds": "#a67116", + "mil": "#49b1e6", + "ftm": "#14b4ec", + "sol": "#63f89e", + "joy": "#7f4ddf", + "band": "#4f68f9", + "whive": "#c4ed08", + "flo": "#207ea0", + "mc": "#ee9c42", + "zrx": "#2f2b2b", + "okb": "#2a67ec", + "nav": "#e6e6e7", + "wld": "#040404", + "space_bep20": "#ec456a", + "doge": "#c1a433", + "blk": "#d6ac58", + "bnt": "#040d2a", + "matic": "#6e40d7", + "banano": "#383839", + "bal": "#1d1d1d", + "game": "#2c4559", + "gala": "#051d35", + "ren": "#090917", + "bnb": "#f1b82e", + "uno": "#e2b21f", + "aipg": "#6b6769", + "nok": "#df3839", + "tusd": "#2a2d7e", + "ltc": "#bebaba", + "nmc": "#176a9b", + "space": "#ec456a", + "btcz": "#4b4a47", + "vra": "#df1a3f", + "gleec": "#8c44fc", + "zer": "#050505", + "dai": "#f2b530", + "clam": "#20c3d1", + "qrc20": "#2e98ce", + "trc": "#3c3c3c", + "xep": "#10409c", + "hlc": "#043c7c", + "emc2": "#04c9fa", + "ada": "#0d1d2f", + "iotx": "#c6f4f5", + "uma": "#f94848", + "link": "#2958d8", + "space_ftm20": "#ec456a", + "case": "#080e10", + "efl": "#fb930d", + "wbtc": "#201a2c", + "eiln": "#52ada1", + "xsgd": "#144be4", + "btx": "#fb2da1", + "tryb": "#04144b", + "rpl": "#fc9f60", + "sxp": "#fc6431", + "cro": "#103d65", + "ape": "#0755f8", + "doc": "#7e4152", + "nexo": "#c4cee4", + "dot": "#e30477", + "testbtc": "#f6911a", + "celr": "#040404", + "dime": "#0c4ca4", + "qkc": "#2a7ebb", + "vrsc": "#3063d2", + "omg": "#101010", + "movr": "#f4b406", + "jsek": "#0a6fa5", + "eos": "#282828", + "solve": "#d2d2db", + "chips": "#577f80", + "leo": "#14051c", + "sbch": "#c3e454", + "ils": "#1541a9", + "husd": "#045efa", + "ewt": "#a464fc", + "pivx": "#5c4575", + "usdf": "#0cc38c", + "gnt": "#041c54", + "colx": "#75c1ae", + "rtb": "#861b19", + "prcy": "#0b3732", + "gmx": "#1395ea", + "iris": "#7a559a", + "avax": "#e74041", + "sek": "#155889", + "qi": "#242424", + "ton": "#048bcc", + "ava": "#3f3c61", + "crnc": "#e88717", + "xvc": "#fb0808", + "ilnf": "#1a4716", + "cvc": "#39ae3d", + "cvt": "#1c0c4c", + "scrt": "#1e1e1e", + "xlm": "#040404", + "evr": "#39cdf7", + "usdd": "#d2e1dd", + "exn": "#4ceac3", + "pink": "#ea77a7", + "pyr": "#f69725", + "babydoge": "#3b241e", + "mona": "#ddc699", + "ethr": "#627de8", + "jrt": "#54fc74", + "euroe": "#243c4b", + "nyan": "#215cae", + "qrc": "#2e98ce", + "mcl": "#ec0404", + "yfii": "#ec2c76", + "lstr": "#773394", + "sgd": "#e24652", + "ttt": "#7029b1", + "fet": "#1f324a", + "gt": "#cc5454", + "dust": "#65042b", + "jst": "#b41515", + "dx": "#f2a214", + "eth": "#627de8", + "gns": "#47f29a", + "jjpy": "#bc052d", + "1inch": "#d62122", + "ink": "#de1a13", + "nzds": "#293c4f", + "seele": "#d3e1e5", + "put": "#3bb474", + "rlc": "#fbd604", + "bep": "#f1b82e", + "eca": "#a815db", + "bgn": "#129271", + "flexusd": "#dfd9fc", + "kmd": "#58C0AB", + "jsgd": "#ec2637", + "ron": "#112258", + "inj": "#1bddee", + "glm": "#041c55", + "xor": "#e4242c", + "xna": "#d4c4da", + "borg": "#04c28c", + "jgold": "#d39b53", + "jgbp": "#cb1932", + "ant": "#2bd1e0", + "minu": "#e3822b", + "ddd": "#8cc41c", + "dent": "#646464", + "xvg": "#8ee6fc", + "dkk": "#c12240", + "dia": "#9c3898", + "awc": "#299cfc", + "hpy": "#2484e1", + "xtz": "#a4df04", + "ftmt": "#14b4ec", + "hex": "#fc683f", + "zoin": "#1e180d", + "myr": "#1944a2", + "dpc": "#049c94", + "il8p": "#050505", + "hkd": "#c82717", + "jbrl": "#0e9e41", + "diac": "#1b8beb", + "icx": "#1cc4cb", + "rvn": "#373f80", + "brz": "#acdac5", + "aibc": "#c8ad59", + "ldo": "#f49c8b", + "tblk": "#1f201f", + "ilv": "#593790", + "lrc": "#29b5f5", + "yfi": "#0568e0", + "usdt": "#259e78", + "brl": "#bacca3", + "pnk": "#3c3b3e", + "usdc": "#3d72c3", + "jusd": "#723456", + "xmy": "#eb0f74", + "marty": "#6e4953", + "dfx": "#08d7f4", + "xvs": "#f4bb51", + "varrr": "#0c151c", + "jpyc": "#ced3e7", + "vtc": "#048556", + "space_avx20": "#ec456a", + "clp": "#3b3f4b", + "taz": "#31190e", + "powr": "#06b9a6", + "glmr": "#e4147c", + "fjc": "#04aceb", + "ht": "#2a3069", + "aby": "#b81c18", + "mewc": "#d2ba7c", + "glc": "#fcbc04", + "swap": "#4464e3", + "pln": "#c81835", + "bkc": "#73c173", + "lynx": "#0f61ad", + "jcny": "#ec1e25", + "tbch": "#8cc250", + "maza": "#e6d8be", + "chsb": "#04c28c", + "inr": "#ebc7a8", + "pot": "#0f592e", + "gm": "#faecdf", + "czk": "#c31a1e", + "jpln": "#dc163e", + "ont": "#31a2bc", + "signa": "#c6c6c6", + "vprm": "#121212", + "bsty": "#1d3d75", + "hrk": "#d44141", + "shr": "#196fb4", + "koin": "#045322", + "aud": "#2c4475", + "atom": "#2e3147", + "mxn": "#dad3d0", + "fil": "#41bfc9", + "twt": "#3474bc", + "val": "#14202c", + "xidr": "#f41515", + "ankr": "#2e6af5", + "uqc": "#cd343b", + "tbtc": "#1c242c", + "smart_chain": "#276580", + "enj": "#604cbd", + "jaud": "#0a1269", + "uis": "#1c2b44", + "jmxn": "#166d4b", + "iln": "#d9d9d5", + "pnd": "#dcdbd3", + "paxg": "#e2cc4c", + "qiair": "#242424", + "supernet": "#f28535", + "aur": "#108a5f", + "qc": "#05d3b8", + "ninja": "#080f14", + "aave": "#2db9c5", + "mln": "#0a1428", + "xcn": "#353234", + "cvx": "#e0bca5", + "pax": "#38805e", + "ufo": "#0c1d2d", + "bidr": "#fceab5", + "bnbt": "#f1b82e", + "elf": "#2a5cb9", + "leash": "#e58113", + "gbx": "#1664ad", + "imx": "#6f71b4", + "rtm": "#b54a2c", + "iost": "#1c1c1c", + "actn": "#fb0404", + "one": "#04ade8", + "dp": "#e41c24", + "jeur": "#d8b617", + "zombie": "#500f0a", + "srm": "#63d8e7", + "zar": "#dcb7ab", + "jdb": "#273a50", + "avn": "#27cbba", + "dogedash": "#e29a55", + "dgc": "#d7d5cf", + "aya": "#f9bd16", + "axe": "#fb0404", + "space_plg20": "#ec456a", + "dimi": "#11e0e6", + "blocx": "#1c232d", + "ethk": "#627de8", + "fei": "#249c6c", + "maze": "#d57113", + "jcad": "#fccece", + "kcs": "#0491db", + "knc": "#31ca9d", + "best": "#eb3354", + "prux": "#f3b21c", + "waves": "#0453fb", + "mana": "#f72c52", + "comp": "#04d193", + "etc": "#328132", + "gno": "#05a6c3", + "gmt": "#dab258", + "thc_bep20": "#efae2c", + "rndr": "#cc1414", + "lswap": "#fc4207", + "dgb": "#0468d0", + "cad": "#e3dddd", + "axs": "#0454d4", + "ccl": "#35acf3", + "clc": "#e2e7eb", + "skl": "#040404", + "med": "#04aefb", + "vrm": "#5b6c7b", + "aslp": "#0cc38c", + "trx": "#ec0426", + "sum": "#194ad5", + "hot": "#8733fb", + "btu": "#654585", + "jtry": "#e40e16", + "gft": "#9a2f62", + "ric": "#5ee3db", + "ppc": "#3bae53", + "nzd": "#2b4374", + "sushi": "#d55891", + "rdd": "#e1c327", + "lnc": "#fac02b", + "bat": "#fb4f04", + "ubt": "#2e5d85", + "rev": "#751a4c", + "gst": "#2b2527", + "uos": "#7c54d2", + "sibm": "#cfdbdd", + "btt": "#050505", + "zinu": "#f1ab29", + "usbl": "#1d9454", + "btc": "#f6911a", + "idr": "#f0f0f0", + "qiad": "#242424", + "crt": "#d1d3e9", + "mm": "#efa206", + "try": "#d7343e", + "fxs": "#040404", + "jnzd": "#cf778b", + "cdn": "#f60808", + "sca": "#546c7c", + "fjcb": "#f69f04", + "rbtc": "#fc9d37", + "stfiro": "#9b2434", + "tkl": "#1e2636", + "chz": "#cb0423", + "bone": "#d53920", + "thb": "#dad7df", + "crv": "#3e629d", + "vgx": "#4d4cd8", + "jphp": "#cb1627", + "storj": "#2681fb", + "cadc": "#fcdbdb", + "ageur": "#8cb0fa", + "floki": "#f09925", + "lbc": "#045f48", + "dogec": "#246cec", + "bch": "#8cc250", + "ilnsw": "#6be0ca", + "grs": "#377d95", + "ksm": "#040404", + "bitn": "#e1deda", + "php": "#b61523", + "iota": "#232323", + "sand": "#04abee", + "adx": "#1b73ba", + "tama": "#f3ca05", + "ocean": "#181818", + "mir": "#2b9ae9", + "matictest": "#6e40d7", + "loop": "#ec1b23", + "thc": "#477928", + "kip0004": "#2f3848", + "sour": "#efbf1e", + "gusd": "#04dbfa", + "zil": "#47bebc", + "qnt": "#040404", + "via": "#545454", + "grms": "#129eb2", + "spacecoin": "#32406a", + "kiiro": "#fcac44", + "flow": "#04eb8c", + "wwcn": "#e5a011", + "req": "#04e29c", + "doggy": "#203a54", + "eure": "#0582c0", + "mask": "#1f6cf4", + "rep": "#0e0e20", + "eurs": "#2699f5", + "firo": "#9c1c2c", + "lcc": "#189c29", + "bte": "#fce404", + "erc": "#627de8", + "spice": "#e64f48", + "flux": "#2a60cf", + "vet": "#14bafb", + "oc": "#1a5590", + "btcd": "#fc6404", + "tsl": "#5eac85", + "cummies": "#f506c1", + "xpm": "#fbd61b", + "cel": "#4354a4", + "vote2024": "#2f3848", + "doi": "#239ddc", + "boli": "#1f2633", + "ftc": "#263139", + "kip0003": "#2f3848", + "xrg": "#142c54", + "s4f": "#f18e14", + "xrp": "#22282e", + "cst": "#3469bf", + "near": "#040404", + "qbt": "#1ad5be", + "grlc": "#f9d76b", + "pgx": "#cc0404", + "grt": "#5841cb", + "chta": "#daba61", + "ubq": "#04e88e", + "bbk": "#24cfd9", + "nvc": "#ebe533", + "arrr": "#c29f47", + "tqtum": "#2e98ce", + "utk": "#2f3478", + "kip0002": "#2f3848", + "snt": "#596bed", +}; diff --git a/lib/shared/utils/sorting.dart b/lib/shared/utils/sorting.dart new file mode 100644 index 0000000000..05f5804d7f --- /dev/null +++ b/lib/shared/utils/sorting.dart @@ -0,0 +1,45 @@ +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +/// unit tests: [testSorting] +int sortByDouble( + double first, + double second, + SortDirection sortDirection, +) { + if (first == second) return -1; + switch (sortDirection) { + case SortDirection.increase: + return first - second > 0 ? 1 : -1; + case SortDirection.decrease: + return second - first > 0 ? 1 : -1; + case SortDirection.none: + return -1; + } +} + +int sortByOrderType( + TradeSide first, TradeSide second, SortDirection sortDirection) { + if (first == second || sortDirection == SortDirection.none) return -1; + switch (sortDirection) { + case SortDirection.increase: + return first == TradeSide.taker && second == TradeSide.maker ? 1 : -1; + + case SortDirection.decrease: + return second == TradeSide.taker && first == TradeSide.maker ? 1 : -1; + case SortDirection.none: + return -1; + } +} + +int sortByBool(bool first, bool second, SortDirection sortDirection) { + if (first == second) return -1; + switch (sortDirection) { + case SortDirection.increase: + return first && !second ? 1 : -1; + case SortDirection.decrease: + return first && !second ? -1 : 1; + case SortDirection.none: + return -1; + } +} diff --git a/lib/shared/utils/utils.dart b/lib/shared/utils/utils.dart new file mode 100644 index 0000000000..a7771d3423 --- /dev/null +++ b/lib/shared/utils/utils.dart @@ -0,0 +1,638 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:app_theme/app_theme.dart'; +import 'package:bip39/bip39.dart' as bip39; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:rational/rational.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/performance_analytics/performance_analytics.dart'; +import 'package:web_dex/services/logger/get_logger.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:http/http.dart' as http; +export 'package:web_dex/shared/utils/extensions/async_extensions.dart'; +export 'package:web_dex/shared/utils/prominent_colors.dart'; + +Future systemClockIsValid() async { + try { + final response = await http + .get(Uri.parse('https://worldtimeapi.org/api/timezone/UTC')) + .timeout(const Duration(seconds: 20)); + + if (response.statusCode == 200) { + final jsonResponse = json.decode(response.body); + final apiTimeStr = jsonResponse['datetime']; + final apiTime = DateTime.parse(apiTimeStr).toUtc(); + final localTime = DateTime.now().toUtc(); + final difference = apiTime.difference(localTime).abs().inSeconds; + + return difference < 60; + } else { + log('Failed to get time from API'); + return true; // Do not block the usage + } + } catch (e) { + log('Failed to validate system clock'); + return true; // Do not block the usage + } +} + +void copyToClipBoard(BuildContext context, String str) { + final themeData = Theme.of(context); + try { + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + duration: const Duration(seconds: 2), + content: Text( + LocaleKeys.clipBoard.tr(), + style: themeData.textTheme.bodyLarge!.copyWith( + color: themeData.brightness == Brightness.dark + ? themeData.hintColor + : themeData.primaryColor), + ), + )); + } catch (_) {} + + Clipboard.setData(ClipboardData(text: str)); +} + +/// Converts a double value [dv] to a string representation with specified decimal places [fractions]. +/// Parameters: +/// - [dv] (double): The input double value to be converted to a string. +/// - [fractions] (int): The number of decimal places to format the double value. Default is 8. +/// +/// Return Value: +/// - (String): The formatted string representation of the double value. +/// +/// Example Usage: +/// ```dart +/// double inputValue1 = 123.456789; +/// String result1 = doubleToString(inputValue1, 3); +/// print(result1); // Output: "123.457" +/// ``` +/// ```dart +/// double inputValue2 = 1000.0; +/// String result2 = doubleToString(inputValue2); +/// print(result2); // Output: "1000" +/// ``` +/// unit tests: [testCustomDoubleToString] +String doubleToString(double dv, [int fractions = 8]) { + final Rational r = Rational.parse(dv.toString()); + if (r.isInteger) return r.toStringAsFixed(0); + String sv = r.toStringAsFixed(fractions > 20 ? 20 : fractions); + final dot = sv.indexOf('.'); + // Looks like we already have [cutTrailingZeros] + sv = sv.replaceFirst(RegExp(r'0+$'), '', dot); + if (sv.length - 1 == dot) sv = sv.substring(0, dot); + if (sv == '-0') sv = sv.replaceAll('-', ''); + return sv; +} + +/// Converts a map [fract] containing numerator and denominator to a [Rational] value. +/// Parameters: +/// - [fract] (Map?): The map containing numerator and denominator values. +/// +/// Return Value: +/// - (Rational?): The [Rational] value representing the numerator and denominator, +/// or null if conversion fails or [fract] is null. +/// +/// Example Usage: +/// ```dart +/// Map fractionMap = {'numer': 3, 'denom': 4}; +/// Rational? result = fract2rat(fractionMap); +/// print(result); // Output: Rational with value 3/4 +/// ``` +/// ```dart +/// Rational? result = fract2rat(null); +/// print(result); // Output: null +/// ``` +/// unit tests: [testRatToFracAndViseVersa] +Rational? fract2rat(Map? fract, [bool willLog = true]) { + if (fract == null) return null; + + try { + final rat = Rational( + BigInt.from(double.parse(fract['numer'])), + BigInt.from(double.parse(fract['denom'])), + ); + return rat; + } catch (e) { + if (willLog) { + log('Error fract2rat: $e', isError: true); + } + return null; + } +} + +/// Converts a [Rational] value [rat] to a map containing numerator and denominator. +/// +/// Parameters: +/// - [rat] (Rational?): The [Rational] value to be converted to a map. +/// - [toLog] (bool): Whether to log errors. Default is true. +/// +/// Return Value: +/// - (Map?): The map containing 'numer' and 'denom' keys and values, +/// or null if conversion fails or [rat] is null. +/// +/// Example Usage: +/// ```dart +/// Rational inputRational = Rational.fromBigInt(BigInt.from(3), BigInt.from(4)); +/// Map? result = rat2fract(inputRational); +/// print(result); // Output: {'numer': '3', 'denom': '4'} +/// ``` +/// ```dart +/// Map? result = rat2fract(null); +/// print(result); // Output: null +/// ``` +/// unit tests: [testRatToFracAndViseVersa] +Map? rat2fract(Rational? rat, [bool toLog = true]) { + if (rat == null) return null; + + try { + return { + 'numer': rat.numerator.toString(), + 'denom': rat.denominator.toString(), + }; + } catch (e) { + if (toLog) { + log('Error rat2fract: $e', isError: true); + } + return null; + } +} + +String generateSeed() => bip39.generateMnemonic(); + +String getTxExplorerUrl(Coin coin, String txHash) { + final String explorerUrl = coin.explorerUrl; + final String explorerTxUrl = coin.explorerTxUrl; + if (explorerUrl.isEmpty) return ''; + + final hash = coin.type == CoinType.iris ? txHash.toUpperCase() : txHash; + + return coin.need0xPrefixForTxHash && !hash.startsWith('0x') + ? '$explorerUrl${explorerTxUrl}0x$hash' + : '$explorerUrl$explorerTxUrl$hash'; +} + +String getAddressExplorerUrl(Coin coin, String address) { + final String explorerUrl = coin.explorerUrl; + final String explorerAddressUrl = coin.explorerAddressUrl; + if (explorerUrl.isEmpty) return ''; + + return '$explorerUrl$explorerAddressUrl$address'; +} + +void viewHashOnExplorer(Coin coin, String address, HashExplorerType type) { + late String url; + switch (type) { + case HashExplorerType.address: + url = getAddressExplorerUrl(coin, address); + break; + case HashExplorerType.tx: + url = getTxExplorerUrl(coin, address); + break; + } + launchURL(url); +} + +Future launchURL( + String url, { + bool? inSeparateTab, +}) async { + final uri = Uri.parse(url); + + if (await canLaunchUrl(uri)) { + await launchUrl( + uri, + mode: inSeparateTab == null + ? LaunchMode.platformDefault + : inSeparateTab == true + ? LaunchMode.externalApplication + : LaunchMode.inAppWebView, + ); + } else { + throw 'Could not launch $url'; + } +} + +void log( + String message, { + String? path, + StackTrace? trace, + bool isError = false, +}) async { + final timer = Stopwatch()..start(); + // todo(yurii & ivan): to finish stacktrace parsing + // if (trace != null) { + // final String errorTrace = getInfoFromStackTrace(trace); + // logger.write('$errorTrace: $errorOrUsefulData'); + // } + if (isTestMode && isError) { + // ignore: avoid_print + print('path: $path'); + // ignore: avoid_print + print('error: $message'); + } + + try { + await logger.write(message, path); + + performance.logTimeWritingLogs(timer.elapsedMilliseconds); + } catch (e) { + // TODO: replace below with crashlytics reporting or show UI the printed + // message in a snackbar/banner. + // ignore: avoid_print + print( + 'ERROR: Writing logs failed. Exported log files may be incomplete.' + '\nError message: $e', + ); + } finally { + timer.stop(); + } +} + +/// Returns the ticker from the coin abbreviation. +/// +/// Parameters: +/// - [abbr] (String): The abbreviation of the coin, including suffixes like the +/// coin token type (e.g. 'ETH-ERC20', 'BNB-BEP20') and whether the coin is +/// a test or OLD coin (e.g. 'ETH_OLD', 'BNB-TEST'). +/// +/// Return Value: +/// - (String): The ticker of the coin, with the suffixes removed. +/// +/// Example Usage: +/// ```dart +/// String abbr = 'ETH-ERC20'; +/// +/// String ticker = abbr2Ticker(abbr); +/// print(ticker); // Output: "ETH" +/// ``` +String abbr2Ticker(String abbr) { + if (_abbr2TickerCache.containsKey(abbr)) return _abbr2TickerCache[abbr]!; + if (!abbr.contains('-') && !abbr.contains('_')) return abbr; + + const List filteredSuffixes = [ + 'ERC20', + 'BEP20', + 'QRC20', + 'FTM20', + 'HRC20', + 'MVR20', + 'AVX20', + 'HCO20', + 'PLG20', + 'KRC20', + 'SLP', + 'IBC_IRIS', + 'IBC-IRIS', + 'IRIS', + 'segwit', + 'OLD', + 'IBC_NUCLEUSTEST', + ]; + + // Join the suffixes with '|' to form the regex pattern + final String regexPattern = '(${filteredSuffixes.join('|')})'; + + final String ticker = abbr + .replaceAll(RegExp('-$regexPattern'), '') + .replaceAll(RegExp('_$regexPattern'), ''); + + _abbr2TickerCache[abbr] = ticker; + return ticker; +} + +/// Returns the ticker from the coin abbreviation with the following suffixes: +/// - 'OLD' for OLD coins. +/// - 'TESTNET' for test coins. +/// +/// Parameters: +/// - [abbr] (String): The abbreviation of the coin, including suffixes like the +/// coin token type (e.g. 'ETH-ERC20', 'BNB-BEP20') and whether the coin is +/// a test or OLD coin (e.g. 'ETH_OLD', 'BNB-TEST'). +/// +/// Return Value: +/// - (String): The ticker of the coin, with the suffixes removed and the +/// suffixes 'OLD' or 'TESTNET' added if present in the abbreviation. +String abbr2TickerWithSuffix(String abbr) { + final isOldCoin = RegExp(r'[-_]OLD$', caseSensitive: false).hasMatch(abbr); + final ticker = abbr2Ticker(abbr); + if (isOldCoin) { + return '$ticker (OLD)'; + } + return ticker; +} + +final Map _abbr2TickerCache = {}; + +String? getErcTransactionHistoryUrl(Coin coin) { + final String? address = coin.address; + if (address == null) return null; + + final String? contractAddress = coin.protocolData?.contractAddress; + + // anchor: protocols support + switch (coin.type) { + case CoinType.erc20: + return _getErcTransactionHistoryUrl( + coin.protocolType, + ethUrl, + ercUrl, + address, + contractAddress, + coin.isTestCoin, + ); // 'ETH', 'ETHR' + + case CoinType.bep20: + return _getErcTransactionHistoryUrl( + coin.protocolType, + bnbUrl, + bepUrl, + address, + contractAddress, + coin.isTestCoin, + ); // 'BNB', 'BNBT' + case CoinType.ftm20: + return _getErcTransactionHistoryUrl( + coin.protocolType, + ftmUrl, + ftmTokenUrl, + address, + contractAddress, + coin.isTestCoin, + ); // 'FTM', 'FTMT' + case CoinType.etc: + return _getErcTransactionHistoryUrl( + coin.protocolType, + etcUrl, + '', + address, + contractAddress, + false, + ); // ETC + case CoinType.avx20: + return _getErcTransactionHistoryUrl( + coin.protocolType, + avaxUrl, + avaxTokenUrl, + address, + contractAddress, + coin.isTestCoin, + ); // AVAX, AVAXT + case CoinType.mvr20: + return _getErcTransactionHistoryUrl( + coin.protocolType, + mvrUrl, + mvrTokenUrl, + address, + contractAddress, + coin.isTestCoin, + ); // MVR + case CoinType.hco20: + return _getErcTransactionHistoryUrl( + coin.protocolType, + hecoUrl, + hecoTokenUrl, + address, + contractAddress, + coin.isTestCoin, + ); + case CoinType.plg20: + return _getErcTransactionHistoryUrl( + coin.protocolType, + maticUrl, + maticTokenUrl, + address, + contractAddress, + coin.isTestCoin, + ); // Polygon, MATICTEST + case CoinType.sbch: + return _getErcTransactionHistoryUrl( + coin.protocolType, + '', + '', + address, + contractAddress, + coin.isTestCoin, + ); + case CoinType.ubiq: + return _getErcTransactionHistoryUrl( + coin.protocolType, + '', + '', + address, + contractAddress, + coin.isTestCoin, + ); // Ubiq + case CoinType.hrc20: + return _getErcTransactionHistoryUrl( + coin.protocolType, + '', + '', + address, + contractAddress, + coin.isTestCoin, + ); // ONE + case CoinType.krc20: + return _getErcTransactionHistoryUrl( + coin.protocolType, + kcsUrl, + kcsTokenUrl, + address, + contractAddress, + coin.isTestCoin, + ); // KCS + case CoinType.cosmos: + case CoinType.iris: + case CoinType.qrc20: + case CoinType.smartChain: + case CoinType.utxo: + case CoinType.slp: + return null; + } +} + +String _getErcTransactionHistoryUrl( + String protocolType, + String protocolUrl, + String tokenProtocolUrl, + String address, + String? contractAddress, + bool isTestCoin, +) { + return (protocolType == 'ETH' + ? '$protocolUrl/$address' + : '$tokenProtocolUrl/$contractAddress/$address') + + (isTestCoin ? '&testnet=true' : ''); +} + +Color getProtocolColor(CoinType type) { + switch (type) { + case CoinType.utxo: + return const Color.fromRGBO(233, 152, 60, 1); + case CoinType.erc20: + return const Color.fromRGBO(108, 147, 237, 1); + case CoinType.smartChain: + return const Color.fromRGBO(32, 22, 49, 1); + case CoinType.bep20: + return const Color.fromRGBO(255, 199, 0, 1); + case CoinType.qrc20: + return const Color.fromRGBO(0, 168, 226, 1); + case CoinType.ftm20: + return const Color.fromRGBO(25, 105, 255, 1); + case CoinType.hrc20: + return const Color.fromRGBO(29, 195, 219, 1); + case CoinType.etc: + return const Color.fromRGBO(16, 185, 129, 1); + case CoinType.avx20: + return const Color.fromRGBO(232, 65, 66, 1); + case CoinType.mvr20: + return const Color.fromRGBO(242, 183, 5, 1); + case CoinType.hco20: + return const Color.fromRGBO(1, 148, 67, 1); + case CoinType.plg20: + return const Color.fromRGBO(130, 71, 229, 1); + case CoinType.sbch: + return const Color.fromRGBO(117, 222, 84, 1); + case CoinType.ubiq: + return const Color.fromRGBO(0, 234, 144, 1); + case CoinType.krc20: + return const Color.fromRGBO(66, 229, 174, 1); + case CoinType.cosmos: + return const Color.fromRGBO(60, 60, 85, 1); + case CoinType.iris: + return const Color.fromRGBO(136, 87, 138, 1); + case CoinType.slp: + return const Color.fromRGBO(134, 184, 124, 1); + } +} + +bool hasTxHistorySupport(Coin coin) { + if (coin.enabledType == WalletType.trezor) { + return true; + } + switch (coin.type) { + case CoinType.sbch: + case CoinType.ubiq: + case CoinType.hrc20: + return false; + case CoinType.krc20: + case CoinType.cosmos: + case CoinType.iris: + case CoinType.utxo: + case CoinType.erc20: + case CoinType.smartChain: + case CoinType.bep20: + case CoinType.qrc20: + case CoinType.ftm20: + case CoinType.etc: + case CoinType.avx20: + case CoinType.mvr20: + case CoinType.hco20: + case CoinType.plg20: + case CoinType.slp: + return true; + } +} + +String getNativeExplorerUrlByCoin(Coin coin, String? address) { + final bool hasSupport = hasTxHistorySupport(coin); + assert(!hasSupport); + + switch (coin.type) { + case CoinType.sbch: + case CoinType.iris: + return '${coin.explorerUrl}address/${coin.address}'; + case CoinType.cosmos: + return '${coin.explorerUrl}account/${coin.address}'; + + case CoinType.utxo: + case CoinType.smartChain: + case CoinType.erc20: + case CoinType.bep20: + case CoinType.qrc20: + case CoinType.ftm20: + case CoinType.avx20: + case CoinType.mvr20: + case CoinType.hco20: + case CoinType.plg20: + case CoinType.etc: + case CoinType.hrc20: + case CoinType.ubiq: + case CoinType.krc20: + case CoinType.slp: + return '${coin.explorerUrl}address/${address ?? coin.address}'; + } +} + +String get themeAssetPostfix => theme.mode == ThemeMode.dark ? '_dark' : ''; + +void rebuildAll(BuildContext? context) { + void rebuild(Element element) { + element.markNeedsBuild(); + element.visitChildren(rebuild); + } + + ((materialPageContext ?? context) as Element).visitChildren(rebuild); +} + +int get nowMs => DateTime.now().millisecondsSinceEpoch; + +String? assertString(dynamic value) { + if (value == null) return null; + + switch (value.runtimeType) { + case int: + case double: + return value.toString(); + default: + return value; + } +} + +int? assertInt(dynamic value) { + if (value == null) return null; + + switch (value.runtimeType) { + case String: + return int.parse(value); + default: + return value; + } +} + +Future pauseWhile( + bool Function() condition, { + Duration timeout = const Duration(seconds: 30), +}) async { + final int startMs = nowMs; + bool timedOut = false; + while (condition() && !timedOut) { + await Future.delayed(const Duration(milliseconds: 10)); + timedOut = nowMs - startMs > timeout.inMilliseconds; + } +} + +Future waitMM2StatusChange(MM2Status status, MM2 mm2, + {int waitingTime = 3000}) async { + final int start = DateTime.now().millisecondsSinceEpoch; + + while (await mm2.status() != status && + DateTime.now().millisecondsSinceEpoch - start < waitingTime) { + await Future.delayed(const Duration(milliseconds: 100)); + } +} + +enum HashExplorerType { + address, + tx, +} diff --git a/lib/shared/utils/validators.dart b/lib/shared/utils/validators.dart new file mode 100644 index 0000000000..3e7dbfbbad --- /dev/null +++ b/lib/shared/utils/validators.dart @@ -0,0 +1,15 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +String? validateConfirmPassword(String password, String confirmPassword) { + return password != confirmPassword + ? LocaleKeys.walletCreationConfirmPasswordError.tr() + : null; +} + +/// unit test: [testValidatePassword] +String? validatePassword(String password, String errorText) { + final RegExp exp = + RegExp(r'^(?:(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9\s])).{12,}$'); + return password.isEmpty || !password.contains(exp) ? errorText : null; +} diff --git a/lib/shared/widgets/alpha_version_warning.dart b/lib/shared/widgets/alpha_version_warning.dart new file mode 100644 index 0000000000..fcf9b6889a --- /dev/null +++ b/lib/shared/widgets/alpha_version_warning.dart @@ -0,0 +1,66 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/send_analytics_checkbox.dart'; + +class AlphaVersionWarning extends StatelessWidget { + const AlphaVersionWarning({Key? key, required this.onAccept}) + : super(key: key); + final Function() onAccept; + + @override + Widget build(BuildContext context) { + final appTheme = Theme.of(context); + final ScrollController scrollController = ScrollController(); + return SingleChildScrollView( + controller: scrollController, + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 340), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + '$assetsPath/logo/alpha_warning.png', + filterQuality: FilterQuality.high, + ), + Padding( + padding: const EdgeInsets.only(top: 25.0), + child: Text( + LocaleKeys.alphaVersionWarningTitle.tr(), + style: appTheme.textTheme.headlineMedium, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + LocaleKeys.alphaVersionWarningDescription.tr(), + style: appTheme.textTheme.bodyMedium + ?.copyWith(fontSize: 14, fontWeight: FontWeight.w500), + textAlign: TextAlign.justify, + ), + ), + const Padding( + padding: EdgeInsets.only(top: 8.0), + child: SendAnalyticsCheckbox(), + ), + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: UiPrimaryButton( + key: const Key('accept-alpha-warning-button'), + height: 30, + text: LocaleKeys.accept.tr(), + onPressed: () { + onAccept(); + Navigator.of(context).pop(); + }, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/auto_scroll_text.dart b/lib/shared/widgets/auto_scroll_text.dart new file mode 100644 index 0000000000..87523f1a1a --- /dev/null +++ b/lib/shared/widgets/auto_scroll_text.dart @@ -0,0 +1,344 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +class AutoScrollText extends StatefulWidget { + const AutoScrollText({ + required this.text, + this.style, + this.textAlign, + this.isSelectable = false, + super.key, + }); + + final String text; + final TextStyle? style; + final bool isSelectable; + + final TextAlign? textAlign; + + @override + State createState() => _AutoScrollTextState(); +} + +class _AutoScrollTextState extends State + with SingleTickerProviderStateMixin { + /// To avoid unnecessary animations, we only animate the text if it's wider + /// than the parent's constraints by this threshold. + static const double _kAnimationThresholdWidth = 5; + + static const Duration _kPauseBeforeRepeat = Duration(seconds: 10); + + static const Duration _kInitialPause = Duration(seconds: 2); + + static const Duration _kPauseBeforeReverse = Duration(seconds: 3); + + static const Duration _kMovingDuration = Duration(seconds: 4); + + // TODO: Add a "Debug animations" settings option to the app settings. + static bool animationDebugMode = false; + + late final AnimationController _controller; + + Size? _lastAvailableSize; + + @override + void initState() { + _controller = AnimationController(vsync: this); + + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + // TODO: Possible future refactoring to only run animation if the text + // animation is shown (`isTextAnimatable`). + unawaited(runAnimation()); + }); + } + + /// Updates the animations/calculations based on the available size and + /// stores the size to recognise when the available size changes. + void computeAnimation(Size size) { + if (!mounted) return; + final textWidth = calculateTextSize().width; + final parentWidth = size.width; + + final animation = getAnimation( + textWidth: textWidth, + parentWidth: parentWidth, + controller: _controller, + ); + + if (animation == null && _animation == null) { + return; + } + + setState(() { + _animation = animation; + }); + } + + @override + Widget build(BuildContext context) { + final isTextAnimatable = _animation != null; + + // TODO: Initially the text overflow shows as faded, but we have to disable + // the edge-fade when the text starts animating. There is an initial "jump" + // from faded to non-faded text when the animation starts. This is not + // ideal, but it's not a big issue. In the future, see if there is an + // efficient way to always show the overflow edge as faded. E.g. Container + // gradient decoration. NB: Don't assume that text is always LTR. + final overflow = isTextAnimatable + ? renderedTextStyle.overflow + : widget.style?.overflow ?? TextOverflow.fade; + + final textWidget = _TextDisplay( + text: widget.text, + style: renderedTextStyle, + textAlign: widget.textAlign, + isSelectable: widget.isSelectable, + isTextAnimatable: isTextAnimatable, + overflow: overflow, + ); + + return LayoutBuilder( + key: const ValueKey('AutoScrollText-LayoutBuilder'), + builder: (context, constraints) { + final availableSize = constraints.biggest; + + final didAvailableSizeChange = _lastAvailableSize != availableSize; + + _lastAvailableSize = availableSize; + + if (didAvailableSizeChange && isTextAnimatable) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => computeAnimation(constraints.biggest), + ); + } + + if (!isTextAnimatable) { + return ClipRRect( + key: const ValueKey('AutoScrollText-ClipRRect'), + child: textWidget, + ); + } + + return Container( + key: const ValueKey('AutoScrollText-Container'), + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + color: isTextAnimatable && animationDebugMode + ? Colors.purple.withOpacity(0.5) + : null, + ), + width: double.infinity, + child: SlideTransition( + position: _animation!, + child: textWidget, + ), + ); + }, + ); + } + + /// The predicted size of the text based on the current text style. + /// + /// This is far more efficient and cleaner than determining the size of the + /// text by actually laying it out. There may be minor differences between + /// the calculated vs actual size, but it's not significant. + /// [_kAnimationThresholdWidth] is used to account for these differences. + Size calculateTextSize() { + if (!mounted || widget.text.isEmpty) { + return Size.zero; + } + + // There's a bug in Flutter web where the text painter's width is slightly + // narrower than the actual text width. Issue may be related to a + // known bug here: https://github.com/flutter/flutter/issues/125582 + final width = textWidth * (kIsWeb ? 1.04 : 1); + + // We are returning double.infinity for the height because we don't need + // to calculate the height, but we may want to implement this in the future + // for vertical scrolling text. + return Size(width, double.infinity); + } + + double? _textWidth; + + double get textWidth { + if (_textWidth != null) return _textWidth!; + + _textWidth = TextPainter.computeWidth( + text: TextSpan( + text: widget.text, + style: renderedTextStyle, + ), + textDirection: TextDirection.ltr, + textAlign: widget.textAlign ?? + // DefaultTextStyle.of(context).textAlign ?? + TextAlign.start, + maxLines: 1, + textWidthBasis: TextWidthBasis.longestLine, + ); + + return _textWidth!; + } + + Future runAnimation() async { + await Future.delayed(_kInitialPause); + if (!mounted) return; + + computeAnimation(_lastAvailableSize!); + + while (mounted) { + try { + await _controller.animateTo(1, duration: _kMovingDuration); + + await Future.delayed(_kPauseBeforeReverse); + + if (!mounted) break; + + await _controller.animateBack(0, duration: _kMovingDuration); + + await Future.delayed(_kPauseBeforeRepeat); + } catch (e) { + // There may be a brief period after the widget is unmounted and/or + // the conttoller is disposed of, but before the animation is stopped. + // These errors can be safely ignored. + + assert( + !mounted, + 'AutoScrollText animation is disposed of while Widget is' + ' still alive (mounted). This should not happen.', + ); + } + } + } + + Animation? _animation; + + static Animation? getAnimation({ + required double textWidth, + required double parentWidth, + required AnimationController controller, + }) { + const begin = Offset.zero; + + // We only want to animate the text if it's longer than the parent widget. + // The threshold is to avoid unnecessary animations where text is only + // slightly longer than the parent widget. + + final isTextWiderThanParentByThreshold = + textWidth > parentWidth + _kAnimationThresholdWidth; + + if (!isTextWiderThanParentByThreshold) return null; + + final overflowAmount = textWidth - parentWidth; + final overflowFraction = overflowAmount / parentWidth; + final overflowOffset = Offset(-1 * overflowFraction, 0); + + final animation = Tween( + begin: begin, + end: overflowOffset, + ).animate(controller); + + return animation; + } + + /// The memoized [TextStyle] from [renderedTextStyle] which is the input + /// text style with certain properties overridden to make sure that the text + /// is rendered correctly. + /// + /// NB: This is not intended to be referenced in the build method because it + /// is relies on the build method to be called in order to be memoized. + TextStyle? _renderedTextStyle; + + TextStyle get renderedTextStyle { + if (_renderedTextStyle != null) return _renderedTextStyle!; + + final mustHighlightBackground = animationDebugMode; + + final widgetStyle = (widget.style ?? DefaultTextStyle.of(context).style); + + // We have to override certain properties of the style to ensure that the + // overflow text is rendered correctly. + TextStyle style = widgetStyle.copyWith( + overflow: TextOverflow.visible, + ); + + if (mustHighlightBackground) { + style = style.copyWith(backgroundColor: Colors.red.withOpacity(0.3)); + } + + return _renderedTextStyle = style; + } + + /// Clears all the memoized ("cached") state values. NB: Does not call + /// setState() to rebuild the widget. + void _resetMemoizedValues() { + _animation = null; + _lastAvailableSize = null; + _textWidth = null; + _renderedTextStyle = null; + } + + @override + void didUpdateWidget(AutoScrollText oldWidget) { + super.didUpdateWidget(oldWidget); + + final didWidgetValuesChange = oldWidget.text != widget.text || + oldWidget.style != widget.style || + oldWidget.textAlign != widget.textAlign; + + if (didWidgetValuesChange) { + _resetMemoizedValues(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } +} + +class _TextDisplay extends StatelessWidget { + final String text; + final TextStyle style; + final TextAlign? textAlign; + final bool isSelectable; + final bool isTextAnimatable; + final TextOverflow? overflow; + + const _TextDisplay({ + required this.text, + required this.style, + this.textAlign, + this.isSelectable = false, + this.isTextAnimatable = false, + required this.overflow, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + if (isSelectable) { + return SelectableText( + text, + style: style, + textAlign: textAlign, + maxLines: 1, + ); + } else { + return Text( + text, + style: style, + textAlign: textAlign, + overflow: overflow, + softWrap: false, + maxLines: 1, + textWidthBasis: TextWidthBasis.longestLine, + ); + } + } +} diff --git a/lib/shared/widgets/coin_balance.dart b/lib/shared/widgets/coin_balance.dart new file mode 100644 index 0000000000..bf20fe7dc7 --- /dev/null +++ b/lib/shared/widgets/coin_balance.dart @@ -0,0 +1,28 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; + +class CoinBalance extends StatelessWidget { + const CoinBalance({required this.coin}); + final Coin coin; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + doubleToString(coin.balance), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(height: 2), + CoinFiatBalance( + coin, + style: TextStyle(color: theme.custom.increaseColor), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/coin_fiat_balance.dart b/lib/shared/widgets/coin_fiat_balance.dart new file mode 100644 index 0000000000..4761572546 --- /dev/null +++ b/lib/shared/widgets/coin_fiat_balance.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; + +class CoinFiatBalance extends StatelessWidget { + const CoinFiatBalance( + this.coin, { + Key? key, + this.style, + this.isSelectable = false, + this.isAutoScrollEnabled = false, + }) : super(key: key); + + final Coin coin; + final TextStyle? style; + final bool isSelectable; + final bool isAutoScrollEnabled; + + @override + Widget build(BuildContext context) { + final balanceStr = coin.getFormattedUsdBalance; + + final TextStyle mergedStyle = + const TextStyle(fontSize: 12, fontWeight: FontWeight.w500).merge(style); + + if (isAutoScrollEnabled) { + return AutoScrollText( + text: balanceStr, + style: mergedStyle, + isSelectable: isSelectable, + ); + } + + return isSelectable + ? SelectableText(balanceStr, style: mergedStyle) + : Text(balanceStr, style: mergedStyle); + } +} diff --git a/lib/shared/widgets/coin_fiat_change.dart b/lib/shared/widgets/coin_fiat_change.dart new file mode 100644 index 0000000000..df1f392dcf --- /dev/null +++ b/lib/shared/widgets/coin_fiat_change.dart @@ -0,0 +1,82 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +class CoinFiatChange extends StatefulWidget { + const CoinFiatChange( + this.coin, { + Key? key, + this.style, + this.padding, + this.useDashForCoinWithoutFiat = false, + }) : super(key: key); + + final Coin coin; + final bool useDashForCoinWithoutFiat; + final TextStyle? style; + final EdgeInsets? padding; + + @override + State createState() => _CoinFiatChangeState(); +} + +class _CoinFiatChangeState extends State { + @override + Widget build(BuildContext context) { + final double? change24h = widget.coin.usdPrice?.change24h; + + if (change24h == null) { + return _NonFiat( + useDashForCoinWithoutFiat: widget.useDashForCoinWithoutFiat, + padding: widget.padding, + style: widget.style, + ); + } + + Color? color; + if (change24h > 0) { + color = theme.custom.increaseColor; + } else if (change24h < 0) { + color = theme.custom.decreaseColor; + } + + final TextStyle style = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: color, + ).merge(widget.style); + + return Container( + padding: widget.padding, + child: Text( + '${formatAmt(change24h)}%', + style: style, + ), + ); + } +} + +class _NonFiat extends StatelessWidget { + final bool useDashForCoinWithoutFiat; + final EdgeInsets? padding; + final TextStyle? style; + + const _NonFiat( + {required this.useDashForCoinWithoutFiat, this.padding, this.style}); + + @override + Widget build(BuildContext context) { + if (useDashForCoinWithoutFiat) return const SizedBox(); + return Container( + padding: padding, + child: Text( + '-', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ).merge(style), + ), + ); + } +} diff --git a/lib/shared/widgets/coin_fiat_price.dart b/lib/shared/widgets/coin_fiat_price.dart new file mode 100644 index 0000000000..a9c174f7de --- /dev/null +++ b/lib/shared/widgets/coin_fiat_price.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +class CoinFiatPrice extends StatelessWidget { + const CoinFiatPrice( + this.coin, { + Key? key, + this.style, + }) : super(key: key); + + final Coin coin; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final double? usdPrice = coin.usdPrice?.price; + if (usdPrice == null || usdPrice == 0) return const SizedBox(); + + final TextStyle style = const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ).merge(this.style); + + // Using separate widgets here to facilitate integration testing + return Row( + children: [ + Text('\$', style: style), + Text( + formatAmt(usdPrice), + key: Key('fiat-price-${coin.abbr.toLowerCase()}'), + style: style, + ), + ], + ); + } +} diff --git a/lib/shared/widgets/coin_icon.dart b/lib/shared/widgets/coin_icon.dart new file mode 100644 index 0000000000..11cf05be3c --- /dev/null +++ b/lib/shared/widgets/coin_icon.dart @@ -0,0 +1,2 @@ +export 'package:komodo_ui_kit/komodo_ui_kit.dart' + show CoinIcon, checkIfAssetExists; diff --git a/lib/shared/widgets/coin_item/coin_amount.dart b/lib/shared/widgets/coin_item/coin_amount.dart new file mode 100644 index 0000000000..404f50f279 --- /dev/null +++ b/lib/shared/widgets/coin_item/coin_amount.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; + +class CoinAmount extends StatelessWidget { + const CoinAmount({ + super.key, + required this.amount, + this.style, + }); + + final double amount; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + return AutoScrollText( + key: Key('coin-amount-scroll-text-$amount'), + text: formatDexAmt(amount), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ).merge(style), + ); + } +} diff --git a/lib/shared/widgets/coin_item/coin_item.dart b/lib/shared/widgets/coin_item/coin_item.dart new file mode 100644 index 0000000000..0e593547ca --- /dev/null +++ b/lib/shared/widgets/coin_item/coin_item.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.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'; +import 'package:web_dex/shared/widgets/coin_item/coin_logo.dart'; + +class CoinItem extends StatelessWidget { + const CoinItem({ + super.key, + required this.coin, + this.amount, + this.size = CoinItemSize.medium, + this.subtitleText, + }); + + final Coin? coin; + final double? amount; + final CoinItemSize size; + final String? subtitleText; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CoinLogo( + coin: coin, + size: size.coinLogo, + ), + SizedBox(width: size.spacer), + Flexible( + child: CoinItemBody( + coin: coin, + amount: amount, + size: size, + subtitleText: subtitleText, + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/coin_item/coin_item_body.dart b/lib/shared/widgets/coin_item/coin_item_body.dart new file mode 100644 index 0000000000..a12494b7f9 --- /dev/null +++ b/lib/shared/widgets/coin_item/coin_item_body.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_subtitle.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_title.dart'; + +class CoinItemBody extends StatelessWidget { + const CoinItemBody({ + super.key, + required this.coin, + this.amount, + this.size = CoinItemSize.medium, + this.subtitleText, + }); + + final Coin? coin; + final double? amount; + final CoinItemSize size; + final String? subtitleText; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: size.spacer), + CoinItemTitle(coin: coin, size: size, amount: amount), + SizedBox(height: size.spacer), + CoinItemSubtitle( + coin: coin, + size: size, + amount: amount, + text: subtitleText, + ), + ], + ); + } +} diff --git a/lib/shared/widgets/coin_item/coin_item_size.dart b/lib/shared/widgets/coin_item/coin_item_size.dart new file mode 100644 index 0000000000..c50181d3fb --- /dev/null +++ b/lib/shared/widgets/coin_item/coin_item_size.dart @@ -0,0 +1,60 @@ +enum CoinItemSize { + small, + medium, + large; + + double get segwitIconSize { + switch (this) { + case CoinItemSize.small: + return 14; + case CoinItemSize.medium: + return 15; + case CoinItemSize.large: + return 16; + } + } + + double get subtitleFontSize { + switch (this) { + case CoinItemSize.small: + return 10; + case CoinItemSize.medium: + return 11; + case CoinItemSize.large: + return 12; + } + } + + double get titleFontSize { + switch (this) { + case CoinItemSize.small: + return 11; + case CoinItemSize.medium: + return 13; + case CoinItemSize.large: + return 14; + } + } + + double get coinLogo { + switch (this) { + case CoinItemSize.small: + return 26; + case CoinItemSize.medium: + return 30; + case CoinItemSize.large: + return 34; + } + } + + double get spacer { + switch (this) { + case CoinItemSize.small: + return 3; + case CoinItemSize.medium: + return 3; + case CoinItemSize.large: + return 4; + } + } +} diff --git a/lib/shared/widgets/coin_item/coin_item_subtitle.dart b/lib/shared/widgets/coin_item/coin_item_subtitle.dart new file mode 100644 index 0000000000..c1cfe2965e --- /dev/null +++ b/lib/shared/widgets/coin_item/coin_item_subtitle.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_amount.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_protocol_name.dart'; +import 'package:web_dex/shared/widgets/segwit_icon.dart'; + +class CoinItemSubtitle extends StatelessWidget { + const CoinItemSubtitle({ + required this.coin, + required this.size, + super.key, + this.amount, + this.text, + }); + + final Coin? coin; + final CoinItemSize size; + final double? amount; + final String? text; + + @override + Widget build(BuildContext context) { + return amount != null + ? CoinAmount( + amount: amount!, + style: TextStyle(fontSize: size.titleFontSize, height: 1), + ) + : coin?.mode == CoinMode.segwit && text == null + ? SegwitIcon(height: size.segwitIconSize) + : CoinProtocolName( + text: text ?? coin?.typeNameWithTestnet, + upperCase: text == null, + size: size, + ); + } +} diff --git a/lib/shared/widgets/coin_item/coin_item_title.dart b/lib/shared/widgets/coin_item/coin_item_title.dart new file mode 100644 index 0000000000..4115c79658 --- /dev/null +++ b/lib/shared/widgets/coin_item/coin_item_title.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_name.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_protocol_name.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_ticker.dart'; +import 'package:web_dex/shared/widgets/segwit_icon.dart'; + +class CoinItemTitle extends StatelessWidget { + const CoinItemTitle({ + required this.coin, + required this.size, + super.key, + this.amount, + }); + + final Coin? coin; + final CoinItemSize size; + final double? amount; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + CoinTicker( + coinId: coin?.abbr, + style: TextStyle(fontSize: size.titleFontSize, height: 1), + // Show the 'OLD' and 'TESTNET' suffixes if the coin name is not shown + // i.e. when the amount is null + showSuffix: amount != null, + ), + SizedBox(width: size.spacer), + Flexible( + child: amount == null + ? CoinName( + text: coin?.name, + style: TextStyle(fontSize: size.titleFontSize, height: 1), + ) + : coin?.mode == CoinMode.segwit + ? SegwitIcon(height: size.segwitIconSize) + : CoinProtocolName( + text: coin?.typeNameWithTestnet, + size: size, + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/coin_item/coin_logo.dart b/lib/shared/widgets/coin_item/coin_logo.dart new file mode 100644 index 0000000000..ace45c2a74 --- /dev/null +++ b/lib/shared/widgets/coin_item/coin_logo.dart @@ -0,0 +1,170 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +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/utils.dart'; +import 'package:web_dex/shared/widgets/coin_icon.dart'; + +class CoinLogo extends StatelessWidget { + const CoinLogo({this.coin, this.size}); + + final Coin? coin; + final double? size; + + @override + Widget build(BuildContext context) { + final double size = this.size ?? 41; + final Coin? coin = this.coin; + + if (coin == null) return _CoinLogoPlaceholder(size); + + return Stack( + clipBehavior: Clip.none, + children: [ + _CoinIcon( + coin: coin, + logoSize: size, + ), + _ProtocolIcon( + coin: coin, + logoSize: size, + ), + ], + ); + } +} + +class _CoinIcon extends StatelessWidget { + const _CoinIcon({ + required this.coin, + required this.logoSize, + }); + + final Coin coin; + final double logoSize; + + @override + Widget build(BuildContext context) { + return Container( + width: logoSize, + height: logoSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dexPageColors.emptyPlace, + ), + child: Padding( + padding: const EdgeInsets.only(top: 2), + child: CoinIcon(coin.abbr, size: logoSize), + ), + ); + } +} + +class _ProtocolIcon extends StatelessWidget { + const _ProtocolIcon({ + required this.coin, + required this.logoSize, + }); + + final Coin coin; + final double logoSize; + + double get protocolSizeWithBorder => logoSize * 0.45; + double get protocolBorder => protocolSizeWithBorder * 0.1; + double get protocolLeftPosition => logoSize * 0.55; + double get protocolTopPosition => logoSize * 0.55; + String get protocolIconPath => + '$assetsPath/coin_icons/png/${getProtocolIcon(coin)}.png'; + + @override + Widget build(BuildContext context) { + if (coin.type == CoinType.utxo || coin.protocolData == null) { + return const SizedBox.shrink(); + } + + return Positioned( + left: protocolLeftPosition, + top: protocolTopPosition, + width: protocolSizeWithBorder, + height: protocolSizeWithBorder, + child: Container( + alignment: Alignment.center, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: Colors.black.withOpacity(0.5), blurRadius: 2) + ], + ), + child: Container( + width: protocolSizeWithBorder - protocolBorder, + height: protocolSizeWithBorder - protocolBorder, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white, + image: DecorationImage(image: AssetImage(protocolIconPath)), + ), + ), + ), + ); + } +} + +class _CoinLogoPlaceholder extends StatelessWidget { + const _CoinLogoPlaceholder(this.logoSize); + + final double logoSize; + + @override + Widget build(BuildContext context) { + return Container( + width: logoSize, + height: logoSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: dexPageColors.emptyPlace, + ), + ); + } +} + +String getProtocolIcon(Coin coin) { + switch (coin.type) { + case CoinType.smartChain: + return 'kmd'; + case CoinType.erc20: + return 'eth'; + case CoinType.bep20: + return 'bnb'; + case CoinType.qrc20: + return 'qtum'; + case CoinType.ftm20: + return 'ftm'; + case CoinType.etc: + return 'etc'; + case CoinType.avx20: + return 'avax'; + case CoinType.mvr20: + return 'movr'; + case CoinType.hco20: + return 'ht'; + case CoinType.plg20: + return 'matic'; + case CoinType.sbch: + return 'sbch'; + case CoinType.ubiq: + return 'ubq'; + case CoinType.hrc20: + return 'one'; + case CoinType.krc20: + return 'kcs'; + case CoinType.iris: + return 'iris'; + case CoinType.slp: + return 'slp'; + case CoinType.utxo: + case CoinType.cosmos: + return abbr2Ticker(coin.abbr).toLowerCase(); + } +} diff --git a/lib/shared/widgets/coin_item/coin_name.dart b/lib/shared/widgets/coin_item/coin_name.dart new file mode 100644 index 0000000000..6fe63b925a --- /dev/null +++ b/lib/shared/widgets/coin_item/coin_name.dart @@ -0,0 +1,29 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class CoinName extends StatelessWidget { + const CoinName({ + required this.text, + super.key, + this.style, + }); + + final String? text; + final TextStyle? style; + + @override + Widget build(BuildContext context) { + final String? coinName = text; + if (coinName == null) return const SizedBox.shrink(); + + return Text( + coinName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.custom.dexCoinProtocolColor, + ).merge(style), + overflow: TextOverflow.ellipsis, + ); + } +} diff --git a/lib/shared/widgets/coin_item/coin_protocol_name.dart b/lib/shared/widgets/coin_item/coin_protocol_name.dart new file mode 100644 index 0000000000..3675b84e52 --- /dev/null +++ b/lib/shared/widgets/coin_item/coin_protocol_name.dart @@ -0,0 +1,36 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; + +class CoinProtocolName extends StatelessWidget { + const CoinProtocolName({ + super.key, + this.text, + this.size, + this.upperCase = true, + }); + + final String? text; + final CoinItemSize? size; + final bool upperCase; + + @override + Widget build(BuildContext context) { + if (text == null) return const SizedBox.shrink(); + + return AutoScrollText( + text: upperCase ? text!.toUpperCase() : text!, + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 11, + color: theme.custom.dexCoinProtocolColor, + ).merge( + TextStyle( + fontSize: size?.subtitleFontSize, + height: 1, + ), + ), + ); + } +} diff --git a/lib/shared/widgets/coin_item/coin_ticker.dart b/lib/shared/widgets/coin_item/coin_ticker.dart new file mode 100644 index 0000000000..676754229c --- /dev/null +++ b/lib/shared/widgets/coin_item/coin_ticker.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; + +class CoinTicker extends StatelessWidget { + const CoinTicker({ + required this.coinId, + this.showSuffix = false, + super.key, + this.style, + }); + + final String? coinId; + final TextStyle? style; + final bool showSuffix; + + @override + Widget build(BuildContext context) { + final String? coin = coinId; + if (coin == null) return const SizedBox.shrink(); + + return AutoScrollText( + text: showSuffix ? abbr2TickerWithSuffix(coin) : abbr2Ticker(coin), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ).merge(style), + ); + } +} diff --git a/lib/shared/widgets/coin_type_tag.dart b/lib/shared/widgets/coin_type_tag.dart new file mode 100644 index 0000000000..ac19eac9f2 --- /dev/null +++ b/lib/shared/widgets/coin_type_tag.dart @@ -0,0 +1,79 @@ +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/shared/utils/utils.dart'; + +// todo @dmitrii: Looks similar to BlockchainBadge +// Make common +class CoinTypeTag extends StatelessWidget { + const CoinTypeTag(this.coin); + + final Coin coin; + + @override + Widget build(BuildContext context) { + final Color protocolColor = getProtocolColor(coin.type); + return Container( + width: 124, + height: 20, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: getProtocolColor(coin.type), + border: Border.all( + color: coin.type == CoinType.smartChain + ? theme.custom.smartchainLabelBorderColor + : protocolColor, + ), + ), + child: Center( + child: Text(_protocolName, + 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.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.cosmos: + return 'Cosmos'; + case CoinType.iris: + return 'Iris'; + case CoinType.slp: + return 'SLP'; + } + } +} diff --git a/lib/shared/widgets/connect_wallet/connect_wallet_button.dart b/lib/shared/widgets/connect_wallet/connect_wallet_button.dart new file mode 100644 index 0000000000..2b86d63944 --- /dev/null +++ b/lib/shared/widgets/connect_wallet/connect_wallet_button.dart @@ -0,0 +1,119 @@ +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:flutter_svg/flutter_svg.dart'; +import 'package:web_dex/app_config/app_config.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/taker_form/taker_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_event.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_wrapper.dart'; + +class ConnectWalletButton extends StatefulWidget { + const ConnectWalletButton({ + Key? key, + required this.eventType, + this.withText = true, + this.withIcon = false, + Size? buttonSize, + }) : buttonSize = buttonSize ?? const Size(double.infinity, 40), + super(key: key); + final Size buttonSize; + final bool withIcon; + final bool withText; + final WalletsManagerEventType eventType; + + @override + State createState() => _ConnectWalletButtonState(); +} + +class _ConnectWalletButtonState extends State { + static const String walletIconPath = + '$assetsPath/nav_icons/desktop/dark/wallet.svg'; + + PopupDispatcher? _popupDispatcher; + + @override + void dispose() { + _popupDispatcher?.close(); + _popupDispatcher = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.withText + ? UiPrimaryButton( + key: Key('connect-wallet-${widget.eventType.name}'), + width: widget.buttonSize.width, + height: widget.buttonSize.height, + prefix: widget.withIcon + ? Padding( + padding: const EdgeInsets.only(right: 7.0), + child: SvgPicture.asset( + walletIconPath, + colorFilter: ColorFilter.mode( + theme.custom.defaultGradientButtonTextColor, + BlendMode.srcIn), + width: 15, + height: 15, + ), + ) + : null, + text: LocaleKeys.connectSomething + .tr(args: [LocaleKeys.wallet.tr().toLowerCase()]), + onPressed: onButtonPressed, + ) + : ElevatedButton( + key: Key('connect-wallet-${widget.eventType.name}'), + style: ElevatedButton.styleFrom( + backgroundColor: theme.currentGlobal.primaryColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + minimumSize: const Size(48, 48), + padding: EdgeInsets.zero, + ), + onPressed: onButtonPressed, + child: SvgPicture.asset( + walletIconPath, + colorFilter: ColorFilter.mode( + theme.custom.defaultGradientButtonTextColor, BlendMode.srcIn), + width: 20, + ), + ); + } + + void onButtonPressed() { + _popupDispatcher = _createPopupDispatcher(); + _popupDispatcher?.show(); + } + + PopupDispatcher _createPopupDispatcher() { + final TakerBloc takerBloc = context.read(); + final BridgeBloc bridgeBloc = context.read(); + + return PopupDispatcher( + borderColor: theme.custom.specificButtonBorderColor, + barrierColor: isMobile ? Theme.of(context).colorScheme.onSurface : null, + width: 320, + context: scaffoldKey.currentContext ?? context, + popupContent: WalletsManagerWrapper( + eventType: widget.eventType, + onSuccess: (_) async { + takerBloc.add(TakerReInit()); + bridgeBloc.add(const BridgeReInit()); + await reInitTradingForms(); + _popupDispatcher?.close(); + }, + ), + ); + } +} diff --git a/lib/shared/widgets/connect_wallet/connect_wallet_wrapper.dart b/lib/shared/widgets/connect_wallet/connect_wallet_wrapper.dart new file mode 100644 index 0000000000..a62392c258 --- /dev/null +++ b/lib/shared/widgets/connect_wallet/connect_wallet_wrapper.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_button.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; + +class ConnectWalletWrapper extends StatelessWidget { + const ConnectWalletWrapper({ + Key? key, + required this.child, + required this.eventType, + this.withIcon = false, + this.buttonSize, + }) : super(key: key); + + final Widget child; + final Size? buttonSize; + final bool withIcon; + final WalletsManagerEventType eventType; + + @override + Widget build(BuildContext context) { + final AuthorizeMode mode = context.watch().state.mode; + + return mode == AuthorizeMode.logIn + ? child + : ConnectWalletButton( + buttonSize: buttonSize, + withIcon: withIcon, + eventType: eventType, + ); + } +} diff --git a/lib/shared/widgets/copied_text.dart b/lib/shared/widgets/copied_text.dart new file mode 100644 index 0000000000..66501e7c7c --- /dev/null +++ b/lib/shared/widgets/copied_text.dart @@ -0,0 +1,191 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/truncate_middle_text.dart'; + +class CopiedText extends StatelessWidget { + const CopiedText({ + Key? key, + required this.copiedValue, + this.text, + this.maxLines, + this.backgroundColor, + this.isTruncated = false, + this.isCopiedValueShown = true, + this.padding = const EdgeInsets.symmetric(horizontal: 20.0, vertical: 12), + this.fontSize = 14, + this.fontColor, + this.fontWeight = FontWeight.w500, + this.iconSize = 22, + this.height, + }) : super(key: key); + + final String copiedValue; + final String? text; + final bool isTruncated; + final bool isCopiedValueShown; + final int? maxLines; + final Color? backgroundColor; + final EdgeInsets padding; + final double? fontSize; + final double iconSize; + final Color? fontColor; + final FontWeight? fontWeight; + final double? height; + + @override + Widget build(BuildContext context) { + final softWrap = (maxLines ?? 0) > 1; + final String? showingText = text; + final Color? background = + backgroundColor ?? Theme.of(context).inputDecorationTheme.fillColor; + + return Material( + color: background, + borderRadius: BorderRadius.circular(18), + child: InkWell( + onTap: () { + copyToClipBoard(context, copiedValue); + }, + borderRadius: BorderRadius.circular(18), + child: Padding( + padding: padding, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (isCopiedValueShown) ...[ + Container( + key: const Key('coin-details-address-field'), + child: isTruncated + ? Flexible( + child: TruncatedMiddleText( + copiedValue, + style: TextStyle( + fontSize: fontSize, + fontWeight: fontWeight, + color: fontColor, + height: height, + ), + ), + ) + : Text( + copiedValue, + maxLines: maxLines, + softWrap: softWrap, + style: TextStyle( + fontSize: fontSize, + fontWeight: fontWeight, + color: fontColor, + height: height, + ), + ), + ), + const SizedBox( + width: 16, + ), + ], + Icon( + Icons.copy_rounded, + color: Theme.of(context).textTheme.labelLarge?.color, + size: iconSize, + ), + if (showingText != null) ...[ + const SizedBox( + width: 10, + ), + Text(showingText), + ] + ], + ), + ), + ), + ); + } +} + +class CopiedTextV2 extends StatelessWidget { + const CopiedTextV2({ + Key? key, + required this.copiedValue, + this.text, + this.maxLines, + this.isTruncated = false, + this.isCopiedValueShown = true, + this.fontSize = 12, + this.iconSize = 12, + this.backgroundColor, + this.textColor, + }) : super(key: key); + + final String copiedValue; + final String? text; + final bool isTruncated; + final bool isCopiedValueShown; + final int? maxLines; + + final double fontSize; + final double iconSize; + + final Color? backgroundColor; + final Color? textColor; + + @override + Widget build(BuildContext context) { + final softWrap = (maxLines ?? 0) > 1; + final String? showingText = text; + + return InkWell( + onTap: () { + copyToClipBoard(context, copiedValue); + }, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(22), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isCopiedValueShown) ...[ + Container( + key: const Key('coin-details-address-field'), + child: isTruncated + ? Flexible( + child: TruncatedMiddleText( + copiedValue, + level: 4, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w700, + color: textColor ?? const Color(0xFFADAFC4)), + ), + ) + : Text( + copiedValue, + maxLines: maxLines, + softWrap: softWrap, + style: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.w700, + color: textColor ?? const Color(0xFFADAFC4)), + ), + ), + const SizedBox(width: 4), + ], + Icon( + Icons.copy_rounded, + color: textColor ?? const Color(0xFFADAFC4), + size: iconSize, + ), + if (showingText != null) ...[ + const SizedBox(width: 10), + Text(showingText), + ] + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/copyable_link.dart b/lib/shared/widgets/copyable_link.dart new file mode 100644 index 0000000000..835c77318a --- /dev/null +++ b/lib/shared/widgets/copyable_link.dart @@ -0,0 +1,73 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class CopyableLink extends StatefulWidget { + const CopyableLink({ + super.key, + required this.text, + required this.valueToCopy, + required this.onLinkTap, + }); + + final String text; + final String valueToCopy; + final VoidCallback? onLinkTap; + + @override + State createState() => _CopyableLinkState(); +} + +class _CopyableLinkState extends State { + bool isHovered = false; + bool isPressed = false; + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension()!; + final textTheme = Theme.of(context).extension()!; + final bgIconColor = isPressed + ? colorScheme.surfCont + : isHovered + ? colorScheme.s40 + : colorScheme.surfContHighest; + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + InkWell( + onTap: widget.onLinkTap, + child: Text( + widget.text, + style: textTheme.bodySBold.copyWith( + color: widget.onLinkTap == null + ? colorScheme.secondary + : colorScheme.primary, + ), + softWrap: false, + ), + ), + const SizedBox(width: 2), + InkWell( + onTap: () => copyToClipBoard(context, widget.valueToCopy), + onHover: (value) { + setState(() => isHovered = value); + }, + onTapDown: (_) => setState(() => isPressed = true), + onTapUp: (_) => setState(() => isPressed = false), + child: Container( + decoration: BoxDecoration( + color: bgIconColor, + borderRadius: BorderRadius.circular(20.0), + ), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Icon( + Icons.copy_rounded, + size: 16, + color: colorScheme.secondary, + ), + ), + ) + ], + ); + } +} diff --git a/lib/shared/widgets/details_dropdown.dart b/lib/shared/widgets/details_dropdown.dart new file mode 100644 index 0000000000..a0e0813403 --- /dev/null +++ b/lib/shared/widgets/details_dropdown.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +class DetailsDropdown extends StatefulWidget { + const DetailsDropdown({ + Key? key, + required this.summary, + required this.content, + }) : super(key: key); + + final String summary; + final Widget content; + + @override + State createState() => _DetailsDropdownState(); +} + +class _DetailsDropdownState extends State { + bool isOpen = false; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: _toggle, + child: Row( + children: [ + Text(widget.summary), + isOpen + ? const Icon(Icons.arrow_drop_up) + : const Icon(Icons.arrow_drop_down), + ], + ), + ), + const SizedBox( + height: 10, + ), + if (isOpen) ...[ + const SizedBox( + height: 10, + ), + widget.content, + ], + ], + ); + } + + void _toggle() { + setState(() { + isOpen = !isOpen; + }); + } +} diff --git a/lib/shared/widgets/disclaimer/constants.dart b/lib/shared/widgets/disclaimer/constants.dart new file mode 100644 index 0000000000..5cf2cf8767 --- /dev/null +++ b/lib/shared/widgets/disclaimer/constants.dart @@ -0,0 +1,64 @@ +const String disclaimerEulaTitle1 = + 'End-User License Agreement (EULA) of Komodo Wallet:\n\n'; + +const String disclaimerEulaTitle2 = + 'TERMS and CONDITIONS: (APPLICATION USER AGREEMENT)\n\n'; +const String disclaimerEulaTitle3 = + 'TERMS AND CONDITIONS OF USE AND DISCLAIMER\n\n'; +const String disclaimerEulaTitle4 = 'GENERAL USE\n\n'; +const String disclaimerEulaTitle5 = 'MODIFICATIONS\n\n'; +const String disclaimerEulaTitle6 = 'LIMITATIONS ON USE\n\n'; +const String disclaimerEulaTitle7 = 'Accounts and membership\n\n'; +const String disclaimerEulaTitle8 = 'Backups\n\n'; +const String disclaimerEulaTitle9 = 'GENERAL WARNING\n\n'; +const String disclaimerEulaTitle10 = 'ACCESS AND SECURITY\n\n'; +const String disclaimerEulaTitle11 = 'INTELLECTUAL PROPERTY RIGHTS\n\n'; +const String disclaimerEulaTitle12 = 'DISCLAIMER\n\n'; +const String disclaimerEulaTitle13 = + 'REPRESENTATIONS AND WARRANTIES, INDEMNIFICATION, AND LIMITATION OF LIABILITY\n\n'; +const String disclaimerEulaTitle14 = 'GENERAL RISK FACTORS\n\n'; +const String disclaimerEulaTitle15 = 'INDEMNIFICATION\n\n'; +const String disclaimerEulaTitle16 = + 'RISK DISCLOSURES RELATING TO THE WALLET\n\n'; +const String disclaimerEulaTitle17 = 'NO INVESTMENT ADVICE OR BROKERAGE\n\n'; +const String disclaimerEulaTitle18 = 'TERMINATION\n\n'; +const String disclaimerEulaTitle19 = 'THIRD PARTY RIGHTS\n\n'; +const String disclaimerEulaTitle20 = 'OUR LEGAL OBLIGATIONS\n\n'; +const String disclaimerEulaParagraph1 = + "This End-User License Agreement ('EULA') is a legal agreement between you and Komodo Platform.\n\nThis EULA agreement governs your acquisition and use of our Komodo Wallet software ('Software', 'Web Application', 'Application' or 'App') directly from Komodo Platform or indirectly through a Komodo Platform authorized entity, reseller or distributor (a 'Distributor').\nPlease read this EULA agreement carefully before completing the installation process and using the Komodo Wallet software. It provides a license to use the Komodo Wallet software and contains warranty information and liability disclaimers.\nIf you register for the beta program of the Komodo Wallet software, this EULA agreement will also govern that trial. By clicking 'accept' or installing and/or using the Komodo Wallet software, you are confirming your acceptance of the Software and agreeing to become bound by the terms of this EULA agreement.\nIf you are entering into this EULA agreement on behalf of a company or other legal entity, you represent that you have the authority to bind such entity and its affiliates to these terms and conditions. If you do not have such authority or if you do not agree with the terms and conditions of this EULA agreement, do not install or use the Software, and you must not accept this EULA agreement.\nThis EULA agreement shall apply only to the Software supplied by Komodo Platform herewith regardless of whether other software is referred to or described herein. The terms also apply to any Komodo Platform updates, supplements, Internet-based services, and support services for the Software, unless other terms accompany those items on delivery. If so, those terms apply.\nLicense Grant\nKomodo Platform hereby grants you a personal, non-transferable, non-exclusive license to use the Komodo Wallet software on your devices in accordance with the terms of this EULA agreement.\n\nYou are permitted to load the Komodo Wallet software (for example a PC, laptop, mobile or tablet) under your control. You are responsible for ensuring your device meets the minimum security and resource requirements of the Komodo Wallet software.\nYou are not permitted to:\nEdit, alter, modify, adapt, translate or otherwise change the whole or any part of the Software nor permit the whole or any part of the Software to be combined with or become incorporated in any other software, nor decompile, disassemble or reverse engineer the Software or attempt to do any such things\nReproduce, copy, distribute, resell or otherwise use the Software for any commercial purpose\nUse the Software in any way which breaches any applicable local, national or international law\nuse the Software for any purpose that Komodo Platform considers is a breach of this EULA agreement\nIntellectual Property and Ownership\nKomodo Platform shall at all times retain ownership of the Software as originally downloaded by you and all subsequent downloads of the Software by you. The Software (and the copyright, and other intellectual property rights of whatever nature in the Software, including any modifications made thereto) are and shall remain the property of Komodo Platform.\n\nKomodo Platform reserves the right to grant licences to use the Software to third parties.\nTermination\nThis EULA agreement is effective from the date you first use the Software and shall continue until terminated. You may terminate it at any time upon written notice to Komodo Platform.\nIt will also terminate immediately if you fail to comply with any term of this EULA agreement. Upon such termination, the licenses granted by this EULA agreement will immediately terminate and you agree to stop all access and use of the Software. The provisions that by their nature continue and survive will survive any termination of this EULA agreement.\nGoverning Law\nThis EULA agreement, and any dispute arising out of or in connection with this EULA agreement, shall be governed by and construed in accordance with the laws of Vietnam.\n\nThis document was last updated on January 31st, 2020\n\n"; +const String disclaimerEulaParagraph2 = + "This disclaimer applies to the contents and services of the app Komodo Wallet and is valid for all users of the “Application” ('Software', “Web Application”, “Application” or “App”).\n\nThe Application is owned by Komodo Platform.\n\nWe reserve the right to amend the following Terms and Conditions (governing the use of the application Komodo Wallet”) at any time without prior notice and at our sole discretion. It is your responsibility to periodically check this Terms and Conditions for any updates to these Terms, which shall come into force once published.\nYour continued use of the application shall be deemed as acceptance of the following Terms. \nWe are a company incorporated in Vietnam and these Terms and Conditions are governed by and subject to the laws of Vietnam. \nIf You do not agree with these Terms and Conditions, You must not use or access this software.\n\n"; +const String disclaimerEulaParagraph3 = + 'By entering into this User (each subject accessing or using the site) Agreement (this writing) You declare that You are an individual over the age of majority (at least 18 or older) and have the capacity to enter into this User Agreement and accept to be legally bound by the terms and conditions of this User Agreement, as incorporated herein and amended from time to time. \n\n'; +const String disclaimerEulaParagraph4 = + 'We may change the terms of this User Agreement at any time. Any such changes will take effect when published in the application, or when You use the Services.\n\n\nRead the User Agreement carefully every time You use our Services. Your continued use of the Services shall signify your acceptance to be bound by the current User Agreement. Our failure or delay in enforcing or partially enforcing any provision of this User Agreement shall not be construed as a waiver of any.\n\n'; +const String disclaimerEulaParagraph5 = + 'You are not allowed to decompile, decode, disassemble, rent, lease, loan, sell, sublicense, or create derivative works from the Komodo Wallet application or the user content. Nor are You allowed to use any network monitoring or detection software to determine the software architecture, or extract information about usage or individuals’ or users’ identities. \nYou are not allowed to copy, modify, reproduce, republish, distribute, display, or transmit for commercial, non-profit or public purposes all or any portion of the application or the user content without our prior written authorization.\n\n'; +const String disclaimerEulaParagraph6 = + 'If you create an account in the Web Application, you are responsible for maintaining the security of your account and you are fully responsible for all activities that occur under the account and any other actions taken in connection with it. We will not be liable for any acts or omissions by you, including any damages of any kind incurred as a result of such acts or omissions. \n\n Komodo Wallet is a non-custodial wallet implementation and thus Komodo Platform can not access nor restore your account in case of (data) loss.\n\n'; +const String disclaimerEulaParagraph7 = + 'We are not responsible for seed-phrases residing in the Web Application. In no event shall we be held liable for any loss of any kind. It is your sole responsibility to maintain appropriate backups of your accounts and their seedphrases.\n\n'; +const String disclaimerEulaParagraph8 = + 'You should not act, or refrain from acting solely on the basis of the content of this application. \nYour access to this application does not itself create an adviser-client relationship between You and us. \nThe content of this application does not constitute a solicitation or inducement to invest in any financial products or services offered by us. \nAny advice included in this application has been prepared without taking into account your objectives, financial situation or needs. You should consider our Risk Disclosure Notice before making any decision on whether to acquire the product described in that document.\n\n'; +const String disclaimerEulaParagraph9 = + 'We do not guarantee your continuous access to the application or that your access or use will be error-free. \nWe will not be liable in the event that the application is unavailable to You for any reason (for example, due to computer downtime ascribable to malfunctions, upgrades, server problems, precautionary or corrective maintenance activities or interruption in telecommunication supplies). \n\n'; +const String disclaimerEulaParagraph10 = + 'Komodo Platform is the owner and/or authorized user of all trademarks, service marks, design marks, patents, copyrights, database rights and all other intellectual property appearing on or contained within the application, unless otherwise indicated. All information, text, material, graphics, software and advertisements on the application interface are copyright of Komodo Platform, its suppliers and licensors, unless otherwise expressly indicated by Komodo Platform. \nExcept as provided in the Terms, use of the application does not grant You any right, title, interest or license to any such intellectual property You may have access to on the application. \nWe own the rights, or have permission to use, the trademarks listed in our application. You are not authorised to use any of those trademarks without our written authorization – doing so would constitute a breach of our or another party’s intellectual property rights. \nAlternatively, we might authorise You to use the content in our application if You previously contact us and we agree in writing.\n\n'; +const String disclaimerEulaParagraph11 = + "Komodo Platform cannot guarantee the safety or security of your computer systems. We do not accept liability for any loss or corruption of electronically stored data or any damage to any computer system occurred in connection with the use of the application or of the user content.\nKomodo Platform makes no representation or warranty of any kind, express or implied, as to the operation of the application or the user content. You expressly agree that your use of the application is entirely at your sole risk.\nYou agree that the content provided in the application and the user content do not constitute financial product, legal or taxation advice, and You agree on not representing the user content or the application as such.\nTo the extent permitted by current legislation, the application is provided on an “as is, as available” basis.\n\nKomodo Platform expressly disclaims all responsibility for any loss, injury, claim, liability, or damage, or any indirect, incidental, special or consequential damages or loss of profits whatsoever resulting from, arising out of or in any way related to: \n(a) any errors in or omissions of the application and/or the user content, including but not limited to technical inaccuracies and typographical errors; \n(b) any third party website, application or content directly or indirectly accessed through links in the application, including but not limited to any errors or omissions; \n(c) the unavailability of the application or any portion of it; \n(d) your use of the application;\n(e) your use of any equipment or software in connection with the application. \nAny Services offered in connection with the Platform are provided on an 'as is' basis, without any representation or warranty, whether express, implied or statutory. To the maximum extent permitted by applicable law, we specifically disclaim any implied warranties of title, merchantability, suitability for a particular purpose and/or non-infringement. We do not make any representations or warranties that use of the Platform will be continuous, uninterrupted, timely, or error-free.\nWe make no warranty that any Platform will be free from viruses, malware, or other related harmful material and that your ability to access any Platform will be uninterrupted. Any defects or malfunction in the product should be directed to the third party offering the Platform, not to Komodo. \nWe will not be responsible or liable to You for any loss of any kind, from action taken, or taken in reliance on the material or information contained in or through the Platform.\nThis is experimental and unfinished software. Use at your own risk. No warranty for any kind of damage. By using this application you agree to this terms and conditions.\n\n"; +const String disclaimerEulaParagraph12 = + 'When accessing or using the Services, You agree that You are solely responsible for your conduct while accessing and using our Services. Without limiting the generality of the foregoing, You agree that You will not:\n(a) Use the Services in any manner that could interfere with, disrupt, negatively affect or inhibit other users from fully enjoying the Services, or that could damage, disable, overburden or impair the functioning of our Services in any manner;\n(b) Use the Services to pay for, support or otherwise engage in any illegal activities, including, but not limited to illegal gambling, fraud, money laundering, or terrorist activities;\n(c) Use any robot, spider, crawler, scraper or other automated means or interface not provided by us to access our Services or to extract data;\n(d) Use or attempt to use another user’s Wallet or credentials without authorization;\n(e) Attempt to circumvent any content filtering techniques we employ, or attempt to access any service or area of our Services that You are not authorized to access;\n(f) Introduce to the Services any virus, Trojan, worms, logic bombs or other harmful material;\n(g) Develop any third-party applications that interact with our Services without our prior written consent;\n(h) Provide false, inaccurate, or misleading information; \n(i) Encourage or induce any other person to engage in any of the activities prohibited under this Section.\n\n\n'; +const String disclaimerEulaParagraph13 = + 'You agree and understand that there are risks associated with utilizing Services involving Virtual Currencies including, but not limited to, the risk of failure of hardware, software and internet connections, the risk of malicious software introduction, and the risk that third parties may obtain unauthorized access to information stored within your Wallet, including but not limited to your public and private keys. You agree and understand that Komodo Platform will not be responsible for any communication failures, disruptions, errors, distortions or delays You may experience when using the Services, however caused.\nYou accept and acknowledge that there are risks associated with utilizing any virtual currency network, including, but not limited to, the risk of unknown vulnerabilities in or unanticipated changes to the network protocol. You acknowledge and accept that Komodo Platform has no control over any cryptocurrency network and will not be responsible for any harm occurring as a result of such risks, including, but not limited to, the inability to reverse a transaction, and any losses in connection therewith due to erroneous or fraudulent actions.\nThe risk of loss in using Services involving Virtual Currencies may be substantial and losses may occur over a short period of time. In addition, price and liquidity are subject to significant fluctuations that may be unpredictable.\nVirtual Currencies are not legal tender and are not backed by any sovereign government. In addition, the legislative and regulatory landscape around Virtual Currencies is constantly changing and may affect your ability to use, transfer, or exchange Virtual Currencies.\nCFDs are complex instruments and come with a high risk of losing money rapidly due to leverage. 80.6% of retail investor accounts lose money when trading CFDs with this provider. You should consider whether You understand how CFDs work and whether You can afford to take the high risk of losing your money.\n\n'; +const String disclaimerEulaParagraph14 = + 'You agree to indemnify, defend and hold harmless Komodo Platform, its officers, directors, employees, agents, licensors, suppliers and any third party information providers to the application from and against all losses, expenses, damages and costs, including reasonable lawyer fees, resulting from any violation of the Terms by You.\nYou also agree to indemnify Komodo Platform against any claims that information or material which You have submitted to Komodo Platform is in violation of any law or in breach of any third party rights (including, but not limited to, claims in respect of defamation, invasion of privacy, breach of confidence, infringement of copyright or infringement of any other intellectual property right).\n\n'; +const String disclaimerEulaParagraph15 = + 'In order to be completed, any Virtual Currency transaction created with the Komodo Platform must be confirmed and recorded in the Virtual Currency ledger associated with the relevant Virtual Currency network. Such networks are decentralized, peer-to-peer networks supported by independent third parties, which are not owned, controlled or operated by Komodo Platform.\nKomodo Platform has no control over any Virtual Currency network and therefore cannot and does not ensure that any transaction details You submit via our Services will be confirmed on the relevant Virtual Currency network. You agree and understand that the transaction details You submit via our Services may not be completed, or may be substantially delayed, by the Virtual Currency network used to process the transaction. We do not guarantee that the Wallet can transfer title or right in any Virtual Currency or make any warranties whatsoever with regard to title.\nOnce transaction details have been submitted to a Virtual Currency network, we cannot assist You to cancel or otherwise modify your transaction or transaction details. Komodo Platform has no control over any Virtual Currency network and does not have the ability to facilitate any cancellation or modification requests.\nIn the event of a Fork, Komodo Platform may not be able to support activity related to your Virtual Currency. You agree and understand that, in the event of a Fork, the transactions may not be completed, completed partially, incorrectly completed, or substantially delayed. Komodo Platform is not responsible for any loss incurred by You caused in whole or in part, directly or indirectly, by a Fork.\nIn no event shall Komodo Platform, its affiliates and service providers, or any of their respective officers, directors, agents, employees or representatives, be liable for any lost profits or any special, incidental, indirect, intangible, or consequential damages, whether based on contract, tort, negligence, strict liability, or otherwise, arising out of or in connection with authorized or unauthorized use of the services, or this agreement, even if an authorized representative of Komodo Platform has been advised of, has known of, or should have known of the possibility of such damages. \nFor example (and without limiting the scope of the preceding sentence), You may not recover for lost profits, lost business opportunities, or other types of special, incidental, indirect, intangible, or consequential damages. Some jurisdictions do not allow the exclusion or limitation of incidental or consequential damages, so the above limitation may not apply to You. \nWe will not be responsible or liable to You for any loss and take no responsibility for damages or claims arising in whole or in part, directly or indirectly from: (a) user error such as forgotten passwords, incorrectly constructed transactions, or mistyped Virtual Currency addresses; (b) server failure or data loss; (c) corrupted or otherwise non-performing Wallets or Wallet files; (d) unauthorized access to applications; (e) any unauthorized activities, including without limitation the use of hacking, viruses, phishing, brute forcing or other means of attack against the Services.\n\n'; +const String disclaimerEulaParagraph16 = + 'For the avoidance of doubt, Komodo Platform does not provide investment, tax or legal advice, nor does Komodo Platform broker trades on your behalf. All Komodo Platform trades are executed automatically, based on the parameters of your order instructions and in accordance with posted Trade execution procedures, and You are solely responsible for determining whether any investment, investment strategy or related transaction is appropriate for You based on your personal investment objectives, financial circumstances and risk tolerance. You should consult your legal or tax professional regarding your specific situation.Neither Komodo nor its owners, members, officers, directors, partners, consultants, nor anyone involved in the publication of this application, is a registered investment adviser or broker-dealer or associated person with a registered investment adviser or broker-dealer and none of the foregoing make any recommendation that the purchase or sale of crypto-assets or securities of any company profiled in the web Application is suitable or advisable for any person or that an investment or transaction in such crypto-assets or securities will be profitable. The information contained in the web Application is not intended to be, and shall not constitute, an offer to sell or the solicitation of any offer to buy any crypto-asset or security. The information presented in the web Application is provided for informational purposes only and is not to be treated as advice or a recommendation to make any specific investment or transaction. Please, consult with a qualified professional before making any decisions.The opinions and analysis included in this applications are based on information from sources deemed to be reliable and are provided “as is” in good faith. Komodo makes no representation or warranty, expressed, implied, or statutory, as to the accuracy or completeness of such information, which may be subject to change without notice. Komodo shall not be liable for any errors or any actions taken in relation to the above. Statements of opinion and belief are those of the authors and/or editors who contribute to this application, and are based solely upon the information possessed by such authors and/or editors. No inference should be drawn that Komodo or such authors or editors have any special or greater knowledge about the crypto-assets or companies profiled or any particular expertise in the industries or markets in which the profiled crypto-assets and companies operate and compete.Information on this application is obtained from sources deemed to be reliable; however, Komodo takes no responsibility for verifying the accuracy of such information and makes no representation that such information is accurate or complete. Certain statements included in this application may be forward-looking statements based on current expectations. Komodo makes no representation and provides no assurance or guarantee that such forward-looking statements will prove to be accurate.Persons using the Komodo application are urged to consult with a qualified professional with respect to an investment or transaction in any crypto-asset or company profiled herein. Additionally, persons using this application expressly represent that the content in this application is not and will not be a consideration in such persons’ investment or transaction decisions. Traders should verify independently information provided in the Komodo application by completing their own due diligence on any crypto-asset or company in which they are contemplating an investment or transaction of any kind and review a complete information package on that crypto-asset or company, which should include, but not be limited to, related blog updates and press releases.Past performance of profiled crypto-assets and securities is not indicative of future results. Crypto-assets and companies profiled on this site may lack an active trading market and invest in a crypto-asset or security that lacks an active trading market or trade on certain media, platforms and markets are deemed highly speculative and carry a high degree of risk. Anyone holding such crypto-assets and securities should be financially able and prepared to bear the risk of loss and the actual loss of his or her entire trade. The information in this application is not designed to be used as a basis for an investment decision. Persons using the Komodo application should confirm to their own satisfaction the veracity of any information prior to entering into any investment or making any transaction. The decision to buy or sell any crypto-asset or security that may be featured by Komodo is done purely and entirely at the reader’s own risk. As a reader and user of this application, You agree that under no circumstances will You seek to hold liable owners, members, officers, directors, partners, consultants or other persons involved in the publication of this application for any losses incurred by the use of information contained in this applicationKomodo and its contractors and affiliates may profit in the event the crypto-assets and securities increase or decrease in value. Such crypto-assets and securities may be bought or sold from time to time, even after Komodo has distributed positive information regarding the crypto-assets and companies. Komodo has no obligation to inform readers of its trading activities or the trading activities of any of its owners, members, officers, directors, contractors and affiliates and/or any companies affiliated with BC Relations’ owners, members, officers, directors, contractors and affiliates.Komodo and its affiliates may from time to time enter into agreements to purchase crypto-assets or securities to provide a method to reach their goals.\n\n'; +const String disclaimerEulaParagraph17 = + 'The Terms are effective until terminated by Komodo Platform. \nIn the event of termination, You are no longer authorized to access the Application, but all restrictions imposed on You and the disclaimers and limitations of liability set out in the Terms will survive termination. \nSuch termination shall not affect any legal right that may have accrued to Komodo Platform against You up to the date of termination. \nKomodo Platform may also remove the Application as a whole or any sections or features of the Application at any time. \n\n'; +const String disclaimerEulaParagraph18 = + 'The provisions of previous paragraphs are for the benefit of Komodo Platform and its officers, directors, employees, agents, licensors, suppliers, and any third party information providers to the Application. Each of these individuals or entities shall have the right to assert and enforce those provisions directly against You on its own behalf.\n\n'; +const String disclaimerEulaParagraph19 = + 'Komodo Wallet is a non-custodial, decentralized and blockchain based application and as such does Komodo Platform never store any user-data (accounts and authentication data). \nWe also collect and process non-personal, anonymized data for statistical purposes and analysis and to help us provide a better service.\n\nThis document was last updated on January 31st, 2020\n\n'; diff --git a/lib/shared/widgets/disclaimer/disclaimer.dart b/lib/shared/widgets/disclaimer/disclaimer.dart new file mode 100644 index 0000000000..a6f2c2c8e4 --- /dev/null +++ b/lib/shared/widgets/disclaimer/disclaimer.dart @@ -0,0 +1,152 @@ +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/ui/ui_primary_button.dart'; +import 'package:web_dex/shared/widgets/disclaimer/constants.dart'; +import 'package:web_dex/shared/widgets/disclaimer/tos_content.dart'; + +class Disclaimer extends StatefulWidget { + const Disclaimer({Key? key, required this.onClose}) : super(key: key); + final Function() onClose; + + @override + State createState() => _DisclaimerState(); +} + +class _DisclaimerState extends State with TickerProviderStateMixin { + @override + Widget build(BuildContext context) { + final List disclaimerToSText = [ + TextSpan( + text: disclaimerEulaTitle2, + style: Theme.of(context).textTheme.titleLarge), + TextSpan( + text: disclaimerEulaParagraph2, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle3, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaTitle4, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph3, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle5, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph4, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle6, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph5, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle7, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph6, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle8, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph7, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle9, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph8, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle10, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph9, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle11, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph10, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle12, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph11, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle13, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph12, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle14, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph13, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle15, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph14, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle16, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph15, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle17, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph16, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle18, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph17, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle19, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph18, + style: Theme.of(context).textTheme.bodyMedium), + TextSpan( + text: disclaimerEulaTitle20, + style: Theme.of(context).textTheme.titleSmall), + TextSpan( + text: disclaimerEulaParagraph19, + style: Theme.of(context).textTheme.bodyMedium) + ]; + + return Column( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 2 / 3, + child: SingleChildScrollView( + controller: ScrollController(), + child: TosContent(disclaimerToSText: disclaimerToSText), + ), + ), + const SizedBox(height: 24), + UiPrimaryButton( + key: const Key('close-disclaimer'), + onPressed: widget.onClose, + width: 300, + text: LocaleKeys.close.tr(), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/disclaimer/eula.dart b/lib/shared/widgets/disclaimer/eula.dart new file mode 100644 index 0000000000..db7fe15363 --- /dev/null +++ b/lib/shared/widgets/disclaimer/eula.dart @@ -0,0 +1,46 @@ +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/ui/ui_primary_button.dart'; +import 'package:web_dex/shared/widgets/disclaimer/constants.dart'; +import 'package:web_dex/shared/widgets/disclaimer/tos_content.dart'; + +class Eula extends StatefulWidget { + const Eula({Key? key, required this.onClose}) : super(key: key); + final Function() onClose; + + @override + State createState() => _EulaState(); +} + +class _EulaState extends State with TickerProviderStateMixin { + @override + Widget build(BuildContext context) { + final List disclaimerToSText = [ + TextSpan( + text: disclaimerEulaTitle1, + style: Theme.of(context).textTheme.titleLarge), + TextSpan( + text: disclaimerEulaParagraph1, + style: Theme.of(context).textTheme.bodyMedium), + ]; + + return Column( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height * 2 / 3, + child: SingleChildScrollView( + controller: ScrollController(), + child: TosContent(disclaimerToSText: disclaimerToSText), + )), + const SizedBox(height: 24), + UiPrimaryButton( + key: const Key('close-disclaimer'), + onPressed: widget.onClose, + width: 300, + text: LocaleKeys.close.tr(), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/disclaimer/eula_tos_checkboxes.dart b/lib/shared/widgets/disclaimer/eula_tos_checkboxes.dart new file mode 100644 index 0000000000..ebd7d88b2a --- /dev/null +++ b/lib/shared/widgets/disclaimer/eula_tos_checkboxes.dart @@ -0,0 +1,129 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +import 'package:web_dex/shared/widgets/disclaimer/disclaimer.dart'; +import 'package:web_dex/shared/widgets/disclaimer/eula.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class EulaTosCheckboxes extends StatefulWidget { + const EulaTosCheckboxes( + {Key? key, this.isChecked = false, required this.onCheck}) + : super(key: key); + + final bool isChecked; + final void Function(bool) onCheck; + + @override + State createState() => _EulaTosCheckboxesState(); +} + +class _EulaTosCheckboxesState extends State { + bool _checkBoxEULA = false; + bool _checkBoxTOC = false; + PopupDispatcher? _eulaPopupManager; + PopupDispatcher? _disclaimerPopupManager; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + UiCheckbox( + checkboxKey: const Key('checkbox-eula'), + value: _checkBoxEULA, + onChanged: (bool? value) { + setState(() { + _checkBoxEULA = !_checkBoxEULA; + }); + _onCheck(); + }, + ), + const SizedBox(width: 5), + Text(LocaleKeys.accept.tr(), style: const TextStyle(fontSize: 14)), + const SizedBox(width: 5), + InkWell( + onTap: _showEula, + child: Text(LocaleKeys.disclaimerAcceptEulaCheckbox.tr(), + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 14, + decoration: TextDecoration.underline)), + ) + ], + ), + const SizedBox(height: 10), + Row( + children: [ + UiCheckbox( + checkboxKey: const Key('checkbox-toc'), + value: _checkBoxTOC, + onChanged: (bool? value) { + setState(() { + _checkBoxTOC = !_checkBoxTOC; + _onCheck(); + }); + }, + ), + const SizedBox(width: 5), + Text(LocaleKeys.accept.tr(), style: const TextStyle(fontSize: 14)), + const SizedBox(width: 5), + InkWell( + onTap: _showDisclaimer, + child: Text( + LocaleKeys.disclaimerAcceptTermsAndConditionsCheckbox.tr(), + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 14, + decoration: TextDecoration.underline)), + ) + ], + ), + const SizedBox(height: 8), + Text( + LocaleKeys.disclaimerAcceptDescription.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ); + } + + @override + void initState() { + _checkBoxEULA = widget.isChecked; + _checkBoxTOC = widget.isChecked; + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _disclaimerPopupManager = PopupDispatcher( + context: context, + popupContent: Disclaimer( + onClose: () { + _disclaimerPopupManager?.close(); + }, + )); + _eulaPopupManager = PopupDispatcher( + context: context, + popupContent: Eula( + onClose: () { + _eulaPopupManager?.close(); + }, + )); + }); + super.initState(); + } + + void _onCheck() { + widget.onCheck(_checkBoxEULA && _checkBoxTOC); + } + + void _showDisclaimer() { + _disclaimerPopupManager?.show(); + } + + void _showEula() { + _eulaPopupManager?.show(); + } +} diff --git a/lib/shared/widgets/disclaimer/tos_content.dart b/lib/shared/widgets/disclaimer/tos_content.dart new file mode 100644 index 0000000000..d5b45fa636 --- /dev/null +++ b/lib/shared/widgets/disclaimer/tos_content.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; + +class TosContent extends StatelessWidget { + const TosContent({ + super.key, + required this.disclaimerToSText, + }); + + final List disclaimerToSText; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(16.0), + child: SelectableText.rich( + TextSpan( + style: Theme.of(context).textTheme.bodyMedium, + children: disclaimerToSText, + ), + ), + ); + } +} diff --git a/lib/shared/widgets/dry_intrinsic.dart b/lib/shared/widgets/dry_intrinsic.dart new file mode 100644 index 0000000000..ef346fc11d --- /dev/null +++ b/lib/shared/widgets/dry_intrinsic.dart @@ -0,0 +1,49 @@ +// Using dry intrinsic workaround to fix TextFormField inside SimpleDialog error +// https://github.com/flutter/flutter/issues/71687 + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class DryIntrinsicWidth extends SingleChildRenderObjectWidget { + const DryIntrinsicWidth({Key? key, Widget? child}) + : super(key: key, child: child); + + @override + RenderDryIntrinsicWidth createRenderObject(BuildContext context) => + RenderDryIntrinsicWidth(); +} + +class RenderDryIntrinsicWidth extends RenderIntrinsicWidth { + @override + Size computeDryLayout(BoxConstraints constraints) { + if (child != null) { + final width = child!.computeMinIntrinsicWidth(constraints.maxHeight); + final height = child!.computeMinIntrinsicHeight(width); + return Size(width, height); + } else { + return Size.zero; + } + } +} + +class DryIntrinsicHeight extends SingleChildRenderObjectWidget { + const DryIntrinsicHeight({Key? key, Widget? child}) + : super(key: key, child: child); + + @override + RenderDryIntrinsicHeight createRenderObject(BuildContext context) => + RenderDryIntrinsicHeight(); +} + +class RenderDryIntrinsicHeight extends RenderIntrinsicHeight { + @override + Size computeDryLayout(BoxConstraints constraints) { + if (child != null) { + final height = child!.computeMinIntrinsicHeight(constraints.maxWidth); + final width = child!.computeMinIntrinsicWidth(height); + return Size(width, height); + } else { + return Size.zero; + } + } +} diff --git a/lib/shared/widgets/feedback_form/feedback_form.dart b/lib/shared/widgets/feedback_form/feedback_form.dart new file mode 100644 index 0000000000..21f4724f7e --- /dev/null +++ b/lib/shared/widgets/feedback_form/feedback_form.dart @@ -0,0 +1,102 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_bloc.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_event.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class FeedbackForm extends StatefulWidget { + const FeedbackForm({Key? key, required this.formState}) : super(key: key); + final FeedbackFormState formState; + + @override + State createState() => _FeedbackFormState(); +} + +class _FeedbackFormState extends State { + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _messageController = TextEditingController(); + + @override + Widget build(BuildContext context) { + final FeedbackFormState state = widget.formState; + final bool isSending = state is FeedbackFormSendingState; + BaseError? emailError; + BaseError? messageError; + BaseError? sendingError; + if (state is FeedbackFormFailureState) { + emailError = state.emailError; + messageError = state.messageError; + sendingError = state.sendingError; + } + return Form( + key: const Key('feedback-form'), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + UiTextFormField( + key: const Key('feedback-email-field'), + controller: _emailController, + textInputAction: TextInputAction.next, + hintText: LocaleKeys.email.tr(), + keyboardType: TextInputType.emailAddress, + validationMode: InputValidationMode.eager, + validator: (_) => emailError?.message, + ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: UiTextFormField( + key: const Key('feedback-message-field'), + controller: _messageController, + hintText: LocaleKeys.yourFeedback.tr(), + maxLines: 4, + keyboardType: TextInputType.multiline, + maxLength: 500, + counterText: '', + textInputAction: TextInputAction.send, + validationMode: InputValidationMode.eager, + validator: (_) => messageError?.message, + ), + ), + if (sendingError != null) + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: Text( + sendingError.message, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: UiPrimaryButton( + key: const Key('feedback-submit-button'), + text: LocaleKeys.sendFeedback.tr(), + prefix: isSending + ? const Padding( + padding: EdgeInsets.only(right: 12.0), + child: UiSpinner(), + ) + : null, + onPressed: isSending + ? null + : () { + context + .read() + .add(FeedbackFormSubmitted( + email: _emailController.text, + message: _messageController.text, + )); + }, + ), + ) + ], + ), + ); + } +} diff --git a/lib/shared/widgets/feedback_form/feedback_form_thanks.dart b/lib/shared/widgets/feedback_form/feedback_form_thanks.dart new file mode 100644 index 0000000000..aac91e7008 --- /dev/null +++ b/lib/shared/widgets/feedback_form/feedback_form_thanks.dart @@ -0,0 +1,64 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_bloc.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_event.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_light_button.dart'; + +class FeedbackFormThanks extends StatelessWidget { + const FeedbackFormThanks({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('feedback-thanks'), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18.0)), + color: Theme.of(context).colorScheme.surface, + ), + padding: const EdgeInsets.all(18), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Image.asset( + '$assetsPath/logo/komodian_thanks.png', + height: 162, + width: 169, + filterQuality: FilterQuality.high, + ), + Padding( + padding: const EdgeInsets.only(top: 26), + child: Text( + LocaleKeys.feedbackFormThanksTitle.tr(), + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 11), + child: Text( + LocaleKeys.feedbackFormThanksDescription.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 21.0), + child: UiLightButton( + key: const Key('feedback-add-more-button'), + backgroundColor: Theme.of(context).colorScheme.tertiary, + text: LocaleKeys.addMoreFeedback.tr(), + onPressed: () { + context + .read() + .add(const FeedbackFormReset()); + }), + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/feedback_form/feedback_form_wrapper.dart b/lib/shared/widgets/feedback_form/feedback_form_wrapper.dart new file mode 100644 index 0000000000..f874eab5f5 --- /dev/null +++ b/lib/shared/widgets/feedback_form/feedback_form_wrapper.dart @@ -0,0 +1,55 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_bloc.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_repo.dart'; +import 'package:web_dex/bloc/feedback_form/feedback_form_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/feedback_form/feedback_form.dart'; +import 'package:web_dex/shared/widgets/feedback_form/feedback_form_thanks.dart'; + +class FeedbackFormWrapper extends StatelessWidget { + const FeedbackFormWrapper({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + FeedbackFormBloc(feedbackFormRepo: FeedbackFormRepo()), + child: BlocBuilder( + builder: (context, state) { + if (state is FeedbackFormSuccessState) { + return const FeedbackFormThanks(); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocaleKeys.feedbackFormTitle.tr(), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + LocaleKeys.feedbackFormDescription.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20.0), + child: FeedbackForm(formState: state), + ), + ], + ); + }, + )); + } +} diff --git a/lib/shared/widgets/focusable_widget.dart b/lib/shared/widgets/focusable_widget.dart new file mode 100644 index 0000000000..ca136f072d --- /dev/null +++ b/lib/shared/widgets/focusable_widget.dart @@ -0,0 +1,38 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class FocusableWidget extends StatefulWidget { + const FocusableWidget( + {Key? key, required this.child, this.borderRadius, this.onTap}) + : super(key: key); + final Widget child; + final BorderRadiusGeometry? borderRadius; + final VoidCallback? onTap; + + @override + State createState() => _FocusableWidgetState(); +} + +class _FocusableWidgetState extends State { + bool _hasFocus = false; + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + borderRadius: widget.borderRadius, + border: Border.all( + color: _hasFocus + ? theme.custom.headerFloatBoxColor + : Colors.transparent), + ), + child: InkWell( + onFocusChange: (value) { + setState(() { + _hasFocus = value; + }); + }, + onTap: widget.onTap, + child: widget.child, + )); + } +} diff --git a/lib/shared/widgets/hash_explorer_link.dart b/lib/shared/widgets/hash_explorer_link.dart new file mode 100644 index 0000000000..54bded1de0 --- /dev/null +++ b/lib/shared/widgets/hash_explorer_link.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.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/copyable_link.dart'; + +class HashExplorerLink extends StatelessWidget { + const HashExplorerLink({ + super.key, + required this.coin, + required this.hash, + required this.type, + }); + final Coin coin; + final String hash; + final HashExplorerType type; + + @override + Widget build(BuildContext context) { + return CopyableLink( + text: truncateMiddleSymbols(hash), + valueToCopy: hash, + onLinkTap: () => viewHashOnExplorer(coin, hash, type), + ); + } +} diff --git a/lib/shared/widgets/hidden_with_wallet.dart b/lib/shared/widgets/hidden_with_wallet.dart new file mode 100644 index 0000000000..87e9a7efc8 --- /dev/null +++ b/lib/shared/widgets/hidden_with_wallet.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/wallet.dart'; + +class HiddenWithWallet extends StatelessWidget { + const HiddenWithWallet({Key? key, required this.child}) : super(key: key); + final Widget child; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: currentWalletBloc.wallet, + stream: currentWalletBloc.outWallet, + builder: (BuildContext context, + AsyncSnapshot currentWalletSnapshot) { + return currentWalletSnapshot.data == null + ? child + : const SizedBox.shrink(); + }); + } +} diff --git a/lib/shared/widgets/hidden_without_wallet.dart b/lib/shared/widgets/hidden_without_wallet.dart new file mode 100644 index 0000000000..1dd431d229 --- /dev/null +++ b/lib/shared/widgets/hidden_without_wallet.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/wallet.dart'; + +class HiddenWithoutWallet extends StatelessWidget { + const HiddenWithoutWallet( + {Key? key, required this.child, this.isHiddenForHw = false}) + : super(key: key); + final Widget child; + final bool isHiddenForHw; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: currentWalletBloc.wallet, + stream: currentWalletBloc.outWallet, + builder: (BuildContext context, + AsyncSnapshot currentWalletSnapshot) { + final Wallet? currentWallet = currentWalletSnapshot.data; + if (currentWallet == null) { + return const SizedBox.shrink(); + } + + if (isHiddenForHw && currentWallet.isHW) { + return const SizedBox.shrink(); + } + + return child; + }); + } +} diff --git a/lib/shared/widgets/html_parser.dart b/lib/shared/widgets/html_parser.dart new file mode 100644 index 0000000000..f7f3cd4ddd --- /dev/null +++ b/lib/shared/widgets/html_parser.dart @@ -0,0 +1,81 @@ +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class HtmlParser extends StatefulWidget { + const HtmlParser( + this.html, { + this.textStyle, + this.linkStyle, + }); + + final String html; + final TextStyle? textStyle; + final TextStyle? linkStyle; + + @override + State createState() => _HtmlParserState(); +} + +class _HtmlParserState extends State { + final List recognizers = []; + late TextStyle textStyle; + late TextStyle linkStyle; + + @override + void dispose() { + for (TapGestureRecognizer recognizer in recognizers) { + recognizer.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + textStyle = widget.textStyle ?? const TextStyle(); + linkStyle = widget.linkStyle ?? const TextStyle(); + final List chunks = _splitLinks(widget.html); + final List children = []; + + for (String chunk in chunks) { + final RegExpMatch? linkMatch = + // ignore: unnecessary_string_escapes + RegExp(']+href=\'(.*?)\'[^>]*>(.*)?<\/a>').firstMatch(chunk); + if (linkMatch == null) { + children.add(TextSpan( + text: chunk, + style: textStyle, + )); + } else { + children.add(_buildClickable(linkMatch)); + } + } + + return SelectableText.rich(TextSpan( + children: children, + )); + } + + List _splitLinks(String text) { + final List list = []; + final Iterable allMatches = + RegExp(r'(^|.*?)(]*>.*?<\/a>|$)').allMatches(text); + + for (RegExpMatch match in allMatches) { + if (match[1] != '') list.add(match[1]!); + if (match[2] != '') list.add(match[2]!); + } + + return list; + } + + InlineSpan _buildClickable(RegExpMatch match) { + recognizers.add(TapGestureRecognizer()..onTap = () => launchURL(match[1]!)); + + return TextSpan( + text: match[2], + style: textStyle.merge(linkStyle), + recognizer: recognizers.last, + ); + } +} diff --git a/lib/shared/widgets/information_popup.dart b/lib/shared/widgets/information_popup.dart new file mode 100644 index 0000000000..20093a4574 --- /dev/null +++ b/lib/shared/widgets/information_popup.dart @@ -0,0 +1,43 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class InformationPopup extends PopupDispatcher { + InformationPopup({ + required BuildContext context, + this.text = '', + super.barrierDismissible = true, + }) : super(context: context); + String text; + + @override + Widget get popupContent => Container( + constraints: isMobile ? null : const BoxConstraints(maxWidth: 360), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + text, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: UiUnderlineTextButton( + text: LocaleKeys.close.tr().toLowerCase(), + onPressed: () { + close(); + })), + ], + ) + ], + ), + ); +} diff --git a/lib/shared/widgets/launch_native_explorer_button.dart b/lib/shared/widgets/launch_native_explorer_button.dart new file mode 100644 index 0000000000..e3fb75f9ff --- /dev/null +++ b/lib/shared/widgets/launch_native_explorer_button.dart @@ -0,0 +1,28 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class LaunchNativeExplorerButton extends StatelessWidget { + const LaunchNativeExplorerButton({ + Key? key, + required this.coin, + this.address, + }) : super(key: key); + final Coin coin; + final String? address; + + @override + Widget build(BuildContext context) { + return UiPrimaryButton( + width: 160, + height: 30, + onPressed: () { + launchURL(getNativeExplorerUrlByCoin(coin, address)); + }, + text: LocaleKeys.viewOnExplorer.tr(), + ); + } +} diff --git a/lib/shared/widgets/logout_popup.dart b/lib/shared/widgets/logout_popup.dart new file mode 100644 index 0000000000..2d7ec935e0 --- /dev/null +++ b/lib/shared/widgets/logout_popup.dart @@ -0,0 +1,72 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc_event.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class LogOutPopup extends StatelessWidget { + const LogOutPopup({ + super.key, + required this.onConfirm, + required this.onCancel, + }); + final VoidCallback onCancel; + final VoidCallback onConfirm; + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 300), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText( + LocaleKeys.logoutPopupTitle.tr(), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 12), + if (currentWalletBloc.wallet?.config.type == WalletType.iguana) + SelectableText( + LocaleKeys.logoutPopupDescription.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 25), + Row( + mainAxisSize: MainAxisSize.min, + children: [ + UiUnderlineTextButton( + key: const Key('popup-cancel-logout-button'), + width: 120, + height: 36, + text: LocaleKeys.cancel.tr(), + onPressed: onCancel, + ), + const SizedBox(width: 12), + UiPrimaryButton( + key: const Key('popup-confirm-logout-button'), + width: 120, + height: 36, + text: LocaleKeys.logOut.tr(), + onPressed: () { + context.read().add(const AuthLogOutEvent()); + onConfirm(); + }, + ), + ], + ), + ], + ), + ); + } +} diff --git a/lib/shared/widgets/need_attention_mark.dart b/lib/shared/widgets/need_attention_mark.dart new file mode 100644 index 0000000000..116aae7034 --- /dev/null +++ b/lib/shared/widgets/need_attention_mark.dart @@ -0,0 +1,19 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class NeedAttentionMark extends StatelessWidget { + const NeedAttentionMark(this.needAttention, {Key? key}) : super(key: key); + + final bool needAttention; + + @override + Widget build(BuildContext context) { + return Container( + width: 4, + height: 25, + decoration: BoxDecoration( + color: needAttention ? theme.custom.warningColor : Colors.transparent, + borderRadius: const BorderRadius.all(Radius.circular(18))), + ); + } +} diff --git a/lib/shared/widgets/nft/nft_badge.dart b/lib/shared/widgets/nft/nft_badge.dart new file mode 100644 index 0000000000..5e51f12224 --- /dev/null +++ b/lib/shared/widgets/nft/nft_badge.dart @@ -0,0 +1,82 @@ +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/model/nft.dart'; + +class BlockchainBadge extends StatelessWidget { + final NftBlockchains blockchain; + const BlockchainBadge({ + super.key, + required this.blockchain, + this.width = 105, + this.padding = const EdgeInsets.symmetric(vertical: 3, horizontal: 5), + this.iconSize = 15, + this.iconColor = Colors.white, + this.textStyle, + }); + final double width; + final EdgeInsets padding; + final double iconSize; + final Color iconColor; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final isSmallFont = blockchain.toString().length > 5; + final style = textStyle ?? + TextStyle( + fontSize: isSmallFont ? 9 : 10, + fontWeight: FontWeight.w600, + color: iconColor, + ); + return Container( + width: width, + padding: padding, + decoration: BoxDecoration( + color: getColor(), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + '$assetsPath/blockchain_icons/svg/32px/${blockchain.toApiRequest().toLowerCase()}.svg', + colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), + height: iconSize, + width: iconSize), + const SizedBox(width: 2), + Flexible( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 2), + child: Text( + blockchain.toString(), + style: style, + ), + ), + const SizedBox(width: 1), + ], + )), + ], + ), + ); + } + + Color getColor() { + switch (blockchain) { + case NftBlockchains.eth: + return const Color(0xFF3D77E9); + case NftBlockchains.bsc: + return const Color(0xFFE6BC41); + case NftBlockchains.avalanche: + return const Color(0xFFD54F49); + case NftBlockchains.polygon: + return const Color(0xFF7B49DD); + case NftBlockchains.fantom: + return const Color(0xFF3267F6); + } + } +} diff --git a/lib/shared/widgets/password_visibility_control.dart b/lib/shared/widgets/password_visibility_control.dart new file mode 100644 index 0000000000..e6e3ba9bae --- /dev/null +++ b/lib/shared/widgets/password_visibility_control.dart @@ -0,0 +1,95 @@ +import 'dart:async'; +import 'dart:math'; +import 'package:flutter/material.dart'; + +/// #644: We want the password to be obscured most of the time +/// in order to workaround the problem of some devices ignoring `TYPE_TEXT_FLAG_NO_SUGGESTIONS`, +/// https://github.com/flutter/engine/blob/d1c71e5206bd9546f4ff64b7336c4e74e3f4ccfd/shell/platform/android/io/flutter/plugin/editing/TextInputPlugin.java#L93-L99 +class PasswordVisibilityControl extends StatefulWidget { + const PasswordVisibilityControl({ + required this.onVisibilityChange, + }); + final void Function(bool) onVisibilityChange; + + @override + State createState() => + _PasswordVisibilityControlState(); +} + +class _PasswordVisibilityControlState extends State { + bool _isObscured = true; + Offset _tapStartPosition = const Offset(0, 0); + Timer? _timer; + + void _setObscureTo(bool isObscured) { + if (!mounted) { + return; + } + _timer?.cancel(); + setState(() { + _isObscured = isObscured; + }); + widget.onVisibilityChange(_isObscured); + } + + bool _wasLongPressMoved(Offset position) { + final double distance = sqrt(pow(_tapStartPosition.dx - position.dx, 2) + + pow(_tapStartPosition.dy - position.dy, 2)); + return distance > 20; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + // NB: Both the long press and the tap start with `onTabDown`. + onTapDown: (TapDownDetails details) { + _tapStartPosition = details.globalPosition; + _setObscureTo(!_isObscured); + }, + // #644: Most users expect the eye to react to the taps (behaving as a toggle) + // whereas long press handling starts too late to produce any visible reaction. + // Flashing the password for a few seconds in order not to befuddle the users. + onTapUp: (TapUpDetails details) { + _timer = Timer(const Duration(seconds: 2), () { + _setObscureTo(true); + }); + }, + onLongPressStart: (LongPressStartDetails details) { + _timer?.cancel(); + }, + onLongPressEnd: (LongPressEndDetails details) { + _setObscureTo(true); + }, + + // #644: Fires when we press on the eye and *a few seconds later* drag the finger off screen. + onLongPressMoveUpdate: (LongPressMoveUpdateDetails details) { + if (_wasLongPressMoved(details.globalPosition)) { + _setObscureTo(true); + } + }, + // #644: Fires when we press on the eye and *immediately* drag the finger off screen. + onVerticalDragStart: (DragStartDetails details) { + _setObscureTo(true); + }, + onHorizontalDragStart: (DragStartDetails details) { + _setObscureTo(true); + }, + + child: InkWell( + mouseCursor: SystemMouseCursors.click, + child: SizedBox( + width: 60, + child: Icon( + _isObscured + ? Icons.visibility_off_outlined + : Icons.visibility_outlined, + color: Theme.of(context) + .textTheme + .bodyMedium + ?.color + ?.withOpacity(0.7)), + ), + ), + ); + } +} diff --git a/lib/shared/widgets/segwit_icon.dart b/lib/shared/widgets/segwit_icon.dart new file mode 100644 index 0000000000..8db4b1348f --- /dev/null +++ b/lib/shared/widgets/segwit_icon.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/svg.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_state.dart'; + +class SegwitIcon extends StatelessWidget { + const SegwitIcon({super.key, this.width, this.height}); + final double? width; + final double? height; + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) { + return state.themeMode; + }, + builder: (context, themeMode) { + return SvgPicture.asset( + width: width, + height: height, + _getIconPath(themeMode), + ); + }, + ); + } + + String _getIconPath(ThemeMode mode) { + switch (mode) { + case ThemeMode.system: + return '$assetsPath/ui_icons/segwit_dark.svg'; + case ThemeMode.light: + return '$assetsPath/ui_icons/segwit_light.svg'; + case ThemeMode.dark: + return '$assetsPath/ui_icons/segwit_dark.svg'; + } + } +} diff --git a/lib/shared/widgets/send_analytics_checkbox.dart b/lib/shared/widgets/send_analytics_checkbox.dart new file mode 100644 index 0000000000..fdaff63a2a --- /dev/null +++ b/lib/shared/widgets/send_analytics_checkbox.dart @@ -0,0 +1,53 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/bloc/analytics/analytics_event.dart'; +import 'package:web_dex/bloc/analytics/analytics_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class SendAnalyticsCheckbox extends StatelessWidget { + const SendAnalyticsCheckbox({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) { + return state.isSendDataAllowed; + }, + builder: (context, isAllowed) { + final AnalyticsBloc analyticsBloc = context.read(); + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + UiSwitcher( + key: const Key('send-analytics-switcher'), + value: isAllowed, + onChanged: (bool? isChecked) { + final bool checked = isChecked ?? false; + if (checked) { + analyticsBloc.add(const AnalyticsActivateEvent()); + } else { + analyticsBloc.add(const AnalyticsDeactivateEvent()); + } + }, + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Text( + LocaleKeys.sendToAnalytics.tr(), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/shared/widgets/simple_copyable_link.dart b/lib/shared/widgets/simple_copyable_link.dart new file mode 100644 index 0000000000..bdea41570f --- /dev/null +++ b/lib/shared/widgets/simple_copyable_link.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/copyable_link.dart'; + +class SimpleCopyableLink extends StatelessWidget { + const SimpleCopyableLink({ + super.key, + required this.text, + required this.link, + required this.valueToCopy, + }); + final String text; + final String valueToCopy; + final String? link; + + @override + Widget build(BuildContext context) { + final link = this.link; + return CopyableLink( + text: text, + valueToCopy: valueToCopy, + onLinkTap: link == null ? null : () => launchURL(link), + ); + } +} diff --git a/lib/shared/widgets/truncate_middle_text.dart b/lib/shared/widgets/truncate_middle_text.dart new file mode 100644 index 0000000000..bcee37dba4 --- /dev/null +++ b/lib/shared/widgets/truncate_middle_text.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; + +class TruncatedMiddleText extends StatelessWidget { + final String string; + final TextStyle style; + final int level; + + const TruncatedMiddleText(this.string, + {this.style = const TextStyle(), this.level = 6, super.key}); + + @override + Widget build(BuildContext context) { + if (string.length < level) { + return Text( + string, + style: style, + ); + } + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: Text( + string.substring(0, string.length - level + 1), + key: key, + overflow: TextOverflow.ellipsis, + style: style.copyWith(fontFamily: 'Sans-Serif, Arial'), + textAlign: TextAlign.center, + ), + ), + Text( + string.substring(string.length - level + 1), + style: style.copyWith(fontFamily: 'Sans-Serif, Arial'), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/update_popup.dart b/lib/shared/widgets/update_popup.dart new file mode 100644 index 0000000000..61433a9036 --- /dev/null +++ b/lib/shared/widgets/update_popup.dart @@ -0,0 +1,122 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_markdown/flutter_markdown.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/blocs/update_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class UpdatePopUp extends StatelessWidget { + const UpdatePopUp({ + Key? key, + required this.versionInfo, + required this.onAccept, + required this.onCancel, + }) : super(key: key); + final UpdateVersionInfo versionInfo; + final VoidCallback onCancel; + final VoidCallback onAccept; + + @override + Widget build(BuildContext context) { + final bool isUpdateRequired = versionInfo.status == UpdateStatus.required; + final scrollController = ScrollController(); + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + '$assetsPath/logo/update_logo.png', + height: 150, + filterQuality: FilterQuality.high, + ), + Padding( + padding: const EdgeInsets.only(top: 30.0), + child: Text( + LocaleKeys.updatePopupTitle.tr(), + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 14), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 15), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.whatsNew.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + DexScrollbar( + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: SizedBox( + width: double.infinity, + child: SizedBox( + height: 110, + width: 320, + child: Markdown( + styleSheet: MarkdownStyleSheet( + listIndent: 10.0, + p: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: + Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + data: versionInfo.changelog, + ), + ), + ), + ), + ), + ], + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (!isUpdateRequired) + Expanded( + child: Padding( + padding: const EdgeInsets.only(right: 16.0), + child: UiUnderlineTextButton( + text: LocaleKeys.remindLater.tr(), + onPressed: () { + onCancel(); + Navigator.of(context).pop(); + }, + ), + ), + ), + Expanded( + child: UiPrimaryButton( + text: LocaleKeys.updateNow.tr(), + backgroundColor: Theme.of(context).colorScheme.secondary, + onPressed: () async { + onAccept(); + await updateBloc.update(); + }, + ), + ), + ], + ), + ) + ], + ); + } +} diff --git a/lib/views/bitrefill/bitrefill_button.dart b/lib/views/bitrefill/bitrefill_button.dart new file mode 100644 index 0000000000..d9165313e2 --- /dev/null +++ b/lib/views/bitrefill/bitrefill_button.dart @@ -0,0 +1,133 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/bitrefill/bloc/bitrefill_bloc.dart'; +import 'package:web_dex/bloc/bitrefill/models/bitrefill_event.dart'; +import 'package:web_dex/bloc/bitrefill/models/bitrefill_event_factory.dart'; +import 'package:web_dex/bloc/bitrefill/models/bitrefill_payment_intent_event.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/bitrefill/bitrefill_button_view.dart'; +import 'package:web_dex/views/bitrefill/bitrefill_desktop_webview_button.dart'; +import 'package:web_dex/views/bitrefill/bitrefill_inappbrowser_button.dart'; + +/// A button that opens the Bitrefill widget in a new window or tab. +/// The Bitrefill widget is a web page that allows the user to purchase gift +/// cards and mobile top-ups with cryptocurrency. +/// +/// The widget is disabled if the Bitrefill widget fails to load, if the coin +/// is not supported, or if the coin is suspended. +/// +/// The widget returns a payment intent event when the user completes a purchase. +/// The event is passed to the [onPaymentRequested] callback. +class BitrefillButton extends StatefulWidget { + const BitrefillButton({ + super.key, + required this.coin, + required this.onPaymentRequested, + this.windowTitle = 'Bitrefill', + }); + + final Coin coin; + final String windowTitle; + final void Function(BitrefillPaymentIntentEvent) onPaymentRequested; + + @override + State createState() => _BitrefillButtonState(); +} + +class _BitrefillButtonState extends State { + final bool isInAppBrowserSupported = + !kIsWeb && (Platform.isAndroid || Platform.isIOS || Platform.isMacOS); + + @override + void initState() { + context + .read() + .add(BitrefillLoadRequested(coin: widget.coin)); + super.initState(); + } + + @override + Widget build(BuildContext context) { + void handleMessage(String event) => _handleMessage(event, context); + + return BlocConsumer( + listener: (BuildContext context, BitrefillState state) { + if (state is BitrefillPaymentInProgress) { + widget.onPaymentRequested(state.paymentIntent); + } + }, + builder: (BuildContext context, BitrefillState state) { + final bool bitrefillLoadSuccess = state is BitrefillLoadSuccess; + bool isCoinSupported = false; + if (bitrefillLoadSuccess) { + isCoinSupported = state.supportedCoins.contains(widget.coin.abbr); + } + final bool hasNonZeroBalance = widget.coin.balance > 0; + final bool isEnabled = bitrefillLoadSuccess && + isCoinSupported && + !widget.coin.isSuspended && + hasNonZeroBalance; + final String url = state is BitrefillLoadSuccess ? state.url : ''; + + if (!isEnabled) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + if (kIsWeb) + // Temporary solution for web until this PR is approved and released: + // https://github.com/pichillilorenzo/flutter_inappwebview/pull/2058 + BitrefillButtonView( + onPressed: isEnabled + ? () => _openBitrefillInNewTab(context, url) + : null, + ) + else if (isInAppBrowserSupported) + BitrefillInAppBrowserButton( + windowTitle: widget.windowTitle, + url: url, + enabled: isEnabled, + onMessage: handleMessage, + ) + else + BitrefillDesktopWebviewButton( + windowTitle: widget.windowTitle, + url: url, + enabled: isEnabled, + onMessage: handleMessage, + ), + ], + ); + }, + ); + } + + void _openBitrefillInNewTab(BuildContext context, String url) { + launchURL(url, inSeparateTab: true); + context.read().add(const BitrefillLaunchRequested()); + } + + /// Handles messages from the Bitrefill widget. + /// The message is a JSON string that contains the event name and event data. + /// The event name is used to create a [BitrefillWidgetEvent] object. + void _handleMessage(String event, BuildContext context) { + // Convert from JSON string to Map here to avoid library and + // platform-specific javascript object conversion issues. + final Map decodedEvent = + jsonDecode(event) as Map; + + final BitrefillWidgetEvent bitrefillEvent = + BitrefillEventFactory.createEvent(decodedEvent); + if (bitrefillEvent is BitrefillPaymentIntentEvent) { + context + .read() + .add(BitrefillPaymentIntentReceived(bitrefillEvent)); + } + } +} diff --git a/lib/views/bitrefill/bitrefill_button_view.dart b/lib/views/bitrefill/bitrefill_button_view.dart new file mode 100644 index 0000000000..90e6084384 --- /dev/null +++ b/lib/views/bitrefill/bitrefill_button_view.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; + +class BitrefillButtonView extends StatelessWidget { + const BitrefillButtonView({ + super.key, + required this.onPressed, + }); + + final void Function()? onPressed; + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + return UiPrimaryButton( + height: isMobile ? 52 : 40, + prefix: Container( + padding: const EdgeInsets.only(right: 14), + child: SvgPicture.asset( + '$assetsPath/others/bitrefill_logo.svg', + ), + ), + textStyle: themeData.textTheme.labelLarge + ?.copyWith(fontSize: 14, fontWeight: FontWeight.w600), + backgroundColor: themeData.colorScheme.tertiary, + onPressed: onPressed, + text: LocaleKeys.spend.tr(), + ); + } +} diff --git a/lib/views/bitrefill/bitrefill_desktop_webview_button.dart b/lib/views/bitrefill/bitrefill_desktop_webview_button.dart new file mode 100644 index 0000000000..c5fb68a705 --- /dev/null +++ b/lib/views/bitrefill/bitrefill_desktop_webview_button.dart @@ -0,0 +1,86 @@ +import 'dart:io'; + +import 'package:desktop_webview_window/desktop_webview_window.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/bitrefill/bloc/bitrefill_bloc.dart'; +import 'package:web_dex/views/bitrefill/bitrefill_button_view.dart'; + +/// A button that opens the provided [url] in a Browser window on Desktop platforms. +/// This widget uses the desktop_webview_window package to open a new window. +/// The window is closed when a BitrefillPaymentInProgress event is received. +/// +/// NOTE: this widget only works on Windows, Linux and macOS +class BitrefillDesktopWebviewButton extends StatefulWidget { + /// The [onMessage] callback is called when a message is received from the webview. + /// The [enabled] property determines if the button is enabled. + /// The [windowTitle] property is used as the title of the window. + /// The [url] property is the URL to open in the window. + const BitrefillDesktopWebviewButton({ + super.key, + required this.url, + required this.windowTitle, + required this.enabled, + required this.onMessage, + }); + + /// The title of the pop-up browser window. + final String windowTitle; + + /// The URL to open in the pop-up browser window. + final String url; + + /// Determines if the button is enabled. + final bool enabled; + + /// The callback function that is called when a message is received from the webview. + final dynamic Function(String) onMessage; + + @override + BitrefillDesktopWebviewButtonState createState() => + BitrefillDesktopWebviewButtonState(); +} + +class BitrefillDesktopWebviewButtonState + extends State { + Webview? webview; + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (BuildContext context, BitrefillState state) { + if (state is BitrefillPaymentInProgress) { + webview?.close(); + } + }, + child: BitrefillButtonView( + onPressed: widget.enabled ? _openWebview : null, + ), + ); + } + + void _openWebview() { + WebviewWindow.isWebviewAvailable().then((bool value) { + _createWebview(); + }); + } + + Future _createWebview() async { + webview?.close(); + webview = await WebviewWindow.create( + configuration: CreateConfiguration( + title: widget.windowTitle, + titleBarTopPadding: Platform.isMacOS ? 20 : 0, + ), + ); + webview + ?..registerJavaScriptMessageHandler('test', (String name, dynamic body) { + widget.onMessage(body as String); + }) + ..addOnWebMessageReceivedCallback( + (String body) => widget.onMessage(body), + ) + ..setApplicationNameForUserAgent(' WebviewExample/1.0.0') + ..launch(widget.url); + } +} diff --git a/lib/views/bitrefill/bitrefill_inappbrowser_button.dart b/lib/views/bitrefill/bitrefill_inappbrowser_button.dart new file mode 100644 index 0000000000..f08b43a8f8 --- /dev/null +++ b/lib/views/bitrefill/bitrefill_inappbrowser_button.dart @@ -0,0 +1,93 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +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'; + +/// A button that opens the provided [url] in an InAppBrowser window. +/// This widget uses the flutter_inappwebview package to open a new window. +/// The window is closed when a BitrefillPaymentInProgress event is received. +/// +/// NOTE: this widget only works on Web, Android, iOS, and macOS (for now). +class BitrefillInAppBrowserButton extends StatefulWidget { + /// The [onMessage] callback is called when a message is received from the webview. + /// The [enabled] property determines if the button is enabled. + /// The [windowTitle] property is used as the title of the window. + /// The [url] property is the URL to open in the window. + const BitrefillInAppBrowserButton({ + super.key, + required this.url, + required this.windowTitle, + required this.enabled, + required this.onMessage, + }); + + /// The title of the pop-up browser window. + final String windowTitle; + + /// The URL to open in the pop-up browser window. + final String url; + + /// Determines if the button is enabled. + final bool enabled; + + /// The callback function that is called when a message is received from the webview. + final dynamic Function(String) onMessage; + + @override + BitrefillInAppBrowserButtonState createState() => + BitrefillInAppBrowserButtonState(); +} + +class BitrefillInAppBrowserButtonState + extends State { + CustomInAppBrowser? browser; + final InAppBrowserClassSettings settings = InAppBrowserClassSettings( + browserSettings: InAppBrowserSettings(), + webViewSettings: InAppWebViewSettings( + isInspectable: kDebugMode, + ), + ); + + @override + void initState() { + super.initState(); + browser = CustomInAppBrowser(onMessage: widget.onMessage); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (BuildContext context, BitrefillState state) { + if (state is BitrefillPaymentInProgress) { + browser?.close(); + } + }, + child: BitrefillButtonView( + onPressed: widget.enabled ? _openBrowserWindow : null, + ), + ); + } + + Future _openBrowserWindow() async { + browser?.openUrlRequest( + urlRequest: URLRequest( + url: WebUri(widget.url), + ), + ); + } +} + +/// A custom InAppBrowser that calls the [onMessage] callback when a +/// console message is received (`console.log(message)` in JavaScript). +class CustomInAppBrowser extends InAppBrowser { + CustomInAppBrowser({required this.onMessage}) : super(); + + final dynamic Function(String) onMessage; + + @override + void onConsoleMessage(ConsoleMessage consoleMessage) { + onMessage(consoleMessage.message); + } +} diff --git a/lib/views/bitrefill/bitrefill_transaction_completed_dialog.dart b/lib/views/bitrefill/bitrefill_transaction_completed_dialog.dart new file mode 100644 index 0000000000..acf2bb572b --- /dev/null +++ b/lib/views/bitrefill/bitrefill_transaction_completed_dialog.dart @@ -0,0 +1,75 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; + +class BitrefillTransactionCompletedDialog extends StatelessWidget { + const BitrefillTransactionCompletedDialog({ + super.key, + required this.title, + required this.message, + required this.onViewInvoicePressed, + this.onPositiveButtonPressed, + }); + + final String title; + final String message; + final VoidCallback? onPositiveButtonPressed; + final VoidCallback onViewInvoicePressed; + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + return AlertDialog( + title: Text(title), + content: SingleChildScrollView( + child: Text(message), + ), + actions: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + // Expanded( + // child: UiPrimaryButton( + // height: isMobile ? 52 : 40, + // prefix: Container( + // padding: const EdgeInsets.only(right: 14), + // child: SvgPicture.asset( + // '$assetsPath/others/bitrefill_logo.svg', + // ), + // ), + // textStyle: themeData.textTheme.labelLarge + // ?.copyWith(fontSize: 14, fontWeight: FontWeight.w600), + // backgroundColor: themeData.colorScheme.tertiary, + // onPressed: onViewInvoicePressed, + // text: LocaleKeys.viewInvoice.tr(), + // ), + // ), + // const SizedBox(width: 10), + Expanded( + child: UiPrimaryButton( + height: isMobile ? 52 : 40, + prefix: Container( + padding: const EdgeInsets.only(right: 14), + child: SvgPicture.asset( + '$assetsPath/others/tick.svg', + height: 16, + ), + ), + textStyle: themeData.textTheme.labelLarge + ?.copyWith(fontSize: 14, fontWeight: FontWeight.w600), + backgroundColor: themeData.colorScheme.tertiary, + onPressed: onPositiveButtonPressed ?? + () => Navigator.of(context).pop(), + text: LocaleKeys.ok.tr(), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/views/bridge/bridge_available_balance.dart b/lib/views/bridge/bridge_available_balance.dart new file mode 100644 index 0000000000..1be9a8598a --- /dev/null +++ b/lib/views/bridge/bridge_available_balance.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; +import 'package:web_dex/views/dex/simple/form/taker/available_balance.dart'; + +class BridgeAvailableBalance extends StatelessWidget { + const BridgeAvailableBalance({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, cur) { + return prev.maxSellAmount != cur.maxSellAmount || + prev.availableBalanceState != cur.availableBalanceState; + }, + builder: (context, state) { + return AvailableBalance( + state.maxSellAmount, + state.availableBalanceState, + ); + }, + ); + } +} diff --git a/lib/views/bridge/bridge_confirmation.dart b/lib/views/bridge/bridge_confirmation.dart new file mode 100644 index 0000000000..f4b8803277 --- /dev/null +++ b/lib/views/bridge/bridge_confirmation.dart @@ -0,0 +1,410 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:rational/rational.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/blocs/blocs.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/dex_form_error.dart'; +import 'package:web_dex/model/trade_preimage.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'; +import 'package:web_dex/shared/utils/formatters.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:web_dex/views/bridge/bridge_total_fees.dart'; +import 'package:web_dex/views/bridge/view/bridge_exchange_rate.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class BridgeConfirmation extends StatefulWidget { + const BridgeConfirmation({Key? key}) : super(key: key); + + @override + State createState() => _BridgeOrderConfirmationState(); +} + +class _BridgeOrderConfirmationState extends State { + @override + Widget build(BuildContext context) { + return BlocConsumer( + listener: (BuildContext context, BridgeState state) async { + final String? swapUuid = state.swapUuid; + if (swapUuid == null) return; + + context.read().add(const BridgeClear()); + routingState.bridgeState.setDetailsAction(swapUuid); + + await tradingEntitiesBloc.fetch(); + }, + builder: (BuildContext context, BridgeState state) { + final TradePreimage? preimage = state.preimageData?.data; + if (preimage == null) return const UiSpinner(); + + final Coin? sellCoin = coinsBloc.getCoin(preimage.request.base); + final Coin? buyCoin = coinsBloc.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.somethingWrong.tr()} :(')); + } + + final confirmDto = _ConfirmDTO( + sellCoin: sellCoin, + buyCoin: buyCoin, + sellAmount: sellAmount, + buyAmount: buyAmount, + ); + + final scrollController = ScrollController(); + return Container( + padding: EdgeInsets.only(top: isMobile ? 18.0 : 30), + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: DexScrollbar( + scrollController: scrollController, + isMobile: isMobile, + child: SingleChildScrollView( + controller: scrollController, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const _ConfirmTitle(), + const SizedBox(height: 10), + _ReceiveGroup(confirmDto), + _FiatReceive(confirmDto), + const SizedBox(height: 23), + _SendGroup(confirmDto), + const SizedBox(height: 24), + const BridgeExchangeRate(), + const SizedBox(height: 10), + const BridgeTotalFees(), + const SizedBox(height: 24), + const _ErrorGroup(), + _ButtonsRow(onCancel, startSwap), + ], + ), + ), + ), + ); + }, + ); + } + + Future startSwap() async { + context.read().add(const BridgeStartSwap()); + } + + void onCancel() { + context.read().add(const BridgeBackClick()); + } +} + +class _ConfirmDTO { + _ConfirmDTO( + {required this.sellCoin, + required this.buyCoin, + this.sellAmount, + this.buyAmount}); + + final Coin sellCoin; + final Coin buyCoin; + final Rational? sellAmount; + final Rational? buyAmount; +} + +class _ProgressIndicator extends StatelessWidget { + const _ProgressIndicator(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: UiSpinner( + color: Theme.of(context).textTheme.bodyMedium?.color, + width: 10, + height: 10, + strokeWidth: 1, + ), + ); + } +} + +class _ReceiveGroup extends StatelessWidget { + const _ReceiveGroup(this.dto); + + final _ConfirmDTO dto; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SelectableText( + LocaleKeys.swapConfirmationYouReceive.tr(), + 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), + children: [ + TextSpan( + text: '${formatDexAmt(dto.buyAmount)} ', + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + )), + TextSpan(text: dto.buyCoin.abbr), + ], + ), + ), + ], + ); + } +} + +class _FiatReceive extends StatelessWidget { + const _FiatReceive(this.dto); + + final _ConfirmDTO dto; + + @override + Widget build(BuildContext context) { + if (dto.sellAmount == null || dto.buyAmount == null) { + return const SizedBox(); + } + + double? percentage; + + final double sellAmtFiat = getFiatAmount(dto.sellCoin, dto.sellAmount!); + final double receiveAmtFiat = getFiatAmount(dto.buyCoin, dto.buyAmount!); + + if (sellAmtFiat > 0 && receiveAmtFiat > 0) { + percentage = (receiveAmtFiat - sellAmtFiat) * 100 / sellAmtFiat; + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + FiatAmount(coin: dto.buyCoin, amount: dto.buyAmount!), + _Percentage(percentage: percentage), + ], + ); + } +} + +class _Percentage extends StatelessWidget { + final double? percentage; + const _Percentage({this.percentage}); + + @override + Widget build(BuildContext context) { + final percentage = this.percentage; + if (percentage == null) { + return const SizedBox(); + } 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, + ); + return Text(text, style: style); + } + } +} + +class _SendGroup extends StatelessWidget { + const _SendGroup(this.dto); + + final _ConfirmDTO dto; + + @override + Widget build(BuildContext context) { + 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, + ); + final Coin? coin = coinsBloc.getCoin(dto.sellCoin.abbr); + if (coin == null) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: theme.custom.subCardBackgroundColor, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText(LocaleKeys.swapConfirmationYouSending.tr(), + style: style1), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + CoinItem(coin: coin, size: CoinItemSize.large), + const SizedBox(width: 8), + ], + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText( + formatDexAmt(dto.sellAmount), + style: style3, + ), + _FiatSend(dto), + ], + ), + ], + ), + ], + ), + ); + } +} + +class _ConfirmTitle extends StatelessWidget { + const _ConfirmTitle(); + + @override + Widget build(BuildContext context) { + return SelectableText( + LocaleKeys.swapConfirmationTitle.tr(), + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontSize: 16), + ); + } +} + +class _ErrorGroup extends StatelessWidget { + const _ErrorGroup(); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.error, + builder: (context, error) { + if (error == null) return const SizedBox(); + + 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), + ); + }, + ); + } +} + +class _ButtonsRow extends StatelessWidget { + const _ButtonsRow(this.onCancel, this.startSwap); + + final void Function()? onCancel; + final void Function()? startSwap; + + @override + Widget build(BuildContext context) { + return Flexible( + child: Row( + children: [ + _BackButton(onCancel), + const SizedBox(width: 23), + _ConfirmButton(startSwap), + ], + ), + ); + } +} + +class _BackButton extends StatelessWidget { + const _BackButton(this.onPressed); + + final void Function()? onPressed; + + @override + 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(), + ), + ); + }), + ); + } +} + +class _ConfirmButton extends StatelessWidget { + const _ConfirmButton(this.onPressed); + + final void Function()? onPressed; + + @override + Widget build(BuildContext context) { + 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: LocaleKeys.confirm.tr(), + onPressed: inProgress ? null : onPressed, + ), + ); + }), + ); + } +} + +class _FiatSend extends StatelessWidget { + const _FiatSend(this.dto); + + final _ConfirmDTO dto; + + @override + Widget build(BuildContext context) { + if (dto.sellAmount == null) return const SizedBox(); + return Container( + padding: const EdgeInsets.fromLTRB(0, 0, 2, 0), + child: FiatAmount(coin: dto.sellCoin, amount: dto.sellAmount!), + ); + } +} diff --git a/lib/views/bridge/bridge_exchange_form.dart b/lib/views/bridge/bridge_exchange_form.dart new file mode 100644 index 0000000000..2739b12f44 --- /dev/null +++ b/lib/views/bridge/bridge_exchange_form.dart @@ -0,0 +1,178 @@ +import 'dart:async'; + +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:web_dex/app_config/app_config.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/system_health/system_health_bloc.dart'; +import 'package:web_dex/bloc/system_health/system_health_state.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_wrapper.dart'; +import 'package:web_dex/views/bridge/bridge_group.dart'; +import 'package:web_dex/views/bridge/bridge_source_protocol_header.dart'; +import 'package:web_dex/views/bridge/bridge_target_protocol_header.dart'; +import 'package:web_dex/views/bridge/bridge_ticker_selector.dart'; +import 'package:web_dex/views/bridge/bridge_total_fees.dart'; +import 'package:web_dex/views/bridge/view/bridge_exchange_rate.dart'; +import 'package:web_dex/views/bridge/view/bridge_source_amount_group.dart'; +import 'package:web_dex/views/bridge/view/bridge_source_protocol_row.dart'; +import 'package:web_dex/views/bridge/view/bridge_target_amount_row.dart'; +import 'package:web_dex/views/bridge/view/bridge_target_protocol_row.dart'; +import 'package:web_dex/views/bridge/view/error_list/bridge_form_error_list.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class BridgeExchangeForm extends StatefulWidget { + const BridgeExchangeForm({Key? key}) : super(key: key); + + @override + State createState() => _BridgeExchangeFormState(); +} + +class _BridgeExchangeFormState extends State { + StreamSubscription? _coinsListener; + + @override + void initState() { + final bridgeBloc = context.read(); + bridgeBloc.add(const BridgeInit(ticker: defaultDexCoin)); + bridgeBloc.add(BridgeSetWalletIsReady(coinsBloc.loginActivationFinished)); + _coinsListener = coinsBloc.outLoginActivationFinished.listen((value) { + bridgeBloc.add(BridgeSetWalletIsReady(value)); + }); + + super.initState(); + } + + @override + void dispose() { + _coinsListener?.cancel(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return const Column( + mainAxisSize: MainAxisSize.max, + children: [ + BridgeTickerSelector(), + SizedBox(height: 30), + BridgeGroup( + header: SourceProtocolHeader(), + child: SourceProtocol(), + ), + SizedBox(height: 19), + BridgeGroup( + header: TargetProtocolHeader(), + child: TargetProtocol(), + ), + SizedBox(height: 12), + BridgeFormErrorList(), + SizedBox(height: 12), + BridgeExchangeRate(), + SizedBox(height: 12), + BridgeTotalFees(), + SizedBox(height: 24), + _ExchangeButton(), + SizedBox(height: 12), + ], + ); + } +} + +class SourceProtocol extends StatelessWidget { + const SourceProtocol({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: BridgeSourceProtocolRow()), + Flexible(child: BridgeSourceAmountGroup()), + ], + ); + } +} + +class TargetProtocol extends StatelessWidget { + const TargetProtocol({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible(child: BridgeTargetProtocolRow()), + Flexible(child: BridgeTargetAmountRow()), + ], + ); + } +} + +class _ExchangeButton extends StatelessWidget { + const _ExchangeButton(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, systemHealthState) { + // Determine if system clock is valid + final isSystemClockValid = systemHealthState is SystemHealthLoadSuccess && + systemHealthState.isValid; + + return BlocSelector( + selector: (state) => state.inProgress, + builder: (context, inProgress) { + final isDisabled = inProgress || !isSystemClockValid; + return SizedBox( + width: theme.custom.dexFormWidth, + child: ConnectWalletWrapper( + eventType: WalletsManagerEventType.bridge, + child: Opacity( + opacity: isDisabled ? 0.8 : 1, + child: SizedBox( + width: theme.custom.dexFormWidth, + child: UiPrimaryButton( + height: 40, + prefix: inProgress ? const _Spinner() : null, + text: LocaleKeys.exchange.tr(), + onPressed: isDisabled ? null : () => _onPressed(context), + ), + ), + ), + ), + ); + }); + }); + } + + void _onPressed(BuildContext context) { + context.read().add(const BridgeSubmitClick()); + } +} + +class _Spinner extends StatelessWidget { + const _Spinner(); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 4), + child: UiSpinner( + width: 10, + height: 10, + strokeWidth: 1, + color: theme.custom.defaultGradientButtonTextColor, + ), + ); + } +} diff --git a/lib/views/bridge/bridge_form.dart b/lib/views/bridge/bridge_form.dart new file mode 100644 index 0000000000..268276c7f3 --- /dev/null +++ b/lib/views/bridge/bridge_form.dart @@ -0,0 +1,156 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/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/common/screen.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/hidden_without_wallet.dart'; +import 'package:web_dex/views/bridge/bridge_confirmation.dart'; +import 'package:web_dex/views/bridge/bridge_exchange_form.dart'; +import 'package:web_dex/views/bridge/view/bridge_header.dart'; +import 'package:web_dex/views/bridge/view/table/bridge_source_protocols_table.dart'; +import 'package:web_dex/views/bridge/view/table/bridge_target_protocols_table.dart'; +import 'package:web_dex/views/bridge/view/table/bridge_tickers_list.dart'; + +class BridgeForm extends StatelessWidget { + const BridgeForm({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: SingleChildScrollView( + key: const Key('bridge-form-scroll'), + controller: scrollController, + child: BlocSelector( + selector: (state) => state.step, + builder: (context, step) { + switch (step) { + case BridgeStep.confirm: + return const BridgeConfirmation(); + case BridgeStep.form: + return ConstrainedBox( + constraints: + BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + HiddenWithoutWallet(child: SizedBox(height: 20)), + SizedBox(height: 25), + BridgeHeader(), + SizedBox(height: 20), + Flexible(child: _BridgeFormContent()), + ], + ), + ); + } + }, + ), + ), + ); + } +} + +class _BridgeFormContent extends StatelessWidget { + const _BridgeFormContent({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Stack( + clipBehavior: Clip.none, + alignment: Alignment.topCenter, + children: [ + BridgeExchangeForm(), + _TickerDropdown(), + _SourceDropdown(), + _TargetDropdown(), + ], + ); + } +} + +class _TickerDropdown extends StatelessWidget { + const _TickerDropdown({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.showTickerDropdown, + builder: (context, showTickerDropdown) { + if (!showTickerDropdown) return const SizedBox.shrink(); + + return BridgeTickersList( + onSelect: (Coin coin) { + context + .read() + .add(BridgeTickerChanged(abbr2Ticker(coin.abbr))); + }, + ); + }, + ); + } +} + +class _SourceDropdown extends StatelessWidget { + const _SourceDropdown({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.showSourceDropdown, + builder: (context, showSourceDropdown) { + if (!showSourceDropdown) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 98), + child: BridgeSourceProtocolsTable( + onSelect: (Coin coin) => + context.read().add(BridgeSetSellCoin(coin)), + onClose: () { + context + .read() + .add(const BridgeShowSourceDropdown(false)); + }, + ), + ); + }, + ); + } +} + +class _TargetDropdown extends StatelessWidget { + const _TargetDropdown({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.showTargetDropdown, + builder: (context, showTargetDropdown) { + if (!showTargetDropdown) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.only(top: 196), + child: BridgeTargetProtocolsTable( + onSelect: (BestOrder order) { + context.read().add(BridgeSelectBestOrder(order)); + }, + onClose: () => context + .read() + .add(const BridgeShowTargetDropdown(false)), + ), + ); + }, + ); + } +} diff --git a/lib/views/bridge/bridge_group.dart b/lib/views/bridge/bridge_group.dart new file mode 100644 index 0000000000..18979c40b4 --- /dev/null +++ b/lib/views/bridge/bridge_group.dart @@ -0,0 +1,33 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class BridgeGroup extends StatelessWidget { + const BridgeGroup({ + this.header, + required this.child, + }); + + final Widget? header; + final Widget child; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (header != null) header!, + Flexible( + child: Container( + padding: const EdgeInsets.fromLTRB(11, 10, 6, 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: theme.custom.dexPageTheme.frontPlate, + border: Border.all( + width: 1, color: theme.currentGlobal.dividerColor)), + child: child, + ), + ), + ], + ); + } +} diff --git a/lib/views/bridge/bridge_page.dart b/lib/views/bridge/bridge_page.dart new file mode 100644 index 0000000000..5726152e2f --- /dev/null +++ b/lib/views/bridge/bridge_page.dart @@ -0,0 +1,159 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc_state.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/router/state/bridge_section_state.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/shared/ui/clock_warning_banner.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/hidden_without_wallet.dart'; +import 'package:web_dex/views/bridge/bridge_form.dart'; +import 'package:web_dex/views/bridge/bridge_tab_bar.dart'; +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'; + +class BridgePage extends StatefulWidget { + const BridgePage() : super(key: const Key('bridge-page')); + + @override + State createState() => _BridgePageState(); +} + +class _BridgePageState extends State with TickerProviderStateMixin { + int _activeTabIndex = 0; + bool _showSwap = false; + + @override + void initState() { + routingState.bridgeState.addListener(_onRouteChange); + super.initState(); + } + + @override + void dispose() { + routingState.bridgeState.removeListener(_onRouteChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state.mode == AuthorizeMode.noLogin) { + setState(() { + _activeTabIndex = 0; + }); + } + }, + child: _showSwap ? _buildTradingDetails() : _buildBridgePage(), + ); + } + + Widget _buildTradingDetails() { + return TradingDetails( + uuid: routingState.bridgeState.uuid, + ); + } + + Widget _buildBridgePage() { + return PageLayout( + content: Expanded( + child: Container( + margin: isMobile ? const EdgeInsets.only(top: 14) : null, + padding: const EdgeInsets.fromLTRB(16, 22, 16, 20), + decoration: BoxDecoration( + color: _backgroundColor(context), + borderRadius: BorderRadius.circular(18.0), + ), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + ConstrainedBox( + constraints: + BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: HiddenWithoutWallet( + child: BridgeTabBar( + currentTabIndex: _activeTabIndex, + onTabClick: _setActiveTab, + ), + ), + ), + const Padding( + padding: EdgeInsets.only(top: 12.0), + child: ClockWarningBanner(), + ), + Flexible( + child: _TabContent( + activeTabIndex: _activeTabIndex, + ), + ), + ], + ), + ), + ), + ); + } + + void _setActiveTab(int i) { + setState(() { + _activeTabIndex = i; + }); + } + + Color? _backgroundColor(BuildContext context) { + if (isMobile) { + final ThemeMode mode = theme.mode; + return mode == ThemeMode.dark ? null : Theme.of(context).cardColor; + } + return null; + } + + void _onRouteChange() { + setState(() { + _showSwap = + routingState.bridgeState.action == BridgeAction.tradingDetails; + }); + } +} + +class _TabContent extends StatelessWidget { + final int _activeTabIndex; + const _TabContent({required int activeTabIndex}) + : _activeTabIndex = activeTabIndex; + + @override + Widget build(BuildContext context) { + final List tabContents = [ + const BridgeForm(), + Padding( + padding: const EdgeInsets.only(top: 20), + child: InProgressList( + filter: _bridgeSwapsFilter, onItemClick: _onSwapItemClick), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: HistoryList( + filter: _bridgeSwapsFilter, + onItemClick: _onSwapItemClick, + ), + ), + ]; + + return tabContents[_activeTabIndex]; + } + + void _onSwapItemClick(Swap swap) { + routingState.bridgeState.setDetailsAction(swap.uuid); + } + + bool _bridgeSwapsFilter(Swap swap) { + return abbr2Ticker(swap.sellCoin) == abbr2Ticker(swap.buyCoin); + } +} diff --git a/lib/views/bridge/bridge_protocol_label.dart b/lib/views/bridge/bridge_protocol_label.dart new file mode 100644 index 0000000000..865e798ad0 --- /dev/null +++ b/lib/views/bridge/bridge_protocol_label.dart @@ -0,0 +1,75 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +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/model/coin_utils.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_logo.dart'; + +class BridgeProtocolLabel extends StatelessWidget { + const BridgeProtocolLabel(this.coin); + + final Coin coin; + + @override + Widget build(BuildContext context) { + final Color backgroundColor = coin.type == CoinType.utxo + ? Theme.of(context).cardColor + : getProtocolColor(coin.type); + final Color borderColor = coin.type == CoinType.utxo + ? getProtocolColor(coin.type) + : coin.type == CoinType.smartChain + ? theme.custom.smartchainLabelBorderColor + : backgroundColor; + + return Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(20), + border: Border.all( + width: 1, + color: borderColor, + ), + ), + padding: const EdgeInsets.fromLTRB(3, 3, 10, 3), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildIcon(), + const SizedBox(width: 6), + _buildText(backgroundColor), + ], + ), + ); + } + + Widget _buildIcon() { + return ClipRRect( + borderRadius: BorderRadius.circular(8), + child: SizedBox( + width: 16, + child: Image.asset( + '$assetsPath/coin_icons/png/${getProtocolIcon(coin)}.png'), + ), + ); + } + + Widget _buildText(Color protocolColor) { + return Text( + coin.type == CoinType.utxo + ? coin.abbr + : getCoinTypeName(coin.type).toUpperCase(), + style: TextStyle( + color: ThemeData.estimateBrightnessForColor(protocolColor) == + Brightness.dark + ? Colors.white + : Colors.black, + fontSize: 12, + fontWeight: FontWeight.w500, + height: 1.2, + ), + ); + } +} diff --git a/lib/views/bridge/bridge_source_protocol_header.dart b/lib/views/bridge/bridge_source_protocol_header.dart new file mode 100644 index 0000000000..23521d7506 --- /dev/null +++ b/lib/views/bridge/bridge_source_protocol_header.dart @@ -0,0 +1,70 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/bridge/bridge_available_balance.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_form_group_header.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_small_button.dart'; + +class SourceProtocolHeader extends StatelessWidget { + const SourceProtocolHeader({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return DexFormGroupHeader( + title: LocaleKeys.from.tr().toUpperCase(), + actions: const [ + BridgeAvailableBalance(), + SizedBox(width: 12), + _HalfMaxButtons() + ], + ); + } +} + +class _HalfMaxButtons extends StatelessWidget { + const _HalfMaxButtons({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, cur) { + return prev.sellCoin != cur.sellCoin || + prev.maxSellAmount != cur.maxSellAmount; + }, + builder: (context, state) { + final bool showMaxButton = + state.sellCoin != null && state.maxSellAmount != null; + + return !showMaxButton + ? const SizedBox.shrink() + : Row( + children: [ + _HalfButton(), + const SizedBox(width: 10), + _MaxButton(), + ], + ); + }, + ); + } +} + +class _MaxButton extends DexSmallButton { + _MaxButton() + : super(LocaleKeys.max.tr().toLowerCase(), (context) { + final BridgeBloc bridgeBloc = BlocProvider.of(context); + bridgeBloc.add(BridgeAmountButtonClick(1)); + }); +} + +class _HalfButton extends DexSmallButton { + _HalfButton() + : super(LocaleKeys.half.tr().toLowerCase(), (context) { + final BridgeBloc bridgeBloc = BlocProvider.of(context); + bridgeBloc.add(BridgeAmountButtonClick(0.5)); + }); +} diff --git a/lib/views/bridge/bridge_source_protocol_selector_tile.dart b/lib/views/bridge/bridge_source_protocol_selector_tile.dart new file mode 100644 index 0000000000..9e81def460 --- /dev/null +++ b/lib/views/bridge/bridge_source_protocol_selector_tile.dart @@ -0,0 +1,85 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/bridge/bridge_protocol_label.dart'; +import 'package:web_dex/views/bridge/pick_item.dart'; + +class BridgeSourceProtocolSelectorTile extends StatefulWidget { + const BridgeSourceProtocolSelectorTile( + {Key? key, this.coin, required this.title, required this.onTap}) + : super(key: key); + + final Coin? coin; + final String title; + final Function() onTap; + + @override + State createState() => + _BridgeSourceProtocolSelectorTileState(); +} + +class _BridgeSourceProtocolSelectorTileState + extends State { + @override + Widget build(BuildContext context) { + final Coin? coin = widget.coin; + + return BlocSelector( + selector: (state) => state.showSourceDropdown, + builder: (context, expanded) { + return SizedBox( + height: 24, + child: coin == null + ? PickItem( + title: widget.title, + onTap: widget.onTap, + expanded: expanded, + ) + : _SelectedProtocolTile( + coin: coin, + onTap: widget.onTap, + expanded: expanded, + ), + ); + }); + } +} + +class _SelectedProtocolTile extends StatelessWidget { + const _SelectedProtocolTile({ + Key? key, + required this.coin, + required this.expanded, + required this.onTap, + }) : super(key: key); + + final Coin coin; + final bool expanded; + final Function() onTap; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(20), + hoverColor: theme.custom.noColor, + onTap: onTap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BridgeProtocolLabel(coin), + const SizedBox(width: 6), + Icon( + expanded ? Icons.expand_less : Icons.expand_more, + color: Theme.of(context).textTheme.bodyLarge?.color, + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/bridge/bridge_tab_bar.dart b/lib/views/bridge/bridge_tab_bar.dart new file mode 100644 index 0000000000..1d02c553a0 --- /dev/null +++ b/lib/views/bridge/bridge_tab_bar.dart @@ -0,0 +1,82 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab_bar.dart'; + +class BridgeTabBar extends StatefulWidget { + const BridgeTabBar( + {Key? key, required this.currentTabIndex, required this.onTabClick}) + : super(key: key); + final int currentTabIndex; + final Function(int) onTabClick; + + @override + State createState() => _BridgeTabBarState(); +} + +class _BridgeTabBarState extends State { + int? _inProgressCount; + int? _completedCount; + final List _listeners = []; + + @override + void initState() { + _onDataChange(null); + + _listeners.add(tradingEntitiesBloc.outMyOrders.listen(_onDataChange)); + _listeners.add(tradingEntitiesBloc.outSwaps.listen(_onDataChange)); + + super.initState(); + } + + @override + void dispose() { + _listeners.map((listener) => listener.cancel()); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return UiTabBar( + currentTabIndex: widget.currentTabIndex, + tabs: [ + UiTab( + key: const Key('bridge-exchange-tab'), + text: LocaleKeys.bridgeExchange.tr(), + isSelected: widget.currentTabIndex == 0, + onClick: () => widget.onTabClick(0), + ), + UiTab( + key: const Key('bridge-in-progress-tab'), + text: '${LocaleKeys.inProgress.tr()} ($_inProgressCount)', + isSelected: widget.currentTabIndex == 1, + onClick: () => widget.onTabClick(1), + ), + UiTab( + key: const Key('bridge-history-tab'), + text: '${LocaleKeys.history.tr()} ($_completedCount)', + isSelected: widget.currentTabIndex == 2, + onClick: () => widget.onTabClick(2), + ), + ], + ); + } + + void _onDataChange(dynamic _) { + if (!mounted) return; + + setState(() { + _inProgressCount = tradingEntitiesBloc.swaps + .where((swap) => !swap.isCompleted && swap.isTheSameTicker) + .length; + _completedCount = tradingEntitiesBloc.swaps + .where((swap) => swap.isCompleted && swap.isTheSameTicker) + .length; + }); + } +} diff --git a/lib/views/bridge/bridge_target_protocol_header.dart b/lib/views/bridge/bridge_target_protocol_header.dart new file mode 100644 index 0000000000..0f40ef244d --- /dev/null +++ b/lib/views/bridge/bridge_target_protocol_header.dart @@ -0,0 +1,15 @@ +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/views/dex/simple/form/common/dex_form_group_header.dart'; + +class TargetProtocolHeader extends StatelessWidget { + const TargetProtocolHeader({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return DexFormGroupHeader( + title: LocaleKeys.to.tr().toUpperCase(), + ); + } +} diff --git a/lib/views/bridge/bridge_target_protocol_selector_tile.dart b/lib/views/bridge/bridge_target_protocol_selector_tile.dart new file mode 100644 index 0000000000..ccfff94ac1 --- /dev/null +++ b/lib/views/bridge/bridge_target_protocol_selector_tile.dart @@ -0,0 +1,92 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/bridge/bridge_protocol_label.dart'; +import 'package:web_dex/views/bridge/pick_item.dart'; + +class BridgeTargetProtocolSelectorTile extends StatefulWidget { + const BridgeTargetProtocolSelectorTile({ + Key? key, + this.coin, + this.bestOrder, + this.disabled = false, + required this.title, + required this.onTap, + }) : super(key: key); + + final Coin? coin; + final BestOrder? bestOrder; + final bool disabled; + final String title; + final Function() onTap; + + @override + State createState() => + _BridgeTargetProtocolSelectorTileState(); +} + +class _BridgeTargetProtocolSelectorTileState + extends State { + bool get noSelected => widget.coin == null && widget.bestOrder == null; + + Coin? get coin { + final widgetCoin = widget.coin; + if (widgetCoin != null) return widgetCoin; + final bestOrder = widget.bestOrder; + if (bestOrder != null) return coinsBloc.getCoin(bestOrder.coin); + return null; + } + + @override + Widget build(BuildContext context) { + return noSelected + ? PickItem( + title: widget.title, + onTap: widget.disabled ? null : widget.onTap, + expanded: context.read().state.showTargetDropdown, + ) + : _SelectedProtocolTile( + coin: coin!, + onTap: widget.onTap, + ); + } +} + +class _SelectedProtocolTile extends StatelessWidget { + const _SelectedProtocolTile({ + Key? key, + required this.coin, + required this.onTap, + }) : super(key: key); + + final Coin coin; + final Function() onTap; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(18), + hoverColor: theme.custom.noColor, + onTap: onTap, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BridgeProtocolLabel(coin), + const SizedBox(width: 6), + Icon( + context.read().state.showTargetDropdown + ? Icons.expand_less + : Icons.expand_more, + color: Theme.of(context).textTheme.bodyLarge?.color), + ], + ), + ), + ); + } +} diff --git a/lib/views/bridge/bridge_ticker_selector.dart b/lib/views/bridge/bridge_ticker_selector.dart new file mode 100644 index 0000000000..30f0e42840 --- /dev/null +++ b/lib/views/bridge/bridge_ticker_selector.dart @@ -0,0 +1,126 @@ +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: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/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/utils/extensions/string_extensions.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/shared/widgets/coin_icon.dart'; +import 'package:web_dex/views/bridge/pick_item.dart'; + +const double bridgeTickerSelectWidthCollapsed = 162; +const double bridgeTickerSelectWidthExpanded = 300; + +class BridgeTickerSelector extends StatelessWidget { + const BridgeTickerSelector({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, cur) { + return prev.showTickerDropdown != cur.showTickerDropdown || + prev.selectedTicker != cur.selectedTicker; + }, + builder: (context, state) { + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all( + width: 1, + color: state.showTickerDropdown + ? theme.custom.noColor + : theme.currentGlobal.colorScheme.primary, + ), + ), + child: SizedBox( + height: 42, + width: state.showTickerDropdown + ? bridgeTickerSelectWidthExpanded + : bridgeTickerSelectWidthCollapsed, + child: _SelectedTickerTile( + title: LocaleKeys.token.tr().toCapitalize(), + ticker: state.selectedTicker, + onTap: () => _toggleTickerDropdown(context), + expanded: state.showTickerDropdown, + ), + ), + ); + }, + ); + } + + void _toggleTickerDropdown(BuildContext context) { + final BridgeBloc bridgeBloc = context.read(); + final bridgeState = bridgeBloc.state; + + bridgeBloc.add(BridgeShowTickerDropdown(!bridgeState.showTickerDropdown)); + } +} + +class _SelectedTickerTile extends StatelessWidget { + const _SelectedTickerTile({ + Key? key, + required this.ticker, + required this.onTap, + required this.title, + required this.expanded, + }) : super(key: key); + + final String? ticker; + final String title; + final Function() onTap; + final bool expanded; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + + return ticker == null + ? PickItem( + title: title, + onTap: onTap, + ) + : Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(18), + hoverColor: theme.custom.noColor, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.fromLTRB(7, 7, 5, 7), + child: Row( + children: [ + Container( + height: 30, + width: 30, + alignment: const Alignment(0, 0), + decoration: BoxDecoration( + color: themeData.cardColor, + borderRadius: BorderRadius.circular(15)), + child: CoinIcon( + ticker!, + size: 26, + ), + ), + const SizedBox( + width: 4, + ), + Expanded( + child: AutoScrollText( + text: ticker!, + style: themeData.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, fontSize: 14), + ), + ), + Icon(expanded ? Icons.expand_less : Icons.expand_more, + color: Theme.of(context).textTheme.bodyMedium?.color) + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/bridge/bridge_tickers_list_item.dart b/lib/views/bridge/bridge_tickers_list_item.dart new file mode 100644 index 0000000000..ea2cbd359c --- /dev/null +++ b/lib/views/bridge/bridge_tickers_list_item.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/shared/widgets/coin_icon.dart'; + +class BridgeTickersListItem extends StatelessWidget { + const BridgeTickersListItem({ + Key? key, + required this.coin, + required this.onSelect, + }) : super(key: key); + + final Coin coin; + final Function onSelect; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).cardColor, + child: InkWell( + key: Key('bridge-coin-table-item-${coin.abbr}'), + onTap: () => onSelect(), + child: Container( + padding: const EdgeInsets.all(10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Row( + children: [ + Container( + height: 30, + width: 30, + alignment: const Alignment(0, 0), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(15)), + child: CoinIcon( + coin.abbr, + size: 26, + ), + ), + const SizedBox( + width: 4, + ), + Expanded( + child: AutoScrollText( + text: coin.name, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, fontSize: 14), + ), + ) + ], + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/bridge/bridge_total_fees.dart b/lib/views/bridge/bridge_total_fees.dart new file mode 100644 index 0000000000..a0dd432acd --- /dev/null +++ b/lib/views/bridge/bridge_total_fees.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/views/dex/simple/form/exchange_info/total_fees.dart'; + +class BridgeTotalFees extends StatelessWidget { + const BridgeTotalFees({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.preimageData?.data, + builder: (context, tradePreimage) { + return TotalFees(preimage: tradePreimage); + }, + ); + } +} diff --git a/lib/views/bridge/pick_item.dart b/lib/views/bridge/pick_item.dart new file mode 100644 index 0000000000..e0d34bb41a --- /dev/null +++ b/lib/views/bridge/pick_item.dart @@ -0,0 +1,42 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; + +class PickItem extends StatelessWidget { + const PickItem({ + Key? key, + required this.title, + this.onTap, + this.expanded = false, + }) : super(key: key); + final String title; + final Function()? onTap; + final bool expanded; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(20), + hoverColor: theme.custom.noColor, + onTap: onTap, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 0, 0), + child: AutoScrollText(text: title), + ), + ), + const SizedBox(width: 6), + Icon(expanded ? Icons.expand_less : Icons.expand_more, + color: Theme.of(context).textTheme.bodyLarge?.color) + ], + ), + ), + ); + } +} diff --git a/lib/views/bridge/view/bridge_exchange_rate.dart b/lib/views/bridge/view/bridge_exchange_rate.dart new file mode 100644 index 0000000000..615f2119f8 --- /dev/null +++ b/lib/views/bridge/view/bridge_exchange_rate.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/views/dex/simple/form/exchange_info/exchange_rate.dart'; + +class BridgeExchangeRate extends StatelessWidget { + const BridgeExchangeRate({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.bestOrder, + builder: (context, selectedOrder) { + final String? base = context.read().state.sellCoin?.abbr; + final String? rel = selectedOrder?.coin; + final Rational? rate = selectedOrder?.price; + + return ExchangeRate( + rate: rate, + base: base, + rel: rel, + showDetails: false, + ); + }, + ); + } +} diff --git a/lib/views/bridge/view/bridge_header.dart b/lib/views/bridge/view/bridge_header.dart new file mode 100644 index 0000000000..35919da64b --- /dev/null +++ b/lib/views/bridge/view/bridge_header.dart @@ -0,0 +1,24 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class BridgeHeader extends StatelessWidget { + const BridgeHeader({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + LocaleKeys.selectToken.tr().toUpperCase(), + style: theme.custom.bridgeFormHeader, + ), + ], + ), + ); + } +} diff --git a/lib/views/bridge/view/bridge_source_amount_group.dart b/lib/views/bridge/view/bridge_source_amount_group.dart new file mode 100644 index 0000000000..6f9e90706a --- /dev/null +++ b/lib/views/bridge/view/bridge_source_amount_group.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rational/rational.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/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/views/dex/common/trading_amount_field.dart'; +import 'package:web_dex/views/dex/simple/form/dex_fiat_amount.dart'; + +class BridgeSourceAmountGroup extends StatelessWidget { + const BridgeSourceAmountGroup({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.sellCoin, + builder: (context, sellCoin) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _AmountField(sellCoin), + _FiatAmount(sellCoin), + ], + ); + }, + ); + } +} + +class _FiatAmount extends StatelessWidget { + const _FiatAmount(this.coin, {Key? key}) : super(key: key); + + final Coin? coin; + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.sellAmount, + builder: (context, sellAmount) { + return DexFiatAmount( + coin: coin, + amount: sellAmount, + padding: const EdgeInsets.fromLTRB(0, 0, 14, 0), + ); + }, + ); + } +} + +class _AmountField extends StatefulWidget { + const _AmountField(this.coin); + + final Coin? coin; + + @override + State<_AmountField> createState() => _AmountFieldState(); +} + +class _AmountFieldState extends State<_AmountField> { + final TextEditingController _controller = TextEditingController(); + + @override + void initState() { + final Rational? sellAmount = context.read().state.sellAmount; + formatAmountInput(_controller, sellAmount); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (prev, cur) => prev.sellAmount != cur.sellAmount, + listener: (context, state) => + formatAmountInput(_controller, state.sellAmount), + buildWhen: (prev, cur) => prev.sellCoin != cur.sellCoin, + builder: (context, state) { + final bool isEnabled = state.sellCoin != null; + + return GestureDetector( + onTap: !isEnabled + ? () => context + .read() + .add(const BridgeShowSourceDropdown(true)) + : null, + child: TradingAmountField( + controller: _controller, + enabled: isEnabled, + height: 18, + contentPadding: const EdgeInsets.only(right: 12), + onChanged: (String value) { + final bloc = context.read(); + + bloc.add(BridgeSellAmountChange(value)); + if (value.isEmpty) { + bloc.add(const BridgeShowTargetDropdown(false)); + } + }, + ), + ); + }, + ); + } +} diff --git a/lib/views/bridge/view/bridge_source_protocol_row.dart b/lib/views/bridge/view/bridge_source_protocol_row.dart new file mode 100644 index 0000000000..bdfb720473 --- /dev/null +++ b/lib/views/bridge/view/bridge_source_protocol_row.dart @@ -0,0 +1,41 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/views/bridge/bridge_source_protocol_selector_tile.dart'; + +class BridgeSourceProtocolRow extends StatelessWidget { + const BridgeSourceProtocolRow({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, cur) { + return prev.sellCoin != cur.sellCoin || + prev.selectedTicker != cur.selectedTicker; + }, + builder: (context, state) { + return BridgeSourceProtocolSelectorTile( + coin: state.sellCoin, + title: LocaleKeys.selectProtocol.tr(), + onTap: () { + if (state.selectedTicker == null) { + context.read().add(BridgeSetError(DexFormError( + error: LocaleKeys.bridgeSelectTokenFirstError.tr(), + ))); + return; + } + + context + .read() + .add(BridgeShowSourceDropdown(!state.showSourceDropdown)); + }, + ); + }, + ); + } +} diff --git a/lib/views/bridge/view/bridge_target_amount_row.dart b/lib/views/bridge/view/bridge_target_amount_row.dart new file mode 100644 index 0000000000..ef0d50d2a4 --- /dev/null +++ b/lib/views/bridge/view/bridge_target_amount_row.dart @@ -0,0 +1,86 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/views/dex/common/trading_amount_field.dart'; +import 'package:web_dex/views/dex/simple/form/dex_fiat_amount.dart'; + +class BridgeTargetAmountRow extends StatelessWidget { + const BridgeTargetAmountRow({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _TargetAmount(), + _FiatAmount(), + ], + ); + } +} + +class _TargetAmount extends StatefulWidget { + const _TargetAmount({Key? key}) : super(key: key); + + @override + State<_TargetAmount> createState() => _TargetAmountState(); +} + +class _TargetAmountState extends State<_TargetAmount> { + final _controller = TextEditingController(); + + @override + void initState() { + final Rational? buyAmount = context.read().state.buyAmount; + formatAmountInput(_controller, buyAmount); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (prev, cur) => prev.buyAmount != cur.buyAmount, + listener: (context, state) { + formatAmountInput(_controller, state.buyAmount); + }, + child: TradingAmountField( + controller: _controller, + enabled: false, + height: 18, + contentPadding: const EdgeInsets.only(right: 12), + ), + ); + } +} + +class _FiatAmount extends StatelessWidget { + const _FiatAmount({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, cur) { + return prev.bestOrder != cur.bestOrder || + prev.buyAmount != cur.buyAmount; + }, + builder: (context, state) { + final String? abbr = state.bestOrder?.coin; + final Coin? coin = abbr == null ? null : coinsBloc.getCoin(abbr); + + return DexFiatAmount( + coin: coin, + amount: state.buyAmount, + padding: const EdgeInsets.fromLTRB(0, 0, 14, 0), + ); + }, + ); + } +} diff --git a/lib/views/bridge/view/bridge_target_protocol_row.dart b/lib/views/bridge/view/bridge_target_protocol_row.dart new file mode 100644 index 0000000000..b3050fdcef --- /dev/null +++ b/lib/views/bridge/view/bridge_target_protocol_row.dart @@ -0,0 +1,40 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/views/bridge/bridge_target_protocol_selector_tile.dart'; + +class BridgeTargetProtocolRow extends StatelessWidget { + const BridgeTargetProtocolRow({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, cur) { + return prev.bestOrder != cur.bestOrder || prev.sellCoin != cur.sellCoin; + }, + builder: (context, state) { + return BridgeTargetProtocolSelectorTile( + bestOrder: state.bestOrder, + title: LocaleKeys.selectProtocol.tr(), + onTap: () { + if (state.sellCoin == null) { + context.read().add(BridgeSetError(DexFormError( + error: LocaleKeys.bridgeSelectFromProtocolError.tr(), + ))); + return; + } + + final bridgeBloc = context.read(); + bridgeBloc.add( + BridgeShowTargetDropdown(!bridgeBloc.state.showTargetDropdown)); + }, + ); + }, + ); + } +} diff --git a/lib/views/bridge/view/error_list/bridge_form_error_list.dart b/lib/views/bridge/view/error_list/bridge_form_error_list.dart new file mode 100644 index 0000000000..baeabe4a71 --- /dev/null +++ b/lib/views/bridge/view/error_list/bridge_form_error_list.dart @@ -0,0 +1,80 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/views/dex/simple/form/error_list/dex_form_error_simple.dart'; +import 'package:web_dex/views/dex/simple/form/error_list/dex_form_error_with_action.dart'; + +class BridgeFormErrorList extends StatefulWidget { + const BridgeFormErrorList({Key? key}) : super(key: key); + + @override + State createState() => _BridgeFormErrorListState(); +} + +class _BridgeFormErrorListState extends State { + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.error, + builder: (context, error) { + if (error == null) return const SizedBox(); + + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: Container( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 10), + child: Padding( + padding: const EdgeInsets.only(top: 8.0), + child: _errorBuilder(error), + ), + ), + ); + }, + ); + } + + Widget _errorBuilder(DexFormError error) { + switch (error.type) { + case DexFormErrorType.simple: + return DexFormErrorSimple(error: error); + case DexFormErrorType.largerMaxSellVolume: + return _buildLargerMaxSellVolumeError(error); + case DexFormErrorType.largerMaxBuyVolume: + return _buildLargerMaxBuyVolumeError(error); + case DexFormErrorType.lessMinVolume: + return _buildLessMinVolumeError(error); + } + } + + Widget _buildLargerMaxSellVolumeError(DexFormError error) { + assert(error.type == DexFormErrorType.largerMaxSellVolume); + assert(error.action != null); + + return DexFormErrorWithAction( + error: error, + action: error.action!, + ); + } + + Widget _buildLargerMaxBuyVolumeError(DexFormError error) { + assert(error.type == DexFormErrorType.largerMaxBuyVolume); + + return DexFormErrorWithAction( + error: error, + action: error.action!, + ); + } + + Widget _buildLessMinVolumeError(DexFormError error) { + assert(error.type == DexFormErrorType.lessMinVolume); + assert(error.action != null); + + return DexFormErrorWithAction( + error: error, + action: error.action!, + ); + } +} diff --git a/lib/views/bridge/view/table/bridge_nothing_found.dart b/lib/views/bridge/view/table/bridge_nothing_found.dart new file mode 100644 index 0000000000..55b88259df --- /dev/null +++ b/lib/views/bridge/view/table/bridge_nothing_found.dart @@ -0,0 +1,21 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class BridgeNothingFound extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(0, 30, 0, 20), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + LocaleKeys.nothingFound.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ); + } +} diff --git a/lib/views/bridge/view/table/bridge_protocol_table_item.dart b/lib/views/bridge/view/table/bridge_protocol_table_item.dart new file mode 100644 index 0000000000..c838732ace --- /dev/null +++ b/lib/views/bridge/view/table/bridge_protocol_table_item.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/views/bridge/bridge_protocol_label.dart'; + +class BridgeProtocolTableItem extends StatelessWidget { + const BridgeProtocolTableItem({ + Key? key, + required this.coin, + required this.onSelect, + required this.index, + }) : super(key: key); + + final Coin coin; + final Function onSelect; + final int index; + + @override + Widget build(BuildContext context) { + final double? balance = coin.isActive ? coin.balance : null; + + return Material( + type: MaterialType.transparency, + child: InkWell( + key: Key('bridge-protocol-table-item-${coin.abbr}-$index'), + borderRadius: BorderRadius.circular(18), + onTap: () => onSelect(), + child: Container( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + BridgeProtocolLabel(coin), + const Expanded( + child: SizedBox(), + ), + Text( + formatDexAmt(balance), + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(width: 8), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/bridge/view/table/bridge_protocol_table_order_item.dart b/lib/views/bridge/view/table/bridge_protocol_table_order_item.dart new file mode 100644 index 0000000000..54aa7dba77 --- /dev/null +++ b/lib/views/bridge/view/table/bridge_protocol_table_order_item.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.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/views/bridge/bridge_protocol_label.dart'; + +class BridgeProtocolTableOrderItem extends StatelessWidget { + const BridgeProtocolTableOrderItem({ + Key? key, + required this.order, + required this.coin, + required this.onSelect, + required this.index, + }) : super(key: key); + + final BestOrder order; + final Coin coin; + final Function onSelect; + final int index; + + @override + Widget build(BuildContext context) { + final double? balance = coin.isActive ? coin.balance : null; + + log('BridgeProtocolTableOrderItem.build([context]) $balance'); + + return Material( + type: MaterialType.transparency, + child: InkWell( + key: Key('bridge-protocol-table-item-${order.coin}-$index'), + borderRadius: BorderRadius.circular(18), + onTap: () => onSelect(), + child: Container( + padding: const EdgeInsets.all(10), + child: Row( + children: [ + BridgeProtocolLabel(coin), + const Expanded( + child: SizedBox(), + ), + Text( + formatDexAmt(balance), + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(width: 8), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/bridge/view/table/bridge_source_protocols_table.dart b/lib/views/bridge/view/table/bridge_source_protocols_table.dart new file mode 100644 index 0000000000..eebb8d8176 --- /dev/null +++ b/lib/views/bridge/view/table/bridge_source_protocols_table.dart @@ -0,0 +1,119 @@ +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/bridge_form/bridge_bloc.dart'; +import 'package:web_dex/bloc/bridge_form/bridge_state.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/typedef.dart'; +import 'package:web_dex/views/bridge/bridge_exchange_form.dart'; +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'; + +class BridgeSourceProtocolsTable extends StatefulWidget { + const BridgeSourceProtocolsTable({ + required this.onSelect, + required this.onClose, + Key? key, + }) : super(key: key); + + final Function(Coin) onSelect; + final GestureTapCallback onClose; + + @override + State createState() => + _BridgeSourceProtocolsTableState(); +} + +class _BridgeSourceProtocolsTableState + extends State { + @override + Widget build(BuildContext context) { + return BridgeGroup( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SourceProtocol(), + const Divider(), + Flexible( + child: BlocBuilder( + buildWhen: (prev, cur) { + return prev.selectedTicker != cur.selectedTicker || + prev.sellCoins != cur.sellCoins; + }, + builder: (context, state) { + final CoinsByTicker? sellCoins = state.sellCoins; + if (sellCoins == null) return const UiSpinnerList(); + if (sellCoins.isEmpty) return BridgeNothingFound(); + + final ticker = state.selectedTicker; + if (ticker == null) return BridgeNothingFound(); + + final List? coins = sellCoins[ticker]; + if (coins == null || coins.isEmpty) { + return BridgeNothingFound(); + } + + return _SourceProtocolItems( + key: const Key('source-protocols-items'), + coins: coins, + onSelect: widget.onSelect, + ); + }, + ), + ), + ], + ), + ); + } +} + +class _SourceProtocolItems extends StatelessWidget { + const _SourceProtocolItems({ + super.key, + required this.coins, + required this.onSelect, + }); + + final List coins; + final Function(Coin) onSelect; + + @override + Widget build(BuildContext context) { + if (coins.isEmpty) return BridgeNothingFound(); + final scrollController = ScrollController(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const BridgeTableColumnHeads(), + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: ListView.builder( + controller: scrollController, + padding: EdgeInsets.zero, + shrinkWrap: true, + itemCount: coins.length, + itemBuilder: (BuildContext context, int index) { + final Coin coin = coins[index]; + + return BridgeProtocolTableItem( + index: index, + coin: coin, + onSelect: () => onSelect(coin), + ); + }, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/bridge/view/table/bridge_table_column_heads.dart b/lib/views/bridge/view/table/bridge_table_column_heads.dart new file mode 100644 index 0000000000..0e194e8345 --- /dev/null +++ b/lib/views/bridge/view/table/bridge_table_column_heads.dart @@ -0,0 +1,29 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class BridgeTableColumnHeads extends StatelessWidget { + const BridgeTableColumnHeads(); + + @override + Widget build(BuildContext context) { + const style = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ); + + return Container( + padding: const EdgeInsets.fromLTRB(0, 10, 0, 0), + child: Container( + padding: const EdgeInsets.fromLTRB(10, 10, 10, 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(LocaleKeys.protocol.tr(), style: style), + Text(LocaleKeys.balance.tr(), style: style), + ], + ), + ), + ); + } +} diff --git a/lib/views/bridge/view/table/bridge_target_protocols_table.dart b/lib/views/bridge/view/table/bridge_target_protocols_table.dart new file mode 100644 index 0000000000..09024c1351 --- /dev/null +++ b/lib/views/bridge/view/table/bridge_target_protocols_table.dart @@ -0,0 +1,194 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/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/blocs/blocs.dart'; +import 'package:web_dex/common/screen.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'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/bridge/bridge_exchange_form.dart'; +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'; + +class BridgeTargetProtocolsTable extends StatefulWidget { + const BridgeTargetProtocolsTable({ + required this.onSelect, + required this.onClose, + this.multiProtocol = false, + Key? key, + }) : super(key: key); + + final Function(BestOrder) onSelect; + final GestureTapCallback onClose; + final bool multiProtocol; + + @override + State createState() => + _BridgeTargetProtocolsTableState(); +} + +class _BridgeTargetProtocolsTableState + extends State { + @override + void initState() { + _update(silent: true); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BridgeGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + const TargetProtocol(), + const Divider(), + Flexible( + child: 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 _TargetProtocolErrorMessage( + key: const Key('target-protocols-error'), + error: error, + onRetry: () => _update(silent: false), + ); + } + + final Map> orders = bestOrders.result!; + return _TargetProtocolItems( + key: const Key('target-protocols-items'), + bestOrders: orders, + onSelect: widget.onSelect, + ); + }, + ), + ), + ], + ), + ); + } + + void _update({required bool silent}) { + context.read().add(BridgeUpdateBestOrders(silent: silent)); + } +} + +class _TargetProtocolItems extends StatelessWidget { + const _TargetProtocolItems({ + super.key, + required this.bestOrders, + required this.onSelect, + }); + + final Map> bestOrders; + final Function(BestOrder) onSelect; + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + final sellCoin = bloc.state.sellCoin; + if (sellCoin == null) return BridgeNothingFound(); + + final targetsList = bloc.prepareTargetsList(bestOrders); + if (targetsList.isEmpty) return BridgeNothingFound(); + + final scrollController = ScrollController(); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const BridgeTableColumnHeads(), + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 200), + child: DexScrollbar( + scrollController: scrollController, + isMobile: isMobile, + child: ListView.builder( + controller: scrollController, + shrinkWrap: true, + itemBuilder: (BuildContext context, int index) { + final BestOrder order = targetsList[index]; + final Coin coin = coinsBloc.getCoin(order.coin)!; + + return BridgeProtocolTableOrderItem( + index: index, + coin: coin, + order: order, + onSelect: () => onSelect(order), + ); + }, + itemCount: targetsList.length, + ), + ), + ), + ), + ], + ); + } +} + +class _TargetProtocolErrorMessage extends StatelessWidget { + const _TargetProtocolErrorMessage({ + super.key, + required this.error, + required this.onRetry, + }); + + final BaseError error; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 30, 12, 10), + alignment: const Alignment(0, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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, + )), + const SizedBox(height: 4), + UiSimpleButton( + onPressed: onRetry, + child: Text( + 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 new file mode 100644 index 0000000000..449a0eed25 --- /dev/null +++ b/lib/views/bridge/view/table/bridge_tickers_list.dart @@ -0,0 +1,148 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.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/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/typedef.dart'; +import 'package:web_dex/shared/ui/borderless_search_field.dart'; +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'; + +class BridgeTickersList extends StatefulWidget { + const BridgeTickersList({ + required this.onSelect, + Key? key, + }) : super(key: key); + + final Function(Coin) onSelect; + + @override + State createState() => _BridgeTickersListState(); +} + +class _BridgeTickersListState extends State { + String? _searchTerm; + + @override + void initState() { + context.read().add(const BridgeUpdateTickers()); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + width: bridgeTickerSelectWidthExpanded, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.rectangle, + borderRadius: BorderRadius.circular(18), + border: Border.all(width: 1, color: theme.currentGlobal.primaryColor), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.25), + spreadRadius: 0, + blurRadius: 4, + offset: const Offset(0, 4), + ) + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + Column( + children: [ + const BridgeTickerSelector(), + Padding( + padding: const EdgeInsets.fromLTRB(8, 8, 8, 0), + child: BorderLessSearchField( + onChanged: (String value) { + if (_searchTerm == value) return; + + setState(() => _searchTerm = value); + }, + ), + ), + ], + ), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 10), + Flexible(child: _buildItems()), + const SizedBox(height: 10), + UiFlatButton( + text: LocaleKeys.close.tr(), + height: 40, + onPressed: () => context + .read() + .add(const BridgeShowTickerDropdown(false)), + ), + ], + ), + ), + ], + ), + ); + } + + Widget _buildItems() { + return BlocSelector( + selector: (state) => state.tickers, + builder: (context, tickers) { + if (tickers == null) return const UiSpinnerList(); + + final Coins coinsList = + tickers.entries.fold([], (previousValue, element) { + previousValue.add(element.value.first); + return previousValue; + }); + + if (_searchTerm != null && _searchTerm!.isNotEmpty) { + final String searchTerm = _searchTerm!.toLowerCase(); + coinsList.removeWhere((t) { + if (t.abbr.toLowerCase().contains(searchTerm)) return false; + if (t.name.toLowerCase().contains(searchTerm)) return false; + + return true; + }); + } + + if (coinsList.isEmpty) return const NothingFound(); + final scrollController = ScrollController(); + + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 300), + child: DexScrollbar( + scrollController: scrollController, + isMobile: isMobile, + child: ListView.builder( + controller: scrollController, + shrinkWrap: true, + itemBuilder: (BuildContext context, int index) { + final Coin coin = coinsList[index]; + return BridgeTickersListItem( + coin: coin, + onSelect: () => widget.onSelect(coin), + ); + }, + itemCount: coinsList.length, + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/common/header/actions/account_switcher.dart b/lib/views/common/header/actions/account_switcher.dart new file mode 100644 index 0000000000..3275cb528b --- /dev/null +++ b/lib/views/common/header/actions/account_switcher.dart @@ -0,0 +1,173 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.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/blocs/blocs.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_wrapper.dart'; +import 'package:web_dex/shared/widgets/logout_popup.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; + +const double minWidth = 100; +const double maxWidth = 350; + +class AccountSwitcher extends StatefulWidget { + const AccountSwitcher({Key? key}) : super(key: key); + + @override + State createState() => _AccountSwitcherState(); +} + +class _AccountSwitcherState extends State { + late PopupDispatcher _logOutPopupManager; + bool _isOpen = false; + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _logOutPopupManager = PopupDispatcher( + context: scaffoldKey.currentContext ?? context, + popupContent: LogOutPopup( + onConfirm: () => _logOutPopupManager.close(), + onCancel: () => _logOutPopupManager.close(), + ), + ); + }); + super.initState(); + } + + @override + void dispose() { + _logOutPopupManager.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ConnectWalletWrapper( + buttonSize: const Size(160, 30), + withIcon: true, + eventType: WalletsManagerEventType.header, + child: UiDropdown( + isOpen: _isOpen, + onSwitch: (isOpen) { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() => _isOpen = isOpen); + }); + }, + switcher: const _AccountSwitcher(), + dropdown: _AccountDropdown( + onTap: () { + _logOutPopupManager.show(); + setState(() { + _isOpen = false; + }); + }, + ), + ), + ); + } +} + +class _AccountSwitcher extends StatelessWidget { + const _AccountSwitcher(); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(minWidth: minWidth), + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + StreamBuilder( + stream: currentWalletBloc.outWallet, + builder: (context, snapshot) { + return Container( + constraints: const BoxConstraints(maxWidth: maxWidth), + child: Text( + currentWalletBloc.wallet?.name ?? '', + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Theme.of(context).textTheme.labelLarge?.color, + ), + textAlign: TextAlign.end, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ); + }), + const SizedBox(width: 6), + const _AccountIcon(), + ], + ), + ); + } +} + +class _AccountDropdown extends StatelessWidget { + final VoidCallback onTap; + const _AccountDropdown({required this.onTap}); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.all( + color: theme.custom.specificButtonBorderColor, + ), + ), + constraints: const BoxConstraints(minWidth: minWidth, maxWidth: maxWidth), + child: InkWell( + onTap: onTap, + child: Container( + height: 40, + padding: const EdgeInsets.fromLTRB(12, 0, 22, 0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + LocaleKeys.logOut.tr(), + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _AccountIcon extends StatelessWidget { + const _AccountIcon(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(2.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.tertiary), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: SvgPicture.asset( + '$assetsPath/ui_icons/account.svg', + colorFilter: ColorFilter.mode( + theme.custom.headerFloatBoxColor, + BlendMode.srcIn, + ), + ), + ), + ); + } +} diff --git a/lib/views/common/header/actions/header_actions.dart b/lib/views/common/header/actions/header_actions.dart new file mode 100644 index 0000000000..dfd8ad3ae9 --- /dev/null +++ b/lib/views/common/header/actions/header_actions.dart @@ -0,0 +1,63 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.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/blocs/blocs.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/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: StreamBuilder>( + initialData: coinsBloc.walletCoinsMap.values, + stream: coinsBloc.outWalletCoins, + builder: (context, snapshot) { + return ActionTextButton( + text: LocaleKeys.balance.tr(), + secondaryText: '\$${formatAmt(_getTotalBalance(snapshot.data!))}', + onTap: null, + ); + }, + ), + ), + const Padding( + padding: headerActionsPadding, + child: AccountSwitcher(), + ), + if (!isWideScreen) const SizedBox(width: mainLayoutPadding), + ]; +} + +double _getTotalBalance(Iterable coins) { + double total = coins.fold(0, (prev, coin) => prev + (coin.usdBalance ?? 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 new file mode 100644 index 0000000000..0285826f23 --- /dev/null +++ b/lib/views/common/header/app_header.dart @@ -0,0 +1,67 @@ +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'; + +PreferredSize? buildAppHeader() { + return isMobile + ? null + : const PreferredSize( + preferredSize: Size.fromHeight(appBarHeight), + child: AppHeader(), + ); +} + +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/constants.dart b/lib/views/common/hw_wallet_dialog/constants.dart new file mode 100644 index 0000000000..1c765467cd --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/constants.dart @@ -0,0 +1,12 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +const double trezorDialogWidth = 320; +const TextStyle trezorDialogTitle = + TextStyle(fontSize: 16, fontWeight: FontWeight.w700); +const TextStyle trezorDialogSubtitle = + TextStyle(fontSize: 14, fontWeight: FontWeight.w500); +final TextStyle trezorDialogDescription = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.currentGlobal.textTheme.bodyLarge?.color); diff --git a/lib/views/common/hw_wallet_dialog/hw_dialog_init.dart b/lib/views/common/hw_wallet_dialog/hw_dialog_init.dart new file mode 100644 index 0000000000..7d11f5001f --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/hw_dialog_init.dart @@ -0,0 +1,38 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_bloc.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_event.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/hw_wallet/hw_wallet.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart'; + +class HwDialogInit extends StatelessWidget { + const HwDialogInit({Key? key, required this.close}) : super(key: key); + final VoidCallback close; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + HwDialogWalletSelect( + onSelect: (WalletBrand brand) async { + if (brand == WalletBrand.trezor && + !context.read().state.inProgress) { + context.read().add(const TrezorInit()); + } + }, + ), + Padding( + padding: const EdgeInsets.only(top: 12), + child: UiUnderlineTextButton( + text: LocaleKeys.cancel.tr(), + onPressed: close, + ), + ) + ], + ); + } +} diff --git a/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart b/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart new file mode 100644 index 0000000000..76bb0489b8 --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/hw_dialog_wallet_select.dart @@ -0,0 +1,148 @@ +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:flutter_svg/svg.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_bloc.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/hw_wallet/hw_wallet.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class HwDialogWalletSelect extends StatefulWidget { + const HwDialogWalletSelect({ + Key? key, + required this.onSelect, + }) : super(key: key); + + final Function(WalletBrand) onSelect; + + @override + State createState() => _HwDialogWalletSelectState(); +} + +class _HwDialogWalletSelectState extends State { + WalletBrand? _selectedBrand; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + LocaleKeys.trezorSelectTitle.tr(), + style: trezorDialogTitle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + LocaleKeys.trezorSelectSubTitle.tr(), + style: trezorDialogSubtitle, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + _HwWalletTile( + selected: _selectedBrand == WalletBrand.trezor, + onSelect: () { + setState(() => _selectedBrand = WalletBrand.trezor); + }, + child: Center( + child: SvgPicture.asset(theme.mode == ThemeMode.light + ? '$assetsPath/others/trezor_logo_light.svg' + : '$assetsPath/others/trezor_logo.svg'), + )), + const SizedBox(height: 12), + _HwWalletTile( + disabled: true, + selected: _selectedBrand == WalletBrand.ledger, + onSelect: () { + setState(() => _selectedBrand = WalletBrand.ledger); + }, + child: Stack( + children: [ + Center( + child: SvgPicture.asset(theme.mode == ThemeMode.light + ? '$assetsPath/others/ledger_logo_light.svg' + : '$assetsPath/others/ledger_logo.svg'), + ), + Positioned( + right: 12, + top: 0, + bottom: 2, + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + LocaleKeys.comingSoon.tr().toLowerCase(), + style: theme.currentGlobal.textTheme.bodySmall, + ), + ], + ), + ) + ], + )), + const SizedBox(height: 24), + BlocSelector( + selector: (state) { + return state.inProgress; + }, + builder: (context, inProgress) { + return UiPrimaryButton( + text: LocaleKeys.continueText.tr(), + prefix: inProgress ? const UiSpinner() : null, + onPressed: _selectedBrand == null || inProgress + ? null + : () { + widget.onSelect(_selectedBrand!); + }, + ); + }, + ), + ], + ); + } +} + +class _HwWalletTile extends StatelessWidget { + const _HwWalletTile({ + Key? key, + required this.child, + required this.onSelect, + required this.selected, + this.disabled = false, + }) : super(key: key); + + final Widget child; + final bool disabled; + final bool selected; + final VoidCallback onSelect; + + @override + Widget build(BuildContext context) { + return Container( + height: 66, + decoration: BoxDecoration( + border: Border.all( + width: 2, + color: selected + ? theme.currentGlobal.colorScheme.secondary + : theme.custom.noColor, + ), + borderRadius: BorderRadius.circular(20), + color: theme.currentGlobal.colorScheme.onSurface, + ), + child: Opacity( + opacity: disabled ? 0.4 : 1, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: disabled ? null : () => onSelect(), + borderRadius: BorderRadius.circular(20), + child: child), + )), + ); + } +} 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 new file mode 100644 index 0000000000..f0e187749a --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/show_trezor_passphrase_dialog.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/bloc/trezor_bloc/trezor_repo.dart'; +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 'trezor_steps/trezor_dialog_select_wallet.dart'; + +Future showTrezorPassphraseDialog(TrezorTask task) async { + late PopupDispatcher popupManager; + bool isOpen = false; + final BuildContext? context = materialPageContext; + if (context == null) return; + + void close() { + popupManager.close(); + isOpen = false; + } + + popupManager = PopupDispatcher( + context: context, + width: trezorDialogWidth, + onDismiss: close, + popupContent: TrezorDialogSelectWallet( + onComplete: (String passphrase) async { + await trezorRepo.sendPassphrase(passphrase, task); + // todo(yurii): handle invalid pin + close(); + }, + ), + ); + + isOpen = true; + popupManager.show(); + + while (isOpen) { + await Future.delayed(const Duration(milliseconds: 100)); + } +} 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 new file mode 100644 index 0000000000..f783aac804 --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/show_trezor_pin_dialog.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/bloc/trezor_bloc/trezor_repo.dart'; +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/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart'; + +Future showTrezorPinDialog(TrezorTask task) async { + late PopupDispatcher popupManager; + bool isOpen = false; + final BuildContext? context = materialPageContext; + if (context == null) return; + + void close() { + popupManager.close(); + isOpen = false; + } + + popupManager = PopupDispatcher( + context: context, + width: trezorDialogWidth, + onDismiss: close, + popupContent: TrezorDialogPinPad( + onComplete: (String pin) async { + await trezorRepo.sendPin(pin, task); + // todo(yurii): handle invalid pin + close(); + }, + onClose: close, + ), + ); + + isOpen = true; + popupManager.show(); + + while (isOpen) { + await Future.delayed(const Duration(milliseconds: 100)); + } +} diff --git a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_error.dart b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_error.dart new file mode 100644 index 0000000000..419d24899f --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_error.dart @@ -0,0 +1,61 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_bloc.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_event.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status_error.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; + +class TrezorDialogError extends StatelessWidget { + const TrezorDialogError(this.error, {Key? key}) : super(key: key); + + final dynamic error; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(32), + child: SvgPicture.asset('$assetsPath/ui_icons/error.svg'), + ), + Text(_errorMessage, style: trezorDialogSubtitle), + const SizedBox(height: 24), + UiPrimaryButton( + text: LocaleKeys.retryButtonText.tr(), + onPressed: () => + context.read().add(const TrezorInitReset()), + ), + ], + ); + } + + String _parseErrorMessage(TrezorStatusError? error) { + if (error != null && error.error.contains('Error claiming an interface')) { + return LocaleKeys.trezorErrorBusy.tr(); + } + + switch (error?.errorData) { + case TrezorStatusErrorData.invalidPin: + return LocaleKeys.trezorErrorInvalidPin.tr(); + default: + return error?.error ?? LocaleKeys.somethingWrong.tr(); + } + } + + String get _errorMessage { + if (error is TrezorStatusError) { + return _parseErrorMessage(error); + } + if (error is BaseError) { + return error.text; + } + + return error.toString(); + } +} diff --git a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_in_progress.dart b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_in_progress.dart new file mode 100644 index 0000000000..b28a8da864 --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_in_progress.dart @@ -0,0 +1,77 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/hw_wallet/trezor_progress_status.dart'; +import 'package:web_dex/shared/ui/ui_light_button.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class TrezorDialogInProgress extends StatelessWidget { + const TrezorDialogInProgress(this.progressStatus, + {Key? key, required this.onClose}) + : super(key: key); + + final TrezorProgressStatus? progressStatus; + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const SizedBox(height: 24), + const UiSpinner( + width: 58, + height: 58, + strokeWidth: 4, + ), + const SizedBox(height: 48), + Builder(builder: (context) { + if (progressStatus == + TrezorProgressStatus.waitingForTrezorToConnect) { + return _buildConnectTrezor(); + } + if (progressStatus == + TrezorProgressStatus.followHwDeviceInstructions) { + return _buildFollowInstructionsOnTrezor(); + } + + return Text( + progressStatus?.name ?? LocaleKeys.inProgress.tr(), + style: trezorDialogTitle, + ); + }) + ], + ); + } + + Widget _buildConnectTrezor() { + return Column( + children: [ + Text(LocaleKeys.trezorInProgressTitle.tr(), style: trezorDialogTitle), + const SizedBox(height: 12), + Text( + LocaleKeys.trezorInProgressHint.tr(), + style: trezorDialogDescription, + ), + const SizedBox(height: 24), + UiLightButton( + text: LocaleKeys.cancel.tr(), + onPressed: () => onClose(), + ), + ], + ); + } + + Widget _buildFollowInstructionsOnTrezor() { + return Column( + children: [ + Text(LocaleKeys.confirmOnTrezor.tr(), style: trezorDialogTitle), + const SizedBox(height: 12), + Text( + LocaleKeys.followTrezorInstructions.tr(), + style: trezorDialogDescription, + ), + ], + ); + } +} diff --git a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_message.dart b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_message.dart new file mode 100644 index 0000000000..a4771a825b --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_message.dart @@ -0,0 +1,14 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/constants.dart'; + +/// Default dialog view, covers all unhandled events +class TrezorDialogMessage extends StatelessWidget { + const TrezorDialogMessage(this.text, {Key? key}) : super(key: key); + + final String text; + + @override + Widget build(BuildContext context) { + return Text(text, style: trezorDialogSubtitle); + } +} 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 new file mode 100644 index 0000000000..3fbd455c18 --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart @@ -0,0 +1,221 @@ +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/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:komodo_ui_kit/komodo_ui_kit.dart'; + +const List> _keys = [ + [7, 8, 9], + [4, 5, 6], + [1, 2, 3], +]; + +class TrezorDialogPinPad extends StatefulWidget { + const TrezorDialogPinPad({ + Key? key, + required this.onComplete, + required this.onClose, + }) : super(key: key); + + final Function(String) onComplete; + final VoidCallback onClose; + + @override + State createState() => _TrezorDialogPinPadState(); +} + +class _TrezorDialogPinPadState extends State { + final TextEditingController _pinController = TextEditingController(text: ''); + final _focus = FocusNode(); + @override + void initState() { + _pinController.addListener(_onPinChange); + super.initState(); + } + + @override + void dispose() { + _pinController.removeListener(_onPinChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return KeyboardListener( + autofocus: true, + onKeyEvent: _onKeyEvent, + focusNode: _focus, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + LocaleKeys.trezorEnterPinTitle.tr(), + style: trezorDialogTitle, + ), + const SizedBox(height: 4), + Text( + LocaleKeys.trezorEnterPinHint.tr(), + style: trezorDialogDescription, + ), + const SizedBox(height: 12), + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30), + child: _buildObscuredPin(), + ), + const SizedBox(height: 12), + _buildKeys(), + _buildButtons(), + ], + ), + ); + } + + Widget _buildObscuredPin() { + final Color? backspaceColor = _pinController.text.isEmpty + ? Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.7) + : Theme.of(context).textTheme.bodyMedium?.color; + + return UiTextFormField( + controller: _pinController, + readOnly: true, + obscureText: true, + style: const TextStyle(fontSize: 36), + inputContentPadding: + const EdgeInsets.symmetric(vertical: 14, horizontal: 12), + suffixIcon: Padding( + padding: const EdgeInsets.only(right: 12.0), + child: IconButton( + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + icon: Icon( + Icons.backspace, + color: backspaceColor, + ), + onPressed: _pinController.text.isEmpty ? null : _deleteLast, + ), + ), + ); + } + + Widget _buildKeys() { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: _keys.map(_buildKeysRow).toList(), + ); + } + + Widget _buildKeysRow(List keysRow) { + final List children = []; + for (int value in keysRow) { + children.add(Expanded( + child: _Key( + onTap: () => _onKeyTap(value), + ))); + final bool isLast = keysRow.indexOf(value) == keysRow.length - 1; + if (!isLast) { + children.add(const SizedBox(width: 16)); + } + } + return Padding( + padding: const EdgeInsets.only(bottom: 16.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: children, + ), + ); + } + + void _onKeyEvent(KeyEvent event) { + if (event is! KeyDownEvent) return; + if (event.logicalKey.keyLabel == 'Backspace') { + _deleteLast(); + return; + } + if (event.logicalKey.keyLabel == 'Enter' && + _pinController.text.isNotEmpty) { + widget.onComplete(_pinController.text); + } + final int? character = int.tryParse(event.character ?? ''); + if (character == null) return; + _onKeyTap(character); + } + + void _deleteLast() { + final String pinValue = _pinController.text; + if (pinValue.isEmpty) return; + + _pinController.value = TextEditingValue( + text: pinValue.substring(0, pinValue.length - 1), + ); + } + + Widget _buildButtons() { + return Container( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: UiLightButton( + onPressed: widget.onClose, + text: LocaleKeys.cancel.tr(), + ), + ), + const SizedBox(width: 12), + Flexible( + child: UiPrimaryButton( + onPressed: _pinController.text.isEmpty + ? null + : () => widget.onComplete(_pinController.text), + text: LocaleKeys.continueText.tr(), + ), + ), + ], + ), + ); + } + + void _onKeyTap(int value) { + if (_pinController.text.length >= 50) return; + + _pinController.value = + TextEditingValue(text: _pinController.text + value.toString()); + } + + void _onPinChange() { + setState(() {}); + } +} + +class _Key extends StatelessWidget { + const _Key({required this.onTap}); + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 76, + child: Material( + color: theme.custom.keyPadColor, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Center( + child: Container( + width: 14, + height: 14, + decoration: BoxDecoration( + color: theme.custom.keyPadTextColor, shape: BoxShape.circle), + ), + ), + ), + ), + ); + } +} 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 new file mode 100644 index 0000000000..728cc7083e --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart @@ -0,0 +1,183 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class TrezorDialogSelectWallet extends StatelessWidget { + const TrezorDialogSelectWallet({ + Key? key, + required this.onComplete, + }) : super(key: key); + + final Function(String) onComplete; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + LocaleKeys.selectWalletType.tr(), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 18), + _TrezorStandardWallet( + onTap: () => onComplete(''), + ), + const Padding( + padding: EdgeInsets.symmetric(vertical: 6.0), + child: UiDivider(), + ), + _TrezorHiddenWallet( + onSubmit: (String passphrase) => onComplete(passphrase), + ), + ], + ); + } +} + +class _TrezorStandardWallet extends StatelessWidget { + const _TrezorStandardWallet({required this.onTap}); + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return _TrezorWalletItem( + icon: Icons.account_balance_wallet_outlined, + title: LocaleKeys.standardWallet.tr(), + description: LocaleKeys.noPassphrase.tr(), + isIconShown: true, + onTap: onTap, + ); + } +} + +class _TrezorHiddenWallet extends StatefulWidget { + const _TrezorHiddenWallet({required this.onSubmit}); + final Function(String) onSubmit; + + @override + State<_TrezorHiddenWallet> createState() => _TrezorHiddenWalletState(); +} + +class _TrezorHiddenWalletState extends State<_TrezorHiddenWallet> { + final TextEditingController _passphraseController = TextEditingController(); + final GlobalKey _formKey = GlobalKey(); + final FocusNode _passphraseFieldFocusNode = FocusNode(); + + @override + void initState() { + _passphraseController.addListener(() => setState(() {})); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _TrezorWalletItem( + title: LocaleKeys.hiddenWallet.tr(), + description: LocaleKeys.passphraseRequired.tr(), + icon: Icons.lock_outline, + isIconShown: _isSendAllowed, + onTap: _onSubmit, + ), + const SizedBox(height: 12), + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 30), + child: _buildObscuredPassphrase(), + ), + ], + ); + } + + Widget _buildObscuredPassphrase() { + return Form( + key: _formKey, + child: UiTextFormField( + controller: _passphraseController, + hintText: LocaleKeys.passphrase.tr(), + keyboardType: TextInputType.emailAddress, + obscureText: true, + focusNode: _passphraseFieldFocusNode, + onFieldSubmitted: (_) => _onSubmit(), + validator: (String? text) { + if (text == null || text.isEmpty) { + return LocaleKeys.passphraseIsEmpty.tr(); + } + + return null; + }, + ), + ); + } + + void _onSubmit() { + final isValid = _formKey.currentState?.validate() ?? false; + if (!isValid) { + _passphraseFieldFocusNode.requestFocus(); + return; + } + + widget.onSubmit(_passphraseController.text); + } + + bool get _isSendAllowed => _passphraseController.text.isNotEmpty; +} + +class _TrezorWalletItem extends StatelessWidget { + const _TrezorWalletItem({ + required this.title, + required this.description, + required this.icon, + required this.isIconShown, + required this.onTap, + }); + final String title; + final String description; + final IconData icon; + final VoidCallback onTap; + final bool isIconShown; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + borderRadius: BorderRadius.circular(18.0), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + 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), + ), + const SizedBox(height: 6), + Text( + description, + style: theme.textTheme.bodySmall + ?.copyWith(color: theme.textTheme.bodyLarge?.color), + ), + ], + ), + const Spacer(), + if (isIconShown) const Icon(Icons.keyboard_arrow_right_rounded), + ], + ), + ), + ); + } +} diff --git a/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_success.dart b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_success.dart new file mode 100644 index 0000000000..c9210d893c --- /dev/null +++ b/lib/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_success.dart @@ -0,0 +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/views/common/hw_wallet_dialog/constants.dart'; + +class TrezorDialogSuccess extends StatelessWidget { + const TrezorDialogSuccess({Key? key, required this.onClose}) + : super(key: key); + + final VoidCallback onClose; + + @override + Widget build(BuildContext context) { + return Text(LocaleKeys.success.tr(), style: trezorDialogTitle); + } +} diff --git a/lib/views/common/main_menu/main_menu_bar_mobile.dart b/lib/views/common/main_menu/main_menu_bar_mobile.dart new file mode 100644 index 0000000000..008930eefc --- /dev/null +++ b/lib/views/common/main_menu/main_menu_bar_mobile.dart @@ -0,0 +1,81 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_state.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/common/main_menu/main_menu_bar_mobile_item.dart'; + +class MainMenuBarMobile extends StatelessWidget { + @override + Widget build(BuildContext context) { + final MainMenuValue selected = routingState.selectedMenu; + + return BlocBuilder( + builder: (context, state) { + final bool isMMBotEnabled = state.mmBotSettings.isMMBotEnabled; + return DecoratedBox( + decoration: BoxDecoration( + color: theme.currentGlobal.cardColor, + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + offset: const Offset(0, -10), + blurRadius: 10, + ), + ], + ), + child: SafeArea( + child: SizedBox( + height: 75, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + MainMenuBarMobileItem( + value: MainMenuValue.wallet, + isActive: selected == MainMenuValue.wallet, + ), + MainMenuBarMobileItem( + value: MainMenuValue.fiat, + enabled: currentWalletBloc.wallet?.isHW != true, + isActive: selected == MainMenuValue.fiat, + ), + MainMenuBarMobileItem( + value: MainMenuValue.dex, + enabled: currentWalletBloc.wallet?.isHW != true, + isActive: selected == MainMenuValue.dex, + ), + MainMenuBarMobileItem( + value: MainMenuValue.bridge, + enabled: currentWalletBloc.wallet?.isHW != true, + isActive: selected == MainMenuValue.bridge, + ), + // TODO(Francois): consider moving into sub-menu somewhere to + // avoid cluttering the main menu (and text wrapping) + if (isMMBotEnabled) + MainMenuBarMobileItem( + enabled: currentWalletBloc.wallet?.isHW != true, + value: MainMenuValue.marketMakerBot, + isActive: selected == MainMenuValue.marketMakerBot, + ), + MainMenuBarMobileItem( + value: MainMenuValue.nft, + enabled: currentWalletBloc.wallet?.isHW != true, + isActive: selected == MainMenuValue.nft, + ), + MainMenuBarMobileItem( + value: MainMenuValue.settings, + isActive: selected == MainMenuValue.settings, + ), + ], + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/common/main_menu/main_menu_bar_mobile_item.dart b/lib/views/common/main_menu/main_menu_bar_mobile_item.dart new file mode 100644 index 0000000000..a6d23fad1b --- /dev/null +++ b/lib/views/common/main_menu/main_menu_bar_mobile_item.dart @@ -0,0 +1,67 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; + +class MainMenuBarMobileItem extends StatelessWidget { + MainMenuBarMobileItem({ + required this.value, + required this.isActive, + this.enabled = true, + }) : super(key: Key('main-menu-${value.name}')); + + final MainMenuValue value; + final bool enabled; + final bool isActive; + + @override + Widget build(BuildContext context) { + return Expanded( + child: Opacity( + opacity: enabled ? 1 : 0.5, + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: enabled + ? () { + routingState.selectedMenu = value; + } + : null, + highlightColor: Colors.transparent, + child: Padding( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + key: Key('main-menu-item-icon-${value.name}'), + padding: const EdgeInsets.only(bottom: 6.0), + child: NavIcon(item: value, isActive: isActive), + ), + AutoScrollText( + text: value.title, + style: isActive + ? theme.currentGlobal.bottomNavigationBarTheme + .selectedLabelStyle + ?.copyWith( + color: theme.currentGlobal.bottomNavigationBarTheme + .selectedItemColor, + ) + : theme.currentGlobal.bottomNavigationBarTheme + .unselectedLabelStyle + ?.copyWith( + color: theme.currentGlobal.bottomNavigationBarTheme + .unselectedItemColor, + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/common/main_menu/main_menu_desktop.dart b/lib/views/common/main_menu/main_menu_desktop.dart new file mode 100644 index 0000000000..5bb8548ed2 --- /dev/null +++ b/lib/views/common/main_menu/main_menu_desktop.dart @@ -0,0 +1,151 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_event.dart'; +import 'package:web_dex/bloc/settings/settings_state.dart'; +import 'package:web_dex/blocs/blocs.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/main_menu_value.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/common/main_menu/main_menu_desktop_item.dart'; + +class MainMenuDesktop extends StatefulWidget { + @override + State createState() => _MainMenuDesktopState(); +} + +class _MainMenuDesktopState extends State { + @override + Widget build(BuildContext context) { + final isAuthenticated = context + .select((AuthBloc bloc) => bloc.state.mode == AuthorizeMode.logIn); + + return StreamBuilder( + stream: currentWalletBloc.outWallet, + builder: (context, currentWalletSnapshot) { + return BlocBuilder( + builder: (context, settingsState) { + final bool isDarkTheme = settingsState.themeMode == ThemeMode.dark; + final bool isMMBotEnabled = + settingsState.mmBotSettings.isMMBotEnabled; + final SettingsBloc settings = context.read(); + return Container( + margin: isWideScreen + ? const EdgeInsets.fromLTRB(0, mainLayoutPadding + 12, 24, 0) + : const EdgeInsets.fromLTRB( + mainLayoutPadding, + mainLayoutPadding + 12, + 27, + mainLayoutPadding, + ), + child: FocusTraversalGroup( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + DesktopMenuDesktopItem( + key: const Key('main-menu-wallet'), + menu: MainMenuValue.wallet, + onTap: onTapItem, + isSelected: _checkSelectedItem(MainMenuValue.wallet), + ), + DesktopMenuDesktopItem( + key: const Key('main-menu-fiat'), + enabled: currentWalletBloc.wallet?.isHW != true, + menu: MainMenuValue.fiat, + onTap: onTapItem, + isSelected: _checkSelectedItem(MainMenuValue.fiat), + ), + DesktopMenuDesktopItem( + key: const Key('main-menu-dex'), + enabled: currentWalletBloc.wallet?.isHW != true, + menu: MainMenuValue.dex, + onTap: onTapItem, + isSelected: _checkSelectedItem(MainMenuValue.dex), + ), + DesktopMenuDesktopItem( + key: const Key('main-menu-bridge'), + enabled: currentWalletBloc.wallet?.isHW != true, + menu: MainMenuValue.bridge, + onTap: onTapItem, + isSelected: _checkSelectedItem(MainMenuValue.bridge), + ), + if (isMMBotEnabled && isAuthenticated) + DesktopMenuDesktopItem( + key: const Key('main-menu-market-maker-bot'), + enabled: currentWalletBloc.wallet?.isHW != true, + menu: MainMenuValue.marketMakerBot, + onTap: onTapItem, + isSelected: + _checkSelectedItem(MainMenuValue.marketMakerBot), + ), + DesktopMenuDesktopItem( + key: const Key('main-menu-nft'), + enabled: currentWalletBloc.wallet?.isHW != true, + menu: MainMenuValue.nft, + onTap: onTapItem, + isSelected: _checkSelectedItem(MainMenuValue.nft)), + const Spacer(), + DesktopMenuDesktopItem( + key: const Key('main-menu-settings'), + menu: MainMenuValue.settings, + onTap: onTapItem, + needAttention: + currentWalletBloc.wallet?.config.hasBackup == false, + isSelected: _checkSelectedItem(MainMenuValue.settings), + ), + 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, + ), + ); + }), + ), + const SizedBox(height: 48), + ], + ), + ), + ); + }, + ); + }, + ); + } + + void onTapItem(MainMenuValue selectedMenu) { + routingState.selectedMenu = selectedMenu; + } + + bool _checkSelectedItem(MainMenuValue menu) { + return routingState.selectedMenu == menu; + } +} diff --git a/lib/views/common/main_menu/main_menu_desktop_item.dart b/lib/views/common/main_menu/main_menu_desktop_item.dart new file mode 100644 index 0000000000..624a6c7459 --- /dev/null +++ b/lib/views/common/main_menu/main_menu_desktop_item.dart @@ -0,0 +1,141 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/shared/widgets/need_attention_mark.dart'; + +class DesktopMenuDesktopItem extends StatelessWidget { + const DesktopMenuDesktopItem({ + Key? key, + required this.menu, + required this.isSelected, + required this.onTap, + this.needAttention = false, + this.enabled = true, + }) : super(key: key); + + final MainMenuValue menu; + final bool isSelected; + final bool enabled; + final bool needAttention; + final Function(MainMenuValue) onTap; + + @override + Widget build(BuildContext context) { + final TextStyle? textStyle = _getTextStyle(context); + + return Opacity( + opacity: enabled ? 1 : 0.4, + child: Container( + margin: const EdgeInsets.fromLTRB(0, 0, 0, 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: _getBackgroundColor(context), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: enabled ? () => onTap(menu) : null, + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + mouseCursor: enabled + ? SystemMouseCursors.click + : SystemMouseCursors.forbidden, + child: Row( + children: [ + NeedAttentionMark(needAttention), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 15), + child: Row( + children: [ + const SizedBox(width: 16), + NavIcon(item: menu, isActive: isSelected), + const SizedBox(width: 12), + Expanded( + child: Row( + children: [ + Flexible( + child: AutoScrollText( + text: menu.title, + style: textStyle, + ), + ), + if (menu.isNew) + const SizedBox(width: 6), // Add some spacing + if (menu.isNew) const _LabelNew(), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + TextStyle? _getTextStyle(BuildContext context) { + final ThemeData themeData = Theme.of(context); + + if (enabled) { + return themeData.textTheme.labelLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + color: isSelected + ? theme.custom.mainMenuSelectedItemColor + : theme.custom.mainMenuItemColor, + ); + } + + return Theme.of(context).textTheme.labelLarge?.copyWith(fontSize: 14); + } + + Color _getBackgroundColor(BuildContext context) { + return enabled && isSelected + ? theme.custom.selectedMenuBackgroundColor + : Theme.of(context).colorScheme.onSurface; + } +} + +class _LabelNew extends StatelessWidget { + const _LabelNew(); + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + themeData.colorScheme.primary, + themeData.colorScheme.secondary, + ], + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(5, 1, 5, 1), + child: Text( + LocaleKeys.newText.tr(), + style: TextStyle( + color: theme.custom.defaultGradientButtonTextColor, + fontSize: 11, + height: 1, + ), + ), + ), + ); + } +} diff --git a/lib/views/common/page_header/back_button_desktop.dart b/lib/views/common/page_header/back_button_desktop.dart new file mode 100644 index 0000000000..f40d44c0f4 --- /dev/null +++ b/lib/views/common/page_header/back_button_desktop.dart @@ -0,0 +1,48 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/ui/ui_gradient_icon.dart'; + +class BackButtonDesktop extends StatelessWidget { + const BackButtonDesktop({ + required this.text, + required this.onPressed, + }); + final String text; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + key: const Key('back-button'), + onTap: onPressed, + borderRadius: BorderRadius.circular(18), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 4), + SizedBox( + height: 30, + child: UiGradientIcon( + icon: Icons.chevron_left, + color: theme.custom.headerIconColor, + size: 24, + ), + ), + const SizedBox(width: 6), + Text( + text, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.custom.headerIconColor, + ), + ), + const SizedBox(width: 10), + ], + ), + ), + ); + } +} diff --git a/lib/views/common/page_header/back_button_mobile.dart b/lib/views/common/page_header/back_button_mobile.dart new file mode 100644 index 0000000000..94f010642d --- /dev/null +++ b/lib/views/common/page_header/back_button_mobile.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/common/app_assets.dart'; + +class BackButtonMobile extends StatelessWidget { + const BackButtonMobile({required this.onPressed}); + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 12), + child: IconButton( + key: const Key('back-button'), + onPressed: onPressed, + alignment: Alignment.center, + splashRadius: 15, + padding: const EdgeInsets.all(0), + icon: const DexSvgImage( + path: Assets.chevronLeftMobile, + colorFilter: ColorFilterEnum.headerIconColor, + )), + ); + } +} diff --git a/lib/views/common/page_header/disable_coin_button.dart b/lib/views/common/page_header/disable_coin_button.dart new file mode 100644 index 0000000000..f99d2c4736 --- /dev/null +++ b/lib/views/common/page_header/disable_coin_button.dart @@ -0,0 +1,22 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class DisableCoinButton extends StatelessWidget { + const DisableCoinButton({required this.onClick}); + final VoidCallback onClick; + + @override + Widget build(BuildContext context) { + return UiUnderlineTextButton( + key: const Key('disable-coin-button'), + width: 100, + height: 24, + textFontSize: 12, + textFontWeight: FontWeight.w500, + text: LocaleKeys.disable.tr(), + onPressed: onClick, + ); + } +} diff --git a/lib/views/common/page_header/page_header.dart b/lib/views/common/page_header/page_header.dart new file mode 100644 index 0000000000..8b41f3c5f5 --- /dev/null +++ b/lib/views/common/page_header/page_header.dart @@ -0,0 +1,156 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_button.dart'; +import 'package:web_dex/views/common/page_header/back_button_desktop.dart'; +import 'package:web_dex/views/common/page_header/back_button_mobile.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; + +class PageHeader extends StatelessWidget { + const PageHeader({ + required this.title, + this.onBackButtonPressed, + this.backText, + this.actions, + this.widgetTitle, + }); + + final String title; + final VoidCallback? onBackButtonPressed; + final String? backText; + final List? actions; + final Widget? widgetTitle; + + @override + Widget build(BuildContext context) { + if (isMobile) { + return _MobileHeader( + onBackButtonPressed: onBackButtonPressed, + title: title, + actions: actions, + widgetTitle: widgetTitle, + ); + } + return _DesktopHeader( + onBackButtonPressed: onBackButtonPressed, + title: title, + backText: backText, + actions: actions, + widgetTitle: widgetTitle, + ); + } +} + +class _MobileHeader extends StatelessWidget { + const _MobileHeader({ + required this.onBackButtonPressed, + required this.title, + this.actions, + this.widgetTitle, + }); + + final String title; + final VoidCallback? onBackButtonPressed; + final List? actions; + final Widget? widgetTitle; + + @override + Widget build(BuildContext context) { + final Widget? widget = widgetTitle; + return AppBar( + elevation: 0, + backgroundColor: theme.custom.noColor, + leadingWidth: 30, + leading: onBackButtonPressed == null + ? null + : BackButtonMobile(onPressed: onBackButtonPressed!), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + if (widget != null) widget, + ], + ), + centerTitle: true, + actions: [ + if (actions != null) ...actions!, + if (context.watch().state.mode != AuthorizeMode.logIn) + const Padding( + padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 20.0), + child: ConnectWalletButton( + eventType: WalletsManagerEventType.header, + withText: false, + ), + ), + ], + ); + } +} + +class _DesktopHeader extends StatelessWidget { + const _DesktopHeader({ + required this.title, + this.onBackButtonPressed, + this.backText, + this.actions, + this.widgetTitle, + }); + + final String title; + final VoidCallback? onBackButtonPressed; + final String? backText; + final List? actions; + final Widget? widgetTitle; + + @override + Widget build(BuildContext context) { + final widget = widgetTitle; + return Stack( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + onBackButtonPressed == null + ? const SizedBox() + : BackButtonDesktop( + text: backText ?? '', + onPressed: onBackButtonPressed!, + ), + if (actions != null) + Row( + mainAxisSize: MainAxisSize.min, + children: actions!, + ), + ], + ), + Center( + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + if (widget != null) widget, + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/common/pages/page_layout.dart b/lib/views/common/pages/page_layout.dart new file mode 100644 index 0000000000..024d9c1787 --- /dev/null +++ b/lib/views/common/pages/page_layout.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/views/common/pages/page_plate.dart'; +import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/backup_seed_notification.dart'; + +class PageLayout extends StatelessWidget { + const PageLayout( + {required this.content, this.header, this.noBackground = false}); + + final Widget content; + final Widget? header; + final bool noBackground; + + @override + Widget build(BuildContext context) { + if (isMobile) return _MobileLayout(header: header, content: content); + return _DesktopLayout( + header: header, + content: content, + noBackground: noBackground, + ); + } +} + +class _MobileLayout extends StatelessWidget { + const _MobileLayout({ + required this.content, + this.header, + }); + + final Widget? header; + final Widget content; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + const BackupSeedNotification(), + if (header != null) header!, + Flexible( + child: PagePlate( + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + content, + ], + ), + ), + ) + ], + ); + } +} + +class _DesktopLayout extends StatelessWidget { + const _DesktopLayout({ + required this.content, + this.header, + this.noBackground = false, + }); + + final Widget content; + final Widget? header; + final bool noBackground; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const BackupSeedNotification(), + Flexible( + child: PagePlate( + noBackground: noBackground, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 23), + if (header != null) header!, + content, + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/common/pages/page_plate.dart b/lib/views/common/pages/page_plate.dart new file mode 100644 index 0000000000..addcd2f6ef --- /dev/null +++ b/lib/views/common/pages/page_plate.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; + +class PagePlate extends StatelessWidget { + const PagePlate({required this.child, this.noBackground = false}); + + final Widget child; + final bool noBackground; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(15, 0, 15, 0), + width: double.infinity, + decoration: BoxDecoration( + color: noBackground ? null : Theme.of(context).cardColor, + borderRadius: isDesktop ? BorderRadius.circular(18) : null, + ), + child: child, + ); + } +} diff --git a/lib/views/common/wallet_password_dialog/password_dialog_content.dart b/lib/views/common/wallet_password_dialog/password_dialog_content.dart new file mode 100644 index 0000000000..6df1fa11d1 --- /dev/null +++ b/lib/views/common/wallet_password_dialog/password_dialog_content.dart @@ -0,0 +1,125 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:web_dex/blocs/blocs.dart'; +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:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/shared/widgets/password_visibility_control.dart'; + +class PasswordDialogContent extends StatefulWidget { + const PasswordDialogContent({ + Key? key, + required this.onSuccess, + required this.onCancel, + this.wallet, + }) : super(key: key); + + final Function(String) onSuccess; + + final VoidCallback onCancel; + final Wallet? wallet; + + @override + State createState() => _PasswordDialogContentState(); +} + +class _PasswordDialogContentState extends State { + bool _isObscured = true; + final TextEditingController _passwordController = TextEditingController(); + String? _error; + bool _inProgress = false; + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + constraints: isMobile + ? const BoxConstraints(maxWidth: 362) + : const BoxConstraints(maxHeight: 320, maxWidth: 362), + padding: EdgeInsets.symmetric(horizontal: isMobile ? 16 : 46), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocaleKeys.confirmationForShowingSeedPhraseTitle.tr(), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.only(top: 24), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + UiTextFormField( + key: const Key('confirmation-showing-seed-phrase'), + controller: _passwordController, + autocorrect: false, + enableInteractiveSelection: true, + obscureText: _isObscured, + inputFormatters: [LengthLimitingTextInputFormatter(40)], + errorMaxLines: 6, + errorText: _error, + hintText: LocaleKeys.enterThePassword.tr(), + suffixIcon: PasswordVisibilityControl( + onVisibilityChange: (bool isPasswordObscured) { + setState(() { + _isObscured = isPasswordObscured; + }); + }, + ), + onFieldSubmitted: (text) => _onContinue(), + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: UiPrimaryButton( + onPressed: _inProgress ? null : _onContinue, + text: LocaleKeys.continueText.tr(), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: UiUnderlineTextButton( + text: LocaleKeys.cancel.tr(), + onPressed: widget.onCancel, + ), + ) + ], + ), + ), + ], + ), + ); + } + + Future _onContinue() async { + final Wallet? wallet = widget.wallet ?? currentWalletBloc.wallet; + if (wallet == null) return; + final String password = _passwordController.text; + + setState(() => _inProgress = true); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + final String seed = await wallet.getSeed(password); + if (seed.isEmpty) { + if (mounted) { + setState(() { + _error = LocaleKeys.invalidPasswordError.tr(); + _inProgress = false; + }); + } + + return; + } + + widget.onSuccess(password); + + if (mounted) setState(() => _inProgress = false); + }); + } +} diff --git a/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart b/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart new file mode 100644 index 0000000000..757d70a2e5 --- /dev/null +++ b/lib/views/common/wallet_password_dialog/wallet_password_dialog.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.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'; + +// Shows wallet password dialog and +// returns password value or null (if wrong or cancelled) +Future walletPasswordDialog( + BuildContext context, { + Wallet? wallet, +}) async { + wallet ??= currentWalletBloc.wallet; + late PopupDispatcher popupManager; + bool isOpen = false; + String? password; + + void close() { + popupManager.close(); + isOpen = false; + } + + popupManager = PopupDispatcher( + context: context, + popupContent: PasswordDialogContent( + wallet: wallet, + onSuccess: (String pass) { + password = pass; + close(); + }, + onCancel: close, + ), + ); + + isOpen = true; + popupManager.show(); + + while (isOpen) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + return password; +} diff --git a/lib/views/dex/common/dex_text_button.dart b/lib/views/dex/common/dex_text_button.dart new file mode 100644 index 0000000000..8ade51c46b --- /dev/null +++ b/lib/views/dex/common/dex_text_button.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class DexTextButton extends StatelessWidget { + const DexTextButton({ + Key? key, + required this.text, + required this.isActive, + this.onTap, + }) : super(key: key); + + final String text; + final bool isActive; + final GestureTapCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(18), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration( + color: isActive ? theme.primaryColor : Colors.transparent, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: isActive + ? (Color.lerp(theme.primaryColor, Colors.white, 0.1) ?? + theme.primaryColor) + : theme.disabledColor, + ), + ), + child: Text( + text, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: isActive + ? theme.primaryTextTheme.labelLarge?.color + : theme.textTheme.labelLarge?.color, + ), + ), + ), + ); + } +} diff --git a/lib/views/dex/common/divider_line.dart b/lib/views/dex/common/divider_line.dart new file mode 100644 index 0000000000..af6ae82e2d --- /dev/null +++ b/lib/views/dex/common/divider_line.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; + +class DividerDecoration extends BoxDecoration { + DividerDecoration(Color color) + : super( + border: Border( + bottom: BorderSide( + width: 1, + color: color, + ), + ), + ); +} diff --git a/lib/views/dex/common/fiat_amount.dart b/lib/views/dex/common/fiat_amount.dart new file mode 100644 index 0000000000..1e0b5da951 --- /dev/null +++ b/lib/views/dex/common/fiat_amount.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class DexFiatAmountText extends StatelessWidget { + final Coin coin; + final Rational amount; + final TextStyle? style; + const DexFiatAmountText( + {super.key, required this.coin, required this.amount, this.style}); + + @override + Widget build(BuildContext context) { + final TextStyle? textStyle = + Theme.of(context).textTheme.bodySmall?.merge(style); + + return Text( + getFormattedFiatAmount(coin.abbr, amount), + style: textStyle, + ); + } +} diff --git a/lib/views/dex/common/form_plate.dart b/lib/views/dex/common/form_plate.dart new file mode 100644 index 0000000000..69f4846cc7 --- /dev/null +++ b/lib/views/dex/common/form_plate.dart @@ -0,0 +1,22 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/ui/gradient_border.dart'; + +class FormPlate extends StatelessWidget { + const FormPlate({required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return GradientBorder( + innerColor: dexPageColors.frontPlate, + gradient: dexPageColors.formPlateGradient, + child: Container( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: child, + ), + ); + } +} diff --git a/lib/views/dex/common/front_plate.dart b/lib/views/dex/common/front_plate.dart new file mode 100644 index 0000000000..f377bf26ca --- /dev/null +++ b/lib/views/dex/common/front_plate.dart @@ -0,0 +1,33 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class FrontPlate extends StatelessWidget { + const FrontPlate({required this.child, this.shadowEnabled = false}); + + final Widget child; + final bool shadowEnabled; + + @override + Widget build(BuildContext context) { + final borderRadius = BorderRadius.circular(18); + final shadow = BoxShadow( + color: Colors.black.withOpacity(0.25), + spreadRadius: 0, + blurRadius: 4, + offset: const Offset(0, 4), + ); + return Container( + constraints: const BoxConstraints(minHeight: 36, minWidth: 36), + width: double.infinity, + decoration: BoxDecoration( + color: dexPageColors.frontPlateInner, + borderRadius: borderRadius, + boxShadow: shadowEnabled ? [shadow] : null, + ), + child: ClipRRect( + borderRadius: borderRadius, + child: child, + ), + ); + } +} diff --git a/lib/views/dex/common/section_switcher.dart b/lib/views/dex/common/section_switcher.dart new file mode 100644 index 0000000000..93cdf27dfa --- /dev/null +++ b/lib/views/dex/common/section_switcher.dart @@ -0,0 +1,54 @@ +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:web_dex/bloc/trading_kind/trading_kind_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/dex/common/dex_text_button.dart'; + +class SectionSwitcher extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + padding: const EdgeInsets.only(bottom: 4), + child: Row( + children: [ + _TakerBtn(), + const SizedBox(width: 12), + _MakerBtn(), + ], + ), + ); + } +} + +class _TakerBtn extends StatelessWidget { + @override + Widget build(BuildContext context) { + final TradingKindBloc bloc = context.read(); + final isActive = bloc.state.isTaker; + final onTap = isActive ? null : () => bloc.setKind(TradingKind.taker); + return DexTextButton( + text: LocaleKeys.takerOrder.tr(), + isActive: isActive, + onTap: onTap, + key: const Key('take-order-tab'), + ); + } +} + +class _MakerBtn extends StatelessWidget { + @override + Widget build(BuildContext context) { + final TradingKindBloc bloc = context.read(); + final isActive = bloc.state.isMaker; + final onTap = isActive ? null : () => bloc.setKind(TradingKind.maker); + return DexTextButton( + text: LocaleKeys.makerOrder.tr(), + isActive: isActive, + onTap: onTap, + key: const Key('make-order-tab'), + ); + } +} diff --git a/lib/views/dex/common/trading_amount_field.dart b/lib/views/dex/common/trading_amount_field.dart new file mode 100644 index 0000000000..5085b93e89 --- /dev/null +++ b/lib/views/dex/common/trading_amount_field.dart @@ -0,0 +1,50 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +class TradingAmountField extends StatelessWidget { + const TradingAmountField({ + super.key, + required this.controller, + this.enabled = true, + this.onChanged, + this.height = 20, + this.contentPadding = const EdgeInsets.all(0), + }); + + final TextEditingController controller; + final bool enabled; + final Function(String)? onChanged; + final double height; + final EdgeInsetsGeometry contentPadding; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + child: TextFormField( + key: const Key('amount-input'), + controller: controller, + enabled: enabled, + textInputAction: TextInputAction.done, + textAlign: TextAlign.end, + inputFormatters: currencyInputFormatters, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + color: dexPageColors.activeText, + decoration: TextDecoration.none, + ), + decoration: InputDecoration( + hintText: '0.00', + contentPadding: contentPadding, + fillColor: Colors.transparent, + focusColor: Colors.transparent, + hoverColor: Colors.transparent, + ), + onChanged: onChanged, + ), + ); + } +} diff --git a/lib/views/dex/dex_helpers.dart b/lib/views/dex/dex_helpers.dart new file mode 100644 index 0000000000..c0ad3b4b23 --- /dev/null +++ b/lib/views/dex/dex_helpers.dart @@ -0,0 +1,430 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.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/dex_form_error.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/model/trade_preimage_extended_fee_info.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/balances_formatter.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +class FiatAmount extends StatelessWidget { + final Coin coin; + final Rational amount; + final TextStyle? style; + + const FiatAmount({ + Key? 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); + + return Text( + getFormattedFiatAmount(coin.abbr, amount), + style: textStyle, + ); + } +} + +String getFormattedFiatAmount(String coinAbbr, Rational amount, + [int digits = 8]) { + final Coin? coin = coinsBloc.getCoin(coinAbbr); + if (coin == null) return ''; + return '≈\$${formatAmt(getFiatAmount(coin, amount))}'; +} + +List applyFiltersForSwap( + List swaps, TradingEntitiesFilter entitiesFilterData) { + return swaps.where((swap) { + final String? sellCoin = entitiesFilterData.sellCoin; + final String? buyCoin = entitiesFilterData.buyCoin; + final int? startDate = entitiesFilterData.startDate?.millisecondsSinceEpoch; + final int? endDate = entitiesFilterData.endDate?.millisecondsSinceEpoch; + final List? statuses = entitiesFilterData.statuses; + final List? shownSides = entitiesFilterData.shownSides; + + if (sellCoin != null && swap.sellCoin != sellCoin) return false; + if (buyCoin != null && swap.buyCoin != buyCoin) return false; + if (startDate != null && swap.myInfo.startedAt < startDate / 1000) { + return false; + } + if (endDate != null && + swap.myInfo.startedAt > (endDate + millisecondsIn24H) / 1000) { + return false; + } + if (statuses != null && statuses.isNotEmpty) { + if (statuses.contains(TradingStatus.successful) && + statuses.contains(TradingStatus.failed)) return true; + if (statuses.contains(TradingStatus.successful)) { + return swap.isSuccessful; + } + if (statuses.contains(TradingStatus.failed)) return swap.isFailed; + } + + if (shownSides != null && + shownSides.isNotEmpty && + !shownSides.contains(swap.type)) return false; + + return true; + }).toList(); +} + +List applyFiltersForOrders( + List orders, TradingEntitiesFilter entitiesFilterData) { + return orders.where((order) { + final String? sellCoin = entitiesFilterData.sellCoin; + final String? buyCoin = entitiesFilterData.buyCoin; + final int? startDate = entitiesFilterData.startDate?.millisecondsSinceEpoch; + final int? endDate = entitiesFilterData.endDate?.millisecondsSinceEpoch; + final List? shownSides = entitiesFilterData.shownSides; + + if (sellCoin != null && order.base != sellCoin) return false; + if (buyCoin != null && order.rel != buyCoin) return false; + if (startDate != null && order.createdAt < startDate / 1000) return false; + if (endDate != null && + order.createdAt > (endDate + millisecondsIn24H) / 1000) return false; + if ((shownSides != null && shownSides.isNotEmpty) && + !shownSides.contains(order.orderType)) return false; + + return true; + }).toList(); +} + +Map> getCoinAbbrMapFromOrderList( + List list, bool isSellCoin) { + final Map> coinAbbrMap = isSellCoin + ? list.fold>>({}, (previousValue, element) { + final List coinAbbrList = previousValue[element.base] ?? []; + coinAbbrList.add(element.rel); + previousValue[element.base] = coinAbbrList; + return previousValue; + }) + : list.fold>>({}, (previousValue, element) { + final List coinAbbrList = previousValue[element.rel] ?? []; + coinAbbrList.add(element.base); + previousValue[element.rel] = coinAbbrList; + return previousValue; + }); + return coinAbbrMap; +} + +Map> getCoinAbbrMapFromSwapList( + List list, bool isSellCoin) { + final Map> coinAbbrMap = isSellCoin + ? list.fold>>({}, (previousValue, element) { + final List coinAbbrList = + previousValue[element.sellCoin] ?? []; + coinAbbrList.add(element.buyCoin); + previousValue[element.sellCoin] = coinAbbrList; + return previousValue; + }) + : list.fold>>({}, (previousValue, element) { + final List coinAbbrList = + previousValue[element.buyCoin] ?? []; + coinAbbrList.add(element.sellCoin); + previousValue[element.buyCoin] = coinAbbrList; + return previousValue; + }); + return coinAbbrMap; +} + +int getCoinPairsCountFromCoinAbbrMap(Map> coinAbbrMap, + String coinAbbr, String? secondCoinAbbr) { + return (coinAbbrMap[coinAbbr] ?? []) + .where((abbr) => secondCoinAbbr == null || secondCoinAbbr == abbr) + .toList() + .length; +} + +void removeSuspendedCoinOrders( + List orders, AuthorizeMode authorizeMode) { + if (authorizeMode == AuthorizeMode.noLogin) return; + orders.removeWhere((BestOrder order) { + final Coin? coin = coinsBloc.getCoin(order.coin); + if (coin == null) return true; + + return coin.isSuspended; + }); +} + +void removeWalletOnlyCoinOrders(List orders) { + orders.removeWhere((BestOrder order) { + final Coin? coin = coinsBloc.getCoin(order.coin); + if (coin == null) return true; + + return coin.walletOnly; + }); +} + +/// Compares the rate of a decentralized exchange (DEX) with a centralized exchange (CEX) in percentage. +/// +/// The comparison is based on the provided exchange rates and a given [rate] of the DEX. +/// The DEX rate is converted to a double using `toDouble()` from the [rate], while the CEX rate +/// is calculated as the ratio of [baseUsdPrice] to [relUsdPrice]. +/// The method then computes the percentage difference between the DEX rate and CEX rate. +/// If either [baseUsdPrice] or [relUsdPrice] is 0, or the [rate] is equal to zero (Rational.zero), +/// the comparison result will be 0 to avoid potential division by zero. +/// +/// Parameters: +/// - [baseUsdPrice] (double): The USD price of the base currency on the centralized exchange (CEX). +/// - [relUsdPrice] (double): The USD price of the relative currency on the centralized exchange (CEX). +/// - [rate] (Rational): The rate of the base currency to the relative currency on the decentralized exchange (DEX). +/// +/// Return Value: +/// - (double): The percentage difference between the DEX rate and CEX rate. +/// +/// Example Usage: +/// ```dart +/// double cexBasePrice = 5000.0; // USD price of the base currency on CEX +/// double cexRelPrice = 100.0; // USD price of the relative currency on CEX +/// Rational dexRate = Rational.fromDouble(40); // DEX rate: 40 base currency units per relative currency unit +/// +/// double comparisonResult = compareToCex(cexBasePrice, cexRelPrice, dexRate); +/// print(comparisonResult); // Output: 1000.0 (percentage difference: 1000%) +/// ``` +/// ```dart +/// double cexBasePrice = 10.0; // USD price of the base currency on CEX +/// double cexRelPrice = 5.0; // USD price of the relative currency on CEX +/// Rational dexRate = Rational.fromInt(1); // DEX rate: 1 base currency unit per relative currency unit +/// +/// double comparisonResult = compareToCex(cexBasePrice, cexRelPrice, dexRate); +/// print(comparisonResult); // Output: -50.0 (percentage difference: -50%) +/// ``` +/// unit tests: [compare_dex_to_cex_tests] +double compareToCex(double baseUsdPrice, double relUsdPrice, Rational rate) { + if (baseUsdPrice == 0 || relUsdPrice == 0) return 0; + if (rate == Rational.zero) return 0; + + final double dexRate = rate.toDouble(); + final double cexRate = baseUsdPrice / relUsdPrice; + + return (dexRate - cexRate) * 100 / cexRate; +} + +Future> activateCoinIfNeeded(String? abbr) async { + final List errors = []; + if (abbr == null) return errors; + + final Coin? coin = coinsBloc.getCoin(abbr); + if (coin == null) return errors; + + if (!coin.isActive) { + try { + await coinsBloc.activateCoins([coin]); + } catch (e) { + errors.add(DexFormError( + error: '${LocaleKeys.unableToActiveCoin.tr(args: [coin.abbr])}: $e')); + } + } else { + final Coin? parentCoin = coin.parentCoin; + if (parentCoin != null && !parentCoin.isActive) { + try { + await coinsBloc.activateCoins([parentCoin]); + } catch (e) { + errors.add(DexFormError( + error: + '${LocaleKeys.unableToActiveCoin.tr(args: [coin.abbr])}: $e')); + } + } + } + + return errors; +} + +Future reInitTradingForms() async { + // If some of the DEX or Bridge forms were modified by user during + // interaction in 'no-login' mode, their blocs may link to special + // instances of [Coin], initialized in that mode. + // After login to iguana wallet, + // we must replace them with regular [Coin] instances, and + // auto-activate corresponding coins if needed + await makerFormBloc.reInitForm(); +} + +/// unit tests: [testMaxMinRational] +Rational? maxRational(List values) { + if (values.isEmpty) return null; + + Rational maxValue = values.first; + for (Rational value in values) { + if (value > maxValue) maxValue = value; + } + + return maxValue; +} + +/// unit tests: [testMaxMinRational] +Rational? minRational(List values) { + if (values.isEmpty) return null; + + Rational minValue = values.first; + for (Rational value in values) { + if (value < minValue) minValue = value; + } + + return minValue; +} + +/// Returns the amount of the buy currency that can be bought for the given sell amount and selected order. +/// Parameters: +/// - [sellAmount] (Rational): The amount of the sell currency to be sold. +/// - [selectedOrder] (BestOrder): The selected order. +/// Return Value: +/// - (Rational): The amount of the buy currency that can be bought for the given sell amount and selected order. +/// Example Usage: +/// ```dart +/// Rational sellAmount = Rational.fromInt(100); +/// BestOrder selectedOrder = BestOrder(price: Rational.fromInt(10), ...); +/// Rational buyAmount = calculateBuyAmount(sellAmount: sellAmount, selectedOrder: selectedOrder); +/// print(buyAmount); // Output: 1000 +/// ``` +/// unit tests: [testCalculateBuyAmount] +Rational? calculateBuyAmount({ + required Rational? sellAmount, + required BestOrder? selectedOrder, +}) { + if (sellAmount == null) return null; + if (selectedOrder == null) return null; + + return selectedOrder.price * sellAmount; +} + +/// Calculates and formats the total fee amount based on a list of [TradePreimageExtendedFeeInfo]. +/// +/// The method calculates the total fee amount in USD equivalent for each fee in the [totalFeesInitial] list. +/// The provided [getCoin] function is used to retrieve the Coin object based on its abbreviation. +/// The method then formats the total fee amount and returns it as a string. +/// +/// Parameters: +/// - [totalFeesInitial] (List?): List of fee information objects. +/// - [getCoin] (Coin Function(String abbr)): Function to retrieve Coin objects based on abbreviation. +/// +/// Return Value: +/// - (String): The formatted total fee amount string. +/// +/// Example Usage: +/// ```dart +/// List fees = [ +/// TradePreimageExtendedFeeInfo('BTC', '0.001'), +/// TradePreimageExtendedFeeInfo('ETH', '0.01'), +/// TradePreimageExtendedFeeInfo('USD', '5.0'), +/// ]; +/// String result = getTotalFee(fees, (abbr) => Coin(abbr)); +/// print(result); // Output: "\$6.01 +0.001 BTC +0.01 ETH" +/// ``` +/// unit tests: [testGetTotalFee] +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 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; + final String coin = element.key; + if (amount == 0) return previousValue; + + if (previousValue.isNotEmpty) previousValue += ' +$_nbsp'; + if (coin == 'USD') { + previousValue += '\$${cutTrailingZeros(formatAmt(amount))}'; + } else { + previousValue += + '${cutTrailingZeros(formatAmt(amount))}$_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); + + if (feeUsdAmount > 0) { + previousValue['USD'] = previousValue['USD']! + feeUsdAmount; + } else if (feeAmount > 0) { + previousValue[fee.coin] = feeAmount; + } + return previousValue; +} + +/// Calculates the sell amount based on the maximum sell amount and a fraction. +////// Parameters: +/// - [amount] (Rational): The maximum sell amount for a trade. +/// - [fraction] (double): The fraction of the [amount] to be calculated. +/// +/// Return Value: +/// - (Rational): The calculated sell amount based on the provided [amount] and [fraction]. +/// +/// Example Usage: +/// ```dart +/// Rational maxSellAmount = Rational.fromInt(100); +/// double fraction = 0.75; +/// Rational result = getSellAmount(maxSellAmount, fraction); +/// print(result); // Output: 75 +/// ``` +/// unit tests: [testGetSellAmount] +Rational getFractionOfAmount(Rational amount, double fraction) { + final Rational fractionedAmount = amount * Rational.parse('$fraction'); + return fractionedAmount; +} + +/// Return the price and buy amount based on provided values of sellAmount, price and buyAmount. +/// +/// Parameters: +/// - [sellAmount] (Rational?): The sell amount value. +/// - [price] (Rational?): The price value. +/// - [buyAmount] (Rational?): The buy amount value. +/// +/// Return Value: +/// - ((Rational?, Rational?)?): A tuple containing the updated [buyAmount] and [price]. +/// +/// Example Usage: +/// ```dart +/// Rational? sellAmount = Rational.fromInt(100); +/// Rational? price = Rational.fromInt(2); +/// Rational? buyAmount = null; +/// var result = updateSellAmount(sellAmount, price, buyAmount); +/// print(result); // Output: (200, 2) +/// ``` +(Rational?, Rational?)? processBuyAmountAndPrice( + Rational? sellAmount, Rational? price, Rational? buyAmount) { + if (sellAmount == null) return null; + if (price == null && buyAmount == null) return null; + if (price != null) { + buyAmount = sellAmount * price; + return (buyAmount, price); + } else if (buyAmount != null) { + try { + price = buyAmount / sellAmount; + return (buyAmount, price); + } catch (_) { + return (buyAmount, null); + } + } + return (buyAmount, price); +} diff --git a/lib/views/dex/dex_list_filter/common/dex_list_filter_type.dart b/lib/views/dex/dex_list_filter/common/dex_list_filter_type.dart new file mode 100644 index 0000000000..a1b0359420 --- /dev/null +++ b/lib/views/dex/dex_list_filter/common/dex_list_filter_type.dart @@ -0,0 +1,179 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class DexListFilterType extends StatelessWidget { + const DexListFilterType({ + Key? key, + required this.values, + required this.selectedValues, + required this.onChange, + required this.label, + required this.isMobile, + required this.titile, + }) : super(key: key); + + final Function(List?) onChange; + final List> values; + final List? selectedValues; + final String label; + final bool isMobile; + final String titile; + + @override + Widget build(BuildContext context) { + return isMobile + ? _DexListFilterTypeMobile( + label: label, + selectedValues: selectedValues, + values: values, + onChange: onChange, + ) + : _DexListFilterTypeDesktop( + title: titile, + selectedValues: selectedValues, + onChange: onChange, + values: values, + ); + } +} + +class _DexListFilterTypeDesktop extends StatelessWidget { + const _DexListFilterTypeDesktop( + {Key? key, + required this.values, + required this.selectedValues, + required this.onChange, + required this.title}) + : super(key: key); + + final List> values; + final List? selectedValues; + final Function(List) onChange; + final String title; + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).brightness == Brightness.light + ? newThemeLight + : newThemeDark, + child: Builder(builder: (context) { + final ext = Theme.of(context).extension(); + return MultiSelectDropdownButton( + title: title, + items: values.map((e) => e.value).toList(), + displayItem: (p0) => + values.firstWhere((element) => element.value == p0).label, + selectedItems: selectedValues, + onChanged: onChange, + colorScheme: UIChipColorScheme( + emptyContainerColor: ext?.surfCont, + emptyTextColor: ext?.s70, + pressedContainerColor: ext?.surfContLowest, + selectedContainerColor: ext?.primary, + selectedTextColor: ext?.surf, + ), + ); + }), + ); + } +} + +class _DexListFilterTypeMobile extends StatelessWidget { + const _DexListFilterTypeMobile({ + Key? key, + required this.label, + required this.values, + required this.selectedValues, + required this.onChange, + }) : super(key: key); + + final List> values; + final List? selectedValues; + final String label; + + final Function(List?) onChange; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + flex: 2, + child: Wrap( + alignment: WrapAlignment.spaceBetween, + children: [ + _buildItem(values, LocaleKeys.all.tr(), context), + ...values.map((v) => _buildItem([v], v.label, context)), + ], + ), + ), + ], + ); + } + + Widget _buildItem(final List> values, String label, + BuildContext context) { + const double borderWidth = 2.0; + const double topPadding = 6.0; + final selectedValues = this.selectedValues; + final bool isSelected = selectedValues != null && + selectedValues.length == values.length && + selectedValues + .every((sv) => values.where((v) => v.value == sv).isNotEmpty); + + return Padding( + padding: isSelected + ? const EdgeInsets.only(top: topPadding) + : const EdgeInsets.fromLTRB( + borderWidth, + topPadding + borderWidth, + borderWidth, + borderWidth, + ), + child: InkWell( + onTap: () => onChange(values.map((e) => e.value).toList()), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(26), + border: isSelected + ? Border.all( + color: theme.custom.defaultBorderButtonBorder, width: 2) + : null, + ), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 12), + child: Text( + label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } +} + +class DexListFilterTypeValue { + DexListFilterTypeValue({ + required this.label, + required this.value, + }); + final String label; + final T value; +} diff --git a/lib/views/dex/dex_list_filter/desktop/dex_list_filter_coin_desktop.dart b/lib/views/dex/dex_list_filter/desktop/dex_list_filter_coin_desktop.dart new file mode 100644 index 0000000000..2d94e3d297 --- /dev/null +++ b/lib/views/dex/dex_list_filter/desktop/dex_list_filter_coin_desktop.dart @@ -0,0 +1,212 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/dex_list_type.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; + +class DexListFilterCoinDesktop extends StatelessWidget { + const DexListFilterCoinDesktop({ + Key? key, + required this.label, + required this.coinAbbr, + required this.anotherCoinAbbr, + required this.isSellCoin, + required this.listType, + required this.onCoinSelect, + }) : super(key: key); + final String label; + final String? coinAbbr; + final String? anotherCoinAbbr; + final bool isSellCoin; + final DexListType listType; + final void Function(String?) onCoinSelect; + + @override + Widget build(BuildContext context) { + switch (listType) { + case DexListType.orders: + return StreamBuilder>( + stream: tradingEntitiesBloc.outMyOrders, + initialData: tradingEntitiesBloc.myOrders, + builder: (context, snapshot) { + final list = snapshot.data ?? []; + final Map> coinAbbrMap = + getCoinAbbrMapFromOrderList(list, isSellCoin); + + return _DropDownButton( + label: label, + onCoinSelect: onCoinSelect, + value: coinAbbr, + items: _getItems(coinAbbrMap), + selectedItemBuilder: (context) => _getItems( + coinAbbrMap, + selected: true, + ), + ); + }, + ); + case DexListType.inProgress: + case DexListType.history: + return StreamBuilder>( + stream: tradingEntitiesBloc.outSwaps, + initialData: tradingEntitiesBloc.swaps, + builder: (context, snapshot) { + final list = snapshot.data ?? []; + final filtered = listType == DexListType.history + ? list.where((s) => s.isCompleted).toList() + : list.where((s) => !s.isCompleted).toList(); + final Map> coinAbbrMap = + getCoinAbbrMapFromSwapList(filtered, isSellCoin); + + return _DropDownButton( + label: label, + onCoinSelect: onCoinSelect, + value: coinAbbr, + items: _getItems(coinAbbrMap), + selectedItemBuilder: (context) => _getItems( + coinAbbrMap, + selected: true, + ), + ); + }, + ); + case DexListType.swap: + return const SizedBox(); + } + } + + List> _getItems( + Map> coinAbbrMap, { + bool selected = false, + }) { + final Iterable coinAbbrList = + coinAbbrMap.keys.where((abbr) => abbr != anotherCoinAbbr); + + return selected + ? coinAbbrList.map((abbr) { + return _buildSelectedItem(abbr); + }).toList() + : coinAbbrList.map((abbr) { + final int pairsCount = getCoinPairsCountFromCoinAbbrMap( + coinAbbrMap, + abbr, + anotherCoinAbbr, + ); + return _buildItem(abbr, pairsCount); + }).toList(); + } + + DropdownMenuItem _buildItem( + String coinAbbr, + int pairsCount, + ) { + final Coin? coin = coinsBloc.getCoin(coinAbbr); + if (coin == null) return const DropdownMenuItem(child: SizedBox()); + + return DropdownMenuItem( + value: coinAbbr, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible(child: CoinItem(coin: coin, size: CoinItemSize.small)), + const SizedBox(width: 4), + Text( + '($pairsCount)', + style: TextStyle( + color: theme.currentGlobal.textTheme.bodyMedium?.color, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } + + DropdownMenuItem _buildSelectedItem(String coinAbbr) { + final Coin? coin = coinsBloc.getCoin(coinAbbr); + if (coin == null) return const DropdownMenuItem(child: SizedBox()); + + return DropdownMenuItem( + value: coinAbbr, + child: Padding( + padding: const EdgeInsets.fromLTRB(4, 0, 4, 0), + child: CoinItem(coin: coin, size: CoinItemSize.small), + ), + ); + } + + String get innerText { + return coinAbbr ?? label; + } +} + +class _DropDownButton extends StatelessWidget { + const _DropDownButton({ + required this.label, + required this.onCoinSelect, + required this.value, + required this.items, + required this.selectedItemBuilder, + }); + + final String label; + final void Function(String? p1) onCoinSelect; + final String? value; + final List> items; + final List Function(BuildContext)? selectedItemBuilder; + + @override + Widget build(BuildContext context) { + return Theme( + data: Theme.of(context).brightness == Brightness.light + ? newThemeLight + : newThemeDark, + child: Builder( + builder: (context) { + final ext = Theme.of(context).extension(); + return Container( + padding: value != null && value!.isNotEmpty + ? const EdgeInsets.symmetric(horizontal: 8, vertical: 4) + : const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: value != null && value!.isNotEmpty + ? ext?.primary + : ext?.surfCont, + borderRadius: BorderRadius.circular(15), + ), + constraints: const BoxConstraints(maxHeight: 50), + child: DropdownButton( + hint: Text( + label, + style: theme.currentGlobal.textTheme.bodySmall?.copyWith( + color: value != null && value!.isNotEmpty + ? ext?.surf + : ext?.s70), + ), + iconSize: 12, + value: value, + items: items, + onChanged: onCoinSelect, + focusColor: Colors.transparent, + icon: Icon( + Icons.keyboard_arrow_down, + color: + value != null && value!.isNotEmpty ? ext?.surf : ext?.s70, + ), + underline: const SizedBox(), + isExpanded: true, + selectedItemBuilder: selectedItemBuilder, + ), + ); + }, + ), + ); + } +} diff --git a/lib/views/dex/dex_list_filter/desktop/dex_list_filter_desktop.dart b/lib/views/dex/dex_list_filter/desktop/dex_list_filter_desktop.dart new file mode 100644 index 0000000000..46df93a1e2 --- /dev/null +++ b/lib/views/dex/dex_list_filter/desktop/dex_list_filter_desktop.dart @@ -0,0 +1,230 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/dex_list_type.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/views/dex/dex_list_filter/common/dex_list_filter_type.dart'; +import 'package:web_dex/views/dex/dex_list_filter/desktop/dex_list_filter_coin_desktop.dart'; + +class DexListFilterDesktop extends StatefulWidget { + const DexListFilterDesktop({ + Key? key, + this.filterData, + required this.onApplyFilter, + required this.listType, + }) : super(key: key); + final TradingEntitiesFilter? filterData; + final DexListType listType; + final void Function(TradingEntitiesFilter?) onApplyFilter; + + @override + State createState() => _DexListFilterDesktopState(); +} + +const double _itemHeight = 42; + +class _DexListFilterDesktopState extends State { + late TradingEntitiesFilter _filterData; + + @override + void initState() { + _update(); + + super.initState(); + } + + @override + void didUpdateWidget(covariant DexListFilterDesktop oldWidget) { + if (oldWidget.filterData != widget.filterData) _update(); + + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(15), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: Theme.of(context).colorScheme.onSurface, + ), + child: SizedBox( + width: double.infinity, + child: Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 18, + children: [ + Wrap( + runSpacing: 8, + spacing: 6, + children: [ + ConstrainedBox( + constraints: const BoxConstraints.tightFor( + width: 130, height: _itemHeight), + child: DexListFilterCoinDesktop( + label: LocaleKeys.buyAsset.tr(), + coinAbbr: _filterData.buyCoin, + isSellCoin: false, + listType: widget.listType, + anotherCoinAbbr: widget.filterData?.sellCoin, + onCoinSelect: (String? coin) { + setState(() { + _filterData.buyCoin = coin; + }); + _applyFiltersData(); + }, + ), + ), + ConstrainedBox( + constraints: const BoxConstraints.tightFor( + width: 130, height: _itemHeight), + child: DexListFilterCoinDesktop( + label: LocaleKeys.sellAsset.tr(), + coinAbbr: _filterData.sellCoin, + isSellCoin: true, + listType: widget.listType, + anotherCoinAbbr: widget.filterData?.buyCoin, + onCoinSelect: (String? coin) { + setState(() { + _filterData.sellCoin = coin; + }); + _applyFiltersData(); + }, + ), + ), + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 113, maxHeight: _itemHeight), + child: UiDatePicker( + formatter: DateFormat('dd.MM.yyyy').format, + date: _filterData.startDate, + text: LocaleKeys.fromDate.tr(), + endDate: _filterData.endDate, + onDateSelect: (time) { + setState(() { + _filterData.startDate = time; + }); + _applyFiltersData(); + }, + ), + ), + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 113, maxHeight: _itemHeight), + child: UiDatePicker( + formatter: DateFormat('dd.MM.yyyy').format, + date: _filterData.endDate, + text: LocaleKeys.toDate.tr(), + startDate: _filterData.startDate, + onDateSelect: (time) { + setState(() { + _filterData.endDate = time; + }); + _applyFiltersData(); + }, + ), + ), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: _itemHeight), + child: DexListFilterType( + titile: 'Trade Side', + values: [ + DexListFilterTypeValue( + label: LocaleKeys.taker.tr(), + value: TradeSide.taker, + ), + DexListFilterTypeValue( + label: LocaleKeys.maker.tr(), + value: TradeSide.maker, + ), + ], + selectedValues: _filterData.shownSides, + onChange: (shownSides) { + setState(() { + _filterData.shownSides = shownSides; + }); + _applyFiltersData(); + }, + label: '${LocaleKeys.taker.tr()}/${LocaleKeys.maker.tr()}', + isMobile: false, + ), + ), + if (widget.listType == DexListType.history) + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: _itemHeight), + child: DexListFilterType( + titile: 'Trading Status', + values: [ + DexListFilterTypeValue( + label: LocaleKeys.successful.tr(), + value: TradingStatus.successful, + ), + DexListFilterTypeValue( + label: LocaleKeys.failed.tr(), + value: TradingStatus.failed, + ), + ], + selectedValues: _filterData.statuses, + onChange: (statuses) { + setState(() { + _filterData.statuses = statuses; + }); + _applyFiltersData(); + }, + label: LocaleKeys.status.tr(), + isMobile: false, + ), + ), + ], + ), + InkWell( + onTap: () { + _reset(); + _applyFiltersData(); + }, + child: Theme( + data: Theme.of(context).brightness == Brightness.light + ? newThemeLight + : newThemeDark, + child: Builder( + builder: (context) { + final ext = + Theme.of(context).extension(); + return UIChip( + showIcon: false, + title: LocaleKeys.clearFilter.tr(), + status: _filterData.isEmpty + ? UIChipState.empty + : UIChipState.selected, + colorScheme: UIChipColorScheme( + emptyContainerColor: ext?.surfCont, + emptyTextColor: ext?.s70, + pressedContainerColor: ext?.surfContLowest, + selectedContainerColor: ext?.primary, + selectedTextColor: ext?.surf, + ), + ); + }, + ), + ), + ), + ], + ), + ), + ); + } + + void _applyFiltersData() => widget.onApplyFilter(_filterData); + + void _reset() => setState(() { + _filterData = TradingEntitiesFilter(); + }); + + void _update() => setState(() { + _filterData = TradingEntitiesFilter.from(widget.filterData); + }); +} diff --git a/lib/views/dex/dex_list_filter/mobile/dex_list_filter_coin_mobile.dart b/lib/views/dex/dex_list_filter/mobile/dex_list_filter_coin_mobile.dart new file mode 100644 index 0000000000..a2e970f5c1 --- /dev/null +++ b/lib/views/dex/dex_list_filter/mobile/dex_list_filter_coin_mobile.dart @@ -0,0 +1,71 @@ +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/coin_icon.dart'; + +class DexListFilterCoinMobile extends StatelessWidget { + const DexListFilterCoinMobile({ + Key? key, + required this.label, + required this.coinAbbr, + required this.showCoinList, + }) : super(key: key); + final String label; + final String? coinAbbr; + final VoidCallback showCoinList; + + @override + Widget build(BuildContext context) { + final String? abbr = coinAbbr; + final ThemeData themeData = Theme.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + label, + style: TextStyle( + color: themeData.inputDecorationTheme.labelStyle?.color, + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + InkWell( + radius: 18, + onTap: showCoinList, + child: Container( + constraints: const BoxConstraints.tightFor(width: double.infinity), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 18), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: themeData.inputDecorationTheme.fillColor, + ), + child: Row( + children: [ + if (abbr != null) CoinIcon(abbr), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + innerText, + style: themeData.textTheme.bodyLarge?.copyWith( + fontSize: 14, + ), + ), + ), + const Spacer(), + Icon(Icons.expand_more, + color: themeData.inputDecorationTheme.labelStyle?.color) + ], + ), + ), + ), + ], + ); + } + + String get innerText { + return coinAbbr ?? LocaleKeys.exchangeCoin.tr(); + } +} diff --git a/lib/views/dex/dex_list_filter/mobile/dex_list_filter_coins_list_mobile.dart b/lib/views/dex/dex_list_filter/mobile/dex_list_filter_coins_list_mobile.dart new file mode 100644 index 0000000000..98f1e936a1 --- /dev/null +++ b/lib/views/dex/dex_list_filter/mobile/dex_list_filter_coins_list_mobile.dart @@ -0,0 +1,161 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.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/dex_list_type.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class DexListFilterCoinsList extends StatefulWidget { + const DexListFilterCoinsList({ + Key? key, + required this.isSellCoin, + required this.anotherCoin, + required this.onCoinSelect, + required this.listType, + }) : super(key: key); + final DexListType listType; + final bool isSellCoin; + final String? anotherCoin; + final void Function(String?) onCoinSelect; + + @override + State createState() => _DexListFilterCoinsListState(); +} + +class _DexListFilterCoinsListState extends State { + String _searchPhrase = ''; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 24, 12, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + UiTextFormField( + hintText: LocaleKeys.searchAssets.tr(), + onChanged: (String searchPhrase) { + setState(() { + _searchPhrase = searchPhrase; + }); + }, + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only(top: 24), + child: widget.listType == DexListType.orders + ? _buildOrderCoinList() + : _buildSwapCoinList(), + ), + ), + UiUnderlineTextButton( + height: 62, + text: LocaleKeys.cancel.tr(), + onPressed: () => widget.onCoinSelect(null), + ), + ], + ), + ); + } + + Widget _buildSwapCoinList() { + return StreamBuilder>( + stream: tradingEntitiesBloc.outSwaps, + initialData: tradingEntitiesBloc.swaps, + builder: (context, snapshot) { + final list = snapshot.data ?? []; + final filtered = widget.listType == DexListType.history + ? list.where((s) => s.isCompleted).toList() + : list.where((s) => !s.isCompleted).toList(); + final Map> coinAbbrMap = + getCoinAbbrMapFromSwapList(filtered, widget.isSellCoin); + + return _buildCoinList(coinAbbrMap); + }); + } + + Widget _buildOrderCoinList() { + return StreamBuilder>( + stream: tradingEntitiesBloc.outMyOrders, + initialData: tradingEntitiesBloc.myOrders, + builder: (context, snapshot) { + final list = snapshot.data ?? []; + final Map> coinAbbrMap = + getCoinAbbrMapFromOrderList(list, widget.isSellCoin); + + return _buildCoinList(coinAbbrMap); + }); + } + + Widget _buildCoinList(Map> coinAbbrMap) { + final List coinAbbrList = (_searchPhrase.isEmpty + ? coinAbbrMap.keys.toList() + : coinAbbrMap.keys.where((String coinAbbr) => + coinAbbr.toLowerCase().contains(_searchPhrase))) + .where((abbr) => abbr != widget.anotherCoin) + .toList(); + + final int lastIndex = coinAbbrList.length - 1; + + final scrollController = ScrollController(); + return DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: ListView.builder( + controller: scrollController, + shrinkWrap: true, + itemCount: coinAbbrList.length, + itemBuilder: (BuildContext context, int i) { + final coinAbbr = coinAbbrList[i]; + final String? anotherCoinAbbr = widget.anotherCoin; + final coinPairsCount = getCoinPairsCountFromCoinAbbrMap( + coinAbbrMap, + coinAbbr, + anotherCoinAbbr, + ); + + return Padding( + padding: EdgeInsets.fromLTRB( + 18, + 5.0, + 18, + lastIndex == i ? 20.0 : 0.0, + ), + child: _buildCoinListItem(coinAbbr, coinPairsCount), + ); + }), + ); + } + + Widget _buildCoinListItem(String coinAbbr, int pairCount) { + final bool isSegwit = Coin.checkSegwitByAbbr(coinAbbr); + return Material( + borderRadius: BorderRadius.circular(15), + color: Theme.of(context).cardColor, + child: InkWell( + borderRadius: BorderRadius.circular(15), + onTap: () => widget.onCoinSelect(coinAbbr), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 5, horizontal: 8), + child: Row( + children: [ + CoinIcon(coinAbbr), + Padding( + padding: const EdgeInsets.only(left: 5.0), + child: Text( + '${Coin.normalizeAbbr(coinAbbr)} ${isSegwit ? ' (segwit)' : ''}'), + ), + const Spacer(), + Text('($pairCount)'), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/dex/dex_list_filter/mobile/dex_list_filter_mobile.dart b/lib/views/dex/dex_list_filter/mobile/dex_list_filter_mobile.dart new file mode 100644 index 0000000000..09edf22b5e --- /dev/null +++ b/lib/views/dex/dex_list_filter/mobile/dex_list_filter_mobile.dart @@ -0,0 +1,200 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/dex_list_type.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/views/dex/dex_list_filter/common/dex_list_filter_type.dart'; +import 'package:web_dex/views/dex/dex_list_filter/mobile/dex_list_filter_coin_mobile.dart'; +import 'package:web_dex/views/dex/dex_list_filter/mobile/dex_list_filter_coins_list_mobile.dart'; + +class DexListFilterMobile extends StatefulWidget { + const DexListFilterMobile({ + Key? key, + required this.onApplyFilter, + required this.listType, + required this.filterData, + }) : super(key: key); + final TradingEntitiesFilter? filterData; + final DexListType listType; + final void Function(TradingEntitiesFilter?) onApplyFilter; + + @override + State createState() => _DexListFilterMobileState(); +} + +class _DexListFilterMobileState extends State { + bool _isSellCoin = false; + bool _isCoinListShown = false; + late TradingEntitiesFilter _filterData; + + @override + void initState() { + _update(); + + super.initState(); + } + + @override + void didUpdateWidget(covariant DexListFilterMobile oldWidget) { + if (oldWidget.filterData != widget.filterData) _update(); + + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return _isCoinListShown ? _buildList(false) : _buildMobileFilters(); + } + + Widget _buildMobileFilters() { + return SingleChildScrollView( + controller: ScrollController(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + DexListFilterCoinMobile( + label: LocaleKeys.sellAsset.tr(), + coinAbbr: _filterData.sellCoin, + showCoinList: () { + setState(() { + _isCoinListShown = true; + _isSellCoin = true; + }); + }, + ), + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: DexListFilterCoinMobile( + label: LocaleKeys.buyAsset.tr(), + coinAbbr: _filterData.buyCoin, + showCoinList: () { + setState(() { + _isCoinListShown = true; + _isSellCoin = false; + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: DexListFilterType( + titile: 'Trade State', + values: [ + DexListFilterTypeValue( + label: LocaleKeys.taker.tr(), + value: TradeSide.taker, + ), + DexListFilterTypeValue( + label: LocaleKeys.maker.tr(), + value: TradeSide.maker, + ), + ], + selectedValues: _filterData.shownSides, + onChange: (shownSides) => setState(() { + _filterData.shownSides = shownSides; + }), + label: '${LocaleKeys.taker.tr()}/${LocaleKeys.maker.tr()}', + isMobile: true, + ), + ), + if (widget.listType == DexListType.history) + Padding( + padding: const EdgeInsets.only(top: 20), + child: DexListFilterType( + titile: 'Trading Status', + values: [ + DexListFilterTypeValue( + label: LocaleKeys.successful.tr(), + value: TradingStatus.successful, + ), + DexListFilterTypeValue( + label: LocaleKeys.failed.tr(), + value: TradingStatus.failed, + ), + ], + selectedValues: _filterData.statuses, + onChange: (statuses) => setState(() { + _filterData.statuses = statuses; + }), + label: LocaleKeys.status.tr(), + isMobile: true, + ), + ), + Container( + padding: const EdgeInsets.only(top: 32), + width: double.infinity, + child: Wrap( + runSpacing: 12.0, + alignment: WrapAlignment.spaceBetween, + children: [ + UiDatePicker( + formatter: DateFormat('dd.MM.yyyy').format, + date: _filterData.startDate, + text: LocaleKeys.fromDate.tr(), + endDate: _filterData.endDate, + onDateSelect: (time) { + setState(() { + _filterData.startDate = time; + }); + }, + ), + UiDatePicker( + formatter: DateFormat('dd.MM.yyyy').format, + date: _filterData.endDate, + text: LocaleKeys.toDate.tr(), + startDate: _filterData.startDate, + onDateSelect: (time) { + setState(() { + _filterData.endDate = time; + }); + }, + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(top: 32.0), + child: UiPrimaryButton( + text: LocaleKeys.apply.tr(), + onPressed: () => _applyFiltersData(), + ), + ), + ], + ), + ); + } + + Widget _buildList(bool isApply) { + return DexListFilterCoinsList( + listType: widget.listType, + isSellCoin: _isSellCoin, + anotherCoin: _isSellCoin ? _filterData.buyCoin : _filterData.sellCoin, + onCoinSelect: (String? abbr) { + if (_isSellCoin) { + setState(() { + _filterData.sellCoin = abbr; + _isCoinListShown = false; + }); + } else { + setState(() { + _filterData.buyCoin = abbr; + _isCoinListShown = false; + }); + } + if (isApply) { + _applyFiltersData(); + } + }, + ); + } + + void _applyFiltersData() => widget.onApplyFilter( + _filterData, + ); + + void _update() => setState(() { + _filterData = TradingEntitiesFilter.from(widget.filterData); + }); +} diff --git a/lib/views/dex/dex_list_filter/mobile/dex_list_header_mobile.dart b/lib/views/dex/dex_list_filter/mobile/dex_list_header_mobile.dart new file mode 100644 index 0000000000..95e5ce8ccb --- /dev/null +++ b/lib/views/dex/dex_list_filter/mobile/dex_list_header_mobile.dart @@ -0,0 +1,327 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.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/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/dex_list_type.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; + +class DexListHeaderMobile extends StatelessWidget { + const DexListHeaderMobile({ + Key? key, + required this.listType, + required this.entitiesFilterData, + required this.onFilterPressed, + required this.onFilterDataChange, + required this.isFilterShown, + }) : super(key: key); + final DexListType listType; + final TradingEntitiesFilter? entitiesFilterData; + final bool isFilterShown; + final VoidCallback onFilterPressed; + final void Function(TradingEntitiesFilter?) onFilterDataChange; + + @override + Widget build(BuildContext context) { + final List filterElements = _getFilterElements(context); + final filterData = entitiesFilterData; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildFilterButton(context), + if (listType == DexListType.orders) + UiPrimaryButton( + text: LocaleKeys.cancelAll.tr(), + width: 100, + height: 30, + onPressed: () => tradingEntitiesBloc.cancelAllOrders(), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + if (filterData != null) + Padding( + padding: const EdgeInsets.only(top: 12.0), + child: SingleChildScrollView( + controller: ScrollController(), + scrollDirection: Axis.horizontal, + child: Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: filterElements, + ), + ), + ), + ), + ], + ); + } + + List _getFilterElements(BuildContext context) { + final filterData = entitiesFilterData; + + final String? sellCoin = filterData?.sellCoin; + final String? buyCoin = filterData?.buyCoin; + + final DateTime? startDate = filterData?.startDate; + final DateTime? endDate = filterData?.endDate; + final String? startDateString = + startDate != null ? DateFormat('dd.MM.yyyy').format(startDate) : null; + final String? endDateString = + endDate != null ? DateFormat('dd.MM.yyyy').format(endDate) : null; + + final List? statuses = filterData?.statuses; + final List? shownSides = filterData?.shownSides; + + List children = []; + + if (buyCoin != null) { + children.add( + _buildManageFilterItem( + LocaleKeys.buy.tr(), + buyCoin, + () => onFilterDataChange( + TradingEntitiesFilter( + buyCoin: null, + sellCoin: sellCoin, + startDate: startDate, + endDate: endDate, + statuses: statuses, + shownSides: shownSides, + ), + ), + context, + ), + ); + } + if (sellCoin != null) { + children.add( + _buildManageFilterItem( + LocaleKeys.sell.tr(), + sellCoin, + () => onFilterDataChange( + TradingEntitiesFilter( + buyCoin: buyCoin, + sellCoin: null, + startDate: startDate, + endDate: endDate, + statuses: statuses, + shownSides: shownSides, + ), + ), + context, + ), + ); + } + if (statuses != null) { + children.addAll( + statuses.map( + (s) => _buildManageFilterItem( + LocaleKeys.status.tr(), + s == TradingStatus.successful + ? LocaleKeys.successful.tr() + : LocaleKeys.failed.tr(), + () => onFilterDataChange( + TradingEntitiesFilter( + buyCoin: buyCoin, + sellCoin: sellCoin, + startDate: startDate, + endDate: endDate, + statuses: statuses.where((e) => e != s).toList(), + shownSides: shownSides, + ), + ), + context), + ), + ); + } + if (shownSides != null) { + children.addAll( + shownSides.map( + (s) => _buildManageFilterItem( + LocaleKeys.type.tr(), + s == TradeSide.taker + ? LocaleKeys.taker.tr() + : LocaleKeys.maker.tr(), + () => onFilterDataChange( + TradingEntitiesFilter( + buyCoin: buyCoin, + sellCoin: sellCoin, + startDate: startDate, + endDate: endDate, + statuses: statuses, + shownSides: + filterData?.shownSides?.where((e) => e != s).toList(), + ), + ), + context, + ), + ), + ); + } + if (startDateString != null) { + children.add(_buildManageFilterItem( + LocaleKeys.fromDate.tr(), + startDateString, + () => onFilterDataChange( + TradingEntitiesFilter( + buyCoin: buyCoin, + sellCoin: sellCoin, + startDate: null, + endDate: endDate, + statuses: statuses, + shownSides: shownSides, + ), + ), + context, + )); + } + if (endDateString != null) { + children.add(_buildManageFilterItem( + LocaleKeys.toDate.tr(), + endDateString, + () => onFilterDataChange( + TradingEntitiesFilter( + buyCoin: buyCoin, + sellCoin: sellCoin, + startDate: startDate, + endDate: null, + statuses: statuses, + shownSides: shownSides, + ), + ), + context, + )); + } + + if (children.length > 1) { + children = [_buildResetAllButton(context), ...children]; + } + + return children; + } + + Widget _buildFilterButton(BuildContext context) { + return InkWell( + radius: 18, + borderRadius: BorderRadius.circular(18), + onTap: onFilterPressed, + child: Container( + width: 100, + height: 30, + decoration: BoxDecoration( + border: Border.all(color: theme.custom.specificButtonBorderColor), + color: theme.custom.specificButtonBackgroundColor, + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Row(children: [ + isFilterShown + ? Icon( + Icons.close, + color: Theme.of(context).textTheme.labelLarge?.color, + size: 14, + ) + : SvgPicture.asset( + '$assetsPath/ui_icons/filters.svg', + colorFilter: ColorFilter.mode( + Theme.of(context).textTheme.labelLarge?.color ?? + Colors.white, + BlendMode.srcIn, + ), + width: 14, + ), + Padding( + padding: const EdgeInsets.only(left: 12.0), + child: Text( + isFilterShown ? LocaleKeys.close.tr() : LocaleKeys.filters.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ) + ]), + ), + ), + ); + } + + Widget _buildManageFilterItem(String text, String value, + VoidCallback removeFilter, BuildContext context) { + return Flexible( + child: Padding( + padding: const EdgeInsets.only(right: 6), + child: InkWell( + onTap: removeFilter, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: theme.custom.specificButtonBackgroundColor, + border: Border.all(color: const Color.fromRGBO(237, 237, 237, 1)), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '$text: $value', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: Icon( + Icons.close, + color: Theme.of(context).textTheme.labelLarge?.color, + size: 12, + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildResetAllButton(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 6), + child: InkWell( + onTap: () => onFilterDataChange(null), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Theme.of(context).colorScheme.primary, + ), + child: Text( + LocaleKeys.resetAll.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/dex/dex_page.dart b/lib/views/dex/dex_page.dart new file mode 100644 index 0000000000..5695c3d4d3 --- /dev/null +++ b/lib/views/dex/dex_page.dart @@ -0,0 +1,94 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/dex_tab_bar/dex_tab_bar_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/dex_list_type.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/shared/ui/clock_warning_banner.dart'; +import 'package:web_dex/shared/widgets/hidden_without_wallet.dart'; +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'; + +class DexPage extends StatelessWidget { + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + key: const Key('dex-page'), + create: (BuildContext context) => DexTabBarBloc( + DexTabBarState.initial(), + authRepo, + ), + ), + ], + child: routingState.dexState.isTradingDetails + ? TradingDetails(uuid: routingState.dexState.uuid) + : _DexContent(), + ); + } +} + +class _DexContent extends StatefulWidget { + @override + State<_DexContent> createState() => _DexContentState(); +} + +class _DexContentState extends State<_DexContent> { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, DexTabBarState state) { + return PageLayout( + content: Flexible( + child: Container( + margin: isMobile ? const EdgeInsets.only(top: 14) : null, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: _backgroundColor(context), + borderRadius: BorderRadius.circular(18.0), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const HiddenWithoutWallet( + child: Padding( + padding: EdgeInsets.only(bottom: 12.0), + child: DexTabBar(), + ), + ), + const ClockWarningBanner(), + Flexible( + child: shouldShowTabContent(state.tabIndex) + ? DexListWrapper( + key: Key('dex-list-wrapper-${state.tabIndex}'), + DexListType.values[state.tabIndex], + ) + : const SizedBox.shrink(), + ), + ], + ), + ), + ), + ); + }, + ); + } + + Color? _backgroundColor(BuildContext context) { + if (isMobile) { + final ThemeMode mode = theme.mode; + return mode == ThemeMode.dark ? null : Theme.of(context).cardColor; + } + return null; + } + + bool shouldShowTabContent(int tabIndex) { + return (DexListType.values.length > tabIndex); + } +} diff --git a/lib/views/dex/dex_tab_bar.dart b/lib/views/dex/dex_tab_bar.dart new file mode 100644 index 0000000000..1b1f554b6f --- /dev/null +++ b/lib/views/dex/dex_tab_bar.dart @@ -0,0 +1,49 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/dex_tab_bar/dex_tab_bar_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/dex_list_type.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab_bar.dart'; + +class DexTabBar extends StatelessWidget { + const DexTabBar({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final DexTabBarBloc bloc = context.read(); + return StreamBuilder>( + stream: tradingEntitiesBloc.outMyOrders, + builder: (context, _) => StreamBuilder>( + stream: tradingEntitiesBloc.outSwaps, + builder: (context, _) => ConstrainedBox( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: UiTabBar( + currentTabIndex: bloc.tabIndex, + tabs: _buildTabs(bloc), + ), + ), + ), + ); + }, + ); + } + + List _buildTabs(DexTabBarBloc bloc) { + const values = DexListType.values; + return List.generate(values.length, (index) { + final tab = values[index]; + return UiTab( + key: Key(tab.key), + text: tab.name(bloc), + isSelected: bloc.state.tabIndex == index, + onClick: () => bloc.add(TabChanged(index)), + ); + }); + } +} diff --git a/lib/views/dex/entities_list/common/buy_price_mobile.dart b/lib/views/dex/entities_list/common/buy_price_mobile.dart new file mode 100644 index 0000000000..cf06c3077e --- /dev/null +++ b/lib/views/dex/entities_list/common/buy_price_mobile.dart @@ -0,0 +1,48 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.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'; + +class BuyPriceMobile extends StatelessWidget { + const BuyPriceMobile({ + Key? key, + required this.buyCoin, + required this.sellAmount, + required this.buyAmount, + }) : super(key: key); + final String buyCoin; + final Rational sellAmount; + final Rational buyAmount; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 17), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(7), + ), + child: Column( + children: [ + Text( + LocaleKeys.buyPrice.tr(), + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 11, + ), + ), + Text( + '${formatDexAmt(tradingEntitiesBloc.getPriceFromAmount(sellAmount, buyAmount))} ${Coin.normalizeAbbr(buyCoin)}', + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 12, + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/dex/entities_list/common/coin_amount_mobile.dart b/lib/views/dex/entities_list/common/coin_amount_mobile.dart new file mode 100644 index 0000000000..5b53d95a74 --- /dev/null +++ b/lib/views/dex/entities_list/common/coin_amount_mobile.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; + +class CoinAmountMobile extends StatelessWidget { + const CoinAmountMobile( + {Key? key, required this.coinAbbr, required this.amount}) + : super(key: key); + final String coinAbbr; + final Rational amount; + + @override + Widget build(BuildContext context) { + final Coin? coin = coinsBloc.getCoin(coinAbbr); + + if (coin == null) return const SizedBox.shrink(); + + return CoinItem(coin: coin, amount: amount.toDouble()); + } +} diff --git a/lib/views/dex/entities_list/common/count_down_timer.dart b/lib/views/dex/entities_list/common/count_down_timer.dart new file mode 100644 index 0000000000..e075c0ffce --- /dev/null +++ b/lib/views/dex/entities_list/common/count_down_timer.dart @@ -0,0 +1,64 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; + +class CountDownTimer extends StatefulWidget { + const CountDownTimer({Key? key, required this.orderMatchingTime}) + : super(key: key); + final int orderMatchingTime; + + @override + State createState() => _CountDownTimerState(); +} + +class _CountDownTimerState extends State { + late Timer _timer; + late int _currentTimerValue; + final _maxValue = 30; + + @override + void initState() { + _currentTimerValue = widget.orderMatchingTime; + super.initState(); + _timer = Timer.periodic(const Duration(seconds: 1), (_) { + if (_currentTimerValue == 0) { + _timer.cancel(); + return; + } + if (mounted) { + setState(() { + _currentTimerValue--; + }); + } + }); + } + + @override + void dispose() { + _timer.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + return SizedBox( + width: 25, + height: 25, + child: Stack( + children: [ + Positioned.fill( + child: CircularProgressIndicator( + value: _currentTimerValue / _maxValue, + backgroundColor: themeData.hintColor, + strokeWidth: 2, + )), + Align( + alignment: FractionalOffset.center, + child: Text( + _currentTimerValue.toString(), + style: themeData.textTheme.bodyMedium!.copyWith(fontSize: 12), + )) + ], + )); + } +} diff --git a/lib/views/dex/entities_list/common/dex_empty_list.dart b/lib/views/dex/entities_list/common/dex_empty_list.dart new file mode 100644 index 0000000000..12f8daf772 --- /dev/null +++ b/lib/views/dex/entities_list/common/dex_empty_list.dart @@ -0,0 +1,16 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class DexEmptyList extends StatelessWidget { + const DexEmptyList(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 70, 12, 70), + alignment: Alignment.topCenter, + child: Text(LocaleKeys.listIsEmpty.tr()), + ); + } +} diff --git a/lib/views/dex/entities_list/common/dex_error_message.dart b/lib/views/dex/entities_list/common/dex_error_message.dart new file mode 100644 index 0000000000..38d69c3862 --- /dev/null +++ b/lib/views/dex/entities_list/common/dex_error_message.dart @@ -0,0 +1,20 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class DexErrorMessage extends StatelessWidget { + const DexErrorMessage(); + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + return Container( + padding: const EdgeInsets.fromLTRB(12, 70, 12, 70), + alignment: Alignment.topCenter, + child: Text( + LocaleKeys.dexErrorMessage.tr(), + style: TextStyle(color: themeData.colorScheme.error), + ), + ); + } +} diff --git a/lib/views/dex/entities_list/common/entity_item_status_wrapper.dart b/lib/views/dex/entities_list/common/entity_item_status_wrapper.dart new file mode 100644 index 0000000000..396e966a83 --- /dev/null +++ b/lib/views/dex/entities_list/common/entity_item_status_wrapper.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +class EntityItemStatusWrapper extends StatelessWidget { + const EntityItemStatusWrapper({ + Key? key, + required this.text, + required this.icon, + required this.width, + required this.backgroundColor, + required this.textColor, + }) : super(key: key); + + final String text; + final double width; + final Widget icon; + final Color backgroundColor; + final Color? textColor; + + @override + Widget build(BuildContext context) { + assert(icon is Icon || icon is SvgPicture); + + return Container( + padding: const EdgeInsets.all(8), + constraints: BoxConstraints.tightFor(width: width), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), color: backgroundColor), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + icon, + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: Text( + text, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: textColor, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/dex/entities_list/common/trade_amount_desktop.dart b/lib/views/dex/entities_list/common/trade_amount_desktop.dart new file mode 100644 index 0000000000..cf8c2d9d07 --- /dev/null +++ b/lib/views/dex/entities_list/common/trade_amount_desktop.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; + +class TradeAmountDesktop extends StatelessWidget { + const TradeAmountDesktop({ + Key? key, + required this.coinAbbr, + required this.amount, + }) : super(key: key); + final String coinAbbr; + final Rational? amount; + + @override + Widget build(BuildContext context) { + final Coin? coin = coinsBloc.getCoin(coinAbbr); + if (coin == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(right: 4), + child: CoinItem(coin: coin, amount: amount?.toDouble()), + ); + } +} diff --git a/lib/views/dex/entities_list/dex_list_wrapper.dart b/lib/views/dex/entities_list/dex_list_wrapper.dart new file mode 100644 index 0000000000..fc9227d573 --- /dev/null +++ b/lib/views/dex/entities_list/dex_list_wrapper.dart @@ -0,0 +1,226 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/trading_kind/trading_kind_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/dex_list_type.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/dex/dex_list_filter/desktop/dex_list_filter_desktop.dart'; +import 'package:web_dex/views/dex/dex_list_filter/mobile/dex_list_filter_mobile.dart'; +import 'package:web_dex/views/dex/dex_list_filter/mobile/dex_list_header_mobile.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/entities_list/orders/orders_list.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_layout.dart'; +import 'package:web_dex/views/dex/simple/form/taker/taker_form.dart'; + +class DexListWrapper extends StatefulWidget { + const DexListWrapper(this.listType, {super.key}); + final DexListType listType; + + @override + State createState() => _DexListWrapperState(); +} + +class _DexListWrapperState extends State { + final filters = { + DexListType.swap: null, + DexListType.orders: null, + DexListType.inProgress: null, + DexListType.history: null, + }; + bool _isFilterShown = false; + DexListType? previouseType; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => + TradingKindBloc(TradingKindState.initial()), + child: BlocBuilder( + builder: (context, state) { + final filter = filters[widget.listType]; + final isTaker = state.isTaker; + previouseType ??= widget.listType; + if (previouseType != widget.listType) { + _isFilterShown = false; + previouseType = widget.listType; + } + final child = _DexListWidget( + key: Key('dex-list-${widget.listType}'), + filter: filter, + type: widget.listType, + onSwapItemClick: _onSwapItemClick, + isTaker: isTaker, + ); + return isMobile + ? _MobileWidget( + key: const Key('dex-list-wrapper-mobile'), + type: widget.listType, + filterData: filter, + onApplyFilter: _setFilter, + isFilterShown: _isFilterShown, + onFilterTap: () => setState(() { + _isFilterShown = !_isFilterShown; + }), + child: child, + ) + : _DesktopWidget( + key: const Key('dex-list-wrapper-desktop'), + type: widget.listType, + filterData: filter, + onApplyFilter: _setFilter, + child: child, + ); + }, + ), + ); + } + + void _setFilter(TradingEntitiesFilter? filter) { + setState(() { + filters[widget.listType] = filter; + }); + } + + void _onSwapItemClick(Swap swap) { + routingState.dexState.setDetailsAction(swap.uuid); + } +} + +class _DexListWidget extends StatelessWidget { + final TradingEntitiesFilter? filter; + final DexListType type; + final void Function(Swap) onSwapItemClick; + final bool isTaker; + const _DexListWidget({ + this.filter, + required this.type, + required this.onSwapItemClick, + required this.isTaker, + super.key, + }); + + @override + Widget build(BuildContext context) { + switch (type) { + case DexListType.orders: + return OrdersList( + entitiesFilterData: filter, + ); + case DexListType.inProgress: + return InProgressList( + entitiesFilterData: filter, + onItemClick: onSwapItemClick, + ); + case DexListType.history: + return HistoryList( + entitiesFilterData: filter, + onItemClick: onSwapItemClick, + ); + case DexListType.swap: + return isTaker ? const TakerForm() : const MakerFormLayout(); + } + } +} + +class _MobileWidget extends StatelessWidget { + final DexListType type; + final Widget child; + final TradingEntitiesFilter? filterData; + final bool isFilterShown; + final VoidCallback onFilterTap; + final void Function(TradingEntitiesFilter?) onApplyFilter; + + const _MobileWidget({ + required this.type, + required this.child, + required this.onApplyFilter, + this.filterData, + required this.isFilterShown, + required this.onFilterTap, + super.key, + }); + + @override + Widget build(BuildContext context) { + if (type == DexListType.swap) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 16), + Flexible( + child: child, + ), + ], + ); + } else { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + DexListHeaderMobile( + entitiesFilterData: filterData, + listType: type, + isFilterShown: isFilterShown, + onFilterDataChange: onApplyFilter, + onFilterPressed: onFilterTap, + ), + const SizedBox(height: 6), + Flexible( + child: isFilterShown + ? DexListFilterMobile( + filterData: filterData, + onApplyFilter: onApplyFilter, + listType: type, + ) + : child, + ), + ], + ); + } + } +} + +class _DesktopWidget extends StatelessWidget { + final DexListType type; + final Widget child; + final TradingEntitiesFilter? filterData; + final void Function(TradingEntitiesFilter?) onApplyFilter; + const _DesktopWidget({ + required this.type, + required this.child, + required this.filterData, + required this.onApplyFilter, + super.key, + }); + + @override + Widget build(BuildContext context) { + if (type == DexListType.swap) { + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 16), + Flexible(child: child), + ], + ); + } else { + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + DexListFilterDesktop( + filterData: filterData, + onApplyFilter: onApplyFilter, + listType: type, + ), + Flexible(child: child), + ], + ); + } + } +} diff --git a/lib/views/dex/entities_list/history/history_item.dart b/lib/views/dex/entities_list/history/history_item.dart new file mode 100644 index 0000000000..58e9e3f538 --- /dev/null +++ b/lib/views/dex/entities_list/history/history_item.dart @@ -0,0 +1,404 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/shared/ui/ui_light_button.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/focusable_widget.dart'; +import 'package:web_dex/views/dex/entities_list/common/coin_amount_mobile.dart'; +import 'package:web_dex/views/dex/entities_list/common/entity_item_status_wrapper.dart'; +import 'package:web_dex/views/dex/entities_list/common/trade_amount_desktop.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class HistoryItem extends StatefulWidget { + const HistoryItem(this.swap, {Key? key, required this.onClick}) + : super(key: key); + + final Swap swap; + final VoidCallback onClick; + + @override + State createState() => _HistoryItemState(); +} + +class _HistoryItemState extends State { + bool _isRecovering = false; + + @override + Widget build(BuildContext context) { + final String uuid = widget.swap.uuid; + final String sellCoin = widget.swap.sellCoin; + final Rational sellAmount = widget.swap.sellAmount; + final String buyCoin = widget.swap.buyCoin; + final Rational buyAmount = widget.swap.buyAmount; + final String date = getFormattedDate(widget.swap.myInfo.startedAt); + final bool isSuccessful = !widget.swap.isFailed; + final bool isTaker = widget.swap.isTaker; + final bool isRecoverable = widget.swap.recoverable; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isMobile) + Text( + tradingEntitiesBloc.getTypeString(isTaker), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: _typeColor, + ), + ), + FocusableWidget( + key: Key('swap-item-$uuid'), + onTap: widget.onClick, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.fromLTRB(6, 12, 6, 12), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.onSurface, + ), + child: isMobile + ? _HistoryItemMobile( + key: Key('swap-item-$uuid-mobile'), + uuid: uuid, + isRecovering: _isRecovering, + buyAmount: buyAmount, + buyCoin: buyCoin, + date: date, + isSuccessful: isSuccessful, + sellAmount: sellAmount, + sellCoin: sellCoin, + onRecoverPressed: isRecoverable ? _onRecoverPressed : null, + ) + : _HistoryItemDesktop( + key: Key('swap-item-$uuid-desktop'), + uuid: uuid, + isRecovering: _isRecovering, + buyAmount: buyAmount, + buyCoin: buyCoin, + date: date, + isSuccessful: isSuccessful, + isTaker: isTaker, + sellAmount: sellAmount, + sellCoin: sellCoin, + typeColor: _typeColor, + onRecoverPressed: isRecoverable ? _onRecoverPressed : null, + ), + ), + ), + ], + ); + } + + Future _onRecoverPressed() async { + if (_isRecovering) return; + setState(() { + _isRecovering = true; + }); + await tradingEntitiesBloc.recoverFundsOfSwap(widget.swap.uuid); + setState(() { + _isRecovering = false; + }); + } + + Color get _typeColor => widget.swap.isTaker + ? theme.custom.dexPageTheme.takerLabelColor + : theme.custom.dexPageTheme.makerLabelColor; +} + +class _HistoryItemDesktop extends StatelessWidget { + const _HistoryItemDesktop({ + Key? key, + required this.uuid, + required this.isRecovering, + required this.sellCoin, + required this.buyCoin, + required this.sellAmount, + required this.buyAmount, + required this.isSuccessful, + required this.isTaker, + required this.date, + required this.typeColor, + required this.onRecoverPressed, + }) : super(key: key); + final String uuid; + + final bool isRecovering; + final String sellCoin; + final Rational sellAmount; + final String buyCoin; + final Rational buyAmount; + final bool isSuccessful; + final bool isTaker; + final String date; + final Color typeColor; + final VoidCallback? onRecoverPressed; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: EntityItemStatusWrapper( + text: isSuccessful + ? LocaleKeys.successful.tr() + : LocaleKeys.failed.tr(), + width: 100, + icon: isSuccessful + ? Icon( + Icons.check, + size: 12, + color: theme + .custom.dexPageTheme.successfulSwapStatusColor, + ) + : Icon( + Icons.circle, + size: 12, + color: + theme.custom.dexPageTheme.failedSwapStatusColor, + ), + textColor: isSuccessful + ? theme.custom.dexPageTheme.successfulSwapStatusColor + : Theme.of(context).textTheme.bodyMedium?.color, + backgroundColor: isSuccessful + ? theme.custom.dexPageTheme + .successfulSwapStatusBackgroundColor + : Theme.of(context).colorScheme.surface, + ), + ), + ], + ), + ), + Expanded( + key: Key('history-item-$uuid-sell-amount'), + child: TradeAmountDesktop( + coinAbbr: sellCoin, + amount: sellAmount, + ), + ), + Expanded( + child: TradeAmountDesktop( + coinAbbr: buyCoin, + amount: buyAmount, + ), + ), + Expanded( + child: Text( + formatAmt( + tradingEntitiesBloc.getPriceFromAmount( + sellAmount, + buyAmount, + ), + ), + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + ), + Expanded( + child: Text( + date, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + flex: 0, + child: Text( + tradingEntitiesBloc.getTypeString(isTaker), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: typeColor, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + onRecoverPressed != null + ? UiLightButton( + width: 80, + height: 22, + backgroundColor: theme.currentGlobal.colorScheme.error, + text: isRecovering ? '' : LocaleKeys.recover.tr(), + prefix: isRecovering + ? const UiSpinner( + width: 12, + height: 12, + color: Colors.orange, + ) + : null, + textStyle: + const TextStyle(color: Colors.white, fontSize: 12), + onPressed: onRecoverPressed, + ) + : const SizedBox(width: 80), + ], + ), + ), + ], + ); + } +} + +class _HistoryItemMobile extends StatelessWidget { + const _HistoryItemMobile({ + Key? key, + required this.uuid, + required this.isRecovering, + required this.sellCoin, + required this.buyCoin, + required this.sellAmount, + required this.buyAmount, + required this.isSuccessful, + required this.date, + required this.onRecoverPressed, + }) : super(key: key); + final String uuid; + final bool isRecovering; + final String sellCoin; + final Rational sellAmount; + final String buyCoin; + final Rational buyAmount; + final bool isSuccessful; + final String date; + final VoidCallback? onRecoverPressed; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.send.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: CoinAmountMobile( + coinAbbr: sellCoin, + amount: sellAmount, + ), + ), + ], + ), + ), + onRecoverPressed != null + ? UiLightButton( + width: 70, + height: 22, + prefix: isRecovering + ? const UiSpinner(color: Colors.orange) + : null, + backgroundColor: + theme.custom.dexPageTheme.failedSwapStatusColor, + text: isRecovering ? '' : LocaleKeys.recover.tr(), + textStyle: + const TextStyle(color: Colors.white, fontSize: 11), + onPressed: onRecoverPressed, + ) + : const SizedBox(), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + LocaleKeys.receive.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: CoinAmountMobile( + coinAbbr: buyCoin, + amount: buyAmount, + ), + ), + Text( + date, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Container( + margin: const EdgeInsets.only(top: 14), + padding: const EdgeInsets.all(12), + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: isSuccessful + ? theme.custom.dexPageTheme.successfulSwapStatusBackgroundColor + : Theme.of(context).colorScheme.surface, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + isSuccessful + ? Icon( + Icons.check, + size: 12, + color: + theme.custom.dexPageTheme.successfulSwapStatusColor, + ) + : Icon( + Icons.circle, + size: 12, + color: theme.custom.dexPageTheme.failedSwapStatusColor, + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + isSuccessful + ? LocaleKeys.successful.tr() + : LocaleKeys.failed.tr(), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/views/dex/entities_list/history/history_list.dart b/lib/views/dex/entities_list/history/history_list.dart new file mode 100644 index 0000000000..e6a3d99d4d --- /dev/null +++ b/lib/views/dex/entities_list/history/history_list.dart @@ -0,0 +1,169 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/views/dex/entities_list/common/dex_empty_list.dart'; +import 'package:web_dex/views/dex/entities_list/common/dex_error_message.dart'; +import 'package:web_dex/views/dex/entities_list/history/history_item.dart'; +import 'package:web_dex/views/dex/entities_list/history/history_list_header.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +import 'swap_history_sort_mixin.dart'; + +class HistoryList extends StatefulWidget { + const HistoryList({ + Key? key, + this.filter, + required this.onItemClick, + this.entitiesFilterData, + this.onFilterChange, + }) : super(key: key); + + final bool Function(Swap)? filter; + final Function(Swap) onItemClick; + final TradingEntitiesFilter? entitiesFilterData; + final VoidCallback? onFilterChange; + + @override + State createState() => _HistoryListState(); +} + +class _HistoryListState extends State + with SwapHistorySortingMixin { + final _mainScrollController = ScrollController(); + + SortData _sortData = const SortData( + sortDirection: SortDirection.none, + sortType: HistoryListSortType.none, + ); + + StreamSubscription>? _swapsSubscription; + List _processedSwaps = []; + + List _unprocessedSwaps = []; + + String? error; + @override + void initState() { + super.initState(); + + _swapsSubscription = listenForSwaps(); + } + + @override + Widget build(BuildContext context) { + if (error != null) { + return const DexErrorMessage(); + } + + if (_processedSwaps.isEmpty) { + return const DexEmptyList(); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isMobile) + HistoryListHeader( + sortData: _sortData, + onSortChange: _onSortChange, + ), + Expanded( + child: Padding( + padding: EdgeInsets.only(top: isMobile ? 0 : 10.0), + child: DexScrollbar( + isMobile: isMobile, + scrollController: _mainScrollController, + child: ListView.builder( + key: const Key('swap-history-list-list-view'), + shrinkWrap: false, + controller: _mainScrollController, + itemBuilder: (BuildContext context, int index) { + final Swap swap = _processedSwaps[index]; + + return HistoryItem( + key: Key('swap-item-${swap.uuid}'), + swap, + onClick: () => widget.onItemClick(swap), + ); + }, + itemCount: _processedSwaps.length, + ), + ), + ), + ), + ], + ); + } + + void _onSortChange(SortData sortData) { + setState(() { + _sortData = sortData; + }); + _processSwapFilters(_unprocessedSwaps); + } + + StreamSubscription> listenForSwaps() { + return tradingEntitiesBloc.outSwaps.where((swaps) { + final didSwapsChange = !areSwapsSame(swaps, _unprocessedSwaps); + + _unprocessedSwaps = swaps; + + return didSwapsChange; + }).listen( + _processSwapFilters, + onError: (e) { + setState(() => error = e.toString()); + }, + cancelOnError: false, + ); + } + + /// Clears the error message and triggers rebuild only if there was an error. + void clearErrorIfExists() { + if (error != null) { + setState(() => error = null); + } + } + + void _processSwapFilters(List swaps) { + Iterable completedSwaps = swaps.where((swap) => swap.isCompleted); + + if (widget.filter != null) { + completedSwaps = completedSwaps.where(widget.filter!); + } + + final entitiesFilterData = widget.entitiesFilterData; + + final filteredSwaps = entitiesFilterData != null + ? applyFiltersForSwap(completedSwaps.toList(), entitiesFilterData) + : completedSwaps.toList(); + + setState(() { + clearErrorIfExists(); + _processedSwaps = sortSwaps(filteredSwaps, sortData: _sortData); + }); + } + + @override + void didUpdateWidget(covariant HistoryList oldWidget) { + super.didUpdateWidget(oldWidget); + + final didFiltersChange = oldWidget.filter != widget.filter || + oldWidget.entitiesFilterData != widget.entitiesFilterData; + + if (didFiltersChange) { + _processSwapFilters(_unprocessedSwaps); + } + } + + @override + void dispose() { + _swapsSubscription?.cancel(); + super.dispose(); + } +} diff --git a/lib/views/dex/entities_list/history/history_list_header.dart b/lib/views/dex/entities_list/history/history_list_header.dart new file mode 100644 index 0000000000..e398a3c5b5 --- /dev/null +++ b/lib/views/dex/entities_list/history/history_list_header.dart @@ -0,0 +1,69 @@ +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/ui/ui_list_header_with_sortings.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class HistoryListHeader extends StatelessWidget { + const HistoryListHeader({ + Key? key, + required this.sortData, + required this.onSortChange, + }) : super(key: key); + final SortData sortData; + final void Function(SortData) onSortChange; + + @override + Widget build(BuildContext context) { + return UiListHeaderWithSorting( + items: _headerItems, + sortData: sortData, + onSortChange: onSortChange, + ); + } +} + +List> _headerItems = [ + SortHeaderItemData( + text: LocaleKeys.status.tr(), + value: HistoryListSortType.status, + ), + SortHeaderItemData( + text: LocaleKeys.send.tr(), + value: HistoryListSortType.send, + ), + SortHeaderItemData( + text: LocaleKeys.receive.tr(), + value: HistoryListSortType.receive, + ), + SortHeaderItemData( + text: LocaleKeys.price.tr(), + value: HistoryListSortType.price, + ), + SortHeaderItemData( + text: LocaleKeys.date.tr(), + value: HistoryListSortType.date, + ), + SortHeaderItemData( + text: LocaleKeys.orderType.tr(), + flex: 0, + value: HistoryListSortType.orderType, + ), + SortHeaderItemData( + text: '', + width: 80, + flex: 0, + isEmpty: true, + value: HistoryListSortType.none, + ), +]; + +enum HistoryListSortType { + status, + send, + receive, + price, + date, + orderType, + none, +} diff --git a/lib/views/dex/entities_list/history/swap_history_sort_mixin.dart b/lib/views/dex/entities_list/history/swap_history_sort_mixin.dart new file mode 100644 index 0000000000..fdfcdae571 --- /dev/null +++ b/lib/views/dex/entities_list/history/swap_history_sort_mixin.dart @@ -0,0 +1,134 @@ +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/shared/utils/sorting.dart'; +import 'package:web_dex/views/dex/entities_list/history/history_list_header.dart'; + +mixin SwapHistorySortingMixin { + bool areSwapsSame(List newSwaps, List oldSwaps) { + if (newSwaps.length != oldSwaps.length) { + return false; + } + + return newSwaps.every((newSwap) => oldSwaps.contains(newSwap)); + } + + List sortSwaps( + List swaps, { + required SortData sortData, + }) { + final direction = sortData.sortDirection; + + switch (sortData.sortType) { + case HistoryListSortType.send: + return _sortByAmount(swaps, true, direction); + case HistoryListSortType.receive: + return _sortByAmount(swaps, false, direction); + case HistoryListSortType.price: + return _sortByPrice(swaps, sortDirection: direction); + case HistoryListSortType.date: + return _sortByDate(swaps, sortDirection: direction); + case HistoryListSortType.orderType: + return _sortByType(swaps, sortDirection: direction); + case HistoryListSortType.status: + return _sortByStatus(swaps, sortData.sortDirection); + case HistoryListSortType.none: + return swaps; + } + } + + List _sortByStatus(List swaps, SortDirection sortDirection) { + swaps.sort((first, second) { + switch (sortDirection) { + case SortDirection.increase: + if (first.isFailed) { + return second.isFailed ? -1 : 1; + } else { + return second.isFailed ? 1 : -1; + } + case SortDirection.decrease: + if (first.isCompleted) { + return second.isCompleted ? -1 : 1; + } else { + return second.isCompleted ? 1 : -1; + } + case SortDirection.none: + return -1; + } + }); + return swaps; + } + + List _sortByAmount( + List swaps, + bool isSend, + SortDirection sortDirection, + ) { + if (isSend) { + swaps.sort( + (first, second) => sortByDouble( + first.sellAmount.toDouble(), + second.sellAmount.toDouble(), + sortDirection, + ), + ); + } else { + swaps.sort( + (first, second) => sortByDouble( + first.buyAmount.toDouble(), + second.buyAmount.toDouble(), + sortDirection, + ), + ); + } + return swaps; + } + + List _sortByPrice( + List swaps, { + required SortDirection sortDirection, + }) { + swaps.sort( + (first, second) => sortByDouble( + tradingEntitiesBloc.getPriceFromAmount( + first.sellAmount, + first.buyAmount, + ), + tradingEntitiesBloc.getPriceFromAmount( + second.sellAmount, + second.buyAmount, + ), + sortDirection, + ), + ); + return swaps; + } + + List _sortByDate( + List swaps, { + required SortDirection sortDirection, + }) { + swaps.sort( + (first, second) => sortByDouble( + first.myInfo.startedAt.toDouble(), + second.myInfo.startedAt.toDouble(), + sortDirection, + ), + ); + return swaps; + } + + List _sortByType( + List swaps, { + required SortDirection sortDirection, + }) { + swaps.sort( + (first, second) => sortByBool( + first.isTaker, + second.isTaker, + sortDirection, + ), + ); + return swaps; + } +} diff --git a/lib/views/dex/entities_list/in_progress/in_progress_item.dart b/lib/views/dex/entities_list/in_progress/in_progress_item.dart new file mode 100644 index 0000000000..643ade609b --- /dev/null +++ b/lib/views/dex/entities_list/in_progress/in_progress_item.dart @@ -0,0 +1,300 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/focusable_widget.dart'; +import 'package:web_dex/views/dex/entities_list/common/buy_price_mobile.dart'; +import 'package:web_dex/views/dex/entities_list/common/coin_amount_mobile.dart'; +import 'package:web_dex/views/dex/entities_list/common/entity_item_status_wrapper.dart'; +import 'package:web_dex/views/dex/entities_list/common/trade_amount_desktop.dart'; + +class InProgressItem extends StatelessWidget { + const InProgressItem(this.swap, {Key? key, required this.onClick}) + : super(key: key); + final Swap swap; + final VoidCallback onClick; + + @override + Widget build(BuildContext context) { + final String sellCoin = swap.sellCoin; + final Rational sellAmount = swap.sellAmount; + final String buyCoin = swap.buyCoin; + final Rational buyAmount = swap.buyAmount; + final String date = getFormattedDate(swap.myInfo.startedAt); + final bool isTaker = swap.isTaker; + final String typeText = tradingEntitiesBloc.getTypeString(isTaker); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isMobile) + Text( + typeText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: _protocolColor, + ), + ), + FocusableWidget( + borderRadius: BorderRadius.circular(10), + onTap: onClick, + child: Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.onSurface, + ), + child: isMobile + ? _InProgressItemMobile( + buyAmount: buyAmount, + buyCoin: buyCoin, + date: date, + sellAmount: sellAmount, + sellCoin: sellCoin, + status: swap.status, + statusStep: swap.statusStep, + ) + : _InProgressItemDesktop( + buyAmount: buyAmount, + buyCoin: buyCoin, + date: date, + protocolColor: _protocolColor, + sellAmount: sellAmount, + sellCoin: sellCoin, + status: swap.status, + statusStep: swap.statusStep, + typeText: typeText, + ), + ), + ), + ], + ); + } + + Color get _protocolColor => swap.isTaker + ? const Color.fromRGBO(47, 179, 239, 1) + : const Color.fromRGBO(106, 77, 227, 1); +} + +class _InProgressItemDesktop extends StatelessWidget { + const _InProgressItemDesktop({ + Key? key, + required this.sellCoin, + required this.sellAmount, + required this.buyCoin, + required this.buyAmount, + required this.status, + required this.statusStep, + required this.date, + required this.typeText, + required this.protocolColor, + }) : super(key: key); + final String sellCoin; + final Rational sellAmount; + final String buyCoin; + final Rational buyAmount; + final SwapStatus status; + final int statusStep; + final String date; + final String typeText; + final Color protocolColor; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: EntityItemStatusWrapper( + textColor: null, + text: + '${Swap.getSwapStatusString(status)} $statusStep/${Swap.statusSteps}', + width: 110, + icon: SvgPicture.asset( + '$assetsPath/others/swap.svg', + width: 12, + height: 12, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.secondary, + BlendMode.srcIn, + ), + ), + backgroundColor: Theme.of(context).colorScheme.surface, + ), + ), + ], + ), + ), + Expanded( + child: TradeAmountDesktop(coinAbbr: sellCoin, amount: sellAmount)), + Expanded( + child: TradeAmountDesktop(coinAbbr: buyCoin, amount: buyAmount), + ), + Expanded( + child: Text( + formatDexAmt(tradingEntitiesBloc.getPriceFromAmount( + sellAmount, + buyAmount, + )), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + )), + ), + Expanded( + child: Text(date, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ))), + Expanded( + flex: 0, + child: Text(typeText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: protocolColor, + )), + ), + ], + ); + } +} + +class _InProgressItemMobile extends StatelessWidget { + const _InProgressItemMobile({ + Key? key, + required this.sellCoin, + required this.sellAmount, + required this.buyCoin, + required this.buyAmount, + required this.status, + required this.statusStep, + required this.date, + }) : super(key: key); + final String sellCoin; + final Rational sellAmount; + final String buyCoin; + final Rational buyAmount; + final SwapStatus status; + final int statusStep; + final String date; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.send.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: CoinAmountMobile( + coinAbbr: sellCoin, + amount: sellAmount, + ), + ), + ], + ), + ), + BuyPriceMobile( + buyCoin: buyCoin, + buyAmount: buyAmount, + sellAmount: sellAmount, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + LocaleKeys.receive.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 5), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: CoinAmountMobile( + coinAbbr: buyCoin, + amount: buyAmount, + ), + ), + Text( + date, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + Container( + margin: const EdgeInsets.only(top: 12), + padding: const EdgeInsets.fromLTRB(6, 12, 6, 12), + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: Theme.of(context).colorScheme.onSurface), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SvgPicture.asset( + '$assetsPath/others/swap.svg', + width: 12, + height: 12, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.secondary, + BlendMode.srcIn, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Text( + '${Swap.getSwapStatusString(status)} $statusStep/${Swap.statusSteps}', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/views/dex/entities_list/in_progress/in_progress_list.dart b/lib/views/dex/entities_list/in_progress/in_progress_list.dart new file mode 100644 index 0000000000..b14176d85b --- /dev/null +++ b/lib/views/dex/entities_list/in_progress/in_progress_list.dart @@ -0,0 +1,185 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/shared/utils/sorting.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/views/dex/entities_list/common/dex_empty_list.dart'; +import 'package:web_dex/views/dex/entities_list/common/dex_error_message.dart'; +import 'package:web_dex/views/dex/entities_list/in_progress/in_progress_item.dart'; +import 'package:web_dex/views/dex/entities_list/in_progress/in_progress_list_header.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class InProgressList extends StatefulWidget { + const InProgressList({ + Key? key, + required this.onItemClick, + this.entitiesFilterData, + this.filter, + }) : super(key: key); + + final bool Function(Swap)? filter; + final Function(Swap) onItemClick; + + final TradingEntitiesFilter? entitiesFilterData; + @override + State createState() => _InProgressListState(); +} + +class _InProgressListState extends State { + final _mainScrollController = ScrollController(); + SortData _sortData = + const SortData( + sortDirection: SortDirection.none, + sortType: InProgressListSortType.none); + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + initialData: tradingEntitiesBloc.swaps, + stream: tradingEntitiesBloc.outSwaps, + builder: (context, swapsSnapshot) { + final List swaps = (swapsSnapshot.data ?? []) + .where((swap) => !swap.isCompleted) + .toList(); + + if (swapsSnapshot.hasError) { + return const DexErrorMessage(); + } + + if (widget.filter != null) { + swaps.retainWhere(widget.filter!); + } + + final TradingEntitiesFilter? entitiesFilterData = + widget.entitiesFilterData; + + final filtered = entitiesFilterData != null + ? applyFiltersForSwap(swaps, entitiesFilterData) + : swaps; + + if (!swapsSnapshot.hasData || filtered.isEmpty) { + return const DexEmptyList(); + } + + final List sortedSwaps = _sortSwaps(filtered); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (!isMobile) + InProgressListHeader( + sortData: _sortData, + onSortChange: _onSortChange, + ), + Flexible( + child: Padding( + padding: EdgeInsets.only(top: isMobile ? 0 : 10.0), + child: DexScrollbar( + isMobile: isMobile, + scrollController: _mainScrollController, + child: ListView.builder( + shrinkWrap: true, + controller: _mainScrollController, + itemBuilder: (BuildContext context, int index) { + final Swap swap = sortedSwaps[index]; + + return InProgressItem( + swap, + onClick: () { + widget.onItemClick(swap); + }, + ); + }, + itemCount: sortedSwaps.length, + ), + ), + ), + ), + ], + ); + }); + } + + void _onSortChange(SortData sortData) { + setState(() { + _sortData = sortData; + }); + } + + List _sortSwaps(List swaps) { + switch (_sortData.sortType) { + case InProgressListSortType.send: + return _sortByAmount(swaps, true); + case InProgressListSortType.receive: + return _sortByAmount(swaps, false); + case InProgressListSortType.price: + return _sortByPrice(swaps); + case InProgressListSortType.date: + return _sortByDate(swaps); + case InProgressListSortType.orderType: + return _sortByType(swaps); + case InProgressListSortType.status: + return _sortByStatus(swaps); + case InProgressListSortType.none: + return swaps; + } + } + + List _sortByStatus(List swaps) { + swaps.sort((first, second) => sortByDouble(first.statusStep.toDouble(), + second.statusStep.toDouble(), _sortData.sortDirection)); + return swaps; + } + + List _sortByAmount(List swaps, bool isSend) { + if (isSend) { + swaps.sort((first, second) => sortByDouble( + first.sellAmount.toDouble(), + second.sellAmount.toDouble(), + _sortData.sortDirection, + )); + } else { + swaps.sort((first, second) => sortByDouble( + first.buyAmount.toDouble(), + second.buyAmount.toDouble(), + _sortData.sortDirection, + )); + } + return swaps; + } + + List _sortByPrice(List swaps) { + swaps.sort((first, second) => sortByDouble( + tradingEntitiesBloc.getPriceFromAmount( + first.sellAmount, + first.buyAmount, + ), + tradingEntitiesBloc.getPriceFromAmount( + second.sellAmount, + second.buyAmount, + ), + _sortData.sortDirection, + )); + return swaps; + } + + List _sortByDate(List swaps) { + swaps.sort((first, second) => sortByDouble( + first.myInfo.startedAt.toDouble(), + second.myInfo.startedAt.toDouble(), + _sortData.sortDirection, + )); + return swaps; + } + + List _sortByType(List swaps) { + swaps.sort((first, second) => sortByBool( + first.isTaker, + second.isTaker, + _sortData.sortDirection, + )); + return swaps; + } +} diff --git a/lib/views/dex/entities_list/in_progress/in_progress_list_header.dart b/lib/views/dex/entities_list/in_progress/in_progress_list_header.dart new file mode 100644 index 0000000000..6a255166f9 --- /dev/null +++ b/lib/views/dex/entities_list/in_progress/in_progress_list_header.dart @@ -0,0 +1,62 @@ +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/ui/ui_list_header_with_sortings.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class InProgressListHeader extends StatelessWidget { + const InProgressListHeader({ + Key? key, + required this.sortData, + required this.onSortChange, + }) : super(key: key); + final SortData sortData; + final void Function(SortData) onSortChange; + + @override + Widget build(BuildContext context) { + return UiListHeaderWithSorting( + items: _headerItems, + sortData: sortData, + onSortChange: onSortChange, + ); + } +} + +List> _headerItems = [ + SortHeaderItemData( + text: LocaleKeys.status.tr(), + value: InProgressListSortType.status, + ), + SortHeaderItemData( + text: LocaleKeys.send.tr(), + value: InProgressListSortType.send, + ), + SortHeaderItemData( + text: LocaleKeys.receive.tr(), + value: InProgressListSortType.receive, + ), + SortHeaderItemData( + text: LocaleKeys.price.tr(), + value: InProgressListSortType.price, + ), + SortHeaderItemData( + text: LocaleKeys.date.tr(), + value: InProgressListSortType.date, + ), + SortHeaderItemData( + flex: 0, + text: LocaleKeys.orderType.tr(), + value: InProgressListSortType.orderType, + ), +]; + +enum InProgressListSortType { + status, + send, + receive, + price, + date, + orderType, + none, +} diff --git a/lib/views/dex/entities_list/orders/order_cancel_button.dart b/lib/views/dex/entities_list/orders/order_cancel_button.dart new file mode 100644 index 0000000000..d6c4d3095d --- /dev/null +++ b/lib/views/dex/entities_list/orders/order_cancel_button.dart @@ -0,0 +1,61 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/shared/ui/ui_light_button.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class OrderCancelButton extends StatefulWidget { + const OrderCancelButton({ + Key? key, + required this.order, + }) : super(key: key); + + final MyOrder order; + + @override + State createState() => _OrderCancelButtonState(); +} + +class _OrderCancelButtonState extends State { + bool _isCancelling = false; + + @override + Widget build(BuildContext context) { + return UiLightButton( + text: LocaleKeys.cancel.tr(), + width: 80, + height: 22, + prefix: _isCancelling ? const UiSpinner(width: 12, height: 12) : null, + backgroundColor: Colors.transparent, + border: Border.all( + color: const Color.fromRGBO(234, 234, 234, 1), + width: 1.0, + ), + textStyle: const TextStyle(fontSize: 12), + onPressed: _isCancelling + ? null + : () => onCancel(widget.order), //isCancelling ? null : onCancel, + ); + } + + Future onCancel(MyOrder order) async { + setState(() { + _isCancelling = true; + }); + final String? error = await tradingEntitiesBloc.cancelOrder(order.uuid); + setState(() { + _isCancelling = false; + }); + if (error != null) { + // TODO(Francois): move to bloc / data layer? + log( + 'Error order cancellation: ${error.toString()}', + path: 'order_item => _onCancel', + isError: true, + ); + } + } +} diff --git a/lib/views/dex/entities_list/orders/order_item.dart b/lib/views/dex/entities_list/orders/order_item.dart new file mode 100644 index 0000000000..cf72bee01c --- /dev/null +++ b/lib/views/dex/entities_list/orders/order_item.dart @@ -0,0 +1,391 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:vector_math/vector_math_64.dart' as vector_math; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/focusable_widget.dart'; +import 'package:web_dex/views/dex/entities_list/common/buy_price_mobile.dart'; +import 'package:web_dex/views/dex/entities_list/common/coin_amount_mobile.dart'; +import 'package:web_dex/views/dex/entities_list/common/count_down_timer.dart'; +import 'package:web_dex/views/dex/entities_list/common/trade_amount_desktop.dart'; + +class OrderItem extends StatefulWidget { + const OrderItem(this.order, {super.key, this.actions = const []}); + + final MyOrder order; + final List actions; + + @override + State createState() => _OrderItemState(); +} + +class _OrderItemState extends State { + @override + Widget build(BuildContext context) { + final order = widget.order; + final String sellCoin = order.base; + final Rational sellAmount = order.baseAmount; + final String buyCoin = order.rel; + final Rational buyAmount = order.relAmount; + final bool isTaker = order.orderType == TradeSide.taker; + final String date = getFormattedDate(order.createdAt); + final int orderMatchingTime = order.orderMatchingTime; + final double fillProgress = tradingEntitiesBloc.getProgressFillSwap(order); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isMobile) + Text( + tradingEntitiesBloc.getTypeString(isTaker), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: _protocolColor, + ), + ), + FocusableWidget( + onTap: () { + routingState.dexState.setDetailsAction(order.uuid); + }, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.onSurface, + ), + child: isMobile + ? _OrderItemMobile( + buyAmount: buyAmount, + buyCoin: buyCoin, + sellCoin: sellCoin, + sellAmount: sellAmount, + date: date, + isTaker: isTaker, + fillProgress: fillProgress, + orderMatchingTime: orderMatchingTime, + actions: widget.actions, + ) + : _OrderItemDesktop( + buyAmount: buyAmount, + buyCoin: buyCoin, + sellCoin: sellCoin, + sellAmount: sellAmount, + date: date, + isTaker: isTaker, + fillProgress: fillProgress, + orderMatchingTime: orderMatchingTime, + actions: widget.actions, + ), + ), + ), + ], + ); + } + + Color get _protocolColor => widget.order.orderType == TradeSide.taker + ? const Color.fromRGBO(47, 179, 239, 1) + : const Color.fromRGBO(106, 77, 227, 1); +} + +class _OrderItemDesktop extends StatelessWidget { + const _OrderItemDesktop({ + required this.buyCoin, + required this.buyAmount, + required this.sellCoin, + required this.sellAmount, + required this.date, + required this.fillProgress, + required this.isTaker, + required this.orderMatchingTime, + this.actions = const [], + }); + final String buyCoin; + final Rational buyAmount; + final String sellCoin; + final Rational sellAmount; + final String date; + final double fillProgress; + final bool isTaker; + final int orderMatchingTime; + final List actions; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: TradeAmountDesktop(coinAbbr: sellCoin, amount: sellAmount), + ), + Expanded( + child: TradeAmountDesktop(coinAbbr: buyCoin, amount: buyAmount), + ), + Expanded( + child: Text( + formatAmt( + tradingEntitiesBloc.getPriceFromAmount( + sellAmount, + buyAmount, + ), + ), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + Expanded( + child: Text( + date, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + flex: 0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tradingEntitiesBloc.getTypeString(isTaker), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isTaker + ? const Color.fromRGBO(47, 179, 239, 1) + : const Color.fromRGBO(106, 77, 227, 1), + ), + ), + if (isTaker) + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: CountDownTimer(orderMatchingTime: orderMatchingTime), + ) + else + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: SizedBox( + width: 18, + height: 18, + child: CustomPaint( + painter: _FillPainter( + context: context, + fillProgress: fillProgress, + ), + ), + ), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: actions.isNotEmpty + ? Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + ...actions, + ], + ) + : const SizedBox(width: 80), + ), + ], + ); + } +} + +class _FillPainter extends CustomPainter { + _FillPainter({ + required this.context, + required this.fillProgress, + }); + + final BuildContext context; + final double fillProgress; + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return true; + } + + @override + void paint(Canvas canvas, Size size) { + final theme = Theme.of(context); + + final Paint paint = Paint() + ..color = Theme.of(context).highlightColor + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.fill; + + final Offset center = Offset(size.width / 2, size.height / 2); + canvas.drawCircle(center, size.width / 2, paint); + + final Paint fillPaint = Paint() + ..style = PaintingStyle.stroke + ..color = + theme.progressIndicatorTheme.color ?? theme.colorScheme.secondary + ..strokeWidth = size.width * 1.1 / 2; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: size.width / 4), + vector_math.radians(0), + vector_math.radians(fillProgress * 360), + false, + fillPaint, + ); + } +} + +class _OrderItemMobile extends StatelessWidget { + const _OrderItemMobile({ + required this.buyCoin, + required this.buyAmount, + required this.sellCoin, + required this.sellAmount, + required this.date, + required this.fillProgress, + required this.isTaker, + required this.orderMatchingTime, + this.actions = const [], + }); + + final String buyCoin; + final Rational buyAmount; + final String sellCoin; + final Rational sellAmount; + final String date; + final double fillProgress; + final bool isTaker; + final int orderMatchingTime; + final List actions; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.send.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: CoinAmountMobile( + coinAbbr: sellCoin, + amount: sellAmount, + ), + ), + ], + ), + ), + if (isTaker) + Container( + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: const Color.fromRGBO(255, 255, 255, 1), + ), + child: CountDownTimer(orderMatchingTime: orderMatchingTime), + ) + else + BuyPriceMobile( + buyCoin: buyCoin, + sellAmount: sellAmount, + buyAmount: buyAmount, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.receive.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 5.0), + child: CoinAmountMobile( + coinAbbr: buyCoin, + amount: buyAmount, + ), + ), + ], + ), + ), + ...actions, + ], + ), + ), + if (!isTaker) + Container( + margin: const EdgeInsets.only(top: 14), + padding: const EdgeInsets.fromLTRB(6, 12, 6, 12), + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: Theme.of(context).colorScheme.surface, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 18, + height: 18, + child: CustomPaint( + painter: _FillPainter( + context: context, + fillProgress: fillProgress, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Text( + LocaleKeys.percentFilled + .tr(args: [(fillProgress * 100).toStringAsFixed(0)]), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/views/dex/entities_list/orders/order_list_header.dart b/lib/views/dex/entities_list/orders/order_list_header.dart new file mode 100644 index 0000000000..5c5dedd073 --- /dev/null +++ b/lib/views/dex/entities_list/orders/order_list_header.dart @@ -0,0 +1,65 @@ +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/ui/ui_list_header_with_sortings.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class OrderListHeader extends StatelessWidget { + const OrderListHeader({ + Key? key, + required this.sortData, + required this.onSortChange, + }) : super(key: key); + final SortData sortData; + final void Function(SortData) onSortChange; + + @override + Widget build(BuildContext context) { + return UiListHeaderWithSorting( + items: _headerItems, + sortData: sortData, + onSortChange: onSortChange, + ); + } +} + +List> _headerItems = [ + SortHeaderItemData( + text: LocaleKeys.send.tr(), + value: OrderListSortType.send, + ), + SortHeaderItemData( + text: LocaleKeys.receive.tr(), + value: OrderListSortType.receive, + ), + SortHeaderItemData( + text: LocaleKeys.price.tr(), + value: OrderListSortType.price, + ), + SortHeaderItemData( + text: LocaleKeys.date.tr(), + value: OrderListSortType.date, + ), + SortHeaderItemData( + text: LocaleKeys.orderType.tr(), + flex: 0, + width: 100, + value: OrderListSortType.orderType, + ), + SortHeaderItemData( + text: '', + flex: 0, + width: 80, + value: OrderListSortType.none, + isEmpty: true, + ), +]; + +enum OrderListSortType { + send, + receive, + price, + date, + orderType, + none, +} diff --git a/lib/views/dex/entities_list/orders/orders_list.dart b/lib/views/dex/entities_list/orders/orders_list.dart new file mode 100644 index 0000000000..e6b47283e1 --- /dev/null +++ b/lib/views/dex/entities_list/orders/orders_list.dart @@ -0,0 +1,183 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/shared/utils/sorting.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/views/dex/entities_list/common/dex_empty_list.dart'; +import 'package:web_dex/views/dex/entities_list/common/dex_error_message.dart'; +import 'package:web_dex/views/dex/entities_list/orders/order_cancel_button.dart'; +import 'package:web_dex/views/dex/entities_list/orders/order_item.dart'; +import 'package:web_dex/views/dex/entities_list/orders/order_list_header.dart'; + +class OrdersList extends StatefulWidget { + const OrdersList({ + Key? key, + required this.entitiesFilterData, + }) : super(key: key); + final TradingEntitiesFilter? entitiesFilterData; + + @override + State createState() => _OrdersListState(); +} + +class _OrdersListState extends State { + final _mainScrollController = ScrollController(); + + SortData _sortData = const SortData( + sortDirection: SortDirection.increase, sortType: OrderListSortType.send); + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + initialData: tradingEntitiesBloc.myOrders, + stream: tradingEntitiesBloc.outMyOrders, + builder: (context, ordersSnapshot) { + final List orders = ordersSnapshot.data ?? []; + + if (ordersSnapshot.hasError) { + return const DexErrorMessage(); + } + + final TradingEntitiesFilter? entitiesFilterData = + widget.entitiesFilterData; + + final filtered = entitiesFilterData != null + ? applyFiltersForOrders(orders, entitiesFilterData) + : orders; + + if (!ordersSnapshot.hasData || filtered.isEmpty) { + return const DexEmptyList(); + } + final List sortedOrders = _sortOrders(filtered); + + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + if (!isMobile) + Column( + children: [ + const Align( + alignment: Alignment.bottomRight, + child: SizedBox(height: 8), + ), + Align( + alignment: Alignment.bottomRight, + child: UiPrimaryButton( + text: LocaleKeys.cancelAll.tr(), + height: 32, + width: 120, + onPressed: () => tradingEntitiesBloc.cancelAllOrders(), + ), + ), + OrderListHeader( + sortData: _sortData, + onSortChange: _onSortChange, + ), + ], + ), + Flexible( + child: Padding( + padding: EdgeInsets.only(top: isMobile ? 0 : 10.0), + child: DexScrollbar( + isMobile: isMobile, + scrollController: _mainScrollController, + child: ListView.builder( + shrinkWrap: true, + controller: _mainScrollController, + itemBuilder: (BuildContext context, int index) { + final MyOrder order = sortedOrders[index]; + final bool isCancelable = order.cancelable; + + return OrderItem(order, + actions: !isCancelable + ? [] + : [OrderCancelButton(order: order)]); + }, + itemCount: sortedOrders.length, + ), + ), + ), + ), + ], + ); + }); + } + + void _onSortChange(SortData sortData) { + setState(() { + _sortData = sortData; + }); + } + + List _sortOrders(List orders) { + switch (_sortData.sortType) { + case OrderListSortType.send: + return _sortByAmount(orders, true); + case OrderListSortType.receive: + return _sortByAmount(orders, false); + case OrderListSortType.price: + return _sortByPrice(orders); + case OrderListSortType.date: + return _sortByDate(orders); + case OrderListSortType.orderType: + return _sortByType(orders); + case OrderListSortType.none: + return orders; + } + } + + List _sortByAmount(List orders, bool isSend) { + if (isSend) { + orders.sort((first, second) => sortByDouble( + first.baseAmount.toDouble(), + second.baseAmount.toDouble(), + _sortData.sortDirection, + )); + } else { + orders.sort((first, second) => sortByDouble( + first.relAmount.toDouble(), + second.relAmount.toDouble(), + _sortData.sortDirection, + )); + } + return orders; + } + + List _sortByPrice(List orders) { + orders.sort((first, second) => sortByDouble( + tradingEntitiesBloc.getPriceFromAmount( + first.baseAmount, + first.relAmount, + ), + tradingEntitiesBloc.getPriceFromAmount( + second.baseAmount, + second.relAmount, + ), + _sortData.sortDirection, + )); + return orders; + } + + List _sortByDate(List orders) { + orders.sort((first, second) => sortByDouble( + first.createdAt.toDouble(), + second.createdAt.toDouble(), + _sortData.sortDirection, + )); + return orders; + } + + List _sortByType(List orders) { + orders.sort((first, second) => sortByOrderType( + first.orderType, + second.orderType, + _sortData.sortDirection, + )); + return orders; + } +} diff --git a/lib/views/dex/entity_details/maker_order/maker_order_details_page.dart b/lib/views/dex/entity_details/maker_order/maker_order_details_page.dart new file mode 100644 index 0000000000..0422458ecb --- /dev/null +++ b/lib/views/dex/entity_details/maker_order/maker_order_details_page.dart @@ -0,0 +1,236 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/order_status/cancellation_reason.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/router/state/dex_state.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/ui/ui_light_button.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/dex/entity_details/trading_details_coin_pair.dart'; +import 'package:web_dex/views/dex/entity_details/trading_details_header.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class MakerOrderDetailsPage extends StatefulWidget { + const MakerOrderDetailsPage(this.makerOrderStatus, {Key? key}) + : super(key: key); + + final MakerOrderStatus makerOrderStatus; + + @override + State createState() => _MakerOrderDetailsPageState(); +} + +class _MakerOrderDetailsPageState extends State { + bool _inProgress = false; + String? _cancelingError; + + @override + Widget build(BuildContext context) { + final MyOrder order = widget.makerOrderStatus.order; + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + TradingDetailsHeader( + title: LocaleKeys.makerOrderDetails.tr(), + ), + const SizedBox(height: 40), + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TradingDetailsCoinPair( + baseCoin: order.base, + baseAmount: order.baseAmountAvailable ?? order.baseAmount, + relCoin: order.rel, + relAmount: order.relAmountAvailable ?? order.relAmount, + ), + const SizedBox(height: 30), + _buildDetails(), + const SizedBox(height: 30), + _buildCancelButton(), + ], + ), + ), + ), + ], + ); + } + + Widget _buildDetails() { + return Container( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 0), + child: Table( + columnWidths: const {0: FlexColumnWidth(1.4), 1: FlexColumnWidth(4)}, + children: [ + _buildPrice(), + _buildCreatedAt(), + _buildOrderId(), + _buildStatus(), + ], + ), + ); + } + + Widget _buildCancelButton() { + if (!widget.makerOrderStatus.order.cancelable) { + return const SizedBox.shrink(); + } + + return Column( + children: [ + if (_cancelingError != null) + Container( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 10), + child: Text( + _cancelingError!, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + ), + IgnorePointer( + ignoring: _inProgress, + child: UiLightButton( + text: LocaleKeys.cancelOrder.tr(), + onPressed: _cancelOrder, + prefix: _inProgress + ? Padding( + padding: const EdgeInsets.only(right: 4), + child: UiSpinner( + width: 10, + height: 10, + strokeWidth: 1, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ) + : null, + ), + ), + ], + ); + } + + TableRow _buildStatus() { + final MakerOrderCancellationReason reason = + widget.makerOrderStatus.cancellationReason; + + String status = LocaleKeys.active.tr(); + switch (reason) { + case MakerOrderCancellationReason.cancelled: + status = LocaleKeys.cancelled.tr(); + break; + case MakerOrderCancellationReason.fulfilled: + status = LocaleKeys.fulfilled.tr(); + break; + case MakerOrderCancellationReason.insufficientBalance: + status = LocaleKeys.cancelledInsufficientBalance.tr(); + break; + case MakerOrderCancellationReason.none: + break; + } + + return TableRow( + children: [ + SizedBox( + height: 30, + child: Text('${LocaleKeys.status.tr()}:', + style: Theme.of(context).textTheme.bodyLarge)), + Text(status), + ], + ); + } + + TableRow _buildOrderId() { + final MyOrder order = widget.makerOrderStatus.order; + return TableRow( + children: [ + SizedBox( + height: 30, + child: Text('${LocaleKeys.orderId.tr()}:', + style: Theme.of(context).textTheme.bodyLarge)), + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: InkWell( + onTap: () => copyToClipBoard(context, order.uuid), + child: Text( + order.uuid, + key: const Key('maker-order-uuid'), + ), + ), + ), + ], + ); + } + + TableRow _buildCreatedAt() { + final String createdAt = DateFormat('dd MMM yyyy, HH:mm').format( + DateTime.fromMillisecondsSinceEpoch( + widget.makerOrderStatus.order.createdAt * 1000)); + + return TableRow( + children: [ + SizedBox( + height: 30, + child: Text('${LocaleKeys.createdAt.tr()}:', + style: Theme.of(context).textTheme.bodyLarge)), + Text(createdAt), + ], + ); + } + + TableRow _buildPrice() { + final MyOrder order = widget.makerOrderStatus.order; + final String price = + formatAmt((order.relAmount / order.baseAmount).toDouble()); + + return TableRow( + children: [ + SizedBox( + height: 30, + child: Text('${LocaleKeys.price.tr()}:', + style: Theme.of(context).textTheme.bodyLarge)), + Row( + children: [ + Text( + price, + style: const TextStyle(fontWeight: FontWeight.w500), + ), + const SizedBox( + width: 5, + ), + Text(order.rel) + ], + ), + ], + ); + } + + Future _cancelOrder() async { + setState(() { + _cancelingError = null; + _inProgress = true; + }); + + final String? error = await tradingEntitiesBloc + .cancelOrder(widget.makerOrderStatus.order.uuid); + + await Future.delayed(const Duration(milliseconds: 1000)); + + setState(() => _inProgress = false); + + if (error != null) { + setState(() => _cancelingError = error); + } else { + routingState.dexState.action = DexAction.none; + routingState.dexState.uuid = ''; + } + } +} diff --git a/lib/views/dex/entity_details/swap/swap_details.dart b/lib/views/dex/entity_details/swap/swap_details.dart new file mode 100644 index 0000000000..b0443fa04c --- /dev/null +++ b/lib/views/dex/entity_details/swap/swap_details.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/views/dex/entity_details/swap/swap_details_step_list.dart'; +import 'package:web_dex/views/dex/entity_details/swap/swap_recover_button.dart'; +import 'package:web_dex/views/dex/entity_details/trading_details_coin_pair.dart'; +import 'package:web_dex/views/dex/entity_details/trading_details_total_time.dart'; + +class SwapDetails extends StatelessWidget { + const SwapDetails( + {Key? key, required this.swapStatus, required this.isFailed}) + : super(key: key); + final Swap swapStatus; + final bool isFailed; + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 420), + padding: isMobile ? const EdgeInsets.symmetric(horizontal: 12) : null, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (swapStatus.recoverable) + Padding( + padding: const EdgeInsets.only(bottom: 20), + child: SwapRecoverButton( + uuid: swapStatus.uuid, + ), + ), + TradingDetailsCoinPair( + baseCoin: swapStatus.isTaker + ? swapStatus.takerCoin + : swapStatus.makerCoin, + baseAmount: swapStatus.isTaker + ? swapStatus.takerAmount + : swapStatus.makerAmount, + relCoin: swapStatus.isTaker + ? swapStatus.makerCoin + : swapStatus.takerCoin, + relAmount: swapStatus.isTaker + ? swapStatus.makerAmount + : swapStatus.takerAmount, + ), + const SizedBox(height: 20), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TradingDetailsTotalTime( + startedTime: swapStatus.myInfo.startedAt * 1000, + finishedTime: _finishedTime, + ), + const SizedBox(height: 24), + Flexible( + child: Padding( + padding: const EdgeInsets.only(bottom: 20.0), + child: SwapDetailsStepList(swapStatus: swapStatus), + ), + ), + ], + ) + ], + ), + ); + } + + int? get _finishedTime { + if (swapStatus.events.isEmpty) { + return null; + } + if (swapStatus.events.last.event.type == swapStatus.successEvents.last || + isFailed) { + return swapStatus.events.last.timestamp; + } + + return null; + } +} diff --git a/lib/views/dex/entity_details/swap/swap_details_page.dart b/lib/views/dex/entity_details/swap/swap_details_page.dart new file mode 100644 index 0000000000..07f7f838b7 --- /dev/null +++ b/lib/views/dex/entity_details/swap/swap_details_page.dart @@ -0,0 +1,62 @@ +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/views/dex/entity_details/swap/swap_details.dart'; +import 'package:web_dex/views/dex/entity_details/trading_details_header.dart'; +import 'package:web_dex/views/dex/entity_details/trading_progress_status.dart'; + +class SwapDetailsPage extends StatelessWidget { + const SwapDetailsPage(this.swapStatus, {Key? key}) : super(key: key); + + final Swap swapStatus; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + TradingDetailsHeader( + title: _headerText, + ), + SwapProgressStatus(progress: _progress, isFailed: _isFailed), + SwapDetails(swapStatus: swapStatus, isFailed: _isFailed), + ], + ); + } + + String get _headerText { + if (_isFailed) return LocaleKeys.tradingDetailsTitleFailed.tr(); + + final haveEvents = swapStatus.events.isNotEmpty; + + if (haveEvents) { + final isSuccess = + swapStatus.events.last.event.type == swapStatus.successEvents.last; + + if (isSuccess) return LocaleKeys.tradingDetailsTitleCompleted.tr(); + return LocaleKeys.tradingDetailsTitleInProgress.tr(); + } + return LocaleKeys.tradingDetailsTitleOrderMatching.tr(); + } + + bool get _isFailed { + return swapStatus.events.firstWhereOrNull( + (event) => swapStatus.errorEvents.contains(event.event.type)) != + null; + } + + int get _progress { + // successEvents has MakerPaymentSpent and MakerPaymentSpentByWatcher + // But events can have only one of them so we have -1 here + return min( + 100, + 100 * + swapStatus.events.length / + (swapStatus.successEvents.length - 1)) + .ceil(); + } +} diff --git a/lib/views/dex/entity_details/swap/swap_details_step.dart b/lib/views/dex/entity_details/swap/swap_details_step.dart new file mode 100644 index 0000000000..33c4375041 --- /dev/null +++ b/lib/views/dex/entity_details/swap/swap_details_step.dart @@ -0,0 +1,211 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/shared/widgets/copied_text.dart'; + +class SwapDetailsStep extends StatelessWidget { + const SwapDetailsStep({ + Key? key, + required this.event, + this.isCurrentStep = false, + this.isProcessedStep = false, + this.isDisabled = false, + this.isFailedStep = false, + this.isLastStep = false, + this.timeSpent = 0, + this.txHash, + this.coin, + }) : super(key: key); + + final int timeSpent; + final String event; + final bool isCurrentStep; + final bool isProcessedStep; + final bool isDisabled; + final bool isFailedStep; + final bool isLastStep; + final String? txHash; + final Coin? coin; + + Color get _circleColor { + if (isFailedStep) { + return theme.custom.tradingDetailsTheme.swapStepCircleFailedColor; + } + if (isDisabled) { + return theme.custom.tradingDetailsTheme.swapStepCircleDisabledColor; + } + + return theme.custom.tradingDetailsTheme.swapStepCircleNormalColor; + } + + Color get _textColor { + if (isFailedStep) { + return theme.custom.tradingDetailsTheme.swapStepTextFailedColor; + } + if (isDisabled) { + return theme.custom.tradingDetailsTheme.swapStepTextDisabledColor; + } + if (isCurrentStep) { + return theme.custom.tradingDetailsTheme.swapStepTextCurrentColor; + } + + return const Color.fromRGBO(106, 77, 227, 1); + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final String? txHash = this.txHash; + final Coin? coin = this.coin; + + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Column( + children: [ + Container( + width: 20, + height: 20, + decoration: + BoxDecoration(color: _circleColor, shape: BoxShape.circle), + child: Padding( + padding: const EdgeInsets.all(2), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isProcessedStep || isFailedStep + ? Colors.transparent + : themeData.colorScheme.surface), + ), + ), + ), + if (!isLastStep) + Container( + height: 40, + width: 1, + color: isProcessedStep + ? theme.custom.progressBarPassedColor + : themeData.textTheme.bodyMedium?.color?.withOpacity(0.3) ?? + Colors.transparent, + ), + ], + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: AutoScrollText( + text: event, + style: TextStyle( + color: _textColor, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: _buildAdditionalInfo(context), + ), + ], + ), + if (txHash != null && coin != null) + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: CopiedText( + copiedValue: txHash, + padding: const EdgeInsets.fromLTRB(8, 4, 8, 4), + isTruncated: true, + fontSize: 11, + iconSize: 14, + backgroundColor: + theme.custom.specificButtonBackgroundColor, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 6.0, right: 10), + child: Material( + child: Tooltip( + message: LocaleKeys.viewOnExplorer.tr(), + child: InkWell( + child: const Icon( + Icons.open_in_browser, + size: 20, + ), + onTap: () => + launchURL(getTxExplorerUrl(coin, txHash)), + ), + ), + ), + ) + ], + ) + ], + ), + ), + ], + ); + } + + Widget _buildAdditionalInfo(BuildContext context) { + if (isFailedStep) { + return SelectableText( + LocaleKeys.swapDetailsStepStatusFailed.tr(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: _textColor, + ), + ); + } + if (isCurrentStep) { + return SelectableText( + LocaleKeys.swapDetailsStepStatusInProcess.tr(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: _textColor, + ), + ); + } + if (!isDisabled) { + return SelectableText( + _getTimeSpent(context), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: theme.custom.tradingDetailsTheme.swapStepTimerColor, + ), + ); + } + return const Text(''); + } + + String _getTimeSpent(BuildContext context) { + return LocaleKeys.swapDetailsStepStatusTimeSpent.tr(args: [ + durationFormat( + Duration(milliseconds: timeSpent), + DurationLocalization( + milliseconds: LocaleKeys.milliseconds.tr(), + seconds: LocaleKeys.seconds.tr(), + minutes: LocaleKeys.minutes.tr(), + hours: LocaleKeys.hours.tr(), + ), + ), + ]); + } +} diff --git a/lib/views/dex/entity_details/swap/swap_details_step_list.dart b/lib/views/dex/entity_details/swap/swap_details_step_list.dart new file mode 100644 index 0000000000..a369279ed4 --- /dev/null +++ b/lib/views/dex/entity_details/swap/swap_details_step_list.dart @@ -0,0 +1,113 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/views/dex/entity_details/swap/swap_details_step.dart'; + +class SwapDetailsStepList extends StatelessWidget { + const SwapDetailsStepList({Key? key, required this.swapStatus}) + : super(key: key); + final Swap swapStatus; + + @override + Widget build(BuildContext context) { + final bool isFailedSwap = _checkFailedSwap(); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: swapStatus.successEvents.map( + (event) { + if ((event == 'MakerPaymentSpentByWatcher' && + swapStatus.events.any((SwapEventItem e) => + e.event.type == 'MakerPaymentSpent')) || + (event == 'MakerPaymentSpent' && + swapStatus.events.any((SwapEventItem e) => + e.event.type == 'MakerPaymentSpentByWatcher'))) { + return const SizedBox.shrink(); + } + + final bool isLastStep = event == 'Finished'; + final bool isExistStep = swapStatus.events + .firstWhereOrNull((e) => e.event.type == event) != + null; + final bool isProcessedStep = + isExistStep && !(isLastStep && isFailedSwap); + final bool isCurrentStep = + !isProcessedStep && _checkCurrentStep(swapStatus, event); + final bool isDisabledStep = !isCurrentStep && !isProcessedStep || + (isLastStep && isFailedSwap); + final SwapEventItem? eventData = + swapStatus.events.firstWhereOrNull((e) => e.event.type == event); + final Coin? coin = _getCoinForTransaction(event, swapStatus); + + return SwapDetailsStep( + key: Key('swap-details-step-$event'), + event: event, + isCurrentStep: isCurrentStep, + isProcessedStep: isProcessedStep, + isDisabled: isDisabledStep, + isLastStep: isLastStep, + isFailedStep: isCurrentStep && isFailedSwap, + timeSpent: _calculateTimeSpent(event), + txHash: eventData?.event.data?.txHash, + coin: coin, + ); + }, + ).toList(), + ); + } + + bool _checkCurrentStep(Swap swapStatus, String event) { + final int index = swapStatus.successEvents.indexOf(event); + final int previousStepIndex = swapStatus.events.indexWhere( + (e) => (swapStatus.successEvents.indexOf(e.event.type) + 1) == index); + return previousStepIndex != -1; + } + + bool _checkFailedSwap() => + swapStatus.events.firstWhereOrNull( + (e) => swapStatus.errorEvents.contains(e.event.type)) != + null; + + bool isLastStep(String event) => event == swapStatus.successEvents.last; + + int _calculateTimeSpent(String event) { + final SwapEventItem? currentEvent = + swapStatus.events.firstWhereOrNull((e) => e.event.type == event); + if (currentEvent == null) { + return 0; + } + final int currentEventIndex = swapStatus.events + .indexWhere((e) => e.event.type == currentEvent.event.type); + if (currentEventIndex == 0) { + return currentEvent.timestamp - swapStatus.myInfo.startedAt * 1000; + } + final SwapEventItem previousEvent = + swapStatus.events[currentEventIndex - 1]; + return currentEvent.timestamp - previousEvent.timestamp; + } + + Coin? _getCoinForTransaction(String event, Swap swapStatus) { + final List takerEvents = [ + 'TakerPaymentSent', + 'TakerPaymentSpent', + 'TakerFeeSent', + 'TakerFeeValidated', + 'TakerPaymentReceived' + ]; + final List makerEvents = [ + 'MakerPaymentReceived', + 'MakerPaymentSpent', + 'MakerPaymentSpentByWatcher', + 'MakerPaymentSent', + ]; + if (takerEvents.contains(event)) { + return coinsBloc.getCoin(swapStatus.takerCoin); + } + if (makerEvents.contains(event)) { + return coinsBloc.getCoin(swapStatus.makerCoin); + } + return null; + } +} diff --git a/lib/views/dex/entity_details/swap/swap_recover_button.dart b/lib/views/dex/entity_details/swap/swap_recover_button.dart new file mode 100644 index 0000000000..955ab32f22 --- /dev/null +++ b/lib/views/dex/entity_details/swap/swap_recover_button.dart @@ -0,0 +1,126 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/recover_funds_of_swap/recover_funds_of_swap_response.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class SwapRecoverButton extends StatefulWidget { + const SwapRecoverButton({Key? key, required this.uuid}) : super(key: key); + + final String uuid; + + @override + State createState() => _SwapRecoverButtonState(); +} + +class _SwapRecoverButtonState extends State { + bool _isLoading = false; + bool _isFailedRecover = false; + String _message = ''; + RecoverFundsOfSwapResponse? _recoverResponse; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible(child: SelectableText(LocaleKeys.swapRecoverButtonTitle.tr())), + const SizedBox( + height: 10, + ), + Flexible( + child: _isLoading + ? const Center( + child: UiSpinner( + width: 48, + height: 48, + ), + ) + : UiPrimaryButton( + text: LocaleKeys.swapRecoverButtonText.tr(), + onPressed: () async { + if (_isLoading) { + return; + } + setState(() { + _isLoading = true; + _isFailedRecover = false; + _recoverResponse = null; + _message = ''; + }); + final response = await tradingEntitiesBloc + .recoverFundsOfSwap(widget.uuid); + await Future.delayed(const Duration(seconds: 1)); + if (response == null) { + setState(() { + _message = + LocaleKeys.swapRecoverButtonErrorMessage.tr(); + _isFailedRecover = true; + }); + } else { + setState(() { + _message = + LocaleKeys.swapRecoverButtonSuccessMessage.tr(); + _recoverResponse = response; + _isFailedRecover = false; + }); + } + setState(() { + _isLoading = false; + }); + }, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 5.0), + child: _message.isNotEmpty ? _buildMessage() : const SizedBox(), + ), + ], + ); + } + + Widget _buildMessage() { + final ThemeData themeData = Theme.of(context); + final RecoverFundsOfSwapResponse? response = _recoverResponse; + if (_isFailedRecover) { + return SelectableText( + _message, + style: TextStyle( + fontWeight: FontWeight.w500, + color: themeData.colorScheme.error, + ), + ); + } + final Coin? coin = coinsBloc.getCoin(response?.result.coin ?? ''); + if (coin == null || response == null) { + return const SizedBox(); + } + final String url = getTxExplorerUrl(coin, response.result.txHash); + + return Column( + children: [ + SelectableText( + _message, + style: TextStyle( + fontWeight: FontWeight.w500, + color: theme.custom.successColor, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 5.0), + child: InkWell( + child: Text( + '${LocaleKeys.transactionHash.tr()}: ${response.result.txHash}'), + onTap: () { + launchURL(url); + }, + ), + ), + ], + ); + } +} diff --git a/lib/views/dex/entity_details/taker_order/taker_order_details.dart b/lib/views/dex/entity_details/taker_order/taker_order_details.dart new file mode 100644 index 0000000000..267d07e452 --- /dev/null +++ b/lib/views/dex/entity_details/taker_order/taker_order_details.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/services/orders_service/my_orders_service.dart'; +import 'package:web_dex/views/dex/entity_details/swap/swap_details_step.dart'; +import 'package:web_dex/views/dex/entity_details/trading_details_coin_pair.dart'; + +class TakerOrderDetails extends StatelessWidget { + const TakerOrderDetails({ + Key? key, + required this.takerOrderStatus, + required this.isFailed, + }) : super(key: key); + + final TakerOrderStatus takerOrderStatus; + final bool isFailed; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TradingDetailsCoinPair( + baseCoin: takerOrderStatus.order.base, + baseAmount: takerOrderStatus.order.baseAmount, + relCoin: takerOrderStatus.order.rel, + relAmount: takerOrderStatus.order.relAmount, + ), + const SizedBox(height: 40), + SwapDetailsStep( + event: 'Started', + isCurrentStep: true, + isFailedStep: isFailed, + isLastStep: true, + ), + const SizedBox(height: 40), + ], + ), + ); + } +} diff --git a/lib/views/dex/entity_details/taker_order/taker_order_details_page.dart b/lib/views/dex/entity_details/taker_order/taker_order_details_page.dart new file mode 100644 index 0000000000..21b4430a52 --- /dev/null +++ b/lib/views/dex/entity_details/taker_order/taker_order_details_page.dart @@ -0,0 +1,52 @@ +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/order_status/cancellation_reason.dart'; +import 'package:web_dex/services/orders_service/my_orders_service.dart'; +import 'package:web_dex/views/dex/entity_details/taker_order/taker_order_details.dart'; +import 'package:web_dex/views/dex/entity_details/trading_details_header.dart'; +import 'package:web_dex/views/dex/entity_details/trading_progress_status.dart'; + +class TakerOrderDetailsPage extends StatefulWidget { + const TakerOrderDetailsPage(this.takerOrderStatus, {Key? key}) + : super(key: key); + + final TakerOrderStatus takerOrderStatus; + + @override + State createState() => _TakerOrderDetailsPageState(); +} + +class _TakerOrderDetailsPageState extends State { + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(), + TakerOrderDetails( + takerOrderStatus: widget.takerOrderStatus, + isFailed: _isFailed, + ), + ], + ); + } + + Widget _buildHeader() { + return Column( + children: [ + TradingDetailsHeader( + title: LocaleKeys.tradingDetailsTitleOrderMatching.tr(), + ), + const SwapProgressStatus(progress: 0), + ], + ); + } + + bool get _isFailed { + return ![ + TakerOrderCancellationReason.none, + TakerOrderCancellationReason.fulfilled + ].contains(widget.takerOrderStatus.cancellationReason); + } +} diff --git a/lib/views/dex/entity_details/trading_details.dart b/lib/views/dex/entity_details/trading_details.dart new file mode 100644 index 0000000000..7d65db1f58 --- /dev/null +++ b/lib/views/dex/entity_details/trading_details.dart @@ -0,0 +1,113 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:web_dex/bloc/dex_repository.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/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'; +import 'package:web_dex/views/dex/entity_details/swap/swap_details_page.dart'; +import 'package:web_dex/views/dex/entity_details/taker_order/taker_order_details_page.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class TradingDetails extends StatefulWidget { + const TradingDetails({Key? key, required this.uuid}) : super(key: key); + + final String uuid; + + @override + State createState() => _TradingDetailsState(); +} + +class _TradingDetailsState extends State { + late Timer _statusTimer; + Swap? _swapStatus; + OrderStatus? _orderStatus; + + @override + void initState() { + _statusTimer = Timer.periodic(const Duration(seconds: 1), (_) { + _updateStatus(); + }); + + super.initState(); + } + + @override + void dispose() { + _statusTimer.cancel(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final dynamic entityStatus = _swapStatus ?? + _orderStatus?.takerOrderStatus ?? + _orderStatus?.makerOrderStatus; + + if (entityStatus == null) return const Center(child: UiSpinner()); + final scrollController = ScrollController(); + return DexScrollbar( + scrollController: scrollController, + 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), + ); + }), + ), + ); + } + + Widget _getDetailsPage(dynamic entityStatus) { + if (entityStatus is Swap) { + return SwapDetailsPage(entityStatus); + } else if (entityStatus is TakerOrderStatus) { + return TakerOrderDetailsPage(entityStatus); + } else if (entityStatus is MakerOrderStatus) { + return MakerOrderDetailsPage(entityStatus); + } + + return const SizedBox.shrink(); + } + + Future _updateStatus() async { + Swap? swapStatus; + try { + swapStatus = await dexRepository.getSwapStatus(widget.uuid); + } on TextError catch (e, s) { + log( + e.error, + path: 'trading_details =>_updateStatus', + trace: s, + isError: true, + ); + swapStatus = null; + } catch (e, s) { + log( + e.toString(), + path: 'trading_details =>_updateStatus', + trace: s, + isError: true, + ); + swapStatus = null; + } + + final OrderStatus? orderStatus = + await myOrdersService.getStatus(widget.uuid); + + if (!mounted) return; + setState(() { + _swapStatus = swapStatus; + _orderStatus = orderStatus; + }); + } +} diff --git a/lib/views/dex/entity_details/trading_details_coin_pair.dart b/lib/views/dex/entity_details/trading_details_coin_pair.dart new file mode 100644 index 0000000000..620babc95d --- /dev/null +++ b/lib/views/dex/entity_details/trading_details_coin_pair.dart @@ -0,0 +1,64 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; + +class TradingDetailsCoinPair extends StatelessWidget { + const TradingDetailsCoinPair({ + Key? key, + required this.baseCoin, + required this.baseAmount, + required this.relCoin, + required this.relAmount, + }) : super(key: key); + final String baseCoin; + final Rational baseAmount; + final String relCoin; + final Rational relAmount; + @override + Widget build(BuildContext context) { + final Coin? coinBase = coinsBloc.getCoin(baseCoin); + final Coin? coinRel = coinsBloc.getCoin(relCoin); + + if (coinBase == null || coinRel == null) return const SizedBox.shrink(); + + return Container( + padding: const EdgeInsets.fromLTRB(10, 20, 10, 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: theme.currentGlobal.colorScheme.surface, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + Flexible( + child: CoinItem( + coin: coinBase, + amount: baseAmount.toDouble(), + size: CoinItemSize.large, + ), + ), + Column( + children: [ + SvgPicture.asset( + '$assetsPath/ui_icons/arrows.svg', + ), + ], + ), + Flexible( + child: CoinItem( + coin: coinRel, + amount: relAmount.toDouble(), + size: CoinItemSize.large, + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/dex/entity_details/trading_details_header.dart b/lib/views/dex/entity_details/trading_details_header.dart new file mode 100644 index 0000000000..511f3c2441 --- /dev/null +++ b/lib/views/dex/entity_details/trading_details_header.dart @@ -0,0 +1,55 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/main_menu_value.dart'; +import 'package:web_dex/router/state/bridge_section_state.dart'; +import 'package:web_dex/router/state/dex_state.dart'; +import 'package:web_dex/router/state/market_maker_bot_state.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/common/page_header/page_header.dart'; + +class TradingDetailsHeader extends StatelessWidget { + const TradingDetailsHeader({ + Key? key, + required this.title, + }) : super(key: key); + final String title; + + @override + Widget build(BuildContext context) { + return PageHeader( + title: title, + backText: _backButtonText, + onBackButtonPressed: () { + if (routingState.bridgeState.action != BridgeAction.none) { + routingState.bridgeState.action = BridgeAction.none; + routingState.bridgeState.uuid = ''; + } else if (routingState.dexState.action != DexAction.none) { + routingState.dexState.action = DexAction.none; + routingState.dexState.uuid = ''; + } else if (routingState.marketMakerState.action != + MarketMakerBotAction.none) { + routingState.marketMakerState.action = MarketMakerBotAction.none; + routingState.marketMakerState.uuid = ''; + } + }, + ); + } + + String get _backButtonText { + String text; + + switch (routingState.selectedMenu) { + case MainMenuValue.dex: + text = LocaleKeys.backToDex.tr(); + break; + case MainMenuValue.bridge: + text = LocaleKeys.backToBridge.tr(); + break; + default: + text = LocaleKeys.back.tr(); + } + + return text; + } +} diff --git a/lib/views/dex/entity_details/trading_details_total_time.dart b/lib/views/dex/entity_details/trading_details_total_time.dart new file mode 100644 index 0000000000..74c28c3cf4 --- /dev/null +++ b/lib/views/dex/entity_details/trading_details_total_time.dart @@ -0,0 +1,66 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class TradingDetailsTotalTime extends StatefulWidget { + const TradingDetailsTotalTime( + {Key? key, required this.startedTime, this.finishedTime}) + : super(key: key); + + final int startedTime; + final int? finishedTime; + + @override + State createState() => + _TradingDetailsTotalTimeState(); +} + +class _TradingDetailsTotalTimeState extends State { + late Timer timer; + int currentTime = DateTime.now().millisecondsSinceEpoch; + + @override + Widget build(BuildContext context) { + return SelectableText(_totalSpentTime()); + } + + @override + void dispose() { + timer.cancel(); + super.dispose(); + } + + @override + void initState() { + timer = Timer.periodic(const Duration(seconds: 1), (timer) { + _updateTime(); + }); + super.initState(); + } + + String _totalSpentTime() { + final int? finishedTime = widget.finishedTime; + final int timeSpent = finishedTime != null + ? finishedTime - widget.startedTime + : currentTime - widget.startedTime; + final DateTime date = + DateTime.fromMillisecondsSinceEpoch(timeSpent, isUtc: true); + if (date.hour == 0) { + return LocaleKeys.tradingDetailsTotalSpentTime + .tr(args: [date.minute.toString(), date.second.toString()]); + } + return LocaleKeys.tradingDetailsTotalSpentTimeWithHours.tr(args: [ + date.hour.toString(), + date.minute.toString(), + date.second.toString() + ]); + } + + void _updateTime() { + setState(() { + currentTime = DateTime.now().millisecondsSinceEpoch; + }); + } +} diff --git a/lib/views/dex/entity_details/trading_progress_status.dart b/lib/views/dex/entity_details/trading_progress_status.dart new file mode 100644 index 0000000000..891eee7e80 --- /dev/null +++ b/lib/views/dex/entity_details/trading_progress_status.dart @@ -0,0 +1,184 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.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/generated/codegen_loader.g.dart'; + +class SwapProgressStatus extends StatelessWidget { + const SwapProgressStatus({ + Key? key, + required this.progress, + this.isFailed = false, + }) : super(key: key); + + final int progress; + final bool isFailed; + + @override + Widget build(BuildContext context) { + const double circleSize = 220.0; + if (progress == 100) { + return const _CompletedSwapStatus(key: Key('swap-status-success')); + } + return isFailed + ? const _FailedSwapStatus( + circleSize: circleSize, + ) + : _InProgressSwapStatus(progress: progress, circleSize: circleSize); + } +} + +class _CompletedSwapStatus extends StatelessWidget { + const _CompletedSwapStatus({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 24, 0, 30), + child: SvgPicture.asset( + '$assetsPath/ui_icons/success_swap.svg', + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + width: 66, + height: 66, + ), + ), + ); + } +} + +class _FailedSwapStatus extends StatelessWidget { + const _FailedSwapStatus({Key? key, required this.circleSize}) + : super(key: key); + final double circleSize; + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 30, 10, 40), + child: Container( + width: circleSize, + height: circleSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: + theme.custom.tradingDetailsTheme.swapFailedStatusColors), + ), + child: Padding( + padding: const EdgeInsets.all(30), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.surface, + ), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocaleKeys.swapProgressStatusFailed.tr(), + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _InProgressSwapStatus extends StatefulWidget { + const _InProgressSwapStatus( + {Key? key, required this.progress, required this.circleSize}) + : super(key: key); + final int progress; + final double circleSize; + + @override + State<_InProgressSwapStatus> createState() => _InProgressSwapStatusState(); +} + +class _InProgressSwapStatusState extends State<_InProgressSwapStatus> + with TickerProviderStateMixin { + late AnimationController _colorAnimationController; + late Animation _colorAnimation; + + @override + void initState() { + _colorAnimationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 2500), + ); + _colorAnimationController.repeat(reverse: true); + _colorAnimation = Tween(begin: 0.0, end: 1.0) + .chain(CurveTween(curve: Curves.easeIn)) + .animate(_colorAnimationController) + ..addListener(() { + setState(() {}); + }); + super.initState(); + } + + @override + void dispose() { + _colorAnimationController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Center( + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 30, 10, 40), + child: Container( + width: widget.circleSize, + height: widget.circleSize, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + stops: [ + _colorAnimation.value - 0.7, + _colorAnimation.value, + _colorAnimation.value + 0.4, + _colorAnimation.value + 0.7, + ], + colors: theme.custom.tradingDetailsTheme.swapStatusColors, + ), + ), + child: Padding( + padding: const EdgeInsets.all(30), + child: DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.surface, + ), + child: Center( + child: Text( + '${widget.progress.toString()} %', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.secondary, + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/dex/orderbook/orderbook_error_message.dart b/lib/views/dex/orderbook/orderbook_error_message.dart new file mode 100644 index 0000000000..ac13b32973 --- /dev/null +++ b/lib/views/dex/orderbook/orderbook_error_message.dart @@ -0,0 +1,89 @@ +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, { + Key? key, + required this.onReloadClick, + }) : super(key: key); + + final OrderbookResponse response; + final VoidCallback onReloadClick; + + @override + State createState() => _OrderbookErrorMessageState(); +} + +class _OrderbookErrorMessageState extends State { + bool _isExpanded = false; + + @override + Widget build(BuildContext context) { + final String? error = widget.response.error; + if (error == null) return const SizedBox.shrink(); + + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text(LocaleKeys.orderBookFailedLoadError.tr()), + const SizedBox(height: 8), + Row( + children: [ + UiSimpleButton( + onPressed: widget.onReloadClick, + child: Text( + LocaleKeys.reloadButtonText.tr(), + style: const TextStyle(fontSize: 12), + ), + ), + 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, + ), + ], + )), + ], + ), + if (_isExpanded) + Flexible( + child: SingleChildScrollView( + controller: ScrollController(), + child: Container( + padding: const EdgeInsets.only(top: 8), + child: SelectableText( + error, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/dex/orderbook/orderbook_table.dart b/lib/views/dex/orderbook/orderbook_table.dart new file mode 100644 index 0000000000..9ad1afc71c --- /dev/null +++ b/lib/views/dex/orderbook/orderbook_table.dart @@ -0,0 +1,248 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.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/orderbook/order.dart'; +import 'package:web_dex/model/orderbook/orderbook.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/views/dex/orderbook/orderbook_table_item.dart'; +import 'package:web_dex/views/dex/orderbook/orderbook_table_title.dart'; + +class OrderbookTable extends StatelessWidget { + const OrderbookTable( + this.orderbook, { + Key? key, + this.myOrder, + this.selectedOrderUuid, + this.onAskClick, + this.onBidClick, + }) : super(key: key); + + final Orderbook orderbook; + final Order? myOrder; + final String? selectedOrderUuid; + final Function(Order)? onAskClick; + final Function(Order)? onBidClick; + + @override + Widget build(BuildContext context) { + final highestVolume = _getHighestVolume(); + + return Container( + key: const Key('orderbook-asks-bids-container'), + constraints: const BoxConstraints(maxHeight: 375), + child: ClipRRect( + borderRadius: BorderRadius.circular(8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildHeader(), + Flexible(child: _buildAsks(highestVolume)), + Container( + height: 30, + alignment: Alignment.centerLeft, + child: _buildSpotPrice(), + ), + Flexible(child: _buildBids(highestVolume)), + ], + ), + ), + ); + } + + Widget _buildSpotPrice() { + const TextStyle style = TextStyle(fontSize: 11); + final Coin? baseCoin = coinsBloc.getCoin(orderbook.base); + final Coin? relCoin = coinsBloc.getCoin(orderbook.rel); + if (baseCoin == null || relCoin == null) return const SizedBox.shrink(); + + final double? baseUsdPrice = baseCoin.usdPrice?.price; + final double? relUsdPrice = relCoin.usdPrice?.price; + if (baseUsdPrice == null || relUsdPrice == null) { + return const SizedBox.shrink(); + } + if (baseUsdPrice == 0 || relUsdPrice == 0) { + return const SizedBox.shrink(); + } + + final double spotPrice = baseUsdPrice / relUsdPrice; + + return Row( + children: [ + const SizedBox(width: 10), + Text( + formatAmt(spotPrice), + style: style.copyWith(fontWeight: FontWeight.w500), + ), + const Text(' ≈ ', style: style), + Text('\$$baseUsdPrice', style: style) + ], + ); + } + + Widget _buildHeader() { + return Container( + padding: const EdgeInsets.fromLTRB(12, 10, 0, 0), + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + OrderbookTableTitle(LocaleKeys.price.tr(), + suffix: Coin.normalizeAbbr(orderbook.rel)), + OrderbookTableTitle(LocaleKeys.volume.tr(), + suffix: Coin.normalizeAbbr(orderbook.base)), + ], + ), + const SizedBox(height: 1), + const UiDivider(), + const SizedBox(height: 0), + ], + ), + ); + } + + Widget _buildAsks(Rational highestVolume) { + final List asks = List.from(orderbook.asks); + if (myOrder?.direction == OrderDirection.ask) { + asks.add(myOrder!); + } + + asks.sort((a, b) { + if (a.price > b.price) return 1; + if (a.price < b.price) return -1; + + if (a.maxVolume > b.maxVolume) return -1; + if (a.maxVolume < b.maxVolume) return 1; + + return 0; + }); + + if (asks.isEmpty) { + return Row( + children: [ + const SizedBox(width: 4), + Text( + LocaleKeys.orderBookNoAsks.tr(), + style: TextStyle( + fontSize: 11, + color: theme.custom.asksColor, + ), + ), + ], + ); + } + final scrollController = ScrollController(); + return DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: ListView.builder( + key: const Key('orderbook-asks-list'), + controller: scrollController, + reverse: true, + primary: false, + shrinkWrap: true, + itemCount: asks.length, + itemBuilder: (context, i) { + final Order ask = asks[i]; + late double volFraction; + try { + volFraction = (ask.maxVolume / highestVolume).toDouble(); + } catch (_) { + volFraction = 1; + } + + return OrderbookTableItem( + ask, + key: Key('orderbook-ask-item-${ask.uuid ?? 'target'}'), + volumeFraction: volFraction, + onClick: onAskClick, + isSelected: ask.uuid == selectedOrderUuid, + ); + }, + ), + ); + } + + Widget _buildBids(Rational highestVolume) { + final List bids = List.from(orderbook.bids); + if (myOrder?.direction == OrderDirection.bid) { + bids.add(myOrder!); + } + + bids.sort((a, b) { + if (a.price > b.price) return -1; + if (a.price < b.price) return 1; + + if (a.maxVolume > b.maxVolume) return -1; + if (a.maxVolume < b.maxVolume) return 1; + + return 0; + }); + + if (bids.isEmpty) { + return Row( + children: [ + const SizedBox(width: 4), + Text( + LocaleKeys.orderBookNoBids.tr(), + style: TextStyle( + fontSize: 11, + color: theme.custom.bidsColor, + ), + ), + ], + ); + } + final scrollController = ScrollController(); + return DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: ListView.builder( + key: const Key('orderbook-bids-list'), + controller: scrollController, + primary: false, + shrinkWrap: true, + itemCount: bids.length, + itemBuilder: (context, i) { + final Order bid = bids[i]; + late double volFraction; + try { + volFraction = (bid.maxVolume / highestVolume).toDouble(); + } catch (_) { + volFraction = 1; + } + + return OrderbookTableItem( + bid, + key: Key('orderbook-bid-item-${bid.uuid}'), + volumeFraction: volFraction, + onClick: onBidClick, + isSelected: bid.uuid == selectedOrderUuid, + ); + }, + ), + ); + } + + Rational _getHighestVolume() { + final List allOrders = [ + ...orderbook.asks, + ...orderbook.bids, + ]; + Rational highest = Rational.zero; + + for (Order order in allOrders) { + if (order.maxVolume > highest) highest = order.maxVolume; + } + + return highest; + } +} diff --git a/lib/views/dex/orderbook/orderbook_table_item.dart b/lib/views/dex/orderbook/orderbook_table_item.dart new file mode 100644 index 0000000000..af743b8d5e --- /dev/null +++ b/lib/views/dex/orderbook/orderbook_table_item.dart @@ -0,0 +1,164 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/orderbook/order.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; + +class OrderbookTableItem extends StatefulWidget { + const OrderbookTableItem( + this.order, { + Key? key, + required this.volumeFraction, + this.isSelected = false, + this.onClick, + }) : super(key: key); + + final Order order; + final double volumeFraction; + final bool isSelected; + final Function(Order)? onClick; + + @override + State createState() => _OrderbookTableItemState(); +} + +class _OrderbookTableItemState extends State { + double _scale = 0.1; + late Color _color; + late TextStyle _style; + late bool _isPreview; + late bool _isTradeWithSelf; + + @override + void initState() { + _isPreview = widget.order.uuid == orderPreviewUuid; + _isTradeWithSelf = + widget.order.address == coinsBloc.getCoin(widget.order.rel)?.address; + _style = const TextStyle(fontSize: 11, fontWeight: FontWeight.w500); + _color = _isPreview + ? theme.custom.targetColor + : widget.order.direction == OrderDirection.ask + ? theme.custom.asksColor + : theme.custom.bidsColor; + + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _scale = 1; + }); + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + if (_isPreview) { + return AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: _scale, + child: _buildItem(), + ); + } + + return _buildItem(); + } + + Widget _buildItem() { + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(4), + onTap: widget.onClick == null || _isPreview + ? null + : () { + widget.onClick!(widget.order); + }, + child: Stack( + alignment: Alignment.centerRight, + clipBehavior: Clip.none, + children: [ + _buildPointerIfNeeded(), + _buildChartBar(), + _buildTextData(), + ], + ), + ), + ); + } + + Widget _buildPointerIfNeeded() { + if (_isTradeWithSelf) { + return Positioned( + left: 2, + child: Icon( + Icons.circle, + size: 4, + color: _color, + ), + ); + } + + if (_isPreview || widget.isSelected) { + return Positioned( + left: 0, + child: Icon( + Icons.forward, + size: 8, + color: _color, + ), + ); + } + + return const SizedBox.shrink(); + } + + Widget _buildChartBar() { + return FractionallySizedBox( + widthFactor: widget.volumeFraction, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 21), + child: Container( + color: _color.withOpacity(0.1), + ), + ), + ); + } + + Widget _buildTextData() { + return Container( + decoration: BoxDecoration( + border: _isPreview + ? Border( + bottom: BorderSide( + width: 0.5, + color: _color.withOpacity(0.3), + ), + top: BorderSide( + width: 0.5, + color: _color.withOpacity(0.3), + ), + ) + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + const SizedBox(width: 10), + Expanded( + child: AutoScrollText( + text: widget.order.price.toDouble().toStringAsFixed(8), + style: _style.copyWith(color: _color), + ), + ), + const SizedBox(width: 10), + Text(formatAmt(widget.order.maxVolume.toDouble()), + style: _style.copyWith( + color: _isPreview ? _color : null, + )), + const SizedBox(width: 4), + ], + ), + ); + } +} diff --git a/lib/views/dex/orderbook/orderbook_table_title.dart b/lib/views/dex/orderbook/orderbook_table_title.dart new file mode 100644 index 0000000000..94557ba6f8 --- /dev/null +++ b/lib/views/dex/orderbook/orderbook_table_title.dart @@ -0,0 +1,41 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class OrderbookTableTitle extends StatelessWidget { + const OrderbookTableTitle( + this.title, { + this.suffix, + this.titleTextSize = 11, + this.hidden = false, + }); + final String title; + final String? suffix; + final bool hidden; + final double titleTextSize; + + @override + Widget build(BuildContext context) { + final titleStyle = TextStyle( + fontSize: titleTextSize, + fontWeight: FontWeight.w500, + color: dexPageColors.activeText, + ); + final coinStyle = TextStyle( + fontSize: 10, + fontWeight: FontWeight.w700, + color: dexPageColors.blueText, + ); + + final coin = suffix; + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text(title, style: titleStyle), + if (coin != null) const SizedBox(width: 3), + if (coin != null) Text(coin, style: coinStyle), + ], + ); + } +} diff --git a/lib/views/dex/orderbook/orderbook_view.dart b/lib/views/dex/orderbook/orderbook_view.dart new file mode 100644 index 0000000000..78d696c24e --- /dev/null +++ b/lib/views/dex/orderbook/orderbook_view.dart @@ -0,0 +1,128 @@ +import 'package:app_theme/app_theme.dart'; +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:web_dex/model/coin.dart'; +import 'package:web_dex/model/orderbook/order.dart'; +import 'package:web_dex/model/orderbook/orderbook.dart'; +import 'package:web_dex/model/orderbook_model.dart'; +import 'package:web_dex/shared/ui/gradient_border.dart'; +import 'package:web_dex/views/dex/orderbook/orderbook_error_message.dart'; +import 'package:web_dex/views/dex/orderbook/orderbook_table.dart'; +import 'package:web_dex/views/dex/orderbook/orderbook_table_title.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class OrderbookView extends StatefulWidget { + const OrderbookView({ + required this.base, + required this.rel, + this.myOrder, + this.selectedOrderUuid, + this.onBidClick, + this.onAskClick, + }); + + final Coin? base; + final Coin? rel; + final Order? myOrder; + final String? selectedOrderUuid; + final Function(Order)? onBidClick; + final Function(Order)? onAskClick; + + @override + State createState() => _OrderbookViewState(); +} + +class _OrderbookViewState extends State { + late OrderbookModel _model; + + @override + void initState() { + _model = OrderbookModel( + base: widget.base, + rel: widget.rel, + ); + + super.initState(); + } + + @override + void dispose() { + _model.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant OrderbookView oldWidget) { + if (widget.base != oldWidget.base) _model.base = widget.base; + if (widget.rel != oldWidget.rel) _model.rel = widget.rel; + + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: _model.response, + stream: _model.outResponse, + builder: (context, snapshot) { + if (!_model.isComplete) return const SizedBox.shrink(); + + final OrderbookResponse? response = snapshot.data; + + if (response == null) { + return const Center(child: UiSpinner()); + } + + if (response.error != null) { + return OrderbookErrorMessage( + response, + onReloadClick: _model.reload, + ); + } + + final Orderbook? orderbook = response.result; + if (orderbook == null) { + return Center( + child: Text(LocaleKeys.orderBookEmpty.tr()), + ); + } + + return GradientBorder( + innerColor: dexPageColors.frontPlate, + gradient: dexPageColors.formPlateGradient, + child: Container( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + padding: + const EdgeInsets.symmetric(horizontal: 16.0, vertical: 16.0), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 10), + child: OrderbookTableTitle( + LocaleKeys.orderBook.tr(), + titleTextSize: 14, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 7), + child: OrderbookTable( + orderbook, + myOrder: widget.myOrder, + selectedOrderUuid: widget.selectedOrderUuid, + onAskClick: widget.onAskClick, + onBidClick: widget.onBidClick, + ), + ), + ], + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/dex/simple/confirm/maker_order_confirmation.dart b/lib/views/dex/simple/confirm/maker_order_confirmation.dart new file mode 100644 index 0000000000..0aa72126aa --- /dev/null +++ b/lib/views/dex/simple/confirm/maker_order_confirmation.dart @@ -0,0 +1,314 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.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/text_error.dart'; +import 'package:web_dex/model/trade_preimage.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'; +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:web_dex/shared/widgets/segwit_icon.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_exchange_rate.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_total_fees.dart'; + +class MakerOrderConfirmation extends StatefulWidget { + const MakerOrderConfirmation( + {Key? key, required this.onCreateOrder, required this.onCancel}) + : super(key: key); + + final VoidCallback onCancel; + final VoidCallback onCreateOrder; + + @override + State createState() => _MakerOrderConfirmationState(); +} + +class _MakerOrderConfirmationState extends State { + String? _errorMessage; + bool _inProgress = false; + + @override + Widget build(BuildContext context) { + return Container( + padding: isMobile + ? const EdgeInsets.only(top: 18.0) + : 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 = coinsBloc.getCoin(preimage.request.base); + final Coin? buyCoin = coinsBloc.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(), + ) + ], + ), + ); + }), + ); + } + + Widget _buildBackButton() { + return UiLightButton( + onPressed: _inProgress ? null : widget.onCancel, + text: LocaleKeys.back.tr(), + ); + } + + Widget _buildButtons() { + return Row( + children: [ + Flexible( + child: _buildBackButton(), + ), + const SizedBox(width: 23), + Flexible( + child: _buildConfirmButton(), + ), + ], + ); + } + + Widget _buildConfirmButton() { + 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 ? null : _startSwap, + text: LocaleKeys.confirm.tr()), + ); + } + + Widget _buildError() { + final String? message = _errorMessage; + if (message == null) return const SizedBox(); + + return Container( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 20), + child: Text( + message, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + ); + } + + Widget _buildFiatReceive({ + required Coin sellCoin, + Rational? sellAmount, + required Coin buyCoin, + Rational? buyAmount, + }) { + if (sellAmount == null || buyAmount == null) return const SizedBox(); + + Color? color = Theme.of(context).textTheme.bodyMedium?.color; + double? percentage; + + final double sellAmtFiat = getFiatAmount(sellCoin, sellAmount); + final double receiveAmtFiat = getFiatAmount(buyCoin, buyAmount); + + if (sellAmtFiat < receiveAmtFiat) { + color = theme.custom.increaseColor; + } else if (sellAmtFiat > receiveAmtFiat) { + color = theme.custom.decreaseColor; + } + + if (sellAmtFiat > 0 && receiveAmtFiat > 0) { + percentage = (receiveAmtFiat - sellAmtFiat) * 100 / sellAmtFiat; + } + + return Row( + mainAxisSize: MainAxisSize.min, + 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, + )), + ], + ); + } + + 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)); + } + + Widget _buildReceive(Coin coin, Rational? amount) { + return Column( + children: [ + SelectableText( + LocaleKeys.swapConfirmationYouReceive.tr(), + 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( + Coin.normalizeAbbr(coin.abbr), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: theme.custom.balanceColor), + ), + if (coin.mode == CoinMode.segwit) + const Padding( + padding: EdgeInsets.only(left: 4), + child: SegwitIcon(height: 16), + ), + ], + ), + ], + ); + } + + Widget _buildSend(Coin coin, Rational? amount) { + return Container( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 16, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: theme.custom.subCardBackgroundColor, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + LocaleKeys.swapConfirmationYouSending.tr(), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: theme.custom.dexSubTitleColor, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + CoinItem(coin: coin, size: CoinItemSize.large), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText( + formatDexAmt(amount), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w500, + ), + ), + _buildFiatSend(coin, amount), + ], + ), + ], + ), + ], + ), + ); + } + + Widget _buildTitle() { + return SelectableText( + LocaleKeys.swapConfirmationTitle.tr(), + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontSize: 16), + ); + } + + Future _startSwap() async { + setState(() { + _errorMessage = null; + _inProgress = true; + }); + + final TextError? error = await makerFormBloc.makeOrder(); + + await tradingEntitiesBloc.fetch(); + + // Delay helps to avoid buttons enabled/disabled state blinking + // if setprice RPC was proceeded very fast + await Future.delayed(const Duration(milliseconds: 500)); + setState(() => _inProgress = false); + + if (error != null) { + setState(() => _errorMessage = error.error); + return; + } + + 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 new file mode 100644 index 0000000000..1ea65ad0a3 --- /dev/null +++ b/lib/views/dex/simple/confirm/taker_order_confirmation.dart @@ -0,0 +1,322 @@ +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:rational/rational.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/blocs/blocs.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/dex_form_error.dart'; +import 'package:web_dex/model/trade_preimage.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'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; +import 'package:web_dex/shared/widgets/segwit_icon.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/views/dex/simple/form/taker/taker_form_exchange_rate.dart'; +import 'package:web_dex/views/dex/simple/form/taker/taker_form_total_fees.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class TakerOrderConfirmation extends StatefulWidget { + const TakerOrderConfirmation({Key? key}) : super(key: key); + + @override + State createState() => _TakerOrderConfirmationState(); +} + +class _TakerOrderConfirmationState extends State { + @override + Widget build(BuildContext context) { + return Container( + padding: EdgeInsets.only(top: isMobile ? 18.0 : 9.00), + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: BlocConsumer( + listenWhen: (prev, current) => current.swapUuid != null, + listener: _onSwapStarted, + buildWhen: (prev, current) { + return prev.tradePreimage != current.tradePreimage; + }, + builder: (context, state) { + final TradePreimage? preimage = state.tradePreimage; + if (preimage == null) return const UiSpinner(); + + final Coin? sellCoin = coinsBloc.getCoin(preimage.request.base); + final Coin? buyCoin = coinsBloc.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())); + } + final scrollController = ScrollController(); + return DexScrollbar( + scrollController: scrollController, + child: SingleChildScrollView( + key: const Key('taker-order-confirmation-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 TakerFormExchangeRate(), + const SizedBox(height: 10), + const TakerFormTotalFees(), + const SizedBox(height: 24), + _buildError(), + Flexible( + child: _buildButtons(), + ) + ], + ), + ), + ); + }, + ), + ); + } + + Widget _buildBackButton() { + return BlocSelector( + selector: (state) => state.inProgress, + builder: (context, inProgress) { + return UiLightButton( + onPressed: inProgress + ? null + : () => context.read().add(TakerBackButtonClick()), + text: LocaleKeys.back.tr(), + ); + }, + ); + } + + Widget _buildButtons() { + return Row( + children: [ + Flexible( + child: _buildBackButton(), + ), + const SizedBox(width: 23), + Flexible( + child: _buildConfirmButton(), + ), + ], + ); + } + + Widget _buildConfirmButton() { + return BlocSelector( + selector: (state) => state.inProgress, + builder: (context, inProgress) { + 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 ? null : () => _startSwap(context), + text: LocaleKeys.confirm.tr()), + ); + }, + ); + } + + Widget _buildError() { + return BlocSelector>( + selector: (state) => state.errors, + builder: (context, errors) { + if (errors.isEmpty) return const SizedBox.shrink(); + final String message = errors.first.error; + + return Container( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 20), + child: Text( + message, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + ); + }, + ); + } + + Widget _buildFiatReceive({ + required Coin sellCoin, + Rational? sellAmount, + required Coin buyCoin, + Rational? buyAmount, + }) { + if (sellAmount == null || buyAmount == null) return const SizedBox(); + + Color? color = Theme.of(context).textTheme.bodyMedium?.color; + double? percentage; + + final double sellAmtFiat = getFiatAmount(sellCoin, sellAmount); + final double receiveAmtFiat = getFiatAmount(buyCoin, buyAmount); + + if (sellAmtFiat < receiveAmtFiat) { + color = theme.custom.increaseColor; + } else if (sellAmtFiat > receiveAmtFiat) { + color = theme.custom.decreaseColor; + } + + if (sellAmtFiat > 0 && receiveAmtFiat > 0) { + percentage = (receiveAmtFiat - sellAmtFiat) * 100 / sellAmtFiat; + } + + return Row( + mainAxisSize: MainAxisSize.min, + 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, + )), + ], + ); + } + + 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)); + } + + Widget _buildReceive(Coin coin, Rational? amount) { + return Column( + children: [ + SelectableText( + LocaleKeys.swapConfirmationYouReceive.tr(), + 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( + Coin.normalizeAbbr(coin.abbr), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: theme.custom.balanceColor), + ), + if (coin.mode == CoinMode.segwit) + const Padding( + padding: EdgeInsets.only(left: 4), + child: SegwitIcon(height: 16), + ), + ], + ), + ], + ); + } + + Widget _buildSend(Coin coin, Rational? amount) { + return Container( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 16, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: theme.custom.subCardBackgroundColor, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + LocaleKeys.swapConfirmationYouSending.tr(), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: theme.custom.dexSubTitleColor, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + CoinItem(coin: coin, size: CoinItemSize.large), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText( + formatDexAmt(amount), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w500, + ), + ), + _buildFiatSend(coin, amount), + ], + ), + ], + ), + ], + ), + ); + } + + Widget _buildTitle() { + return SelectableText( + LocaleKeys.swapConfirmationTitle.tr(), + style: Theme.of(context).textTheme.headlineSmall?.copyWith(fontSize: 16), + ); + } + + Future _startSwap(BuildContext context) async { + context.read().add(TakerStartSwap()); + } + + Future _onSwapStarted(BuildContext context, TakerState state) async { + final String? uuid = state.swapUuid; + if (uuid == null) return; + + context.read().add(TakerClear()); + routingState.dexState.setDetailsAction(uuid); + + await tradingEntitiesBloc.fetch(); + } +} diff --git a/lib/views/dex/simple/form/amount_input_field.dart b/lib/views/dex/simple/form/amount_input_field.dart new file mode 100644 index 0000000000..7019a36b58 --- /dev/null +++ b/lib/views/dex/simple/form/amount_input_field.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +class AmountInputField extends StatefulWidget { + const AmountInputField({ + Key? key, + required this.stream, + required this.initialData, + required this.isEnabled, + this.height = 44, + this.contentPadding = const EdgeInsets.fromLTRB(12, 0, 12, 0), + this.hint, + this.suffix, + this.onChanged, + this.background, + this.textAlign, + this.textStyle, + }) : super(key: key); + + final Stream stream; + final Rational? initialData; + final bool isEnabled; + final Widget? suffix; + final String? hint; + final Function(String)? onChanged; + final Color? background; + final TextAlign? textAlign; + final double height; + final TextStyle? textStyle; + final EdgeInsetsGeometry? contentPadding; + + @override + State createState() => _AmountInputFieldState(); +} + +class _AmountInputFieldState extends State { + final _controller = TextEditingController(); + StreamSubscription? _dataListener; + + @override + void initState() { + super.initState(); + + _dataListener = widget.stream.listen(_onDataChange); + _onDataChange(widget.initialData); + } + + @override + void dispose() { + _dataListener?.cancel(); + _controller.dispose(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final InputBorder border = OutlineInputBorder( + borderSide: BorderSide.none, borderRadius: BorderRadius.circular(18)); + + return SizedBox( + height: widget.height, + child: TextFormField( + key: const Key('amount-input'), + inputFormatters: currencyInputFormatters, + controller: _controller, + enabled: widget.isEnabled, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: Theme.of(context) + .textTheme + .titleSmall + ?.copyWith(fontSize: 14) + .merge(widget.textStyle), + textInputAction: TextInputAction.done, + onChanged: widget.onChanged, + textAlign: widget.textAlign ?? TextAlign.left, + decoration: InputDecoration( + contentPadding: widget.contentPadding, + suffix: widget.suffix, + suffixStyle: widget.textStyle, + hintText: widget.hint ?? 'Enter an amount', + border: border, + fillColor: widget.background, + hoverColor: widget.background, + focusColor: widget.background, + ), + ), + ); + } + + void _onDataChange(Rational? value) { + if (!mounted) return; + final String currentText = _controller.text; + if (currentText.isNotEmpty && Rational.parse(currentText) == value) return; + + final String newText = value == null ? '' : formatDexAmt(value); + + _controller.value = TextEditingValue( + text: newText, + selection: TextSelection.collapsed(offset: newText.length), + composing: TextRange.empty); + } +} diff --git a/lib/views/dex/simple/form/common/dex_flip_button.dart b/lib/views/dex/simple/form/common/dex_flip_button.dart new file mode 100644 index 0000000000..380aea4967 --- /dev/null +++ b/lib/views/dex/simple/form/common/dex_flip_button.dart @@ -0,0 +1,59 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/app_assets.dart'; + +class DexFlipButton extends StatefulWidget { + final Future Function()? onTap; + + const DexFlipButton({ + Key? key, + this.onTap, + }) : super(key: key); + + @override + DexFlipButtonState createState() => DexFlipButtonState(); +} + +class DexFlipButtonState extends State { + double _rotation = 0; + + @override + Widget build(BuildContext context) { + return Center( + child: InkWell( + onTap: () async { + if (widget.onTap != null) { + if (await widget.onTap!()) { + setState(() { + _rotation = (_rotation + 180) % 360; + }); + } + } + }, + child: Opacity( + opacity: widget.onTap == null ? 0.5 : 1.0, + child: Stack( + alignment: Alignment.center, + children: [ + // Outer circle + CircleAvatar( + backgroundColor: dexPageColors.frontPlate, + radius: 28, + ), + // Inner circle + CircleAvatar( + backgroundColor: dexPageColors.frontPlateInner, + radius: 20, + ), + AnimatedRotation( + turns: _rotation / 360, + duration: const Duration(milliseconds: 300), + child: const DexSvgImage(path: Assets.dexSwapCoins, size: 16), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/dex/simple/form/common/dex_flip_button_overlapper.dart b/lib/views/dex/simple/form/common/dex_flip_button_overlapper.dart new file mode 100644 index 0000000000..2512260626 --- /dev/null +++ b/lib/views/dex/simple/form/common/dex_flip_button_overlapper.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_flip_button.dart'; + +class DexFlipButtonOverlapper extends StatelessWidget { + final Future Function()? onTap; + final Widget topWidget; + final Widget bottomWidget; + final double offsetTop; + + const DexFlipButtonOverlapper({ + Key? key, + required this.onTap, + required this.topWidget, + required this.bottomWidget, + this.offsetTop = 84, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Column( + children: [ + topWidget, + const SizedBox(height: 12), + bottomWidget, + ], + ), + Positioned( + top: offsetTop, + left: 0, + right: 0, + child: DexFlipButton( + onTap: onTap, + ), + ), + ], + ); + } +} diff --git a/lib/views/dex/simple/form/common/dex_form_group_header.dart b/lib/views/dex/simple/form/common/dex_form_group_header.dart new file mode 100644 index 0000000000..bb815b474a --- /dev/null +++ b/lib/views/dex/simple/form/common/dex_form_group_header.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_form_title.dart'; + +class DexFormGroupHeader extends StatelessWidget { + const DexFormGroupHeader( + {this.title, this.actions, this.background, Key? key}) + : super(key: key); + + final String? title; + final List? actions; + final Widget? background; + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + if (background != null) + Positioned( + left: 0, + right: 0, + top: 0, + bottom: 0, + child: background!, + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 12, 12, 8), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 16), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (title != null) DexFormTitle(title!), + if (actions != null) + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + children: actions!, + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/dex/simple/form/common/dex_form_title.dart b/lib/views/dex/simple/form/common/dex_form_title.dart new file mode 100644 index 0000000000..d3a5fe922e --- /dev/null +++ b/lib/views/dex/simple/form/common/dex_form_title.dart @@ -0,0 +1,20 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class DexFormTitle extends StatelessWidget { + const DexFormTitle(this.title); + + final String title; + + @override + Widget build(BuildContext context) { + final titleStyle = TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: dexPageColors.activeText, + letterSpacing: 4, + ); + + return Text(title, style: titleStyle); + } +} diff --git a/lib/views/dex/simple/form/common/dex_info_container.dart b/lib/views/dex/simple/form/common/dex_info_container.dart new file mode 100644 index 0000000000..d50f9952bf --- /dev/null +++ b/lib/views/dex/simple/form/common/dex_info_container.dart @@ -0,0 +1,29 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class DexInfoContainer extends StatelessWidget { + final List children; + + const DexInfoContainer({ + Key? key, + required this.children, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(12.0), + decoration: BoxDecoration( + color: Colors.transparent, + border: Border.all( + color: dexPageColors.frontPlateBorder, + width: 1.0, + ), + borderRadius: BorderRadius.circular(12.0), + ), + child: Column( + children: children, + ), + ); + } +} diff --git a/lib/views/dex/simple/form/common/dex_small_button.dart b/lib/views/dex/simple/form/common/dex_small_button.dart new file mode 100644 index 0000000000..d4e335b7ba --- /dev/null +++ b/lib/views/dex/simple/form/common/dex_small_button.dart @@ -0,0 +1,34 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class DexSmallButton extends StatelessWidget { + const DexSmallButton(this.text, this.onTap); + + final String text; + final Function(BuildContext)? onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap == null ? null : () => onTap!(context), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(7), + color: dexPageColors.smallButton, + ), + width: 46, + height: 16, + child: Center( + child: Text( + text, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: dexPageColors.smallButtonText, + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/dex/simple/form/dex_fiat_amount.dart b/lib/views/dex/simple/form/dex_fiat_amount.dart new file mode 100644 index 0000000000..67a37eaf8b --- /dev/null +++ b/lib/views/dex/simple/form/dex_fiat_amount.dart @@ -0,0 +1,37 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +class DexFiatAmount extends StatelessWidget { + const DexFiatAmount({ + Key? key, + required this.coin, + required this.amount, + this.padding, + this.textStyle, + }) : super(key: key); + + final Coin? coin; + final Rational? amount; + final EdgeInsets? padding; + final TextStyle? textStyle; + + @override + Widget build(BuildContext context) { + final Rational estAmount = amount ?? Rational.zero; + final double usdPrice = coin?.usdPrice?.price ?? 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)), + ); + } +} diff --git a/lib/views/dex/simple/form/error_list/dex_form_error_list.dart b/lib/views/dex/simple/form/error_list/dex_form_error_list.dart new file mode 100644 index 0000000000..cd0ffa942d --- /dev/null +++ b/lib/views/dex/simple/form/error_list/dex_form_error_list.dart @@ -0,0 +1,78 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/views/dex/simple/form/error_list/dex_form_error_simple.dart'; +import 'package:web_dex/views/dex/simple/form/error_list/dex_form_error_with_action.dart'; + +class DexFormErrorList extends StatefulWidget { + const DexFormErrorList({ + required this.errors, + Key? key, + }) : super(key: key); + + final List errors; + + @override + State createState() => _DexFormErrorListState(); +} + +class _DexFormErrorListState extends State { + @override + Widget build(BuildContext context) { + final List errorList = widget.errors; + if (errorList.isEmpty) return const SizedBox(); + + return Container( + padding: const EdgeInsets.fromLTRB(0, 10, 0, 10), + child: Column( + children: errorList + .map((e) => Padding( + padding: const EdgeInsets.only(top: 8.0), + child: _errorBuilder(e), + )) + .toList(), + ), + ); + } + + Widget _errorBuilder(DexFormError error) { + switch (error.type) { + case DexFormErrorType.simple: + return DexFormErrorSimple(error: error); + case DexFormErrorType.largerMaxSellVolume: + return _buildLargerMaxSellVolumeError(error); + case DexFormErrorType.largerMaxBuyVolume: + return _buildLargerMaxBuyVolumeError(error); + case DexFormErrorType.lessMinVolume: + return _buildLessMinVolumeError(error); + } + } + + Widget _buildLargerMaxSellVolumeError(DexFormError error) { + assert(error.type == DexFormErrorType.largerMaxSellVolume); + assert(error.action != null); + + return DexFormErrorWithAction( + error: error, + action: error.action!, + ); + } + + Widget _buildLargerMaxBuyVolumeError(DexFormError error) { + assert(error.type == DexFormErrorType.largerMaxBuyVolume); + + return DexFormErrorWithAction( + error: error, + action: error.action!, + ); + } + + Widget _buildLessMinVolumeError(DexFormError error) { + assert(error.type == DexFormErrorType.lessMinVolume); + assert(error.action != null); + + return DexFormErrorWithAction( + error: error, + action: error.action!, + ); + } +} diff --git a/lib/views/dex/simple/form/error_list/dex_form_error_simple.dart b/lib/views/dex/simple/form/error_list/dex_form_error_simple.dart new file mode 100644 index 0000000000..f592f10319 --- /dev/null +++ b/lib/views/dex/simple/form/error_list/dex_form_error_simple.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/dex_form_error.dart'; + +class DexFormErrorSimple extends StatelessWidget { + const DexFormErrorSimple({ + Key? key, + required this.error, + }) : super(key: key); + final DexFormError error; + + @override + Widget build(BuildContext context) { + assert(error.type == DexFormErrorType.simple); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.warning_amber, size: 14, color: Colors.orange), + const SizedBox(width: 4), + Flexible( + child: SelectableText( + error.message, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } +} diff --git a/lib/views/dex/simple/form/error_list/dex_form_error_with_action.dart b/lib/views/dex/simple/form/error_list/dex_form_error_with_action.dart new file mode 100644 index 0000000000..9c3008e616 --- /dev/null +++ b/lib/views/dex/simple/form/error_list/dex_form_error_with_action.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class DexFormErrorWithAction extends StatefulWidget { + const DexFormErrorWithAction({ + Key? key, + required this.error, + required this.action, + }) : super(key: key); + + final DexFormError error; + final DexFormErrorAction action; + + @override + State createState() => _DexFormErrorWithActionState(); +} + +class _DexFormErrorWithActionState extends State { + bool _isLoading = false; + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.warning_amber, size: 14, color: Colors.orange), + const SizedBox(width: 4), + Flexible( + child: SelectableText( + widget.error.message, + style: Theme.of(context).textTheme.bodySmall, + )), + _isLoading + ? const Padding( + padding: EdgeInsets.symmetric(horizontal: 4), + child: UiSpinner( + height: 12, + width: 12, + strokeWidth: 1, + ), + ) + : UiSimpleButton( + child: Text( + widget.action.text, + style: Theme.of(context).textTheme.bodySmall, + ), + onPressed: () async { + setState(() { + _isLoading = true; + }); + await widget.action.callback(); + setState(() { + _isLoading = false; + }); + }, + ) + ], + ); + } +} + +class DexFormErrorAction { + DexFormErrorAction({required this.text, required this.callback}); + + final String text; + final Future Function() callback; +} 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 new file mode 100644 index 0000000000..47f8c32061 --- /dev/null +++ b/lib/views/dex/simple/form/exchange_info/dex_compared_to_cex.dart @@ -0,0 +1,81 @@ +import 'dart:math'; + +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/app_config/app_config.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/ui/custom_tooltip.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class DexComparedToCex extends StatelessWidget { + const DexComparedToCex({ + required this.base, + required this.rel, + required this.rate, + }); + + final Coin? base; + final Coin? rel; + final Rational? rate; + + @override + Widget build(BuildContext context) { + final double? baseUsd = base?.usdPrice?.price; + final double? relUsd = rel?.usdPrice?.price; + + double diff = 0; + if (baseUsd != null && relUsd != null && rate != null) { + diff = compareToCex(baseUsd, relUsd, rate!); + } + + return _View(diff); + } +} + +class _View extends StatelessWidget { + const _View(this.diff); + + final double diff; + + @override + Widget build(BuildContext context) { + const header = TextStyle(fontSize: 14, fontWeight: FontWeight.w500); + Color? color = header.color; + if (diff > 0) { + color = theme.custom.increaseColor; + } else if (diff < 0) { + color = theme.custom.decreaseColor; + } + + final double maxWidth = min(220, screenWidth - 190); + final style = header.copyWith(color: color); + return Row( + children: [ + Text(LocaleKeys.comparedToCexTitle.tr(), style: header), + const SizedBox(width: 7), + CustomTooltip( + tooltip: Text( + LocaleKeys.comparedToCexInfo.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + maxWidth: maxWidth, + child: SvgPicture.asset( + '$assetsPath/others/round_question_mark.svg', + colorFilter: ColorFilter.mode( + Theme.of(context).textTheme.bodySmall?.color ?? Colors.white, + BlendMode.srcIn, + ), + ), + ), + const Spacer(), + Text('${formatAmt(diff)}%', style: style), + ], + ); + } +} diff --git a/lib/views/dex/simple/form/exchange_info/exchange_rate.dart b/lib/views/dex/simple/form/exchange_info/exchange_rate.dart new file mode 100644 index 0000000000..b80ad2e047 --- /dev/null +++ b/lib/views/dex/simple/form/exchange_info/exchange_rate.dart @@ -0,0 +1,126 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.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/widgets/auto_scroll_text.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class ExchangeRate extends StatelessWidget { + const ExchangeRate({ + Key? key, + required this.base, + required this.rel, + required this.rate, + this.showDetails = true, + }) : super(key: key); + + final String? base; + final String? rel; + final Rational? rate; + final bool showDetails; + + @override + Widget build(BuildContext context) { + final isEmptyData = rate == null || base == null || rel == null; + return Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Text( + '${LocaleKeys.rate.tr()}:', + style: theme.custom.tradingFormDetailsLabel, + ), + isEmptyData + ? Text('0.00', style: theme.custom.tradingFormDetailsContent) + : Flexible( + child: _Rates( + base: base, + rel: rel, + rate: rate, + showDetails: showDetails, + ), + ), + ], + ); + } +} + +class _Rates extends StatelessWidget { + const _Rates({ + required this.base, + required this.rel, + required this.rate, + this.showDetails = true, + }); + + final String? base; + final String? rel; + final Rational? rate; + final bool showDetails; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.max, + children: [ + Text( + ' 1 ${Coin.normalizeAbbr(base ?? '')} = ', + style: theme.custom.tradingFormDetailsContent, + ), + Flexible( + child: AutoScrollText( + text: ' $price ${Coin.normalizeAbbr(rel ?? '')}', + style: theme.custom.tradingFormDetailsContent, + ), + ), + Text( + showDetails ? '($baseFiat)' : '', + style: theme.custom.tradingFormDetailsContent, + ), + ], + ), + if (showDetails) + Text( + '1 ${Coin.normalizeAbbr(rel ?? '')} =' + ' $quotePrice' + ' ${Coin.normalizeAbbr(base ?? '')}' + ' ($relFiat)', + style: TextStyle( + fontSize: 12, + color: theme.custom.subBalanceColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + } + + String get baseFiat { + return getFormattedFiatAmount(rel ?? '', rate ?? Rational.zero); + } + + String get relFiat { + if (rate == Rational.zero) { + return getFormattedFiatAmount(base ?? '', Rational.zero); + } + return getFormattedFiatAmount(base ?? '', rate?.inverse ?? Rational.zero); + } + + String get price { + if (rate == null) return '0'; + return formatDexAmt(rate); + } + + String get quotePrice { + if (rate == null || rate == Rational.zero) return '0'; + return formatDexAmt(rate!.inverse); + } +} diff --git a/lib/views/dex/simple/form/exchange_info/total_fees.dart b/lib/views/dex/simple/form/exchange_info/total_fees.dart new file mode 100644 index 0000000000..44be1f6dad --- /dev/null +++ b/lib/views/dex/simple/form/exchange_info/total_fees.dart @@ -0,0 +1,240 @@ +import 'dart:math'; + +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.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/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/model/trade_preimage_extended_fee_info.dart'; +import 'package:web_dex/shared/ui/custom_tooltip.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class TotalFees extends StatefulWidget { + const TotalFees({ + Key? key, + required this.preimage, + }) : super(key: key); + + final TradePreimage? preimage; + + @override + State createState() => _TotalFeesState(); +} + +class _TotalFeesState extends State { + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text(LocaleKeys.totalFees.tr(), + style: theme.custom.tradingFormDetailsLabel), + const SizedBox(width: 7), + widget.preimage == null + ? const SizedBox.shrink() + : CustomTooltip( + tooltip: _buildDetails(), + maxWidth: min(350, screenWidth - 140), + child: SvgPicture.asset( + '$assetsPath/others/round_question_mark.svg', + colorFilter: ColorFilter.mode( + Theme.of(context).textTheme.bodySmall?.color ?? + Colors.white, + BlendMode.srcIn, + ), + ), + ), + Expanded( + child: Container( + alignment: Alignment.centerRight, + child: AutoScrollText( + text: getTotalFee(widget.preimage?.totalFees, coinsBloc.getCoin), + style: theme.custom.tradingFormDetailsContent, + ), + ), + ), + ], + ); + } + + Widget? _buildDetails() { + if (widget.preimage == null) return null; + + return Column( + children: [ + _buildPaidFromBalance(), + const SizedBox(height: 4), + _buildPaidFromTrade(), + const SizedBox(height: 4), + ], + ); + } + + Widget _buildPaidFromBalance() { + final TradePreimage? preimage = widget.preimage; + if (preimage == null) return const SizedBox.shrink(); + + final TradePreimageExtendedFeeInfo? takerFee = preimage.takerFee; + final TradePreimageExtendedFeeInfo? feeToSendTakerFee = + preimage.feeToSendTakerFee; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(4, 2, 4, 2), + color: Theme.of(context).highlightColor.withAlpha(25), + child: SelectableText( + LocaleKeys.swapFeeDetailsPaidFromBalance.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 4), + if (!preimage.baseCoinFee.paidFromTradingVol) + Container( + padding: const EdgeInsets.fromLTRB(8, 2, 4, 2), + child: SelectableText( + '• ${cutTrailingZeros(formatAmt(double.tryParse(preimage.baseCoinFee.amount) ?? 0))} ' + '${preimage.baseCoinFee.coin} ' + '(${getFormattedFiatAmount(preimage.baseCoinFee.coin, preimage.baseCoinFee.amountRational, 8)}): ' + '${LocaleKeys.swapFeeDetailsSendCoinTxFee.tr(args: [ + preimage.baseCoinFee.coin + ])}', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (!preimage.relCoinFee.paidFromTradingVol) + Container( + padding: const EdgeInsets.fromLTRB(8, 2, 4, 2), + child: SelectableText( + '• ${cutTrailingZeros(formatAmt(double.tryParse(preimage.relCoinFee.amount) ?? 0))} ' + '${preimage.relCoinFee.coin} ' + '(${getFormattedFiatAmount(preimage.relCoinFee.coin, preimage.relCoinFee.amountRational, 8)}): ' + '${LocaleKeys.swapFeeDetailsReceiveCoinTxFee.tr(args: [ + preimage.relCoinFee.coin + ])}', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (takerFee != null && !takerFee.paidFromTradingVol) + Container( + padding: const EdgeInsets.fromLTRB(8, 2, 4, 2), + child: SelectableText( + '• ${cutTrailingZeros(formatAmt(double.tryParse(takerFee.amount) ?? 0))} ' + '${takerFee.coin} ' + '(${getFormattedFiatAmount(takerFee.coin, takerFee.amountRational, 8)}): ' + '${LocaleKeys.swapFeeDetailsTradingFee.tr()}', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (feeToSendTakerFee != null && !feeToSendTakerFee.paidFromTradingVol) + Container( + padding: const EdgeInsets.fromLTRB(8, 2, 4, 2), + child: SelectableText( + '• ${cutTrailingZeros(formatAmt(double.tryParse(feeToSendTakerFee.amount) ?? 0))} ' + '${feeToSendTakerFee.coin} ' + '(${getFormattedFiatAmount(feeToSendTakerFee.coin, feeToSendTakerFee.amountRational, 8)}): ' + '${LocaleKeys.swapFeeDetailsSendTradingFeeTxFee.tr()}', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ], + ); + } + + Widget _buildPaidFromTrade() { + final TradePreimage? preimage = widget.preimage; + if (preimage == null) return const SizedBox.shrink(); + + final TradePreimageExtendedFeeInfo? takerFee = preimage.takerFee; + final TradePreimageExtendedFeeInfo? feeToSendTakerFee = + preimage.feeToSendTakerFee; + final List items = []; + + if (preimage.baseCoinFee.paidFromTradingVol) { + items.add(Container( + padding: const EdgeInsets.fromLTRB(8, 2, 4, 2), + child: SelectableText( + '• ${cutTrailingZeros(formatAmt(double.tryParse(preimage.baseCoinFee.amount) ?? 0))} ' + '${preimage.baseCoinFee.coin} ' + '(${getFormattedFiatAmount(preimage.baseCoinFee.coin, preimage.baseCoinFee.amountRational, 8)}): ' + '${LocaleKeys.swapFeeDetailsSendCoinTxFee.tr(args: [ + preimage.baseCoinFee.coin + ])}', + style: Theme.of(context).textTheme.bodySmall, + ), + )); + } + + if (preimage.relCoinFee.paidFromTradingVol) { + items.add(Container( + padding: const EdgeInsets.fromLTRB(8, 2, 4, 2), + child: SelectableText( + '• ${cutTrailingZeros(formatAmt(double.tryParse(preimage.relCoinFee.amount) ?? 0))} ' + '${preimage.relCoinFee.coin} ' + '(${getFormattedFiatAmount(preimage.relCoinFee.coin, preimage.relCoinFee.amountRational, 8)}): ' + '${LocaleKeys.swapFeeDetailsReceiveCoinTxFee.tr(args: [ + preimage.relCoinFee.coin + ])}', + style: Theme.of(context).textTheme.bodySmall, + ), + )); + } + + if (takerFee != null && takerFee.paidFromTradingVol) { + items.add(Container( + padding: const EdgeInsets.fromLTRB(8, 2, 4, 2), + child: SelectableText( + '• ${cutTrailingZeros(formatAmt(double.tryParse(takerFee.amount) ?? 0))} ' + '${takerFee.coin} ' + '(${getFormattedFiatAmount(takerFee.coin, takerFee.amountRational, 8)}): ' + '${LocaleKeys.swapFeeDetailsTradingFee.tr()}', + style: Theme.of(context).textTheme.bodySmall, + ), + )); + } + + if (feeToSendTakerFee != null && feeToSendTakerFee.paidFromTradingVol) { + items.add(Container( + padding: const EdgeInsets.fromLTRB(8, 2, 4, 2), + child: SelectableText( + '• ${cutTrailingZeros(formatAmt(double.tryParse(feeToSendTakerFee.amount) ?? 0))} ' + '${feeToSendTakerFee.coin} ' + '(${getFormattedFiatAmount(feeToSendTakerFee.coin, feeToSendTakerFee.amountRational, 8)}): ' + '${LocaleKeys.swapFeeDetailsSendTradingFeeTxFee.tr()}', + style: Theme.of(context).textTheme.bodySmall, + ), + )); + } + + if (items.isEmpty) { + items.add(Container( + padding: const EdgeInsets.fromLTRB(8, 2, 4, 2), + child: SelectableText( + '• ${LocaleKeys.swapFeeDetailsNone.tr()}', + style: Theme.of(context).textTheme.bodySmall, + ), + )); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(4, 2, 4, 2), + color: Theme.of(context).highlightColor.withAlpha(25), + child: SelectableText( + LocaleKeys.swapFeeDetailsPaidFromReceivedVolume.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ), + const SizedBox(height: 4), + ...items, + ], + ); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_buy_amount.dart b/lib/views/dex/simple/form/maker/maker_form_buy_amount.dart new file mode 100644 index 0000000000..9951487d31 --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_buy_amount.dart @@ -0,0 +1,111 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class MakerFormBuyAmount extends StatelessWidget { + const MakerFormBuyAmount(this.isEnabled, {Key? key}) : super(key: key); + + final bool isEnabled; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(right: 18, top: 1), + child: _BuyAmountInput( + key: const Key('maker-buy-amount'), + isEnabled: isEnabled, + ), + ), + const Padding( + padding: EdgeInsets.only(right: 18), + child: _BuyAmountFiat(), + ), + ], + ), + ); + } +} + +class _BuyAmountFiat extends StatelessWidget { + const _BuyAmountFiat(); + + @override + Widget build(BuildContext context) { + final TextStyle? textStyle = Theme.of(context).textTheme.bodySmall; + return StreamBuilder( + initialData: makerFormBloc.buyAmount, + stream: makerFormBloc.outBuyAmount, + builder: (context, snapshot) { + final Coin? coin = makerFormBloc.buyCoin; + if (coin == null) return const SizedBox(); + final amount = snapshot.data ?? Rational.zero; + + return Text( + getFormattedFiatAmount(coin.abbr, amount), + style: textStyle, + ); + }, + ); + } +} + +class _BuyAmountInput extends StatelessWidget { + _BuyAmountInput({ + Key? key, + required this.isEnabled, + }) : super(key: key); + + final bool isEnabled; + + final _textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.buyAmount, + stream: makerFormBloc.outBuyAmount, + builder: (context, snapshot) { + formatAmountInput(_textController, makerFormBloc.buyAmount); + + return SizedBox( + height: 20, + child: TextFormField( + key: const Key('maker-buy-amount-input'), + controller: _textController, + enabled: isEnabled, + textInputAction: TextInputAction.done, + textAlign: TextAlign.end, + inputFormatters: currencyInputFormatters, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + color: dexPageColors.activeText, + decoration: TextDecoration.none, + ), + onChanged: (String value) { + makerFormBloc.setBuyAmount(value); + }, + decoration: const InputDecoration( + hintText: '0.00', + contentPadding: EdgeInsets.all(0), + fillColor: Colors.transparent, + focusColor: Colors.transparent, + hoverColor: Colors.transparent, + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_buy_coin_table.dart b/lib/views/dex/simple/form/maker/maker_form_buy_coin_table.dart new file mode 100644 index 0000000000..5dedaad698 --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_buy_coin_table.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_buy_switcher.dart'; +import 'package:web_dex/views/dex/simple/form/tables/coins_table/coins_table.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class MakerFormBuyCoinTable extends StatelessWidget { + const MakerFormBuyCoinTable({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.showBuyCoinSelect, + stream: makerFormBloc.outShowBuyCoinSelect, + builder: (context, isOpenSnapshot) { + if (isOpenSnapshot.data != true) return const SizedBox.shrink(); + + return StreamBuilder( + initialData: makerFormBloc.buyCoin, + stream: makerFormBloc.outBuyCoin, + builder: (context, coinSnapshot) { + final Coin? coin = coinSnapshot.data; + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 167, 16, 10), + child: CoinsTable( + key: const Key('maker-buy-coins-table'), + head: Padding( + padding: const EdgeInsets.fromLTRB(0, 16, 0, 12), + child: MakerFormBuySwitcher( + controller: TradeCoinController( + coin: coin, + isEnabled: coin != null, + isOpened: true, + onTap: () => makerFormBloc.showBuyCoinSelect = false, + )), + ), + maxHeight: 250, + onSelect: (Coin coin) { + makerFormBloc.buyCoin = coin; + makerFormBloc.showBuyCoinSelect = false; + }, + ), + ); + }); + }); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_buy_item.dart b/lib/views/dex/simple/form/maker/maker_form_buy_item.dart new file mode 100644 index 0000000000..315f4417ac --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_buy_item.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_buy_switcher.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class MakerFormBuyItem extends StatefulWidget { + const MakerFormBuyItem({ + Key? key, + }) : super(key: key); + @override + State createState() => _MakerFormBuyItemState(); +} + +class _MakerFormBuyItemState extends State { + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.buyCoin, + stream: makerFormBloc.outBuyCoin, + builder: (context, coinSnapshot) { + final Coin? coin = coinSnapshot.data; + + return Padding( + padding: const EdgeInsets.fromLTRB(0, 16, 0, 14), + child: StreamBuilder( + initialData: makerFormBloc.showBuyCoinSelect, + stream: makerFormBloc.outShowBuyCoinSelect, + builder: (context, isOpenSnapshot) { + final bool isOpen = isOpenSnapshot.data == true; + + return MakerFormBuySwitcher( + controller: TradeCoinController( + coin: coin, + onTap: () => + makerFormBloc.showBuyCoinSelect = !isOpen, + isOpened: isOpen, + isEnabled: coin != null), + ); + })); + }); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_buy_switcher.dart b/lib/views/dex/simple/form/maker/maker_form_buy_switcher.dart new file mode 100644 index 0000000000..83f1e37141 --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_buy_switcher.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_buy_amount.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/coin_group.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class MakerFormBuySwitcher extends StatelessWidget { + const MakerFormBuySwitcher({required this.controller, Key? key}) + : super(key: key); + + final TradeCoinController controller; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CoinGroup(controller, key: const Key('maker-form-buy-switcher')), + const SizedBox(width: 5), + Expanded(child: MakerFormBuyAmount(controller.isEnabled)), + ], + ), + ], + ); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_compare_to_cex.dart b/lib/views/dex/simple/form/maker/maker_form_compare_to_cex.dart new file mode 100644 index 0000000000..ea6eb27975 --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_compare_to_cex.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/views/dex/simple/form/exchange_info/dex_compared_to_cex.dart'; + +class MakerFormCompareToCex extends StatelessWidget { + const MakerFormCompareToCex({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.price, + stream: makerFormBloc.outPrice, + builder: (context, snapshot) { + return DexComparedToCex( + base: makerFormBloc.sellCoin, + rel: makerFormBloc.buyCoin, + rate: snapshot.data, + ); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_content.dart b/lib/views/dex/simple/form/maker/maker_form_content.dart new file mode 100644 index 0000000000..727825bca1 --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_content.dart @@ -0,0 +1,120 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_light_button.dart'; +import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_wrapper.dart'; +import 'package:web_dex/views/dex/common/form_plate.dart'; +import 'package:web_dex/views/dex/common/front_plate.dart'; +import 'package:web_dex/views/dex/common/section_switcher.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_flip_button_overlapper.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_form_group_header.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_info_container.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_buy_item.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_compare_to_cex.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_error_list.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_exchange_rate.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_price_item.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_sell_item.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_total_fees.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_trade_button.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; + +class MakerFormContent extends StatelessWidget { + const MakerFormContent({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return FormPlate( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 20), + child: Column( + children: [ + SectionSwitcher(), + const SizedBox(height: 6), + DexFlipButtonOverlapper( + onTap: () async { + final tmp = makerFormBloc.sellCoin; + makerFormBloc.sellCoin = makerFormBloc.buyCoin; + makerFormBloc.buyCoin = tmp; + return true; + }, + topWidget: const MakerFormSellItem(), + bottomWidget: const FrontPlate( + child: Column( + children: [ + _BuyItemHeader(), + MakerFormBuyItem(), + MakerFormPriceItem(), + ], + ), + ), + ), + const _FormControls(), + ], + ), + ), + ); + } +} + +class _FormControls extends StatelessWidget { + const _FormControls({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const Column(children: [ + MakerFormErrorList(), + SizedBox(height: 24), + DexInfoContainer(children: [ + MakerFormExchangeRate(), + SizedBox(height: 8), + MakerFormCompareToCex(), + SizedBox(height: 8), + MakerFormTotalFees(), + ]), + SizedBox(height: 24), + Row( + mainAxisSize: MainAxisSize.max, + children: [ + Flexible(flex: 3, child: _ClearButton()), + SizedBox(width: 12), + Flexible( + flex: 7, + child: ConnectWalletWrapper( + key: Key('connect-wallet-maker-form'), + eventType: WalletsManagerEventType.dex, + child: MakerFormTradeButton(), + ), + ), + ], + ) + ]); + } +} + +class _ClearButton extends StatelessWidget { + const _ClearButton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return UiLightButton( + text: LocaleKeys.clear.tr(), + onPressed: () { + makerFormBloc.clear(); + }, + height: 40, + ); + } +} + +class _BuyItemHeader extends StatelessWidget { + const _BuyItemHeader({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return DexFormGroupHeader( + title: LocaleKeys.buy.tr(), + ); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_error_list.dart b/lib/views/dex/simple/form/maker/maker_form_error_list.dart new file mode 100644 index 0000000000..5c7135f9aa --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_error_list.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/views/dex/simple/form/error_list/dex_form_error_list.dart'; + +class MakerFormErrorList extends StatelessWidget { + const MakerFormErrorList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + initialData: makerFormBloc.getFormErrors(), + stream: makerFormBloc.outFormErrors, + builder: (context, snapshot) { + if (!snapshot.hasData) return const SizedBox.shrink(); + + return DexFormErrorList(errors: snapshot.data!); + }); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_exchange_rate.dart b/lib/views/dex/simple/form/maker/maker_form_exchange_rate.dart new file mode 100644 index 0000000000..13f0f1582b --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_exchange_rate.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/views/dex/simple/form/exchange_info/exchange_rate.dart'; + +class MakerFormExchangeRate extends StatelessWidget { + const MakerFormExchangeRate({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.price, + stream: makerFormBloc.outPrice, + builder: (context, snapshot) { + return ExchangeRate( + base: makerFormBloc.sellCoin?.abbr, + rel: makerFormBloc.buyCoin?.abbr, + rate: snapshot.data, + ); + }); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_layout.dart b/lib/views/dex/simple/form/maker/maker_form_layout.dart new file mode 100644 index 0000000000..ecb292d64e --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_layout.dart @@ -0,0 +1,142 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc_state.dart'; +import 'package:web_dex/bloc/dex_tab_bar/dex_tab_bar_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/views/dex/simple/confirm/maker_order_confirmation.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_buy_coin_table.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_content.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_orderbook.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_sell_coin_table.dart'; + +class MakerFormLayout extends StatefulWidget { + const MakerFormLayout(); + + @override + State createState() => _MakerFormLayoutState(); +} + +class _MakerFormLayoutState extends State { + @override + void initState() { + makerFormBloc.setDefaultSellCoin(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final DexTabBarBloc bloc = context.read(); + return BlocListener( + listener: (context, state) { + if (state.mode == AuthorizeMode.noLogin) { + makerFormBloc.showConfirmation = false; + } + }, + child: StreamBuilder( + initialData: makerFormBloc.showConfirmation, + stream: makerFormBloc.outShowConfirmation, + builder: (context, snapshot) { + if (snapshot.data == true) { + return MakerOrderConfirmation( + onCreateOrder: () => bloc.add(const TabChanged(1)), + onCancel: () { + makerFormBloc.showConfirmation = false; + }, + ); + } + + return isMobile + ? const _MakerFormMobileLayout() + : const _MakerFormDesktopLayout(); + }, + ), + ); + } +} + +class _MakerFormDesktopLayout extends StatelessWidget { + const _MakerFormDesktopLayout(); + + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // We want to place form in the middle of the screen, + // and orderbook, when shown, should be on the right side + // (leaving the form in the middle) + const Expanded(flex: 3, child: SizedBox.shrink()), + Flexible( + flex: 6, + child: DexScrollbar( + scrollController: scrollController, + isMobile: isMobile, + child: SingleChildScrollView( + key: const Key('maker-form-layout-scroll'), + controller: scrollController, + child: ConstrainedBox( + constraints: + BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: const Stack( + clipBehavior: Clip.none, + children: [ + MakerFormContent(), + MakerFormSellCoinTable(), + MakerFormBuyCoinTable(), + ], + ), + ), + ), + ), + ), + Expanded( + flex: 3, + child: Padding( + padding: const EdgeInsets.only(left: 20), + child: SingleChildScrollView( + controller: ScrollController(), + child: const MakerFormOrderbook(), + ), + ), + ), + ], + ); + } +} + +class _MakerFormMobileLayout extends StatelessWidget { + const _MakerFormMobileLayout(); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + key: const Key('maker-form-layout-scroll'), + controller: ScrollController(), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: const Stack( + children: [ + Column( + mainAxisSize: MainAxisSize.min, + children: [ + MakerFormContent(), + SizedBox(height: 22), + MakerFormOrderbook(), + ], + ), + MakerFormSellCoinTable(), + MakerFormBuyCoinTable(), + ], + ), + ), + ); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_orderbook.dart b/lib/views/dex/simple/form/maker/maker_form_orderbook.dart new file mode 100644 index 0000000000..44b811766f --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_orderbook.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/orderbook/order.dart'; +import 'package:web_dex/views/dex/orderbook/orderbook_view.dart'; + +class MakerFormOrderbook extends StatelessWidget { + const MakerFormOrderbook({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.sellCoin, + stream: makerFormBloc.outSellCoin, + builder: (context, sellCoin) { + return StreamBuilder( + initialData: makerFormBloc.buyCoin, + stream: makerFormBloc.outBuyCoin, + builder: (context, buyCoin) { + return StreamBuilder( + initialData: makerFormBloc.price, + stream: makerFormBloc.outPrice, + builder: (context, price) { + return _buildOrderbook( + base: sellCoin.data, + rel: buyCoin.data, + price: price.data, + ); + }, + ); + }, + ); + }, + ); + } + + Widget _buildOrderbook({ + required Coin? base, + required Coin? rel, + required Rational? price, + }) { + return OrderbookView( + base: makerFormBloc.sellCoin, + rel: makerFormBloc.buyCoin, + myOrder: _getMyOrder(price), + onAskClick: _onAskClick, + ); + } + + Order? _getMyOrder(Rational? price) { + final Coin? sellCoin = makerFormBloc.sellCoin; + final Coin? buyCoin = makerFormBloc.buyCoin; + final Rational? sellAmount = makerFormBloc.sellAmount; + + if (sellCoin == null) return null; + if (buyCoin == null) return null; + if (sellAmount == null || sellAmount == Rational.zero) return null; + if (price == null || price == Rational.zero) return null; + + return Order( + base: sellCoin.abbr, + rel: buyCoin.abbr, + maxVolume: sellAmount, + price: price, + direction: OrderDirection.ask, + uuid: orderPreviewUuid, + ); + } + + void _onAskClick(Order order) { + if (makerFormBloc.sellAmount == null) makerFormBloc.setMaxSellAmount(); + makerFormBloc.setPriceValue(order.price.toDouble().toStringAsFixed(8)); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_price_item.dart b/lib/views/dex/simple/form/maker/maker_form_price_item.dart new file mode 100644 index 0000000000..6af460eb7b --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_price_item.dart @@ -0,0 +1,111 @@ +import 'dart:async'; + +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/dex/simple/form/amount_input_field.dart'; + +class MakerFormPriceItem extends StatefulWidget { + const MakerFormPriceItem({Key? key}) : super(key: key); + + @override + State createState() => _MakerFormPriceItemState(); +} + +class _MakerFormPriceItemState extends State { + final List _listeners = []; + Coin? _sellCoin = makerFormBloc.sellCoin; + Coin? _buyCoin = makerFormBloc.buyCoin; + + @override + void initState() { + _listeners.add(makerFormBloc.outSellCoin.listen(_onFormStateChange)); + _listeners.add(makerFormBloc.outBuyCoin.listen(_onFormStateChange)); + super.initState(); + } + + @override + void dispose() { + _listeners.map((listener) => listener.cancel()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(16, 0, 16, 12), + padding: const EdgeInsets.fromLTRB(0, 12, 0, 0), + decoration: BoxDecoration( + border: Border( + top: BorderSide( + width: 1, + color: theme.currentGlobal.dividerColor, + ), + ), + ), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + _buildLabel(), + const SizedBox(width: 24), + Expanded( + child: _buildPriceField(), + ), + ], + ), + ); + } + + Widget _buildLabel() { + return Container( + padding: const EdgeInsets.fromLTRB(6, 3, 6, 6), + child: Text( + '${LocaleKeys.price.tr()}:', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ); + } + + Widget _buildPriceField() { + return AmountInputField( + hint: '', + stream: makerFormBloc.outPrice, + initialData: makerFormBloc.price, + isEnabled: _sellCoin != null && _buyCoin != null, + suffix: _buildSuffix(), + onChanged: (String value) { + makerFormBloc.setPriceValue(value); + }, + height: 18, + background: theme.custom.noColor, + textAlign: TextAlign.right, + textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500), + contentPadding: const EdgeInsets.all(0)); + } + + Widget _buildSuffix() { + final Coin? buyCoin = _buyCoin; + if (buyCoin == null) return const SizedBox.shrink(); + + return Text( + Coin.normalizeAbbr(buyCoin.abbr), + style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14), + textAlign: TextAlign.right, + ); + } + + void _onFormStateChange(dynamic _) { + if (!mounted) return; + + setState(() { + _sellCoin = makerFormBloc.sellCoin; + _buyCoin = makerFormBloc.buyCoin; + }); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_sell_amount.dart b/lib/views/dex/simple/form/maker/maker_form_sell_amount.dart new file mode 100644 index 0000000000..4db7234a04 --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_sell_amount.dart @@ -0,0 +1,117 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class MakerFormSellAmount extends StatelessWidget { + const MakerFormSellAmount(this.isEnabled, {Key? key}) : super(key: key); + + final bool isEnabled; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(right: 18, top: 1), + child: _SellAmountInput( + key: const Key('maker-sell-amount'), + isEnabled: isEnabled, + ), + ), + const Padding( + padding: EdgeInsets.only(right: 18), + child: _SellAmountFiat(), + ), + ], + ), + ); + } +} + +class _SellAmountFiat extends StatelessWidget { + const _SellAmountFiat(); + + @override + Widget build(BuildContext context) { + final TextStyle? textStyle = Theme.of(context).textTheme.bodySmall; + return StreamBuilder( + initialData: makerFormBloc.sellAmount, + stream: makerFormBloc.outSellAmount, + builder: (context, snapshot) { + final amount = snapshot.data ?? Rational.zero; + + return StreamBuilder( + initialData: makerFormBloc.sellCoin, + stream: makerFormBloc.outSellCoin, + builder: (context, snapshot) { + final Coin? coin = snapshot.data; + if (coin == null) return const SizedBox(); + + return Text( + getFormattedFiatAmount(coin.abbr, amount), + style: textStyle, + ); + }); + }, + ); + } +} + +class _SellAmountInput extends StatelessWidget { + _SellAmountInput({ + Key? key, + required this.isEnabled, + }) : super(key: key); + + final bool isEnabled; + + final _textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.sellAmount, + stream: makerFormBloc.outSellAmount, + builder: (context, snapshot) { + formatAmountInput(_textController, makerFormBloc.sellAmount); + + return SizedBox( + height: 20, + child: TextFormField( + key: const Key('maker-sell-amount-input'), + controller: _textController, + enabled: isEnabled, + textInputAction: TextInputAction.done, + textAlign: TextAlign.end, + inputFormatters: currencyInputFormatters, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + color: dexPageColors.activeText, + decoration: TextDecoration.none, + ), + onChanged: (String value) { + makerFormBloc.setSellAmount(value); + }, + decoration: const InputDecoration( + hintText: '0.00', + contentPadding: EdgeInsets.all(0), + fillColor: Colors.transparent, + focusColor: Colors.transparent, + hoverColor: Colors.transparent, + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_sell_coin_table.dart b/lib/views/dex/simple/form/maker/maker_form_sell_coin_table.dart new file mode 100644 index 0000000000..b7def2597f --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_sell_coin_table.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_sell_switcher.dart'; +import 'package:web_dex/views/dex/simple/form/tables/coins_table/coins_table.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class MakerFormSellCoinTable extends StatelessWidget { + const MakerFormSellCoinTable({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.showSellCoinSelect, + stream: makerFormBloc.outShowSellCoinSelect, + builder: (context, snapshot) { + if (snapshot.data != true) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.fromLTRB(16, 52, 16, 10), + child: StreamBuilder( + initialData: makerFormBloc.sellCoin, + stream: makerFormBloc.outSellCoin, + builder: (context, coinSnapshot) { + final Coin? coin = coinSnapshot.data; + + return CoinsTable( + key: const Key('maker-sell-coins-table'), + head: Padding( + padding: const EdgeInsets.fromLTRB(0, 16, 0, 12), + child: MakerFormSellSwitcher( + controller: TradeCoinController( + coin: coin, + onTap: () => + makerFormBloc.showSellCoinSelect = false, + isOpened: true, + isEnabled: coin != null), + ), + ), + maxHeight: 330, + onSelect: (Coin coin) { + makerFormBloc.sellCoin = coin; + makerFormBloc.showSellCoinSelect = false; + }, + ); + }), + ); + }); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_sell_header.dart b/lib/views/dex/simple/form/maker/maker_form_sell_header.dart new file mode 100644 index 0000000000..e90d29baae --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_sell_header.dart @@ -0,0 +1,79 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/available_balance_state.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_form_group_header.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_small_button.dart'; +import 'package:web_dex/views/dex/simple/form/taker/available_balance.dart'; + +class MakerFormSellHeader extends StatelessWidget { + const MakerFormSellHeader({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return DexFormGroupHeader( + title: LocaleKeys.sell.tr(), + actions: const [ + Flexible(child: _AvailableBalance()), + SizedBox(width: 8), + _HalfMaxButtons(), + ], + ); + } +} + +class _AvailableBalance extends StatelessWidget { + const _AvailableBalance({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.maxSellAmount, + stream: makerFormBloc.outMaxSellAmount, + builder: (context, snapshot) { + return StreamBuilder( + initialData: makerFormBloc.availableBalanceState, + stream: makerFormBloc.outAvailableBalanceState, + builder: (context, state) { + return AvailableBalance( + snapshot.data, + state.data ?? AvailableBalanceState.initial, + ); + }); + }); + } +} + +class _HalfMaxButtons extends StatelessWidget { + const _HalfMaxButtons({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.maxSellAmount, + stream: makerFormBloc.outMaxSellAmount, + builder: (context, snapshot) { + return Row( + children: [ + _MaxButton(), + const SizedBox(width: 3), + _HalfButton(), + ], + ); + }); + } +} + +class _MaxButton extends DexSmallButton { + _MaxButton() + : super( + LocaleKeys.max.tr(), (context) => makerFormBloc.setMaxSellAmount()); +} + +class _HalfButton extends DexSmallButton { + _HalfButton() + : super(LocaleKeys.half.tr(), + (context) => makerFormBloc.setHalfSellAmount()); +} diff --git a/lib/views/dex/simple/form/maker/maker_form_sell_item.dart b/lib/views/dex/simple/form/maker/maker_form_sell_item.dart new file mode 100644 index 0000000000..cbf622bcd1 --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_sell_item.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.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/maker/maker_form_sell_header.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_sell_switcher.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class MakerFormSellItem extends StatefulWidget { + const MakerFormSellItem({ + Key? key, + }) : super(key: key); + @override + State createState() => _MakerFormSellItemState(); +} + +class _MakerFormSellItemState extends State { + @override + Widget build(BuildContext context) { + return FrontPlate( + child: StreamBuilder( + initialData: makerFormBloc.sellCoin, + stream: makerFormBloc.outSellCoin, + builder: (context, coinSnapshot) { + final Coin? coin = coinSnapshot.data; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: StreamBuilder( + initialData: makerFormBloc.showSellCoinSelect, + stream: makerFormBloc.outShowSellCoinSelect, + builder: (context, isOpenSnapshot) { + final bool isOpen = isOpenSnapshot.data == true; + + return Column( + children: [ + const MakerFormSellHeader(), + const SizedBox(height: 16), + MakerFormSellSwitcher( + controller: TradeCoinController( + coin: coin, + onTap: () => + makerFormBloc.showSellCoinSelect = !isOpen, + isOpened: isOpen, + isEnabled: coin != null), + ), + ], + ); + }), + ); + }, + ), + ); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_sell_switcher.dart b/lib/views/dex/simple/form/maker/maker_form_sell_switcher.dart new file mode 100644 index 0000000000..67dac02af6 --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_sell_switcher.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/views/dex/simple/form/maker/maker_form_sell_amount.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/coin_group.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class MakerFormSellSwitcher extends StatelessWidget { + const MakerFormSellSwitcher({required this.controller, Key? key}) + : super(key: key); + + final TradeCoinController controller; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + CoinGroup(controller, key: const Key('maker-form-sell-switcher')), + const SizedBox(width: 5), + Expanded(child: MakerFormSellAmount(controller.isEnabled)), + ], + ), + ], + ); + } +} diff --git a/lib/views/dex/simple/form/maker/maker_form_total_fees.dart b/lib/views/dex/simple/form/maker/maker_form_total_fees.dart new file mode 100644 index 0000000000..f53371af12 --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_total_fees.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/views/dex/simple/form/exchange_info/total_fees.dart'; + +class MakerFormTotalFees extends StatelessWidget { + const MakerFormTotalFees({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: makerFormBloc.preimage, + stream: makerFormBloc.outPreimage, + builder: (context, snapshot) { + return TotalFees(preimage: snapshot.data); + }); + } +} 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 new file mode 100644 index 0000000000..23a1db2300 --- /dev/null +++ b/lib/views/dex/simple/form/maker/maker_form_trade_button.dart @@ -0,0 +1,64 @@ +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:web_dex/bloc/system_health/system_health_bloc.dart'; +import 'package:web_dex/bloc/system_health/system_health_state.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class MakerFormTradeButton extends StatelessWidget { + const MakerFormTradeButton({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, systemHealthState) { + // Determine if system clock is valid + final bool isSystemClockValid = + systemHealthState is SystemHealthLoadSuccess && + systemHealthState.isValid; + + 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: LocaleKeys.makeOrder.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 + ? null + : () async { + while (!coinsBloc.loginActivationFinished) { + await Future.delayed( + const Duration(milliseconds: 300)); + } + final bool isValid = await makerFormBloc.validate(); + if (!isValid) return; + + 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 new file mode 100644 index 0000000000..c8140c10e4 --- /dev/null +++ b/lib/views/dex/simple/form/tables/coins_table/coins_table.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.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'; +import 'package:web_dex/views/dex/simple/form/tables/table_search_field.dart'; + +class CoinsTable extends StatefulWidget { + const CoinsTable({ + required this.onSelect, + this.maxHeight = 300, + this.head, + Key? key, + }) : super(key: key); + + final Function(Coin) onSelect; + final Widget? head; + final double maxHeight; + + @override + State createState() => _CoinsTableState(); +} + +class _CoinsTableState extends State { + String? _searchTerm; + + @override + Widget build(BuildContext context) { + return FocusTraversalGroup( + child: FrontPlate( + shadowEnabled: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.head != null) widget.head!, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TableSearchField( + height: 30, + onChanged: (String value) { + if (_searchTerm == value) return; + setState(() => _searchTerm = value); + }, + ), + ), + const SizedBox(height: 5), + CoinsTableContent( + onSelect: widget.onSelect, + searchString: _searchTerm, + maxHeight: widget.maxHeight, + ), + ], + ), + ), + ); + } +} 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 new file mode 100644 index 0000000000..01158b139b --- /dev/null +++ b/lib/views/dex/simple/form/tables/coins_table/coins_table_content.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.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'; +import 'package:web_dex/views/dex/simple/form/tables/table_utils.dart'; + +class CoinsTableContent extends StatelessWidget { + const CoinsTableContent({ + required this.onSelect, + required this.searchString, + required this.maxHeight, + }); + + final Function(Coin) onSelect; + final String? searchString; + final double maxHeight; + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + stream: coinsBloc.outKnownCoins, + initialData: coinsBloc.knownCoins, + builder: (context, snapshot) { + final coins = prepareCoinsForTable(coinsBloc.knownCoins, searchString); + if (coins.isEmpty) return const NothingFound(); + + return GroupedListView( + items: coins, + onSelect: onSelect, + maxHeight: maxHeight, + ); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/tables/coins_table/coins_table_item.dart b/lib/views/dex/simple/form/tables/coins_table/coins_table_item.dart new file mode 100644 index 0000000000..c016cde038 --- /dev/null +++ b/lib/views/dex/simple/form/tables/coins_table/coins_table_item.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/coin_balance.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/item_decoration.dart'; + +class CoinsTableItem extends StatelessWidget { + const CoinsTableItem({ + super.key, + required this.data, + required this.onSelect, + required this.coin, + this.isGroupHeader = false, + this.subtitleText, + }); + + final T? data; + final Coin coin; + final Function(T) onSelect; + final bool isGroupHeader; + final String? subtitleText; + + @override + Widget build(BuildContext context) { + final child = ItemDecoration( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + CoinItem( + coin: coin, + size: CoinItemSize.large, + subtitleText: subtitleText, + ), + const SizedBox(width: 8), + if (coin.isActive) CoinBalance(coin: coin), + ], + ), + ); + + return Material( + color: Colors.transparent, + child: isGroupHeader + ? child + : InkWell( + key: Key('${T.toString()}-table-item-${coin.abbr}'), + borderRadius: BorderRadius.circular(18), + onTap: () => onSelect(data as T), + child: child, + ), + ); + } +} diff --git a/lib/views/dex/simple/form/tables/coins_table/taker_sell_coins_table.dart b/lib/views/dex/simple/form/tables/coins_table/taker_sell_coins_table.dart new file mode 100644 index 0000000000..f542a5d31e --- /dev/null +++ b/lib/views/dex/simple/form/tables/coins_table/taker_sell_coins_table.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/model/coin.dart'; +import 'package:web_dex/views/dex/simple/form/tables/coins_table/coins_table.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/taker_form_sell_switcher.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class TakerSellCoinsTable extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, curr) { + if (prev.showCoinSelector != curr.showCoinSelector) return true; + if (prev.sellCoin != curr.sellCoin) return true; + + return false; + }, + builder: (context, state) { + if (!state.showCoinSelector) return const SizedBox(); + + return CoinsTable( + key: const Key('taker-sell-coins-table'), + onSelect: (Coin coin) => + context.read().add(TakerSetSellCoin(coin)), + head: TakerFormSellSwitcher( + padding: const EdgeInsets.only(top: 16, bottom: 12), + controller: TradeCoinController( + coin: state.sellCoin, + onTap: () => + context.read().add(TakerCoinSelectorClick()), + isEnabled: false, + isOpened: true, + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/tables/nothing_found.dart b/lib/views/dex/simple/form/tables/nothing_found.dart new file mode 100644 index 0000000000..93c4e7bc8b --- /dev/null +++ b/lib/views/dex/simple/form/tables/nothing_found.dart @@ -0,0 +1,18 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class NothingFound extends StatelessWidget { + const NothingFound(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 30, 16, 20), + child: Text( + LocaleKeys.nothingFound.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + ); + } +} 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 new file mode 100644 index 0000000000..c8aac36e71 --- /dev/null +++ b/lib/views/dex/simple/form/tables/orders_table/grouped_list_view.dart @@ -0,0 +1,131 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/dex/simple/form/tables/coins_table/coins_table_item.dart'; +import 'package:web_dex/views/market_maker_bot/coin_search_dropdown.dart' + as coin_dropdown; + +class GroupedListView extends StatelessWidget { + const GroupedListView({ + super.key, + required this.items, + required this.onSelect, + required this.maxHeight, + }); + + final List items; + final Function(T) onSelect; + final double maxHeight; + + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + final groupedItems = _groupList(items); + + // Add right padding to the last column if there are grouped items + // to align the grouped and non-grouped + final areGroupedItemsPresent = groupedItems.isNotEmpty && + groupedItems.entries + .where((element) => element.value.length > 1) + .isNotEmpty; + final rightPadding = areGroupedItemsPresent + ? const EdgeInsets.only(right: 52) + : const EdgeInsets.all(0); + + return Flexible( + child: ConstrainedBox( + constraints: BoxConstraints(maxHeight: maxHeight), + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: ListView.builder( + controller: scrollController, + primary: false, + shrinkWrap: true, + itemCount: groupedItems.length, + itemBuilder: (BuildContext context, int index) { + final group = groupedItems.entries.elementAt(index); + return group.value.length > 1 + ? ExpansionTile( + tilePadding: const EdgeInsets.fromLTRB(0, 0, 16, 0), + childrenPadding: const EdgeInsets.fromLTRB(0, 0, 0, 0), + initiallyExpanded: false, + title: CoinsTableItem( + data: group.value.first, + coin: _createHeaderCoinData(group.value), + onSelect: onSelect, + isGroupHeader: true, + subtitleText: LocaleKeys.nNetworks + .tr(args: [group.value.length.toString()]), + ), + children: group.value + .map((item) => buildItem(context, item, onSelect)) + .toList(), + ) + : buildItem( + context, + group.value.first, + onSelect, + padding: rightPadding, + ); + }, + ), + ), + ), + ); + } + + Widget buildItem( + BuildContext context, + T item, + dynamic onSelect, { + EdgeInsets padding = const EdgeInsets.all(0), + }) { + return Padding( + padding: padding, + child: CoinsTableItem( + data: item, + coin: getCoin(item), + onSelect: onSelect, + ), + ); + } + + Coin _createHeaderCoinData(List list) { + final firstCoin = getCoin(list.first); + double totalBalance = list.fold(0, (sum, item) { + final coin = getCoin(item); + return sum + coin.balance; + }); + + final coin = firstCoin.dummyCopyWithoutProtocolData(); + + coin.balance = totalBalance; + + return coin; + } + + Map> _groupList(List list) { + Map> grouped = {}; + for (final item in list) { + final coin = getCoin(item); + grouped.putIfAbsent(coin.name, () => []).add(item); + } + return grouped; + } + + Coin getCoin(T item) { + if (item is Coin) { + return item as Coin; + } else if (item is coin_dropdown.CoinSelectItem) { + return coinsBloc.getCoin(item.coinId)!; + } else { + return coinsBloc.getCoin((item as BestOrder).coin)!; + } + } +} diff --git a/lib/views/dex/simple/form/tables/orders_table/orders_table.dart b/lib/views/dex/simple/form/tables/orders_table/orders_table.dart new file mode 100644 index 0000000000..952ee539f1 --- /dev/null +++ b/lib/views/dex/simple/form/tables/orders_table/orders_table.dart @@ -0,0 +1,72 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/blocs/blocs.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/views/dex/common/front_plate.dart'; +import 'package:web_dex/views/dex/simple/form/tables/orders_table/orders_table_content.dart'; +import 'package:web_dex/views/dex/simple/form/tables/table_search_field.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/taker_form_buy_switcher.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class OrdersTable extends StatefulWidget { + const OrdersTable({Key? key}) : super(key: key); + + @override + State createState() => _OrdersTableState(); +} + +class _OrdersTableState extends State { + String? _searchTerm; + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.selectedOrder, + builder: (context, selectedOrder) { + final coin = coinsBloc.getCoin(selectedOrder?.coin ?? ''); + final controller = TradeOrderController( + order: selectedOrder, + coin: coin, + onTap: () => + context.read().add(TakerOrderSelectorClick()), + isEnabled: false, + isOpened: true, + ); + + return FocusTraversalGroup( + child: FrontPlate( + shadowEnabled: true, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + TakerFormBuySwitcher( + controller, + padding: const EdgeInsets.only(top: 16, bottom: 12), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: TableSearchField( + height: 30, + onChanged: (String value) { + if (_searchTerm == value) return; + setState(() => _searchTerm = value); + }, + ), + ), + const SizedBox(height: 5), + OrdersTableContent( + onSelect: (BestOrder order) => + context.read().add(TakerSelectOrder(order)), + searchString: _searchTerm, + ), + ], + ), + ), + ); + }); + } +} 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 new file mode 100644 index 0000000000..771ef1ec3d --- /dev/null +++ b/lib/views/dex/simple/form/tables/orders_table/orders_table_content.dart @@ -0,0 +1,100 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_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/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'; +import 'package:web_dex/model/authorize_mode.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'; +import 'package:web_dex/views/dex/simple/form/tables/table_utils.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class OrdersTableContent extends StatelessWidget { + const OrdersTableContent({ + required this.onSelect, + required this.searchString, + this.maxHeight = 200, + }); + + final Function(BestOrder) onSelect; + final String? searchString; + final double maxHeight; + + @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(), + ); + } + + 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(ordersMap, searchString, mode); + + if (orders.isEmpty) return const NothingFound(); + + return GroupedListView( + items: orders, + onSelect: onSelect, + maxHeight: maxHeight, + ); + }, + ); + } +} + +class _ErrorMessage extends StatelessWidget { + const _ErrorMessage(this.error); + final BaseError error; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(12, 30, 12, 10), + alignment: const Alignment(0, 0), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + 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, + )), + const SizedBox(height: 4), + UiSimpleButton( + child: Text( + LocaleKeys.retryButtonText.tr(), + style: Theme.of(context).textTheme.bodySmall, + ), + onPressed: () => + context.read().add(TakerUpdateBestOrders()), + ) + ], + ), + ], + ), + ); + } +} diff --git a/lib/views/dex/simple/form/tables/orders_table/taker_orders_table.dart b/lib/views/dex/simple/form/tables/orders_table/taker_orders_table.dart new file mode 100644 index 0000000000..e103f32d8c --- /dev/null +++ b/lib/views/dex/simple/form/tables/orders_table/taker_orders_table.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_state.dart'; +import 'package:web_dex/views/dex/simple/form/tables/orders_table/orders_table.dart'; + +class TakerOrdersTable extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.showOrderSelector, + builder: (context, showOrdersSelector) { + if (!showOrdersSelector) return const SizedBox(); + + return const OrdersTable(key: Key('taker-orders-table')); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/tables/table_search_field.dart b/lib/views/dex/simple/form/tables/table_search_field.dart new file mode 100644 index 0000000000..1af2589819 --- /dev/null +++ b/lib/views/dex/simple/form/tables/table_search_field.dart @@ -0,0 +1,40 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class TableSearchField extends StatelessWidget { + const TableSearchField({Key? key, required this.onChanged, this.height = 44}) + : super(key: key); + final Function(String) onChanged; + final double height; + + @override + Widget build(BuildContext context) { + final style = + Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 12); + + return SizedBox( + height: height, + child: TextField( + key: const Key('search-field'), + onChanged: onChanged, + autofocus: isDesktop, + decoration: InputDecoration( + contentPadding: EdgeInsets.zero, + hintText: LocaleKeys.searchCoin.tr(), + border: OutlineInputBorder( + borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(height * 0.5), + ), + prefixIcon: Icon( + Icons.search, + size: 12, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + style: style, + ), + ); + } +} diff --git a/lib/views/dex/simple/form/tables/table_utils.dart b/lib/views/dex/simple/form/tables/table_utils.dart new file mode 100644 index 0000000000..7a17e3b958 --- /dev/null +++ b/lib/views/dex/simple/form/tables/table_utils.dart @@ -0,0 +1,67 @@ +import 'package:web_dex/blocs/blocs.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/views/dex/dex_helpers.dart'; + +List prepareCoinsForTable(List coins, String? searchString) { + coins = List.from(coins); + coins = removeWalletOnly(coins); + coins = removeSuspended(coins); + coins = sortFiatBalance(coins); + coins = filterCoinsByPhrase(coins, searchString ?? '').toList(); + return coins; +} + +List prepareOrdersForTable(Map>? orders, + String? searchString, AuthorizeMode mode) { + if (orders == null) return []; + final List sorted = _sortBestOrders(orders); + if (sorted.isEmpty) return []; + + removeSuspendedCoinOrders(sorted, mode); + if (sorted.isEmpty) return []; + + removeWalletOnlyCoinOrders(sorted); + if (sorted.isEmpty) return []; + + final String? filter = searchString?.toLowerCase(); + if (filter == null || filter.isEmpty) { + return sorted; + } + final List filtered = sorted.where((order) { + final Coin? coin = coinsBloc.getCoin(order.coin); + if (coin == null) return false; + return compareCoinByPhrase(coin, filter); + }).toList(); + + return filtered; +} + +List _sortBestOrders(Map> unsorted) { + if (unsorted.isEmpty) return []; + + final List sorted = []; + unsorted.forEach((ticker, list) { + if (coinsBloc.getCoin(list[0].coin) == null) return; + sorted.add(list[0]); + }); + + sorted.sort((a, b) { + final Coin? coinA = coinsBloc.getCoin(a.coin); + final Coin? coinB = coinsBloc.getCoin(b.coin); + if (coinA == null || coinB == null) return 0; + + final double fiatPriceA = getFiatAmount(coinA, a.price); + final double fiatPriceB = getFiatAmount(coinB, b.price); + + if (fiatPriceA > fiatPriceB) return -1; + if (fiatPriceA < fiatPriceB) return 1; + + return coinA.abbr.compareTo(coinB.abbr); + }); + + return sorted; +} diff --git a/lib/views/dex/simple/form/taker/available_balance.dart b/lib/views/dex/simple/form/taker/available_balance.dart new file mode 100644 index 0000000000..c918e7a835 --- /dev/null +++ b/lib/views/dex/simple/form/taker/available_balance.dart @@ -0,0 +1,90 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/available_balance_state.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class AvailableBalance extends StatelessWidget { + const AvailableBalance(this.availableBalance, this.state, [Key? key]) + : super(key: key); + + final Rational? availableBalance; + final AvailableBalanceState state; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + isMobile + ? LocaleKeys.available.tr() + : LocaleKeys.availableForSwaps.tr(), + style: TextStyle( + color: dexPageColors.inactiveText, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(width: 8), + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 38), + child: _Balance( + availableBalance: availableBalance, + state: state, + ), + ), + ), + ], + ); + } +} + +class _Balance extends StatelessWidget { + const _Balance({required this.availableBalance, required this.state}); + final Rational? availableBalance; + final AvailableBalanceState state; + + @override + Widget build(BuildContext context) { + final Rational balance = availableBalance ?? Rational.zero; + String value = formatAmt(balance.toDouble()); + switch (state) { + case AvailableBalanceState.loading: + case AvailableBalanceState.initial: + if (availableBalance == null) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 13), + child: UiSpinner( + height: 12, + width: 12, + strokeWidth: 1.5, + ), + ); + } + break; + case AvailableBalanceState.unavailable: + value = formatAmt(0.0); + break; + case AvailableBalanceState.success: + case AvailableBalanceState.failure: + break; + } + + return AutoScrollText( + text: value, + style: TextStyle( + color: dexPageColors.activeText, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + textAlign: TextAlign.end, + ); + } +} diff --git a/lib/views/dex/simple/form/taker/coin_item/balance_text.dart b/lib/views/dex/simple/form/taker/coin_item/balance_text.dart new file mode 100644 index 0000000000..bd58c3639f --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/balance_text.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +class BalanceText extends StatelessWidget { + const BalanceText(this.coin); + final Coin coin; + + @override + Widget build(BuildContext context) { + final double? balance = coin.isActive ? coin.balance : null; + return Text( + formatDexAmt(balance), + style: TextStyle( + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ); + } +} diff --git a/lib/views/dex/simple/form/taker/coin_item/coin_group.dart b/lib/views/dex/simple/form/taker/coin_item/coin_group.dart new file mode 100644 index 0000000000..d8a8959be1 --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/coin_group.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_logo.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/coin_name_and_protocol.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class CoinGroup extends StatelessWidget { + const CoinGroup(this.controller, {Key? key}) : super(key: key); + + final TradeController controller; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: controller.onTap, + child: Padding( + padding: const EdgeInsets.only(left: 15), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CoinLogo(coin: controller.coin), + const SizedBox(width: 9), + CoinNameAndProtocol(controller.coin, controller.isOpened), + const SizedBox(width: 9), + ], + ), + ), + ); + } +} diff --git a/lib/views/dex/simple/form/taker/coin_item/coin_group_name.dart b/lib/views/dex/simple/form/taker/coin_item/coin_group_name.dart new file mode 100644 index 0000000000..74f0aa9db7 --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/coin_group_name.dart @@ -0,0 +1,51 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class CoinGroupName extends StatelessWidget { + const CoinGroupName({super.key, this.coin, this.opened = false}); + + final Coin? coin; + final bool opened; + + @override + Widget build(BuildContext context) { + final title = _getTitleFromCoinId(coin?.abbr); + final chevron = opened + ? const DexSvgImage( + path: Assets.dexChevronUp, + colorFilter: ColorFilterEnum.expandMode, + ) + : const DexSvgImage( + path: Assets.dexChevronDown, + colorFilter: ColorFilterEnum.expandMode, + ); + + return Row( + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: dexPageColors.activeText, + ), + ), + const SizedBox(width: 4), + chevron, + ], + ); + } + + String _getTitleFromCoinId(String? coinId) { + final title = coinId != null + ? abbr2TickerWithSuffix(coinId) + : LocaleKeys.selectAToken.tr(); + + return title; + } +} diff --git a/lib/views/dex/simple/form/taker/coin_item/coin_group_protocol.dart b/lib/views/dex/simple/form/taker/coin_item/coin_group_protocol.dart new file mode 100644 index 0000000000..1be9711b9a --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/coin_group_protocol.dart @@ -0,0 +1,43 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/segwit_icon.dart'; + +class CoinGroupProtocol extends StatelessWidget { + const CoinGroupProtocol([this.coin]); + + final Coin? coin; + + @override + Widget build(BuildContext context) { + if (coin == null) return const SizedBox(); + return Row( + children: [ + _CoinProtocol(coin!), + if (coin?.mode == CoinMode.segwit) + const Padding( + padding: EdgeInsets.only(left: 4.0), + child: SegwitIcon(height: 18), + ), + ], + ); + } +} + +class _CoinProtocol extends StatelessWidget { + const _CoinProtocol(this.coin); + + final Coin coin; + + @override + Widget build(BuildContext context) { + return Text( + coin.typeNameWithTestnet.toUpperCase(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: theme.custom.dexCoinProtocolColor, + ), + ); + } +} diff --git a/lib/views/dex/simple/form/taker/coin_item/coin_name_and_protocol.dart b/lib/views/dex/simple/form/taker/coin_item/coin_name_and_protocol.dart new file mode 100644 index 0000000000..b94ad3a407 --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/coin_name_and_protocol.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/coin_group_name.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/coin_group_protocol.dart'; + +class CoinNameAndProtocol extends StatelessWidget { + const CoinNameAndProtocol(this.coin, this.opened); + + final Coin? coin; + final bool opened; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 2), + CoinGroupName(coin: coin, opened: opened), + const SizedBox(height: 2), + CoinGroupProtocol(coin), + ], + ); + } +} diff --git a/lib/views/dex/simple/form/taker/coin_item/item_decoration.dart b/lib/views/dex/simple/form/taker/coin_item/item_decoration.dart new file mode 100644 index 0000000000..bce1d5515c --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/item_decoration.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +class ItemDecoration extends StatelessWidget { + const ItemDecoration({required this.child}); + + final Widget child; + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 15), + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + ), + child: child, + ); + } +} diff --git a/lib/views/dex/simple/form/taker/coin_item/taker_form_buy_amount.dart b/lib/views/dex/simple/form/taker/coin_item/taker_form_buy_amount.dart new file mode 100644 index 0000000000..529234f0ed --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/taker_form_buy_amount.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_state.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/views/dex/common/trading_amount_field.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class TakerFormBuyAmount extends StatelessWidget { + const TakerFormBuyAmount(this.isEnabled); + + final bool isEnabled; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(right: 18, top: 1), + child: _BuyAmountInput( + key: const Key('taker-buy-amount'), + isEnabled: isEnabled, + ), + ), + const Padding( + padding: EdgeInsets.only(right: 18), + child: _BuyPriceField(), + ), + ], + ); + } +} + +class _BuyPriceField extends StatelessWidget { + const _BuyPriceField(); + + @override + Widget build(BuildContext context) { + final TextStyle? textStyle = Theme.of(context).textTheme.bodySmall; + + return BlocBuilder( + buildWhen: (prev, curr) { + if (prev.selectedOrder != curr.selectedOrder) return true; + if (prev.buyAmount != curr.buyAmount) return true; + + return false; + }, + builder: (context, state) { + final BestOrder? order = state.selectedOrder; + if (order == null) return const SizedBox(); + + final amount = state.buyAmount ?? Rational.zero; + return Text( + getFormattedFiatAmount(order.coin, amount), + style: textStyle, + ); + }, + ); + } +} + +class _BuyAmountInput extends StatelessWidget { + _BuyAmountInput({ + Key? key, + required this.isEnabled, + }) : super(key: key); + + final bool isEnabled; + + final _textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.buyAmount, + builder: (context, buyAmount) { + formatAmountInput(_textController, buyAmount); + + return TradingAmountField( + controller: _textController, + enabled: isEnabled, + ); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/taker/coin_item/taker_form_buy_item.dart b/lib/views/dex/simple/form/taker/coin_item/taker_form_buy_item.dart new file mode 100644 index 0000000000..f0ac9dfa0e --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/taker_form_buy_item.dart @@ -0,0 +1,57 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/dex/common/front_plate.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_form_group_header.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/taker_form_buy_switcher.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; +import 'package:easy_localization/easy_localization.dart'; + +class TakerFormBuyItem extends StatelessWidget { + const TakerFormBuyItem({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, curr) { + if (prev.selectedOrder != curr.selectedOrder) return true; + if (prev.sellCoin != curr.sellCoin) return true; + + return false; + }, + builder: (context, state) { + final coin = coinsBloc.getCoin(state.selectedOrder?.coin ?? ''); + + final controller = TradeOrderController( + order: state.selectedOrder, + coin: coin, + isEnabled: false, + isOpened: false, + onTap: () { + context.read().add(TakerOrderSelectorClick()); + }, + ); + + return FrontPlate( + child: Column( + children: [ + _BuyHeader(), + TakerFormBuySwitcher(controller), + ], + ), + ); + }, + ); + } +} + +class _BuyHeader extends StatelessWidget { + @override + Widget build(BuildContext context) => DexFormGroupHeader( + title: LocaleKeys.buy.tr(), + ); +} diff --git a/lib/views/dex/simple/form/taker/coin_item/taker_form_buy_switcher.dart b/lib/views/dex/simple/form/taker/coin_item/taker_form_buy_switcher.dart new file mode 100644 index 0000000000..d1a52f43ee --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/taker_form_buy_switcher.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/coin_group.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/taker_form_buy_amount.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class TakerFormBuySwitcher extends StatelessWidget { + const TakerFormBuySwitcher( + this.controller, { + this.padding = const EdgeInsets.only(top: 16, bottom: 14), + }); + + final TradeOrderController controller; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: padding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CoinGroup(controller, key: const Key('taker-form-buy-switcher')), + const SizedBox(width: 5), + Expanded(child: TakerFormBuyAmount(controller.isEnabled)), + ], + ), + ), + ], + ); + } +} diff --git a/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_amount.dart b/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_amount.dart new file mode 100644 index 0000000000..7c2717181b --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_amount.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rational/rational.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/shared/utils/formatters.dart'; +import 'package:web_dex/views/dex/common/trading_amount_field.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class TakerFormSellAmount extends StatelessWidget { + const TakerFormSellAmount(this.isEnabled); + + final bool isEnabled; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(right: 18, top: 1), + child: _SellAmountInput( + key: const Key('taker-sell-amount'), + isEnabled: isEnabled, + ), + ), + const Padding( + padding: EdgeInsets.only(right: 18), + child: _SellPriceField(), + ), + ], + ); + } +} + +class _SellPriceField extends StatelessWidget { + const _SellPriceField(); + + @override + Widget build(BuildContext context) { + final TextStyle? textStyle = Theme.of(context).textTheme.bodySmall; + + return BlocBuilder(buildWhen: (prev, curr) { + if (prev.sellCoin != curr.sellCoin) return true; + if (prev.sellAmount != curr.sellAmount) return true; + + return false; + }, builder: (context, state) { + final coin = state.sellCoin; + if (coin == null) return const SizedBox(); + + final amount = state.sellAmount ?? Rational.zero; + return Text( + getFormattedFiatAmount(coin.abbr, amount), + style: textStyle, + ); + }); + } +} + +class _SellAmountInput extends StatelessWidget { + _SellAmountInput({ + Key? key, + required this.isEnabled, + }) : super(key: key); + + final bool isEnabled; + + final _textController = TextEditingController(); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.sellAmount, + builder: (context, sellAmount) { + formatAmountInput(_textController, sellAmount); + + return TradingAmountField( + controller: _textController, + enabled: isEnabled, + onChanged: (String value) { + context.read().add(TakerSellAmountChange(value)); + + if (value.isEmpty) { + context.read().add(TakerOrderSelectorOpen(false)); + } + }, + ); + }, + ); + } +} 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 new file mode 100644 index 0000000000..cef689793d --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_item.dart @@ -0,0 +1,87 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.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/common/dex_form_group_header.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_small_button.dart'; +import 'package:web_dex/views/dex/simple/form/taker/available_balance.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/taker_form_sell_switcher.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class TakerFormSellItem extends StatelessWidget { + const TakerFormSellItem({super.key}); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.sellCoin, + builder: (context, sellCoin) { + return FrontPlate( + child: Column( + children: [ + _SellHeader(), + TakerFormSellSwitcher( + controller: TradeCoinController( + coin: sellCoin, + onTap: () => + context.read().add(TakerCoinSelectorClick()), + isEnabled: sellCoin != null, + isOpened: false, + ), + ), + ], + ), + ); + }, + ); + } +} + +class _SellHeader extends StatelessWidget { + @override + Widget build(BuildContext context) { + return DexFormGroupHeader( + title: LocaleKeys.sell.tr(), + actions: [ + Flexible(child: _AvailableGroup()), + const SizedBox(width: 8), + _MaxButton(), + const SizedBox(width: 3), + _HalfButton(), + ], + ); + } +} + +class _AvailableGroup extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return AvailableBalance( + state.maxSellAmount, + state.availableBalanceState, + ); + }, + ); + } +} + +class _HalfButton extends DexSmallButton { + _HalfButton() + : super(LocaleKeys.half.tr(), (context) { + context.read().add(TakerAmountButtonClick(0.5)); + }); +} + +class _MaxButton extends DexSmallButton { + _MaxButton() + : super(LocaleKeys.max.tr(), (context) { + context.read().add(TakerAmountButtonClick(1)); + }); +} diff --git a/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_switcher.dart b/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_switcher.dart new file mode 100644 index 0000000000..acab193447 --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/taker_form_sell_switcher.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/coin_group.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/taker_form_sell_amount.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/trade_controller.dart'; + +class TakerFormSellSwitcher extends StatelessWidget { + const TakerFormSellSwitcher({ + required this.controller, + this.padding = const EdgeInsets.only(top: 16, bottom: 12), + }); + + final TradeCoinController controller; + final EdgeInsetsGeometry padding; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: padding, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CoinGroup(controller, key: const Key('taker-form-sell-switcher')), + const SizedBox(width: 5), + Expanded(child: TakerFormSellAmount(controller.isEnabled)), + ], + ), + ), + ], + ); + } +} diff --git a/lib/views/dex/simple/form/taker/coin_item/trade_controller.dart b/lib/views/dex/simple/form/taker/coin_item/trade_controller.dart new file mode 100644 index 0000000000..4592219e8c --- /dev/null +++ b/lib/views/dex/simple/form/taker/coin_item/trade_controller.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/coin.dart'; + +abstract class TradeController { + TradeController({ + required this.coin, + required this.onTap, + required this.isOpened, + required this.isEnabled, + }); + + final Coin? coin; + final GestureTapCallback? onTap; + final bool isOpened; + final bool isEnabled; +} + +class TradeCoinController extends TradeController { + TradeCoinController({ + required super.coin, + required super.onTap, + required super.isOpened, + required super.isEnabled, + }); +} + +class TradeOrderController extends TradeController { + TradeOrderController({ + required super.coin, + required this.order, + required super.onTap, + required super.isOpened, + required super.isEnabled, + }); + + final BestOrder? order; +} diff --git a/lib/views/dex/simple/form/taker/taker_form.dart b/lib/views/dex/simple/form/taker/taker_form.dart new file mode 100644 index 0000000000..93492a707f --- /dev/null +++ b/lib/views/dex/simple/form/taker/taker_form.dart @@ -0,0 +1,43 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/blocs/blocs.dart'; +import 'package:web_dex/views/dex/simple/form/taker/taker_form_layout.dart'; + +class TakerForm extends StatefulWidget { + const TakerForm({super.key}); + + @override + State createState() => _TakerFormState(); +} + +class _TakerFormState extends State { + StreamSubscription? _coinsListener; + + @override + void initState() { + final takerBloc = context.read(); + takerBloc.add(TakerSetDefaults()); + takerBloc.add(TakerSetWalletIsReady(coinsBloc.loginActivationFinished)); + _coinsListener = coinsBloc.outLoginActivationFinished.listen((value) { + takerBloc.add(TakerSetWalletIsReady(value)); + }); + + super.initState(); + } + + @override + void dispose() { + _coinsListener?.cancel(); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return const TakerFormLayout(); + } +} diff --git a/lib/views/dex/simple/form/taker/taker_form_content.dart b/lib/views/dex/simple/form/taker/taker_form_content.dart new file mode 100644 index 0000000000..373f17f99b --- /dev/null +++ b/lib/views/dex/simple/form/taker/taker_form_content.dart @@ -0,0 +1,159 @@ +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:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; +import 'package:web_dex/bloc/system_health/system_health_state.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/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_light_button.dart'; +import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_wrapper.dart'; +import 'package:web_dex/views/dex/common/form_plate.dart'; +import 'package:web_dex/views/dex/common/section_switcher.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_flip_button_overlapper.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/taker_form_buy_item.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/taker_form_sell_item.dart'; +import 'package:web_dex/views/dex/simple/form/taker/taker_form_error_list.dart'; +import 'package:web_dex/views/dex/simple/form/taker/taker_form_exchange_info.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:collection/collection.dart'; + +class TakerFormContent extends StatelessWidget { + @override + Widget build(BuildContext context) { + return FormPlate( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 12), + SectionSwitcher(), + const SizedBox(height: 6), + DexFlipButtonOverlapper( + onTap: () async { + final takerBloc = context.read(); + final selectedOrder = takerBloc.state.selectedOrder; + if (selectedOrder == null) return false; + + final knownCoins = await coinsRepo.getKnownCoins(); + final buyCoin = knownCoins.firstWhereOrNull( + (element) => element.abbr == selectedOrder.coin); + if (buyCoin == null) return false; + + takerBloc.add(TakerSetSellCoin(buyCoin, + autoSelectOrderAbbr: takerBloc.state.sellCoin?.abbr)); + return true; + }, + topWidget: const TakerFormSellItem(), + bottomWidget: const TakerFormBuyItem(), + ), + const TakerFormErrorList(), + const SizedBox(height: 24), + const TakerFormExchangeInfo(), + const SizedBox(height: 24), + const _FormControls(), + const SizedBox(height: 16), + ], + ), + ); + } +} + +class _FormControls extends StatelessWidget { + const _FormControls(); + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth - 32), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const ResetSwapFormButton(), + const SizedBox(width: 10), + Expanded( + child: ConnectWalletWrapper( + key: const Key('connect-wallet-taker-form'), + eventType: WalletsManagerEventType.dex, + buttonSize: Size( + 112, + isMobile ? 52 : 40, + ), + child: const TradeButton(), + ), + ), + ], + ), + ); + } +} + +class ResetSwapFormButton extends StatelessWidget { + const ResetSwapFormButton(); + + @override + Widget build(BuildContext context) { + return UiLightButton( + width: 112, + height: isMobile ? 52 : 40, + text: LocaleKeys.clear.tr(), + onPressed: () => context.read().add(TakerClear()), + ); + } +} + +class TradeButton extends StatelessWidget { + const TradeButton(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, systemHealthState) { + final bool isSystemClockValid = + systemHealthState is SystemHealthLoadSuccess && + systemHealthState.isValid; + + return BlocSelector( + selector: (state) => state.inProgress, + builder: (context, inProgress) { + final bool disabled = inProgress || !isSystemClockValid; + + return Opacity( + opacity: disabled ? 0.8 : 1, + child: UiPrimaryButton( + key: const Key('take-order-button'), + text: LocaleKeys.swapNow.tr(), + prefix: inProgress ? const TradeButtonSpinner() : null, + onPressed: disabled + ? null + : () => context.read().add(TakerFormSubmitClick()), + height: isMobile ? 52 : 40, + ), + ); + }, + ); + }); + } +} + +class TradeButtonSpinner extends StatelessWidget { + const TradeButtonSpinner(); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(right: 4), + child: UiSpinner( + width: 10, + height: 10, + strokeWidth: 1, + color: theme.custom.defaultGradientButtonTextColor, + ), + ); + } +} diff --git a/lib/views/dex/simple/form/taker/taker_form_error_list.dart b/lib/views/dex/simple/form/taker/taker_form_error_list.dart new file mode 100644 index 0000000000..7acb86c4ef --- /dev/null +++ b/lib/views/dex/simple/form/taker/taker_form_error_list.dart @@ -0,0 +1,20 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_state.dart'; +import 'package:web_dex/model/dex_form_error.dart'; +import 'package:web_dex/views/dex/simple/form/error_list/dex_form_error_list.dart'; + +class TakerFormErrorList extends StatelessWidget { + const TakerFormErrorList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocSelector>( + selector: (state) => state.errors, + builder: (context, errors) { + return DexFormErrorList(errors: errors); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/taker/taker_form_exchange_info.dart b/lib/views/dex/simple/form/taker/taker_form_exchange_info.dart new file mode 100644 index 0000000000..4167e6ca27 --- /dev/null +++ b/lib/views/dex/simple/form/taker/taker_form_exchange_info.dart @@ -0,0 +1,56 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_state.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_info_container.dart'; +import 'package:web_dex/views/dex/simple/form/exchange_info/dex_compared_to_cex.dart'; +import 'package:web_dex/views/dex/simple/form/taker/taker_form_exchange_rate.dart'; +import 'package:web_dex/views/dex/simple/form/taker/taker_form_total_fees.dart'; + +class TakerFormExchangeInfo extends StatelessWidget { + const TakerFormExchangeInfo(); + + @override + Widget build(BuildContext context) { + return const DexInfoContainer( + children: [ + TakerFormExchangeRate(), + SizedBox(height: 8), + _TakerComparedToCex(), + SizedBox(height: 8), + TakerFormTotalFees(), + ], + ); + } +} + +class _TakerComparedToCex extends StatelessWidget { + const _TakerComparedToCex({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, curr) { + if (prev.selectedOrder != curr.selectedOrder) return true; + if (prev.sellCoin != curr.sellCoin) return true; + + return false; + }, + builder: (context, state) { + final BestOrder? bestOrder = state.selectedOrder; + final Coin? sellCoin = state.sellCoin; + final Coin? buyCoin = + bestOrder == null ? null : coinsBloc.getCoin(bestOrder.coin); + + return DexComparedToCex( + base: sellCoin, + rel: buyCoin, + rate: bestOrder?.price, + ); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/taker/taker_form_exchange_rate.dart b/lib/views/dex/simple/form/taker/taker_form_exchange_rate.dart new file mode 100644 index 0000000000..e60356f813 --- /dev/null +++ b/lib/views/dex/simple/form/taker/taker_form_exchange_rate.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_state.dart'; +import 'package:web_dex/views/dex/simple/form/exchange_info/exchange_rate.dart'; + +class TakerFormExchangeRate extends StatelessWidget { + const TakerFormExchangeRate({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, curr) { + if (prev.selectedOrder != curr.selectedOrder) return true; + if (prev.sellCoin != curr.sellCoin) return true; + + return false; + }, + builder: (context, state) { + final String? base = state.sellCoin?.abbr; + final String? rel = state.selectedOrder?.coin; + final Rational? rate = state.selectedOrder?.price; + + return ExchangeRate(rate: rate, base: base, rel: rel); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/taker/taker_form_layout.dart b/lib/views/dex/simple/form/taker/taker_form_layout.dart new file mode 100644 index 0000000000..f2560a8bd7 --- /dev/null +++ b/lib/views/dex/simple/form/taker/taker_form_layout.dart @@ -0,0 +1,118 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_state.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/views/dex/simple/confirm/taker_order_confirmation.dart'; +import 'package:web_dex/views/dex/simple/form/tables/coins_table/taker_sell_coins_table.dart'; +import 'package:web_dex/views/dex/simple/form/tables/orders_table/taker_orders_table.dart'; +import 'package:web_dex/views/dex/simple/form/taker/taker_form_content.dart'; +import 'package:web_dex/views/dex/simple/form/taker/taker_order_book.dart'; + +class TakerFormLayout extends StatelessWidget { + const TakerFormLayout({super.key}); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.step, + builder: (context, step) { + return step == TakerStep.confirm + ? const TakerOrderConfirmation() + : isMobile + ? const _TakerFormMobileLayout() + : _TakerFormDesktopLayout(); + }, + ); + } +} + +class _TakerFormDesktopLayout extends StatelessWidget { + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // We want to place form in the middle of the screen, + // and orderbook, when shown, should be on the right side + // (leaving the form in the middle) + const Expanded(flex: 3, child: SizedBox.shrink()), + Flexible( + flex: 6, + child: DexScrollbar( + scrollController: scrollController, + isMobile: isMobile, + child: SingleChildScrollView( + key: const Key('taker-form-layout-scroll'), + controller: scrollController, + child: ConstrainedBox( + constraints: + BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: Stack( + clipBehavior: Clip.none, + children: [ + TakerFormContent(), + Padding( + padding: const EdgeInsets.fromLTRB(16, 52, 16, 0), + child: TakerSellCoinsTable(), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 167, 16, 0), + child: TakerOrdersTable(), + ), + ], + ), + ), + ), + ), + ), + Expanded( + flex: 3, + child: Padding( + padding: const EdgeInsets.only(left: 20), + child: SingleChildScrollView( + controller: ScrollController(), child: const TakerOrderbook()), + ), + ) + ], + ); + } +} + +class _TakerFormMobileLayout extends StatelessWidget { + const _TakerFormMobileLayout(); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + controller: ScrollController(), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: Stack( + children: [ + Column( + children: [ + TakerFormContent(), + const SizedBox(height: 22), + const TakerOrderbook(), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 37, 16, 0), + child: TakerSellCoinsTable(), + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 134, 16, 0), + child: TakerOrdersTable(), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/dex/simple/form/taker/taker_form_total_fees.dart b/lib/views/dex/simple/form/taker/taker_form_total_fees.dart new file mode 100644 index 0000000000..0ff1b01015 --- /dev/null +++ b/lib/views/dex/simple/form/taker/taker_form_total_fees.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_bloc.dart'; +import 'package:web_dex/bloc/taker_form/taker_state.dart'; +import 'package:web_dex/model/trade_preimage.dart'; +import 'package:web_dex/views/dex/simple/form/exchange_info/total_fees.dart'; + +class TakerFormTotalFees extends StatelessWidget { + const TakerFormTotalFees(); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.tradePreimage, + builder: (context, tradePreimage) { + return TotalFees(preimage: tradePreimage); + }, + ); + } +} diff --git a/lib/views/dex/simple/form/taker/taker_order_book.dart b/lib/views/dex/simple/form/taker/taker_order_book.dart new file mode 100644 index 0000000000..7e4a617a51 --- /dev/null +++ b/lib/views/dex/simple/form/taker/taker_order_book.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_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/blocs/blocs.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/model/orderbook/order.dart'; +import 'package:web_dex/views/dex/orderbook/orderbook_view.dart'; + +class TakerOrderbook extends StatelessWidget { + const TakerOrderbook(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + buildWhen: (prev, cur) { + if (prev.sellCoin?.abbr != cur.sellCoin?.abbr) return true; + if (prev.selectedOrder?.uuid != cur.selectedOrder?.uuid) return true; + + return false; + }, + builder: (context, state) { + final selectedOrder = state.selectedOrder; + + return OrderbookView( + base: state.sellCoin, + rel: selectedOrder == null + ? null + : coinsBloc.getKnownCoin(selectedOrder.coin), + selectedOrderUuid: state.selectedOrder?.uuid, + onBidClick: (Order order) { + if (state.selectedOrder?.uuid == order.uuid) return; + context.read().add(TakerSelectOrder( + BestOrder.fromOrder(order, state.selectedOrder?.coin))); + }, + ); + }, + ); + } +} diff --git a/lib/views/fiat/fiat_action_tab.dart b/lib/views/fiat/fiat_action_tab.dart new file mode 100644 index 0000000000..291e0c11fc --- /dev/null +++ b/lib/views/fiat/fiat_action_tab.dart @@ -0,0 +1,61 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab_bar.dart'; + +class FiatActionTabBar extends StatefulWidget { + const FiatActionTabBar({ + Key? key, + required this.currentTabIndex, + required this.onTabClick, + }) : super(key: key); + final int currentTabIndex; + final Function(int) onTabClick; + + @override + State createState() => _FiatActionTabBarState(); +} + +class _FiatActionTabBarState extends State { + final List _listeners = []; + + @override + void initState() { + _onDataChange(null); + + super.initState(); + } + + @override + void dispose() { + _listeners.map((listener) => listener.cancel()); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return UiTabBar( + currentTabIndex: widget.currentTabIndex, + tabs: [ + UiTab( + key: const Key('fiat-buy-tab'), + text: 'Buy crypto', + isSelected: widget.currentTabIndex == 0, + onClick: () => widget.onTabClick(0), + ), + UiTab( + key: const Key('fiat-sell-tab'), + text: 'Sell crypto', + isSelected: widget.currentTabIndex == 1, + onClick: () => widget.onTabClick(1), + ), + ], + ); + } + + void _onDataChange(dynamic _) { + if (!mounted) return; + } +} diff --git a/lib/views/fiat/fiat_form.dart b/lib/views/fiat/fiat_form.dart new file mode 100644 index 0000000000..e95de03aee --- /dev/null +++ b/lib/views/fiat/fiat_form.dart @@ -0,0 +1,817 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:universal_html/html.dart' + as html; //TODO! Non-web implementation +import 'package:web_dex/bloc/fiat/base_fiat_provider.dart'; +import 'package:web_dex/bloc/fiat/fiat_order_status.dart'; +import 'package:web_dex/bloc/fiat/fiat_repository.dart'; +import 'package:web_dex/blocs/blocs.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_type.dart'; +import 'package:web_dex/shared/ui/gradient_border.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_wrapper.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +import 'package:web_dex/views/fiat/fiat_action_tab.dart'; +import 'package:web_dex/views/fiat/fiat_inputs.dart'; +import 'package:web_dex/views/fiat/fiat_payment_method.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; + +class FiatForm extends StatefulWidget { + const FiatForm({required this.onCheckoutComplete, super.key}); + + // TODO: Remove this when we have a proper bloc for this page + final Function({required bool isSuccess}) onCheckoutComplete; + + @override + State createState() => _FiatFormState(); +} + +enum FiatMode { onramp, offramp } + +class _FiatFormState extends State { + int _activeTabIndex = 0; + + Currency _selectedFiat = Currency( + "USD", + 'United States Dollar', + isFiat: true, + ); + + Currency _selectedCoin = Currency( + "BTC", + 'Bitcoin', + chainType: CoinType.utxo, + isFiat: false, + ); + + Map? selectedPaymentMethod; + Map? selectedPaymentMethodPrice; + String? accountReference; + String? coinReceiveAddress; // null if not set, '' if not found + String? fiatAmount; + String? checkoutUrl; + bool loading = false; + bool orderFailed = false; + Map? error; + List>? paymentMethods; + Timer? _fiatInputDebounce; + + static const bool useSimpleLoadingSpinner = true; + + static const fillerFiatAmount = '100000'; + + bool _isLoggedIn = currentWalletBloc.wallet != null; + + StreamSubscription>? _walletCoinsListener; + StreamSubscription? _loginActivationListener; + StreamSubscription>>? _paymentMethodsListener; + + FiatMode get selectedFiatMode => [ + FiatMode.onramp, + FiatMode.offramp, + ].elementAt(_activeTabIndex); + + @override + void dispose() { + _walletCoinsListener?.cancel(); + _loginActivationListener?.cancel(); + _paymentMethodsListener?.cancel(); + _fiatInputDebounce?.cancel(); + + super.dispose(); + } + + void _setActiveTab(int i) { + setState(() { + _activeTabIndex = i; + }); + } + + void _handleAccountStatusChange(bool isLoggedIn) async { + if (_isLoggedIn != isLoggedIn) { + setState(() => _isLoggedIn = isLoggedIn); + } + + if (isLoggedIn) { + await fillAccountInformation(); + } else { + await _clearAccountData(); + } + } + + @override + void initState() { + super.initState(); + + _walletCoinsListener = coinsBloc.outWalletCoins.listen((walletCoins) async { + _handleAccountStatusChange(walletCoins.isNotEmpty); + }); + + _loginActivationListener = + coinsBloc.outLoginActivationFinished.listen((isLoggedIn) async { + _handleAccountStatusChange(isLoggedIn); + }); + + // Prefetch the hardcoded pair (like USD/BTC) + _refreshForm(); + } + + Future getCoinAddress(String abbr) async { + if (_isLoggedIn && currentWalletBloc.wallet != null) { + final accountKey = currentWalletBloc.wallet!.id; + final abbrKey = abbr; + + // Cache check + if (coinsBloc.addressCache.containsKey(accountKey) && + coinsBloc.addressCache[accountKey]!.containsKey(abbrKey)) { + return coinsBloc.addressCache[accountKey]![abbrKey]; + } else { + await activateCoinIfNeeded(abbr); + final coin = + coinsBloc.walletCoins.firstWhereOrNull((c) => c.abbr == abbr); + + if (coin != null && coin.address != null) { + if (!coinsBloc.addressCache.containsKey(accountKey)) { + coinsBloc.addressCache[accountKey] = {}; + } + + // Cache this wallet's addresses + for (final walletCoin in coinsBloc.walletCoins) { + if (walletCoin.address != null && + !coinsBloc.addressCache[accountKey]! + .containsKey(walletCoin.abbr)) { + // Exit if the address already exists in a different account + // Address belongs to another account, this is a bug, gives outdated data + for (final entry in coinsBloc.addressCache.entries) { + if (entry.key != accountKey && + entry.value.containsValue(walletCoin.address)) { + return null; + } + } + + coinsBloc.addressCache[accountKey]![walletCoin.abbr] = + walletCoin.address!; + } + } + + return coinsBloc.addressCache[accountKey]![abbrKey]; + } + } + } + + return null; + } + + Future _updateFiatAmount(String? value) async { + setState(() { + fiatAmount = value; + }); + + if (_fiatInputDebounce?.isActive ?? false) { + _paymentMethodsListener?.cancel(); + _fiatInputDebounce!.cancel(); + } + _fiatInputDebounce = Timer(const Duration(milliseconds: 500), () async { + fillPaymentMethods(_selectedFiat.symbol, _selectedCoin, + forceUpdate: true); + }); + } + + String? getNonZeroFiatAmount() { + if (fiatAmount == null) return null; + final amount = double.tryParse(fiatAmount!); + + if (amount == null || amount < 10) return null; + return fiatAmount; + } + + Future _updateSelectedOptions( + Currency selectedFiat, + Currency selectedCoin, { + bool forceUpdate = false, + }) async { + bool coinChanged = _selectedCoin != selectedCoin; + bool fiatChanged = _selectedFiat != selectedFiat; + + // Set coins + setState(() { + _selectedFiat = selectedFiat; + _selectedCoin = selectedCoin; + + // Clear the previous data + if (forceUpdate || coinChanged || fiatChanged) { + selectedPaymentMethod = null; + selectedPaymentMethodPrice = null; + _paymentMethodsListener?.cancel(); + paymentMethods = null; + } + + if (forceUpdate) accountReference = null; + + if (forceUpdate || coinChanged) coinReceiveAddress = null; + }); + + // Fetch new payment methods based on the selected options + if (forceUpdate || (fiatChanged || coinChanged)) { + fillAccountInformation(); + fillPaymentMethods(_selectedFiat.symbol, _selectedCoin, + forceUpdate: true); + } + } + + Future fillAccountReference() async { + final address = await getCoinAddress('KMD'); + + if (!mounted) return; + setState(() { + accountReference = address; + }); + } + + Future fillCoinReceiveAddress() async { + final address = await getCoinAddress(_selectedCoin.getAbbr()); + + if (!mounted) return; + setState(() { + coinReceiveAddress = address; + }); + } + + Future fillAccountInformation() async { + fillAccountReference(); + fillCoinReceiveAddress(); + } + + Future fillPaymentMethods(String fiat, Currency coin, + {bool forceUpdate = false}) async { + try { + final sourceAmount = getNonZeroFiatAmount(); + _paymentMethodsListener = fiatRepository + .getPaymentMethodsList(fiat, coin, sourceAmount ?? fillerFiatAmount) + .listen((newPaymentMethods) { + setState(() { + paymentMethods = newPaymentMethods; + }); + + // if fiat amount has changed, exit early + final fiatChanged = sourceAmount != getNonZeroFiatAmount(); + final coinChanged = _selectedCoin != coin; + if (fiatChanged || coinChanged) { + return; + } + + if ((forceUpdate || selectedPaymentMethod == null) && + paymentMethods!.isNotEmpty) { + final method = selectedPaymentMethod == null + ? paymentMethods!.first + : paymentMethods!.firstWhere( + (method) => method['id'] == selectedPaymentMethod!['id'], + orElse: () => paymentMethods!.first); + changePaymentMethod(method); + } + }); + } catch (e) { + setState(() { + paymentMethods = []; + }); + } + } + + Future changePaymentMethod(Map method) async { + setState(() { + selectedPaymentMethod = method; + + if (selectedPaymentMethod != null) { + final sourceAmount = getNonZeroFiatAmount(); + final currentPriceInfo = selectedPaymentMethod!['price_info']; + final priceInfo = sourceAmount == null || + currentPriceInfo == null || + double.parse(sourceAmount) != + double.parse( + selectedPaymentMethod!['price_info']['fiat_amount']) + ? null + : currentPriceInfo; + + selectedPaymentMethodPrice = priceInfo; + } else { + // selectedPaymentMethodPrice = null; + } + }); + } + + String? getFormIssue() { + if (!_isLoggedIn) { + return 'Please connect your wallet to purchase coins'; + } + if (paymentMethods == null) { + return 'Payment methods not fetched yet'; + } + if (paymentMethods!.isEmpty) { + return 'No payment method for this pair'; + } + if (coinReceiveAddress == null) { + return 'Wallet adress is not fetched yet or no login'; + } + if (coinReceiveAddress!.isEmpty) { + return 'No wallet, or coin/network might not be supported'; + } + if (accountReference == null) { + return 'Account reference (KMD Address) is not fetched yet'; + } + if (accountReference!.isEmpty) { + return 'Account reference (KMD Address) could not be fetched'; + } + if (fiatAmount == null) { + return 'Fiat amount is not set'; + } + if (fiatAmount!.isEmpty) { + return 'Fiat amount is empty'; + } + + final fiatAmountValue = getFiatAmountValue(); + + if (fiatAmountValue == null) { + return 'Invalid fiat amount'; + } + if (fiatAmountValue <= 0) { + return 'Fiat amount should be higher than zero'; + } + + if (selectedPaymentMethod == null) { + return 'Fiat not selected'; + } + + final boundariesError = getBoundariesError(); + if (boundariesError != null) return boundariesError; + + return null; + } + + String? getBoundariesError() { + return isFiatAmountTooLow() + ? 'Please enter more than ${getMinFiatAmount()} ${_selectedFiat.symbol}' + : isFiatAmountTooHigh() + ? 'Please enter less than ${getMaxFiatAmount()} ${_selectedFiat.symbol}' + : null; + } + + double? getFiatAmountValue() { + if (fiatAmount == null) return null; + return double.tryParse(fiatAmount!); + } + + Map? getFiatLimitData() { + if (selectedPaymentMethod == null) return null; + + final txLimits = + selectedPaymentMethod!['transaction_limits'] as List?; + if (txLimits == null || txLimits.isEmpty) return null; + + final limitData = txLimits.first; + if (limitData.isEmpty) return null; + + final fiatCode = limitData['fiat_code']; + if (fiatCode == null || fiatCode != _selectedFiat.symbol) return null; + + return limitData; + } + + double? getMinFiatAmount() { + final limitData = getFiatLimitData(); + if (limitData == null) return null; + return double.tryParse(limitData['min']); + } + + double? getMaxFiatAmount() { + final limitData = getFiatLimitData(); + if (limitData == null) return null; + return double.tryParse(limitData['max']); + } + + bool isFiatAmountTooHigh() { + final fiatAmountValue = getFiatAmountValue(); + if (fiatAmountValue == null) return false; + + final limit = getMaxFiatAmount(); + if (limit == null) return false; + + return fiatAmountValue > limit; + } + + bool isFiatAmountTooLow() { + final fiatAmountValue = getFiatAmountValue(); + if (fiatAmountValue == null) return false; + + final limit = getMinFiatAmount(); + if (limit == null) return false; + + return fiatAmountValue < limit; + } + + //TODO! Non-web native implementation + String successUrl() { + // Base URL to the HTML redirect page + final baseUrl = '${html.window.location.origin}/assets' + '/web_pages/checkout_status_redirect.html'; + + final queryString = { + 'account_reference': accountReference!, + 'status': 'success', + } + .entries + .map((e) => '${e.key}=${Uri.encodeComponent(e.value)}') + .join('&'); + + return '$baseUrl?$queryString'; + } + + Future completeOrder() async { + final formIssue = getFormIssue(); + if (formIssue != null) { + log('Fiat order form is not complete: $formIssue'); + return; + } + + setState(() { + checkoutUrl = null; + orderFailed = false; + loading = true; + error = null; + }); + + try { + final newOrder = await fiatRepository.buyCoin( + accountReference!, + _selectedFiat.symbol, + _selectedCoin, + coinReceiveAddress!, + selectedPaymentMethod!, + fiatAmount!, + successUrl(), + ); + + setState(() { + checkoutUrl = newOrder['data']?['order']?['checkout_url']; + orderFailed = checkoutUrl == null; + loading = false; + error = null; + + if (!orderFailed) { + return openCheckoutPage(); + } + + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(LocaleKeys.orderFailedTryAgain.tr()), + ), + ); + }); + + log('New order failed: $newOrder'); + + if (error != null) { + log( + 'Error message: ${'${error!['code'] ?? ''} ' + '- ${error!['title']}${error!['description'] != null ? ' - Details:' + ' ${error!['description']}' : ''}'}', + ); + } + + // Ramp does not have an order ID + // TODO: Abstract out provider-specific order ID parsing. + final maybeOrderId = newOrder['data']['order']['id'] as String? ?? ''; + showOrderStatusUpdates(selectedPaymentMethod!, maybeOrderId); + } catch (e) { + setState(() { + checkoutUrl = null; + orderFailed = true; + loading = false; + if (e is Map && e.containsKey('errors')) { + error = e['errors']; + } else { + error = null; + } + }); + } + } + + void openCheckoutPage() { + if (checkoutUrl == null) return; + launchURL(checkoutUrl!, inSeparateTab: true); + } + + void showOrderStatusUpdates( + Map paymentMethod, + String orderId, + ) async { + FiatOrderStatus? lastStatus; + // TODO: Move to bloc & use bloc listener to show changes. + final statusStream = + fiatRepository.watchOrderStatus(paymentMethod, orderId); + + await for (final status in statusStream) { + //TODO? We can still show the alerts if we're no mounted by using the + // app's navigator key. This will be useful if the user has navigated + // to another page before completing the order. + if (!mounted) return; + + if (lastStatus == status) continue; + lastStatus = status; + + if (status != FiatOrderStatus.pending && checkoutUrl != null) { + setState(() => checkoutUrl = null); + } + + if (status == FiatOrderStatus.failed) setState(() => orderFailed = true); + + if (status != FiatOrderStatus.pending) { + showPaymentStatusDialog(status); + + // TODO: Differentiate between inProgress and success callback in bloc. + // That will deftermine whether they are changed to the "In Progress" + // tab or the "History" tab. + widget.onCheckoutComplete(isSuccess: true); + } + } + } + + void showPaymentStatusDialog(FiatOrderStatus status) { + if (!mounted) return; + + String? title; + String? content; + + // TODO: Use theme-based semantic colors + Icon? icon; + + switch (status) { + case FiatOrderStatus.pending: + throw Exception('Pending status should not be shown in dialog.'); + + case FiatOrderStatus.success: + title = 'Order successful!'; + content = 'Your coins have been deposited to your wallet.'; + icon = const Icon(Icons.check_circle_outline); + break; + + case FiatOrderStatus.failed: + title = 'Payment failed'; + // TODO: Localise all [FiatOrderStatus] messages. If we implement + // provider-specific error messages, we can include support details. + content = 'Your payment has failed. Please check your email for ' + 'more information or contact the provider\'s support.'; + icon = const Icon(Icons.error_outline, color: Colors.red); + break; + + case FiatOrderStatus.inProgress: + title = 'Payment received'; + content = 'Congratulations! Your payment has been received and the ' + 'coins are on the way to your wallet. \n\n' + 'You will receive your coins in 1-60 minutes.'; + icon = const Icon(Icons.hourglass_bottom_outlined); + break; + } + + //TODO: Localize + showAdaptiveDialog( + context: context, + builder: (context) => AlertDialog.adaptive( + title: Text(title!), + icon: icon, + content: Text(content!), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(LocaleKeys.ok.tr()), + ), + ], + ), + ).ignore(); + } + + Widget buildPaymentMethodsSection() { + final isLoading = paymentMethods == null; + if (isLoading) { + return useSimpleLoadingSpinner + ? const UiSpinner( + width: 36, + height: 36, + strokeWidth: 4, + ) + : _buildSkeleton(); + } + + final hasPaymentMethods = paymentMethods?.isNotEmpty ?? false; + if (!hasPaymentMethods) { + return Center( + child: Text( + LocaleKeys.noOptionsToPurchase + .tr(args: [_selectedCoin.symbol, _selectedFiat.symbol]), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } else { + final groupedPaymentMethods = + groupPaymentMethodsByProviderId(paymentMethods!); + return Column( + children: [ + for (var entry in groupedPaymentMethods.entries) ...[ + _buildPaymentMethodGroup(entry.key, entry.value), + const SizedBox(height: 16), + ], + ], + ); + } + } + + Map>> groupPaymentMethodsByProviderId( + List> paymentMethods) { + final groupedMethods = >>{}; + for (final method in paymentMethods) { + final providerId = method['provider_id']; + if (!groupedMethods.containsKey(providerId)) { + groupedMethods[providerId] = []; + } + groupedMethods[providerId]!.add(method); + } + return groupedMethods; + } + + Widget _buildPaymentMethodGroup( + String providerId, List>? methods) { + return Card( + margin: const EdgeInsets.all(0), + color: Theme.of(context).colorScheme.onSurface, + elevation: 4, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: Theme.of(context).primaryColor.withOpacity( + selectedPaymentMethod != null && + selectedPaymentMethod!['provider_id'] == providerId + ? 1 + : 0.25)), + ), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(providerId), + const SizedBox(height: 16), + GridView.builder( + shrinkWrap: true, + itemCount: methods!.length, + // TODO: Improve responsiveness by making crossAxisCount dynamic based on + // min and max child width. + gridDelegate: _gridDelegate, + itemBuilder: (context, index) { + return FiatPaymentMethod( + key: ValueKey(index), + fiatAmount: getNonZeroFiatAmount(), + paymentMethodData: methods[index], + selectedPaymentMethod: selectedPaymentMethod, + onSelect: changePaymentMethod, + ); + }, + ), + ], + ), + ), + ); + } + + Widget _buildSkeleton() { + return GridView( + shrinkWrap: true, + gridDelegate: _gridDelegate, + children: + List.generate(4, (index) => const Card(child: SkeletonListTile())), + ); + } + + SliverGridDelegate get _gridDelegate => + SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: isMobile ? 1 : 2, + crossAxisSpacing: 12, + mainAxisSpacing: 12, + mainAxisExtent: 90, + ); + + Future _refreshForm() async { + await _updateSelectedOptions( + _selectedFiat, + _selectedCoin, + forceUpdate: true, + ); + } + + Future _clearAccountData() async { + setState(() { + coinReceiveAddress = null; + accountReference = null; + }); + } + + @override + Widget build(BuildContext context) { + final formIssue = getFormIssue(); + + final canSubmit = !loading && + accountReference != null && + formIssue == null && + error == null; + + // TODO: Add optimisations to re-use the generated checkout URL if the user + // submits the form again without changing any data. When the user presses + // the "Buy Now" button, we create the checkout URL and open it in a new + // tab. + // Previously we would also navigate them to a separate page with a button + // in case they needed to open the checkout page again, but we removed that + // because it confused users on how to return and was not needed since user + // can just press the "Buy Now" button again. + // However! This may cause issues in the future when implement the order + // history tab since some providers require creating an order before + // creating the checkout URL. This could clutter up the order history with + // orders that were never completed. + + final scrollController = ScrollController(); + return DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: SingleChildScrollView( + key: const Key('fiat-form-scroll'), + controller: scrollController, + child: Column( + children: [ + FiatActionTabBar( + currentTabIndex: _activeTabIndex, + onTabClick: _setActiveTab, + ), + const SizedBox(height: 16), + if (selectedFiatMode == FiatMode.offramp) + Center(child: Text(LocaleKeys.comingSoon.tr())) + else + GradientBorder( + innerColor: dexPageColors.frontPlate, + gradient: dexPageColors.formPlateGradient, + child: Container( + padding: const EdgeInsets.fromLTRB(16, 32, 16, 16), + child: Column( + children: [ + FiatInputs( + onUpdate: _updateSelectedOptions, + onFiatAmountUpdate: _updateFiatAmount, + initialFiat: _selectedFiat, + initialCoin: _selectedCoin, + selectedPaymentMethodPrice: selectedPaymentMethodPrice, + receiveAddress: coinReceiveAddress, + isLoggedIn: _isLoggedIn, + fiatMinAmount: getMinFiatAmount(), + fiatMaxAmount: getMaxFiatAmount(), + boundariesError: getBoundariesError(), + ), + const SizedBox(height: 16), + buildPaymentMethodsSection(), + const SizedBox(height: 16), + ConnectWalletWrapper( + key: const Key('connect-wallet-fiat-form'), + eventType: WalletsManagerEventType.fiat, + child: UiPrimaryButton( + height: 40, + text: loading + ? '${LocaleKeys.submitting.tr()}...' + : LocaleKeys.buyNow.tr(), + onPressed: canSubmit ? completeOrder : null, + ), + ), + const SizedBox(height: 16), + Text( + _isLoggedIn + ? error != null + ? LocaleKeys.fiatCantCompleteOrder.tr() + : LocaleKeys.fiatPriceCanChange.tr() + : LocaleKeys.fiatConnectWallet.tr(), + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall, + ) + ], + ), + ), + ) + ], + ), + ), + ); + } +} diff --git a/lib/views/fiat/fiat_icon.dart b/lib/views/fiat/fiat_icon.dart new file mode 100644 index 0000000000..4e0a30cca2 --- /dev/null +++ b/lib/views/fiat/fiat_icon.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:web_dex/app_config/app_config.dart'; + +class FiatIcon extends StatefulWidget { + const FiatIcon({required this.symbol, super.key}); + + static const _fiatAssetsFolder = '$assetsPath/fiat/fiat_icons_square'; + final String symbol; + + // Static map to memoize asset checks + static final Map _assetExistenceCache = {}; + + @override + State createState() => _FiatIconState(); +} + +class _FiatIconState extends State { + bool? _assetExists; + + @override + void initState() { + super.initState(); + + setOrFetchAssetExistence(); + } + + @override + void didUpdateWidget(FiatIcon oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.symbol != widget.symbol) { + setOrFetchAssetExistence(); + } + } + + void setOrFetchAssetExistence() { + if (_knownAssetExistence != null) { + setState(() => _assetExists = _knownAssetExistence); + } else { + _checkIfAssetExists(context).then((exists) { + setState(() => _assetExists = exists); + }); + } + } + + String get _assetPath => + '${FiatIcon._fiatAssetsFolder}/${widget.symbol.toLowerCase()}.webp'; + + bool? get _knownAssetExistence => FiatIcon._assetExistenceCache[_assetPath]; + + Future _checkIfAssetExists(BuildContext context) { + return _knownAssetExistence != null + ? Future.value(_knownAssetExistence) + : Future(() async { + // ignore: use_build_context_synchronously + final bundle = await _loadAssetManifest(context); + + // Check if asset exists in the asset bundle + final assetExists = bundle.contains(_assetPath); + + FiatIcon._assetExistenceCache[_assetPath] = assetExists; + + return assetExists; + }); + } + + Future> _loadAssetManifest(BuildContext context) async { + String manifestContent = + await DefaultAssetBundle.of(context).loadString('AssetManifest.json'); + Map manifestMap = json.decode(manifestContent); + return manifestMap.keys.toSet(); + } + + @override + Widget build(BuildContext context) { + return Card( + shape: const CircleBorder(), + clipBehavior: Clip.antiAlias, + elevation: 0, + child: Container( + alignment: Alignment.center, + width: 36, + child: (_assetExists == true) + ? Image.asset( + '${FiatIcon._fiatAssetsFolder}/${widget.symbol.toLowerCase()}.webp', + key: Key(widget.symbol), + filterQuality: FilterQuality.high, + ) + : Icon( + Icons.attach_money_outlined, + size: 24, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/lib/views/fiat/fiat_inputs.dart b/lib/views/fiat/fiat_inputs.dart new file mode 100644 index 0000000000..74e1744543 --- /dev/null +++ b/lib/views/fiat/fiat_inputs.dart @@ -0,0 +1,552 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:web_dex/bloc/fiat/base_fiat_provider.dart'; +import 'package:web_dex/bloc/fiat/fiat_repository.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin_utils.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/coin_icon.dart'; +import 'package:web_dex/views/fiat/fiat_icon.dart'; + +class FiatInputs extends StatefulWidget { + final Function(Currency, Currency) onUpdate; + final Function(String?) onFiatAmountUpdate; + final Currency initialFiat; + final Currency initialCoin; + final Map? selectedPaymentMethodPrice; + final bool isLoggedIn; + final String? receiveAddress; + final double? fiatMinAmount; + final double? fiatMaxAmount; + final String? boundariesError; + + const FiatInputs({ + required this.onUpdate, + required this.onFiatAmountUpdate, + required this.initialFiat, + required this.initialCoin, + required this.receiveAddress, + required this.isLoggedIn, + this.selectedPaymentMethodPrice, + this.fiatMinAmount, + this.fiatMaxAmount, + this.boundariesError, + }); + + @override + FiatInputsState createState() => FiatInputsState(); +} + +class FiatInputsState extends State { + TextEditingController fiatController = TextEditingController(); + + late Currency selectedFiat; + late Currency selectedCoin; + List fiatList = []; + List coinList = []; + + // As part of refactoring, we need to move this to a bloc state. In this + // instance, we wanted to show the loading indicator in the parent widget + // but that's not possible with the current implementation. + bool get isLoading => fiatList.length < 2 || coinList.length < 2; + + @override + void dispose() { + fiatController.dispose(); + + super.dispose(); + } + + @override + void initState() { + super.initState(); + setState(() { + selectedFiat = widget.initialFiat; + selectedCoin = widget.initialCoin; + fiatList = [widget.initialFiat]; + coinList = [widget.initialCoin]; + }); + initFiatList(); + initCoinList(); + } + + void initFiatList() async { + final list = await fiatRepository.getFiatList(); + if (mounted) { + setState(() { + fiatList = list; + }); + } + } + + void initCoinList() async { + final list = await fiatRepository.getCoinList(); + if (mounted) { + setState(() { + coinList = list; + }); + } + } + + void updateParent() { + widget.onUpdate( + selectedFiat, + selectedCoin, + ); + } + + void changeFiat(Currency? newValue) { + if (newValue == null) return; + + if (mounted) { + setState(() { + selectedFiat = newValue; + }); + } + updateParent(); + } + + void changeCoin(Currency? newValue) { + if (newValue == null) return; + + if (mounted) { + setState(() { + selectedCoin = newValue; + }); + } + updateParent(); + } + + void fiatAmountChanged(String? newValue) { + setState(() {}); + widget.onFiatAmountUpdate(newValue); + } + + @override + Widget build(BuildContext context) { + final priceInfo = widget.selectedPaymentMethodPrice; + + final coinAmount = priceInfo != null && priceInfo.isNotEmpty + ? priceInfo['coin_amount'] + : null; + final fiatListLoading = fiatList.length <= 1; + final coinListLoading = coinList.length <= 1; + + final boundariesString = widget.fiatMaxAmount == null && + widget.fiatMinAmount == null + ? '' + : '(${widget.fiatMinAmount ?? '1'} - ${widget.fiatMaxAmount ?? '∞'})'; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomFiatInputField( + controller: fiatController, + hintText: '${LocaleKeys.enterAmount.tr()} $boundariesString', + onTextChanged: fiatAmountChanged, + label: Text(LocaleKeys.spend.tr()), + assetButton: _buildCurrencyItem( + disabled: fiatListLoading, + currency: selectedFiat, + icon: FiatIcon( + key: Key('fiat_icon_${selectedFiat.symbol}'), + symbol: selectedFiat.symbol, + ), + onTap: () => _showAssetSelectionDialog('fiat'), + isListTile: false, + ), + inputError: widget.boundariesError, + ), + AnimatedContainer( + duration: const Duration(milliseconds: 00), + height: widget.boundariesError == null ? 0 : 8, + ), + Card( + margin: const EdgeInsets.all(0), + color: Theme.of(context).colorScheme.onSurface, + child: ListTile( + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + subtitle: Padding( + padding: const EdgeInsets.only(top: 6.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(LocaleKeys.youReceive.tr()), + Text( + fiatController.text.isEmpty || priceInfo == null + ? '0.00' + : coinAmount ?? LocaleKeys.unknown.tr(), + style: Theme.of(context) + .textTheme + .headlineMedium + ?.copyWith(fontSize: 24), + ), + ], + ), + const SizedBox(width: 12), + SizedBox( + height: 48, + child: _buildCurrencyItem( + disabled: coinListLoading, + currency: selectedCoin, + icon: Icon(_getDefaultAssetIcon('coin')), + onTap: () => _showAssetSelectionDialog('coin'), + isListTile: false, + ), + ), + ], + ), + ), + ), + ), + if (widget.isLoggedIn) ...[ + const SizedBox(height: 16), + AddressBar(receiveAddress: widget.receiveAddress), + ], + ], + ); + } + + IconData _getDefaultAssetIcon(String type) { + return type == 'fiat' ? Icons.attach_money : Icons.monetization_on; + } + + void _showAssetSelectionDialog(String type) { + final isFiat = type == 'fiat'; + List itemList = isFiat ? fiatList : coinList; + final icon = Icon(_getDefaultAssetIcon(type)); + Function(Currency) onItemSelected = isFiat ? changeFiat : changeCoin; + + _showSelectionDialog( + context: context, + title: isFiat ? LocaleKeys.selectFiat.tr() : LocaleKeys.selectCoin.tr(), + itemList: itemList, + icon: icon, + onItemSelected: onItemSelected, + ); + } + + void _showSelectionDialog({ + required BuildContext context, + required String title, + required List itemList, + required Widget icon, + required Function(Currency) onItemSelected, + }) { + showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: Text(title), + content: SizedBox( + width: 450, + child: ListView.builder( + shrinkWrap: true, + itemCount: itemList.length, + itemBuilder: (BuildContext context, int index) { + final item = itemList[index]; + return _buildCurrencyItem( + disabled: false, + currency: item, + icon: icon, + onTap: () { + onItemSelected(item); + Navigator.of(context).pop(); + }, + isListTile: true, + ); + }, + ), + ), + ); + }, + ); + } + + Widget _buildCurrencyItem({ + required bool disabled, + required Currency currency, + required Widget icon, + required VoidCallback onTap, + required bool isListTile, + }) { + return FutureBuilder( + future: currency.isFiat + ? Future.value(true) + : checkIfAssetExists(currency.symbol), + builder: (context, snapshot) { + final assetExists = snapshot.connectionState == ConnectionState.done + ? snapshot.data ?? false + : null; + return isListTile + ? _buildListTile( + currency: currency, + icon: icon, + assetExists: assetExists, + onTap: onTap, + ) + : _buildButton( + enabled: !disabled, + currency: currency, + icon: icon, + assetExists: assetExists, + onTap: onTap, + ); + }, + ); + } + + Widget _getAssetIcon({ + required Currency currency, + required Widget icon, + bool? assetExists, + required VoidCallback onTap, + }) { + double size = 36.0; + + if (currency.isFiat) { + return FiatIcon(symbol: currency.symbol); + } + + if (assetExists != null && assetExists) { + return CoinIcon(currency.symbol, size: size); + } else { + return icon; + } + } + + Widget _buildListTile({ + required Currency currency, + required Widget icon, + bool? assetExists, + required VoidCallback onTap, + }) { + return ListTile( + leading: _getAssetIcon( + currency: currency, + icon: icon, + assetExists: assetExists, + onTap: onTap, + ), + title: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + '${currency.name}${currency.chainType != null ? ' (${getCoinTypeName(currency.chainType!)})' : ''}', + ), + Text(currency.symbol), + ], + ), + onTap: onTap, + ); + } + + Color get foregroundColor => Theme.of(context).colorScheme.onSurfaceVariant; + + Widget _buildButton({ + required bool enabled, + required Currency? currency, + required Widget icon, + bool? assetExists, + required VoidCallback onTap, + }) { + // TODO: Refactor so that [Currency] holds an enum for fiat/coin or create + // a separate class for fiat/coin that extend the same base class. + final isFiat = currency?.isFiat ?? false; + + return FilledButton.icon( + onPressed: enabled ? onTap : null, + label: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.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.withOpacity(0.5), + ), + ), + if (!isFiat && currency != null) + Text( + currency.chainType != null + ? getCoinTypeName(currency.chainType!) + : '', + style: DefaultTextStyle.of(context).style.copyWith( + color: enabled + ? foregroundColor.withOpacity(0.5) + : foregroundColor.withOpacity(0.25), + ), + ), + ], + ), + const SizedBox(width: 4), + Icon( + Icons.keyboard_arrow_down, + size: 28, + color: foregroundColor.withOpacity(enabled ? 1 : 0.5), + ), + ], + ), + style: (Theme.of(context).filledButtonTheme.style ?? const ButtonStyle()) + .copyWith( + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.onSurface), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(vertical: 0, horizontal: 0), + ), + shape: WidgetStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + icon: currency == null + ? Icon(_getDefaultAssetIcon(isFiat ? 'fiat' : 'coin')) + : _getAssetIcon( + currency: currency, + icon: icon, + assetExists: assetExists, + onTap: onTap, + ), + ); + } +} + +class AddressBar extends StatelessWidget { + const AddressBar({ + super.key, + required this.receiveAddress, + }); + + final String? receiveAddress; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: Card( + child: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + onTap: () => copyToClipBoard(context, receiveAddress!), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + if (receiveAddress != null && receiveAddress!.isNotEmpty) + const Icon(Icons.copy, size: 16) + else + const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + truncateMiddleSymbols(receiveAddress ?? ''), + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class CustomFiatInputField extends StatelessWidget { + final TextEditingController controller; + final String hintText; + final Widget? label; + final Function(String?) onTextChanged; + final bool readOnly; + final Widget assetButton; + final String? inputError; + + const CustomFiatInputField({ + required this.controller, + required this.hintText, + required this.onTextChanged, + this.label, + this.readOnly = false, + required this.assetButton, + this.inputError, + }); + + @override + Widget build(BuildContext context) { + final textColor = Theme.of(context).colorScheme.onSurfaceVariant; + + final inputStyle = Theme.of(context).textTheme.headlineLarge?.copyWith( + fontSize: 18, + fontWeight: FontWeight.w300, + color: textColor, + letterSpacing: 1.1, + ); + + InputDecoration inputDecoration = InputDecoration( + label: label, + labelStyle: inputStyle, + fillColor: Theme.of(context).colorScheme.onSurface, + floatingLabelStyle: + Theme.of(context).inputDecorationTheme.floatingLabelStyle, + floatingLabelBehavior: FloatingLabelBehavior.always, + contentPadding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), + hintText: hintText, + border: const OutlineInputBorder( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(4), + topLeft: Radius.circular(4), + bottomRight: Radius.circular(18), + topRight: Radius.circular(18), + ), + ), + errorText: inputError, + errorMaxLines: 1, + helperText: '', + ); + + return Stack( + clipBehavior: Clip.none, + alignment: Alignment.centerRight, + children: [ + TextField( + autofocus: true, + controller: controller, + style: inputStyle, + decoration: inputDecoration, + readOnly: readOnly, + onChanged: onTextChanged, + inputFormatters: [FilteringTextInputFormatter.allow(numberRegExp)], + keyboardType: const TextInputType.numberWithOptions(decimal: true), + ), + Positioned( + right: 16, + bottom: 26, + top: 2, + child: assetButton, + ), + ], + ); + } +} diff --git a/lib/views/fiat/fiat_page.dart b/lib/views/fiat/fiat_page.dart new file mode 100644 index 0000000000..bd6052fa75 --- /dev/null +++ b/lib/views/fiat/fiat_page.dart @@ -0,0 +1,175 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc_state.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/router/state/fiat_state.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +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/fiat/fiat_form.dart'; + +class FiatPage extends StatefulWidget { + const FiatPage() : super(key: const Key('fiat-page')); + + @override + State createState() => _FiatPageState(); +} + +class _FiatPageState extends State with TickerProviderStateMixin { + int _activeTabIndex = 0; + bool _showSwap = false; + + @override + void initState() { + routingState.fiatState.addListener(_onRouteChange); + super.initState(); + } + + @override + void dispose() { + routingState.fiatState.removeListener(_onRouteChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state.mode == AuthorizeMode.noLogin) { + setState(() { + _activeTabIndex = 0; + }); + } + }, + child: _showSwap ? _buildTradingDetails() : _buildFiatPage(), + ); + } + + Widget _buildTradingDetails() { + return TradingDetails( + uuid: routingState.fiatState.uuid, + ); + } + + Widget _buildFiatPage() { + return PageLayout( + content: Expanded( + child: Container( + constraints: const BoxConstraints(maxWidth: 650), + margin: isMobile ? const EdgeInsets.only(top: 14) : null, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: _backgroundColor(context), + borderRadius: BorderRadius.circular(18.0), + ), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + // TODO: Future feature to show fiat purchase history. Until then, + // we'll hide the tabs since only the first one is used. + // ConstrainedBox( + // constraints: + // BoxConstraints(maxWidth: theme.custom.dexFormWidth), + // child: HiddenWithoutWallet( + // child: FiatTabBar( + // currentTabIndex: _activeTabIndex, + // onTabClick: _setActiveTab, + // ), + // ), + // ), + Flexible( + child: _TabContent( + activeTabIndex: _activeTabIndex, + onCheckoutComplete: _onCheckoutComplete, + ), + ), + ], + ), + ), + ), + ); + } + + // Will be used in the future for switching between tabs when we implement + // the purchase history tab. + // void _setActiveTab(int i) { + // setState(() { + // _activeTabIndex = i; + // }); + // } + + Color? _backgroundColor(BuildContext context) { + if (isMobile) { + final ThemeMode mode = theme.mode; + return mode == ThemeMode.dark ? null : Theme.of(context).cardColor; + } + return null; + } + + void _onRouteChange() { + setState(() { + _showSwap = routingState.fiatState.action == FiatAction.tradingDetails; + }); + } + + void _onCheckoutComplete({required bool isSuccess}) { + if (isSuccess) { + // In the future, we will navigate to the purchase history tab when the + // purchase is complete. + // _setActiveTab(1); + } + } +} + +class _TabContent extends StatelessWidget { + const _TabContent({ + required int activeTabIndex, + required this.onCheckoutComplete, + // ignore: unused_element + super.key, + }) : _activeTabIndex = activeTabIndex; + + // TODO: Remove this when we have a proper bloc for this page + final Function({required bool isSuccess}) onCheckoutComplete; + + final int _activeTabIndex; + + @override + Widget build(BuildContext context) { + final List tabContents = [ + FiatForm(onCheckoutComplete: onCheckoutComplete), + Padding( + padding: const EdgeInsets.only(top: 20), + child: InProgressList( + filter: _fiatSwapsFilter, + onItemClick: _onSwapItemClick, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 20), + child: HistoryList( + filter: _fiatSwapsFilter, + onItemClick: _onSwapItemClick, + ), + ), + ]; + + return tabContents[_activeTabIndex]; + } + + void _onSwapItemClick(Swap swap) { + routingState.fiatState.setDetailsAction(swap.uuid); + } + + bool _fiatSwapsFilter(Swap swap) { + return abbr2Ticker(swap.sellCoin) == abbr2Ticker(swap.buyCoin); + } +} diff --git a/lib/views/fiat/fiat_payment_method.dart b/lib/views/fiat/fiat_payment_method.dart new file mode 100644 index 0000000000..73bc9de1dc --- /dev/null +++ b/lib/views/fiat/fiat_payment_method.dart @@ -0,0 +1,110 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class FiatPaymentMethod extends StatefulWidget { + final String? fiatAmount; + final Map paymentMethodData; + final Map? selectedPaymentMethod; + final Function(Map) onSelect; + + const FiatPaymentMethod({ + required this.fiatAmount, + required this.paymentMethodData, + required this.selectedPaymentMethod, + required this.onSelect, + super.key, + }); + + @override + FiatPaymentMethodState createState() => FiatPaymentMethodState(); +} + +class FiatPaymentMethodState extends State { + @override + Widget build(BuildContext context) { + bool isSelected = widget.selectedPaymentMethod != null && + widget.selectedPaymentMethod!['id'] == widget.paymentMethodData['id']; + + final priceInfo = widget.paymentMethodData['price_info']; + + final relativePercent = + widget.paymentMethodData['relative_percent'] as double?; + + final isBestOffer = relativePercent == null; + + return InkWell( + onTap: () { + widget.onSelect(widget.paymentMethodData); + }, + borderRadius: BorderRadius.circular(8), + child: Card( + margin: const EdgeInsets.all(0), + color: Theme.of(context).colorScheme.onSurface, + elevation: 4, + clipBehavior: Clip.antiAlias, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: BorderSide( + color: Theme.of(context) + .primaryColor + .withOpacity(isSelected ? 1 : 0.25)), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Center( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(width: 32, child: providerLogo), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${widget.paymentMethodData['name']}', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + Text( + widget.paymentMethodData['provider_id'], + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + const SizedBox(width: 8), + if (priceInfo != null) + isBestOffer + ? Chip( + label: Text(LocaleKeys.bestOffer.tr()), + backgroundColor: Colors.green, + ) + : Text( + '${(relativePercent * 100).toStringAsFixed(2)}%', + style: Theme.of(context).textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + } + + Widget get providerLogo { + final assetPath = + widget.paymentMethodData['provider_icon_asset_path'] as String; + + //TODO: Additional validation that the asset exists + + return SvgPicture.asset(assetPath, fit: BoxFit.contain); + } +} diff --git a/lib/views/fiat/fiat_tab_bar.dart b/lib/views/fiat/fiat_tab_bar.dart new file mode 100644 index 0000000000..c7ad1f458c --- /dev/null +++ b/lib/views/fiat/fiat_tab_bar.dart @@ -0,0 +1,67 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab_bar.dart'; + +class FiatTabBar extends StatefulWidget { + const FiatTabBar({ + Key? key, + required this.currentTabIndex, + required this.onTabClick, + }) : super(key: key); + final int currentTabIndex; + final Function(int) onTabClick; + + @override + State createState() => _FiatTabBarState(); +} + +class _FiatTabBarState extends State { + final List _listeners = []; + + @override + void initState() { + _onDataChange(null); + + super.initState(); + } + + @override + void dispose() { + _listeners.map((listener) => listener.cancel()); + + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return UiTabBar( + currentTabIndex: widget.currentTabIndex, + tabs: [ + UiTab( + key: const Key('fiat-form-tab'), + text: 'Form', + isSelected: widget.currentTabIndex == 0, + onClick: () => widget.onTabClick(0), + ), + UiTab( + key: const Key('fiat-second-tab'), + text: 'In Progress', + isSelected: widget.currentTabIndex == 1, + onClick: () => widget.onTabClick(1), + ), + UiTab( + key: const Key('fiat-third-tab'), + text: 'History', + isSelected: widget.currentTabIndex == 2, + onClick: () => widget.onTabClick(2), + ), + ], + ); + } + + void _onDataChange(dynamic _) { + if (!mounted) return; + } +} diff --git a/lib/views/main_layout/main_layout.dart b/lib/views/main_layout/main_layout.dart new file mode 100644 index 0000000000..4699a91a54 --- /dev/null +++ b/lib/views/main_layout/main_layout.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc_state.dart'; +import 'package:web_dex/blocs/startup_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/shared/utils/utils.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:komodo_ui_kit/komodo_ui_kit.dart'; + +class MainLayout extends StatefulWidget { + @override + State createState() => _MainLayoutState(); +} + +class _MainLayoutState extends State { + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) async { + await AlphaVersionWarningService().run(); + updateBloc.init(); + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state.mode == AuthorizeMode.noLogin) { + routingState.resetOnLogOut(); + } + }, + child: Scaffold( + key: scaffoldKey, + floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, + appBar: buildAppHeader(), + body: SafeArea(child: _buildAppBody()), + bottomNavigationBar: !isDesktop ? MainMenuBarMobile() : null, + ), + ); + } + + Widget _buildAppBody() { + return StreamBuilder( + initialData: startUpBloc.running, + stream: startUpBloc.outRunning, + builder: (context, snapshot) { + log('_LayoutWrapperState.build([context]) StreamBuilder: $snapshot'); + if (!snapshot.hasData) { + return const Center(child: UiSpinner()); + } + + return MainLayoutRouter(); + }); + } +} 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 new file mode 100644 index 0000000000..56a1fd1767 --- /dev/null +++ b/lib/views/market_maker_bot/add_market_maker_bot_trade_button.dart @@ -0,0 +1,35 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/system_health/system_health_bloc.dart'; +import 'package:web_dex/bloc/system_health/system_health_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class AddMarketMakerBotTradeButton extends StatelessWidget { + const AddMarketMakerBotTradeButton({ + super.key, + required this.onPressed, + this.enabled = true, + }); + + final bool enabled; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, systemHealthState) { + return Opacity( + opacity: !enabled ? 0.8 : 1, + child: UiPrimaryButton( + key: const Key('make-order-button'), + text: LocaleKeys.makeOrder.tr(), + onPressed: !enabled ? null : () => onPressed(), + height: 40, + ), + ); + }, + ); + } +} diff --git a/lib/views/market_maker_bot/animated_bot_status_indicator.dart b/lib/views/market_maker_bot/animated_bot_status_indicator.dart new file mode 100644 index 0000000000..2845f5a02a --- /dev/null +++ b/lib/views/market_maker_bot/animated_bot_status_indicator.dart @@ -0,0 +1,112 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_status.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class AnimatedBotStatusIndicator extends StatefulWidget { + final MarketMakerBotStatus status; + + const AnimatedBotStatusIndicator({Key? key, required this.status}) + : super(key: key); + + @override + State createState() => + _AnimatedBotStatusIndicatorState(); +} + +class _AnimatedBotStatusIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 1500), + vsync: this, + )..repeat(reverse: true); + } + + @override + void didUpdateWidget(AnimatedBotStatusIndicator oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.status != oldWidget.status) { + _controller.reset(); + _controller.repeat(reverse: true); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _controller, + builder: (_, child) { + return Container( + width: 16, + height: 16, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _getStatusColor(widget.status) + .withOpacity(_getOpacity(widget.status)), + ), + ); + }, + ), + const SizedBox(width: 8), + Text( + widget.status.text, + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ); + } + + double _getOpacity(MarketMakerBotStatus status) { + switch (status) { + case MarketMakerBotStatus.starting: + case MarketMakerBotStatus.stopping: + return 0.3 + (_controller.value * 0.7); + case MarketMakerBotStatus.running: + return 1.0; + case MarketMakerBotStatus.stopped: + return 0.5; + } + } +} + +Color _getStatusColor(MarketMakerBotStatus status) { + switch (status) { + case MarketMakerBotStatus.starting: + return Colors.yellow; + case MarketMakerBotStatus.stopping: + return Colors.orange; + case MarketMakerBotStatus.running: + return Colors.green; + case MarketMakerBotStatus.stopped: + return Colors.red; + } +} + +extension on MarketMakerBotStatus { + String get text { + switch (this) { + case MarketMakerBotStatus.running: + return LocaleKeys.mmBotStatusRunning.tr(); + case MarketMakerBotStatus.stopped: + return LocaleKeys.mmBotStatusStopped.tr(); + case MarketMakerBotStatus.starting: + return LocaleKeys.mmBotStatusStarting.tr(); + case MarketMakerBotStatus.stopping: + return LocaleKeys.mmBotStatusStopping.tr(); + } + } +} diff --git a/lib/views/market_maker_bot/buy_coin_select_dropdown.dart b/lib/views/market_maker_bot/buy_coin_select_dropdown.dart new file mode 100644 index 0000000000..b02554f7bd --- /dev/null +++ b/lib/views/market_maker_bot/buy_coin_select_dropdown.dart @@ -0,0 +1,58 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/forms/coin_select_input.dart'; +import 'package:web_dex/model/forms/coin_trade_amount_input.dart'; +import 'package:web_dex/views/market_maker_bot/coin_selection_and_amount_input.dart'; +import 'package:web_dex/views/market_maker_bot/coin_trade_amount_form_field.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_form_error_message_extensions.dart'; + +class BuyCoinSelectDropdown extends StatelessWidget { + const BuyCoinSelectDropdown({ + required this.buyCoin, + required this.buyAmount, + required this.coins, + this.onItemSelected, + super.key, + }); + final CoinSelectInput buyCoin; + final CoinTradeAmountInput buyAmount; + final List coins; + final Function(Coin?)? onItemSelected; + + @override + Widget build(BuildContext context) { + return CoinSelectionAndAmountInput( + coins: coins, + title: LocaleKeys.buy.tr(), + selectedCoin: buyCoin.value, + onItemSelected: onItemSelected, + trailing: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + CoinTradeAmountFormField( + coin: buyCoin.value, + initialValue: buyAmount.value, + isEnabled: false, + errorText: buyCoin.displayError?.text(buyCoin.value), + ), + Padding( + padding: const EdgeInsets.only(right: 18), + child: Text( + '* ${LocaleKeys.mmBotFirstTradeEstimate.tr()}', + style: TextStyle( + color: dexPageColors.inactiveText, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/market_maker_bot/coin_search_dropdown.dart b/lib/views/market_maker_bot/coin_search_dropdown.dart new file mode 100644 index 0000000000..ff7c5d807c --- /dev/null +++ b/lib/views/market_maker_bot/coin_search_dropdown.dart @@ -0,0 +1,468 @@ +import 'dart:async'; + +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/coin_icon.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_body.dart'; +import 'package:web_dex/views/dex/simple/form/tables/orders_table/grouped_list_view.dart'; + +class CoinSelectItem { + CoinSelectItem({ + required this.name, + required this.coinId, + required this.coinProtocol, + this.leading, + this.trailing, + this.title, + }); + + final String name; + final String coinId; + final String coinProtocol; + + /// The widget to display on the right side of the list item. + /// + /// E.g. to show balance or price increase percentage. + /// + /// If null, nothing will be displayed. + final Widget? trailing; + + /// The widget to display on the left side of the list item. + /// + /// E.g. to show the coin icon. + /// + /// If null, the CoinIcon will be displayed with a size of 20. + final Widget? leading; + + /// The widget to display the title of the list item. + /// + /// If null, the [name] will be displayed. + final Widget? title; +} + +bool doesCoinMatchSearch(String searchQuery, CoinSelectItem item) { + final lowerCaseQuery = searchQuery.toLowerCase(); + final nameContains = item.name.toLowerCase().contains(lowerCaseQuery); + final idMatches = item.coinId.toLowerCase().contains(lowerCaseQuery); + final protocolMatches = + item.coinProtocol.toLowerCase().contains(lowerCaseQuery); + + return nameContains || idMatches || protocolMatches; +} + +class CryptoSearchDelegate extends SearchDelegate { + CryptoSearchDelegate(this.items); + + final Iterable items; + + @override + List buildActions(BuildContext context) { + return [ + IconButton(icon: const Icon(Icons.clear), onPressed: () => query = ''), + ]; + } + + @override + Widget buildLeading(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, null), + ); + } + + @override + Widget buildResults(BuildContext context) { + final results = + items.where((item) => doesCoinMatchSearch(query, item)).toList(); + + return ListView.builder( + itemCount: results.length, + itemBuilder: (context, index) { + final item = results[index]; + return ListTile( + leading: item.leading ?? CoinIcon(item.coinId), + title: item.title ?? Text(item.name), + trailing: item.trailing, + onTap: () => close(context, item), + ); + }, + ); + } + + @override + Widget buildSuggestions(BuildContext context) { + final suggestions = + items.where((item) => doesCoinMatchSearch(query, item)).toList(); + + return ListView.builder( + itemCount: suggestions.length, + itemBuilder: (context, index) { + final item = suggestions[index]; + return ListTile( + leading: item.leading ?? CoinIcon(item.coinId), + title: item.title ?? Text(item.name), + trailing: item.trailing, + onTap: () => query = item.name, + ); + }, + ); + } +} + +Future showCoinSearch( + BuildContext context, { + required List coins, + + /// The builder function to create a custom list item + CoinSelectItem Function(String coinId)? customCoinItemBuilder, + double maxHeight = 330, +}) async { + final isMobile = MediaQuery.of(context).size.width < 600; + + final items = coins.map( + (coin) => + customCoinItemBuilder?.call(coin) ?? _defaultCoinItemBuilder(coin), + ); + + if (isMobile) { + return await showSearch( + context: context, + delegate: CryptoSearchDelegate(items), + ); + } else { + return await showDropdownSearch(context, items, maxHeight: maxHeight); + } +} + +CoinSelectItem _defaultCoinItemBuilder(String coin) { + return CoinSelectItem( + name: coin, + coinId: coin, + coinProtocol: coin, + leading: CoinIcon(coin), + ); +} + +OverlayEntry? _overlayEntry; +Completer? _completer; + +Future showDropdownSearch( + BuildContext context, + Iterable items, { + double maxHeight = 330, +}) async { + final renderBox = context.findRenderObject() as RenderBox; + final offset = renderBox.localToGlobal(Offset.zero); + + void clearOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + _completer = null; + } + + void onItemSelected(CoinSelectItem? item) { + _completer?.complete(item); + clearOverlay(); + } + + clearOverlay(); + + _completer = Completer(); + _overlayEntry = OverlayEntry( + builder: (context) { + return GestureDetector( + onTap: () => onItemSelected(null), + behavior: HitTestBehavior.translucent, + child: Stack( + children: [ + Positioned( + left: offset.dx, + top: offset.dy + renderBox.size.height, + width: renderBox.size.width, + child: _DropdownSearch( + items: items, + onSelected: onItemSelected, + maxHeight: maxHeight, + ), + ), + ], + ), + ); + }, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + Overlay.of(context).insert(_overlayEntry!); + }); + + return _completer!.future; +} + +class _DropdownSearch extends StatefulWidget { + final Iterable items; + final ValueChanged onSelected; + final double maxHeight; + + const _DropdownSearch({ + required this.items, + required this.onSelected, + this.maxHeight = 300, + }); + + @override + State<_DropdownSearch> createState() => __DropdownSearchState(); +} + +class __DropdownSearchState extends State<_DropdownSearch> { + late Iterable filteredItems; + String query = ''; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + filteredItems = widget.items; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focusNode.requestFocus(); + } + }); + } + + void updateSearchQuery(String newQuery) { + setState(() { + query = newQuery; + filteredItems = widget.items.where( + (item) => doesCoinMatchSearch(query, item), + ); + }); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + focusNode: _focusNode, + autofocus: true, + decoration: InputDecoration( + hintText: 'Search', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.search), + ), + onChanged: updateSearchQuery, + ), + ), + filteredItems.isNotEmpty + ? GroupedListView( + items: filteredItems.toList(), + onSelect: widget.onSelected, + maxHeight: widget.maxHeight, + ) + : Padding( + padding: const EdgeInsets.all(16), + child: Text(LocaleKeys.nothingFound.tr()), + ), + ], + ), + ); + } +} + +class CoinDropdown extends StatefulWidget { + final List items; + final Widget? child; + final Function(CoinSelectItem) onItemSelected; + + const CoinDropdown({ + super.key, + required this.items, + required this.onItemSelected, + this.child, + }); + + @override + State createState() => _CoinDropdownState(); +} + +class _CoinDropdownState extends State { + CoinSelectItem? selectedItem; + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _overlayEntry; + + void _showSearch(BuildContext context) async { + _overlayEntry = _createOverlayEntry(context); + Overlay.of(context).insert(_overlayEntry!); + } + + OverlayEntry _createOverlayEntry(BuildContext context) { + RenderBox renderBox = context.findRenderObject() as RenderBox; + Size size = renderBox.size; + Offset offset = renderBox.localToGlobal(Offset.zero); + + final screenSize = MediaQuery.of(context).size; + final availableHeightBelow = screenSize.height - offset.dy - size.height; + final availableHeightAbove = offset.dy; + + final showAbove = availableHeightBelow < widget.items.length * 48 && + availableHeightAbove > availableHeightBelow; + + final dropdownHeight = + (showAbove ? availableHeightAbove : availableHeightBelow) + .clamp(100.0, 330.0); + + return OverlayEntry( + builder: (context) { + return GestureDetector( + onTap: () { + _overlayEntry?.remove(); + _overlayEntry = null; + }, + behavior: HitTestBehavior.translucent, + child: Stack( + children: [ + Positioned( + width: size.width, + left: offset.dx, + top: showAbove + ? offset.dy - dropdownHeight + : offset.dy + size.height, + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: Offset(0.0, showAbove ? -size.height : 0.0), + child: Material( + elevation: 4.0, + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: dropdownHeight, + ), + child: _DropdownSearch( + items: widget.items, + onSelected: (selected) { + if (selected == null) return; + setState(() { + selectedItem = selected; + _overlayEntry?.remove(); + _overlayEntry = null; + }); + widget.onItemSelected(selected); + }, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + final coin = + selectedItem == null ? null : coinsBloc.getCoin(selectedItem!.coinId); + + return CompositedTransformTarget( + link: _layerLink, + child: InkWell( + onTap: () => _showSearch(context), + child: widget.child ?? + Padding( + padding: const EdgeInsets.only(left: 15), + child: CoinItemBody(coin: coin), + ), + ), + ); + } +} + +// Example usage + +// void main() { +// runApp(const MyApp()); +// } + +// class MyApp extends StatelessWidget { +// const MyApp({super.key}); + +// @override +// Widget build(BuildContext context) { +// final items = [ +// CoinSelectItem( +// name: "KMD", +// symbol: "KMD", +// changePercent: 2.9, +// trailing: const Text('+2.9%', style: TextStyle(color: Colors.green)), +// ), +// CoinSelectItem( +// name: "SecondLive", +// symbol: "SL", +// changePercent: 322.9, +// trailing: const Text('+322.9%', style: TextStyle(color: Colors.green)), +// ), +// CoinSelectItem( +// name: "KiloEx", +// symbol: "KE", +// changePercent: -2.09, +// trailing: const Text('-2.09%', style: TextStyle(color: Colors.red)), +// ), +// CoinSelectItem( +// name: "Native", +// symbol: "NT", +// changePercent: 225.9, +// trailing: const Text('+225.9%', style: TextStyle(color: Colors.green)), +// ), +// CoinSelectItem( +// name: "XY Finance", +// symbol: "XY", +// changePercent: 62.9, +// trailing: const Text('+62.9%', style: TextStyle(color: Colors.green)), +// ), +// CoinSelectItem( +// name: "KMD", +// symbol: "KMD", +// changePercent: 2.9, +// trailing: const Text('+2.9%', style: TextStyle(color: Colors.green)), +// ), +// ]; + +// return MaterialApp( +// home: Scaffold( +// appBar: AppBar(title: const Text('Crypto Selector')), +// body: Padding( +// padding: const EdgeInsets.all(16.0), +// child: CoinDropdown( +// items: items, +// onItemSelected: (item) { +// // Handle item selection +// print('Selected item: ${item.name}'); +// }, +// ), +// ), +// ), +// ); +// } +// } 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 new file mode 100644 index 0000000000..e9a2f9a2c3 --- /dev/null +++ b/lib/views/market_maker_bot/coin_selection_and_amount_input.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/coin_icon.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'; +import 'package:web_dex/shared/widgets/coin_item/coin_logo.dart'; +import 'package:web_dex/views/dex/common/front_plate.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_form_group_header.dart'; +import 'package:web_dex/views/dex/simple/form/tables/table_utils.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/balance_text.dart'; +import 'package:web_dex/views/dex/simple/form/taker/coin_item/coin_name_and_protocol.dart'; +import 'package:web_dex/views/market_maker_bot/coin_search_dropdown.dart'; + +class CoinSelectionAndAmountInput extends StatefulWidget { + const CoinSelectionAndAmountInput({ + super.key, + required this.coins, + this.selectedCoin, + required this.title, + this.trailing, + this.onItemSelected, + this.useFrontPlate = true, + }); + + final Coin? selectedCoin; + final List coins; + final String title; + final Widget? trailing; + final Function(Coin?)? onItemSelected; + final bool useFrontPlate; + + @override + State createState() => + _CoinSelectionAndAmountInputState(); +} + +class _CoinSelectionAndAmountInputState + extends State { + late List _items; + + @override + void initState() { + super.initState(); + _prepareItems(); + } + + @override + void didUpdateWidget(CoinSelectionAndAmountInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.coins != oldWidget.coins) { + _prepareItems(); + } + } + + void _prepareItems() { + _items = prepareCoinsForTable(widget.coins, null) + .map( + (coin) => CoinSelectItem( + name: coin.name, + coinId: coin.abbr, + coinProtocol: coin.typeNameWithTestnet, + trailing: BalanceText(coin), + title: CoinItemBody(coin: coin, size: CoinItemSize.large), + leading: CoinIcon( + coin.abbr, + size: CoinItemSize.large.coinLogo, + ), + ), + ) + .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, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CoinLogo(coin: widget.selectedCoin), + const SizedBox(width: 9), + CoinNameAndProtocol(widget.selectedCoin, true), + const SizedBox(width: 9), + ], + ), + const SizedBox(width: 5), + Expanded(child: widget.trailing ?? const SizedBox.shrink()), + ], + ), + ), + ], + ); + + if (widget.useFrontPlate) { + content = FrontPlate(child: content); + } + + return CoinDropdown( + items: _items, + onItemSelected: (item) => + widget.onItemSelected?.call(coinsBloc.getCoin(item.coinId)), + child: content, + ); + } +} diff --git a/lib/views/market_maker_bot/coin_trade_amount_form_field.dart b/lib/views/market_maker_bot/coin_trade_amount_form_field.dart new file mode 100644 index 0000000000..f9bfc78443 --- /dev/null +++ b/lib/views/market_maker_bot/coin_trade_amount_form_field.dart @@ -0,0 +1,188 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +class CoinTradeAmountFormField extends StatefulWidget { + const CoinTradeAmountFormField({ + super.key, + this.isEnabled = true, + this.coin, + this.initialValue = '', + this.onChanged, + this.errorText, + }); + + final bool isEnabled; + final Coin? coin; + final String initialValue; + final Function(String)? onChanged; + final String? errorText; + + @override + State createState() => + _CoinTradeAmountFormFieldState(); +} + +class _CoinTradeAmountFormFieldState extends State { + final TextEditingController _controller = TextEditingController(); + late VoidCallback _inputChangedListener; + + @override + void initState() { + _inputChangedListener = () => widget.onChanged?.call(_controller.text); + + final value = double.tryParse(widget.initialValue) ?? 0.0; + _controller.text = value.toStringAsFixed(widget.coin?.decimals ?? 8); + _controller.addListener(_inputChangedListener); + + super.initState(); + } + + @override + void dispose() { + _controller..removeListener(_inputChangedListener) + ..dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant CoinTradeAmountFormField oldWidget) { + super.didUpdateWidget(oldWidget); + + final textValue = double.tryParse(_controller.text); + final initialValue = double.tryParse(widget.initialValue); + + final initialValueChanged = oldWidget.initialValue != widget.initialValue; + final textSameAsValue = textValue == initialValue; + + if (initialValueChanged && !textSameAsValue) { + _controller.removeListener(_inputChangedListener); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final value = double.tryParse(widget.initialValue) ?? 0.0; + _controller.text = value.toStringAsFixed(widget.coin?.decimals ?? 8); + _controller.addListener(_inputChangedListener); + } + }); + } + } + + @override + Widget build(BuildContext context) { + final amount = _controller.text.isNotEmpty + ? Rational.parse(_controller.text) + : Rational.zero; + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 250), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Padding( + padding: const EdgeInsets.only(right: 18, top: 1), + child: TradeAmountTextFormField( + key: const Key('maker-sell-amount'), + enabled: widget.isEnabled, + controller: _controller, + ), + ), + Padding( + padding: const EdgeInsets.only(right: 18), + child: TradeAmountFiatPriceText( + key: const Key('maker-sell-amount-fiat'), + coin: widget.coin, + amount: amount, + ), + ), + if (widget.errorText != null) + Padding( + padding: const EdgeInsets.only(right: 18), + child: AutoScrollText( + text: widget.errorText!, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ) + else + const SizedBox(height: 16), + ], + ), + ); + } +} + +class TradeAmountFiatPriceText extends StatelessWidget { + const TradeAmountFiatPriceText({super.key, this.coin, this.amount}); + + final Rational? amount; + final Coin? coin; + + @override + Widget build(BuildContext context) { + return Text( + coin == null + ? r'≈$0' + : getFormattedFiatAmount(coin!.abbr, amount ?? Rational.zero), + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ); + } +} + +class TradeAmountTextFormField extends StatelessWidget { + const TradeAmountTextFormField({ + required this.controller, + super.key, + this.enabled = true, + }); + + final bool enabled; + final TextEditingController controller; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Flexible( + child: SizedBox( + height: 20, + child: TextFormField( + key: const Key('market-maker-bot-amount-input'), + enabled: enabled, + controller: controller, + textInputAction: TextInputAction.done, + textAlign: TextAlign.end, + inputFormatters: currencyInputFormatters, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontSize: 16, + fontWeight: FontWeight.w500, + color: dexPageColors.activeText, + decoration: TextDecoration.none, + ), + decoration: const InputDecoration( + hintText: '0.00', + contentPadding: EdgeInsets.zero, + fillColor: Colors.transparent, + focusColor: Colors.transparent, + hoverColor: Colors.transparent, + ), + ), + ), + ), + Text( + '*', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontFeatures: [const FontFeature.superscripts()], + ), + ), + ], + ); + } +} diff --git a/lib/views/market_maker_bot/important_note.dart b/lib/views/market_maker_bot/important_note.dart new file mode 100644 index 0000000000..903094a530 --- /dev/null +++ b/lib/views/market_maker_bot/important_note.dart @@ -0,0 +1,39 @@ +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/views/dex/common/front_plate.dart'; + +class ImportantNote extends StatelessWidget { + const ImportantNote({ + super.key, + required this.text, + }); + + final String text; + + @override + Widget build(BuildContext context) { + return FrontPlate( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Text( + LocaleKeys.important.tr(), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + Text( + text, + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ), + ); + } +} 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 new file mode 100644 index 0000000000..87f9ea25d2 --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_bot_confirmation_form.dart @@ -0,0 +1,383 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:rational/rational.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/ui/ui_light_button.dart'; +import 'package:web_dex/shared/utils/balances_formatter.dart'; +import 'package:web_dex/shared/utils/formatters.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:web_dex/shared/widgets/segwit_icon.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; +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'; + +class MarketMakerBotConfirmationForm extends StatefulWidget { + const MarketMakerBotConfirmationForm({ + Key? key, + required this.onCreateOrder, + required this.onCancel, + }) : super(key: key); + + final VoidCallback onCancel; + final VoidCallback onCreateOrder; + + @override + State createState() => + _MarketMakerBotConfirmationFormState(); +} + +class _MarketMakerBotConfirmationFormState + extends State { + @override + void initState() { + context + .read() + .add(const MarketMakerConfirmationPreviewRequested()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: isMobile + ? const EdgeInsets.only(top: 18.0) + : const EdgeInsets.only(top: 9.0), + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: BlocBuilder( + builder: (context, state) { + if (state.status == MarketMakerTradeFormStatus.loading) { + return const UiSpinner(); + } + + if (state.buyCoin.value == null || state.sellCoin.value == null) { + return const SizedBox(); + } + + final hasError = state.tradePreImageError != null || + state.status == MarketMakerTradeFormStatus.error; + + return SingleChildScrollView( + key: const Key('maker-order-conformation-scroll'), + controller: ScrollController(), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SelectableText( + LocaleKeys.mmBotFirstTradePreview.tr(), + style: Theme.of(context) + .textTheme + .headlineSmall + ?.copyWith(fontSize: 16), + ), + const SizedBox(height: 37), + ImportantNote( + text: LocaleKeys.mmBotFirstOrderVolume + .tr(args: [state.sellCoin.value?.abbr ?? '']), + ), + const SizedBox(height: 10), + SwapReceiveAmount( + context: context, + coin: state.buyCoin.value!, + amount: state.buyAmount.valueAsRational, + ), + SwapFiatReceivedAmount( + context: context, + sellCoin: state.sellCoin.value!, + sellAmount: state.sellAmount.valueAsRational, + buyCoin: state.buyCoin.value!, + buyAmount: state.buyAmount.valueAsRational, + ), + const SizedBox(height: 23), + SwapSendAmount( + context: context, + coin: state.sellCoin.value!, + amount: state.sellAmount.valueAsRational, + ), + const SizedBox(height: 24), + ExchangeRate( + base: state.sellCoin.value?.abbr, + rel: state.buyCoin.value?.abbr, + rate: state.priceFromUsdWithMarginRational, + ), + const SizedBox(height: 10), + TotalFees(preimage: state.tradePreImage), + const SizedBox(height: 24), + SwapErrorMessage( + errorMessage: state.tradePreImageError + ?.text(state.sellCoin.value, state.buyCoin.value), + context: context, + ), + Flexible( + child: SwapActionButtons( + onCancel: widget.onCancel, + onCreateOrder: hasError ? null : widget.onCreateOrder, + ), + ), + ], + ), + ); + }, + ), + ); + } +} + +class SwapActionButtons extends StatelessWidget { + const SwapActionButtons({ + super.key, + required this.onCancel, + required this.onCreateOrder, + }); + + final VoidCallback onCancel; + final VoidCallback? onCreateOrder; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Flexible( + child: UiLightButton( + onPressed: onCancel, + text: LocaleKeys.back.tr(), + ), + ), + const SizedBox(width: 23), + Flexible( + child: UiPrimaryButton( + key: const Key('market-maker-bot-order-confirm-button'), + onPressed: onCreateOrder, + text: LocaleKeys.confirm.tr(), + ), + ), + ], + ); + } +} + +class SwapErrorMessage extends StatelessWidget { + const SwapErrorMessage({ + super.key, + required String? errorMessage, + required this.context, + }) : _errorMessage = errorMessage; + + final String? _errorMessage; + final BuildContext context; + + @override + Widget build(BuildContext context) { + final String? message = _errorMessage; + if (message == null) return const SizedBox(); + + return Container( + padding: const EdgeInsets.fromLTRB(8, 0, 8, 20), + child: Text( + message, + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.error), + ), + ); + } +} + +class SwapSendAmount extends StatelessWidget { + const SwapSendAmount({ + super.key, + required this.context, + required this.coin, + required this.amount, + }); + + final BuildContext context; + final Coin coin; + final Rational? amount; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric( + vertical: 14, + horizontal: 16, + ), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: theme.custom.subCardBackgroundColor, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + LocaleKeys.swapConfirmationYouSending.tr(), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: theme.custom.dexSubTitleColor, + ), + ), + const SizedBox(height: 10), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + CoinItem(coin: coin, size: CoinItemSize.large), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SelectableText( + formatDexAmt(amount), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14.0, + fontWeight: FontWeight.w500, + ), + ), + SwapFiatSendAmount(coin: coin, amount: amount), + ], + ), + ], + ), + ], + ), + ); + } +} + +class SwapFiatSendAmount extends StatelessWidget { + const SwapFiatSendAmount({ + super.key, + required this.coin, + required this.amount, + }); + + final Coin coin; + final Rational? amount; + + @override + Widget build(BuildContext context) { + if (amount == null) return const SizedBox(); + return Container( + padding: const EdgeInsets.fromLTRB(0, 0, 2, 0), + child: FiatAmount(coin: coin, amount: amount ?? Rational.zero), + ); + } +} + +class SwapFiatReceivedAmount extends StatelessWidget { + const SwapFiatReceivedAmount({ + super.key, + required this.context, + required this.sellCoin, + required this.sellAmount, + required this.buyCoin, + required this.buyAmount, + }); + + final BuildContext context; + final Coin sellCoin; + final Rational? sellAmount; + final Coin buyCoin; + final Rational? buyAmount; + + @override + Widget build(BuildContext context) { + if (sellAmount == null || buyAmount == null) return const SizedBox(); + + 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); + + if (sellAmtFiat < receiveAmtFiat) { + color = theme.custom.increaseColor; + } else if (sellAmtFiat > receiveAmtFiat) { + color = theme.custom.decreaseColor; + } + + if (sellAmtFiat > 0 && receiveAmtFiat > 0) { + percentage = (receiveAmtFiat - sellAmtFiat) * 100 / sellAmtFiat; + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + FiatAmount(coin: buyCoin, amount: buyAmount ?? Rational.zero), + if (percentage != null) + Text( + ' (${percentage > 0 ? '+' : ''}${formatAmt(percentage)}%)', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 11, + color: color, + fontWeight: FontWeight.w200, + ), + ), + ], + ); + } +} + +class SwapReceiveAmount extends StatelessWidget { + const SwapReceiveAmount({ + super.key, + required this.context, + required this.coin, + required this.amount, + }); + + final BuildContext context; + final Coin coin; + final Rational? amount; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + SelectableText( + LocaleKeys.swapConfirmationYouReceive.tr(), + 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( + Coin.normalizeAbbr(coin.abbr), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: theme.custom.balanceColor), + ), + if (coin.mode == CoinMode.segwit) + const Padding( + padding: EdgeInsets.only(left: 4), + child: SegwitIcon(height: 16), + ), + ], + ), + ], + ); + } +} diff --git a/lib/views/market_maker_bot/market_maker_bot_form.dart b/lib/views/market_maker_bot/market_maker_bot_form.dart new file mode 100644 index 0000000000..be6d6affa0 --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_bot_form.dart @@ -0,0 +1,203 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +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/dex_tab_bar/dex_tab_bar_bloc.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/orderbook/order.dart'; +import 'package:web_dex/views/dex/orderbook/orderbook_view.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_confirmation_form.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_form_content.dart'; + +class MarketMakerBotForm extends StatelessWidget { + const MarketMakerBotForm(); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.stage, + builder: (context, formStage) { + if (formStage == MarketMakerTradeFormStage.confirmationRequired) { + return MarketMakerBotConfirmationForm( + onCreateOrder: () => _onCreateOrderPressed(context), + onCancel: () { + context + .read() + .add(const MarketMakerConfirmationPreviewCancelRequested()); + }, + ); + } + + return isMobile + ? const _MakerFormMobileLayout() + : const _MakerFormDesktopLayout(); + }, + ); + } + + void _onCreateOrderPressed(BuildContext context) { + final marketMakerTradeFormBloc = context.read(); + final tradePair = marketMakerTradeFormBloc.state.toTradePairConfig(); + + context + .read() + .add(MarketMakerBotOrderUpdateRequested(tradePair)); + + context.read().add(const TabChanged(1)); + + marketMakerTradeFormBloc.add(const MarketMakerTradeFormClearRequested()); + } +} + +class _MakerFormDesktopLayout extends StatelessWidget { + const _MakerFormDesktopLayout(); + + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // We want to place form in the middle of the screen, + // and orderbook, when shown, should be on the right side + // (leaving the form in the middle) + const Expanded(flex: 3, child: SizedBox.shrink()), + Flexible( + flex: 6, + child: DexScrollbar( + scrollController: scrollController, + isMobile: isMobile, + child: SingleChildScrollView( + key: const Key('maker-form-layout-scroll'), + controller: scrollController, + child: ConstrainedBox( + constraints: + BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: StreamBuilder>( + initialData: coinsBloc.walletCoins, + stream: coinsBloc.outWalletCoins, + builder: (context, snapshot) { + final coins = snapshot.data + ?.where( + (e) => + e != null && + e.usdPrice != null && + e.usdPrice!.price > 0, + ) + .cast() + .toList() ?? + []; + return MarketMakerBotFormContent(coins: coins); + }, + ), + ), + ), + ), + ), + Expanded( + flex: 3, + child: Padding( + padding: const EdgeInsets.only(left: 20), + child: SingleChildScrollView( + controller: ScrollController(), + child: const MarketMakerBotOrderbook(), + ), + ), + ), + ], + ); + } +} + +class _MakerFormMobileLayout extends StatelessWidget { + const _MakerFormMobileLayout(); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + key: const Key('maker-form-layout-scroll'), + controller: ScrollController(), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + StreamBuilder>( + initialData: coinsBloc.walletCoins, + stream: coinsBloc.outWalletCoins, + builder: (context, snapshot) { + final coins = snapshot.data + ?.where( + (e) => + e != null && + e.usdPrice != null && + e.usdPrice!.price > 0, + ) + .cast() + .toList() ?? + []; + return MarketMakerBotFormContent(coins: coins); + }, + ), + const SizedBox(height: 22), + const MarketMakerBotOrderbook(), + ], + ), + ), + ); + } +} + +class MarketMakerBotOrderbook extends StatelessWidget { + const MarketMakerBotOrderbook({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => OrderbookView( + base: state.sellCoin.value, + rel: state.buyCoin.value, + myOrder: _getMyOrder(context, Rational.zero), + onAskClick: (order) => _onAskClick(context, order), + ), + ); + } +} + +Order? _getMyOrder(BuildContext context, Rational? price) { + final state = context.read().state; + final Coin? sellCoin = state.sellCoin.value; + final Coin? buyCoin = state.buyCoin.value; + final Rational sellAmount = + Rational.zero; //Rational.parse(state.sellAmount.value); + + if (sellCoin == null) return null; + if (buyCoin == null) return null; + if (sellAmount == Rational.zero) return null; + if (price == null || price == Rational.zero) return null; + + return Order( + base: sellCoin.abbr, + rel: buyCoin.abbr, + maxVolume: sellAmount, + price: price, + direction: OrderDirection.ask, + uuid: orderPreviewUuid, + ); +} + +void _onAskClick(BuildContext context, Order 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 new file mode 100644 index 0000000000..6edeb75866 --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_bot_form_content.dart @@ -0,0 +1,239 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/ui/ui_light_button.dart'; +import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_wrapper.dart'; +import 'package:web_dex/views/dex/common/form_plate.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_flip_button_overlapper.dart'; +import 'package:web_dex/views/dex/simple/form/common/dex_info_container.dart'; +import 'package:web_dex/views/dex/simple/form/exchange_info/exchange_rate.dart'; +import 'package:web_dex/views/market_maker_bot/add_market_maker_bot_trade_button.dart'; +import 'package:web_dex/views/market_maker_bot/buy_coin_select_dropdown.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/views/market_maker_bot/sell_coin_select_dropdown.dart'; +import 'package:web_dex/views/market_maker_bot/trade_bot_update_interval.dart'; +import 'package:web_dex/views/market_maker_bot/update_interval_dropdown.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; + +class MarketMakerBotFormContent extends StatefulWidget { + const MarketMakerBotFormContent({required this.coins, super.key}); + + final List coins; + + @override + State createState() => + _MarketMakerBotFormContentState(); +} + +class _MarketMakerBotFormContentState extends State { + @override + void initState() { + _setSellCoinToDefaultCoin(); + super.initState(); + } + + @override + void didUpdateWidget(MarketMakerBotFormContent oldWidget) { + if (oldWidget.coins != widget.coins) { + final formBloc = context.read(); + if (formBloc.state.sellCoin.value == null) { + _setSellCoinToDefaultCoin(); + } else { + formBloc.add( + MarketMakerTradeFormSellCoinChanged(formBloc.state.sellCoin.value), + ); + } + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + const keyPrefix = 'market-maker-bot-form'; + + return BlocBuilder( + builder: (context, state) { + return FormPlate( + child: Padding( + padding: const EdgeInsets.fromLTRB(0, 12, 0, 20), + child: Form( + child: Column( + children: [ + DexFlipButtonOverlapper( + offsetTop: 208.0, + onTap: _swapBuyAndSellCoins, + topWidget: SellCoinSelectDropdown( + key: const Key('$keyPrefix-sell-select'), + sellCoin: state.sellCoin, + sellAmount: state.sellAmount, + coins: _coinsWithUsdBalance(widget.coins), + minimumTradeVolume: state.minimumTradeVolume, + maximumTradeVolume: state.maximumTradeVolume, + onItemSelected: _onSelectSellCoin, + onTradeVolumeChanged: _onVolumeRangeChanged, + ), + bottomWidget: BuyCoinSelectDropdown( + key: const Key('$keyPrefix-buy-select'), + buyCoin: state.buyCoin, + buyAmount: state.buyAmount, + coins: _filteredCoinsList(state.sellCoin.value), + onItemSelected: _onBuyCoinSelected, + ), + ), + const SizedBox(height: 16), + DexInfoContainer( + children: [ + PercentageInput( + key: const Key('$keyPrefix-trade-margin'), + label: Text( + '${LocaleKeys.margin.tr()}:', + style: theme.custom.tradingFormDetailsLabel, + ), + initialValue: state.tradeMargin.value, + onChanged: _onTradeMarginChanged, + errorText: state.tradeMargin.displayError + ?.text(maxValue: 1000), + maxIntegerDigits: 4, + maxFractionDigits: 5, + ), + const SizedBox(height: 8), + UpdateIntervalDropdown( + key: const Key('$keyPrefix-update-interval'), + label: Text( + '${LocaleKeys.updateInterval.tr()}:', + style: theme.custom.tradingFormDetailsLabel, + ), + interval: state.updateInterval.interval, + onChanged: _onUpdateIntervalChanged, + ), + const SizedBox(height: 12), + ExchangeRate( + key: const Key('$keyPrefix-exchange-rate'), + rate: state.priceFromUsdWithMarginRational, + base: state.sellCoin.value?.abbr, + rel: state.buyCoin.value?.abbr, + ), + ], + ), + const SizedBox(height: 12), + if (state.tradePreImageError != null) + ImportantNote( + text: state.tradePreImageError?.text( + state.sellCoin.value, + state.buyCoin.value, + ) ?? + '', + ), + const SizedBox(height: 24), + Row( + children: [ + Flexible( + flex: 3, + child: UiLightButton( + key: const Key('$keyPrefix-clear-button'), + text: LocaleKeys.clear.tr(), + onPressed: _onClearFormPressed, + height: 40, + ), + ), + const SizedBox(width: 10), + Flexible( + flex: 7, + child: ConnectWalletWrapper( + key: const Key('$keyPrefix-connect-wallet-button'), + eventType: WalletsManagerEventType.dex, + child: AddMarketMakerBotTradeButton( + enabled: state.isValid, + onPressed: _onMakeOrderPressed, + ), + ), + ), + ], + ), + ], + ), + ), + ), + ); + }, + ); + } + + List _coinsWithUsdBalance(List coins) { + return coins.where((coin) => (coin.usdBalance ?? 0) > 0).toList(); + } + + void _onMakeOrderPressed() { + context + .read() + .add(const MarketMakerConfirmationPreviewRequested()); + } + + void _setSellCoinToDefaultCoin() { + final defaultCoin = coinsBloc.getCoin(defaultDexCoin); + final tradeFormBloc = context.read(); + if (defaultCoin != null && tradeFormBloc.state.sellCoin.value == null) { + tradeFormBloc.add(MarketMakerTradeFormSellCoinChanged(defaultCoin)); + } + } + + List _filteredCoinsList(Coin? coin) { + return widget.coins.where((e) => e.abbr != coin?.abbr).toList(); + } + + void _onTradeMarginChanged(String value) { + context + .read() + .add(MarketMakerTradeFormTradeMarginChanged(value)); + } + + void _onUpdateIntervalChanged(TradeBotUpdateInterval? value) { + context.read().add( + MarketMakerTradeFormUpdateIntervalChanged( + value?.seconds.toString() ?? '', + ), + ); + } + + void _onClearFormPressed() { + context + .read() + .add(const MarketMakerTradeFormClearRequested()); + } + + void _onBuyCoinSelected(Coin? value) { + context + .read() + .add(MarketMakerTradeFormBuyCoinChanged(value)); + } + + Future _swapBuyAndSellCoins() async { + context + .read() + .add(const MarketMakerTradeFormSwapCoinsRequested()); + return true; + } + + void _onSelectSellCoin(Coin? value) { + context + .read() + .add(MarketMakerTradeFormSellCoinChanged(value)); + } + + void _onVolumeRangeChanged(RangeValues values) { + context.read().add( + MarketMakerTradeFormTradeVolumeChanged( + minimumTradeVolume: values.start, + maximumTradeVolume: values.end, + ), + ); + } +} diff --git a/lib/views/market_maker_bot/market_maker_bot_order_list.dart b/lib/views/market_maker_bot/market_maker_bot_order_list.dart new file mode 100644 index 0000000000..4615984304 --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_bot_order_list.dart @@ -0,0 +1,209 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_bloc.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/trade_pair.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/shared/ui/ui_light_button.dart'; +import 'package:web_dex/views/market_maker_bot/animated_bot_status_indicator.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_order_list_header.dart'; +import 'package:web_dex/views/market_maker_bot/trade_pair_list_item.dart'; + +class MarketMakerBotOrdersList extends StatefulWidget { + const MarketMakerBotOrdersList({ + super.key, + required this.entitiesFilterData, + this.onEdit, + this.onCancel, + this.onCancelAll, + }); + + final TradingEntitiesFilter? entitiesFilterData; + final Function(TradePair)? onEdit; + final Function(TradePair)? onCancel; + final Function(List)? onCancelAll; + + @override + State createState() => + _MarketMakerBotOrdersListState(); +} + +class _MarketMakerBotOrdersListState extends State { + final _mainScrollController = ScrollController(); + + @override + void initState() { + context + .read() + .add(const MarketMakerOrderListRequested(Duration(seconds: 3))); + super.initState(); + } + + @override + void dispose() { + _mainScrollController.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(MarketMakerBotOrdersList oldWidget) { + if (oldWidget.entitiesFilterData != widget.entitiesFilterData) { + context + .read() + .add(MarketMakerOrderListFilterChanged(widget.entitiesFilterData)); + } + super.didUpdateWidget(oldWidget); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state.status == MarketMakerOrderListStatus.loading) { + return const Center(child: CircularProgressIndicator()); + } + + return BlocBuilder( + builder: (context, botState) => Column( + mainAxisSize: MainAxisSize.max, + children: [ + if (!isMobile) + Column( + children: [ + const Align( + alignment: Alignment.bottomRight, + child: SizedBox(height: 8), + ), + Align( + alignment: Alignment.bottomRight, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AnimatedBotStatusIndicator( + status: botState.status, + ), + const SizedBox(width: 24), + UiPrimaryButton( + text: botState.isRunning + ? LocaleKeys.mmBotStop.tr() + : LocaleKeys.mmBotStart.tr(), + width: 120, + height: 32, + textStyle: const TextStyle(fontSize: 12), + onPressed: botState.isUpdating || + state.makerBotOrders.isEmpty + ? null + : botState.isRunning + ? _onStopBotPressed + : _onStartBotPressed, + ), + const SizedBox(width: 12), + UiPrimaryButton( + text: LocaleKeys.cancelAll.tr(), + width: 120, + height: 32, + textStyle: const TextStyle(fontSize: 12), + onPressed: botState.isUpdating || + !botState.isRunning || + state.makerBotOrders.isEmpty + ? null + : () => widget.onCancelAll + ?.call(state.makerBotOrders), + ), + ], + ), + ), + MarketMakerBotOrderListHeader( + sortData: state.sortData, + onSortChange: _onSortChange, + ), + ], + ), + Flexible( + child: Padding( + padding: EdgeInsets.only(top: isMobile ? 0 : 10.0), + child: DexScrollbar( + isMobile: isMobile, + scrollController: _mainScrollController, + child: ListView.builder( + shrinkWrap: true, + controller: _mainScrollController, + itemCount: state.makerBotOrders.length, + itemBuilder: (BuildContext context, int index) { + final TradePair pair = state.makerBotOrders[index]; + return TradePairListItem( + pair, + isBotRunning: + botState.isRunning || botState.isUpdating, + onTap: pair.order != null + ? () => _navigateToOrderDetails(pair) + : null, + actions: [ + UiLightButton( + text: LocaleKeys.edit.tr(), + width: 60, + height: 22, + backgroundColor: Colors.transparent, + border: Border.all( + color: const Color.fromRGBO(234, 234, 234, 1), + width: 1.0, + ), + textStyle: const TextStyle(fontSize: 12), + onPressed: botState.isUpdating + ? null + : () => widget.onEdit?.call(pair), + ), + UiLightButton( + text: LocaleKeys.cancel.tr(), + width: 60, + height: 22, + backgroundColor: Colors.transparent, + border: Border.all( + color: const Color.fromRGBO(234, 234, 234, 1), + width: 1.0, + ), + textStyle: const TextStyle(fontSize: 12), + onPressed: botState.isUpdating + ? null + : () => widget.onCancel?.call(pair), + ), + ], + ); + }, + ), + ), + ), + ), + ], + ), + ); + }, + ); + } + + void _navigateToOrderDetails(TradePair pair) { + return routingState.marketMakerState.setDetailsAction(pair.order!.uuid); + } + + void _onStopBotPressed() { + context.read().add(const MarketMakerBotStopRequested()); + } + + void _onStartBotPressed() { + context + .read() + .add(const MarketMakerBotStartRequested()); + } + + void _onSortChange(SortData sortData) { + context + .read() + .add(MarketMakerOrderListSortChanged(sortData)); + } +} diff --git a/lib/views/market_maker_bot/market_maker_bot_order_list_header.dart b/lib/views/market_maker_bot/market_maker_bot_order_list_header.dart new file mode 100644 index 0000000000..6c7b58c190 --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_bot_order_list_header.dart @@ -0,0 +1,73 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_list_header_with_sortings.dart'; + +class MarketMakerBotOrderListHeader extends StatelessWidget { + const MarketMakerBotOrderListHeader({ + Key? key, + required this.sortData, + required this.onSortChange, + }) : super(key: key); + final SortData sortData; + final void Function(SortData) onSortChange; + + @override + Widget build(BuildContext context) { + return UiListHeaderWithSorting( + items: _headerItems, + sortData: sortData, + onSortChange: onSortChange, + ); + } +} + +List> _headerItems = [ + SortHeaderItemData( + text: LocaleKeys.offer.tr(), + value: MarketMakerBotOrderListType.send, + flex: 5, + ), + SortHeaderItemData( + text: LocaleKeys.asking.tr(), + value: MarketMakerBotOrderListType.receive, + flex: 5, + ), + SortHeaderItemData( + text: LocaleKeys.price.tr(), + value: MarketMakerBotOrderListType.price, + flex: 3, + ), + SortHeaderItemData( + text: LocaleKeys.margin.tr(), + value: MarketMakerBotOrderListType.margin, + flex: 3, + ), + SortHeaderItemData( + text: LocaleKeys.updateInterval.tr(), + value: MarketMakerBotOrderListType.updateInterval, + flex: 4, + ), + SortHeaderItemData( + text: LocaleKeys.date.tr(), + value: MarketMakerBotOrderListType.date, + flex: 4, + ), + SortHeaderItemData( + text: '', + flex: 5, + value: MarketMakerBotOrderListType.none, + isEmpty: true, + ), +]; + +enum MarketMakerBotOrderListType { + send, + receive, + price, + margin, + updateInterval, + date, + none, +} diff --git a/lib/views/market_maker_bot/market_maker_bot_page.dart b/lib/views/market_maker_bot/market_maker_bot_page.dart new file mode 100644 index 0000000000..97ce17b6b9 --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_bot_page.dart @@ -0,0 +1,88 @@ +// TODO(Francois): delete once the migration is done +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc_state.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/dex_repository.dart'; +import 'package:web_dex/bloc/dex_tab_bar/dex_tab_bar_bloc.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/market_maker_bot_order_list_repository.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/market_maker_order_list_bloc.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_repository.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/services/orders_service/my_orders_service.dart'; +import 'package:web_dex/views/dex/entity_details/trading_details.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_view.dart'; + +class MarketMakerBotPage extends StatefulWidget { + const MarketMakerBotPage() : super(key: const Key('market-maker-bot-page')); + + @override + State createState() => _MarketMakerBotPageState(); +} + +class _MarketMakerBotPageState extends State { + bool isTradingDetails = false; + + @override + void initState() { + routingState.marketMakerState.addListener(_onRouteChange); + super.initState(); + } + + @override + void dispose() { + routingState.marketMakerState.removeListener(_onRouteChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return MultiBlocProvider( + providers: [ + BlocProvider( + create: (BuildContext context) => DexTabBarBloc( + DexTabBarState.initial(), + authRepo, + ), + ), + BlocProvider( + create: (BuildContext context) => MarketMakerTradeFormBloc( + dexRepo: DexRepository(), + coinsRepo: coinsBloc, + ), + ), + BlocProvider( + create: (BuildContext context) => MarketMakerOrderListBloc( + MarketMakerBotOrderListRepository( + myOrdersService, + SettingsRepository(), + ), + ), + ), + ], + child: BlocListener( + listener: (context, state) { + if (state.mode == AuthorizeMode.noLogin) { + context + .read() + .add(const MarketMakerBotStopRequested()); + } + }, + child: isTradingDetails + ? TradingDetails(uuid: routingState.marketMakerState.uuid) + : MarketMakerBotView(), + ), + ); + } + + void _onRouteChange() { + setState( + () => isTradingDetails = routingState.marketMakerState.isTradingDetails, + ); + } +} diff --git a/lib/views/market_maker_bot/market_maker_bot_tab_bar.dart b/lib/views/market_maker_bot/market_maker_bot_tab_bar.dart new file mode 100644 index 0000000000..df9409fb01 --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_bot_tab_bar.dart @@ -0,0 +1,49 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/dex_tab_bar/dex_tab_bar_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/my_orders/my_order.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab.dart'; +import 'package:web_dex/shared/ui/ui_tab_bar/ui_tab_bar.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_tab_type.dart'; + +class MarketMakerBotTabBar extends StatelessWidget { + const MarketMakerBotTabBar({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final DexTabBarBloc bloc = context.read(); + return StreamBuilder>( + stream: tradingEntitiesBloc.outMyOrders, + builder: (context, _) => StreamBuilder>( + stream: tradingEntitiesBloc.outSwaps, + builder: (context, _) => ConstrainedBox( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + child: UiTabBar( + currentTabIndex: bloc.tabIndex, + tabs: _buidTabs(bloc), + ), + ), + ), + ); + }, + ); + } + + List _buidTabs(DexTabBarBloc bloc) { + const values = MarketMakerBotTabType.values; + return List.generate(values.length, (index) { + final tab = values[index]; + return UiTab( + key: Key(tab.key), + text: tab.name(bloc), + isSelected: bloc.state.tabIndex == index, + onClick: () => bloc.add(TabChanged(index)), + ); + }); + } +} diff --git a/lib/views/market_maker_bot/market_maker_bot_tab_content_wrapper.dart b/lib/views/market_maker_bot/market_maker_bot_tab_content_wrapper.dart new file mode 100644 index 0000000000..e105726981 --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_bot_tab_content_wrapper.dart @@ -0,0 +1,251 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/dex_tab_bar/dex_tab_bar_bloc.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/trade_pair.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/swap.dart'; +import 'package:web_dex/model/trading_entities_filter.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/dex/dex_list_filter/desktop/dex_list_filter_desktop.dart'; +import 'package:web_dex/views/dex/dex_list_filter/mobile/dex_list_filter_mobile.dart'; +import 'package:web_dex/views/dex/dex_list_filter/mobile/dex_list_header_mobile.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/market_maker_bot/market_maker_bot_form.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_order_list.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_tab_type.dart'; + +class MarketMakerBotTabContentWrapper extends StatefulWidget { + const MarketMakerBotTabContentWrapper( + this.listType, { + this.filter, + super.key, + }); + + final MarketMakerBotTabType listType; + final TradingEntitiesFilter? filter; + + @override + State createState() => + _MarketMakerBotTabContentWrapperState(); +} + +class _MarketMakerBotTabContentWrapperState + extends State { + bool _isFilterShown = false; + MarketMakerBotTabType? previouseType; + + @override + Widget build(BuildContext context) { + previouseType ??= widget.listType; + if (previouseType != widget.listType) { + _isFilterShown = false; + previouseType = widget.listType; + } + final child = _SelectedTabContent( + key: Key('dex-list-${widget.listType}'), + filter: widget.filter, + type: widget.listType, + ); + + // the reason why the widgets need to prop drill all filter data, + // is because the widget wraps a table with filters and a dex/market + // maker widget. Widget type = enum value at current tab index + return isMobile + ? _MobileWidget( + key: const Key('dex-list-wrapper-mobile'), + type: widget.listType, + filterData: widget.filter, + onApplyFilter: _setFilter, + isFilterShown: _isFilterShown, + onFilterTap: () => setState(() { + _isFilterShown = !_isFilterShown; + }), + child: child, + ) + : _DesktopWidget( + key: const Key('dex-list-wrapper-desktop'), + type: widget.listType, + filterData: widget.filter, + onApplyFilter: _setFilter, + child: child, + ); + } + + void _setFilter(TradingEntitiesFilter? filter) { + context.read().add( + FilterChanged( + tabType: widget.listType, + filter: filter, + ), + ); + } +} + +class _SelectedTabContent extends StatelessWidget { + const _SelectedTabContent({ + this.filter, + required this.type, + super.key, + }); + + // TODO: get the current filter and type from BLoC state + final TradingEntitiesFilter? filter; + final MarketMakerBotTabType type; + + @override + Widget build(BuildContext context) { + final marketMakerBotBloc = context.read(); + + switch (type) { + case MarketMakerBotTabType.orders: + return MarketMakerBotOrdersList( + entitiesFilterData: filter, + onEdit: (order) => _editTradingBotOrder(context, order), + onCancel: (order) => _deleteTradingBotOrders( + [order], + marketMakerBotBloc, + ), + onCancelAll: (orders) { + _deleteTradingBotOrders(orders, marketMakerBotBloc); + }, + ); + case MarketMakerBotTabType.inProgress: + return InProgressList( + entitiesFilterData: filter, + onItemClick: _onSwapItemClick, + ); + case MarketMakerBotTabType.history: + return HistoryList( + entitiesFilterData: filter, + onItemClick: _onSwapItemClick, + ); + case MarketMakerBotTabType.marketMaker: + return const MarketMakerBotForm(); + } + } + + /// Cancels the existing order, updates the trading pairs in the settings + /// and updates the market maker bot. + /// + /// [tradePair] the order to delete + /// [marketMakerBotBloc] the market maker bot bloc + void _deleteTradingBotOrders( + Iterable tradePair, + MarketMakerBotBloc marketMakerBotBloc, + ) { + final tradePairs = tradePair.map((e) => e.config).toList(); + marketMakerBotBloc.add(MarketMakerBotOrderCancelRequested(tradePairs)); + } + + void _editTradingBotOrder(BuildContext context, TradePair order) { + context + .read() + .add(MarketMakerTradeFormEditOrderRequested(order)); + context.read().add(const TabChanged(0)); + } + + void _onSwapItemClick(Swap swap) { + routingState.dexState.setDetailsAction(swap.uuid); + } +} + +class _MobileWidget extends StatelessWidget { + final MarketMakerBotTabType type; + final Widget child; + final TradingEntitiesFilter? filterData; + final bool isFilterShown; + final VoidCallback onFilterTap; + final void Function(TradingEntitiesFilter?) onApplyFilter; + + const _MobileWidget({ + required this.type, + required this.child, + required this.onApplyFilter, + this.filterData, + required this.isFilterShown, + required this.onFilterTap, + super.key, + }); + + @override + Widget build(BuildContext context) { + if (type == MarketMakerBotTabType.marketMaker) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible( + child: child, + ), + ], + ); + } else { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + DexListHeaderMobile( + entitiesFilterData: filterData, + listType: type.toDexListType(), + isFilterShown: isFilterShown, + onFilterDataChange: onApplyFilter, + onFilterPressed: onFilterTap, + ), + const SizedBox(height: 6), + Flexible( + child: isFilterShown + ? DexListFilterMobile( + filterData: filterData, + onApplyFilter: onApplyFilter, + listType: type.toDexListType(), + ) + : child, + ), + ], + ); + } + } +} + +class _DesktopWidget extends StatelessWidget { + final MarketMakerBotTabType type; + final Widget child; + final TradingEntitiesFilter? filterData; + final void Function(TradingEntitiesFilter?) onApplyFilter; + const _DesktopWidget({ + required this.type, + required this.child, + required this.filterData, + required this.onApplyFilter, + super.key, + }); + + @override + Widget build(BuildContext context) { + if (type == MarketMakerBotTabType.marketMaker) { + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Flexible(child: child), + ], + ); + } else { + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + DexListFilterDesktop( + filterData: filterData, + onApplyFilter: onApplyFilter, + listType: type.toDexListType(), + ), + Flexible(child: child), + ], + ); + } + } +} diff --git a/lib/views/market_maker_bot/market_maker_bot_tab_type.dart b/lib/views/market_maker_bot/market_maker_bot_tab_type.dart new file mode 100644 index 0000000000..05f0b4f747 --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_bot_tab_type.dart @@ -0,0 +1,56 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/bloc/dex_tab_bar/dex_tab_bar_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/dex_list_type.dart'; +import 'package:web_dex/views/market_maker_bot/tab_type_enum.dart'; + +enum MarketMakerBotTabType implements TabTypeEnum { + marketMaker, + orders, + inProgress, + history; + + @override + String name(DexTabBarBloc bloc) { + switch (this) { + case marketMaker: + return LocaleKeys.makeMarket.tr(); + case orders: + return '${LocaleKeys.orders.tr()} (${bloc.ordersCount})'; + case inProgress: + return '${LocaleKeys.inProgress.tr()} (${bloc.inProgressCount})'; + case history: + return '${LocaleKeys.history.tr()} (${bloc.completedCount})'; + } + } + + @override + String get key { + switch (this) { + case marketMaker: + return 'market-maker-bot-tab'; + case orders: + return 'market-maker-orders-tab'; + case inProgress: + return 'market-maker-in-progress-tab'; + case history: + return 'market-maker-history-tab'; + } + } + + /// This is a temporary solution to avoid changing the entire DEX flow to add + /// the market maker bot tab. + // TODO(Francois): separate the tab widget logic from the page logic + DexListType toDexListType() { + switch (this) { + case marketMaker: + return DexListType.swap; + case orders: + return DexListType.orders; + case inProgress: + return DexListType.inProgress; + case history: + return DexListType.history; + } + } +} diff --git a/lib/views/market_maker_bot/market_maker_bot_view.dart b/lib/views/market_maker_bot/market_maker_bot_view.dart new file mode 100644 index 0000000000..a83d480d0e --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_bot_view.dart @@ -0,0 +1,63 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/dex_tab_bar/dex_tab_bar_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/shared/ui/clock_warning_banner.dart'; +import 'package:web_dex/shared/widgets/hidden_without_wallet.dart'; +import 'package:web_dex/views/common/pages/page_layout.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_tab_bar.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_tab_content_wrapper.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_bot_tab_type.dart'; + +class MarketMakerBotView extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, DexTabBarState state) { + final listType = MarketMakerBotTabType.values[state.tabIndex]; + + return PageLayout( + content: Flexible( + child: Container( + margin: isMobile ? const EdgeInsets.only(top: 14) : null, + padding: const EdgeInsets.fromLTRB(16, 22, 16, 20), + decoration: BoxDecoration( + color: _backgroundColor(context), + borderRadius: BorderRadius.circular(18.0), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const HiddenWithoutWallet( + child: Padding( + padding: EdgeInsets.only(bottom: 12.0), + child: MarketMakerBotTabBar(), + ), + ), + const ClockWarningBanner(), + Flexible( + child: MarketMakerBotTabContentWrapper( + key: Key('dex-list-wrapper-${state.tabIndex}'), + listType, + filter: state.filters[listType], + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + Color? _backgroundColor(BuildContext context) { + if (isMobile) { + final ThemeMode mode = theme.mode; + return mode == ThemeMode.dark ? null : Theme.of(context).cardColor; + } + return null; + } +} diff --git a/lib/views/market_maker_bot/market_maker_form_error_message_extensions.dart b/lib/views/market_maker_bot/market_maker_form_error_message_extensions.dart new file mode 100644 index 0000000000..67af2d1b0b --- /dev/null +++ b/lib/views/market_maker_bot/market_maker_form_error_message_extensions.dart @@ -0,0 +1,84 @@ +// extension to allow for separation of BLoC and UI concerns +// Localisation should be handled in the UI layer +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_trade_form/market_maker_trade_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/forms/coin_select_input.dart'; +import 'package:web_dex/model/forms/coin_trade_amount_input.dart'; +import 'package:web_dex/model/forms/trade_margin_input.dart'; + +extension TradeMarginValidationErrorText on TradeMarginValidationError { + String? text({double minVAlue = 0, double maxValue = 100}) { + switch (this) { + case TradeMarginValidationError.empty: + return LocaleKeys.postitiveNumberRequired.tr(); + case TradeMarginValidationError.lessThanMinimum: + case TradeMarginValidationError.invalidNumber: + return LocaleKeys.postitiveNumberRequired.tr(); + case TradeMarginValidationError.greaterThanMaximum: + return LocaleKeys.mustBeLessThan.tr(args: [maxValue.toString()]); + default: + return null; + } + } +} + +extension CoinSelectValidationErrorText on CoinSelectValidationError { + String? text(Coin? coin) { + switch (this) { + case CoinSelectValidationError.inactive: + return LocaleKeys.postitiveNumberRequired.tr(); + case CoinSelectValidationError.insufficientBalance: + return LocaleKeys.dexInsufficientFundsError + .tr(args: [coin?.balance.toString() ?? '0', coin?.abbr ?? '']); + case CoinSelectValidationError.insufficientGasBalance: + return LocaleKeys.withdrawNotEnoughBalanceForGasError + .tr(args: [coin?.abbr ?? '']); + case CoinSelectValidationError.parentSuspended: + return LocaleKeys.withdrawNoParentCoinError + .tr(args: [coin?.abbr ?? '']); + default: + return null; + } + } +} + +extension AmountValidationErrorText on AmountValidationError { + String? text(Coin? coin) { + switch (this) { + case AmountValidationError.empty: + return LocaleKeys.mmBotTradeVolumeRequired.tr(); + case AmountValidationError.invalid: + return LocaleKeys.postitiveNumberRequired.tr(); + case AmountValidationError.moreThanMaximum: + return LocaleKeys.dexInsufficientFundsError + .tr(args: [coin?.balance.toString() ?? '0', coin?.abbr ?? '']); + case AmountValidationError.lessThanMinimum: + return LocaleKeys.mmBotMinimumTradeVolume.tr(args: ["0.00000001"]); + default: + return null; + } + } +} + +extension MarketMakerTradeFormErrorText on MarketMakerTradeFormError { + String text(Coin? baseCoin, Coin? relCoin) { + switch (this) { + case MarketMakerTradeFormError.insufficientBalanceBase: + return LocaleKeys.dexInsufficientFundsError.tr( + args: [baseCoin?.balance.toString() ?? '0', baseCoin?.abbr ?? ''], + ); + case MarketMakerTradeFormError.insufficientBalanceRel: + return LocaleKeys.withdrawNotEnoughBalanceForGasError + .tr(args: [relCoin?.abbr ?? '']); + case MarketMakerTradeFormError.insufficientBalanceRelParent: + return LocaleKeys.withdrawNotEnoughBalanceForGasError + .tr(args: [relCoin?.parentCoin?.abbr ?? relCoin?.abbr ?? '']); + case MarketMakerTradeFormError.insufficientTradeAmount: + return LocaleKeys.mmBotMinimumTradeVolume.tr(args: ["0.00000001"]); + default: + return LocaleKeys.dexErrorMessage.tr(); + } + } +} diff --git a/lib/views/market_maker_bot/sell_coin_select_dropdown.dart b/lib/views/market_maker_bot/sell_coin_select_dropdown.dart new file mode 100644 index 0000000000..21668ac0ff --- /dev/null +++ b/lib/views/market_maker_bot/sell_coin_select_dropdown.dart @@ -0,0 +1,96 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/forms/coin_select_input.dart'; +import 'package:web_dex/model/forms/coin_trade_amount_input.dart'; +import 'package:web_dex/model/forms/trade_volume_input.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/views/dex/common/front_plate.dart'; +import 'package:web_dex/views/market_maker_bot/coin_selection_and_amount_input.dart'; +import 'package:web_dex/views/market_maker_bot/coin_trade_amount_form_field.dart'; +import 'package:web_dex/views/market_maker_bot/market_maker_form_error_message_extensions.dart'; + +class SellCoinSelectDropdown extends StatelessWidget { + const SellCoinSelectDropdown({ + required this.sellCoin, + required this.sellAmount, + required this.coins, + required this.minimumTradeVolume, + required this.maximumTradeVolume, + this.onItemSelected, + this.onTradeVolumeChanged, + super.key, + this.padding = EdgeInsets.zero, + }); + final CoinSelectInput sellCoin; + final CoinTradeAmountInput sellAmount; + final TradeVolumeInput minimumTradeVolume; + final TradeVolumeInput maximumTradeVolume; + final List coins; + final EdgeInsets padding; + final Function(Coin?)? onItemSelected; + final Function(RangeValues)? onTradeVolumeChanged; + + @override + Widget build(BuildContext context) { + return FrontPlate( + child: Column( + children: [ + CoinSelectionAndAmountInput( + title: LocaleKeys.sell.tr(), + selectedCoin: sellCoin.value, + coins: coins, + onItemSelected: onItemSelected, + useFrontPlate: false, + trailing: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + CoinTradeAmountFormField( + coin: sellCoin.value, + initialValue: sellAmount.value, + isEnabled: false, + errorText: sellCoin.displayError?.text(sellCoin.value), + ), + Padding( + padding: const EdgeInsets.only(right: 18), + child: Text( + '* ${LocaleKeys.mmBotFirstTradeEstimate.tr()}', + style: TextStyle( + color: dexPageColors.inactiveText, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + textAlign: TextAlign.end, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + PercentageRangeSlider( + title: AutoScrollText( + text: LocaleKeys.mmBotVolumePerTrade.tr( + args: [sellCoin.value?.abbr ?? ''], + ), + style: TextStyle( + color: dexPageColors.activeText, + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + padding: const EdgeInsets.fromLTRB(16, 8, 16, 16), + values: RangeValues( + minimumTradeVolume.value, + maximumTradeVolume.value, + ), + onChanged: onTradeVolumeChanged, + min: 0.01, + ), + ], + ), + ); + } +} diff --git a/lib/views/market_maker_bot/tab_type_enum.dart b/lib/views/market_maker_bot/tab_type_enum.dart new file mode 100644 index 0000000000..b29cf668d9 --- /dev/null +++ b/lib/views/market_maker_bot/tab_type_enum.dart @@ -0,0 +1,6 @@ +import 'package:web_dex/bloc/dex_tab_bar/dex_tab_bar_bloc.dart'; + +abstract class TabTypeEnum { + String get key; + String name(DexTabBarBloc bloc); +} diff --git a/lib/views/market_maker_bot/trade_bot_update_interval.dart b/lib/views/market_maker_bot/trade_bot_update_interval.dart new file mode 100644 index 0000000000..27db0fbf4e --- /dev/null +++ b/lib/views/market_maker_bot/trade_bot_update_interval.dart @@ -0,0 +1,58 @@ +enum TradeBotUpdateInterval { + oneMinute, + threeMinutes, + fiveMinutes; + + @override + String toString() { + switch (this) { + case TradeBotUpdateInterval.oneMinute: + return '1'; + case TradeBotUpdateInterval.threeMinutes: + return '3'; + case TradeBotUpdateInterval.fiveMinutes: + return '5'; + } + } + + static TradeBotUpdateInterval fromString(String interval) { + switch (interval) { + case '1': + return TradeBotUpdateInterval.oneMinute; + case '3': + return TradeBotUpdateInterval.threeMinutes; + case '5': + return TradeBotUpdateInterval.fiveMinutes; + case '60': + return TradeBotUpdateInterval.oneMinute; + case '180': + return TradeBotUpdateInterval.threeMinutes; + case '300': + return TradeBotUpdateInterval.fiveMinutes; + default: + throw ArgumentError('Invalid interval'); + } + } + + int get minutes { + switch (this) { + case TradeBotUpdateInterval.oneMinute: + return 1; + case TradeBotUpdateInterval.threeMinutes: + return 3; + case TradeBotUpdateInterval.fiveMinutes: + return 5; + } + } + + int get seconds { + switch (this) { + case TradeBotUpdateInterval.oneMinute: + return 60; + case TradeBotUpdateInterval.threeMinutes: + return 180; + case TradeBotUpdateInterval.fiveMinutes: + return 300; + } + } +} diff --git a/lib/views/market_maker_bot/trade_pair_list_item.dart b/lib/views/market_maker_bot/trade_pair_list_item.dart new file mode 100644 index 0000000000..948ffa9000 --- /dev/null +++ b/lib/views/market_maker_bot/trade_pair_list_item.dart @@ -0,0 +1,394 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:rational/rational.dart'; +import 'package:vector_math/vector_math_64.dart' as vector_math; +import 'package:web_dex/bloc/market_maker_bot/market_maker_order_list/trade_pair.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/focusable_widget.dart'; +import 'package:web_dex/views/dex/entities_list/common/buy_price_mobile.dart'; +import 'package:web_dex/views/dex/entities_list/common/coin_amount_mobile.dart'; +import 'package:web_dex/views/dex/entities_list/common/trade_amount_desktop.dart'; + +class TradePairListItem extends StatelessWidget { + const TradePairListItem( + this.pair, { + required this.isBotRunning, + super.key, + this.onTap, + this.actions = const [], + }); + + final TradePair pair; + final bool isBotRunning; + final List actions; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final config = pair.config; + final order = pair.order; + final sellCoin = config.baseCoinId; + final sellAmount = order?.baseAmountAvailable ?? Rational.zero; + final buyCoin = config.relCoinId; + final buyAmount = order?.relAmountAvailable ?? Rational.zero; + final String date = order != null ? getFormattedDate(order.createdAt) : '-'; + final double fillProgress = order != null + ? tradingEntitiesBloc.getProgressFillSwap(pair.order!) + : 0; + final showProgressIndicator = pair.order == null && isBotRunning; + + return Column( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + FocusableWidget( + onTap: onTap, + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.surface, + ), + child: isMobile + ? _OrderItemMobile( + buyAmount: buyAmount, + buyCoin: buyCoin, + sellCoin: sellCoin, + sellAmount: sellAmount, + date: date, + fillProgress: fillProgress, + actions: actions, + ) + : _OrderItemDesktop( + buyAmount: buyAmount, + buyCoin: buyCoin, + sellCoin: sellCoin, + sellAmount: sellAmount, + margin: '${config.margin.toStringAsFixed(2)}%', + updateInterval: '${config.updateInterval.minutes} min', + date: date, + fillProgress: fillProgress, + showProgressIndicator: showProgressIndicator, + actions: actions, + ), + ), + ), + ], + ); + } +} + +class _OrderItemDesktop extends StatelessWidget { + const _OrderItemDesktop({ + required this.buyCoin, + required this.buyAmount, + required this.sellCoin, + required this.sellAmount, + required this.margin, + required this.updateInterval, + required this.date, + required this.fillProgress, + required this.showProgressIndicator, + this.actions = const [], + }); + final String buyCoin; + final Rational buyAmount; + final String sellCoin; + final Rational sellAmount; + final String margin; + final String updateInterval; + final String date; + final double fillProgress; + final bool showProgressIndicator; + final List actions; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + flex: 5, + child: TradeAmountDesktop(coinAbbr: sellCoin, amount: sellAmount), + ), + Expanded( + flex: 5, + child: TradeAmountDesktop(coinAbbr: buyCoin, amount: buyAmount), + ), + Expanded( + flex: 3, + child: Text( + showProgressIndicator + ? '-' + : formatAmt( + tradingEntitiesBloc.getPriceFromAmount( + sellAmount, + buyAmount, + ), + ), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + Expanded( + flex: 3, + child: Text( + margin, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + ), + Expanded( + flex: 4, + child: Text( + updateInterval, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + flex: 4, + child: Text( + date, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + Expanded( + flex: 5, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (showProgressIndicator) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 6.0), + child: actions.isNotEmpty + ? TableActionsButtonList( + actions: actions, + ) + : const SizedBox(width: 80), + ), + ), + ], + ), + ), + ], + ); + } +} + +class TableActionsButtonList extends StatelessWidget { + const TableActionsButtonList({ + required this.actions, + }); + + final List actions; + + @override + Widget build(BuildContext context) { + // use layout builder to dynamically switch to column layout if width is + // not sufficient + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth < 120) { + return Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.end, + children: actions, + ); + } + + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: actions, + ); + }, + ); + } +} + +class _FillPainter extends CustomPainter { + _FillPainter({ + required this.context, + required this.fillProgress, + }); + + final BuildContext context; + final double fillProgress; + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return true; + } + + @override + void paint(Canvas canvas, Size size) { + final theme = Theme.of(context); + + final Paint paint = Paint() + ..color = Theme.of(context).highlightColor + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.fill; + + final Offset center = Offset(size.width / 2, size.height / 2); + canvas.drawCircle(center, size.width / 2, paint); + + final Paint fillPaint = Paint() + ..style = PaintingStyle.stroke + ..color = + theme.progressIndicatorTheme.color ?? theme.colorScheme.secondary + ..strokeWidth = size.width * 1.1 / 2; + + canvas.drawArc( + Rect.fromCircle(center: center, radius: size.width / 4), + vector_math.radians(0), + vector_math.radians(fillProgress * 360), + false, + fillPaint, + ); + } +} + +class _OrderItemMobile extends StatelessWidget { + const _OrderItemMobile({ + required this.buyCoin, + required this.buyAmount, + required this.sellCoin, + required this.sellAmount, + required this.date, + required this.fillProgress, + this.actions = const [], + }); + + final String buyCoin; + final Rational buyAmount; + final String sellCoin; + final Rational sellAmount; + final String date; + final double fillProgress; + final List actions; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.send.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 4), + child: CoinAmountMobile( + coinAbbr: sellCoin, + amount: sellAmount, + ), + ), + ], + ), + ), + BuyPriceMobile( + buyCoin: buyCoin, + sellAmount: sellAmount, + buyAmount: buyAmount, + ), + ], + ), + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.receive.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 5.0), + child: CoinAmountMobile( + coinAbbr: buyCoin, + amount: buyAmount, + ), + ), + ], + ), + ), + ...actions, + ], + ), + ), + Container( + margin: const EdgeInsets.only(top: 14), + padding: const EdgeInsets.fromLTRB(6, 12, 6, 12), + width: double.infinity, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9), + color: Theme.of(context).colorScheme.surface, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox( + width: 18, + height: 18, + child: CustomPaint( + painter: _FillPainter( + context: context, + fillProgress: fillProgress, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Text( + LocaleKeys.percentFilled + .tr(args: [(fillProgress * 100).toStringAsFixed(0)]), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/lib/views/market_maker_bot/trade_volume_type.dart b/lib/views/market_maker_bot/trade_volume_type.dart new file mode 100644 index 0000000000..e317674d39 --- /dev/null +++ b/lib/views/market_maker_bot/trade_volume_type.dart @@ -0,0 +1,18 @@ +enum TradeVolumeType { + /// The volume is in USD + usd, + + /// The volume is a percentage of the total balance as a decimal value. + /// For example, 0.5 is 50% of the total balance. + percentage; + + String get symbol => this == TradeVolumeType.usd ? '\$' : '%'; + + String get name => this == TradeVolumeType.usd ? 'USD' : 'Percentage'; + + String get title => name; + + static String getTitle(TradeVolumeType volumeType) { + return volumeType.title; + } +} diff --git a/lib/views/market_maker_bot/update_interval_dropdown.dart b/lib/views/market_maker_bot/update_interval_dropdown.dart new file mode 100644 index 0000000000..ee5e3c99f5 --- /dev/null +++ b/lib/views/market_maker_bot/update_interval_dropdown.dart @@ -0,0 +1,48 @@ +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/views/market_maker_bot/trade_bot_update_interval.dart'; + +class UpdateIntervalDropdown extends StatelessWidget { + const UpdateIntervalDropdown({ + required this.interval, + required this.label, + super.key, + this.onChanged, + }); + + final TradeBotUpdateInterval interval; + final Widget label; + final void Function(TradeBotUpdateInterval?)? onChanged; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [label], + ), + ), + Expanded( + child: DropdownButtonFormField( + value: interval, + onChanged: onChanged, + focusColor: Colors.transparent, + items: TradeBotUpdateInterval.values + .map( + (interval) => DropdownMenuItem( + value: interval, + alignment: Alignment.center, + child: + Text('${interval.minutes} ${LocaleKeys.minutes.tr()}'), + ), + ) + .toList(), + ), + ), + ], + ); + } +} diff --git a/lib/views/nfts/common/widgets/nft_connect_wallet.dart b/lib/views/nfts/common/widgets/nft_connect_wallet.dart new file mode 100644 index 0000000000..ca447e4d10 --- /dev/null +++ b/lib/views/nfts/common/widgets/nft_connect_wallet.dart @@ -0,0 +1,32 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/connect_wallet/connect_wallet_button.dart'; +import 'package:web_dex/views/nfts/common/widgets/nft_no_login.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; + +class NftConnectWallet extends StatelessWidget { + const NftConnectWallet(); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 210), + child: NftNoLogin(text: LocaleKeys.nftMainLoggedOut.tr())), + if (isMobile) + const Padding( + padding: EdgeInsets.only(top: 16), + child: ConnectWalletButton( + eventType: WalletsManagerEventType.nft, + buttonSize: Size(double.infinity, 40), + withIcon: false, + ), + ), + ], + ); + } +} diff --git a/lib/views/nfts/common/widgets/nft_failure.dart b/lib/views/nfts/common/widgets/nft_failure.dart new file mode 100644 index 0000000000..7d4df6e003 --- /dev/null +++ b/lib/views/nfts/common/widgets/nft_failure.dart @@ -0,0 +1,103 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class NftFailure extends StatelessWidget { + const NftFailure({ + Key? key, + required this.title, + required this.subtitle, + required this.message, + required this.onTryAgain, + this.additionSubtitle, + this.isSpinnerShown = false, + }) : super(key: key); + + final String title; + final String subtitle; + final String? additionSubtitle; + final String message; + final VoidCallback onTryAgain; + final bool isSpinnerShown; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension()!; + final textTheme = Theme.of(context).extension()!; + + final additionSubtitle = this.additionSubtitle; + final scrollController = ScrollController(); + return DexScrollbar( + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: SizedBox( + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 15), + DecoratedBox( + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: colorScheme.error, width: 6), + ), + child: Icon( + Icons.close_rounded, + size: 66, + color: colorScheme.error, + ), + ), + const SizedBox(height: 15), + Text( + title, + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(color: colorScheme.error), + ), + const SizedBox(height: 12), + Text( + subtitle, + style: textTheme.bodyS, + textAlign: TextAlign.center, + ), + if (additionSubtitle != null) + Text( + additionSubtitle, + style: textTheme.bodyS, + textAlign: TextAlign.center, + ), + const SizedBox(height: 12), + Container( + constraints: const BoxConstraints(maxWidth: 324), + decoration: BoxDecoration( + color: theme.custom.subCardBackgroundColor, + borderRadius: BorderRadius.circular(18)), + child: SelectableText( + message, + textAlign: TextAlign.center, + style: textTheme.bodyS.copyWith(color: colorScheme.s70), + )), + const SizedBox(height: 24), + UiPrimaryButton( + text: LocaleKeys.retryButtonText.tr(), + width: 324, + prefix: isSpinnerShown + ? null + : UiSpinner( + color: colorScheme.primary, + ), + onPressed: isSpinnerShown ? null : onTryAgain, + ), + const SizedBox(height: 20), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/nfts/common/widgets/nft_image.dart b/lib/views/nfts/common/widgets/nft_image.dart new file mode 100644 index 0000000000..7a34eba39b --- /dev/null +++ b/lib/views/nfts/common/widgets/nft_image.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:video_player/video_player.dart'; +import 'package:web_dex/shared/utils/platform_tuner.dart'; + +enum NftImageType { image, video, placeholder } + +class NftImage extends StatelessWidget { + const NftImage({ + super.key, + this.imagePath, + }); + + final String? imagePath; + + @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(); + } + } + + NftImageType get type { + if (imagePath != null) { + if (imagePath!.endsWith('.mp4')) { + return NftImageType.video; + } else { + return NftImageType.image; + } + } + return NftImageType.placeholder; + } +} + +class _NftImage extends StatelessWidget { + const _NftImage({required this.imageUrl}); + final String imageUrl; + + @override + Widget build(BuildContext context) { + final isSvg = imageUrl.endsWith('.svg'); + return ClipRRect( + borderRadius: BorderRadius.circular(24), + child: isSvg + ? SvgPicture.network(imageUrl, fit: BoxFit.cover) + : Image.network( + imageUrl, + filterQuality: FilterQuality.high, + fit: BoxFit.cover, + errorBuilder: (_, __, ___) => const _NftPlaceholder(), + ), + ); + } +} + +class _NftVideo extends StatefulWidget { + const _NftVideo({required this.videoUrl}); + + final String videoUrl; + + @override + State<_NftVideo> createState() => _NftVideoState(); +} + +class _NftVideoState extends State<_NftVideo> { + late final VideoPlayerController _controller; + + @override + void initState() { + _controller = VideoPlayerController.networkUrl(Uri.parse(widget.videoUrl)); + + _controller.initialize().then((_) { + _controller.setLooping(true); + _controller.play(); + setState(() {}); + }); + + super.initState(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _controller.value.isInitialized + ? ClipRRect( + borderRadius: BorderRadius.circular(24), + child: VideoPlayer(_controller), + ) + : const Center( + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ); + } +} + +class _NftPlaceholder extends StatelessWidget { + const _NftPlaceholder(); + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: const Center( + child: Icon(Icons.monetization_on, size: 36), + ), + ); + } +} diff --git a/lib/views/nfts/common/widgets/nft_no_login.dart b/lib/views/nfts/common/widgets/nft_no_login.dart new file mode 100644 index 0000000000..b99039eec0 --- /dev/null +++ b/lib/views/nfts/common/widgets/nft_no_login.dart @@ -0,0 +1,39 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class NftNoLogin extends StatelessWidget { + const NftNoLogin({super.key, required this.text}); + final String text; + + @override + Widget build(BuildContext context) { + final ColorSchemeExtension colorScheme = + Theme.of(context).extension()!; + final TextThemeExtension textTheme = + Theme.of(context).extension()!; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 124, + height: 124, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: colorScheme.surfCont, + boxShadow: [ + BoxShadow( + color: colorScheme.secondary, + blurRadius: 24, + ) + ]), + ), + const SizedBox(height: 32), + Text( + text, + style: textTheme.bodySBold, + textAlign: TextAlign.center, + ), + ], + ); + } +} diff --git a/lib/views/nfts/details_page/common/nft_data.dart b/lib/views/nfts/details_page/common/nft_data.dart new file mode 100644 index 0000000000..28273806d8 --- /dev/null +++ b/lib/views/nfts/details_page/common/nft_data.dart @@ -0,0 +1,101 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/hash_explorer_link.dart'; +import 'package:web_dex/shared/widgets/nft/nft_badge.dart'; +import 'package:web_dex/shared/widgets/simple_copyable_link.dart'; +import 'package:web_dex/views/nfts/details_page/common/nft_data_row.dart'; + +class NftData extends StatelessWidget { + const NftData({required this.nft, this.header}); + final NftToken nft; + final Widget? header; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension()!; + final textTheme = Theme.of(context).extension()!; + final header = this.header; + final double spaceBetweenRows = isMobile ? 20 : 15; + + return Container( + padding: isMobile + ? const EdgeInsets.fromLTRB(20, 15, 20, 30) + : const EdgeInsets.fromLTRB(20, 18, 20, 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: colorScheme.surfContLow, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (header != null) header, + NftDataRow( + title: LocaleKeys.tokensAmount.tr(), + titleStyle: textTheme.bodyM.copyWith( + color: colorScheme.secondary, + fontWeight: FontWeight.w700, + height: 1, + ), + value: nft.amount, + valueStyle: textTheme.bodyM.copyWith( + color: colorScheme.secondary, + fontWeight: FontWeight.w700, + height: 1, + ), + ), + const SizedBox(height: 15), + Container( + width: double.infinity, + height: 1, + color: colorScheme.surfContHigh, + ), + const SizedBox(height: 15), + NftDataRow( + title: LocaleKeys.contractAddress.tr(), + valueWidget: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 160), + child: HashExplorerLink( + coin: nft.parentCoin, + hash: nft.tokenAddress, + type: HashExplorerType.address, + ), + ), + ), + SizedBox(height: spaceBetweenRows), + NftDataRow( + title: LocaleKeys.tokenID.tr(), + valueWidget: SimpleCopyableLink( + text: truncateMiddleSymbols(nft.tokenId, 6, 7), + valueToCopy: nft.tokenId, + link: nft.tokenUri, + )), + SizedBox(height: spaceBetweenRows), + NftDataRow( + title: LocaleKeys.tokenStandard.tr(), + value: nft.contractType.name.toUpperCase(), + ), + SizedBox(height: isMobile ? spaceBetweenRows : 8), + NftDataRow( + title: LocaleKeys.blockchain.tr(), + valueWidget: BlockchainBadge( + width: 76, + padding: const EdgeInsets.symmetric(vertical: 1, horizontal: 5), + blockchain: nft.chain, + iconSize: 10, + iconColor: colorScheme.surf, + textStyle: + textTheme.bodyXXSBold.copyWith(color: colorScheme.surf), + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/nfts/details_page/common/nft_data_row.dart b/lib/views/nfts/details_page/common/nft_data_row.dart new file mode 100644 index 0000000000..cc4c4a9474 --- /dev/null +++ b/lib/views/nfts/details_page/common/nft_data_row.dart @@ -0,0 +1,61 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class NftDataRow extends StatelessWidget { + const NftDataRow({ + super.key, + this.title, + this.titleWidget, + this.value, + this.valueWidget, + this.titleStyle, + this.valueStyle, + }); + final String? title; + final Widget? titleWidget; + final String? value; + final Widget? valueWidget; + final TextStyle? titleStyle; + final TextStyle? valueStyle; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).extension()!; + final colorScheme = Theme.of(context).extension()!; + final title = this.title; + final titleWidget = this.titleWidget; + final value = this.value; + final valueWidget = this.valueWidget; + final titleStyle = textTheme.bodyS + .copyWith(color: colorScheme.s70, height: 1) + .merge(this.titleStyle); + final valueStyle = textTheme.bodySBold + .copyWith(color: colorScheme.secondary, height: 1) + .merge(this.valueStyle); + + assert(value == null && valueWidget != null || + value != null && valueWidget == null); + assert(title == null && titleWidget != null || + title != null && titleWidget == null); + + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (title != null) + Text( + title, + style: titleStyle, + ) + else if (titleWidget != null) + titleWidget, + if (value != null) + Text( + value, + style: valueStyle, + ) + else if (valueWidget != null) + valueWidget, + ], + ); + } +} diff --git a/lib/views/nfts/details_page/common/nft_description.dart b/lib/views/nfts/details_page/common/nft_description.dart new file mode 100644 index 0000000000..f08908d0f9 --- /dev/null +++ b/lib/views/nfts/details_page/common/nft_description.dart @@ -0,0 +1,47 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/model/nft.dart'; + +class NftDescription extends StatelessWidget { + const NftDescription({ + required this.nft, + this.isDescriptionShown = true, + }); + + final NftToken nft; + final bool isDescriptionShown; + @override + Widget build(BuildContext context) { + final ColorSchemeExtension colorScheme = + Theme.of(context).extension()!; + final TextThemeExtension textTheme = + Theme.of(context).extension()!; + final String? description = nft.description; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + nft.collectionName ?? '', + style: textTheme.bodyM.copyWith(color: colorScheme.s70, height: 1), + ), + const SizedBox(height: 3), + Text( + nft.name, + style: textTheme.heading1.copyWith(height: 1), + ), + if (isDescriptionShown && description != null && description.isNotEmpty) + Container( + margin: const EdgeInsets.only(top: 8), + constraints: const BoxConstraints(maxHeight: 120.0), + child: SingleChildScrollView( + child: Text( + description, + style: textTheme.bodyS, + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/nfts/details_page/desktop/nft_details_header_desktop.dart b/lib/views/nfts/details_page/desktop/nft_details_header_desktop.dart new file mode 100644 index 0000000000..167c04e432 --- /dev/null +++ b/lib/views/nfts/details_page/desktop/nft_details_header_desktop.dart @@ -0,0 +1,18 @@ +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/router/state/routing_state.dart'; +import 'package:web_dex/views/common/page_header/page_header.dart'; + +class NftDetailsHeaderDesktop extends StatelessWidget { + const NftDetailsHeaderDesktop({super.key}); + + @override + Widget build(BuildContext context) { + return PageHeader( + title: '', + backText: LocaleKeys.collectibles.tr(), + onBackButtonPressed: () => routingState.nftsState.reset(), + ); + } +} 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 new file mode 100644 index 0000000000..2663b665ae --- /dev/null +++ b/lib/views/nfts/details_page/desktop/nft_details_page_desktop.dart @@ -0,0 +1,77 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/nfts/common/widgets/nft_image.dart'; +import 'package:web_dex/views/nfts/details_page/common/nft_data.dart'; +import 'package:web_dex/views/nfts/details_page/common/nft_description.dart'; +import 'package:web_dex/views/nfts/details_page/desktop/nft_details_header_desktop.dart'; +import 'package:web_dex/views/nfts/details_page/withdraw/nft_withdraw_view.dart'; + +class NftDetailsPageDesktop extends StatelessWidget { + const NftDetailsPageDesktop({required this.isSend}); + final bool isSend; + + @override + Widget build(BuildContext context) { + final bloc = context.watch(); + final state = bloc.state; + final nft = state.nft; + + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + const NftDetailsHeaderDesktop(), + const SizedBox(height: 20), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 389, maxHeight: 440), + child: NftImage(imagePath: nft.imageUrl), + ), + ), + const SizedBox(width: 32), + Flexible( + child: ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 416, maxHeight: 440), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + NftDescription( + nft: nft, + isDescriptionShown: !isSend, + ), + const SizedBox(height: 12), + if (state is! NftWithdrawSuccessState) NftData(nft: nft), + if (isSend) + Flexible( + child: NftWithdrawView(nft: nft), + ) + else ...[ + const Spacer(), + UiPrimaryButton( + text: LocaleKeys.send.tr(), + height: 40, + onPressed: () { + routingState.nftsState + .setDetailsAction(nft.uuid, true); + }), + ], + ], + ), + ), + ), + ], + ), + ], + ); + } +} diff --git a/lib/views/nfts/details_page/mobile/nft_details_header_mobile.dart b/lib/views/nfts/details_page/mobile/nft_details_header_mobile.dart new file mode 100644 index 0000000000..60cf25b3b5 --- /dev/null +++ b/lib/views/nfts/details_page/mobile/nft_details_header_mobile.dart @@ -0,0 +1,41 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/common/page_header/page_header.dart'; + +class NftDetailsHeaderMobile extends StatelessWidget { + const NftDetailsHeaderMobile({super.key, required this.close}); + final VoidCallback close; + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + final state = bloc.state; + if (state is NftWithdrawSuccessState) return const SizedBox.shrink(); + + return PageHeader( + title: _title(state), + onBackButtonPressed: () => _onBackButtonPressed(bloc), + ); + } + + String _title(NftWithdrawState state) { + if (state is NftWithdrawFillState) { + return LocaleKeys.sendingProcess.tr(); + } else if (state is NftWithdrawConfirmState) { + return LocaleKeys.confirmSend.tr(); + } + return ''; + } + + void _onBackButtonPressed(NftWithdrawBloc bloc) { + final state = bloc.state; + if (state is NftWithdrawFillState) { + close(); + } else if (state is NftWithdrawConfirmState) { + bloc.add(const NftWithdrawShowFillStep()); + } + } +} 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 new file mode 100644 index 0000000000..64b8ca9dfe --- /dev/null +++ b/lib/views/nfts/details_page/mobile/nft_details_page_mobile.dart @@ -0,0 +1,198 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/router/state/nfts_state.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/common/page_header/page_header.dart'; +import 'package:web_dex/views/nfts/common/widgets/nft_image.dart'; +import 'package:web_dex/views/nfts/details_page/common/nft_data.dart'; +import 'package:web_dex/views/nfts/details_page/common/nft_description.dart'; +import 'package:web_dex/views/nfts/details_page/mobile/nft_details_header_mobile.dart'; +import 'package:web_dex/views/nfts/details_page/withdraw/nft_withdraw_view.dart'; + +class NftDetailsPageMobile extends StatefulWidget { + const NftDetailsPageMobile({required this.isRouterSend}); + final bool isRouterSend; + + @override + State createState() => _NftDetailsPageMobileState(); +} + +class _NftDetailsPageMobileState extends State { + bool _isSend = false; + + @override + void initState() { + _isSend = widget.isRouterSend; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + 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, + ), + ); + }); + } + + void _showSend() { + setState(() { + _isSend = true; + }); + } + + void _closeSend() { + if (widget.isRouterSend) { + _livePage(); + } else { + setState(() { + _isSend = false; + }); + } + } + + void _livePage() { + routingState.nftsState.pageState = NFTSelectedState.none; + } +} + +class _Details extends StatelessWidget { + const _Details({ + required this.nft, + required this.onBack, + required this.onSend, + }); + final NftToken nft; + final VoidCallback onBack; + final VoidCallback onSend; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 50), + PageHeader( + title: nft.name, + onBackButtonPressed: onBack, + ), + const SizedBox(height: 5), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 343), + child: NftImage( + imagePath: nft.imageUrl, + ), + ), + const SizedBox(height: 28), + UiPrimaryButton( + text: LocaleKeys.send.tr(), + height: 40, + onPressed: onSend, + ), + const SizedBox(height: 28), + NftDescription(nft: nft) + ], + ); + } +} + +class _Send extends StatelessWidget { + const _Send({ + required this.nft, + required this.close, + }); + final NftToken nft; + final VoidCallback close; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).extension()!; + final colorScheme = Theme.of(context).extension()!; + final state = context.read().state; + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 50), + NftDetailsHeaderMobile( + close: close, + ), + const SizedBox(height: 10), + if (state is! NftWithdrawSuccessState) + Padding( + padding: const EdgeInsets.only(bottom: 28.0), + child: NftData( + nft: nft, + header: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 40, + maxHeight: 40, + ), + child: NftImage(imagePath: nft.imageUrl), + ), + const SizedBox(width: 8), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + nft.name, + style: textTheme.bodySBold.copyWith( + color: colorScheme.primary, height: 1), + ), + const SizedBox(height: 10), + Text( + nft.collectionName ?? '', + style: textTheme.bodyXS + .copyWith(color: colorScheme.s70, height: 1), + ) + ], + ), + ), + ], + ), + const SizedBox(height: 15), + Container( + width: double.infinity, + height: 1, + color: colorScheme.surfContHigh, + ), + const SizedBox(height: 15), + ], + ), + ), + ) + else + const SizedBox(height: 50), + NftWithdrawView(nft: nft), + ], + ); + } +} diff --git a/lib/views/nfts/details_page/nft_details_page.dart b/lib/views/nfts/details_page/nft_details_page.dart new file mode 100644 index 0000000000..3d3f58f66c --- /dev/null +++ b/lib/views/nfts/details_page/nft_details_page.dart @@ -0,0 +1,77 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_bloc.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_repo.dart'; +import 'package:web_dex/bloc/nfts/nft_main_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/views/nfts/details_page/desktop/nft_details_header_desktop.dart'; +import 'package:web_dex/views/nfts/details_page/desktop/nft_details_page_desktop.dart'; +import 'package:web_dex/views/nfts/details_page/mobile/nft_details_page_mobile.dart'; + +class NftDetailsPage extends StatelessWidget { + const NftDetailsPage({super.key, required this.uuid, required this.isSend}); + final String uuid; + final bool isSend; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).extension()!; + + return BlocSelector( + selector: (state) { + return state.isInitialized; + }, + builder: (context, isInitialized) { + if (!isInitialized) { + return const Center( + child: UiSpinner( + width: 24, + height: 24, + ), + ); + } + final nfts = context.read().state.nfts; + final NftToken? nft = nfts.values + .firstWhereOrNull((list) => + list?.firstWhereOrNull((token) => token.uuid == uuid) != null) + ?.firstWhereOrNull((token) => token.uuid == uuid); + + if (nft == null) { + return Column( + children: [ + if (isMobile) const SizedBox(height: 50), + const NftDetailsHeaderDesktop(), + const SizedBox(height: 80), + Center( + child: Text( + LocaleKeys.nothingFound.tr(), + style: textTheme.heading1, + ), + ), + ], + ); + } + + return BlocProvider( + key: Key('nft-withdraw-bloc-provider-${nft.uuid}'), + create: (context) => NftWithdrawBloc( + nft: nft, + repo: NftWithdrawRepo(api: mm2Api.nft), + coinsBloc: coinsBloc, + ), + child: isMobile + ? NftDetailsPageMobile(isRouterSend: isSend) + : NftDetailsPageDesktop(isSend: isSend), + ); + }, + ); + } +} diff --git a/lib/views/nfts/details_page/withdraw/nft_withdraw_confirmation.dart b/lib/views/nfts/details_page/withdraw/nft_withdraw_confirmation.dart new file mode 100644 index 0000000000..1757cf930d --- /dev/null +++ b/lib/views/nfts/details_page/withdraw/nft_withdraw_confirmation.dart @@ -0,0 +1,56 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/truncate_middle_text.dart'; +import 'package:web_dex/views/nfts/details_page/common/nft_data_row.dart'; + +class NftWithdrawConfirmation extends StatelessWidget { + const NftWithdrawConfirmation({required this.state}); + final NftWithdrawConfirmState state; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).extension()!; + final colorScheme = Theme.of(context).extension()!; + final txDetails = state.txDetails; + + final feeString = + '${truncateDecimal(txDetails.feeDetails.feeValue ?? '', decimalRange)} ${Coin.normalizeAbbr(txDetails.feeDetails.coin)}'; + return Column( + children: [ + NftDataRow( + titleWidget: Flexible( + child: Text( + LocaleKeys.recipientAddress.tr(), + style: + textTheme.bodyS.copyWith(color: colorScheme.s50, height: 1), + ), + ), + valueWidget: Flexible( + child: TruncatedMiddleText( + txDetails.to.first, + style: textTheme.bodySBold + .copyWith(color: colorScheme.secondary, height: 1), + ), + )), + const SizedBox(height: 15), + NftDataRow( + title: LocaleKeys.tokensAmount.tr(), + titleStyle: TextStyle(color: colorScheme.s50), + value: txDetails.amount, + ), + const SizedBox(height: 15), + NftDataRow( + title: LocaleKeys.networkFee.tr(), + titleStyle: TextStyle(color: colorScheme.s50), + value: feeString, + ), + ], + ); + } +} diff --git a/lib/views/nfts/details_page/withdraw/nft_withdraw_footer.dart b/lib/views/nfts/details_page/withdraw/nft_withdraw_footer.dart new file mode 100644 index 0000000000..49a7007212 --- /dev/null +++ b/lib/views/nfts/details_page/withdraw/nft_withdraw_footer.dart @@ -0,0 +1,140 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/router/state/nfts_state.dart'; +import 'package:web_dex/router/state/routing_state.dart'; + +class NftWithdrawFooter extends StatelessWidget { + const NftWithdrawFooter(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final bool isSending = + (state is NftWithdrawFillState && state.isSending) || + (state is NftWithdrawConfirmState && state.isSending); + final isSuccess = state is NftWithdrawSuccessState; + if (isSuccess) { + return _buildSuccessFooter(context, state); + } + + return Row( + children: [ + if (!isMobile) + Flexible( + flex: 4, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: _SecondaryButton( + onPressed: () => _onBack(context), + text: LocaleKeys.back.tr(), + ), + ), + ), + Flexible( + flex: isMobile ? 10 : 6, + child: _PrimaryButton( + text: state is NftWithdrawConfirmState + ? LocaleKeys.confirmSend.tr() + : LocaleKeys.send.tr(), + onPressed: () => _onSend(context), + isSending: isSending, + ), + ), + ], + ); + }, + ); + } + + Widget _buildSuccessFooter( + BuildContext context, + NftWithdrawSuccessState state, + ) { + return _PrimaryButton( + text: LocaleKeys.done.tr(), + onPressed: () => routingState.nftsState.pageState = NFTSelectedState.none, + isSending: false, + ); + } + + void _onSend(BuildContext context) { + final bloc = context.read(); + final NftWithdrawState state = bloc.state; + + if (state is NftWithdrawFillState) { + bloc.add( + const NftWithdrawSendEvent(), + ); + } else if (state is NftWithdrawConfirmState) { + bloc.add(const NftWithdrawConfirmSendEvent()); + } + } + + void _onBack(BuildContext context) { + final bloc = context.read(); + final NftWithdrawState state = bloc.state; + if (state is NftWithdrawFillState) { + routingState.nftsState.setDetailsAction(state.nft.uuid, false); + } else { + bloc.add(const NftWithdrawShowFillStep()); + } + } +} + +class _PrimaryButton extends StatelessWidget { + const _PrimaryButton({ + required this.text, + required this.isSending, + required this.onPressed, + }); + final String text; + final bool isSending; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension()!; + return UiPrimaryButton( + text: text, + prefix: isSending + ? Padding( + padding: const EdgeInsets.only(right: 8.0), + child: UiSpinner( + color: colorScheme.secondary, + ), + ) + : null, + height: 40, + onPressed: isSending ? null : onPressed, + ); + } +} + +class _SecondaryButton extends StatelessWidget { + const _SecondaryButton({ + required this.text, + required this.onPressed, + }); + final String text; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension()!; + return UiBorderButton( + height: 40, + text: text, + textColor: colorScheme.secondary, + borderColor: colorScheme.secondary, + backgroundColor: colorScheme.surfContLowest, + borderWidth: 2, + onPressed: onPressed); + } +} diff --git a/lib/views/nfts/details_page/withdraw/nft_withdraw_form.dart b/lib/views/nfts/details_page/withdraw/nft_withdraw_form.dart new file mode 100644 index 0000000000..c05f6716b2 --- /dev/null +++ b/lib/views/nfts/details_page/withdraw/nft_withdraw_form.dart @@ -0,0 +1,238 @@ +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:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/nft.dart'; + +class NftWithdrawForm extends StatelessWidget { + const NftWithdrawForm({ + super.key, + required this.state, + }); + + final NftWithdrawFillState state; + + @override + Widget build(BuildContext context) { + final sendError = state.sendError; + final addressError = state.addressError; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (state.nft.contractType == NftContractType.erc1155) + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + flex: 7, + child: _AddressField( + address: state.address, + textInputAction: TextInputAction.next, + error: addressError, + )), + Flexible( + flex: 3, + child: Padding( + padding: const EdgeInsets.only(left: 8.0), + child: _AmountField( + amount: state.amount, + error: state.amountError, + isEnabled: + state.nft.contractType == NftContractType.erc1155, + ), + ), + ), + ], + ) + else + _AddressField( + address: state.address, + error: addressError, + textInputAction: TextInputAction.done, + ), + if (addressError is MixedCaseAddressError) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: _MixedAddressError( + error: addressError, + )), + if (sendError != null) + Padding( + padding: const EdgeInsets.only(top: 4.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 40), + child: SingleChildScrollView( + child: SelectableText( + sendError.message, + style: Theme.of(context).inputDecorationTheme.errorStyle, + ), + ), + ), + ), + ], + ); + } +} + +class _AddressField extends StatefulWidget { + const _AddressField({ + required this.address, + required this.textInputAction, + required this.error, + }); + final String address; + final TextInputAction textInputAction; + final BaseError? error; + + @override + State<_AddressField> createState() => __AddressFieldState(); +} + +class __AddressFieldState extends State<_AddressField> { + final TextEditingController _addressController = TextEditingController(); + TextSelection _previousTextSelection = + const TextSelection.collapsed(offset: 0); + @override + Widget build(BuildContext context) { + InputBorder? errorBorder; + TextStyle? errorStyle; + final error = widget.error; + + if (error != null) { + final theme = Theme.of(context); + + errorBorder = theme.inputDecorationTheme.errorBorder; + errorStyle = theme.inputDecorationTheme.errorStyle; + } + _addressController + ..text = widget.address + ..selection = _previousTextSelection; + + return UiTextFormField( + controller: _addressController, + autocorrect: false, + autofocus: true, + textInputAction: widget.textInputAction, + enableInteractiveSelection: true, + onChanged: (_) { + _previousTextSelection = _addressController.selection; + context + .read() + .add(NftWithdrawAddressChanged(_addressController.text)); + }, + validationMode: InputValidationMode.aggressive, + inputFormatters: [LengthLimitingTextInputFormatter(256)], + hintText: LocaleKeys.recipientAddress.tr(), + hintTextStyle: errorStyle, + labelStyle: errorStyle, + errorStyle: errorStyle, + style: errorStyle, + enabledBorder: errorBorder, + focusedBorder: errorBorder, + validator: (_) => error is MixedCaseAddressError ? null : error?.message, + errorMaxLines: 2, + ); + } +} + +class _AmountField extends StatefulWidget { + const _AmountField({ + required this.amount, + required this.error, + required this.isEnabled, + }); + final int? amount; + final bool isEnabled; + final BaseError? error; + + @override + State<_AmountField> createState() => __AmountFieldState(); +} + +class __AmountFieldState extends State<_AmountField> { + final TextEditingController _amountController = TextEditingController(); + TextSelection _previousTextSelection = + const TextSelection.collapsed(offset: 0); + + @override + Widget build(BuildContext context) { + final bool isEnabled = widget.isEnabled; + final int? amount = widget.amount; + final error = widget.error; + + InputBorder? errorBorder; + TextStyle? errorStyle; + + if (error != null) { + final theme = Theme.of(context); + + errorBorder = theme.inputDecorationTheme.errorBorder; + errorStyle = theme.inputDecorationTheme.errorStyle; + } + _amountController + ..text = amount?.toString() ?? '' + ..selection = _previousTextSelection; + + return UiTextFormField( + enabled: isEnabled, + controller: _amountController, + validationMode: InputValidationMode.aggressive, + onChanged: (_) { + _previousTextSelection = _amountController.selection; + + context.read().add( + NftWithdrawAmountChanged(int.tryParse(_amountController.text))); + }, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'^[0-9]+$'))], + keyboardType: TextInputType.number, + textInputAction: TextInputAction.done, + hintText: LocaleKeys.amount.tr(), + hintTextStyle: errorStyle, + labelStyle: errorStyle, + errorStyle: errorStyle, + style: errorStyle, + enabledBorder: errorBorder, + focusedBorder: errorBorder, + validator: (_) => widget.error?.message, + ); + } +} + +class _MixedAddressError extends StatelessWidget { + const _MixedAddressError({required this.error}); + final MixedCaseAddressError error; + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).extension()!; + final colorScheme = Theme.of(context).extension()!; + return Row( + children: [ + Flexible( + child: SelectableText( + error.message, + style: const TextStyle(fontSize: 12), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: UiPrimaryButton( + text: LocaleKeys.convert.tr(), + width: 80, + height: 30, + textStyle: textTheme.bodyXSBold.copyWith(color: colorScheme.surf), + onPressed: () => context + .read() + .add(const NftWithdrawConvertAddress()), + ), + ), + ], + ); + } +} diff --git a/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart b/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart new file mode 100644 index 0000000000..41fd138588 --- /dev/null +++ b/lib/views/nfts/details_page/withdraw/nft_withdraw_success.dart @@ -0,0 +1,137 @@ +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:flutter_svg/svg.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/hash_explorer_link.dart'; +import 'package:web_dex/views/nfts/common/widgets/nft_image.dart'; +import 'package:web_dex/views/nfts/details_page/common/nft_data_row.dart'; + +class NftWithdrawSuccess extends StatefulWidget { + const NftWithdrawSuccess({super.key, required this.state}); + final NftWithdrawSuccessState state; + + @override + State createState() => _NftWithdrawSuccessState(); +} + +class _NftWithdrawSuccessState extends State { + @override + void dispose() { + context.read().add(const NftWithdrawInit()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension()!; + final textTheme = Theme.of(context).extension()!; + final nft = widget.state.nft; + + return Container( + padding: const EdgeInsets.all(20.0), + decoration: BoxDecoration( + 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, + ), + 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( + 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, + ), + 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(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( + 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, + ), + ), + ), + 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/details_page/withdraw/nft_withdraw_view.dart b/lib/views/nfts/details_page/withdraw/nft_withdraw_view.dart new file mode 100644 index 0000000000..e9a54d811f --- /dev/null +++ b/lib/views/nfts/details_page/withdraw/nft_withdraw_view.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/nft_withdraw/nft_withdraw_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/views/nfts/details_page/withdraw/nft_withdraw_confirmation.dart'; +import 'package:web_dex/views/nfts/details_page/withdraw/nft_withdraw_footer.dart'; +import 'package:web_dex/views/nfts/details_page/withdraw/nft_withdraw_form.dart'; +import 'package:web_dex/views/nfts/details_page/withdraw/nft_withdraw_success.dart'; + +class NftWithdrawView extends StatefulWidget { + const NftWithdrawView({ + super.key, + required this.nft, + }); + final NftToken nft; + + @override + State createState() => _NftWithdrawViewState(); +} + +class _NftWithdrawViewState extends State { + @override + void initState() { + final bloc = context.read(); + bloc.add(const NftWithdrawInit()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (isMobile) { + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + Builder(builder: (context) { + switch (state) { + case NftWithdrawFillState(): + return Padding( + padding: const EdgeInsets.only(bottom: 28.0), + child: NftWithdrawForm(state: state), + ); + case NftWithdrawConfirmState(): + return NftWithdrawConfirmation(state: state); + case NftWithdrawSuccessState(): + return Padding( + padding: const EdgeInsets.only(bottom: 64.0), + child: NftWithdrawSuccess(state: state), + ); + default: + return const SizedBox.shrink(); + } + }), + const SizedBox(height: 12), + const NftWithdrawFooter(), + ], + ); + } + return Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (state is! NftWithdrawSuccessState) const Spacer(), + Builder(builder: (context) { + switch (state) { + case NftWithdrawFillState(): + return NftWithdrawForm(state: state); + case NftWithdrawConfirmState(): + return NftWithdrawConfirmation(state: state); + case NftWithdrawSuccessState(): + return NftWithdrawSuccess(state: state); + default: + return const SizedBox.shrink(); + } + }), + if (state is NftWithdrawSuccessState) + const Spacer() + else + SizedBox(height: state is NftWithdrawFillState ? 44 : 12), + const NftWithdrawFooter(), + ], + ); + }, + ); + } +} diff --git a/lib/views/nfts/nft_list/nft_list.dart b/lib/views/nfts/nft_list/nft_list.dart new file mode 100644 index 0000000000..04a49f453c --- /dev/null +++ b/lib/views/nfts/nft_list/nft_list.dart @@ -0,0 +1,169 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/nfts/nft_main_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/nfts/nft_list/nft_list_item.dart'; +import 'package:web_dex/views/nfts/nft_main/nft_refresh_button.dart'; + +const _nftItemMobileSize = Size(169, 207); +const _maxNftItemSize = Size(248, 308); +const double _paddingBetweenNft = 12; + +class NftList extends StatelessWidget { + const NftList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final List? nftList = + context.select?>( + (bloc) => bloc.state.nfts[bloc.state.selectedChain], + ); + final bool isInitialized = + context.select((bloc) => bloc.state.isInitialized); + final List list = nftList ?? []; + + if (list.isEmpty && isInitialized) { + return isMobile + ? const SizedBox( + width: double.infinity, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _NothingShow(), + SizedBox(height: 24), + NftRefreshButton(), + ], + ), + ) + : Container( + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(top: 72.0), + child: const Column( + children: [ + _NothingShow(), + SizedBox(height: 24), + NftRefreshButton(), + ], + ), + ); + } + + return _Layout( + nftList: list, + onTap: _onNftTap, + onSendTap: _onSendNftTap, + ); + } + + void _onNftTap(String uuid) { + routingState.nftsState.setDetailsAction(uuid, false); + } + + void _onSendNftTap(String uuid) { + routingState.nftsState.setDetailsAction(uuid, true); + } +} + +class _Layout extends StatelessWidget { + const _Layout({ + required this.nftList, + required this.onTap, + required this.onSendTap, + }); + final List nftList; + final Function(String) onTap; + final Function(String) onSendTap; + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + final count = _calculateNftCountInRow(constraints.maxWidth); + final ScrollController scrollController = ScrollController(); + return DexScrollbar( + scrollController: scrollController, + isMobile: isMobile, + child: SingleChildScrollView( + controller: scrollController, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + mainAxisSpacing: _paddingBetweenNft, + crossAxisSpacing: _paddingBetweenNft, + crossAxisCount: count, + mainAxisExtent: isMobile + ? _nftItemMobileSize.height + : _maxNftItemSize.height, + ), + itemCount: nftList.length, + itemBuilder: (context, index) => NftListItem( + key: ValueKey(nftList[index].uuid), + nft: nftList[index], + onTap: onTap, + onSendTap: onSendTap, + ), + ), + const SizedBox(height: 40), + const Align( + alignment: Alignment.center, + child: NftRefreshButton(), + ), + ], + ), + ), + ); + }, + ); + } + + int _calculateNftCountInRow(double maxWidth) { + if (isDesktop) return 4; + if (isTablet) return 3; + + final maxCount = maxWidth / _nftItemMobileSize.width; + if (nftList.length == 1 && maxCount > 2.5) { + return 2; + } + final max = maxCount.toInt(); + if (nftList.length <= max) { + return nftList.length; + } + return max; + } +} + +class _NothingShow extends StatelessWidget { + const _NothingShow(); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).extension()!; + final String? chain = context.select( + (bloc) => bloc.state.selectedChain.coinAbbr(), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocaleKeys.noCollectibles.tr(), + style: textTheme.heading1, + ), + Text( + LocaleKeys.tryReceiveNft.tr(args: [chain ?? '']), + style: textTheme.bodyM, + ), + ], + ); + } +} diff --git a/lib/views/nfts/nft_list/nft_list_item.dart b/lib/views/nfts/nft_list/nft_list_item.dart new file mode 100644 index 0000000000..faa7be1919 --- /dev/null +++ b/lib/views/nfts/nft_list/nft_list_item.dart @@ -0,0 +1,156 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/views/nfts/common/widgets/nft_image.dart'; + +class NftListItem extends StatefulWidget { + const NftListItem({ + super.key, + required this.nft, + required this.onTap, + required this.onSendTap, + }); + final NftToken nft; + final Function(String) onTap; + final Function(String) onSendTap; + + @override + State createState() => _NftListItemState(); +} + +class _NftListItemState extends State { + bool isHover = false; + + @override + Widget build(BuildContext context) { + const heightSlideUpOnHover = 4.0; + + return Stack( + clipBehavior: Clip.none, + children: [ + AnimatedPositioned( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + top: isHover ? -heightSlideUpOnHover : 0, + bottom: isHover ? heightSlideUpOnHover : 0, + left: 0, + right: 0, + child: MouseRegion( + onEnter: (_) => setState(() => isHover = true), + onExit: (_) => setState(() => isHover = false), + child: Card( + clipBehavior: Clip.hardEdge, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Slidable( + endActionPane: ActionPane( + motion: const ScrollMotion(), + extentRatio: 0.5, + children: [ + SlidableAction( + label: LocaleKeys.send.tr(), + onPressed: (_) => _onSendTap(), + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Theme.of(context).cardColor, + icon: Icons.send, + ), + ], + ), + child: InkWell( + onTap: _onTap, + child: GridTile( + 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), + ), + ), + + // Badge in top right corner to shown NFT amount + Positioned( + top: 8, + right: 8, + child: _NftAmount(nft: widget.nft), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ], + ); + } + + void _onTap() { + widget.onTap(widget.nft.uuid); + } + + void _onSendTap() { + widget.onSendTap(widget.nft.uuid); + } +} + +class _NftAmount extends StatelessWidget { + const _NftAmount({required this.nft}); + final NftToken nft; + + @override + Widget build(BuildContext context) { + if (nft.contractType != NftContractType.erc1155) { + return const SizedBox.shrink(); + } + return Card( + color: Theme.of(context).cardColor.withOpacity(0.8), + shape: const CircleBorder(), + child: Padding( + padding: const EdgeInsets.all(12), + child: Text( + nft.amount, + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ); + } +} + +class _NftData extends StatelessWidget { + 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, + ); + + @override + Widget build(BuildContext context) { + final mustShowSubtitle = + nft.collectionName != null && nft.name != nft.collectionName; + + return GridTileBar( + backgroundColor: Theme.of(context).cardColor.withOpacity(0.9), + title: _tileText(nft.name), + subtitle: !mustShowSubtitle ? null : _tileText(nft.collectionName!), + trailing: const Icon(Icons.more_vert), + ); + } +} diff --git a/lib/views/nfts/nft_main/nft_main.dart b/lib/views/nfts/nft_main/nft_main.dart new file mode 100644 index 0000000000..456b3c7738 --- /dev/null +++ b/lib/views/nfts/nft_main/nft_main.dart @@ -0,0 +1,94 @@ +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:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/nfts/nft_main_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/views/nfts/common/widgets/nft_connect_wallet.dart'; +import 'package:web_dex/views/nfts/nft_list/nft_list.dart'; +import 'package:web_dex/views/nfts/nft_main/nft_main_controls.dart'; +import 'package:web_dex/views/nfts/nft_main/nft_main_failure.dart'; +import 'package:web_dex/views/nfts/nft_main/nft_main_loading.dart'; +import 'package:web_dex/views/nfts/nft_tabs/nft_tabs.dart'; + +class NftMain extends StatelessWidget { + const NftMain({super.key}); + + @override + Widget build(BuildContext context) { + final bool isLoggedIn = context.select( + (bloc) => bloc.state.mode == AuthorizeMode.logIn); + final bool isInitial = + context.select((bloc) => !bloc.state.isInitialized); + + final bool hasLoaded = context.select( + (bloc) => bloc.state.sortedChains.isNotEmpty); + if (isLoggedIn && (isInitial || !hasLoaded)) { + return const NftMainLoading(); + } + final ColorSchemeExtension colorScheme = + Theme.of(context).extension()!; + final textTheme = Theme.of(context).extension()!; + final List tabs = + context.select>( + (bloc) => bloc.state.sortedChains); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isMobile) + Container( + padding: const EdgeInsets.only(top: 50, bottom: 15), + alignment: Alignment.center, + child: Text( + LocaleKeys.yourCollectibles.tr(), + textAlign: TextAlign.center, + style: textTheme.bodyMBold.copyWith(color: colorScheme.secondary), + ), + ), + Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: colorScheme.surfContHighest, + ), + ), + ), + child: NftTabs(tabs: tabs), + ), + const SizedBox(height: 20), + const NftMainControls(), + const SizedBox(height: 20), + Flexible( + child: Builder(builder: (context) { + final mode = context + .select((bloc) => bloc.state.mode); + if (mode != AuthorizeMode.logIn) { + return isMobile + ? const Center(child: NftConnectWallet()) + : const Row( + mainAxisAlignment: MainAxisAlignment.center, + key: Key('msg-connect-wallet'), + children: [NftConnectWallet()], + ); + } + + final BaseError? error = context + .select((bloc) => bloc.state.error); + if (error != null) { + return NftMainFailure(error: error); + } + + return const NftList(); + }), + ), + ], + ); + } +} diff --git a/lib/views/nfts/nft_main/nft_main_controls.dart b/lib/views/nfts/nft_main/nft_main_controls.dart new file mode 100644 index 0000000000..ae80a195ac --- /dev/null +++ b/lib/views/nfts/nft_main/nft_main_controls.dart @@ -0,0 +1,102 @@ +import 'dart:math' as math; + +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/nfts/nft_main_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_wrapper.dart'; + +class NftMainControls extends StatefulWidget { + const NftMainControls({super.key}); + + @override + State createState() => _NftMainControlsState(); +} + +class _NftMainControlsState extends State { + PopupDispatcher? _popupDispatcher; + + @override + void dispose() { + _popupDispatcher?.close(); + _popupDispatcher = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ColorSchemeExtension colorScheme = + Theme.of(context).extension()!; + final textTheme = Theme.of(context).extension()!; + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + UiPrimaryButton( + text: LocaleKeys.receiveNft.tr(), + key: const Key('nft-receive-btn'), + width: 140, + height: 40, + backgroundColor: colorScheme.surfContHighest, + prefix: Transform.rotate( + angle: math.pi / 4, + child: Icon( + Icons.arrow_forward, + color: colorScheme.primary, + )), + onPressed: _onReceiveNft, + textStyle: textTheme.bodySBold.copyWith(color: colorScheme.primary), + ), + const Spacer(), + UiPrimaryButton( + text: LocaleKeys.transactions.tr(), + key: const Key('nft-transactions-btn'), + onPressed: _onTransactionsNFT, + width: 140, + height: 40, + backgroundColor: Colors.transparent, + textStyle: textTheme.bodySBold.copyWith(color: colorScheme.primary), + ), + ], + ); + } + + void _onReceiveNft() { + final mode = context.read().isLoggedIn; + if (mode) { + routingState.nftsState.setReceiveAction(); + } else { + _popupDispatcher = _createPopupDispatcher(); + _popupDispatcher?.show(); + } + } + + void _onTransactionsNFT() { + routingState.nftsState.setTransactionsAction(); + } + + PopupDispatcher _createPopupDispatcher() { + final NftMainBloc nftBloc = context.read(); + + return PopupDispatcher( + borderColor: theme.custom.specificButtonBorderColor, + barrierColor: isMobile ? Theme.of(context).colorScheme.onSurface : null, + width: 320, + context: scaffoldKey.currentContext ?? context, + popupContent: WalletsManagerWrapper( + eventType: WalletsManagerEventType.header, + onSuccess: (_) async { + nftBloc.add(const UpdateChainNftsEvent()); + _popupDispatcher?.close(); + }, + ), + ); + } +} diff --git a/lib/views/nfts/nft_main/nft_main_failure.dart b/lib/views/nfts/nft_main/nft_main_failure.dart new file mode 100644 index 0000000000..82ed428dd6 --- /dev/null +++ b/lib/views/nfts/nft_main/nft_main_failure.dart @@ -0,0 +1,34 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/nfts/nft_main_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/errors.dart'; +import 'package:web_dex/views/nfts/common/widgets/nft_failure.dart'; + +class NftMainFailure extends StatelessWidget { + final BaseError error; + + const NftMainFailure({ + Key? key, + required this.error, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final String? chain = context.select( + (bloc) => bloc.state.selectedChain.coinAbbr()); + return NftFailure( + title: LocaleKeys.loadingError.tr(), + subtitle: LocaleKeys.unableRetrieveNftData.tr(args: [chain ?? '']), + additionSubtitle: error is TransportError + ? LocaleKeys.tryCheckInternetConnection.tr() + : null, + message: error.message, + onTryAgain: () { + context.read().add(const UpdateChainNftsEvent()); + }, + ); + } +} diff --git a/lib/views/nfts/nft_main/nft_main_loading.dart b/lib/views/nfts/nft_main/nft_main_loading.dart new file mode 100644 index 0000000000..6b4b818c3b --- /dev/null +++ b/lib/views/nfts/nft_main/nft_main_loading.dart @@ -0,0 +1,32 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class NftMainLoading extends StatelessWidget { + const NftMainLoading({super.key}); + + @override + Widget build(BuildContext context) { + final ColorSchemeExtension colorScheme = + Theme.of(context).extension()!; + final textTheme = Theme.of(context).extension()!; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.only(top: 50, bottom: 15), + alignment: Alignment.center, + child: Text( + LocaleKeys.loadingNfts.tr(), + textAlign: TextAlign.center, + style: textTheme.bodyMBold.copyWith(color: colorScheme.secondary), + ), + ), + const UiSpinnerList() + ], + ); + } +} diff --git a/lib/views/nfts/nft_main/nft_refresh_button.dart b/lib/views/nfts/nft_main/nft_refresh_button.dart new file mode 100644 index 0000000000..2c6720dcf2 --- /dev/null +++ b/lib/views/nfts/nft_main/nft_refresh_button.dart @@ -0,0 +1,40 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/nfts/nft_main_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/nft.dart'; + +class NftRefreshButton extends StatelessWidget { + const NftRefreshButton({super.key}); + + @override + Widget build(BuildContext context) { + final ColorSchemeExtension colorScheme = + Theme.of(context).extension()!; + final textTheme = Theme.of(context).extension()!; + final selectedChain = context.select( + (bloc) => bloc.state.selectedChain); + final isUpdating = context.select( + (bloc) => bloc.state.updatingChains[selectedChain] ?? false); + + return UiPrimaryButton( + width: 200, + height: 40, + backgroundColor: Colors.transparent, + textStyle: textTheme.bodySBold.copyWith(color: colorScheme.primary), + onPressed: () { + final bloc = context.read(); + bloc.add(RefreshNFTsForChainEvent(selectedChain)); + }, + text: LocaleKeys.refreshList.tr(args: [selectedChain.coinAbbr()]), + child: isUpdating + ? UiSpinner( + color: colorScheme.primary, + ) + : null, + ); + } +} diff --git a/lib/views/nfts/nft_page.dart b/lib/views/nfts/nft_page.dart new file mode 100644 index 0000000000..1c0db1d9e1 --- /dev/null +++ b/lib/views/nfts/nft_page.dart @@ -0,0 +1,106 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/coins_bloc/coins_repo.dart'; +import 'package:web_dex/bloc/nft_transactions/nft_txn_repository.dart'; +import 'package:web_dex/bloc/nfts/nft_main_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_state.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/mm2/mm2_api/mm2_api.dart'; +import 'package:web_dex/router/state/nfts_state.dart'; +import 'package:web_dex/views/common/pages/page_layout.dart'; +import 'package:web_dex/views/nfts/details_page/nft_details_page.dart'; +import 'package:web_dex/views/nfts/nft_main/nft_main.dart'; +import 'package:web_dex/views/nfts/nft_receive/nft_receive_page.dart'; +import 'package:web_dex/views/nfts/nft_transactions/nft_txn_page.dart'; + +class NftPage extends StatelessWidget { + const NftPage({super.key, required this.pageState, required this.uuid}); + + final NFTSelectedState pageState; + final String uuid; + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) { + return state.themeMode; + }, + builder: (context, themeMode) { + final isLightTheme = themeMode == ThemeMode.light; + return Theme( + data: isLightTheme ? newThemeLight : newThemeDark, + child: MultiRepositoryProvider( + providers: [ + RepositoryProvider( + create: (context) => NftTxnRepository( + api: mm2Api.nft, + coinsRepo: coinsRepo, + ), + ), + ], + child: NFTPageView( + pageState: pageState, + uuid: uuid, + ), + ), + ); + }, + ); + } +} + +class NFTPageView extends StatefulWidget { + final NFTSelectedState pageState; + final String uuid; + const NFTPageView({super.key, required this.pageState, required this.uuid}); + + @override + State createState() => _NFTPageViewState(); +} + +class _NFTPageViewState extends State { + late NftMainBloc _nftMainBloc; + @override + void initState() { + _nftMainBloc = context.read(); + _nftMainBloc.add(const UpdateChainNftsEvent()); + _nftMainBloc.add(const StartUpdateNftsEvent()); + super.initState(); + } + + @override + void dispose() { + _nftMainBloc.add(const StopUpdateNftEvent()); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return 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(); + } + }), + ), + ), + ); + } +} diff --git a/lib/views/nfts/nft_receive/common/nft_failure_page.dart b/lib/views/nfts/nft_receive/common/nft_failure_page.dart new file mode 100644 index 0000000000..6b199133e8 --- /dev/null +++ b/lib/views/nfts/nft_receive/common/nft_failure_page.dart @@ -0,0 +1,70 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class NftReceiveFailurePage extends StatelessWidget { + final String message; + final VoidCallback onReload; + + const NftReceiveFailurePage({ + Key? key, + required this.message, + required this.onReload, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final ext = Theme.of(context).extension(); + final textTheme = Theme.of(context).extension(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 15.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: ext!.error, width: 6), + ), + child: Icon(Icons.close_rounded, size: 66, color: ext.error), + ), + ), + Center( + child: Text( + LocaleKeys.loadingError.tr(), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: ext.error, + ), + ), + ), + Center( + child: Container( + padding: const EdgeInsets.all(20), + width: 324, + decoration: BoxDecoration( + color: theme.custom.subCardBackgroundColor, + borderRadius: BorderRadius.circular(18)), + child: SelectableText.rich( + TextSpan( + text: message, + ), + textAlign: TextAlign.center, + style: textTheme?.bodyS, + )), + ), + const SizedBox(height: 20), + Center( + child: UiPrimaryButton( + text: LocaleKeys.retryButtonText.tr(), + width: 324, + onPressed: onReload, + ), + ), + const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/views/nfts/nft_receive/common/nft_receive_card.dart b/lib/views/nfts/nft_receive/common/nft_receive_card.dart new file mode 100644 index 0000000000..c362221c47 --- /dev/null +++ b/lib/views/nfts/nft_receive/common/nft_receive_card.dart @@ -0,0 +1,131 @@ +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:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/shared/widgets/nft/nft_badge.dart'; +import 'package:web_dex/views/wallet/coin_details/receive/qr_code_address.dart'; +import 'package:web_dex/views/wallet/coin_details/receive/receive_address.dart'; + +enum NftReceiveCardAlignment { top, bottom } + +class NftReceiveCard extends StatelessWidget { + final String? currentAddress; + final double qrCodeSize; + final void Function(String?) onAddressChanged; + final Coin coin; + final double maxWidth; + + const NftReceiveCard({ + Key? key, + required this.currentAddress, + required this.qrCodeSize, + required this.onAddressChanged, + required this.coin, + this.maxWidth = 343, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension()!; + final textTheme = Theme.of(context).extension()!; + final address = currentAddress; + final chain = fromCoinToChain(coin); + return Container( + padding: const EdgeInsets.all(20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: colorScheme.surfContLow, + ), + constraints: BoxConstraints(maxWidth: maxWidth), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + LocaleKeys.network.tr(), + style: textTheme.bodyS, + ), + if (chain != null) BlockchainBadge(blockchain: chain), + ], + ), + const SizedBox(height: 16), + Text( + LocaleKeys.scanToGetAddress.tr(), + style: textTheme.bodyS, + ), + const SizedBox(height: 16), + if (address != null) + Column( + children: [ + Container( + padding: const EdgeInsets.all(10.0), + width: qrCodeSize, + height: qrCodeSize, + decoration: BoxDecoration( + border: Border.all(color: colorScheme.surfContHighest), + borderRadius: BorderRadius.circular(20.0), + ), + child: BlocSelector( + selector: (state) => state.themeMode, + builder: (context, state) { + final isDarkTheme = state == ThemeMode.dark; + final Color foregroundColor = + isDarkTheme ? Colors.white : Colors.black; + final Color backgroundColor = + isDarkTheme ? colorScheme.surfContLow : Colors.white; + + return QRCodeAddress( + currentAddress: address, + borderRadius: BorderRadius.circular(0), + padding: EdgeInsets.zero, + backgroundColor: backgroundColor, + foregroundColor: foregroundColor, + ); + }, + ), + ), + const SizedBox(height: 16), + Text( + LocaleKeys.ercStandardDisclaimer.tr(), + style: textTheme.bodyXS.copyWith(color: colorScheme.orange), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ReceiveAddress( + coin: coin, + selectedAddress: address, + onChanged: onAddressChanged, + backgroundColor: colorScheme.surfContHighest, + ), + ], + ), + ], + ), + ); + } + + NftBlockchains? fromCoinToChain(Coin coin) { + switch (coin.abbr) { + case 'ETH': + return NftBlockchains.eth; + case 'BNB': + return NftBlockchains.bsc; + case 'AVAX': + return NftBlockchains.avalanche; + case 'MATIC': + return NftBlockchains.polygon; + case 'FTM': + return NftBlockchains.fantom; + default: + return null; + } + } +} diff --git a/lib/views/nfts/nft_receive/desktop/nft_receive_desktop_view.dart b/lib/views/nfts/nft_receive/desktop/nft_receive_desktop_view.dart new file mode 100644 index 0000000000..b9949717ba --- /dev/null +++ b/lib/views/nfts/nft_receive/desktop/nft_receive_desktop_view.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/nfts/nft_receive/common/nft_receive_card.dart'; + +class NftReceiveDesktopView extends StatelessWidget { + final Coin coin; + final String? currentAddress; + final void Function(String?) onAddressChanged; + + const NftReceiveDesktopView({ + super.key, + required this.coin, + required this.currentAddress, + required this.onAddressChanged, + }); + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.center, + child: NftReceiveCard( + currentAddress: currentAddress, + qrCodeSize: 200, + onAddressChanged: onAddressChanged, + coin: coin, + ), + ); + } +} diff --git a/lib/views/nfts/nft_receive/mobile/nft_receive_mobile_view.dart b/lib/views/nfts/nft_receive/mobile/nft_receive_mobile_view.dart new file mode 100644 index 0000000000..d867d0ffc9 --- /dev/null +++ b/lib/views/nfts/nft_receive/mobile/nft_receive_mobile_view.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/nfts/nft_receive/common/nft_receive_card.dart'; + +class NftReceiveMobileView extends StatelessWidget { + final void Function(String?) onAddressChanged; + final Coin coin; + final String? currentAddress; + const NftReceiveMobileView({ + Key? key, + required this.onAddressChanged, + required this.coin, + required this.currentAddress, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.maxFinite, + child: NftReceiveCard( + onAddressChanged: onAddressChanged, + coin: coin, + currentAddress: currentAddress, + qrCodeSize: 260, + maxWidth: double.infinity, + ), + ); + } +} diff --git a/lib/views/nfts/nft_receive/nft_receive_page.dart b/lib/views/nfts/nft_receive/nft_receive_page.dart new file mode 100644 index 0000000000..947494cc57 --- /dev/null +++ b/lib/views/nfts/nft_receive/nft_receive_page.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/nft_receive/bloc/nft_receive_bloc.dart'; +import 'package:web_dex/bloc/nfts/nft_main_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/views/nfts/nft_receive/nft_receive_view.dart'; + +class NftReceivePage extends StatelessWidget { + const NftReceivePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return BlocProvider( + create: (context) => NftReceiveBloc( + coinsRepo: coinsBloc, + currentWalletBloc: currentWalletBloc, + )..add(NftReceiveEventInitial(chain: state.selectedChain)), + child: NftReceiveView(), + ); + }, + ); + } +} diff --git a/lib/views/nfts/nft_receive/nft_receive_view.dart b/lib/views/nfts/nft_receive/nft_receive_view.dart new file mode 100644 index 0000000000..032b6d3bf3 --- /dev/null +++ b/lib/views/nfts/nft_receive/nft_receive_view.dart @@ -0,0 +1,81 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/nft_receive/bloc/nft_receive_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/common/page_header/page_header.dart'; +import 'package:web_dex/views/nfts/nft_receive/common/nft_failure_page.dart'; +import 'package:web_dex/views/nfts/nft_receive/desktop/nft_receive_desktop_view.dart'; +import 'package:web_dex/views/nfts/nft_receive/mobile/nft_receive_mobile_view.dart'; +import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/backup_seed_notification.dart'; + +class NftReceiveView extends StatelessWidget { + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return Stack( + children: [ + SizedBox( + height: 50, + width: double.maxFinite, + child: PageHeader( + title: '', + backText: LocaleKeys.collectibles.tr(), + onBackButtonPressed: routingState.nftsState.reset, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 50), + child: DexScrollbar( + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: BlocBuilder( + builder: (context, state) { + if (state is NftReceiveInitial) { + return const Center(child: UiSpinner()); + } else if (state is NftReceiveHasBackup) { + return const BackupNotification(); + } else if (state is NftReceiveFailure) { + return NftReceiveFailurePage( + message: state.message ?? + LocaleKeys.pleaseTryActivateAssets.tr(), + onReload: () { + context + .read() + .add(const NftReceiveEventRefresh()); + }, + ); + } else if (state is NftReceiveAddress) { + return isMobile + ? NftReceiveMobileView( + coin: state.coin, + currentAddress: state.address, + onAddressChanged: (value) => context + .read() + .add( + NftReceiveEventChangedAddress(address: value), + ), + ) + : NftReceiveDesktopView( + coin: state.coin, + currentAddress: state.address, + onAddressChanged: (value) => context + .read() + .add( + NftReceiveEventChangedAddress(address: value), + ), + ); + } + return const SizedBox(); + }), + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/nfts/nft_tabs/nft_tab.dart b/lib/views/nfts/nft_tabs/nft_tab.dart new file mode 100644 index 0000000000..f65037de56 --- /dev/null +++ b/lib/views/nfts/nft_tabs/nft_tab.dart @@ -0,0 +1,146 @@ +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:flutter_svg/svg.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/nfts/nft_main_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/nft.dart'; + +class NftTab extends StatelessWidget { + const NftTab({ + super.key, + required this.chain, + required this.isFirst, + required this.onTap, + }); + final NftBlockchains chain; + final bool isFirst; + final void Function(NftBlockchains) onTap; + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final ColorSchemeExtension colorScheme = + themeData.extension()!; + final TextThemeExtension textTheme = + themeData.extension()!; + + return BlocSelector( + selector: (state) { + return state.selectedChain; + }, + builder: (context, selectedChain) { + final bool isSelected = selectedChain == chain; + return InkWell( + key: Key('nft-tab-bnt-$chain'), + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + onTap: () { + onTap(chain); + Scrollable.ensureVisible( + context, + duration: const Duration(milliseconds: 300), + curve: Curves.easeIn, + ); + }, + child: Container( + padding: EdgeInsets.only(left: isFirst ? 0 : 20, bottom: 8), + decoration: isSelected + ? BoxDecoration( + border: Border( + bottom: BorderSide(color: colorScheme.secondary), + ), + ) + : null, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 24, + height: 24, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isSelected ? colorScheme.secondary : colorScheme.s40, + ), + child: Center( + child: SvgPicture.asset( + '$assetsPath/blockchain_icons/svg/32px/${chain.toApiRequest().toLowerCase()}.svg', + width: 16, + height: 16, + key: Key('nft-tab-btn-icon-$chain'), + colorFilter: ColorFilter.mode( + isSelected ? colorScheme.surf : colorScheme.s70, + BlendMode.srcIn, + ), + ), + ), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _title, + key: Key('nft-tab-btn-text-$chain'), + style: textTheme.bodySBold.copyWith( + color: isSelected + ? colorScheme.secondary + : colorScheme.s50, + ), + ), + _NftCount(chain: chain), + ], + ), + ], + ), + ), + ); + }, + ); + } + + String get _title { + switch (chain) { + case NftBlockchains.eth: + return 'Ethereum'; + case NftBlockchains.bsc: + return 'BNB Smart Chain'; + case NftBlockchains.avalanche: + return 'Avalanche C-Chain'; + case NftBlockchains.polygon: + return 'Polygon'; + case NftBlockchains.fantom: + return 'Fantom'; + } + } +} + +class _NftCount extends StatelessWidget { + //ignore: unused_element + const _NftCount({super.key, required this.chain}); + + final NftBlockchains chain; + + @override + Widget build(BuildContext context) { + return BlocSelector>( + selector: (state) { + return state.nftCount; + }, + builder: (context, nftCount) { + final int? count = nftCount[chain]; + final ColorSchemeExtension colorScheme = + Theme.of(context).extension()!; + final TextThemeExtension textTheme = + Theme.of(context).extension()!; + return Text( + count != null ? LocaleKeys.nItems.tr(args: [count.toString()]) : '', + style: textTheme.bodyXXSBold.copyWith(color: colorScheme.s40), + key: Key('ntf-tab-count-$chain')); + }, + ); + } +} diff --git a/lib/views/nfts/nft_tabs/nft_tabs.dart b/lib/views/nfts/nft_tabs/nft_tabs.dart new file mode 100644 index 0000000000..9c541bcf9c --- /dev/null +++ b/lib/views/nfts/nft_tabs/nft_tabs.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/nfts/nft_main_bloc.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/shared/ui/fading_edge_scroll_view.dart'; +import 'package:web_dex/views/nfts/nft_tabs/nft_tab.dart'; + +class NftTabs extends StatelessWidget { + final List tabs; + const NftTabs({super.key, required this.tabs}); + + @override + Widget build(BuildContext context) { + onTap(NftBlockchains chain) => _onTap(chain, context); + final localTabs = tabs.isNotEmpty ? tabs : NftBlockchains.values; + return FadingEdgeScrollView.fromSingleChildScrollView( + gradientFractionOnStart: 0.4, + gradientFractionOnEnd: 0.4, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + controller: ScrollController(), + child: Row( + mainAxisSize: MainAxisSize.min, + children: localTabs + .map( + (NftBlockchains t) => NftTab( + chain: t, + key: Key('nft-tab-${t.name}'), + isFirst: localTabs.first == t, + onTap: onTap, + ), + ) + .toList(), + ), + )); + } + + void _onTap(NftBlockchains chain, BuildContext context) { + final bloc = context.read(); + bloc.add(ChangeNftTabEvent(chain)); + } +} diff --git a/lib/views/nfts/nft_transactions/common/pages/nft_txn_empty_page.dart b/lib/views/nfts/nft_transactions/common/pages/nft_txn_empty_page.dart new file mode 100644 index 0000000000..f7220b2ea2 --- /dev/null +++ b/lib/views/nfts/nft_transactions/common/pages/nft_txn_empty_page.dart @@ -0,0 +1,45 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class NftTxnEmpty extends StatelessWidget { + const NftTxnEmpty(); + + @override + Widget build(BuildContext context) { + return isMobile + ? const Center( + child: _NothingShow(), + ) + : Container( + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(top: 72.0), + child: const _NothingShow(), + ); + } +} + +class _NothingShow extends StatelessWidget { + const _NothingShow(); + + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).extension()!; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + LocaleKeys.transactionsEmptyTitle.tr(), + style: textTheme.heading1, + ), + const SizedBox(height: 16), + Text( + LocaleKeys.transactionsEmptyDescription.tr(), + style: textTheme.bodyM, + ), + ], + ); + } +} diff --git a/lib/views/nfts/nft_transactions/common/pages/nft_txn_failure_page.dart b/lib/views/nfts/nft_transactions/common/pages/nft_txn_failure_page.dart new file mode 100644 index 0000000000..faad6696b6 --- /dev/null +++ b/lib/views/nfts/nft_transactions/common/pages/nft_txn_failure_page.dart @@ -0,0 +1,74 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class NftTxnFailurePage extends StatelessWidget { + final String message; + final VoidCallback onReload; + + const NftTxnFailurePage({ + Key? key, + required this.message, + required this.onReload, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final ext = Theme.of(context).extension(); + final textStyle = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.secondary, + ); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 15.0), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: ext!.error, width: 6), + ), + child: Icon(Icons.close_rounded, size: 66, color: ext.error), + ), + ), + Center( + child: Text( + LocaleKeys.loadingError.tr(), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: ext.error, + ), + ), + ), + Center( + child: Container( + padding: const EdgeInsets.all(20), + width: 324, + decoration: BoxDecoration( + color: theme.custom.subCardBackgroundColor, + borderRadius: BorderRadius.circular(18)), + child: SelectableText.rich( + TextSpan( + text: message, + ), + textAlign: TextAlign.center, + style: textStyle, + )), + ), + const SizedBox(height: 20), + Center( + child: UiPrimaryButton( + text: LocaleKeys.tryAgainButton.tr(), + width: 324, + onPressed: onReload, + ), + ), + const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/views/nfts/nft_transactions/common/pages/nft_txn_loading_page.dart b/lib/views/nfts/nft_transactions/common/pages/nft_txn_loading_page.dart new file mode 100644 index 0000000000..e9da2b0442 --- /dev/null +++ b/lib/views/nfts/nft_transactions/common/pages/nft_txn_loading_page.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +class NftTxnLoading extends StatelessWidget { + const NftTxnLoading({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return const Center( + child: Padding( + padding: EdgeInsets.only(top: 150), + child: CircularProgressIndicator(), + ), + ); + } +} diff --git a/lib/views/nfts/nft_transactions/common/utils/formatter.dart b/lib/views/nfts/nft_transactions/common/utils/formatter.dart new file mode 100644 index 0000000000..033312f21c --- /dev/null +++ b/lib/views/nfts/nft_transactions/common/utils/formatter.dart @@ -0,0 +1,25 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; + +class NftTxFormatter { + static String getFeeValue(NftTransaction tx) { + final coinAbbr = tx.chain.coinAbbr(); + var f = NumberFormat("##0.00#####", "en_US"); + final double? feeValueNum = double.tryParse(tx.feeDetails?.feeValue ?? ''); + if (feeValueNum == null) return '-'; + + return '${f.format(feeValueNum)} $coinAbbr'; + } + + static String getUsdPriceOfFee(NftTransaction tx) { + final feeValue = tx.feeDetails?.feeValue; + final coinUsdPrice = tx.feeDetails?.coinUsdPrice; + if (feeValue == null) return '-'; + if (coinUsdPrice == null) return '-'; + + final double? feeValueNum = double.tryParse(feeValue); + if (feeValueNum == null) return '-'; + + return '${NumberFormat.decimalPatternDigits(locale: "en_US", decimalDigits: 7).format(feeValueNum * coinUsdPrice)} USD'; + } +} diff --git a/lib/views/nfts/nft_transactions/common/widgets/nft_txn_date.dart b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_date.dart new file mode 100644 index 0000000000..8ac472241c --- /dev/null +++ b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_date.dart @@ -0,0 +1,18 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; + +class NftTxnDate extends StatelessWidget { + final formatter = DateFormat('dd MMM yyyy, HH:mm', 'en_US'); + final DateTime blockTimestamp; + NftTxnDate({required this.blockTimestamp}); + + @override + Widget build(BuildContext context) { + final textStyle = Theme.of(context).extension()?.bodyXS; + return Text( + formatter.format(blockTimestamp), + style: textStyle, + ); + } +} diff --git a/lib/views/nfts/nft_transactions/common/widgets/nft_txn_hash.dart b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_hash.dart new file mode 100644 index 0000000000..785b93a48b --- /dev/null +++ b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_hash.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/hash_explorer_link.dart'; + +class NftTxnHash extends StatelessWidget { + const NftTxnHash({super.key, required this.transaction}); + final NftTransaction transaction; + + @override + Widget build(BuildContext context) { + final coin = coinsBloc.getCoin(transaction.chain.coinAbbr()); + if (coin == null) return const SizedBox.shrink(); + return HashExplorerLink( + coin: coin, + hash: transaction.transactionHash, + type: HashExplorerType.tx, + ); + } +} 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 new file mode 100644 index 0000000000..a5871d65ce --- /dev/null +++ b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart @@ -0,0 +1,65 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/views/nfts/common/widgets/nft_image.dart'; + +class NftTxnMedia extends StatelessWidget { + final String? imagePath; + final String? title; + final String collectionName; + final String amount; + const NftTxnMedia({ + required this.imagePath, + required this.title, + required this.collectionName, + required this.amount, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension(); + final textScheme = Theme.of(context).extension(); + final titleTextStyle = textScheme?.bodySBold; + + final subtitleTextStyle = textScheme?.bodyXS.copyWith( + color: colorScheme?.s50, + ); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 40, maxHeight: 40), + child: NftImage(imagePath: imagePath), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + 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), + ], + ), + ) + ], + ); + } +} diff --git a/lib/views/nfts/nft_transactions/common/widgets/nft_txn_status.dart b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_status.dart new file mode 100644 index 0000000000..74c497e4d4 --- /dev/null +++ b/lib/views/nfts/nft_transactions/common/widgets/nft_txn_status.dart @@ -0,0 +1,40 @@ +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/mm2/rpc/nft_transaction/nft_transactions_response.dart'; + +class NftTxnStatus extends StatelessWidget { + final NftTransactionStatuses? status; + const NftTxnStatus({required this.status}); + + @override + Widget build(BuildContext context) { + final statusIconPath = status == NftTransactionStatuses.receive + ? '$assetsPath/custom_icons/arrow_down.svg' + : '$assetsPath/custom_icons/arrow_up.svg'; + final colorScheme = Theme.of(context).extension()!; + final textScheme = Theme.of(context).extension()!; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + statusIconPath, + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + colorScheme.secondary, + BlendMode.srcIn, + ), + ), + const SizedBox(width: 4), + Text( + status.toString(), + style: textScheme.bodyXS.copyWith( + color: colorScheme.secondary, + ), + ), + ], + ); + } +} diff --git a/lib/views/nfts/nft_transactions/desktop/nft_txn_desktop_page.dart.dart b/lib/views/nfts/nft_transactions/desktop/nft_txn_desktop_page.dart.dart new file mode 100644 index 0000000000..c682980b17 --- /dev/null +++ b/lib/views/nfts/nft_transactions/desktop/nft_txn_desktop_page.dart.dart @@ -0,0 +1,103 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/nft_transactions/bloc/nft_transactions_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/nfts/common/widgets/nft_no_login.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/pages/nft_txn_empty_page.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/pages/nft_txn_failure_page.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/pages/nft_txn_loading_page.dart'; +import 'package:web_dex/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_card.dart'; +import 'package:web_dex/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_filters.dart'; +import 'package:web_dex/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_header.dart'; + +class NftTxnDesktopPage extends StatelessWidget { + const NftTxnDesktopPage({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: BlocBuilder( + builder: (context, state) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const NftTxnDesktopFilters(), + const SizedBox(height: 24), + const NftTxnDesktopHeader(), + const SizedBox(height: 4), + const Divider(height: 1), + Builder( + builder: (context) { + if (state.status == NftTxnStatus.noLogin) { + return SizedBox( + height: 250, + child: NftNoLogin( + key: const Key('nft-transactions-nologin-msg'), + text: LocaleKeys.transactionsNoLoginCAT.tr(), + ), + ); + } + if (state.status == NftTxnStatus.loading) { + return const NftTxnLoading(); + } + if (state.status == NftTxnStatus.failure) { + return NftTxnFailurePage( + message: state.errorMessage ?? '--', + onReload: () { + context + .read() + .add(const NftTxnReceiveEvent()); + }, + ); + } + + if (state.filteredTransactions.isEmpty) { + return const NftTxnEmpty(); + } + final scrollController = ScrollController(); + return Flexible( + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: ListView.separated( + controller: scrollController, + key: const Key('nft-page-transactions-list'), + shrinkWrap: true, + padding: isMobile + ? const EdgeInsets.only(top: 5) + : const EdgeInsets.only(top: 8), + itemCount: state.filteredTransactions.length, + itemBuilder: (context, int i) { + final data = state.filteredTransactions[i]; + final txKey = data.getTxKey(); + + return NftTxnDesktopCard( + key: Key(txKey), + transaction: data, + onPressed: () { + context + .read() + .add(NftTxReceiveDetailsEvent(data)); + }, + ); + }, + separatorBuilder: (context, index) => + const SizedBox(height: 8), + ), + ), + ); + }, + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_card.dart b/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_card.dart new file mode 100644 index 0000000000..07df9f6c78 --- /dev/null +++ b/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_card.dart @@ -0,0 +1,330 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; +import 'package:web_dex/shared/widgets/nft/nft_badge.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/utils/formatter.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/widgets/nft_txn_date.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/widgets/nft_txn_hash.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/widgets/nft_txn_status.dart'; +import 'package:web_dex/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_wrapper.dart'; + +class NftTxnDesktopCard extends StatefulWidget { + final NftTransaction transaction; + final VoidCallback onPressed; + + const NftTxnDesktopCard({ + Key? key, + required this.transaction, + required this.onPressed, + }) : super(key: key); + + @override + State createState() => _NftTxnDesktopCardState(); +} + +class _NftTxnDesktopCardState extends State + with AutomaticKeepAliveClientMixin { + bool isSelected = false; + + @override + bool get wantKeepAlive => isSelected; + + @override + Widget build(BuildContext context) { + super.build(context); + + final colorScheme = Theme.of(context).extension(); + + return GestureDetector( + onTap: () { + widget.onPressed(); + + setState(() { + isSelected = !isSelected; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 0), + decoration: BoxDecoration( + color: colorScheme?.surfCont, + borderRadius: BorderRadius.circular(15), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + NftTxnDesktopWrapper( + firstChild: NftTxnStatus( + status: widget.transaction.status, + ), + secondChild: Row( + mainAxisSize: MainAxisSize.min, + children: [ + BlockchainBadge( + blockchain: widget.transaction.chain, + width: 75, + ), + ], + ), + thirdChild: NftTxnMedia( + imagePath: widget.transaction.imageUrl, + title: widget.transaction.tokenName, + collectionName: widget.transaction.collectionName ?? '-', + amount: widget.transaction.amount, + ), + fourthChild: NftTxnDate( + blockTimestamp: widget.transaction.blockTimestamp, + ), + fifthChild: NftTxnHash( + transaction: widget.transaction, + ), + ), + _AdditionalTxnData( + transaction: widget.transaction, + isShown: isSelected, + ) + ], + ), + ), + ); + } +} + +class _AdditionalTxnData extends StatelessWidget { + const _AdditionalTxnData({ + required this.transaction, + required this.isShown, + }); + final NftTransaction transaction; + final bool isShown; + + static const _placeholderSizeOfTransactionFee = Size(91.0, 16); + static const _placeholderSizeOfSuccessStatus = Size(40.0, 16); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension(); + final textScheme = Theme.of(context).extension(); + return Padding( + padding: const EdgeInsets.only(left: 12, right: 12), + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 150), + firstCurve: Curves.easeInOut, + secondCurve: Curves.easeInOut, + firstChild: const SizedBox(width: double.maxFinite), + secondChild: Padding( + padding: const EdgeInsets.only(top: 20), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _TableView( + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon( + Icons.check_circle_outline_rounded, + color: colorScheme?.green, + size: 12, + ), + const SizedBox(width: 2), + Text( + '${LocaleKeys.confirmations.tr()}:', + style: textScheme?.bodyXS + .copyWith(color: colorScheme?.s70), + ), + ], + ), + Row( + children: [ + Icon( + null, + color: colorScheme?.green, + size: 12, + ), + const SizedBox(width: 4), + Text( + '${LocaleKeys.blockHeight.tr()}:', + style: textScheme?.bodyXS + .copyWith(color: colorScheme?.s70), + ), + ], + ), + _Value( + value: transaction.confirmations.toString(), + status: transaction.detailsFetchStatus, + defaultSize: _placeholderSizeOfSuccessStatus, + ), + _Value( + value: transaction.blockNumber.toString(), + ), + true, + ), + _TableView( + Text( + '${LocaleKeys.transactionFee.tr()}:', + style: + textScheme?.bodyXS.copyWith(color: colorScheme?.s70), + ), + const SizedBox(), + _Value( + value: NftTxFormatter.getFeeValue(transaction), + status: transaction.detailsFetchStatus, + defaultSize: _placeholderSizeOfTransactionFee, + ), + _Value( + value: NftTxFormatter.getUsdPriceOfFee(transaction), + status: transaction.detailsFetchStatus, + defaultSize: _placeholderSizeOfTransactionFee, + ), + false, + ), + _TableView( + Text( + '${LocaleKeys.from.tr()}:', + style: + textScheme?.bodyXS.copyWith(color: colorScheme?.s70), + ), + Text( + '${LocaleKeys.to.tr()}:', + style: + textScheme?.bodyXS.copyWith(color: colorScheme?.s70), + ), + Text( + transaction.fromAddress, + style: textScheme?.bodyXS, + ), + Text( + transaction.toAddress, + style: textScheme?.bodyXS, + ), + false, + ), + ], + ), + const SizedBox(height: 16), + Row( + children: [ + const SizedBox(width: 16), + Text( + '${LocaleKeys.fullHash.tr()}:', + style: textScheme?.bodyXS.copyWith(color: colorScheme?.s70), + ), + const SizedBox(width: 4), + Text( + transaction.transactionHash, + style: textScheme?.bodyXS, + ), + ], + ), + ], + ), + ), + crossFadeState: + !isShown ? CrossFadeState.showFirst : CrossFadeState.showSecond, + ), + ); + } +} + +class _TableView extends StatelessWidget { + final Widget titleOne; + final Widget titleTwo; + final Widget valueOne; + final Widget valueTwo; + final bool isLeftAligned; + + const _TableView(this.titleOne, this.titleTwo, this.valueOne, this.valueTwo, + this.isLeftAligned); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: + isLeftAligned ? CrossAxisAlignment.start : CrossAxisAlignment.end, + children: [ + Row( + mainAxisAlignment: + isLeftAligned ? MainAxisAlignment.start : MainAxisAlignment.end, + children: [ + titleOne, + const SizedBox(width: 4), + valueOne, + ], + ), + const SizedBox(height: 4), + Row( + mainAxisAlignment: + isLeftAligned ? MainAxisAlignment.start : MainAxisAlignment.end, + children: [ + titleTwo, + const SizedBox(width: 4), + valueTwo, + ], + ), + ], + ); + } +} + +class _Value extends StatelessWidget { + final String value; + + final NftTxnDetailsStatus status; + final Size? defaultSize; + const _Value({ + Key? key, + required this.value, + this.status = NftTxnDetailsStatus.success, + this.defaultSize, + }) : super(key: key); + + static const double iconSize = 12.0; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension(); + final textScheme = Theme.of(context).extension(); + switch (status) { + case NftTxnDetailsStatus.initial: + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + const UiSpinner( + height: iconSize, + width: iconSize, + strokeWidth: 1, + ), + if (defaultSize != null) + SizedBox( + width: defaultSize!.width - iconSize, + height: defaultSize!.height), + ], + ); + case NftTxnDetailsStatus.success: + return Text(value, style: textScheme?.bodyXS); + case NftTxnDetailsStatus.failure: + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + Icons.error_outline_outlined, + color: colorScheme?.error, + size: iconSize, + ), + if (defaultSize != null) + SizedBox( + width: defaultSize!.width - iconSize, + height: defaultSize!.height), + ], + ); + } + } +} diff --git a/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_filters.dart b/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_filters.dart new file mode 100644 index 0000000000..6f174cc091 --- /dev/null +++ b/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_filters.dart @@ -0,0 +1,170 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/cupertino.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/nft_transactions/bloc/nft_transactions_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; +import 'package:web_dex/model/nft.dart'; + +const double _itemHeight = 42; + +class NftTxnDesktopFilters extends StatelessWidget { + const NftTxnDesktopFilters({super.key}); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension(); + final chipColorScheme = UIChipColorScheme( + emptyContainerColor: colorScheme?.surfCont, + emptyTextColor: colorScheme?.s70, + pressedContainerColor: colorScheme?.surfContLowest, + selectedContainerColor: colorScheme?.primary, + selectedTextColor: colorScheme?.surf, + ); + return BlocBuilder( + builder: (context, state) { + return Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: colorScheme?.surfContHighest, + ), + child: Row(children: [ + Flexible( + flex: 3, + child: SizedBox( + height: 40, + child: CupertinoSearchTextField( + controller: state.filters.searchLine.isEmpty + ? TextEditingController() + : null, + onSubmitted: (value) { + context + .read() + .add(NftTxnEventSearchChanged(value)); + }, + style: Theme.of(context).textTheme.bodySmall, + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(15), + color: colorScheme?.surfCont, + ), + prefixInsets: const EdgeInsets.only(left: 16, right: 8), + prefixIcon: SvgPicture.asset( + '$assetsPath/custom_icons/16px/search.svg', + width: 16, + height: 16, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.secondary, + BlendMode.srcIn, + ), + ), + suffixInsets: const EdgeInsets.only(left: 16, right: 8), + suffixIcon: Icon( + Icons.clear, + color: Theme.of(context).colorScheme.secondary, + size: 18, + ), + onSuffixTap: () { + context + .read() + .add(const NftTxnEventSearchChanged('')); + }, + ), + )), + const SizedBox(width: 24), + MultiSelectDropdownButton( + title: 'Status', + items: NftTransactionStatuses.values, + displayItem: (p0) => p0.toString(), + selectedItems: state.filters.statuses, + onChanged: (value) { + context + .read() + .add(NftTxnEventStatusesChanged(value)); + }, + colorScheme: chipColorScheme, + ), + const SizedBox(width: 8), + MultiSelectDropdownButton( + title: 'Blockchain', + items: NftBlockchains.values, + displayItem: (p0) => p0.toString(), + selectedItems: state.filters.blockchain, + onChanged: (value) { + context + .read() + .add(NftTxnEventBlockchainChanged(value)); + }, + colorScheme: chipColorScheme, + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 120, maxHeight: _itemHeight), + child: UiDatePicker( + formatter: DateFormat('dd.MM.yyyy').format, + date: state.filters.dateFrom, + text: LocaleKeys.fromDate.tr(), + endDate: state.filters.dateTo, + onDateSelect: (time) { + context + .read() + .add(NftTxnEventStartDateChanged(time)); + }, + ), + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: + const BoxConstraints(maxWidth: 120, maxHeight: _itemHeight), + child: UiDatePicker( + formatter: DateFormat('dd.MM.yyyy').format, + date: state.filters.dateTo, + text: LocaleKeys.toDate.tr(), + startDate: state.filters.dateFrom, + onDateSelect: (time) { + context + .read() + .add(NftTxnEventEndDateChanged(time)); + }, + ), + ), + const SizedBox(width: 24), + const Flex(direction: Axis.horizontal), + state.filters.isEmpty + ? UiSecondaryButton( + height: _itemHeight, + width: 72, + text: LocaleKeys.reset.tr(), + borderColor: colorScheme?.s70, + textStyle: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w600, + color: colorScheme?.s70, + fontSize: 14, + ), + onPressed: null, + ) + : UiPrimaryButton( + width: 72, + height: _itemHeight, + text: LocaleKeys.reset.tr(), + padding: EdgeInsets.zero, + onPressed: () { + context + .read() + .add(const NftTxnClearFilters()); + }, + ), + ]), + ); + }, + ); + } +} diff --git a/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_header.dart b/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_header.dart new file mode 100644 index 0000000000..baa7bdf5db --- /dev/null +++ b/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_header.dart @@ -0,0 +1,46 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_wrapper.dart'; + +class NftTxnDesktopHeader extends StatelessWidget { + const NftTxnDesktopHeader({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return isMobile + ? const _CoinsListHeaderMobile() + : const _CoinsListHeaderDesktop(); + } +} + +class _CoinsListHeaderDesktop extends StatelessWidget { + const _CoinsListHeaderDesktop({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension(); + final textScheme = Theme.of(context).extension(); + final style = textScheme?.bodyXS.copyWith( + color: colorScheme?.s50, + ); + return NftTxnDesktopWrapper( + firstChild: Text(LocaleKeys.status.tr(), style: style), + secondChild: Text(LocaleKeys.blockchain.tr(), style: style), + thirdChild: Text(LocaleKeys.nft.tr(), style: style), + fourthChild: Text(LocaleKeys.date.tr(), style: style), + fifthChild: Text(LocaleKeys.hash.tr(), style: style), + ); + } +} + +class _CoinsListHeaderMobile extends StatelessWidget { + const _CoinsListHeaderMobile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_wrapper.dart b/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_wrapper.dart new file mode 100644 index 0000000000..3f58aef7d6 --- /dev/null +++ b/lib/views/nfts/nft_transactions/desktop/widgets/nft_txn_desktop_wrapper.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +class NftTxnDesktopWrapper extends StatelessWidget { + const NftTxnDesktopWrapper({ + super.key, + required this.firstChild, + required this.secondChild, + required this.thirdChild, + required this.fourthChild, + required this.fifthChild, + }); + final Widget firstChild; + final Widget secondChild; + final Widget thirdChild; + final Widget fourthChild; + final Widget fifthChild; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 12, right: 16), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded(flex: 5, child: firstChild), + const SizedBox(width: 16), + Expanded(flex: 6, child: secondChild), + const SizedBox(width: 16), + Expanded(flex: 16, child: thirdChild), + const SizedBox(width: 16), + Expanded(flex: 5, child: fourthChild), + const SizedBox(width: 16), + Expanded(flex: 11, child: fifthChild), + ], + ), + ); + } +} diff --git a/lib/views/nfts/nft_transactions/mobile/nft_txn_mobile_page.dart b/lib/views/nfts/nft_transactions/mobile/nft_txn_mobile_page.dart new file mode 100644 index 0000000000..705ffc85be --- /dev/null +++ b/lib/views/nfts/nft_transactions/mobile/nft_txn_mobile_page.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/nft_transactions/bloc/nft_transactions_bloc.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/pages/nft_txn_empty_page.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/pages/nft_txn_failure_page.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/pages/nft_txn_loading_page.dart'; +import 'package:web_dex/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_app_bar.dart'; +import 'package:web_dex/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_card.dart'; +import 'package:web_dex/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filters.dart'; + +class NftTxnMobilePage extends StatelessWidget { + const NftTxnMobilePage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Stack( + children: [ + NftTxnMobileAppBar( + filters: state.filters, + onSettingsPressed: () => _onSettingsPressed(context), + ), + Padding( + padding: const EdgeInsets.only(top: 56), + child: Builder( + builder: (context) { + if (state.status == NftTxnStatus.loading) { + return const NftTxnLoading(); + } + if (state.status == NftTxnStatus.failure) { + return NftTxnFailurePage( + message: state.errorMessage ?? '--', + onReload: () { + context + .read() + .add(const NftTxnReceiveEvent()); + }, + ); + } + + if (state.filteredTransactions.isEmpty) { + return const NftTxnEmpty(); + } + + return ListView.separated( + controller: ScrollController(), + key: const Key('nft-page-transactions-list'), + shrinkWrap: true, + padding: const EdgeInsets.symmetric(horizontal: 6), + itemCount: state.filteredTransactions.length, + itemBuilder: (context, int i) { + final data = state.filteredTransactions[i]; + + final txKey = data.getTxKey(); + return NftTxnMobileCard( + key: Key(txKey), + transaction: data, + onPressed: () { + context + .read() + .add(NftTxReceiveDetailsEvent(data)); + }); + }, + separatorBuilder: (context, index) => + const SizedBox(height: 8), + ); + }, + ), + ), + ], + ); + }, + ); + } + + void _onSettingsPressed(BuildContext context) { + final bloc = context.read(); + bloc.bottomSheetController = showBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (generalContext) { + return NftTxnMobileFilters( + filters: bloc.state.filters, + onApply: (filters) { + // Navigator.of(context).pop(); + if (filters != null) { + bloc.add(NftTxnEventFullFilterChanged(filters)); + } + }, + ); + }, + ); + } +} diff --git a/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_copied_text.dart b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_copied_text.dart new file mode 100644 index 0000000000..3e575d26c0 --- /dev/null +++ b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_copied_text.dart @@ -0,0 +1,75 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/hash_explorer_link.dart'; + +class NftTxnCopiedText extends StatelessWidget { + const NftTxnCopiedText({ + Key? key, + required this.title, + required this.transaction, + required this.explorerType, + }) : super(key: key); + + final String title; + final NftTransaction transaction; + final NftTxnExplorerType explorerType; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension()!; + final textScheme = Theme.of(context).extension()!; + final coin = _coin; + final textStyle = textScheme.bodyXS.copyWith(color: colorScheme.s70); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text(title, style: textStyle), + const SizedBox(height: 2), + if (coin != null) + HashExplorerLink( + hash: _exploreValue, + coin: coin, + type: _explorerType, + ) + else + const SizedBox.shrink(), + ], + ); + } + + Coin? get _coin { + return coinsBloc.getCoin(transaction.chain.coinAbbr()); + } + + String get _exploreValue { + switch (explorerType) { + case NftTxnExplorerType.tx: + return transaction.transactionHash; + case NftTxnExplorerType.from: + return transaction.fromAddress; + case NftTxnExplorerType.to: + return transaction.toAddress; + } + } + + HashExplorerType get _explorerType { + switch (explorerType) { + case NftTxnExplorerType.tx: + return HashExplorerType.tx; + case NftTxnExplorerType.from: + case NftTxnExplorerType.to: + return HashExplorerType.address; + } + } +} + +enum NftTxnExplorerType { + tx, + from, + to, +} diff --git a/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_app_bar.dart b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_app_bar.dart new file mode 100644 index 0000000000..f1f2800cf9 --- /dev/null +++ b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_app_bar.dart @@ -0,0 +1,60 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:badges/badges.dart' as badges; +import 'package:easy_localization/easy_localization.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/bloc/nft_transactions/bloc/nft_transactions_filters.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/views/common/page_header/page_header.dart'; + +class NftTxnMobileAppBar extends StatelessWidget + implements PreferredSizeWidget { + final void Function() onSettingsPressed; + final NftTransactionsFilter filters; + const NftTxnMobileAppBar({ + required this.filters, + required this.onSettingsPressed, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension()!; + final textScheme = Theme.of(context).extension(); + return PageHeader( + title: LocaleKeys.transactionsHistory.tr(), + onBackButtonPressed: routingState.nftsState.reset, + actions: [ + IconButton( + splashColor: Colors.transparent, + hoverColor: Colors.transparent, + icon: badges.Badge( + showBadge: !filters.isEmpty, + position: badges.BadgePosition.topEnd(top: -5, end: -5), + badgeContent: Text('${filters.count}', + style: textScheme?.bodyXXS.copyWith(color: colorScheme.surf)), + badgeStyle: badges.BadgeStyle( + badgeColor: colorScheme.primary, + ), + child: SvgPicture.asset( + '$assetsPath/custom_icons/filter.svg', + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + filters.isEmpty ? colorScheme.secondary : colorScheme.primary, + BlendMode.srcIn, + ), + ), + ), + onPressed: onSettingsPressed, + color: colorScheme.secondary, + iconSize: 20, + ) + ], + ); + } + + @override + Size get preferredSize => const Size(double.infinity, 56); +} diff --git a/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_card.dart b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_card.dart new file mode 100644 index 0000000000..38d2cd223e --- /dev/null +++ b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_card.dart @@ -0,0 +1,242 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; +import 'package:web_dex/shared/widgets/nft/nft_badge.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/utils/formatter.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/widgets/nft_txn_date.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/widgets/nft_txn_media.dart'; +import 'package:web_dex/views/nfts/nft_transactions/common/widgets/nft_txn_status.dart'; +import 'package:web_dex/views/nfts/nft_transactions/mobile/widgets/nft_txn_copied_text.dart'; + +class NftTxnMobileCard extends StatefulWidget { + final NftTransaction transaction; + + final VoidCallback onPressed; + const NftTxnMobileCard({ + Key? key, + required this.transaction, + required this.onPressed, + }) : super(key: key); + + @override + State createState() => _NftTxnMobileCardState(); +} + +class _NftTxnMobileCardState extends State + with AutomaticKeepAliveClientMixin { + bool isSelected = false; + + @override + bool get wantKeepAlive => isSelected; + + @override + Widget build(BuildContext context) { + super.build(context); + + final colorScheme = Theme.of(context).extension(); + final textScheme = Theme.of(context).extension(); + + return GestureDetector( + onTap: () { + widget.onPressed(); + + setState(() { + isSelected = !isSelected; + }); + }, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), + decoration: BoxDecoration( + color: colorScheme?.surfCont, + borderRadius: BorderRadius.circular(15), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + NftTxnStatus(status: widget.transaction.status), + BlockchainBadge( + blockchain: widget.transaction.chain, + width: 75, + ), + NftTxnDate( + blockTimestamp: widget.transaction.blockTimestamp, + ), + ], + ), + const SizedBox(height: 16), + NftTxnMedia( + imagePath: widget.transaction.imageUrl, + title: widget.transaction.tokenName, + collectionName: widget.transaction.collectionName ?? '-', + amount: widget.transaction.amount, + ), + const SizedBox(height: 16), + AnimatedCrossFade( + duration: const Duration(milliseconds: 300), + firstChild: const SizedBox.shrink(), + crossFadeState: isSelected + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + secondChild: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Divider(height: 1), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + NftTxnCopiedText( + transaction: widget.transaction, + explorerType: NftTxnExplorerType.tx, + title: LocaleKeys.hash.tr(), + ), + _AdditionalInfoLine( + title: LocaleKeys.confirmations.tr(), + successChild: Builder(builder: (context) { + bool isConfirmed = + widget.transaction.confirmations != null; + return Row( + children: [ + Icon( + isConfirmed + ? Icons.check_circle_outline_rounded + : Icons.warning, + color: isConfirmed + ? colorScheme?.green + : colorScheme?.yellow, + size: 14, + ), + const SizedBox(width: 4), + Text(widget.transaction.confirmations.toString(), + style: textScheme?.bodyXSBold), + ], + ); + }), + status: widget.transaction.detailsFetchStatus, + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + NftTxnCopiedText( + transaction: widget.transaction, + explorerType: NftTxnExplorerType.from, + title: LocaleKeys.from.tr(), + ), + _AdditionalInfoLine( + title: LocaleKeys.transactionFee.tr(), + successChild: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + NftTxFormatter.getFeeValue(widget.transaction), + style: textScheme?.bodyXSBold, + ), + const SizedBox(height: 4), + Text( + NftTxFormatter.getUsdPriceOfFee( + widget.transaction), + style: textScheme?.bodyXSBold, + ), + ], + ), + status: widget.transaction.detailsFetchStatus, + ), + ], + ), + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + NftTxnCopiedText( + transaction: widget.transaction, + explorerType: NftTxnExplorerType.to, + title: LocaleKeys.to.tr(), + ), + _AdditionalInfoLine( + title: LocaleKeys.blockHeight.tr(), + successChild: Text( + widget.transaction.blockNumber.toString(), + style: textScheme?.bodyXSBold), + ), + ], + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _AdditionalInfoLine extends StatelessWidget { + const _AdditionalInfoLine({ + required this.title, + required this.successChild, + this.status = NftTxnDetailsStatus.success, + }); + + static const double iconSize = 14.0; + + final String title; + final Widget successChild; + final NftTxnDetailsStatus status; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension(); + final textScheme = Theme.of(context).extension(); + return SizedBox( + width: 140, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text( + title, + style: textScheme?.bodyXS.copyWith(color: colorScheme?.s70), + textAlign: TextAlign.start, + ), + ], + ), + const SizedBox(height: 4), + Builder(builder: (context) { + switch (status) { + case NftTxnDetailsStatus.initial: + return const UiSpinner( + height: iconSize, + width: iconSize, + strokeWidth: 1, + ); + case NftTxnDetailsStatus.success: + return successChild; + + case NftTxnDetailsStatus.failure: + return Icon( + Icons.error_outline_outlined, + color: colorScheme?.error, + size: iconSize, + ); + } + }), + ], + ), + ); + } +} diff --git a/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filter_card.dart b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filter_card.dart new file mode 100644 index 0000000000..c278b66b66 --- /dev/null +++ b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filter_card.dart @@ -0,0 +1,56 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +class NftTxnMobileFilterCard extends StatelessWidget { + final String title; + final String svgPath; + final VoidCallback onTap; + final bool isSelected; + const NftTxnMobileFilterCard({ + super.key, + required this.title, + required this.svgPath, + required this.onTap, + this.isSelected = false, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).extension()!; + final textScheme = Theme.of(context).extension()!; + final color = isSelected ? colorScheme.surf : colorScheme.s70; + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.bottomLeft, + height: 56, + constraints: const BoxConstraints(maxHeight: 56), + decoration: BoxDecoration( + color: isSelected ? colorScheme.primary : colorScheme.surfCont, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: textScheme.bodyS.copyWith(color: color), + ), + const SizedBox(width: 8), + SvgPicture.asset( + svgPath, + colorFilter: ColorFilter.mode( + color, + BlendMode.srcIn, + ), + width: 24, + height: 24, + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filters.dart b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filters.dart new file mode 100644 index 0000000000..d6210d3345 --- /dev/null +++ b/lib/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filters.dart @@ -0,0 +1,230 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/nft_transactions/bloc/nft_transactions_filters.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/rpc/nft_transaction/nft_transactions_response.dart'; +import 'package:web_dex/model/nft.dart'; +import 'package:web_dex/views/nfts/nft_transactions/mobile/widgets/nft_txn_mobile_filter_card.dart'; + +class NftTxnMobileFilters extends StatefulWidget { + final NftTransactionsFilter filters; + final void Function(NftTransactionsFilter?) onApply; + + const NftTxnMobileFilters({ + required this.filters, + required this.onApply, + }); + + @override + State createState() => _NftTxnMobileFiltersState(); +} + +class _NftTxnMobileFiltersState extends State { + final List statuses = []; + final List blockchains = []; + DateTime? dateFrom; + DateTime? dateTo; + + final DateFormat dateFormatter = DateFormat('dd MMMM yyyy', 'en_US'); + + @override + void initState() { + setState(() { + statuses.addAll(widget.filters.statuses); + blockchains.addAll(widget.filters.blockchain); + dateFrom = widget.filters.dateFrom; + dateTo = widget.filters.dateTo; + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + const gridDelete = SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisSpacing: 7, + crossAxisSpacing: 7, + mainAxisExtent: 56, + ); + + final bool isButtonDisabled = statuses.isEmpty && + blockchains.isEmpty && + dateFrom == null && + dateTo == null; + + return Theme( + data: Theme.of(context).brightness == Brightness.light + ? newThemeLight + : newThemeDark, + child: Builder(builder: (context) { + final colorScheme = Theme.of(context).extension(); + final textScheme = Theme.of(context).extension(); + return Container( + decoration: BoxDecoration( + color: colorScheme?.surfContLowest, + border: Border.all( + color: colorScheme?.s40 ?? Colors.transparent, width: 1), + borderRadius: const BorderRadius.only( + topLeft: Radius.circular(24), topRight: Radius.circular(24))), + padding: const EdgeInsets.all(16), + child: SingleChildScrollView( + controller: ScrollController(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 80, + height: 4, + decoration: BoxDecoration( + color: colorScheme?.surf, + borderRadius: BorderRadius.circular(20)), + ), + const SizedBox(height: 24), + Text( + LocaleKeys.filters.tr(), + style: textScheme?.bodyMBold, + ), + const SizedBox(height: 24), + GridView( + gridDelegate: gridDelete, + shrinkWrap: true, + children: [ + NftTxnMobileFilterCard( + title: LocaleKeys.send.tr(), + onTap: () { + setState(() { + statuses.contains(NftTransactionStatuses.send) + ? statuses.remove(NftTransactionStatuses.send) + : statuses.add(NftTransactionStatuses.send); + }); + widget.onApply(getFilters()); + }, + isSelected: + statuses.contains(NftTransactionStatuses.send), + svgPath: '$assetsPath/custom_icons/send.svg', + ), + NftTxnMobileFilterCard( + title: LocaleKeys.receive.tr(), + onTap: () { + setState(() { + statuses.contains(NftTransactionStatuses.receive) + ? statuses.remove(NftTransactionStatuses.receive) + : statuses.add(NftTransactionStatuses.receive); + }); + widget.onApply(getFilters()); + }, + isSelected: + statuses.contains(NftTransactionStatuses.receive), + svgPath: '$assetsPath/custom_icons/receive.svg', + ), + ], + ), + const SizedBox(height: 20), + Text( + LocaleKeys.blockchain.tr(), + style: textScheme?.bodyM, + ), + const SizedBox(height: 8), + GridView.builder( + shrinkWrap: true, + gridDelegate: gridDelete, + itemBuilder: (context, index) { + final NftBlockchains blockchain = + NftBlockchains.values[index]; + return NftTxnMobileFilterCard( + onTap: () { + setState(() { + blockchains.contains(blockchain) + ? blockchains.remove(blockchain) + : blockchains.add(blockchain); + }); + widget.onApply(getFilters()); + }, + title: blockchain.toString(), + isSelected: blockchains.contains(blockchain), + svgPath: + '$assetsPath/blockchain_icons/svg/32px/${blockchain.toApiRequest().toLowerCase()}.svg', + ); + }, + itemCount: NftBlockchains.values.length, + ), + const SizedBox(height: 20), + Text( + LocaleKeys.date.tr(), + style: textScheme?.bodyM, + ), + const SizedBox(height: 8), + GridView( + gridDelegate: gridDelete, + shrinkWrap: true, + children: [ + UiDatePicker( + formatter: dateFormatter.format, + isMobileAlternative: true, + date: dateFrom, + text: LocaleKeys.fromDate.tr(), + endDate: dateTo, + onDateSelect: (time) { + setState(() { + dateFrom = time; + }); + widget.onApply(getFilters()); + }, + ), + UiDatePicker( + formatter: dateFormatter.format, + isMobileAlternative: true, + date: dateTo, + text: LocaleKeys.toDate.tr(), + startDate: dateFrom, + onDateSelect: (time) { + setState(() { + dateTo = time; + }); + widget.onApply(getFilters()); + }, + ), + ], + ), + const SizedBox(height: 20), + Flexible( + child: UiPrimaryButton( + border: Border.all( + color: colorScheme?.secondary ?? Colors.transparent, + width: 2), + backgroundColor: Colors.transparent, + height: 40, + text: LocaleKeys.clearFilter.tr(), + onPressed: isButtonDisabled + ? null + : () { + setState(() { + statuses.clear(); + blockchains.clear(); + dateFrom = null; + dateTo = null; + }); + widget.onApply(getFilters()); + }, + ), + ), + ], + ), + ), + ); + }), + ); + } + + NftTransactionsFilter getFilters() { + return NftTransactionsFilter( + statuses: statuses, + blockchain: blockchains, + dateFrom: dateFrom, + dateTo: dateTo, + ); + } +} diff --git a/lib/views/nfts/nft_transactions/nft_txn_page.dart b/lib/views/nfts/nft_transactions/nft_txn_page.dart new file mode 100644 index 0000000000..e69db31fe1 --- /dev/null +++ b/lib/views/nfts/nft_transactions/nft_txn_page.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/nft_transactions/bloc/nft_transactions_bloc.dart'; +import 'package:web_dex/bloc/nft_transactions/nft_txn_repository.dart'; +import 'package:web_dex/bloc/nfts/nft_main_repo.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/views/nfts/nft_transactions/desktop/nft_txn_desktop_page.dart.dart'; +import 'package:web_dex/views/nfts/nft_transactions/mobile/nft_txn_mobile_page.dart'; + +class NftListOfTransactionsPage extends StatelessWidget { + const NftListOfTransactionsPage({super.key}); + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => NftTransactionsBloc( + nftTxnRepository: context.read(), + nftsRepository: context.read(), + coinsBloc: coinsBloc, + authRepo: authRepo, + isLoggedIn: context.read().state.mode == AuthorizeMode.logIn, + )..add(const NftTxnReceiveEvent()), + child: isMobile ? const NftTxnMobilePage() : const NftTxnDesktopPage(), + ); + } +} diff --git a/lib/views/qr_scanner.dart b/lib/views/qr_scanner.dart new file mode 100644 index 0000000000..14db10ac8e --- /dev/null +++ b/lib/views/qr_scanner.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import 'package:mobile_scanner/mobile_scanner.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class QrScanner extends StatefulWidget { + const QrScanner({super.key}); + + @override + State createState() => _QrScannerState(); +} + +class _QrScannerState extends State { + bool qrDetected = false; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(LocaleKeys.qrScannerTitle.tr()), + foregroundColor: Theme.of(context).textTheme.bodyMedium?.color, + elevation: 0, + ), + body: MobileScanner( + controller: MobileScannerController( + detectionTimeoutMs: 1000, + formats: [BarcodeFormat.qrCode], + ), + errorBuilder: _buildQrScannerError, + onDetect: (capture) { + if (qrDetected) return; + + final List qrCodes = capture.barcodes; + + if (qrCodes.isNotEmpty) { + final r = qrCodes.first.rawValue; + qrDetected = true; + + // MRC: Guarantee that we don't try to close the current screen + // if it was already closed + if (!context.mounted) return; + Navigator.pop(context, r); + } + }, + placeholderBuilder: (context, _) => const Center( + child: CircularProgressIndicator(), + ), + ), + ); + } + + Widget _buildQrScannerError( + BuildContext context, MobileScannerException exception, _) { + late String errorMessage; + + switch (exception.errorCode) { + case MobileScannerErrorCode.controllerUninitialized: + errorMessage = LocaleKeys.qrScannerErrorControllerUninitialized.tr(); + break; + case MobileScannerErrorCode.permissionDenied: + errorMessage = LocaleKeys.qrScannerErrorPermissionDenied.tr(); + break; + case MobileScannerErrorCode.genericError: + default: + errorMessage = LocaleKeys.qrScannerErrorGenericError.tr(); + } + + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon( + Icons.warning, + color: Colors.yellowAccent, + size: 64, + ), + const SizedBox(height: 8), + Text( + LocaleKeys.qrScannerErrorTitle.tr(), + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: 32), + Text(errorMessage, style: Theme.of(context).textTheme.bodyLarge), + if (exception.errorDetails != null) + Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${LocaleKeys.errorCode.tr()}: ${exception.errorDetails!.code}'), + Text( + '${LocaleKeys.errorDetails.tr()}: ${exception.errorDetails!.details}'), + Text( + '${LocaleKeys.errorMessage.tr()}: ${exception.errorDetails!.message}'), + ], + ), + ], + ), + ); + } +} diff --git a/lib/views/settings/settings_page.dart b/lib/views/settings/settings_page.dart new file mode 100644 index 0000000000..1c1fd9d7b7 --- /dev/null +++ b/lib/views/settings/settings_page.dart @@ -0,0 +1,159 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/settings_menu_value.dart'; +import 'package:web_dex/router/state/routing_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/settings/widgets/common/settings_content_wrapper.dart'; +import 'package:web_dex/views/settings/widgets/feedback_page/feedback_page.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/general_settings.dart'; +import 'package:web_dex/views/settings/widgets/security_settings/security_settings_page.dart'; +import 'package:web_dex/views/settings/widgets/settings_menu/settings_menu.dart'; +import 'package:web_dex/views/settings/widgets/support_page/support_page.dart'; + +class SettingsPage extends StatelessWidget { + const SettingsPage({ + Key? key = const Key('settings-page'), + required this.selectedMenu, + }) : super(key: key); + + final SettingsMenuValue selectedMenu; + + @override + Widget build(BuildContext context) { + if (isMobile) { + final showMobileMenu = selectedMenu == SettingsMenuValue.none; + if (showMobileMenu) return _MobileMenuLayout(selectedMenu); + return _MobileContentLayout( + selectedMenu: selectedMenu, + content: _buildContent(selectedMenu), + ); + } + return _DesktopLayout( + selectedMenu: selectedMenu, + content: _buildContent(selectedMenu), + ); + } + + Widget _buildContent(SettingsMenuValue selectedMenu) { + switch (selectedMenu) { + case SettingsMenuValue.general: + return const GeneralSettings(); + case SettingsMenuValue.security: + return SecuritySettingsPage(onBackPressed: _onBackButtonPressed); + case SettingsMenuValue.support: + return SupportPage(); + case SettingsMenuValue.feedback: + return const FeedbackPage(); + + case SettingsMenuValue.none: + return Container(); + } + } +} + +class _MobileMenuLayout extends StatelessWidget { + const _MobileMenuLayout(this.selectedMenu); + + final SettingsMenuValue selectedMenu; + + @override + Widget build(BuildContext context) { + return PageLayout( + header: PageHeader(title: LocaleKeys.settings.tr()), + content: Flexible( + child: SettingsMenu( + selectedMenu: selectedMenu, + onMenuSelect: (value) => + routingState.settingsState.selectedMenu = value, + ), + ), + ); + } +} + +class _MobileContentLayout extends StatelessWidget { + const _MobileContentLayout({ + required this.selectedMenu, + required this.content, + }); + + final SettingsMenuValue selectedMenu; + final Widget content; + + @override + Widget build(BuildContext context) { + switch (selectedMenu) { + case SettingsMenuValue.security: + return content; + case SettingsMenuValue.general: + case SettingsMenuValue.support: + case SettingsMenuValue.feedback: + return PageLayout( + header: PageHeader( + title: selectedMenu.title, + backText: '', + onBackButtonPressed: _onBackButtonPressed, + ), + content: Flexible( + child: SettingsContentWrapper( + child: content, + ), + ), + ); + case SettingsMenuValue.none: + throw Error(); + } + } +} + +class _DesktopLayout extends StatelessWidget { + const _DesktopLayout({required this.selectedMenu, required this.content}); + + final SettingsMenuValue selectedMenu; + final Widget content; + + @override + Widget build(BuildContext context) { + final isTopSpace = selectedMenu != SettingsMenuValue.security && + selectedMenu != SettingsMenuValue.support; + + return PageLayout( + content: Flexible( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + Expanded( + flex: 2, + child: Padding( + padding: const EdgeInsets.only(top: 30), + child: SettingsMenu( + selectedMenu: selectedMenu, + onMenuSelect: (value) => + routingState.settingsState.selectedMenu = value, + ), + ), + ), + Expanded( + flex: 8, + child: SingleChildScrollView( + child: Padding( + padding: EdgeInsets.fromLTRB(30, isTopSpace ? 30 : 0, 0, 0), + child: content, + ), + ), + ), + ], + ), + ), + ); + } +} + +void _onBackButtonPressed() { + routingState.settingsState.selectedMenu = SettingsMenuValue.none; +} diff --git a/lib/views/settings/widgets/common/settings_content_wrapper.dart b/lib/views/settings/widgets/common/settings_content_wrapper.dart new file mode 100644 index 0000000000..55f2f0e0b6 --- /dev/null +++ b/lib/views/settings/widgets/common/settings_content_wrapper.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +class SettingsContentWrapper extends StatelessWidget { + const SettingsContentWrapper({super.key, required this.child}); + final Widget child; + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Container( + padding: const EdgeInsets.symmetric(vertical: 25, horizontal: 15), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18.0), + color: Theme.of(context).cardColor, + ), + child: child, + ), + ); + } +} diff --git a/lib/views/settings/widgets/common/settings_section.dart b/lib/views/settings/widgets/common/settings_section.dart new file mode 100644 index 0000000000..b29223b31b --- /dev/null +++ b/lib/views/settings/widgets/common/settings_section.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class SettingsSection extends StatelessWidget { + const SettingsSection({super.key, required this.title, required this.child}); + final String title; + final Widget child; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 14, fontWeight: FontWeight.w500), + ), + Padding( + padding: const EdgeInsets.only(top: 17), + child: child, + ), + ], + ); + } +} diff --git a/lib/views/settings/widgets/feedback_page/feedback_page.dart b/lib/views/settings/widgets/feedback_page/feedback_page.dart new file mode 100644 index 0000000000..2a4b2b6ed1 --- /dev/null +++ b/lib/views/settings/widgets/feedback_page/feedback_page.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/shared/widgets/feedback_form/feedback_form_wrapper.dart'; + +class FeedbackPage extends StatelessWidget { + const FeedbackPage({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return DexScrollbar( + scrollController: scrollController, + isMobile: isMobile, + child: SingleChildScrollView( + controller: scrollController, + child: Padding( + padding: EdgeInsets.symmetric(vertical: isMobile ? 20 : 8.0), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 400), + child: const FeedbackFormWrapper(), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/settings/widgets/general_settings/app_version_number.dart b/lib/views/settings/widgets/general_settings/app_version_number.dart new file mode 100644 index 0000000000..12349e2670 --- /dev/null +++ b/lib/views/settings/widgets/general_settings/app_version_number.dart @@ -0,0 +1,130 @@ +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/runtime_coin_updates/coin_config_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({Key? key}) : super(key: key); + + @override + 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, + ), + const _ApiVersion(), + const SizedBox(height: 4), + const _BundledCoinsCommitConfig(), + ], + ), + ); + } +} + +class _BundledCoinsCommitConfig extends StatelessWidget { + const _BundledCoinsCommitConfig({Key? key}) : super(key: key); + + // Get the value from `app_build/build_config.json` under the key + // "coins"->"bundled_coins_repo_commit" + Future getBundledCoinsCommit() async { + final String commit = await rootBundle + .loadString('app_build/build_config.json') + .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; + } + + @override + Widget build(BuildContext context) { + final configBlocState = context.watch().state; + + final runtimeCoinUpdatesCommit = (configBlocState is CoinConfigLoadSuccess) + ? configBlocState.updatedCommitHash + : null; + + return Column( + 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( + '${LocaleKeys.updated.tr()}: ${_tryParseCommitHash(runtimeCoinUpdatesCommit) ?? LocaleKeys.notUpdated.tr()}', + style: _textStyle, + ), + ], + ); + } +} + +class _ApiVersion extends StatelessWidget { + const _ApiVersion({Key? key}) : super(key: key); + + @override + Widget build(BuildContext 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); + }, + ), + ), + ], + ); + } +} + +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 new file mode 100644 index 0000000000..f8d00ca97c --- /dev/null +++ b/lib/views/settings/widgets/general_settings/general_settings.dart @@ -0,0 +1,48 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/shared/widgets/hidden_with_wallet.dart'; +import 'package:web_dex/shared/widgets/hidden_without_wallet.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/import_swaps.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/settings_download_logs.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/settings_manage_analytics.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/settings_manage_trading_bot.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/settings_reset_activated_coins.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/settings_theme_switcher.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/show_swap_data.dart'; + +class GeneralSettings extends StatelessWidget { + const GeneralSettings({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (isMobile) const SizedBox(height: 20), + const SettingsThemeSwitcher(), + const SizedBox(height: 25), + const SettingsManageAnalytics(), + const SizedBox(height: 25), + const HiddenWithoutWallet( + child: SettingsManageTradingBot(), + ), + const SizedBox(height: 25), + const SettingsDownloadLogs(), + const SizedBox(height: 25), + const HiddenWithWallet( + child: SettingsResetActivatedCoins(), + ), + const SizedBox(height: 25), + const HiddenWithoutWallet( + isHiddenForHw: true, + child: ShowSwapData(), + ), + const HiddenWithoutWallet( + isHiddenForHw: true, + child: ImportSwaps(), + ), + ], + ); + } +} diff --git a/lib/views/settings/widgets/general_settings/import_swaps.dart b/lib/views/settings/widgets/general_settings/import_swaps.dart new file mode 100644 index 0000000000..5cb9354ce6 --- /dev/null +++ b/lib/views/settings/widgets/general_settings/import_swaps.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; + +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +import 'package:web_dex/shared/ui/ui_light_button.dart'; +import 'package:web_dex/shared/utils/debug_utils.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class ImportSwaps extends StatefulWidget { + const ImportSwaps({Key? key}) : super(key: key); + + @override + State createState() => _ImportSwapsState(); +} + +class _ImportSwapsState extends State { + @override + void initState() { + _preloadFromDebugData(); + + super.initState(); + } + + final TextEditingController _controller = TextEditingController(); + bool _success = false; + String? _error; + bool _showData = false; + bool _inProgress = false; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSwitcher(), + if (_showData) ...{ + const SizedBox(height: 20), + _buildStatus(), + _buildData(), + }, + const SizedBox(height: 20), + ], + ); + } + + Widget _buildSwitcher() { + return UiBorderButton( + width: 146, + height: 32, + borderWidth: 1, + borderColor: theme.custom.specificButtonBorderColor, + backgroundColor: theme.custom.specificButtonBackgroundColor, + fontWeight: FontWeight.w500, + text: LocaleKeys.importSwaps.tr(), + suffix: Icon( + _showData ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 14, + ), + onPressed: + _inProgress ? null : () => setState(() => _showData = !_showData), + ); + } + + Widget _buildData() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: TextField( + controller: _controller, + maxLines: 10, + style: const TextStyle(fontSize: 12), + ), + ), + const SizedBox(height: 10), + UiLightButton( + text: LocaleKeys.import.tr(), + onPressed: _onImport, + ), + const SizedBox(width: 10), + ], + ); + } + + Widget _buildStatus() { + if (!_success && _error == null) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 8), + child: Text( + _error ?? (_success ? '${LocaleKeys.success.tr()}!' : ''), + style: TextStyle( + fontSize: 12, + color: _success + ? theme.custom.successColor + : _error == null + ? null + : theme.currentGlobal.colorScheme.error, + ), + ), + ); + } + + Future _preloadFromDebugData() async { + if (!kDebugMode) return; + + setState(() { + _inProgress = true; + }); + + final data = await loadDebugSwaps(); + if (data != null) { + _controller.text = jsonEncode(data); + } + + setState(() { + _inProgress = false; + }); + } + + Future _onImport() async { + setState(() { + _inProgress = true; + _error = null; + _success = false; + }); + + List? swaps; + try { + swaps = jsonDecode(_controller.text) as List; + if (swaps.isEmpty) throw Exception('The list is empty'); + } catch (e) { + setState(() { + _inProgress = false; + _error = e.toString(); + }); + return; + } + + try { + await importSwapsData(swaps); + } catch (e) { + setState(() { + _inProgress = false; + _error = e.toString(); + }); + return; + } + + _controller.clear(); + setState(() { + _inProgress = false; + _success = true; + }); + } +} diff --git a/lib/views/settings/widgets/general_settings/settings_download_logs.dart b/lib/views/settings/widgets/general_settings/settings_download_logs.dart new file mode 100644 index 0000000000..3478bd7f5a --- /dev/null +++ b/lib/views/settings/widgets/general_settings/settings_download_logs.dart @@ -0,0 +1,89 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/services/logger/get_logger.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/settings/widgets/common/settings_section.dart'; + +class SettingsDownloadLogs extends StatefulWidget { + const SettingsDownloadLogs({Key? key}) : super(key: key); + + @override + State createState() => _SettingsDownloadLogsState(); +} + +class _SettingsDownloadLogsState extends State { + bool _isDownloadFile = false; + bool _isLogFloodBusy = false; + + @override + Widget build(BuildContext context) { + return SettingsSection( + title: LocaleKeys.logs.tr(), + child: Wrap( + spacing: 16, + runSpacing: 16, + children: [ + UiBorderButton( + width: 146, + height: 32, + borderWidth: 1, + borderColor: theme.custom.specificButtonBorderColor, + backgroundColor: theme.custom.specificButtonBackgroundColor, + fontWeight: FontWeight.w500, + text: LocaleKeys.debugSettingsDownloadButton.tr(), + icon: _isDownloadFile + ? const UiSpinner() + : Icon( + Icons.file_download, + color: Theme.of(context).textTheme.bodyMedium?.color, + size: 18, + ), + onPressed: _isDownloadFile ? null : _downloadLogs, + ), + if (shouldShowFloodLogsButton) + FilledButton.icon( + label: Text(LocaleKeys.floodLogs.tr()), + icon: _isLogFloodBusy + ? const UiSpinner() + : Icon( + Icons.warning, + color: Theme.of(context).textTheme.bodyMedium?.color, + size: 18, + ), + onPressed: _isLogFloodBusy ? null : _runDebugFloodLogs, + ), + ], + ), + ); + } + + bool get shouldShowFloodLogsButton => kDebugMode || kProfileMode; + + Future _downloadLogs() async { + setState(() => _isDownloadFile = true); + + await logger + .getLogFile() + .whenComplete(() => setState(() => _isDownloadFile = false)); + } + + Future _runDebugFloodLogs() async { + setState(() => _isLogFloodBusy = true); + + WidgetsBinding.instance.scheduleFrameCallback((_) { + try { + for (int i = 0; i < 10000; i++) { + log('Log spam $i: ${'=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-' * 50}'); + } + } catch (e) { + rethrow; + } finally { + if (mounted) setState(() => _isLogFloodBusy = false); + } + }); + } +} diff --git a/lib/views/settings/widgets/general_settings/settings_manage_analytics.dart b/lib/views/settings/widgets/general_settings/settings_manage_analytics.dart new file mode 100644 index 0000000000..6adb54c405 --- /dev/null +++ b/lib/views/settings/widgets/general_settings/settings_manage_analytics.dart @@ -0,0 +1,17 @@ +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/send_analytics_checkbox.dart'; +import 'package:web_dex/views/settings/widgets/common/settings_section.dart'; + +class SettingsManageAnalytics extends StatelessWidget { + const SettingsManageAnalytics({super.key}); + + @override + Widget build(BuildContext context) { + return SettingsSection( + title: LocaleKeys.manageAnalytics.tr(), + child: const SendAnalyticsCheckbox(), + ); + } +} diff --git a/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart b/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart new file mode 100644 index 0000000000..fd48a53a6d --- /dev/null +++ b/lib/views/settings/widgets/general_settings/settings_manage_trading_bot.dart @@ -0,0 +1,58 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/market_maker_bot/market_maker_bot/market_maker_bot_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_bloc.dart'; +import 'package:web_dex/bloc/settings/settings_event.dart'; +import 'package:web_dex/bloc/settings/settings_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/settings/widgets/common/settings_section.dart'; + +class SettingsManageTradingBot extends StatelessWidget { + const SettingsManageTradingBot({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SettingsSection( + title: LocaleKeys.expertMode.tr(), + child: const EnableTradingBotSwitcher(), + ); + } +} + +class EnableTradingBotSwitcher extends StatelessWidget { + const EnableTradingBotSwitcher({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) => Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + UiSwitcher( + key: const Key('enable-trading-bot-switcher'), + value: state.mmBotSettings.isMMBotEnabled, + onChanged: (value) => _onSwitcherChanged(context, value), + ), + const SizedBox(width: 15), + Text(LocaleKeys.enableTradingBot.tr()), + ], + ), + ); + } + + void _onSwitcherChanged(BuildContext context, bool value) { + final settings = context.read().state.mmBotSettings.copyWith( + isMMBotEnabled: value, + ); + context.read().add(MarketMakerBotSettingsChanged(settings)); + + if (!value) { + context + .read() + .add(const MarketMakerBotStopRequested()); + } + } +} diff --git a/lib/views/settings/widgets/general_settings/settings_reset_activated_coins.dart b/lib/views/settings/widgets/general_settings/settings_reset_activated_coins.dart new file mode 100644 index 0000000000..360c70a7f2 --- /dev/null +++ b/lib/views/settings/widgets/general_settings/settings_reset_activated_coins.dart @@ -0,0 +1,135 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; + +class SettingsResetActivatedCoins extends StatefulWidget { + const SettingsResetActivatedCoins({Key? key}) : super(key: key); + + @override + State createState() => + _SettingsResetActivatedCoinsState(); +} + +class _SettingsResetActivatedCoinsState + extends State { + @override + Widget build(BuildContext context) { + return UiBorderButton( + height: 32, + borderWidth: 1, + borderColor: theme.custom.specificButtonBorderColor, + backgroundColor: theme.custom.specificButtonBackgroundColor, + fontWeight: FontWeight.w500, + text: LocaleKeys.debugSettingsResetActivatedCoins.tr(), + icon: Icon( + Icons.restart_alt, + color: Theme.of(context).textTheme.bodyMedium?.color, + size: 18, + ), + onPressed: _showResetPopup, + ); + } + + void _showResetPopup() async { + await walletsBloc.fetchSavedWallets(); + PopupDispatcher popupDispatcher = _createPopupDispatcher(); + popupDispatcher.show(); + } + + PopupDispatcher _createPopupDispatcher() { + final textStyle = Theme.of(context).textTheme.bodyMedium; + return PopupDispatcher( + borderColor: theme.custom.specificButtonBorderColor, + barrierColor: isMobile ? Theme.of(context).colorScheme.onSurface : null, + width: 320, + popupContent: walletsBloc.wallets.isEmpty + ? Center( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Text( + LocaleKeys.noWalletsAvailable.tr(), + style: textStyle, + ), + ), + ) + : SingleChildScrollView( + child: Column(children: [ + Text( + LocaleKeys.selectWalletToReset.tr(), + style: textStyle, + ), + const SizedBox(height: 8), + ...List.generate(walletsBloc.wallets.length, (index) { + return ListTile( + title: AutoScrollText( + text: walletsBloc.wallets[index].name, + style: textStyle, + ), + onTap: () => + _showConfirmationDialog(walletsBloc.wallets[index]), + ); + }), + ]), + ), + ); + } + + Future _showConfirmationDialog(Wallet wallet) async { + final result = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(LocaleKeys.resetWalletTitle.tr()), + content: SizedBox( + width: 500, + child: Text( + LocaleKeys.resetWalletContent.tr(args: [wallet.name]), + ), + ), + actions: [ + TextButton( + child: Text(LocaleKeys.cancel.tr()), + onPressed: () => + Navigator.of(context).popUntil((route) => route.isFirst), + ), + TextButton( + child: Text(LocaleKeys.reset.tr()), + onPressed: () => Navigator.of(context).pop(true), + ), + ], + ), + ); + + if (result == true) { + await _resetSpecificWallet(wallet); + } + } + + Future _resetSpecificWallet(Wallet wallet) async { + await walletsBloc.resetSpecificWallet(wallet); + + if (!mounted) return; + + // Show Dialog + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(LocaleKeys.resetCompleteTitle.tr()), + content: Text(LocaleKeys.resetCompleteContent.tr(args: [wallet.name])), + actions: [ + TextButton( + child: Text(LocaleKeys.ok.tr()), + onPressed: () => + Navigator.of(context).popUntil((route) => route.isFirst), + ), + ], + ), + ); + } +} diff --git a/lib/views/settings/widgets/general_settings/settings_theme_switcher.dart b/lib/views/settings/widgets/general_settings/settings_theme_switcher.dart new file mode 100644 index 0000000000..ebed58b926 --- /dev/null +++ b/lib/views/settings/widgets/general_settings/settings_theme_switcher.dart @@ -0,0 +1,161 @@ +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: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/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/settings/widgets/common/settings_section.dart'; + +class SettingsThemeSwitcher extends StatelessWidget { + const SettingsThemeSwitcher({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return SettingsSection( + title: LocaleKeys.changeTheme.tr(), + child: Container( + padding: const EdgeInsets.all(10.0), + constraints: const BoxConstraints(maxWidth: 340), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18.0), + color: Theme.of(context).colorScheme.onSurface), + child: const Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Padding( + padding: EdgeInsets.only(right: 6.0), + child: _SettingsModeSelector(mode: ThemeMode.light), + ), + ), + SizedBox(width: 10), + Expanded( + child: Padding( + padding: EdgeInsets.only(right: 6.0), + child: _SettingsModeSelector(mode: ThemeMode.dark), + ), + ), + ]), + ), + ); + } +} + +class _SettingsModeSelector extends StatelessWidget { + const _SettingsModeSelector({required this.mode}); + final ThemeMode mode; + + @override + Widget build(BuildContext context) { + final Color backgroundColor = _getColor(mode, context); + final bool isSelected = + mode == context.select((SettingsBloc bloc) => bloc.state.themeMode); + const double size = 16.0; + return InkWell( + onTap: () => + context.read().add(ThemeModeChanged(mode: mode)), + mouseCursor: SystemMouseCursors.click, + child: Container( + key: Key('theme-settings-switcher-$_themeName'), + height: 60, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18.0), + color: backgroundColor, + image: DecorationImage( + filterQuality: FilterQuality.high, + image: AssetImage(_iconPath), + alignment: Alignment.centerRight, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0), + child: Row( + children: [ + Container( + width: size, + height: size, + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).primaryColor, + ), + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: backgroundColor, + ), + child: DecoratedBox( + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).primaryColor + : theme.custom.noColor, + shape: BoxShape.circle, + ), + ), + ), + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 8.0, right: 2), + child: Text( + _themeName, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontSize: 14, color: _getTextColor(mode, context)), + ), + ), + ), + ], + ), + ), + ), + ); + } + + String get _themeName { + switch (mode) { + case ThemeMode.dark: + return LocaleKeys.dark.tr(); + case ThemeMode.light: + return LocaleKeys.light.tr(); + case ThemeMode.system: + return LocaleKeys.defaultText.tr(); + } + } + + String get _iconPath { + switch (mode) { + case ThemeMode.dark: + return '$assetsPath/logo/dark_theme.png'; + case ThemeMode.light: + return '$assetsPath/logo/light_theme.png'; + case ThemeMode.system: + return '$assetsPath/logo/dark_theme.png'; + } + } + + Color _getColor(ThemeMode mode, BuildContext context) { + switch (mode) { + case ThemeMode.dark: + return const Color.fromRGBO(14, 16, 27, 1); + case ThemeMode.light: + return const Color.fromRGBO(255, 255, 255, 1); + case ThemeMode.system: + return const Color.fromRGBO(0, 0, 0, 0); + } + } + + Color _getTextColor(ThemeMode mode, BuildContext context) { + switch (mode) { + case ThemeMode.dark: + return const Color.fromRGBO(255, 255, 255, 1); + case ThemeMode.light: + return const Color.fromRGBO(125, 144, 161, 1); + case ThemeMode.system: + return const Color.fromRGBO(0, 0, 0, 0); + } + } +} diff --git a/lib/views/settings/widgets/general_settings/show_swap_data.dart b/lib/views/settings/widgets/general_settings/show_swap_data.dart new file mode 100644 index 0000000000..d91a2d45ed --- /dev/null +++ b/lib/views/settings/widgets/general_settings/show_swap_data.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +import 'package:app_theme/app_theme.dart'; +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/mm2_api.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_recent_swaps/my_recent_swaps_request.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class ShowSwapData extends StatefulWidget { + const ShowSwapData({Key? key}) : super(key: key); + + @override + State createState() => _ShowSwapDataState(); +} + +class _ShowSwapDataState extends State { + final TextEditingController _controller = TextEditingController(); + bool _showData = false; + bool _inProgress = false; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSwitcherButton(), + if (_showData) ...{ + const SizedBox(height: 20), + _buildData(), + }, + const SizedBox(height: 20), + ], + ); + } + + Widget _buildSwitcherButton() { + return UiBorderButton( + width: 160, + height: 32, + borderWidth: 1, + borderColor: theme.custom.specificButtonBorderColor, + backgroundColor: theme.custom.specificButtonBackgroundColor, + fontWeight: FontWeight.w500, + text: LocaleKeys.showSwapData.tr(), + suffix: Icon( + _showData ? Icons.arrow_drop_up : Icons.arrow_drop_down, + size: 14, + ), + onPressed: _inProgress + ? null + : () { + if (_showData) { + setState(() => _showData = false); + } else { + _getSwapData(); + } + }, + ); + } + + Widget _buildData() { + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: TextField( + controller: _controller, + maxLines: 10, + style: const TextStyle(fontSize: 12), + ), + ), + const SizedBox(width: 10), + Material( + child: IconButton( + onPressed: () => copyToClipBoard(context, _controller.text), + icon: const Icon(Icons.copy)), + ), + const SizedBox(width: 10), + ], + ); + } + + Future _getSwapData() async { + setState(() => _inProgress = true); + + try { + final response = await mm2Api.getRawSwapData(MyRecentSwapsRequest()); + final Map data = jsonDecode(response); + _controller.text = jsonEncode(data['result']['swaps']).toString(); + } catch (e) { + _controller.text = e.toString(); + } + + setState(() { + _showData = true; + _inProgress = false; + }); + } +} diff --git a/lib/views/settings/widgets/security_settings/change_password_section.dart b/lib/views/settings/widgets/security_settings/change_password_section.dart new file mode 100644 index 0000000000..a791893382 --- /dev/null +++ b/lib/views/settings/widgets/security_settings/change_password_section.dart @@ -0,0 +1,102 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_event.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class ChangePasswordSection extends StatelessWidget { + const ChangePasswordSection(); + + @override + Widget build(BuildContext context) { + return isMobile ? const _MobileBody() : const _DesktopBody(); + } +} + +class _DesktopBody extends StatelessWidget { + const _DesktopBody(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 15, vertical: 12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded(child: _TitleChangePasswordSection()), + SizedBox(width: 8), + _PasswordButton(), + ], + ), + ); + } +} + +class _MobileBody extends StatelessWidget { + const _MobileBody(); + + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(horizontal: 0, vertical: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _TitleChangePasswordSection(), + SizedBox(height: 21), + _PasswordButton(), + ], + ), + ); + } +} + +class _TitleChangePasswordSection extends StatelessWidget { + const _TitleChangePasswordSection(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Text( + LocaleKeys.passwordTitle.tr(), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + SizedBox(height: isMobile ? 5 : 15), + Text( + LocaleKeys.changePasswordSpan1.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ) + ], + ); + } +} + +class _PasswordButton extends StatelessWidget { + const _PasswordButton(); + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + + return UiBorderButton( + width: isMobile ? double.infinity : 191, + height: isMobile ? 52 : 40, + text: LocaleKeys.changeThePassword.tr(), + backgroundColor: Theme.of(context).colorScheme.surface, + onPressed: () async => bloc.add(const PasswordUpdateEvent()), + ); + } +} diff --git a/lib/views/settings/widgets/security_settings/password_update_page.dart b/lib/views/settings/widgets/security_settings/password_update_page.dart new file mode 100644 index 0000000000..815bec0f33 --- /dev/null +++ b/lib/views/settings/widgets/security_settings/password_update_page.dart @@ -0,0 +1,387 @@ +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:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_event.dart'; +import 'package:web_dex/blocs/blocs.dart'; +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/utils/validators.dart'; +import 'package:web_dex/shared/widgets/password_visibility_control.dart'; +import 'package:web_dex/views/common/page_header/page_header.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class PasswordUpdatePage extends StatefulWidget { + const PasswordUpdatePage({Key? key}) : super(key: key); + + @override + State createState() => _PasswordUpdatePageState(); +} + +class _PasswordUpdatePageState extends State { + bool _passwordUpdated = false; + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + const event = ResetEvent(); + gotoSecurityMain() => bloc.add(event); + + late Widget pageContent; + if (_passwordUpdated) { + pageContent = _SuccessView(back: gotoSecurityMain); + } else { + pageContent = _FormView( + onSuccess: () { + setState(() => _passwordUpdated = true); + }, + ); + } + final scrollController = ScrollController(); + return Container( + padding: const EdgeInsets.fromLTRB(24, 0, 24, 24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withOpacity(.3), + borderRadius: BorderRadius.circular(18.0)), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (isNotMobile) + PageHeader( + onBackButtonPressed: gotoSecurityMain, + backText: LocaleKeys.back.tr(), + title: LocaleKeys.changingWalletPassword.tr(), + ), + const SizedBox(height: 28), + Flexible( + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: SingleChildScrollView( + controller: ScrollController(), + child: SizedBox( + width: 270, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (isMobile) ...{ + Text( + '${LocaleKeys.changingWalletPassword.tr()} ', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 15), + }, + Text( + LocaleKeys.changingWalletPasswordDescription.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 21), + pageContent, + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} + +class _FormView extends StatefulWidget { + const _FormView({Key? key, required this.onSuccess}) : super(key: key); + + final VoidCallback onSuccess; + + @override + State<_FormView> createState() => _FormViewState(); +} + +class _FormViewState extends State<_FormView> { + bool _isObscured = true; + final _oldController = TextEditingController(); + final _newController = TextEditingController(); + final _confirmController = TextEditingController(); + final GlobalKey _formKey = GlobalKey(); + String? _error; + + @override + void dispose() { + _oldController.dispose(); + _newController.dispose(); + _confirmController.dispose(); + + super.dispose(); + } + + @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), + ), + const SizedBox(height: 10), + }, + UiPrimaryButton( + onPressed: _onUpdate, + text: LocaleKeys.updatePassword.tr(), + ), + ], + ), + ); + } + + Future _onUpdate() async { + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + final Wallet? wallet = currentWalletBloc.wallet; + if (wallet == null) return; + final String password = _newController.text; + + if (_oldController.text == password) { + setState(() { + _error = LocaleKeys.usedSamePassword.tr(); + }); + return; + } + + final bool isPasswordUpdated = await currentWalletBloc.updatePassword( + _oldController.text, + password, + wallet, + ); + + if (!isPasswordUpdated) { + setState(() { + _error = LocaleKeys.passwordNotAccepted.tr(); + }); + return; + } else { + setState(() => _error = null); + } + + _newController.text = ''; + _confirmController.text = ''; + widget.onSuccess(); + } + + void _onVisibilityChange(bool isPasswordObscured) { + setState(() { + _isObscured = isPasswordObscured; + }); + } +} + +class _CurrentField extends StatefulWidget { + const _CurrentField({ + required this.controller, + required this.isObscured, + required this.onVisibilityChange, + required this.formKey, + }); + + final TextEditingController controller; + final bool isObscured; + final Function(bool) onVisibilityChange; + final GlobalKey formKey; + + @override + State<_CurrentField> createState() => _CurrentFieldState(); +} + +class _CurrentFieldState extends State<_CurrentField> { + String _seedError = ''; + + @override + Widget build(BuildContext context) { + return _PasswordField( + hintText: LocaleKeys.currentPassword.tr(), + controller: widget.controller, + isObscured: widget.isObscured, + validator: (String? password) { + if (password == null || password.isEmpty) { + return LocaleKeys.passwordIsEmpty.tr(); + } + + if (_seedError.isNotEmpty) { + final result = _seedError; + _seedError = ''; + return result; + } + + final Wallet? currentWallet = currentWalletBloc.wallet; + if (currentWallet == null) return LocaleKeys.walletNotFound.tr(); + + _validateSeed(currentWallet, password); + return null; + }, + suffixIcon: PasswordVisibilityControl( + onVisibilityChange: widget.onVisibilityChange, + ), + ); + } + + Future _validateSeed(Wallet currentWallet, String password) async { + _seedError = ''; + final seed = await currentWallet.getSeed(password); + if (seed.isNotEmpty) return; + _seedError = LocaleKeys.invalidPasswordError.tr(); + widget.formKey.currentState?.validate(); + } +} + +class _NewField extends StatelessWidget { + const _NewField({ + required this.controller, + required this.isObscured, + required this.onVisibilityChange, + }); + + final TextEditingController controller; + final bool isObscured; + final Function(bool) onVisibilityChange; + + @override + Widget build(BuildContext context) { + return _PasswordField( + hintText: LocaleKeys.enterNewPassword.tr(), + controller: controller, + isObscured: isObscured, + validator: (String? password) => validatePassword( + password ?? '', + LocaleKeys.walletCreationFormatPasswordError.tr(), + ), + suffixIcon: PasswordVisibilityControl( + onVisibilityChange: onVisibilityChange, + ), + ); + } +} + +class _ConfirmField extends StatelessWidget { + const _ConfirmField({ + required this.confirmController, + required this.newController, + required this.isObscured, + required this.onVisibilityChange, + }); + + final TextEditingController confirmController; + final TextEditingController newController; + final bool isObscured; + final Function(bool) onVisibilityChange; + + @override + Widget build(BuildContext context) { + return _PasswordField( + hintText: LocaleKeys.confirmNewPassword.tr(), + controller: confirmController, + isObscured: isObscured, + validator: (String? confirmPassword) => validateConfirmPassword( + newController.text, + confirmPassword ?? '', + ), + suffixIcon: PasswordVisibilityControl( + onVisibilityChange: onVisibilityChange, + ), + ); + } +} + +class _PasswordField extends StatelessWidget { + const _PasswordField({ + required this.controller, + required this.isObscured, + required this.suffixIcon, + required this.validator, + required this.hintText, + }); + + final TextEditingController controller; + final bool isObscured; + final PasswordVisibilityControl suffixIcon; + final String? Function(String?)? validator; + final String hintText; + + @override + Widget build(BuildContext context) { + return UiTextFormField( + autofocus: true, + controller: controller, + textInputAction: TextInputAction.none, + autocorrect: false, + enableInteractiveSelection: true, + obscureText: isObscured, + inputFormatters: [LengthLimitingTextInputFormatter(40)], + validator: validator, + errorMaxLines: 6, + hintText: hintText, + suffixIcon: suffixIcon, + ); + } +} + +class _SuccessView extends StatelessWidget { + const _SuccessView({Key? key, required this.back}) : super(key: key); + + final VoidCallback back; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + const SizedBox( + height: 20, + ), + Padding( + padding: const EdgeInsets.only(top: 30), + child: UiPrimaryButton( + prefix: const Icon(Icons.check, color: Colors.white), + backgroundColor: theme.custom.passwordButtonSuccessColor, + onPressed: back, + text: LocaleKeys.passwordHasChanged.tr(), + ), + ), + ], + ); + } +} diff --git a/lib/views/settings/widgets/security_settings/plate_seed_backup.dart b/lib/views/settings/widgets/security_settings/plate_seed_backup.dart new file mode 100644 index 0000000000..8b1772c140 --- /dev/null +++ b/lib/views/settings/widgets/security_settings/plate_seed_backup.dart @@ -0,0 +1,213 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/views/common/wallet_password_dialog/wallet_password_dialog.dart'; + +class PlateSeedBackup extends StatelessWidget { + const PlateSeedBackup({required this.onViewSeedPressed}); + final Function(BuildContext context) onViewSeedPressed; + + @override + Widget build(BuildContext context) { + return isMobile + ? _MobileBody(onViewSeedPressed: onViewSeedPressed) + : _DesktopBody(onViewSeedPressed: onViewSeedPressed); + } +} + +class _MobileBody extends StatelessWidget { + const _MobileBody({required this.onViewSeedPressed}); + + final Function(BuildContext context) onViewSeedPressed; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiary, + borderRadius: BorderRadius.circular(18.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 24), + const _AtomicIcon(), + const SizedBox(height: 28), + const _SaveAndRememberTitle(), + const SizedBox(height: 12), + const _SaveAndRememberBody(), + const SizedBox(height: 8), + _SaveAndRememberButtons(onViewSeedPressed: onViewSeedPressed), + const SizedBox(height: 6), + ], + ), + ); + } +} + +class _DesktopBody extends StatelessWidget { + const _DesktopBody({required this.onViewSeedPressed}); + + final Function(BuildContext context) onViewSeedPressed; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiary, + borderRadius: BorderRadius.circular(18.0), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const _AtomicIcon(), + const SizedBox(width: 22.5), + const Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + SizedBox(height: 12), + _SaveAndRememberTitle(), + SizedBox(height: 12), + _SaveAndRememberBody(), + SizedBox(height: 12), + ], + ), + ), + const SizedBox(width: 8), + _SaveAndRememberButtons(onViewSeedPressed: onViewSeedPressed), + ], + ), + ], + ), + ); + } +} + +class _AtomicIcon extends StatelessWidget { + const _AtomicIcon(); + + @override + Widget build(BuildContext context) { + final hasBackup = currentWalletBloc.wallet?.config.hasBackup ?? false; + return DexSvgImage( + path: hasBackup ? Assets.seedBackedUp : Assets.seedNotBackedUp, + size: 50, + ); + } +} + +class _SaveAndRememberTitle extends StatelessWidget { + const _SaveAndRememberTitle(); + + @override + Widget build(BuildContext context) { + final hasBackup = currentWalletBloc.wallet?.config.hasBackup ?? false; + + return Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (!hasBackup) + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: theme.custom.decreaseColor, + borderRadius: BorderRadius.circular(7 / 2), + ), + ), + if (!hasBackup) + const SizedBox( + width: 7, + ), + Text( + LocaleKeys.saveAndRemember.tr(), + style: TextStyle( + fontSize: isMobile ? 15 : 16, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} + +class _SaveAndRememberBody extends StatelessWidget { + const _SaveAndRememberBody(); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: 100), + child: Text( + LocaleKeys.seedAccessSpan1.tr(), + style: TextStyle( + fontSize: isMobile ? 13 : 14, + fontWeight: FontWeight.w500, + ), + ), + ); + } +} + +class _SaveAndRememberButtons extends StatelessWidget { + const _SaveAndRememberButtons({required this.onViewSeedPressed}); + + final Function(BuildContext context) onViewSeedPressed; + + @override + Widget build(BuildContext context) { + final hasBackup = currentWalletBloc.wallet?.config.hasBackup == true; + final text = hasBackup + ? LocaleKeys.viewSeedPhrase.tr() + : LocaleKeys.backupSeedPhrase.tr(); + final width = isMobile ? double.infinity : 187.0; + final height = isMobile ? 52.0 : 40.0; + + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 12), + UiPrimaryButton( + onPressed: () => onViewSeedPressed(context), + width: width, + height: height, + text: text, + textStyle: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 14, + color: theme.custom.defaultGradientButtonTextColor, + ), + ), + const SizedBox(height: 2), + UiUnderlineTextButton( + onPressed: () async { + final String? password = await walletPasswordDialog(context); + if (password == null) return; + + currentWalletBloc.downloadCurrentWallet(password); + }, + width: isMobile ? double.infinity : 187, + height: isMobile ? 52 : 40, + text: LocaleKeys.seedPhraseSettingControlsDownloadSeed.tr(), + textFontSize: 14, + ) + ], + ); + } +} diff --git a/lib/views/settings/widgets/security_settings/security_settings_main_page.dart b/lib/views/settings/widgets/security_settings/security_settings_main_page.dart new file mode 100644 index 0000000000..4cbcd311a5 --- /dev/null +++ b/lib/views/settings/widgets/security_settings/security_settings_main_page.dart @@ -0,0 +1,21 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/views/settings/widgets/security_settings/change_password_section.dart'; +import 'package:web_dex/views/settings/widgets/security_settings/plate_seed_backup.dart'; + +class SecuritySettingsMainPage extends StatelessWidget { + const SecuritySettingsMainPage({required this.onViewSeedPressed}); + final Function(BuildContext context) onViewSeedPressed; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + PlateSeedBackup(onViewSeedPressed: onViewSeedPressed), + const SizedBox(height: 12), + const ChangePasswordSection(), + ], + ); + } +} diff --git a/lib/views/settings/widgets/security_settings/security_settings_page.dart b/lib/views/settings/widgets/security_settings/security_settings_page.dart new file mode 100644 index 0000000000..77f9c13cfe --- /dev/null +++ b/lib/views/settings/widgets/security_settings/security_settings_page.dart @@ -0,0 +1,127 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_event.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_state.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/settings_menu_value.dart'; +import 'package:web_dex/model/wallet.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/common/wallet_password_dialog/wallet_password_dialog.dart'; +import 'package:web_dex/views/settings/widgets/common/settings_content_wrapper.dart'; +import 'package:web_dex/views/settings/widgets/security_settings/password_update_page.dart'; +import 'package:web_dex/views/settings/widgets/security_settings/security_settings_main_page.dart'; +import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/seed_confirm_success.dart'; +import 'package:web_dex/views/settings/widgets/security_settings/seed_settings/seed_show.dart'; + +import 'seed_settings/seed_confirmation/seed_confirmation.dart'; + +class SecuritySettingsPage extends StatefulWidget { + // ignore: prefer_const_constructors_in_immutables + SecuritySettingsPage({super.key, required this.onBackPressed}); + final VoidCallback onBackPressed; + @override + State createState() => _SecuritySettingsPageState(); +} + +class _SecuritySettingsPageState extends State { + String _seed = ''; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (_) => SecuritySettingsBloc(SecuritySettingsState.initialState()), + child: BlocBuilder( + builder: (BuildContext context, SecuritySettingsState state) { + final Widget content = _buildContent(state.step); + if (isMobile) { + return _SecuritySettingsPageMobile( + content: content, + onBackButtonPressed: () { + switch (state.step) { + case SecuritySettingsStep.securityMain: + widget.onBackPressed(); + break; + case SecuritySettingsStep.seedConfirm: + context + .read() + .add(const ShowSeedEvent()); + break; + case SecuritySettingsStep.seedShow: + case SecuritySettingsStep.seedSuccess: + case SecuritySettingsStep.passwordUpdate: + context + .read() + .add(const ResetEvent()); + break; + } + }, + ); + } + return content; + }, + ), + ); + } + + Widget _buildContent(SecuritySettingsStep step) { + switch (step) { + case SecuritySettingsStep.securityMain: + _seed = ''; + return SecuritySettingsMainPage(onViewSeedPressed: onViewSeedPressed); + + case SecuritySettingsStep.seedShow: + return SeedShow(seedPhrase: _seed); + + case SecuritySettingsStep.seedConfirm: + return SeedConfirmation(seedPhrase: _seed); + + case SecuritySettingsStep.seedSuccess: + _seed = ''; + return const SeedConfirmSuccess(); + + case SecuritySettingsStep.passwordUpdate: + _seed = ''; + return const PasswordUpdatePage(); + } + } + + Future onViewSeedPressed(BuildContext context) async { + final SecuritySettingsBloc securitySettingsBloc = + context.read(); + + final String? pass = await walletPasswordDialog(context); + if (pass == null) return; + final Wallet? wallet = currentWalletBloc.wallet; + if (wallet == null) return; + _seed = await wallet.getSeed(pass); + if (_seed.isEmpty) return; + + securitySettingsBloc.add(const ShowSeedEvent()); + } +} + +class _SecuritySettingsPageMobile extends StatelessWidget { + const _SecuritySettingsPageMobile( + {required this.onBackButtonPressed, required this.content}); + final VoidCallback onBackButtonPressed; + final Widget content; + + @override + Widget build(BuildContext context) { + return PageLayout( + header: PageHeader( + title: SettingsMenuValue.security.title, + backText: '', + onBackButtonPressed: onBackButtonPressed, + ), + content: Flexible( + child: SettingsContentWrapper( + child: content, + ), + ), + ); + } +} diff --git a/lib/views/settings/widgets/security_settings/seed_settings/backup_seed_notification.dart b/lib/views/settings/widgets/security_settings/seed_settings/backup_seed_notification.dart new file mode 100644 index 0000000000..d84c1a64fc --- /dev/null +++ b/lib/views/settings/widgets/security_settings/seed_settings/backup_seed_notification.dart @@ -0,0 +1,246 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +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/router/state/routing_state.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; + +class BackupSeedNotification extends StatefulWidget { + const BackupSeedNotification({ + Key? key, + this.title, + this.description, + this.customizationNotification, + this.hideOnMobile = true, + }) : super(key: key); + + final String? title; + final String? description; + final CustomizationBackupNotificationData? customizationNotification; + final bool hideOnMobile; + + @override + State createState() => _BackupSeedNotificationState(); +} + +class _BackupSeedNotificationState extends State { + @override + Widget build(BuildContext context) { + if (isMobile && widget.hideOnMobile) return const SizedBox.shrink(); + + final CustomizationBackupNotificationData customization = + _getCustomization(); + + final String title = + widget.title ?? LocaleKeys.backupSeedNotificationTitle.tr(); + final String description = + widget.description ?? LocaleKeys.backupSeedNotificationDescription.tr(); + + return StreamBuilder( + stream: currentWalletBloc.outWallet, + builder: (context, snapshot) { + final currentWallet = currentWalletBloc.wallet; + if (currentWallet == null || currentWallet.config.hasBackup) { + return const SizedBox(); + } + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 17, + vertical: 15, + ), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18)), + color: customization.backgroundColor ?? + Theme.of(context).colorScheme.surface, + ), + child: customization.isBelowButton + ? Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _Line(customization.line), + Flexible( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: customization.titleStyle), + const SizedBox(height: 8), + Text( + description, + style: customization.descriptionStyle, + ), + const SizedBox(height: 12), + _BackupButton(customization), + ], + ), + ), + ], + ) + : Wrap( + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.center, + runSpacing: 12, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + _Line(customization.line), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, style: customization.titleStyle), + const SizedBox(height: 2), + Text( + description, + style: customization.descriptionStyle, + ), + ], + ), + ), + ], + ), + _BackupButton(customization), + ], + ), + ), + const SizedBox(height: 16), + ], + ); + }, + ); + } + + CustomizationBackupNotificationData _getCustomization() { + final CustomizationBackupNotificationData? customizationNotification = + widget.customizationNotification; + if (customizationNotification != null) { + return customizationNotification; + } + return CustomizationBackupNotificationData( + titleStyle: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w700, + fontSize: 16, + ), + descriptionStyle: Theme.of(context).textTheme.bodyLarge!.copyWith( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + line: BackupNotificationLineStyle( + width: 8, height: 45, color: theme.custom.warningColor), + ); + } +} + +class _BackupButton extends StatelessWidget { + const _BackupButton(this.customization); + + final CustomizationBackupNotificationData customization; + + @override + Widget build(BuildContext context) { + return UiPrimaryButton( + text: LocaleKeys.backupSeedNotificationButton.tr(), + backgroundColor: customization.buttonBackgroundColor ?? + theme.custom.simpleButtonBackgroundColor, + width: 85, + height: 28, + textStyle: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(fontSize: 12, color: customization.buttonTextColor), + onPressed: routingState.settingsState.openSecurity, + ); + } +} + +class _Line extends StatelessWidget { + const _Line(this.line); + + final BackupNotificationLineStyle line; + + @override + Widget build(BuildContext context) { + return Container( + height: line.height, + width: line.width, + margin: const EdgeInsets.only(right: 20), + decoration: BoxDecoration( + color: line.color, + borderRadius: const BorderRadius.all( + Radius.circular(18), + ), + ), + ); + } +} + +class BackupNotificationLineStyle { + BackupNotificationLineStyle({ + required this.width, + required this.height, + required this.color, + }); + + final double width; + final double height; + final Color color; +} + +class CustomizationBackupNotificationData { + CustomizationBackupNotificationData({ + required this.titleStyle, + required this.descriptionStyle, + required this.line, + this.backgroundColor, + this.buttonBackgroundColor, + this.buttonTextColor, + this.isBelowButton = false, + }); + + final TextStyle? titleStyle; + final TextStyle? descriptionStyle; + final bool isBelowButton; + final Color? backgroundColor; + final Color? buttonBackgroundColor; + final Color? buttonTextColor; + final BackupNotificationLineStyle line; +} + +class BackupNotification extends StatelessWidget { + const BackupNotification(); + + @override + Widget build(BuildContext context) { + final textStyle = Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Theme.of(context).textTheme.bodyLarge?.color?.withOpacity(0.7), + fontWeight: FontWeight.w600, + ); + return BackupSeedNotification( + title: LocaleKeys.coinAddressDetailsNotificationTitle.tr(), + description: LocaleKeys.coinAddressDetailsNotificationDescription.tr(), + customizationNotification: CustomizationBackupNotificationData( + titleStyle: + Theme.of(context).textTheme.headlineMedium?.copyWith(fontSize: 20), + descriptionStyle: textStyle, + line: BackupNotificationLineStyle( + color: theme.custom.warningColor, + width: 15, + height: 96, + ), + buttonBackgroundColor: Theme.of(context).colorScheme.primary, + buttonTextColor: Colors.white, + isBelowButton: true, + ), + hideOnMobile: false, + ); + } +} diff --git a/lib/views/settings/widgets/security_settings/seed_settings/seed_back_button.dart b/lib/views/settings/widgets/security_settings/seed_settings/seed_back_button.dart new file mode 100644 index 0000000000..944b427c9b --- /dev/null +++ b/lib/views/settings/widgets/security_settings/seed_settings/seed_back_button.dart @@ -0,0 +1,34 @@ +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/ui/ui_gradient_icon.dart'; + +class SeedBackButton extends StatelessWidget { + const SeedBackButton(this.back); + + final VoidCallback back; + + @override + Widget build(BuildContext context) { + const style = TextStyle(fontSize: 16, fontWeight: FontWeight.w600); + return InkWell( + key: const Key('back-button'), + radius: 30, + onTap: back, + borderRadius: BorderRadius.circular(10), + child: SizedBox( + height: 30, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 9), + const UiGradientIcon(icon: Icons.chevron_left, size: 24), + const SizedBox(width: 14), + Text(LocaleKeys.back.tr(), style: style), + const SizedBox(width: 18), + ], + ), + ), + ); + } +} diff --git a/lib/views/settings/widgets/security_settings/seed_settings/seed_confirm_success.dart b/lib/views/settings/widgets/security_settings/seed_settings/seed_confirm_success.dart new file mode 100644 index 0000000000..cca4be9111 --- /dev/null +++ b/lib/views/settings/widgets/security_settings/seed_settings/seed_confirm_success.dart @@ -0,0 +1,141 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_event.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class SeedConfirmSuccess extends StatelessWidget { + const SeedConfirmSuccess(); + + @override + Widget build(BuildContext context) { + return isMobile ? const _MobileLayout() : const _DesktopLayout(); + } +} + +class _MobileLayout extends StatelessWidget { + const _MobileLayout(); + + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return DexScrollbar( + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: 25), + DexSvgImage(path: Assets.seedSuccess), + SizedBox(height: 20), + _Title(), + SizedBox(height: 9), + _Body(), + SizedBox(height: 20), + _Button(), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _DesktopLayout extends StatelessWidget { + const _DesktopLayout(); + + @override + Widget build(BuildContext context) { + return const SingleChildScrollView( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: 25), + DexSvgImage(path: Assets.seedSuccess), + SizedBox(height: 20), + _Title(), + SizedBox(height: 9), + _Body(), + SizedBox(height: 20), + _Button(), + ], + ), + ], + ), + ); + } +} + +class _Title extends StatelessWidget { + const _Title(); + + @override + Widget build(BuildContext context) { + return Text( + LocaleKeys.seedPhraseSuccessTitle.tr(), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 16, + ), + ); + } +} + +class _Body extends StatelessWidget { + const _Body(); + + @override + Widget build(BuildContext context) { + return Container( + constraints: const BoxConstraints(maxWidth: 415), + child: Row( + children: [ + Expanded( + child: Text( + LocaleKeys.seedPhraseSuccessBody.tr(), + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ); + } +} + +class _Button extends StatelessWidget { + const _Button(); + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + const event = ResetEvent(); + gotoSecurityMain() => bloc.add(event); + + return UiPrimaryButton( + key: const Key('seed-confirm-got-it'), + width: 198, + height: isMobile ? 52 : 40, + onPressed: gotoSecurityMain, + text: LocaleKeys.seedPhraseGotIt.tr(), + ); + } +} 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 new file mode 100644 index 0000000000..995278e432 --- /dev/null +++ b/lib/views/settings/widgets/security_settings/seed_settings/seed_confirmation/seed_confirmation.dart @@ -0,0 +1,299 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_event.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/text_error.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'; + +class SeedConfirmation extends StatefulWidget { + const SeedConfirmation({required this.seedPhrase}); + final String seedPhrase; + + @override + State createState() => _SeedConfirmationState(); +} + +class _SeedConfirmationState extends State { + List<_SeedWord> _selectedWords = []; + late List<_SeedWord> _jumbledWords; + late List<_SeedWord> _originalWords; + TextError? _confirmationError; + + @override + void initState() { + _originalWords = + widget.seedPhrase.split(' ').map((w) => _SeedWord(word: w)).toList(); + _jumbledWords = List.from(_originalWords)..shuffle(); + super.initState(); + } + + @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( + () => 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( + 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), + ], + ), + ), + ], + ), + ), + ); + } + + void _onWordPressed(_SeedWord word) { + if (_selectedWords.contains(word)) { + _selectedWords.remove(word); + } else { + _selectedWords.add(word); + } + setState(() { + _selectedWords = _selectedWords; + _confirmationError = null; + }); + } + + void _onConfirmPressed() { + final String result = _selectedWords.map((w) => w.word).join(' ').trim(); + + if (result == widget.seedPhrase) { + final settingsBloc = context.read(); + settingsBloc.add(const SeedConfirmedEvent()); + return; + } + setState(() { + _confirmationError = + TextError(error: LocaleKeys.seedConfirmIncorrectText.tr()); + }); + } + + void _clear() { + setState(() { + _confirmationError = null; + _selectedWords.clear(); + }); + } + + bool get _isReadyForCheck => _selectedWords.length == _originalWords.length; +} + +class _Title extends StatelessWidget { + const _Title(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocaleKeys.seedConfirmTitle.tr(), + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 16), + ), + const SizedBox(height: 6), + Text( + LocaleKeys.seedConfirmDescription.tr(), + style: Theme.of(context).textTheme.bodyLarge, + ), + ], + ); + } +} + +class _ResultWord extends StatelessWidget { + const _ResultWord(this.word); + + final _SeedWord word; + + @override + Widget build(BuildContext context) { + return Text( + '${word.word} ', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ); + } +} + +class _JumbledSeedWords extends StatelessWidget { + const _JumbledSeedWords( + {required this.onWordPressed, + required this.words, + required this.selectedWords}); + final List<_SeedWord> words; + final List<_SeedWord> selectedWords; + final void Function(_SeedWord) onWordPressed; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: Wrap( + runAlignment: WrapAlignment.spaceBetween, + alignment: WrapAlignment.spaceBetween, + crossAxisAlignment: WrapCrossAlignment.start, + runSpacing: 11, + children: words.map((w) { + return FractionallySizedBox( + widthFactor: isMobile ? 0.5 : 0.25, + child: Padding( + padding: const EdgeInsets.only(right: 10), + child: SeedWordButton( + text: w.word, + onPressed: () => onWordPressed(w), + isSelected: selectedWords.contains(w), + ), + ), + ); + }).toList(), + ), + ); + } +} + +class _SelectedWordsField extends StatelessWidget { + const _SelectedWordsField( + {required this.selectedWords, required this.confirmationError}); + final List<_SeedWord> selectedWords; + final TextError? confirmationError; + + @override + Widget build(BuildContext context) { + final fillColor = Theme.of(context).inputDecorationTheme.fillColor; + final TextError? error = confirmationError; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + decoration: BoxDecoration( + border: error != null + ? Border.all(color: Theme.of(context).colorScheme.error) + : null, + borderRadius: BorderRadius.circular(20), + color: fillColor, + ), + constraints: const BoxConstraints(minHeight: 85), + width: double.infinity, + padding: const EdgeInsets.all(10), + child: Wrap( + runSpacing: 4, + spacing: 8, + children: _createResultWords(selectedWords), + ), + ), + if (error != null) + Padding( + padding: const EdgeInsets.only(top: 6.0), + child: SelectableText( + error.message, + style: Theme.of(context).inputDecorationTheme.errorStyle, + ), + ) + ], + ); + } + + List<_ResultWord> _createResultWords(List<_SeedWord> resultWords) { + final result = <_ResultWord>[]; + for (int i = 0; i < resultWords.length; i++) { + result.add(_ResultWord(resultWords[i])); + } + return result; + } +} + +class _ControlButtons extends StatelessWidget { + const _ControlButtons({ + required this.onClearPressed, + required this.onConfirmPressed, + }); + final VoidCallback onClearPressed; + final VoidCallback? onConfirmPressed; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: UiBorderButton( + key: const Key('seed-confirm-clear-button'), + height: isMobile ? 52 : 40, + onPressed: onClearPressed, + text: LocaleKeys.clear.tr(), + ), + ), + const SizedBox(width: 10), + Expanded( + child: UiPrimaryButton( + key: const Key('seed-confirm-check-button'), + height: isMobile ? 52 : 40, + onPressed: onConfirmPressed, + text: LocaleKeys.confirm.tr(), + ), + ), + ], + ); + } +} + +class _SeedWord { + const _SeedWord({required this.word}); + final String word; +} 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 new file mode 100644 index 0000000000..894404ed87 --- /dev/null +++ b/lib/views/settings/widgets/security_settings/seed_settings/seed_show.dart @@ -0,0 +1,340 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:bip39/bip39.dart' show validateMnemonic; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_bloc.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_event.dart'; +import 'package:web_dex/bloc/security_settings/security_settings_state.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/utils/utils.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:komodo_ui_kit/komodo_ui_kit.dart'; + +class SeedShow extends StatelessWidget { + const SeedShow({required this.seedPhrase}); + final String seedPhrase; + + @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(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) + ], + ), + ), + ); + } +} + +class _TitleRow extends StatelessWidget { + const _TitleRow(); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.seedPhraseShowingTitle.tr(), + style: Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 16), + ), + const SizedBox(height: 6), + Text(LocaleKeys.seedPhraseMakeSureBody.tr(), + style: Theme.of(context).textTheme.bodyLarge), + ], + ); + } +} + +class _CopySeedButton extends StatelessWidget { + const _CopySeedButton({required this.seed}); + final String seed; + + @override + Widget build(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + copyToClipBoard(context, seed); + context.read().add(const ShowSeedCopiedEvent()); + }, + borderRadius: BorderRadius.circular(18), + child: Padding( + padding: const EdgeInsets.fromLTRB(10, 5, 10, 5), + child: Row( + children: [ + Icon( + Icons.copy, + size: 16, + color: theme.currentGlobal.textTheme.bodySmall?.color, + ), + const SizedBox(width: 10), + Text( + LocaleKeys.seedPhraseShowingCopySeed.tr(), + style: theme.currentGlobal.textTheme.bodySmall, + ), + ], + ), + ), + ), + ); + } +} + +class _ShowingSwitcher extends StatelessWidget { + @override + Widget build(BuildContext context) { + final bloc = context.read(); + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + UiSwitcher( + value: bloc.state.showSeedWords, + onChanged: (isChecked) => bloc.add(ShowSeedWordsEvent(isChecked)), + width: 38, + height: 21, + ), + const SizedBox(width: 6), + SelectableText( + LocaleKeys.seedPhraseShowingShowPhrase.tr(), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + ], + ); + } +} + +class _SeedPlace extends StatelessWidget { + const _SeedPlace({required this.seedPhrase}); + final String seedPhrase; + + @override + Widget build(BuildContext context) { + final isCustom = !validateMnemonic(seedPhrase); + if (isCustom) return _SeedField(seedPhrase: seedPhrase); + return _WordsList(seedPhrase: seedPhrase); + } +} + +class _SeedField extends StatelessWidget { + const _SeedField({required this.seedPhrase}); + final String seedPhrase; + + @override + Widget build(BuildContext context) { + final width = screenWidth - 80.0; + + return BlocSelector( + selector: (state) => state.showSeedWords, + builder: (context, showSeedWords) { + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: isMobile ? width : 380), + child: TextField( + controller: TextEditingController() + ..text = showSeedWords ? seedPhrase : _obscured(seedPhrase), + maxLines: 12, + minLines: 5, + readOnly: true, + ), + ); + }, + ); + } + + String _obscured(String source, {String obscuringCharacter = '•'}) { + if (source.isEmpty) return ''; + if (obscuringCharacter.length > 1) { + obscuringCharacter = obscuringCharacter.substring(0, 1); + } else if (obscuringCharacter.isEmpty) { + obscuringCharacter = '•'; + } + final length = source.length; + String result = ''; + for (int i = 0; i < length; i++) { + result += source.codeUnitAt(i) == 32 ? ' ' : obscuringCharacter; + } + return result; + } +} + +class _WordsList extends StatelessWidget { + const _WordsList({required this.seedPhrase}); + final String seedPhrase; + + @override + Widget build(BuildContext context) { + final double runSpacing = isMobile ? 15 : 20; + + final bloc = context.read(); + final showSeedWords = bloc.state.showSeedWords; + + final seedList = seedPhrase.split(' '); + + return SizedBox( + width: double.infinity, + child: Wrap( + runSpacing: runSpacing, + alignment: WrapAlignment.spaceBetween, + children: seedList + .asMap() + .map( + (index, w) => _buildSeedWord(index, w, showSeedWords)) + .values + .toList(), + ), + ); + } + + MapEntry _buildSeedWord(int i, String word, bool showSeedWords) { + return MapEntry( + i, + _SelectableSeedWord( + initialValue: word, + isSeedShown: showSeedWords, + index: i, + ), + ); + } +} + +class _SelectableSeedWord extends StatelessWidget { + const _SelectableSeedWord({ + Key? key, + required this.isSeedShown, + required this.initialValue, + required this.index, + }) : super(key: key); + + final bool isSeedShown; + final String initialValue; + final int index; + + @override + Widget build(BuildContext context) { + final numStyle = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.4), + ); + final TextEditingController seedWordController = TextEditingController() + ..text = isSeedShown ? initialValue : '••••••'; + + return Focus( + descendantsAreFocusable: true, + skipTraversal: true, + onFocusChange: (value) { + if (value) { + seedWordController.selection = TextSelection( + baseOffset: 0, + extentOffset: seedWordController.value.text.length, + ); + } + }, + child: FractionallySizedBox( + widthFactor: isMobile ? 0.5 : 0.25, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 21, + child: Text( + '${index + 1}.', + style: numStyle, + ), + ), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 31), + child: DryIntrinsicWidth( + child: UiTextFormField( + obscureText: !isSeedShown, + readOnly: true, + controller: seedWordController, + ), + ), + ), + ), + const SizedBox(width: 10), + ], + ), + ), + ); + } +} + +class _SeedPhraseConfirmButton extends StatelessWidget { + const _SeedPhraseConfirmButton({required this.seedPhrase}); + final String seedPhrase; + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + final isCustom = !validateMnemonic(seedPhrase); + if (isCustom) return const SizedBox.shrink(); + + onPressed() => bloc.add(const SeedConfirmEvent()); + final text = LocaleKeys.seedPhraseShowingSavedPhraseButton.tr(); + + final contentWidth = screenWidth - 80; + final width = isMobile ? contentWidth : 207.0; + final height = isMobile ? 52.0 : 40.0; + + return BlocSelector( + selector: (state) => state.isSeedSaved, + builder: (context, isSaved) { + return UiPrimaryButton( + width: width, + height: height, + text: text, + onPressed: isSaved ? onPressed : null, + ); + }, + ); + } +} diff --git a/lib/views/settings/widgets/security_settings/seed_settings/seed_word_button.dart b/lib/views/settings/widgets/security_settings/seed_settings/seed_word_button.dart new file mode 100644 index 0000000000..23d7432fd0 --- /dev/null +++ b/lib/views/settings/widgets/security_settings/seed_settings/seed_word_button.dart @@ -0,0 +1,89 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class SeedWordButton extends StatelessWidget { + const SeedWordButton({ + Key? key, + required this.text, + required this.onPressed, + required this.isSelected, + }) : super(key: key); + final String text; + final VoidCallback onPressed; + final bool isSelected; + + @override + Widget build(BuildContext context) { + final opacity = isSelected ? 0.4 : 1.0; + final themeData = Theme.of(context); + final color = themeData.colorScheme.secondary; + + return Opacity( + opacity: opacity, + child: SizedBox( + height: 31, + child: DecoratedBox( + decoration: BoxDecoration( + color: themeData.inputDecorationTheme.fillColor, + borderRadius: BorderRadius.circular(18), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(15), + hoverColor: color.withOpacity(0.05), + highlightColor: color.withOpacity(0.1), + focusColor: color.withOpacity(0.2), + splashColor: color.withOpacity(0.4), + child: Stack( + children: [ + Container( + alignment: Alignment.center, + child: _Text( + text: text, + isSelected: isSelected, + ), + ), + if (isSelected) + Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 8), + child: Icon( + Icons.close, + size: 13, + color: theme.custom.headerIconColor, + ), + ) + ], + ), + ), + ), + ), + ), + ); + } +} + +class _Text extends StatelessWidget { + const _Text({required this.text, required this.isSelected}); + final String text; + final bool isSelected; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + text, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: theme.custom.headerIconColor, + fontWeight: FontWeight.w500, + fontSize: 12, + ), + ), + ], + ); + } +} diff --git a/lib/views/settings/widgets/settings_menu/settings_logout_button.dart b/lib/views/settings/widgets/settings_menu/settings_logout_button.dart new file mode 100644 index 0000000000..33f2ca0036 --- /dev/null +++ b/lib/views/settings/widgets/settings_menu/settings_logout_button.dart @@ -0,0 +1,74 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/widgets/logout_popup.dart'; + +class SettingsLogoutButton extends StatefulWidget { + const SettingsLogoutButton({Key? key}) : super(key: key); + + @override + State createState() => _SettingsLogoutButtonState(); +} + +class _SettingsLogoutButtonState extends State { + late PopupDispatcher _logOutPopupManager; + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + _logOutPopupManager = PopupDispatcher( + context: scaffoldKey.currentContext ?? context, + popupContent: LogOutPopup( + onConfirm: () => _logOutPopupManager.close(), + onCancel: () => _logOutPopupManager.close(), + ), + ); + }); + super.initState(); + } + + @override + void dispose() { + _logOutPopupManager.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return InkWell( + key: const Key('settings-logout-button'), + onTap: () { + _logOutPopupManager.show(); + }, + borderRadius: BorderRadius.circular(18), + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(24, 20, 0, 20), + child: Row( + mainAxisAlignment: + isMobile ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + Text( + LocaleKeys.logOut.tr(), + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.custom.warningColor, + ), + ), + const SizedBox(width: 6), + Icon( + Icons.exit_to_app, + color: theme.custom.warningColor, + size: 18, + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/settings/widgets/settings_menu/settings_menu.dart b/lib/views/settings/widgets/settings_menu/settings_menu.dart new file mode 100644 index 0000000000..fbe42cbcfc --- /dev/null +++ b/lib/views/settings/widgets/settings_menu/settings_menu.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/settings_menu_value.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/widgets/hidden_without_wallet.dart'; +import 'package:web_dex/views/settings/widgets/general_settings/app_version_number.dart'; +import 'package:web_dex/views/settings/widgets/settings_menu/settings_logout_button.dart'; +import 'package:web_dex/views/settings/widgets/settings_menu/settings_menu_item.dart'; + +class SettingsMenu extends StatelessWidget { + const SettingsMenu({ + Key? key, + required this.onMenuSelect, + required this.selectedMenu, + }) : super(key: key); + + final SettingsMenuValue selectedMenu; + + final void Function(SettingsMenuValue) onMenuSelect; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: currentWalletBloc.outWallet, + initialData: currentWalletBloc.wallet, + builder: (context, snapshot) { + final showSecurity = snapshot.data?.isHW == false; + + final Set menuItems = { + SettingsMenuValue.general, + if (showSecurity) SettingsMenuValue.security, + SettingsMenuValue.feedback, + }; + return FocusTraversalGroup( + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Container( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(18.0), + ), + child: Column( + mainAxisSize: MainAxisSize.max, + children: menuItems + .map((item) => _buildItem(item, isMobile, context)) + .toList(), + ), + ), + if (!isMobile) const Spacer(), + const HiddenWithoutWallet(child: SettingsLogoutButton()), + if (isMobile) const Spacer(), + const AppVersionNumber(), + ], + ), + ); + }, + ); + } + + Widget _buildItem( + SettingsMenuValue menuValue, + bool isMobile, + BuildContext context, + ) { + final Widget item = Container( + constraints: isMobile ? null : const BoxConstraints(maxWidth: 206), + child: SettingsMenuItem( + key: Key('settings-menu-item-${menuValue.name}'), + isSelected: menuValue == selectedMenu, + isMobile: isMobile, + menu: menuValue, + onTap: onMenuSelect, + text: menuValue.title, + ), + ); + if (menuValue == SettingsMenuValue.security) { + return HiddenWithoutWallet(child: item); + } + return item; + } +} diff --git a/lib/views/settings/widgets/settings_menu/settings_menu_item.dart b/lib/views/settings/widgets/settings_menu/settings_menu_item.dart new file mode 100644 index 0000000000..0e9a4bf777 --- /dev/null +++ b/lib/views/settings/widgets/settings_menu/settings_menu_item.dart @@ -0,0 +1,77 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/model/settings_menu_value.dart'; + +class SettingsMenuItem extends StatelessWidget { + const SettingsMenuItem({ + Key? key, + required this.menu, + required this.isSelected, + required this.onTap, + required this.text, + required this.isMobile, + this.enabled = true, + }) : super(key: key); + + final SettingsMenuValue menu; + final String text; + final bool isSelected; + final bool enabled; + final bool isMobile; + final Function(SettingsMenuValue) onTap; + + @override + Widget build(BuildContext context) { + return isMobile ? _buildMobileItem(context) : _buildDesktopItem(context); + } + + Widget _buildDesktopItem(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 2), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: isSelected ? theme.custom.settingsMenuItemBackgroundColor : null, + ), + child: InkWell( + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + mouseCursor: + enabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden, + onTap: enabled ? () => onTap(menu) : null, + child: Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(24, 19, 0, 19), + child: Text( + text, + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } + + Widget _buildMobileItem(BuildContext context) { + return InkWell( + onTap: enabled ? () => onTap(menu) : null, + borderRadius: BorderRadius.circular(18.0), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(text), + Icon( + Icons.arrow_forward_ios, + size: 18, + color: Theme.of(context).textTheme.bodySmall?.color, + ), + ]), + ), + ); + } +} diff --git a/lib/views/settings/widgets/support_page/support_item.dart b/lib/views/settings/widgets/support_page/support_item.dart new file mode 100644 index 0000000000..9f29bfd6c8 --- /dev/null +++ b/lib/views/settings/widgets/support_page/support_item.dart @@ -0,0 +1,77 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/shared/ui/ui_gradient_icon.dart'; +import 'package:web_dex/shared/widgets/html_parser.dart'; + +class SupportItem extends StatefulWidget { + const SupportItem({Key? key, required this.data, this.isLast = false}) + : super(key: key); + final Map data; + final bool isLast; + + @override + State createState() => _SupportItemState(); +} + +class _SupportItemState extends State { + bool expanded = false; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: InkWell( + child: Row( + mainAxisAlignment: isMobile + ? MainAxisAlignment.spaceBetween + : MainAxisAlignment.start, + children: [ + Expanded( + child: Text( + widget.data['title']!, + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w700), + ), + ), + if (isMobile) + const SizedBox( + width: 30, + ), + UiGradientIcon( + icon: expanded + ? Icons.keyboard_arrow_up + : Icons.keyboard_arrow_down) + ], + ), + onTap: () { + setState(() { + expanded = !expanded; + }); + }, + ), + ), + const SizedBox( + height: 10, + ), + Visibility( + visible: expanded, + child: HtmlParser( + widget.data['content']!, + linkStyle: TextStyle( + color: theme.custom.headerFloatBoxColor, + fontWeight: FontWeight.w500, + fontSize: 14), + textStyle: + const TextStyle(fontWeight: FontWeight.w500, fontSize: 14), + )), + const UiDivider(), + ], + ); + } +} diff --git a/lib/views/settings/widgets/support_page/support_page.dart b/lib/views/settings/widgets/support_page/support_page.dart new file mode 100644 index 0000000000..64fa74f7e7 --- /dev/null +++ b/lib/views/settings/widgets/support_page/support_page.dart @@ -0,0 +1,202 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/settings/widgets/support_page/support_item.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class SupportPage extends StatelessWidget { + // ignore: prefer_const_constructors_in_immutables + SupportPage({Key? key = const Key('support-page')}) : super(key: key); + + @override + Widget build(BuildContext context) { + if (isMobile) { + return SingleChildScrollView( + controller: ScrollController(), + child: getSettingContent(context), + ); + } else { + return getSettingContent(context); + } + } + + Widget getSettingContent(BuildContext context) { + return Container( + margin: isMobile + ? const EdgeInsets.symmetric(horizontal: 15) + : isTablet + ? const EdgeInsets.all(30) + : const EdgeInsets.all(0.0), + padding: isMobile + ? null + : const EdgeInsets.symmetric(horizontal: 25, vertical: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: Theme.of(context).colorScheme.surface, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Visibility( + visible: !isMobile, + child: SelectableText(LocaleKeys.support.tr(), + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700)), + ), + const SizedBox( + height: 16, + ), + Container( + width: double.infinity, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(18.0)), + child: Stack( + children: [ + const _DiscordIcon(), + Padding( + padding: const EdgeInsets.symmetric( + vertical: 18.0, horizontal: 5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(right: isMobile ? 0 : 160), + child: SelectableText( + LocaleKeys.supportAskSpan.tr(), + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w500), + ), + ), + const SizedBox( + height: 12, + ), + UiBorderButton( + backgroundColor: + Theme.of(context).colorScheme.surface, + prefix: Icon( + Icons.discord, + color: + Theme.of(context).textTheme.bodyMedium?.color, + ), + text: LocaleKeys.supportDiscordButton.tr(), + fontSize: isMobile ? 13 : 14, + width: 400, + height: 40, + allowMultiline: true, + onPressed: () { + launchURL('https://komodoplatform.com/discord'); + }) + ], + ), + ) + ], + ), + ), + const SizedBox(height: 20), + SelectableText( + LocaleKeys.supportFrequentlyQuestionSpan.tr(), + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 30), + /* + if (!isMobile) + Flexible( + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: ListView.builder( + controller: scrollController, + padding: const EdgeInsets.fromLTRB(0, 0, 20, 0), + itemCount: supportInfo.length, + itemBuilder: (context, i) => SupportItem( + data: supportInfo[i], + ), + ), + ), + ), + if (isMobile) + */ + Container( + padding: const EdgeInsets.fromLTRB(0, 0, 12, 0), + child: Column( + children: supportInfo.asMap().entries.map((entry) { + return SupportItem( + data: entry.value, + ); + }).toList()), + ) + ]), + ); + } +} + +class _DiscordIcon extends StatelessWidget { + const _DiscordIcon(); + + @override + Widget build(BuildContext context) { + return Positioned( + right: -100, + top: -85, + child: Visibility( + visible: !isMobile, + child: const SizedBox( + width: 285, + height: 220, + child: Opacity( + opacity: 0.1, + child: DexSvgImage(path: Assets.discord), + ), + ), + ), + ); + } +} + +final List> supportInfo = [ + { + 'title': LocaleKeys.supportInfoTitle1.tr(), + 'content': LocaleKeys.supportInfoContent1.tr() + }, + { + 'title': LocaleKeys.supportInfoTitle2.tr(), + 'content': LocaleKeys.supportInfoContent2.tr() + }, + { + 'title': LocaleKeys.supportInfoTitle3.tr(), + 'content': LocaleKeys.supportInfoContent3.tr() + }, + { + 'title': LocaleKeys.supportInfoTitle4.tr(), + 'content': LocaleKeys.supportInfoContent4.tr() + }, + { + 'title': LocaleKeys.supportInfoTitle5.tr(), + 'content': LocaleKeys.supportInfoContent5.tr() + }, + { + 'title': LocaleKeys.supportInfoTitle6.tr(), + 'content': LocaleKeys.supportInfoContent6.tr() + }, + { + 'title': LocaleKeys.supportInfoTitle7.tr(), + 'content': LocaleKeys.supportInfoContent7.tr() + }, + { + 'title': LocaleKeys.supportInfoTitle8.tr(), + 'content': LocaleKeys.supportInfoContent8.tr() + }, + { + 'title': LocaleKeys.supportInfoTitle9.tr(), + 'content': LocaleKeys.supportInfoContent9.tr() + }, + { + 'title': LocaleKeys.supportInfoTitle10.tr(), + 'content': LocaleKeys.supportInfoContent10.tr() + } +]; diff --git a/lib/views/wallet/coin_details/coin_details.dart b/lib/views/wallet/coin_details/coin_details.dart new file mode 100644 index 0000000000..04779ae0d7 --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details.dart @@ -0,0 +1,118 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_bloc.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_event.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_info.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_page_type.dart'; +import 'package:web_dex/views/wallet/coin_details/faucet/faucet_page.dart'; +import 'package:web_dex/views/wallet/coin_details/receive/receive_details.dart'; +import 'package:web_dex/views/wallet/coin_details/rewards/kmd_reward_claim_success.dart'; +import 'package:web_dex/views/wallet/coin_details/rewards/kmd_rewards_info.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/withdraw_form.dart'; + +class CoinDetails extends StatefulWidget { + const CoinDetails({ + Key? key, + required this.coin, + required this.onBackButtonPressed, + }) : super(key: key); + + final Coin coin; + final VoidCallback onBackButtonPressed; + + @override + State createState() => _CoinDetailsState(); +} + +class _CoinDetailsState extends State { + late TransactionHistoryBloc _txHistoryBloc; + CoinPageType _selectedPageType = CoinPageType.info; + + String _rewardValue = ''; + String _formattedUsdPrice = ''; + + @override + void initState() { + _txHistoryBloc = context.read(); + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + context + .read() + .add(TransactionHistorySubscribe(coin: widget.coin)); + }); + super.initState(); + } + + @override + void dispose() { + _txHistoryBloc.add(TransactionHistoryUnsubscribe(coin: widget.coin)); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + initialData: coinsBloc.walletCoinsMap.values, + stream: coinsBloc.outWalletCoins, + builder: (context, AsyncSnapshot> snapshot) { + return _buildContent(); + }, + ); + } + + Widget _buildContent() { + switch (_selectedPageType) { + case CoinPageType.info: + return CoinDetailsInfo( + coin: widget.coin, + setPageType: _setPageType, + onBackButtonPressed: widget.onBackButtonPressed, + ); + + case CoinPageType.send: + return WithdrawForm( + coin: widget.coin, + onBackButtonPressed: _openInfo, + onSuccess: () => _setPageType(CoinPageType.info), + ); + + case CoinPageType.receive: + return ReceiveDetails( + coin: widget.coin, + onBackButtonPressed: _openInfo, + ); + + case CoinPageType.faucet: + return FaucetPage( + coinAbbr: widget.coin.abbr, + onBackButtonPressed: _openInfo, + coinAddress: widget.coin.defaultAddress, + ); + + case CoinPageType.claim: + return KmdRewardsInfo( + coin: widget.coin, + onBackButtonPressed: _openInfo, + onSuccess: (String reward, String formattedUsd) { + _rewardValue = reward; + _formattedUsdPrice = formattedUsd; + _setPageType(CoinPageType.claimSuccess); + }, + ); + + case CoinPageType.claimSuccess: + return KmdRewardClaimSuccess( + reward: _rewardValue, + formattedUsd: _formattedUsdPrice, + onBackButtonPressed: _openInfo, + ); + } + } + + void _openInfo() => _setPageType(CoinPageType.info); + + void _setPageType(CoinPageType pageType) { + setState(() => _selectedPageType = pageType); + } +} diff --git a/lib/views/wallet/coin_details/coin_details_info/address_select.dart b/lib/views/wallet/coin_details/coin_details_info/address_select.dart new file mode 100644 index 0000000000..2ee040df9f --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details_info/address_select.dart @@ -0,0 +1,184 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/hd_account/hd_account.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/truncate_middle_text.dart'; + +class AddressSelect extends StatefulWidget { + const AddressSelect({ + Key? key, + required this.coin, + required this.addresses, + required this.selectedAddress, + required this.onChanged, + this.maxWidth, + this.maxHeight, + }) : super(key: key); + + final Coin coin; + final List? addresses; + final String selectedAddress; + final Function(String) onChanged; + final double? maxWidth; + final double? maxHeight; + + @override + State createState() => _AddressSelectState(); +} + +class _AddressSelectState extends State { + bool _isOpen = false; + + @override + Widget build(BuildContext context) { + final List? addresses = widget.addresses; + if (addresses == null || addresses.isEmpty) return const SizedBox.shrink(); + + return addresses.length > 1 + ? UiDropdown( + onSwitch: _onSwitch, + isOpen: _isOpen, + borderRadius: BorderRadius.circular(18), + switcher: _buildSelectedAddress(showDropdownIcon: true), + dropdown: _buildDropdown(addresses), + ) + : _buildSelectedAddress(showDropdownIcon: false); + } + + void _onSwitch(bool isOpen) { + if (isOpen == _isOpen) return; + + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() => _isOpen = isOpen); + }); + } + + Widget _buildDropdown(List addresses) { + final scrollController = ScrollController(); + return ConstrainedBox( + constraints: BoxConstraints( + maxWidth: widget.maxWidth ?? double.infinity, + maxHeight: widget.maxHeight ?? double.infinity, + ), + child: DecoratedBox( + decoration: BoxDecoration(boxShadow: [ + BoxShadow( + offset: const Offset(0, 1), + blurRadius: 8, + color: theme.custom.tabBarShadowColor, + ) + ]), + child: ClipRRect( + borderRadius: BorderRadius.circular(18), + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.currentGlobal.colorScheme.surface, + border: Border.all( + width: 1, + color: theme.custom.filterItemBorderColor, + ), + borderRadius: BorderRadius.circular(18), + ), + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: ListView.builder( + controller: scrollController, + shrinkWrap: true, + itemCount: addresses.length, + itemBuilder: (context, i) => + _buildDropdownItem(addresses[i].address), + ), + ), + ), + ), + ), + ); + } + + Widget _buildDropdownItem(String address) { + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + widget.onChanged(address); + setState(() => _isOpen = false); + }, + child: Container( + height: 40, + padding: const EdgeInsets.fromLTRB(12, 0, 22, 0), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + child: TruncatedMiddleText( + address, + style: const TextStyle(fontSize: 14), + ), + ), + const SizedBox(width: 32), + Text( + doubleToString(_getAddressBalance(address)), + style: TextStyle( + fontSize: 12, + color: theme.currentGlobal.textTheme.bodySmall?.color, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _buildSelectedAddress({required bool showDropdownIcon}) { + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: widget.maxWidth ?? double.infinity), + child: Container( + height: 44, + decoration: BoxDecoration( + border: + Border.all(width: 1, color: theme.custom.filterItemBorderColor), + borderRadius: BorderRadius.circular(18), + ), + alignment: const Alignment(-1, 0), + padding: const EdgeInsets.fromLTRB(12, 0, 22, 0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Flexible( + child: TruncatedMiddleText(widget.selectedAddress, + style: const TextStyle(fontSize: 14))), + if (showDropdownIcon) _buildDropdownIcon() + ], + ), + ), + ); + } + + Widget _buildDropdownIcon() { + return Padding( + padding: const EdgeInsets.only(left: 32), + child: DexSvgImage( + path: _isOpen ? Assets.chevronUp : Assets.chevronDown, + ), + ); + } + + double _getAddressBalance(String address) { + final HdAccount? defaultAccount = widget.coin.accounts?.first; + if (defaultAccount == null) return 0.0; + + final HdAddress? hdAddress = defaultAccount.addresses + .firstWhereOrNull((item) => item.address == address); + if (hdAddress == null) return 0.0; + + return hdAddress.balance.spendable; + } +} 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 new file mode 100644 index 0000000000..68d987a24b --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details_info/charts/coin_sparkline.dart @@ -0,0 +1,42 @@ +import 'package:dragon_charts_flutter/dragon_charts_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class CoinSparkline extends StatelessWidget { + final String coinId; + final SparklineRepository repository = sparklineRepository; + + CoinSparkline({required this.coinId}); + + @override + Widget build(BuildContext context) { + return FutureBuilder?>( + future: repository.fetchSparkline(abbr2Ticker(coinId)), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting || + snapshot.hasError) { + return const SizedBox.shrink(); + } else if (snapshot.hasError) { + return Text('Error: ${snapshot.error}'); + } else if (!snapshot.hasData || (snapshot.data?.isEmpty ?? true)) { + return const SizedBox.shrink(); + } else { + return LimitedBox( + maxWidth: 120, + child: SizedBox( + height: 24, + child: SparklineChart( + data: snapshot.data!, + positiveLineColor: Colors.green, + negativeLineColor: Colors.red, + lineThickness: 1.0, + isCurved: true, + ), + ), + ); + } + }, + ); + } +} diff --git a/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart b/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart new file mode 100644 index 0000000000..7dc9309335 --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart @@ -0,0 +1,268 @@ +import 'package:dragon_charts_flutter/dragon_charts_flutter.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart'; +import 'package:web_dex/blocs/blocs.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/views/wallet/wallet_page/charts/coin_prices_chart.dart'; + +class PortfolioGrowthChart extends StatefulWidget { + const PortfolioGrowthChart({super.key, required this.initialCoins}); + + final List initialCoins; + + @override + State createState() => _PortfolioGrowthChartState(); +} + +class _PortfolioGrowthChartState extends State { + late List _selectedCoins = widget.initialCoins; + + Coin? get _singleCoinOrNull => _selectedCoins.singleOrNull; + bool get _isSingleCoinSelected => _selectedCoins.length == 1; + + // Determines if the chart is shown in the wallet overview or on a + // coin's page. Consider changing this to a widget parameter if needed. + bool get _isCoinPage => widget.initialCoins.length == 1; + + double _calculateTotalValue(List chartData) { + return chartData.isNotEmpty ? chartData.last.y : 0.0; + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, PortfolioGrowthState state) { + final List chartData = + (state is PortfolioGrowthChartLoadSuccess) + ? state.portfolioGrowth + .map((point) => ChartData(x: point.x, y: point.y)) + .toList() + : []; + + final totalValue = _calculateTotalValue(chartData); + + final percentageIncrease = state is PortfolioGrowthChartLoadSuccess + ? state.percentageIncrease + : 0.0; + + final (dateAxisLabelCount, dateAxisLabelFormat) = + PriceChartPage.dateAxisLabelCountFormat( + state.selectedPeriod, + ); + + final isChartLoading = state is! PortfolioGrowthChartLoadSuccess && + state is! PortfolioGrowthChartUnsupported; + + return Card( + clipBehavior: Clip.antiAlias, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + MarketChartHeaderControls( + emptySelectAllowed: widget.initialCoins.length > 1, + title: Text( + _singleCoinOrNull?.abbr == null + ? LocaleKeys.portfolioGrowth.tr() + : LocaleKeys.growth.tr(), + ), + leadingIcon: _singleCoinOrNull == null + ? null + : CoinIcon( + _singleCoinOrNull!.abbr, + size: 24, + ), + leadingText: Text( + NumberFormat.currency(symbol: '\$', decimalDigits: 2) + .format(totalValue), + ), + availableCoins: + widget.initialCoins.map((coin) => coin.abbr).toList(), + selectedCoinId: _singleCoinOrNull?.abbr, + onCoinSelected: _isCoinPage ? null : _showSpecificCoin, + centreAmount: totalValue, + percentageIncrease: percentageIncrease, + selectedPeriod: state.selectedPeriod, + onPeriodChanged: (selected) { + if (selected != null) { + final walletId = currentWalletBloc.wallet!.id; + context.read().add( + PortfolioGrowthPeriodChanged( + selectedPeriod: selected, + coins: _selectedCoins, + walletId: walletId, + ), + ); + } + }, + ), + const Gap(16), + Expanded( + child: LineChart( + elements: [ + ChartDataSeries( + data: chartData, + color: (_isSingleCoinSelected + ? getCoinColor(_singleCoinOrNull!.abbr) + : null) ?? + Theme.of(context).colorScheme.primary, + ), + ChartGridLines(isVertical: false, count: 5), + ChartAxisLabels( + isVertical: true, + count: 5, + labelBuilder: (value) => + NumberFormat.compactSimpleCurrency( + // symbol: '\$', + // USD Locale + locale: 'en_US', + // )..maximumFractionDigits = 2 + ).format(value), + ), + ChartAxisLabels( + isVertical: false, + count: dateAxisLabelCount, + labelBuilder: (value) { + return dateAxisLabelFormat.format( + DateTime.fromMillisecondsSinceEpoch( + value.toInt(), + ), + ); + }, + ), + ], + backgroundColor: Theme.of(context).colorScheme.surface, + markerSelectionStrategy: CartesianSelectionStrategy( + snapToClosest: true, + ), + tooltipBuilder: (context, dataPoints, dataColors) { + return _PortfolioGrowthChartTooltip( + dataPoints: dataPoints, + coins: _selectedCoins, + ); + }, + // Use the domain of the beginning of the period + domainExtent: ChartExtent.withBounds( + min: DateTime.now() + .subtract(state.selectedPeriod) + .millisecondsSinceEpoch + .toDouble(), + max: DateTime.now().millisecondsSinceEpoch.toDouble(), + ), + ), + ), + if (isChartLoading) + Container( + clipBehavior: Clip.none, + child: const LinearProgressIndicator( + semanticsLabel: 'Linear progress indicator', + ), + ), + ], + ), + ), + ); + }, + ); + } + + void _showSpecificCoin(String? coinId) { + final coin = coinId == null + ? null + : widget.initialCoins.firstWhere((coin) => coin.abbr == coinId); + final newCoins = coin == null ? widget.initialCoins : [coin]; + + final walletId = currentWalletBloc.wallet!.id; + context.read().add( + PortfolioGrowthPeriodChanged( + selectedPeriod: + context.read().state.selectedPeriod, + coins: newCoins, + walletId: walletId, + ), + ); + + setState(() => _selectedCoins = newCoins); + } +} + +class _PortfolioGrowthChartTooltip extends StatelessWidget { + final List dataPoints; + final List coins; + // final Color backgroundColor; + + const _PortfolioGrowthChartTooltip({ + Key? key, + required this.dataPoints, + required this.coins, + // required this.backgroundColor, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final date = + DateTime.fromMillisecondsSinceEpoch(dataPoints.first.x.toInt()); + final isSingleCoinSelected = coins.length == 1; + + return ChartTooltipContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + DateFormat('MMMM d, y').format(date), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + if (dataPoints.length > 1 || isSingleCoinSelected) + ...dataPoints.asMap().entries.map((entry) { + int index = entry.key; + ChartData data = entry.value; + Coin coin = coins[index]; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + CoinIcon(coin.abbr, size: 16), + const SizedBox(width: 4), + Text( + abbr2Ticker(coin.abbr), + style: Theme.of(context).textTheme.bodyMedium!, + ), + const SizedBox(width: 16), + ], + ), + Text( + formatAmt(data.y), + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ], + ); + }).toList() + else + Text( + NumberFormat.currency(symbol: '\$', decimalDigits: 2) + .format(dataPoints.first.y), + style: Theme.of(context).textTheme.bodyMedium, + ), + ], + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart b/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart new file mode 100644 index 0000000000..a18c4875f7 --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart @@ -0,0 +1,249 @@ +import 'package:dragon_charts_flutter/dragon_charts_flutter.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/prominent_colors.dart'; +import 'package:web_dex/views/wallet/wallet_page/charts/coin_prices_chart.dart'; + +class PortfolioProfitLossChart extends StatefulWidget { + const PortfolioProfitLossChart({super.key, required this.initialCoins}); + + final List initialCoins; + + @override + State createState() => + PortfolioProfitLossChartState(); +} + +class PortfolioProfitLossChartState extends State { + late List _selectedCoins = widget.initialCoins; + + Coin? get _singleCoinOrNull => _selectedCoins.singleOrNull; + + bool get _isSingleCoinSelected => _selectedCoins.length == 1; + + // Determines if the chart is shown in the wallet overview or on a + // coin's page. Consider changing this to a widget parameter if needed. + bool get _isCoinPage => widget.initialCoins.length == 1; + + @override + void didUpdateWidget(PortfolioProfitLossChart oldWidget) { + super.didUpdateWidget(oldWidget); + // TODO: Handle this. And for other charts. This + } + + String? get walletId => currentWalletBloc.wallet?.id; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, ProfitLossState state) { + if (state is ProfitLossLoadFailure) { + return Center( + child: Text(state.error.message), + ); + } + + final (dateAxisLabelCount, dateAxisLabelFormat) = + PriceChartPage.dateAxisLabelCountFormat( + state.selectedPeriod, + ); + final minChartExtent = DateTime.now() + .subtract(state.selectedPeriod) + .millisecondsSinceEpoch + .toDouble(); + final maxChartExtent = DateTime.now().millisecondsSinceEpoch.toDouble(); + + final isSuccess = state is PortfolioProfitLossChartLoadSuccess; + final List chartData = isSuccess + ? state.profitLossChart + .map((point) => ChartData(x: point.x.toDouble(), y: point.y)) + .toList() + : List.empty(); + + if (chartData.isNotEmpty) { + chartData.add(ChartData(x: maxChartExtent, y: chartData.last.y)); + } + + final totalValue = isSuccess ? state.totalValue : 0.0; + final percentageIncrease = isSuccess ? state.percentageIncrease : 0.0; + final formattedValue = + '${totalValue >= 0 ? '+' : '-'}${NumberFormat.currency( + symbol: '\$', + decimalDigits: 2, + ).format(totalValue)}'; + + return Card( + clipBehavior: Clip.antiAlias, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + children: [ + MarketChartHeaderControls( + title: Text( + _singleCoinOrNull?.name == null + ? LocaleKeys.portfolioPerformance.tr() + : LocaleKeys.performance.tr(), + ), + leadingIcon: _singleCoinOrNull == null + ? null + : CoinIcon( + _singleCoinOrNull!.abbr, + size: 24, + ), + leadingText: Text(formattedValue), + emptySelectAllowed: !_isCoinPage, + availableCoins: + widget.initialCoins.map((coin) => coin.abbr).toList(), + selectedCoinId: _singleCoinOrNull?.abbr, + onCoinSelected: _isCoinPage ? null : _showSpecificCoin, + centreAmount: totalValue, + percentageIncrease: percentageIncrease, + selectedPeriod: state.selectedPeriod, + onPeriodChanged: (selected) { + if (selected != null) { + context.read().add( + ProfitLossPortfolioPeriodChanged( + selectedPeriod: selected, + ), + ); + } + }, + ), + const Gap(16), + Expanded( + child: LineChart( + key: const Key('portfolio_profit_loss_chart'), + rangeExtent: const ChartExtent.tight(), + elements: [ + ChartDataSeries( + data: chartData, + color: (_isSingleCoinSelected + ? getCoinColor(_singleCoinOrNull!.abbr) + : null) ?? + Theme.of(context).colorScheme.primary, + ), + ChartGridLines( + isVertical: false, + count: 5, + ), + ChartAxisLabels( + isVertical: true, + count: 5, + labelBuilder: (value) => + NumberFormat.compactSimpleCurrency(locale: 'en_US') + .format(value), + ), + ChartAxisLabels( + isVertical: false, + count: dateAxisLabelCount, + labelBuilder: (value) { + return dateAxisLabelFormat.format( + DateTime.fromMillisecondsSinceEpoch( + value.toInt(), + ), + ); + }, + ), + ], + backgroundColor: Theme.of(context).colorScheme.surface, + markerSelectionStrategy: CartesianSelectionStrategy( + snapToClosest: true, + ), + tooltipBuilder: (context, dataPoints, dataColors) { + return _PortfolioProfitLossTooltip( + date: DateTime.fromMillisecondsSinceEpoch( + dataPoints.first.x.toInt(), + ), + portfolioValue: dataPoints.first.y, + selectedChartCoin: _singleCoinOrNull, + ); + }, + domainExtent: ChartExtent.withBounds( + min: minChartExtent, + max: maxChartExtent, + ), + ), + ), + if (state is! PortfolioProfitLossChartLoadSuccess) + Container( + clipBehavior: Clip.none, + child: const LinearProgressIndicator( + semanticsLabel: 'Linear progress indicator', + ), + ), + ], + ), + ), + ); + }, + ); + } + + void _showSpecificCoin(String? coinId) { + final coin = coinId == null + ? null + : widget.initialCoins.firstWhere((coin) => coin.abbr == coinId); + + final newCoins = coin == null ? widget.initialCoins : [coin]; + + context.read().add( + ProfitLossPortfolioChartLoadRequested( + coins: newCoins, + fiatCoinId: 'USDT', + selectedPeriod: context.read().state.selectedPeriod, + walletId: walletId!, + ), + ); + + setState(() => _selectedCoins = newCoins); + } +} + +class _PortfolioProfitLossTooltip extends StatelessWidget { + const _PortfolioProfitLossTooltip({ + required this.date, + required this.portfolioValue, + required this.selectedChartCoin, + }); + + final DateTime date; + final double portfolioValue; + + final Coin? selectedChartCoin; + + @override + Widget build(BuildContext context) { + final adjective = portfolioValue > 0 ? '+' : '-'; + return ChartTooltipContainer( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + DateFormat('MMMM d, y').format(date), + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 4), + Text( + '$adjective${NumberFormat.currency(symbol: '\$', decimalDigits: 2).format(portfolioValue.abs())}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: portfolioValue > 0 + ? Colors.green + : Theme.of(context).colorScheme.error, + fontWeight: FontWeight.w600, + ), + ), + ], + ), + ); + } +} 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 new file mode 100644 index 0000000000..39cb9ef596 --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart @@ -0,0 +1,177 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; +import 'package:web_dex/views/bitrefill/bitrefill_button.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/coin_details/faucet/faucet_button.dart'; + +class CoinDetailsCommonButtons extends StatelessWidget { + const CoinDetailsCommonButtons({ + Key? key, + required this.isMobile, + required this.selectWidget, + required this.clickSwapButton, + required this.coin, + }) : super(key: key); + + final bool isMobile; + final Coin coin; + final void Function(CoinPageType) selectWidget; + final VoidCallback clickSwapButton; + + @override + Widget build(BuildContext context) { + return isMobile + ? _buildMobileButtons(context) + : _buildDesktopButtons(context); + } + + Widget _buildDesktopButtons(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 120), + child: _buildSendButton(context), + ), + Container( + margin: const EdgeInsets.only(left: 21), + constraints: const BoxConstraints(maxWidth: 120), + child: _buildReceiveButton(context), + ), + if (!coin.walletOnly) + Container( + margin: const EdgeInsets.only(left: 21), + constraints: const BoxConstraints(maxWidth: 120), + child: _buildSwapButton(context)), + if (coin.hasFaucet) + Container( + margin: const EdgeInsets.only(left: 21), + constraints: const BoxConstraints(maxWidth: 120), + child: FaucetButton( + onPressed: () => selectWidget(CoinPageType.faucet), + ), + ), + if (isBitrefillIntegrationEnabled) + Container( + margin: const EdgeInsets.only(left: 21), + constraints: const BoxConstraints(maxWidth: 120), + child: BitrefillButton( + key: Key( + 'coin-details-bitrefill-button-${coin.abbr.toLowerCase()}', + ), + coin: coin, + onPaymentRequested: (_) => selectWidget(CoinPageType.send), + ), + ), + Flexible( + flex: 2, + child: Align( + alignment: Alignment.centerRight, + child: coin.protocolData?.contractAddress.isNotEmpty ?? false + ? SizedBox(width: 230, child: ContractAddressButton(coin)) + : null, + )) + ], + ); + } + + Widget _buildMobileButtons(BuildContext context) { + return Column( + children: [ + Visibility( + visible: coin.protocolData?.contractAddress.isNotEmpty ?? false, + child: ContractAddressButton(coin), + ), + const SizedBox(height: 12), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible(child: _buildSendButton(context)), + const SizedBox(width: 15), + Flexible(child: _buildReceiveButton(context)), + ], + ), + ], + ); + } + + Widget _buildSendButton(BuildContext context) { + final ThemeData themeData = Theme.of(context); + return UiPrimaryButton( + key: const Key('coin-details-send-button'), + height: isMobile ? 52 : 40, + prefix: Container( + padding: const EdgeInsets.only(right: 14), + child: SvgPicture.asset( + '$assetsPath/others/send.svg', + ), + ), + textStyle: themeData.textTheme.labelLarge + ?.copyWith(fontSize: 14, fontWeight: FontWeight.w600), + backgroundColor: themeData.colorScheme.tertiary, + onPressed: coin.isSuspended || coin.balance == 0 + ? null + : () { + selectWidget(CoinPageType.send); + }, + text: LocaleKeys.send.tr(), + ); + } + + Widget _buildReceiveButton(BuildContext context) { + 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', + ), + ), + textStyle: themeData.textTheme.labelLarge + ?.copyWith(fontSize: 14, fontWeight: FontWeight.w600), + backgroundColor: themeData.colorScheme.tertiary, + onPressed: coin.isSuspended + ? null + : () { + selectWidget(CoinPageType.receive); + }, + text: LocaleKeys.receive.tr(), + ); + } + + Widget _buildSwapButton(BuildContext context) { + if (currentWalletBloc.wallet?.config.type != WalletType.iguana) { + return const SizedBox.shrink(); + } + + final ThemeData themeData = Theme.of(context); + return UiPrimaryButton( + key: const Key('coin-details-swap-button'), + height: isMobile ? 52 : 40, + textStyle: themeData.textTheme.labelLarge + ?.copyWith(fontSize: 14, fontWeight: FontWeight.w600), + backgroundColor: themeData.colorScheme.tertiary, + text: LocaleKeys.swapCoin.tr(), + prefix: Padding( + padding: const EdgeInsets.only(right: 10), + child: SvgPicture.asset( + '$assetsPath/others/swap.svg', + ), + ), + onPressed: coin.isSuspended ? null : clickSwapButton, + ); + } +} 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 new file mode 100644 index 0000000000..922bbd88fa --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_info.dart @@ -0,0 +1,665 @@ +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:flutter_svg/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/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_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/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.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:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; +import 'package:web_dex/shared/widgets/segwit_icon.dart'; +import 'package:web_dex/views/common/page_header/disable_coin_button.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/coin_details/coin_details_info/charts/portfolio_growth_chart.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_common_buttons.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/coin_details_info_fiat.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_page_type.dart'; +import 'package:web_dex/views/wallet/coin_details/faucet/faucet_button.dart'; +import 'package:web_dex/views/wallet/coin_details/transactions/transaction_table.dart'; + +class CoinDetailsInfo extends StatefulWidget { + const CoinDetailsInfo({ + Key? key, + required this.coin, + required this.setPageType, + required this.onBackButtonPressed, + }) : super(key: key); + final Coin coin; + final void Function(CoinPageType) setPageType; + final VoidCallback onBackButtonPressed; + + @override + State createState() => _CoinDetailsInfoState(); +} + +class _CoinDetailsInfoState extends State + with SingleTickerProviderStateMixin { + Transaction? _selectedTransaction; + late TabController _tabController; + + String? get _walletId => currentWalletBloc.wallet?.id; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + const selectedDurationInitial = Duration(hours: 1); + final growthBloc = context.read(); + + growthBloc.add( + PortfolioGrowthLoadRequested( + coins: [widget.coin], + fiatCoinId: 'USDT', + selectedPeriod: selectedDurationInitial, + walletId: _walletId!, + updateFrequency: const Duration(minutes: 1), + ), + ); + + final ProfitLossBloc profitLossBloc = context.read(); + + profitLossBloc.add( + ProfitLossPortfolioChartLoadRequested( + coins: [widget.coin], + selectedPeriod: const Duration(hours: 1), + fiatCoinId: 'USDT', + walletId: _walletId!, + ), + ); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return PageLayout( + header: PageHeader( + title: widget.coin.name, + widgetTitle: widget.coin.mode == CoinMode.segwit + ? const Padding( + padding: EdgeInsets.only(left: 6.0), + child: SegwitIcon(height: 22), + ) + : null, + backText: _backText, + onBackButtonPressed: _onBackButtonPressed, + actions: [_buildDisableButton()], + ), + content: Expanded( + child: _buildContent(context), + ), + ); + } + + Widget _buildContent(BuildContext context) { + if (isMobile) { + return _MobileContent( + coin: widget.coin, + selectedTransaction: _selectedTransaction, + setPageType: widget.setPageType, + setTransaction: _selectTransaction, + tabController: _tabController, + ); + } + return _DesktopContent( + coin: widget.coin, + selectedTransaction: _selectedTransaction, + setPageType: widget.setPageType, + setTransaction: _selectTransaction, + tabController: _tabController, + ); + } + + Widget _buildDisableButton() { + if (_haveTransaction) return const SizedBox(); + + return DisableCoinButton( + onClick: () async { + await coinsBloc.deactivateCoin(widget.coin); + widget.onBackButtonPressed(); + }, + ); + } + + void _selectTransaction(Transaction? tx) { + setState(() { + _selectedTransaction = tx; + }); + } + + void _onBackButtonPressed() { + if (_haveTransaction) { + _selectTransaction(null); + return; + } + widget.onBackButtonPressed(); + } + + String get _backText { + if (_haveTransaction) return LocaleKeys.back.tr(); + return LocaleKeys.backToWallet.tr(); + } + + bool get _haveTransaction => _selectedTransaction != null; +} + +class _DesktopContent extends StatelessWidget { + const _DesktopContent({ + required this.coin, + required this.selectedTransaction, + required this.setPageType, + required this.setTransaction, + required this.tabController, + }); + + final Coin coin; + final Transaction? selectedTransaction; + final void Function(CoinPageType) setPageType; + final Function(Transaction?) setTransaction; + final TabController tabController; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(0, 20.0, 0, 20.0), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(18.0), + ), + child: CustomScrollView( + slivers: [ + if (selectedTransaction == null) + SliverToBoxAdapter( + child: _DesktopCoinDetails( + coin: coin, + setPageType: setPageType, + tabController: tabController, + ), + ), + const SliverToBoxAdapter( + child: SizedBox(height: 20), + ), + TransactionTable( + coin: coin, + selectedTransaction: selectedTransaction, + setTransaction: setTransaction, + ), + ], + ), + ); + } +} + +class _DesktopCoinDetails extends StatelessWidget { + const _DesktopCoinDetails({ + required this.coin, + required this.setPageType, + required this.tabController, + }); + + final Coin coin; + final void Function(CoinPageType) setPageType; + final TabController tabController; + + @override + Widget build(BuildContext context) { + final portfolioGrowthState = context.watch().state; + final profitLossState = context.watch().state; + final isPortfolioGrowthSupported = + portfolioGrowthState is! PortfolioGrowthChartUnsupported; + final isProfitLossSupported = + profitLossState is! PortfolioProfitLossChartUnsupported; + final areChartsSupported = + isPortfolioGrowthSupported || isProfitLossSupported; + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(0, 5, 12, 0), + child: CoinIcon( + coin.abbr, + size: 50, + ), + ), + _Balance(coin: coin), + const SizedBox(width: 10), + Padding( + padding: const EdgeInsets.only(top: 18.0), + child: _SpecificButton( + coin: coin, + selectWidget: setPageType, + ), + ), + const Spacer(), + CoinDetailsInfoFiat( + coin: coin, + isMobile: false, + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(2, 28.0, 0, 0), + child: CoinDetailsCommonButtons( + isMobile: false, + selectWidget: setPageType, + clickSwapButton: () => _goToSwap(context, coin), + coin: coin, + ), + ), + const Gap(16), + if (areChartsSupported) + Card( + child: TabBar( + controller: tabController, + tabs: [ + if (isPortfolioGrowthSupported) + Tab(text: LocaleKeys.growth.tr()), + if (isProfitLossSupported) + Tab(text: LocaleKeys.profitAndLoss.tr()), + ], + ), + ), + if (areChartsSupported) + SizedBox( + height: 340, + child: TabBarView( + controller: tabController, + children: [ + if (isPortfolioGrowthSupported) + SizedBox( + width: double.infinity, + height: 340, + child: PortfolioGrowthChart(initialCoins: [coin]), + ), + if (isProfitLossSupported) + SizedBox( + width: double.infinity, + height: 340, + child: PortfolioProfitLossChart(initialCoins: [coin]), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _MobileContent extends StatelessWidget { + const _MobileContent({ + required this.coin, + required this.selectedTransaction, + required this.setPageType, + required this.setTransaction, + required this.tabController, + }); + + final Coin coin; + final Transaction? selectedTransaction; + final void Function(CoinPageType) setPageType; + final Function(Transaction?) setTransaction; + final TabController tabController; + + @override + Widget build(BuildContext context) { + return CustomScrollView( + slivers: [ + if (selectedTransaction == null) + SliverToBoxAdapter( + child: _buildMobileTopContent(context), + ), + const SliverToBoxAdapter( + child: SizedBox(height: 20), + ), + TransactionTable( + coin: coin, + selectedTransaction: selectedTransaction, + setTransaction: setTransaction, + ), + ], + ); + } + + Widget _buildMobileTopContent(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(15, 18, 15, 16), + decoration: BoxDecoration( + color: isMobile ? Theme.of(context).cardColor : null, + borderRadius: BorderRadius.circular(18.0), + ), + child: Column( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CoinIcon( + 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, + ), + ), + Padding( + padding: const EdgeInsets.only(top: 12.0, bottom: 14.0), + child: CoinDetailsCommonButtons( + isMobile: true, + selectWidget: setPageType, + clickSwapButton: () => _goToSwap(context, coin), + coin: coin, + ), + ), + if (!coin.walletOnly) _SwapButton(coin: coin), + if (coin.hasFaucet) + FaucetButton( + onPressed: () => setPageType(CoinPageType.faucet), + ), + Card( + child: TabBar( + controller: tabController, + tabs: [ + Tab(text: LocaleKeys.growth.tr()), + Tab(text: LocaleKeys.profitAndLoss.tr()), + ], + ), + ), + SizedBox( + height: 340, + child: TabBarView( + controller: tabController, + children: [ + SizedBox( + width: double.infinity, + height: 340, + child: PortfolioGrowthChart(initialCoins: [coin]), + ), + SizedBox( + width: double.infinity, + height: 340, + child: PortfolioProfitLossChart(initialCoins: [coin]), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _FaucetButton extends StatelessWidget { + const _FaucetButton({ + required this.coin, + required this.openFaucet, + }); + + final Coin coin; + final VoidCallback openFaucet; + + @override + Widget build(BuildContext context) { + if (!_isShown) return const SizedBox.shrink(); + + return FocusDecorator( + child: InkWell( + onTap: coin.isSuspended ? null : openFaucet, + child: Opacity( + opacity: coin.isSuspended ? 0.4 : 1, + child: Container( + constraints: const BoxConstraints(maxWidth: 55), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all(color: theme.custom.specificButtonBorderColor), + color: theme.custom.specificButtonBackgroundColor, + ), + padding: const EdgeInsets.symmetric( + vertical: 5, + horizontal: 7, + ), + child: Text( + LocaleKeys.faucet.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ); + } + + bool get _isShown => coin.defaultAddress != null; +} + +class _Balance extends StatelessWidget { + const _Balance({required this.coin}); + final Coin coin; + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final value = doubleToString(coin.balance); + + return Column( + crossAxisAlignment: + isMobile ? CrossAxisAlignment.center : CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + isMobile + ? const SizedBox.shrink() + : Text( + LocaleKeys.yourBalance.tr(), + style: themeData.textTheme.titleMedium!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.custom.headerFloatBoxColor, + ), + ), + Flexible( + child: Row( + mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: + isMobile ? MainAxisAlignment.center : MainAxisAlignment.start, + children: [ + Flexible( + child: AutoScrollText( + key: const Key('coin-details-balance'), + text: value, + isSelectable: true, + style: themeData.textTheme.titleMedium!.copyWith( + fontSize: isMobile ? 25 : 22, + fontWeight: FontWeight.w700, + color: theme.custom.headerFloatBoxColor, + height: 1.1, + ), + ), + ), + const SizedBox(width: 5), + Text( + Coin.normalizeAbbr(coin.abbr), + style: themeData.textTheme.titleSmall!.copyWith( + fontSize: isMobile ? 25 : 20, + fontWeight: FontWeight.w500, + color: theme.custom.headerFloatBoxColor, + height: 1.1, + ), + ), + ], + ), + ), + if (!isMobile) _FiatBalance(coin: coin), + ], + ); + } +} + +class _FiatBalance extends StatelessWidget { + const _FiatBalance({required this.coin}); + final Coin coin; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 4.0), + child: Row( + children: [ + Text( + LocaleKeys.fiatBalance.tr(), + style: Theme.of(context).textTheme.bodySmall?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 6.0), + child: CoinFiatBalance( + coin, + isSelectable: true, + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w700), + ), + ), + ], + ), + ); + } +} + +class _SpecificButton extends StatelessWidget { + const _SpecificButton({required this.coin, required this.selectWidget}); + final Coin coin; + final void Function(CoinPageType) selectWidget; + + @override + Widget build(BuildContext context) { + final walletType = currentWalletBloc.wallet?.config.type; + + if (coin.abbr == 'KMD' && walletType == WalletType.iguana) { + return _GetRewardsButton( + coin: coin, + onTap: () => selectWidget(CoinPageType.claim), + ); + } + if (coin.hasFaucet) { + return _FaucetButton( + coin: coin, + openFaucet: () => selectWidget(CoinPageType.faucet), + ); + } + return const SizedBox.shrink(); + } +} + +class _GetRewardsButton extends StatelessWidget { + const _GetRewardsButton({required this.coin, required this.onTap}); + final Coin coin; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return FocusDecorator( + child: InkWell( + onTap: coin.isSuspended ? null : onTap, + child: Opacity( + opacity: coin.isSuspended ? 0.4 : 1, + child: Container( + constraints: const BoxConstraints(maxWidth: 110), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + border: Border.all(color: theme.custom.specificButtonBorderColor), + color: theme.custom.specificButtonBackgroundColor, + ), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 5), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.only(right: 6.0), + child: SvgPicture.asset( + '$assetsPath/ui_icons/rewards.svg', + colorFilter: ColorFilter.mode( + Theme.of(context).textTheme.labelLarge?.color ?? + Colors.white, + BlendMode.srcIn, + ), + allowDrawingOutsideViewBox: true, + ), + ), + Text( + LocaleKeys.getRewards.tr(), + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _SwapButton extends StatelessWidget { + const _SwapButton({required this.coin}); + final Coin coin; + + @override + Widget build(BuildContext context) { + if (currentWalletBloc.wallet?.config.type != WalletType.iguana) { + return const SizedBox.shrink(); + } + + return UiBorderButton( + width: double.infinity, + height: 52, + borderColor: theme.custom.swapButtonColor, + borderWidth: 2, + backgroundColor: Theme.of(context).colorScheme.surface, + text: LocaleKeys.swapCoin.tr(), + textColor: theme.custom.swapButtonColor, + onPressed: coin.isSuspended ? null : () => _goToSwap(context, coin), + prefix: SvgPicture.asset( + '$assetsPath/others/swap.svg', + allowDrawingOutsideViewBox: true, + ), + ); + } +} + +void _goToSwap(BuildContext context, Coin coin) { + context.read().add(TakerSetSellCoin(coin)); + routingState.selectedMenu = MainMenuValue.dex; +} 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 new file mode 100644 index 0000000000..961b21b912 --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details_info/coin_details_info_fiat.dart @@ -0,0 +1,105 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_change.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_price.dart'; + +class CoinDetailsInfoFiat extends StatelessWidget { + const CoinDetailsInfoFiat({ + Key? key, + required this.coin, + required this.isMobile, + }) : super(key: key); + final bool isMobile; + final Coin coin; + + @override + Widget build(BuildContext context) { + return Container( + 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, + mainAxisSize: isMobile ? MainAxisSize.max : MainAxisSize.min, + children: [ + if (isMobile) _buildFiatBalance(context), + _buildPrice(isMobile, context), + if (!isMobile) const SizedBox(height: 6), + _buildChange(isMobile, context), + ], + ), + ); + } + + Widget _buildPrice(bool isMobile, BuildContext context) { + return Flex( + direction: isMobile ? Axis.vertical : Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + CoinFiatPrice( + coin, + style: TextStyle( + fontSize: isMobile ? 16 : 14, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } + + Widget _buildChange(bool isMobile, BuildContext context) { + return Flex( + direction: isMobile ? Axis.vertical : Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + 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), + CoinFiatChange( + coin, + style: TextStyle( + fontSize: isMobile ? 16 : 14, fontWeight: FontWeight.w700), + ), + ], + ); + } + + Widget _buildFiatBalance(BuildContext context) { + return Flex( + direction: isMobile ? Axis.vertical : Axis.horizontal, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.fiatBalance.tr(), + style: Theme.of(context).textTheme.titleSmall!.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + const SizedBox(height: 3), + CoinFiatBalance( + coin, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ], + ); + } +} diff --git a/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart b/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart new file mode 100644 index 0000000000..e09035e4c8 --- /dev/null +++ b/lib/views/wallet/coin_details/coin_details_info/contract_address_button.dart @@ -0,0 +1,184 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.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/utils.dart'; +import 'package:web_dex/shared/widgets/coin_icon.dart'; +import 'package:web_dex/shared/widgets/truncate_middle_text.dart'; + +class ContractAddressButton extends StatelessWidget { + const ContractAddressButton(this.coin, {Key? key}) : super(key: key); + + final Coin coin; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).textTheme.bodyMedium?.color?.withAlpha(5), + borderRadius: BorderRadius.circular(7), + child: InkWell( + borderRadius: BorderRadius.circular(7), + onTap: coin.explorerUrl.isEmpty + ? null + : () { + launchURL( + '${coin.explorerUrl}address/${coin.protocolData?.contractAddress ?? ''}'); + }, + child: isMobile + ? _ContractAddressMobile(coin) + : _ContractAddressDesktop(coin), + ), + ); + } +} + +class _ContractAddressMobile extends StatelessWidget { + const _ContractAddressMobile(this.coin, {Key? key}) : super(key: key); + + final Coin coin; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(10, 5, 5, 5), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const _ContractAddressTitle(), + const SizedBox(height: 4), + _ContractAddressValue(coin), + ], + ), + ), + const Spacer(), + SizedBox( + width: 32, + height: 32, + child: _ContractAddressCopyButton(coin), + ), + ], + ), + ); + } +} + +class _ContractAddressDesktop extends StatelessWidget { + const _ContractAddressDesktop(this.coin, {Key? key}) : super(key: key); + + final Coin coin; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + padding: const EdgeInsets.only(left: 13.0, right: 6.0, top: 4), + child: Stack( + children: [ + const _ContractAddressTitle(), + Align( + alignment: Alignment.topRight, + child: SizedBox( + width: 24, + height: 16, + child: _ContractAddressCopyButton(coin), + ), + ) + ], + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 13.0, + right: 13.0, + bottom: 5, + ), + child: _ContractAddressValue(coin), + ), + ], + ); + } +} + +class _ContractAddressValue extends StatelessWidget { + const _ContractAddressValue(this.coin, {Key? key}) : super(key: key); + + final Coin coin; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + CoinIcon( + coin.protocolData?.platform ?? '', + size: 12, + ), + const SizedBox( + width: 3, + ), + Text( + '${coin.protocolData?.platform ?? ''} ', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(fontWeight: FontWeight.w500, fontSize: 11), + ), + Flexible( + child: TruncatedMiddleText(coin.protocolData?.contractAddress ?? '', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: Theme.of(context).textTheme.bodyMedium?.color, + )), + ), + ], + ); + } +} + +class _ContractAddressCopyButton extends StatelessWidget { + const _ContractAddressCopyButton(this.coin, {Key? key}) : super(key: key); + + final Coin coin; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + copyToClipBoard(context, coin.protocolData?.contractAddress ?? ''); + }, + child: Icon( + Icons.copy, + size: isMobile ? 14 : 10, + color: theme.currentGlobal.textTheme.bodyLarge?.color, + ), + ); + } +} + +class _ContractAddressTitle extends StatelessWidget { + const _ContractAddressTitle({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return Text( + LocaleKeys.contractAddress.tr(), + style: Theme.of(context).textTheme.titleSmall!.copyWith( + fontSize: 9, + fontWeight: FontWeight.w500, + color: + Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(.45), + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/coin_page_type.dart b/lib/views/wallet/coin_details/coin_page_type.dart new file mode 100644 index 0000000000..93b965ddf8 --- /dev/null +++ b/lib/views/wallet/coin_details/coin_page_type.dart @@ -0,0 +1 @@ +enum CoinPageType { receive, send, faucet, claim, info, claimSuccess } diff --git a/lib/views/wallet/coin_details/constants.dart b/lib/views/wallet/coin_details/constants.dart new file mode 100644 index 0000000000..82515581b2 --- /dev/null +++ b/lib/views/wallet/coin_details/constants.dart @@ -0,0 +1,2 @@ +const double withdrawWidth = 400; +const double receiveWidth = 400; diff --git a/lib/views/wallet/coin_details/faucet/cubit/faucet_cubit.dart b/lib/views/wallet/coin_details/faucet/cubit/faucet_cubit.dart new file mode 100644 index 0000000000..2c1a18263a --- /dev/null +++ b/lib/views/wallet/coin_details/faucet/cubit/faucet_cubit.dart @@ -0,0 +1,29 @@ +import 'package:bloc/bloc.dart'; +import 'package:web_dex/3p_api/faucet/faucet.dart' as api; +import 'package:web_dex/3p_api/faucet/faucet_response.dart'; +import 'package:web_dex/views/wallet/coin_details/faucet/cubit/faucet_state.dart'; + +class FaucetCubit extends Cubit { + final String coinAbbr; + final String? coinAddress; + + FaucetCubit({ + required this.coinAbbr, + required this.coinAddress, + }) : super(const FaucetInitial()); + + Future callFaucet() async { + emit(const FaucetLoading()); + try { + final FaucetResponse response = + await api.callFaucet(coinAbbr, coinAddress!); + if (response.status == FaucetStatus.error) { + return emit(FaucetError(response.message)); + } else { + return emit(FaucetSuccess(response)); + } + } catch (error) { + return emit(FaucetError(error.toString())); + } + } +} diff --git a/lib/views/wallet/coin_details/faucet/cubit/faucet_state.dart b/lib/views/wallet/coin_details/faucet/cubit/faucet_state.dart new file mode 100644 index 0000000000..60d2fb44ec --- /dev/null +++ b/lib/views/wallet/coin_details/faucet/cubit/faucet_state.dart @@ -0,0 +1,25 @@ +import 'package:web_dex/3p_api/faucet/faucet_response.dart'; + +abstract class FaucetState { + const FaucetState(); +} + +class FaucetInitial extends FaucetState { + const FaucetInitial(); +} + +class FaucetLoading extends FaucetState { + const FaucetLoading(); +} + +class FaucetSuccess extends FaucetState { + final FaucetResponse response; + + const FaucetSuccess(this.response); +} + +class FaucetError extends FaucetState { + final String message; + + const FaucetError(this.message); +} diff --git a/lib/views/wallet/coin_details/faucet/faucet_button.dart b/lib/views/wallet/coin_details/faucet/faucet_button.dart new file mode 100644 index 0000000000..fd18beef33 --- /dev/null +++ b/lib/views/wallet/coin_details/faucet/faucet_button.dart @@ -0,0 +1,34 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; + +class FaucetButton extends StatelessWidget { + const FaucetButton({ + Key? key, + required this.onPressed, + this.enabled = true, + }) : super(key: key); + + final bool enabled; + final VoidCallback onPressed; + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + return UiPrimaryButton( + key: const Key('coin-details-faucet-button'), + height: isMobile ? 52 : 40, + textStyle: themeData.textTheme.labelLarge + ?.copyWith(fontSize: 14, fontWeight: FontWeight.w600), + backgroundColor: themeData.colorScheme.tertiary, + text: LocaleKeys.faucet.tr(), + prefix: const Padding( + padding: EdgeInsets.only(right: 10), + child: Icon(Icons.local_drink_rounded, color: Colors.blue), + ), + onPressed: !enabled ? null : onPressed, + ); + } +} diff --git a/lib/views/wallet/coin_details/faucet/faucet_page.dart b/lib/views/wallet/coin_details/faucet/faucet_page.dart new file mode 100644 index 0000000000..d874eb00f4 --- /dev/null +++ b/lib/views/wallet/coin_details/faucet/faucet_page.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/views/wallet/coin_details/faucet/faucet_view.dart'; + +import 'cubit/faucet_cubit.dart'; + +class FaucetPage extends StatelessWidget { + const FaucetPage({ + Key? key, + required this.coinAbbr, + required this.onBackButtonPressed, + required this.coinAddress, + }) : super(key: key); + + final String coinAbbr; + final String? coinAddress; + final VoidCallback onBackButtonPressed; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + FaucetCubit(coinAbbr: coinAbbr, coinAddress: coinAddress), + child: FaucetView( + onBackButtonPressed: onBackButtonPressed, + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/faucet/faucet_view.dart b/lib/views/wallet/coin_details/faucet/faucet_view.dart new file mode 100644 index 0000000000..c2ea1447c0 --- /dev/null +++ b/lib/views/wallet/coin_details/faucet/faucet_view.dart @@ -0,0 +1,158 @@ +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:web_dex/3p_api/faucet/faucet_response.dart'; +import 'package:web_dex/generated/codegen_loader.g.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/coin_details/faucet/cubit/faucet_cubit.dart'; +import 'package:web_dex/views/wallet/coin_details/faucet/cubit/faucet_state.dart'; +import 'package:web_dex/views/wallet/coin_details/faucet/widgets/faucet_message.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class FaucetView extends StatelessWidget { + const FaucetView({Key? key, required this.onBackButtonPressed}) + : super(key: key); + + final VoidCallback onBackButtonPressed; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + if (state is FaucetInitial) { + context.read().callFaucet(); + } + final scrollController = ScrollController(); + return PageLayout( + header: PageHeader( + title: title(state), + backText: LocaleKeys.backToWallet.tr(), + onBackButtonPressed: onBackButtonPressed, + ), + content: Flexible( + child: DexScrollbar( + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: _StatesOfPage( + state: state, + onBackButtonPressed: onBackButtonPressed, + ), + ), + ), + ), + ); + }, + ); + } + + String title(FaucetState state) { + if (state is FaucetSuccess) { + return state.response.status.title; + } else if (state is FaucetError) { + return LocaleKeys.faucetFailureTitle.tr(); + } else if (state is FaucetLoading) { + return LocaleKeys.faucetLoadingTitle.tr(); + } else if (state is FaucetInitial) { + return LocaleKeys.faucetInitialTitle.tr(); + } + return LocaleKeys.faucetFailureTitle.tr(); + } +} + +class _StatesOfPage extends StatelessWidget { + final FaucetState state; + final VoidCallback onBackButtonPressed; + const _StatesOfPage({required this.state, required this.onBackButtonPressed}); + + @override + Widget build(BuildContext context) { + final localState = state; + if (localState is FaucetLoading || localState is FaucetInitial) { + return const _Loading(); + } else if (localState is FaucetSuccess) { + final bool isDenied = localState.response.status == FaucetStatus.denied; + return _FaucetResult( + color: isDenied + ? theme.custom.decreaseColor + : theme.custom.headerFloatBoxColor, + icon: isDenied ? Icons.close_rounded : Icons.check_rounded, + message: localState.response.message, + onBackButtonPressed: onBackButtonPressed, + ); + } else if (localState is FaucetError) { + return _FaucetResult( + color: theme.custom.decreaseColor, + icon: Icons.close_rounded, + message: localState.message, + onBackButtonPressed: onBackButtonPressed, + ); + } + + return const SizedBox(); + } +} + +class _Loading extends StatelessWidget { + const _Loading(); + + @override + Widget build(BuildContext context) { + return const Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox(height: 28), + Center(child: UiSpinner()), + SizedBox(height: 28), + ], + ); + } +} + +class _FaucetResult extends StatelessWidget { + final Color color; + final IconData icon; + final String message; + final VoidCallback onBackButtonPressed; + + const _FaucetResult({ + Key? key, + required this.color, + required this.icon, + required this.message, + required this.onBackButtonPressed, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Center( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 15.0), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(40), + border: Border.all(color: color, width: 4)), + child: Icon(icon, size: 66, color: color), + ), + ), + const SizedBox(height: 12), + FaucetMessage(message), + const SizedBox(height: 20), + Center( + child: UiPrimaryButton( + text: LocaleKeys.close.tr(), + width: 324, + onPressed: onBackButtonPressed, + ), + ), + const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/views/wallet/coin_details/faucet/models/faucet_success_info.dart b/lib/views/wallet/coin_details/faucet/models/faucet_success_info.dart new file mode 100644 index 0000000000..3518495a99 --- /dev/null +++ b/lib/views/wallet/coin_details/faucet/models/faucet_success_info.dart @@ -0,0 +1,9 @@ +class FaucetSuccessInfo { + final String message; + final String? link; + + FaucetSuccessInfo({ + required this.message, + this.link, + }); +} diff --git a/lib/views/wallet/coin_details/faucet/widgets/faucet_message.dart b/lib/views/wallet/coin_details/faucet/widgets/faucet_message.dart new file mode 100644 index 0000000000..4a02333441 --- /dev/null +++ b/lib/views/wallet/coin_details/faucet/widgets/faucet_message.dart @@ -0,0 +1,70 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher_string.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/wallet/coin_details/faucet/models/faucet_success_info.dart'; + +class FaucetMessage extends StatelessWidget { + const FaucetMessage(this.message); + final String message; + + @override + Widget build(BuildContext context) { + final info = parseSuccessMessage(message); + final textStyle = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(0.6)); + return Center( + child: Container( + padding: const EdgeInsets.all(20), + width: 324, + decoration: BoxDecoration( + color: theme.custom.subCardBackgroundColor, + borderRadius: BorderRadius.circular(18)), + child: SelectableText.rich( + TextSpan( + text: '${info.message}\n', + children: _getLinkText(context, textStyle, info.link), + ), + textAlign: TextAlign.center, + style: textStyle, + )), + ); + } + + FaucetSuccessInfo parseSuccessMessage(String message) { + if (message.contains('Link:')) { + final link = + message.substring(message.indexOf('<') + 1, message.indexOf('>')); + final mssg = message.substring(0, message.indexOf(' Link')); + + return FaucetSuccessInfo(message: mssg, link: link); + } + return FaucetSuccessInfo(message: message); + } + + List _getLinkText( + BuildContext context, TextStyle textStyle, String? link) { + if (link == null) { + return []; + } + return [ + TextSpan( + text: LocaleKeys.faucetLinkToTransaction.tr(), + mouseCursor: SystemMouseCursors.click, + style: textStyle.copyWith( + decoration: TextDecoration.underline, + ), + recognizer: TapGestureRecognizer() + ..onTap = () async { + await canLaunchUrlString(link) + ? await launchUrlString(link) + : throw 'Could not launch $link}'; + }, + ) + ]; + } +} diff --git a/lib/views/wallet/coin_details/receive/qr_code_address.dart b/lib/views/wallet/coin_details/receive/qr_code_address.dart new file mode 100644 index 0000000000..d54948275d --- /dev/null +++ b/lib/views/wallet/coin_details/receive/qr_code_address.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:qr_flutter/qr_flutter.dart'; + +class QRCodeAddress extends StatelessWidget { + const QRCodeAddress({ + required this.currentAddress, + this.size = 145, + this.borderRadius, + this.backgroundColor = Colors.white, + this.foregroundColor = Colors.black, + this.padding = const EdgeInsets.all(8.0), + }); + final String currentAddress; + final double size; + final BorderRadiusGeometry? borderRadius; + final Color backgroundColor; + final Color foregroundColor; + final EdgeInsets padding; + + @override + Widget build(BuildContext context) { + final address = currentAddress; + + return ClipRRect( + borderRadius: borderRadius ?? BorderRadius.circular(18.0), + child: QrImage( + size: size, + foregroundColor: foregroundColor, + backgroundColor: backgroundColor, + data: address, + padding: padding, + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/receive/receive_address.dart b/lib/views/wallet/coin_details/receive/receive_address.dart new file mode 100644 index 0000000000..606c0c64b6 --- /dev/null +++ b/lib/views/wallet/coin_details/receive/receive_address.dart @@ -0,0 +1,54 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.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/wallet.dart'; +import 'package:web_dex/shared/widgets/copied_text.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; +import 'package:web_dex/views/wallet/coin_details/receive/receive_address_trezor.dart'; + +class ReceiveAddress extends StatelessWidget { + const ReceiveAddress({ + Key? key, + required this.coin, + required this.onChanged, + required this.selectedAddress, + this.backgroundColor, + }) : super(key: key); + + final Coin coin; + final Function(String) onChanged; + final String? selectedAddress; + final Color? backgroundColor; + + @override + Widget build(BuildContext context) { + if (currentWalletBloc.wallet?.config.type == WalletType.trezor) { + return ReceiveAddressTrezor( + coin: coin, + selectedAddress: selectedAddress, + onChanged: onChanged, + ); + } + + if (coin.address == null) { + return Text(LocaleKeys.addressNotFound.tr()); + } + return isMobile + ? CopiedText( + copiedValue: coin.address!, + isTruncated: true, + backgroundColor: backgroundColor, + ) + : ConstrainedBox( + constraints: const BoxConstraints(maxWidth: receiveWidth), + child: CopiedText( + copiedValue: coin.address!, + isTruncated: true, + backgroundColor: backgroundColor, + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/receive/receive_address_trezor.dart b/lib/views/wallet/coin_details/receive/receive_address_trezor.dart new file mode 100644 index 0000000000..4df1d2413b --- /dev/null +++ b/lib/views/wallet/coin_details/receive/receive_address_trezor.dart @@ -0,0 +1,134 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/address_select.dart'; +import 'package:web_dex/views/wallet/coin_details/receive/request_address_button.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class ReceiveAddressTrezor extends StatelessWidget { + const ReceiveAddressTrezor({ + Key? key, + required this.coin, + required this.onChanged, + required this.selectedAddress, + }) : super(key: key); + + final Coin coin; + final Function(String) onChanged; + final String? selectedAddress; + + @override + Widget build(BuildContext context) { + final String? selectedAddress = this.selectedAddress; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (selectedAddress == null) + Text( + LocaleKeys.trezorNoAddresses.tr(), + style: theme.currentGlobal.textTheme.bodySmall, + ) + else + Row( + children: [ + Flexible( + child: _BuildSelect( + key: Key('trezor-receive-select-${coin.abbr}'), + coin: coin, + address: selectedAddress, + onChanged: onChanged, + ), + ), + const SizedBox(width: 4), + _buildCopyButton(context) + ], + ), + _buildRequestButton(), + ], + ); + } + + Widget _buildRequestButton() { + return RequestAddressButton( + coin, + onSuccess: (String newAddress) { + onChanged(newAddress); + }, + ); + } + + Widget _buildCopyButton(BuildContext context) { + return Material( + type: MaterialType.transparency, + child: InkWell( + onTap: () { + copyToClipBoard(context, selectedAddress!); + }, + borderRadius: BorderRadius.circular(20), + child: UiTooltip( + message: LocaleKeys.copyToClipboard.tr(), + child: SizedBox( + width: 40, + height: 40, + child: Icon( + Icons.copy, + size: 16, + color: theme.currentGlobal.textTheme.bodySmall?.color, + ), + ), + ), + ), + ); + } +} + +class _BuildSelect extends StatefulWidget { + const _BuildSelect({ + super.key, + required this.coin, + required this.address, + required this.onChanged, + }); + + final Coin coin; + final String address; + final Function(String) onChanged; + + @override + State<_BuildSelect> createState() => __BuildSelectState(); +} + +class __BuildSelectState extends State<_BuildSelect> { + final GlobalKey _globalKey = GlobalKey(); + RenderBox? _renderBox; + + @override + void initState() { + WidgetsBinding.instance.addPostFrameCallback((_) { + setState(() { + _renderBox = _globalKey.currentContext?.findRenderObject() as RenderBox; + }); + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Container( + key: _globalKey, + child: AddressSelect( + coin: widget.coin, + addresses: widget.coin.accounts?.first.addresses, + selectedAddress: widget.address, + onChanged: widget.onChanged, + maxHeight: 200, + maxWidth: _renderBox?.size.width, + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/receive/receive_details.dart b/lib/views/wallet/coin_details/receive/receive_details.dart new file mode 100644 index 0000000000..b5976d7691 --- /dev/null +++ b/lib/views/wallet/coin_details/receive/receive_details.dart @@ -0,0 +1,173 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.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/wallet.dart'; +import 'package:web_dex/shared/widgets/coin_type_tag.dart'; +import 'package:web_dex/shared/widgets/segwit_icon.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/settings/widgets/security_settings/seed_settings/backup_seed_notification.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; +import 'package:web_dex/views/wallet/coin_details/receive/qr_code_address.dart'; +import 'package:web_dex/views/wallet/coin_details/receive/receive_address.dart'; + +class ReceiveDetails extends StatelessWidget { + const ReceiveDetails({ + Key? key, + required this.coin, + required this.onBackButtonPressed, + }) : super(key: key); + + final Coin coin; + final VoidCallback onBackButtonPressed; + + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return StreamBuilder( + stream: currentWalletBloc.outWallet, + builder: (context, snapshot) { + return PageLayout( + header: PageHeader( + title: LocaleKeys.receive.tr(), + widgetTitle: coin.mode == CoinMode.segwit + ? const Padding( + padding: EdgeInsets.only(left: 6.0), + child: SegwitIcon(height: 22), + ) + : null, + backText: LocaleKeys.backToWallet.tr(), + onBackButtonPressed: onBackButtonPressed, + ), + content: Expanded( + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: _ReceiveDetailsContent(coin: coin), + ), + ), + ), + ); + }, + ); + } +} + +class _ReceiveDetailsContent extends StatefulWidget { + const _ReceiveDetailsContent({required this.coin}); + + final Coin coin; + + @override + State<_ReceiveDetailsContent> createState() => _ReceiveDetailsContentState(); +} + +class _ReceiveDetailsContentState extends State<_ReceiveDetailsContent> { + String? _currentAddress; + @override + void initState() { + _currentAddress = widget.coin.defaultAddress; + super.initState(); + } + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + + if (currentWalletBloc.wallet?.config.hasBackup == false && + !widget.coin.isTestCoin) { + return const BackupNotification(); + } + + final currentAddress = _currentAddress; + + return Container( + decoration: BoxDecoration( + color: isMobile ? themeData.cardColor : null, + borderRadius: BorderRadius.circular(18.0)), + padding: EdgeInsets.symmetric( + vertical: isMobile ? 25 : 0, + horizontal: 15, + ), + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Text( + LocaleKeys.sendToAddress + .tr(args: [Coin.normalizeAbbr(widget.coin.abbr)]), + style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14), + ), + Container( + padding: const EdgeInsets.symmetric(vertical: 25, horizontal: 23), + margin: EdgeInsets.only(top: isMobile ? 25 : 15), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18.0), + color: theme.mode == ThemeMode.dark + ? themeData.colorScheme.onSurface + : themeData.cardColor, + boxShadow: const [ + BoxShadow( + color: Color.fromRGBO(0, 0, 0, 0.08), + offset: Offset(0, 1), + blurRadius: 8, + ), + ]), + constraints: const BoxConstraints(maxWidth: receiveWidth), + child: Column( + children: [ + Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(LocaleKeys.network.tr(), + style: themeData.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + fontSize: 14, + color: themeData.textTheme.labelLarge?.color, + )), + CoinTypeTag(widget.coin), + ], + ), + const SizedBox(height: 30), + ReceiveAddress( + coin: widget.coin, + selectedAddress: _currentAddress, + onChanged: _onAddressChanged, + ), + const SizedBox(height: 30), + if (currentAddress != null) + Column( + children: [ + QRCodeAddress(currentAddress: currentAddress), + const SizedBox(height: 15), + Text( + LocaleKeys.scanToGetAddress.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w400, + fontSize: 14, + color: themeData.textTheme.labelLarge?.color, + ), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } + + void _onAddressChanged(String address) { + setState(() { + _currentAddress = address; + }); + } +} diff --git a/lib/views/wallet/coin_details/receive/request_address_button.dart b/lib/views/wallet/coin_details/receive/request_address_button.dart new file mode 100644 index 0000000000..7c1e3fd5e9 --- /dev/null +++ b/lib/views/wallet/coin_details/receive/request_address_button.dart @@ -0,0 +1,164 @@ +import 'dart:async'; + +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/trezor/get_new_address/get_new_address_response.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/shared/ui/ui_simple_border_button.dart'; +import 'package:web_dex/views/wallet/coin_details/receive/trezor_new_address_confirmation.dart'; + +class RequestAddressButton extends StatefulWidget { + const RequestAddressButton( + this.coin, { + Key? key, + required this.onSuccess, + }) : super(key: key); + + final Coin coin; + final Function(String) onSuccess; + + @override + State createState() => _RequestAddressButtonState(); +} + +class _RequestAddressButtonState extends State { + String? _message; + bool _inProgress = false; + PopupDispatcher? _confirmAddressDispatcher; + + @override + void dispose() { + _message = null; + _inProgress = false; + _confirmAddressDispatcher?.close(); + _confirmAddressDispatcher = null; + coinsBloc.trezor.unsubscribeFromNewAddressStatus(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildMessage(), + const SizedBox(height: 10), + _buildButton(), + ], + ); + } + + Widget _buildMessage() { + final message = _message; + if (message == null) return const SizedBox.shrink(); + + return Text( + message, + style: theme.currentGlobal.textTheme.bodySmall, + ); + } + + Widget _buildButton() { + return Row( + children: [ + UiSimpleBorderButton( + onPressed: _inProgress ? null : _getAddress, + child: SizedBox( + height: 24, + child: Row( + children: [ + if (_inProgress) + const UiSpinner(width: 10, height: 10, strokeWidth: 1) + else + const Icon(Icons.add, size: 16), + const SizedBox(width: 6), + Text(LocaleKeys.freshAddress.tr()), + ], + ), + ), + ), + ], + ); + } + + Future _getAddress() async { + setState(() { + _inProgress = true; + _message = null; + }); + + final taskId = await coinsBloc.trezor.initNewAddress(widget.coin); + if (taskId == null) return; + routingState.isBrowserNavigationBlocked = true; + coinsBloc.trezor + .subscribeOnNewAddressStatus(taskId, widget.coin, _onStatusUpdate); + } + + void _onStatusUpdate(GetNewAddressResponse initNewAddressStatus) { + final String? error = initNewAddressStatus.error; + if (error != null) { + _onError(error); + return; + } + + final GetNewAddressStatus? status = initNewAddressStatus.result?.status; + final GetNewAddressResultDetails? details = + initNewAddressStatus.result?.details; + + switch (status) { + case GetNewAddressStatus.inProgress: + if (details is GetNewAddressResultConfirmAddressDetails) { + _onConfirmAddressStatus(details); + } + return; + case GetNewAddressStatus.ok: + if (details is GetNewAddressResultOkDetails) { + _onOkStatus(details); + } + return; + case GetNewAddressStatus.unknown: + case null: + return; + } + } + + void _onConfirmAddressStatus( + GetNewAddressResultConfirmAddressDetails details) { + _confirmAddressDispatcher ??= PopupDispatcher( + width: 360, + barrierDismissible: false, + popupContent: + TrezorNewAddressConfirmation(address: details.expectedAddress), + ); + if (!_confirmAddressDispatcher!.isShown) { + _confirmAddressDispatcher?.show(); + } + } + + void _onOkStatus(GetNewAddressResultOkDetails details) { + coinsBloc.trezor.unsubscribeFromNewAddressStatus(); + _confirmAddressDispatcher?.close(); + _confirmAddressDispatcher = null; + routingState.isBrowserNavigationBlocked = false; + + widget.onSuccess(details.newAddress.address); + setState(() { + _inProgress = false; + _message = null; + }); + } + + void _onError(String error) { + routingState.isBrowserNavigationBlocked = false; + setState(() { + _inProgress = false; + _message = error; + }); + } +} diff --git a/lib/views/wallet/coin_details/receive/trezor_new_address_confirmation.dart b/lib/views/wallet/coin_details/receive/trezor_new_address_confirmation.dart new file mode 100644 index 0000000000..c01d160433 --- /dev/null +++ b/lib/views/wallet/coin_details/receive/trezor_new_address_confirmation.dart @@ -0,0 +1,42 @@ +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/utils/utils.dart'; +import 'package:web_dex/views/wallet/coin_details/receive/qr_code_address.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class TrezorNewAddressConfirmation extends StatelessWidget { + const TrezorNewAddressConfirmation({super.key, required this.address}); + final String address; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Column( + children: [ + Text( + LocaleKeys.confirmOnTrezor.tr(), + style: theme.textTheme.titleLarge, + ), + const SizedBox(height: 24), + QRCodeAddress(currentAddress: address, size: 160), + const SizedBox(height: 24), + UiTextFormField( + readOnly: true, + initialValue: address, + inputContentPadding: const EdgeInsets.fromLTRB(12, 22, 0, 22), + suffixIcon: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 20, maxHeight: 20), + child: IconButton( + padding: const EdgeInsets.all(0.0), + hoverColor: Colors.transparent, + highlightColor: Colors.transparent, + onPressed: () => copyToClipBoard(context, address), + icon: const Icon(Icons.copy), + ), + ), + ), + ], + ); + } +} diff --git a/lib/views/wallet/coin_details/rewards/kmd_reward_claim_success.dart b/lib/views/wallet/coin_details/rewards/kmd_reward_claim_success.dart new file mode 100644 index 0000000000..adc3f62efc --- /dev/null +++ b/lib/views/wallet/coin_details/rewards/kmd_reward_claim_success.dart @@ -0,0 +1,80 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; +import 'package:web_dex/views/common/page_header/page_header.dart'; +import 'package:web_dex/views/common/pages/page_layout.dart'; + +class KmdRewardClaimSuccess extends StatelessWidget { + const KmdRewardClaimSuccess( + {Key? key, + required this.reward, + required this.formattedUsd, + required this.onBackButtonPressed}) + : super(key: key); + + final String reward; + final String formattedUsd; + final VoidCallback onBackButtonPressed; + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + + return PageLayout( + header: PageHeader( + backText: LocaleKeys.back.tr(), + onBackButtonPressed: onBackButtonPressed, + title: LocaleKeys.successClaim.tr(), + ), + content: getContent(themeData), + ); + } + + Widget getContent(ThemeData themeData) { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 30.0), + const DexSvgImage(path: Assets.assetTick), + const SizedBox(height: 40.0), + Text( + LocaleKeys.youClaimed.tr(), + style: TextStyle( + color: themeData.textTheme.bodyMedium?.color?.withOpacity(0.4), + fontWeight: FontWeight.w700, + fontSize: 14, + ), + ), + const SizedBox(height: 5.0), + SelectableText( + reward, + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 25, + ), + ), + const SizedBox(height: 5.0), + SelectableText( + '\$$formattedUsd', + style: TextStyle( + color: themeData.textTheme.bodyMedium?.color?.withOpacity(0.7), + fontWeight: FontWeight.w500, + fontSize: 14, + ), + ), + const SizedBox(height: 60.0), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 300), + child: UiPrimaryButton( + onPressed: onBackButtonPressed, + text: LocaleKeys.done.tr(), + ), + ) + ], + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/rewards/kmd_reward_info_header.dart b/lib/views/wallet/coin_details/rewards/kmd_reward_info_header.dart new file mode 100644 index 0000000000..710872265e --- /dev/null +++ b/lib/views/wallet/coin_details/rewards/kmd_reward_info_header.dart @@ -0,0 +1,94 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/custom_tooltip.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +class KmdRewardInfoHeader extends StatelessWidget { + const KmdRewardInfoHeader({ + Key? key, + required this.totalReward, + required this.isThereReward, + required this.coinAbbr, + this.totalRewardUsd, + }) : super(key: key); + + final double totalReward; + final double? totalRewardUsd; + final bool isThereReward; + final String coinAbbr; + + @override + Widget build(BuildContext context) { + final String rewardText = isThereReward + ? '+ $coinAbbr ${doubleToString(totalReward)}' + : LocaleKeys.noClaimableRewards.tr(); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SelectableText( + rewardText, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isThereReward ? theme.custom.increaseColor : null, + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + if (isThereReward) + Row( + children: [ + SelectableText( + '\$${cutTrailingZeros(formatAmt(totalRewardUsd ?? 0))}', + style: Theme.of(context).textTheme.titleSmall?.copyWith( + color: Colors.grey, + ), + ), + const SizedBox(width: 6), + CustomTooltip( + maxWidth: 250, + padding: const EdgeInsets.all(12), + tooltip: _buildTooltip(context), + child: const Icon( + Icons.info_outline, + size: 24, + ), + ), + ], + ), + ], + ); + } + + Widget _buildTooltip(BuildContext context) { + return RichText( + text: TextSpan( + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 12), + children: [ + TextSpan(text: '${LocaleKeys.kmdRewardSpan1.tr()}('), + TextSpan( + text: 'coingecko.com', + style: const TextStyle(color: Colors.blue), + recognizer: TapGestureRecognizer() + ..onTap = () { + launchURL('https://www.coingecko.com'); + }, + ), + const TextSpan(text: ', '), + TextSpan( + text: 'openrates.io', + style: const TextStyle(color: Colors.blue), + recognizer: TapGestureRecognizer() + ..onTap = () { + launchURL('https://exchangeratesapi.io'); + }, + ), + const TextSpan(text: ')'), + ], + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart b/lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart new file mode 100644 index 0000000000..b6144426fd --- /dev/null +++ b/lib/views/wallet/coin_details/rewards/kmd_reward_list_item.dart @@ -0,0 +1,220 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_reward_item.dart'; +import 'package:web_dex/shared/ui/custom_tooltip.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +class KmdRewardListItem extends StatelessWidget { + const KmdRewardListItem({ + Key? key, + required this.reward, + }) : super(key: key); + + final KmdRewardItem reward; + + bool get _isThereReward { + return reward.reward != null; + } + + @override + Widget build(BuildContext context) { + return isMobile ? _buildMobileItem(context) : _buildDesktopItem(context); + } + + Widget _buildDesktopItem(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Flexible( + flex: 3, + child: Align( + alignment: const Alignment(-1, 0), + child: SelectableText( + cutTrailingZeros(formatAmt(double.parse(reward.amount))), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 14), + ), + ), + ), + Flexible( + flex: 3, + child: Align( + alignment: const Alignment(-1, 0), + child: _buildReward(context), + ), + ), + Flexible( + flex: 3, + child: Align( + alignment: const Alignment(-1, 0), + child: _buildTimeLeft(context)), + ), + Flexible( + flex: 1, + child: Align( + alignment: Alignment.centerLeft, + child: _buildStatus(context), + ), + ), + ], + ); + } + + Widget _buildMobileItem(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(vertical: 15.0, horizontal: 15), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(15.0)), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiary, + borderRadius: BorderRadius.circular(20)), + child: Icon( + Icons.arrow_downward, + size: 15, + color: theme.custom.increaseColor, + ), + ), + const SizedBox( + width: 15, + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.reward.tr(), + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w500), + ), + _buildTimeLeft(context) + ], + ), + const Spacer(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SelectableText( + '+KMD ' + '${cutTrailingZeros(formatAmt(double.parse(reward.amount)))}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: theme.custom.increaseColor), + ), + SelectableText( + '+KMD ' + '${cutTrailingZeros(formatAmt(double.parse(reward.amount)))}', + style: const TextStyle( + fontSize: 12, fontWeight: FontWeight.w500), + ), + ], + ), + ], + )); + } + + Widget _buildReward(BuildContext context) { + final String text = _isThereReward + ? '+ ${cutTrailingZeros(formatAmt(reward.reward!))}' + : '-'; + final TextStyle? style = + Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14); + return SelectableText( + text, + style: style, + ); + } + + Widget _buildStatus(BuildContext context) { + final RewardItemError? rewardError = reward.error; + + if (rewardError != null) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SelectableText( + rewardError.short, + style: + Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14), + ), + if (rewardError.long.isNotEmpty) + CustomTooltip( + maxWidth: 200, + padding: const EdgeInsets.all(12), + tooltip: SelectableText( + rewardError.long, + style: const TextStyle(fontSize: 13), + ), + child: const Icon( + Icons.info_outlined, + size: 16, + ), + ), + ], + ); + } + + if (_isThereReward) { + return const Padding( + padding: EdgeInsets.fromLTRB(0, 0, 20, 0), + child: Icon( + Icons.check_circle, + color: Colors.green, + size: 14, + ), + ); + } + + return const SizedBox(); + } + + Widget _buildTimeLeft(BuildContext context) { + final Duration? timeLeft = reward.timeLeft; + return timeLeft == null + ? SelectableText('-', + style: + Theme.of(context).textTheme.bodyMedium?.copyWith(fontSize: 14)) + : SelectableText( + _formatTimeLeft(timeLeft), + style: timeLeft.inDays <= 2 + ? Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 14, color: Colors.orange) + : Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 14), + ); + } + + String _formatTimeLeft(Duration duration) { + final int dd = duration.inDays; + final int hh = duration.inHours; + final int mm = duration.inMinutes; + if (dd > 0) { + return '$dd day(s)'; + } + if (hh > 0) { + String minutes = mm.remainder(60).toString(); + if (minutes.length < 2) minutes = '0$minutes'; + return '${hh}h ${minutes}m'; + } + if (mm > 0) { + return '${mm}min'; + } + return '-'; + } +} diff --git a/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart b/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart new file mode 100644 index 0000000000..3df0c76374 --- /dev/null +++ b/lib/views/wallet/coin_details/rewards/kmd_rewards_info.dart @@ -0,0 +1,475 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/blocs/kmd_rewards_bloc.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/common/screen.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/bloc_response.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/kmd_rewards_info/kmd_reward_item.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/views/common/page_header/page_header.dart'; +import 'package:web_dex/views/common/pages/page_layout.dart'; +import 'package:web_dex/views/wallet/coin_details/rewards/kmd_reward_info_header.dart'; +import 'package:web_dex/views/wallet/coin_details/rewards/kmd_reward_list_item.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class KmdRewardsInfo extends StatefulWidget { + const KmdRewardsInfo({ + Key? key, + required this.coin, + required this.onSuccess, + required this.onBackButtonPressed, + }) : super(key: key); + + final Coin coin; + final Function onSuccess; + final VoidCallback onBackButtonPressed; + + @override + State createState() => _KmdRewardsInfoState(); +} + +class _KmdRewardsInfoState extends State { + String _successMessage = ''; + String _errorMessage = ''; + bool _isClaiming = false; + List? _rewards; + double? _totalReward; + double? _totalRewardUsd; + int? _updateTimer; + + bool get _isThereReward => _totalReward != null && _totalReward! > 0; + + Color get _messageColor => _successMessage.isNotEmpty + ? Colors.green[400]! + : theme.currentGlobal.colorScheme.error; + + @override + Widget build(BuildContext context) { + return PageLayout( + header: PageHeader( + title: LocaleKeys.reward.tr(), + backText: LocaleKeys.backToWallet.tr(), + onBackButtonPressed: widget.onBackButtonPressed, + ), + content: Flexible( + child: SingleChildScrollView( + controller: ScrollController(), + child: _content, + ), + ), + ); + } + + Widget get _content => + isDesktop ? _buildDesktop(context) : _buildMobile(context); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _updateRewardsInfo(); + }); + } + + Widget _buildContent(BuildContext context) { + final List? rewards = _rewards; + + if (rewards == null) return const UiSpinnerList(); + + if (rewards.isEmpty) { + return Text(LocaleKeys.noRewards.tr()); + } + + return _buildRewardList(context); + } + + Widget _buildControls(BuildContext context) { + return _isClaiming + ? const UiSpinner(width: 28, height: 28) + : UiPrimaryButton( + key: const Key('reward-claim-button'), + width: 200, + text: LocaleKeys.claim.tr(), + onPressed: _isThereReward + ? () { + _claimRewards(context); + } + : null, + ); + } + + Widget _buildDesktop(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 30), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 20), + _buildTotal(), + _buildMessage(), + const SizedBox(height: 20), + _buildControls(context), + const SizedBox(height: 20), + ], + ), + const Spacer(), + Container( + width: 350, + height: 177, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + gradient: theme.custom.userRewardBoxColor, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 7), + blurRadius: 10, + color: theme.custom.rewardBoxShadowColor) + ]), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.rewardBoxTitle.tr(), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 20, + ), + ), + Text( + LocaleKeys.rewardBoxSubTitle.tr(), + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Theme.of(context) + .textTheme + .bodyMedium + ?.color + ?.withOpacity(0.4), + ), + ), + const SizedBox( + height: 30.0, + ), + UiBorderButton( + width: 160, + height: 38, + text: LocaleKeys.rewardBoxReadMore.tr(), + onPressed: () { + launchURL( + 'https://support.komodoplatform.com/support/solutions/articles/29000024428-komodo-5-active-user-reward-all-you-need-to-know'); + }, + ) + ], + ), + ), + const Positioned( + bottom: 0, + right: 0, + child: RewardBackground(), + ) + ], + )) + ], + ), + const SizedBox(height: 20), + Flexible(child: _buildContent(context)), + ], + ), + ); + } + + Widget _buildMessage() { + final String message = + _successMessage.isEmpty ? _errorMessage : _successMessage; + + return message.isEmpty + ? const SizedBox.shrink() + : Container( + margin: const EdgeInsets.only(top: 20), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all( + color: _messageColor, + ), + ), + child: SelectableText( + message, + style: TextStyle(color: _messageColor), + ), + ); + } + + Widget _buildMobile(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.fromLTRB(20, 20, 20, 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16.0), + color: Theme.of(context).colorScheme.surface), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: double.infinity, + height: 177, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + gradient: theme.custom.userRewardBoxColor, + boxShadow: [ + BoxShadow( + offset: const Offset(0, 7), + blurRadius: 10, + color: theme.custom.rewardBoxShadowColor) + ]), + child: Stack( + children: [ + Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + LocaleKeys.rewardBoxTitle.tr(), + style: const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 20, + ), + ), + Text( + LocaleKeys.rewardBoxSubTitle.tr(), + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Theme.of(context) + .textTheme + .bodyMedium + ?.color + ?.withOpacity(0.3), + ), + ), + const SizedBox(height: 24.0), + UiBorderButton( + width: 160, + height: 38, + text: LocaleKeys.rewardBoxReadMore.tr(), + onPressed: () { + launchURL( + 'https://support.komodoplatform.com/support/solutions/articles/29000024428-komodo-5-active-user-reward-all-you-need-to-know'); + }, + ) + ], + ), + ), + const Positioned( + bottom: 0, + right: 0, + child: RewardBackground(), + ) + ], + )), + const SizedBox(height: 20.0), + _buildTotal(), + _buildMessage(), + const SizedBox(height: 20), + _buildControls(context), + ], + )), + Flexible(child: _buildContent(context)) + ], + ); + } + + Widget _buildRewardList(BuildContext context) { + final scrollController = ScrollController(); + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 0.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceAround, + mainAxisSize: MainAxisSize.min, + children: [ + isDesktop ? _buildRewardListHeader(context) : const SizedBox(), + const SizedBox(height: 10), + Flexible( + child: DexScrollbar( + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: Column( + children: + (_rewards ?? []).map(_buildRewardLstItem).toList(), + ), + ), + ), + ), + ], + )); + } + + Widget _buildRewardListHeader(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + flex: 3, + child: Align( + alignment: const Alignment(-1, 0), + child: Text( + LocaleKeys.kmdAmount.tr(), + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + ), + ), + ), + Expanded( + flex: 3, + child: Align( + alignment: const Alignment(-1, 0), + child: Text( + LocaleKeys.kmdReward.tr(), + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + ), + ), + ), + Expanded( + flex: 3, + child: Align( + alignment: const Alignment(-1, 0), + child: Text( + LocaleKeys.timeLeft.tr(), + style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + ), + ), + ), + if (!isMobile) + Expanded( + flex: 1, + child: Align( + alignment: const Alignment(-1, 0), + child: Text( + LocaleKeys.status.tr(), + style: + const TextStyle(fontWeight: FontWeight.w700, fontSize: 12), + ), + ), + ), + ], + ); + } + + Widget _buildRewardLstItem(KmdRewardItem reward) { + return Padding( + padding: const EdgeInsets.fromLTRB(0, 0, 0, 12), + child: KmdRewardListItem(reward: reward), + ); + } + + Widget _buildTotal() { + final double? totalReward = _totalReward; + if (totalReward == null) { + return const SizedBox(); + } + + return KmdRewardInfoHeader( + totalReward: totalReward, + totalRewardUsd: _totalRewardUsd, + isThereReward: _isThereReward, + coinAbbr: widget.coin.abbr, + ); + } + + Future _claimRewards(BuildContext context) async { + setState(() { + _isClaiming = true; + _errorMessage = ''; + _successMessage = ''; + }); + + final BlocResponse response = + await kmdRewardsBloc.claim(context); + final BaseError? error = response.error; + if (error != null) { + setState(() { + _isClaiming = false; + _errorMessage = error.message; + }); + return; + } + + await coinsBloc.updateBalances(); // consider refactoring (add timeout?) + await _updateInfoUntilSuccessOrTimeOut(30000); + + final String reward = + doubleToString(double.tryParse(response.result!) ?? 0); + final double? usdPrice = + coinsBloc.getUsdPriceByAmount(response.result!, 'KMD'); + final String formattedUsdPrice = cutTrailingZeros(formatAmt(usdPrice ?? 0)); + setState(() { + _isClaiming = false; + }); + widget.onSuccess(reward, formattedUsdPrice); + } + + bool _rewardsEquals( + List previous, List current) { + if (previous.length != current.length) return false; + + for (int i = 0; i < previous.length; i++) { + if (previous[i].accrueStartAt != current[i].accrueStartAt) return false; + } + + return true; + } + + Future _updateInfoUntilSuccessOrTimeOut(int timeOut) async { + _updateTimer ??= DateTime.now().millisecondsSinceEpoch; + final List prevRewards = + List.from(_rewards ?? []); + + await _updateRewardsInfo(); + + final bool isTimedOut = + DateTime.now().millisecondsSinceEpoch - _updateTimer! > timeOut; + final bool isUpdated = !_rewardsEquals(prevRewards, _rewards ?? []); + + if (isUpdated || isTimedOut) { + _updateTimer = null; + return; + } + + await Future.delayed(const Duration(milliseconds: 1000)); + await _updateInfoUntilSuccessOrTimeOut(timeOut); + } + + Future _updateRewardsInfo() async { + final double? total = await kmdRewardsBloc.getTotal(context); + final List currentRewards = await kmdRewardsBloc.getInfo(); + final double? totalUsd = + coinsBloc.getUsdPriceByAmount((total ?? 0).toString(), 'KMD'); + + if (!mounted) return; + setState(() { + _rewards = currentRewards; + _totalReward = total; + _totalRewardUsd = totalUsd; + }); + } +} diff --git a/lib/views/wallet/coin_details/transactions/transaction_details.dart b/lib/views/wallet/coin_details/transactions/transaction_details.dart new file mode 100644 index 0000000000..1e7ae6fcc7 --- /dev/null +++ b/lib/views/wallet/coin_details/transactions/transaction_details.dart @@ -0,0 +1,399 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.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/copied_text.dart'; + +class TransactionDetails extends StatelessWidget { + const TransactionDetails({ + Key? key, + required this.transaction, + required this.onClose, + required this.coin, + }) : super(key: key); + + final Transaction transaction; + final void Function() onClose; + final Coin coin; + + @override + Widget build(BuildContext context) { + final EdgeInsets padding = EdgeInsets.only( + top: isMobile ? 16 : 0, + left: 16, + right: 16, + bottom: isMobile ? 20 : 30); + final scrollController = ScrollController(); + + return DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Flexible( + child: Container( + constraints: const BoxConstraints(maxWidth: 550), + padding: padding, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Container( + width: double.infinity, + padding: const EdgeInsets.fromLTRB(0, 26, 0, 24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: theme.custom.subCardBackgroundColor, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + LocaleKeys.transactionDetailsTitle.tr(), + style: Theme.of(context) + .textTheme + .titleLarge + ?.copyWith(fontSize: 18), + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: CoinIcon(coin.abbr, size: 32), + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: SelectableText(coin.name), + ), + Padding( + padding: const EdgeInsets.only(top: 16), + child: _buildBalanceChanges(context), + ), + ], + ), + ), + const SizedBox( + height: 30, + ), + _buildSimpleData( + context, + title: LocaleKeys.date.tr(), + value: transaction.formattedTime, + hasBackground: true, + ), + _buildFee(context), + _buildMemo(context), + _buildSimpleData( + context, + title: LocaleKeys.confirmations.tr(), + value: transaction.confirmations.toString(), + hasBackground: true, + ), + _buildSimpleData( + context, + title: LocaleKeys.blockHeight.tr(), + value: transaction.blockHeight.toString(), + ), + _buildSimpleData( + context, + title: LocaleKeys.transactionHash.tr(), + value: transaction.txHash, + isCopied: true, + ), + const SizedBox(height: 20), + _buildAddresses(isMobile, context), + _buildControls(context, isMobile), + ], + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildAddress(BuildContext context, + {required String title, required String address}) { + return Padding( + padding: const EdgeInsets.only(bottom: 20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title, + style: + Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 14), + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: CopiedText( + copiedValue: address, + isTruncated: true, + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + fontSize: 14, + ), + ), + ], + ), + ); + } + + Widget _buildAddresses(bool isMobile, BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.only(bottom: 10), + child: isMobile + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildAddress( + context, + title: LocaleKeys.from.tr(), + address: transaction.from.first, + ), + _buildAddress( + context, + title: LocaleKeys.to.tr(), + address: transaction.toAddress, + ), + ], + ) + : Row( + children: [ + Expanded( + child: Container( + alignment: Alignment.centerLeft, + child: _buildAddress( + context, + title: LocaleKeys.from.tr(), + address: transaction.from.first, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Container( + alignment: Alignment.centerLeft, + child: _buildAddress( + context, + title: LocaleKeys.to.tr(), + address: transaction.toAddress, + ), + ), + ), + ], + ), + ); + } + + Widget _buildBalanceChanges(BuildContext context) { + final String formatted = + formatDexAmt(double.parse(transaction.myBalanceChange).abs()); + final String sign = transaction.isReceived ? '+' : '-'; + final double? usd = + coinsBloc.getUsdPriceByAmount(formatted, transaction.coin); + final String formattedUsd = formatAmt(usd ?? 0); + final String value = + '$sign $formatted ${Coin.normalizeAbbr(transaction.coin)} (\$$formattedUsd)'; + + return SelectableText( + value, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 22, + color: theme.custom.balanceColor, + ), + ); + } + + Widget _buildControls(BuildContext context, bool isMobile) { + final double buttonHeight = isMobile ? 50 : 40; + final double buttonWidth = isMobile ? 130 : 150; + final double fontSize = isMobile ? 12 : 14; + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + UiPrimaryButton( + width: buttonWidth, + height: buttonHeight, + textStyle: Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, + fontSize: fontSize, + color: theme.custom.defaultGradientButtonTextColor, + ), + onPressed: () { + launchURL(getTxExplorerUrl(coin, transaction.txHash)); + }, + text: LocaleKeys.viewOnExplorer.tr(), + ), + SizedBox(width: isMobile ? 4 : 20), + UiPrimaryButton( + width: buttonWidth, + height: buttonHeight, + onPressed: onClose, + textStyle: Theme.of(context).textTheme.labelLarge?.copyWith( + fontSize: fontSize, + fontWeight: FontWeight.w700, + ), + backgroundColor: theme.custom.lightButtonColor, + text: LocaleKeys.done.tr(), + ), + ], + ); + } + + Widget _buildFee(BuildContext context) { + final String? fee = transaction.feeDetails.feeValue; + final String formattedFee = + getNumberWithoutExponent(double.parse(fee ?? '').abs().toString()); + final double? usd = coinsBloc.getUsdPriceByAmount(formattedFee, _feeCoin); + final String formattedUsd = formatAmt(usd ?? 0); + + final String title = LocaleKeys.fees.tr(); + final String value = + '- ${Coin.normalizeAbbr(_feeCoin)} $formattedFee (\$$formattedUsd)'; + + return Padding( + padding: const EdgeInsets.only(bottom: 15.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + flex: 4, + child: Text( + title, + style: + Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 14), + ), + ), + Expanded( + flex: 6, + child: Container( + constraints: const BoxConstraints(maxHeight: 35), + alignment: Alignment.centerLeft, + child: SelectableText( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.custom.decreaseColor, + ), + ), + ), + ), + ], + ), + ); + } + + Widget _buildMemo(BuildContext context) { + final String? memo = transaction.memo; + if (memo == null || memo.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 15.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + flex: 4, + child: Text( + '${LocaleKeys.memo.tr()}: ', + style: + Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 14), + ), + ), + Expanded( + flex: 6, + child: Container( + constraints: const BoxConstraints(maxHeight: 35), + alignment: Alignment.centerLeft, + child: SelectableText( + memo, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 14), + ), + ), + ), + ], + ), + ); + } + + Widget _buildSimpleData( + BuildContext context, { + required String title, + required String value, + bool hasBackground = false, + bool isCopied = false, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: 10.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + flex: 4, + child: Text( + title, + style: + Theme.of(context).textTheme.bodyLarge?.copyWith(fontSize: 14), + ), + ), + Expanded( + flex: 6, + child: Align( + alignment: Alignment.centerLeft, + child: isCopied + ? ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 340), + child: CopiedText( + copiedValue: value, + isTruncated: true, + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 16, + ), + fontSize: 14, + ), + ) + : SelectableText( + value, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontSize: 14), + ), + ), + ), + ], + ), + ); + } + + String get _feeCoin { + return transaction.feeDetails.coin.isNotEmpty + ? transaction.feeDetails.coin + : transaction.coin; + } +} diff --git a/lib/views/wallet/coin_details/transactions/transaction_list.dart b/lib/views/wallet/coin_details/transactions/transaction_list.dart new file mode 100644 index 0000000000..902cdb153a --- /dev/null +++ b/lib/views/wallet/coin_details/transactions/transaction_list.dart @@ -0,0 +1,120 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; +import 'package:web_dex/views/wallet/coin_details/transactions/transaction_list_item.dart'; + +class TransactionList extends StatelessWidget { + const TransactionList({ + Key? key, + required this.transactions, + required this.isInProgress, + required this.setTransaction, + required this.coinAbbr, + }) : super(key: key); + final List transactions; + final String coinAbbr; + final bool isInProgress; + final void Function(Transaction tx) setTransaction; + + @override + Widget build(BuildContext context) { + return transactions.isEmpty + ? const _EmptyList() + : _List( + transactions: transactions, + isInProgress: isInProgress, + coinAbbr: coinAbbr, + setTransaction: setTransaction, + ); + } +} + +class _List extends StatelessWidget { + const _List({ + required this.transactions, + required this.isInProgress, + required this.setTransaction, + required this.coinAbbr, + }); + final List transactions; + final String coinAbbr; + final bool isInProgress; + final void Function(Transaction tx) setTransaction; + + @override + Widget build(BuildContext context) { + final hasTitle = transactions.isNotEmpty || !isMobile; + final indexOffset = hasTitle ? 1 : 0; + + return SliverList( + key: const Key('coin-details-transaction-list'), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + if (index == 0) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Text( + LocaleKeys.history.tr(), + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: isMobile ? 16 : 18, + ), + ), + ); + } + + final adjustedIndex = index - indexOffset; + + if (adjustedIndex + 1 == transactions.length && isInProgress) { + return const UiSpinnerList(height: 50); + } + + final Transaction tx = transactions[adjustedIndex]; + return TransactionListRow( + transaction: tx, + coinAbbr: coinAbbr, + setTransaction: setTransaction, + ); + }, + childCount: transactions.length + indexOffset, + ), + ); + } +} + +class _EmptyList extends StatelessWidget { + const _EmptyList(); + + @override + Widget build(BuildContext context) { + final double verticalPadding = isMobile ? 50 : 70; + return SliverToBoxAdapter( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: verticalPadding), + Text( + LocaleKeys.noTransactionsTitle.tr(), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: isMobile ? 14 : 18, + color: theme.custom.noTransactionsTextColor, + ), + ), + Text( + LocaleKeys.noTransactionsDescription.tr(), + style: Theme.of(context).textTheme.titleSmall?.copyWith( + fontSize: isMobile ? 12 : 14, + color: theme.custom.noTransactionsTextColor, + ), + ), + SizedBox(height: verticalPadding), + ], + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/transactions/transaction_list_item.dart b/lib/views/wallet/coin_details/transactions/transaction_list_item.dart new file mode 100644 index 0000000000..27af03f82b --- /dev/null +++ b/lib/views/wallet/coin_details/transactions/transaction_list_item.dart @@ -0,0 +1,276 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/ui/custom_tooltip.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +class TransactionListRow extends StatefulWidget { + const TransactionListRow({ + Key? key, + required this.transaction, + required this.setTransaction, + required this.coinAbbr, + }) : super(key: key); + + final Transaction transaction; + final String coinAbbr; + final void Function(Transaction tx) setTransaction; + + @override + State createState() => _TransactionListRowState(); +} + +class _TransactionListRowState extends State { + IconData get _icon { + return _isReceived ? Icons.arrow_circle_down : Icons.arrow_circle_up; + } + + bool get _isReceived => widget.transaction.isReceived; + + String get _sign { + return _isReceived ? '+' : '-'; + } + + bool _hasFocus = false; + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface, + borderRadius: BorderRadius.circular(4), + ), + child: InkWell( + onFocusChange: (value) { + setState(() { + _hasFocus = value; + }); + }, + hoverColor: Theme.of(context).primaryColor.withAlpha(20), + child: Container( + color: _hasFocus + ? Theme.of(context).colorScheme.tertiary + : Colors.transparent, + margin: const EdgeInsets.symmetric(vertical: 5), + padding: isMobile + ? const EdgeInsets.fromLTRB(0, 12, 0, 12) + : const EdgeInsets.fromLTRB(12, 12, 12, 12), + child: + isMobile ? _buildMobileRow(context) : _buildNormalRow(context)), + onTap: () => widget.setTransaction(widget.transaction), + ), + ); + } + + Widget _buildAmountChangesMobile(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + _buildUsdChanges(), + _buildBalanceMobile(), + ], + ); + } + + Widget _buildBalanceChanges() { + final String formatted = + formatDexAmt(double.parse(widget.transaction.myBalanceChange).abs()); + + return Row( + children: [ + Icon( + _icon, + size: 16, + color: _isReceived + ? theme.custom.increaseColor + : theme.custom.decreaseColor, + ), + const SizedBox(width: 4), + Text( + '${Coin.normalizeAbbr(widget.transaction.coin)} $formatted', + style: TextStyle( + color: _isReceived + ? theme.custom.increaseColor + : theme.custom.decreaseColor, + fontSize: 14, + fontWeight: FontWeight.w500), + ), + ], + ); + } + + Widget _buildBalanceChangesMobile(BuildContext context) { + return Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _isReceived ? LocaleKeys.receive.tr() : LocaleKeys.send.tr(), + style: TextStyle( + color: _isReceived + ? theme.custom.increaseColor + : theme.custom.decreaseColor, + fontSize: 14, + fontWeight: FontWeight.w500), + ), + Text( + widget.transaction.formattedTime, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ), + ], + ) + ], + ); + } + + Widget _buildBalanceMobile() { + final String formatted = + formatDexAmt(double.parse(widget.transaction.myBalanceChange).abs()); + + return Text( + '${Coin.normalizeAbbr(widget.transaction.coin)} $formatted', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ); + } + + Widget _buildMemoAndDate() { + return Align( + alignment: isMobile ? const Alignment(-1, 0) : const Alignment(1, 0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _buildMemo(), + const SizedBox(width: 6), + Text( + widget.transaction.formattedTime, + style: isMobile + ? TextStyle(color: Colors.grey[400]) + : const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + ], + ), + ); + } + + Widget _buildMobileRow(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 35, + height: 35, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiary, + borderRadius: BorderRadius.circular(20)), + child: Center( + child: Icon( + _isReceived ? Icons.arrow_downward : Icons.arrow_upward, + color: _isReceived + ? theme.custom.increaseColor + : theme.custom.decreaseColor, + size: 15, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + flex: 5, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildBalanceChangesMobile(context), + ], + ), + ), + Expanded( + flex: 5, + child: Align( + alignment: const Alignment(1, 0), + child: _buildAmountChangesMobile(context), + ), + ), + ], + ), + ); + } + + Widget _buildNormalRow(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + children: [ + const SizedBox(width: 4), + Expanded( + flex: 4, + child: Text( + _isReceived ? LocaleKeys.receive.tr() : LocaleKeys.send.tr(), + style: const TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + ), + )), + Expanded(flex: 4, child: _buildBalanceChanges()), + Expanded(flex: 4, child: _buildUsdChanges()), + Expanded(flex: 3, child: _buildMemoAndDate()), + ], + ); + } + + Widget _buildMemo() { + final String? memo = widget.transaction.memo; + if (memo == null || memo.isEmpty) return const SizedBox(); + + return CustomTooltip( + maxWidth: 200, + tooltip: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${LocaleKeys.memo.tr()}:', + style: theme.currentGlobal.textTheme.bodyLarge, + ), + const SizedBox(height: 6), + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 120), + child: SingleChildScrollView( + controller: ScrollController(), + child: Text( + memo, + style: const TextStyle(fontSize: 14), + ), + ), + ), + ], + ), + child: Icon( + Icons.note, + size: 14, + color: theme.currentGlobal.colorScheme.onSurface, + )); + } + + Widget _buildUsdChanges() { + final double? usdChanges = coinsBloc.getUsdPriceByAmount( + widget.transaction.myBalanceChange, + widget.coinAbbr, + ); + return Text( + '$_sign \$${formatAmt((usdChanges ?? 0).abs())}', + style: TextStyle( + color: _isReceived + ? theme.custom.increaseColor + : theme.custom.decreaseColor, + fontSize: 14, + fontWeight: FontWeight.w500), + ); + } +} diff --git a/lib/views/wallet/coin_details/transactions/transaction_table.dart b/lib/views/wallet/coin_details/transactions/transaction_table.dart new file mode 100644 index 0000000000..d2b536e82d --- /dev/null +++ b/lib/views/wallet/coin_details/transactions/transaction_table.dart @@ -0,0 +1,195 @@ +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:web_dex/bloc/transaction_history/transaction_history_bloc.dart'; +import 'package:web_dex/bloc/transaction_history/transaction_history_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/launch_native_explorer_button.dart'; +import 'package:web_dex/views/wallet/coin_details/transactions/transaction_details.dart'; +import 'package:web_dex/views/wallet/coin_details/transactions/transaction_list.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class TransactionTable extends StatelessWidget { + const TransactionTable({ + Key? key, + required this.coin, + required this.setTransaction, + this.selectedTransaction, + }) : super(key: key); + + final Coin coin; + final Transaction? selectedTransaction; + final Function(Transaction?) setTransaction; + + @override + Widget build(BuildContext context) { + if (coin.isSuspended) { + return SliverToBoxAdapter( + child: _ErrorMessage( + text: LocaleKeys.txHistoryNoTransactions.tr(), + textColor: theme.currentGlobal.textTheme.bodyLarge?.color, + ), + ); + } + + final isTxHistorySupported = hasTxHistorySupport(coin); + if (!isTxHistorySupported) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0), + child: _IguanaCoinWithoutTxHistorySupport(coin: coin), + ), + ); + } + + final Transaction? selectedTx = selectedTransaction; + + if (selectedTx == null) { + return _buildTransactionList(context); + } + + return _buildTransactionDetails(selectedTx); + } + + Widget _buildTransactionDetails(Transaction tx) { + return SliverToBoxAdapter( + child: TransactionDetails( + transaction: tx, + coin: coin, + onClose: () => setTransaction(null), + ), + ); + } + + Widget _buildTransactionList(BuildContext context) { + return BlocBuilder( + builder: (BuildContext ctx, TransactionHistoryState state) { + if (coin.isActivating || state is TransactionHistoryInitialState) { + return const SliverToBoxAdapter( + child: UiSpinnerList(), + ); + } + + if (state is TransactionHistoryFailureState) { + return SliverToBoxAdapter( + child: _ErrorMessage( + text: state.error.message, + textColor: theme.currentGlobal.colorScheme.error, + ), + ); + } + + if (state is TransactionHistoryInProgressState) { + return _TransactionsListWrapper( + coinAbbr: coin.abbr, + setTransaction: setTransaction, + transactions: state.transactions, + isInProgress: true, + ); + } + + if (state is TransactionHistoryLoadedState) { + return _TransactionsListWrapper( + coinAbbr: coin.abbr, + setTransaction: setTransaction, + transactions: state.transactions, + isInProgress: false, + ); + } + return const SliverToBoxAdapter( + child: SizedBox(), + ); + }, + ); + } +} + +class _TransactionsListWrapper extends StatelessWidget { + const _TransactionsListWrapper({ + required this.coinAbbr, + required this.transactions, + required this.setTransaction, + required this.isInProgress, + }); + + final String coinAbbr; + final List transactions; + final bool isInProgress; + final void Function(Transaction tx) setTransaction; + + @override + Widget build(BuildContext context) { + return TransactionList( + coinAbbr: coinAbbr, + transactions: transactions, + isInProgress: isInProgress, + setTransaction: setTransaction, + ); + } +} + +class _ErrorMessage extends StatelessWidget { + const _ErrorMessage({Key? key, required this.text, this.textColor}) + : super(key: key); + final String text; + final Color? textColor; + + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return DexScrollbar( + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 185), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: theme.currentGlobal.colorScheme.onSurface, + ), + padding: const EdgeInsets.symmetric(vertical: 20), + margin: const EdgeInsets.fromLTRB(0, 30, 0, 20), + child: Center( + child: SelectableText( + text, + style: TextStyle( + color: textColor, + fontSize: 13, + ), + ), + ), + ), + ), + ), + ); + } +} + +class _IguanaCoinWithoutTxHistorySupport extends StatelessWidget { + 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, + ), + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: LaunchNativeExplorerButton(coin: coin), + ), + ], + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/pages/complete_page.dart b/lib/views/wallet/coin_details/withdraw_form/pages/complete_page.dart new file mode 100644 index 0000000000..7396e91792 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/pages/complete_page.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form_footer.dart'; + +class CompletePage extends StatelessWidget { + const CompletePage(); + + @override + Widget build(BuildContext context) { + return const Column( + children: [ + DexSvgImage(path: Assets.assetTick), + SizedBox(height: 20), + SendCompleteForm(), + SizedBox(height: 20), + SendCompleteFormFooter(), + SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/pages/confirm_page.dart b/lib/views/wallet/coin_details/withdraw_form/pages/confirm_page.dart new file mode 100644 index 0000000000..337fef9322 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/pages/confirm_page.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_footer.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart'; + +class ConfirmPage extends StatelessWidget { + const ConfirmPage(); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: isMobile + ? const EdgeInsets.only(bottom: 12) + : const EdgeInsets.only(top: 24, bottom: 22), + child: const SendConfirmForm(), + ), + const SendConfirmFooter(), + if (isMobile) const SizedBox(height: 20), + ], + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart b/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart new file mode 100644 index 0000000000..18463ab64a --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/pages/failed_page.dart @@ -0,0 +1,186 @@ +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:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; + +class FailedPage extends StatelessWidget { + const FailedPage(); + + @override + Widget build(BuildContext context) { + final maxWidth = isMobile ? double.infinity : withdrawWidth; + + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: const Column( + children: [ + DexSvgImage(path: Assets.assetsDenied), + SizedBox(height: 20), + _SendErrorText(), + SizedBox(height: 20), + _SendErrorHeader(), + SizedBox(height: 15), + _SendErrorBody(), + SizedBox(height: 20), + _CloseButton(), + ], + ), + ); + } +} + +class _SendErrorText extends StatelessWidget { + const _SendErrorText(); + + @override + Widget build(BuildContext context) { + return Text( + LocaleKeys.tryAgain.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 14, + color: Theme.of(context).colorScheme.error, + ), + ); + } +} + +class _SendErrorHeader extends StatelessWidget { + const _SendErrorHeader(); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Text(LocaleKeys.errorDescription.tr(), + style: Theme.of(context).textTheme.bodyMedium), + ], + ); + } +} + +class _SendErrorBody extends StatelessWidget { + const _SendErrorBody(); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) => state.sendError.message, + builder: (BuildContext context, String errorText) { + final iconColor = + Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(.7); + + return Material( + color: theme.custom.buttonColorDefault, + borderRadius: BorderRadius.circular(18), + child: InkWell( + onTap: () => copyToClipBoard(context, errorText), + borderRadius: BorderRadius.circular(18), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 70, maxWidth: 300), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded(child: _MultilineText(errorText)), + const SizedBox(width: 16), + Icon( + Icons.copy_rounded, + color: iconColor, + size: 22, + ), + ], + ), + ), + ), + ), + ); + }, + ); + } +} + +class _MultilineText extends StatelessWidget { + const _MultilineText(this.text); + + final String text; + + @override + Widget build(BuildContext context) { + return Text( + text, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.left, + style: Theme.of(context).textTheme.bodyMedium, + softWrap: true, + maxLines: 3, + ); + } +} + +class _CloseButton extends StatelessWidget { + const _CloseButton(); + + @override + Widget build(BuildContext context) { + final height = isMobile ? 52.0 : 40.0; + return UiPrimaryButton( + height: height, + onPressed: () => + context.read().add(const WithdrawFormReset()), + text: LocaleKeys.close.tr(), + ); + } +} + +// class _PageContent extends StatelessWidget { +// const _PageContent(); +// +// @override +// Widget build(BuildContext context) { +// if (isMobile) return const _MobileContent(); +// return const _DesktopContent(); +// } +// } +// +// class _DesktopContent extends StatelessWidget { +// const _DesktopContent(); +// +// @override +// Widget build(BuildContext context) { +// return Column( +// children: [ +// const SizedBox(height: 20), +// assets.denied, +// const SizedBox(height: 19), +// const _Content(), +// ], +// ); +// } +// } +// +// class _MobileContent extends StatelessWidget { +// const _MobileContent(); +// +// @override +// Widget build(BuildContext context) { +// return Column( +// children: [ +// const SizedBox(height: 22), +// assets.denied, +// const SizedBox(height: 19), +// const _Content(), +// ], +// ); +// } +// } diff --git a/lib/views/wallet/coin_details/withdraw_form/pages/fill_form_page.dart b/lib/views/wallet/coin_details/withdraw_form/pages/fill_form_page.dart new file mode 100644 index 0000000000..b979076a5b --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/pages/fill_form_page.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/fill_form_custom_fee.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_amount.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_recipient_address.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_trezor_sender_address.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_error.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_footer.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_title.dart'; + +class FillFormPage extends StatelessWidget { + const FillFormPage(); + + @override + Widget build(BuildContext context) { + final double maxWidth = isMobile ? double.infinity : withdrawWidth; + final state = context.watch().state; + return ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: + isMobile ? MainAxisAlignment.spaceBetween : MainAxisAlignment.start, + children: [ + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FillFormTitle(state.coin.abbr), + const SizedBox(height: 28), + if (state.coin.enabledType == WalletType.trezor) + Padding( + padding: const EdgeInsets.only(bottom: 20.0), + child: FillFormTrezorSenderAddress( + coin: state.coin, + addresses: state.senderAddresses, + selectedAddress: state.selectedSenderAddress, + ), + ), + FillFormRecipientAddress(), + const SizedBox(height: 20), + FillFormAmount(), + if (state.coin.isTxMemoSupported) + const Padding( + padding: EdgeInsets.only(top: 20), + child: FillFormMemo(), + ), + if (state.coin.isCustomFeeSupported) + Padding( + padding: const EdgeInsets.only(top: 9.0), + child: FillFormCustomFee(), + ), + const SizedBox(height: 10), + const FillFormError(), + ], + ), + const SizedBox(height: 10), + FillFormFooter(), + ], + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/buttons/convert_address_button.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/buttons/convert_address_button.dart new file mode 100644 index 0000000000..8025a5cf3b --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/buttons/convert_address_button.dart @@ -0,0 +1,28 @@ +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:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; + +class ConvertAddressButton extends StatelessWidget { + const ConvertAddressButton(); + + @override + Widget build(BuildContext context) { + return UiPrimaryButton( + text: LocaleKeys.convert.tr(), + width: 80, + height: 30, + textStyle: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 12, + color: theme.custom.defaultGradientButtonTextColor, + ), + onPressed: () => context + .read() + .add(const WithdrawFormConvertAddress()), + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/buttons/sell_max_button.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/buttons/sell_max_button.dart new file mode 100644 index 0000000000..a724bae060 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/buttons/sell_max_button.dart @@ -0,0 +1,50 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class SellMaxButton extends StatefulWidget { + const SellMaxButton(); + + @override + State createState() => _SellMaxButtonState(); +} + +class _SellMaxButtonState extends State { + bool _hasFocus = false; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + final fontWeight = _hasFocus ? FontWeight.w900 : FontWeight.w500; + final color = + state.isMaxAmount ? Theme.of(context).colorScheme.primary : null; + return InkWell( + onFocusChange: (value) => setState(() { + _hasFocus = value; + }), + onTap: () => context + .read() + .add(WithdrawFormMaxTapped(isEnabled: !state.isMaxAmount)), + borderRadius: BorderRadius.circular(7), + child: Container( + width: 46, + height: 23, + margin: const EdgeInsets.only(top: 10, bottom: 10, right: 10), + padding: const EdgeInsets.only(left: 10, top: 2, right: 10), + child: Text( + LocaleKeys.max.tr().toLowerCase(), + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + fontSize: 12, + fontWeight: fontWeight, + color: color, + ), + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/custom_fee_field_evm.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/custom_fee_field_evm.dart new file mode 100644 index 0000000000..88e8cfd506 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/custom_fee_field_evm.dart @@ -0,0 +1,128 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/ui/custom_numeric_text_form_field.dart'; + +class CustomFeeFieldEVM extends StatefulWidget { + @override + State createState() => _CustomFeeFieldEVMState(); +} + +class _CustomFeeFieldEVMState extends State { + final TextEditingController _gasLimitController = TextEditingController(); + final TextEditingController _gasPriceController = TextEditingController(); + TextSelection _gasLimitSelection = const TextSelection.collapsed(offset: 0); + TextSelection _gasPriceSelection = const TextSelection.collapsed(offset: 0); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: _buildGasLimitField(), + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: _buildGasPriceField(), + ), + ], + ); + } + + Widget _buildGasLimitField() { + return BlocSelector( + selector: (state) { + return state.gasLimitError.message; + }, + builder: (context, error) { + return BlocSelector( + selector: (state) { + return state.customFee.gas?.toString() ?? ''; + }, + builder: (context, gasLimit) { + _gasLimitController + ..text = gasLimit + ..selection = _gasLimitSelection; + return CustomNumericTextFormField( + controller: _gasLimitController, + validationMode: InputValidationMode.aggressive, + validator: (_) { + if (error.isEmpty) return null; + return error; + }, + onChanged: (_) { + _change(); + }, + filteringRegExp: r'^(|[1-9]\d*)$', + style: _style, + hintText: LocaleKeys.gasLimit.tr(), + hintTextStyle: _hintTextStyle, + ); + }, + ); + }, + ); + } + + Widget _buildGasPriceField() { + return BlocSelector( + selector: (state) { + return state.gasLimitError.message; + }, builder: (context, error) { + return BlocSelector( + selector: (state) { + return state.customFee.gasPrice ?? ''; + }, + builder: (context, gasPrice) { + final String price = gasPrice; + + _gasPriceController + ..text = price + ..selection = _gasPriceSelection; + return CustomNumericTextFormField( + controller: _gasPriceController, + validationMode: InputValidationMode.aggressive, + validator: (_) { + if (error.isEmpty) return null; + return error; + }, + onChanged: (_) { + _change(); + }, + filteringRegExp: numberRegExp.pattern, + style: _style, + hintText: LocaleKeys.gasPriceGwei.tr(), + hintTextStyle: _hintTextStyle, + ); + }, + ); + }); + } + + void _change() { + setState(() { + _gasLimitSelection = _gasLimitController.selection; + _gasPriceSelection = _gasPriceController.selection; + }); + context.read().add( + WithdrawFormCustomEvmFeeChanged( + gas: double.tryParse(_gasLimitController.text)?.toInt(), + gasPrice: _gasPriceController.text, + ), + ); + } +} + +const _style = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, +); +const _hintTextStyle = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, +); diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/custom_fee_field_utxo.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/custom_fee_field_utxo.dart new file mode 100644 index 0000000000..b53c5b11e1 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/custom_fee_field_utxo.dart @@ -0,0 +1,73 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/ui/custom_numeric_text_form_field.dart'; + +class CustomFeeFieldUtxo extends StatefulWidget { + @override + State createState() => _CustomFeeFieldUtxoState(); +} + +class _CustomFeeFieldUtxoState extends State { + final TextEditingController _feeController = TextEditingController(); + TextSelection _previousTextSelection = + const TextSelection.collapsed(offset: 0); + @override + Widget build(BuildContext context) { + final style = TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + color: Theme.of(context).textTheme.bodyMedium?.color); + + return BlocSelector( + selector: (state) { + return state.utxoCustomFeeError; + }, + builder: (context, customFeeError) { + return BlocSelector( + selector: (state) { + return state.customFee.amount; + }, + builder: (context, feeAmount) { + final amount = feeAmount ?? ''; + _feeController + ..text = amount + ..selection = _previousTextSelection; + + return CustomNumericTextFormField( + controller: _feeController, + validationMode: InputValidationMode.aggressive, + validator: (_) { + if (customFeeError.message.isEmpty) return null; + return customFeeError.message; + }, + onChanged: (String? value) { + setState(() { + _previousTextSelection = _feeController.selection; + }); + context + .read() + .add(WithdrawFormCustomFeeChanged(amount: value ?? '')); + }, + filteringRegExp: numberRegExp.pattern, + style: style, + hintText: LocaleKeys.customFeeCoin.tr(args: [ + Coin.normalizeAbbr( + context.read().state.coin.abbr, + ) + ]), + hintTextStyle: + const TextStyle(fontSize: 12, fontWeight: FontWeight.w500), + ); + }, + ); + }, + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/fill_form_custom_fee.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/fill_form_custom_fee.dart new file mode 100644 index 0000000000..07c7c35476 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/fill_form_custom_fee.dart @@ -0,0 +1,176 @@ +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:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/app_assets.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/fee_type.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/custom_fee_field_evm.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/custom_fee/custom_fee_field_utxo.dart'; + +class FillFormCustomFee extends StatefulWidget { + @override + State createState() => _FillFormCustomFeeState(); +} + +class _FillFormCustomFeeState extends State { + bool _isOpen = false; + + @override + void initState() { + _isOpen = context.read().state.isCustomFeeEnabled; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return InkWell( + radius: 18, + onTap: () { + final bool newOpenState = !_isOpen; + context.read().add(newOpenState + ? const WithdrawFormCustomFeeEnabled() + : const WithdrawFormCustomFeeDisabled()); + setState(() { + _isOpen = newOpenState; + }); + }, + child: Container( + width: double.infinity, + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(18)), + color: Colors.transparent, + ), + child: _isOpen ? _Expanded() : _Collapsed()), + ); + } +} + +class _Collapsed extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container( + width: double.infinity, + height: 25, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18)), + border: + Border.all(color: theme.custom.specificButtonBorderColor)), + child: const Padding( + padding: EdgeInsets.only(left: 13, right: 13), + child: _Header( + DexSvgImage(path: Assets.chevronDown), + ), + ), + ), + ], + ); + } +} + +class _Expanded extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Container( + width: double.infinity, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18)), + border: + Border.all(color: theme.custom.specificButtonBorderColor)), + child: Padding( + padding: const EdgeInsets.only(left: 13, right: 13), + child: Column( + children: [ + const _Header(DexSvgImage(path: Assets.chevronDown)), + const SizedBox(height: 4), + const _Line(), + const SizedBox(height: 12), + const _Warning(), + const SizedBox(height: 9), + _FeeAmount(), + const SizedBox(height: 15), + ], + ), + ), + ), + ], + ); + } +} + +class _Header extends StatelessWidget { + const _Header(this.chevron); + + final Widget chevron; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + LocaleKeys.customFeeOptional.tr(), + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + color: Theme.of(context).inputDecorationTheme.labelStyle?.color, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 9), + child: chevron, + ), + ], + ); + } +} + +class _Line extends StatelessWidget { + const _Line(); + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + height: 1, + color: const Color.fromARGB(0, 255, 0, 0), + ); + } +} + +class _Warning extends StatelessWidget { + const _Warning(); + + @override + Widget build(BuildContext context) { + return Text( + LocaleKeys.customFeesWarning.tr(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Theme.of(context).inputDecorationTheme.labelStyle?.color, + ), + ); + } +} + +class _FeeAmount extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (ctx, state) { + final isUtxo = state.customFee.type == feeType.utxoFixed; + + return isUtxo ? CustomFeeFieldUtxo() : CustomFeeFieldEVM(); + }); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_amount.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_amount.dart new file mode 100644 index 0000000000..f1f6d8f5ab --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_amount.dart @@ -0,0 +1,63 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/ui/custom_numeric_text_form_field.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/buttons/sell_max_button.dart'; + +class FillFormAmount extends StatefulWidget { + @override + State createState() => _FillFormAmountState(); +} + +class _FillFormAmountState extends State { + final TextEditingController _amountController = TextEditingController(); + TextSelection _previousTextSelection = + const TextSelection.collapsed(offset: 0); + @override + void initState() { + _amountController.text = context.read().state.amount; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocConsumer( + listenWhen: (prev, cur) => prev.amount != cur.amount, + listener: (context, state) { + _amountController + ..text = state.amount + ..selection = _previousTextSelection; + }, + buildWhen: (prev, cur) => prev.amountError != cur.amountError, + builder: (context, state) { + return CustomNumericTextFormField( + key: const Key('enter-form-amount-input'), + controller: _amountController, + filteringRegExp: numberRegExp.pattern, + hintText: LocaleKeys.amountToSend.tr(), + hintTextStyle: + const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + suffixIcon: const SellMaxButton(), + onChanged: (String? amount) { + setState(() { + _previousTextSelection = _amountController.selection; + }); + context + .read() + .add(WithdrawFormAmountChanged(amount: amount ?? '')); + }, + validationMode: InputValidationMode.aggressive, + validator: (_) { + final String amountError = state.amountError.message; + if (amountError.isEmpty) return null; + return amountError; + }, + ); + }, + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart new file mode 100644 index 0000000000..c21e4e6a95 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_memo.dart @@ -0,0 +1,31 @@ +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/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class FillFormMemo extends StatelessWidget { + const FillFormMemo({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return UiTextFormField( + key: const Key('withdraw-form-memo-field'), + autocorrect: false, + textInputAction: TextInputAction.next, + enableInteractiveSelection: true, + onChanged: (String? memo) { + context + .read() + .add(WithdrawFormMemoUpdated(text: memo)); + }, + inputFormatters: [LengthLimitingTextInputFormatter(256)], + maxLength: 256, + counterText: '', + hintText: LocaleKeys.memoOptional.tr(), + hintTextStyle: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_recipient_address.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_recipient_address.dart new file mode 100644 index 0000000000..bbd3bcec6f --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_recipient_address.dart @@ -0,0 +1,129 @@ +import 'dart:io'; + +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/qr_scanner.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/buttons/convert_address_button.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class FillFormRecipientAddress extends StatefulWidget { + @override + State createState() => + _FillFormRecipientAddressState(); +} + +class _FillFormRecipientAddressState extends State { + final TextEditingController _addressController = TextEditingController(); + TextSelection _previousTextSelection = + const TextSelection.collapsed(offset: 0); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) { + return state.addressError; + }, + builder: (context, addressError) { + return BlocSelector( + selector: (state) { + return state.address; + }, + builder: (context, address) { + _addressController + ..text = address + ..selection = _previousTextSelection; + return Column( + children: [ + UiTextFormField( + key: const Key('withdraw-recipient-address-input'), + controller: _addressController, + autocorrect: false, + textInputAction: TextInputAction.next, + enableInteractiveSelection: true, + onChanged: (String? address) { + setState(() { + _previousTextSelection = _addressController.selection; + }); + context.read().add( + WithdrawFormAddressChanged(address: address ?? '')); + }, + validator: (String? value) { + if (addressError.message.isEmpty) return null; + if (addressError is MixedCaseAddressError) { + return null; + } + return addressError.message; + }, + validationMode: InputValidationMode.aggressive, + inputFormatters: [LengthLimitingTextInputFormatter(256)], + hintText: LocaleKeys.recipientAddress.tr(), + hintTextStyle: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w500), + suffixIcon: + (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) + ? IconButton( + icon: const Icon(Icons.qr_code_scanner), + onPressed: () async { + final address = await Navigator.push( + context, + MaterialPageRoute( + builder: (context) => const QrScanner()), + ); + + if (context.mounted) { + context.read().add( + WithdrawFormAddressChanged( + address: address ?? '')); + } + }, + ) + : null, + ), + if (addressError is MixedCaseAddressError) + _ErrorAddressRow( + error: addressError, + ), + ], + ); + }, + ); + }, + ); + } +} + +class _ErrorAddressRow extends StatelessWidget { + const _ErrorAddressRow({required this.error}); + final MixedCaseAddressError error; + + @override + Widget build(BuildContext context) { + return Container( + constraints: BoxConstraints(maxWidth: theme.custom.dexFormWidth), + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Flexible( + child: SelectableText( + error.message, + style: const TextStyle(fontSize: 12), + ), + ), + const Padding( + padding: EdgeInsets.only(left: 6.0), + child: ConvertAddressButton(), + ) + ], + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_trezor_sender_address.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_trezor_sender_address.dart new file mode 100644 index 0000000000..fd980bdea6 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fields/fill_form_trezor_sender_address.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/hd_account/hd_account.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/address_select.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; + +class FillFormTrezorSenderAddress extends StatelessWidget { + const FillFormTrezorSenderAddress({ + required this.coin, + required this.addresses, + required this.selectedAddress, + }); + + final Coin coin; + final List addresses; + final String selectedAddress; + + @override + Widget build(BuildContext context) { + return AddressSelect( + coin: coin, + addresses: addresses, + selectedAddress: selectedAddress, + onChanged: (String address) { + context + .read() + .add(WithdrawFormSenderAddressChanged(address: address)); + }, + maxWidth: withdrawWidth, + maxHeight: 300, + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_error.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_error.dart new file mode 100644 index 0000000000..66a932cc24 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_error.dart @@ -0,0 +1,48 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/shared/widgets/copied_text.dart'; +import 'package:web_dex/shared/widgets/details_dropdown.dart'; + +class FillFormError extends StatelessWidget { + const FillFormError(); + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (ctx, state) { + if (!state.hasSendError) { + return const SizedBox(); + } + final BaseError sendError = state.sendError; + return Column( + children: [ + SizedBox( + width: double.infinity, + child: SelectableText( + sendError.message, + textAlign: TextAlign.left, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ), + if (sendError is ErrorWithDetails) + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: DetailsDropdown( + summary: LocaleKeys.showMore.tr(), + content: SingleChildScrollView( + controller: ScrollController(), + child: CopiedText( + copiedValue: (sendError as ErrorWithDetails).details), + ), + ), + ) + ], + ); + }); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_footer.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_footer.dart new file mode 100644 index 0000000000..367aeee033 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_footer.dart @@ -0,0 +1,36 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/fill_form/send_form_preloader.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class FillFormFooter extends StatelessWidget { + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, WithdrawFormState state) { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: withdrawWidth), + child: state.isSending + ? FillFormPreloader(state.trezorProgressStatus) + : UiBorderButton( + key: const Key('send-enter-button'), + backgroundColor: Theme.of(context).colorScheme.surface, + width: isMobile ? double.infinity : withdrawWidth, + height: isMobile ? 52 : 40, + onPressed: () { + context + .read() + .add(const WithdrawFormSubmitted()); + }, + text: LocaleKeys.send.tr(), + ), + ); + }, + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_title.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_title.dart new file mode 100644 index 0000000000..08f810bc0f --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/fill_form_title.dart @@ -0,0 +1,40 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; + +class FillFormTitle extends StatelessWidget { + const FillFormTitle(this.coinAbbr); + + final String coinAbbr; + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.center, + child: SelectableText.rich( + TextSpan( + text: '${LocaleKeys.youSend.tr()} ', + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + color: Theme.of(context) + .textTheme + .bodyMedium + ?.color + ?.withOpacity(.4), + ), + children: [ + TextSpan( + text: Coin.normalizeAbbr(coinAbbr), + style: TextStyle( + fontWeight: FontWeight.w700, + fontSize: 14, + color: Theme.of(context).textTheme.bodyMedium?.color, + ), + ), + ]), + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/send_form_preloader.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/send_form_preloader.dart new file mode 100644 index 0000000000..3de62b5b1f --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/fill_form/send_form_preloader.dart @@ -0,0 +1,30 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class FillFormPreloader extends StatelessWidget { + const FillFormPreloader([this.message]); + + final String? message; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const UiSpinner(), + if (message != null) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + message!, + style: theme.currentGlobal.textTheme.bodySmall, + ), + ), + ], + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart new file mode 100644 index 0000000000..563de41da5 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form.dart @@ -0,0 +1,166 @@ +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:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_item.dart'; + +class SendCompleteForm extends StatelessWidget { + const SendCompleteForm(); + + @override + Widget build(BuildContext context) { + final decoration = BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: theme.custom.buttonColorDefault, + ); + + return BlocBuilder( + builder: (context, WithdrawFormState state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isMobile ? double.infinity : withdrawWidth, + padding: const EdgeInsets.all(26), + decoration: decoration, + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SendConfirmItem( + title: LocaleKeys.recipientAddress.tr(), + value: state.withdrawDetails.toAddress, + centerAlign: true, + ), + const SizedBox(height: 7), + Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const SizedBox(height: 10), + SelectableText( + '-${state.amount} ${Coin.normalizeAbbr(state.coin.abbr)}', + style: TextStyle( + fontSize: 25, + fontWeight: FontWeight.w700, + color: theme.custom.headerFloatBoxColor), + ), + const SizedBox(height: 5), + SelectableText( + '\$${state.usdAmountPrice ?? 0}', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: theme.custom.headerFloatBoxColor), + ), + ], + ), + if (state.hasSendError) + _SendCompleteError(error: state.sendError), + ], + ), + ), + _TransactionHash( + feeValue: state.withdrawDetails.feeValue, + feeCoin: state.withdrawDetails.feeCoin, + txHash: state.withdrawDetails.txHash, + usdFeePrice: state.usdFeePrice, + isFeePriceExpensive: state.isFeePriceExpensive, + ), + ], + ); + }, + ); + } +} + +class _SendCompleteError extends StatelessWidget { + const _SendCompleteError({required this.error}); + + final BaseError error; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(0, 20, 0, 10), + width: double.infinity, + child: Text( + error.message, + textAlign: TextAlign.left, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ); + } +} + +class _TransactionHash extends StatelessWidget { + const _TransactionHash({ + required this.feeValue, + required this.txHash, + required this.feeCoin, + required this.usdFeePrice, + required this.isFeePriceExpensive, + }); + final String txHash; + final String feeValue; + final String feeCoin; + final double? usdFeePrice; + final bool isFeePriceExpensive; + + @override + Widget build(BuildContext context) { + return Container( + width: isMobile ? double.infinity : withdrawWidth, + padding: const EdgeInsets.only(top: 12), + child: Column( + children: [ + SendConfirmItem( + title: '${LocaleKeys.fee.tr()}:', + value: + '${truncateDecimal(feeValue, decimalRange)} ${Coin.normalizeAbbr(feeCoin)}', + usdPrice: usdFeePrice ?? 0, + isWarningShown: isFeePriceExpensive, + ), + const SizedBox(height: 21), + const _BuildMemo(), + SendConfirmItem( + title: '${LocaleKeys.transactionHash.tr()}:', + value: txHash, + isCopied: true, + isCopiedValueTruncated: true, + ), + ], + ), + ); + } +} + +class _BuildMemo extends StatelessWidget { + const _BuildMemo({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return BlocSelector( + selector: (state) { + return state.memo; + }, builder: (context, memo) { + if (memo == null || memo.isEmpty) return const SizedBox.shrink(); + + return Padding( + padding: const EdgeInsets.only(bottom: 21), + child: SendConfirmItem( + title: '${LocaleKeys.memo.tr()}:', + value: memo, + ), + ); + }); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form_buttons.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form_buttons.dart new file mode 100644 index 0000000000..40cfa81f11 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form_buttons.dart @@ -0,0 +1,105 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/bitrefill/bloc/bitrefill_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/app_button.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; + +class SendCompleteFormButtons extends StatelessWidget { + const SendCompleteFormButtons({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + if (isMobile) return const _MobileButtons(); + return const _DesktopButtons(); + } +} + +class _MobileButtons extends StatelessWidget { + const _MobileButtons(); + + @override + Widget build(BuildContext context) { + const height = 52.0; + final WithdrawFormBloc withdrawFormBloc = context.read(); + final WithdrawFormState state = withdrawFormBloc.state; + + return Row(children: [ + Expanded( + child: AppDefaultButton( + key: const Key('send-complete-view-on-explorer'), + height: height + 6, + padding: const EdgeInsets.symmetric(vertical: 0), + onPressed: () => viewHashOnExplorer( + state.coin, + state.withdrawDetails.txHash, + HashExplorerType.tx, + ), + text: LocaleKeys.viewOnExplorer.tr(), + ), + ), + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: UiPrimaryButton( + key: const Key('send-complete-done'), + height: height, + onPressed: () => withdrawFormBloc.add(const WithdrawFormReset()), + text: LocaleKeys.done.tr(), + ), + ), + ), + ]); + } +} + +class _DesktopButtons extends StatelessWidget { + const _DesktopButtons(); + + @override + Widget build(BuildContext context) { + const height = 40.0; + const space = 16.0; + final WithdrawFormBloc withdrawFormBloc = context.read(); + final WithdrawFormState state = withdrawFormBloc.state; + const width = (withdrawWidth - space) / 2; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AppDefaultButton( + key: const Key('send-complete-view-on-explorer'), + width: width, + height: height + 6, + padding: const EdgeInsets.symmetric(vertical: 0), + onPressed: () => viewHashOnExplorer( + state.coin, state.withdrawDetails.txHash, HashExplorerType.tx), + text: LocaleKeys.viewOnExplorer.tr(), + ), + Padding( + padding: const EdgeInsets.only(left: space), + child: UiPrimaryButton( + key: const Key('send-complete-done'), + width: width, + height: height, + onPressed: () => _sendCompleteDone(context), + text: LocaleKeys.done.tr(), + ), + ) + ], + ); + } + + void _sendCompleteDone(BuildContext context) { + context.read().add(const WithdrawFormReset()); + if (isBitrefillIntegrationEnabled) { + context.read().add(const BitrefillPaymentCompleted()); + } + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form_footer.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form_footer.dart new file mode 100644 index 0000000000..06962d0687 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form_footer.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/send_complete_form/send_complete_form_buttons.dart'; + +class SendCompleteFormFooter extends StatelessWidget { + const SendCompleteFormFooter(); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: isMobile ? double.infinity : withdrawWidth, + child: const SendCompleteFormButtons( + key: Key('complete-buttons'), + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_buttons.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_buttons.dart new file mode 100644 index 0000000000..e219e1e065 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_buttons.dart @@ -0,0 +1,102 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/ui/app_button.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; + +class SendConfirmButtons extends StatelessWidget { + const SendConfirmButtons( + {required this.hasSendError, required this.onBackTap}); + final bool hasSendError; + final VoidCallback onBackTap; + @override + Widget build(BuildContext context) { + if (isMobile) { + return _MobileButtons( + hasError: hasSendError, + onBackTap: onBackTap, + ); + } + return _DesktopButtons(hasError: hasSendError, onBackTap: onBackTap); + } +} + +class _MobileButtons extends StatelessWidget { + const _MobileButtons({required this.hasError, required this.onBackTap}); + final bool hasError; + final VoidCallback onBackTap; + @override + Widget build(BuildContext context) { + const height = 52.0; + + return Row(children: [ + Expanded( + child: AppDefaultButton( + key: const Key('confirm-back-button'), + height: height + 6, + padding: const EdgeInsets.symmetric(vertical: 0), + onPressed: onBackTap, + text: LocaleKeys.back.tr(), + ), + ), + if (!hasError) + Expanded( + child: Padding( + padding: const EdgeInsets.only(left: 16.0), + child: UiPrimaryButton( + key: const Key('confirm-agree-button'), + height: height, + onPressed: () => context + .read() + .add(const WithdrawFormSendRawTx()), + text: LocaleKeys.confirm.tr(), + ), + ), + ), + ]); + } +} + +class _DesktopButtons extends StatelessWidget { + const _DesktopButtons({required this.hasError, required this.onBackTap}); + final bool hasError; + final VoidCallback onBackTap; + @override + Widget build(BuildContext context) { + const double height = 40.0; + const double space = 16.0; + + final width = hasError ? withdrawWidth : (withdrawWidth - space) / 2; + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + AppDefaultButton( + key: const Key('confirm-back-button'), + width: width, + height: height + 6, + padding: const EdgeInsets.symmetric(vertical: 0), + onPressed: onBackTap, + text: LocaleKeys.back.tr(), + ), + if (!hasError) + Padding( + padding: const EdgeInsets.only(left: space), + child: UiPrimaryButton( + key: const Key('confirm-agree-button'), + width: width, + height: height, + onPressed: () => context + .read() + .add(const WithdrawFormSendRawTx()), + text: LocaleKeys.confirm.tr(), + ), + ) + ], + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_footer.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_footer.dart new file mode 100644 index 0000000000..4a459d4541 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_footer.dart @@ -0,0 +1,34 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_buttons.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class SendConfirmFooter extends StatelessWidget { + const SendConfirmFooter(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, WithdrawFormState state) { + return SizedBox( + width: isMobile ? double.infinity : withdrawWidth, + child: state.isSending + ? const Padding( + padding: EdgeInsets.only(top: 10), + child: Center(child: UiSpinner()), + ) + : SendConfirmButtons( + hasSendError: state.hasSendError, + onBackTap: () => context.read().add( + const WithdrawFormStepReverted( + step: WithdrawFormStep.confirm), + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart new file mode 100644 index 0000000000..ae6b8304b2 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form.dart @@ -0,0 +1,73 @@ +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:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/constants.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/views/wallet/coin_details/constants.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form_error.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_item.dart'; + +class SendConfirmForm extends StatelessWidget { + const SendConfirmForm(); + + @override + Widget build(BuildContext context) { + final decoration = BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: theme.custom.buttonColorDefault, + ); + + return BlocBuilder( + builder: (context, WithdrawFormState state) { + final amountString = + '${truncateDecimal(state.amountToSendString, decimalRange)} ${Coin.normalizeAbbr(state.withdrawDetails.coin)}'; + final feeString = + '${truncateDecimal(state.withdrawDetails.feeValue, decimalRange)} ${Coin.normalizeAbbr(state.withdrawDetails.feeCoin)}'; + + return Container( + width: isMobile ? double.infinity : withdrawWidth, + padding: const EdgeInsets.all(26), + decoration: decoration, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SendConfirmItem( + title: '${LocaleKeys.recipientAddress.tr()}:', + value: state.withdrawDetails.toAddress, + centerAlign: false, + ), + const SizedBox(height: 26), + SendConfirmItem( + title: '${LocaleKeys.amount.tr()}:', + value: amountString, + usdPrice: state.usdAmountPrice ?? 0, + ), + const SizedBox(height: 26), + SendConfirmItem( + title: '${LocaleKeys.fee.tr()}:', + value: feeString, + usdPrice: state.usdFeePrice ?? 0, + isWarningShown: state.isFeePriceExpensive, + ), + if (state.memo != null) + Padding( + padding: const EdgeInsets.only(top: 26), + child: SendConfirmItem( + title: '${LocaleKeys.memo.tr()}:', + value: state.memo!, + isWarningShown: false, + ), + ), + const SendConfirmFormError(), + ], + ), + ); + }, + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form_error.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form_error.dart new file mode 100644 index 0000000000..dfd57bf00a --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_form_error.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/base.dart'; + +class SendConfirmFormError extends StatelessWidget { + const SendConfirmFormError(); + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (BuildContext context, WithdrawFormState state) { + final BaseError sendError = state.sendError; + + return Container( + padding: const EdgeInsets.fromLTRB(0, 10, 0, 10), + width: double.infinity, + child: Text( + sendError.message, + textAlign: TextAlign.left, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + ); + }); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_item.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_item.dart new file mode 100644 index 0000000000..95349ebe0f --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/send_confirm_form/send_confirm_item.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; +import 'package:web_dex/shared/widgets/copied_text.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class SendConfirmItem extends StatelessWidget { + const SendConfirmItem({ + Key? key, + required this.title, + required this.value, + this.url = '', + this.usdPrice, + this.isCopied = false, + this.isCopiedValueTruncated = false, + this.isWarningShown = false, + this.centerAlign = false, + }) : super(key: key); + + final String title; + final String value; + final String url; + final bool isWarningShown; + final bool isCopied; + final bool isCopiedValueTruncated; + final double? usdPrice; + final bool centerAlign; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: + centerAlign ? CrossAxisAlignment.center : CrossAxisAlignment.start, + children: [ + SizedBox( + width: double.infinity, + child: Text( + title, + textAlign: centerAlign ? TextAlign.center : TextAlign.start, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + fontSize: 14, + color: Theme.of(context) + .textTheme + .titleLarge + ?.color + ?.withOpacity(.6)), + ), + ), + const SizedBox(height: 8), + SizedBox( + width: double.infinity, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: centerAlign + ? MainAxisAlignment.center + : MainAxisAlignment.start, + crossAxisAlignment: centerAlign + ? CrossAxisAlignment.center + : CrossAxisAlignment.start, + children: [ + Flexible( + child: _ValueText( + value: value, + url: url, + isCopied: isCopied, + isCopiedValueTruncated: isCopiedValueTruncated, + centerAlign: centerAlign, + isWarningShown: isWarningShown, + )), + if (usdPrice != null) ...[ + const SizedBox(height: 10), + Flexible( + child: _USDPrice( + usdPrice: usdPrice, + isWarningShown: isWarningShown, + )), + ], + ], + ), + ), + ], + ); + } +} + +class _ValueText extends StatelessWidget { + const _ValueText({ + required this.value, + required this.url, + required this.isCopied, + required this.isCopiedValueTruncated, + required this.centerAlign, + required this.isWarningShown, + }); + final String value; + final String url; + final bool isCopied; + final bool isCopiedValueTruncated; + final bool centerAlign; + final bool isWarningShown; + + @override + Widget build(BuildContext context) { + if (url.isNotEmpty) { + return Hyperlink( + text: value, + onPressed: () async => await launchURL(url), + ); + } + if (isCopied) { + return SizedBox( + width: double.infinity, + child: CopiedText( + copiedValue: value, + isTruncated: isCopiedValueTruncated, + ), + ); + } + + return SelectableText( + value, + textAlign: centerAlign ? TextAlign.center : TextAlign.start, + style: TextStyle( + color: isWarningShown ? Colors.orange[300] : null, + fontSize: 14, + fontFamily: 'Manrope', + fontWeight: FontWeight.w500), + ); + } + + Future launchURL(String url) async { + final uri = Uri.parse(url); + + if (await canLaunchUrl(uri)) { + await launchUrl(uri); + } else { + throw Exception('Could not launch $url'); + } + } +} + +class _USDPrice extends StatelessWidget { + const _USDPrice({this.usdPrice, required this.isWarningShown}); + + final double? usdPrice; + final bool isWarningShown; + + @override + Widget build(BuildContext context) { + return SelectableText( + '\$${formatAmt(usdPrice ?? 0)}', + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 14, + color: isWarningShown ? Colors.orange[300] : null, + ), + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/widgets/withdraw_form_header.dart b/lib/views/wallet/coin_details/withdraw_form/widgets/withdraw_form_header.dart new file mode 100644 index 0000000000..4e15593f92 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/widgets/withdraw_form_header.dart @@ -0,0 +1,36 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/widgets/segwit_icon.dart'; +import 'package:web_dex/views/common/page_header/page_header.dart'; + +class WithdrawFormHeader extends StatelessWidget { + const WithdrawFormHeader({ + this.isIndicatorShown = true, + required this.coin, + }); + final bool isIndicatorShown; + final Coin coin; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, WithdrawFormState state) { + return PageHeader( + title: state.step.title, + widgetTitle: coin.mode == CoinMode.segwit + ? const Padding( + padding: EdgeInsets.only(left: 6.0), + child: SegwitIcon(height: 22), + ) + : null, + backText: LocaleKeys.backToWallet.tr(), + onBackButtonPressed: context.read().goBack, + ); + }, + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart new file mode 100644 index 0000000000..53c2aab3c3 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/withdraw_form.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/bitrefill/bloc/bitrefill_bloc.dart'; +import 'package:web_dex/bloc/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/bitrefill/bitrefill_transaction_completed_dialog.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/withdraw_form_index.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class WithdrawForm extends StatelessWidget { + const WithdrawForm({ + super.key, + required this.coin, + required this.onBackButtonPressed, + required this.onSuccess, + }); + final Coin coin; + final VoidCallback onBackButtonPressed; + final VoidCallback onSuccess; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (BuildContext context) => WithdrawFormBloc( + coin: coin, + coinsBloc: coinsBloc, + goBack: onBackButtonPressed, + ), + child: isBitrefillIntegrationEnabled + ? BlocConsumer( + listener: (BuildContext context, BitrefillState state) { + if (state is BitrefillPaymentSuccess) { + onSuccess(); + _showBitrefillPaymentSuccessDialog(context, state); + } + }, + builder: (BuildContext context, BitrefillState state) { + final BitrefillPaymentInProgress? paymentState = + state is BitrefillPaymentInProgress ? state : null; + + final String? paymentAddress = + paymentState?.paymentIntent.paymentAddress; + final String? paymentAmount = + paymentState?.paymentIntent.paymentAmount.toString(); + + return WithdrawFormIndex( + coin: coin, + address: paymentAddress, + amount: paymentAmount, + ); + }, + ) + : WithdrawFormIndex( + coin: coin, + ), + ); + } + + void _showBitrefillPaymentSuccessDialog( + BuildContext context, + BitrefillPaymentSuccess state, + ) { + showDialog( + context: context, + builder: (BuildContext context) { + return BitrefillTransactionCompletedDialog( + title: LocaleKeys.bitrefillPaymentSuccessfull.tr(), + message: LocaleKeys.bitrefillPaymentSuccessfullInstruction.tr( + args: [state.invoiceId], + ), + onViewInvoicePressed: () {}, + ); + }, + ); + } +} diff --git a/lib/views/wallet/coin_details/withdraw_form/withdraw_form_index.dart b/lib/views/wallet/coin_details/withdraw_form/withdraw_form_index.dart new file mode 100644 index 0000000000..296f5d3b54 --- /dev/null +++ b/lib/views/wallet/coin_details/withdraw_form/withdraw_form_index.dart @@ -0,0 +1,92 @@ +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/withdraw_form/withdraw_form_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/common/pages/page_layout.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/pages/complete_page.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/pages/confirm_page.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/pages/failed_page.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/pages/fill_form_page.dart'; +import 'package:web_dex/views/wallet/coin_details/withdraw_form/widgets/withdraw_form_header.dart'; + +class WithdrawFormIndex extends StatefulWidget { + const WithdrawFormIndex({ + required this.coin, + this.address, + this.amount, + }); + + final Coin coin; + final String? address; + final String? amount; + + @override + State createState() => _WithdrawFormIndexState(); +} + +class _WithdrawFormIndexState extends State { + @override + void initState() { + super.initState(); + + if (widget.address != null) { + context.read().add( + WithdrawFormAddressChanged( + address: widget.address!, + ), + ); + } + + if (widget.amount != null) { + context.read().add( + WithdrawFormAmountChanged( + amount: widget.amount!, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + final scrollController = ScrollController(); + return BlocSelector( + selector: (state) => state.step, + builder: (context, step) => PageLayout( + header: WithdrawFormHeader(coin: widget.coin), + content: Flexible( + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: SingleChildScrollView( + controller: scrollController, + child: Container( + padding: + const EdgeInsets.symmetric(vertical: 20, horizontal: 15), + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: BorderRadius.circular(18.0), + ), + child: Builder( + builder: (context) { + switch (step) { + case WithdrawFormStep.fill: + return const FillFormPage(); + case WithdrawFormStep.confirm: + return const ConfirmPage(); + case WithdrawFormStep.success: + return const CompletePage(); + case WithdrawFormStep.failed: + return const FailedPage(); + } + }, + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_controls.dart b/lib/views/wallet/coins_manager/coins_manager_controls.dart new file mode 100644 index 0000000000..893fb61cb0 --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_controls.dart @@ -0,0 +1,82 @@ +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:flutter_bloc/flutter_bloc.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/bloc/coins_manager/coins_manager_event.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +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); + final bool isMobile; + + @override + Widget build(BuildContext context) { + if (isMobile) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSearchField(context), + Padding( + padding: const EdgeInsets.only(top: 14.0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Padding( + padding: EdgeInsets.only(left: 20.0), + child: CoinsManagerSelectAllButton(), + ), + const Spacer(), + CoinsManagerFiltersDropdown(), + ], + ), + ), + ], + ); + } + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 240), + height: 45, + child: _buildSearchField(context), + ), + CoinsManagerFiltersDropdown(), + ], + ), + ], + ); + } + + Widget _buildSearchField(BuildContext context) { + return UiTextFormField( + key: const Key('coins-manager-search-field'), + fillColor: isMobile + ? theme.custom.coinsManagerTheme.searchFieldMobileBackgroundColor + : null, + autocorrect: false, + textInputAction: TextInputAction.none, + enableInteractiveSelection: true, + 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)), + ); + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_filter_type_label.dart b/lib/views/wallet/coins_manager/coins_manager_filter_type_label.dart new file mode 100644 index 0000000000..f01ee6ae4e --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_filter_type_label.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; + +class CoinsManagerFilterTypeLabel extends StatelessWidget { + const CoinsManagerFilterTypeLabel({ + Key? key, + required this.text, + required this.backgroundColor, + this.textStyle, + required this.onTap, + this.border, + }) : super(key: key); + final String text; + final Color backgroundColor; + final Border? border; + final TextStyle? textStyle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(16), + border: border, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + text, + style: textStyle ?? + const TextStyle( + fontWeight: FontWeight.w600, + fontSize: 12, + color: Colors.white, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 8.0), + child: Icon( + Icons.close, + size: 18, + color: textStyle?.color ?? Colors.white, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_filters_dropdown.dart b/lib/views/wallet/coins_manager/coins_manager_filters_dropdown.dart new file mode 100644 index 0000000000..93195ccae3 --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_filters_dropdown.dart @@ -0,0 +1,220 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_svg/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_manager/coins_manager_bloc.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_event.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_state.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin_type.dart'; +import 'package:web_dex/model/coin_utils.dart'; +import 'package:web_dex/model/wallet.dart'; + +class CoinsManagerFiltersDropdown extends StatefulWidget { + @override + State createState() => + _CoinsManagerFiltersDropdownState(); +} + +class _CoinsManagerFiltersDropdownState + extends State { + bool _isOpen = false; + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + + return UiDropdown( + borderRadius: BorderRadius.circular(16), + switcher: _Switcher(isOpen: _isOpen), + dropdown: _Dropdown(bloc: bloc), + onSwitch: (bool isOpen) => setState(() { + _isOpen = isOpen; + }), + ); + } +} + +class _Switcher extends StatelessWidget { + const _Switcher({required this.isOpen}); + final bool isOpen; + + @override + Widget build(BuildContext context) { + return Container( + key: const Key('filters-dropdown'), + width: 100, + height: 30, + decoration: BoxDecoration( + border: Border.all(color: theme.custom.specificButtonBorderColor), + color: theme.custom.specificButtonBackgroundColor, + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + child: Row( + children: [ + isOpen + ? Icon( + Icons.close, + color: Theme.of(context).textTheme.labelLarge?.color, + size: 14, + ) + : SvgPicture.asset( + '$assetsPath/ui_icons/filters.svg', + colorFilter: ColorFilter.mode( + Theme.of(context).textTheme.labelLarge?.color ?? + Colors.white, + BlendMode.srcIn, + ), + width: 14, + ), + Padding( + padding: const EdgeInsets.only(left: 12.0), + child: Text( + isOpen ? LocaleKeys.close.tr() : LocaleKeys.filters.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ) + ], + ), + ), + ); + } +} + +class _Dropdown extends StatelessWidget { + const _Dropdown({required this.bloc}); + final CoinsManagerBloc bloc; + + @override + Widget build(BuildContext context) { + return BlocBuilder( + bloc: bloc, + builder: (context, state) { + final List selectedCoinTypes = bloc.state.selectedCoinTypes; + final List listTypes = + CoinType.values.where(_filterTypes).toList(); + onTap(CoinType type) => + bloc.add(CoinsManagerCoinTypeSelect(type: type)); + + final bool isLongListTypes = listTypes.length > 2; + + return Container( + constraints: + BoxConstraints(maxWidth: isLongListTypes ? 320.0 : 140.0), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + border: Border.all(color: theme.custom.specificButtonBorderColor), + borderRadius: BorderRadius.circular(14), + boxShadow: [ + theme.custom.coinsManagerTheme.filtersPopupShadow, + ], + ), + padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 10), + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: listTypes + .map((type) => FractionallySizedBox( + widthFactor: isLongListTypes ? 0.5 : 1, + child: _DropdownItem( + type: type, + isSelected: selectedCoinTypes.contains(type), + onTap: onTap, + isFirst: listTypes.indexOf(type) == 0, + isWide: !isLongListTypes, + ), + )) + .toList(), + )); + }, + ); + } + + bool _filterTypes(CoinType type) { + switch (currentWalletBloc.wallet?.config.type) { + case WalletType.iguana: + return coinsBloc.knownCoins + .firstWhereOrNull((coin) => coin.type == type) != + null; + case WalletType.trezor: + return coinsBloc.knownCoins.firstWhereOrNull( + (coin) => coin.type == type && coin.hasTrezorSupport) != + null; + case WalletType.metamask: + case WalletType.keplr: + case null: + return false; + } + } +} + +class _DropdownItem extends StatelessWidget { + const _DropdownItem({ + required this.type, + required this.isSelected, + required this.isFirst, + required this.isWide, + required this.onTap, + }); + final CoinType type; + final bool isSelected; + final bool isFirst; + final bool isWide; + final Function(CoinType) onTap; + + @override + Widget build(BuildContext context) { + const double borderWidth = 2.0; + const double topPadding = 6.0; + + return Container( + alignment: Alignment.centerLeft, + padding: isSelected + ? EdgeInsets.only(top: isFirst ? 0.0 : topPadding) + : EdgeInsets.fromLTRB( + borderWidth, + isFirst ? borderWidth : topPadding + borderWidth, + borderWidth, + borderWidth, + ), + child: InkWell( + key: Key('filter-item-${type.name.toLowerCase()}'), + onTap: () => onTap(type), + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(26), + border: isSelected + ? Border.all( + color: theme + .custom.coinsManagerTheme.filterPopupItemBorderColor, + width: 2) + : null, + ), + padding: const EdgeInsets.symmetric(vertical: 6, horizontal: 15), + child: Row( + mainAxisSize: isWide ? MainAxisSize.max : MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + getCoinTypeNameLong(type), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_helpers.dart b/lib/views/wallet/coins_manager/coins_manager_helpers.dart new file mode 100644 index 0000000000..e2e039560c --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_helpers.dart @@ -0,0 +1,53 @@ +import 'package:web_dex/model/coin.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +List sortByName(List coins, SortDirection sortDirection) { + if (sortDirection == SortDirection.none) return coins; + if (sortDirection == SortDirection.increase) { + coins.sort((a, b) => a.name.compareTo(b.name)); + return coins; + } else { + coins.sort((a, b) => b.name.compareTo(a.name)); + return coins; + } +} + +List sortByProtocol(List coins, SortDirection sortDirection) { + if (sortDirection == SortDirection.none) return coins; + if (sortDirection == SortDirection.increase) { + coins + .sort((a, b) => a.typeNameWithTestnet.compareTo(b.typeNameWithTestnet)); + return coins; + } else { + coins + .sort((a, b) => b.typeNameWithTestnet.compareTo(a.typeNameWithTestnet)); + return coins; + } +} + +List sortByUsdBalance(List coins, SortDirection sortDirection) { + if (sortDirection == SortDirection.none) return coins; + if (sortDirection == SortDirection.increase) { + coins.sort((a, b) { + final double firstUsdBalance = a.usdBalance ?? 0; + final double secondUsdBalance = b.usdBalance ?? 0; + return firstUsdBalance == secondUsdBalance + ? -1 + : firstUsdBalance - secondUsdBalance > 0 + ? 1 + : -1; + }); + return coins; + } else { + coins.sort((a, b) { + final double firstUsdBalance = a.usdBalance ?? 0; + final double secondUsdBalance = b.usdBalance ?? 0; + return firstUsdBalance == secondUsdBalance + ? -1 + : secondUsdBalance - firstUsdBalance > 0 + ? 1 + : -1; + }); + return coins; + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_list.dart b/lib/views/wallet/coins_manager/coins_manager_list.dart new file mode 100644 index 0000000000..0c10123270 --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_list.dart @@ -0,0 +1,55 @@ +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/coins_manager/coins_manager_bloc.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/wallet/coins_manager/coins_manager_list_item.dart'; + +class CoinsManagerList extends StatelessWidget { + CoinsManagerList({ + Key? key, + required this.coinList, + required this.isAddAssets, + required this.onCoinSelect, + }) : super(key: key); + final List coinList; + final bool isAddAssets; + final void Function(Coin) onCoinSelect; + final ScrollController _scrollController = ScrollController(); + + @override + Widget build(BuildContext context) { + final List selectedCoins = + context.watch().state.selectedCoins; + + return Material( + color: Colors.transparent, + child: DexScrollbar( + scrollController: _scrollController, + isMobile: isMobile, + child: ListView.builder( + key: const Key('coins-manager-list'), + shrinkWrap: true, + itemCount: coinList.length, + controller: _scrollController, + itemBuilder: (context, int i) { + final Coin coin = coinList[i]; + + return Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: CoinsManagerListItem( + coin: coin, + isSelected: + selectedCoins.where((c) => c.abbr == coin.abbr).isNotEmpty, + isMobile: isMobile, + isAddAssets: isAddAssets, + onSelect: () => onCoinSelect(coin), + ), + ); + }, + ), + ), + ); + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_list_header.dart b/lib/views/wallet/coins_manager/coins_manager_list_header.dart new file mode 100644 index 0000000000..7fc4823fec --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_list_header.dart @@ -0,0 +1,81 @@ +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/views/wallet/coins_manager/coins_manager_list_wrapper.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class CoinsManagerListHeader extends StatelessWidget { + const CoinsManagerListHeader({ + Key? key, + required this.sortData, + required this.isAddAssets, + required this.onSortChange, + }) : super(key: key); + final CoinsManagerSortData sortData; + final bool isAddAssets; + final void Function(CoinsManagerSortData) onSortChange; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(bottom: 5), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: 1, + color: Theme.of(context).dividerColor, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only(left: 20, right: 20), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + const Padding( + padding: EdgeInsets.only(left: 60, right: 20.0), + child: SizedBox(), + ), + Expanded( + flex: 2, + child: UiSortListButton( + onClick: _onSortChange, + value: CoinsManagerSortType.name, + sortData: sortData, + text: LocaleKeys.assetName.tr(), + ), + ), + Expanded( + flex: isAddAssets ? 2 : 1, + child: UiSortListButton( + onClick: _onSortChange, + value: CoinsManagerSortType.protocol, + sortData: sortData, + text: LocaleKeys.protocol.tr(), + ), + ), + if (!isAddAssets) + Expanded( + flex: 2, + child: UiSortListButton( + onClick: _onSortChange, + value: CoinsManagerSortType.balance, + sortData: sortData, + text: LocaleKeys.balance.tr(), + ), + ), + ], + ), + ), + ); + } + + void _onSortChange(SortData sortData) { + onSortChange( + CoinsManagerSortData( + sortType: sortData.sortType, + sortDirection: sortData.sortDirection, + ), + ); + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_list_item.dart b/lib/views/wallet/coins_manager/coins_manager_list_item.dart new file mode 100644 index 0000000000..84334b22a8 --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_list_item.dart @@ -0,0 +1,312 @@ +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:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; + +class CoinsManagerListItem extends StatelessWidget { + const CoinsManagerListItem({ + Key? key, + required this.coin, + required this.isSelected, + required this.isMobile, + required this.isAddAssets, + required this.onSelect, + }) : super(key: key); + + final Coin coin; + final bool isSelected; + final bool isMobile; + final bool isAddAssets; + final Function() onSelect; + + @override + Widget build(BuildContext context) { + return isMobile + ? _CoinsManagerListItemMobile( + coin: coin, + isAddAssets: isAddAssets, + protocolColor: getProtocolColor(coin.type), + protocolText: _protocolText, + isSelected: isSelected, + onSelect: onSelect, + ) + : _CoinsManagerListItemDesktop( + coin: coin, + isAddAssets: isAddAssets, + protocolColor: getProtocolColor(coin.type), + protocolText: _protocolText, + isSelected: isSelected, + onSelect: onSelect, + ); + } + + String get _protocolText => getCoinTypeName(coin.type); +} + +class _CoinsManagerListItemDesktop extends StatelessWidget { + const _CoinsManagerListItemDesktop({ + Key? key, + required this.isAddAssets, + required this.coin, + required this.isSelected, + required this.onSelect, + required this.protocolText, + required this.protocolColor, + }) : super(key: key); + + final bool isAddAssets; + final Coin coin; + final bool isSelected; + final VoidCallback onSelect; + final String protocolText; + final Color protocolColor; + + @override + Widget build(BuildContext context) { + final bool isZeroBalance = coin.balance == 0.0; + final Color balanceColor = isZeroBalance + ? theme.custom.coinsManagerTheme.listItemZeroBalanceColor + : theme.custom.balanceColor; + return InkWell( + key: Key('coins-manager-list-item-${coin.abbr.toLowerCase()}'), + borderRadius: BorderRadius.circular(8), + onTap: onSelect, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: Theme.of(context).colorScheme.onSurface, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.max, + children: [ + Padding( + padding: const EdgeInsets.only(right: 20.0), + child: Switch( + value: isSelected, + splashRadius: 18, + onChanged: (_) => onSelect(), + ), + ), + Expanded( + flex: 2, + child: CoinItem(coin: coin, size: CoinItemSize.large), + ), + Expanded( + flex: isAddAssets ? 2 : 1, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: + const EdgeInsets.symmetric(vertical: 4, horizontal: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(20), + color: protocolColor, + border: Border.all( + color: coin.type == CoinType.smartChain + ? theme.custom.smartchainLabelBorderColor + : protocolColor, + ), + ), + child: Text( + protocolText, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: theme + .custom.coinsManagerTheme.listItemProtocolTextColor, + ), + ), + ), + ], + ), + ), + if (!isAddAssets) + Expanded( + flex: 2, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: AutoScrollText( + text: isZeroBalance + ? formatAmt(coin.balance) + : formatDexAmt(coin.balance), + style: TextStyle( + color: balanceColor, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Text( + coin.abbr, + style: TextStyle( + color: balanceColor, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 4.0), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '(', + style: TextStyle( + color: balanceColor, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + Flexible( + child: CoinFiatBalance( + coin, + isAutoScrollEnabled: true, + style: TextStyle( + color: balanceColor, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + ')', + style: TextStyle( + color: balanceColor, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _CoinsManagerListItemMobile extends StatelessWidget { + const _CoinsManagerListItemMobile({ + Key? key, + required this.isAddAssets, + required this.coin, + required this.isSelected, + required this.onSelect, + required this.protocolText, + required this.protocolColor, + }) : super(key: key); + + final bool isAddAssets; + final Coin coin; + final bool isSelected; + final VoidCallback onSelect; + final String protocolText; + final Color protocolColor; + + @override + Widget build(BuildContext context) { + final bool isZeroBalance = coin.balance == 0.0; + final Color balanceColor = isZeroBalance + ? theme.custom.coinsManagerTheme.listItemZeroBalanceColor + : theme.custom.balanceColor; + return InkWell( + key: Key('coins-manager-list-item-${coin.abbr.toLowerCase()}'), + borderRadius: BorderRadius.circular(8), + onTap: onSelect, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: Theme.of(context).colorScheme.surface, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.max, + children: [ + Checkbox( + value: isSelected, + splashRadius: 18, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + onChanged: (_) => onSelect(), + ), + const SizedBox(width: 8), + Expanded(child: CoinItem(coin: coin, size: CoinItemSize.large)), + if (!isAddAssets) + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + AutoScrollText( + text: isZeroBalance + ? formatAmt(coin.balance) + : formatDexAmt(coin.balance), + style: TextStyle( + color: balanceColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + '(', + style: TextStyle( + color: balanceColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + Flexible( + child: CoinFiatBalance( + coin, + isAutoScrollEnabled: true, + style: TextStyle( + color: balanceColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + Text( + ')', + style: TextStyle( + color: balanceColor, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ); + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_list_wrapper.dart b/lib/views/wallet/coins_manager/coins_manager_list_wrapper.dart new file mode 100644 index 0000000000..d5acf9aa2a --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_list_wrapper.dart @@ -0,0 +1,141 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_event.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_state.dart'; +import 'package:web_dex/blocs/blocs.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/router/state/routing_state.dart'; +import 'package:web_dex/router/state/wallet_state.dart'; +import 'package:web_dex/shared/widgets/information_popup.dart'; +import 'package:web_dex/views/wallet/coins_manager/coins_manager_controls.dart'; +import 'package:web_dex/views/wallet/coins_manager/coins_manager_helpers.dart'; +import 'package:web_dex/views/wallet/coins_manager/coins_manager_list.dart'; +import 'package:web_dex/views/wallet/coins_manager/coins_manager_list_header.dart'; +import 'package:web_dex/views/wallet/coins_manager/coins_manager_selected_types_list.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class CoinsManagerListWrapper extends StatefulWidget { + const CoinsManagerListWrapper({Key? key}) : super(key: key); + + @override + State createState() => + _CoinsManagerListWrapperState(); +} + +class _CoinsManagerListWrapperState extends State { + CoinsManagerSortData _sortData = const CoinsManagerSortData( + sortDirection: SortDirection.none, sortType: CoinsManagerSortType.none); + late InformationPopup _informationPopup; + + @override + void initState() { + _informationPopup = InformationPopup(context: context); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listenWhen: (previous, current) => + previous.isSwitching && !current.isSwitching, + listener: (context, state) { + if (!state.isSwitching) { + routingState.walletState.action = coinsManagerRouteAction.none; + } + }, + child: BlocBuilder( + builder: (BuildContext context, CoinsManagerState state) { + final List sortedCoins = _sortCoins([...state.coins]); + final bool isAddAssets = state.action == CoinsManagerAction.add; + + return Column( + mainAxisSize: MainAxisSize.max, + children: [ + CoinsManagerFilters(isMobile: isMobile), + if (!isMobile) + Padding( + padding: const EdgeInsets.only(top: 20), + child: CoinsManagerListHeader( + sortData: _sortData, + isAddAssets: isAddAssets, + onSortChange: _onSortChange, + ), + ), + SizedBox(height: isMobile ? 4.0 : 14.0), + const CoinsManagerSelectedTypesList(), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: CoinsManagerList( + coinList: sortedCoins, + isAddAssets: isAddAssets, + onCoinSelect: _onCoinSelect, + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ], + ); + }, + ), + ); + } + + void _onSortChange(CoinsManagerSortData sortData) { + setState(() { + _sortData = sortData; + }); + } + + List _sortCoins(List coins) { + switch (_sortData.sortType) { + case CoinsManagerSortType.name: + return sortByName(coins, _sortData.sortDirection); + case CoinsManagerSortType.protocol: + return sortByProtocol(coins, _sortData.sortDirection); + case CoinsManagerSortType.balance: + return sortByUsdBalance(coins, _sortData.sortDirection); + case CoinsManagerSortType.none: + return coins; + } + } + + void _onCoinSelect(Coin coin) { + final bloc = context.read(); + if (bloc.state.action == CoinsManagerAction.remove && + tradingEntitiesBloc.isCoinBusy(coin.abbr)) { + _informationPopup.text = + LocaleKeys.coinDisableSpan1.tr(args: [coin.abbr]); + _informationPopup.show(); + return; + } + bloc.add(CoinsManagerCoinSelect(coin: coin)); + } +} + +enum CoinsManagerSortType { + protocol, + balance, + name, + none, +} + +class CoinsManagerSortData implements SortData { + const CoinsManagerSortData({ + required this.sortDirection, + required this.sortType, + }); + + @override + final CoinsManagerSortType sortType; + @override + final SortDirection sortDirection; +} diff --git a/lib/views/wallet/coins_manager/coins_manager_page.dart b/lib/views/wallet/coins_manager/coins_manager_page.dart new file mode 100644 index 0000000000..2622722bfd --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_page.dart @@ -0,0 +1,66 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; +import 'package:web_dex/blocs/blocs.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:komodo_ui_kit/komodo_ui_kit.dart'; + +class CoinsManagerPage extends StatelessWidget { + const CoinsManagerPage({ + Key? key, + required this.action, + required this.closePage, + }) : super(key: key); + + final CoinsManagerAction action; + final void Function() closePage; + + @override + Widget build(BuildContext context) { + assert(action == CoinsManagerAction.add || + action == CoinsManagerAction.remove); + + final title = action == CoinsManagerAction.add + ? 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: StreamBuilder( + initialData: coinsBloc.loginActivationFinished, + stream: coinsBloc.outLoginActivationFinished, + builder: + (context, AsyncSnapshot walletCoinsEnabledSnapshot) { + if (!(walletCoinsEnabledSnapshot.data ?? false)) { + return const Center( + child: Padding( + padding: EdgeInsets.fromLTRB(0, 100, 0, 100), + child: UiSpinner(), + ), + ); + } + return BlocProvider( + key: Key('coins-manager-page-${action.toString()}'), + create: (context) => CoinsManagerBloc( + action: action, + coinsRepo: coinsBloc, + ), + child: const CoinsManagerListWrapper(), + ); + })), + ), + ); + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_select_all_button.dart b/lib/views/wallet/coins_manager/coins_manager_select_all_button.dart new file mode 100644 index 0000000000..29cd2da0a5 --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_select_all_button.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_event.dart'; + +class CoinsManagerSelectAllButton extends StatelessWidget { + const CoinsManagerSelectAllButton({ + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final bloc = context.watch(); + final bool isSelectedAllEnabled = bloc.state.isSelectedAllCoinsEnabled; + final ThemeData theme = Theme.of(context); + return Checkbox( + value: true, + splashRadius: 18, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + side: isSelectedAllEnabled + ? null + : WidgetStateBorderSide.resolveWith((states) => BorderSide( + width: 2.0, + color: theme.colorScheme.primary, + )), + checkColor: isSelectedAllEnabled ? null : theme.colorScheme.primary, + fillColor: isSelectedAllEnabled + ? null + : WidgetStateProperty.all(theme.colorScheme.surface), + onChanged: (_) => bloc.add(const CoinsManagerSelectAllTap()), + ); + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_selected_types_list.dart b/lib/views/wallet/coins_manager/coins_manager_selected_types_list.dart new file mode 100644 index 0000000000..7cf638459a --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_selected_types_list.dart @@ -0,0 +1,111 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_event.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_state.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.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'; +import 'package:web_dex/views/wallet/coins_manager/coins_manager_filter_type_label.dart'; + +class CoinsManagerSelectedTypesList extends StatelessWidget { + const CoinsManagerSelectedTypesList({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + + return BlocSelector>( + selector: (state) { + return state.selectedCoinTypes; + }, + builder: (context, types) { + if (types.isEmpty) return const SizedBox(); + final scrollController = ScrollController(); + return Padding( + padding: const EdgeInsets.only(top: 12.0, bottom: 20.0), + child: Container( + constraints: const BoxConstraints(maxHeight: 28), + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + itemCount: types.length, + itemBuilder: (BuildContext context, int index) { + final type = types[index]; + final Color protocolColor = getProtocolColor(type); + if (index == 0) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: CoinsManagerFilterTypeLabel( + text: LocaleKeys.resetAll.tr(), + textStyle: TextStyle( + fontWeight: FontWeight.w500, + fontSize: 12, + color: themeData.textTheme.labelLarge?.color, + ), + backgroundColor: themeData.colorScheme.surface, + border: Border.all( + color: + theme.custom.specificButtonBorderColor), + onTap: () { + context.read().add( + const CoinsManagerSelectedTypesReset()); + }, + ), + ), + const SizedBox(width: 8), + Flexible( + child: CoinsManagerFilterTypeLabel( + text: getCoinTypeName(type), + backgroundColor: protocolColor, + border: Border.all( + color: type == CoinType.smartChain + ? theme.custom.smartchainLabelBorderColor + : protocolColor, + ), + onTap: () { + context.read().add( + CoinsManagerCoinTypeSelect(type: type)); + }, + ), + ), + const SizedBox(width: 8), + ], + ); + } + + return Padding( + padding: const EdgeInsets.only(right: 8.0), + child: CoinsManagerFilterTypeLabel( + text: getCoinTypeName(type), + backgroundColor: protocolColor, + border: Border.all( + color: type == CoinType.smartChain + ? theme.custom.smartchainLabelBorderColor + : protocolColor, + ), + onTap: () { + context + .read() + .add(CoinsManagerCoinTypeSelect(type: type)); + }, + ), + ); + }), + ), + ), + ); + }, + ); + } +} diff --git a/lib/views/wallet/coins_manager/coins_manager_switch_button.dart b/lib/views/wallet/coins_manager/coins_manager_switch_button.dart new file mode 100644 index 0000000000..9f433da806 --- /dev/null +++ b/lib/views/wallet/coins_manager/coins_manager_switch_button.dart @@ -0,0 +1,41 @@ +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:web_dex/bloc/coins_manager/coins_manager_bloc.dart'; +import 'package:web_dex/bloc/coins_manager/coins_manager_event.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/router/state/wallet_state.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class CoinsManagerSwitchButton extends StatelessWidget { + const CoinsManagerSwitchButton({super.key}); + + @override + Widget build(BuildContext context) { + final state = context.watch().state; + + return UiPrimaryButton( + buttonKey: const Key('coins-manager-switch-button'), + prefix: state.isSwitching + ? Padding( + padding: const EdgeInsets.only(right: 8), + child: UiSpinner( + color: theme.custom.defaultGradientButtonTextColor, + width: 14, + height: 14, + ), + ) + : null, + text: state.action == CoinsManagerAction.add + ? LocaleKeys.addAssets.tr() + : LocaleKeys.removeAssets.tr(), + width: 260, + onPressed: state.selectedCoins.isNotEmpty + ? () => context + .read() + .add(const CoinsManagerCoinsSwitch()) + : null, + ); + } +} diff --git a/lib/views/wallet/common/wallet_helper.dart b/lib/views/wallet/common/wallet_helper.dart new file mode 100644 index 0000000000..dff5f7da47 --- /dev/null +++ b/lib/views/wallet/common/wallet_helper.dart @@ -0,0 +1,50 @@ +import 'package:rational/rational.dart'; +import 'package:web_dex/model/coin.dart'; + +/// Calculates the total 24-hour change percentage for a list of coins. +/// +/// The method calculates the total 24-hour change percentage across all coins +/// based on their balances, USD prices, and 24-hour change percentages. +/// +/// Parameters: +/// - [coins] (List?): List of Coin objects representing different coins. +/// +/// Return Value: +/// - (double?): The total 24-hour change percentage, or null if input is empty or null. +/// +/// Example Usage: +/// ```dart +/// List coins = [ +/// Coin(1.0, usdPrice: Price(100.0, change24h: 0.05)), +/// Coin(2.0, usdPrice: Price(50.0, change24h: -0.03)), +/// Coin(3.0, usdPrice: Price(10.0, change24h: 0.02)), +/// ]; +/// double? result = getTotal24Change(coins); +/// print(result); // Output: 0.014 +/// ``` +/// unit tests: [testGetTotal24Change] +double? getTotal24Change(Iterable? coins) { + double getTotalUsdBalance(Iterable coins) { + return coins.fold(0, (prev, coin) { + return prev + coin.balance * (coin.usdPrice?.price ?? 0.00); + }); + } + + if (coins == null || coins.isEmpty) return null; + + final double totalUsdBalance = getTotalUsdBalance(coins); + if (totalUsdBalance == 0) return null; + + Rational totalChange = Rational.zero; + for (Coin coin in coins) { + final double? coin24Change = coin.usdPrice?.change24h; + if (coin24Change == null) continue; + + final Rational coinFraction = Rational.parse(coin.balance.toString()) * + Rational.parse((coin.usdPrice?.price ?? 0).toString()) / + Rational.parse(totalUsdBalance.toString()); + final coin24ChangeRat = Rational.parse(coin24Change.toString()); + totalChange = totalChange + coin24ChangeRat * coinFraction; + } + return totalChange.toDouble(); +} diff --git a/lib/views/wallet/wallet_page/charts/coin_prices_chart.dart b/lib/views/wallet/wallet_page/charts/coin_prices_chart.dart new file mode 100644 index 0000000000..5a7a478cbc --- /dev/null +++ b/lib/views/wallet/wallet_page/charts/coin_prices_chart.dart @@ -0,0 +1,241 @@ +import 'package:dragon_charts_flutter/dragon_charts_flutter.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:intl/intl.dart' hide TextDirection; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/cex_market_data/price_chart/models/price_chart_data.dart'; +import 'package:web_dex/bloc/cex_market_data/price_chart/models/time_period.dart'; +import 'package:web_dex/bloc/cex_market_data/price_chart/price_chart_bloc.dart'; +import 'package:web_dex/bloc/cex_market_data/price_chart/price_chart_event.dart'; +import 'package:web_dex/bloc/cex_market_data/price_chart/price_chart_state.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +import 'price_chart_tooltip.dart'; + +class PriceChartPage extends StatelessWidget { + final List intervals = TimePeriod.values; + + const PriceChartPage({super.key}); + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAlias, + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Container( + height: 340, + padding: const EdgeInsets.all(16), + child: BlocBuilder( + builder: (context, state) { + return Column( + children: [ + MarketChartHeaderControls( + title: const Text('Statistics'), + leadingIcon: CoinIcon( + state.data.firstOrNull?.info.ticker ?? '???', + size: 22, + ), + leadingText: Text( + NumberFormat.currency(symbol: '\$', decimalDigits: 4) + .format( + state.data.firstOrNull?.data.lastOrNull?.usdValue ?? 0, + ), + ), + availableCoins: state.availableCoins.keys.toList(), + selectedCoinId: state.data.firstOrNull?.info.ticker, + onCoinSelected: (coinId) { + context.read().add( + PriceChartCoinsSelected( + coinId == null ? [] : [coinId]), + ); + }, + centreAmount: + state.data.firstOrNull?.data.lastOrNull?.usdValue ?? 0, + percentageIncrease: state.data.firstOrNull?.info + .selectedPeriodIncreasePercentage ?? + 0, + selectedPeriod: state.selectedPeriod, + onPeriodChanged: (newPeriod) { + context.read().add( + PriceChartPeriodChanged(newPeriod!), + ); + }, + customCoinItemBuilder: (coinId) { + final coin = state.availableCoins[coinId]; + return CoinSelectItem( + coinId: coinId, + trailing: TrendPercentageText( + investmentReturnPercentage: + coin?.selectedPeriodIncreasePercentage ?? 0, + ), + name: coin?.name ?? coinId, + ); + }, + ), + const Gap(16), + Expanded( + child: BlocBuilder( + builder: (context, state) { + if (state.status == PriceChartStatus.failure) { + return Center(child: Text('Error: ${state.error}')); + } else if (state.status == PriceChartStatus.loading && + !state.hasData) { + return const Center(child: CircularProgressIndicator()); + } else if (state.status == PriceChartStatus.success || + state.hasData) { + final coinsData = state.data; + + final (labelCount, labelDateFormat) = + dateAxisLabelCountFormat(state.selectedPeriod); + + return SizedBox( + width: double.infinity, + child: LineChart( + key: const Key('price_chart'), + domainExtent: const ChartExtent.tight(), + rangeExtent: + const ChartExtent.tight(paddingPortion: 0.1), + elements: [ + ChartAxisLabels( + isVertical: true, + count: 5, + labelBuilder: (value) { + return NumberFormat.compact().format(value); + }, + ), + ChartAxisLabels( + isVertical: false, + count: labelCount, + labelBuilder: (value) { + return labelDateFormat.format( + DateTime.fromMillisecondsSinceEpoch( + value.toInt(), + ), + ); + }, + ), + for (var i = 0; i < coinsData.length; i++) + ChartDataSeries( + data: coinsData[i].data.map((e) { + return ChartData( + x: e.unixTimestamp, + y: e.usdValue, + ); + }).toList(), + color: getCoinColor( + coinsData.elementAt(i).info.ticker, + ) ?? + Theme.of(context).colorScheme.primary, + strokeWidth: 3, + ), + ChartGridLines(isVertical: false, count: 5), + ], + backgroundColor: Colors.transparent, + tooltipBuilder: (context, dataPoints, dataColors) { + final Map + dataPointCoinMap = { + for (var i = 0; i < dataPoints.length; i++) + PriceChartSeriesPoint( + usdValue: dataPoints[i].y, + unixTimestamp: dataPoints[i].x, + ): coinsData[i].info, + }; + return PriceChartTooltip( + dataPointCoinMap: dataPointCoinMap, + ); + }, + markerSelectionStrategy: CartesianSelectionStrategy( + snapToClosest: true, + lineWidth: 1, + dashSpace: 0.5, + verticalLineColor: const Color(0xFF45464E), + ), + ), + ); + } else { + return const Center( + child: Text('Select an interval to load data'), + ); + } + }, + ), + ), + ], + ); + }, + ), + ), + ); + } + + /// Returns the count and format for the date axis labels based on the + /// selected period. + /// + /// (int) count: The number of labels to show. + /// (DateFormat) format: The format to use for the labels. + /// + /// Example usage: + /// ```dart + /// final (count, format) = dateAxisLabelCountFormat(Duration(days: 7)); + /// + /// ``` + /// + static (int, DateFormat) dateAxisLabelCountFormat(Duration period) { + const averageDaysPerMonth = 30.437; + + int? count; + DateFormat? format; + + // For more than 1 month, show one label for each month up to a max of + // 12 labels. Include the abbreviated year if the period is more than 1 year. + if (period.inDays >= 28) { + final monthsCount = period.inDays ~/ averageDaysPerMonth; + // 1 label for each month with minimum of 6 labels and max of 12 labels. + count = monthsCount.clamp(6, 12); + format = DateFormat("MMM"); // e.g. Jan + + // If the period is more than 1 year, include the year in the label. + // e.g. Jan '21 + if (period.inDays >= 365) { + format = DateFormat("MMM ''yy"); + } + // If there are more than 1 label for each month, show the month and day. + // e.g. Jan 1 + if (count > monthsCount) { + format = DateFormat("MMM d"); + } + return (count, format); + } + // Otherwise, if it's more than 1 week, but less than 1 month, show + // 2 labels for each week. + else if (period.inDays > 7) { + count = (period.inDays ~/ 7) * 2; + format = DateFormat("d"); // e.g. 1 + return (count, format); + } + + // Otherwise if it's more than 3 days, but less than 1 week, show a label + // for each day with the short day name. + else if (period.inDays > 3) { + count = period.inDays; + format = DateFormat("EEE"); // e.g. Mon + return (count, format); + } + // Otherwise if it's more than 24 hours, but less than 3 days, show 6 + // labels with the short day name and time. + else if (period.inHours > 24) { + count = 6; + format = DateFormat("EEE HH:mm"); // e.g. Mon 12:00 + return (count, format); + } + + // Otherwise if it's less than 24 hours, show 6 labels with the time. + count = 6; + format = DateFormat("HH:mm"); // e.g. 12:00 + + return (count, format); + } +} diff --git a/lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart b/lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart new file mode 100644 index 0000000000..a6987ee734 --- /dev/null +++ b/lib/views/wallet/wallet_page/charts/price_chart_tooltip.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/cex_market_data/price_chart/models/price_chart_data.dart'; + +class PriceChartTooltip extends StatelessWidget { + final Map dataPointCoinMap; + + PriceChartTooltip({ + Key? key, + required this.dataPointCoinMap, + }) : super(key: key); + + late final double? commonX = dataPointCoinMap.keys.every((element) => + element.unixTimestamp == dataPointCoinMap.keys.first.unixTimestamp) + ? dataPointCoinMap.keys.first.unixTimestamp + : null; + + String valueToString(double value) { + if (value.abs() > 1000) { + return '\$${value.toStringAsFixed(2)}'; + } else { + return '\$${value.toStringAsPrecision(4)}'; + } + } + + @override + Widget build(BuildContext context) { + final isMultipleCoins = dataPointCoinMap.length > 1; + return ChartTooltipContainer( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (commonX != null) ...[ + Text( + // TODO! Dynamic based on selected period. Try share logic + // with parent widget. + + // For 1M, use format with example of "June 12, 2023" + DateFormat('MMMM d, y').format( + DateTime.fromMillisecondsSinceEpoch(commonX!.toInt())), + style: Theme.of(context).textTheme.labelMedium), + const SizedBox(height: 4), + ], + if (isMultipleCoins) + ...dataPointCoinMap.entries.map((entry) { + final data = entry.key; + final coin = entry.value; + return Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + CoinIcon(coin.id), + const SizedBox(width: 4), + Text( + '${coin.name}: ${valueToString(data.usdValue)}', + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(color: Colors.white), + ), + ], + ); + }).toList() + else + Text( + valueToString(dataPointCoinMap.keys.first.usdValue), + style: Theme.of(context).textTheme.labelLarge, + ), + ], + ), + ); + } +} diff --git a/lib/views/wallet/wallet_page/common/coin_list_item.dart b/lib/views/wallet/wallet_page/common/coin_list_item.dart new file mode 100644 index 0000000000..0cf8b32c92 --- /dev/null +++ b/lib/views/wallet/wallet_page/common/coin_list_item.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/coin_list_item_desktop.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/coin_list_item_mobile.dart'; + +class CoinListItem extends StatelessWidget { + const CoinListItem({ + Key? key, + required this.coin, + required this.backgroundColor, + required this.onTap, + }) : super(key: key); + + final Coin coin; + final Color backgroundColor; + final Function(Coin) onTap; + + @override + Widget build(BuildContext context) { + return Opacity(opacity: coin.isActivating ? 0.3 : 1, child: _buildItem()); + } + + Widget _buildItem() { + return isMobile + ? CoinListItemMobile( + coin: coin, + backgroundColor: backgroundColor, + onTap: onTap, + ) + : CoinListItemDesktop( + coin: coin, + backgroundColor: backgroundColor, + onTap: onTap, + ); + } +} diff --git a/lib/views/wallet/wallet_page/common/coin_list_item_desktop.dart b/lib/views/wallet/wallet_page/common/coin_list_item_desktop.dart new file mode 100644 index 0000000000..407a243fb8 --- /dev/null +++ b/lib/views/wallet/wallet_page/common/coin_list_item_desktop.dart @@ -0,0 +1,215 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/ui/ui_simple_border_button.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_change.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_price.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item_size.dart'; +import 'package:web_dex/shared/widgets/need_attention_mark.dart'; +import 'package:web_dex/shared/widgets/coin_item/coin_item.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/coin_sparkline.dart'; + +class CoinListItemDesktop extends StatelessWidget { + const CoinListItemDesktop({ + Key? key, + required this.coin, + required this.backgroundColor, + required this.onTap, + }) : super(key: key); + + final Coin coin; + final Color backgroundColor; + final Function(Coin) onTap; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.fromLTRB(0, 0, 0, 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + color: backgroundColor, + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: BorderRadius.circular(10), + hoverColor: theme.custom.zebraHoverColor, + onTap: coin.isActivating ? null : () => onTap(coin), + child: Container( + padding: const EdgeInsets.fromLTRB(0, 10, 16, 10), + child: Row( + key: Key('active-coin-item-${(coin.abbr).toLowerCase()}'), + children: [ + Expanded( + flex: 5, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + NeedAttentionMark(coin.isSuspended), + const SizedBox(width: 11), + Stack( + clipBehavior: Clip.none, + children: [ + CoinItem(coin: coin, size: CoinItemSize.large), + if (coin.isActivating) + const Positioned( + top: 4, + right: -20, + child: UiSpinner( + width: 12, + height: 12, + strokeWidth: 1.5, + ), + ), + ], + ), + const SizedBox(width: 24.0), + ], + ), + ), + Expanded( + flex: 5, + child: coin.isSuspended + ? _SuspendedMessage( + key: Key('suspended-asset-message-${coin.abbr}'), + coin: coin, + isReEnabling: coin.isActivating, + ) + : _CoinBalance( + key: Key('balance-asset-${coin.abbr}'), + coin: coin, + ), + ), + Expanded( + flex: 2, + child: coin.isSuspended + ? const SizedBox.shrink() + : CoinFiatChange( + coin, + style: const TextStyle(fontSize: _fontSize), + ), + ), + Expanded( + flex: 2, + child: coin.isSuspended + ? const SizedBox.shrink() + : CoinFiatPrice( + coin, + style: const TextStyle(fontSize: _fontSize), + ), + ), + Expanded( + flex: 2, + child: + CoinSparkline(coinId: coin.abbr), // Using CoinSparkline + ), + ], + ), + ), + ), + ), + ); + } +} + +class _CoinBalance extends StatelessWidget { + const _CoinBalance({ + Key? key, + required this.coin, + }) : super(key: key); + + final Coin coin; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Flexible( + flex: 2, + child: AutoScrollText( + text: doubleToString(coin.balance), + style: const TextStyle( + fontSize: _fontSize, + fontWeight: FontWeight.w500, + ), + ), + ), + Text( + ' ${Coin.normalizeAbbr(coin.abbr)}', + style: const TextStyle( + fontSize: _fontSize, + fontWeight: FontWeight.w500, + ), + ), + const Text(' (', + style: TextStyle( + fontSize: _fontSize, + fontWeight: FontWeight.w500, + )), + Flexible( + child: CoinFiatBalance( + coin, + isAutoScrollEnabled: true, + ), + ), + const Text(')', + style: TextStyle( + fontSize: _fontSize, + fontWeight: FontWeight.w500, + )), + ], + ); + } +} + +class _SuspendedMessage extends StatelessWidget { + const _SuspendedMessage({ + super.key, + required this.coin, + required this.isReEnabling, + }); + final Coin coin; + final bool isReEnabling; + + @override + Widget build(BuildContext context) { + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Opacity( + opacity: 0.6, + child: Text( + LocaleKeys.activationFailedMessage.tr(), + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: _fontSize, + fontWeight: FontWeight.w500, + ), + )), + const SizedBox(width: 12), + Padding( + padding: const EdgeInsets.only(top: 1.0), + child: UiSimpleBorderButton( + key: Key('retry-suspended-asset-${(coin.abbr)}'), + onPressed: isReEnabling + ? null + : () async { + await coinsBloc.activateCoins([coin]); + }, + inProgress: isReEnabling, + child: const Text(LocaleKeys.retryButtonText).tr(), + ), + ), + ], + ); + } +} + +const double _fontSize = 14; diff --git a/lib/views/wallet/wallet_page/common/coin_list_item_mobile.dart b/lib/views/wallet/wallet_page/common/coin_list_item_mobile.dart new file mode 100644 index 0000000000..a5df56fc2b --- /dev/null +++ b/lib/views/wallet/wallet_page/common/coin_list_item_mobile.dart @@ -0,0 +1,116 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_balance.dart'; +import 'package:web_dex/shared/widgets/coin_fiat_change.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:web_dex/shared/widgets/need_attention_mark.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/coin_sparkline.dart'; + +class CoinListItemMobile extends StatelessWidget { + const CoinListItemMobile({ + Key? key, + required this.coin, + required this.backgroundColor, + required this.onTap, + }) : super(key: key); + + final Coin coin; + final Color backgroundColor; + final Function(Coin) onTap; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 0), + child: InkWell( + onTap: coin.isActivating ? null : () => onTap(coin), + borderRadius: BorderRadius.circular(15), + child: Container( + padding: const EdgeInsets.fromLTRB(0, 14, 15, 14), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.circular(15), + ), + child: Row( + key: Key('active-coin-item-${(coin.abbr).toLowerCase()}'), + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + NeedAttentionMark(coin.isSuspended), + const SizedBox(width: 11), + Stack( + clipBehavior: Clip.none, + children: [ + CoinItem(coin: coin, size: CoinItemSize.large), + if (coin.isActivating) + const Positioned( + top: 4, + right: -20, + child: UiSpinner( + width: 12, + height: 12, + strokeWidth: 1.5, + ), + ), + ], + ), + const Spacer(), + Expanded( + flex: 5, + child: _CoinBalance(coin: coin), + ), + const SizedBox(width: 10), + Expanded( + flex: 2, + child: CoinSparkline(coinId: coin.abbr), + ), + ], + ), + ), + ), + ); + } +} + +class _CoinBalance extends StatelessWidget { + const _CoinBalance({required this.coin}); + final Coin coin; + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + children: [ + Text( + '${doubleToString(coin.balance)} ${Coin.normalizeAbbr(coin.abbr)}', + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500), + ), + const SizedBox(width: 10), + CoinFiatBalance( + coin, + style: TextStyle( + color: theme.custom.increaseColor, + fontSize: 13, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + coin.isSuspended + ? const SizedBox.shrink() + : CoinFiatChange( + coin, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w400, + ), + ), + ], + ); + } +} diff --git a/lib/views/wallet/wallet_page/common/coins_list_header.dart b/lib/views/wallet/wallet_page/common/coins_list_header.dart new file mode 100644 index 0000000000..c847479527 --- /dev/null +++ b/lib/views/wallet/wallet_page/common/coins_list_header.dart @@ -0,0 +1,66 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; + +class CoinsListHeader extends StatelessWidget { + const CoinsListHeader({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return isMobile + ? const _CoinsListHeaderMobile() + : const _CoinsListHeaderDesktop(); + } +} + +class _CoinsListHeaderDesktop extends StatelessWidget { + const _CoinsListHeaderDesktop({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + const style = TextStyle(fontSize: 14, fontWeight: FontWeight.w500); + + return Container( + padding: const EdgeInsets.fromLTRB(0, 0, 16, 4), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Expanded( + flex: 5, + child: Padding( + padding: const EdgeInsets.only(left: 20.0), + child: Text(LocaleKeys.asset.tr(), style: style), + ), + ), + Expanded( + flex: 5, + child: Text(LocaleKeys.balance.tr(), style: style), + ), + Expanded( + flex: 2, + child: Text(LocaleKeys.change24hRevert.tr(), style: style), + ), + Expanded( + flex: 2, + child: Text(LocaleKeys.price.tr(), style: style), + ), + Expanded( + flex: 2, + child: Text(LocaleKeys.trend.tr(), style: style), + ), + ], + ), + ); + } +} + +class _CoinsListHeaderMobile extends StatelessWidget { + const _CoinsListHeaderMobile({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/lib/views/wallet/wallet_page/common/wallet_coins_list.dart b/lib/views/wallet/wallet_page/common/wallet_coins_list.dart new file mode 100644 index 0000000000..51fe67c738 --- /dev/null +++ b/lib/views/wallet/wallet_page/common/wallet_coins_list.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/coin_list_item.dart'; + +class WalletCoinsList extends StatelessWidget { + const WalletCoinsList({ + Key? key, + required this.coins, + required this.onCoinItemTap, + }) : super(key: key); + + final List coins; + final Function(Coin) onCoinItemTap; + + @override + Widget build(BuildContext context) { + return SliverList( + key: const Key('wallet-page-coins-list'), + delegate: SliverChildBuilderDelegate( + (BuildContext context, int index) { + final Coin coin = coins[index]; + final bool isEven = (index + 1) % 2 == 0; + final Color backgroundColor = isEven + ? Theme.of(context).colorScheme.surface + : Theme.of(context).colorScheme.onSurface; + return CoinListItem( + key: Key('wallet-coin-list-item-${coin.abbr.toLowerCase()}'), + coin: coin, + backgroundColor: backgroundColor, + onTap: onCoinItemTap, + ); + }, + childCount: coins.length, + )); + } +} 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 new file mode 100644 index 0000000000..42f898fce4 --- /dev/null +++ b/lib/views/wallet/wallet_page/wallet_main/active_coins_list.dart @@ -0,0 +1,63 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.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/views/wallet/wallet_page/common/wallet_coins_list.dart'; + +class ActiveCoinsList extends StatelessWidget { + const ActiveCoinsList({ + Key? key, + required this.searchPhrase, + required this.withBalance, + required this.onCoinItemTap, + }) : super(key: key); + final String searchPhrase; + final bool withBalance; + final Function(Coin) onCoinItemTap; + + @override + Widget build(BuildContext context) { + return StreamBuilder( + initialData: coinsBloc.loginActivationFinished, + stream: coinsBloc.outLoginActivationFinished, + builder: (context, AsyncSnapshot walletCoinsEnabledSnapshot) { + return StreamBuilder>( + initialData: coinsBloc.walletCoinsMap.values, + stream: coinsBloc.outWalletCoins, + builder: (context, AsyncSnapshot> snapshot) { + final List coins = List.from(snapshot.data ?? []); + final Iterable displayedCoins = _getDisplayedCoins(coins); + + if (displayedCoins.isEmpty && + (searchPhrase.isNotEmpty || withBalance)) { + return SliverToBoxAdapter( + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.all(8.0), + child: + SelectableText(LocaleKeys.walletPageNoSuchAsset.tr()), + ), + ); + } + + final sorted = sortFiatBalance(displayedCoins.toList()); + + return WalletCoinsList( + coins: sorted, + onCoinItemTap: onCoinItemTap, + ); + }, + ); + }); + } + + Iterable _getDisplayedCoins(Iterable coins) => + filterCoinsByPhrase(coins, searchPhrase).where((Coin coin) { + if (withBalance) { + return coin.balance > 0; + } + return true; + }).toList(); +} diff --git a/lib/views/wallet/wallet_page/wallet_main/all_coins_list.dart b/lib/views/wallet/wallet_page/wallet_main/all_coins_list.dart new file mode 100644 index 0000000000..507557f373 --- /dev/null +++ b/lib/views/wallet/wallet_page/wallet_main/all_coins_list.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_utils.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/wallet_coins_list.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class AllCoinsList extends StatelessWidget { + const AllCoinsList({ + Key? key, + required this.searchPhrase, + required this.withBalance, + required this.onCoinItemTap, + }) : super(key: key); + final String searchPhrase; + final bool withBalance; + final Function(Coin) onCoinItemTap; + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + initialData: coinsBloc.knownCoins, + stream: coinsBloc.outKnownCoins, + builder: (context, AsyncSnapshot> snapshot) { + final List coins = snapshot.data ?? []; + + if (coins.isEmpty) { + return const SliverToBoxAdapter(child: UiSpinner()); + } + + final displayedCoins = + sortByPriority(filterCoinsByPhrase(coins, searchPhrase)); + return WalletCoinsList( + coins: displayedCoins.toList(), + onCoinItemTap: onCoinItemTap, + ); + }); + } +} diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart new file mode 100644 index 0000000000..18e4451a81 --- /dev/null +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_main.dart @@ -0,0 +1,310 @@ +import 'dart:async'; + +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/bloc/assets_overview/bloc/asset_overview_bloc.dart'; +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/cex_market_data/portfolio_growth/portfolio_growth_bloc.dart'; +import 'package:web_dex/bloc/cex_market_data/profit_loss/profit_loss_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/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/model/coin.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/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'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_growth_chart.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details_info/charts/portfolio_profit_loss_chart.dart'; +import 'package:web_dex/views/wallet/wallet_page/charts/coin_prices_chart.dart'; +import 'package:web_dex/views/wallet/wallet_page/wallet_main/active_coins_list.dart'; +import 'package:web_dex/views/wallet/wallet_page/wallet_main/all_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'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_wrapper.dart'; + +class WalletMain extends StatefulWidget { + const WalletMain({Key? key = const Key('wallet-page')}) : super(key: key); + + @override + State createState() => _WalletMainState(); +} + +class _WalletMainState extends State + with SingleTickerProviderStateMixin { + bool _showCoinWithBalance = false; + String _searchKey = ''; + PopupDispatcher? _popupDispatcher; + StreamSubscription? _walletSubscription; + late TabController _tabController; + + @override + void initState() { + super.initState(); + + if (currentWalletBloc.wallet != null) { + _loadWalletData(currentWalletBloc.wallet!.id); + } + + _walletSubscription = currentWalletBloc.outWallet.listen((wallet) { + if (wallet != null) { + _loadWalletData(wallet.id); + } else { + _clearWalletData(); + } + }); + + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _walletSubscription?.cancel(); + _popupDispatcher?.close(); + _popupDispatcher = null; + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final walletCoinsFiltered = coinsBloc.walletCoinsMap.values; + + final authState = context.select((AuthBloc bloc) => bloc.state.mode); + + return PageLayout( + noBackground: true, + header: isMobile ? PageHeader(title: LocaleKeys.wallet.tr()) : null, + content: Expanded( + child: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Column( + children: [ + if (authState == AuthorizeMode.logIn) ...[ + WalletOverview( + onPortfolioGrowthPressed: () => + _tabController.animateTo(0), + onPortfolioProfitLossPressed: () => + _tabController.animateTo(1), + ), + const Gap(8), + ], + if (authState != AuthorizeMode.logIn) + const SizedBox( + width: double.infinity, + height: 340, + child: PriceChartPage(key: Key('price-chart')), + ) + else ...[ + Card( + child: TabBar( + controller: _tabController, + tabs: [ + Tab(text: LocaleKeys.portfolioGrowth.tr()), + Tab(text: LocaleKeys.profitAndLoss.tr()), + ], + ), + ), + SizedBox( + height: 340, + child: TabBarView( + controller: _tabController, + children: [ + SizedBox( + width: double.infinity, + height: 340, + child: PortfolioGrowthChart( + initialCoins: walletCoinsFiltered.toList(), + ), + ), + SizedBox( + width: double.infinity, + height: 340, + child: PortfolioProfitLossChart( + initialCoins: walletCoinsFiltered.toList(), + ), + ), + ], + ), + ), + ], + const Gap(8), + ], + ), + ), + SliverPersistentHeader( + pinned: true, + delegate: _SliverSearchBarDelegate( + withBalance: _showCoinWithBalance, + onSearchChange: _onSearchChange, + onWithBalanceChange: _onShowCoinsWithBalanceClick, + mode: authState, + ), + ), + _buildCoinList(authState), + ], + ), + )); + } + + void _loadWalletData(String walletId) { + final portfolioGrowthBloc = context.read(); + final profitLossBloc = context.read(); + final assetOverviewBloc = context.read(); + + portfolioGrowthBloc.add( + PortfolioGrowthLoadRequested( + coins: coinsBloc.walletCoins, + fiatCoinId: 'USDT', + updateFrequency: const Duration(minutes: 1), + selectedPeriod: portfolioGrowthBloc.state.selectedPeriod, + walletId: walletId, + ), + ); + + profitLossBloc.add( + ProfitLossPortfolioChartLoadRequested( + coins: coinsBloc.walletCoins, + selectedPeriod: profitLossBloc.state.selectedPeriod, + fiatCoinId: 'USDT', + walletId: walletId, + ), + ); + + assetOverviewBloc + ..add( + PortfolioAssetsOverviewLoadRequested( + coins: coinsBloc.walletCoins, + walletId: walletId, + ), + ) + ..add( + PortfolioAssetsOverviewSubscriptionRequested( + coins: coinsBloc.walletCoins, + walletId: walletId, + updateFrequency: const Duration(minutes: 1), + ), + ); + } + + void _clearWalletData() { + final portfolioGrowthBloc = context.read(); + final profitLossBloc = context.read(); + final assetOverviewBloc = context.read(); + + portfolioGrowthBloc.add(const PortfolioGrowthClearRequested()); + profitLossBloc.add(const ProfitLossPortfolioChartClearRequested()); + assetOverviewBloc.add(const AssetOverviewClearRequested()); + } + + Widget _buildCoinList(AuthorizeMode mode) { + switch (mode) { + case AuthorizeMode.logIn: + return ActiveCoinsList( + searchPhrase: _searchKey, + withBalance: _showCoinWithBalance, + onCoinItemTap: _onActiveCoinItemTap, + ); + case AuthorizeMode.hiddenLogin: + case AuthorizeMode.noLogin: + return AllCoinsList( + searchPhrase: _searchKey, + withBalance: _showCoinWithBalance, + onCoinItemTap: _onCoinItemTap, + ); + } + } + + void _onShowCoinsWithBalanceClick(bool? value) { + setState(() { + _showCoinWithBalance = value ?? false; + }); + } + + void _onSearchChange(String searchKey) { + setState(() { + _searchKey = searchKey.toLowerCase(); + }); + } + + void _onActiveCoinItemTap(Coin coin) { + routingState.walletState.selectedCoin = coin.abbr; + routingState.walletState.action = coinsManagerRouteAction.none; + } + + void _onCoinItemTap(Coin coin) { + _popupDispatcher = _createPopupDispatcher(); + _popupDispatcher!.show(); + } + + PopupDispatcher _createPopupDispatcher() { + final TakerBloc takerBloc = context.read(); + final BridgeBloc bridgeBloc = context.read(); + + return PopupDispatcher( + width: 320, + context: scaffoldKey.currentContext ?? context, + barrierColor: isMobile ? Theme.of(context).colorScheme.onSurface : null, + borderColor: theme.custom.specificButtonBorderColor, + popupContent: WalletsManagerWrapper( + eventType: WalletsManagerEventType.wallet, + onSuccess: (_) async { + takerBloc.add(TakerReInit()); + bridgeBloc.add(const BridgeReInit()); + await reInitTradingForms(); + _popupDispatcher?.close(); + }, + ), + ); + } +} + +class _SliverSearchBarDelegate extends SliverPersistentHeaderDelegate { + final bool withBalance; + final Function(String) onSearchChange; + final Function(bool) onWithBalanceChange; + final AuthorizeMode mode; + + _SliverSearchBarDelegate({ + required this.withBalance, + required this.onSearchChange, + required this.onWithBalanceChange, + required this.mode, + }); + + @override + double get minExtent => 120; + @override + double get maxExtent => 120; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return WalletManageSection( + withBalance: withBalance, + onSearchChange: onSearchChange, + onWithBalanceChange: onWithBalanceChange, + mode: mode, + pinned: shrinkOffset > 0, + ); + } + + @override + bool shouldRebuild(_SliverSearchBarDelegate oldDelegate) { + return withBalance != oldDelegate.withBalance || mode != oldDelegate.mode; + } +} diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart new file mode 100644 index 0000000000..92170cc0e4 --- /dev/null +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_manage_section.dart @@ -0,0 +1,234 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/authorize_mode.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/router/state/wallet_state.dart'; +import 'package:web_dex/shared/widgets/hidden_without_wallet.dart'; +import 'package:web_dex/views/wallet/wallet_page/common/coins_list_header.dart'; +import 'package:web_dex/views/wallet/wallet_page/wallet_main/wallet_manager_search_field.dart'; + +class WalletManageSection extends StatelessWidget { + const WalletManageSection({ + required this.mode, + required this.withBalance, + required this.onSearchChange, + required this.onWithBalanceChange, + required this.pinned, + super.key, + }); + final bool withBalance; + final AuthorizeMode mode; + final Function(bool) onWithBalanceChange; + final Function(String) onSearchChange; + final bool pinned; + + @override + Widget build(BuildContext context) { + return isMobile + ? _buildMobileSection(context) + : _buildDesktopSection(context); + } + + Widget _buildDesktopSection(BuildContext context) { + final ThemeData themeData = Theme.of(context); + return Card( + clipBehavior: Clip.antiAlias, + color: Theme.of(context).colorScheme.surface, + margin: const EdgeInsets.all(0), + elevation: pinned ? 4 : 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + HiddenWithoutWallet( + child: Container( + padding: const EdgeInsets.all(3.0), + decoration: BoxDecoration( + color: theme.custom.walletEditButtonsBackgroundColor, + borderRadius: BorderRadius.circular(18.0), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + UiPrimaryButton( + buttonKey: const Key('add-assets-button'), + onPressed: _onAddAssetsPress, + text: LocaleKeys.addAssets.tr(), + height: 30.0, + width: 110, + backgroundColor: themeData.colorScheme.surface, + textStyle: TextStyle( + color: themeData.colorScheme.primary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 3.0), + child: UiPrimaryButton( + buttonKey: const Key('remove-assets-button'), + onPressed: _onRemoveAssetsPress, + text: LocaleKeys.removeAssets.tr(), + height: 30.0, + width: 125, + backgroundColor: themeData.colorScheme.surface, + textStyle: TextStyle( + color: themeData.textTheme.labelLarge?.color + ?.withOpacity(0.7), + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ), + Row( + children: [ + HiddenWithoutWallet( + child: Padding( + padding: const EdgeInsets.only(right: 30.0), + child: CoinsWithBalanceCheckbox( + withBalance: withBalance, + onWithBalanceChange: onWithBalanceChange, + ), + ), + ), + WalletManagerSearchField(onChange: onSearchChange), + ], + ), + ], + ), + ), + const CoinsListHeader(), + ], + ), + ); + } + + Widget _buildMobileSection(BuildContext context) { + final ThemeData themeData = Theme.of(context); + + return Container( + padding: const EdgeInsets.fromLTRB(2, 20, 2, 10), + child: Column( + children: [ + HiddenWithoutWallet( + child: Padding( + padding: const EdgeInsets.only(bottom: 17.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + LocaleKeys.portfolio.tr(), + style: const TextStyle( + fontWeight: FontWeight.w700, + fontSize: 16, + ), + ), + Container( + padding: const EdgeInsets.all(3.0), + decoration: BoxDecoration( + color: themeData.colorScheme.surface, + borderRadius: BorderRadius.circular(18.0), + ), + child: Row( + children: [ + UiPrimaryButton( + buttonKey: const Key('add-assets-button'), + onPressed: _onAddAssetsPress, + text: LocaleKeys.addAssets.tr(), + height: 25.0, + width: 110, + backgroundColor: themeData.colorScheme.onSurface, + textStyle: TextStyle( + color: themeData.colorScheme.secondary, + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 3.0), + child: UiPrimaryButton( + buttonKey: const Key('remove-assets-button'), + onPressed: _onRemoveAssetsPress, + text: LocaleKeys.remove.tr(), + height: 25.0, + width: 80, + backgroundColor: themeData.colorScheme.onSurface, + textStyle: TextStyle( + color: themeData.textTheme.labelLarge?.color + ?.withOpacity(0.7), + fontSize: 12, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + HiddenWithoutWallet( + child: CoinsWithBalanceCheckbox( + withBalance: withBalance, + onWithBalanceChange: onWithBalanceChange, + ), + ), + WalletManagerSearchField(onChange: onSearchChange), + ], + ), + ], + ), + ); + } + + void _onAddAssetsPress() { + routingState.walletState.action = coinsManagerRouteAction.addAssets; + } + + void _onRemoveAssetsPress() { + routingState.walletState.action = coinsManagerRouteAction.removeAssets; + } +} + +class CoinsWithBalanceCheckbox extends StatelessWidget { + const CoinsWithBalanceCheckbox({ + required this.withBalance, + required this.onWithBalanceChange, + super.key, + }); + + final bool withBalance; + final Function(bool) onWithBalanceChange; + + @override + Widget build(BuildContext context) { + return Row( + children: [ + UiCheckbox( + key: const Key('coins-with-balance-checkbox'), + value: withBalance, + text: LocaleKeys.withBalance.tr(), + onChanged: onWithBalanceChange, + ), + ], + ); + } +} diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_manager_search_field.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_manager_search_field.dart new file mode 100644 index 0000000000..105fc64fe7 --- /dev/null +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_manager_search_field.dart @@ -0,0 +1,92 @@ +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/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +const double _hiddenSearchFieldWidth = 38; +const double _normalSearchFieldWidth = 150; + +class WalletManagerSearchField extends StatefulWidget { + const WalletManagerSearchField({required this.onChange}); + final Function(String) onChange; + + @override + State createState() => + _WalletManagerSearchFieldState(); +} + +class _WalletManagerSearchFieldState extends State { + double _searchFieldWidth = _normalSearchFieldWidth; + final TextEditingController _searchController = TextEditingController(); + @override + void initState() { + _searchController.addListener(_onChange); + if (isMobile) { + _changeSearchFieldWidth(false); + } + super.initState(); + } + + @override + void dispose() { + _searchController.removeListener(_onChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: const Duration(milliseconds: 200), + constraints: BoxConstraints.tightFor( + width: _searchFieldWidth, + height: isMobile ? _hiddenSearchFieldWidth : 30, + ), + child: UiTextFormField( + key: const Key('wallet-page-search-field'), + controller: _searchController, + autocorrect: false, + onFocus: (FocusNode node) { + _searchController.text = _searchController.text.trim(); + if (!isMobile) return; + _changeSearchFieldWidth(node.hasFocus); + }, + textInputAction: TextInputAction.none, + enableInteractiveSelection: true, + prefixIcon: Icon( + Icons.search, + size: isMobile ? 25 : 18, + ), + inputFormatters: [LengthLimitingTextInputFormatter(40)], + hintText: LocaleKeys.searchAssets.tr(), + hintTextStyle: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + overflow: TextOverflow.ellipsis, + height: 1.3), + inputContentPadding: const EdgeInsets.fromLTRB(0, 0, 12, 0), + maxLines: 1, + style: const TextStyle(fontSize: 12), + fillColor: _searchFieldColor, + ), + ); + } + + void _changeSearchFieldWidth(bool hasFocus) { + if (hasFocus) { + setState(() => _searchFieldWidth = _normalSearchFieldWidth); + } else if (_searchController.text.isEmpty) { + setState(() => _searchFieldWidth = _hiddenSearchFieldWidth); + } + } + + void _onChange() { + widget.onChange(_searchController.text.trim()); + } + + Color? get _searchFieldColor { + return isMobile ? theme.custom.searchFieldMobile : null; + } +} diff --git a/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart b/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart new file mode 100644 index 0000000000..837cc09314 --- /dev/null +++ b/lib/views/wallet/wallet_page/wallet_main/wallet_overview.dart @@ -0,0 +1,100 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/assets_overview/bloc/asset_overview_bloc.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/coin.dart'; + +class WalletOverview extends StatelessWidget { + const WalletOverview({ + this.onPortfolioGrowthPressed, + this.onPortfolioProfitLossPressed, + }); + + final VoidCallback? onPortfolioGrowthPressed; + final VoidCallback? onPortfolioProfitLossPressed; + + @override + Widget build(BuildContext context) { + return StreamBuilder>( + initialData: coinsBloc.walletCoinsMap.values.toList(), + stream: coinsBloc.outWalletCoins, + builder: (context, snapshot) { + final List? coins = snapshot.data; + if (!snapshot.hasData || coins == null) return _buildSpinner(); + + final portfolioAssetsOverviewBloc = context.watch(); + + int assetCount = coins.length; + + final stateWithData = portfolioAssetsOverviewBloc.state + is PortfolioAssetsOverviewLoadSuccess + ? portfolioAssetsOverviewBloc.state + as PortfolioAssetsOverviewLoadSuccess + : null; + + return Wrap( + runSpacing: 16, + children: [ + FractionallySizedBox( + widthFactor: isMobile ? 1 : 0.5, + child: StatisticCard( + caption: Text(LocaleKeys.allTimeInvestment.tr()), + value: stateWithData?.totalInvestment.value ?? 0, + actionIcon: const Icon(CustomIcons.fiatIconCircle), + onPressed: onPortfolioGrowthPressed, + footer: Container( + height: 28, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(28), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.pie_chart, + size: 16, + ), + const SizedBox(width: 4), + Text('$assetCount ${LocaleKeys.asset.tr()}'), + ], + ), + ), + ), + ), + FractionallySizedBox( + widthFactor: isMobile ? 1 : 0.5, + child: StatisticCard( + caption: Text(LocaleKeys.allTimeProfit.tr()), + value: stateWithData?.profitAmount.value ?? 0, + footer: TrendPercentageText( + investmentReturnPercentage: + stateWithData?.profitIncreasePercentage ?? 0, + ), + actionIcon: const Icon(Icons.trending_up), + onPressed: onPortfolioProfitLossPressed, + ), + ), + ], + ); + }, + ); + } + + Widget _buildSpinner() { + return const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.all(20.0), + child: UiSpinner(), + ), + ], + ); + } +} diff --git a/lib/views/wallet/wallet_page/wallet_page.dart b/lib/views/wallet/wallet_page/wallet_page.dart new file mode 100644 index 0000000000..384f5920ed --- /dev/null +++ b/lib/views/wallet/wallet_page/wallet_page.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/router/state/routing_state.dart'; +import 'package:web_dex/router/state/wallet_state.dart'; +import 'package:web_dex/views/wallet/coin_details/coin_details.dart'; +import 'package:web_dex/views/wallet/coins_manager/coins_manager_page.dart'; +import 'package:web_dex/views/wallet/wallet_page/wallet_main/wallet_main.dart'; + +class WalletPage extends StatelessWidget { + const WalletPage({ + required this.coinAbbr, + required this.action, + }); + final String? coinAbbr; + final CoinsManagerAction action; + + @override + Widget build(BuildContext context) { + final Coin? coin = coinsBloc.getWalletCoin(coinAbbr ?? ''); + if (coin != null && coin.enabledType != null) { + return CoinDetails( + key: Key(coin.abbr), + coin: coin, + onBackButtonPressed: _onBackButtonPressed, + ); + } + + final action = this.action; + + if (action != CoinsManagerAction.none) { + return CoinsManagerPage( + action: action, + closePage: _onBackButtonPressed, + ); + } + + return const WalletMain(); + } + + void _onBackButtonPressed() { + routingState.resetDataForPageContent(); + } +} diff --git a/lib/views/wallets_manager/wallets_manager_events_factory.dart b/lib/views/wallets_manager/wallets_manager_events_factory.dart new file mode 100644 index 0000000000..7c5653f53f --- /dev/null +++ b/lib/views/wallets_manager/wallets_manager_events_factory.dart @@ -0,0 +1,51 @@ +import 'package:web_dex/bloc/analytics/analytics_repo.dart'; + +final WalletsManagerEventsFactory walletsManagerEventsFactory = + WalletsManagerEventsFactory(); + +class WalletsManagerEventsFactory { + AnalyticsEventData createEvent( + WalletsManagerEventType type, WalletsManagerEventMethod method) { + return WalletsManagerEvent( + name: 'login', + source: type.name, + method: method.name, + ); + } +} + +enum WalletsManagerEventType { + header, + wallet, + fiat, + dex, + nft, + bridge; +} + +enum WalletsManagerEventMethod { + create, + import, + loginExisting, + nft, + hardware; +} + +class WalletsManagerEvent extends AnalyticsEventData { + final String source; + final String method; + + WalletsManagerEvent({ + required String name, + required this.source, + required this.method, + }) { + this.name = name; + } + + @override + Map get parameters => { + 'source': source, + 'method': method, + }; +} diff --git a/lib/views/wallets_manager/wallets_manager_wrapper.dart b/lib/views/wallets_manager/wallets_manager_wrapper.dart new file mode 100644 index 0000000000..3e14a4c1a2 --- /dev/null +++ b/lib/views/wallets_manager/wallets_manager_wrapper.dart @@ -0,0 +1,72 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallets_manager.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallets_type_list.dart'; + +class WalletsManagerWrapper extends StatefulWidget { + const WalletsManagerWrapper({ + required this.eventType, + this.onSuccess, + Key? key = const Key('wallets-manager-wrapper'), + }) : super(key: key); + + final Function(Wallet)? onSuccess; + final WalletsManagerEventType eventType; + + @override + State createState() => _WalletsManagerWrapperState(); +} + +class _WalletsManagerWrapperState extends State { + WalletType? _selectedWalletType; + @override + void initState() { + walletsBloc.fetchSavedWallets(); + super.initState(); + } + + @override + Widget build(BuildContext context) { + final WalletType? selectedWalletType = _selectedWalletType; + if (selectedWalletType == null) { + return Column( + children: [ + Text( + LocaleKeys.walletsTypeListTitle.tr(), + style: + Theme.of(context).textTheme.titleLarge?.copyWith(fontSize: 16), + ), + Padding( + padding: const EdgeInsets.only(top: 30.0), + child: WalletsTypeList( + onWalletTypeClick: _onWalletTypeClick, + ), + ), + ], + ); + } + + return WalletsManager( + eventType: widget.eventType, + walletType: selectedWalletType, + close: _closeWalletManager, + onSuccess: widget.onSuccess ?? (_) {}, + ); + } + + Future _onWalletTypeClick(WalletType type) async { + setState(() { + _selectedWalletType = type; + }); + } + + Future _closeWalletManager() async { + setState(() { + _selectedWalletType = null; + }); + } +} diff --git a/lib/views/wallets_manager/widgets/creation_password_fields.dart b/lib/views/wallets_manager/widgets/creation_password_fields.dart new file mode 100644 index 0000000000..7390380d60 --- /dev/null +++ b/lib/views/wallets_manager/widgets/creation_password_fields.dart @@ -0,0 +1,109 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/utils/validators.dart'; +import 'package:web_dex/shared/widgets/password_visibility_control.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class CreationPasswordFields extends StatefulWidget { + const CreationPasswordFields({ + Key? key, + required this.passwordController, + this.onFieldSubmitted, + }) : super(key: key); + + final TextEditingController passwordController; + final void Function(String)? onFieldSubmitted; + + @override + State createState() => _CreationPasswordFieldsState(); +} + +class _CreationPasswordFieldsState extends State { + final TextEditingController _confirmPasswordController = + TextEditingController(text: ''); + bool _isObscured = true; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildPasswordField(), + const SizedBox(height: 20), + _buildPasswordConfirmationField(), + ], + ); + } + + @override + void dispose() { + _confirmPasswordController.dispose(); + super.dispose(); + } + + @override + void initState() { + if (widget.passwordController.text.isNotEmpty) { + widget.passwordController.text = ''; + } + super.initState(); + } + + Widget _buildPasswordConfirmationField() { + return UiTextFormField( + key: const Key('create-password-field-confirm'), + controller: _confirmPasswordController, + textInputAction: TextInputAction.done, + autocorrect: false, + obscureText: _isObscured, + enableInteractiveSelection: true, + validationMode: InputValidationMode.eager, + inputFormatters: [LengthLimitingTextInputFormatter(40)], + validator: _validateConfirmPasswordField, + onFieldSubmitted: widget.onFieldSubmitted, + errorMaxLines: 6, + hintText: LocaleKeys.walletCreationConfirmPasswordHint.tr(), + ); + } + + Widget _buildPasswordField() { + return UiTextFormField( + key: const Key('create-password-field'), + controller: widget.passwordController, + textInputAction: TextInputAction.next, + autocorrect: false, + enableInteractiveSelection: true, + obscureText: _isObscured, + inputFormatters: [LengthLimitingTextInputFormatter(40)], + validator: _validatePasswordField, + errorMaxLines: 6, + hintText: LocaleKeys.walletCreationPasswordHint.tr(), + suffixIcon: PasswordVisibilityControl( + onVisibilityChange: (bool isPasswordObscured) { + setState(() { + _isObscured = isPasswordObscured; + }); + }, + ), + ); + } + + // Password validator + String? _validatePasswordField(String? passwordFieldInput) { + return validatePassword( + passwordFieldInput ?? '', + LocaleKeys.walletCreationFormatPasswordError.tr(), + ); + } + + String? _validateConfirmPasswordField(String? confirmPasswordFieldInput) { + final originalPassword = widget.passwordController.text; + + return validateConfirmPassword( + originalPassword, + confirmPasswordFieldInput ?? '', + ); + } +} diff --git a/lib/views/wallets_manager/widgets/custom_seed_dialog.dart b/lib/views/wallets_manager/widgets/custom_seed_dialog.dart new file mode 100644 index 0000000000..f72cfad128 --- /dev/null +++ b/lib/views/wallets_manager/widgets/custom_seed_dialog.dart @@ -0,0 +1,76 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/dispatchers/popup_dispatcher.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +Future customSeedDialog(BuildContext context) async { + late PopupDispatcher popupManager; + bool isOpen = false; + bool isConfirmed = false; + + void close() { + popupManager.close(); + isOpen = false; + } + + popupManager = PopupDispatcher( + context: context, + popupContent: StatefulBuilder(builder: (context, setState) { + return Container( + constraints: isMobile ? null : const BoxConstraints(maxWidth: 360), + child: Column( + children: [ + Text( + LocaleKeys.customSeedWarningText.tr(), + style: const TextStyle(fontSize: 14), + ), + const SizedBox(height: 20), + TextField( + key: const Key('custom-seed-dialog-input'), + autofocus: true, + onChanged: (String text) { + setState(() { + isConfirmed = text.trim().toLowerCase() == + LocaleKeys.customSeedIUnderstand.tr().toLowerCase(); + }); + }, + ), + const SizedBox(height: 20), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Flexible( + child: UiUnderlineTextButton( + key: const Key('custom-seed-dialog-cancel-button'), + text: LocaleKeys.cancel.tr(), + onPressed: () { + setState(() => isConfirmed = false); + close(); + })), + const SizedBox(width: 12), + Flexible( + child: UiPrimaryButton( + key: const Key('custom-seed-dialog-ok-button'), + text: LocaleKeys.ok.tr(), + onPressed: !isConfirmed ? null : close, + ), + ), + ], + ) + ], + ), + ); + }), + ); + + isOpen = true; + popupManager.show(); + + while (isOpen) { + await Future.delayed(const Duration(milliseconds: 100)); + } + + return isConfirmed; +} diff --git a/lib/views/wallets_manager/widgets/hardware_wallets_manager.dart b/lib/views/wallets_manager/widgets/hardware_wallets_manager.dart new file mode 100644 index 0000000000..e891130fe5 --- /dev/null +++ b/lib/views/wallets_manager/widgets/hardware_wallets_manager.dart @@ -0,0 +1,148 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/bloc/analytics/analytics_event.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_repository.dart'; +import 'package:web_dex/bloc/trezor_bloc/trezor_repo.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_bloc.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_event.dart'; +import 'package:web_dex/bloc/trezor_init_bloc/trezor_init_state.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/hw_wallet/init_trezor.dart'; +import 'package:web_dex/model/hw_wallet/trezor_status.dart'; +import 'package:web_dex/model/text_error.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/hw_dialog_init.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_error.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_in_progress.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_message.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_pin_pad.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_select_wallet.dart'; +import 'package:web_dex/views/common/hw_wallet_dialog/trezor_steps/trezor_dialog_success.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; + +class HardwareWalletsManager extends StatelessWidget { + const HardwareWalletsManager( + {super.key, required this.close, required this.eventType}); + + final WalletsManagerEventType eventType; + final VoidCallback close; + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => + TrezorInitBloc(authRepo: authRepo, trezorRepo: trezorRepo), + child: HardwareWalletsManagerView( + close: close, + eventType: eventType, + ), + ); + } +} + +class HardwareWalletsManagerView extends StatefulWidget { + const HardwareWalletsManagerView({ + super.key, + required this.eventType, + required this.close, + }); + final WalletsManagerEventType eventType; + final VoidCallback close; + + @override + State createState() => + _HardwareWalletsManagerViewState(); +} + +class _HardwareWalletsManagerViewState + extends State { + @override + void initState() { + context.read().add(const TrezorInitReset()); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + final status = state.status; + if (status?.trezorStatus == InitTrezorStatus.ok) { + context.read().add(AnalyticsSendDataEvent( + walletsManagerEventsFactory.createEvent( + widget.eventType, WalletsManagerEventMethod.hardware))); + } + }, + child: BlocSelector( + selector: (state) { + return state.error; + }, + builder: (context, error) { + if (error != null) { + return TrezorDialogError(error); + } + + return _buildContent(); + }, + ), + ); + } + + Widget _buildContent() { + return BlocSelector( + selector: (state) { + return state.status; + }, + builder: (context, initStatus) { + switch (initStatus?.trezorStatus) { + case null: + return HwDialogInit(close: widget.close); + + case InitTrezorStatus.inProgress: + return TrezorDialogInProgress( + initStatus?.details.progressDetails, + onClose: widget.close, + ); + + case InitTrezorStatus.userActionRequired: + final TrezorUserAction? actionDetails = + initStatus?.details.actionDetails; + if (actionDetails == TrezorUserAction.enterTrezorPin) { + return TrezorDialogPinPad( + onComplete: (String pin) { + context.read().add(TrezorInitSendPin(pin)); + }, + onClose: () async { + context.read().add(const TrezorInitReset()); + }, + ); + } else if (actionDetails == + TrezorUserAction.enterTrezorPassphrase) { + return TrezorDialogSelectWallet( + onComplete: (String passphrase) { + context + .read() + .add(TrezorInitSendPassphrase(passphrase)); + }, + ); + } + + return TrezorDialogMessage( + '${LocaleKeys.userActionRequired.tr()}:' + ' ${initStatus?.details.actionDetails?.name ?? LocaleKeys.unknown.tr().toLowerCase()}', + ); + + case InitTrezorStatus.error: + return TrezorDialogError(initStatus?.details.errorDetails); + + case InitTrezorStatus.ok: + return TrezorDialogSuccess(onClose: widget.close); + + default: + return TrezorDialogMessage(initStatus!.trezorStatus.name); + } + }, + ); + } +} diff --git a/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart b/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart new file mode 100644 index 0000000000..bf308d11cc --- /dev/null +++ b/lib/views/wallets_manager/widgets/iguana_wallets_manager.dart @@ -0,0 +1,300 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/bloc/analytics/analytics_bloc.dart'; +import 'package:web_dex/bloc/analytics/analytics_event.dart'; +import 'package:web_dex/bloc/analytics/analytics_repo.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc_event.dart'; +import 'package:web_dex/bloc/auth_bloc/auth_bloc_state.dart'; +import 'package:web_dex/blocs/blocs.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/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/wallets_list.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallets_manager_controls.dart'; + +class IguanaWalletsManager extends StatefulWidget { + const IguanaWalletsManager({ + Key? key, + required this.eventType, + required this.close, + required this.onSuccess, + }) : super(key: key); + final WalletsManagerEventType eventType; + final VoidCallback close; + final Function(Wallet) onSuccess; + + @override + State createState() => _IguanaWalletsManagerState(); +} + +class _IguanaWalletsManagerState extends State { + bool _isLoading = false; + WalletsManagerAction _action = WalletsManagerAction.none; + String? _errorText; + Wallet? _selectedWallet; + WalletsManagerExistWalletAction _existWalletAction = + WalletsManagerExistWalletAction.none; + + @override + Widget build(BuildContext context) { + return BlocListener( + listener: (context, state) { + if (state.mode == AuthorizeMode.logIn) { + _onLogIn(); + } + }, + child: Builder( + builder: (context) { + if (_action == WalletsManagerAction.none && + _existWalletAction == WalletsManagerExistWalletAction.none) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + children: [ + WalletsList( + walletType: WalletType.iguana, + onWalletClick: (Wallet wallet, + WalletsManagerExistWalletAction existWalletAction) { + setState(() { + _selectedWallet = wallet; + _existWalletAction = existWalletAction; + }); + }, + ), + Padding( + padding: const EdgeInsets.only(top: 15.0), + child: WalletsManagerControls(onTap: (newAction) { + setState(() { + _action = newAction; + }); + }), + ), + Padding( + padding: const EdgeInsets.only(top: 12), + child: UiUnderlineTextButton( + text: LocaleKeys.cancel.tr(), + onPressed: widget.close, + ), + ) + ], + ), + ); + } + + return Center( + child: isMobile ? _buildMobileContent() : _buildNormalContent(), + ); + }, + ), + ); + } + + Widget _buildContent() { + final selectedWallet = _selectedWallet; + if (selectedWallet != null && + _existWalletAction != WalletsManagerExistWalletAction.none) { + switch (_existWalletAction) { + case WalletsManagerExistWalletAction.delete: + return WalletDeleting( + wallet: selectedWallet, + close: _cancel, + ); + case WalletsManagerExistWalletAction.logIn: + case WalletsManagerExistWalletAction.none: + return WalletLogIn( + wallet: selectedWallet, + onLogin: _logInToWallet, + onCancel: _cancel, + errorText: _errorText, + ); + } + } + switch (_action) { + case WalletsManagerAction.import: + return WalletImportWrapper( + key: const Key('wallet-import'), + onImport: _importWallet, + onCreate: _createWallet, + onCancel: _cancel, + ); + case WalletsManagerAction.create: + case WalletsManagerAction.none: + return WalletCreation( + action: _action, + key: const Key('wallet-creation'), + onCreate: _createWallet, + onCancel: _cancel, + ); + } + } + + Widget _buildMobileContent() { + return SingleChildScrollView( + controller: ScrollController(), + child: Stack( + children: [ + _buildContent(), + if (_isLoading) + Positioned.fill( + child: Container( + color: Colors.transparent, + alignment: Alignment.center, + child: const UiSpinner(), + ), + ), + ], + ), + ); + } + + Widget _buildNormalContent() { + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 530), + child: Stack( + children: [ + _buildContent(), + if (_isLoading) + Positioned.fill( + child: Container( + color: Colors.transparent, + alignment: Alignment.center, + child: const UiSpinner(), + ), + ), + ], + ), + ); + } + + void _cancel() { + setState(() { + _errorText = null; + _selectedWallet = null; + _action = WalletsManagerAction.none; + _existWalletAction = WalletsManagerExistWalletAction.none; + }); + } + + Future _createWallet({ + required String name, + required String password, + required String seed, + }) async { + setState(() { + _isLoading = true; + }); + final Wallet? newWallet = await walletsBloc.createNewWallet( + name: name, + password: password, + seed: seed, + ); + + if (newWallet == null) { + setState(() { + _errorText = + LocaleKeys.walletsManagerStepBuilderCreationWalletError.tr(); + }); + + return; + } + + await _reLogin( + seed, + newWallet, + walletsManagerEventsFactory.createEvent( + widget.eventType, WalletsManagerEventMethod.create), + ); + } + + Future _importWallet({ + required String name, + required String password, + required WalletConfig walletConfig, + }) async { + setState(() { + _isLoading = true; + }); + final Wallet? newWallet = await walletsBloc.importWallet( + name: name, + password: password, + walletConfig: walletConfig, + ); + + if (newWallet == null) { + setState(() { + _errorText = + LocaleKeys.walletsManagerStepBuilderCreationWalletError.tr(); + }); + + return; + } + + await _reLogin( + walletConfig.seedPhrase, + newWallet, + walletsManagerEventsFactory.createEvent( + widget.eventType, WalletsManagerEventMethod.import)); + } + + Future _logInToWallet(String password, Wallet wallet) async { + setState(() { + _isLoading = true; + _errorText = null; + }); + + final String seed = await wallet.getSeed(password); + if (seed.isEmpty) { + setState(() { + _isLoading = false; + _errorText = LocaleKeys.invalidPasswordError.tr(); + }); + + return; + } + await _reLogin( + seed, + wallet, + walletsManagerEventsFactory.createEvent( + widget.eventType, WalletsManagerEventMethod.loginExisting), + ); + } + + void _onLogIn() { + final wallet = currentWalletBloc.wallet; + _action = WalletsManagerAction.none; + if (wallet != null) { + widget.onSuccess(wallet); + } + + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + + Future _reLogin( + String seed, Wallet wallet, AnalyticsEventData analyticsEventData) async { + final AnalyticsBloc analyticsBloc = context.read(); + final AuthBloc authBloc = context.read(); + if (await authBloc.isLoginAllowed(wallet)) { + analyticsBloc.add(AnalyticsSendDataEvent(analyticsEventData)); + authBloc.add(AuthReLogInEvent(seed: seed, wallet: wallet)); + } + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } +} diff --git a/lib/views/wallets_manager/widgets/wallet_creation.dart b/lib/views/wallets_manager/widgets/wallet_creation.dart new file mode 100644 index 0000000000..aa9173fb45 --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_creation.dart @@ -0,0 +1,148 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallets_manager_models.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/disclaimer/eula_tos_checkboxes.dart'; +import 'package:web_dex/views/wallets_manager/widgets/creation_password_fields.dart'; + +class WalletCreation extends StatefulWidget { + const WalletCreation({ + Key? key, + required this.action, + required this.onCreate, + required this.onCancel, + }) : super(key: key); + + final WalletsManagerAction action; + final void Function({ + required String name, + required String password, + required String seed, + }) onCreate; + final void Function() onCancel; + + @override + State createState() => _WalletCreationState(); +} + +class _WalletCreationState extends State { + final TextEditingController _nameController = TextEditingController(text: ''); + final TextEditingController _passwordController = + TextEditingController(text: ''); + final GlobalKey _formKey = GlobalKey(); + bool _eulaAndTosChecked = false; + bool _inProgress = false; + + @override + Widget build(BuildContext context) { + return 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, + ), + onPressed: _isCreateButtonEnabled ? _onCreate : null, + ), + const SizedBox(height: 20), + UiUnderlineTextButton( + onPressed: widget.onCancel, + text: LocaleKeys.cancel.tr(), + ), + ], + ), + ); + } + + @override + void dispose() { + _nameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Widget _buildFields() { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildNameField(), + const SizedBox(height: 20), + const UiDivider(), + const SizedBox(height: 20), + CreationPasswordFields( + passwordController: _passwordController, + onFieldSubmitted: (_) { + if (_isCreateButtonEnabled) _onCreate(); + }, + ), + const SizedBox(height: 16), + ], + ); + } + + Widget _buildNameField() { + return UiTextFormField( + key: const Key('name-wallet-field'), + controller: _nameController, + autofocus: true, + autocorrect: false, + textInputAction: TextInputAction.next, + enableInteractiveSelection: true, + validator: (String? name) => + _inProgress ? null : walletsBloc.validateWalletName(name ?? ''), + inputFormatters: [LengthLimitingTextInputFormatter(40)], + hintText: LocaleKeys.walletCreationNameHint.tr(), + ); + } + + void _onCreate() { + if (!_eulaAndTosChecked) return; + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() => _inProgress = true); + + WidgetsBinding.instance.addPostFrameCallback((timeStamp) { + final String seed = generateSeed(); + + widget.onCreate( + name: _nameController.text, + password: _passwordController.text, + seed: seed, + ); + }); + } + + bool get _isCreateButtonEnabled => _eulaAndTosChecked && !_inProgress; +} diff --git a/lib/views/wallets_manager/widgets/wallet_deleting.dart b/lib/views/wallets_manager/widgets/wallet_deleting.dart new file mode 100644 index 0000000000..74b643a9e2 --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_deleting.dart @@ -0,0 +1,120 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class WalletDeleting extends StatefulWidget { + const WalletDeleting({ + super.key, + required this.wallet, + required this.close, + }); + final Wallet wallet; + final VoidCallback close; + + @override + State createState() => _WalletDeletingState(); +} + +class _WalletDeletingState extends State { + bool _isDeleting = false; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + _buildHeader(), + Padding( + padding: const EdgeInsets.only(top: 18.0), + child: Text( + LocaleKeys.deleteWalletTitle.tr(args: [widget.wallet.name]), + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 8.0), + child: Text( + LocaleKeys.deleteWalletInfo.tr(), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 25.0), + child: _buildButtons(), + ), + ], + ); + } + + Widget _buildHeader() { + return Row( + children: [ + IconButton( + alignment: Alignment.center, + padding: const EdgeInsets.all(0), + icon: Icon( + Icons.chevron_left, + color: theme.custom.headerIconColor, + ), + splashRadius: 15, + iconSize: 18, + onPressed: widget.close, + ), + Text( + LocaleKeys.back.tr(), + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith(fontWeight: FontWeight.w600, fontSize: 16), + ), + ], + ); + } + + Widget _buildButtons() { + return Row( + children: [ + Flexible( + child: UiBorderButton( + text: LocaleKeys.cancel.tr(), + onPressed: widget.close, + height: 40, + width: 150, + borderWidth: 2, + borderColor: theme.custom.specificButtonBorderColor, + ), + ), + const SizedBox(width: 8.0), + Flexible( + child: UiPrimaryButton( + text: LocaleKeys.delete.tr(), + onPressed: _isDeleting ? null : _deleteWallet, + prefix: _isDeleting ? const UiSpinner() : null, + height: 40, + width: 150, + ), + ) + ], + ); + } + + Future _deleteWallet() async { + setState(() { + _isDeleting = true; + }); + await walletsBloc.deleteWallet(widget.wallet); + setState(() { + _isDeleting = false; + }); + widget.close(); + } +} diff --git a/lib/views/wallets_manager/widgets/wallet_import_by_file.dart b/lib/views/wallets_manager/widgets/wallet_import_by_file.dart new file mode 100644 index 0000000000..8803ab1eec --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_import_by_file.dart @@ -0,0 +1,194 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.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/password_visibility_control.dart'; + +class WalletFileData { + const WalletFileData({required this.content, required this.name}); + final String content; + final String name; +} + +class WalletImportByFile extends StatefulWidget { + const WalletImportByFile({ + Key? key, + required this.fileData, + required this.onImport, + required this.onCancel, + }) : super(key: key); + final WalletFileData fileData; + + final void Function({ + required String name, + required String password, + required WalletConfig walletConfig, + }) onImport; + final void Function() onCancel; + + @override + State createState() => _WalletImportByFileState(); +} + +class _WalletImportByFileState extends State { + final TextEditingController _filePasswordController = + TextEditingController(text: ''); + final GlobalKey _formKey = GlobalKey(); + bool _isObscured = true; + + String? _filePasswordError; + String? _commonError; + + bool get _isValidData { + return _filePasswordError == null; + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + LocaleKeys.walletImportByFileTitle.tr(), + style: Theme.of(context).textTheme.titleLarge!.copyWith( + fontSize: 18, + ), + ), + const SizedBox(height: 36), + Text(LocaleKeys.walletImportByFileDescription.tr(), + style: Theme.of(context).textTheme.bodyLarge), + const SizedBox(height: 22), + Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + UiTextFormField( + key: const Key('file-password-field'), + controller: _filePasswordController, + textInputAction: TextInputAction.next, + autocorrect: false, + enableInteractiveSelection: true, + obscureText: _isObscured, + 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, + ), + const SizedBox(width: 12), + 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), + const UiDivider(), + const SizedBox(height: 30), + UiPrimaryButton( + key: const Key('confirm-password-button'), + height: 50, + text: LocaleKeys.import.tr(), + onPressed: _onImport, + ), + const SizedBox(height: 20), + UiUnderlineTextButton( + onPressed: widget.onCancel, + text: LocaleKeys.back.tr(), + ), + ], + ), + ), + ], + ); + } + + @override + void dispose() { + _filePasswordController.dispose(); + + super.dispose(); + } + + Future _onImport() async { + final EncryptionTool encryptionTool = EncryptionTool(); + final String? fileData = await encryptionTool.decryptData( + _filePasswordController.text, + widget.fileData.content, + ); + if (fileData == null) { + setState(() { + _filePasswordError = LocaleKeys.invalidPasswordError.tr(); + }); + _formKey.currentState?.validate(); + return; + } else { + setState(() { + _filePasswordError = null; + }); + } + _formKey.currentState?.validate(); + try { + final WalletConfig walletConfig = + WalletConfig.fromJson(json.decode(fileData)); + final String? decryptedSeed = await encryptionTool.decryptData( + _filePasswordController.text, walletConfig.seedPhrase); + if (decryptedSeed == null) return; + if (!_isValidData) return; + + walletConfig.seedPhrase = decryptedSeed; + + final String name = widget.fileData.name.split('.').first; + final bool isNameExisted = + walletsBloc.wallets.firstWhereOrNull((w) => w.name == name) != null; + if (isNameExisted) { + setState(() { + _commonError = LocaleKeys.walletCreationExistNameError.tr(); + }); + return; + } + widget.onImport( + name: name, + password: _filePasswordController.text, + walletConfig: walletConfig, + ); + } catch (_) { + setState(() { + _commonError = LocaleKeys.somethingWrong.tr(); + }); + } + } +} diff --git a/lib/views/wallets_manager/widgets/wallet_import_wrapper.dart b/lib/views/wallets_manager/widgets/wallet_import_wrapper.dart new file mode 100644 index 0000000000..026e338e2e --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_import_wrapper.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_import_by_file.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_simple_import.dart'; + +class WalletImportWrapper extends StatefulWidget { + const WalletImportWrapper({ + Key? key, + required this.onCreate, + required this.onImport, + required this.onCancel, + }) : super(key: key); + + final void Function({ + required String name, + required String password, + required String seed, + }) onCreate; + final void Function({ + required String name, + required String password, + required WalletConfig walletConfig, + }) onImport; + final void Function() onCancel; + + @override + State createState() => _WalletImportWrapperState(); +} + +class _WalletImportWrapperState extends State { + WalletImportTypes _importType = WalletImportTypes.simple; + WalletFileData? _fileData; + + @override + Widget build(BuildContext context) { + return _importType == WalletImportTypes.simple + ? _buildSimpleImport() + : _buildFileImport(); + } + + Widget _buildSimpleImport() { + return WalletSimpleImport( + onImport: widget.onImport, + onUploadFiles: _onUploadFiles, + onCancel: _onCancel, + ); + } + + Widget _buildFileImport() { + final WalletFileData? fileData = _fileData; + assert(fileData != null); + if (fileData != null) { + return WalletImportByFile( + fileData: fileData, + onImport: widget.onImport, + onCancel: _onCancel, + ); + } + return const SizedBox(); + } + + void _onUploadFiles({required String fileData, required String fileName}) { + setState(() { + _fileData = WalletFileData(content: fileData, name: fileName); + _importType = WalletImportTypes.file; + }); + } + + void _onCancel() { + if (_importType == WalletImportTypes.file) { + setState(() { + _importType = WalletImportTypes.simple; + _fileData = null; + }); + return; + } + + widget.onCancel(); + } +} + +enum WalletImportTypes { + simple, + file, +} diff --git a/lib/views/wallets_manager/widgets/wallet_list_item.dart b/lib/views/wallets_manager/widgets/wallet_list_item.dart new file mode 100644 index 0000000000..e1f31bacee --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_list_item.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/model/wallets_manager_models.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; + +class WalletListItem extends StatelessWidget { + const WalletListItem({Key? key, required this.wallet, required this.onClick}) + : super(key: key); + final Wallet wallet; + final void Function(Wallet, WalletsManagerExistWalletAction) onClick; + + @override + Widget build(BuildContext context) { + return UiPrimaryButton( + height: 40, + backgroundColor: Theme.of(context).colorScheme.onSurface, + onPressed: () => onClick(wallet, WalletsManagerExistWalletAction.logIn), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + DecoratedBox( + decoration: const BoxDecoration(shape: BoxShape.circle), + child: Icon( + Icons.person, + size: 21, + color: Theme.of(context).textTheme.labelLarge?.color, + ), + ), + const SizedBox(width: 8), + Expanded( + child: AutoScrollText( + text: wallet.name, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + IconButton( + onPressed: () => + onClick(wallet, WalletsManagerExistWalletAction.delete), + icon: const Icon(Icons.close)) + ], + ), + ); + } +} diff --git a/lib/views/wallets_manager/widgets/wallet_login.dart b/lib/views/wallets_manager/widgets/wallet_login.dart new file mode 100644 index 0000000000..dcb7f3d4f9 --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_login.dart @@ -0,0 +1,147 @@ +import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/blocs/blocs.dart'; +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:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/shared/widgets/password_visibility_control.dart'; + +class WalletLogIn extends StatefulWidget { + const WalletLogIn({ + Key? key, + required this.wallet, + required this.onLogin, + required this.onCancel, + this.errorText, + }) : super(key: key); + + final Wallet wallet; + final void Function(String, Wallet) onLogin; + final void Function() onCancel; + final String? errorText; + + @override + State createState() => _WalletLogInState(); +} + +class _WalletLogInState extends State { + bool _isPasswordObscured = true; + bool _errorDisplay = false; + final _backKeyButton = GlobalKey(); + final TextEditingController _passwordController = TextEditingController(); + bool _inProgress = false; + + @override + void dispose() { + _passwordController.dispose(); + super.dispose(); + } + + void _submitLogin() async { + final Wallet? wallet = + walletsBloc.wallets.firstWhereOrNull((w) => w.id == widget.wallet.id); + if (wallet == null) return; + + setState(() { + _errorDisplay = true; + _inProgress = true; + }); + + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onLogin( + _passwordController.text, + wallet, + ); + + if (mounted) setState(() => _inProgress = false); + }); + } + + @override + Widget build(BuildContext context) { + return 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), + _buildWalletField(), + const SizedBox( + height: 20, + ), + _buildPasswordField(), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 2.0), + child: UiPrimaryButton( + height: 50, + text: _inProgress + ? '${LocaleKeys.pleaseWait.tr()}...' + : LocaleKeys.logIn.tr(), + textStyle: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + onPressed: _inProgress ? null : _submitLogin, + ), + ), + const SizedBox(height: 20), + UiUnderlineTextButton( + key: _backKeyButton, + onPressed: () { + widget.onCancel(); + }, + text: LocaleKeys.cancel.tr(), + ), + ], + ); + } + + Widget _buildWalletField() { + return UiTextFormField( + key: const Key('wallet-field'), + initialValue: widget.wallet.name, + readOnly: true, + autocorrect: false, + enableInteractiveSelection: true, + ); + } + + Widget _buildPasswordField() { + return Stack( + children: [ + UiTextFormField( + key: const Key('create-password-field'), + controller: _passwordController, + textInputAction: TextInputAction.next, + autocorrect: false, + enableInteractiveSelection: true, + obscureText: _isPasswordObscured, + errorText: !_inProgress && _errorDisplay ? widget.errorText : null, + hintText: LocaleKeys.walletCreationPasswordHint.tr(), + onChanged: (text) { + if (text == '') { + setState(() { + _errorDisplay = false; + }); + } + }, + suffixIcon: PasswordVisibilityControl( + onVisibilityChange: (bool isPasswordObscured) { + setState(() { + _isPasswordObscured = isPasswordObscured; + }); + }, + ), + onFieldSubmitted: (text) { + if (!_inProgress) _submitLogin(); + }, + ), + ], + ); + } +} diff --git a/lib/views/wallets_manager/widgets/wallet_simple_import.dart b/lib/views/wallets_manager/widgets/wallet_simple_import.dart new file mode 100644 index 0000000000..569d8bfab1 --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_simple_import.dart @@ -0,0 +1,309 @@ +import 'package:bip39/bip39.dart' as bip39; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/services/file_loader/file_loader.dart'; +import 'package:web_dex/services/file_loader/get_file_loader.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.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/views/wallets_manager/widgets/creation_password_fields.dart'; +import 'package:web_dex/views/wallets_manager/widgets/custom_seed_dialog.dart'; + +class WalletSimpleImport extends StatefulWidget { + const WalletSimpleImport({ + Key? key, + required this.onImport, + required this.onUploadFiles, + required this.onCancel, + }) : super(key: key); + + final void Function({ + required String name, + required String password, + required WalletConfig walletConfig, + }) onImport; + + final void Function() onCancel; + + final void Function({required String fileName, required String fileData}) + onUploadFiles; + + @override + State createState() => _WalletImportWrapperState(); +} + +enum WalletSimpleImportSteps { + nameAndSeed, + password, +} + +class _WalletImportWrapperState extends State { + WalletSimpleImportSteps _step = WalletSimpleImportSteps.nameAndSeed; + final TextEditingController _nameController = TextEditingController(text: ''); + final TextEditingController _seedController = TextEditingController(text: ''); + final TextEditingController _passwordController = + TextEditingController(text: ''); + final GlobalKey _formKey = GlobalKey(); + bool _isSeedHidden = true; + bool _eulaAndTosChecked = false; + bool _inProgress = false; + bool? _allowCustomSeed; + + bool get _isButtonEnabled { + return _eulaAndTosChecked && !_inProgress; + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + SelectableText( + _step == WalletSimpleImportSteps.nameAndSeed + ? LocaleKeys.walletImportTitle.tr() + : LocaleKeys.walletImportCreatePasswordTitle + .tr(args: [_nameController.text]), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontSize: 18, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 36), + Form( + key: _formKey, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _buildFields(), + const SizedBox(height: 32), + 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, + ), + const SizedBox(height: 20), + UiUnderlineTextButton( + onPressed: _onCancel, + text: _step == WalletSimpleImportSteps.nameAndSeed + ? LocaleKeys.cancel.tr() + : LocaleKeys.back.tr(), + ), + ], + ), + ), + ], + ); + } + + @override + void dispose() { + _nameController.dispose(); + _seedController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Widget _buildCheckBoxCustomSeed() { + return UiCheckbox( + checkboxKey: const Key('checkbox-custom-seed'), + value: _allowCustomSeed!, + text: LocaleKeys.allowCustomFee.tr(), + onChanged: (bool? data) async { + if (data == null) return; + if (!_allowCustomSeed!) { + final bool confirmed = await customSeedDialog(context); + if (!confirmed) return; + } + + setState(() { + _allowCustomSeed = !_allowCustomSeed!; + }); + + if (_seedController.text.isNotEmpty && + _nameController.text.isNotEmpty) { + _formKey.currentState!.validate(); + } + }, + ); + } + + Widget _buildFields() { + switch (_step) { + case WalletSimpleImportSteps.nameAndSeed: + return _buildNameAndSeed(); + case WalletSimpleImportSteps.password: + return CreationPasswordFields( + passwordController: _passwordController, + onFieldSubmitted: !_isButtonEnabled + ? null + : (text) { + _onImport(); + }, + ); + } + } + + Widget _buildImportFileButton() { + return UploadButton( + buttonText: LocaleKeys.walletCreationUploadFile.tr(), + uploadFile: () async { + await fileLoader.upload( + onUpload: (fileName, fileData) => widget.onUploadFiles( + fileData: fileData ?? '', + fileName: fileName, + ), + onError: (String error) { + log( + error, + path: + 'wallet_simple_import => _buildImportFileButton => onErrorUploadFiles', + isError: true, + ); + }, + fileType: LoadFileType.text, + ); + }, + ); + } + + Widget _buildNameAndSeed() { + return Column( + children: [ + _buildNameField(), + const SizedBox(height: 16), + _buildSeedField(), + if (_allowCustomSeed != null) ...[ + const SizedBox(height: 15), + _buildCheckBoxCustomSeed(), + ], + const SizedBox(height: 25), + UiDivider(text: LocaleKeys.or.tr()), + const SizedBox(height: 20), + _buildImportFileButton(), + const SizedBox(height: 22), + EulaTosCheckboxes( + key: const Key('import-wallet-eula-checks'), + isChecked: _eulaAndTosChecked, + onCheck: (isChecked) { + setState(() { + _eulaAndTosChecked = isChecked; + }); + }, + ), + ], + ); + } + + Widget _buildNameField() { + return UiTextFormField( + key: const Key('name-wallet-field'), + controller: _nameController, + autofocus: true, + autocorrect: false, + textInputAction: TextInputAction.next, + enableInteractiveSelection: true, + validator: (String? name) => + _inProgress ? null : walletsBloc.validateWalletName(name ?? ''), + inputFormatters: [LengthLimitingTextInputFormatter(40)], + hintText: LocaleKeys.walletCreationNameHint.tr(), + validationMode: InputValidationMode.eager, + ); + } + + Widget _buildSeedField() { + return UiTextFormField( + key: const Key('import-seed-field'), + controller: _seedController, + autofocus: true, + validator: _validateSeed, + textInputAction: TextInputAction.done, + autocorrect: false, + obscureText: _isSeedHidden, + enableInteractiveSelection: true, + maxLines: _isSeedHidden ? 1 : null, + errorMaxLines: 4, + style: Theme.of(context).textTheme.bodyMedium, + hintText: LocaleKeys.importSeedEnterSeedPhraseHint.tr(), + suffixIcon: PasswordVisibilityControl( + onVisibilityChange: (bool isObscured) { + setState(() { + _isSeedHidden = isObscured; + }); + }, + ), + onFieldSubmitted: !_isButtonEnabled + ? null + : (text) { + _onImport(); + }, + ); + } + + void _onCancel() { + if (_step == WalletSimpleImportSteps.password) { + setState(() { + _step = WalletSimpleImportSteps.nameAndSeed; + }); + return; + } + widget.onCancel(); + } + + void _onImport() { + if (!(_formKey.currentState?.validate() ?? false)) { + return; + } + + if (_step == WalletSimpleImportSteps.nameAndSeed) { + setState(() { + _step = WalletSimpleImportSteps.password; + }); + return; + } + + final WalletConfig config = WalletConfig( + activatedCoins: enabledByDefaultCoins, + hasBackup: true, + seedPhrase: _seedController.text, + ); + + setState(() => _inProgress = true); + + WidgetsBinding.instance.addPostFrameCallback((_) { + widget.onImport( + name: _nameController.text, + password: _passwordController.text, + walletConfig: config, + ); + }); + } + + String? _validateSeed(String? seed) { + if (seed == null || seed.isEmpty) { + return LocaleKeys.walletCreationEmptySeedError.tr(); + } else if ((_allowCustomSeed != true) && !bip39.validateMnemonic(seed)) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _allowCustomSeed = false; + }); + } + }); + return LocaleKeys.walletCreationBip39SeedError.tr(); + } + return null; + } +} diff --git a/lib/views/wallets_manager/widgets/wallet_type_list_item.dart b/lib/views/wallets_manager/widgets/wallet_type_list_item.dart new file mode 100644 index 0000000000..1de5e7682f --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallet_type_list_item.dart @@ -0,0 +1,113 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:easy_localization/easy_localization.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/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class WalletTypeListItem extends StatelessWidget { + const WalletTypeListItem({ + Key? key, + required this.type, + required this.onClick, + }) : super(key: key); + final WalletType type; + final void Function(WalletType) onClick; + + @override + Widget build(BuildContext context) { + final bool needAttractAttention = type == WalletType.iguana; + final bool isSupported = _checkWalletSupport(type); + + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + UiPrimaryButton( + height: 50, + backgroundColor: needAttractAttention + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.tertiary, + onPressed: isSupported ? () => onClick(type) : null, + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + if (type != WalletType.iguana) + SvgPicture.asset( + _iconPath, + width: 25, + ), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + LocaleKeys.connectSomething.tr(args: [_walletTypeName]), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + if (!isSupported) + Text(LocaleKeys.comingSoon.tr(), + textAlign: TextAlign.right, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w500, + )), + ], + ), + ) + ], + ), + ) + ], + ); + } + + String get _iconPath { + switch (type) { + case WalletType.iguana: + return '$assetsPath/ui_icons/atomic_dex.svg'; + case WalletType.metamask: + return '$assetsPath/ui_icons/metamask.svg'; + case WalletType.keplr: + return '$assetsPath/ui_icons/keplr.svg'; + case WalletType.trezor: + if (theme.mode == ThemeMode.dark) { + return '$assetsPath/ui_icons/hardware_wallet.svg'; + } else { + return '$assetsPath/ui_icons/hardware_wallet_dark.svg'; + } + } + } + + String get _walletTypeName { + switch (type) { + case WalletType.iguana: + return LocaleKeys.komodoWalletSeed.tr(); + case WalletType.metamask: + return LocaleKeys.metamask.tr(); + case WalletType.keplr: + return 'Keplr'; + case WalletType.trezor: + return LocaleKeys.hardwareWallet.tr(); + } + } + + bool _checkWalletSupport(WalletType type) { + switch (type) { + case WalletType.iguana: + case WalletType.trezor: + return true; + case WalletType.keplr: + case WalletType.metamask: + return false; + } + } +} diff --git a/lib/views/wallets_manager/widgets/wallets_list.dart b/lib/views/wallets_manager/widgets/wallets_list.dart new file mode 100644 index 0000000000..f8b2d6d0e7 --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallets_list.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:web_dex/blocs/blocs.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/model/wallets_manager_models.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_list_item.dart'; + +class WalletsList extends StatelessWidget { + const WalletsList( + {Key? key, required this.walletType, required this.onWalletClick}) + : super(key: key); + final WalletType walletType; + final void Function(Wallet, WalletsManagerExistWalletAction) onWalletClick; + @override + Widget build(BuildContext context) { + return StreamBuilder>( + initialData: walletsBloc.wallets, + stream: walletsBloc.outWallets, + builder: (BuildContext context, AsyncSnapshot> snapshot) { + final List wallets = snapshot.data ?? []; + final List filteredWallets = + wallets.where((w) => w.config.type == walletType).toList(); + if (wallets.isEmpty) { + return const SizedBox(width: 0, height: 0); + } + final scrollController = ScrollController(); + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.onSurface, + borderRadius: BorderRadius.circular(18.0), + ), + child: DexScrollbar( + isMobile: isMobile, + scrollController: scrollController, + child: ListView.builder( + controller: scrollController, + itemCount: filteredWallets.length, + shrinkWrap: true, + itemBuilder: (BuildContext context, int i) { + return WalletListItem( + wallet: filteredWallets[i], + onClick: onWalletClick, + ); + }), + ), + ); + }, + ); + } +} diff --git a/lib/views/wallets_manager/widgets/wallets_manager.dart b/lib/views/wallets_manager/widgets/wallets_manager.dart new file mode 100644 index 0000000000..e9d4dea83a --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallets_manager.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/views/wallets_manager/wallets_manager_events_factory.dart'; +import 'package:web_dex/views/wallets_manager/widgets/hardware_wallets_manager.dart'; +import 'package:web_dex/views/wallets_manager/widgets/iguana_wallets_manager.dart'; + +class WalletsManager extends StatelessWidget { + const WalletsManager({ + Key? key, + required this.eventType, + required this.walletType, + required this.close, + required this.onSuccess, + }) : super(key: key); + final WalletsManagerEventType eventType; + final WalletType walletType; + final VoidCallback close; + final Function(Wallet) onSuccess; + + @override + Widget build(BuildContext context) { + switch (walletType) { + case WalletType.iguana: + return IguanaWalletsManager( + close: close, + onSuccess: onSuccess, + eventType: eventType, + ); + + case WalletType.trezor: + return HardwareWalletsManager( + close: close, + eventType: eventType, + ); + case WalletType.keplr: + case WalletType.metamask: + return const SizedBox(); + } + } +} diff --git a/lib/views/wallets_manager/widgets/wallets_manager_controls.dart b/lib/views/wallets_manager/widgets/wallets_manager_controls.dart new file mode 100644 index 0000000000..2424bcf907 --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallets_manager_controls.dart @@ -0,0 +1,82 @@ +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter/material.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/model/wallets_manager_models.dart'; +import 'package:web_dex/shared/ui/ui_primary_button.dart'; + +class WalletsManagerControls extends StatelessWidget { + const WalletsManagerControls({ + Key? key, + required this.onTap, + }) : super(key: key); + final Function(WalletsManagerAction) onTap; + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _buildCreateButton(context), + Padding( + padding: const EdgeInsets.only(top: 10.0), + child: _buildImportButton(context), + ), + ], + ); + } + + Widget _buildCreateButton(BuildContext context) { + return UiPrimaryButton( + key: const Key('create-wallet-button'), + height: 50, + backgroundColor: Theme.of(context).colorScheme.onSurface, + child: Row( + children: [ + Icon( + Icons.add, + color: Theme.of(context).textTheme.labelLarge?.color, + size: 15, + ), + Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Text( + LocaleKeys.walletsManagerCreateWalletButton.tr(), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + onPressed: () => onTap(WalletsManagerAction.create), + ); + } + + Widget _buildImportButton(BuildContext context) => UiPrimaryButton( + key: const Key('import-wallet-button'), + height: 50, + backgroundColor: Theme.of(context).colorScheme.onSurface, + onPressed: () => onTap(WalletsManagerAction.import), + child: Row( + children: [ + Icon( + Icons.download, + color: Theme.of(context).textTheme.labelLarge?.color, + size: 15, + ), + Padding( + padding: const EdgeInsets.only(left: 10.0), + child: Text( + LocaleKeys.walletsManagerImportWalletButton.tr(), + style: Theme.of(context).textTheme.labelLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ], + ), + ); +} diff --git a/lib/views/wallets_manager/widgets/wallets_type_list.dart b/lib/views/wallets_manager/widgets/wallets_type_list.dart new file mode 100644 index 0000000000..77b9735e92 --- /dev/null +++ b/lib/views/wallets_manager/widgets/wallets_type_list.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/views/wallets_manager/widgets/wallet_type_list_item.dart'; + +class WalletsTypeList extends StatelessWidget { + const WalletsTypeList({Key? key, required this.onWalletTypeClick}) + : super(key: key); + final void Function(WalletType) onWalletTypeClick; + + @override + Widget build(BuildContext context) { + return Column( + children: WalletType.values + .map((type) => Padding( + padding: const EdgeInsets.only(bottom: 12.0), + child: WalletTypeListItem( + key: Key('wallet-type-list-item-${type.name}'), + type: type, + onClick: onWalletTypeClick, + ), + )) + .toList(), + ); + } +} diff --git a/linux/.gitignore b/linux/.gitignore new file mode 100644 index 0000000000..d3896c9844 --- /dev/null +++ b/linux/.gitignore @@ -0,0 +1 @@ +flutter/ephemeral diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt new file mode 100644 index 0000000000..6e9c958168 --- /dev/null +++ b/linux/CMakeLists.txt @@ -0,0 +1,143 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.10) +project(runner LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "KomodoWallet") +# The unique GTK application identifier for this application. See: +# https://wiki.gnome.org/HowDoI/ChooseApplicationID +set(APPLICATION_ID "com.komodo.KomodoWallet") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Load bundled libraries from the lib/ directory relative to the binary. +set(CMAKE_INSTALL_RPATH "$ORIGIN/lib") + +# Root filesystem for cross-building. +if(FLUTTER_TARGET_PLATFORM_SYSROOT) + set(CMAKE_SYSROOT ${FLUTTER_TARGET_PLATFORM_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH ${CMAKE_SYSROOT}) + set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) + set(CMAKE_FIND_ROOT_PATH_MODE_PACKAGE ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) + set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) +endif() + +# Define build configuration options. +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") +endif() + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_14) + target_compile_options(${TARGET} PRIVATE -Wall -Werror) + target_compile_options(${TARGET} PRIVATE "$<$>:-O3>") + target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) + +add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") + +# Define the application target. To change its name, change BINARY_NAME above, +# not the value here, or `flutter run` will no longer work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} + "main.cc" + "my_application.cc" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" +) + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add dependency libraries. Add any application-specific dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter) +target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) + +# Only the install-generated bundle's copy of the executable will launch +# correctly, since the resources must in the right relative locations. To avoid +# people trying to run the unbundled copy, put it in a subdirectory instead of +# the default top-level location. +set_target_properties(${BINARY_NAME} + PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/intermediates_do_not_run" +) + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# By default, "installing" just makes a relocatable bundle in the build +# directory. +set(BUILD_BUNDLE_DIR "${PROJECT_BINARY_DIR}/bundle") +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +# Start with a clean build bundle directory every time. +install(CODE " + file(REMOVE_RECURSE \"${BUILD_BUNDLE_DIR}/\") + " COMPONENT Runtime) + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}/lib") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +foreach(bundled_library ${PLUGIN_BUNDLED_LIBRARIES}) + install(FILES "${bundled_library}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endforeach(bundled_library) + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +if(NOT CMAKE_BUILD_TYPE MATCHES "Debug") + install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +install(CODE " + configure_file(\"${CMAKE_CURRENT_SOURCE_DIR}/mm2/mm2\" \"${CMAKE_INSTALL_PREFIX}/mm2\" COPYONLY) + " +) \ No newline at end of file diff --git a/linux/KomodoWallet.desktop b/linux/KomodoWallet.desktop new file mode 100644 index 0000000000..7bf01979b6 --- /dev/null +++ b/linux/KomodoWallet.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Version=1.0 +Type=Application +Name=Komodo Wallet +Exec=./KomodoWallet +Icon=./KomodoWallet.svg +Categories=Office;Finance; +Terminal=false +StartupNotify=true \ No newline at end of file diff --git a/linux/KomodoWallet.svg b/linux/KomodoWallet.svg new file mode 100644 index 0000000000..947ab6afe3 --- /dev/null +++ b/linux/KomodoWallet.svg @@ -0,0 +1 @@ +komodo-sign_gradient \ No newline at end of file diff --git a/linux/flutter/CMakeLists.txt b/linux/flutter/CMakeLists.txt new file mode 100644 index 0000000000..d5bd01648a --- /dev/null +++ b/linux/flutter/CMakeLists.txt @@ -0,0 +1,88 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.10) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. + +# Serves the same purpose as list(TRANSFORM ... PREPEND ...), +# which isn't available in 3.10. +function(list_prepend LIST_NAME PREFIX) + set(NEW_LIST "") + foreach(element ${${LIST_NAME}}) + list(APPEND NEW_LIST "${PREFIX}${element}") + endforeach(element) + set(${LIST_NAME} "${NEW_LIST}" PARENT_SCOPE) +endfunction() + +# === Flutter Library === +# System-level dependencies. +find_package(PkgConfig REQUIRED) +pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) +pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) +pkg_check_modules(GIO REQUIRED IMPORTED_TARGET gio-2.0) + +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/libflutter_linux_gtk.so") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/lib/libapp.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "fl_basic_message_channel.h" + "fl_binary_codec.h" + "fl_binary_messenger.h" + "fl_dart_project.h" + "fl_engine.h" + "fl_json_message_codec.h" + "fl_json_method_codec.h" + "fl_message_codec.h" + "fl_method_call.h" + "fl_method_channel.h" + "fl_method_codec.h" + "fl_method_response.h" + "fl_plugin_registrar.h" + "fl_plugin_registry.h" + "fl_standard_message_codec.h" + "fl_standard_method_codec.h" + "fl_string_codec.h" + "fl_value.h" + "fl_view.h" + "flutter_linux.h" +) +list_prepend(FLUTTER_LIBRARY_HEADERS "${EPHEMERAL_DIR}/flutter_linux/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}") +target_link_libraries(flutter INTERFACE + PkgConfig::GTK + PkgConfig::GLIB + PkgConfig::GIO +) +add_dependencies(flutter flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CMAKE_CURRENT_BINARY_DIR}/_phony_ + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.sh" + ${FLUTTER_TARGET_PLATFORM} ${CMAKE_BUILD_TYPE} + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} +) diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..55e53518fd --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) desktop_webview_window_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewWindowPlugin"); + desktop_webview_window_plugin_register_with_registrar(desktop_webview_window_registrar); + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); + g_autoptr(FlPluginRegistrar) window_size_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "WindowSizePlugin"); + window_size_plugin_register_with_registrar(window_size_registrar); +} diff --git a/linux/flutter/generated_plugin_registrant.h b/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..e0f0a47bc0 --- /dev/null +++ b/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..8d2c737526 --- /dev/null +++ b/linux/flutter/generated_plugins.cmake @@ -0,0 +1,26 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_webview_window + url_launcher_linux + window_size +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/linux/main.cc b/linux/main.cc new file mode 100644 index 0000000000..e7c5c54370 --- /dev/null +++ b/linux/main.cc @@ -0,0 +1,6 @@ +#include "my_application.h" + +int main(int argc, char** argv) { + g_autoptr(MyApplication) app = my_application_new(); + return g_application_run(G_APPLICATION(app), argc, argv); +} diff --git a/linux/mm2/.gitkeep b/linux/mm2/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/linux/my_application.cc b/linux/my_application.cc new file mode 100644 index 0000000000..d7f5d5e7d6 --- /dev/null +++ b/linux/my_application.cc @@ -0,0 +1,112 @@ +#include "my_application.h" + +#include +#ifdef GDK_WINDOWING_X11 +#include +#endif + +#include "flutter/generated_plugin_registrant.h" + +struct _MyApplication { + GtkApplication parent_instance; + char** dart_entrypoint_arguments; +}; + +G_DEFINE_TYPE(MyApplication, my_application, GTK_TYPE_APPLICATION) + +// Implements GApplication::activate. +static void my_application_activate(GApplication* application) { + MyApplication* self = MY_APPLICATION(application); + GtkWindow* window = + GTK_WINDOW(gtk_application_window_new(GTK_APPLICATION(application))); + + // Set window icon + GError* error = NULL; + gtk_window_set_icon_from_file(window, "KomodoWallet.svg", &error); + if (error) { + g_warning("Failed to set window icon: %s", error->message); + g_error_free(error); + } + + // Use a header bar when running in GNOME as this is the common style used + // by applications and is the setup most users will be using (e.g. Ubuntu + // desktop). + // If running on X and not using GNOME then just use a traditional title bar + // in case the window manager does more exotic layout, e.g. tiling. + // If running on Wayland assume the header bar will work (may need changing + // if future cases occur). + gboolean use_header_bar = TRUE; +#ifdef GDK_WINDOWING_X11 + GdkScreen* screen = gtk_window_get_screen(window); + if (GDK_IS_X11_SCREEN(screen)) { + const gchar* wm_name = gdk_x11_screen_get_window_manager_name(screen); + if (g_strcmp0(wm_name, "GNOME Shell") != 0) { + use_header_bar = FALSE; + } + } +#endif + if (use_header_bar) { + GtkHeaderBar* header_bar = GTK_HEADER_BAR(gtk_header_bar_new()); + gtk_widget_show(GTK_WIDGET(header_bar)); + gtk_header_bar_set_title(header_bar, "KomodoWallet"); + gtk_header_bar_set_show_close_button(header_bar, TRUE); + gtk_window_set_titlebar(window, GTK_WIDGET(header_bar)); + } else { + gtk_window_set_title(window, "KomodoWallet"); + } + + gtk_window_set_default_size(window, 1280, 720); + gtk_widget_show(GTK_WIDGET(window)); + + g_autoptr(FlDartProject) project = fl_dart_project_new(); + fl_dart_project_set_dart_entrypoint_arguments(project, self->dart_entrypoint_arguments); + + FlView* view = fl_view_new(project); + gtk_widget_show(GTK_WIDGET(view)); + gtk_container_add(GTK_CONTAINER(window), GTK_WIDGET(view)); + + fl_register_plugins(FL_PLUGIN_REGISTRY(view)); + + gtk_widget_grab_focus(GTK_WIDGET(view)); +} + +// Implements GApplication::local_command_line. +static gboolean my_application_local_command_line(GApplication* application, gchar*** arguments, int* exit_status) { + MyApplication* self = MY_APPLICATION(application); + // Strip out the first argument as it is the binary name. + self->dart_entrypoint_arguments = g_strdupv(*arguments + 1); + + g_autoptr(GError) error = nullptr; + if (!g_application_register(application, nullptr, &error)) { + g_warning("Failed to register: %s", error->message); + *exit_status = 1; + return TRUE; + } + + g_application_activate(application); + *exit_status = 0; + + return TRUE; +} + +// Implements GObject::dispose. +static void my_application_dispose(GObject* object) { + MyApplication* self = MY_APPLICATION(object); + g_clear_pointer(&self->dart_entrypoint_arguments, g_strfreev); + G_OBJECT_CLASS(my_application_parent_class)->dispose(object); +} + +static void my_application_class_init(MyApplicationClass* klass) { + G_APPLICATION_CLASS(klass)->activate = my_application_activate; + G_APPLICATION_CLASS(klass)->local_command_line = my_application_local_command_line; + G_OBJECT_CLASS(klass)->dispose = my_application_dispose; +} + +static void my_application_init(MyApplication* self) {} + +MyApplication* my_application_new() { + return MY_APPLICATION(g_object_new(my_application_get_type(), + "application-id", APPLICATION_ID, + "flags", G_APPLICATION_NON_UNIQUE, + nullptr)); +} diff --git a/linux/my_application.h b/linux/my_application.h new file mode 100644 index 0000000000..72271d5e41 --- /dev/null +++ b/linux/my_application.h @@ -0,0 +1,18 @@ +#ifndef FLUTTER_MY_APPLICATION_H_ +#define FLUTTER_MY_APPLICATION_H_ + +#include + +G_DECLARE_FINAL_TYPE(MyApplication, my_application, MY, APPLICATION, + GtkApplication) + +/** + * my_application_new: + * + * Creates a new Flutter-based application. + * + * Returns: a new #MyApplication. + */ +MyApplication* my_application_new(); + +#endif // FLUTTER_MY_APPLICATION_H_ diff --git a/macos/.gitignore b/macos/.gitignore new file mode 100644 index 0000000000..d2fd377230 --- /dev/null +++ b/macos/.gitignore @@ -0,0 +1,6 @@ +# Flutter-related +**/Flutter/ephemeral/ +**/Pods/ + +# Xcode-related +**/xcuserdata/ diff --git a/macos/Flutter/Flutter-Debug.xcconfig b/macos/Flutter/Flutter-Debug.xcconfig new file mode 100644 index 0000000000..785633d3a8 --- /dev/null +++ b/macos/Flutter/Flutter-Debug.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/Flutter-Release.xcconfig b/macos/Flutter/Flutter-Release.xcconfig new file mode 100644 index 0000000000..5fba960c3a --- /dev/null +++ b/macos/Flutter/Flutter-Release.xcconfig @@ -0,0 +1,2 @@ +#include "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" +#include "ephemeral/Flutter-Generated.xcconfig" diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000000..c287d6c379 --- /dev/null +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,34 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import desktop_webview_window +import firebase_analytics +import firebase_core +import flutter_inappwebview_macos +import mobile_scanner +import package_info_plus +import path_provider_foundation +import share_plus +import shared_preferences_foundation +import url_launcher_macos +import video_player_avfoundation +import window_size + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + DesktopWebviewWindowPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewWindowPlugin")) + FLTFirebaseAnalyticsPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAnalyticsPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) + FLTPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FLTPackageInfoPlusPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) + FVPVideoPlayerPlugin.register(with: registry.registrar(forPlugin: "FVPVideoPlayerPlugin")) + WindowSizePlugin.register(with: registry.registrar(forPlugin: "WindowSizePlugin")) +} diff --git a/macos/Podfile b/macos/Podfile new file mode 100644 index 0000000000..1560be84db --- /dev/null +++ b/macos/Podfile @@ -0,0 +1,46 @@ +platform :osx, '10.14' + +# CocoaPods analytics sends network stats synchronously affecting flutter build latency. +ENV['COCOAPODS_DISABLE_STATS'] = 'true' + +project 'Runner', { + 'Debug' => :debug, + 'Profile' => :release, + 'Release' => :release, +} + +def flutter_root + generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'ephemeral', 'Flutter-Generated.xcconfig'), __FILE__) + unless File.exist?(generated_xcode_build_settings_path) + raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure \"flutter pub get\" is executed first" + end + + File.foreach(generated_xcode_build_settings_path) do |line| + matches = line.match(/FLUTTER_ROOT\=(.*)/) + return matches[1].strip if matches + end + raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Flutter-Generated.xcconfig, then run \"flutter pub get\"" +end + +require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root) + +flutter_macos_podfile_setup + +target 'Runner' do + use_frameworks! + use_modular_headers! + + flutter_install_all_macos_pods File.dirname(File.realpath(__FILE__)) + # target 'RunnerTests' do + # inherit! :search_paths + # end +end + +post_install do |installer| + installer.pods_project.targets.each do |target| + flutter_additional_macos_build_settings(target) + target.build_configurations.each do |config| + config.build_settings['MACOSX_DEPLOYMENT_TARGET'] = '11.0' + end + end +end diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000000..156a72c5ad --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,209 @@ +PODS: + - desktop_webview_window (0.0.1): + - FlutterMacOS + - Firebase/Analytics (10.25.0): + - Firebase/Core + - Firebase/Core (10.25.0): + - Firebase/CoreOnly + - FirebaseAnalytics (~> 10.25.0) + - Firebase/CoreOnly (10.25.0): + - FirebaseCore (= 10.25.0) + - firebase_analytics (10.10.5): + - Firebase/Analytics (= 10.25.0) + - firebase_core + - FlutterMacOS + - firebase_core (2.31.0): + - Firebase/CoreOnly (~> 10.25.0) + - FlutterMacOS + - FirebaseAnalytics (10.25.0): + - FirebaseAnalytics/AdIdSupport (= 10.25.0) + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - FirebaseAnalytics/AdIdSupport (10.25.0): + - FirebaseCore (~> 10.0) + - FirebaseInstallations (~> 10.0) + - GoogleAppMeasurement (= 10.25.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - FirebaseCore (10.25.0): + - FirebaseCoreInternal (~> 10.0) + - GoogleUtilities/Environment (~> 7.12) + - GoogleUtilities/Logger (~> 7.12) + - FirebaseCoreInternal (10.28.0): + - "GoogleUtilities/NSData+zlib (~> 7.8)" + - FirebaseInstallations (10.28.0): + - FirebaseCore (~> 10.0) + - GoogleUtilities/Environment (~> 7.8) + - GoogleUtilities/UserDefaults (~> 7.8) + - PromisesObjC (~> 2.1) + - flutter_inappwebview_macos (0.0.1): + - FlutterMacOS + - OrderedSet (~> 5.0) + - FlutterMacOS (1.0.0) + - GoogleAppMeasurement (10.25.0): + - GoogleAppMeasurement/AdIdSupport (= 10.25.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleAppMeasurement/AdIdSupport (10.25.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 10.25.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleAppMeasurement/WithoutAdIdSupport (10.25.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleUtilities/AppDelegateSwizzler (7.13.3): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (7.13.3): + - GoogleUtilities/Privacy + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.13.3): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/Network (7.13.3): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.13.3)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (7.13.3) + - GoogleUtilities/Reachability (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/UserDefaults (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - mobile_scanner (5.1.1): + - FlutterMacOS + - nanopb (2.30910.0): + - nanopb/decode (= 2.30910.0) + - nanopb/encode (= 2.30910.0) + - nanopb/decode (2.30910.0) + - nanopb/encode (2.30910.0) + - OrderedSet (5.0.0) + - package_info_plus (0.0.1): + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - PromisesObjC (2.4.0) + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - video_player_avfoundation (0.0.1): + - Flutter + - FlutterMacOS + - window_size (0.0.2): + - FlutterMacOS + +DEPENDENCIES: + - desktop_webview_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos`) + - firebase_analytics (from `Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos`) + - firebase_core (from `Flutter/ephemeral/.symlinks/plugins/firebase_core/macos`) + - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - mobile_scanner (from `Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos`) + - package_info_plus (from `Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - video_player_avfoundation (from `Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin`) + - window_size (from `Flutter/ephemeral/.symlinks/plugins/window_size/macos`) + +SPEC REPOS: + trunk: + - Firebase + - FirebaseAnalytics + - FirebaseCore + - FirebaseCoreInternal + - FirebaseInstallations + - GoogleAppMeasurement + - GoogleUtilities + - nanopb + - OrderedSet + - PromisesObjC + +EXTERNAL SOURCES: + desktop_webview_window: + :path: Flutter/ephemeral/.symlinks/plugins/desktop_webview_window/macos + firebase_analytics: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_analytics/macos + firebase_core: + :path: Flutter/ephemeral/.symlinks/plugins/firebase_core/macos + flutter_inappwebview_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + mobile_scanner: + :path: Flutter/ephemeral/.symlinks/plugins/mobile_scanner/macos + package_info_plus: + :path: Flutter/ephemeral/.symlinks/plugins/package_info_plus/macos + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + video_player_avfoundation: + :path: Flutter/ephemeral/.symlinks/plugins/video_player_avfoundation/darwin + window_size: + :path: Flutter/ephemeral/.symlinks/plugins/window_size/macos + +SPEC CHECKSUMS: + desktop_webview_window: d4365e71bcd4e1aa0c14cf0377aa24db0c16a7e2 + Firebase: 0312a2352584f782ea56f66d91606891d4607f06 + firebase_analytics: 25af54d88e440c4f65ae10a31f3a57268416ce82 + firebase_core: fdf12e0c4349815c2e832d9dcad59fbff0ff394b + FirebaseAnalytics: ec00fe8b93b41dc6fe4a28784b8e51da0647a248 + FirebaseCore: 7ec4d0484817f12c3373955bc87762d96842d483 + FirebaseCoreInternal: 58d07f1362fddeb0feb6a857d1d1d1c5e558e698 + FirebaseInstallations: 60c1d3bc1beef809fd1ad1189a8057a040c59f2e + flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + GoogleAppMeasurement: 9abf64b682732fed36da827aa2a68f0221fd2356 + GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 + mobile_scanner: 1efac1e53c294b24e3bb55bcc7f4deee0233a86b + nanopb: 438bc412db1928dac798aa6fd75726007be04262 + OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c + package_info_plus: 02d7a575e80f194102bef286361c6c326e4c29ce + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + url_launcher_macos: 5f437abeda8c85500ceb03f5c1938a8c5a705399 + video_player_avfoundation: 7c6c11d8470e1675df7397027218274b6d2360b3 + window_size: 339dafa0b27a95a62a843042038fa6c3c48de195 + +PODFILE CHECKSUM: 837d51985fe358f89b82d0f3805fc2fd357bd915 + +COCOAPODS: 1.15.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj new file mode 100644 index 0000000000..2119f7e23a --- /dev/null +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -0,0 +1,740 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 54; + objects = { + +/* Begin PBXAggregateTarget section */ + 33CC111A2044C6BA0003C045 /* Flutter Assemble */ = { + isa = PBXAggregateTarget; + buildConfigurationList = 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */; + buildPhases = ( + 33CC111E2044C6BF0003C045 /* ShellScript */, + ); + dependencies = ( + ); + name = "Flutter Assemble"; + productName = FLX; + }; +/* End PBXAggregateTarget section */ + +/* Begin PBXBuildFile section */ + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */; }; + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC10F02044A3C60003C045 /* AppDelegate.swift */; }; + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + D60A10D52711A1B300EB58E3 /* CoreFoundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D60A10D42711A1B300EB58E3 /* CoreFoundation.framework */; platformFilter = maccatalyst; }; + D60A10D72711A1D000EB58E3 /* SystemConfiguration.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = D60A10D62711A1D000EB58E3 /* SystemConfiguration.framework */; platformFilter = maccatalyst; }; + D68B8E5A2710401800D6C7D1 /* mm2.m in Sources */ = {isa = PBXBuildFile; fileRef = D68B8E592710401800D6C7D1 /* mm2.m */; }; + D68B8E5C2710416100D6C7D1 /* libmm2.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D68B8E5B2710416000D6C7D1 /* libmm2.a */; }; + D6B034F02711A360007FC221 /* libz.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = D6B034EF2711A360007FC221 /* libz.tbd */; }; + 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 */ + 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC111A2044C6BA0003C045; + remoteInfo = FLX; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXCopyFilesBuildPhase section */ + 33CC110E2044A8840003C045 /* Bundle Framework */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Bundle Framework"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + +/* Begin PBXFileReference section */ + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; + 33CC10ED2044A3C60003C045 /* Komodo Wallet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Komodo Wallet.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; + 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; + 33CC10F72044A3C60003C045 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; name = Info.plist; path = Runner/Info.plist; sourceTree = ""; }; + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainFlutterWindow.swift; sourceTree = ""; }; + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Debug.xcconfig"; sourceTree = ""; }; + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = "Flutter-Release.xcconfig"; sourceTree = ""; }; + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = "Flutter-Generated.xcconfig"; path = "ephemeral/Flutter-Generated.xcconfig"; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + CBED5C6C4A1CA4CE9B9F2193 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 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; }; + D68B8E572710401800D6C7D1 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; + D68B8E582710401800D6C7D1 /* mm2.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = mm2.h; sourceTree = ""; }; + D68B8E592710401800D6C7D1 /* mm2.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = mm2.m; sourceTree = ""; }; + D68B8E5B2710416000D6C7D1 /* libmm2.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libmm2.a; sourceTree = ""; }; + 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 */ + 33CC10EA2044A3C60003C045 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + D6B034F02711A360007FC221 /* libz.tbd in Frameworks */, + D6F2739D2710691C005CC4F3 /* libc++.tbd in Frameworks */, + D60A10D72711A1D000EB58E3 /* SystemConfiguration.framework in Frameworks */, + D68B8E5C2710416100D6C7D1 /* libmm2.a in Frameworks */, + D60A10D52711A1B300EB58E3 /* CoreFoundation.framework in Frameworks */, + F0C41ACB9674358D4A6C7838 /* Pods_Runner.framework in Frameworks */, + D6F273A12710694D005CC4F3 /* libSystem.tbd in Frameworks */, + D6F2739F27106934005CC4F3 /* libresolv.tbd in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 33BA886A226E78AF003329D5 /* Configs */ = { + isa = PBXGroup; + children = ( + 33E5194F232828860026EE4D /* AppInfo.xcconfig */, + 9740EEB21CF90195004384FC /* Debug.xcconfig */, + 7AFA3C8E1D35360C0083082E /* Release.xcconfig */, + 333000ED22D3DE5D00554162 /* Warnings.xcconfig */, + ); + path = Configs; + sourceTree = ""; + }; + 33CC10E42044A3C60003C045 = { + isa = PBXGroup; + children = ( + 33FAB671232836740065AC1E /* Runner */, + 33CEB47122A05771004F2AC0 /* Flutter */, + 33CC10EE2044A3C60003C045 /* Products */, + D73912EC22F37F3D000D13A0 /* Frameworks */, + 3931C7C5835EE32D50936E8A /* Pods */, + ); + sourceTree = ""; + }; + 33CC10EE2044A3C60003C045 /* Products */ = { + isa = PBXGroup; + children = ( + 33CC10ED2044A3C60003C045 /* Komodo Wallet.app */, + ); + name = Products; + sourceTree = ""; + }; + 33CC11242044D66E0003C045 /* Resources */ = { + isa = PBXGroup; + children = ( + 33CC10F22044A3C60003C045 /* Assets.xcassets */, + 33CC10F42044A3C60003C045 /* MainMenu.xib */, + 33CC10F72044A3C60003C045 /* Info.plist */, + ); + name = Resources; + path = ..; + sourceTree = ""; + }; + 33CEB47122A05771004F2AC0 /* Flutter */ = { + isa = PBXGroup; + children = ( + 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */, + 33CEB47222A05771004F2AC0 /* Flutter-Debug.xcconfig */, + 33CEB47422A05771004F2AC0 /* Flutter-Release.xcconfig */, + 33CEB47722A0578A004F2AC0 /* Flutter-Generated.xcconfig */, + ); + path = Flutter; + sourceTree = ""; + }; + 33FAB671232836740065AC1E /* Runner */ = { + isa = PBXGroup; + children = ( + D68B8E582710401800D6C7D1 /* mm2.h */, + D68B8E592710401800D6C7D1 /* mm2.m */, + 33CC10F02044A3C60003C045 /* AppDelegate.swift */, + 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */, + 33E51913231747F40026EE4D /* DebugProfile.entitlements */, + 33E51914231749380026EE4D /* Release.entitlements */, + 33CC11242044D66E0003C045 /* Resources */, + 33BA886A226E78AF003329D5 /* Configs */, + D68B8E572710401800D6C7D1 /* Runner-Bridging-Header.h */, + ); + path = Runner; + sourceTree = ""; + }; + 3931C7C5835EE32D50936E8A /* Pods */ = { + isa = PBXGroup; + children = ( + AC3362C2E5FD3245C1DF46DE /* Pods-Runner.debug.xcconfig */, + EB312012C5DDB0C61F2B00DD /* Pods-Runner.release.xcconfig */, + F3467061AB9E6B9F454F9E55 /* Pods-Runner.profile.xcconfig */, + ); + path = Pods; + sourceTree = ""; + }; + D73912EC22F37F3D000D13A0 /* Frameworks */ = { + isa = PBXGroup; + children = ( + D68B8E5B2710416000D6C7D1 /* libmm2.a */, + D6B034EF2711A360007FC221 /* libz.tbd */, + D60A10D62711A1D000EB58E3 /* SystemConfiguration.framework */, + D60A10D42711A1B300EB58E3 /* CoreFoundation.framework */, + D6F273A027106944005CC4F3 /* libSystem.tbd */, + D6F2739E2710692B005CC4F3 /* libresolv.tbd */, + D6F2739C2710690C005CC4F3 /* libc++.tbd */, + CBED5C6C4A1CA4CE9B9F2193 /* Pods_Runner.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 33CC10EC2044A3C60003C045 /* Runner */ = { + isa = PBXNativeTarget; + buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; + buildPhases = ( + A4BFE8B57517ABC4F933089B /* [CP] Check Pods Manifest.lock */, + 33CC10E92044A3C60003C045 /* Sources */, + 33CC10EA2044A3C60003C045 /* Frameworks */, + 33CC10EB2044A3C60003C045 /* Resources */, + 33CC110E2044A8840003C045 /* Bundle Framework */, + 3399D490228B24CF009A79C7 /* ShellScript */, + CC7BBD7412CA68EC0C78C62C /* [CP] Embed Pods Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + 33CC11202044C79F0003C045 /* PBXTargetDependency */, + ); + name = Runner; + productName = Runner; + productReference = 33CC10ED2044A3C60003C045 /* Komodo Wallet.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 33CC10E52044A3C60003C045 /* Project object */ = { + isa = PBXProject; + attributes = { + LastSwiftUpdateCheck = 0920; + LastUpgradeCheck = 1510; + ORGANIZATIONNAME = "The Flutter Authors"; + TargetAttributes = { + 33CC10EC2044A3C60003C045 = { + CreatedOnToolsVersion = 9.2; + LastSwiftMigration = 1300; + ProvisioningStyle = Automatic; + SystemCapabilities = { + com.apple.Sandbox = { + enabled = 1; + }; + }; + }; + 33CC111A2044C6BA0003C045 = { + CreatedOnToolsVersion = 9.2; + ProvisioningStyle = Manual; + }; + }; + }; + buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; + compatibilityVersion = "Xcode 8.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 33CC10E42044A3C60003C045; + productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 33CC10EC2044A3C60003C045 /* Runner */, + 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 33CC10EB2044A3C60003C045 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */, + 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXShellScriptBuildPhase section */ + 3399D490228B24CF009A79C7 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + outputFileListPaths = ( + ); + outputPaths = ( + ); + 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"; + }; + 33CC111E2044C6BF0003C045 /* ShellScript */ = { + isa = PBXShellScriptBuildPhase; + 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"; + }; + A4BFE8B57517ABC4F933089B /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + 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 = "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; + }; + CC7BBD7412CA68EC0C78C62C /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh", + "${BUILT_PRODUCTS_DIR}/FirebaseCore/FirebaseCore.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseCoreInternal/FirebaseCoreInternal.framework", + "${BUILT_PRODUCTS_DIR}/FirebaseInstallations/FirebaseInstallations.framework", + "${BUILT_PRODUCTS_DIR}/GoogleUtilities/GoogleUtilities.framework", + "${BUILT_PRODUCTS_DIR}/OrderedSet/OrderedSet.framework", + "${BUILT_PRODUCTS_DIR}/PromisesObjC/FBLPromises.framework", + "${BUILT_PRODUCTS_DIR}/desktop_webview_window/desktop_webview_window.framework", + "${BUILT_PRODUCTS_DIR}/flutter_inappwebview_macos/flutter_inappwebview_macos.framework", + "${BUILT_PRODUCTS_DIR}/mobile_scanner/mobile_scanner.framework", + "${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework", + "${BUILT_PRODUCTS_DIR}/package_info_plus/package_info_plus.framework", + "${BUILT_PRODUCTS_DIR}/path_provider_foundation/path_provider_foundation.framework", + "${BUILT_PRODUCTS_DIR}/share_plus/share_plus.framework", + "${BUILT_PRODUCTS_DIR}/shared_preferences_foundation/shared_preferences_foundation.framework", + "${BUILT_PRODUCTS_DIR}/url_launcher_macos/url_launcher_macos.framework", + "${BUILT_PRODUCTS_DIR}/video_player_avfoundation/video_player_avfoundation.framework", + "${BUILT_PRODUCTS_DIR}/window_size/window_size.framework", + ); + name = "[CP] Embed Pods Frameworks"; + outputPaths = ( + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCore.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCoreInternal.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseInstallations.framework", + "${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}/desktop_webview_window.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/flutter_inappwebview_macos.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/mobile_scanner.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/package_info_plus.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/path_provider_foundation.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/share_plus.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/shared_preferences_foundation.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/url_launcher_macos.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/video_player_avfoundation.framework", + "${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/window_size.framework", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; +/* End PBXShellScriptBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 33CC10E92044A3C60003C045 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D68B8E5A2710401800D6C7D1 /* mm2.m in Sources */, + 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */, + 33CC10F12044A3C60003C045 /* AppDelegate.swift in Sources */, + 335BBD1B22A9A15E00E9071D /* GeneratedPluginRegistrant.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 33CC11202044C79F0003C045 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; + targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin PBXVariantGroup section */ + 33CC10F42044A3C60003C045 /* MainMenu.xib */ = { + isa = PBXVariantGroup; + children = ( + 33CC10F52044A3C60003C045 /* Base */, + ); + name = MainMenu.xib; + path = Runner; + sourceTree = ""; + }; +/* End PBXVariantGroup section */ + +/* Begin XCBuildConfiguration section */ + 338D0CE9231458BD00FA5F75 /* Profile */ = { + 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 = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Profile; + }; + 338D0CEA231458BD00FA5F75 /* Profile */ = { + 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*]" = "-"; + CODE_SIGN_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = ""; + EXCLUDED_ARCHS = arm64; + 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)", + ); + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Profile; + }; + 338D0CEB231458BD00FA5F75 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Profile; + }; + 33CC10F92044A3C60003C045 /* Debug */ = { + 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 = 12.0; + MTL_ENABLE_DEBUG_INFO = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = macosx; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 33CC10FA2044A3C60003C045 /* Release */ = { + 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 = 12.0; + MTL_ENABLE_DEBUG_INFO = NO; + SDKROOT = macosx; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_OPTIMIZATION_LEVEL = "-O"; + }; + name = Release; + }; + 33CC10FC2044A3C60003C045 /* Debug */ = { + 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_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = G3VBBBMD8T; + EXCLUDED_ARCHS = arm64; + 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)", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.komodowallet; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + }; + name = Debug; + }; + 33CC10FD2044A3C60003C045 /* Release */ = { + 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_STYLE = Automatic; + COMBINE_HIDPI_IMAGES = YES; + DEVELOPMENT_TEAM = G3VBBBMD8T; + EXCLUDED_ARCHS = arm64; + 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)", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.komodo.komodowallet; + PROVISIONING_PROFILE_SPECIFIER = ""; + SWIFT_OBJC_BRIDGING_HEADER = "Runner/Runner-Bridging-Header.h"; + SWIFT_VERSION = 5.0; + }; + name = Release; + }; + 33CC111C2044C6BA0003C045 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Manual; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Debug; + }; + 33CC111D2044C6BA0003C045 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + PRODUCT_NAME = "$(TARGET_NAME)"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10F92044A3C60003C045 /* Debug */, + 33CC10FA2044A3C60003C045 /* Release */, + 338D0CE9231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC10FC2044A3C60003C045 /* Debug */, + 33CC10FD2044A3C60003C045 /* Release */, + 338D0CEA231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 33CC111B2044C6BA0003C045 /* Build configuration list for PBXAggregateTarget "Flutter Assemble" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 33CC111C2044C6BA0003C045 /* Debug */, + 33CC111D2044C6BA0003C045 /* Release */, + 338D0CEB231458BD00FA5F75 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 33CC10E52044A3C60003C045 /* Project object */; +} diff --git a/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/macos/Runner.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme new file mode 100644 index 0000000000..170663764b --- /dev/null +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -0,0 +1,97 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000000..21a3cc14c7 --- /dev/null +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,10 @@ + + + + + + + diff --git a/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000000..18d981003d --- /dev/null +++ b/macos/Runner.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/macos/Runner/AppDelegate.swift b/macos/Runner/AppDelegate.swift new file mode 100644 index 0000000000..77cae2b3ff --- /dev/null +++ b/macos/Runner/AppDelegate.swift @@ -0,0 +1,73 @@ +import Cocoa +import FlutterMacOS + +import os.log + +var mm2StartArgs: String? +var eventSink: FlutterEventSink? + +func mm2Callback(line: UnsafePointer?) { + if let lineStr = line, let sink = eventSink { + let logMessage = String(cString: lineStr) + sink(logMessage) + } +} + +@available(macOS 10.12, *) +func performMM2Start() -> Int32 { + eventSink?("START MM2 --------------------------------") + let error = Int32(mm2_main(mm2StartArgs, mm2Callback)) + eventSink?("START MM2 RESULT: \(error) ---------------") + + return error; +} +func performMM2Stop() -> Int32 { + eventSink?("STOP MM2 --------------------------------"); + let error = Int32(mm2_stop()); + eventSink?("STOP MM2 RESULT: \(error) ---------------"); + return error; +} + +@available(macOS 10.12, *) +@NSApplicationMain +class AppDelegate: FlutterAppDelegate, FlutterStreamHandler { + + override func applicationDidFinishLaunching(_ notification: Notification) { + let controller : FlutterViewController = mainFlutterWindow?.contentViewController as! FlutterViewController + let channelMain = FlutterMethodChannel.init(name: "komodo-web-dex", binaryMessenger: controller.engine.binaryMessenger) + + let eventChannel = FlutterEventChannel(name: "komodo-web-dex/event", binaryMessenger: controller.engine.binaryMessenger) + eventChannel.setStreamHandler(self) + + channelMain.setMethodCallHandler({ + (_ call: FlutterMethodCall, _ result: FlutterResult) -> Void in + if ("start" == call.method) { + guard let arg = (call.arguments as! Dictionary)["params"] else { result(0); return } + mm2StartArgs = arg; + let error: Int32 = performMM2Start(); + + result(error) + } else if ("status" == call.method) { + let ret = Int32(mm2_main_status()); + result(ret) + } else if ("stop" == call.method) { + let error: Int32 = performMM2Stop() + result(error) + } + }); + } + + override func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { + return true + } + + func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? { + eventSink = events + return nil + } + + func onCancel(withArguments arguments: Any?) -> FlutterError? { + eventSink = nil + return nil + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000000..1c5dd38d02 --- /dev/null +++ b/macos/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "app_icon_16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "app_icon_32.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "app_icon_32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "app_icon_64.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "app_icon_128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "app_icon_256.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "app_icon_256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "app_icon_512.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "app_icon_512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "app_icon_1024.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png new file mode 100644 index 0000000000..7fec80eb9c Binary files /dev/null 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 new file mode 100644 index 0000000000..193092d58b Binary files /dev/null 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 new file mode 100644 index 0000000000..34cc1220ad Binary files /dev/null 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 new file mode 100644 index 0000000000..d6c9de43a1 Binary files /dev/null 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 new file mode 100644 index 0000000000..dade7c16d5 Binary files /dev/null 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 new file mode 100644 index 0000000000..a6614de14c Binary files /dev/null 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 new file mode 100644 index 0000000000..7622d56e96 Binary files /dev/null and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/macos/Runner/Assets.xcassets/Contents.json b/macos/Runner/Assets.xcassets/Contents.json new file mode 100644 index 0000000000..73c00596a7 --- /dev/null +++ b/macos/Runner/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/macos/Runner/Base.lproj/MainMenu.xib b/macos/Runner/Base.lproj/MainMenu.xib new file mode 100644 index 0000000000..537341abf9 --- /dev/null +++ b/macos/Runner/Base.lproj/MainMenu.xib @@ -0,0 +1,339 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig new file mode 100644 index 0000000000..81f10cc83c --- /dev/null +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -0,0 +1,14 @@ +// Application-level settings for the Runner target. +// +// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the +// future. If not, the values below would default to using the project name when this becomes a +// 'flutter create' template. + +// The application's name. By default this is also the title of the Flutter window. +PRODUCT_NAME = Komodo Wallet + +// The application's bundle identifier +PRODUCT_BUNDLE_IDENTIFIER = com.komodo.komodowallet + +// 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 new file mode 100644 index 0000000000..36b0fd9464 --- /dev/null +++ b/macos/Runner/Configs/Debug.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Debug.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Release.xcconfig b/macos/Runner/Configs/Release.xcconfig new file mode 100644 index 0000000000..dff4f49561 --- /dev/null +++ b/macos/Runner/Configs/Release.xcconfig @@ -0,0 +1,2 @@ +#include "../../Flutter/Flutter-Release.xcconfig" +#include "Warnings.xcconfig" diff --git a/macos/Runner/Configs/Warnings.xcconfig b/macos/Runner/Configs/Warnings.xcconfig new file mode 100644 index 0000000000..42bcbf4780 --- /dev/null +++ b/macos/Runner/Configs/Warnings.xcconfig @@ -0,0 +1,13 @@ +WARNING_CFLAGS = -Wall -Wconditional-uninitialized -Wnullable-to-nonnull-conversion -Wmissing-method-return-type -Woverlength-strings +GCC_WARN_UNDECLARED_SELECTOR = YES +CLANG_UNDEFINED_BEHAVIOR_SANITIZER_NULLABILITY = YES +CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE +CLANG_WARN__DUPLICATE_METHOD_MATCH = YES +CLANG_WARN_PRAGMA_PACK = YES +CLANG_WARN_STRICT_PROTOTYPES = YES +CLANG_WARN_COMMA = YES +GCC_WARN_STRICT_SELECTOR_MATCH = YES +CLANG_WARN_OBJC_REPEATED_USE_OF_WEAK = YES +CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES +GCC_WARN_SHADOW = YES +CLANG_WARN_UNREACHABLE_CODE = YES diff --git a/macos/Runner/DebugProfile.entitlements b/macos/Runner/DebugProfile.entitlements new file mode 100644 index 0000000000..304a169252 --- /dev/null +++ b/macos/Runner/DebugProfile.entitlements @@ -0,0 +1,16 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.cs.allow-jit + + com.apple.security.device.usb + + com.apple.security.network.client + + com.apple.security.network.server + + + diff --git a/macos/Runner/GoogleService-Info.plist b/macos/Runner/GoogleService-Info.plist new file mode 100644 index 0000000000..bef7488865 --- /dev/null +++ b/macos/Runner/GoogleService-Info.plist @@ -0,0 +1,30 @@ + + + + + API_KEY + THIS_IS_AUTOGENERATED + GCM_SENDER_ID + THIS_IS_AUTOGENERATED + PLIST_VERSION + 1 + BUNDLE_ID + THIS_IS_AUTOGENERATED + PROJECT_ID + THIS_IS_AUTOGENERATED + STORAGE_BUCKET + THIS_IS_AUTOGENERATED + IS_ADS_ENABLED + + IS_ANALYTICS_ENABLED + + IS_APPINVITE_ENABLED + + IS_GCM_ENABLED + + IS_SIGNIN_ENABLED + + GOOGLE_APP_ID + THIS_IS_AUTOGENERATED + + \ No newline at end of file diff --git a/macos/Runner/Info.plist b/macos/Runner/Info.plist new file mode 100644 index 0000000000..4789daa6a4 --- /dev/null +++ b/macos/Runner/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIconFile + + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + $(FLUTTER_BUILD_NAME) + CFBundleVersion + $(FLUTTER_BUILD_NUMBER) + LSMinimumSystemVersion + $(MACOSX_DEPLOYMENT_TARGET) + NSHumanReadableCopyright + $(PRODUCT_COPYRIGHT) + NSMainNibFile + MainMenu + NSPrincipalClass + NSApplication + + diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift new file mode 100644 index 0000000000..3e75d9ad24 --- /dev/null +++ b/macos/Runner/MainFlutterWindow.swift @@ -0,0 +1,13 @@ +import Cocoa +import FlutterMacOS + +class MainFlutterWindow: NSWindow { + override func awakeFromNib() { + let flutterViewController = FlutterViewController.init() + self.contentViewController = flutterViewController + + RegisterGeneratedPlugins(registry: flutterViewController) + + super.awakeFromNib() + } +} diff --git a/macos/Runner/Release.entitlements b/macos/Runner/Release.entitlements new file mode 100644 index 0000000000..ee95ab7e58 --- /dev/null +++ b/macos/Runner/Release.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.network.client + + + diff --git a/macos/Runner/Runner-Bridging-Header.h b/macos/Runner/Runner-Bridging-Header.h new file mode 100644 index 0000000000..88601cc4bc --- /dev/null +++ b/macos/Runner/Runner-Bridging-Header.h @@ -0,0 +1 @@ +#import "mm2.h" \ No newline at end of file diff --git a/macos/Runner/mm2.h b/macos/Runner/mm2.h new file mode 100644 index 0000000000..a132efafa8 --- /dev/null +++ b/macos/Runner/mm2.h @@ -0,0 +1,36 @@ +#ifndef mm2_h +#define mm2_h + +#include + +char* writeable_dir (void); + +void start_mm2 (const char* mm2_conf); + +/// Checks if the MM2 singleton thread is currently running or not. +/// 0 .. not running. +/// 1 .. running, but no context yet. +/// 2 .. context, but no RPC yet. +/// 3 .. RPC is up. +int8_t mm2_main_status (void); + +/// Defined in "common/for_c.rs". +uint8_t is_loopback_ip (const char* ip); +/// Defined in "mm2_lib.rs". +int8_t mm2_main (const char* conf, void (*log_cb) (const char* line)); + +/// Defined in "mm2_lib.rs". +/// 0 .. MM2 has been stopped successfully. +/// 1 .. not running. +/// 2 .. error stopping an MM2 instance. +int8_t mm2_stop (void); + +void lsof (void); + +/// Measurement of application metrics: network traffic, CPU usage, etc. +const char* metrics (void); + +/// Corresponds to the `applicationDocumentsDirectory` used in Dart. +const char* documentDirectory (void); + +#endif /* mm2_h */ diff --git a/macos/Runner/mm2.m b/macos/Runner/mm2.m new file mode 100644 index 0000000000..d2469813e0 --- /dev/null +++ b/macos/Runner/mm2.m @@ -0,0 +1,235 @@ +#include "mm2.h" + +#import +#import +#import +#import +#import +#import // os_log +#import // NSException + +#include +#include + +#include // task_info, mach_task_self + +#include // strcpy +#include +#include +#include + +// Note that the network interface traffic is not the same as the application traffic. +// Might still be useful with picking some trends in how the application is using the network, +// and for troubleshooting. +void network (NSMutableDictionary* ret) { + // https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/getifaddrs.3.html + struct ifaddrs *addrs = NULL; + int rc = getifaddrs (&addrs); + if (rc != 0) return; + + for (struct ifaddrs *addr = addrs; addr != NULL; addr = addr->ifa_next) { + if (addr->ifa_addr->sa_family != AF_LINK) continue; + + // Known aliases: “en0” is wi-fi, “pdp_ip0” is mobile. + // AG: “lo0” on my iPhone 5s seems to be measuring the Wi-Fi traffic. + const char* name = addr->ifa_name; + + struct if_data *stats = (struct if_data*) addr->ifa_data; + if (name == NULL || stats == NULL) continue; + if (stats->ifi_ipackets == 0 || stats->ifi_opackets == 0) continue; + + int8_t log = 0; + if (log == 1) os_log (OS_LOG_DEFAULT, + "network] if %{public}s ipackets %lld ibytes %lld opackets %lld obytes %lld", + name, + (int64_t) stats->ifi_ipackets, + (int64_t) stats->ifi_ibytes, + (int64_t) stats->ifi_opackets, + (int64_t) stats->ifi_obytes); + + NSDictionary* readings = @{ + @"ipackets": @((int64_t) stats->ifi_ipackets), + @"ibytes": @((int64_t) stats->ifi_ibytes), + @"opackets": @((int64_t) stats->ifi_opackets), + @"obytes": @((int64_t) stats->ifi_obytes)}; + NSString* key = [[NSString alloc] initWithUTF8String:name]; + [ret setObject:readings forKey:key];} + + freeifaddrs (addrs);} + +// Results in a `EXC_CRASH (SIGABRT)` crash log. +void throw_example (void) { + @throw [NSException exceptionWithName:@"exceptionName" reason:@"throw_example" userInfo:nil];} + +const char* documentDirectory (void) { + NSFileManager* sharedFM = [NSFileManager defaultManager]; + NSArray* urls = [sharedFM URLsForDirectory:NSDocumentDirectory inDomains:NSUserDomainMask]; + //for (NSURL* url in urls) os_log (OS_LOG_DEFAULT, "documentDirectory] supp dir: %{public}s\n", url.fileSystemRepresentation); + if (urls.count < 1) {os_log (OS_LOG_DEFAULT, "documentDirectory] Can't get a NSApplicationSupportDirectory"); return NULL;} + const char* wr_dir = urls[0].fileSystemRepresentation; + return wr_dir; +} + +// “in_use” stops at 256. +void file_example (void) { + const char* documents = documentDirectory(); + NSString* dir = [[NSString alloc] initWithUTF8String:documents]; + NSArray* files = [[NSFileManager defaultManager] contentsOfDirectoryAtPath:dir error:NULL]; + static int32_t total = 0; + [files enumerateObjectsUsingBlock:^ (id obj, NSUInteger idx, BOOL *stop) { + NSString* filename = (NSString*) obj; + os_log (OS_LOG_DEFAULT, "file_example] filename: %{public}s", filename.UTF8String); + + NSString* path = [NSString stringWithFormat:@"%@/%@", dir, filename]; + int fd = open (path.UTF8String, O_RDWR); + if (fd > 0) ++total;}]; + + int32_t in_use = 0; + for (int fd = 0; fd < (int) FD_SETSIZE; ++fd) if (fcntl (fd, F_GETFD, 0) != -1) ++in_use; + + os_log (OS_LOG_DEFAULT, "file_example] leaked %d; in_use %d / %d", total, in_use, (int32_t) FD_SETSIZE);} + +// On iPhone 5s the app stopped at “phys_footprint 646 MiB; rs 19 MiB”. +// It didn't get to a memory allocation failure but was killed by Jetsam instead +// (“JetsamEvent-2020-04-03-175018.ips” was generated in the iTunes crash logs directory). +void leak_example (void) { + static int8_t* leaks[9999]; // Preserve the pointers for GC + static int32_t next_leak = 0; + int32_t size = 9 * 1024 * 1024; + os_log (OS_LOG_DEFAULT, "leak_example] Leaking %d MiB…", size / 1024 / 1024); + int8_t* leak = malloc (size); + if (leak == NULL) {os_log (OS_LOG_DEFAULT, "leak_example] Allocation failed"); return;} + leaks[next_leak++] = leak; + // Fill with random junk to workaround memory compression + for (int ix = 0; ix < size; ++ix) leak[ix] = (int8_t) rand(); + os_log (OS_LOG_DEFAULT, "leak_example] Leak %d, allocated %d MiB", next_leak, size / 1024 / 1024);} + +int32_t fds_simple (void) { + int32_t fds = 0; + for (int fd = 0; fd < (int) FD_SETSIZE; ++fd) if (fcntl (fd, F_GETFD, 0) != -1) ++fds; + return fds;} + +int32_t fds (void) { + // fds_simple is likely to generate a number of interrupts + // (FD_SETSIZE of 1024 would likely mean 1024 interrupts). + // We should actually check it: maybe it will help us with reproducing the high number of `wakeups`. + // But for production use we want to reduce the number of `fcntl` invocations. + + // We'll skip the first portion of file descriptors because most of the time we have them opened anyway. + int fd = 66; + int32_t fds = 66; + int32_t gap = 0; + + while (fd < (int) FD_SETSIZE && fd < 333) { + if (fcntl (fd, F_GETFD, 0) != -1) { // If file descriptor exists + gap = 0; + if (fd < 220) { + // We will count the files by ten, hoping that iOS traditionally fills the gaps. + fd += 10; + fds += 10; + } else { + // Unless we're close to the limit, where we want more precision. + ++fd; ++fds;} + continue;} + // Sample with increasing step while inside the gap. + int step = 1 + gap / 3; + fd += step; + gap += step;} + + return fds;} + +const char* metrics (void) { + //file_example(); + //leak_example(); + + mach_port_t self = mach_task_self(); + if (self == MACH_PORT_NULL || self == MACH_PORT_DEAD) return "{}"; + + // cf. https://forums.developer.apple.com/thread/105088#357415 + int32_t footprint = 0, rs = 0; + task_vm_info_data_t vmInfo; + mach_msg_type_number_t count = TASK_VM_INFO_COUNT; + kern_return_t rc = task_info (self, TASK_VM_INFO, (task_info_t) &vmInfo, &count); + if (rc == KERN_SUCCESS) { + footprint = (int32_t) vmInfo.phys_footprint / (1024 * 1024); + rs = (int32_t) vmInfo.resident_size / (1024 * 1024);} + + // iOS applications are in danger of being killed if the number of iterrupts is too high, + // so it might be interesting to maintain some statistics on the number of interrupts. + int64_t wakeups = 0; + task_power_info_data_t powInfo; + count = TASK_POWER_INFO_COUNT; + rc = task_info (self, TASK_POWER_INFO, (task_info_t) &powInfo, &count); + if (rc == KERN_SUCCESS) wakeups = (int64_t) powInfo.task_interrupt_wakeups; + + int32_t files = fds(); + + NSMutableDictionary* ret = [NSMutableDictionary new]; + + //os_log (OS_LOG_DEFAULT, + // "metrics] phys_footprint %d MiB; rs %d MiB; wakeups %lld; files %d", footprint, rs, wakeups, files); + ret[@"footprint"] = @(footprint); + ret[@"rs"] = @(rs); + ret[@"wakeups"] = @(wakeups); + ret[@"files"] = @(files); + + network (ret); + + NSError *err; + NSData *js = [NSJSONSerialization dataWithJSONObject:ret options:0 error: &err]; + if (js == NULL) {os_log (OS_LOG_DEFAULT, "metrics] !json: %@", err); return "{}";} + NSString *jss = [[NSString alloc] initWithData:js encoding:NSUTF8StringEncoding]; + const char *cs = [jss UTF8String]; + return cs;} + +void lsof (void) +{ + // AG: For now `os_log` allows me to see the information in the logs, + // but in the future we might want to return the information to Flutter + // in order to gather statistics on the use of file descriptors in the app, etc. + + int flags; + int fd; + char buf[MAXPATHLEN+1] ; + int n = 1 ; + + for (fd = 0; fd < (int) FD_SETSIZE; fd++) { + errno = 0; + flags = fcntl(fd, F_GETFD, 0); + if (flags == -1 && errno) { + if (errno != EBADF) { + return ; + } + else + continue; + } + if (fcntl(fd , F_GETPATH, buf ) >= 0) + { + printf("File Descriptor %d number %d in use for: %s\n", fd, n, buf); + os_log (OS_LOG_DEFAULT, "lsof] File Descriptor %d number %d in use for: %{public}s", fd, n, buf); + } + else + { + //[...] + + struct sockaddr_in addr; + socklen_t addr_size = sizeof(struct sockaddr); + int res = getpeername(fd, (struct sockaddr*)&addr, &addr_size); + if (res >= 0) + { + char clientip[20]; + strcpy(clientip, inet_ntoa(addr.sin_addr)); + uint16_t port = \ + (uint16_t)((((uint16_t)(addr.sin_port) & 0xff00) >> 8) | \ + (((uint16_t)(addr.sin_port) & 0x00ff) << 8)); + printf("File Descriptor %d, %s:%d \n", fd, clientip, port); + os_log (OS_LOG_DEFAULT, "lsof] File Descriptor %d, %{public}s:%d", fd, clientip, port); + } + else { + printf("File Descriptor %d number %d couldn't get path or socket\n", fd, n); + os_log (OS_LOG_DEFAULT, "lsof] File Descriptor %d number %d couldn't get path or socket", fd, n); + } + } + ++n ; + } +} diff --git a/macos/firebase_app_id_file.json b/macos/firebase_app_id_file.json new file mode 100644 index 0000000000..84783c766d --- /dev/null +++ b/macos/firebase_app_id_file.json @@ -0,0 +1,7 @@ +{ + "file_generated_by": "FlutterFire CLI", + "purpose": "FirebaseAppID & ProjectID for this Firebase app in this directory", + "GOOGLE_APP_ID": "", + "FIREBASE_PROJECT_ID": "", + "GCM_SENDER_ID": "" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000..ab845d760b --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1905 @@ +{ + "name": "web_dex", + "version": "0.2.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "web_dex", + "version": "0.2.0", + "license": "ISC", + "dependencies": { + "jszip": "^3.10.1" + }, + "devDependencies": { + "clean-webpack-plugin": "^4.0.0", + "html-webpack-plugin": "^5.5.0", + "webpack": "^5.88.2", + "webpack-cli": "^4.10.0" + } + }, + "node_modules/@discoveryjs/json-ext": { + "version": "0.5.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/set-array": "^1.0.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.9" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.0", + "@jridgewell/trace-mapping": "^0.3.9" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.4.14", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.18", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.18.tgz", + "integrity": "sha512-w+niJYzMHdd7USdiH2U6869nqhD2nbfZXND5Yp93qIbEmnDNk7PD48o+YchRVpzMU7M6jVCbenTR7PA1FLQ9pA==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "3.1.0", + "@jridgewell/sourcemap-codec": "1.4.14" + } + }, + "node_modules/@types/eslint": { + "version": "8.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", + "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", + "dev": true + }, + "node_modules/@types/glob": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/minimatch": "*", + "@types/node": "*" + } + }, + "node_modules/@types/html-minifier-terser": { + "version": "6.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.11", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/minimatch": { + "version": "3.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "17.0.23", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.11.6.tgz", + "integrity": "sha512-IN1xI7PwOvLPgjcf180gC1bqn3q/QaOCwYUahIOhbYUu8KA/3tw2RT/T0Gidi1l7Hhj5D/INhJxiICObqpMu4Q==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", + "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", + "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.11.6.tgz", + "integrity": "sha512-z3nFzdcp1mb8nEOFFk8DrYLpHvhKC3grJD2ardfKOzmbmJvEf/tPIqCY+sNcwZIY8ZD7IkB2l7/pqhUhqm7hLA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", + "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", + "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.11.6.tgz", + "integrity": "sha512-LPpZbSOwTpEC2cgn4hTydySy1Ke+XEu+ETXuoyvuyezHO3Kjdu90KK95Sh9xTbmjrCsUwvWwCOQQNta37VrS9g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", + "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", + "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", + "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.11.6.tgz", + "integrity": "sha512-Ybn2I6fnfIGuCR+Faaz7YcvtBKxvoLV3Lebn1tM4o/IAJzmi9AWYIPWpyBfU8cC+JxAO57bk4+zdsTjJR+VTOw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/helper-wasm-section": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-opt": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6", + "@webassemblyjs/wast-printer": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.11.6.tgz", + "integrity": "sha512-3XOqkZP/y6B4F0PBAXvI1/bky7GryoogUtfwExeP/v7Nzwo1QLcq5oQmpKlftZLbT+ERUOAZVQjuNVak6UXjPA==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.11.6.tgz", + "integrity": "sha512-cOrKuLRE7PCe6AsOVl7WasYf3wbSo4CeOk6PkrjS7g57MFfVUF9u6ysQBBODX0LdgSvQqRiGz3CXvIDKcPNy4g==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-buffer": "1.11.6", + "@webassemblyjs/wasm-gen": "1.11.6", + "@webassemblyjs/wasm-parser": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.11.6.tgz", + "integrity": "sha512-6ZwPeGzMJM3Dqp3hCsLgESxBGtT/OeCvCZ4TA1JUPYgmhAx38tTPR9JaKy0S5H3evQpO/h2uWs2j6Yc/fjkpTQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/helper-wasm-bytecode": "1.11.6", + "@webassemblyjs/ieee754": "1.11.6", + "@webassemblyjs/leb128": "1.11.6", + "@webassemblyjs/utf8": "1.11.6" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.11.6", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.11.6.tgz", + "integrity": "sha512-JM7AhRcE+yW2GWYaKeHL5vt4xqee5N2WcezptmgyhNS+ScggqcT1OtXykhAb13Sn5Yas0j2uv9tHgrjwvzAP4A==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.11.6", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webpack-cli/configtest": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/configtest/-/configtest-1.2.0.tgz", + "integrity": "sha512-4FB8Tj6xyVkyqjj1OaTqCjXYULB9FMkqQ8yGrZjRDrYh0nOE+7Lhs45WioWQQMV+ceFlE368Ukhe6xdvJM9Egg==", + "dev": true, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x", + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/info": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/info/-/info-1.5.0.tgz", + "integrity": "sha512-e8tSXZpw2hPl2uMJY6fsMswaok5FdlGNRTktvFk2sD8RjH0hE2+XistawJx1vmKteh4NmGmNUrp+Tb2w+udPcQ==", + "dev": true, + "dependencies": { + "envinfo": "^7.7.3" + }, + "peerDependencies": { + "webpack-cli": "4.x.x" + } + }, + "node_modules/@webpack-cli/serve": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@webpack-cli/serve/-/serve-1.7.0.tgz", + "integrity": "sha512-oxnCNGj88fL+xzV+dacXs44HcDwf1ovs3AuEzvP7mqXw7fQntqIhQ1BRmynh4qEKQSSSRSWVyXRjmTbZIX9V2Q==", + "dev": true, + "peerDependencies": { + "webpack-cli": "4.x.x" + }, + "peerDependenciesMeta": { + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, + "node_modules/acorn": { + "version": "8.8.2", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.8.2.tgz", + "integrity": "sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-assertions": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/acorn-import-assertions/-/acorn-import-assertions-1.9.0.tgz", + "integrity": "sha512-cmMwop9x+8KFhxvKrKfPYmN6/pKTYYHBqLa0DfvVZcKMJWNyWLnaqND7dx/qn66R7ewM1UX5XMaDVP5wlVTaVA==", + "dev": true, + "peerDependencies": { + "acorn": "^8" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/array-union": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/boolbase": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/brace-expansion": { + "version": "1.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.20.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + } + ], + "license": "MIT", + "dependencies": { + "caniuse-lite": "^1.0.30001317", + "electron-to-chromium": "^1.4.84", + "escalade": "^3.1.1", + "node-releases": "^2.0.2", + "picocolors": "^1.0.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "dev": true, + "license": "MIT" + }, + "node_modules/camel-case": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "pascal-case": "^3.1.2", + "tslib": "^2.0.3" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001325", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chrome-trace-event": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/clean-css": { + "version": "5.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "source-map": "~0.6.0" + }, + "engines": { + "node": ">= 10.0" + } + }, + "node_modules/clean-webpack-plugin": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "del": "^4.1.1" + }, + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "webpack": ">=4.0.0 <6.0.0" + } + }, + "node_modules/clone-deep": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4", + "kind-of": "^6.0.2", + "shallow-clone": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/colorette": { + "version": "2.0.16", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "2.20.3", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "node_modules/cross-spawn": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", + "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "dev": true, + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-select": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.0.1", + "domhandler": "^4.3.1", + "domutils": "^2.8.0", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.1.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/del": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/glob": "^7.1.1", + "globby": "^6.1.0", + "is-path-cwd": "^2.0.0", + "is-path-in-cwd": "^2.0.0", + "p-map": "^2.0.0", + "pify": "^4.0.1", + "rimraf": "^2.6.3" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-converter": { + "version": "0.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "utila": "~0.4" + } + }, + "node_modules/dom-serializer": { + "version": "1.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.2.0", + "entities": "^2.0.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.2.0", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "4.3.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.2.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "2.8.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^1.0.1", + "domelementtype": "^2.2.0", + "domhandler": "^4.2.0" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dot-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.4.105", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.15.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz", + "integrity": "sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "2.2.0", + "dev": true, + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/envinfo": { + "version": "7.8.1", + "resolved": "https://registry.npmjs.org/envinfo/-/envinfo-7.8.1.tgz", + "integrity": "sha512-/o+BXHmB7ocbHEAs6F2EnG0ogybVVUdkRunTT2glZU9XAaGmhqskrvKwqXuDfNjEO0LZKWdejEEpnq8aM0tOaw==", + "dev": true, + "bin": { + "envinfo": "dist/cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.2.1.tgz", + "integrity": "sha512-9978wrXM50Y4rTMmW5kXIC09ZdXQZqkE4mxhwkd8VbzsGkXGPgV4zWuqQJgCEzYngdo2dYDa0l8xhX4fkSwJSg==", + "dev": true + }, + "node_modules/escalade": { + "version": "3.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/events": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, + "node_modules/fastest-levenshtein": { + "version": "1.0.12", + "dev": true, + "license": "MIT" + }, + "node_modules/find-up": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/glob": { + "version": "7.2.0", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, + "node_modules/globby": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "array-union": "^1.0.1", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/globby/node_modules/pify": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true + }, + "node_modules/has": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.1" + }, + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/he": { + "version": "1.2.0", + "dev": true, + "license": "MIT", + "bin": { + "he": "bin/he" + } + }, + "node_modules/html-minifier-terser": { + "version": "6.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "camel-case": "^4.1.2", + "clean-css": "^5.2.2", + "commander": "^8.3.0", + "he": "^1.2.0", + "param-case": "^3.0.4", + "relateurl": "^0.2.7", + "terser": "^5.10.0" + }, + "bin": { + "html-minifier-terser": "cli.js" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/html-minifier-terser/node_modules/commander": { + "version": "8.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/html-webpack-plugin": { + "version": "5.5.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/html-minifier-terser": "^6.0.0", + "html-minifier-terser": "^6.0.2", + "lodash": "^4.17.21", + "pretty-error": "^4.0.0", + "tapable": "^2.0.0" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/html-webpack-plugin" + }, + "peerDependencies": { + "webpack": "^5.20.0" + } + }, + "node_modules/htmlparser2": { + "version": "6.1.0", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.0.1", + "domhandler": "^4.0.0", + "domutils": "^2.5.2", + "entities": "^2.0.0" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==" + }, + "node_modules/import-local": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "pkg-dir": "^4.2.0", + "resolve-cwd": "^3.0.0" + }, + "bin": { + "import-local-fixture": "fixtures/cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/interpret": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-core-module": { + "version": "2.8.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has": "^1.0.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-path-cwd": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-in-cwd": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-path-inside": "^2.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-path-inside": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "path-is-inside": "^1.0.2" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/is-plain-object": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "isobject": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true + }, + "node_modules/isobject": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==" + }, + "node_modules/kind-of": { + "version": "6.0.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/loader-runner": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + } + }, + "node_modules/locate-path": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/lodash": { + "version": "4.17.21", + "dev": true, + "license": "MIT" + }, + "node_modules/lower-case": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, + "node_modules/mime-db": { + "version": "1.52.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/no-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, + "node_modules/node-releases": { + "version": "2.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/nth-check": { + "version": "2.0.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/p-limit": { + "version": "2.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-map": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/param-case": { + "version": "3.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/pascal-case": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-is-inside": { + "version": "1.0.2", + "dev": true, + "license": "(WTFPL OR MIT)" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.0.0", + "dev": true, + "license": "ISC" + }, + "node_modules/pify": { + "version": "4.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pkg-dir": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/pretty-error": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.20", + "renderkid": "^3.0.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==" + }, + "node_modules/punycode": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.0.tgz", + "integrity": "sha512-rRV+zQD8tVFys26lAGR9WUuS4iUAngJScM+ZRSKtvl5tKeZ2t5bvdNFdNHBW9FWR4guGHlgmsZ1G7BSm2wTbuA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/rechoir": { + "version": "0.7.1", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve": "^1.9.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/relateurl": { + "version": "0.2.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/renderkid": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "css-select": "^4.1.3", + "dom-converter": "^0.2.0", + "htmlparser2": "^6.1.0", + "lodash": "^4.17.21", + "strip-ansi": "^6.0.1" + } + }, + "node_modules/resolve": { + "version": "1.22.0", + "dev": true, + "license": "MIT", + "dependencies": { + "is-core-module": "^2.8.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-cwd": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-from": "^5.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/resolve-from": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/rimraf": { + "version": "2.7.1", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/serialize-javascript": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.1.tgz", + "integrity": "sha512-owoXEFjWRllis8/M1Q+Cw5k8ZH40e3zhp/ovX+Xr/vi1qj6QesbyXXViFbpNvWvPNAD62SutwEXavefrLJWj7w==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + }, + "node_modules/shallow-clone": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^6.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==" + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tapable": { + "version": "2.2.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/terser": { + "version": "5.17.3", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.17.3.tgz", + "integrity": "sha512-AudpAZKmZHkG9jueayypz4duuCFJMMNGRMwaPvQKWfxKedh8Z2x3OCoDqIIi1xx5+iwx1u6Au8XQcc9Lke65Yg==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.2", + "acorn": "^8.5.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.8", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.8.tgz", + "integrity": "sha512-WiHL3ElchZMsK27P8uIUh4604IgJyAW47LVXGbEoB21DbQcZ+OuMpGjVYnEUaqcWM6dO8uS2qUbA7LSCWqvsbg==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.17", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.16.8" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/tslib": { + "version": "2.3.1", + "dev": true, + "license": "0BSD" + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "node_modules/utila": { + "version": "0.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/watchpack": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.0.tgz", + "integrity": "sha512-Lcvm7MGST/4fup+ifyKi2hjyIAwcdI4HRgtvTpIUxBRhB+RFtUh8XtDOxUfctVCnhVi+QQj49i91OyvzkJl6cg==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack": { + "version": "5.88.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", + "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.3", + "@types/estree": "^1.0.0", + "@webassemblyjs/ast": "^1.11.5", + "@webassemblyjs/wasm-edit": "^1.11.5", + "@webassemblyjs/wasm-parser": "^1.11.5", + "acorn": "^8.7.1", + "acorn-import-assertions": "^1.9.0", + "browserslist": "^4.14.5", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.15.0", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.9", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.7", + "watchpack": "^2.4.0", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-cli": { + "version": "4.10.0", + "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-4.10.0.tgz", + "integrity": "sha512-NLhDfH/h4O6UOy+0LSso42xvYypClINuMNBVVzX4vX98TmTaTUxwRbXdhucbFMd2qLaCTcLq/PdYrvi8onw90w==", + "dev": true, + "dependencies": { + "@discoveryjs/json-ext": "^0.5.0", + "@webpack-cli/configtest": "^1.2.0", + "@webpack-cli/info": "^1.5.0", + "@webpack-cli/serve": "^1.7.0", + "colorette": "^2.0.14", + "commander": "^7.0.0", + "cross-spawn": "^7.0.3", + "fastest-levenshtein": "^1.0.12", + "import-local": "^3.0.2", + "interpret": "^2.2.0", + "rechoir": "^0.7.0", + "webpack-merge": "^5.7.3" + }, + "bin": { + "webpack-cli": "bin/cli.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "4.x.x || 5.x.x" + }, + "peerDependenciesMeta": { + "@webpack-cli/generators": { + "optional": true + }, + "@webpack-cli/migrate": { + "optional": true + }, + "webpack-bundle-analyzer": { + "optional": true + }, + "webpack-dev-server": { + "optional": true + } + } + }, + "node_modules/webpack-cli/node_modules/commander": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, + "node_modules/webpack-merge": { + "version": "5.8.0", + "dev": true, + "license": "MIT", + "dependencies": { + "clone-deep": "^4.0.1", + "wildcard": "^2.0.0" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wildcard": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "dev": true, + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000..ae583c6f10 --- /dev/null +++ b/package.json @@ -0,0 +1,30 @@ +{ + "name": "web_dex", + "version": "0.2.0", + "description": "Developer guide.", + "main": "web/index.js", + "directories": { + "lib": "lib", + "test": "test" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1", + "build": "npx webpack --config=webpack.config.js --mode=production", + "build:dev": "npx webpack --config=webpack.config.js --mode=development" + }, + "repository": { + "type": "git", + "url": "''" + }, + "author": "", + "license": "ISC", + "devDependencies": { + "clean-webpack-plugin": "^4.0.0", + "html-webpack-plugin": "^5.5.0", + "webpack": "^5.88.2", + "webpack-cli": "^4.10.0" + }, + "dependencies": { + "jszip": "^3.10.1" + } +} diff --git a/packages/komodo_cex_market_data/.gitignore b/packages/komodo_cex_market_data/.gitignore new file mode 100644 index 0000000000..3cceda5578 --- /dev/null +++ b/packages/komodo_cex_market_data/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/komodo_cex_market_data/CHANGELOG.md b/packages/komodo_cex_market_data/CHANGELOG.md new file mode 100644 index 0000000000..b78d64c626 --- /dev/null +++ b/packages/komodo_cex_market_data/CHANGELOG.md @@ -0,0 +1,3 @@ +## 0.0.1 + +- Initial version. diff --git a/packages/komodo_cex_market_data/README.md b/packages/komodo_cex_market_data/README.md new file mode 100644 index 0000000000..9f9acf04e7 --- /dev/null +++ b/packages/komodo_cex_market_data/README.md @@ -0,0 +1,22 @@ +# Komodo CEX Market Data + +Provide a consistent interface through which to access multiple CEX market data APIs. + +## Features + +- [x] Implement a consistent interface for accessing market data from multiple CEX APIs +- [x] Get market data from multiple CEX APIs + +## Getting started + +- Flutter Stable + +## Usage + +TODO: Add usage examples + +## Additional information + +TODO: Tell users more about the package: where to find more information, how to +contribute to the package, how to file issues, what response they can expect +from the package authors, and more. diff --git a/packages/komodo_cex_market_data/analysis_options.yaml b/packages/komodo_cex_market_data/analysis_options.yaml new file mode 100644 index 0000000000..b57be5d0a1 --- /dev/null +++ b/packages/komodo_cex_market_data/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + require_trailing_commas: true diff --git a/packages/komodo_cex_market_data/example/komodo_cex_market_data_example.dart b/packages/komodo_cex_market_data/example/komodo_cex_market_data_example.dart new file mode 100644 index 0000000000..f133ff223e --- /dev/null +++ b/packages/komodo_cex_market_data/example/komodo_cex_market_data_example.dart @@ -0,0 +1,3 @@ +void main() { + throw UnimplementedError(); +} diff --git a/packages/komodo_cex_market_data/lib/komodo_cex_market_data.dart b/packages/komodo_cex_market_data/lib/komodo_cex_market_data.dart new file mode 100644 index 0000000000..96fcecc1d5 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/komodo_cex_market_data.dart @@ -0,0 +1,6 @@ +/// Support for doing something awesome. +/// +/// More dartdocs go here. +library; + +export 'src/komodo_cex_market_data_base.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/binance/binance.dart b/packages/komodo_cex_market_data/lib/src/binance/binance.dart new file mode 100644 index 0000000000..8b1c2bc553 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/binance/binance.dart @@ -0,0 +1,6 @@ +export 'data/binance_provider.dart'; +export 'data/binance_repository.dart'; +export 'models/binance_exchange_info.dart'; +export 'models/filter.dart'; +export 'models/rate_limit.dart'; +export 'models/symbol.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/binance/data/binance_provider.dart b/packages/komodo_cex_market_data/lib/src/binance/data/binance_provider.dart new file mode 100644 index 0000000000..946769c3c9 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/binance/data/binance_provider.dart @@ -0,0 +1,134 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:komodo_cex_market_data/src/binance/models/binance_exchange_info.dart'; +import 'package:komodo_cex_market_data/src/binance/models/binance_exchange_info_reduced.dart'; +import 'package:komodo_cex_market_data/src/models/coin_ohlc.dart'; + +/// A provider class for fetching data from the Binance API. +class BinanceProvider { + /// Creates a new BinanceProvider instance. + const BinanceProvider({this.apiUrl = 'https://api.binance.com/api/v3'}); + + /// The base URL for the Binance API. + /// Defaults to 'https://api.binance.com/api/v3'. + final String apiUrl; + + /// Fetches candlestick chart data from Binance API. + /// + /// Retrieves the candlestick chart data for a specific symbol and interval + /// from the Binance API. + /// Optionally, you can specify the start time, end time, and limit of the + /// data to fetch. + /// + /// Parameters: + /// - [symbol]: The trading symbol for which to fetch the candlestick + /// chart data. + /// - [interval]: The time interval for the candlestick chart data + /// (e.g., '1m', '1h', '1d'). + /// - [startTime]: The start time (in milliseconds since epoch, Unix time) of + /// the data range to fetch (optional). + /// - [endTime]: The end time (in milliseconds since epoch, Unix time) of the + /// data range to fetch (optional). + /// - [limit]: The maximum number of data points to fetch (optional). Defaults + /// to 500, maximum is 1000. + /// + /// Returns: + /// A [Future] that resolves to a [CoinOhlc] object containing the fetched + /// candlestick chart data. + /// + /// Example usage: + /// ```dart + /// final BinanceKlinesResponse klines = await fetchKlines( + /// 'BTCUSDT', + /// '1h', + /// limit: 100, + /// ); + /// ``` + /// + /// Throws: + /// - [Exception] if the API request fails. + Future fetchKlines( + String symbol, + String interval, { + int? startUnixTimestampMilliseconds, + int? endUnixTimestampMilliseconds, + int? limit, + String? baseUrl, + }) async { + final queryParameters = { + 'symbol': symbol, + 'interval': interval, + if (startUnixTimestampMilliseconds != null) + 'startTime': startUnixTimestampMilliseconds.toString(), + if (endUnixTimestampMilliseconds != null) + 'endTime': endUnixTimestampMilliseconds.toString(), + if (limit != null) 'limit': limit.toString(), + }; + + final baseRequestUrl = baseUrl ?? apiUrl; + final uri = Uri.parse('$baseRequestUrl/klines') + .replace(queryParameters: queryParameters); + + final response = await http.get(uri); + if (response.statusCode == 200) { + return CoinOhlc.fromJson( + jsonDecode(response.body) as List, + ); + } else { + throw Exception( + 'Failed to load klines: ${response.statusCode} ${response.body}', + ); + } + } + + /// Fetches the exchange information from Binance. + /// + /// Returns a [Future] that resolves to a [BinanceExchangeInfoResponse] object + /// Throws an [Exception] if the request fails. + Future fetchExchangeInfo({ + String? baseUrl, + }) async { + final requestUrl = baseUrl ?? apiUrl; + final response = await http.get(Uri.parse('$requestUrl/exchangeInfo')); + + if (response.statusCode == 200) { + return BinanceExchangeInfoResponse.fromJson( + jsonDecode(response.body) as Map, + ); + } else { + throw http.ClientException( + 'Failed to load exchange info: ${response.statusCode} ${response.body}', + ); + } + } + + /// Fetches the exchange information from Binance. + /// + /// Returns a [Future] that resolves to a [BinanceExchangeInfoResponseReduced] + /// object. + /// Throws an [Exception] if the request fails. + Future fetchExchangeInfoReduced({ + String? baseUrl, + }) async { + final requestUrl = baseUrl ?? apiUrl; + final response = await http.get(Uri.parse('$requestUrl/exchangeInfo')); + + if (response.statusCode == 200) { + return BinanceExchangeInfoResponseReduced.fromJson( + jsonDecode(response.body) as Map, + ); + } else if (response.statusCode == 451) { + // service unavailable for legal reasons + return BinanceExchangeInfoResponseReduced( + timezone: '', + serverTime: 0, + symbols: List.empty(), + ); + } else { + throw http.ClientException( + 'Failed to load exchange info: ${response.statusCode} ${response.body}', + ); + } + } +} diff --git a/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart b/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart new file mode 100644 index 0000000000..5e302316ca --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/binance/data/binance_repository.dart @@ -0,0 +1,201 @@ +// Using relative imports in this "package" to make it easier to track external +// dependencies when moving or copying this "package" to another project. +import 'package:komodo_cex_market_data/src/binance/data/binance_provider.dart'; +import 'package:komodo_cex_market_data/src/binance/models/binance_exchange_info_reduced.dart'; +import 'package:komodo_cex_market_data/src/cex_repository.dart'; +import 'package:komodo_cex_market_data/src/models/models.dart'; + +// Declaring constants here to make this easier to copy & move around +/// The base URL for the Binance API. +List get binanceApiEndpoint => + ['https://api.binance.com/api/v3', 'https://api.binance.us/api/v3']; + +BinanceRepository binanceRepository = BinanceRepository( + binanceProvider: const BinanceProvider(), +); + +/// A repository class for interacting with the Binance API. +/// This class provides methods to fetch legacy tickers and OHLC candle data. +class BinanceRepository implements CexRepository { + /// Creates a new [BinanceRepository] instance. + BinanceRepository({required BinanceProvider binanceProvider}) + : _binanceProvider = binanceProvider; + + final BinanceProvider _binanceProvider; + + List? _cachedCoinsList; + + @override + Future> getCoinList() async { + if (_cachedCoinsList != null) { + return _cachedCoinsList!; + } + + try { + return await _executeWithRetry((String baseUrl) async { + final exchangeInfo = + await _binanceProvider.fetchExchangeInfoReduced(baseUrl: baseUrl); + _cachedCoinsList = _convertSymbolsToCoins(exchangeInfo); + return _cachedCoinsList!; + }); + } catch (e) { + _cachedCoinsList = List.empty(); + } + + return _cachedCoinsList!; + } + + Future _executeWithRetry(Future Function(String) callback) async { + for (int i = 0; i < binanceApiEndpoint.length; i++) { + try { + return await callback(binanceApiEndpoint.elementAt(i)); + } catch (e) { + if (i >= binanceApiEndpoint.length) { + rethrow; + } + } + } + + throw Exception('Invalid state'); + } + + CexCoin _binanceCoin(String baseCoinAbbr, String quoteCoinAbbr) { + return CexCoin( + id: baseCoinAbbr, + symbol: baseCoinAbbr, + name: baseCoinAbbr, + currencies: {quoteCoinAbbr}, + source: 'binance', + ); + } + + @override + Future getCoinOhlc( + CexCoinPair symbol, + GraphInterval interval, { + DateTime? startAt, + DateTime? endAt, + int? limit, + }) async { + if (symbol.baseCoinTicker.toUpperCase() == + symbol.relCoinTicker.toUpperCase()) { + throw ArgumentError('Base and rel coin tickers cannot be the same'); + } + + final startUnixTimestamp = startAt?.millisecondsSinceEpoch; + final endUnixTimestamp = endAt?.millisecondsSinceEpoch; + final intervalAbbreviation = interval.toAbbreviation(); + + return await _executeWithRetry((String baseUrl) async { + return await _binanceProvider.fetchKlines( + symbol.toString(), + intervalAbbreviation, + startUnixTimestampMilliseconds: startUnixTimestamp, + endUnixTimestampMilliseconds: endUnixTimestamp, + limit: limit, + baseUrl: baseUrl, + ); + }); + } + + @override + Future getCoinFiatPrice( + String coinId, { + DateTime? priceDate, + String fiatCoinId = 'usdt', + }) async { + if (coinId.toUpperCase() == fiatCoinId.toUpperCase()) { + throw ArgumentError('Coin and fiat coin cannot be the same'); + } + + final trimmedCoinId = coinId.replaceAll(RegExp('-segwit'), ''); + + final endAt = priceDate ?? DateTime.now(); + final startAt = endAt.subtract(const Duration(days: 1)); + + final ohlcData = await getCoinOhlc( + CexCoinPair(baseCoinTicker: trimmedCoinId, relCoinTicker: fiatCoinId), + GraphInterval.oneDay, + startAt: startAt, + endAt: endAt, + limit: 1, + ); + return ohlcData.ohlc.first.close; + } + + @override + Future> getCoinFiatPrices( + String coinId, + List dates, { + String fiatCoinId = 'usdt', + }) async { + if (coinId.toUpperCase() == fiatCoinId.toUpperCase()) { + throw ArgumentError('Coin and fiat coin cannot be the same'); + } + + dates.sort(); + final trimmedCoinId = coinId.replaceAll(RegExp('-segwit'), ''); + + if (dates.isEmpty) { + return {}; + } + + final startDate = dates.first.add(const Duration(days: -2)); + final endDate = dates.last.add(const Duration(days: 2)); + final daysDiff = endDate.difference(startDate).inDays; + + final result = {}; + + for (var i = 0; i <= daysDiff; i += 500) { + final batchStartDate = startDate.add(Duration(days: i)); + final batchEndDate = + i + 500 > daysDiff ? endDate : startDate.add(Duration(days: i + 500)); + + final ohlcData = await getCoinOhlc( + CexCoinPair(baseCoinTicker: trimmedCoinId, relCoinTicker: fiatCoinId), + GraphInterval.oneDay, + startAt: batchStartDate, + endAt: batchEndDate, + ); + + final batchResult = + ohlcData.ohlc.fold>({}, (map, ohlc) { + final date = DateTime.fromMillisecondsSinceEpoch( + ohlc.closeTime, + ); + map[DateTime(date.year, date.month, date.day)] = ohlc.close; + return map; + }); + + result.addAll(batchResult); + } + + return result; + } + + List _convertSymbolsToCoins( + BinanceExchangeInfoResponseReduced exchangeInfo, + ) { + final coins = {}; + for (final symbol in exchangeInfo.symbols) { + final baseAsset = symbol.baseAsset; + final quoteAsset = symbol.quoteAsset; + + // TODO(Anon): Decide if this belongs at the repository level considering + // that the repository should provide and transform data as required + // without implementing business logic (or make it an optional parameter). + if (!symbol.isSpotTradingAllowed) { + continue; + } + + if (!coins.containsKey(baseAsset)) { + coins[baseAsset] = _binanceCoin(baseAsset, quoteAsset); + } else { + coins[baseAsset] = coins[baseAsset]!.copyWith( + currencies: {...coins[baseAsset]!.currencies, quoteAsset}, + ); + } + } + return coins.values.toList(); + } +} diff --git a/packages/komodo_cex_market_data/lib/src/binance/models/binance_exchange_info.dart b/packages/komodo_cex_market_data/lib/src/binance/models/binance_exchange_info.dart new file mode 100644 index 0000000000..e92cc8e1a9 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/binance/models/binance_exchange_info.dart @@ -0,0 +1,38 @@ +import 'package:komodo_cex_market_data/src/binance/models/rate_limit.dart'; +import 'package:komodo_cex_market_data/src/binance/models/symbol.dart'; + +/// Represents the response from the Binance Exchange Info API. +class BinanceExchangeInfoResponse { + BinanceExchangeInfoResponse({ + required this.timezone, + required this.serverTime, + required this.rateLimits, + required this.symbols, + }); + + /// Creates a new instance of [BinanceExchangeInfoResponse] from a JSON map. + factory BinanceExchangeInfoResponse.fromJson(Map json) { + return BinanceExchangeInfoResponse( + timezone: json['timezone'] as String, + serverTime: json['serverTime'] as int, + rateLimits: (json['rateLimits'] as List) + .map((dynamic v) => RateLimit.fromJson(v as Map)) + .toList(), + symbols: (json['symbols'] as List) + .map((dynamic v) => Symbol.fromJson(v as Map)) + .toList(), + ); + } + + /// The timezone of the server. Defaults to 'UTC'. + String timezone; + + /// The server time in Unix time (milliseconds). + int serverTime; + + /// The rate limit types for the API endpoints. + List rateLimits; + + /// The list of symbols available on the exchange. + List symbols; +} diff --git a/packages/komodo_cex_market_data/lib/src/binance/models/binance_exchange_info_reduced.dart b/packages/komodo_cex_market_data/lib/src/binance/models/binance_exchange_info_reduced.dart new file mode 100644 index 0000000000..cd25ca3852 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/binance/models/binance_exchange_info_reduced.dart @@ -0,0 +1,33 @@ +import 'package:komodo_cex_market_data/src/binance/models/symbol_reduced.dart'; + +/// Represents a reduced version of the response from the Binance Exchange Info +/// endpoint. +class BinanceExchangeInfoResponseReduced { + BinanceExchangeInfoResponseReduced({ + required this.timezone, + required this.serverTime, + required this.symbols, + }); + + /// Creates a new instance of [BinanceExchangeInfoResponseReduced] from a JSON map. + factory BinanceExchangeInfoResponseReduced.fromJson( + Map json, + ) { + return BinanceExchangeInfoResponseReduced( + timezone: json['timezone'] as String, + serverTime: json['serverTime'] as int, + symbols: (json['symbols'] as List) + .map((dynamic v) => SymbolReduced.fromJson(v as Map)) + .toList(), + ); + } + + /// The timezone of the server. Defaults to 'UTC'. + String timezone; + + /// The server time in Unix time (milliseconds). + int serverTime; + + /// The list of symbols available on the exchange. + List symbols; +} diff --git a/packages/komodo_cex_market_data/lib/src/binance/models/filter.dart b/packages/komodo_cex_market_data/lib/src/binance/models/filter.dart new file mode 100644 index 0000000000..1eb4786e59 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/binance/models/filter.dart @@ -0,0 +1,87 @@ +/// Represents a filter applied to a symbol. +class Filter { + /// Creates a new instance of [Filter]. + Filter({ + required this.filterType, + this.minPrice, + this.maxPrice, + this.tickSize, + this.minQty, + this.maxQty, + this.stepSize, + this.limit, + this.minNotional, + this.applyMinToMarket, + this.maxNotional, + this.applyMaxToMarket, + this.avgPriceMins, + this.maxNumOrders, + this.maxNumAlgoOrders, + }); + + /// Creates a new instance of [Filter] from a JSON map. + factory Filter.fromJson(Map json) { + return Filter( + filterType: json['filterType'] as String, + minPrice: json['minPrice'] as String?, + maxPrice: json['maxPrice'] as String?, + tickSize: json['tickSize'] as String?, + minQty: json['minQty'] as String?, + maxQty: json['maxQty'] as String?, + stepSize: json['stepSize'] as String?, + limit: json['limit'] as int?, + minNotional: json['minNotional'] as String?, + applyMinToMarket: json['applyMinToMarket'] as bool?, + maxNotional: json['maxNotional'] as String?, + applyMaxToMarket: json['applyMaxToMarket'] as bool?, + avgPriceMins: json['avgPriceMins'] as int?, + maxNumOrders: json['maxNumOrders'] as int?, + maxNumAlgoOrders: json['maxNumAlgoOrders'] as int?, + ); + } + + /// The type of filter. + String filterType; + + /// The minimum price allowed for the symbol. + String? minPrice; + + /// The maximum price allowed for the symbol. + String? maxPrice; + + /// The tick size for the symbol. + String? tickSize; + + /// The minimum quantity allowed for the symbol. + String? minQty; + + /// The maximum quantity allowed for the symbol. + String? maxQty; + + /// The step size for the symbol. + String? stepSize; + + /// The maximum number of orders allowed for the symbol. + int? limit; + + /// The minimum notional value allowed for the symbol. + String? minNotional; + + /// Whether the minimum notional value applies to market orders. + bool? applyMinToMarket; + + /// The maximum notional value allowed for the symbol. + String? maxNotional; + + /// Whether the maximum notional value applies to market orders. + bool? applyMaxToMarket; + + /// The number of minutes required to calculate the average price. + int? avgPriceMins; + + /// The maximum number of orders allowed for the symbol. + int? maxNumOrders; + + /// The maximum number of algorithmic orders allowed for the symbol. + int? maxNumAlgoOrders; +} diff --git a/packages/komodo_cex_market_data/lib/src/binance/models/rate_limit.dart b/packages/komodo_cex_market_data/lib/src/binance/models/rate_limit.dart new file mode 100644 index 0000000000..0cab9afed3 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/binance/models/rate_limit.dart @@ -0,0 +1,32 @@ +/// Represents a rate limit type for an API endpoint. +class RateLimit { + /// Creates a new instance of [RateLimit]. + RateLimit({ + required this.rateLimitType, + required this.interval, + required this.intervalNum, + required this.limit, + }); + + /// Creates a new instance of [RateLimit] from a JSON map. + factory RateLimit.fromJson(Map json) { + return RateLimit( + rateLimitType: json['rateLimitType'] as String, + interval: json['interval'] as String, + intervalNum: json['intervalNum'] as int, + limit: json['limit'] as int, + ); + } + + /// The type of rate limit. + String rateLimitType; + + /// The interval of the rate limit. + String interval; + + /// The number of intervals. + int intervalNum; + + /// The limit for the rate limit. + int limit; +} diff --git a/packages/komodo_cex_market_data/lib/src/binance/models/symbol.dart b/packages/komodo_cex_market_data/lib/src/binance/models/symbol.dart new file mode 100644 index 0000000000..62b34e6456 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/binance/models/symbol.dart @@ -0,0 +1,129 @@ +import 'package:komodo_cex_market_data/src/binance/models/filter.dart'; + +/// Represents a symbol on the exchange. +class Symbol { + /// Creates a new instance of [Symbol]. + Symbol({ + required this.symbol, + required this.status, + required this.baseAsset, + required this.baseAssetPrecision, + required this.quoteAsset, + required this.quotePrecision, + required this.quoteAssetPrecision, + required this.baseCommissionPrecision, + required this.quoteCommissionPrecision, + required this.orderTypes, + required this.icebergAllowed, + required this.ocoAllowed, + required this.quoteOrderQtyMarketAllowed, + required this.allowTrailingStop, + required this.cancelReplaceAllowed, + required this.isSpotTradingAllowed, + required this.isMarginTradingAllowed, + required this.filters, + required this.permissions, + required this.defaultSelfTradePreventionMode, + required this.allowedSelfTradePreventionModes, + }); + + /// Creates a new instance of [Symbol] from a JSON map. + factory Symbol.fromJson(Map json) { + return Symbol( + symbol: json['symbol'] as String, + status: json['status'] as String, + baseAsset: json['baseAsset'] as String, + baseAssetPrecision: json['baseAssetPrecision'] as int, + quoteAsset: json['quoteAsset'] as String, + quotePrecision: json['quotePrecision'] as int, + quoteAssetPrecision: json['quoteAssetPrecision'] as int, + baseCommissionPrecision: json['baseCommissionPrecision'] as int, + quoteCommissionPrecision: json['quoteCommissionPrecision'] as int, + orderTypes: (json['orderTypes'] as List) + .map((dynamic v) => v as String) + .toList(), + icebergAllowed: json['icebergAllowed'] as bool, + ocoAllowed: json['ocoAllowed'] as bool, + quoteOrderQtyMarketAllowed: json['quoteOrderQtyMarketAllowed'] as bool, + allowTrailingStop: json['allowTrailingStop'] as bool, + cancelReplaceAllowed: json['cancelReplaceAllowed'] as bool, + isSpotTradingAllowed: json['isSpotTradingAllowed'] as bool, + isMarginTradingAllowed: json['isMarginTradingAllowed'] as bool, + filters: (json['filters'] as List) + .map((dynamic v) => Filter.fromJson(v as Map)) + .toList(), + permissions: (json['permissions'] as List) + .map((dynamic v) => v as String) + .toList(), + defaultSelfTradePreventionMode: + json['defaultSelfTradePreventionMode'] as String, + allowedSelfTradePreventionModes: + (json['allowedSelfTradePreventionModes'] as List) + .map((dynamic v) => v as String) + .toList(), + ); + } + + /// The symbol name. + String symbol; + + /// The status of the symbol. + String status; + + /// The base asset of the symbol. + String baseAsset; + + /// The precision of the base asset. + int baseAssetPrecision; + + /// The quote asset of the symbol. + String quoteAsset; + + /// The precision of the quote asset. + int quotePrecision; + + /// The precision of the quote asset for commission calculations. + int quoteAssetPrecision; + + /// The precision of the base asset for commission calculations. + int baseCommissionPrecision; + + /// The precision of the quote asset for commission calculations. + int quoteCommissionPrecision; + + /// The types of orders supported for the symbol. + List orderTypes; + + /// Whether iceberg orders are allowed for the symbol. + bool icebergAllowed; + + /// Whether OCO (One-Cancels-the-Other) orders are allowed for the symbol. + bool ocoAllowed; + + /// Whether quote order quantity market orders are allowed for the symbol. + bool quoteOrderQtyMarketAllowed; + + /// Whether trailing stop orders are allowed for the symbol. + bool allowTrailingStop; + + /// Whether cancel/replace orders are allowed for the symbol. + bool cancelReplaceAllowed; + + /// Whether spot trading is allowed for the symbol. + bool isSpotTradingAllowed; + + /// Whether margin trading is allowed for the symbol. + bool isMarginTradingAllowed; + + /// The filters applied to the symbol. + List filters; + + /// The permissions required to trade the symbol. + List permissions; + + /// The default self-trade prevention mode for the symbol. + String defaultSelfTradePreventionMode; + + /// The allowed self-trade prevention modes for the symbol. + List allowedSelfTradePreventionModes; +} diff --git a/packages/komodo_cex_market_data/lib/src/binance/models/symbol_reduced.dart b/packages/komodo_cex_market_data/lib/src/binance/models/symbol_reduced.dart new file mode 100644 index 0000000000..663c3a363f --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/binance/models/symbol_reduced.dart @@ -0,0 +1,54 @@ +/// A reduced version of [Symbol] with the bare minimum required fields. +/// This class is used to reduce the amount of data fetched parsed from the +/// Binance API response to reduce memory and CPU usage. +class SymbolReduced { + /// Creates a new instance of [SymbolReduced]. + SymbolReduced({ + required this.symbol, + required this.status, + required this.baseAsset, + required this.baseAssetPrecision, + required this.quoteAsset, + required this.quotePrecision, + required this.quoteAssetPrecision, + required this.isSpotTradingAllowed, + }); + + /// Creates a new instance of [SymbolReduced] from a JSON map. + factory SymbolReduced.fromJson(Map json) { + return SymbolReduced( + symbol: json['symbol'] as String, + status: json['status'] as String, + baseAsset: json['baseAsset'] as String, + baseAssetPrecision: json['baseAssetPrecision'] as int, + quoteAsset: json['quoteAsset'] as String, + quotePrecision: json['quotePrecision'] as int, + quoteAssetPrecision: json['quoteAssetPrecision'] as int, + isSpotTradingAllowed: json['isSpotTradingAllowed'] as bool, + ); + } + + /// The symbol name. + String symbol; + + /// The status of the symbol. + String status; + + /// The base asset of the symbol. + String baseAsset; + + /// The precision of the base asset. + int baseAssetPrecision; + + /// The quote asset of the symbol. + String quoteAsset; + + /// The precision of the quote asset. + int quotePrecision; + + /// The precision of the quote asset for commission calculations. + int quoteAssetPrecision; + + /// Whether spot trading is allowed for the symbol. + bool isSpotTradingAllowed; +} diff --git a/packages/komodo_cex_market_data/lib/src/cex_repository.dart b/packages/komodo_cex_market_data/lib/src/cex_repository.dart new file mode 100644 index 0000000000..dbe17684ed --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/cex_repository.dart @@ -0,0 +1,108 @@ +import 'package:komodo_cex_market_data/src/models/models.dart'; + +/// An abstract class that defines the methods for fetching data from a +/// cryptocurrency exchange. The exchange-specific repository classes should +/// implement this class. +abstract class CexRepository { + /// Fetches a list of all available coins on the exchange. + /// + /// Throws an [Exception] if the request fails. + /// + /// # Example usage: + /// ```dart + /// import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; + /// + /// final CexRepository repo = + /// BinanceRepository(binanceProvider: BinanceProvider()); + /// final List coins = await repo.getCoinList(); + /// ``` + Future> getCoinList(); + + /// Fetches OHLC data for a given coin symbol. + /// + /// [symbol]: The trading symbol for which to fetch the OHLC data. + /// [interval]: The time interval for the OHLC data. + /// [startTime]: The start time for the OHLC data (optional). + /// [endTime]: The end time for the OHLC data (optional). + /// [limit]: The maximum number of data points to fetch (optional). + /// + /// Throws an [Exception] if the request fails. + /// + /// The [startAt] and [endAt] parameters are used to restrict the time + /// range of the OHLC data when provided. When [startAt] is provided, the + /// first data point will start at or after the specified time. When [endAt] + /// is provided, the last data point will end at or before the specified time. + /// + /// # Example usage: + /// ```dart + /// import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; + /// + /// final CexRepository repo = + /// BinanceRepository(binanceProvider: BinanceProvider()); + /// final CoinOhlc ohlcData = + /// await repo.getCoinOhlc('BTCUSDT', '1d', limit: 100); + /// ``` + Future getCoinOhlc( + CexCoinPair symbol, + GraphInterval interval, { + DateTime? startAt, + DateTime? endAt, + int? limit, + }); + + /// Fetches the value of the given coin in terms of the specified fiat + /// currency at the specified timestamp. + /// + /// [coinId]: The coin symbol for which to fetch the price. + /// [priceData]: The date and time for which to fetch the price. Defaults to + /// [DateTime.now()]. + /// [fiatCoinId]: The fiat currency symbol in which to fetch the price. + /// + /// Throws an [Exception] if the request fails. + /// + /// # Example usage: + /// ```dart + /// import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; + /// + /// final CexRepository repo = + /// BinanceRepository(binanceProvider: BinanceProvider()); + /// final double price = await repo.getCoinFiatPrice( + /// 'BTC', + /// priceDate: DateTime.now(), + /// fiatCoinId: 'usdt' + /// ); + /// ``` + Future getCoinFiatPrice( + String coinId, { + DateTime? priceDate, + String fiatCoinId = 'usdt', + }); + + /// Fetches the value of the given coin in terms of the specified fiat currency + /// at the specified timestamps. + /// + /// [coinId]: The coin symbol for which to fetch the price. + /// [dates]: The list of dates and times for which to fetch the price. + /// [fiatCoinId]: The fiat currency symbol in which to fetch the price. + /// + /// Throws an [Exception] if the request fails. + /// + /// # Example usage: + /// ```dart + /// import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; + /// + /// final CexRepository repo = BinanceRepository( + /// binanceProvider: BinanceProvider(), + /// ); + /// final Map prices = await repo.getCoinFiatPrices( + /// 'BTC', + /// [DateTime.now(), DateTime.now().subtract(Duration(days: 1))], + /// fiatCoinId: 'usdt', + /// ); + /// ``` + Future> getCoinFiatPrices( + String coinId, + List dates, { + String fiatCoinId = 'usdt', + }); +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/coingecko.dart b/packages/komodo_cex_market_data/lib/src/coingecko/coingecko.dart new file mode 100644 index 0000000000..745e4eb756 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/coingecko.dart @@ -0,0 +1,5 @@ +export 'data/coingecko_cex_provider.dart'; +export 'data/coingecko_repository.dart'; +export 'data/sparkline_repository.dart'; +export 'models/coin_market_chart.dart'; +export 'models/coin_market_data.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart new file mode 100644 index 0000000000..dc0d865446 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_cex_provider.dart @@ -0,0 +1,288 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/coin_historical_data.dart'; + +/// A class for fetching data from CoinGecko API. +class CoinGeckoCexProvider { + /// Creates a new instance of [CoinGeckoCexProvider]. + CoinGeckoCexProvider({ + this.baseUrl = 'api.coingecko.com', + this.apiVersion = '/api/v3', + }); + + /// The base URL for the CoinGecko API. + final String baseUrl; + + /// The API version for the CoinGecko API. + final String apiVersion; + + /// Fetches the list of coins supported by CoinGecko. + /// + /// [includePlatforms] Include platform contract addresses. + Future> fetchCoinList({bool includePlatforms = false}) async { + final queryParameters = { + 'include_platform': includePlatforms.toString(), + }; + final uri = Uri.https(baseUrl, '$apiVersion/coins/list', queryParameters); + + final response = await http.get(uri); + if (response.statusCode == 200) { + final coins = jsonDecode(response.body) as List; + return coins + .map( + (dynamic element) => + CexCoin.fromJson(element as Map), + ) + .toList(); + } else { + throw Exception( + 'Failed to load coin list: ${response.statusCode} ${response.body}', + ); + } + } + + /// Fetches the list of supported vs currencies. + Future> fetchSupportedVsCurrencies() async { + final uri = + Uri.https(baseUrl, '$apiVersion/simple/supported_vs_currencies'); + + final response = await http.get(uri); + if (response.statusCode == 200) { + final currencies = jsonDecode(response.body) as List; + return currencies.map((dynamic currency) => currency as String).toList(); + } else { + throw Exception( + 'Failed to load supported vs currencies: ${response.statusCode} ${response.body}', + ); + } + } + + /// Fetches the market data for a specific currency. + /// + /// [vsCurrency] The target currency of market data (usd, eur, jpy, etc.). + /// [ids] The ids of the coins, comma separated. + /// [category] The category of the coins. + /// [order] The order of the coins. + /// [perPage] Total results per page. + /// [page] Page through results. + /// [sparkline] Include sparkline 7 days data. + /// [priceChangePercentage] Comma-sepa + /// [locale] The localization of the market data. + /// [precision] The price's precision. + Future> fetchCoinMarketData({ + String vsCurrency = 'usd', + List? ids, + String? category, + String order = 'market_cap_asc', + int perPage = 100, + int page = 1, + bool sparkline = false, + String? priceChangePercentage, + String locale = 'en', + String? precision, + }) { + final queryParameters = { + 'vs_currency': vsCurrency, + if (ids != null) 'ids': ids.join(','), + if (category != null) 'category': category, + 'order': order, + 'per_page': perPage.toString(), + 'page': page.toString(), + 'sparkline': sparkline.toString(), + if (priceChangePercentage != null) + 'price_change_percentage': priceChangePercentage, + 'locale': locale, + if (precision != null) 'price_change_percentage': precision, + }; + final uri = + Uri.https(baseUrl, '$apiVersion/coins/markets', queryParameters); + + return http.get(uri).then((http.Response response) { + if (response.statusCode == 200) { + final coins = jsonDecode(response.body) as List; + return coins + .map( + (dynamic element) => + CoinMarketData.fromJson(element as Map), + ) + .toList(); + } else { + throw Exception( + 'Failed to load coin market data: ${response.statusCode} ${response.body}', + ); + } + }); + } + + /// Fetches the market chart data for a specific currency. + /// + /// [id] The id of the coin. + /// [vsCurrency] The target currency of market data (usd, eur, jpy, etc.). + /// [fromUnixTimestamp] From date in UNIX Timestamp. + /// [toUnixTimestamp] To date in UNIX Timestamp. + /// [precision] The price's precision. + Future fetchCoinMarketChart({ + required String id, + required String vsCurrency, + required int fromUnixTimestamp, + required int toUnixTimestamp, + String? precision, + }) { + final queryParameters = { + 'vs_currency': vsCurrency, + 'from': fromUnixTimestamp.toString(), + 'to': toUnixTimestamp.toString(), + if (precision != null) 'precision': precision, + }; + final uri = Uri.https( + baseUrl, + '$apiVersion/coins/$id/market_chart/range', + queryParameters, + ); + + return http.get(uri).then((http.Response response) { + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + return CoinMarketChart.fromJson(data); + } else { + throw Exception( + 'Failed to load coin market chart: ${response.statusCode} ${response.body}', + ); + } + }); + } + + /// Fetches the market chart data for a specific currency. + /// + /// [id] The id of the coin. + /// [vsCurrency] The target currency of market data (usd, eur, jpy, etc.). + /// [date] The date of the market data to fetch. + /// [localization] Include all the localized languages in response. Defaults to false. + Future fetchCoinHistoricalMarketData({ + required String id, + required DateTime date, + String vsCurrency = 'usd', + bool localization = false, + }) { + final queryParameters = { + 'date': _formatDate(date), + 'localization': localization.toString(), + }; + final uri = Uri.https( + baseUrl, + '$apiVersion/coins/$id/history', + queryParameters, + ); + + return http.get(uri).then((http.Response response) { + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + return CoinHistoricalData.fromJson(data); + } else { + throw Exception( + 'Failed to load coin market chart: ${response.statusCode} ${response.body}', + ); + } + }); + } + + String _formatDate(DateTime date) { + final day = date.day.toString().padLeft(2, '0'); + final month = date.month.toString().padLeft(2, '0'); + final year = date.year.toString(); + + return '$day-$month-$year'; + } + + /// Fetches prices from CoinGecko API. + /// The CoinGecko API is used as a fallback when the Komodo API is down. + /// + /// The [coinGeckoIds] are the CoinGecko IDs of the coins to fetch prices for. + /// The [vsCurrencies] is a comma-separated list of currencies to compare to. + /// + /// Returns a map of coingecko IDs to their [CexPrice]s. + /// + /// Throws an error if the request fails. + /// + /// Example: + /// ```dart + /// final prices = await cexPriceProvider.getCoinGeckoPrices( + /// ['bitcoin', 'ethereum'], + /// ); + Future> fetchCoinPrices( + List coinGeckoIds, { + List vsCurrencies = const ['usd'], + }) async { + final currencies = vsCurrencies.join(','); + coinGeckoIds.removeWhere((String id) => id.isEmpty); + + final tickersUrl = Uri.https(baseUrl, '$apiVersion/simple/price', { + 'ids': coinGeckoIds.join(','), + 'vs_currencies': currencies, + }); + + final res = await http.get(tickersUrl); + final body = res.body; + + final json = jsonDecode(body) as Map?; + if (json == null) { + throw Exception('Invalid response from CoinGecko API: empty JSON'); + } + + final prices = {}; + json.forEach((String coingeckoId, dynamic pricesData) { + if (coingeckoId == 'test-coin') { + return; + } + + // TODO(Francois): map to multiple currencies, or only allow 1 vs currency + final price = (pricesData as Map)['usd'] as num?; + + prices[coingeckoId] = CexPrice( + ticker: coingeckoId, + price: price?.toDouble() ?? 0, + ); + }); + + return prices; + } + + /// Fetches the ohlc data for a specific currency. + /// + /// [id] The id of the coin. + /// [vsCurrency] The target currency of market data (usd, eur, jpy, etc.). + /// [days] Data up to number of days ago. + /// [precision] The price's precision. + Future fetchCoinOhlc( + String id, + String vsCurrency, + int days, { + int? precision, + }) { + final queryParameters = { + 'id': id, + 'vs_currency': vsCurrency, + 'days': days.toString(), + if (precision != null) 'precision': precision.toString(), + }; + + final uri = Uri.https( + baseUrl, + '$apiVersion/coins/$id/ohlc', + queryParameters, + ); + + return http.get(uri).then((http.Response response) { + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as List; + return CoinOhlc.fromJson(data); + } else { + throw Exception( + 'Failed to load coin ohlc data: ${response.statusCode} ${response.body}', + ); + } + }); + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart new file mode 100644 index 0000000000..724034006c --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/data/coingecko_repository.dart @@ -0,0 +1,85 @@ +import 'package:komodo_cex_market_data/src/cex_repository.dart'; +import 'package:komodo_cex_market_data/src/coingecko/coingecko.dart'; +import 'package:komodo_cex_market_data/src/models/models.dart'; + +/// The number of seconds in a day. +const int secondsInDay = 86400; + +/// A repository class for interacting with the CoinGecko API. +class CoinGeckoRepository implements CexRepository { + /// Creates a new instance of [CoinGeckoRepository]. + CoinGeckoRepository({required this.coinGeckoProvider}); + + /// The CoinGecko provider to use for fetching data. + final CoinGeckoCexProvider coinGeckoProvider; + + /// Fetches the CoinGecko market data. + /// + /// Returns a list of [CoinMarketData] objects containing the market data. + /// + /// Throws an [Exception] if the API request fails. + /// + /// Example usage: + /// ```dart + /// final List marketData = await getCoinGeckoMarketData(); + /// ``` + Future> getCoinGeckoMarketData() async { + final coinGeckoMarketData = await coinGeckoProvider.fetchCoinMarketData(); + return coinGeckoMarketData; + } + + @override + Future> getCoinList() async { + final coins = await coinGeckoProvider.fetchCoinList(); + final supportedCurrencies = + await coinGeckoProvider.fetchSupportedVsCurrencies(); + + return coins + .map((CexCoin e) => e.copyWith(currencies: supportedCurrencies.toSet())) + .toList(); + } + + @override + Future getCoinOhlc( + CexCoinPair symbol, + GraphInterval interval, { + DateTime? startAt, + DateTime? endAt, + int? limit, + }) { + var days = 1; + if (startAt != null && endAt != null) { + final timeDelta = endAt.difference(startAt); + days = (timeDelta.inSeconds.toDouble() / secondsInDay).ceil(); + } + + return coinGeckoProvider.fetchCoinOhlc( + symbol.baseCoinTicker, + symbol.relCoinTicker, + days, + ); + } + + @override + Future getCoinFiatPrice( + String coinId, { + DateTime? priceDate, + String fiatCoinId = 'usdt', + }) async { + final coinPrice = await coinGeckoProvider.fetchCoinHistoricalMarketData( + id: coinId, + date: priceDate ?? DateTime.now(), + ); + return coinPrice.marketData?.currentPrice?.usd?.toDouble() ?? 0; + } + + @override + Future> getCoinFiatPrices( + String coinId, + List dates, { + String fiatCoinId = 'usdt', + }) { + // TODO: implement getCoinFiatPrices + throw UnimplementedError(); + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/data/sparkline_repository.dart b/packages/komodo_cex_market_data/lib/src/coingecko/data/sparkline_repository.dart new file mode 100644 index 0000000000..5b211d1cb9 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/data/sparkline_repository.dart @@ -0,0 +1,113 @@ +// ignore_for_file: strict_raw_type + +import 'dart:async'; + +import 'package:hive/hive.dart'; +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; + +SparklineRepository sparklineRepository = SparklineRepository(); + +class SparklineRepository { + SparklineRepository() { + _binanceRepository = binanceRepository; + } + late BinanceRepository _binanceRepository; + bool isInitialized = false; + final Duration cacheExpiry = const Duration(hours: 1); + + Box>? _box; + + Set _availableCoins = {}; + + // Initialize the Hive box + Future init() async { + if (isInitialized) { + return; + } + + // Check if the Hive box is already open + if (!Hive.isBoxOpen('sparkline_data')) { + try { + _box = await Hive.openBox('sparkline_data'); + } catch (e) { + _box = null; + throw Exception('Failed to open Hive box: $e'); + } + + final coins = await _binanceRepository.getCoinList(); + _availableCoins = coins.map((e) => e.id).toSet(); + + isInitialized = true; + } + } + + Future?> fetchSparkline(String symbol) async { + if (!isInitialized) { + throw Exception('SparklineRepository is not initialized'); + } + if (_box == null) { + throw Exception('Hive box is not initialized'); + } + + // Check if data is cached and not expired + if (_box!.containsKey(symbol)) { + final cachedData = _box!.get(symbol)?.cast(); + if (cachedData != null) { + final cachedTime = DateTime.parse(cachedData['timestamp'] as String); + if (DateTime.now().difference(cachedTime) < cacheExpiry) { + return (cachedData['data'] as List).cast(); + } + } + } + + if (!_availableCoins.contains(symbol)) { + return null; + } + + try { + final startAt = DateTime.now().subtract(const Duration(days: 7)); + final endAt = DateTime.now(); + + CoinOhlc ohlcData; + if (symbol.split('-').firstOrNull?.toUpperCase() == 'USDT') { + final interval = endAt.difference(startAt).inSeconds ~/ 500; + ohlcData = CoinOhlc.fromConstantPrice( + startAt: startAt, + endAt: endAt, + intervalSeconds: interval, + ); + } else { + ohlcData = await _binanceRepository.getCoinOhlc( + CexCoinPair(baseCoinTicker: symbol, relCoinTicker: 'USDT'), + GraphInterval.oneDay, + startAt: startAt, + endAt: endAt, + ); + } + + final sparklineData = ohlcData.ohlc.map((e) => e.close).toList(); + + // Cache the data with a timestamp + await _box!.put(symbol, { + 'data': sparklineData, + 'timestamp': endAt.toIso8601String(), + }); + + return sparklineData; + } catch (e) { + if (e is Exception) { + final errorMessage = e.toString(); + if (['400', 'klines'].every(errorMessage.contains)) { + // Cache the invalid symbol as null + await _box!.put(symbol, { + 'data': null, + 'timestamp': DateTime.now().toIso8601String(), + }); + return null; + } + } + // Handle other errors appropriately + throw Exception('Failed to fetch sparkline data: $e'); + } + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/code_additions_deletions4_weeks.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/code_additions_deletions4_weeks.dart new file mode 100644 index 0000000000..5473d8b571 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/code_additions_deletions4_weeks.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; + +class CodeAdditionsDeletions4Weeks extends Equatable { + const CodeAdditionsDeletions4Weeks({this.additions, this.deletions}); + + factory CodeAdditionsDeletions4Weeks.fromJson(Map json) { + return CodeAdditionsDeletions4Weeks( + additions: json['additions'] as dynamic, + deletions: json['deletions'] as dynamic, + ); + } + final dynamic additions; + final dynamic deletions; + + Map toJson() => { + 'additions': additions, + 'deletions': deletions, + }; + + CodeAdditionsDeletions4Weeks copyWith({ + dynamic additions, + dynamic deletions, + }) { + return CodeAdditionsDeletions4Weeks( + additions: additions ?? this.additions, + deletions: deletions ?? this.deletions, + ); + } + + @override + List get props => [additions, deletions]; +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/coin_historical_data.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/coin_historical_data.dart new file mode 100644 index 0000000000..5ca8db5f67 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/coin_historical_data.dart @@ -0,0 +1,114 @@ +import 'package:equatable/equatable.dart'; + +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/community_data.dart'; +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/developer_data.dart'; +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/image.dart'; +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/localization.dart'; +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/market_data.dart'; +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/public_interest_stats.dart'; + +class CoinHistoricalData extends Equatable { + const CoinHistoricalData({ + this.id, + this.symbol, + this.name, + this.localization, + this.image, + this.marketData, + this.communityData, + this.developerData, + this.publicInterestStats, + }); + + factory CoinHistoricalData.fromJson(Map json) { + return CoinHistoricalData( + id: json['id'] as String?, + symbol: json['symbol'] as String?, + name: json['name'] as String?, + localization: json['localization'] == null + ? null + : Localization.fromJson(json['localization'] as Map), + image: json['image'] == null + ? null + : Image.fromJson(json['image'] as Map), + marketData: json['market_data'] == null + ? null + : MarketData.fromJson(json['market_data'] as Map), + communityData: json['community_data'] == null + ? null + : CommunityData.fromJson( + json['community_data'] as Map, + ), + developerData: json['developer_data'] == null + ? null + : DeveloperData.fromJson( + json['developer_data'] as Map, + ), + publicInterestStats: json['public_interest_stats'] == null + ? null + : PublicInterestStats.fromJson( + json['public_interest_stats'] as Map, + ), + ); + } + final String? id; + final String? symbol; + final String? name; + final Localization? localization; + final Image? image; + final MarketData? marketData; + final CommunityData? communityData; + final DeveloperData? developerData; + final PublicInterestStats? publicInterestStats; + + Map toJson() => { + 'id': id, + 'symbol': symbol, + 'name': name, + 'localization': localization?.toJson(), + 'image': image?.toJson(), + 'market_data': marketData?.toJson(), + 'community_data': communityData?.toJson(), + 'developer_data': developerData?.toJson(), + 'public_interest_stats': publicInterestStats?.toJson(), + }; + + CoinHistoricalData copyWith({ + String? id, + String? symbol, + String? name, + Localization? localization, + Image? image, + MarketData? marketData, + CommunityData? communityData, + DeveloperData? developerData, + PublicInterestStats? publicInterestStats, + }) { + return CoinHistoricalData( + id: id ?? this.id, + symbol: symbol ?? this.symbol, + name: name ?? this.name, + localization: localization ?? this.localization, + image: image ?? this.image, + marketData: marketData ?? this.marketData, + communityData: communityData ?? this.communityData, + developerData: developerData ?? this.developerData, + publicInterestStats: publicInterestStats ?? this.publicInterestStats, + ); + } + + @override + List get props { + return [ + id, + symbol, + name, + localization, + image, + marketData, + communityData, + developerData, + publicInterestStats, + ]; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/community_data.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/community_data.dart new file mode 100644 index 0000000000..314e607411 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/community_data.dart @@ -0,0 +1,69 @@ +import 'package:equatable/equatable.dart'; + +class CommunityData extends Equatable { + const CommunityData({ + this.facebookLikes, + this.twitterFollowers, + this.redditAveragePosts48h, + this.redditAverageComments48h, + this.redditSubscribers, + this.redditAccountsActive48h, + }); + + factory CommunityData.fromJson(Map json) => CommunityData( + facebookLikes: json['facebook_likes'] as dynamic, + twitterFollowers: json['twitter_followers'] as dynamic, + redditAveragePosts48h: json['reddit_average_posts_48h'] as int?, + redditAverageComments48h: json['reddit_average_comments_48h'] as int?, + redditSubscribers: json['reddit_subscribers'] as dynamic, + redditAccountsActive48h: json['reddit_accounts_active_48h'] as dynamic, + ); + final dynamic facebookLikes; + final dynamic twitterFollowers; + final int? redditAveragePosts48h; + final int? redditAverageComments48h; + final dynamic redditSubscribers; + final dynamic redditAccountsActive48h; + + Map toJson() => { + 'facebook_likes': facebookLikes, + 'twitter_followers': twitterFollowers, + 'reddit_average_posts_48h': redditAveragePosts48h, + 'reddit_average_comments_48h': redditAverageComments48h, + 'reddit_subscribers': redditSubscribers, + 'reddit_accounts_active_48h': redditAccountsActive48h, + }; + + CommunityData copyWith({ + dynamic facebookLikes, + dynamic twitterFollowers, + int? redditAveragePosts48h, + int? redditAverageComments48h, + dynamic redditSubscribers, + dynamic redditAccountsActive48h, + }) { + return CommunityData( + facebookLikes: facebookLikes ?? this.facebookLikes, + twitterFollowers: twitterFollowers ?? this.twitterFollowers, + redditAveragePosts48h: + redditAveragePosts48h ?? this.redditAveragePosts48h, + redditAverageComments48h: + redditAverageComments48h ?? this.redditAverageComments48h, + redditSubscribers: redditSubscribers ?? this.redditSubscribers, + redditAccountsActive48h: + redditAccountsActive48h ?? this.redditAccountsActive48h, + ); + } + + @override + List get props { + return [ + facebookLikes, + twitterFollowers, + redditAveragePosts48h, + redditAverageComments48h, + redditSubscribers, + redditAccountsActive48h, + ]; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/current_price.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/current_price.dart new file mode 100644 index 0000000000..5a56ecd06a --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/current_price.dart @@ -0,0 +1,458 @@ +import 'package:equatable/equatable.dart'; + +class CurrentPrice extends Equatable { + const CurrentPrice({ + this.aed, + this.ars, + this.aud, + this.bch, + this.bdt, + this.bhd, + this.bmd, + this.bnb, + this.brl, + this.btc, + this.cad, + this.chf, + this.clp, + this.cny, + this.czk, + this.dkk, + this.dot, + this.eos, + this.eth, + this.eur, + this.gbp, + this.gel, + this.hkd, + this.huf, + this.idr, + this.ils, + this.inr, + this.jpy, + this.krw, + this.kwd, + this.lkr, + this.ltc, + this.mmk, + this.mxn, + this.myr, + this.ngn, + this.nok, + this.nzd, + this.php, + this.pkr, + this.pln, + this.rub, + this.sar, + this.sek, + this.sgd, + this.thb, + this.tRY, + this.twd, + this.uah, + this.usd, + this.vef, + this.vnd, + this.xag, + this.xau, + this.xdr, + this.xlm, + this.xrp, + this.yfi, + this.zar, + this.bits, + this.link, + this.sats, + }); + + factory CurrentPrice.fromJson(Map json) => CurrentPrice( + aed: (json['aed'] as num?)?.toDouble(), + ars: (json['ars'] as num?)?.toDouble(), + aud: (json['aud'] as num?)?.toDouble(), + bch: (json['bch'] as num?)?.toDouble(), + bdt: (json['bdt'] as num?)?.toDouble(), + bhd: (json['bhd'] as num?)?.toDouble(), + bmd: (json['bmd'] as num?)?.toDouble(), + bnb: (json['bnb'] as num?)?.toDouble(), + brl: (json['brl'] as num?)?.toDouble(), + btc: json['btc'] as num?, + cad: (json['cad'] as num?)?.toDouble(), + chf: (json['chf'] as num?)?.toDouble(), + clp: (json['clp'] as num?)?.toDouble(), + cny: (json['cny'] as num?)?.toDouble(), + czk: (json['czk'] as num?)?.toDouble(), + dkk: (json['dkk'] as num?)?.toDouble(), + dot: (json['dot'] as num?)?.toDouble(), + eos: (json['eos'] as num?)?.toDouble(), + eth: (json['eth'] as num?)?.toDouble(), + eur: (json['eur'] as num?)?.toDouble(), + gbp: (json['gbp'] as num?)?.toDouble(), + gel: (json['gel'] as num?)?.toDouble(), + hkd: (json['hkd'] as num?)?.toDouble(), + huf: (json['huf'] as num?)?.toDouble(), + idr: (json['idr'] as num?)?.toDouble(), + ils: (json['ils'] as num?)?.toDouble(), + inr: (json['inr'] as num?)?.toDouble(), + jpy: (json['jpy'] as num?)?.toDouble(), + krw: (json['krw'] as num?)?.toDouble(), + kwd: (json['kwd'] as num?)?.toDouble(), + lkr: (json['lkr'] as num?)?.toDouble(), + ltc: (json['ltc'] as num?)?.toDouble(), + mmk: (json['mmk'] as num?)?.toDouble(), + mxn: (json['mxn'] as num?)?.toDouble(), + myr: (json['myr'] as num?)?.toDouble(), + ngn: (json['ngn'] as num?)?.toDouble(), + nok: (json['nok'] as num?)?.toDouble(), + nzd: (json['nzd'] as num?)?.toDouble(), + php: (json['php'] as num?)?.toDouble(), + pkr: (json['pkr'] as num?)?.toDouble(), + pln: (json['pln'] as num?)?.toDouble(), + rub: (json['rub'] as num?)?.toDouble(), + sar: (json['sar'] as num?)?.toDouble(), + sek: (json['sek'] as num?)?.toDouble(), + sgd: (json['sgd'] as num?)?.toDouble(), + thb: (json['thb'] as num?)?.toDouble(), + tRY: (json['try'] as num?)?.toDouble(), + twd: (json['twd'] as num?)?.toDouble(), + uah: (json['uah'] as num?)?.toDouble(), + usd: (json['usd'] as num?)?.toDouble(), + vef: (json['vef'] as num?)?.toDouble(), + vnd: (json['vnd'] as num?)?.toDouble(), + xag: (json['xag'] as num?)?.toDouble(), + xau: (json['xau'] as num?)?.toDouble(), + xdr: (json['xdr'] as num?)?.toDouble(), + xlm: (json['xlm'] as num?)?.toDouble(), + xrp: (json['xrp'] as num?)?.toDouble(), + yfi: (json['yfi'] as num?)?.toDouble(), + zar: (json['zar'] as num?)?.toDouble(), + bits: (json['bits'] as num?)?.toDouble(), + link: (json['link'] as num?)?.toDouble(), + sats: (json['sats'] as num?)?.toDouble(), + ); + final num? aed; + final num? ars; + final num? aud; + final num? bch; + final num? bdt; + final num? bhd; + final num? bmd; + final num? bnb; + final num? brl; + final num? btc; + final num? cad; + final num? chf; + final num? clp; + final num? cny; + final num? czk; + final num? dkk; + final num? dot; + final num? eos; + final num? eth; + final num? eur; + final num? gbp; + final num? gel; + final num? hkd; + final num? huf; + final num? idr; + final num? ils; + final num? inr; + final num? jpy; + final num? krw; + final num? kwd; + final num? lkr; + final num? ltc; + final num? mmk; + final num? mxn; + final num? myr; + final num? ngn; + final num? nok; + final num? nzd; + final num? php; + final num? pkr; + final num? pln; + final num? rub; + final num? sar; + final num? sek; + final num? sgd; + final num? thb; + final num? tRY; + final num? twd; + final num? uah; + final num? usd; + final num? vef; + final num? vnd; + final num? xag; + final num? xau; + final num? xdr; + final num? xlm; + final num? xrp; + final num? yfi; + final num? zar; + final num? bits; + final num? link; + final num? sats; + + Map toJson() => { + 'aed': aed, + 'ars': ars, + 'aud': aud, + 'bch': bch, + 'bdt': bdt, + 'bhd': bhd, + 'bmd': bmd, + 'bnb': bnb, + 'brl': brl, + 'btc': btc, + 'cad': cad, + 'chf': chf, + 'clp': clp, + 'cny': cny, + 'czk': czk, + 'dkk': dkk, + 'dot': dot, + 'eos': eos, + 'eth': eth, + 'eur': eur, + 'gbp': gbp, + 'gel': gel, + 'hkd': hkd, + 'huf': huf, + 'idr': idr, + 'ils': ils, + 'inr': inr, + 'jpy': jpy, + 'krw': krw, + 'kwd': kwd, + 'lkr': lkr, + 'ltc': ltc, + 'mmk': mmk, + 'mxn': mxn, + 'myr': myr, + 'ngn': ngn, + 'nok': nok, + 'nzd': nzd, + 'php': php, + 'pkr': pkr, + 'pln': pln, + 'rub': rub, + 'sar': sar, + 'sek': sek, + 'sgd': sgd, + 'thb': thb, + 'try': tRY, + 'twd': twd, + 'uah': uah, + 'usd': usd, + 'vef': vef, + 'vnd': vnd, + 'xag': xag, + 'xau': xau, + 'xdr': xdr, + 'xlm': xlm, + 'xrp': xrp, + 'yfi': yfi, + 'zar': zar, + 'bits': bits, + 'link': link, + 'sats': sats, + }; + + CurrentPrice copyWith({ + num? aed, + num? ars, + num? aud, + num? bch, + num? bdt, + num? bhd, + num? bmd, + num? bnb, + num? brl, + num? btc, + num? cad, + num? chf, + num? clp, + num? cny, + num? czk, + num? dkk, + num? dot, + num? eos, + num? eth, + num? eur, + num? gbp, + num? gel, + num? hkd, + num? huf, + num? idr, + num? ils, + num? inr, + num? jpy, + num? krw, + num? kwd, + num? lkr, + num? ltc, + num? mmk, + num? mxn, + num? myr, + num? ngn, + num? nok, + num? nzd, + num? php, + num? pkr, + num? pln, + num? rub, + num? sar, + num? sek, + num? sgd, + num? thb, + num? tRY, + num? twd, + num? uah, + num? usd, + num? vef, + num? vnd, + num? xag, + num? xau, + num? xdr, + num? xlm, + num? xrp, + num? yfi, + num? zar, + num? bits, + num? link, + num? sats, + }) { + return CurrentPrice( + aed: aed ?? this.aed, + ars: ars ?? this.ars, + aud: aud ?? this.aud, + bch: bch ?? this.bch, + bdt: bdt ?? this.bdt, + bhd: bhd ?? this.bhd, + bmd: bmd ?? this.bmd, + bnb: bnb ?? this.bnb, + brl: brl ?? this.brl, + btc: btc ?? this.btc, + cad: cad ?? this.cad, + chf: chf ?? this.chf, + clp: clp ?? this.clp, + cny: cny ?? this.cny, + czk: czk ?? this.czk, + dkk: dkk ?? this.dkk, + dot: dot ?? this.dot, + eos: eos ?? this.eos, + eth: eth ?? this.eth, + eur: eur ?? this.eur, + gbp: gbp ?? this.gbp, + gel: gel ?? this.gel, + hkd: hkd ?? this.hkd, + huf: huf ?? this.huf, + idr: idr ?? this.idr, + ils: ils ?? this.ils, + inr: inr ?? this.inr, + jpy: jpy ?? this.jpy, + krw: krw ?? this.krw, + kwd: kwd ?? this.kwd, + lkr: lkr ?? this.lkr, + ltc: ltc ?? this.ltc, + mmk: mmk ?? this.mmk, + mxn: mxn ?? this.mxn, + myr: myr ?? this.myr, + ngn: ngn ?? this.ngn, + nok: nok ?? this.nok, + nzd: nzd ?? this.nzd, + php: php ?? this.php, + pkr: pkr ?? this.pkr, + pln: pln ?? this.pln, + rub: rub ?? this.rub, + sar: sar ?? this.sar, + sek: sek ?? this.sek, + sgd: sgd ?? this.sgd, + thb: thb ?? this.thb, + tRY: tRY ?? this.tRY, + twd: twd ?? this.twd, + uah: uah ?? this.uah, + usd: usd ?? this.usd, + vef: vef ?? this.vef, + vnd: vnd ?? this.vnd, + xag: xag ?? this.xag, + xau: xau ?? this.xau, + xdr: xdr ?? this.xdr, + xlm: xlm ?? this.xlm, + xrp: xrp ?? this.xrp, + yfi: yfi ?? this.yfi, + zar: zar ?? this.zar, + bits: bits ?? this.bits, + link: link ?? this.link, + sats: sats ?? this.sats, + ); + } + + @override + List get props { + return [ + aed, + ars, + aud, + bch, + bdt, + bhd, + bmd, + bnb, + brl, + btc, + cad, + chf, + clp, + cny, + czk, + dkk, + dot, + eos, + eth, + eur, + gbp, + gel, + hkd, + huf, + idr, + ils, + inr, + jpy, + krw, + kwd, + lkr, + ltc, + mmk, + mxn, + myr, + ngn, + nok, + nzd, + php, + pkr, + pln, + rub, + sar, + sek, + sgd, + thb, + tRY, + twd, + uah, + usd, + vef, + vnd, + xag, + xau, + xdr, + xlm, + xrp, + yfi, + zar, + bits, + link, + sats, + ]; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/developer_data.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/developer_data.dart new file mode 100644 index 0000000000..d1903864d0 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/developer_data.dart @@ -0,0 +1,98 @@ +import 'package:equatable/equatable.dart'; + +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/code_additions_deletions4_weeks.dart'; + +class DeveloperData extends Equatable { + const DeveloperData({ + this.forks, + this.stars, + this.subscribers, + this.totalIssues, + this.closedIssues, + this.pullRequestsMerged, + this.pullRequestContributors, + this.codeAdditionsDeletions4Weeks, + this.commitCount4Weeks, + }); + + factory DeveloperData.fromJson(Map json) => DeveloperData( + forks: json['forks'] as dynamic, + stars: json['stars'] as dynamic, + subscribers: json['subscribers'] as dynamic, + totalIssues: json['total_issues'] as dynamic, + closedIssues: json['closed_issues'] as dynamic, + pullRequestsMerged: json['pull_requests_merged'] as dynamic, + pullRequestContributors: json['pull_request_contributors'] as dynamic, + codeAdditionsDeletions4Weeks: + json['code_additions_deletions_4_weeks'] == null + ? null + : CodeAdditionsDeletions4Weeks.fromJson( + json['code_additions_deletions_4_weeks'] + as Map, + ), + commitCount4Weeks: json['commit_count_4_weeks'] as dynamic, + ); + final dynamic forks; + final dynamic stars; + final dynamic subscribers; + final dynamic totalIssues; + final dynamic closedIssues; + final dynamic pullRequestsMerged; + final dynamic pullRequestContributors; + final CodeAdditionsDeletions4Weeks? codeAdditionsDeletions4Weeks; + final dynamic commitCount4Weeks; + + Map toJson() => { + 'forks': forks, + 'stars': stars, + 'subscribers': subscribers, + 'total_issues': totalIssues, + 'closed_issues': closedIssues, + 'pull_requests_merged': pullRequestsMerged, + 'pull_request_contributors': pullRequestContributors, + 'code_additions_deletions_4_weeks': + codeAdditionsDeletions4Weeks?.toJson(), + 'commit_count_4_weeks': commitCount4Weeks, + }; + + DeveloperData copyWith({ + dynamic forks, + dynamic stars, + dynamic subscribers, + dynamic totalIssues, + dynamic closedIssues, + dynamic pullRequestsMerged, + dynamic pullRequestContributors, + CodeAdditionsDeletions4Weeks? codeAdditionsDeletions4Weeks, + dynamic commitCount4Weeks, + }) { + return DeveloperData( + forks: forks ?? this.forks, + stars: stars ?? this.stars, + subscribers: subscribers ?? this.subscribers, + totalIssues: totalIssues ?? this.totalIssues, + closedIssues: closedIssues ?? this.closedIssues, + pullRequestsMerged: pullRequestsMerged ?? this.pullRequestsMerged, + pullRequestContributors: + pullRequestContributors ?? this.pullRequestContributors, + codeAdditionsDeletions4Weeks: + codeAdditionsDeletions4Weeks ?? this.codeAdditionsDeletions4Weeks, + commitCount4Weeks: commitCount4Weeks ?? this.commitCount4Weeks, + ); + } + + @override + List get props { + return [ + forks, + stars, + subscribers, + totalIssues, + closedIssues, + pullRequestsMerged, + pullRequestContributors, + codeAdditionsDeletions4Weeks, + commitCount4Weeks, + ]; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/image.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/image.dart new file mode 100644 index 0000000000..2de5344f7e --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/image.dart @@ -0,0 +1,30 @@ +import 'package:equatable/equatable.dart'; + +class Image extends Equatable { + const Image({this.thumb, this.small}); + + factory Image.fromJson(Map json) => Image( + thumb: json['thumb'] as String?, + small: json['small'] as String?, + ); + final String? thumb; + final String? small; + + Map toJson() => { + 'thumb': thumb, + 'small': small, + }; + + Image copyWith({ + String? thumb, + String? small, + }) { + return Image( + thumb: thumb ?? this.thumb, + small: small ?? this.small, + ); + } + + @override + List get props => [thumb, small]; +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/localization.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/localization.dart new file mode 100644 index 0000000000..d644502f48 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/localization.dart @@ -0,0 +1,262 @@ +import 'package:equatable/equatable.dart'; + +class Localization extends Equatable { + const Localization({ + this.en, + this.de, + this.es, + this.fr, + this.it, + this.pl, + this.ro, + this.hu, + this.nl, + this.pt, + this.sv, + this.vi, + this.tr, + this.ru, + this.ja, + this.zh, + this.zhTw, + this.ko, + this.ar, + this.th, + this.id, + this.cs, + this.da, + this.el, + this.hi, + this.no, + this.sk, + this.uk, + this.he, + this.fi, + this.bg, + this.hr, + this.lt, + this.sl, + }); + + factory Localization.fromJson(Map json) => Localization( + en: json['en'] as String?, + de: json['de'] as String?, + es: json['es'] as String?, + fr: json['fr'] as String?, + it: json['it'] as String?, + pl: json['pl'] as String?, + ro: json['ro'] as String?, + hu: json['hu'] as String?, + nl: json['nl'] as String?, + pt: json['pt'] as String?, + sv: json['sv'] as String?, + vi: json['vi'] as String?, + tr: json['tr'] as String?, + ru: json['ru'] as String?, + ja: json['ja'] as String?, + zh: json['zh'] as String?, + zhTw: json['zh-tw'] as String?, + ko: json['ko'] as String?, + ar: json['ar'] as String?, + th: json['th'] as String?, + id: json['id'] as String?, + cs: json['cs'] as String?, + da: json['da'] as String?, + el: json['el'] as String?, + hi: json['hi'] as String?, + no: json['no'] as String?, + sk: json['sk'] as String?, + uk: json['uk'] as String?, + he: json['he'] as String?, + fi: json['fi'] as String?, + bg: json['bg'] as String?, + hr: json['hr'] as String?, + lt: json['lt'] as String?, + sl: json['sl'] as String?, + ); + final String? en; + final String? de; + final String? es; + final String? fr; + final String? it; + final String? pl; + final String? ro; + final String? hu; + final String? nl; + final String? pt; + final String? sv; + final String? vi; + final String? tr; + final String? ru; + final String? ja; + final String? zh; + final String? zhTw; + final String? ko; + final String? ar; + final String? th; + final String? id; + final String? cs; + final String? da; + final String? el; + final String? hi; + final String? no; + final String? sk; + final String? uk; + final String? he; + final String? fi; + final String? bg; + final String? hr; + final String? lt; + final String? sl; + + Map toJson() => { + 'en': en, + 'de': de, + 'es': es, + 'fr': fr, + 'it': it, + 'pl': pl, + 'ro': ro, + 'hu': hu, + 'nl': nl, + 'pt': pt, + 'sv': sv, + 'vi': vi, + 'tr': tr, + 'ru': ru, + 'ja': ja, + 'zh': zh, + 'zh-tw': zhTw, + 'ko': ko, + 'ar': ar, + 'th': th, + 'id': id, + 'cs': cs, + 'da': da, + 'el': el, + 'hi': hi, + 'no': no, + 'sk': sk, + 'uk': uk, + 'he': he, + 'fi': fi, + 'bg': bg, + 'hr': hr, + 'lt': lt, + 'sl': sl, + }; + + Localization copyWith({ + String? en, + String? de, + String? es, + String? fr, + String? it, + String? pl, + String? ro, + String? hu, + String? nl, + String? pt, + String? sv, + String? vi, + String? tr, + String? ru, + String? ja, + String? zh, + String? zhTw, + String? ko, + String? ar, + String? th, + String? id, + String? cs, + String? da, + String? el, + String? hi, + String? no, + String? sk, + String? uk, + String? he, + String? fi, + String? bg, + String? hr, + String? lt, + String? sl, + }) { + return Localization( + en: en ?? this.en, + de: de ?? this.de, + es: es ?? this.es, + fr: fr ?? this.fr, + it: it ?? this.it, + pl: pl ?? this.pl, + ro: ro ?? this.ro, + hu: hu ?? this.hu, + nl: nl ?? this.nl, + pt: pt ?? this.pt, + sv: sv ?? this.sv, + vi: vi ?? this.vi, + tr: tr ?? this.tr, + ru: ru ?? this.ru, + ja: ja ?? this.ja, + zh: zh ?? this.zh, + zhTw: zhTw ?? this.zhTw, + ko: ko ?? this.ko, + ar: ar ?? this.ar, + th: th ?? this.th, + id: id ?? this.id, + cs: cs ?? this.cs, + da: da ?? this.da, + el: el ?? this.el, + hi: hi ?? this.hi, + no: no ?? this.no, + sk: sk ?? this.sk, + uk: uk ?? this.uk, + he: he ?? this.he, + fi: fi ?? this.fi, + bg: bg ?? this.bg, + hr: hr ?? this.hr, + lt: lt ?? this.lt, + sl: sl ?? this.sl, + ); + } + + @override + List get props { + return [ + en, + de, + es, + fr, + it, + pl, + ro, + hu, + nl, + pt, + sv, + vi, + tr, + ru, + ja, + zh, + zhTw, + ko, + ar, + th, + id, + cs, + da, + el, + hi, + no, + sk, + uk, + he, + fi, + bg, + hr, + lt, + sl, + ]; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_cap.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_cap.dart new file mode 100644 index 0000000000..5144e63522 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_cap.dart @@ -0,0 +1,458 @@ +import 'package:equatable/equatable.dart'; + +class MarketCap extends Equatable { + const MarketCap({ + this.aed, + this.ars, + this.aud, + this.bch, + this.bdt, + this.bhd, + this.bmd, + this.bnb, + this.brl, + this.btc, + this.cad, + this.chf, + this.clp, + this.cny, + this.czk, + this.dkk, + this.dot, + this.eos, + this.eth, + this.eur, + this.gbp, + this.gel, + this.hkd, + this.huf, + this.idr, + this.ils, + this.inr, + this.jpy, + this.krw, + this.kwd, + this.lkr, + this.ltc, + this.mmk, + this.mxn, + this.myr, + this.ngn, + this.nok, + this.nzd, + this.php, + this.pkr, + this.pln, + this.rub, + this.sar, + this.sek, + this.sgd, + this.thb, + this.tRY, + this.twd, + this.uah, + this.usd, + this.vef, + this.vnd, + this.xag, + this.xau, + this.xdr, + this.xlm, + this.xrp, + this.yfi, + this.zar, + this.bits, + this.link, + this.sats, + }); + + factory MarketCap.fromJson(Map json) => MarketCap( + aed: (json['aed'] as num?)?.toDouble(), + ars: (json['ars'] as num?)?.toDouble(), + aud: (json['aud'] as num?)?.toDouble(), + bch: (json['bch'] as num?)?.toDouble(), + bdt: (json['bdt'] as num?)?.toDouble(), + bhd: (json['bhd'] as num?)?.toDouble(), + bmd: (json['bmd'] as num?)?.toDouble(), + bnb: (json['bnb'] as num?)?.toDouble(), + brl: (json['brl'] as num?)?.toDouble(), + btc: json['btc'] as num?, + cad: (json['cad'] as num?)?.toDouble(), + chf: (json['chf'] as num?)?.toDouble(), + clp: (json['clp'] as num?)?.toDouble(), + cny: (json['cny'] as num?)?.toDouble(), + czk: (json['czk'] as num?)?.toDouble(), + dkk: (json['dkk'] as num?)?.toDouble(), + dot: (json['dot'] as num?)?.toDouble(), + eos: (json['eos'] as num?)?.toDouble(), + eth: (json['eth'] as num?)?.toDouble(), + eur: (json['eur'] as num?)?.toDouble(), + gbp: (json['gbp'] as num?)?.toDouble(), + gel: (json['gel'] as num?)?.toDouble(), + hkd: (json['hkd'] as num?)?.toDouble(), + huf: (json['huf'] as num?)?.toDouble(), + idr: json['idr'] as num?, + ils: (json['ils'] as num?)?.toDouble(), + inr: (json['inr'] as num?)?.toDouble(), + jpy: (json['jpy'] as num?)?.toDouble(), + krw: (json['krw'] as num?)?.toDouble(), + kwd: (json['kwd'] as num?)?.toDouble(), + lkr: (json['lkr'] as num?)?.toDouble(), + ltc: (json['ltc'] as num?)?.toDouble(), + mmk: json['mmk'] as num?, + mxn: (json['mxn'] as num?)?.toDouble(), + myr: (json['myr'] as num?)?.toDouble(), + ngn: (json['ngn'] as num?)?.toDouble(), + nok: (json['nok'] as num?)?.toDouble(), + nzd: (json['nzd'] as num?)?.toDouble(), + php: (json['php'] as num?)?.toDouble(), + pkr: (json['pkr'] as num?)?.toDouble(), + pln: (json['pln'] as num?)?.toDouble(), + rub: (json['rub'] as num?)?.toDouble(), + sar: (json['sar'] as num?)?.toDouble(), + sek: (json['sek'] as num?)?.toDouble(), + sgd: (json['sgd'] as num?)?.toDouble(), + thb: (json['thb'] as num?)?.toDouble(), + tRY: (json['try'] as num?)?.toDouble(), + twd: (json['twd'] as num?)?.toDouble(), + uah: (json['uah'] as num?)?.toDouble(), + usd: (json['usd'] as num?)?.toDouble(), + vef: (json['vef'] as num?)?.toDouble(), + vnd: json['vnd'] as num?, + xag: (json['xag'] as num?)?.toDouble(), + xau: (json['xau'] as num?)?.toDouble(), + xdr: (json['xdr'] as num?)?.toDouble(), + xlm: (json['xlm'] as num?)?.toDouble(), + xrp: (json['xrp'] as num?)?.toDouble(), + yfi: (json['yfi'] as num?)?.toDouble(), + zar: (json['zar'] as num?)?.toDouble(), + bits: (json['bits'] as num?)?.toDouble(), + link: (json['link'] as num?)?.toDouble(), + sats: (json['sats'] as num?)?.toDouble(), + ); + final num? aed; + final num? ars; + final num? aud; + final num? bch; + final num? bdt; + final num? bhd; + final num? bmd; + final num? bnb; + final num? brl; + final num? btc; + final num? cad; + final num? chf; + final num? clp; + final num? cny; + final num? czk; + final num? dkk; + final num? dot; + final num? eos; + final num? eth; + final num? eur; + final num? gbp; + final num? gel; + final num? hkd; + final num? huf; + final num? idr; + final num? ils; + final num? inr; + final num? jpy; + final num? krw; + final num? kwd; + final num? lkr; + final num? ltc; + final num? mmk; + final num? mxn; + final num? myr; + final num? ngn; + final num? nok; + final num? nzd; + final num? php; + final num? pkr; + final num? pln; + final num? rub; + final num? sar; + final num? sek; + final num? sgd; + final num? thb; + final num? tRY; + final num? twd; + final num? uah; + final num? usd; + final num? vef; + final num? vnd; + final num? xag; + final num? xau; + final num? xdr; + final num? xlm; + final num? xrp; + final num? yfi; + final num? zar; + final num? bits; + final num? link; + final num? sats; + + Map toJson() => { + 'aed': aed, + 'ars': ars, + 'aud': aud, + 'bch': bch, + 'bdt': bdt, + 'bhd': bhd, + 'bmd': bmd, + 'bnb': bnb, + 'brl': brl, + 'btc': btc, + 'cad': cad, + 'chf': chf, + 'clp': clp, + 'cny': cny, + 'czk': czk, + 'dkk': dkk, + 'dot': dot, + 'eos': eos, + 'eth': eth, + 'eur': eur, + 'gbp': gbp, + 'gel': gel, + 'hkd': hkd, + 'huf': huf, + 'idr': idr, + 'ils': ils, + 'inr': inr, + 'jpy': jpy, + 'krw': krw, + 'kwd': kwd, + 'lkr': lkr, + 'ltc': ltc, + 'mmk': mmk, + 'mxn': mxn, + 'myr': myr, + 'ngn': ngn, + 'nok': nok, + 'nzd': nzd, + 'php': php, + 'pkr': pkr, + 'pln': pln, + 'rub': rub, + 'sar': sar, + 'sek': sek, + 'sgd': sgd, + 'thb': thb, + 'try': tRY, + 'twd': twd, + 'uah': uah, + 'usd': usd, + 'vef': vef, + 'vnd': vnd, + 'xag': xag, + 'xau': xau, + 'xdr': xdr, + 'xlm': xlm, + 'xrp': xrp, + 'yfi': yfi, + 'zar': zar, + 'bits': bits, + 'link': link, + 'sats': sats, + }; + + MarketCap copyWith({ + num? aed, + num? ars, + num? aud, + num? bch, + num? bdt, + num? bhd, + num? bmd, + num? bnb, + num? brl, + num? btc, + num? cad, + num? chf, + num? clp, + num? cny, + num? czk, + num? dkk, + num? dot, + num? eos, + num? eth, + num? eur, + num? gbp, + num? gel, + num? hkd, + num? huf, + num? idr, + num? ils, + num? inr, + num? jpy, + num? krw, + num? kwd, + num? lkr, + num? ltc, + num? mmk, + num? mxn, + num? myr, + num? ngn, + num? nok, + num? nzd, + num? php, + num? pkr, + num? pln, + num? rub, + num? sar, + num? sek, + num? sgd, + num? thb, + num? tRY, + num? twd, + num? uah, + num? usd, + num? vef, + num? vnd, + num? xag, + num? xau, + num? xdr, + num? xlm, + num? xrp, + num? yfi, + num? zar, + num? bits, + num? link, + num? sats, + }) { + return MarketCap( + aed: aed ?? this.aed, + ars: ars ?? this.ars, + aud: aud ?? this.aud, + bch: bch ?? this.bch, + bdt: bdt ?? this.bdt, + bhd: bhd ?? this.bhd, + bmd: bmd ?? this.bmd, + bnb: bnb ?? this.bnb, + brl: brl ?? this.brl, + btc: btc ?? this.btc, + cad: cad ?? this.cad, + chf: chf ?? this.chf, + clp: clp ?? this.clp, + cny: cny ?? this.cny, + czk: czk ?? this.czk, + dkk: dkk ?? this.dkk, + dot: dot ?? this.dot, + eos: eos ?? this.eos, + eth: eth ?? this.eth, + eur: eur ?? this.eur, + gbp: gbp ?? this.gbp, + gel: gel ?? this.gel, + hkd: hkd ?? this.hkd, + huf: huf ?? this.huf, + idr: idr ?? this.idr, + ils: ils ?? this.ils, + inr: inr ?? this.inr, + jpy: jpy ?? this.jpy, + krw: krw ?? this.krw, + kwd: kwd ?? this.kwd, + lkr: lkr ?? this.lkr, + ltc: ltc ?? this.ltc, + mmk: mmk ?? this.mmk, + mxn: mxn ?? this.mxn, + myr: myr ?? this.myr, + ngn: ngn ?? this.ngn, + nok: nok ?? this.nok, + nzd: nzd ?? this.nzd, + php: php ?? this.php, + pkr: pkr ?? this.pkr, + pln: pln ?? this.pln, + rub: rub ?? this.rub, + sar: sar ?? this.sar, + sek: sek ?? this.sek, + sgd: sgd ?? this.sgd, + thb: thb ?? this.thb, + tRY: tRY ?? this.tRY, + twd: twd ?? this.twd, + uah: uah ?? this.uah, + usd: usd ?? this.usd, + vef: vef ?? this.vef, + vnd: vnd ?? this.vnd, + xag: xag ?? this.xag, + xau: xau ?? this.xau, + xdr: xdr ?? this.xdr, + xlm: xlm ?? this.xlm, + xrp: xrp ?? this.xrp, + yfi: yfi ?? this.yfi, + zar: zar ?? this.zar, + bits: bits ?? this.bits, + link: link ?? this.link, + sats: sats ?? this.sats, + ); + } + + @override + List get props { + return [ + aed, + ars, + aud, + bch, + bdt, + bhd, + bmd, + bnb, + brl, + btc, + cad, + chf, + clp, + cny, + czk, + dkk, + dot, + eos, + eth, + eur, + gbp, + gel, + hkd, + huf, + idr, + ils, + inr, + jpy, + krw, + kwd, + lkr, + ltc, + mmk, + mxn, + myr, + ngn, + nok, + nzd, + php, + pkr, + pln, + rub, + sar, + sek, + sgd, + thb, + tRY, + twd, + uah, + usd, + vef, + vnd, + xag, + xau, + xdr, + xlm, + xrp, + yfi, + zar, + bits, + link, + sats, + ]; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_data.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_data.dart new file mode 100644 index 0000000000..aefbf0635c --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/market_data.dart @@ -0,0 +1,49 @@ +import 'package:equatable/equatable.dart'; + +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/current_price.dart'; +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/market_cap.dart'; +import 'package:komodo_cex_market_data/src/coingecko/models/coin_historical_data/total_volume.dart'; + +class MarketData extends Equatable { + const MarketData({this.currentPrice, this.marketCap, this.totalVolume}); + + factory MarketData.fromJson(Map json) => MarketData( + currentPrice: json['current_price'] == null + ? null + : CurrentPrice.fromJson( + json['current_price'] as Map, + ), + marketCap: json['market_cap'] == null + ? null + : MarketCap.fromJson(json['market_cap'] as Map), + totalVolume: json['total_volume'] == null + ? null + : TotalVolume.fromJson( + json['total_volume'] as Map, + ), + ); + final CurrentPrice? currentPrice; + final MarketCap? marketCap; + final TotalVolume? totalVolume; + + Map toJson() => { + 'current_price': currentPrice?.toJson(), + 'market_cap': marketCap?.toJson(), + 'total_volume': totalVolume?.toJson(), + }; + + MarketData copyWith({ + CurrentPrice? currentPrice, + MarketCap? marketCap, + TotalVolume? totalVolume, + }) { + return MarketData( + currentPrice: currentPrice ?? this.currentPrice, + marketCap: marketCap ?? this.marketCap, + totalVolume: totalVolume ?? this.totalVolume, + ); + } + + @override + List get props => [currentPrice, marketCap, totalVolume]; +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/public_interest_stats.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/public_interest_stats.dart new file mode 100644 index 0000000000..9cde50ab6f --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/public_interest_stats.dart @@ -0,0 +1,32 @@ +import 'package:equatable/equatable.dart'; + +class PublicInterestStats extends Equatable { + const PublicInterestStats({this.alexaRank, this.bingMatches}); + + factory PublicInterestStats.fromJson(Map json) { + return PublicInterestStats( + alexaRank: json['alexa_rank'] as dynamic, + bingMatches: json['bing_matches'] as dynamic, + ); + } + final dynamic alexaRank; + final dynamic bingMatches; + + Map toJson() => { + 'alexa_rank': alexaRank, + 'bing_matches': bingMatches, + }; + + PublicInterestStats copyWith({ + dynamic alexaRank, + dynamic bingMatches, + }) { + return PublicInterestStats( + alexaRank: alexaRank ?? this.alexaRank, + bingMatches: bingMatches ?? this.bingMatches, + ); + } + + @override + List get props => [alexaRank, bingMatches]; +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/total_volume.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/total_volume.dart new file mode 100644 index 0000000000..b4520e4dbe --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_historical_data/total_volume.dart @@ -0,0 +1,458 @@ +import 'package:equatable/equatable.dart'; + +class TotalVolume extends Equatable { + const TotalVolume({ + this.aed, + this.ars, + this.aud, + this.bch, + this.bdt, + this.bhd, + this.bmd, + this.bnb, + this.brl, + this.btc, + this.cad, + this.chf, + this.clp, + this.cny, + this.czk, + this.dkk, + this.dot, + this.eos, + this.eth, + this.eur, + this.gbp, + this.gel, + this.hkd, + this.huf, + this.idr, + this.ils, + this.inr, + this.jpy, + this.krw, + this.kwd, + this.lkr, + this.ltc, + this.mmk, + this.mxn, + this.myr, + this.ngn, + this.nok, + this.nzd, + this.php, + this.pkr, + this.pln, + this.rub, + this.sar, + this.sek, + this.sgd, + this.thb, + this.tRY, + this.twd, + this.uah, + this.usd, + this.vef, + this.vnd, + this.xag, + this.xau, + this.xdr, + this.xlm, + this.xrp, + this.yfi, + this.zar, + this.bits, + this.link, + this.sats, + }); + + factory TotalVolume.fromJson(Map json) => TotalVolume( + aed: (json['aed'] as num?)?.toDouble(), + ars: (json['ars'] as num?)?.toDouble(), + aud: (json['aud'] as num?)?.toDouble(), + bch: (json['bch'] as num?)?.toDouble(), + bdt: (json['bdt'] as num?)?.toDouble(), + bhd: (json['bhd'] as num?)?.toDouble(), + bmd: (json['bmd'] as num?)?.toDouble(), + bnb: (json['bnb'] as num?)?.toDouble(), + brl: (json['brl'] as num?)?.toDouble(), + btc: (json['btc'] as num?)?.toDouble(), + cad: (json['cad'] as num?)?.toDouble(), + chf: (json['chf'] as num?)?.toDouble(), + clp: (json['clp'] as num?)?.toDouble(), + cny: (json['cny'] as num?)?.toDouble(), + czk: (json['czk'] as num?)?.toDouble(), + dkk: (json['dkk'] as num?)?.toDouble(), + dot: (json['dot'] as num?)?.toDouble(), + eos: (json['eos'] as num?)?.toDouble(), + eth: (json['eth'] as num?)?.toDouble(), + eur: (json['eur'] as num?)?.toDouble(), + gbp: (json['gbp'] as num?)?.toDouble(), + gel: (json['gel'] as num?)?.toDouble(), + hkd: (json['hkd'] as num?)?.toDouble(), + huf: (json['huf'] as num?)?.toDouble(), + idr: (json['idr'] as num?)?.toDouble(), + ils: (json['ils'] as num?)?.toDouble(), + inr: (json['inr'] as num?)?.toDouble(), + jpy: (json['jpy'] as num?)?.toDouble(), + krw: (json['krw'] as num?)?.toDouble(), + kwd: (json['kwd'] as num?)?.toDouble(), + lkr: (json['lkr'] as num?)?.toDouble(), + ltc: (json['ltc'] as num?)?.toDouble(), + mmk: (json['mmk'] as num?)?.toDouble(), + mxn: (json['mxn'] as num?)?.toDouble(), + myr: (json['myr'] as num?)?.toDouble(), + ngn: (json['ngn'] as num?)?.toDouble(), + nok: (json['nok'] as num?)?.toDouble(), + nzd: (json['nzd'] as num?)?.toDouble(), + php: (json['php'] as num?)?.toDouble(), + pkr: (json['pkr'] as num?)?.toDouble(), + pln: (json['pln'] as num?)?.toDouble(), + rub: (json['rub'] as num?)?.toDouble(), + sar: (json['sar'] as num?)?.toDouble(), + sek: (json['sek'] as num?)?.toDouble(), + sgd: (json['sgd'] as num?)?.toDouble(), + thb: (json['thb'] as num?)?.toDouble(), + tRY: (json['try'] as num?)?.toDouble(), + twd: (json['twd'] as num?)?.toDouble(), + uah: (json['uah'] as num?)?.toDouble(), + usd: (json['usd'] as num?)?.toDouble(), + vef: (json['vef'] as num?)?.toDouble(), + vnd: json['vnd'] as num?, + xag: (json['xag'] as num?)?.toDouble(), + xau: (json['xau'] as num?)?.toDouble(), + xdr: (json['xdr'] as num?)?.toDouble(), + xlm: (json['xlm'] as num?)?.toDouble(), + xrp: (json['xrp'] as num?)?.toDouble(), + yfi: (json['yfi'] as num?)?.toDouble(), + zar: (json['zar'] as num?)?.toDouble(), + bits: (json['bits'] as num?)?.toDouble(), + link: (json['link'] as num?)?.toDouble(), + sats: (json['sats'] as num?)?.toDouble(), + ); + final num? aed; + final num? ars; + final num? aud; + final num? bch; + final num? bdt; + final num? bhd; + final num? bmd; + final num? bnb; + final num? brl; + final num? btc; + final num? cad; + final num? chf; + final num? clp; + final num? cny; + final num? czk; + final num? dkk; + final num? dot; + final num? eos; + final num? eth; + final num? eur; + final num? gbp; + final num? gel; + final num? hkd; + final num? huf; + final num? idr; + final num? ils; + final num? inr; + final num? jpy; + final num? krw; + final num? kwd; + final num? lkr; + final num? ltc; + final num? mmk; + final num? mxn; + final num? myr; + final num? ngn; + final num? nok; + final num? nzd; + final num? php; + final num? pkr; + final num? pln; + final num? rub; + final num? sar; + final num? sek; + final num? sgd; + final num? thb; + final num? tRY; + final num? twd; + final num? uah; + final num? usd; + final num? vef; + final num? vnd; + final num? xag; + final num? xau; + final num? xdr; + final num? xlm; + final num? xrp; + final num? yfi; + final num? zar; + final num? bits; + final num? link; + final num? sats; + + Map toJson() => { + 'aed': aed, + 'ars': ars, + 'aud': aud, + 'bch': bch, + 'bdt': bdt, + 'bhd': bhd, + 'bmd': bmd, + 'bnb': bnb, + 'brl': brl, + 'btc': btc, + 'cad': cad, + 'chf': chf, + 'clp': clp, + 'cny': cny, + 'czk': czk, + 'dkk': dkk, + 'dot': dot, + 'eos': eos, + 'eth': eth, + 'eur': eur, + 'gbp': gbp, + 'gel': gel, + 'hkd': hkd, + 'huf': huf, + 'idr': idr, + 'ils': ils, + 'inr': inr, + 'jpy': jpy, + 'krw': krw, + 'kwd': kwd, + 'lkr': lkr, + 'ltc': ltc, + 'mmk': mmk, + 'mxn': mxn, + 'myr': myr, + 'ngn': ngn, + 'nok': nok, + 'nzd': nzd, + 'php': php, + 'pkr': pkr, + 'pln': pln, + 'rub': rub, + 'sar': sar, + 'sek': sek, + 'sgd': sgd, + 'thb': thb, + 'try': tRY, + 'twd': twd, + 'uah': uah, + 'usd': usd, + 'vef': vef, + 'vnd': vnd, + 'xag': xag, + 'xau': xau, + 'xdr': xdr, + 'xlm': xlm, + 'xrp': xrp, + 'yfi': yfi, + 'zar': zar, + 'bits': bits, + 'link': link, + 'sats': sats, + }; + + TotalVolume copyWith({ + num? aed, + num? ars, + num? aud, + num? bch, + num? bdt, + num? bhd, + num? bmd, + num? bnb, + num? brl, + num? btc, + num? cad, + num? chf, + num? clp, + num? cny, + num? czk, + num? dkk, + num? dot, + num? eos, + num? eth, + num? eur, + num? gbp, + num? gel, + num? hkd, + num? huf, + num? idr, + num? ils, + num? inr, + num? jpy, + num? krw, + num? kwd, + num? lkr, + num? ltc, + num? mmk, + num? mxn, + num? myr, + num? ngn, + num? nok, + num? nzd, + num? php, + num? pkr, + num? pln, + num? rub, + num? sar, + num? sek, + num? sgd, + num? thb, + num? tRY, + num? twd, + num? uah, + num? usd, + num? vef, + num? vnd, + num? xag, + num? xau, + num? xdr, + num? xlm, + num? xrp, + num? yfi, + num? zar, + num? bits, + num? link, + num? sats, + }) { + return TotalVolume( + aed: aed ?? this.aed, + ars: ars ?? this.ars, + aud: aud ?? this.aud, + bch: bch ?? this.bch, + bdt: bdt ?? this.bdt, + bhd: bhd ?? this.bhd, + bmd: bmd ?? this.bmd, + bnb: bnb ?? this.bnb, + brl: brl ?? this.brl, + btc: btc ?? this.btc, + cad: cad ?? this.cad, + chf: chf ?? this.chf, + clp: clp ?? this.clp, + cny: cny ?? this.cny, + czk: czk ?? this.czk, + dkk: dkk ?? this.dkk, + dot: dot ?? this.dot, + eos: eos ?? this.eos, + eth: eth ?? this.eth, + eur: eur ?? this.eur, + gbp: gbp ?? this.gbp, + gel: gel ?? this.gel, + hkd: hkd ?? this.hkd, + huf: huf ?? this.huf, + idr: idr ?? this.idr, + ils: ils ?? this.ils, + inr: inr ?? this.inr, + jpy: jpy ?? this.jpy, + krw: krw ?? this.krw, + kwd: kwd ?? this.kwd, + lkr: lkr ?? this.lkr, + ltc: ltc ?? this.ltc, + mmk: mmk ?? this.mmk, + mxn: mxn ?? this.mxn, + myr: myr ?? this.myr, + ngn: ngn ?? this.ngn, + nok: nok ?? this.nok, + nzd: nzd ?? this.nzd, + php: php ?? this.php, + pkr: pkr ?? this.pkr, + pln: pln ?? this.pln, + rub: rub ?? this.rub, + sar: sar ?? this.sar, + sek: sek ?? this.sek, + sgd: sgd ?? this.sgd, + thb: thb ?? this.thb, + tRY: tRY ?? this.tRY, + twd: twd ?? this.twd, + uah: uah ?? this.uah, + usd: usd ?? this.usd, + vef: vef ?? this.vef, + vnd: vnd ?? this.vnd, + xag: xag ?? this.xag, + xau: xau ?? this.xau, + xdr: xdr ?? this.xdr, + xlm: xlm ?? this.xlm, + xrp: xrp ?? this.xrp, + yfi: yfi ?? this.yfi, + zar: zar ?? this.zar, + bits: bits ?? this.bits, + link: link ?? this.link, + sats: sats ?? this.sats, + ); + } + + @override + List get props { + return [ + aed, + ars, + aud, + bch, + bdt, + bhd, + bmd, + bnb, + brl, + btc, + cad, + chf, + clp, + cny, + czk, + dkk, + dot, + eos, + eth, + eur, + gbp, + gel, + hkd, + huf, + idr, + ils, + inr, + jpy, + krw, + kwd, + lkr, + ltc, + mmk, + mxn, + myr, + ngn, + nok, + nzd, + php, + pkr, + pln, + rub, + sar, + sek, + sgd, + thb, + tRY, + twd, + uah, + usd, + vef, + vnd, + xag, + xau, + xdr, + xlm, + xrp, + yfi, + zar, + bits, + link, + sats, + ]; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_market_chart.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_market_chart.dart new file mode 100644 index 0000000000..cd4c913c86 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_market_chart.dart @@ -0,0 +1,47 @@ +class CoinMarketChart { + CoinMarketChart({ + required this.prices, + required this.marketCaps, + required this.totalVolumes, + }); + + factory CoinMarketChart.fromJson(Map json) { + return CoinMarketChart( + prices: (json['prices'] as List) + .map( + (dynamic e) => + (e as List).map((dynamic e) => e as num).toList(), + ) + .toList(), + marketCaps: (json['market_caps'] as List) + .map( + (dynamic e) => + (e as List).map((dynamic e) => e as num).toList(), + ) + .toList(), + totalVolumes: (json['total_volumes'] as List) + .map( + (dynamic e) => + (e as List).map((dynamic e) => e as num).toList(), + ) + .toList(), + ); + } + + final List> prices; + final List> marketCaps; + final List> totalVolumes; + + Map toJson() { + return { + 'prices': + prices.map((List e) => e.map((num e) => e).toList()).toList(), + 'market_caps': marketCaps + .map((List e) => e.map((num e) => e).toList()) + .toList(), + 'total_volumes': totalVolumes + .map((List e) => e.map((num e) => e).toList()) + .toList(), + }; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_market_data.dart b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_market_data.dart new file mode 100644 index 0000000000..1400d2bec6 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/coingecko/models/coin_market_data.dart @@ -0,0 +1,212 @@ +import 'package:equatable/equatable.dart'; + +/// Represents the market data of a coin. +class CoinMarketData extends Equatable { + const CoinMarketData({ + this.id, + this.symbol, + this.name, + this.image, + this.currentPrice, + this.marketCap, + this.marketCapRank, + this.fullyDilutedValuation, + this.totalVolume, + this.high24h, + this.low24h, + this.priceChange24h, + this.priceChangePercentage24h, + this.marketCapChange24h, + this.marketCapChangePercentage24h, + this.circulatingSupply, + this.totalSupply, + this.maxSupply, + this.ath, + this.athChangePercentage, + this.athDate, + this.atl, + this.atlChangePercentage, + this.atlDate, + this.roi, + this.lastUpdated, + }); + + factory CoinMarketData.fromJson(Map json) { + return CoinMarketData( + id: json['id'] as String?, + symbol: json['symbol'] as String?, + name: json['name'] as String?, + image: json['image'] as String?, + currentPrice: (json['current_price'] as num?)?.toDouble(), + marketCap: (json['market_cap'] as num?)?.toDouble(), + marketCapRank: (json['market_cap_rank'] as num?)?.toDouble(), + fullyDilutedValuation: + (json['fully_diluted_valuation'] as num?)?.toDouble(), + totalVolume: (json['total_volume'] as num?)?.toDouble(), + high24h: (json['high_24h'] as num?)?.toDouble(), + low24h: (json['low_24h'] as num?)?.toDouble(), + priceChange24h: (json['price_change_24h'] as num?)?.toDouble(), + priceChangePercentage24h: + (json['price_change_percentage_24h'] as num?)?.toDouble(), + marketCapChange24h: (json['market_cap_change_24h'] as num?)?.toDouble(), + marketCapChangePercentage24h: + (json['market_cap_change_percentage_24h'] as num?)?.toDouble(), + circulatingSupply: (json['circulating_supply'] as num?)?.toDouble(), + totalSupply: (json['total_supply'] as num?)?.toDouble(), + maxSupply: (json['max_supply'] as num?)?.toDouble(), + ath: (json['ath'] as num?)?.toDouble(), + athChangePercentage: (json['ath_change_percentage'] as num?)?.toDouble(), + athDate: json['ath_date'] == null + ? null + : DateTime.parse(json['ath_date'] as String), + atl: (json['atl'] as num?)?.toDouble(), + atlChangePercentage: (json['atl_change_percentage'] as num?)?.toDouble(), + atlDate: json['atl_date'] == null + ? null + : DateTime.parse(json['atl_date'] as String), + roi: json['roi'] as dynamic, + lastUpdated: json['last_updated'] == null + ? null + : DateTime.parse(json['last_updated'] as String), + ); + } + + /// The unique identifier of the coin. + final String? id; + + /// The symbol of the coin. + final String? symbol; + + /// The name of the coin. + final String? name; + + /// The URL of the coin's image. + final String? image; + + /// The current price of the coin. + final double? currentPrice; + + /// The market capitalization of the coin. + final double? marketCap; + + /// The rank of the coin based on market capitalization. + final double? marketCapRank; + + /// The fully diluted valuation of the coin. + final double? fullyDilutedValuation; + + /// The total trading volume of the coin in the last 24 hours. + final double? totalVolume; + + /// The highest price of the coin in the last 24 hours. + final double? high24h; + + /// The lowest price of the coin in the last 24 hours. + final double? low24h; + + /// The price change of the coin in the last 24 hours. + final double? priceChange24h; + + /// The percentage price change of the coin in the last 24 hours. + final double? priceChangePercentage24h; + + /// The market capitalization change of the coin in the last 24 hours. + final double? marketCapChange24h; + + /// The percentage market capitalization change of the coin in the last 24 hours. + final double? marketCapChangePercentage24h; + + /// The circulating supply of the coin. + final double? circulatingSupply; + + /// The total supply of the coin. + final double? totalSupply; + + /// The maximum supply of the coin. + final double? maxSupply; + + /// The all-time high price of the coin. + final double? ath; + + /// The percentage change from the all-time high price of the coin. + final double? athChangePercentage; + + /// The date when the all-time high price of the coin was reached. + final DateTime? athDate; + + /// The all-time low price of the coin. + final double? atl; + + /// The percentage change from the all-time low price of the coin. + final double? atlChangePercentage; + + /// The date when the all-time low price of the coin was reached. + final DateTime? atlDate; + + /// The return on investment (ROI) of the coin. + final dynamic roi; + + /// The date and time when the market data was last updated. + final DateTime? lastUpdated; + + Map toJson() => { + 'id': id, + 'symbol': symbol, + 'name': name, + 'image': image, + 'current_price': currentPrice, + 'market_cap': marketCap, + 'market_cap_rank': marketCapRank, + 'fully_diluted_valuation': fullyDilutedValuation, + 'total_volume': totalVolume, + 'high_24h': high24h, + 'low_24h': low24h, + 'price_change_24h': priceChange24h, + 'price_change_percentage_24h': priceChangePercentage24h, + 'market_cap_change_24h': marketCapChange24h, + 'market_cap_change_percentage_24h': marketCapChangePercentage24h, + 'circulating_supply': circulatingSupply, + 'total_supply': totalSupply, + 'max_supply': maxSupply, + 'ath': ath, + 'ath_change_percentage': athChangePercentage, + 'ath_date': athDate?.toIso8601String(), + 'atl': atl, + 'atl_change_percentage': atlChangePercentage, + 'atl_date': atlDate?.toIso8601String(), + 'roi': roi, + 'last_updated': lastUpdated?.toIso8601String(), + }; + + @override + List get props { + return [ + id, + symbol, + name, + image, + currentPrice, + marketCap, + marketCapRank, + fullyDilutedValuation, + totalVolume, + high24h, + low24h, + priceChange24h, + priceChangePercentage24h, + marketCapChange24h, + marketCapChangePercentage24h, + circulatingSupply, + totalSupply, + maxSupply, + ath, + athChangePercentage, + athDate, + atl, + atlChangePercentage, + atlDate, + roi, + lastUpdated, + ]; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/komodo/komodo.dart b/packages/komodo_cex_market_data/lib/src/komodo/komodo.dart new file mode 100644 index 0000000000..0e8a7ea1a0 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/komodo/komodo.dart @@ -0,0 +1 @@ +export 'prices/prices.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart new file mode 100644 index 0000000000..e48c7bdf3a --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_provider.dart @@ -0,0 +1,50 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:komodo_cex_market_data/src/models/models.dart'; + +/// A class for fetching prices from Komodo API. +class KomodoPriceProvider { + /// Creates a new instance of [KomodoPriceProvider]. + KomodoPriceProvider({ + this.mainTickersUrl = + 'https://defi-stats.komodo.earth/api/v3/prices/tickers_v2?expire_at=600', + }); + + /// The URL to fetch the main tickers from. + final String mainTickersUrl; + + /// Fetches prices from Komodo API. + /// + /// Returns a map of coin IDs to their prices. + /// + /// Throws an error if the request fails. + /// + /// Example: + /// ```dart + /// final Map? prices = + /// await cexPriceProvider.getLegacyKomodoPrices(); + /// ``` + Future> getKomodoPrices() async { + final mainUri = Uri.parse(mainTickersUrl); + + http.Response res; + String body; + res = await http.get(mainUri); + body = res.body; + + final json = jsonDecode(body) as Map?; + + if (json == null) { + throw Exception('Invalid response from Komodo API: empty JSON'); + } + + final prices = {}; + json.forEach((String priceTicker, dynamic pricesData) { + prices[priceTicker] = + CexPrice.fromJson(priceTicker, pricesData as Map); + }); + return prices; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart new file mode 100644 index 0000000000..e9912ca9f2 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/komodo/prices/komodo_price_repository.dart @@ -0,0 +1,44 @@ +import 'package:komodo_cex_market_data/src/komodo/prices/komodo_price_provider.dart'; +import 'package:komodo_cex_market_data/src/models/models.dart'; + +/// A repository for fetching the prices of coins from the Komodo Defi API. +class KomodoPriceRepository { + /// Creates a new instance of [KomodoPriceRepository]. + KomodoPriceRepository({ + required KomodoPriceProvider cexPriceProvider, + }) : _cexPriceProvider = cexPriceProvider; + + /// The price provider to fetch the prices from. + final KomodoPriceProvider _cexPriceProvider; + + /// Fetches the prices of the provided coin IDs at the given timestamps. + /// + /// The [coinId] is the ID of the coin to fetch the prices for. + /// The [timestamps] are the timestamps to fetch the prices for. + /// The [vsCurrency] is the currency to compare the prices to. + /// + /// Returns a map of timestamps to the prices of the coins. + Future getCexFiatPrices( + String coinId, + List timestamps, { + String vsCurrency = 'usd', + }) async { + return (await _cexPriceProvider.getKomodoPrices()) + .values + .firstWhere((CexPrice element) { + if (element.ticker != coinId) { + return false; + } + + // return timestamps.contains(element.timestamp); + return true; + }).price; + } + + /// Fetches the prices of the provided coin IDs. + /// + /// Returns a map of coin IDs to their prices. + Future> getKomodoPrices() async { + return _cexPriceProvider.getKomodoPrices(); + } +} diff --git a/packages/komodo_cex_market_data/lib/src/komodo/prices/prices.dart b/packages/komodo_cex_market_data/lib/src/komodo/prices/prices.dart new file mode 100644 index 0000000000..ec6de51bb2 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/komodo/prices/prices.dart @@ -0,0 +1,2 @@ +export 'komodo_price_provider.dart'; +export 'komodo_price_repository.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/komodo_cex_market_data_base.dart b/packages/komodo_cex_market_data/lib/src/komodo_cex_market_data_base.dart new file mode 100644 index 0000000000..d231abd31b --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/komodo_cex_market_data_base.dart @@ -0,0 +1,5 @@ +export 'binance/binance.dart'; +export 'cex_repository.dart'; +export 'coingecko/coingecko.dart'; +export 'komodo/komodo.dart'; +export 'models/models.dart'; diff --git a/packages/komodo_cex_market_data/lib/src/models/cex_coin.dart b/packages/komodo_cex_market_data/lib/src/models/cex_coin.dart new file mode 100644 index 0000000000..1303e724d7 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/models/cex_coin.dart @@ -0,0 +1,67 @@ +import 'package:equatable/equatable.dart'; + +/// Represents a coin that can be traded on the CEX exchange. +class CexCoin extends Equatable { + const CexCoin({ + required this.id, + required this.symbol, + required this.name, + required this.currencies, + this.source, + }); + + factory CexCoin.fromJson(Map json) { + return CexCoin( + id: json['id'] as String, + symbol: json['symbol'] as String, + name: json['name'] as String, + currencies: ((json['currencies'] ?? []) as List).toSet(), + ); + } + + /// The unique identifier of the coin. + final String id; + + /// The symbol (abbreviation) of the coin. + final String symbol; + + /// The friendly name of the coin. + final String name; + + /// The list of currencies that the coin can be traded with. + final Set currencies; + + /// The source of the coin data. + final String? source; + + Map toJson() { + return { + 'id': id, + 'symbol': symbol, + 'name': name, + 'currencies': currencies, + }; + } + + CexCoin copyWith({ + String? id, + String? symbol, + String? name, + Set? currencies, + }) { + return CexCoin( + id: id ?? this.id, + symbol: symbol ?? this.symbol, + name: name ?? this.name, + currencies: currencies ?? this.currencies, + ); + } + + @override + String toString() { + return 'Coin{id: $id, symbol: $symbol, name: $name}'; + } + + @override + List get props => [id]; +} diff --git a/packages/komodo_cex_market_data/lib/src/models/cex_coin_pair.dart b/packages/komodo_cex_market_data/lib/src/models/cex_coin_pair.dart new file mode 100644 index 0000000000..ed2417ab5f --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/models/cex_coin_pair.dart @@ -0,0 +1,81 @@ +import 'package:equatable/equatable.dart'; +import 'package:komodo_cex_market_data/src/models/cex_coin.dart'; + +/// Represents a trading pair of coin on CEX exchanges, with the +/// [baseCoinTicker] as the coin being sold and [relCoinTicker] as the coin +/// being bought. +class CexCoinPair extends Equatable { + /// Creates a new [CexCoinPair] with the given [baseCoinTicker] and + /// [relCoinTicker]. + const CexCoinPair({ + required this.baseCoinTicker, + required this.relCoinTicker, + }); + + factory CexCoinPair.fromJson(Map json) { + return CexCoinPair( + baseCoinTicker: json['baseCoinTicker'] as String, + relCoinTicker: json['relCoinTicker'] as String, + ); + } + + const CexCoinPair.usdtPrice(this.baseCoinTicker) : relCoinTicker = 'USDT'; + + /// The ticker symbol of the coin being sold. + final String baseCoinTicker; + + /// The ticker symbol of the coin being bought. + final String relCoinTicker; + + Map toJson() { + return { + 'baseCoinTicker': baseCoinTicker, + 'relCoinTicker': relCoinTicker, + }; + } + + CexCoinPair copyWith({ + String? baseCoinTicker, + String? relCoinTicker, + }) { + return CexCoinPair( + baseCoinTicker: baseCoinTicker ?? this.baseCoinTicker, + relCoinTicker: relCoinTicker ?? this.relCoinTicker, + ); + } + + @override + List get props => [baseCoinTicker, relCoinTicker]; + + @override + String toString() { + return '$baseCoinTicker$relCoinTicker'.toUpperCase(); + } +} + +/// An extension on [CexCoinPair] to check if the coin pair is supported by the +/// exchange given the list of supported coins. +extension CexCoinPairExtension on CexCoinPair { + /// Returns `true` if the coin pair is supported by the exchange given the + /// list of [supportedCoins]. + bool isCoinSupported(List supportedCoins) { + final baseCoinId = baseCoinTicker.toUpperCase(); + final relCoinId = relCoinTicker.toUpperCase(); + + final cexCoin = supportedCoins + .where( + (supportedCoin) => supportedCoin.id.toUpperCase() == baseCoinId, + ) + .firstOrNull; + final isCoinSupported = cexCoin != null; + + final isFiatCoinInSupportedCurrencies = cexCoin?.currencies + .where( + (supportedVsCoin) => supportedVsCoin.toUpperCase() == relCoinId, + ) + .isNotEmpty ?? + false; + + return isCoinSupported && isFiatCoinInSupportedCurrencies; + } +} diff --git a/packages/komodo_cex_market_data/lib/src/models/cex_price.dart b/packages/komodo_cex_market_data/lib/src/models/cex_price.dart new file mode 100644 index 0000000000..ab9556fc86 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/models/cex_price.dart @@ -0,0 +1,116 @@ +import 'package:equatable/equatable.dart'; + +/// A class for representing a price from a CEX API. +class CexPrice extends Equatable { + /// Creates a new instance of [CexPrice]. + const CexPrice({ + required this.ticker, + required this.price, + this.lastUpdated, + this.priceProvider, + this.change24h, + this.changeProvider, + this.volume24h, + this.volumeProvider, + }); + + /// Creates a new instance of [CexPrice] from a JSON object. + factory CexPrice.fromJson(String ticker, Map json) { + return CexPrice( + ticker: ticker, + price: double.tryParse(json['last_price'] as String? ?? '') ?? 0, + lastUpdated: DateTime.fromMillisecondsSinceEpoch( + (json['last_updated_timestamp'] as int?) ?? 0 * 1000, + ), + priceProvider: cexDataProvider(json['price_provider'] as String? ?? ''), + change24h: double.tryParse(json['change_24h'] as String? ?? ''), + changeProvider: + cexDataProvider(json['change_24h_provider'] as String? ?? ''), + volume24h: double.tryParse(json['volume24h'] as String? ?? ''), + volumeProvider: cexDataProvider(json['volume_provider'] as String? ?? ''), + ); + } + + /// The ticker of the price. + final String ticker; + + /// The price of the ticker. + final double price; + + /// The last time the price was updated. + final DateTime? lastUpdated; + + /// The provider of the price. + final CexDataProvider? priceProvider; + + /// The 24-hour volume of the ticker. + final double? volume24h; + + /// The provider of the volume. + final CexDataProvider? volumeProvider; + + /// The 24-hour change of the ticker. + final double? change24h; + + /// The provider of the change. + final CexDataProvider? changeProvider; + + /// Converts the [CexPrice] to a JSON object. + Map toJson() { + return { + ticker: { + 'last_price': price, + 'last_updated_timestamp': lastUpdated, + 'price_provider': priceProvider, + 'volume24h': volume24h, + 'volume_provider': volumeProvider, + 'change_24h': change24h, + 'change_24h_provider': changeProvider, + }, + }; + } + + @override + String toString() { + return 'CexPrice(ticker: $ticker, price: $price)'; + } + + @override + List get props => [ + ticker, + price, + lastUpdated, + priceProvider, + volume24h, + volumeProvider, + change24h, + changeProvider, + ]; +} + +/// An enum for representing a CEX data provider. +enum CexDataProvider { + /// Binance API. + binance, + + /// CoinGecko API. + coingecko, + + /// CoinMarketCap API. + coinpaprika, + + /// CryptoCompare API. + nomics, + + /// Unknown provider. + unknown, +} + +/// Returns a [CexDataProvider] from a string. If the string does not match any +/// of the known providers, [CexDataProvider.unknown] is returned. +CexDataProvider cexDataProvider(String string) { + return CexDataProvider.values.firstWhere( + (CexDataProvider e) => e.toString().split('.').last == string, + orElse: () => CexDataProvider.unknown, + ); +} diff --git a/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.dart b/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.dart new file mode 100644 index 0000000000..6af06aa8a7 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/models/coin_ohlc.dart @@ -0,0 +1,190 @@ +import 'package:equatable/equatable.dart'; + +/// Represents Open-High-Low-Close (OHLC) data. +class CoinOhlc extends Equatable { + /// Creates a new instance of [CoinOhlc]. + const CoinOhlc({required this.ohlc}); + + /// Creates a new instance of [CoinOhlc] from a JSON array. + factory CoinOhlc.fromJson(List json) { + return CoinOhlc( + ohlc: json + .map((dynamic kline) => Ohlc.fromJson(kline as List)) + .toList(), + ); + } + + /// Creates a new instance of [CoinOhlc] with constant price data between + /// [startAt] and [endAt] with an interval of [intervalSeconds]. + factory CoinOhlc.fromConstantPrice({ + required DateTime startAt, + required DateTime endAt, + required int intervalSeconds, + double constantValue = 1.0, + }) { + final coinOhlc = CoinOhlc( + ohlc: List.generate( + (endAt.difference(startAt).inSeconds / intervalSeconds).ceil(), + (index) { + final time = startAt.add( + Duration(seconds: index * intervalSeconds), + ); + return Ohlc( + high: constantValue, + low: constantValue, + open: constantValue, + close: constantValue, + openTime: time.millisecondsSinceEpoch, + closeTime: time.millisecondsSinceEpoch, + ); + }, + ), + ); + + coinOhlc.ohlc.add( + Ohlc( + high: constantValue, + low: constantValue, + open: constantValue, + close: constantValue, + openTime: endAt.millisecondsSinceEpoch, + closeTime: endAt.millisecondsSinceEpoch, + ), + ); + + return coinOhlc; + } + + /// The list of klines (candlestick data). + final List ohlc; + + /// Converts the [CoinOhlc] object to a JSON array. + List toJson() { + return ohlc.map((Ohlc kline) => kline.toJson()).toList(); + } + + @override + List get props => [ohlc]; +} + +/// Extension for converting a list of [Ohlc] objects to a `CoinOhlc` object. + +extension OhlcListToCoinOhlc on List { + /// Converts a list of [Ohlc] objects to a `CoinOhlc` object. + CoinOhlc toCoinOhlc() { + return CoinOhlc(ohlc: this); + } +} + +/// Represents a Binance Kline (candlestick) data. +class Ohlc extends Equatable { + /// Creates a new instance of [Ohlc]. + const Ohlc({ + required this.openTime, + required this.open, + required this.high, + required this.low, + required this.close, + required this.closeTime, + this.volume, + this.quoteAssetVolume, + this.numberOfTrades, + this.takerBuyBaseAssetVolume, + this.takerBuyQuoteAssetVolume, + }); + + /// Creates a new instance of [Ohlc] from a JSON array. + factory Ohlc.fromJson(List json) { + return Ohlc( + openTime: json[0] as int, + open: double.parse(json[1] as String), + high: double.parse(json[2] as String), + low: double.parse(json[3] as String), + close: double.parse(json[4] as String), + volume: double.parse(json[5] as String), + closeTime: json[6] as int, + quoteAssetVolume: double.parse(json[7] as String), + numberOfTrades: json[8] as int, + takerBuyBaseAssetVolume: double.parse(json[9] as String), + takerBuyQuoteAssetVolume: double.parse(json[10] as String), + ); + } + + /// Converts the [Ohlc] object to a JSON array. + List toJson() { + return [ + openTime, + open, + high, + low, + close, + volume, + closeTime, + quoteAssetVolume, + numberOfTrades, + takerBuyBaseAssetVolume, + takerBuyQuoteAssetVolume, + ]; + } + + /// Converts the kline data into a JSON object like that returned in the previously used OHLC endpoint. + Map toMap() { + return { + 'timestamp': openTime, + 'open': open, + 'high': high, + 'low': low, + 'close': close, + 'volume': volume, + 'quote_volume': quoteAssetVolume, + }; + } + + /// The opening time of the kline as a Unix timestamp since epoch (UTC). + final int openTime; + + /// The opening price of the kline. + final double open; + + /// The highest price reached during the kline. + final double high; + + /// The lowest price reached during the kline. + final double low; + + /// The closing price of the kline. + final double close; + + /// The trading volume during the kline. + final double? volume; + + /// The closing time of the kline. + final int closeTime; + + /// The quote asset volume during the kline. + final double? quoteAssetVolume; + + /// The number of trades executed during the kline. + final int? numberOfTrades; + + /// The volume of the asset bought by takers during the kline. + final double? takerBuyBaseAssetVolume; + + /// The quote asset volume of the asset bought by takers during the kline. + final double? takerBuyQuoteAssetVolume; + + @override + List get props => [ + openTime, + open, + high, + low, + close, + volume, + closeTime, + quoteAssetVolume, + numberOfTrades, + takerBuyBaseAssetVolume, + takerBuyQuoteAssetVolume, + ]; +} diff --git a/packages/komodo_cex_market_data/lib/src/models/graph_interval.dart b/packages/komodo_cex_market_data/lib/src/models/graph_interval.dart new file mode 100644 index 0000000000..eacf0d567c --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/models/graph_interval.dart @@ -0,0 +1,66 @@ +enum GraphInterval { + oneSecond, + oneMinute, + threeMinutes, + fiveMinutes, + fifteenMinutes, + thirtyMinutes, + oneHour, + twoHours, + fourHours, + sixHours, + eightHours, + twelveHours, + oneDay, + threeDays, + oneWeek, + oneMonth, +} + +extension GraphIntervalExtension on GraphInterval { + String toAbbreviation() { + return graphIntervalsMap[this]!; + } + + int toSeconds() { + return graphIntervalsInSeconds[this]!; + } +} + +const Map graphIntervalsMap = { + GraphInterval.oneSecond: '1s', + GraphInterval.oneMinute: '1m', + GraphInterval.threeMinutes: '3m', + GraphInterval.fiveMinutes: '5m', + GraphInterval.fifteenMinutes: '15m', + GraphInterval.thirtyMinutes: '30m', + GraphInterval.oneHour: '1h', + GraphInterval.twoHours: '2h', + GraphInterval.fourHours: '4h', + GraphInterval.eightHours: '8h', + GraphInterval.sixHours: '6h', + GraphInterval.twelveHours: '12h', + GraphInterval.oneDay: '1d', + GraphInterval.threeDays: '3d', + GraphInterval.oneWeek: '1w', + GraphInterval.oneMonth: '1M', +}; + +const Map graphIntervalsInSeconds = { + GraphInterval.oneSecond: 1, + GraphInterval.oneMinute: 60, + GraphInterval.threeMinutes: 180, + GraphInterval.fiveMinutes: 300, + GraphInterval.fifteenMinutes: 900, + GraphInterval.thirtyMinutes: 1800, + GraphInterval.oneHour: 3600, + GraphInterval.twoHours: 7200, + GraphInterval.fourHours: 14400, + GraphInterval.sixHours: 21600, + GraphInterval.eightHours: 28800, + GraphInterval.twelveHours: 43200, + GraphInterval.oneDay: 86400, + GraphInterval.threeDays: 259200, + GraphInterval.oneWeek: 604800, + GraphInterval.oneMonth: 2592000, +}; diff --git a/packages/komodo_cex_market_data/lib/src/models/models.dart b/packages/komodo_cex_market_data/lib/src/models/models.dart new file mode 100644 index 0000000000..7377688277 --- /dev/null +++ b/packages/komodo_cex_market_data/lib/src/models/models.dart @@ -0,0 +1,5 @@ +export 'cex_coin.dart'; +export 'cex_coin_pair.dart'; +export 'cex_price.dart'; +export 'coin_ohlc.dart'; +export 'graph_interval.dart'; diff --git a/packages/komodo_cex_market_data/pubspec.yaml b/packages/komodo_cex_market_data/pubspec.yaml new file mode 100644 index 0000000000..8f73e78c17 --- /dev/null +++ b/packages/komodo_cex_market_data/pubspec.yaml @@ -0,0 +1,27 @@ +name: komodo_cex_market_data +description: A starting point for Dart libraries or applications. +version: 0.0.1 +publish_to: none # publishable packages should not have git dependencies + +environment: + sdk: ">=3.0.0 <4.0.0" + +# Add regular dependencies here. +dependencies: + http: 0.13.6 # dart.dev + + equatable: + git: + url: https://github.com/KomodoPlatform/equatable.git + ref: 2117551ff3054f8edb1a58f63ffe1832a8d25623 #2.0.5 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + hive: + git: + url: https://github.com/KomodoPlatform/hive.git + path: hive/ + ref: 470473ffc1ba39f6c90f31ababe0ee63b76b69fe #2.2.3 + +dev_dependencies: + flutter_lints: ^2.0.0 # flutter.dev + test: ^1.24.0 diff --git a/packages/komodo_cex_market_data/test/binance/binance_provider_test.dart b/packages/komodo_cex_market_data/test/binance/binance_provider_test.dart new file mode 100644 index 0000000000..be128f6a2d --- /dev/null +++ b/packages/komodo_cex_market_data/test/binance/binance_provider_test.dart @@ -0,0 +1,5 @@ +import 'package:test/test.dart'; + +void main() { + group('BinanceProvider', () {}); +} diff --git a/packages/komodo_cex_market_data/test/binance/binance_repository_test.dart b/packages/komodo_cex_market_data/test/binance/binance_repository_test.dart new file mode 100644 index 0000000000..1e2cbb3904 --- /dev/null +++ b/packages/komodo_cex_market_data/test/binance/binance_repository_test.dart @@ -0,0 +1,5 @@ +import 'package:test/test.dart'; + +void main() { + group('BinanceRepository', () {}); +} diff --git a/packages/komodo_cex_market_data/test/coingecko/coingecko_cex_provider_test.dart b/packages/komodo_cex_market_data/test/coingecko/coingecko_cex_provider_test.dart new file mode 100644 index 0000000000..1f2b6a4b22 --- /dev/null +++ b/packages/komodo_cex_market_data/test/coingecko/coingecko_cex_provider_test.dart @@ -0,0 +1,75 @@ +import 'package:komodo_cex_market_data/komodo_cex_market_data.dart'; +import 'package:test/test.dart'; + +void main() { + group('Coingecko CEX provider tests', () { + setUp(() { + // Additional setup goes here. + }); + + test('fetchCoinList test', () async { + // Arrange + final provider = CoinGeckoCexProvider(); + + // Act + final result = await provider.fetchCoinList(); + + // Assert + expect(result, isA>()); + expect(result.length, greaterThan(0)); + }); + + test('fetchCoinMarketData test', () async { + // Arrange + final provider = CoinGeckoCexProvider(); + + // Act + final result = await provider.fetchCoinMarketData(); + + // Assert + expect(result, isA>()); + expect(result.length, greaterThan(0)); + }); + + test('fetchCoinMarketChart test', () async { + // Arrange + final provider = CoinGeckoCexProvider(); + + // Act + final result = await provider.fetchCoinMarketChart( + id: 'bitcoin', + vsCurrency: 'usd', + fromUnixTimestamp: 1712403721, + toUnixTimestamp: 1712749321, + ); + + // Assert + expect(result, isA()); + expect(result.prices, isA>>()); + expect(result.prices.length, greaterThan(0)); + expect(result.marketCaps, isA>>()); + expect(result.marketCaps.length, greaterThan(0)); + expect(result.totalVolumes, isA>>()); + expect(result.totalVolumes.length, greaterThan(0)); + }); + }); + + // test('fetchCoinHistoricalData test', () async { + // // Arrange + // final CoinGeckoCexProvider provider = CoinGeckoCexProvider(); + // const String id = 'bitcoin'; + // const String date = '2023-04-20'; + + // // Act + // final CoinHistoricalData result = await provider.fetchCoinHistoricalData( + // id: id, + // date: date, + // ); + + // // Assert + // expect(result, isA()); + // expect(result.marketData, isA()); + // expect(result.marketData?.currentPrice, isNotNull); + // expect(result.marketData?.currentPrice?.usd, isA()); + // }); +} diff --git a/packages/komodo_cex_market_data/test/komodo/cex_price_repository_test.dart b/packages/komodo_cex_market_data/test/komodo/cex_price_repository_test.dart new file mode 100644 index 0000000000..5a9bd3e3a8 --- /dev/null +++ b/packages/komodo_cex_market_data/test/komodo/cex_price_repository_test.dart @@ -0,0 +1,24 @@ +import 'package:komodo_cex_market_data/src/komodo/prices/komodo_price_provider.dart'; +import 'package:komodo_cex_market_data/src/komodo/prices/komodo_price_repository.dart'; +import 'package:test/test.dart'; + +void main() { + late KomodoPriceRepository cexPriceRepository; + setUp(() { + cexPriceRepository = + KomodoPriceRepository(cexPriceProvider: KomodoPriceProvider()); + }); + + group('getPrices', () { + test('should return Komodo fiat rates list', () async { + // Arrange + + // Act + final result = await cexPriceRepository.getKomodoPrices(); + + // Assert + expect(result.length, greaterThan(0)); + expect(result.keys, contains('KMD')); + }); + }); +} diff --git a/packages/komodo_cex_market_data/test/komodo_cex_market_data_test.dart b/packages/komodo_cex_market_data/test/komodo_cex_market_data_test.dart new file mode 100644 index 0000000000..d4a7b5e34e --- /dev/null +++ b/packages/komodo_cex_market_data/test/komodo_cex_market_data_test.dart @@ -0,0 +1,11 @@ +import 'package:test/test.dart'; + +void main() { + group('A group of tests', () { + setUp(() {}); + + test('First Test', () { + throw UnimplementedError(); + }); + }); +} diff --git a/packages/komodo_coin_updates/.gitignore b/packages/komodo_coin_updates/.gitignore new file mode 100644 index 0000000000..3cceda5578 --- /dev/null +++ b/packages/komodo_coin_updates/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/komodo_coin_updates/CHANGELOG.md b/packages/komodo_coin_updates/CHANGELOG.md new file mode 100644 index 0000000000..b66cbecb5a --- /dev/null +++ b/packages/komodo_coin_updates/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 0.0.1 + +- Initial version. diff --git a/packages/komodo_coin_updates/README.md b/packages/komodo_coin_updates/README.md new file mode 100644 index 0000000000..49fc6bc602 --- /dev/null +++ b/packages/komodo_coin_updates/README.md @@ -0,0 +1,71 @@ +# Komodo Coin Updater + +This package provides the funcionality to update the coins list and configuration files for the Komodo Platform at runtime. + +## Usage + +To use this package, you need to add `komodo_coin_updater` to your `pubspec.yaml` file. + +```yaml +dependencies: + komodo_coin_updater: ^1.0.0 +``` + +### Initialize the package + +Then you can use the `KomodoCoinUpdater` class to initialize the package. + +```dart +import 'package:komodo_coin_updater/komodo_coin_updater.dart'; + +void main() async { + await KomodoCoinUpdater.ensureInitialized("path/to/komodo/coin/files"); +} +``` + +### Provider + +The coins provider is responsible for fetching the coins list and configuration files from GitHub. + +```dart +import 'package:komodo_coin_updater/komodo_coin_updater.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await KomodoCoinUpdater.ensureInitialized("path/to/komodo/coin/files"); + + final provider = const CoinConfigProvider(); + final coins = await provider.getLatestCoins(); + final coinsConfigs = await provider.getLatestCoinConfigs(); +} +``` + +### Repository + +The repository is responsible for managing the coins list and configuration files, fetching from GitHub and persisting to storage. + +```dart +import 'package:komodo_coin_updater/komodo_coin_updater.dart'; + +Future main() async { + WidgetsFlutterBinding.ensureInitialized(); + await KomodoCoinUpdater.ensureInitialized("path/to/komodo/coin/files"); + + final repository = CoinConfigRepository( + api: const CoinConfigProvider(), + storageProvider: CoinConfigStorageProvider.withDefaults(), + ); + + // Load the coin configuration if it is saved, otherwise update it + if(await repository.coinConfigExists()) { + if (await repository.isLatestCommit()) { + await repository.loadCoinConfigs(); + } else { + await repository.updateCoinConfig(); + } + } + else { + await repository.updateCoinConfig(); + } +} +``` diff --git a/packages/komodo_coin_updates/analysis_options.yaml b/packages/komodo_coin_updates/analysis_options.yaml new file mode 100644 index 0000000000..0f9ee263df --- /dev/null +++ b/packages/komodo_coin_updates/analysis_options.yaml @@ -0,0 +1,250 @@ +# Specify analysis options. +# +# For a list of lints, see: https://dart.dev/lints +# For guidelines on configuring static analysis, see: +# https://dart.dev/guides/language/analysis-options +# +# There are other similar analysis options files in the flutter repos, +# which should be kept in sync with this file: +# +# - analysis_options.yaml (this file) +# - https://github.com/flutter/engine/blob/main/analysis_options.yaml +# - https://github.com/flutter/packages/blob/main/analysis_options.yaml +# +# This file contains the analysis options used for code in the flutter/flutter +# repository. + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # allow self-reference to deprecated members (we do this because otherwise we have + # to annotate every member in every test, assert, etc, when we deprecate something) + deprecated_member_use_from_same_package: ignore + exclude: + - "bin/cache/**" + # Ignore protoc generated files + - "dev/conductor/lib/proto/*" + +linter: + rules: + # This list is derived from the list of all available lints located at + # https://github.com/dart-lang/linter/blob/main/example/all.yaml + - always_declare_return_types + - always_put_control_body_on_new_line + # - always_put_required_named_parameters_first # we prefer having parameters in the same order as fields https://github.com/flutter/flutter/issues/10219 + - always_specify_types + # - always_use_package_imports # we do this commonly + - annotate_overrides + # - avoid_annotating_with_dynamic # conflicts with always_specify_types + - avoid_bool_literals_in_conditional_expressions + # - avoid_catches_without_on_clauses # blocked on https://github.com/dart-lang/linter/issues/3023 + # - avoid_catching_errors # blocked on https://github.com/dart-lang/linter/issues/3023 + # - avoid_classes_with_only_static_members # we do this commonly for `abstract final class`es + - avoid_double_and_int_checks + - avoid_dynamic_calls + - avoid_empty_else + - avoid_equals_and_hash_code_on_mutable_classes + - avoid_escaping_inner_quotes + - avoid_field_initializers_in_const_classes + # - avoid_final_parameters # incompatible with prefer_final_parameters + - avoid_function_literals_in_foreach_calls + # - avoid_implementing_value_types # see https://github.com/dart-lang/linter/issues/4558 + - avoid_init_to_null + - avoid_js_rounded_ints + # - avoid_multiple_declarations_per_line # seems to be a stylistic choice we don't subscribe to + - avoid_null_checks_in_equality_operators + - avoid_positional_boolean_parameters # would have been nice to enable this but by now there's too many places that break it + - avoid_print + # - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356) + - avoid_redundant_argument_values + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null_for_void + # - avoid_returning_this # there are enough valid reasons to return `this` that this lint ends up with too many false positives + - avoid_setters_without_getters + - avoid_shadowing_type_parameters + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_type_to_string + - avoid_types_as_parameter_names + # - avoid_types_on_closure_parameters # conflicts with always_specify_types + - avoid_unnecessary_containers + - avoid_unused_constructor_parameters + - avoid_void_async + # - avoid_web_libraries_in_flutter # we use web libraries in web-specific code, and our tests prevent us from using them elsewhere + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + # - cascade_invocations # doesn't match the typical style of this repo + - cast_nullable_to_non_nullable + # - close_sinks # not reliable enough + - collection_methods_unrelated_type + - combinators_ordering + # - comment_references # blocked on https://github.com/dart-lang/linter/issues/1142 + - conditional_uri_does_not_exist + # - constant_identifier_names # needs an opt-out https://github.com/dart-lang/linter/issues/204 + - control_flow_in_finally + - curly_braces_in_flow_control_structures + - dangling_library_doc_comments + - depend_on_referenced_packages + - deprecated_consistency + # - deprecated_member_use_from_same_package # we allow self-references to deprecated members + # - diagnostic_describe_all_properties # enabled only at the framework level (packages/flutter/lib) + - directives_ordering + # - discarded_futures # too many false positives, similar to unawaited_futures + # - do_not_use_environment # there are appropriate times to use the environment, especially in our tests and build logic + - empty_catches + - empty_constructor_bodies + - empty_statements + - eol_at_end_of_file + - exhaustive_cases + - file_names + - flutter_style_todos + - hash_and_equals + - implementation_imports + - implicit_call_tearoffs + - implicit_reopen + - invalid_case_patterns + # - join_return_with_assignment # not required by flutter style + - leading_newlines_in_multiline_strings + - library_annotations + - library_names + - library_prefixes + - library_private_types_in_public_api + # - lines_longer_than_80_chars # not required by flutter style + - literal_only_boolean_expressions + # - matching_super_parameters # blocked on https://github.com/dart-lang/language/issues/2509 + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_default_cases + - no_duplicate_case_values + - no_leading_underscores_for_library_prefixes + - no_leading_underscores_for_local_identifiers + - no_literal_bool_comparisons + - no_logic_in_create_state + # - no_runtimeType_toString # ok in tests; we enable this only in packages/ + - no_self_assignments + - no_wildcard_variable_uses + - non_constant_identifier_names + - noop_primitive_operations + - null_check_on_nullable_type_parameter + - null_closures + # - omit_local_variable_types # opposite of always_specify_types + # - one_member_abstracts # too many false positives + - only_throw_errors # this does get disabled in a few places where we have legacy code that uses strings et al + - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + # - parameter_assignments # we do this commonly + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + # - prefer_asserts_with_message # not required by flutter style + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + # - prefer_constructors_over_static_methods # far too many false positives + - prefer_contains + # - prefer_double_quotes # opposite of prefer_single_quotes + # - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#consider-using--for-short-functions-and-methods + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + # - prefer_final_parameters # adds too much verbosity + - prefer_for_elements_to_map_fromIterable + - prefer_foreach + - prefer_function_declarations_over_variables + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + # - prefer_int_literals # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#use-double-literals-for-double-constants + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + - prefer_mixin + # - prefer_null_aware_method_calls # "call()" is confusing to people new to the language since it's not documented anywhere + - prefer_null_aware_operators + - prefer_relative_imports + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + - provide_deprecation_message + # - public_member_api_docs # enabled on a case-by-case basis; see e.g. packages/analysis_options.yaml + - recursive_getters + - require_trailing_commas # would be nice, but requires a lot of manual work: 10,000+ code locations would need to be reformatted by hand after bulk fix is applied + - secure_pubspec_urls + - sized_box_for_whitespace + - sized_box_shrink_expand + - slash_for_doc_comments + - sort_child_properties_last + - sort_constructors_first + # - sort_pub_dependencies # prevents separating pinned transitive dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + - tighten_type_of_initializing_formals + # - type_annotate_public_apis # subset of always_specify_types + - type_init_formals + - type_literal_in_constant_pattern + # - unawaited_futures # too many false positives, especially with the way AnimationController works + - unnecessary_await_in_return + - unnecessary_brace_in_string_interps + - unnecessary_breaks + - unnecessary_const + - unnecessary_constructor_name + # - unnecessary_final # conflicts with prefer_final_locals + - unnecessary_getters_setters + # - unnecessary_lambdas # has false positives: https://github.com/dart-lang/linter/issues/498 + - unnecessary_late + - unnecessary_library_directive + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_aware_operator_on_extension_on_nullable + - unnecessary_null_checks + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_overrides + - unnecessary_parenthesis + # - unnecessary_raw_strings # what's "necessary" is a matter of opinion; consistency across strings can help readability more than this lint + - unnecessary_statements + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unnecessary_to_list_in_spreads + - unreachable_from_main + - unrelated_type_equality_checks + - unsafe_html + - use_build_context_synchronously + - use_colored_box + # - use_decorated_box # leads to bugs: DecoratedBox and Container are not equivalent (Container inserts extra padding) + - use_enums + - use_full_hex_values_for_flutter_colors + - use_function_type_syntax_for_parameters + - use_if_null_to_convert_nulls_to_bools + - use_is_even_rather_than_modulo + - use_key_in_widget_constructors + - use_late_for_private_fields_and_variables + - use_named_constants + - use_raw_strings + - use_rethrow_when_possible + - use_setters_to_change_properties + # - use_string_buffers # has false positives: https://github.com/dart-lang/sdk/issues/34182 + - use_string_in_part_of_directives + - use_super_parameters + - use_test_throws_matchers + # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review + - valid_regexps + - void_checks \ No newline at end of file diff --git a/packages/komodo_coin_updates/example/komodo_coin_updates_example.dart b/packages/komodo_coin_updates/example/komodo_coin_updates_example.dart new file mode 100644 index 0000000000..337c98cd8f --- /dev/null +++ b/packages/komodo_coin_updates/example/komodo_coin_updates_example.dart @@ -0,0 +1,4 @@ +void main() { + // TODO(Francois): implement this + throw UnimplementedError(); +} diff --git a/packages/komodo_coin_updates/lib/komodo_coin_updates.dart b/packages/komodo_coin_updates/lib/komodo_coin_updates.dart new file mode 100644 index 0000000000..76f6f27d05 --- /dev/null +++ b/packages/komodo_coin_updates/lib/komodo_coin_updates.dart @@ -0,0 +1,8 @@ +/// Support for doing something awesome. +/// +/// More dartdocs go here. +library; + +export 'src/data/data.dart'; +export 'src/komodo_coin_updater.dart'; +export 'src/models/models.dart'; diff --git a/packages/komodo_coin_updates/lib/src/data/coin_config_provider.dart b/packages/komodo_coin_updates/lib/src/data/coin_config_provider.dart new file mode 100644 index 0000000000..8290cd54ed --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/data/coin_config_provider.dart @@ -0,0 +1,102 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../models/models.dart'; + +/// A provider that fetches the coins and coin configs from the repository. +/// The repository is hosted on GitHub. +/// The repository contains a list of coins and a map of coin configs. +class CoinConfigProvider { + CoinConfigProvider({ + this.branch = 'master', + this.coinsGithubContentUrl = + 'https://raw.githubusercontent.com/KomodoPlatform/coins', + this.coinsGithubApiUrl = + 'https://api.github.com/repos/KomodoPlatform/coins', + this.coinsPath = 'coins', + this.coinsConfigPath = 'utils/coins_config_unfiltered.json', + }); + + factory CoinConfigProvider.fromConfig(RuntimeUpdateConfig config) { + // TODO(Francois): derive all the values from the config + return CoinConfigProvider( + branch: config.coinsRepoBranch, + ); + } + + final String branch; + final String coinsGithubContentUrl; + final String coinsGithubApiUrl; + final String coinsPath; + final String coinsConfigPath; + + /// Fetches the coins from the repository. + /// [commit] is the commit hash to fetch the coins from. + /// If [commit] is not provided, it will fetch the coins from the latest commit. + /// Returns a list of [Coin] objects. + /// Throws an [Exception] if the request fails. + Future> getCoins(String commit) async { + final Uri url = _contentUri(coinsPath, branchOrCommit: commit); + final http.Response response = await http.get(url); + final List items = jsonDecode(response.body) as List; + return items + .map((dynamic e) => Coin.fromJson(e as Map)) + .toList(); + } + + /// Fetches the coins from the repository. + /// Returns a list of [Coin] objects. + /// Throws an [Exception] if the request fails. + Future> getLatestCoins() async { + return getCoins(branch); + } + + /// Fetches the coin configs from the repository. + /// [commit] is the commit hash to fetch the coin configs from. + /// If [commit] is not provided, it will fetch the coin configs + /// from the latest commit. + /// Returns a map of [CoinConfig] objects. + /// Throws an [Exception] if the request fails. + /// The key of the map is the coin symbol. + Future> getCoinConfigs(String commit) async { + final Uri url = _contentUri(coinsConfigPath, branchOrCommit: commit); + final http.Response response = await http.get(url); + final Map items = + jsonDecode(response.body) as Map; + return { + for (final String key in items.keys) + key: CoinConfig.fromJson(items[key] as Map), + }; + } + + /// Fetches the latest coin configs from the repository. + /// Returns a map of [CoinConfig] objects. + /// Throws an [Exception] if the request fails. + Future> getLatestCoinConfigs() async { + return getCoinConfigs(branch); + } + + /// Fetches the latest commit hash from the repository. + /// Returns the latest commit hash. + /// Throws an [Exception] if the request fails. + Future getLatestCommit() async { + final http.Client client = http.Client(); + final Uri url = Uri.parse('$coinsGithubApiUrl/branches/$branch'); + final Map header = { + 'Accept': 'application/vnd.github+json', + }; + final http.Response response = await client.get(url, headers: header); + + final Map json = + jsonDecode(response.body) as Map; + final Map commit = json['commit'] as Map; + final String latestCommitHash = commit['sha'] as String; + return latestCommitHash; + } + + Uri _contentUri(String path, {String? branchOrCommit}) { + branchOrCommit ??= branch; + return Uri.parse('$coinsGithubContentUrl/$branch/$path'); + } +} diff --git a/packages/komodo_coin_updates/lib/src/data/coin_config_repository.dart b/packages/komodo_coin_updates/lib/src/data/coin_config_repository.dart new file mode 100644 index 0000000000..b62d6e2fc7 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/data/coin_config_repository.dart @@ -0,0 +1,164 @@ +import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; + +import '../../komodo_coin_updates.dart'; +import '../models/coin_info.dart'; + +/// A repository that fetches the coins and coin configs from the provider and +/// stores them in the storage provider. +class CoinConfigRepository implements CoinConfigStorage { + /// Creates a coin config repository. + /// [coinConfigProvider] is the provider that fetches the coins and coin configs. + /// [coinsDatabase] is the database that stores the coins and their configs. + /// [coinSettingsDatabase] is the database that stores the coin settings + /// (i.e. current commit hash). + CoinConfigRepository({ + required this.coinConfigProvider, + required this.coinsDatabase, + required this.coinSettingsDatabase, + }); + + /// Creates a coin config storage provider with default databases. + /// The default databases are HiveLazyBoxProvider. + /// The default databases are named 'coins' and 'coins_settings'. + CoinConfigRepository.withDefaults(RuntimeUpdateConfig config) + : coinConfigProvider = CoinConfigProvider.fromConfig(config), + coinsDatabase = HiveLazyBoxProvider( + name: 'coins', + ), + coinSettingsDatabase = HiveBoxProvider( + name: 'coins_settings', + ); + + /// The provider that fetches the coins and coin configs. + final CoinConfigProvider coinConfigProvider; + + /// The database that stores the coins. The key is the coin id. + final PersistenceProvider coinsDatabase; + + /// The database that stores the coin settings. The key is the coin settings key. + final PersistenceProvider coinSettingsDatabase; + + /// The key for the coins commit. The value is the commit hash. + final String coinsCommitKey = 'coins_commit'; + + String? _latestCommit; + + /// Updates the coin configs from the provider and stores them in the storage provider. + /// Throws an [Exception] if the request fails. + Future updateCoinConfig({ + List excludedAssets = const [], + }) async { + final List coins = await coinConfigProvider.getLatestCoins(); + final Map coinConfig = + await coinConfigProvider.getLatestCoinConfigs(); + + await saveCoinData(coins, coinConfig, _latestCommit ?? ''); + } + + @override + Future isLatestCommit() async { + final String? commit = await getCurrentCommit(); + if (commit != null) { + _latestCommit = await coinConfigProvider.getLatestCommit(); + return commit == _latestCommit; + } + return false; + } + + @override + Future?> getCoins({ + List excludedAssets = const [], + }) async { + final List result = await coinsDatabase.getAll(); + return result + .where( + (CoinInfo? coin) => + coin != null && !excludedAssets.contains(coin.coin.coin), + ) + .map((CoinInfo? coin) => coin!.coin) + .toList(); + } + + @override + Future getCoin(String coinId) async { + return (await coinsDatabase.get(coinId))!.coin; + } + + @override + Future?> getCoinConfigs({ + List excludedAssets = const [], + }) async { + final List coinConfigs = (await coinsDatabase.getAll()) + .where((CoinInfo? e) => e != null && e.coinConfig != null) + .cast() + .map((CoinInfo e) => e.coinConfig) + .cast() + .toList(); + + return { + for (final CoinConfig coinConfig in coinConfigs) + coinConfig.primaryKey: coinConfig, + }; + } + + @override + Future getCoinConfig(String coinId) async { + return (await coinsDatabase.get(coinId))!.coinConfig; + } + + @override + Future getCurrentCommit() async { + return coinSettingsDatabase + .get(coinsCommitKey) + .then((PersistedString? persistedString) { + return persistedString?.value; + }); + } + + @override + Future saveCoinData( + List coins, + Map coinConfig, + String commit, + ) async { + final Map combinedCoins = {}; + for (final Coin coin in coins) { + combinedCoins[coin.coin] = CoinInfo( + coin: coin, + coinConfig: coinConfig[coin.coin], + ); + } + + await coinsDatabase.insertAll(combinedCoins.values.toList()); + await coinSettingsDatabase.insert(PersistedString(coinsCommitKey, commit)); + _latestCommit = _latestCommit ?? await coinConfigProvider.getLatestCommit(); + } + + @override + Future coinConfigExists() async { + return await coinsDatabase.exists() && await coinSettingsDatabase.exists(); + } + + @override + Future saveRawCoinData( + List coins, + Map coinConfig, + String commit, + ) async { + final Map combinedCoins = {}; + for (final dynamic coin in coins) { + // ignore: avoid_dynamic_calls + final String coinAbbr = coin['coin'] as String; + final CoinConfig? config = coinConfig[coinAbbr] != null + ? CoinConfig.fromJson(coinConfig[coinAbbr] as Map) + : null; + combinedCoins[coinAbbr] = CoinInfo( + coin: Coin.fromJson(coin as Map), + coinConfig: config, + ); + } + + await coinsDatabase.insertAll(combinedCoins.values.toList()); + await coinSettingsDatabase.insert(PersistedString(coinsCommitKey, commit)); + } +} diff --git a/packages/komodo_coin_updates/lib/src/data/coin_config_storage.dart b/packages/komodo_coin_updates/lib/src/data/coin_config_storage.dart new file mode 100644 index 0000000000..3e71671a74 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/data/coin_config_storage.dart @@ -0,0 +1,67 @@ +import '../models/coin.dart'; +import '../models/coin_config.dart'; + +/// A storage provider that fetches the coins and coin configs from the storage. +/// The storage provider is responsible for fetching the coins and coin configs +/// from the storage and saving the coins and coin configs to the storage. +abstract class CoinConfigStorage { + /// Fetches the coins from the storage provider. + /// Returns a list of [Coin] objects. + /// Throws an [Exception] if the request fails. + Future?> getCoins(); + + /// Fetches the specified coin from the storage provider. + /// [coinId] is the coin symbol. + /// Returns a [Coin] object. + /// Throws an [Exception] if the request fails. + Future getCoin(String coinId); + + /// Fetches the coin configs from the storage provider. + /// Returns a map of [CoinConfig] objects. + /// Throws an [Exception] if the request fails. + Future?> getCoinConfigs(); + + /// Fetches the specified coin config from the storage provider. + /// [coinId] is the coin symbol. + /// Returns a [CoinConfig] object. + /// Throws an [Exception] if the request fails. + Future getCoinConfig(String coinId); + + /// Checks if the latest commit is the same as the current commit. + /// Returns `true` if the latest commit is the same as the current commit, + /// otherwise `false`. + /// Throws an [Exception] if the request fails. + Future isLatestCommit(); + + /// Fetches the current commit hash. + /// Returns the commit hash as a [String]. + /// Throws an [Exception] if the request fails. + Future getCurrentCommit(); + + /// Checks if the coin configs are saved in the storage provider. + /// Returns `true` if the coin configs are saved, otherwise `false`. + /// Throws an [Exception] if the request fails. + Future coinConfigExists(); + + /// Saves the coin data to the storage provider. + /// [coins] is a list of [Coin] objects. + /// [coinConfig] is a map of [CoinConfig] objects. + /// [commit] is the commit hash. + /// Throws an [Exception] if the request fails. + Future saveCoinData( + List coins, + Map coinConfig, + String commit, + ); + + /// Saves the raw coin data to the storage provider. + /// [coins] is a list of [Coin] objects in raw JSON `dynamic` form. + /// [coinConfig] is a map of [CoinConfig] objects in raw JSON `dynamic` form. + /// [commit] is the commit hash. + /// Throws an [Exception] if the request fails. + Future saveRawCoinData( + List coins, + Map coinConfig, + String commit, + ); +} diff --git a/packages/komodo_coin_updates/lib/src/data/data.dart b/packages/komodo_coin_updates/lib/src/data/data.dart new file mode 100644 index 0000000000..aea56ef55b --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/data/data.dart @@ -0,0 +1,3 @@ +export 'coin_config_provider.dart'; +export 'coin_config_repository.dart'; +export 'coin_config_storage.dart'; diff --git a/packages/komodo_coin_updates/lib/src/komodo_coin_updater.dart b/packages/komodo_coin_updates/lib/src/komodo_coin_updater.dart new file mode 100644 index 0000000000..6dbf468023 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/komodo_coin_updater.dart @@ -0,0 +1,34 @@ +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; + +import 'models/coin_info.dart'; +import 'models/models.dart'; + +class KomodoCoinUpdater { + static Future ensureInitialized(String appFolder) async { + await Hive.initFlutter(appFolder); + initializeAdapters(); + } + + static void ensureInitializedIsolate(String fullAppFolderPath) { + Hive.init(fullAppFolderPath); + initializeAdapters(); + } + + static void initializeAdapters() { + Hive.registerAdapter(AddressFormatAdapter()); + Hive.registerAdapter(CheckPointBlockAdapter()); + Hive.registerAdapter(CoinAdapter()); + Hive.registerAdapter(CoinConfigAdapter()); + Hive.registerAdapter(CoinInfoAdapter()); + Hive.registerAdapter(ConsensusParamsAdapter()); + Hive.registerAdapter(ContactAdapter()); + Hive.registerAdapter(ElectrumAdapter()); + Hive.registerAdapter(LinksAdapter()); + Hive.registerAdapter(NodeAdapter()); + Hive.registerAdapter(PersistedStringAdapter()); + Hive.registerAdapter(ProtocolAdapter()); + Hive.registerAdapter(ProtocolDataAdapter()); + Hive.registerAdapter(RpcUrlAdapter()); + } +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/address_format_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/address_format_adapter.dart new file mode 100644 index 0000000000..59b451cde0 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/address_format_adapter.dart @@ -0,0 +1,38 @@ +part of '../address_format.dart'; + +class AddressFormatAdapter extends TypeAdapter { + @override + final int typeId = 3; + + @override + AddressFormat read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return AddressFormat( + format: fields[0] as String?, + network: fields[1] as String?, + ); + } + + @override + void write(BinaryWriter writer, AddressFormat obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.format) + ..writeByte(1) + ..write(obj.network); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is AddressFormatAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/checkpoint_block_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/checkpoint_block_adapter.dart new file mode 100644 index 0000000000..923c3409c8 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/checkpoint_block_adapter.dart @@ -0,0 +1,44 @@ +part of '../checkpoint_block.dart'; + +class CheckPointBlockAdapter extends TypeAdapter { + @override + final int typeId = 6; + + @override + CheckPointBlock read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CheckPointBlock( + height: fields[0] as num?, + time: fields[1] as num?, + hash: fields[2] as String?, + saplingTree: fields[3] as String?, + ); + } + + @override + void write(BinaryWriter writer, CheckPointBlock obj) { + writer + ..writeByte(4) + ..writeByte(0) + ..write(obj.height) + ..writeByte(1) + ..write(obj.time) + ..writeByte(2) + ..write(obj.hash) + ..writeByte(3) + ..write(obj.saplingTree); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CheckPointBlockAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/coin_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/coin_adapter.dart new file mode 100644 index 0000000000..6099fb802c --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/coin_adapter.dart @@ -0,0 +1,167 @@ +part of '../coin.dart'; + +class CoinAdapter extends TypeAdapter { + @override + final int typeId = 0; + + @override + Coin read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Coin( + coin: fields[0] as String, + name: fields[1] as String?, + fname: fields[2] as String?, + rpcport: fields[3] as num?, + mm2: fields[4] as num?, + chainId: fields[5] as num?, + requiredConfirmations: fields[6] as num?, + avgBlocktime: fields[7] as num?, + decimals: fields[8] as num?, + protocol: fields[9] as Protocol?, + derivationPath: fields[10] as String?, + trezorCoin: fields[11] as String?, + links: fields[12] as Links?, + isPoS: fields[13] as num?, + pubtype: fields[14] as num?, + p2shtype: fields[15] as num?, + wiftype: fields[16] as num?, + txfee: fields[17] as num?, + dust: fields[18] as num?, + matureConfirmations: fields[19] as num?, + segwit: fields[20] as bool?, + signMessagePrefix: fields[21] as String?, + asset: fields[22] as String?, + txversion: fields[23] as num?, + overwintered: fields[24] as num?, + requiresNotarization: fields[25] as bool?, + walletOnly: fields[26] as bool?, + bech32Hrp: fields[27] as String?, + isTestnet: fields[28] as bool?, + forkId: fields[29] as String?, + signatureVersion: fields[30] as String?, + confpath: fields[31] as String?, + addressFormat: fields[32] as AddressFormat?, + aliasTicker: fields[33] as String?, + estimateFeeMode: fields[34] as String?, + orderbookTicker: fields[35] as String?, + taddr: fields[36] as num?, + forceMinRelayFee: fields[37] as bool?, + p2p: fields[38] as num?, + magic: fields[39] as String?, + nSPV: fields[40] as String?, + isPoSV: fields[41] as num?, + versionGroupId: fields[42] as String?, + consensusBranchId: fields[43] as String?, + estimateFeeBlocks: fields[44] as num?, + ); + } + + @override + void write(BinaryWriter writer, Coin obj) { + writer + ..writeByte(45) + ..writeByte(0) + ..write(obj.coin) + ..writeByte(1) + ..write(obj.name) + ..writeByte(2) + ..write(obj.fname) + ..writeByte(3) + ..write(obj.rpcport) + ..writeByte(4) + ..write(obj.mm2) + ..writeByte(5) + ..write(obj.chainId) + ..writeByte(6) + ..write(obj.requiredConfirmations) + ..writeByte(7) + ..write(obj.avgBlocktime) + ..writeByte(8) + ..write(obj.decimals) + ..writeByte(9) + ..write(obj.protocol) + ..writeByte(10) + ..write(obj.derivationPath) + ..writeByte(11) + ..write(obj.trezorCoin) + ..writeByte(12) + ..write(obj.links) + ..writeByte(13) + ..write(obj.isPoS) + ..writeByte(14) + ..write(obj.pubtype) + ..writeByte(15) + ..write(obj.p2shtype) + ..writeByte(16) + ..write(obj.wiftype) + ..writeByte(17) + ..write(obj.txfee) + ..writeByte(18) + ..write(obj.dust) + ..writeByte(19) + ..write(obj.matureConfirmations) + ..writeByte(20) + ..write(obj.segwit) + ..writeByte(21) + ..write(obj.signMessagePrefix) + ..writeByte(22) + ..write(obj.asset) + ..writeByte(23) + ..write(obj.txversion) + ..writeByte(24) + ..write(obj.overwintered) + ..writeByte(25) + ..write(obj.requiresNotarization) + ..writeByte(26) + ..write(obj.walletOnly) + ..writeByte(27) + ..write(obj.bech32Hrp) + ..writeByte(28) + ..write(obj.isTestnet) + ..writeByte(29) + ..write(obj.forkId) + ..writeByte(30) + ..write(obj.signatureVersion) + ..writeByte(31) + ..write(obj.confpath) + ..writeByte(32) + ..write(obj.addressFormat) + ..writeByte(33) + ..write(obj.aliasTicker) + ..writeByte(34) + ..write(obj.estimateFeeMode) + ..writeByte(35) + ..write(obj.orderbookTicker) + ..writeByte(36) + ..write(obj.taddr) + ..writeByte(37) + ..write(obj.forceMinRelayFee) + ..writeByte(38) + ..write(obj.p2p) + ..writeByte(39) + ..write(obj.magic) + ..writeByte(40) + ..write(obj.nSPV) + ..writeByte(41) + ..write(obj.isPoSV) + ..writeByte(42) + ..write(obj.versionGroupId) + ..writeByte(43) + ..write(obj.consensusBranchId) + ..writeByte(44) + ..write(obj.estimateFeeBlocks); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CoinAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/coin_config_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/coin_config_adapter.dart new file mode 100644 index 0000000000..3138e02028 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/coin_config_adapter.dart @@ -0,0 +1,248 @@ +part of '../coin_config.dart'; + +class CoinConfigAdapter extends TypeAdapter { + @override + final int typeId = 7; + + @override + CoinConfig read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CoinConfig( + coin: fields[0] as String, + type: fields[1] as String?, + name: fields[2] as String?, + coingeckoId: fields[3] as String?, + livecoinwatchId: fields[4] as String?, + explorerUrl: fields[5] as String?, + explorerTxUrl: fields[6] as String?, + explorerAddressUrl: fields[7] as String?, + supported: (fields[8] as List?)?.cast(), + active: fields[9] as bool?, + isTestnet: fields[10] as bool?, + currentlyEnabled: fields[11] as bool?, + walletOnly: fields[12] as bool?, + fname: fields[13] as String?, + rpcport: fields[14] as num?, + mm2: fields[15] as num?, + chainId: fields[16] as num?, + requiredConfirmations: fields[17] as num?, + avgBlocktime: fields[18] as num?, + decimals: fields[19] as num?, + protocol: fields[20] as Protocol?, + derivationPath: fields[21] as String?, + contractAddress: fields[22] as String?, + parentCoin: fields[23] as String?, + swapContractAddress: fields[24] as String?, + fallbackSwapContract: fields[25] as String?, + nodes: (fields[26] as List?)?.cast(), + explorerBlockUrl: fields[27] as String?, + tokenAddressUrl: fields[28] as String?, + trezorCoin: fields[29] as String?, + links: fields[30] as Links?, + pubtype: fields[31] as num?, + p2shtype: fields[32] as num?, + wiftype: fields[33] as num?, + txfee: fields[34] as num?, + dust: fields[35] as num?, + segwit: fields[36] as bool?, + electrum: (fields[37] as List?)?.cast(), + signMessagePrefix: fields[38] as String?, + lightWalletDServers: (fields[39] as List?)?.cast(), + asset: fields[40] as String?, + txversion: fields[41] as num?, + overwintered: fields[42] as num?, + requiresNotarization: fields[43] as bool?, + checkpointHeight: fields[44] as num?, + checkpointBlocktime: fields[45] as num?, + binanceId: fields[46] as String?, + bech32Hrp: fields[47] as String?, + forkId: fields[48] as String?, + signatureVersion: fields[49] as String?, + confpath: fields[50] as String?, + matureConfirmations: fields[51] as num?, + bchdUrls: (fields[52] as List?)?.cast(), + otherTypes: (fields[53] as List?)?.cast(), + addressFormat: fields[54] as AddressFormat?, + allowSlpUnsafeConf: fields[55] as bool?, + slpPrefix: fields[56] as String?, + tokenId: fields[57] as String?, + forexId: fields[58] as String?, + isPoS: fields[59] as num?, + aliasTicker: fields[60] as String?, + estimateFeeMode: fields[61] as String?, + orderbookTicker: fields[62] as String?, + taddr: fields[63] as num?, + forceMinRelayFee: fields[64] as bool?, + isClaimable: fields[65] as bool?, + minimalClaimAmount: fields[66] as String?, + isPoSV: fields[67] as num?, + versionGroupId: fields[68] as String?, + consensusBranchId: fields[69] as String?, + estimateFeeBlocks: fields[70] as num?, + rpcUrls: (fields[71] as List?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, CoinConfig obj) { + writer + ..writeByte(72) + ..writeByte(0) + ..write(obj.coin) + ..writeByte(1) + ..write(obj.type) + ..writeByte(2) + ..write(obj.name) + ..writeByte(3) + ..write(obj.coingeckoId) + ..writeByte(4) + ..write(obj.livecoinwatchId) + ..writeByte(5) + ..write(obj.explorerUrl) + ..writeByte(6) + ..write(obj.explorerTxUrl) + ..writeByte(7) + ..write(obj.explorerAddressUrl) + ..writeByte(8) + ..write(obj.supported) + ..writeByte(9) + ..write(obj.active) + ..writeByte(10) + ..write(obj.isTestnet) + ..writeByte(11) + ..write(obj.currentlyEnabled) + ..writeByte(12) + ..write(obj.walletOnly) + ..writeByte(13) + ..write(obj.fname) + ..writeByte(14) + ..write(obj.rpcport) + ..writeByte(15) + ..write(obj.mm2) + ..writeByte(16) + ..write(obj.chainId) + ..writeByte(17) + ..write(obj.requiredConfirmations) + ..writeByte(18) + ..write(obj.avgBlocktime) + ..writeByte(19) + ..write(obj.decimals) + ..writeByte(20) + ..write(obj.protocol) + ..writeByte(21) + ..write(obj.derivationPath) + ..writeByte(22) + ..write(obj.contractAddress) + ..writeByte(23) + ..write(obj.parentCoin) + ..writeByte(24) + ..write(obj.swapContractAddress) + ..writeByte(25) + ..write(obj.fallbackSwapContract) + ..writeByte(26) + ..write(obj.nodes) + ..writeByte(27) + ..write(obj.explorerBlockUrl) + ..writeByte(28) + ..write(obj.tokenAddressUrl) + ..writeByte(29) + ..write(obj.trezorCoin) + ..writeByte(30) + ..write(obj.links) + ..writeByte(31) + ..write(obj.pubtype) + ..writeByte(32) + ..write(obj.p2shtype) + ..writeByte(33) + ..write(obj.wiftype) + ..writeByte(34) + ..write(obj.txfee) + ..writeByte(35) + ..write(obj.dust) + ..writeByte(36) + ..write(obj.segwit) + ..writeByte(37) + ..write(obj.electrum) + ..writeByte(38) + ..write(obj.signMessagePrefix) + ..writeByte(39) + ..write(obj.lightWalletDServers) + ..writeByte(40) + ..write(obj.asset) + ..writeByte(41) + ..write(obj.txversion) + ..writeByte(42) + ..write(obj.overwintered) + ..writeByte(43) + ..write(obj.requiresNotarization) + ..writeByte(44) + ..write(obj.checkpointHeight) + ..writeByte(45) + ..write(obj.checkpointBlocktime) + ..writeByte(46) + ..write(obj.binanceId) + ..writeByte(47) + ..write(obj.bech32Hrp) + ..writeByte(48) + ..write(obj.forkId) + ..writeByte(49) + ..write(obj.signatureVersion) + ..writeByte(50) + ..write(obj.confpath) + ..writeByte(51) + ..write(obj.matureConfirmations) + ..writeByte(52) + ..write(obj.bchdUrls) + ..writeByte(53) + ..write(obj.otherTypes) + ..writeByte(54) + ..write(obj.addressFormat) + ..writeByte(55) + ..write(obj.allowSlpUnsafeConf) + ..writeByte(56) + ..write(obj.slpPrefix) + ..writeByte(57) + ..write(obj.tokenId) + ..writeByte(58) + ..write(obj.forexId) + ..writeByte(59) + ..write(obj.isPoS) + ..writeByte(60) + ..write(obj.aliasTicker) + ..writeByte(61) + ..write(obj.estimateFeeMode) + ..writeByte(62) + ..write(obj.orderbookTicker) + ..writeByte(63) + ..write(obj.taddr) + ..writeByte(64) + ..write(obj.forceMinRelayFee) + ..writeByte(65) + ..write(obj.isClaimable) + ..writeByte(66) + ..write(obj.minimalClaimAmount) + ..writeByte(67) + ..write(obj.isPoSV) + ..writeByte(68) + ..write(obj.versionGroupId) + ..writeByte(69) + ..write(obj.consensusBranchId) + ..writeByte(70) + ..write(obj.estimateFeeBlocks) + ..writeByte(71) + ..write(obj.rpcUrls); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is CoinConfigAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/coin_info_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/coin_info_adapter.dart new file mode 100644 index 0000000000..a661914d4a --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/coin_info_adapter.dart @@ -0,0 +1,38 @@ +part of '../coin_info.dart'; + +class CoinInfoAdapter extends TypeAdapter { + @override + final int typeId = 13; + + @override + CoinInfo read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return CoinInfo( + coin: fields[0] as Coin, + coinConfig: fields[1] as CoinConfig?, + ); + } + + @override + void write(BinaryWriter writer, CoinInfo obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.coin) + ..writeByte(1) + ..write(obj.coinConfig); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NodeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/consensus_params_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/consensus_params_adapter.dart new file mode 100644 index 0000000000..ea3714aa3e --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/consensus_params_adapter.dart @@ -0,0 +1,65 @@ +part of '../consensus_params.dart'; + +class ConsensusParamsAdapter extends TypeAdapter { + @override + final int typeId = 5; + + @override + ConsensusParams read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ConsensusParams( + overwinterActivationHeight: fields[0] as num?, + saplingActivationHeight: fields[1] as num?, + blossomActivationHeight: fields[2] as num?, + heartwoodActivationHeight: fields[3] as num?, + canopyActivationHeight: fields[4] as num?, + coinType: fields[5] as num?, + hrpSaplingExtendedSpendingKey: fields[6] as String?, + hrpSaplingExtendedFullViewingKey: fields[7] as String?, + hrpSaplingPaymentAddress: fields[8] as String?, + b58PubkeyAddressPrefix: (fields[9] as List?)?.cast(), + b58ScriptAddressPrefix: (fields[10] as List?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, ConsensusParams obj) { + writer + ..writeByte(11) + ..writeByte(0) + ..write(obj.overwinterActivationHeight) + ..writeByte(1) + ..write(obj.saplingActivationHeight) + ..writeByte(2) + ..write(obj.blossomActivationHeight) + ..writeByte(3) + ..write(obj.heartwoodActivationHeight) + ..writeByte(4) + ..write(obj.canopyActivationHeight) + ..writeByte(5) + ..write(obj.coinType) + ..writeByte(6) + ..write(obj.hrpSaplingExtendedSpendingKey) + ..writeByte(7) + ..write(obj.hrpSaplingExtendedFullViewingKey) + ..writeByte(8) + ..write(obj.hrpSaplingPaymentAddress) + ..writeByte(9) + ..write(obj.b58PubkeyAddressPrefix) + ..writeByte(10) + ..write(obj.b58ScriptAddressPrefix); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ConsensusParamsAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/contact_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/contact_adapter.dart new file mode 100644 index 0000000000..f93c7118d5 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/contact_adapter.dart @@ -0,0 +1,38 @@ +part of '../contact.dart'; + +class ContactAdapter extends TypeAdapter { + @override + final int typeId = 10; + + @override + Contact read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Contact( + email: fields[0] as String?, + github: fields[1] as String?, + ); + } + + @override + void write(BinaryWriter writer, Contact obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.email) + ..writeByte(1) + ..write(obj.github); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ContactAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/electrum_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/electrum_adapter.dart new file mode 100644 index 0000000000..3de6a608b9 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/electrum_adapter.dart @@ -0,0 +1,41 @@ +part of '../electrum.dart'; + +class ElectrumAdapter extends TypeAdapter { + @override + final int typeId = 8; + + @override + Electrum read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Electrum( + url: fields[0] as String?, + protocol: fields[1] as String?, + contact: (fields[2] as List?)?.cast(), + ); + } + + @override + void write(BinaryWriter writer, Electrum obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.url) + ..writeByte(1) + ..write(obj.protocol) + ..writeByte(2) + ..write(obj.contact); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ElectrumAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/links_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/links_adapter.dart new file mode 100644 index 0000000000..1fc2666af4 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/links_adapter.dart @@ -0,0 +1,38 @@ +part of '../links.dart'; + +class LinksAdapter extends TypeAdapter { + @override + final int typeId = 4; + + @override + Links read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Links( + github: fields[0] as String?, + homepage: fields[1] as String?, + ); + } + + @override + void write(BinaryWriter writer, Links obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.github) + ..writeByte(1) + ..write(obj.homepage); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is LinksAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/node_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/node_adapter.dart new file mode 100644 index 0000000000..9e968302d2 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/node_adapter.dart @@ -0,0 +1,38 @@ +part of '../node.dart'; + +class NodeAdapter extends TypeAdapter { + @override + final int typeId = 9; + + @override + Node read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Node( + url: fields[0] as String?, + guiAuth: fields[1] as bool?, + ); + } + + @override + void write(BinaryWriter writer, Node obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.url) + ..writeByte(1) + ..write(obj.guiAuth); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is NodeAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/protocol_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/protocol_adapter.dart new file mode 100644 index 0000000000..807c4292c3 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/protocol_adapter.dart @@ -0,0 +1,41 @@ +part of '../protocol.dart'; + +class ProtocolAdapter extends TypeAdapter { + @override + final int typeId = 1; + + @override + Protocol read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return Protocol( + type: fields[0] as String?, + protocolData: fields[1] as ProtocolData?, + bip44: fields[2] as String?, + ); + } + + @override + void write(BinaryWriter writer, Protocol obj) { + writer + ..writeByte(3) + ..writeByte(0) + ..write(obj.type) + ..writeByte(1) + ..write(obj.protocolData) + ..writeByte(2) + ..write(obj.bip44); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProtocolAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/protocol_data_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/protocol_data_adapter.dart new file mode 100644 index 0000000000..683b5d14c3 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/protocol_data_adapter.dart @@ -0,0 +1,68 @@ +part of '../protocol_data.dart'; + +class ProtocolDataAdapter extends TypeAdapter { + @override + final int typeId = 2; + + @override + ProtocolData read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return ProtocolData( + platform: fields[0] as String?, + contractAddress: fields[1] as String?, + consensusParams: fields[2] as ConsensusParams?, + checkPointBlock: fields[3] as CheckPointBlock?, + slpPrefix: fields[4] as String?, + decimals: fields[5] as num?, + tokenId: fields[6] as String?, + requiredConfirmations: fields[7] as num?, + denom: fields[8] as String?, + accountPrefix: fields[9] as String?, + chainId: fields[10] as String?, + gasPrice: fields[11] as num?, + ); + } + + @override + void write(BinaryWriter writer, ProtocolData obj) { + writer + ..writeByte(12) + ..writeByte(0) + ..write(obj.platform) + ..writeByte(1) + ..write(obj.contractAddress) + ..writeByte(2) + ..write(obj.consensusParams ?? const ConsensusParams()) + ..writeByte(3) + ..write(obj.checkPointBlock ?? const CheckPointBlock()) + ..writeByte(4) + ..write(obj.slpPrefix) + ..writeByte(5) + ..write(obj.decimals) + ..writeByte(6) + ..write(obj.tokenId) + ..writeByte(7) + ..write(obj.requiredConfirmations) + ..writeByte(8) + ..write(obj.denom) + ..writeByte(9) + ..write(obj.accountPrefix) + ..writeByte(10) + ..write(obj.chainId) + ..writeByte(11) + ..write(obj.gasPrice); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is ProtocolDataAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/adapters/rpc_url_adapter.dart b/packages/komodo_coin_updates/lib/src/models/adapters/rpc_url_adapter.dart new file mode 100644 index 0000000000..14460c1281 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/adapters/rpc_url_adapter.dart @@ -0,0 +1,35 @@ +part of '../rpc_url.dart'; + +class RpcUrlAdapter extends TypeAdapter { + @override + final int typeId = 11; + + @override + RpcUrl read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return RpcUrl( + url: fields[0] as String?, + ); + } + + @override + void write(BinaryWriter writer, RpcUrl obj) { + writer + ..writeByte(1) + ..writeByte(0) + ..write(obj.url); + } + + @override + int get hashCode => typeId.hashCode; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is RpcUrlAdapter && + runtimeType == other.runtimeType && + typeId == other.typeId; +} diff --git a/packages/komodo_coin_updates/lib/src/models/address_format.dart b/packages/komodo_coin_updates/lib/src/models/address_format.dart new file mode 100644 index 0000000000..4b50241087 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/address_format.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; + +part 'adapters/address_format_adapter.dart'; + +class AddressFormat extends Equatable { + const AddressFormat({ + this.format, + this.network, + }); + + factory AddressFormat.fromJson(Map json) { + return AddressFormat( + format: json['format'] as String?, + network: json['network'] as String?, + ); + } + + final String? format; + final String? network; + + Map toJson() { + return { + 'format': format, + 'network': network, + }; + } + + @override + List get props => [format, network]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/checkpoint_block.dart b/packages/komodo_coin_updates/lib/src/models/checkpoint_block.dart new file mode 100644 index 0000000000..921c431e27 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/checkpoint_block.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; + +part 'adapters/checkpoint_block_adapter.dart'; + +class CheckPointBlock extends Equatable { + const CheckPointBlock({ + this.height, + this.time, + this.hash, + this.saplingTree, + }); + + factory CheckPointBlock.fromJson(Map json) { + return CheckPointBlock( + height: json['height'] as num?, + time: json['time'] as num?, + hash: json['hash'] as String?, + saplingTree: json['saplingTree'] as String?, + ); + } + + final num? height; + final num? time; + final String? hash; + final String? saplingTree; + + Map toJson() { + return { + 'height': height, + 'time': time, + 'hash': hash, + 'saplingTree': saplingTree, + }; + } + + @override + List get props => [height, time, hash, saplingTree]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/coin.dart b/packages/komodo_coin_updates/lib/src/models/coin.dart new file mode 100644 index 0000000000..7c778e779f --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/coin.dart @@ -0,0 +1,219 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; +import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; + +import 'address_format.dart'; +import 'links.dart'; +import 'protocol.dart'; + +part 'adapters/coin_adapter.dart'; + +class Coin extends Equatable implements ObjectWithPrimaryKey { + const Coin({ + required this.coin, + this.name, + this.fname, + this.rpcport, + this.mm2, + this.chainId, + this.requiredConfirmations, + this.avgBlocktime, + this.decimals, + this.protocol, + this.derivationPath, + this.trezorCoin, + this.links, + this.isPoS, + this.pubtype, + this.p2shtype, + this.wiftype, + this.txfee, + this.dust, + this.matureConfirmations, + this.segwit, + this.signMessagePrefix, + this.asset, + this.txversion, + this.overwintered, + this.requiresNotarization, + this.walletOnly, + this.bech32Hrp, + this.isTestnet, + this.forkId, + this.signatureVersion, + this.confpath, + this.addressFormat, + this.aliasTicker, + this.estimateFeeMode, + this.orderbookTicker, + this.taddr, + this.forceMinRelayFee, + this.p2p, + this.magic, + this.nSPV, + this.isPoSV, + this.versionGroupId, + this.consensusBranchId, + this.estimateFeeBlocks, + }); + + factory Coin.fromJson(Map json) { + return Coin( + coin: json['coin'] as String, + name: json['name'] as String?, + fname: json['fname'] as String?, + rpcport: json['rpcport'] as num?, + mm2: json['mm2'] as num?, + chainId: json['chain_id'] as num?, + requiredConfirmations: json['required_confirmations'] as num?, + avgBlocktime: json['avg_blocktime'] as num?, + decimals: json['decimals'] as num?, + protocol: json['protocol'] != null + ? Protocol.fromJson(json['protocol'] as Map) + : null, + derivationPath: json['derivation_path'] as String?, + trezorCoin: json['trezor_coin'] as String?, + links: json['links'] != null + ? Links.fromJson(json['links'] as Map) + : null, + isPoS: json['isPoS'] as num?, + pubtype: json['pubtype'] as num?, + p2shtype: json['p2shtype'] as num?, + wiftype: json['wiftype'] as num?, + txfee: json['txfee'] as num?, + dust: json['dust'] as num?, + matureConfirmations: json['mature_confirmations'] as num?, + segwit: json['segwit'] as bool?, + signMessagePrefix: json['sign_message_prefix'] as String?, + asset: json['asset'] as String?, + txversion: json['txversion'] as num?, + overwintered: json['overwintered'] as num?, + requiresNotarization: json['requires_notarization'] as bool?, + walletOnly: json['wallet_only'] as bool?, + bech32Hrp: json['bech32_hrp'] as String?, + isTestnet: json['is_testnet'] as bool?, + forkId: json['fork_id'] as String?, + signatureVersion: json['signature_version'] as String?, + confpath: json['confpath'] as String?, + addressFormat: json['address_format'] != null + ? AddressFormat.fromJson( + json['address_format'] as Map, + ) + : null, + aliasTicker: json['alias_ticker'] as String?, + estimateFeeMode: json['estimate_fee_mode'] as String?, + orderbookTicker: json['orderbook_ticker'] as String?, + taddr: json['taddr'] as num?, + forceMinRelayFee: json['force_min_relay_fee'] as bool?, + p2p: json['p2p'] as num?, + magic: json['magic'] as String?, + nSPV: json['nSPV'] as String?, + isPoSV: json['isPoSV'] as num?, + versionGroupId: json['version_group_id'] as String?, + consensusBranchId: json['consensus_branch_id'] as String?, + estimateFeeBlocks: json['estimate_fee_blocks'] as num?, + ); + } + + final String coin; + final String? name; + final String? fname; + final num? rpcport; + final num? mm2; + final num? chainId; + final num? requiredConfirmations; + final num? avgBlocktime; + final num? decimals; + final Protocol? protocol; + final String? derivationPath; + final String? trezorCoin; + final Links? links; + final num? isPoS; + final num? pubtype; + final num? p2shtype; + final num? wiftype; + final num? txfee; + final num? dust; + final num? matureConfirmations; + final bool? segwit; + final String? signMessagePrefix; + final String? asset; + final num? txversion; + final num? overwintered; + final bool? requiresNotarization; + final bool? walletOnly; + final String? bech32Hrp; + final bool? isTestnet; + final String? forkId; + final String? signatureVersion; + final String? confpath; + final AddressFormat? addressFormat; + final String? aliasTicker; + final String? estimateFeeMode; + final String? orderbookTicker; + final num? taddr; + final bool? forceMinRelayFee; + final num? p2p; + final String? magic; + final String? nSPV; + final num? isPoSV; + final String? versionGroupId; + final String? consensusBranchId; + final num? estimateFeeBlocks; + + Map toJson() { + return { + 'coin': coin, + 'name': name, + 'fname': fname, + 'rpcport': rpcport, + 'mm2': mm2, + 'chain_id': chainId, + 'required_confirmations': requiredConfirmations, + 'avg_blocktime': avgBlocktime, + 'decimals': decimals, + 'protocol': protocol?.toJson(), + 'derivation_path': derivationPath, + 'trezor_coin': trezorCoin, + 'links': links?.toJson(), + 'isPoS': isPoS, + 'pubtype': pubtype, + 'p2shtype': p2shtype, + 'wiftype': wiftype, + 'txfee': txfee, + 'dust': dust, + 'mature_confirmations': matureConfirmations, + 'segwit': segwit, + 'sign_message_prefix': signMessagePrefix, + 'asset': asset, + 'txversion': txversion, + 'overwintered': overwintered, + 'requires_notarization': requiresNotarization, + 'wallet_only': walletOnly, + 'bech32_hrp': bech32Hrp, + 'is_testnet': isTestnet, + 'fork_id': forkId, + 'signature_version': signatureVersion, + 'confpath': confpath, + 'address_format': addressFormat?.toJson(), + 'alias_ticker': aliasTicker, + 'estimate_fee_mode': estimateFeeMode, + 'orderbook_ticker': orderbookTicker, + 'taddr': taddr, + 'force_min_relay_fee': forceMinRelayFee, + 'p2p': p2p, + 'magic': magic, + 'nSPV': nSPV, + 'isPoSV': isPoSV, + 'version_group_id': versionGroupId, + 'consensus_branch_id': consensusBranchId, + 'estimate_fee_blocks': estimateFeeBlocks, + }; + } + + @override + List get props => [coin]; + + @override + String get primaryKey => coin; +} diff --git a/packages/komodo_coin_updates/lib/src/models/coin_config.dart b/packages/komodo_coin_updates/lib/src/models/coin_config.dart new file mode 100644 index 0000000000..f2d3a2de3b --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/coin_config.dart @@ -0,0 +1,417 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; +import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; + +import 'address_format.dart'; +import 'electrum.dart'; +import 'links.dart'; +import 'node.dart'; +import 'protocol.dart'; +import 'rpc_url.dart'; + +part 'adapters/coin_config_adapter.dart'; + +class CoinConfig extends Equatable implements ObjectWithPrimaryKey { + const CoinConfig({ + required this.coin, + this.type, + this.name, + this.coingeckoId, + this.livecoinwatchId, + this.explorerUrl, + this.explorerTxUrl, + this.explorerAddressUrl, + this.supported, + this.active, + this.isTestnet, + this.currentlyEnabled, + this.walletOnly, + this.fname, + this.rpcport, + this.mm2, + this.chainId, + this.requiredConfirmations, + this.avgBlocktime, + this.decimals, + this.protocol, + this.derivationPath, + this.contractAddress, + this.parentCoin, + this.swapContractAddress, + this.fallbackSwapContract, + this.nodes, + this.explorerBlockUrl, + this.tokenAddressUrl, + this.trezorCoin, + this.links, + this.pubtype, + this.p2shtype, + this.wiftype, + this.txfee, + this.dust, + this.segwit, + this.electrum, + this.signMessagePrefix, + this.lightWalletDServers, + this.asset, + this.txversion, + this.overwintered, + this.requiresNotarization, + this.checkpointHeight, + this.checkpointBlocktime, + this.binanceId, + this.bech32Hrp, + this.forkId, + this.signatureVersion, + this.confpath, + this.matureConfirmations, + this.bchdUrls, + this.otherTypes, + this.addressFormat, + this.allowSlpUnsafeConf, + this.slpPrefix, + this.tokenId, + this.forexId, + this.isPoS, + this.aliasTicker, + this.estimateFeeMode, + this.orderbookTicker, + this.taddr, + this.forceMinRelayFee, + this.isClaimable, + this.minimalClaimAmount, + this.isPoSV, + this.versionGroupId, + this.consensusBranchId, + this.estimateFeeBlocks, + this.rpcUrls, + }); + + factory CoinConfig.fromJson(Map json) { + return CoinConfig( + coin: json['coin'] as String, + type: json['type'] as String?, + name: json['name'] as String?, + coingeckoId: json['coingecko_id'] as String?, + livecoinwatchId: json['livecoinwatch_id'] as String?, + explorerUrl: json['explorer_url'] as String?, + explorerTxUrl: json['explorer_tx_url'] as String?, + explorerAddressUrl: json['explorer_address_url'] as String?, + supported: (json['supported'] as List?) + ?.map((dynamic e) => e as String) + .toList(), + active: json['active'] as bool?, + isTestnet: json['is_testnet'] as bool?, + currentlyEnabled: json['currently_enabled'] as bool?, + walletOnly: json['wallet_only'] as bool?, + fname: json['fname'] as String?, + rpcport: json['rpcport'] as num?, + mm2: json['mm2'] as num?, + chainId: json['chain_id'] as num?, + requiredConfirmations: json['required_confirmations'] as num?, + avgBlocktime: json['avg_blocktime'] as num?, + decimals: json['decimals'] as num?, + protocol: json['protocol'] == null + ? null + : Protocol.fromJson(json['protocol'] as Map), + derivationPath: json['derivation_path'] as String?, + contractAddress: json['contractAddress'] as String?, + parentCoin: json['parent_coin'] as String?, + swapContractAddress: json['swap_contract_address'] as String?, + fallbackSwapContract: json['fallback_swap_contract'] as String?, + nodes: (json['nodes'] as List?) + ?.map((dynamic e) => Node.fromJson(e as Map)) + .toList(), + explorerBlockUrl: json['explorer_block_url'] as String?, + tokenAddressUrl: json['token_address_url'] as String?, + trezorCoin: json['trezor_coin'] as String?, + links: json['links'] == null + ? null + : Links.fromJson(json['links'] as Map), + pubtype: json['pubtype'] as num?, + p2shtype: json['p2shtype'] as num?, + wiftype: json['wiftype'] as num?, + txfee: json['txfee'] as num?, + dust: json['dust'] as num?, + segwit: json['segwit'] as bool?, + electrum: (json['electrum'] as List?) + ?.map((dynamic e) => Electrum.fromJson(e as Map)) + .toList(), + signMessagePrefix: json['sign_message_refix'] as String?, + lightWalletDServers: (json['light_wallet_d_servers'] as List?) + ?.map((dynamic e) => e as String) + .toList(), + asset: json['asset'] as String?, + txversion: json['txversion'] as num?, + overwintered: json['overwintered'] as num?, + requiresNotarization: json['requires_notarization'] as bool?, + checkpointHeight: json['checkpoint_height'] as num?, + checkpointBlocktime: json['checkpoint_blocktime'] as num?, + binanceId: json['binance_id'] as String?, + bech32Hrp: json['bech32_hrp'] as String?, + forkId: json['forkId'] as String?, + signatureVersion: json['signature_version'] as String?, + confpath: json['confpath'] as String?, + matureConfirmations: json['mature_confirmations'] as num?, + bchdUrls: (json['bchd_urls'] as List?) + ?.map((dynamic e) => e as String) + .toList(), + otherTypes: (json['other_types'] as List?) + ?.map((dynamic e) => e as String) + .toList(), + addressFormat: json['address_format'] == null + ? null + : AddressFormat.fromJson( + json['address_format'] as Map, + ), + allowSlpUnsafeConf: json['allow_slp_unsafe_conf'] as bool?, + slpPrefix: json['slp_prefix'] as String?, + tokenId: json['token_id'] as String?, + forexId: json['forex_id'] as String?, + isPoS: json['isPoS'] as num?, + aliasTicker: json['alias_ticker'] as String?, + estimateFeeMode: json['estimate_fee_mode'] as String?, + orderbookTicker: json['orderbook_ticker'] as String?, + taddr: json['taddr'] as num?, + forceMinRelayFee: json['force_min_relay_fee'] as bool?, + isClaimable: json['is_claimable'] as bool?, + minimalClaimAmount: json['minimal_claim_amount'] as String?, + isPoSV: json['isPoSV'] as num?, + versionGroupId: json['version_group_id'] as String?, + consensusBranchId: json['consensus_branch_id'] as String?, + estimateFeeBlocks: json['estimate_fee_blocks'] as num?, + rpcUrls: (json['rpc_urls'] as List?) + ?.map((dynamic e) => RpcUrl.fromJson(e as Map)) + .toList(), + ); + } + + final String coin; + final String? type; + final String? name; + final String? coingeckoId; + final String? livecoinwatchId; + final String? explorerUrl; + final String? explorerTxUrl; + final String? explorerAddressUrl; + final List? supported; + final bool? active; + final bool? isTestnet; + final bool? currentlyEnabled; + final bool? walletOnly; + final String? fname; + final num? rpcport; + final num? mm2; + final num? chainId; + final num? requiredConfirmations; + final num? avgBlocktime; + final num? decimals; + final Protocol? protocol; + final String? derivationPath; + final String? contractAddress; + final String? parentCoin; + final String? swapContractAddress; + final String? fallbackSwapContract; + final List? nodes; + final String? explorerBlockUrl; + final String? tokenAddressUrl; + final String? trezorCoin; + final Links? links; + final num? pubtype; + final num? p2shtype; + final num? wiftype; + final num? txfee; + final num? dust; + final bool? segwit; + final List? electrum; + final String? signMessagePrefix; + final List? lightWalletDServers; + final String? asset; + final num? txversion; + final num? overwintered; + final bool? requiresNotarization; + final num? checkpointHeight; + final num? checkpointBlocktime; + final String? binanceId; + final String? bech32Hrp; + final String? forkId; + final String? signatureVersion; + final String? confpath; + final num? matureConfirmations; + final List? bchdUrls; + final List? otherTypes; + final AddressFormat? addressFormat; + final bool? allowSlpUnsafeConf; + final String? slpPrefix; + final String? tokenId; + final String? forexId; + final num? isPoS; + final String? aliasTicker; + final String? estimateFeeMode; + final String? orderbookTicker; + final num? taddr; + final bool? forceMinRelayFee; + final bool? isClaimable; + final String? minimalClaimAmount; + final num? isPoSV; + final String? versionGroupId; + final String? consensusBranchId; + final num? estimateFeeBlocks; + final List? rpcUrls; + + Map toJson() { + return { + 'coin': coin, + 'type': type, + 'name': name, + 'coingecko_id': coingeckoId, + 'livecoinwatch_id': livecoinwatchId, + 'explorer_url': explorerUrl, + 'explorer_tx_url': explorerTxUrl, + 'explorer_address_url': explorerAddressUrl, + 'supported': supported, + 'active': active, + 'is_testnet': isTestnet, + 'currently_enabled': currentlyEnabled, + 'wallet_only': walletOnly, + 'fname': fname, + 'rpcport': rpcport, + 'mm2': mm2, + 'chain_id': chainId, + 'required_confirmations': requiredConfirmations, + 'avg_blocktime': avgBlocktime, + 'decimals': decimals, + 'protocol': protocol?.toJson(), + 'derivation_path': derivationPath, + 'contractAddress': contractAddress, + 'parent_coin': parentCoin, + 'swap_contract_address': swapContractAddress, + 'fallback_swap_contract': fallbackSwapContract, + 'nodes': nodes?.map((Node e) => e.toJson()).toList(), + 'explorer_block_url': explorerBlockUrl, + 'token_address_url': tokenAddressUrl, + 'trezor_coin': trezorCoin, + 'links': links?.toJson(), + 'pubtype': pubtype, + 'p2shtype': p2shtype, + 'wiftype': wiftype, + 'txfee': txfee, + 'dust': dust, + 'segwit': segwit, + 'electrum': electrum?.map((Electrum e) => e.toJson()).toList(), + 'sign_message_refix': signMessagePrefix, + 'light_wallet_d_servers': lightWalletDServers, + 'asset': asset, + 'txversion': txversion, + 'overwintered': overwintered, + 'requires_notarization': requiresNotarization, + 'checkpoint_height': checkpointHeight, + 'checkpoint_blocktime': checkpointBlocktime, + 'binance_id': binanceId, + 'bech32_hrp': bech32Hrp, + 'forkId': forkId, + 'signature_version': signatureVersion, + 'confpath': confpath, + 'mature_confirmations': matureConfirmations, + 'bchd_urls': bchdUrls, + 'other_types': otherTypes, + 'address_format': addressFormat?.toJson(), + 'allow_slp_unsafe_conf': allowSlpUnsafeConf, + 'slp_prefix': slpPrefix, + 'token_id': tokenId, + 'forex_id': forexId, + 'isPoS': isPoS, + 'alias_ticker': aliasTicker, + 'estimate_fee_mode': estimateFeeMode, + 'orderbook_ticker': orderbookTicker, + 'taddr': taddr, + 'force_min_relay_fee': forceMinRelayFee, + 'is_claimable': isClaimable, + 'minimal_claim_amount': minimalClaimAmount, + 'isPoSV': isPoSV, + 'version_group_id': versionGroupId, + 'consensus_branch_id': consensusBranchId, + 'estimate_fee_blocks': estimateFeeBlocks, + 'rpc_urls': rpcUrls?.map((RpcUrl e) => e.toJson()).toList(), + }; + } + + @override + List get props => [ + coin, + type, + name, + coingeckoId, + livecoinwatchId, + explorerUrl, + explorerTxUrl, + explorerAddressUrl, + supported, + active, + isTestnet, + currentlyEnabled, + walletOnly, + fname, + rpcport, + mm2, + chainId, + requiredConfirmations, + avgBlocktime, + decimals, + protocol, + derivationPath, + contractAddress, + parentCoin, + swapContractAddress, + fallbackSwapContract, + nodes, + explorerBlockUrl, + tokenAddressUrl, + trezorCoin, + links, + pubtype, + p2shtype, + wiftype, + txfee, + dust, + segwit, + electrum, + signMessagePrefix, + lightWalletDServers, + asset, + txversion, + overwintered, + requiresNotarization, + checkpointHeight, + checkpointBlocktime, + binanceId, + bech32Hrp, + forkId, + signatureVersion, + confpath, + matureConfirmations, + bchdUrls, + otherTypes, + addressFormat, + allowSlpUnsafeConf, + slpPrefix, + tokenId, + forexId, + isPoS, + aliasTicker, + estimateFeeMode, + orderbookTicker, + taddr, + forceMinRelayFee, + isClaimable, + minimalClaimAmount, + isPoSV, + versionGroupId, + consensusBranchId, + estimateFeeBlocks, + rpcUrls, + ]; + + @override + String get primaryKey => coin; +} diff --git a/packages/komodo_coin_updates/lib/src/models/coin_info.dart b/packages/komodo_coin_updates/lib/src/models/coin_info.dart new file mode 100644 index 0000000000..2030d57863 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/coin_info.dart @@ -0,0 +1,24 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; +import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; + +import '../../komodo_coin_updates.dart'; + +part 'adapters/coin_info_adapter.dart'; + +class CoinInfo extends Equatable implements ObjectWithPrimaryKey { + const CoinInfo({ + required this.coin, + required this.coinConfig, + }); + + final Coin coin; + final CoinConfig? coinConfig; + + @override + String get primaryKey => coin.coin; + + @override + // TODO(Francois): optimize for comparisons - decide on fields to use when comparing + List get props => [coin, coinConfig]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/consensus_params.dart b/packages/komodo_coin_updates/lib/src/models/consensus_params.dart new file mode 100644 index 0000000000..ddd41d64a5 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/consensus_params.dart @@ -0,0 +1,85 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; + +part 'adapters/consensus_params_adapter.dart'; + +class ConsensusParams extends Equatable { + const ConsensusParams({ + this.overwinterActivationHeight, + this.saplingActivationHeight, + this.blossomActivationHeight, + this.heartwoodActivationHeight, + this.canopyActivationHeight, + this.coinType, + this.hrpSaplingExtendedSpendingKey, + this.hrpSaplingExtendedFullViewingKey, + this.hrpSaplingPaymentAddress, + this.b58PubkeyAddressPrefix, + this.b58ScriptAddressPrefix, + }); + + factory ConsensusParams.fromJson(Map json) { + return ConsensusParams( + overwinterActivationHeight: json['overwinter_activation_height'] as num?, + saplingActivationHeight: json['sapling_activation_height'] as num?, + blossomActivationHeight: json['blossom_activation_height'] as num?, + heartwoodActivationHeight: json['heartwood_activation_height'] as num?, + canopyActivationHeight: json['canopy_activation_height'] as num?, + coinType: json['coin_type'] as num?, + hrpSaplingExtendedSpendingKey: + json['hrp_sapling_extended_spending_key'] as String?, + hrpSaplingExtendedFullViewingKey: + json['hrp_sapling_extended_full_viewing_key'] as String?, + hrpSaplingPaymentAddress: json['hrp_sapling_payment_address'] as String?, + b58PubkeyAddressPrefix: json['b58_pubkey_address_prefix'] != null + ? List.from(json['b58_pubkey_address_prefix'] as List) + : null, + b58ScriptAddressPrefix: json['b58_script_address_prefix'] != null + ? List.from(json['b58_script_address_prefix'] as List) + : null, + ); + } + + final num? overwinterActivationHeight; + final num? saplingActivationHeight; + final num? blossomActivationHeight; + final num? heartwoodActivationHeight; + final num? canopyActivationHeight; + final num? coinType; + final String? hrpSaplingExtendedSpendingKey; + final String? hrpSaplingExtendedFullViewingKey; + final String? hrpSaplingPaymentAddress; + final List? b58PubkeyAddressPrefix; + final List? b58ScriptAddressPrefix; + + Map toJson() { + return { + 'overwinter_activation_height': overwinterActivationHeight, + 'sapling_activation_height': saplingActivationHeight, + 'blossom_activation_height': blossomActivationHeight, + 'heartwood_activation_height': heartwoodActivationHeight, + 'canopy_activation_height': canopyActivationHeight, + 'coin_type': coinType, + 'hrp_sapling_extended_spending_key': hrpSaplingExtendedSpendingKey, + 'hrp_sapling_extended_full_viewing_key': hrpSaplingExtendedFullViewingKey, + 'hrp_sapling_payment_address': hrpSaplingPaymentAddress, + 'b58_pubkey_address_prefix': b58PubkeyAddressPrefix, + 'b58_script_address_prefix': b58ScriptAddressPrefix, + }; + } + + @override + List get props => [ + overwinterActivationHeight, + saplingActivationHeight, + blossomActivationHeight, + heartwoodActivationHeight, + canopyActivationHeight, + coinType, + hrpSaplingExtendedSpendingKey, + hrpSaplingExtendedFullViewingKey, + hrpSaplingPaymentAddress, + b58PubkeyAddressPrefix, + b58ScriptAddressPrefix, + ]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/contact.dart b/packages/komodo_coin_updates/lib/src/models/contact.dart new file mode 100644 index 0000000000..309638d8e2 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/contact.dart @@ -0,0 +1,28 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; + +part 'adapters/contact_adapter.dart'; + +class Contact extends Equatable { + const Contact({this.email, this.github}); + + factory Contact.fromJson(Map json) { + return Contact( + email: json['email'] as String?, + github: json['github'] as String?, + ); + } + + final String? email; + final String? github; + + Map toJson() { + return { + 'email': email, + 'github': github, + }; + } + + @override + List get props => [email, github]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/electrum.dart b/packages/komodo_coin_updates/lib/src/models/electrum.dart new file mode 100644 index 0000000000..978ab5d289 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/electrum.dart @@ -0,0 +1,43 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; +import 'contact.dart'; + +part 'adapters/electrum_adapter.dart'; + +// ignore: must_be_immutable +class Electrum extends Equatable { + Electrum({ + this.url, + this.wsUrl, + this.protocol, + this.contact, + }); + + factory Electrum.fromJson(Map json) { + return Electrum( + url: json['url'] as String?, + wsUrl: json['ws_url'] as String?, + protocol: json['protocol'] as String?, + contact: (json['contact'] as List?) + ?.map((dynamic e) => Contact.fromJson(e as Map)) + .toList(), + ); + } + + final String? url; + String? wsUrl; + final String? protocol; + final List? contact; + + Map toJson() { + return { + 'url': url, + 'ws_url': wsUrl, + 'protocol': protocol, + 'contact': contact?.map((Contact e) => e.toJson()).toList(), + }; + } + + @override + List get props => [url, wsUrl, protocol, contact]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/links.dart b/packages/komodo_coin_updates/lib/src/models/links.dart new file mode 100644 index 0000000000..d23675c44c --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/links.dart @@ -0,0 +1,31 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; + +part 'adapters/links_adapter.dart'; + +class Links extends Equatable { + const Links({ + this.github, + this.homepage, + }); + + factory Links.fromJson(Map json) { + return Links( + github: json['github'] as String?, + homepage: json['homepage'] as String?, + ); + } + + final String? github; + final String? homepage; + + Map toJson() { + return { + 'github': github, + 'homepage': homepage, + }; + } + + @override + List get props => [github, homepage]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/models.dart b/packages/komodo_coin_updates/lib/src/models/models.dart new file mode 100644 index 0000000000..691addb211 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/models.dart @@ -0,0 +1,13 @@ +export 'address_format.dart'; +export 'checkpoint_block.dart'; +export 'coin.dart'; +export 'coin_config.dart'; +export 'consensus_params.dart'; +export 'contact.dart'; +export 'electrum.dart'; +export 'links.dart'; +export 'node.dart'; +export 'protocol.dart'; +export 'protocol_data.dart'; +export 'rpc_url.dart'; +export 'runtime_update_config.dart'; diff --git a/packages/komodo_coin_updates/lib/src/models/node.dart b/packages/komodo_coin_updates/lib/src/models/node.dart new file mode 100644 index 0000000000..2c854378b5 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/node.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; + +import '../../komodo_coin_updates.dart'; + +part 'adapters/node_adapter.dart'; + +class Node extends Equatable { + const Node({this.url, this.wsUrl, this.guiAuth, this.contact}); + + factory Node.fromJson(Map json) { + return Node( + url: json['url'] as String?, + wsUrl: json['ws_url'] as String?, + guiAuth: (json['gui_auth'] ?? json['komodo_proxy']) as bool?, + contact: json['contact'] != null + ? Contact.fromJson(json['contact'] as Map) + : null, + ); + } + + final String? url; + final String? wsUrl; + final bool? guiAuth; + final Contact? contact; + + Map toJson() { + return { + 'url': url, + 'ws_url': wsUrl, + 'gui_auth': guiAuth, + 'komodo_proxy': guiAuth, + 'contact': contact?.toJson(), + }; + } + + @override + List get props => [url, wsUrl, guiAuth, contact]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/protocol.dart b/packages/komodo_coin_updates/lib/src/models/protocol.dart new file mode 100644 index 0000000000..c09a84a5c7 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/protocol.dart @@ -0,0 +1,39 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; + +import 'protocol_data.dart'; + +part 'adapters/protocol_adapter.dart'; + +class Protocol extends Equatable { + const Protocol({ + this.type, + this.protocolData, + this.bip44, + }); + + factory Protocol.fromJson(Map json) { + return Protocol( + type: json['type'] as String?, + protocolData: (json['protocol_data'] != null) + ? ProtocolData.fromJson(json['protocol_data'] as Map) + : null, + bip44: json['bip44'] as String?, + ); + } + + final String? type; + final ProtocolData? protocolData; + final String? bip44; + + Map toJson() { + return { + 'type': type, + 'protocol_data': protocolData?.toJson(), + 'bip44': bip44, + }; + } + + @override + List get props => [type, protocolData, bip44]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/protocol_data.dart b/packages/komodo_coin_updates/lib/src/models/protocol_data.dart new file mode 100644 index 0000000000..7d014d3cef --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/protocol_data.dart @@ -0,0 +1,95 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; + +import 'checkpoint_block.dart'; +import 'consensus_params.dart'; + +part 'adapters/protocol_data_adapter.dart'; + +class ProtocolData extends Equatable { + const ProtocolData({ + this.platform, + this.contractAddress, + this.consensusParams, + this.checkPointBlock, + this.slpPrefix, + this.decimals, + this.tokenId, + this.requiredConfirmations, + this.denom, + this.accountPrefix, + this.chainId, + this.gasPrice, + }); + + factory ProtocolData.fromJson(Map json) { + return ProtocolData( + platform: json['platform'] as String?, + contractAddress: json['contract_address'] as String?, + consensusParams: json['consensus_params'] != null + ? ConsensusParams.fromJson( + json['consensus_params'] as Map, + ) + : null, + checkPointBlock: json['check_point_block'] != null + ? CheckPointBlock.fromJson( + json['check_point_block'] as Map, + ) + : null, + slpPrefix: json['slp_prefix'] as String?, + decimals: json['decimals'] as num?, + tokenId: json['token_id'] as String?, + requiredConfirmations: json['required_confirmations'] as num?, + denom: json['denom'] as String?, + accountPrefix: json['account_prefix'] as String?, + chainId: json['chain_id'] as String?, + gasPrice: json['gas_price'] as num?, + ); + } + + final String? platform; + final String? contractAddress; + final ConsensusParams? consensusParams; + final CheckPointBlock? checkPointBlock; + final String? slpPrefix; + final num? decimals; + final String? tokenId; + final num? requiredConfirmations; + final String? denom; + final String? accountPrefix; + final String? chainId; + final num? gasPrice; + + Map toJson() { + return { + 'platform': platform, + 'contract_address': contractAddress, + 'consensus_params': consensusParams?.toJson(), + 'check_point_block': checkPointBlock?.toJson(), + 'slp_prefix': slpPrefix, + 'decimals': decimals, + 'token_id': tokenId, + 'required_confirmations': requiredConfirmations, + 'denom': denom, + 'account_prefix': accountPrefix, + 'chain_id': chainId, + 'gas_price': gasPrice, + }; + } + + @override + List get props => [ + platform, + contractAddress, + consensusParams, + checkPointBlock, + slpPrefix, + decimals, + tokenId, + requiredConfirmations, + denom, + accountPrefix, + chainId, + gasPrice, + ]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/rpc_url.dart b/packages/komodo_coin_updates/lib/src/models/rpc_url.dart new file mode 100644 index 0000000000..71c2639d7c --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/rpc_url.dart @@ -0,0 +1,25 @@ +import 'package:equatable/equatable.dart'; +import 'package:hive/hive.dart'; + +part 'adapters/rpc_url_adapter.dart'; + +class RpcUrl extends Equatable { + const RpcUrl({this.url}); + + factory RpcUrl.fromJson(Map json) { + return RpcUrl( + url: json['url'] as String?, + ); + } + + final String? url; + + Map toJson() { + return { + 'url': url, + }; + } + + @override + List get props => [url]; +} diff --git a/packages/komodo_coin_updates/lib/src/models/runtime_update_config.dart b/packages/komodo_coin_updates/lib/src/models/runtime_update_config.dart new file mode 100644 index 0000000000..7d048e9153 --- /dev/null +++ b/packages/komodo_coin_updates/lib/src/models/runtime_update_config.dart @@ -0,0 +1,45 @@ +import 'package:equatable/equatable.dart'; + +class RuntimeUpdateConfig extends Equatable { + const RuntimeUpdateConfig({ + required this.bundledCoinsRepoCommit, + required this.coinsRepoApiUrl, + required this.coinsRepoContentUrl, + required this.coinsRepoBranch, + required this.runtimeUpdatesEnabled, + }); + + factory RuntimeUpdateConfig.fromJson(Map json) { + return RuntimeUpdateConfig( + bundledCoinsRepoCommit: json['bundled_coins_repo_commit'] as String, + coinsRepoApiUrl: json['coins_repo_api_url'] as String, + coinsRepoContentUrl: json['coins_repo_content_url'] as String, + coinsRepoBranch: json['coins_repo_branch'] as String, + runtimeUpdatesEnabled: json['runtime_updates_enabled'] as bool, + ); + } + final String bundledCoinsRepoCommit; + final String coinsRepoApiUrl; + final String coinsRepoContentUrl; + final String coinsRepoBranch; + final bool runtimeUpdatesEnabled; + + Map toJson() { + return { + 'bundled_coins_repo_commit': bundledCoinsRepoCommit, + 'coins_repo_api_url': coinsRepoApiUrl, + 'coins_repo_content_url': coinsRepoContentUrl, + 'coins_repo_branch': coinsRepoBranch, + 'runtime_updates_enabled': runtimeUpdatesEnabled, + }; + } + + @override + List get props => [ + bundledCoinsRepoCommit, + coinsRepoApiUrl, + coinsRepoContentUrl, + coinsRepoBranch, + runtimeUpdatesEnabled, + ]; +} diff --git a/packages/komodo_coin_updates/pubspec.yaml b/packages/komodo_coin_updates/pubspec.yaml new file mode 100644 index 0000000000..eda1a1fc7d --- /dev/null +++ b/packages/komodo_coin_updates/pubspec.yaml @@ -0,0 +1,45 @@ +name: komodo_coin_updates +description: Runtime coin config update coin updates. +version: 1.0.0 +publish_to: none # publishable packages can't have git dependencies + +environment: + sdk: ">=3.0.0 <4.0.0" + +# Add regular dependencies here. +dependencies: + http: 0.13.6 # dart.dev + + komodo_persistence_layer: + path: ../komodo_persistence_layer/ + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + flutter_bloc: + git: + url: https://github.com/KomodoPlatform/bloc.git + path: packages/flutter_bloc/ + ref: 32d5002fb8b8a1e548fe8021d8468327680875ff # 8.1.1 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + equatable: + git: + url: https://github.com/KomodoPlatform/equatable.git + ref: 2117551ff3054f8edb1a58f63ffe1832a8d25623 #2.0.5 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + hive: + git: + url: https://github.com/KomodoPlatform/hive.git + path: hive/ + ref: 470473ffc1ba39f6c90f31ababe0ee63b76b69fe #2.2.3 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + hive_flutter: + git: + url: https://github.com/KomodoPlatform/hive.git + path: hive_flutter/ + ref: 0cbaab793be77b19b4740bc85d7ea6461b9762b4 #1.1.0 + +dev_dependencies: + lints: ^2.1.0 + test: ^1.24.0 diff --git a/packages/komodo_coin_updates/test/komodo_coin_updates_test.dart b/packages/komodo_coin_updates/test/komodo_coin_updates_test.dart new file mode 100644 index 0000000000..9e1306911c --- /dev/null +++ b/packages/komodo_coin_updates/test/komodo_coin_updates_test.dart @@ -0,0 +1,14 @@ +import 'package:test/test.dart'; + +void main() { + group('A group of tests', () { + setUp(() { + // Additional setup goes here. + }); + + test('First Test', () { + // TODO(Francois): Implement test + throw UnimplementedError(); + }); + }); +} diff --git a/packages/komodo_persistence_layer/.gitignore b/packages/komodo_persistence_layer/.gitignore new file mode 100644 index 0000000000..3cceda5578 --- /dev/null +++ b/packages/komodo_persistence_layer/.gitignore @@ -0,0 +1,7 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ + +# Avoid committing pubspec.lock for library packages; see +# https://dart.dev/guides/libraries/private-files#pubspeclock. +pubspec.lock diff --git a/packages/komodo_persistence_layer/CHANGELOG.md b/packages/komodo_persistence_layer/CHANGELOG.md new file mode 100644 index 0000000000..effe43c82c --- /dev/null +++ b/packages/komodo_persistence_layer/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/komodo_persistence_layer/README.md b/packages/komodo_persistence_layer/README.md new file mode 100644 index 0000000000..7d7864f133 --- /dev/null +++ b/packages/komodo_persistence_layer/README.md @@ -0,0 +1,49 @@ +# Komodo Persistence Layer + +This package provides the functionality to persist data to storage and retrieve it. + + + + + +## Usage + +### Create + +```dart +import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; + +Future main() async { + final PersistenceProvider coinsProvider = HiveBoxProvider(); + final Coin coin = Coin( + id: 'bitcoin', + fname: 'Bitcoin', + ); + await coinsProvider.insert(coin); +} +``` + +### Read + +```dart +import 'package:komodo_persistence_layer/komodo_persistence_layer.dart'; + +Future main() async { + final PersistenceProvider coinsProvider = HiveBoxProvider(); + final List coins = await coinsProvider.getAll(); + for (final coin in coins) { + print(coin.fname); + } +} +``` + + diff --git a/packages/komodo_persistence_layer/analysis_options.yaml b/packages/komodo_persistence_layer/analysis_options.yaml new file mode 100644 index 0000000000..c68e9c36b1 --- /dev/null +++ b/packages/komodo_persistence_layer/analysis_options.yaml @@ -0,0 +1,250 @@ +# Specify analysis options. +# +# For a list of lints, see: https://dart.dev/lints +# For guidelines on configuring static analysis, see: +# https://dart.dev/guides/language/analysis-options +# +# There are other similar analysis options files in the flutter repos, +# which should be kept in sync with this file: +# +# - analysis_options.yaml (this file) +# - https://github.com/flutter/engine/blob/main/analysis_options.yaml +# - https://github.com/flutter/packages/blob/main/analysis_options.yaml +# +# This file contains the analysis options used for code in the flutter/flutter +# repository. + +analyzer: + language: + strict-casts: true + strict-inference: true + strict-raw-types: true + errors: + # allow self-reference to deprecated members (we do this because otherwise we have + # to annotate every member in every test, assert, etc, when we deprecate something) + deprecated_member_use_from_same_package: ignore + exclude: + - "bin/cache/**" + # Ignore protoc generated files + - "dev/conductor/lib/proto/*" + +linter: + rules: + # This list is derived from the list of all available lints located at + # https://github.com/dart-lang/linter/blob/main/example/all.yaml + - always_declare_return_types + - always_put_control_body_on_new_line + # - always_put_required_named_parameters_first # we prefer having parameters in the same order as fields https://github.com/flutter/flutter/issues/10219 + - always_specify_types + # - always_use_package_imports # we do this commonly + - annotate_overrides + # - avoid_annotating_with_dynamic # conflicts with always_specify_types + - avoid_bool_literals_in_conditional_expressions + # - avoid_catches_without_on_clauses # blocked on https://github.com/dart-lang/linter/issues/3023 + # - avoid_catching_errors # blocked on https://github.com/dart-lang/linter/issues/3023 + # - avoid_classes_with_only_static_members # we do this commonly for `abstract final class`es + - avoid_double_and_int_checks + - avoid_dynamic_calls + - avoid_empty_else + - avoid_equals_and_hash_code_on_mutable_classes + - avoid_escaping_inner_quotes + - avoid_field_initializers_in_const_classes + # - avoid_final_parameters # incompatible with prefer_final_parameters + - avoid_function_literals_in_foreach_calls + # - avoid_implementing_value_types # see https://github.com/dart-lang/linter/issues/4558 + - avoid_init_to_null + - avoid_js_rounded_ints + # - avoid_multiple_declarations_per_line # seems to be a stylistic choice we don't subscribe to + - avoid_null_checks_in_equality_operators + - avoid_positional_boolean_parameters # would have been nice to enable this but by now there's too many places that break it + - avoid_print + # - avoid_private_typedef_functions # we prefer having typedef (discussion in https://github.com/flutter/flutter/pull/16356) + - avoid_redundant_argument_values + - avoid_relative_lib_imports + - avoid_renaming_method_parameters + - avoid_return_types_on_setters + - avoid_returning_null_for_void + # - avoid_returning_this # there are enough valid reasons to return `this` that this lint ends up with too many false positives + - avoid_setters_without_getters + - avoid_shadowing_type_parameters + - avoid_single_cascade_in_expression_statements + - avoid_slow_async_io + - avoid_type_to_string + - avoid_types_as_parameter_names + # - avoid_types_on_closure_parameters # conflicts with always_specify_types + - avoid_unnecessary_containers + - avoid_unused_constructor_parameters + - avoid_void_async + # - avoid_web_libraries_in_flutter # we use web libraries in web-specific code, and our tests prevent us from using them elsewhere + - await_only_futures + - camel_case_extensions + - camel_case_types + - cancel_subscriptions + # - cascade_invocations # doesn't match the typical style of this repo + - cast_nullable_to_non_nullable + # - close_sinks # not reliable enough + - collection_methods_unrelated_type + - combinators_ordering + # - comment_references # blocked on https://github.com/dart-lang/linter/issues/1142 + - conditional_uri_does_not_exist + # - constant_identifier_names # needs an opt-out https://github.com/dart-lang/linter/issues/204 + - control_flow_in_finally + - curly_braces_in_flow_control_structures + - dangling_library_doc_comments + - depend_on_referenced_packages + - deprecated_consistency + # - deprecated_member_use_from_same_package # we allow self-references to deprecated members + # - diagnostic_describe_all_properties # enabled only at the framework level (packages/flutter/lib) + - directives_ordering + # - discarded_futures # too many false positives, similar to unawaited_futures + # - do_not_use_environment # there are appropriate times to use the environment, especially in our tests and build logic + - empty_catches + - empty_constructor_bodies + - empty_statements + - eol_at_end_of_file + - exhaustive_cases + - file_names + - flutter_style_todos + - hash_and_equals + - implementation_imports + - implicit_call_tearoffs + - implicit_reopen + - invalid_case_patterns + # - join_return_with_assignment # not required by flutter style + - leading_newlines_in_multiline_strings + - library_annotations + - library_names + - library_prefixes + - library_private_types_in_public_api + # - lines_longer_than_80_chars # not required by flutter style + - literal_only_boolean_expressions + # - matching_super_parameters # blocked on https://github.com/dart-lang/language/issues/2509 + - missing_whitespace_between_adjacent_strings + - no_adjacent_strings_in_list + - no_default_cases + - no_duplicate_case_values + - no_leading_underscores_for_library_prefixes + - no_leading_underscores_for_local_identifiers + - no_literal_bool_comparisons + - no_logic_in_create_state + # - no_runtimeType_toString # ok in tests; we enable this only in packages/ + - no_self_assignments + - no_wildcard_variable_uses + - non_constant_identifier_names + - noop_primitive_operations + - null_check_on_nullable_type_parameter + - null_closures + # - omit_local_variable_types # opposite of always_specify_types + # - one_member_abstracts # too many false positives + - only_throw_errors # this does get disabled in a few places where we have legacy code that uses strings et al + - overridden_fields + - package_api_docs + - package_names + - package_prefixed_library_names + # - parameter_assignments # we do this commonly + - prefer_adjacent_string_concatenation + - prefer_asserts_in_initializer_lists + # - prefer_asserts_with_message # not required by flutter style + - prefer_collection_literals + - prefer_conditional_assignment + - prefer_const_constructors + - prefer_const_constructors_in_immutables + - prefer_const_declarations + - prefer_const_literals_to_create_immutables + # - prefer_constructors_over_static_methods # far too many false positives + - prefer_contains + # - prefer_double_quotes # opposite of prefer_single_quotes + # - prefer_expression_function_bodies # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#consider-using--for-short-functions-and-methods + - prefer_final_fields + - prefer_final_in_for_each + - prefer_final_locals + # - prefer_final_parameters # adds too much verbosity + - prefer_for_elements_to_map_fromIterable + - prefer_foreach + - prefer_function_declarations_over_variables + - prefer_generic_function_type_aliases + - prefer_if_elements_to_conditional_expressions + - prefer_if_null_operators + - prefer_initializing_formals + - prefer_inlined_adds + # - prefer_int_literals # conflicts with https://github.com/flutter/flutter/wiki/Style-guide-for-Flutter-repo#use-double-literals-for-double-constants + - prefer_interpolation_to_compose_strings + - prefer_is_empty + - prefer_is_not_empty + - prefer_is_not_operator + - prefer_iterable_whereType + - prefer_mixin + # - prefer_null_aware_method_calls # "call()" is confusing to people new to the language since it's not documented anywhere + - prefer_null_aware_operators + - prefer_relative_imports + - prefer_single_quotes + - prefer_spread_collections + - prefer_typing_uninitialized_variables + - prefer_void_to_null + - provide_deprecation_message + # - public_member_api_docs # enabled on a case-by-case basis; see e.g. packages/analysis_options.yaml + - recursive_getters + - require_trailing_commas # would be nice, but requires a lot of manual work: 10,000+ code locations would need to be reformatted by hand after bulk fix is applied + - secure_pubspec_urls + - sized_box_for_whitespace + - sized_box_shrink_expand + - slash_for_doc_comments + - sort_child_properties_last + - sort_constructors_first + # - sort_pub_dependencies # prevents separating pinned transitive dependencies + - sort_unnamed_constructors_first + - test_types_in_equals + - throw_in_finally + - tighten_type_of_initializing_formals + # - type_annotate_public_apis # subset of always_specify_types + - type_init_formals + - type_literal_in_constant_pattern + # - unawaited_futures # too many false positives, especially with the way AnimationController works + - unnecessary_await_in_return + - unnecessary_brace_in_string_interps + - unnecessary_breaks + - unnecessary_const + - unnecessary_constructor_name + # - unnecessary_final # conflicts with prefer_final_locals + - unnecessary_getters_setters + # - unnecessary_lambdas # has false positives: https://github.com/dart-lang/linter/issues/498 + - unnecessary_late + - unnecessary_library_directive + - unnecessary_new + - unnecessary_null_aware_assignments + - unnecessary_null_aware_operator_on_extension_on_nullable + - unnecessary_null_checks + - unnecessary_null_in_if_null_operators + - unnecessary_nullable_for_final_variable_declarations + - unnecessary_overrides + - unnecessary_parenthesis + # - unnecessary_raw_strings # what's "necessary" is a matter of opinion; consistency across strings can help readability more than this lint + - unnecessary_statements + - unnecessary_string_escapes + - unnecessary_string_interpolations + - unnecessary_this + - unnecessary_to_list_in_spreads + - unreachable_from_main + - unrelated_type_equality_checks + - unsafe_html + - use_build_context_synchronously + - use_colored_box + # - use_decorated_box # leads to bugs: DecoratedBox and Container are not equivalent (Container inserts extra padding) + - use_enums + - use_full_hex_values_for_flutter_colors + - use_function_type_syntax_for_parameters + - use_if_null_to_convert_nulls_to_bools + - use_is_even_rather_than_modulo + - use_key_in_widget_constructors + - use_late_for_private_fields_and_variables + - use_named_constants + - use_raw_strings + - use_rethrow_when_possible + - use_setters_to_change_properties + # - use_string_buffers # has false positives: https://github.com/dart-lang/sdk/issues/34182 + - use_string_in_part_of_directives + - use_super_parameters + - use_test_throws_matchers + # - use_to_and_as_if_applicable # has false positives, so we prefer to catch this by code-review + - valid_regexps + - void_checks diff --git a/packages/komodo_persistence_layer/example/komodo_persistence_layer_example.dart b/packages/komodo_persistence_layer/example/komodo_persistence_layer_example.dart new file mode 100644 index 0000000000..f133ff223e --- /dev/null +++ b/packages/komodo_persistence_layer/example/komodo_persistence_layer_example.dart @@ -0,0 +1,3 @@ +void main() { + throw UnimplementedError(); +} diff --git a/packages/komodo_persistence_layer/lib/komodo_persistence_layer.dart b/packages/komodo_persistence_layer/lib/komodo_persistence_layer.dart new file mode 100644 index 0000000000..f5514038d8 --- /dev/null +++ b/packages/komodo_persistence_layer/lib/komodo_persistence_layer.dart @@ -0,0 +1,6 @@ +/// Support for doing something awesome. +/// +/// More dartdocs go here. +library; + +export 'src/komodo_persistence_layer_base.dart'; diff --git a/packages/komodo_persistence_layer/lib/src/hive/box.dart b/packages/komodo_persistence_layer/lib/src/hive/box.dart new file mode 100644 index 0000000000..7d93351d53 --- /dev/null +++ b/packages/komodo_persistence_layer/lib/src/hive/box.dart @@ -0,0 +1,97 @@ +import 'package:hive/hive.dart'; + +import '../persistence_provider.dart'; + +/// A [PersistenceProvider] that uses a Hive box as the underlying storage. +/// +/// The type parameters are: +/// - `K`: The type of the primary key of the objects that the provider stores. +/// - `T`: The type of the objects that the provider stores. The objects must +/// implement the [ObjectWithPrimaryKey] interface. +class HiveBoxProvider> + extends PersistenceProvider { + HiveBoxProvider({ + required this.name, + }); + + HiveBoxProvider.init({ + required this.name, + required Box box, + }) : _box = box; + + final String name; + Box? _box; + + static Future> + create>({ + required String name, + }) async { + final Box box = await Hive.openBox(name); + return HiveBoxProvider.init(name: name, box: box); + } + + @override + Future delete(K key) async { + _box ??= await Hive.openBox(name); + await _box!.delete(key); + } + + @override + Future deleteAll() async { + _box ??= await Hive.openBox(name); + await _box!.deleteAll(_box!.keys); + } + + @override + Future get(K key) async { + _box ??= await Hive.openBox(name); + return _box!.get(key); + } + + @override + Future> getAll() async { + _box ??= await Hive.openBox(name); + return _box!.values.toList(); + } + + @override + Future insert(T object) async { + _box ??= await Hive.openBox(name); + await _box!.put(object.primaryKey, object); + } + + @override + Future insertAll(List objects) async { + _box ??= await Hive.openBox(name); + + final Map map = {}; + for (final T object in objects) { + map[object.primaryKey] = object; + } + + await _box!.putAll(map); + } + + @override + Future update(T object) async { + // Hive replaces the object if it already exists. + await insert(object); + } + + @override + Future updateAll(List objects) async { + await insertAll(objects); + } + + @override + Future exists() async { + return Hive.boxExists(name); + } + + @override + Future containsKey(K key) async { + _box ??= await Hive.openBox(name); + + return _box!.containsKey(key); + } +} diff --git a/packages/komodo_persistence_layer/lib/src/hive/hive.dart b/packages/komodo_persistence_layer/lib/src/hive/hive.dart new file mode 100644 index 0000000000..3faa0d4033 --- /dev/null +++ b/packages/komodo_persistence_layer/lib/src/hive/hive.dart @@ -0,0 +1,2 @@ +export 'box.dart'; +export 'lazy_box.dart'; diff --git a/packages/komodo_persistence_layer/lib/src/hive/lazy_box.dart b/packages/komodo_persistence_layer/lib/src/hive/lazy_box.dart new file mode 100644 index 0000000000..03df5faace --- /dev/null +++ b/packages/komodo_persistence_layer/lib/src/hive/lazy_box.dart @@ -0,0 +1,101 @@ +import 'package:hive/hive.dart'; + +import '../persistence_provider.dart'; + +/// A [PersistenceProvider] that uses a Hive box as the underlying storage. +/// +/// The type parameters are: +/// - `K`: The type of the primary key of the objects that the provider stores. +/// - `T`: The type of the objects that the provider stores. The objects must +/// implement the [ObjectWithPrimaryKey] interface. +class HiveLazyBoxProvider> + extends PersistenceProvider { + HiveLazyBoxProvider({ + required this.name, + }); + + HiveLazyBoxProvider.init({ + required this.name, + required LazyBox box, + }) : _box = box; + + final String name; + LazyBox? _box; + + static Future> + create>({ + required String name, + }) async { + final LazyBox box = await Hive.openLazyBox(name); + return HiveLazyBoxProvider.init(name: name, box: box); + } + + @override + Future delete(K key) async { + _box ??= await Hive.openLazyBox(name); + await _box!.delete(key); + } + + @override + Future deleteAll() async { + _box ??= await Hive.openLazyBox(name); + await _box!.deleteAll(_box!.keys); + } + + @override + Future get(K key) async { + _box ??= await Hive.openLazyBox(name); + return _box!.get(key); + } + + @override + Future> getAll() async { + _box ??= await Hive.openLazyBox(name); + + final Iterable> valueFutures = + _box!.keys.map((dynamic key) => _box!.get(key as K)); + final List result = await Future.wait(valueFutures); + return result; + } + + @override + Future insert(T object) async { + _box ??= await Hive.openLazyBox(name); + await _box!.put(object.primaryKey, object); + } + + @override + Future insertAll(List objects) async { + _box ??= await Hive.openLazyBox(name); + + final Map map = {}; + for (final T object in objects) { + map[object.primaryKey] = object; + } + + await _box!.putAll(map); + } + + @override + Future update(T object) async { + // Hive replaces the object if it already exists. + await insert(object); + } + + @override + Future updateAll(List objects) async { + await insertAll(objects); + } + + @override + Future exists() async { + return Hive.boxExists(name); + } + + @override + Future containsKey(K key) async { + _box ??= await Hive.openLazyBox(name); + + return _box!.containsKey(key); + } +} diff --git a/packages/komodo_persistence_layer/lib/src/komodo_persistence_layer_base.dart b/packages/komodo_persistence_layer/lib/src/komodo_persistence_layer_base.dart new file mode 100644 index 0000000000..3e56cb5397 --- /dev/null +++ b/packages/komodo_persistence_layer/lib/src/komodo_persistence_layer_base.dart @@ -0,0 +1,3 @@ +export 'hive/hive.dart'; +export 'persisted_types.dart'; +export 'persistence_provider.dart'; diff --git a/packages/komodo_persistence_layer/lib/src/persisted_types.dart b/packages/komodo_persistence_layer/lib/src/persisted_types.dart new file mode 100644 index 0000000000..4bb0d90e9c --- /dev/null +++ b/packages/komodo_persistence_layer/lib/src/persisted_types.dart @@ -0,0 +1,43 @@ +import 'package:hive/hive.dart'; + +import 'persistence_provider.dart'; + +abstract class PersistedBasicType implements ObjectWithPrimaryKey { + PersistedBasicType(this.primaryKey, this.value); + + final T value; + + @override + final T primaryKey; +} + +class PersistedString extends PersistedBasicType { + PersistedString(super.primaryKey, super.value); +} + +class PersistedStringAdapter extends TypeAdapter { + @override + final int typeId = 12; + + @override + PersistedString read(BinaryReader reader) { + final int numOfFields = reader.readByte(); + final Map fields = { + for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), + }; + return PersistedString( + fields[0] as String, + fields[1] as String, + ); + } + + @override + void write(BinaryWriter writer, PersistedString obj) { + writer + ..writeByte(2) + ..writeByte(0) + ..write(obj.primaryKey) + ..writeByte(1) + ..write(obj.value); + } +} diff --git a/packages/komodo_persistence_layer/lib/src/persistence_provider.dart b/packages/komodo_persistence_layer/lib/src/persistence_provider.dart new file mode 100644 index 0000000000..a6986541d3 --- /dev/null +++ b/packages/komodo_persistence_layer/lib/src/persistence_provider.dart @@ -0,0 +1,36 @@ +/// A generic interface for objects that have a primary key. +/// +/// This interface is used to define the primary key of objects that are stored +/// in a persistence provider. The primary key is used to uniquely identify the +/// object. +/// +/// The type parameter `T` is the type of the primary key. +abstract class ObjectWithPrimaryKey { + T get primaryKey; +} + +typedef TableWithStringPK = ObjectWithPrimaryKey; +typedef TableWithIntPK = ObjectWithPrimaryKey; +typedef TableWithDoublePK = ObjectWithPrimaryKey; + +/// A generic interface for a persistence provider. +/// +/// This interface defines the basic CRUD operations that a persistence provider +/// should implement. The operations are asynchronous and return a [Future]. +/// +/// The type parameters are: +/// - `K`: The type of the primary key of the objects that the provider stores. +/// - `T`: The type of the objects that the provider stores. The objects must +/// implement the [ObjectWithPrimaryKey] interface. +abstract class PersistenceProvider> { + Future get(K key); + Future> getAll(); + Future containsKey(K key); + Future insert(T object); + Future insertAll(List objects); + Future update(T object); + Future updateAll(List objects); + Future delete(K key); + Future deleteAll(); + Future exists(); +} diff --git a/packages/komodo_persistence_layer/pubspec.yaml b/packages/komodo_persistence_layer/pubspec.yaml new file mode 100644 index 0000000000..8a1ad6c74e --- /dev/null +++ b/packages/komodo_persistence_layer/pubspec.yaml @@ -0,0 +1,20 @@ +name: komodo_persistence_layer +description: Persistence layer abstractions for Flutter/Dart. +version: 0.0.1 +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + +# Add regular dependencies here. +dependencies: + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + hive: + git: + url: https://github.com/KomodoPlatform/hive.git + path: hive/ + ref: 470473ffc1ba39f6c90f31ababe0ee63b76b69fe #2.2.3 + +dev_dependencies: + lints: ^2.1.0 + test: ^1.24.0 diff --git a/packages/komodo_persistence_layer/test/komodo_persistence_layer_test.dart b/packages/komodo_persistence_layer/test/komodo_persistence_layer_test.dart new file mode 100644 index 0000000000..be1ee30af1 --- /dev/null +++ b/packages/komodo_persistence_layer/test/komodo_persistence_layer_test.dart @@ -0,0 +1,13 @@ +import 'package:test/test.dart'; + +void main() { + group('A group of tests', () { + setUp(() { + // Additional setup goes here. + }); + + test('First Test', () { + throw UnimplementedError(); + }); + }); +} diff --git a/packages/komodo_ui_kit/README.md b/packages/komodo_ui_kit/README.md new file mode 100644 index 0000000000..f2b9f562ab --- /dev/null +++ b/packages/komodo_ui_kit/README.md @@ -0,0 +1,9 @@ +# UI Kit of Komodo Platform for Flutter + +## Main rules + +Before adding anything new to our project, we should ask ourselves, "Can we incorporate this package into our independent project?" If the addition brings valuable syntax sugar, improvements, or new libraries that enhance the usage of the package, then it's a good choice. However, if the proposed addition doesn't provide any significant benefits, it's better to avoid it. + +## TODO +- [ ] Why do we use `Theme.of(context)`? We don't know how user have set his project and do we have any colors or texts for example for `labelLarge` +- [ ] Create documentation for the main widgets \ No newline at end of file diff --git a/packages/komodo_ui_kit/analysis_options.yaml b/packages/komodo_ui_kit/analysis_options.yaml new file mode 100644 index 0000000000..d09b221bc2 --- /dev/null +++ b/packages/komodo_ui_kit/analysis_options.yaml @@ -0,0 +1,5 @@ +include: package:flutter_lints/flutter.yaml + +linter: + rules: + require_trailing_commas: true \ No newline at end of file diff --git a/packages/komodo_ui_kit/lib/komodo_ui_kit.dart b/packages/komodo_ui_kit/lib/komodo_ui_kit.dart new file mode 100644 index 0000000000..090a753745 --- /dev/null +++ b/packages/komodo_ui_kit/lib/komodo_ui_kit.dart @@ -0,0 +1,75 @@ +/// WebDex UI Kit +library komodo_ui_kit; + +// UI components + +// Buttons +// This category includes various button widgets used throughout the UI, +// providing different styles and functionalities. +export 'src/buttons/divided_button.dart'; // New button +export 'src/buttons/hyperlink.dart'; +export 'src/buttons/language_switcher/language_switcher.dart'; +export 'src/buttons/multiselect_dropdown/filter_container.dart'; +export 'src/buttons/multiselect_dropdown/multiselect_dropdown.dart'; +export 'src/buttons/text_dropdown_button.dart'; +export 'src/buttons/theme_switcher/theme_switcher.dart'; +export 'src/buttons/ui_action_text_button.dart'; +export 'src/buttons/ui_border_button.dart'; +export 'src/buttons/ui_checkbox.dart'; +export 'src/buttons/ui_dropdown.dart'; +export 'src/buttons/ui_primary_button.dart'; +export 'src/buttons/ui_secondary_button.dart'; +export 'src/buttons/ui_simple_button.dart'; +export 'src/buttons/ui_sort_list_button.dart'; +export 'src/buttons/ui_switcher.dart'; +export 'src/buttons/ui_underline_text_button.dart'; +export 'src/buttons/upload_button.dart'; +// Containers +// Container widgets for organizing and displaying other widgets. +export 'src/containers/chart_tooltip_container.dart'; +// Controls +// Widgets that handle user interaction and control other parts of the UI. +export 'src/controls/market_chart_header_controls.dart'; +export 'src/controls/selected_coin_graph_control.dart'; // New control widget +// Fonts +export 'src/custom_icons/custom_icons.dart'; +// Display +// Widgets primarily focused on displaying data and information. +export 'src/display/statistic_card.dart'; +export 'src/display/trend_percentage_text.dart'; +// Dividers +// Widgets for dividing content or adding scrollbars. +export 'src/dividers/ui_divider.dart'; +export 'src/dividers/ui_scrollbar.dart'; +// Images +// Widgets for displaying images and icons. +export 'src/images/coin_icon.dart' show CoinIcon, checkIfAssetExists; +// Inputs +// Widgets related to data input and selection, including text fields and selectors. +export 'src/inputs/coin_search_dropdown.dart' + show CoinSelectItem, showCoinSearch; +export 'src/inputs/input_validation_mode.dart'; +export 'src/inputs/percentage_input.dart'; +export 'src/inputs/percentage_range_slider.dart'; +export 'src/inputs/range_slider_labelled.dart'; +export 'src/inputs/time_period_selector.dart'; +export 'src/inputs/ui_date_selector.dart'; +export 'src/inputs/ui_text_form_field.dart'; +// Painters +// Custom painters used to decorate or add effects to other widgets. +export 'src/painter/focus_decorator.dart'; +// Skeleton loaders +// Widgets for displaying skeleton loaders, used to indicate loading states. +export 'src/skeleton_loaders/skeleton_loader_list_tile.dart'; +// Tables +export 'src/tables/ui_table.dart'; +// Themes +// Themed widgets such as spinners and loaders to indicate progress or loading states. +export 'src/tips/ui_spinner.dart'; +export 'src/tips/ui_spinner_list.dart'; +// Tooltips +// Widgets for displaying tooltips, providing additional context or information. +export 'src/tips/ui_tooltip.dart'; +// Utils +// Utility widgets providing small but useful functions like spacing. +export 'src/utils/gap.dart'; // New utility widget diff --git a/packages/komodo_ui_kit/lib/src/buttons/divided_button.dart b/packages/komodo_ui_kit/lib/src/buttons/divided_button.dart new file mode 100644 index 0000000000..9d4bab2063 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/divided_button.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; + +class DividedButton extends StatelessWidget { + final List children; + final EdgeInsetsGeometry? childPadding; + final VoidCallback? onPressed; + + const DividedButton({ + required this.children, + this.childPadding = const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + super.key, + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return FilledButton( + style: + (Theme.of(context).segmentedButtonTheme.style ?? const ButtonStyle()) + .copyWith( + shape: WidgetStatePropertyAll( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + ), + textStyle: WidgetStatePropertyAll( + Theme.of(context).textTheme.labelMedium, + ), + padding: const WidgetStatePropertyAll(EdgeInsets.zero), + backgroundColor: WidgetStatePropertyAll( + Theme.of(context) + .segmentedButtonTheme + .style + ?.backgroundColor + ?.resolve({WidgetState.focused}) ?? + Theme.of(context).colorScheme.surface, + ), + ), + onPressed: onPressed, + child: Row( + children: [ + for (int i = 0; i < children.length; i++) ...[ + Padding( + padding: childPadding!, + child: children[i], + ), + if (i < children.length - 1) + const SizedBox( + height: 32, + child: VerticalDivider( + width: 1, + thickness: 1, + indent: 2, + endIndent: 2, + ), + ), + ], + ], + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/hyperlink.dart b/packages/komodo_ui_kit/lib/src/buttons/hyperlink.dart new file mode 100644 index 0000000000..1fb169d70d --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/hyperlink.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; + +class Hyperlink extends StatefulWidget { + const Hyperlink({ + required this.text, + required this.onPressed, + super.key, + }); + + final String text; + final VoidCallback? onPressed; + + @override + State createState() => _HyperlinkState(); +} + +class _HyperlinkState extends State { + Color hyperlinkTextColor = Colors.blue; + + @override + Widget build(BuildContext context) { + return InkWell( + hoverColor: Colors.transparent, + onHover: (isHover) { + setState(() { + hyperlinkTextColor = isHover ? Colors.blue.shade300 : Colors.blue; + }); + }, + onTap: widget.onPressed, + child: Container( + padding: const EdgeInsets.only( + bottom: 0.2, + ), + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: hyperlinkTextColor, + width: 0.5, + ), + ), + ), + child: Text( + widget.text, + style: TextStyle(color: hyperlinkTextColor), + ), + ), // () => launchURL(widget.url), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/language_switcher/language_line.dart b/packages/komodo_ui_kit/lib/src/buttons/language_switcher/language_line.dart new file mode 100644 index 0000000000..3e425d33ea --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/language_switcher/language_line.dart @@ -0,0 +1,42 @@ +import 'package:flutter/material.dart'; + +class LanguageLine extends StatelessWidget { + const LanguageLine({ + required this.currentLocale, + this.showChevron = false, + this.flag, + super.key, + }); + + final String currentLocale; + final bool showChevron; + final Widget? flag; + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: showChevron ? 5 : 10, + children: [ + if (flag != null) flag!, + Padding( + padding: const EdgeInsets.only(top: 1, left: 2), + child: Text( + currentLocale.toUpperCase(), + style: TextStyle( + fontSize: 12, + color: Theme.of(context).textTheme.labelLarge?.color, + fontWeight: FontWeight.w500, + ), + ), + ), + if (showChevron) + Icon( + Icons.keyboard_arrow_down_rounded, + size: 20, + color: + Theme.of(context).textTheme.bodyMedium?.color?.withOpacity(.5), + ), + ], + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/language_switcher/language_switcher.dart b/packages/komodo_ui_kit/lib/src/buttons/language_switcher/language_switcher.dart new file mode 100644 index 0000000000..e287f0b488 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/language_switcher/language_switcher.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/src/buttons/language_switcher/language_line.dart'; +import 'package:komodo_ui_kit/src/buttons/language_switcher/ui_action_button.dart'; + +class LanguageSwitcher extends StatelessWidget { + const LanguageSwitcher({ + required this.currentLocale, + required this.languageCodes, + required this.flags, + super.key, + }); + final String currentLocale; + final List languageCodes; + final Map? flags; + + @override + Widget build(BuildContext context) { + return ActionButton( + child: LanguageLine( + currentLocale: currentLocale, + showChevron: true, + flag: flags?[currentLocale], + ), + onTap: (Offset position, Size size) { + showMenu( + context: context, + elevation: 0, + color: Theme.of(context).colorScheme.tertiary, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + position: RelativeRect.fromLTRB( + position.dx - 12, + 45, + (position.dx - 12) + size.width, + 45 + size.height, + ), + items: _getLocaleItems(), + ); + }, + ); + } + + List> _getLocaleItems() { + return languageCodes + .map( + (e) => PopupMenuItem( + value: e, + child: LanguageLine(currentLocale: e, flag: flags?[e]), + ), + ) + .toList(); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/language_switcher/ui_action_button.dart b/packages/komodo_ui_kit/lib/src/buttons/language_switcher/ui_action_button.dart new file mode 100644 index 0000000000..5d78309248 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/language_switcher/ui_action_button.dart @@ -0,0 +1,65 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class ActionButton extends StatefulWidget { + const ActionButton({ + required this.child, + required this.onTap, + super.key, + }); + + final void Function(Offset, Size) onTap; + final Widget child; + + @override + State createState() => _ActionButton(); +} + +class _ActionButton extends State { + final _keyInkWell = GlobalKey(); + bool _hasFocus = false; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + InkWell( + key: _keyInkWell, + customBorder: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + onFocusChange: (value) { + setState(() { + _hasFocus = value; + }); + }, + onTap: _onTap, + child: Padding( + padding: EdgeInsets.zero, + child: Container( + padding: const EdgeInsets.all(2), + decoration: BoxDecoration( + border: Border.all( + color: _hasFocus + ? theme.custom.headerFloatBoxColor + : Colors.transparent, + ), + borderRadius: BorderRadius.circular(10), + color: Theme.of(context).colorScheme.tertiary, + ), + child: widget.child, + ), + ), + ), + ], + ); + } + + void _onTap() { + final renderBoxInkwell = + _keyInkWell.currentContext!.findRenderObject() as RenderBox?; + final position = renderBoxInkwell!.localToGlobal(Offset.zero); + final size = renderBoxInkwell.size; + widget.onTap(position, size); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/multiselect_dropdown/filter_container.dart b/packages/komodo_ui_kit/lib/src/buttons/multiselect_dropdown/filter_container.dart new file mode 100644 index 0000000000..6ea8cd6ddb --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/multiselect_dropdown/filter_container.dart @@ -0,0 +1,96 @@ +import 'package:flutter/material.dart'; + +enum UIChipState { + empty, + pressed, + selected, +} + +class UIChipColorScheme { + final Color? emptyContainerColor; + final Color? pressedContainerColor; + final Color? selectedContainerColor; + final Color? selectedTextColor; + final Color? emptyTextColor; + UIChipColorScheme({ + required this.emptyContainerColor, + required this.pressedContainerColor, + required this.selectedContainerColor, + required this.selectedTextColor, + required this.emptyTextColor, + }); +} + +class UIChip extends StatelessWidget { + final String title; + final UIChipState status; + final bool showIcon; + final TextStyle? textStyle; + final UIChipColorScheme colorScheme; + + const UIChip({ + super.key, + required this.title, + required this.status, + required this.colorScheme, + this.showIcon = true, + this.textStyle, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: getColor(context), + borderRadius: BorderRadius.circular(15), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + title, + style: + (textStyle ?? Theme.of(context).textTheme.bodySmall)?.copyWith( + color: getTextColor(context), + ), + ), + const SizedBox(width: 4), + if (showIcon) + SizedBox( + width: 12, + child: Icon( + UIChipState.pressed == status + ? Icons.keyboard_arrow_up_rounded + : Icons.keyboard_arrow_down_rounded, + size: 12, + color: getTextColor(context), + ), + ), + ], + ), + ); + } + + Color? getColor(BuildContext context) { + switch (status) { + case UIChipState.empty: + return colorScheme.emptyContainerColor; + case UIChipState.pressed: + return colorScheme.pressedContainerColor; + case UIChipState.selected: + return colorScheme.selectedContainerColor; + } + } + + Color? getTextColor(BuildContext context) { + switch (status) { + case UIChipState.empty: + case UIChipState.pressed: + return colorScheme.emptyTextColor; + case UIChipState.selected: + return colorScheme.selectedTextColor; + } + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/multiselect_dropdown/multiselect_dropdown.dart b/packages/komodo_ui_kit/lib/src/buttons/multiselect_dropdown/multiselect_dropdown.dart new file mode 100644 index 0000000000..adf13c19bf --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/multiselect_dropdown/multiselect_dropdown.dart @@ -0,0 +1,244 @@ +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/src/buttons/multiselect_dropdown/filter_container.dart'; +import 'package:komodo_ui_kit/src/buttons/ui_dropdown.dart'; + +class MultiSelectDropdownButton extends StatefulWidget { + final String title; + final List? items; + final ValueChanged>? onChanged; + final List? selectedItems; + final Widget? icon; + final BorderRadius? borderRadius; + final TextStyle? style; + final String Function(T) displayItem; + final UIChipColorScheme colorScheme; + + const MultiSelectDropdownButton({ + required this.title, + required this.items, + required this.onChanged, + this.selectedItems, + this.icon, + this.borderRadius, + super.key, + this.style, + required this.displayItem, + required this.colorScheme, + }); + + @override + State> createState() => + _MultiSelectDropdownButtonState(); +} + +class _MultiSelectDropdownButtonState + extends State> { + final List _selectedIndexes = []; + UIChipState state = UIChipState.empty; + + TextStyle get _textStyle => + widget.style ?? + Theme.of(context) + .textTheme + .bodySmall + ?.copyWith(color: Theme.of(context).colorScheme.secondary) ?? + TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.secondary, + fontWeight: FontWeight.w500, + ); + + @override + void initState() { + _updateSelectedIndexes(); + super.initState(); + } + + @override + void didUpdateWidget(covariant MultiSelectDropdownButton oldWidget) { + _updateSelectedIndexes(); + super.didUpdateWidget(oldWidget); + } + + @override + void didChangeDependencies() { + _updateSelectedIndexes(); + super.didChangeDependencies(); + } + + void _updateSelectedIndexes() { + if (widget.items == null || + widget.items!.isEmpty || + (widget.selectedItems == null && + widget.items! + .where( + (T item) => widget.selectedItems?.contains(item) ?? false, + ) + .isEmpty)) { + _updateButtonState(false); + return; + } + + if (widget.selectedItems?.isEmpty ?? false) { + _selectedIndexes.clear(); + _updateButtonState(false); + return; + } + for (int itemIndex = 0; itemIndex < widget.items!.length; itemIndex++) { + if (widget.selectedItems! + .contains(widget.items![itemIndex] == widget.selectedItems)) { + _selectedIndexes.add(itemIndex); + } + } + _updateButtonState(false); + return; + } + + @override + Widget build(BuildContext context) { + final items = widget.items!; + return UiDropdown( + borderRadius: BorderRadius.circular(16), + dropdown: _MultiselectDropdownContainer( + items: [ + for (int index = 0; index < items.length; index += 1) + _MultiSelectDropdownItem( + title: widget.displayItem(items[index]), + index: index, + isSelected: _selectedIndexes.contains(index), + onChange: (isShown, value) { + if (isShown!) { + _selectedIndexes.add(value); + } else { + _selectedIndexes.remove(value); + } + if (widget.onChanged != null) { + widget.onChanged!( + _selectedIndexes.map((e) => items[e]).toList(), + ); + } + }, + textStyle: _textStyle, + ), + ], + backgroundColor: widget.colorScheme.emptyContainerColor, + ), + switcher: UIChip( + title: widget.title, + status: state, + colorScheme: widget.colorScheme, + ), + onSwitch: _updateButtonState, + ); + } + + void _updateButtonState(bool isOpened) { + setState(() { + if (isOpened) { + state = UIChipState.pressed; + } else { + if (_selectedIndexes.isEmpty) { + state = UIChipState.empty; + } else { + state = UIChipState.selected; + } + } + }); + } +} + +class _MultiSelectDropdownItem extends StatefulWidget { + final bool isSelected; + final void Function(bool?, int) onChange; + final String title; + final int index; + final TextStyle textStyle; + + const _MultiSelectDropdownItem({ + super.key, + required this.isSelected, + required this.onChange, + required this.title, + required this.index, + required this.textStyle, + }); + + @override + State<_MultiSelectDropdownItem> createState() => + _MultiSelectDropdownItemState(); +} + +class _MultiSelectDropdownItemState + extends State<_MultiSelectDropdownItem> { + bool isSelected = false; + @override + void initState() { + isSelected = widget.isSelected; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.min, + key: Key('filter-chain-${widget.title}'), + children: [ + Transform.scale( + scale: 0.7, + child: Checkbox( + value: isSelected, + splashRadius: 18, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(5), + ), + onChanged: (bool? choosed) { + onChange(choosed ?? false); + }, + ), + ), + Text( + widget.title, + style: widget.textStyle, + ), + ], + ); + } + + void onChange(bool choosed) { + setState(() { + isSelected = !isSelected; + }); + widget.onChange(choosed, widget.index); + } +} + +class _MultiselectDropdownContainer extends StatelessWidget { + final Color? backgroundColor; + final List<_MultiSelectDropdownItem> items; + const _MultiselectDropdownContainer({ + Key? key, + required this.backgroundColor, + required this.items, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + child: Container( + constraints: const BoxConstraints(maxHeight: 250), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: backgroundColor, + ), + padding: const EdgeInsets.fromLTRB(5, 4, 12, 4), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: items, + ), + ), + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/text_dropdown_button.dart b/packages/komodo_ui_kit/lib/src/buttons/text_dropdown_button.dart new file mode 100644 index 0000000000..950a0e4d1b --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/text_dropdown_button.dart @@ -0,0 +1,127 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class TextDropdownButton extends StatefulWidget { + final List items; + final String Function(T) itemAsString; + final String hint; + final T? initialValue; + final void Function(T)? onChanged; + + const TextDropdownButton({ + Key? key, + required this.items, + required this.itemAsString, + this.hint = 'Select an item', + this.initialValue, + this.onChanged, + }) : super(key: key); + + @override + State> createState() => _TextDropdownButtonState(); +} + +class _TextDropdownButtonState extends State> { + final LayerLink _layerLink = LayerLink(); + OverlayEntry? _overlayEntry; + T? _selectedItem; + bool _isOpen = false; + + @override + void initState() { + super.initState(); + _selectedItem = widget.initialValue; + } + + @override + Widget build(BuildContext context) { + return CompositedTransformTarget( + link: _layerLink, + child: GestureDetector( + onTap: _toggleDropdown, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: dexPageColors.frontPlateInner, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + _selectedItem != null + ? widget.itemAsString(_selectedItem as T) + : widget.hint, + overflow: TextOverflow.ellipsis, + ), + ), + Icon( + Icons.arrow_drop_down, + color: Theme.of(context).textTheme.labelLarge?.color, + ), + ], + ), + ), + ), + ); + } + + OverlayEntry _createOverlayEntry() { + RenderBox renderBox = context.findRenderObject() as RenderBox; + var size = renderBox.size; + + return OverlayEntry( + builder: (context) => Positioned( + width: size.width, + child: CompositedTransformFollower( + link: _layerLink, + showWhenUnlinked: false, + offset: Offset(0, size.height + 5), + child: Material( + elevation: 4, + child: ListView( + padding: EdgeInsets.zero, + shrinkWrap: true, + children: widget.items + .map( + (item) => ListTile( + title: Text(widget.itemAsString(item)), + onTap: () { + setState(() { + _selectedItem = item; + _isOpen = false; + }); + widget.onChanged?.call(_selectedItem as T); + _overlayEntry?.remove(); + _overlayEntry = null; + }, + ), + ) + .toList(), + ), + ), + ), + ), + ); + } + + void _toggleDropdown() { + if (_isOpen) { + _overlayEntry?.remove(); + _overlayEntry = null; + } else { + _overlayEntry = _createOverlayEntry(); + Overlay.of(context).insert(_overlayEntry!); + } + setState(() { + _isOpen = !_isOpen; + }); + } + + @override + void dispose() { + _overlayEntry?.remove(); + super.dispose(); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/theme_switcher/theme_switcher.dart b/packages/komodo_ui_kit/lib/src/buttons/theme_switcher/theme_switcher.dart new file mode 100644 index 0000000000..d22624a6ff --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/theme_switcher/theme_switcher.dart @@ -0,0 +1,240 @@ +import 'package:flutter/material.dart'; + +class DexThemeSwitcher extends StatefulWidget { + const DexThemeSwitcher({ + super.key, + required this.isDarkTheme, + required this.onThemeModeChanged, + required this.lightThemeTitle, + required this.darkThemeTitle, + required this.buttonKeyValue, + required this.switcherStyle, + }); + final String lightThemeTitle; + final String darkThemeTitle; + final bool isDarkTheme; + final void Function(ThemeMode) onThemeModeChanged; + final String buttonKeyValue; + final DexThemeSwitcherStyle switcherStyle; + + static const borderRadius = BorderRadius.all(Radius.circular(20)); + + @override + State createState() => _DexThemeSwitcherState(); +} + +class _DexThemeSwitcherState extends State { + bool _isHovered = false; + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constrains) { + DexThemeSwitcherStyle style = widget.switcherStyle; + final rightConstrain = + constrains.maxWidth - style.widthOfThumb - 2 * style.padding; + + return InkWell( + hoverColor: Colors.transparent, + onHover: (value) => setState(() => _isHovered = value), + key: Key(widget.buttonKeyValue), + borderRadius: DexThemeSwitcher.borderRadius, + onTap: () { + widget.onThemeModeChanged( + widget.isDarkTheme ? ThemeMode.light : ThemeMode.dark, + ); + }, + child: AnimatedContainer( + duration: style.bgAnimationDuration, + width: 208, + height: style.height, + padding: EdgeInsets.all(style.padding), + decoration: BoxDecoration( + color: style.switcherBgColor, + borderRadius: DexThemeSwitcher.borderRadius, + ), + curve: style.curve, + child: Stack( + children: [ + if (constrains.maxWidth > 140) + _Text( + isDarkTheme: widget.isDarkTheme, + lightThemeTitle: widget.lightThemeTitle, + darkThemeTitle: widget.darkThemeTitle, + style: style, + ), + AnimatedPositioned( + left: widget.isDarkTheme ? rightConstrain : 0, + duration: style.mainAnimationDuration, + curve: style.curve, + child: _Thumb( + isDarkTheme: widget.isDarkTheme, + style: style, + isHovered: _isHovered, + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +class _Text extends StatelessWidget { + const _Text({ + required bool isDarkTheme, + required this.lightThemeTitle, + required this.darkThemeTitle, + required this.style, + }) : _isDarkTheme = isDarkTheme; + + final bool _isDarkTheme; + final String lightThemeTitle; + final String darkThemeTitle; + final DexThemeSwitcherStyle style; + + static const String _textKey1 = 'animated-switcher-text-1'; + static const String _textKey2 = 'animated-switcher-text-2'; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox(height: style.height), + AnimatedSwitcher( + duration: style.bgAnimationDuration, + switchInCurve: Curves.easeInOutCubic, + switchOutCurve: Curves.easeInOutCubic, + transitionBuilder: (child, animation) { + return FadeTransition( + opacity: animation, + child: child, + ); + }, + child: Padding( + key: Key(_isDarkTheme ? _textKey1 : _textKey2), + padding: EdgeInsets.only( + left: _isDarkTheme + ? style.padding + : style.widthOfThumb + style.padding, + right: _isDarkTheme + ? style.widthOfThumb + style.padding + : style.padding, + ), + child: Text( + _isDarkTheme ? darkThemeTitle : lightThemeTitle, + textAlign: TextAlign.center, + style: TextStyle( + color: style.textColor, + fontSize: 14, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ], + ); + } +} + +class _Thumb extends StatelessWidget { + final bool isDarkTheme; + final DexThemeSwitcherStyle style; + final bool isHovered; + + const _Thumb({ + required this.isDarkTheme, + required this.style, + required this.isHovered, + }); + + static const _iconKey1 = 'animated-switcher-icon-1'; + static const _iconKey2 = 'animated-switcher-icon-2'; + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + height: style.widthOfThumb, + width: style.widthOfThumb, + alignment: Alignment.center, + duration: style.mainAnimationDuration, + curve: style.curve, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: style.thumbBgColor.withOpacity(isHovered ? 0.6 : 1), + borderRadius: BorderRadius.circular(15), + ), + child: AnimatedScale( + duration: style.bgAnimationDuration, + scale: isHovered ? 1.1 : 1, + curve: style.curve, + child: AnimatedSwitcher( + duration: style.bgAnimationDuration, + switchInCurve: style.curve, + switchOutCurve: style.curve, + child: Icon( + key: Key( + isDarkTheme ? _iconKey1 : _iconKey2, + ), + isDarkTheme ? Icons.dark_mode_sharp : Icons.light_mode_sharp, + color: style.textColor, + size: 24, + ), + ), + ), + ); + } +} + +class DexThemeSwitcherStyle { + final Cubic curve; + final double padding; + final double widthOfThumb; + final double height; + final Duration mainAnimationDuration; + final Duration bgAnimationDuration; + final Color textColor; + final Color thumbBgColor; + final Color switcherBgColor; + + DexThemeSwitcherStyle({ + this.curve = Curves.ease, + this.padding = 4, + this.widthOfThumb = 48, + this.height = 56, + this.mainAnimationDuration = const Duration(milliseconds: 300), + this.bgAnimationDuration = const Duration(milliseconds: 100), + required this.textColor, + required this.thumbBgColor, + required this.switcherBgColor, + }); + + DexThemeSwitcherStyle copyWith({ + Cubic? curve, + double? padding, + double? widthOfThumb, + double? height, + Duration? mainAnimationDuration, + Duration? bgAnimationDuration, + Color? textColor, + Color? thumbBgColor, + Color? switcherBgColor, + }) { + return DexThemeSwitcherStyle( + curve: curve ?? this.curve, + padding: padding ?? this.padding, + widthOfThumb: widthOfThumb ?? this.widthOfThumb, + height: height ?? this.height, + mainAnimationDuration: + mainAnimationDuration ?? this.mainAnimationDuration, + bgAnimationDuration: bgAnimationDuration ?? this.bgAnimationDuration, + textColor: textColor ?? this.textColor, + thumbBgColor: thumbBgColor ?? this.thumbBgColor, + switcherBgColor: switcherBgColor ?? this.switcherBgColor, + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_action_text_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_action_text_button.dart new file mode 100644 index 0000000000..518eb65718 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_action_text_button.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +class ActionTextButton extends StatelessWidget { + const ActionTextButton({ + required this.text, + required this.onTap, + this.secondaryText = '', + super.key, + }); + + final String text; + final String secondaryText; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + InkWell( + borderRadius: BorderRadius.circular(4), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(8.5), + child: Row( + children: [ + Text( + text, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).textTheme.labelLarge?.color, + ), + ), + Padding( + padding: const EdgeInsets.only(left: 10), + child: Text( + secondaryText, + style: TextStyle( + fontWeight: FontWeight.w600, + fontSize: 14, + color: Theme.of(context).textTheme.labelLarge?.color, + ), + ), + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_base_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_base_button.dart new file mode 100644 index 0000000000..c0a9261bed --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_base_button.dart @@ -0,0 +1,35 @@ +import 'package:flutter/cupertino.dart'; + +class UIBaseButton extends StatelessWidget { + const UIBaseButton({ + required this.isEnabled, + required this.child, + required this.width, + required this.height, + required this.border, + super.key, + }); + final bool isEnabled; + final double width; + final double height; + final BoxBorder? border; + final Widget child; + + @override + Widget build(BuildContext context) { + return IgnorePointer( + ignoring: !isEnabled, + child: Opacity( + opacity: isEnabled ? 1 : 0.4, + child: Container( + constraints: BoxConstraints.tightFor(width: width, height: height), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18)), + border: border, + ), + child: child, + ), + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_border_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_border_button.dart new file mode 100644 index 0000000000..08338e04e0 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_border_button.dart @@ -0,0 +1,158 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class UiBorderButton extends StatelessWidget { + const UiBorderButton({ + required this.text, + required this.onPressed, + super.key, + this.width = 300, + this.height = 48, + this.borderColor, + this.borderWidth = 3, + this.backgroundColor, + this.prefix, + this.suffix, + this.icon, + this.allowMultiline = false, + this.fontWeight = FontWeight.w700, + this.fontSize = 14, + this.textColor, + }); + final String text; + + final double width; + final double height; + final Widget? prefix; + final Widget? suffix; + final Color? borderColor; + final Color? backgroundColor; + final double borderWidth; + final Widget? icon; + final void Function()? onPressed; + final bool allowMultiline; + final FontWeight fontWeight; + final double fontSize; + final Color? textColor; + + @override + Widget build(BuildContext context) { + final icon = this.icon; + final secondaryColor = Theme.of(context).colorScheme.secondary; + return Opacity( + opacity: onPressed == null ? 0.4 : 1, + child: Container( + constraints: BoxConstraints.tightFor( + width: width, + height: allowMultiline ? null : height, + ), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18)), + color: borderColor ?? theme.custom.defaultBorderButtonBorder, + ), + child: Padding( + padding: EdgeInsets.all(borderWidth), + child: DecoratedBox( + decoration: BoxDecoration( + color: + backgroundColor ?? theme.custom.defaultBorderButtonBackground, + borderRadius: BorderRadius.circular(15), + ), + child: Material( + type: MaterialType.transparency, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.circular(15), + hoverColor: secondaryColor.withOpacity(0.05), + highlightColor: secondaryColor.withOpacity(0.1), + focusColor: secondaryColor.withOpacity(0.2), + splashColor: secondaryColor.withOpacity(0.4), + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 6, 12, 6), + child: Builder( + builder: (context) { + if (icon == null) { + return _ButtonText( + prefix: prefix, + text: text, + fontSize: fontSize, + fontWeight: fontWeight, + textColor: textColor, + suffix: suffix, + ); + } + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisSize: MainAxisSize.min, + children: [ + icon, + Flexible( + child: _ButtonText( + prefix: prefix, + text: text, + fontSize: fontSize, + fontWeight: fontWeight, + textColor: textColor, + suffix: suffix, + ), + ), + ], + ); + }, + ), + ), + ), + ), + ), + ), + ), + ); + } +} + +class _ButtonText extends StatelessWidget { + const _ButtonText({ + required this.prefix, + required this.text, + required this.fontWeight, + required this.fontSize, + required this.suffix, + this.textColor, + }); + + final Widget? prefix; + final String text; + final FontWeight fontWeight; + final double fontSize; + final Color? textColor; + final Widget? suffix; + + @override + Widget build(BuildContext context) { + final prefix = this.prefix; + final suffix = this.suffix; + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (prefix != null) ...[ + prefix, + const SizedBox(width: 9), + ], + Flexible( + child: Text( + text, + style: Theme.of(context).textTheme.labelLarge?.copyWith( + color: textColor, + fontWeight: fontWeight, + fontSize: fontSize, + ), + ), + ), + if (suffix != null) ...[ + const SizedBox(width: 9), + suffix, + ], + ], + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_checkbox.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_checkbox.dart new file mode 100644 index 0000000000..0afcbb7c5c --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_checkbox.dart @@ -0,0 +1,83 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class UiCheckbox extends StatelessWidget { + const UiCheckbox({ + required this.value, + this.checkboxKey, + this.onChanged, + this.text = '', + this.size = 18, + this.textColor, + this.borderColor, + super.key, + }); + + final Key? checkboxKey; + final bool value; + final String text; + final double size; + final Color? borderColor; + final Color? textColor; + final void Function(bool)? onChanged; + + @override + Widget build(BuildContext context) { + final onTap = onChanged; + final borderRadius = BorderRadius.circular(size / 3.6); + + return Material( + type: MaterialType.transparency, + child: InkWell( + borderRadius: borderRadius, + onTap: onTap != null ? () => onTap(!value) : null, + child: Padding( + padding: const EdgeInsets.all(2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + key: checkboxKey, + constraints: BoxConstraints.tightFor(width: size, height: size), + decoration: BoxDecoration( + color: value + ? theme.custom.defaultCheckboxColor + : theme.custom.noColor, + borderRadius: borderRadius, + border: Border.all( + color: borderColor ?? + (value + ? theme.custom.defaultCheckboxColor + : theme.custom.borderCheckboxColor), + ), + ), + child: value + ? Center( + child: Icon( + Icons.check, + size: size * 0.8, + color: theme.custom.checkCheckboxColor, + ), + ) + : const SizedBox.shrink(), + ), + if (text.isNotEmpty) + Flexible( + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 2), + child: Text( + text, + style: Theme.of(context) + .textTheme + .labelLarge + ?.copyWith(fontSize: 14, color: textColor), + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_dropdown.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_dropdown.dart new file mode 100644 index 0000000000..32dadd3593 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_dropdown.dart @@ -0,0 +1,169 @@ +import 'package:flutter/material.dart'; + +class UiDropdown extends StatefulWidget { + const UiDropdown({ + Key? key, + required this.dropdown, + required this.switcher, + this.borderRadius, + this.onSwitch, + this.switcherKey, + this.isOpen = false, + }) : super(key: key); + final Widget dropdown; + final Widget switcher; + final GlobalKey? switcherKey; + final BorderRadius? borderRadius; + final Function(bool)? onSwitch; + final bool isOpen; + + @override + State createState() => _UiDropdownState(); +} + +class _UiDropdownState extends State with WidgetsBindingObserver { + late OverlayEntry _tooltipWrapper; + GlobalKey _switcherKey = GlobalKey(); + Size? _switcherSize; + Offset? _switcherOffset; + + @override + void initState() { + final switcherKey = widget.switcherKey; + if (switcherKey != null) { + _switcherKey = switcherKey; + } + WidgetsBinding.instance.addObserver(this); + + WidgetsBinding.instance.addPostFrameCallback((_) { + final RenderBox? renderObject = + _switcherKey.currentContext?.findRenderObject() as RenderBox?; + if (renderObject != null) { + _switcherSize = renderObject.size; + _switcherOffset = renderObject.localToGlobal(Offset.zero); + } + _tooltipWrapper = _buildTooltipWrapper(); + }); + + if (widget.isOpen) _open(); + + super.initState(); + } + + @override + void didUpdateWidget(covariant UiDropdown oldWidget) { + if (widget.isOpen == oldWidget.isOpen) return; + + if (widget.isOpen != _tooltipWrapper.mounted) _switch(); + super.didUpdateWidget(oldWidget); + } + + @override + void didChangeMetrics() { + final RenderBox? renderObject = + _switcherKey.currentContext?.findRenderObject() as RenderBox?; + if (renderObject != null) { + setState(() { + _switcherSize = renderObject.size; + _switcherOffset = renderObject.localToGlobal(Offset.zero); + }); + } + _tooltipWrapper = _buildTooltipWrapper(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + if (_tooltipWrapper.mounted) { + _tooltipWrapper.remove(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: widget.borderRadius, + key: _switcherKey, + onTap: _switch, + child: widget.switcher, + ), + ); + } + + OverlayEntry _buildTooltipWrapper() { + return OverlayEntry( + builder: (context) => Stack( + children: [ + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: Material( + color: Colors.transparent, + child: InkWell( + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + hoverColor: Colors.transparent, + onTap: () => _switch(), + ), + ), + ), + Positioned( + top: (_top ?? 0) + 10, + right: _right, + child: Material( + color: Colors.transparent, + child: widget.dropdown, + ), + ), + ], + ), + ); + } + + void _switch() { + if (_tooltipWrapper.mounted) { + _close(); + } else { + _open(); + } + } + + void _open() { + 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); + } + + double? get _top { + final Offset? switcherOffset = _switcherOffset; + final Size? switcherSize = _switcherSize; + if (switcherOffset == null || switcherSize == null) { + return null; + } + + return switcherOffset.dy + switcherSize.height; + } + + double? get _right { + final Offset? switcherOffset = _switcherOffset; + final Size? switcherSize = _switcherSize; + + if (switcherOffset == null || switcherSize == null) { + return null; + } + final double windowWidth = MediaQuery.of(context).size.width; + + return windowWidth - (switcherOffset.dx + switcherSize.width); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart new file mode 100644 index 0000000000..b23846d9ec --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_primary_button.dart @@ -0,0 +1,131 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/src/buttons/ui_base_button.dart'; + +class UiPrimaryButton extends StatefulWidget { + const UiPrimaryButton({ + required this.onPressed, + this.buttonKey, + this.text = '', + this.width = double.infinity, + this.height = 48.0, + this.backgroundColor, + this.textStyle, + this.prefix, + this.border, + this.focusNode, + this.shadowColor, + this.child, + this.padding, + this.borderRadius, + super.key, + }); + + final String text; + final TextStyle? textStyle; + final double width; + final double height; + final Color? backgroundColor; + final Widget? prefix; + final Key? buttonKey; + final BoxBorder? border; + final void Function()? onPressed; + final FocusNode? focusNode; + final Color? shadowColor; + final Widget? child; + final EdgeInsets? padding; + final double? borderRadius; + + @override + State createState() => _UiPrimaryButtonState(); +} + +class _UiPrimaryButtonState extends State { + bool _hasFocus = false; + @override + Widget build(BuildContext context) { + return UIBaseButton( + isEnabled: widget.onPressed != null, + width: widget.width, + height: widget.height, + border: widget.border, + child: ElevatedButton( + focusNode: widget.focusNode, + onFocusChange: (value) { + setState(() { + _hasFocus = value; + }); + }, + onPressed: widget.onPressed ?? () {}, + key: widget.buttonKey, + style: ElevatedButton.styleFrom( + shape: _shape, + shadowColor: _shadowColor, + elevation: 1, + backgroundColor: _backgroundColor, + foregroundColor: _foregroundColor, + padding: widget.padding, + ), + child: widget.child ?? + _ButtonContent( + text: widget.text, + textStyle: widget.textStyle, + prefix: widget.prefix, + ), + ), + ); + } + + Color get _backgroundColor { + return widget.backgroundColor ?? Theme.of(context).colorScheme.primary; + } + + Color get _shadowColor { + return _hasFocus + ? widget.shadowColor ?? Theme.of(context).colorScheme.primary + : Colors.transparent; + } + + Color get _foregroundColor { + return ThemeData.estimateBrightnessForColor(_backgroundColor) == + Brightness.dark + ? theme.global.light.colorScheme.onSurface + : Theme.of(context).colorScheme.secondary; + } + + OutlinedBorder get _shape => RoundedRectangleBorder( + borderRadius: + BorderRadius.all(Radius.circular(widget.borderRadius ?? 18)), + ); +} + +class _ButtonContent extends StatelessWidget { + const _ButtonContent({ + required this.text, + required this.textStyle, + required this.prefix, + }); + + final String text; + final TextStyle? textStyle; + final Widget? prefix; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (prefix != null) prefix!, + Text(text, style: textStyle ?? _defaultTextStyle(context)), + ], + ); + } + + TextStyle? _defaultTextStyle(BuildContext context) { + return Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 14, + color: theme.custom.defaultGradientButtonTextColor, + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_secondary_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_secondary_button.dart new file mode 100644 index 0000000000..422109051c --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_secondary_button.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/src/buttons/ui_base_button.dart'; + +class UiSecondaryButton extends StatefulWidget { + const UiSecondaryButton({ + required this.onPressed, + this.buttonKey, + this.text = '', + this.width = double.infinity, + this.height = 48.0, + this.borderColor, + this.textStyle, + this.prefix, + this.border, + this.focusNode, + this.shadowColor, + this.child, + super.key, + }); + + final String text; + final TextStyle? textStyle; + final double width; + final double height; + final Color? borderColor; + final Widget? prefix; + final Key? buttonKey; + final BoxBorder? border; + final void Function()? onPressed; + final FocusNode? focusNode; + final Color? shadowColor; + final Widget? child; + + @override + State createState() => _UiSecondaryButtonState(); +} + +class _UiSecondaryButtonState extends State { + bool _hasFocus = false; + @override + Widget build(BuildContext context) { + return UIBaseButton( + isEnabled: widget.onPressed != null, + width: widget.width, + height: widget.height, + border: widget.border, + child: ElevatedButton( + focusNode: widget.focusNode, + onFocusChange: (value) { + setState(() { + _hasFocus = value; + }); + }, + onPressed: widget.onPressed ?? () {}, + key: widget.buttonKey, + style: ElevatedButton.styleFrom( + shape: _shape, + side: BorderSide( + color: _borderColor, + width: 1, + ), + shadowColor: _shadowColor, + elevation: 1, + backgroundColor: Colors.transparent, + foregroundColor: _borderColor, + padding: EdgeInsets.zero, + ), + child: widget.child ?? + _ButtonContent( + text: widget.text, + textStyle: widget.textStyle, + prefix: widget.prefix, + ), + ), + ); + } + + Color get _borderColor { + return widget.borderColor ?? Theme.of(context).colorScheme.secondary; + } + + Color get _shadowColor { + return _hasFocus + ? widget.shadowColor ?? Theme.of(context).colorScheme.primary + : Colors.transparent; + } + + OutlinedBorder get _shape => const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(18)), + ); +} + +class _ButtonContent extends StatelessWidget { + const _ButtonContent({ + required this.text, + required this.textStyle, + required this.prefix, + }); + + final String text; + final TextStyle? textStyle; + final Widget? prefix; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (prefix != null) prefix!, + Text(text, style: textStyle ?? _defaultTextStyle(context)), + ], + ); + } + + TextStyle? _defaultTextStyle(BuildContext context) { + return Theme.of(context).textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.bold, + fontSize: 14, + color: Theme.of(context).colorScheme.secondary, + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_simple_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_simple_button.dart new file mode 100644 index 0000000000..88dd707e8a --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_simple_button.dart @@ -0,0 +1,38 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class UiSimpleButton extends StatelessWidget { + const UiSimpleButton({ + required this.child, + this.disabled = false, + this.onPressed, + this.borderRadius = 8, + super.key, + }); + + final Widget child; + final bool disabled; + final double borderRadius; + final VoidCallback? onPressed; + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(borderRadius), + onTap: disabled ? null : onPressed, + child: Container( + padding: const EdgeInsets.fromLTRB(8, 2, 8, 2), + decoration: BoxDecoration( + color: disabled + ? Colors.transparent + : theme.custom.simpleButtonBackgroundColor, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: child, + ), + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_sort_list_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_sort_list_button.dart new file mode 100644 index 0000000000..2a968178a4 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_sort_list_button.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; + +class UiSortListButton extends StatelessWidget { + const UiSortListButton({ + required this.value, + required this.sortData, + required this.text, + required this.onClick, + this.iconWidth = 14, + this.iconHeight = 6, + super.key, + }); + final String text; + final T value; + final SortData sortData; + final void Function(SortData) onClick; + final double iconWidth; + final double iconHeight; + + @override + Widget build(BuildContext context) { + const textStyle = TextStyle(fontSize: 11, fontWeight: FontWeight.w500); + return InkWell( + onTap: _onClick, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(text, style: textStyle), + SortListIcon( + currentSortData: sortData, + iconWidth: iconWidth, + iconHeight: iconHeight, + sortType: value, + ), + ], + ), + ); + } + + void _onClick() { + if (sortData.sortType != value) { + onClick( + SortData( + sortType: value, + sortDirection: SortDirection.increase, + ), + ); + return; + } + + switch (sortData.sortDirection) { + case SortDirection.decrease: + onClick( + SortData( + sortType: value, + sortDirection: SortDirection.none, + ), + ); + return; + case SortDirection.increase: + onClick( + SortData( + sortType: value, + sortDirection: SortDirection.decrease, + ), + ); + return; + case SortDirection.none: + onClick( + SortData(sortType: value, sortDirection: SortDirection.increase), + ); + return; + } + } +} + +class SortListIcon extends StatelessWidget { + const SortListIcon({ + required this.currentSortData, + required this.iconWidth, + required this.iconHeight, + required this.sortType, + super.key, + }); + + final SortData currentSortData; + final double iconWidth; + final double iconHeight; + final T sortType; + + @override + Widget build(BuildContext context) { + final buttonSortDirection = currentSortData.sortType == sortType + ? currentSortData.sortDirection + : SortDirection.none; + + if (currentSortData.sortType != sortType) { + return SortListIconItem( + sortDirection: buttonSortDirection, + iconWidth: iconWidth, + iconHeight: iconHeight, + ); + } + final color = currentSortData.sortDirection != SortDirection.none + ? Theme.of(context).colorScheme.primary + : null; + return SortListIconItem( + sortDirection: buttonSortDirection, + iconWidth: iconWidth, + iconHeight: iconHeight, + iconColor: color, + ); + } +} + +class SortListIconItem extends StatelessWidget { + const SortListIconItem({ + required this.sortDirection, + required this.iconWidth, + required this.iconHeight, + this.iconColor, + super.key, + }); + final double iconWidth; + final double iconHeight; + final Color? iconColor; + final SortDirection sortDirection; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 4), + child: Column( + children: [ + if (sortDirection != SortDirection.decrease) + Container( + width: iconWidth, + height: iconHeight, + constraints: const BoxConstraints(), + child: Icon( + Icons.arrow_drop_up, + color: iconColor ?? Colors.grey[300], + size: iconWidth, + ), + ), + if (sortDirection != SortDirection.increase) + Container( + width: iconWidth, + height: iconHeight, + constraints: const BoxConstraints(), + child: Icon( + Icons.arrow_drop_down, + color: iconColor ?? Colors.grey[300], + size: iconWidth, + ), + ), + SizedBox(height: iconHeight), + ], + ), + ); + } +} + +enum SortDirection { + increase, + decrease, + none, +} + +class SortData { + const SortData({ + required this.sortDirection, + required this.sortType, + }); + final T sortType; + final SortDirection sortDirection; +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_switcher.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_switcher.dart new file mode 100644 index 0000000000..677f521551 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_switcher.dart @@ -0,0 +1,105 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class UiSwitcher extends StatefulWidget { + const UiSwitcher({ + required this.value, + required this.onChanged, + this.width = 46, + this.height = 24, + super.key, + }); + final bool value; + final double width; + final double height; + final void Function(bool) onChanged; + + @override + State createState() => _UiSwitcherState(); +} + +class _UiSwitcherState extends State + with SingleTickerProviderStateMixin { + late final Animation _toggleAnimation; + late final AnimationController _animationController; + + @override + void initState() { + _animationController = AnimationController( + vsync: this, + value: widget.value ? 1.0 : 0.0, + duration: const Duration(milliseconds: 200), + ); + _toggleAnimation = AlignmentTween( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ).animate( + CurvedAnimation( + parent: _animationController, + curve: Curves.linear, + ), + ); + super.initState(); + } + + @override + void didUpdateWidget(UiSwitcher oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.value == widget.value) return; + + if (widget.value) { + _animationController.forward(); + } else { + _animationController.reverse(); + } + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return InkWell( + hoverColor: Colors.transparent, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + onTap: () => widget.onChanged(!widget.value), + child: Container( + width: widget.width, + height: widget.height, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(18)), + gradient: theme.custom.defaultSwitchColor, + ), + child: Padding( + padding: const EdgeInsets.all(2), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16)), + color: widget.value + ? null + : Theme.of(context).colorScheme.surface, + ), + child: Container( + alignment: _toggleAnimation.value, + padding: const EdgeInsets.all(1), + child: Container( + width: widget.height - 4, + height: widget.height - 4, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.value ? Colors.white : null, + gradient: + widget.value ? null : theme.custom.defaultSwitchColor, + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/ui_underline_text_button.dart b/packages/komodo_ui_kit/lib/src/buttons/ui_underline_text_button.dart new file mode 100644 index 0000000000..f6c69a2798 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/ui_underline_text_button.dart @@ -0,0 +1,65 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class UiUnderlineTextButton extends StatefulWidget { + const UiUnderlineTextButton({ + required this.text, + required this.onPressed, + this.width = double.infinity, + this.height = 48, + this.textFontWeight = FontWeight.w700, + this.textFontSize = 14, + super.key, + }); + final String text; + final double width; + final double height; + final FontWeight textFontWeight; + final double? textFontSize; + final void Function()? onPressed; + + @override + State createState() => _UiUnderlineTextButtonState(); +} + +class _UiUnderlineTextButtonState extends State { + @override + Widget build(BuildContext context) { + final buttonTextStyle = Theme.of(context).textTheme.labelLarge; + + return Container( + constraints: + BoxConstraints.tightFor(width: widget.width, height: widget.height), + decoration: const BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(18)), + ), + child: ElevatedButton( + onPressed: widget.onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.transparent, + shadowColor: Colors.transparent, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(18), + ), + ), + child: Container( + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + width: 0.7, + color: buttonTextStyle?.color ?? theme.custom.noColor, + ), + ), + ), + child: Text( + widget.text, + style: buttonTextStyle?.copyWith( + fontWeight: widget.textFontWeight, + fontSize: widget.textFontSize, + ), + ), + ), + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/buttons/upload_button.dart b/packages/komodo_ui_kit/lib/src/buttons/upload_button.dart new file mode 100644 index 0000000000..dd92ebccda --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/buttons/upload_button.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/src/buttons/ui_border_button.dart'; + +class UploadButton extends StatelessWidget { + const UploadButton({ + required this.uploadFile, + this.buttonText = 'Select a file', + super.key, + }); + + final String buttonText; + final Future Function() uploadFile; + + @override + Widget build(BuildContext context) { + final themeData = Theme.of(context); + + return UiBorderButton( + onPressed: uploadFile, + text: buttonText, + width: double.infinity, + textColor: themeData.colorScheme.primary, + borderColor: themeData.colorScheme.primary.withOpacity(0.3), + backgroundColor: Theme.of(context).colorScheme.surface, + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/containers/chart_tooltip_container.dart b/packages/komodo_ui_kit/lib/src/containers/chart_tooltip_container.dart new file mode 100644 index 0000000000..62a4c61478 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/containers/chart_tooltip_container.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class ChartTooltipContainer extends StatelessWidget { + const ChartTooltipContainer({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return Card( + clipBehavior: Clip.antiAliasWithSaveLayer, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: Theme.of(context).colorScheme.outlineVariant, + width: 1, + ), + ), + color: Theme.of(context).colorScheme.surfaceContainerLowest, + child: Container( + padding: const EdgeInsets.all(12), + child: IntrinsicWidth(child: child), + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/controls/README b/packages/komodo_ui_kit/lib/src/controls/README new file mode 100644 index 0000000000..e22f34b23f --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/controls/README @@ -0,0 +1 @@ +Widgets that control interactions, data selection, or display dynamic information based on user input. \ No newline at end of file diff --git a/packages/komodo_ui_kit/lib/src/controls/market_chart_header_controls.dart b/packages/komodo_ui_kit/lib/src/controls/market_chart_header_controls.dart new file mode 100644 index 0000000000..9dc0f266f6 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/controls/market_chart_header_controls.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/src/controls/selected_coin_graph_control.dart'; +import 'package:komodo_ui_kit/src/inputs/coin_search_dropdown.dart'; +import 'package:komodo_ui_kit/src/inputs/time_period_selector.dart'; +import 'package:komodo_ui_kit/src/utils/gap.dart'; + +class MarketChartHeaderControls extends StatelessWidget { + final Widget title; + final Widget? leadingIcon; + final Widget leadingText; + final List availableCoins; + final String? selectedCoinId; + final void Function(String?)? onCoinSelected; + final double centreAmount; + final double percentageIncrease; + final List timePeriods; + final Duration selectedPeriod; + final void Function(Duration?) onPeriodChanged; + final CoinSelectItem Function(String coinId)? customCoinItemBuilder; + final bool emptySelectAllowed; + + const MarketChartHeaderControls({ + super.key, + required this.title, + this.leadingIcon, + required this.leadingText, + required this.availableCoins, + this.selectedCoinId, + this.onCoinSelected, + required this.centreAmount, + required this.percentageIncrease, + this.timePeriods = const [ + Duration(hours: 1), + Duration(days: 1), + Duration(days: 7), + Duration(days: 30), + Duration(days: 365), + ], + required this.selectedPeriod, + required this.onPeriodChanged, + this.customCoinItemBuilder, + this.emptySelectAllowed = false, + }); + + @override + Widget build(BuildContext context) { + final defaultTextStyle = Theme.of(context).textTheme.labelLarge; + + return Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: Theme.of(context).textTheme.labelMedium!, + child: title, + ), + const Gap(4), + Row( + children: [ + if (leadingIcon != null) ...[ + leadingIcon!, + const Gap(4), + ], + DefaultTextStyle( + style: defaultTextStyle!, + child: leadingText, + ), + ], + ), + ], + ), + const Spacer(), + SelectedCoinGraphControl( + emptySelectAllowed: emptySelectAllowed, + centreAmount: centreAmount, + percentageIncrease: percentageIncrease, + selectedCoinId: selectedCoinId, + availableCoins: availableCoins, + onCoinSelected: onCoinSelected, + customCoinItemBuilder: customCoinItemBuilder, + ), + const Spacer(), + Flexible( + child: TimePeriodSelector( + selectedPeriod: selectedPeriod, + onPeriodChanged: onPeriodChanged, + intervals: timePeriods, + ), + ), + ], + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/controls/selected_coin_graph_control.dart b/packages/komodo_ui_kit/lib/src/controls/selected_coin_graph_control.dart new file mode 100644 index 0000000000..227a9a6c7c --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/controls/selected_coin_graph_control.dart @@ -0,0 +1,115 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import 'package:komodo_ui_kit/src/buttons/divided_button.dart'; +import 'package:komodo_ui_kit/src/display/trend_percentage_text.dart'; +import 'package:komodo_ui_kit/src/images/coin_icon.dart'; +import 'package:komodo_ui_kit/src/inputs/coin_search_dropdown.dart'; + +class SelectedCoinGraphControl extends StatelessWidget { + const SelectedCoinGraphControl({ + required this.centreAmount, + required this.percentageIncrease, + this.onCoinSelected, + this.emptySelectAllowed = true, + this.selectedCoinId, + this.availableCoins, + this.customCoinItemBuilder, + super.key, + }); + + final Function(String?)? onCoinSelected; + final bool emptySelectAllowed; + final String? selectedCoinId; + final double centreAmount; + final double percentageIncrease; + + /// A list of coin IDs that are available for selection. + /// + /// Must be non-null and not empty if [onCoinSelected] is non-null. + final List? availableCoins; + + final CoinSelectItem Function(String)? customCoinItemBuilder; + + @override + Widget build(BuildContext context) { + // assert(onCoinSelected != null || emptySelectAllowed); + + // If onCoinSelected is non-null, then availableCoins must be non-null + assert( + onCoinSelected == null || availableCoins != null, + ); + return SizedBox( + height: 40, + child: DividedButton( + onPressed: onCoinSelected == null + ? null + : () async { + final selectedCoin = await showCoinSearch( + context, + coins: availableCoins!, + customCoinItemBuilder: customCoinItemBuilder, + ); + if (selectedCoin != null) { + onCoinSelected?.call(selectedCoin.coinId); + } + }, + children: [ + Container( + // Min width of 48 + constraints: const BoxConstraints(minWidth: 48), + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 8), + + child: selectedCoinId != null + ? Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + CoinIcon(selectedCoinId!, size: 18), + const SizedBox(width: 8), + Text( + selectedCoinId!, + style: Theme.of(context).textTheme.bodyLarge, + ), + if (emptySelectAllowed && selectedCoinId != null) ...[ + const SizedBox(width: 4), + SizedBox( + width: 16, + child: IconButton( + padding: EdgeInsets.zero, + icon: const Icon(Icons.clear), + iconSize: 16, + splashRadius: 20, + onPressed: () => onCoinSelected?.call(null), + ), + ), + ], + ], + ) + : Text('All', style: Theme.of(context).textTheme.bodyLarge), + ), + Text( + (NumberFormat.currency(symbol: "\$") + ..minimumSignificantDigits = 3 + ..minimumFractionDigits = 2) + .format(centreAmount), + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + // TODO: Incorporate into theme and remove duplication accross charts + fontWeight: FontWeight.w600, + ), + ), + Row( + children: [ + TrendPercentageText( + investmentReturnPercentage: percentageIncrease, + ), + if (onCoinSelected != null) ...[ + const SizedBox(width: 2), + const Icon(Icons.expand_more), + ], + ], + ), + ], + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/custom_icons/Custom.ttf b/packages/komodo_ui_kit/lib/src/custom_icons/Custom.ttf new file mode 100644 index 0000000000..80384d0013 Binary files /dev/null and b/packages/komodo_ui_kit/lib/src/custom_icons/Custom.ttf differ diff --git a/packages/komodo_ui_kit/lib/src/custom_icons/README b/packages/komodo_ui_kit/lib/src/custom_icons/README new file mode 100644 index 0000000000..1369976248 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/custom_icons/README @@ -0,0 +1,7 @@ +The icons in this folder are generated using SVGs from our UI designs. The SVGs are converted to a font using https://www.fluttericon.com/. The font is then imported into the project and used as a custom icon font. + +NB: Ensure custom icons follow the Material Design guidelines for icons. https://m3.material.io/styles/icons/designing-icons +The svg source files are located in the `source_assets` folder. + +Steps to add a new icon: +[TODO] diff --git a/packages/komodo_ui_kit/lib/src/custom_icons/config.json b/packages/komodo_ui_kit/lib/src/custom_icons/config.json new file mode 100644 index 0000000000..07338dc7c4 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/custom_icons/config.json @@ -0,0 +1,24 @@ +{ + "name": "Custom", + "css_prefix_text": "", + "css_use_suffix": false, + "hinting": true, + "units_per_em": 1000, + "ascent": 850, + "glyphs": [ + { + "uid": "f310ae5a2608a07c575928ed6eac7fee", + "css": "fiat_icon_circle", + "code": 59392, + "src": "custom_icons", + "selected": true, + "svg": { + "path": "M500 247.5C483 247.5 469.2 261.3 469.2 278.3V339.7C469.2 356.7 483 370.5 500 370.5 517.1 370.5 530.9 356.7 530.9 339.7V278.3C530.9 261.3 517.1 247.5 500 247.5ZM500 616.3C483 616.3 469.2 630.1 469.2 647V708.5C469.2 725.5 483 739.2 500 739.2 517.1 739.2 530.9 725.5 530.9 708.5V647C530.9 630.1 517.1 616.3 500 616.3ZM500 93.9C500 93.9 581.6 93.9 656.1 125.3 656.1 125.3 728.1 155.6 783.6 210.9 783.6 210.9 839.1 266.2 869.5 337.9 869.5 337.9 901 412.1 901 493.4 901 493.4 901 574.6 869.5 648.9 869.5 648.9 839.1 720.6 783.6 775.9 783.6 775.9 728.1 831.1 656.1 861.5 656.1 861.5 581.6 892.9 500 892.9 500 892.9 418.5 892.9 344 861.5 344 861.5 272 831.1 216.5 775.9 216.5 775.9 161 720.6 130.6 648.9 130.6 648.9 99.1 574.6 99.1 493.4 99.1 493.4 99.1 412.1 130.6 337.9 130.6 337.9 161 266.2 216.5 210.9 216.5 210.9 272 155.6 344 125.3 344 125.3 418.5 93.9 500 93.9ZM500 155.3C500 155.3 431 155.3 368 181.9 368 181.9 307.1 207.6 260.1 254.4 260.1 254.4 213.2 301.2 187.4 361.8 187.4 361.8 160.8 424.6 160.8 493.4 160.8 493.4 160.8 562.2 187.4 624.9 187.4 624.9 213.2 685.6 260.1 732.4 260.1 732.4 307.1 779.2 368 804.9 368 804.9 431 831.4 500 831.4 500 831.4 569.1 831.4 632.1 804.9 632.1 804.9 693 779.2 740 732.4 740 732.4 786.9 685.6 812.7 624.9 812.7 624.9 839.3 562.2 839.3 493.4 839.3 493.4 839.3 424.6 812.7 361.8 812.7 361.8 786.9 301.2 740 254.4 740 254.4 693 207.6 632.1 181.9 632.1 181.9 569.1 155.3 500 155.3ZM453.8 370.5H592.6C609.6 370.5 623.4 356.7 623.4 339.7 623.4 322.8 609.6 309 592.6 309H453.8C409.1 309 377.4 340.5 377.4 340.5 345.8 372 345.8 416.6 345.8 416.6 345.8 461.1 377.4 492.6 377.4 492.6 409.1 524.1 453.8 524.1 453.8 524.1H546.3C565.5 524.1 579 537.6 579 537.6 592.6 551.1 592.6 570.2 592.6 570.2 592.6 589.3 579 602.8 579 602.8 565.5 616.3 546.3 616.3 546.3 616.3H407.5C390.5 616.3 376.7 630.1 376.7 647 376.7 664 390.5 677.8 407.5 677.8H546.3C591 677.8 622.7 646.3 622.7 646.3 654.3 614.8 654.3 570.2 654.3 570.2 654.3 525.7 622.7 494.1 622.7 494.1 591 462.6 546.3 462.6 546.3 462.6H453.8C434.6 462.6 421.1 449.1 421.1 449.1 407.5 435.6 407.5 416.6 407.5 416.6 407.5 397.5 421.1 384 421.1 384 434.6 370.5 453.8 370.5 453.8 370.5Z", + "width": 1000 + }, + "search": [ + "fiat_icon_circled" + ] + } + ] +} \ No newline at end of file diff --git a/packages/komodo_ui_kit/lib/src/custom_icons/custom_icons.dart b/packages/komodo_ui_kit/lib/src/custom_icons/custom_icons.dart new file mode 100644 index 0000000000..eaf46f40cc --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/custom_icons/custom_icons.dart @@ -0,0 +1,26 @@ +/// Flutter icons Custom +/// Copyright (C) 2024 by original authors @ fluttericon.com, fontello.com +/// This font was generated by FlutterIcon.com, which is derived from Fontello. +/// +/// To use this font, place it in your fonts/ directory and include the +/// following in your pubspec.yaml +/// +/// flutter: +/// fonts: +/// - family: Custom +/// fonts: +/// - asset: fonts/Custom.ttf +/// +/// +/// +import 'package:flutter/widgets.dart'; + +class CustomIcons { + CustomIcons._(); + + static const _kFontFam = 'Custom'; + static const String _kFontPkg = 'komodo_ui_kit'; + + static const IconData fiatIconCircle = + IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg); +} diff --git a/packages/komodo_ui_kit/lib/src/custom_icons/source_assets/fiat_icon_circled.svg b/packages/komodo_ui_kit/lib/src/custom_icons/source_assets/fiat_icon_circled.svg new file mode 100644 index 0000000000..69f75c62b0 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/custom_icons/source_assets/fiat_icon_circled.svg @@ -0,0 +1,7 @@ + + + + + + diff --git a/packages/komodo_ui_kit/lib/src/display/README b/packages/komodo_ui_kit/lib/src/display/README new file mode 100644 index 0000000000..c968502b56 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/display/README @@ -0,0 +1 @@ +Widgets that primarily focus on displaying data, formatting, or enhancing visual representation. \ No newline at end of file diff --git a/packages/komodo_ui_kit/lib/src/display/statistic_card.dart b/packages/komodo_ui_kit/lib/src/display/statistic_card.dart new file mode 100644 index 0000000000..ef02741ab7 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/display/statistic_card.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +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 formatter used to format the value for the title + final NumberFormat _valueFormatter; + + final VoidCallback? onPressed; + + final Widget footer; + + final Widget actionIcon; + + StatisticCard({ + super.key, + required this.value, + required this.caption, + required this.footer, + required this.actionIcon, + NumberFormat? valueFormatter, + this.onPressed, + }) : _valueFormatter = valueFormatter ?? NumberFormat.currency(symbol: '\$'); + + @override + Widget build(BuildContext context) { + return Theme( + data: ThemeData.from( + colorScheme: Theme.of(context).colorScheme, + useMaterial3: true, + ), + child: Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + child: LimitedBox( + maxHeight: 148, + maxWidth: 300, + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + gradient: RadialGradient( + colors: [ + Theme.of(context) + .colorScheme + .primaryContainer + .withOpacity(0.25), + Colors.transparent, + ], + center: const Alignment(0.2, 0.1), + radius: 1.5, + ), + ), + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DefaultTextStyle( + style: Theme.of(context).textTheme.titleLarge!.copyWith( + // color: Colors.white, + ), + child: Text( + _valueFormatter.format(value), + ), + ), + const SizedBox(height: 6), + DefaultTextStyle( + style: Theme.of(context).textTheme.bodySmall!, + child: caption, + ), + const Spacer(), + DefaultTextStyle( + style: Theme.of(context).textTheme.bodySmall!, + child: footer, + ), + ], + ), + const Spacer(), + Align( + alignment: Alignment.topRight, + // TODO: Refactor this into re-usable button widget OR update + // app theme to use this style if this styling is used elsewhere + child: SizedBox( + width: 64, + height: 64, + child: IconButton.filledTonal( + isSelected: false, + icon: actionIcon, + iconSize: 42, + onPressed: onPressed, + ), + ), + ), + ], + ), + ), + ), + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/display/trend_percentage_text.dart b/packages/komodo_ui_kit/lib/src/display/trend_percentage_text.dart new file mode 100644 index 0000000000..966be6440d --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/display/trend_percentage_text.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +class TrendPercentageText extends StatelessWidget { + const TrendPercentageText({ + super.key, + required this.investmentReturnPercentage, + }); + + final double investmentReturnPercentage; + + @override + Widget build(BuildContext context) { + final iconTextColor = investmentReturnPercentage > 0 + ? Colors.green + : investmentReturnPercentage == 0 + ? Theme.of(context).disabledColor + : Theme.of(context).colorScheme.error; + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + investmentReturnPercentage > 0 + ? Icons.trending_up + : (investmentReturnPercentage == 0) + ? Icons.trending_flat + : Icons.trending_down, + color: iconTextColor, + ), + const SizedBox(width: 2), + Text( + '${(investmentReturnPercentage).toStringAsFixed(2)}%', + style: (Theme.of(context).textTheme.bodyLarge ?? + const TextStyle( + fontSize: 12, + )) + .copyWith(color: iconTextColor), + ), + ], + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/dividers/ui_divider.dart b/packages/komodo_ui_kit/lib/src/dividers/ui_divider.dart new file mode 100644 index 0000000000..4f2958c039 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/dividers/ui_divider.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; + +class UiDivider extends StatelessWidget { + const UiDivider({super.key, this.text}); + final String? text; + + @override + Widget build(BuildContext context) { + final text = this.text; + return Row( + children: [ + const Expanded(child: Divider()), + if (text != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Text( + text, + style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700), + ), + ), + const Expanded(child: Divider()), + ], + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/dividers/ui_scrollbar.dart b/packages/komodo_ui_kit/lib/src/dividers/ui_scrollbar.dart new file mode 100644 index 0000000000..6550a8bbfa --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/dividers/ui_scrollbar.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +class DexScrollbar extends StatefulWidget { + final Widget child; + final bool isMobile; + final ScrollController scrollController; + + const DexScrollbar({ + Key? key, + required this.child, + required this.scrollController, + this.isMobile = false, + }) : super(key: key); + + @override + DexScrollbarState createState() => DexScrollbarState(); +} + +class DexScrollbarState extends State { + bool isScrollbarVisible = false; + + @override + void initState() { + super.initState(); + widget.scrollController.addListener(_checkScrollbarVisibility); + } + + void _checkScrollbarVisibility() { + if (!mounted) return; + + final maxScroll = widget.scrollController.position.maxScrollExtent; + final newVisibility = maxScroll > 0; + + if (isScrollbarVisible != newVisibility) { + setState(() { + isScrollbarVisible = newVisibility; + }); + } + } + + @override + void dispose() { + widget.scrollController.removeListener(_checkScrollbarVisibility); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.isMobile) return widget.child; + + return LayoutBuilder( + builder: (context, constraints) { + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkScrollbarVisibility(); + }); + + return isScrollbarVisible + ? Scrollbar( + thumbVisibility: true, + thickness: 5, + controller: widget.scrollController, + child: ScrollConfiguration( + behavior: ScrollConfiguration.of(context) + .copyWith(scrollbars: false), + child: Padding( + padding: const EdgeInsets.only(right: 10), + child: widget.child, + ), + ), + ) + : widget.child; + }, + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/images/coin_icon.dart b/packages/komodo_ui_kit/lib/src/images/coin_icon.dart new file mode 100644 index 0000000000..7b16c3bd8e --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/images/coin_icon.dart @@ -0,0 +1,168 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +// NB: ENSURE IT STAYS IN SYNC WITH MAIN PROJECT in `lib/src/utils/utils.dart`. +const coinImagesFolder = 'assets/coin_icons/png/'; + +final Map _assetExistenceCache = {}; +List? _cachedFileList; + +String _getImagePath(String abbr) { + final fileName = abbr2Ticker(abbr).toLowerCase(); + return '$coinImagesFolder$fileName.png'; +} + +Future> _getFileList() async { + if (_cachedFileList == null) { + final manifestContent = await rootBundle.loadString('AssetManifest.json'); + final manifestMap = json.decode(manifestContent); + _cachedFileList = manifestMap.keys + .where((String key) => key.startsWith(coinImagesFolder)) + .toList(); + } + return _cachedFileList!; +} + +Future checkIfAssetExists(String abbr) async { + final filePath = _getImagePath(abbr); + + if (!_assetExistenceCache.containsKey(filePath)) { + final fileList = await _getFileList(); + _assetExistenceCache[filePath] = fileList.contains(filePath); + } + + return _assetExistenceCache[filePath]!; +} + +class CoinIcon extends StatelessWidget { + const CoinIcon( + this.coinAbbr, { + this.size = 20, + this.suspended = false, + super.key, + }); + + /// Convenience constructor for creating a coin icon from a symbol aka + /// abbreviation. This avoids having to call [abbr2Ticker] manually. + /// + /// + CoinIcon.ofSymbol( + String symbol, { + this.size = 20, + this.suspended = false, + super.key, + }) : coinAbbr = abbr2Ticker(symbol); + + final String coinAbbr; + final double size; + final bool suspended; + + @override + Widget build(BuildContext context) { + final placeHolder = Center(child: Icon(Icons.monetization_on, size: size)); + + return Opacity( + opacity: suspended ? 0.4 : 1, + child: SizedBox.square( + dimension: size, + child: _maybeAssetExists() == true + ? _knownImage() + : _maybeAssetExists() == false + ? placeHolder + : FutureBuilder( + future: _getImage(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return snapshot.data!; + } else { + return placeHolder; + } + }, + ), + ), + ); + } + + /// Returns null if the asset existence is unknown. + /// Returns true if the asset exists. + /// Returns false if the asset does not exist. + bool? _maybeAssetExists() => _assetExistenceCache[_getImagePath(coinAbbr)]; + + Image _knownImage() => Image.asset( + _getImagePath(coinAbbr), + filterQuality: FilterQuality.high, + ); + + Future _getImage() async { + if ((await checkIfAssetExists(coinAbbr)) == false) { + return null; + } + + return _knownImage(); + } + + /// Pre-loads the coin icon image into the cache. + /// + /// Whilst ignoring exceptions is generally discouraged, this method allows + /// this because it may be expected that some coin icons are not available. + /// + /// Use with caution when pre-loading many images on resource-constrained + /// devices. See [precacheImage]'s documentation for more information. + static Future precacheCoinIcon( + BuildContext context, + String abbr, { + bool throwExceptions = false, + }) async { + final filePath = _getImagePath(abbr); + final image = AssetImage(filePath); + await precacheImage( + image, + context, + onError: !throwExceptions + ? null + : (e, _) => + throw Exception('Failed to pre-cache image for coin $abbr: $e'), + ); + } +} + +// DUPLICATED FROM MAIN PROJECT in `lib/shared/utils/utils.dart`. +// NB: ENSURE IT STAYS IN SYNC. + +String abbr2Ticker(String abbr) { + if (_abbr2TickerCache.containsKey(abbr)) return _abbr2TickerCache[abbr]!; + if (!abbr.contains('-') && !abbr.contains('_')) return abbr; + + const List filteredSuffixes = [ + 'ERC20', + 'BEP20', + 'QRC20', + 'FTM20', + 'HRC20', + 'MVR20', + 'AVX20', + 'HCO20', + 'PLG20', + 'KRC20', + 'SLP', + 'IBC_IRIS', + 'IBC-IRIS', + 'IRIS', + 'segwit', + 'OLD', + 'IBC_NUCLEUSTEST', + ]; + + // Join the suffixes with '|' to form the regex pattern + String regexPattern = '(${filteredSuffixes.join('|')})'; + + String ticker = abbr + .replaceAll(RegExp('-$regexPattern'), '') + .replaceAll(RegExp('_$regexPattern'), ''); + + _abbr2TickerCache[abbr] = ticker; + return ticker; +} + +final Map _abbr2TickerCache = {}; diff --git a/packages/komodo_ui_kit/lib/src/inputs/coin_search_dropdown.dart b/packages/komodo_ui_kit/lib/src/inputs/coin_search_dropdown.dart new file mode 100644 index 0000000000..06c058219f --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/inputs/coin_search_dropdown.dart @@ -0,0 +1,389 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; + +import 'package:komodo_ui_kit/src/images/coin_icon.dart'; + +class CoinSelectItem { + CoinSelectItem({ + required this.name, + required this.coinId, + this.leading, + this.trailing, + }); + + final String name; + final String coinId; + final Widget? trailing; + final Widget? leading; +} + +class CryptoSearchDelegate extends SearchDelegate { + CryptoSearchDelegate(this.items); + + final Iterable items; + + @override + List buildActions(BuildContext context) { + return [ + IconButton(icon: const Icon(Icons.clear), onPressed: () => query = ''), + ]; + } + + @override + Widget buildLeading(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, null), + ); + } + + @override + Widget buildResults(BuildContext context) { + final results = items + .where((item) => item.name.toLowerCase().contains(query.toLowerCase())) + .toList(); + + return ListView.builder( + itemCount: results.length, + itemBuilder: (context, index) { + final item = results[index]; + return CoinListTile( + item: item, + onTap: () => close(context, item), + ); + }, + ); + } + + @override + Widget buildSuggestions(BuildContext context) { + final suggestions = items + .where((item) => item.name.toLowerCase().contains(query.toLowerCase())) + .toList(); + + return ListView.builder( + itemCount: suggestions.length, + itemBuilder: (context, index) { + final item = suggestions[index]; + return CoinListTile( + item: item, + onTap: () => query = item.name, + ); + }, + ); + } +} + +class CoinListTile extends StatelessWidget { + const CoinListTile({ + Key? key, + required this.item, + this.onTap, + }) : super(key: key); + + final CoinSelectItem item; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: item.leading ?? CoinIcon.ofSymbol(item.coinId), + title: Text(item.name), + trailing: item.trailing, + onTap: onTap, + ); + } +} + +Future showCoinSearch( + BuildContext context, { + required List coins, + CoinSelectItem Function(String coinId)? customCoinItemBuilder, +}) async { + final isMobile = MediaQuery.of(context).size.width < 600; + + final items = coins.map( + (coin) => + customCoinItemBuilder?.call(coin) ?? _defaultCoinItemBuilder(coin), + ); + + if (isMobile) { + return await showSearch( + context: context, + delegate: CryptoSearchDelegate(items), + ); + } else { + return await showDropdownSearch(context, items); + } +} + +CoinSelectItem _defaultCoinItemBuilder(String coin) { + return CoinSelectItem( + name: coin, + coinId: coin, + leading: CoinIcon.ofSymbol(coin), + ); +} + +OverlayEntry? _overlayEntry; +Completer? _completer; + +Future showDropdownSearch( + BuildContext context, + Iterable items, +) async { + final renderBox = context.findRenderObject() as RenderBox; + final offset = renderBox.localToGlobal(Offset.zero); + + void clearOverlay() { + _overlayEntry?.remove(); + _overlayEntry = null; + _completer = null; + } + + void onItemSelected(CoinSelectItem? item) { + _completer?.complete(item); + clearOverlay(); + } + + clearOverlay(); + + _completer = Completer(); + _overlayEntry = OverlayEntry( + builder: (context) { + return GestureDetector( + onTap: () => onItemSelected(null), + behavior: HitTestBehavior.translucent, + child: Stack( + children: [ + Positioned( + left: offset.dx, + top: offset.dy + renderBox.size.height, + width: 300, + child: _DropdownSearch( + items: items, + onSelected: onItemSelected, + ), + ), + ], + ), + ); + }, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) { + Overlay.of(context).insert(_overlayEntry!); + }); + + return _completer!.future; +} + +class _DropdownSearch extends StatefulWidget { + final Iterable items; + final ValueChanged onSelected; + + const _DropdownSearch({required this.items, required this.onSelected}); + + @override + State<_DropdownSearch> createState() => __DropdownSearchState(); +} + +class __DropdownSearchState extends State<_DropdownSearch> { + late Iterable filteredItems; + String query = ''; + final FocusNode _focusNode = FocusNode(); + + @override + void initState() { + super.initState(); + filteredItems = widget.items; + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + _focusNode.requestFocus(); + } + }); + } + + void updateSearchQuery(String newQuery) { + setState(() { + query = newQuery; + filteredItems = widget.items.where( + (item) => item.name.toLowerCase().contains(query.toLowerCase()), + ); + }); + } + + @override + void dispose() { + _focusNode.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Card( + elevation: 4, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + color: Theme.of(context).colorScheme.surfaceContainerLow, + child: Container( + constraints: const BoxConstraints( + maxHeight: 300, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: TextField( + focusNode: _focusNode, + autofocus: true, + decoration: InputDecoration( + hintText: 'Search', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + ), + prefixIcon: const Icon(Icons.search), + ), + onChanged: updateSearchQuery, + ), + ), + Flexible( + child: ListView.builder( + itemCount: filteredItems.length, + itemBuilder: (context, index) { + final item = filteredItems.elementAt(index); + return CoinListTile( + item: item, + onTap: () => widget.onSelected(item), + ); + }, + ), + ), + ], + ), + ), + ); + } +} + +class CoinDropdown extends StatefulWidget { + final List items; + final Function(CoinSelectItem) onItemSelected; + + const CoinDropdown({ + super.key, + required this.items, + required this.onItemSelected, + }); + + @override + State createState() => _CoinDropdownState(); +} + +class _CoinDropdownState extends State { + CoinSelectItem? selectedItem; + + void _showSearch(BuildContext context) async { + final selected = await showCoinSearch( + context, + coins: widget.items.map((e) => e.coinId).toList(), + customCoinItemBuilder: (coinId) { + return widget.items.firstWhere((e) => e.coinId == coinId); + }, + ); + if (selected != null) { + setState(() { + selectedItem = selected; + }); + widget.onItemSelected(selected); + } + } + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: () => _showSearch(context), + child: InputDecorator( + isEmpty: selectedItem == null, + decoration: const InputDecoration( + hintText: 'Select a Coin', + border: OutlineInputBorder(), + ), + child: selectedItem == null + ? null + : Row( + children: [ + Text(selectedItem!.name), + const Spacer(), + selectedItem?.trailing ?? const SizedBox(), + ], + ), + ), + ); + } +} + +// Example usage + +// void main() { +// runApp(const MyApp()); +// } + +// class MyApp extends StatelessWidget { +// const MyApp({super.key}); + +// @override +// Widget build(BuildContext context) { +// final items = [ +// CoinSelectItem( +// name: "KMD", +// coinId: "KMD", +// trailing: const Text('+2.9%', style: TextStyle(color: Colors.green)), +// ), +// CoinSelectItem( +// name: "SecondLive", +// coinId: "SL", +// trailing: const Text('+322.9%', style: TextStyle(color: Colors.green)), +// ), +// CoinSelectItem( +// name: "KiloEx", +// coinId: "KE", +// trailing: const Text('-2.09%', style: TextStyle(color: Colors.red)), +// ), +// CoinSelectItem( +// name: "Native", +// coinId: "NT", +// trailing: const Text('+225.9%', style: TextStyle(color: Colors.green)), +// ), +// CoinSelectItem( +// name: "XY Finance", +// coinId: "XY", +// trailing: const Text('+62.9%', style: TextStyle(color: Colors.green)), +// ), +// CoinSelectItem( +// name: "KMD", +// coinId: "KMD", +// trailing: const Text('+2.9%', style: TextStyle(color: Colors.green)), +// ), +// ]; + +// return MaterialApp( +// home: Scaffold( +// appBar: AppBar(title: const Text('Crypto Selector')), +// body: Padding( +// padding: const EdgeInsets.all(16.0), +// child: CoinDropdown( +// items: items, +// onItemSelected: (item) { +// // Handle item selection +// print('Selected item: ${item.name}'); +// }, +// ), +// ), +// ), +// ); +// } +// } diff --git a/packages/komodo_ui_kit/lib/src/inputs/input_validation_mode.dart b/packages/komodo_ui_kit/lib/src/inputs/input_validation_mode.dart new file mode 100644 index 0000000000..62acd1a26b --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/inputs/input_validation_mode.dart @@ -0,0 +1,12 @@ +/// Defines the modes of validation for the `UiTextFormField`. +/// +/// - `aggressive`: Validate on every input change. +/// - `passive`: Validate on focus loss and form submission. +/// - `lazy`: Validate only on form submission. +/// - `eager`: Validate on focus loss and subsequent input changes. +enum InputValidationMode { + aggressive, + passive, + lazy, + eager, +} diff --git a/packages/komodo_ui_kit/lib/src/inputs/percentage_input.dart b/packages/komodo_ui_kit/lib/src/inputs/percentage_input.dart new file mode 100644 index 0000000000..8787b4101d --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/inputs/percentage_input.dart @@ -0,0 +1,160 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class PercentageInput extends StatefulWidget { + const PercentageInput({ + Key? key, + required this.label, + this.initialValue, + this.errorText, + this.onChanged, + this.validator, + this.maxIntegerDigits = 3, + this.maxFractionDigits = 2, + }) : super(key: key); + + final Widget label; + final String? initialValue; + final String? errorText; + final ValueChanged? onChanged; + final FormFieldValidator? validator; + final int maxIntegerDigits; + final int maxFractionDigits; + + @override + State createState() => _PercentageInputState(); +} + +class _PercentageInputState extends State { + late TextEditingController _controller; + String _lastEmittedValue = ''; + bool _shouldUpdateText = true; + + @override + void initState() { + super.initState(); + _lastEmittedValue = widget.initialValue ?? ''; + _controller = TextEditingController(text: _lastEmittedValue); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(PercentageInput oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.initialValue != oldWidget.initialValue && _shouldUpdateText) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + final newValue = widget.initialValue ?? ''; + if (newValue != _lastEmittedValue) { + _lastEmittedValue = newValue; + _controller.text = newValue; + } + } + }); + } + } + + void _handlePercentageChanged(String value) { + if (value != _lastEmittedValue) { + _lastEmittedValue = value; + _shouldUpdateText = false; + widget.onChanged?.call(value); + _shouldUpdateText = true; + } + } + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [widget.label], + ), + ), + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: UiTextFormField( + controller: _controller, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow( + RegExp( + r'^\d{0,' + + widget.maxIntegerDigits.toString() + + r'}(\.\d{0,' + + widget.maxFractionDigits.toString() + + r'})?$', + ), + replacementString: _lastEmittedValue, + ), + _DecimalInputFormatter(), + ], + onChanged: _handlePercentageChanged, + validator: widget.validator, + errorText: widget.errorText, + ), + ), + const SizedBox(width: 8), + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text( + '%', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ), + ], + ); + } +} + +/// A [TextInputFormatter] that formats the input as a decimal number. +/// It allows only digits and a single dot. +/// It also removes leading zeros from the integer part. +class _DecimalInputFormatter extends TextInputFormatter { + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + if (newValue.text.isEmpty) { + return newValue; + } + + String cleanedText = newValue.text.replaceAll(RegExp(r'[^\d.]'), ''); + List parts = cleanedText.split('.'); + String integerPart = parts[0].replaceFirst(RegExp(r'^0+'), ''); + + if (integerPart.isEmpty) { + integerPart = '0'; + } + + String formattedText = integerPart; + if (parts.length > 1) { + formattedText += '.${parts[1]}'; + } + + int cursorOffset = newValue.selection.baseOffset - + (newValue.text.length - formattedText.length); + cursorOffset = cursorOffset.clamp(0, formattedText.length); + + return TextEditingValue( + text: formattedText, + selection: TextSelection.collapsed(offset: cursorOffset), + ); + } +} 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 new file mode 100644 index 0000000000..b7214f44d1 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/inputs/percentage_range_slider.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/src/inputs/range_slider_labelled.dart'; + +class PercentageRangeSlider extends StatelessWidget { + const PercentageRangeSlider({ + super.key, + required this.values, + this.title, + this.min = 0.0, + this.max = 1.0, + this.padding = const EdgeInsets.all(0), + this.divisions, + this.onChanged, + }); + + final Widget? title; + final double min; + final double max; + final EdgeInsets padding; + final int? divisions; + final RangeValues values; + final Function(RangeValues)? onChanged; + + @override + Widget build(BuildContext context) { + return Padding( + padding: padding, + child: Column( + children: [ + if (title != null) title!, + const SizedBox(height: 8), + RangeSliderLabelled( + values: values, + divisions: divisions, + min: min, + max: max, + onChanged: onChanged, + ), + ], + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/inputs/range_slider_labelled.dart b/packages/komodo_ui_kit/lib/src/inputs/range_slider_labelled.dart new file mode 100644 index 0000000000..f143f84f77 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/inputs/range_slider_labelled.dart @@ -0,0 +1,66 @@ +import 'package:flutter/material.dart'; + +class RangeSliderLabelled extends StatelessWidget { + const RangeSliderLabelled({ + super.key, + required this.values, + this.min = 0.0, + this.max = 1.0, + this.divisions, + this.onChanged, + }); + + final RangeValues values; + final double min; + final double max; + final int? divisions; + final Function(RangeValues)? onChanged; + + @override + Widget build(BuildContext context) { + // This is the padding that the RangeSlider uses internally. We need to + // account for this when calculating the position of the labels. + const paddingOffset = 24; + + return LayoutBuilder( + builder: (context, constraints) { + final sliderWidth = constraints.maxWidth - paddingOffset * 2; + final startPosition = + (values.start - min) / (max - min) * sliderWidth + 8; + final endPosition = (values.end - min) / (max - min) * sliderWidth + 14; + + return Stack( + children: [ + Positioned( + left: startPosition, + top: 0, + child: Container( + alignment: Alignment.center, + width: 40, + child: Text( + '${(values.start * 100).toStringAsFixed(0)}%', + style: Theme.of(context).textTheme.labelSmall, + ), + ), + ), + Positioned( + left: endPosition, + top: 0, + child: Text( + '${(values.end * 100).toStringAsFixed(0)}%', + style: Theme.of(context).textTheme.labelSmall, + ), + ), + RangeSlider( + values: values, + divisions: divisions, + min: min, + max: max, + onChanged: onChanged, + ), + ], + ); + }, + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart b/packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart new file mode 100644 index 0000000000..5286269a7e --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/inputs/time_period_selector.dart @@ -0,0 +1,189 @@ +import 'package:flutter/material.dart'; + +class TimePeriodSelector extends StatelessWidget { + final List intervals; + final Duration? selectedPeriod; + final ValueChanged onPeriodChanged; + final bool emptySelectionAllowed; + + const TimePeriodSelector({ + Key? key, + this.intervals = const [ + Duration(hours: 1), + Duration(days: 1), + Duration(days: 7), + Duration(days: 30), + Duration(days: 365), + ], + this.selectedPeriod, + this.emptySelectionAllowed = false, + required this.onPeriodChanged, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxWidth >= 160) { + return TimePeriodSelectorSegmentedButton( + intervals: intervals, + selectedPeriod: selectedPeriod, + onPeriodChanged: onPeriodChanged, + emptySelectionAllowed: emptySelectionAllowed, + ); + } else { + return TimePeriodSelectorDropdownButton( + intervals: intervals, + selectedPeriod: selectedPeriod, + onPeriodChanged: onPeriodChanged, + emptySelectionAllowed: emptySelectionAllowed, + ); + } + }, + ); + } +} + +class TimePeriodSelectorDropdownButton extends StatelessWidget { + final List intervals; + final Duration? selectedPeriod; + final ValueChanged onPeriodChanged; + final bool emptySelectionAllowed; + + const TimePeriodSelectorDropdownButton({ + Key? key, + required this.intervals, + this.selectedPeriod, + this.emptySelectionAllowed = false, + required this.onPeriodChanged, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + width: 68, + height: 40, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerLowest, + borderRadius: BorderRadius.circular(12), + ), + child: DropdownButton( + value: selectedPeriod, + onChanged: emptySelectionAllowed || selectedPeriod != null + ? onPeriodChanged + : null, + underline: const SizedBox.shrink(), + alignment: Alignment.center, + icon: const Icon(Icons.keyboard_arrow_down), + selectedItemBuilder: (context) => + intervals.map((Duration item) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + margin: const EdgeInsets.fromLTRB(8, 6, 0, 6), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 8), + child: Text( + selectedPeriod != null ? getDurationCode(selectedPeriod!) : '', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w600, + color: Theme.of(context).primaryTextTheme.labelLarge?.color, + ), + ), + ); + }).toList(), + items: intervals.map>((Duration value) { + return DropdownMenuItem( + value: value, + child: Text(getDurationCode(value)), + ); + }).toList(), + ), + ); + } +} + +class TimePeriodSelectorSegmentedButton extends StatelessWidget { + final List intervals; + final Duration? selectedPeriod; + final ValueChanged onPeriodChanged; + final bool emptySelectionAllowed; + + const TimePeriodSelectorSegmentedButton({ + Key? key, + required this.intervals, + this.selectedPeriod, + this.emptySelectionAllowed = false, + required this.onPeriodChanged, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: 160, + child: SegmentedButton( + style: Theme.of(context).segmentedButtonTheme.style!.copyWith( + side: WidgetStateProperty.all(BorderSide.none), + padding: WidgetStateProperty.all( + const EdgeInsets.symmetric(vertical: 4, horizontal: 0), + ), + backgroundColor: WidgetStateProperty.all( + Theme.of(context).colorScheme.surfaceContainerLowest, + ), + ), + showSelectedIcon: false, + segments: intervals.map((Duration value) { + final isSelected = selectedPeriod == value; + return ButtonSegment( + value: value, + label: !isSelected + ? Text(getDurationCode(value)) + : Container( + padding: + const EdgeInsets.symmetric(vertical: 2, horizontal: 4), + margin: const EdgeInsets.symmetric(vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(6), + ), + child: Text(getDurationCode(value)), + ), + ); + }).toList(), + multiSelectionEnabled: false, + emptySelectionAllowed: emptySelectionAllowed, + selected: selectedPeriod != null ? {selectedPeriod!} : {}, + onSelectionChanged: (newSelection) { + onPeriodChanged(newSelection.singleOrNull); + }, + ), + ); + } +} + +String getDurationCode(Duration duration) { + if (duration.inMinutes % 60 == 0 && duration.inHours < 24) { + final hours = duration.inHours; + if (hours == 1) return '1H'; + return '${hours}H'; + } else if (duration.inHours % 24 == 0 && duration.inDays < 7) { + final days = duration.inDays; + if (days == 1) return '1D'; + return '${days}D'; + } else if (duration.inDays % 7 == 0 && duration.inDays < 30) { + final weeks = duration.inDays ~/ 7; + if (weeks == 1) return '1W'; + return '${weeks}W'; + } else if (duration.inDays % 30 == 0 && duration.inDays < 365) { + final months = duration.inDays ~/ 30; + if (months == 1) return '1M'; + return '${months}M'; + } else if (duration.inDays % 365 == 0) { + final years = duration.inDays ~/ 365; + if (years == 1) return '1Y'; + return '${years}Y'; + } + + throw Exception('Unsupported duration: $duration'); +} diff --git a/packages/komodo_ui_kit/lib/src/inputs/ui_date_selector.dart b/packages/komodo_ui_kit/lib/src/inputs/ui_date_selector.dart new file mode 100644 index 0000000000..21a42bf0ad --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/inputs/ui_date_selector.dart @@ -0,0 +1,139 @@ +import 'package:app_theme/app_theme.dart'; + +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class UiDatePicker extends StatelessWidget { + const UiDatePicker({ + Key? key, + required this.date, + required this.text, + required this.onDateSelect, + required this.formatter, + this.startDate, + this.endDate, + this.isMobileAlternative = false, + }) : super(key: key); + final String text; + final DateTime? date; + final DateTime? startDate; + final DateTime? endDate; + final void Function(DateTime?) onDateSelect; + final bool isMobileAlternative; + final String Function(DateTime) formatter; + + @override + Widget build(BuildContext context) { + final ThemeData themeData = Theme.of(context); + final DateTime now = DateTime.now(); + final DateTime? selectedTime = date; + final DateTime initialDate = selectedTime ?? startDate ?? endDate ?? now; + final DateTime firstDate = startDate ?? DateTime(2010); + final DateTime lastDate = endDate ?? now.add(const Duration(days: 1)); + + return InkWell( + radius: 18, + onTap: () async { + final DateTime? time = await showDatePicker( + context: context, + initialDate: initialDate, + firstDate: firstDate, + lastDate: lastDate, + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData( + useMaterial3: false, + dialogTheme: themeData.dialogTheme + .copyWith(backgroundColor: themeData.colorScheme.onSurface), + colorScheme: themeData.colorScheme.copyWith( + surface: themeData.colorScheme.onSurface, + onSurface: themeData.textTheme.bodyMedium?.color, + ), + ), + child: child ?? const SizedBox(), + ); + }, + ); + onDateSelect(time); + }, + child: Theme( + data: Theme.of(context).brightness == Brightness.light + ? newThemeLight + : newThemeDark, + child: Builder( + builder: (context) { + final ext = Theme.of(context).extension(); + return isMobileAlternative + ? _AlternativeMobileCard( + title: + selectedTime != null ? formatter(selectedTime) : text, + selectedCardColor: ext?.primary, + selectedTextColor: ext?.surf, + unselectedCardColor: ext?.surfCont, + unselectedTextColor: ext?.s70, + isSelected: selectedTime != null, + ) + : UIChip( + title: + selectedTime != null ? formatter(selectedTime) : text, + colorScheme: UIChipColorScheme( + emptyContainerColor: ext?.surfCont, + emptyTextColor: ext?.s70, + pressedContainerColor: ext?.surfContLowest, + selectedContainerColor: ext?.primary, + selectedTextColor: ext?.surf, + ), + status: selectedTime != null + ? UIChipState.selected + : UIChipState.empty, + ); + }, + ), + ), + ); + } +} + +class _AlternativeMobileCard extends StatelessWidget { + final String title; + final Color? selectedCardColor; + final Color? selectedTextColor; + final Color? unselectedCardColor; + final Color? unselectedTextColor; + final bool isSelected; + + const _AlternativeMobileCard({ + required this.title, + required this.selectedCardColor, + required this.selectedTextColor, + required this.unselectedCardColor, + required this.unselectedTextColor, + required this.isSelected, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + alignment: Alignment.center, + height: 56, + constraints: const BoxConstraints(maxHeight: 56), + decoration: BoxDecoration( + color: isSelected ? selectedCardColor : unselectedCardColor, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + title, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: isSelected ? selectedTextColor : unselectedTextColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} 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 new file mode 100644 index 0000000000..4b840a9b10 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/inputs/ui_text_form_field.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +/// A reusable text form field widget with customizable validation modes. +/// +/// This widget provides several validation modes to control when validation +/// errors are shown. It also allows customization of its appearance and behavior. +/// +/// The supported validation modes are: +/// - `aggressive`: Validate on every input change. +/// - `passive`: Validate on focus loss and form submission. +/// - `lazy`: Validate only on form submission. +/// - `eager`: Validate on focus loss and subsequent input changes. +/// +/// The `UiTextFormField` can be customized using various parameters such as +/// `hintText`, `controller`, `inputFormatters`, `textInputAction`, and more. +class UiTextFormField extends StatefulWidget { + const UiTextFormField({ + super.key, + this.initialValue, + this.hintText, + this.controller, + this.inputFormatters, + this.textInputAction, + this.style, + this.hintTextStyle, + this.inputContentPadding, + this.keyboardType, + this.validator, + this.errorText, + this.prefixIcon, + this.suffixIcon, + this.maxLines = 1, + this.errorMaxLines, + this.enableInteractiveSelection = true, + this.autocorrect = true, + this.readOnly = false, + this.autofocus = false, + this.obscureText = false, + this.enabled = true, + this.focusNode, + this.onFocus, + this.fillColor, + this.onFieldSubmitted, + this.onChanged, + this.suffix, + this.maxLength, + this.maxLengthEnforcement, + this.counterText, + this.labelStyle, + this.enabledBorder, + this.focusedBorder, + this.errorStyle, + this.validationMode = InputValidationMode.eager, + }); + + final String? initialValue; + final String? hintText; + final TextEditingController? controller; + final List? inputFormatters; + final TextInputAction? textInputAction; + final TextStyle? style; + final TextStyle? hintTextStyle; + final TextStyle? labelStyle; + final TextInputType? keyboardType; + final String? errorText; + final Widget? prefixIcon; + final Widget? suffixIcon; + final int? maxLines; + final int? errorMaxLines; + final bool obscureText; + final bool autocorrect; + final bool enabled; + final bool enableInteractiveSelection; + final bool autofocus; + final bool readOnly; + final EdgeInsets? inputContentPadding; + final FocusNode? focusNode; + final void Function(FocusNode)? onFocus; + final Color? fillColor; + final void Function(String)? onChanged; + final void Function(String)? onFieldSubmitted; + final String? Function(String?)? validator; + final Widget? suffix; + final int? maxLength; + final MaxLengthEnforcement? maxLengthEnforcement; + final String? counterText; + final InputBorder? enabledBorder; + final InputBorder? focusedBorder; + final TextStyle? errorStyle; + final InputValidationMode validationMode; + + @override + State createState() => _UiTextFormFieldState(); +} + +class _UiTextFormFieldState extends State { + String? _hintText; + String? _errorText; + String? _displayedErrorText; + FocusNode _focusNode = FocusNode(); + bool _hasFocusExitedOnce = false; + bool _shouldValidate = false; + + @override + void initState() { + super.initState(); + _hintText = widget.hintText; + _errorText = widget.errorText; + _displayedErrorText = widget.errorText; + + if (_errorText?.isNotEmpty == true || + widget.validationMode == InputValidationMode.aggressive) { + _hasFocusExitedOnce = true; + _shouldValidate = true; + } + if (widget.focusNode != null) { + _focusNode = widget.focusNode!; + } + + _focusNode.addListener(_handleFocusChange); + } + + @override + void didUpdateWidget(covariant UiTextFormField oldWidget) { + super.didUpdateWidget(oldWidget); + + if (widget.errorText != oldWidget.errorText) { + setState(() { + _errorText = widget.errorText; + _displayedErrorText = widget.errorText; + if (_errorText?.isNotEmpty == true) { + _hasFocusExitedOnce = true; + _shouldValidate = true; + } + }); + } + } + + @override + void dispose() { + _focusNode.removeListener(_handleFocusChange); + _focusNode.dispose(); + super.dispose(); + } + + /// Handles the focus change events. + void _handleFocusChange() { + setState(() { + _hintText = _focusNode.hasFocus ? null : widget.hintText; + if (widget.onFocus != null) { + widget.onFocus!(_focusNode); + } + if (!_focusNode.hasFocus) { + if (!_hasFocusExitedOnce) { + _hasFocusExitedOnce = true; + } + if (widget.validationMode == InputValidationMode.eager || + widget.validationMode == InputValidationMode.lazy) { + _shouldValidate = true; + _performValidation(); + } + } + if (_focusNode.hasFocus && + widget.validationMode == InputValidationMode.aggressive) { + _shouldValidate = true; + } + }); + } + + @override + Widget build(BuildContext context) { + final widgetStyle = widget.style; + var style = TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Theme.of(context).textTheme.bodyMedium?.color, + ); + if (widgetStyle != null) { + style = style.merge(widgetStyle); + } + + final TextStyle? hintTextStyle = Theme.of(context) + .inputDecorationTheme + .hintStyle + ?.merge(widget.hintTextStyle); + + final TextStyle? labelStyle = Theme.of(context) + .inputDecorationTheme + .labelStyle + ?.merge(widget.labelStyle); + + final TextStyle? errorStyle = Theme.of(context) + .inputDecorationTheme + .errorStyle + ?.merge(widget.errorStyle); + + return TextFormField( + maxLength: widget.maxLength, + maxLengthEnforcement: widget.maxLengthEnforcement, + initialValue: widget.initialValue, + controller: widget.controller, + inputFormatters: widget.inputFormatters, + validator: (value) => _performValidation(value), + onChanged: (value) { + if (widget.onChanged != null) { + widget.onChanged!(value); + } + if (_shouldValidate) { + _performValidation(value); + } + }, + onFieldSubmitted: widget.onFieldSubmitted, + enableInteractiveSelection: widget.enableInteractiveSelection, + textInputAction: widget.textInputAction, + style: style, + autovalidateMode: _shouldValidate + ? AutovalidateMode.onUserInteraction + : AutovalidateMode.disabled, + keyboardType: widget.keyboardType, + obscureText: widget.obscureText, + autocorrect: widget.autocorrect, + autofocus: widget.autofocus, + maxLines: widget.maxLines, + readOnly: widget.readOnly, + focusNode: _focusNode, + enabled: widget.enabled, + decoration: InputDecoration( + fillColor: widget.fillColor, + hintText: _hintText, + hintStyle: hintTextStyle, + contentPadding: widget.inputContentPadding, + counterText: widget.counterText, + labelText: widget.hintText, + labelStyle: + _hintText != null && !_hasValue ? hintTextStyle : labelStyle, + errorText: _displayedErrorText, + errorStyle: errorStyle, + prefixIcon: widget.prefixIcon, + suffixIcon: widget.suffixIcon, + errorMaxLines: widget.errorMaxLines, + suffix: widget.suffix, + enabledBorder: widget.enabledBorder, + focusedBorder: widget.focusedBorder, + ), + ); + } + + /// Checks if the field has a value. + bool get _hasValue => + (widget.controller?.text.isNotEmpty ?? false) || + (widget.initialValue?.isNotEmpty ?? false); + + /// Performs validation based on the validator function and updates error state. + String? _performValidation([String? value]) { + final error = widget.validator?.call(value ?? widget.controller?.text) ?? + widget.errorText; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) { + setState(() { + _errorText = error; + _displayedErrorText = + _hasFocusExitedOnce || _focusNode.hasFocus ? _errorText : null; + }); + } + }); + return error; + } +} diff --git a/packages/komodo_ui_kit/lib/src/painter/dash_rect_painter.dart b/packages/komodo_ui_kit/lib/src/painter/dash_rect_painter.dart new file mode 100644 index 0000000000..8316c97d0f --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/painter/dash_rect_painter.dart @@ -0,0 +1,94 @@ +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +class DashRectPainter extends CustomPainter { + DashRectPainter({ + this.strokeWidth = 5.0, + this.color = Colors.red, + this.gap = 5.0, + }); + + double strokeWidth; + Color color; + double gap; + + @override + void paint(Canvas canvas, Size size) { + final dashedPaint = Paint() + ..color = color + ..strokeWidth = strokeWidth + ..style = PaintingStyle.stroke; + + final x = size.width; + final y = size.height; + + final topPath = getDashedPath( + const math.Point(0, 0), + math.Point(x, 0), + gap, + ); + + final rightPath = getDashedPath( + math.Point(x, 0), + math.Point(x, y), + gap, + ); + + final bottomPath = getDashedPath( + math.Point(0, y), + math.Point(x, y), + gap, + ); + + final leftPath = getDashedPath( + const math.Point(0, 0), + math.Point(0.001, y), + gap, + ); + + canvas + ..drawPath(topPath, dashedPaint) + ..drawPath(rightPath, dashedPaint) + ..drawPath(bottomPath, dashedPaint) + ..drawPath(leftPath, dashedPaint); + } + + Path getDashedPath( + math.Point a, + math.Point b, + double gap, + ) { + final size = Size(b.x - a.x, b.y - a.y); + final path = Path()..moveTo(a.x, a.y); + var shouldDraw = true; + var currentPoint = math.Point(a.x, a.y); + + final radians = math.atan(size.height / size.width); + + final dx = math.cos(radians) * gap < 0 + ? math.cos(radians) * gap * -1 + : math.cos(radians) * gap; + + final dy = math.sin(radians) * gap < 0 + ? math.sin(radians) * gap * -1 + : math.sin(radians) * gap; + + while (currentPoint.x <= b.x && currentPoint.y <= b.y) { + shouldDraw + ? path.lineTo(currentPoint.x, currentPoint.y) + : path.moveTo(currentPoint.x, currentPoint.y); + shouldDraw = !shouldDraw; + currentPoint = math.Point( + currentPoint.x + dx, + currentPoint.y + dy, + ); + } + return path; + } + + @override + bool shouldRepaint(CustomPainter oldDelegate) { + return true; + } +} diff --git a/packages/komodo_ui_kit/lib/src/painter/focus_decorator.dart b/packages/komodo_ui_kit/lib/src/painter/focus_decorator.dart new file mode 100644 index 0000000000..03b6d52ee7 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/painter/focus_decorator.dart @@ -0,0 +1,48 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/src/painter/dash_rect_painter.dart'; + +class FocusDecorator extends StatefulWidget { + const FocusDecorator({ + required this.child, + this.edgeInsets, + this.skipTraversal = true, + super.key, + }); + + final Widget child; + final bool skipTraversal; + final EdgeInsets? edgeInsets; + + @override + State createState() => _FocusDecoratorState(); +} + +class _FocusDecoratorState extends State { + bool _hasFocus = false; + + @override + Widget build(BuildContext context) { + return Focus( + skipTraversal: widget.skipTraversal, + onFocusChange: (value) { + setState(() { + _hasFocus = value; + }); + }, + child: Container( + padding: widget.edgeInsets, + child: CustomPaint( + painter: DashRectPainter( + color: _hasFocus + ? theme.custom.buttonColorDefaultHover.withOpacity(.8) + : Colors.transparent, + strokeWidth: 1, + gap: 2, + ), + child: widget.child, + ), + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/skeleton_loaders/skeleton_loader_list_tile.dart b/packages/komodo_ui_kit/lib/src/skeleton_loaders/skeleton_loader_list_tile.dart new file mode 100644 index 0000000000..100939d5e6 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/skeleton_loaders/skeleton_loader_list_tile.dart @@ -0,0 +1,94 @@ +import 'package:flutter/material.dart'; + +class SkeletonListTile extends StatefulWidget { + const SkeletonListTile({super.key}); + + @override + State createState() => _SkeletonListTileState(); +} + +class _SkeletonListTileState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _gradientPosition; + + @override + void initState() { + super.initState(); + _controller = + AnimationController(vsync: this, duration: const Duration(seconds: 2)); + _gradientPosition = Tween(begin: -3, end: 10) + .animate(CurvedAnimation(parent: _controller, curve: Curves.linear)) + ..addListener(() { + setState(() {}); + }); + + _controller.repeat(); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + LinearGradient get gradient { + Color backgroundColor = Theme.of(context).colorScheme.primary; + Color highlightColor = Colors.grey[300]!; + return LinearGradient( + begin: Alignment(_gradientPosition.value, 0), + end: const Alignment(-1, 0), + colors: [ + backgroundColor, + highlightColor, + backgroundColor, + ], + ); + } + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Container( + width: 60, + height: 60, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: gradient, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Spacer(), + Container( + width: double.infinity, + height: 10, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + gradient: gradient, + ), + ), + const SizedBox(height: 10), + Container( + width: double.infinity, + height: 10, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + gradient: gradient, + ), + ), + const Spacer(), + ], + ), + ), + ], + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/tables/ui_table.dart b/packages/komodo_ui_kit/lib/src/tables/ui_table.dart new file mode 100644 index 0000000000..dc7c8ad0d6 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/tables/ui_table.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; + +class UiTable extends StatelessWidget { + const UiTable({ + Key? key, + required this.columns, + required this.rows, + this.headerColor, + this.rowColor, + this.padding = const EdgeInsets.all(20), + this.cellPadding = const EdgeInsets.all(8), + this.headerAlignment = Alignment.center, + this.cellAlignment = Alignment.center, + this.headingBorder, + this.rowBorder, + }) : super(key: key); + + final List columns; + final List> rows; + final Color? headerColor; + final Color? rowColor; + final EdgeInsets cellPadding; + final EdgeInsets padding; + final Alignment headerAlignment; + final Alignment cellAlignment; + final Border? headingBorder; + final Border? rowBorder; + + @override + Widget build(BuildContext context) { + return Expanded( + child: SingleChildScrollView( + child: Table( + children: [ + TableRow( + decoration: BoxDecoration( + color: headerColor, + border: Border( + bottom: BorderSide( + // TODO(Francois): change to theme colour + color: Theme.of(context).colorScheme.primary, + width: 2.0, + ), // Adjust color and width as needed + ), + ), + children: columns + .map( + (column) => TableCell( + child: Padding( + padding: cellPadding, + child: Align( + alignment: headerAlignment, + child: column, + ), + ), + ), + ) + .toList(), + ), + ...rows.map( + (row) => TableRow( + decoration: BoxDecoration( + color: rowColor, + ), + children: row + .map( + (cell) => TableCell( + child: Padding( + padding: cellPadding, + child: Align( + alignment: cellAlignment, + child: cell, + ), + ), + ), + ) + .toList(), + ), + ), + ], + ), + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/tips/ui_spinner.dart b/packages/komodo_ui_kit/lib/src/tips/ui_spinner.dart new file mode 100644 index 0000000000..f5758f7462 --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/tips/ui_spinner.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; + +class UiSpinner extends StatelessWidget { + const UiSpinner({ + super.key, + this.height = 20, + this.width = 20, + this.repeat = true, + this.strokeWidth = 2, + this.color, + }); + final double width; + final double height; + final bool repeat; + final double strokeWidth; + final Color? color; + + @override + Widget build(BuildContext context) { + return SizedBox( + height: height, + width: width, + child: CircularProgressIndicator( + color: color, + strokeWidth: strokeWidth, + ), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/tips/ui_spinner_list.dart b/packages/komodo_ui_kit/lib/src/tips/ui_spinner_list.dart new file mode 100644 index 0000000000..dd3802b65b --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/tips/ui_spinner_list.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; + +class UiSpinnerList extends StatelessWidget { + const UiSpinnerList({super.key, this.height = 100}); + + final double height; + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints(maxHeight: height), + child: const Center(child: UiSpinner()), + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/tips/ui_tooltip.dart b/packages/komodo_ui_kit/lib/src/tips/ui_tooltip.dart new file mode 100644 index 0000000000..848a1d0c0a --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/tips/ui_tooltip.dart @@ -0,0 +1,37 @@ +import 'package:app_theme/app_theme.dart'; +import 'package:flutter/material.dart'; + +class UiTooltip extends StatelessWidget { + const UiTooltip({ + required this.message, + required this.child, + super.key, + }); + + final String message; + final Widget child; + + @override + Widget build(BuildContext context) { + return Tooltip( + message: message, + preferBelow: false, + decoration: BoxDecoration( + color: theme.currentGlobal.colorScheme.surface, + boxShadow: const [ + BoxShadow( + color: Colors.black12, + blurRadius: 5, + spreadRadius: 1, + offset: Offset(0, 2), + ), + ], + ), + textStyle: TextStyle( + color: Theme.of(context).colorScheme.onSurface, + fontSize: Theme.of(context).textTheme.bodySmall?.fontSize, + ), + child: child, + ); + } +} diff --git a/packages/komodo_ui_kit/lib/src/utils/gap.dart b/packages/komodo_ui_kit/lib/src/utils/gap.dart new file mode 100644 index 0000000000..5d8ef7c43a --- /dev/null +++ b/packages/komodo_ui_kit/lib/src/utils/gap.dart @@ -0,0 +1,362 @@ +/// NB! The following code is copied from the gap package verbatim to avoid +/// the unnecessary overhead on the OPSEC team. +/// +/// The original package also has a gap widget for slivers, but it is not +/// included here. +/// +/// Credit: https://github.com/letsar/gap/blob/master/lib/src/rendering/gap.dart +/// +/// ============================================================================ + +import 'package:flutter/widgets.dart'; +import 'package:flutter/rendering.dart'; + +/// A widget that takes a fixed amount of space in the direction of its parent. +/// +/// It only works in the following cases: +/// - It is a descendant of a [Row], [Column], or [Flex], +/// and the path from the [Gap] widget to its enclosing [Row], [Column], or +/// [Flex] must contain only [StatelessWidget]s or [StatefulWidget]s (not other +/// kinds of widgets, like [RenderObjectWidget]s). +/// - It is a descendant of a [Scrollable]. +/// +/// See also: +/// +/// * [MaxGap], a gap that can take, at most, the amount of space specified. +/// * [SliverGap], the sliver version of this widget. +class Gap extends StatelessWidget { + /// Creates a widget that takes a fixed [mainAxisExtent] of space in the + /// direction of its parent. + /// + /// The [mainAxisExtent] must not be null and must be positive. + /// The [crossAxisExtent] must be either null or positive. + const Gap( + this.mainAxisExtent, { + Key? key, + this.crossAxisExtent, + this.color, + }) : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity), + assert(crossAxisExtent == null || crossAxisExtent >= 0), + super(key: key); + + /// Creates a widget that takes a fixed [mainAxisExtent] of space in the + /// direction of its parent and expands in the cross axis direction. + /// + /// The [mainAxisExtent] must not be null and must be positive. + const Gap.expand( + double mainAxisExtent, { + Key? key, + Color? color, + }) : this( + mainAxisExtent, + key: key, + crossAxisExtent: double.infinity, + color: color, + ); + + /// The amount of space this widget takes in the direction of its parent. + /// + /// For example: + /// - If the parent is a [Column] this is the height of this widget. + /// - If the parent is a [Row] this is the width of this widget. + /// + /// Must not be null and must be positive. + final double mainAxisExtent; + + /// The amount of space this widget takes in the opposite direction of the + /// parent. + /// + /// For example: + /// - If the parent is a [Column] this is the width of this widget. + /// - If the parent is a [Row] this is the height of this widget. + /// + /// Must be positive or null. If it's null (the default) the cross axis extent + /// will be the same as the constraints of the parent in the opposite + /// direction. + final double? crossAxisExtent; + + /// The color used to fill the gap. + final Color? color; + + @override + Widget build(BuildContext context) { + final scrollableState = Scrollable.maybeOf(context); + final AxisDirection? axisDirection = scrollableState?.axisDirection; + final Axis? fallbackDirection = + axisDirection == null ? null : axisDirectionToAxis(axisDirection); + + return _RawGap( + mainAxisExtent, + crossAxisExtent: crossAxisExtent, + color: color, + fallbackDirection: fallbackDirection, + ); + } +} + +/// A widget that takes, at most, an amount of space in a [Row], [Column], +/// or [Flex] widget. +/// +/// A [MaxGap] widget must be a descendant of a [Row], [Column], or [Flex], +/// and the path from the [MaxGap] widget to its enclosing [Row], [Column], or +/// [Flex] must contain only [StatelessWidget]s or [StatefulWidget]s (not other +/// kinds of widgets, like [RenderObjectWidget]s). +/// +/// See also: +/// +/// * [Gap], the unflexible version of this widget. +class MaxGap extends StatelessWidget { + /// Creates a widget that takes, at most, the specified [mainAxisExtent] of + /// space in a [Row], [Column], or [Flex] widget. + /// + /// The [mainAxisExtent] must not be null and must be positive. + /// The [crossAxisExtent] must be either null or positive. + const MaxGap( + this.mainAxisExtent, { + Key? key, + this.crossAxisExtent, + this.color, + }) : super(key: key); + + /// Creates a widget that takes, at most, the specified [mainAxisExtent] of + /// space in a [Row], [Column], or [Flex] widget and expands in the cross axis + /// direction. + /// + /// The [mainAxisExtent] must not be null and must be positive. + /// The [crossAxisExtent] must be either null or positive. + const MaxGap.expand( + double mainAxisExtent, { + Key? key, + Color? color, + }) : this( + mainAxisExtent, + key: key, + crossAxisExtent: double.infinity, + color: color, + ); + + /// The amount of space this widget takes in the direction of the parent. + /// + /// If the parent is a [Column] this is the height of this widget. + /// If the parent is a [Row] this is the width of this widget. + /// + /// Must not be null and must be positive. + final double mainAxisExtent; + + /// The amount of space this widget takes in the opposite direction of the + /// parent. + /// + /// If the parent is a [Column] this is the width of this widget. + /// If the parent is a [Row] this is the height of this widget. + /// + /// Must be positive or null. If it's null (the default) the cross axis extent + /// will be the same as the constraints of the parent in the opposite + /// direction. + final double? crossAxisExtent; + + /// The color used to fill the gap. + final Color? color; + + @override + Widget build(BuildContext context) { + return Flexible( + child: _RawGap( + mainAxisExtent, + crossAxisExtent: crossAxisExtent, + color: color, + ), + ); + } +} + +class _RawGap extends LeafRenderObjectWidget { + const _RawGap( + this.mainAxisExtent, { + Key? key, + this.crossAxisExtent, + this.color, + this.fallbackDirection, + }) : assert(mainAxisExtent >= 0 && mainAxisExtent < double.infinity), + assert(crossAxisExtent == null || crossAxisExtent >= 0), + super(key: key); + + final double mainAxisExtent; + + final double? crossAxisExtent; + + final Color? color; + + final Axis? fallbackDirection; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderGap( + mainAxisExtent: mainAxisExtent, + crossAxisExtent: crossAxisExtent ?? 0, + color: color, + fallbackDirection: fallbackDirection, + ); + } + + @override + void updateRenderObject(BuildContext context, RenderGap renderObject) { + renderObject + ..mainAxisExtent = mainAxisExtent + ..crossAxisExtent = crossAxisExtent ?? 0 + ..color = color + ..fallbackDirection = fallbackDirection; + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('mainAxisExtent', mainAxisExtent)); + properties.add( + DoubleProperty('crossAxisExtent', crossAxisExtent, defaultValue: 0), + ); + properties.add(ColorProperty('color', color)); + properties.add(EnumProperty('fallbackDirection', fallbackDirection)); + } +} + +class RenderGap extends RenderBox { + RenderGap({ + required double mainAxisExtent, + double? crossAxisExtent, + Axis? fallbackDirection, + Color? color, + }) : _mainAxisExtent = mainAxisExtent, + _crossAxisExtent = crossAxisExtent, + _color = color, + _fallbackDirection = fallbackDirection; + + double get mainAxisExtent => _mainAxisExtent; + double _mainAxisExtent; + set mainAxisExtent(double value) { + if (_mainAxisExtent != value) { + _mainAxisExtent = value; + markNeedsLayout(); + } + } + + double? get crossAxisExtent => _crossAxisExtent; + double? _crossAxisExtent; + set crossAxisExtent(double? value) { + if (_crossAxisExtent != value) { + _crossAxisExtent = value; + markNeedsLayout(); + } + } + + Axis? get fallbackDirection => _fallbackDirection; + Axis? _fallbackDirection; + set fallbackDirection(Axis? value) { + if (_fallbackDirection != value) { + _fallbackDirection = value; + markNeedsLayout(); + } + } + + Axis? get _direction { + final parentNode = parent; + if (parentNode is RenderFlex) { + return parentNode.direction; + } else { + return fallbackDirection; + } + } + + Color? get color => _color; + Color? _color; + set color(Color? value) { + if (_color != value) { + _color = value; + markNeedsPaint(); + } + } + + @override + double computeMinIntrinsicWidth(double height) { + return _computeIntrinsicExtent( + Axis.horizontal, + () => super.computeMinIntrinsicWidth(height), + )!; + } + + @override + double computeMaxIntrinsicWidth(double height) { + return _computeIntrinsicExtent( + Axis.horizontal, + () => super.computeMaxIntrinsicWidth(height), + )!; + } + + @override + double computeMinIntrinsicHeight(double width) { + return _computeIntrinsicExtent( + Axis.vertical, + () => super.computeMinIntrinsicHeight(width), + )!; + } + + @override + double computeMaxIntrinsicHeight(double width) { + return _computeIntrinsicExtent( + Axis.vertical, + () => super.computeMaxIntrinsicHeight(width), + )!; + } + + double? _computeIntrinsicExtent(Axis axis, double Function() compute) { + final Axis? direction = _direction; + if (direction == axis) { + return _mainAxisExtent; + } else { + if (_crossAxisExtent!.isFinite) { + return _crossAxisExtent; + } else { + return compute(); + } + } + } + + @override + Size computeDryLayout(BoxConstraints constraints) { + final Axis? direction = _direction; + + if (direction != null) { + if (direction == Axis.horizontal) { + return constraints.constrain(Size(mainAxisExtent, crossAxisExtent!)); + } else { + return constraints.constrain(Size(crossAxisExtent!, mainAxisExtent)); + } + } else { + throw FlutterError( + 'A Gap widget must be placed directly inside a Flex widget ' + 'or its fallbackDirection must not be null', + ); + } + } + + @override + void performLayout() { + size = computeDryLayout(constraints); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (color != null) { + final Paint paint = Paint()..color = color!; + context.canvas.drawRect(offset & size, paint); + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DoubleProperty('mainAxisExtent', mainAxisExtent)); + properties.add(DoubleProperty('crossAxisExtent', crossAxisExtent)); + properties.add(ColorProperty('color', color)); + properties.add(EnumProperty('fallbackDirection', fallbackDirection)); + } +} diff --git a/packages/komodo_ui_kit/pubspec.lock b/packages/komodo_ui_kit/pubspec.lock new file mode 100644 index 0000000000..4423bfaba0 --- /dev/null +++ b/packages/komodo_ui_kit/pubspec.lock @@ -0,0 +1,111 @@ +# 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: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + lints: + dependency: transitive + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + meta: + dependency: transitive + description: + name: meta + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + plugin_platform_interface: + dependency: transitive + 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.99" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" +sdks: + dart: ">=3.3.0-0 <4.0.0" + flutter: ">=2.5.0" diff --git a/packages/komodo_ui_kit/pubspec.yaml b/packages/komodo_ui_kit/pubspec.yaml new file mode 100644 index 0000000000..b20c8ab055 --- /dev/null +++ b/packages/komodo_ui_kit/pubspec.yaml @@ -0,0 +1,27 @@ +name: komodo_ui_kit +description: Komodo AtomicDEX's UIKit Flutter package. +publish_to: none + +environment: + sdk: ">=3.0.0 <4.0.0" + +dependencies: + flutter: + sdk: flutter + intl: 0.19.0 # flutter.dev + app_theme: + path: ../../app_theme/ + +dev_dependencies: + flutter_lints: ^2.0.0 # flutter.dev + +flutter: + uses-material-design: true + + assets: + - lib/src/custom_icons/Custom.ttf + + fonts: + - family: Custom + fonts: + - asset: lib/src/custom_icons/Custom.ttf \ No newline at end of file diff --git a/packages/komodo_wallet_build_transformer/.gitignore b/packages/komodo_wallet_build_transformer/.gitignore new file mode 100644 index 0000000000..3a85790408 --- /dev/null +++ b/packages/komodo_wallet_build_transformer/.gitignore @@ -0,0 +1,3 @@ +# https://dart.dev/guides/libraries/private-files +# Created by `dart pub` +.dart_tool/ diff --git a/packages/komodo_wallet_build_transformer/CHANGELOG.md b/packages/komodo_wallet_build_transformer/CHANGELOG.md new file mode 100644 index 0000000000..effe43c82c --- /dev/null +++ b/packages/komodo_wallet_build_transformer/CHANGELOG.md @@ -0,0 +1,3 @@ +## 1.0.0 + +- Initial version. diff --git a/packages/komodo_wallet_build_transformer/README.md b/packages/komodo_wallet_build_transformer/README.md new file mode 100644 index 0000000000..b7639b54b7 --- /dev/null +++ b/packages/komodo_wallet_build_transformer/README.md @@ -0,0 +1 @@ +A sample command-line application providing basic argument parsing with an entrypoint in `bin/`. diff --git a/packages/komodo_wallet_build_transformer/analysis_options.yaml b/packages/komodo_wallet_build_transformer/analysis_options.yaml new file mode 100644 index 0000000000..204f8fb329 --- /dev/null +++ b/packages/komodo_wallet_build_transformer/analysis_options.yaml @@ -0,0 +1,29 @@ +# This file configures the static analysis results for your project (errors, +# warnings, and lints). +# +# This enables the 'recommended' set of lints from `package:lints`. +# This set helps identify many issues that may lead to problems when running +# or consuming Dart code, and enforces writing Dart using a single, idiomatic +# style and format. +# +# If you want a smaller set of lints you can change this to specify +# 'package:lints/core.yaml'. These are just the most critical lints +# (the recommended set includes the core lints). +# The core lints are also what is used by pub.dev for scoring packages. + +include: package:flutter_lints/flutter.yaml + +# Uncomment the following section to specify additional rules. + +linter: + rules: + - require_trailing_commas: true +# analyzer: +# exclude: +# - path/to/excluded/files/** + +# For more information about the core and recommended set of lints, see +# https://dart.dev/go/core-lints + +# For additional information about configuring this file, see +# https://dart.dev/guides/language/analysis-options diff --git a/packages/komodo_wallet_build_transformer/bin/komodo_wallet_build_transformer.dart b/packages/komodo_wallet_build_transformer/bin/komodo_wallet_build_transformer.dart new file mode 100644 index 0000000000..8cf12d79c1 --- /dev/null +++ b/packages/komodo_wallet_build_transformer/bin/komodo_wallet_build_transformer.dart @@ -0,0 +1,186 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; +import 'dart:convert'; +import 'package:args/args.dart'; +import 'package:komodo_wallet_build_transformer/src/build_step.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/copy_platform_assets_build_step.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/fetch_coin_assets_build_step.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/fetch_defi_api_build_step.dart'; + +const String version = '0.0.1'; +const inputOptionName = 'input'; +const outputOptionName = 'output'; + +late final ArgResults _argResults; +final String _projectRoot = Directory.current.path; + +/// Defines the build steps that should be executed. Only the build steps that +/// pass the command line flags will be executed. For Flutter transformers, +/// this is configured in the root project's `pubspec.yaml` file. +/// The steps are executed in the order they are defined in this list. +List _buildStepBootstrapper(Map buildConfig) => [ + // TODO: Refactor to use data model classes instead of Map + + FetchDefiApiStep.withBuildConfig(buildConfig), + FetchCoinAssetsBuildStep.withBuildConfig(buildConfig), + CopyPlatformAssetsBuildStep(projectRoot: _projectRoot), + ]; + +const List _knownBuildStepIds = [ + FetchDefiApiStep.idStatic, + FetchCoinAssetsBuildStep.idStatic, + CopyPlatformAssetsBuildStep.idStatic, +]; + +ArgParser buildParser() { + final parser = ArgParser() + ..addOption(inputOptionName, mandatory: true, abbr: 'i') + ..addOption(outputOptionName, mandatory: true, abbr: 'o') + ..addFlag('concurrent', + abbr: 'c', + negatable: false, + help: 'Run build steps concurrently if possible.') + ..addFlag('help', + abbr: 'h', negatable: false, help: 'Print this usage information.') + ..addFlag('verbose', + abbr: 'v', negatable: false, help: 'Show additional command output.') + ..addFlag('version', negatable: false, help: 'Print the tool version.') + ..addFlag('all', abbr: 'a', negatable: false, help: 'Run all build steps.'); + + for (final id in _knownBuildStepIds) { + parser.addFlag( + id, + negatable: false, + help: + 'Run the $id build step. Must provide at least one build step flag or specify -all.', + ); + } + + return parser; +} + +void printUsage(ArgParser argParser) { + print('Usage: dart komodo_wallet_build_transformer.dart [arguments]'); + print(argParser.usage); +} + +Map loadJsonFile(String path) { + final file = File(path); + if (!file.existsSync()) { + _logMessage('Json file not found: $path', error: true); + throw Exception('Json file not found: $path'); + } + final content = file.readAsStringSync(); + return jsonDecode(content); +} + +void main(List arguments) async { + final ArgParser argParser = buildParser(); + try { + _argResults = argParser.parse(arguments); + + if (_argResults.flag('help')) { + printUsage(argParser); + return; + } + if (_argResults.flag('version')) { + _logMessage('komodo_wallet_build_transformer version: $version'); + return; + } + + final canRunConcurrent = _argResults.flag('concurrent'); + // final configFile = File('$_projectRoot/app_build/build_config.json'); + final configFile = File(_argResults.option('input')!); + if (!configFile.existsSync()) { + throw Exception( + 'Config file not found: ${configFile.path}. Trying project asset folder...'); + } + final config = json.decode(configFile.readAsStringSync()); + + final steps = _buildStepBootstrapper(config); + + if (steps.length != _knownBuildStepIds.length) { + throw Exception('Mismatch between build steps and known build step ids'); + } + + final buildStepFutures = steps + .where((step) => _argResults.flag('all') || _argResults.flag(step.id)) + .map((step) => _runStep(step, config)); + + _logMessage('${buildStepFutures.length} build steps to run'); + + if (canRunConcurrent) { + await Future.wait(buildStepFutures); + } else { + for (final future in buildStepFutures) { + await future; + } + } + + _writeSuccessStatus(); + _logMessage('Build steps completed'); + exit(0); + } on FormatException catch (e) { + _logMessage(e.message, error: true); + _logMessage(''); + printUsage(argParser); + exit(64); + } catch (e) { + _logMessage('Error running build steps: ${e.toString()}', error: true); + exit(1); + } +} + +Future _runStep(BuildStep step, Map config) async { + final stepName = step.runtimeType.toString(); + + if (await step.canSkip()) { + _logMessage('$stepName: Skipping build step'); + return; + } + + try { + _logMessage('$stepName: Running build step'); + final timer = Stopwatch()..start(); + + await step.build(); + + _logMessage( + '$stepName: Build step completed in ${timer.elapsedMilliseconds}ms'); + } catch (e) { + _logMessage('$stepName: Error running build step: ${e.toString()}', + error: true); + + await step + .revert((e is Exception) ? e : null) + .catchError((revertError) => _logMessage( + '$stepName: Error reverting build step: $revertError', + )); + + rethrow; + } +} + +/// A function that signals the Flutter asset transformer completed +/// successfully by copying the input file to the output file. +/// +/// This is used because Flutter's asset transformers require an output file +/// to be created in order for the step to be considered successful. +/// +/// NB! The input and output file paths do not refer to the file in our +/// project's assets directory, but rather the a copy that is created by +/// Flutter's asset transformer. +/// +void _writeSuccessStatus() { + final input = File(_argResults.option(inputOptionName)!); + final output = File(_argResults.option(outputOptionName)!); + input.copySync(output.path); +} + +// TODO: Consider how the verbose flag should influence logging +void _logMessage(String message, {bool error = false}) { + final prefix = error ? 'ERROR' : 'INFO'; + final output = error ? stderr : stdout; + output.writeln('[$prefix] $message'); +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/build_step.dart b/packages/komodo_wallet_build_transformer/lib/src/build_step.dart new file mode 100644 index 0000000000..98b875f51f --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/build_step.dart @@ -0,0 +1,41 @@ +/// Example usage: +/// +/// class ExampleBuildStep extends BuildStep { +/// @override +/// Future build() async { +/// final File tempFile = File('${tempWorkingDir.path}/temp.txt'); +/// tempFile.createSync(recursive: true); +/// +/// /// Create a demo empty text file in the assets directory. +/// final File newAssetFile = File('${assetsDir.path}/empty.txt'); +/// newAssetFile.createSync(recursive: true); +/// } +/// +/// @override +/// bool canSkip() { +/// return false; +/// } +/// +/// @override +/// Future revert() async { +/// await Future.delayed(Duration.zero); +/// } +/// } +abstract class BuildStep { + /// A unique identifier for this build step. + String get id; + + /// Execute the build step. This should return a future that completes when + /// the build step is done. + Future build(); + + /// Whether this build step can be skipped if the output artifact already + /// exists. E.g. We don't want to re-download a file if we already have the + /// correct version. + Future canSkip(); + + /// Revert the environment to the state it was in before the build step was + /// executed. This will be called internally by the build system if a build + /// step fails. + Future revert([Exception? e]); +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/build_progress_message.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/build_progress_message.dart new file mode 100644 index 0000000000..c512982cef --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/build_progress_message.dart @@ -0,0 +1,27 @@ +/// Represents a build progress message. +class BuildProgressMessage { + /// Creates a new instance of [BuildProgressMessage]. + /// + /// The [message] parameter represents the message of the progress. + /// The [progress] parameter represents the progress value. + /// The [success] parameter indicates whether the progress was successful or not. + /// The [finished] parameter indicates whether the progress is finished. + const BuildProgressMessage({ + required this.message, + required this.progress, + required this.success, + this.finished = false, + }); + + /// The message of the progress. + final String message; + + /// Indicates whether the progress was successful or not. + final bool success; + + /// The progress value (percentage). + final double progress; + + /// Indicates whether the progress is finished. + final bool finished; +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/coin_ci_config.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/coin_ci_config.dart new file mode 100644 index 0000000000..10f1850363 --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/coin_ci_config.dart @@ -0,0 +1,163 @@ +// ignore_for_file: avoid_print + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/github_file_downloader.dart'; +import 'package:path/path.dart' as path; + +/// Represents the build configuration for fetching coin assets. +class CoinCIConfig { + /// Creates a new instance of [CoinCIConfig]. + CoinCIConfig({ + required this.bundledCoinsRepoCommit, + required this.updateCommitOnBuild, + required this.coinsRepoApiUrl, + required this.coinsRepoContentUrl, + required this.coinsRepoBranch, + required this.runtimeUpdatesEnabled, + required this.mappedFiles, + required this.mappedFolders, + }); + + /// Creates a new instance of [CoinCIConfig] from a JSON object. + factory CoinCIConfig.fromJson(Map json) { + return CoinCIConfig( + updateCommitOnBuild: json['update_commit_on_build'] as bool, + bundledCoinsRepoCommit: json['bundled_coins_repo_commit'].toString(), + coinsRepoApiUrl: json['coins_repo_api_url'].toString(), + coinsRepoContentUrl: json['coins_repo_content_url'].toString(), + coinsRepoBranch: json['coins_repo_branch'].toString(), + runtimeUpdatesEnabled: json['runtime_updates_enabled'] as bool, + mappedFiles: Map.from( + json['mapped_files'] as Map, + ), + mappedFolders: Map.from( + json['mapped_folders'] as Map, + ), + ); + } + + /// The commit hash or branch coins repository to use when fetching coin + /// assets. + final String bundledCoinsRepoCommit; + + /// Indicates whether the commit hash should be updated on build. If `true`, + /// the commit hash will be updated and saved to the build configuration file. + /// If `false`, the commit hash will not be updated and the configured commit + /// hash will be used. + final bool updateCommitOnBuild; + + /// The GitHub API of the coins repository used to fetch directory contents + /// with SHA hashes from the GitHub API. + final String coinsRepoApiUrl; + + /// The raw content GitHub URL of the coins repository used to fetch assets. + final String coinsRepoContentUrl; + + /// The branch of the coins repository to use for fetching assets. + final String coinsRepoBranch; + + /// Indicates whether runtime updates of the coins assets are enabled. + /// + /// NB: This does not affect the build process. + final bool runtimeUpdatesEnabled; + + /// A map of mapped files to download. + /// The keys represent the local paths where the files will be saved, + /// and the values represent the relative paths of the files in the repository + final Map mappedFiles; + + /// A map of mapped folders to download. The keys represent the local paths + /// where the folders will be saved, and the values represent the + /// corresponding paths in the GitHub repository. + final Map mappedFolders; + + CoinCIConfig copyWith({ + String? bundledCoinsRepoCommit, + bool? updateCommitOnBuild, + String? coinsRepoApiUrl, + String? coinsRepoContentUrl, + String? coinsRepoBranch, + bool? runtimeUpdatesEnabled, + Map? mappedFiles, + Map? mappedFolders, + }) { + return CoinCIConfig( + updateCommitOnBuild: updateCommitOnBuild ?? this.updateCommitOnBuild, + bundledCoinsRepoCommit: + bundledCoinsRepoCommit ?? this.bundledCoinsRepoCommit, + coinsRepoApiUrl: coinsRepoApiUrl ?? this.coinsRepoApiUrl, + coinsRepoContentUrl: coinsRepoContentUrl ?? this.coinsRepoContentUrl, + coinsRepoBranch: coinsRepoBranch ?? this.coinsRepoBranch, + runtimeUpdatesEnabled: + runtimeUpdatesEnabled ?? this.runtimeUpdatesEnabled, + mappedFiles: mappedFiles ?? this.mappedFiles, + mappedFolders: mappedFolders ?? this.mappedFolders, + ); + } + + /// Converts the [CoinCIConfig] instance to a JSON object. + Map toJson() => { + 'update_commit_on_build': updateCommitOnBuild, + 'bundled_coins_repo_commit': bundledCoinsRepoCommit, + 'coins_repo_api_url': coinsRepoApiUrl, + 'coins_repo_content_url': coinsRepoContentUrl, + 'coins_repo_branch': coinsRepoBranch, + 'runtime_updates_enabled': runtimeUpdatesEnabled, + 'mapped_files': mappedFiles, + 'mapped_folders': mappedFolders, + }; + + /// Loads the coins runtime update configuration synchronously from the specified [path]. + /// + /// Prints the path from which the configuration is being loaded. + /// Reads the contents of the file at the specified path and decodes it as JSON. + /// If the 'coins' key is not present in the decoded data, prints an error message and exits with code 1. + /// Returns a [CoinCIConfig] object created from the decoded 'coins' data. + static CoinCIConfig loadSync(String path) { + print('Loading coins updates config from $path'); + + try { + final File file = File(path); + final String contents = file.readAsStringSync(); + final Map data = + jsonDecode(contents) as Map; + + return CoinCIConfig.fromJson(data['coins']); + } catch (e) { + print('Error loading coins updates config: $e'); + throw Exception('Error loading coins update config'); + } + } + + /// Saves the coins configuration to the specified asset path and optionally updates the build configuration file. + /// + /// The [assetPath] parameter specifies the path where the coins configuration will be saved. + /// The [updateBuildConfig] parameter indicates whether to update the build configuration file or not. + /// + /// If [updateBuildConfig] is `true`, the coins configuration will also be saved to the build configuration file specified by [buildConfigPath]. + /// + /// If [originalBuildConfig] is provided, the coins configuration will be merged with the original build configuration before saving. + /// + /// Throws an exception if any error occurs during the saving process. + Future save({ + required String assetPath, + Map? originalBuildConfig, + }) async { + final List foldersToCreate = [ + path.dirname(assetPath), + ]; + createFolders(foldersToCreate); + + final mergedConfig = (originalBuildConfig ?? {}) + ..addAll({'coins': toJson()}); + + print('Saving coin assets config to $assetPath'); + const encoder = JsonEncoder.withIndent(" "); + + final String data = encoder.convert(mergedConfig); + await File(assetPath).writeAsString(data, flush: true); + } +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_download_event.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_download_event.dart new file mode 100644 index 0000000000..7c1ab31e39 --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_download_event.dart @@ -0,0 +1,11 @@ +/// Enum representing the events that can occur during a GitHub download. +enum GitHubDownloadEvent { + /// The download was successful. + downloaded, + + /// The download was skipped. + skipped, + + /// The download failed. + failed, +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_file.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_file.dart new file mode 100644 index 0000000000..cf3084b057 --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_file.dart @@ -0,0 +1,78 @@ +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/links.dart'; + +/// Represents a file on GitHub. +class GitHubFile { + /// Creates a new instance of [GitHubFile]. + const GitHubFile({ + required this.name, + required this.path, + required this.sha, + required this.size, + this.url, + this.htmlUrl, + this.gitUrl, + required this.downloadUrl, + required this.type, + this.links, + }); + + /// Creates a new instance of [GitHubFile] from a JSON map. + factory GitHubFile.fromJson(Map data) => GitHubFile( + name: data['name'] as String, + path: data['path'] as String, + sha: data['sha'] as String, + size: data['size'] as int, + url: data['url'] as String?, + htmlUrl: data['html_url'] as String?, + gitUrl: data['git_url'] as String?, + downloadUrl: data['download_url'] as String, + type: data['type'] as String, + links: data['_links'] == null + ? null + : Links.fromJson(data['_links'] as Map), + ); + + /// Converts the [GitHubFile] instance to a JSON map. + Map toJson() => { + 'name': name, + 'path': path, + 'sha': sha, + 'size': size, + 'url': url, + 'html_url': htmlUrl, + 'git_url': gitUrl, + 'download_url': downloadUrl, + 'type': type, + '_links': links?.toJson(), + }; + + /// The name of the file. + final String name; + + /// The path of the file. + final String path; + + /// The SHA value of the file. + final String sha; + + /// The size of the file in bytes. + final int size; + + /// The URL of the file. + final String? url; + + /// The HTML URL of the file. + final String? htmlUrl; + + /// The Git URL of the file. + final String? gitUrl; + + /// The download URL of the file. + final String downloadUrl; + + /// The type of the file. + final String type; + + /// The links associated with the file. + final Links? links; +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_file_download_event.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_file_download_event.dart new file mode 100644 index 0000000000..10c8d57c7a --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_file_download_event.dart @@ -0,0 +1,19 @@ +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/github_download_event.dart'; + +/// Represents an event for downloading a GitHub file. +/// +/// This event contains information about the download event and the local path where the file will be saved. +/// Represents an event for downloading a GitHub file. +class GitHubFileDownloadEvent { + /// Creates a new [GitHubFileDownloadEvent] with the specified [event] and [localPath]. + GitHubFileDownloadEvent({ + required this.event, + required this.localPath, + }); + + /// The download event. + final GitHubDownloadEvent event; + + /// The local path where the file will be saved. + final String localPath; +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_file_downloader.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_file_downloader.dart new file mode 100644 index 0000000000..160e01dbed --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/github_file_downloader.dart @@ -0,0 +1,410 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/build_progress_message.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/github_download_event.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/github_file.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/github_file_download_event.dart'; +import 'package:path/path.dart' as path; + +/// A class that handles downloading files from a GitHub repository. +class GitHubFileDownloader { + /// The [GitHubFileDownloader] class requires the [repoApiUrl] and [repoContentUrl] + /// parameters to be provided during initialization. These parameters specify the + /// API URL and content URL of the GitHub repository from which files will be downloaded. + GitHubFileDownloader({ + required this.repoApiUrl, + required this.repoContentUrl, + this.sendPort, + }); + + final String repoApiUrl; + final String repoContentUrl; + final SendPort? sendPort; + + int _totalFiles = 0; + int _downloadedFiles = 0; + int _skippedFiles = 0; + + double get progress => + ((_downloadedFiles + _skippedFiles) / _totalFiles) * 100; + String get progressMessage => 'Progress: ${progress.toStringAsFixed(2)}%'; + String get downloadStats => + 'Downloaded $_downloadedFiles files, skipped $_skippedFiles files'; + + Future download( + String repoCommit, + Map mappedFiles, + Map mappedFolders, + ) async { + await downloadMappedFiles(repoCommit, mappedFiles); + await downloadMappedFolders(repoCommit, mappedFolders); + } + + /// Retrieves the latest commit hash for a given branch from the repository API. + /// + /// The [branch] parameter specifies the branch name for which to retrieve the latest commit hash. + /// By default, it is set to 'master'. + /// + /// Returns a [Future] that completes with a [String] representing the latest commit hash. + Future getLatestCommitHash({ + String branch = 'master', + }) async { + final String apiUrl = '$repoApiUrl/commits/$branch'; + final http.Response response = await http.get(Uri.parse(apiUrl)); + final Map data = + jsonDecode(response.body) as Map; + return data['sha'] as String; + } + + /// Downloads and saves multiple files from a remote repository. + /// + /// The [repoCommit] parameter specifies the commit hash of the repository. + /// The [mappedFiles] parameter is a map where the keys represent the local paths + /// where the files will be saved, and the values represent the relative paths + /// of the files in the repository. + /// + /// This method creates the necessary folders for the local paths and then + /// iterates over each entry in the [mappedFiles] map. For each entry, it + /// retrieves the file content from the remote repository using the provided + /// commit hash and relative path, and saves it to the corresponding local path. + /// + /// Throws an exception if any error occurs during the download or file saving process. + Future downloadMappedFiles( + String repoCommit, + Map mappedFiles, + ) async { + _totalFiles += mappedFiles.length; + + createFolders(mappedFiles.keys.toList()); + for (final MapEntry entry in mappedFiles.entries) { + final String localPath = entry.key; + final Uri fileContentUrl = + Uri.parse('$repoContentUrl/$repoCommit/${entry.value}'); + final http.Response fileContent = await http.get(fileContentUrl); + await File(localPath).writeAsString(fileContent.body); + + _downloadedFiles++; + sendPort?.send( + BuildProgressMessage( + message: 'Downloading file: $localPath', + progress: progress, + success: true, + ), + ); + } + } + + /// Downloads the mapped folders from a GitHub repository at a specific commit. + /// + /// The [repoCommit] parameter specifies the commit hash of the repository. + /// The [mappedFolders] parameter is a map where the keys represent the local paths + /// where the files will be downloaded, and the values represent the corresponding + /// paths in the GitHub repository. + /// The [timeout] parameter specifies the maximum duration for the download operation. + /// + /// This method iterates over each entry in the [mappedFolders] map and creates the + /// necessary local folders. Then, it retrieves the list of files in the GitHub + /// repository at the specified [repoPath] and [repoCommit]. For each file, it + /// initiates a download using the [downloadFile] method. The downloads are executed + /// concurrently using [Future.wait]. + /// + /// Throws an exception if any of the download operations fail. + Future downloadMappedFolders( + String repoCommit, + Map mappedFolders, { + Duration timeout = const Duration(seconds: 60), + }) async { + final Map> folderContents = + await _getMappedFolderContents(mappedFolders, repoCommit); + + for (final MapEntry> entry + in folderContents.entries) { + await _downloadFolderContents(entry.key, entry.value); + } + + sendPort?.send( + const BuildProgressMessage( + message: '\nDownloaded all files', + progress: 100, + success: true, + finished: true, + ), + ); + } + + Future _downloadFolderContents( + String key, + List value, + ) async { + await for (final GitHubFileDownloadEvent event + in downloadFiles(value, key)) { + switch (event.event) { + case GitHubDownloadEvent.downloaded: + _downloadedFiles++; + sendProgressMessage( + 'Downloading file: ${event.localPath}', + success: true, + ); + case GitHubDownloadEvent.skipped: + _skippedFiles++; + sendProgressMessage( + 'Skipped file: ${event.localPath}', + success: true, + ); + case GitHubDownloadEvent.failed: + sendProgressMessage( + 'Failed to download file: ${event.localPath}', + ); + } + } + } + + Future>> _getMappedFolderContents( + Map mappedFolders, + String repoCommit, + ) async { + final Map> folderContents = {}; + + for (final MapEntry entry in mappedFolders.entries) { + createFolders(mappedFolders.keys.toList()); + final String localPath = entry.key; + final String repoPath = entry.value; + final List coins = + await getGitHubDirectoryContents(repoPath, repoCommit); + + _totalFiles += coins.length; + folderContents[localPath] = coins; + } + return folderContents; + } + + /// Retrieves the contents of a GitHub directory for a given repository and commit. + /// + /// The [repoPath] parameter specifies the path of the directory within the repository. + /// The [repoCommit] parameter specifies the commit hash or branch name. + /// + /// Returns a [Future] that completes with a list of [GitHubFile] objects representing the files in the directory. + Future> getGitHubDirectoryContents( + String repoPath, + String repoCommit, + ) async { + final Map headers = { + 'Accept': 'application/vnd.github.v3+json', + }; + final String apiUrl = '$repoApiUrl/contents/$repoPath?ref=$repoCommit'; + + final http.Request req = http.Request('GET', Uri.parse(apiUrl)); + req.headers.addAll(headers); + final http.StreamedResponse response = await http.Client().send(req); + final String respString = await response.stream.bytesToString(); + final List data = jsonDecode(respString) as List; + + return data + .where( + (dynamic item) => (item as Map)['type'] == 'file', + ) + .map( + (dynamic file) => GitHubFile.fromJson(file as Map), + ) + .toList(); + } + + /// Sends a progress message to the specified [sendPort]. + /// + /// The [message] parameter is the content of the progress message. + /// The [success] parameter indicates whether the progress was successful or not. + void sendProgressMessage(String message, {bool success = false}) { + sendPort?.send( + BuildProgressMessage( + message: message, + progress: progress, + success: success, + ), + ); + } + + /// Downloads a file from GitHub. + /// + /// This method takes a [GitHubFile] object and a [localDir] path as input, + /// and downloads the file to the specified local directory. + /// + /// If the file already exists locally and has the same SHA as the GitHub file, + /// the download is skipped and a [GitHubFileDownloadEvent] with the event type + /// [GitHubDownloadEvent.skipped] is returned. + /// + /// If the file does not exist locally or has a different SHA, the file is downloaded + /// from the GitHub URL specified in the [GitHubFile] object. The downloaded file + /// is saved to the local directory and a [GitHubFileDownloadEvent] with the event type + /// [GitHubDownloadEvent.downloaded] is returned. + /// + /// If an error occurs during the download process, an exception is thrown. + /// + /// Returns a [GitHubFileDownloadEvent] object containing the event type and the + /// local path of the downloaded file. + static Future downloadFile( + GitHubFile item, + String localDir, + ) async { + final String coinName = path.basenameWithoutExtension(item.name); + final String outputPath = path.join(localDir, item.name); + + final File localFile = File(outputPath); + if (localFile.existsSync()) { + final String localFileSha = calculateGithubSha1(outputPath); + if (localFileSha == item.sha) { + return GitHubFileDownloadEvent( + event: GitHubDownloadEvent.skipped, + localPath: outputPath, + ); + } + } + + try { + final String fileResponse = await http.read(Uri.parse(item.downloadUrl)); + await File(outputPath).writeAsBytes(fileResponse.codeUnits); + return GitHubFileDownloadEvent( + event: GitHubDownloadEvent.downloaded, + localPath: outputPath, + ); + } catch (e) { + stderr.writeln('Failed to download icon for $coinName: $e'); + rethrow; + } + } + + /// Downloads multiple files from GitHub and yields download events. + /// + /// Given a list of [files] and a [localDir], this method downloads each file + /// and yields a [GitHubFileDownloadEvent] for each file. The [GitHubFileDownloadEvent] + /// contains information about the download event, such as whether the file was + /// successfully downloaded or skipped, and the [localPath] where the file was saved. + /// + /// Example usage: + /// ```dart + /// List files = [...]; + /// String localDir = '/path/to/local/directory'; + /// Stream downloadStream = downloadFiles(files, localDir); + /// await for (GitHubFileDownloadEvent event in downloadStream) { + /// } + /// ``` + static Stream downloadFiles( + List files, + String localDir, + ) async* { + for (final GitHubFile file in files) { + yield await downloadFile(file, localDir); + } + } + + /// Reverts the changes made to a Git file at the specified [filePath]. + /// Returns `true` if the changes were successfully reverted, `false` otherwise. + static Future revertChangesToGitFile(String filePath) async { + final ProcessResult result = + await Process.run('git', ['checkout', filePath]); + + if (result.exitCode != 0) { + stderr.writeln('Failed to revert changes to $filePath'); + return false; + } else { + stdout.writeln('Reverted changes to $filePath'); + return true; + } + } + + /// Reverts changes made to a Git file or deletes it if it exists. + /// + /// This method takes a [filePath] as input and reverts any changes made to the Git file located at that path. + /// If the file does not exist or the revert operation fails, the file is deleted. + /// + /// Example usage: + /// ```dart + /// await revertOrDeleteGitFile('/Users/francois/Repos/komodo/komodo-wallet-archive/app_build/fetch_coin_assets.dart'); + /// ``` + static Future revertOrDeleteGitFile(String filePath) async { + final bool result = await revertChangesToGitFile(filePath); + if (!result && File(filePath).existsSync()) { + stdout.writeln('Deleting $filePath'); + await File(filePath).delete(); + } + } + + /// Reverts or deletes the specified git files. + /// + /// This method takes a list of file paths and iterates over each path, + /// calling the [revertOrDeleteGitFile] method to revert or delete the file. + /// + /// Example usage: + /// ```dart + /// List filePaths = ['/path/to/file1', '/path/to/file2']; + /// await revertOrDeleteGitFiles(filePaths); + /// ``` + static Future revertOrDeleteGitFiles(List filePaths) async { + for (final String filePath in filePaths) { + await revertOrDeleteGitFile(filePath); + } + } +} + +/// Creates folders based on the provided list of folder paths. +/// +/// If a folder path includes a file extension, the parent directory of the file +/// will be used instead. The function creates the folders if they don't already exist. +/// +/// Example: +/// ```dart +/// List folders = ['/path/to/folder1', '/path/to/folder2/file.txt']; +/// createFolders(folders); +/// ``` +void createFolders(List folders) { + for (String folder in folders) { + if (path.extension(folder).isNotEmpty) { + folder = path.dirname(folder); + } + + final Directory dir = Directory(folder); + if (!dir.existsSync()) { + dir.createSync(recursive: true); + } + } +} + +/// Calculates the SHA-1 hash value of a file. +/// +/// Reads the contents of the file at the given [filePath] and calculates +/// the SHA-1 hash value using the `sha1` algorithm. Returns the hash value +/// as a string. +/// +/// Throws an exception if the file cannot be read or if an error occurs +/// during the hashing process. +Future calculateFileSha1(String filePath) async { + final Uint8List bytes = await File(filePath).readAsBytes(); + final Digest digest = sha1.convert(bytes); + return digest.toString(); +} + +/// Calculates the SHA-1 hash of a list of bytes. +/// +/// Takes a [bytes] parameter, which is a list of integers representing the bytes. +/// Returns the SHA-1 hash as a string. +String calculateBlobSha1(List bytes) { + final Digest digest = sha1.convert(bytes); + return digest.toString(); +} + +/// Calculates the SHA1 hash of a file located at the given [filePath]. +/// +/// The function reads the file as bytes, encodes it as a blob, and then calculates +/// the SHA1 hash of the blob. The resulting hash is returned as a string. +String calculateGithubSha1(String filePath) { + final Uint8List bytes = File(filePath).readAsBytesSync(); + final List blob = + utf8.encode('blob ${bytes.length}${String.fromCharCode(0)}') + bytes; + final String digest = calculateBlobSha1(blob); + return digest; +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/links.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/links.dart new file mode 100644 index 0000000000..4c2a552800 --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/links.dart @@ -0,0 +1,29 @@ +// ignore_for_file: avoid_print, unreachable_from_main +/// Represents the links associated with a GitHub file resource. +class Links { + /// Creates a new instance of the [Links] class. + const Links({this.self, this.git, this.html}); + + /// Creates a new instance of the [Links] class from a JSON map. + factory Links.fromJson(Map data) => Links( + self: data['self'] as String?, + git: data['git'] as String?, + html: data['html'] as String?, + ); + + /// Converts the [Links] instance to a JSON map. + Map toJson() => { + 'self': self, + 'git': git, + 'html': html, + }; + + /// The self link. + final String? self; + + /// The git link. + final String? git; + + /// The HTML link. + final String? html; +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/result.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/result.dart new file mode 100644 index 0000000000..8b622ba71b --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/coin_assets/result.dart @@ -0,0 +1,20 @@ +/// Represents the result of an operation. +class Result { + /// Creates a [Result] object with the specified success status and optional error message. + const Result({ + required this.success, + this.error, + }); + + /// Creates a [Result] object indicating a successful operation. + factory Result.success() => const Result(success: true); + + /// Creates a [Result] object indicating a failed operation with the specified error message. + factory Result.error(String error) => Result(success: false, error: error); + + /// Indicates whether the operation was successful. + final bool success; + + /// The error message associated with a failed operation, or null if the operation was successful. + final String? error; +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/copy_platform_assets_build_step.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/copy_platform_assets_build_step.dart new file mode 100644 index 0000000000..d6bf36c66b --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/copy_platform_assets_build_step.dart @@ -0,0 +1,114 @@ +// ignore_for_file: avoid_print + +import 'dart:io'; +import 'package:komodo_wallet_build_transformer/src/build_step.dart'; +import 'package:path/path.dart' as path; + +/// A build step that copies platform-specific assets to the build directory +/// which aren't copied as part of the native build configuration and Flutter's +/// asset configuration. +/// +/// Prefer using the native build configurations over this build step +/// when possible. +class CopyPlatformAssetsBuildStep extends BuildStep { + CopyPlatformAssetsBuildStep({ + required this.projectRoot, + // required this.buildPlatform, + }); + + final String projectRoot; + // final String buildPlatform; + + @override + final String id = idStatic; + + static const idStatic = 'copy_platform_assets'; + + @override + Future build() async { + // TODO: add conditional logic for copying assets based on the target + // platform if this info is made available to the Dart VM. + + // if (buildPlatform == "linux") { + await _copyLinuxAssets(); + // } + } + + @override + Future canSkip() { + return Future.value(_canSkipLinuxAssets()); + } + + @override + Future revert([Exception? e]) async { + _revertLinuxAssets(); + } + + Future _copyLinuxAssets() async { + try { + await Future.wait([_destDesktop, _destIcon].map((file) async { + if (!file.parent.existsSync()) { + file.parent.createSync(recursive: true); + } + })); + + _sourceIcon.copySync(_destIcon.path); + _sourceDesktop.copySync(_destDesktop.path); + + print("Copying Linux assets completed"); + } catch (e) { + print("Failed to copy files with error: $e"); + + rethrow; + } + } + + void _revertLinuxAssets() async { + try { + // Done in parallel so that if one fails, the other can still be deleted + await Future.wait([_destIcon, _destDesktop].map((file) => file.delete())); + + print("Copying Linux assets completed"); + } catch (e) { + print("Failed to copy files with error: $e"); + + rethrow; + } + } + + bool _canSkipLinuxAssets() { + return !(_sourceIcon.existsSync() || _sourceDesktop.existsSync()) && + _destIcon.existsSync() && + _destDesktop.existsSync() && + _sourceIcon.lastModifiedSync().isBefore(_destIcon.lastModifiedSync()) && + _sourceDesktop + .lastModifiedSync() + .isBefore(_destDesktop.lastModifiedSync()); + } + + late final File _sourceIcon = + File(path.joinAll([projectRoot, "linux", "KomodoWallet.svg"])); + + late final File _destIcon = File(path.joinAll([ + projectRoot, + "build", + "linux", + "x64", + "release", + "bundle", + "KomodoWallet.svg" + ])); + + late final File _sourceDesktop = + File(path.joinAll([projectRoot, "linux", "KomodoWallet.desktop"])); + + late final File _destDesktop = File(path.joinAll([ + projectRoot, + "build", + "linux", + "x64", + "release", + "bundle", + "KomodoWallet.desktop" + ])); +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/fetch_coin_assets_build_step.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/fetch_coin_assets_build_step.dart new file mode 100644 index 0000000000..b9de38ec8f --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/fetch_coin_assets_build_step.dart @@ -0,0 +1,294 @@ +// ignore_for_file: avoid_print +// TODO(Francois): Change print statements to Log statements + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:http/http.dart' as http; +import 'package:komodo_wallet_build_transformer/src/build_step.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/build_progress_message.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/coin_ci_config.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/github_file.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/github_file_downloader.dart'; +import 'package:komodo_wallet_build_transformer/src/steps/coin_assets/result.dart'; +import 'package:path/path.dart' as path; + +/// Entry point used if invoked as a CLI script. +const String defaultBuildConfigPath = 'app_build/build_config.json'; + +class FetchCoinAssetsBuildStep extends BuildStep { + FetchCoinAssetsBuildStep({ + required this.projectDir, + required this.config, + required this.downloader, + required this.originalBuildConfig, + this.receivePort, + }) { + receivePort?.listen( + (dynamic message) => onProgressData(message, receivePort), + onError: onProgressError, + ); + } + + factory FetchCoinAssetsBuildStep.withBuildConfig( + Map buildConfig, + [ReceivePort? receivePort]) { + final CoinCIConfig config = CoinCIConfig.fromJson(buildConfig['coins']); + final GitHubFileDownloader downloader = GitHubFileDownloader( + repoApiUrl: config.coinsRepoApiUrl, + repoContentUrl: config.coinsRepoContentUrl, + ); + + return FetchCoinAssetsBuildStep( + projectDir: Directory.current.path, + config: config, + downloader: downloader, + originalBuildConfig: buildConfig, + ); + } + + @override + final String id = idStatic; + static const idStatic = 'fetch_coin_assets'; + final Map? originalBuildConfig; + final String projectDir; + final CoinCIConfig config; + final GitHubFileDownloader downloader; + final ReceivePort? receivePort; + + @override + Future build() async { + final alreadyHadCoinAssets = File('assets/config/coins.json').existsSync(); + final isDebugBuild = + (Platform.environment['FLUTTER_BUILD_MODE'] ?? '').toLowerCase() == + 'debug'; + final latestCommitHash = await downloader.getLatestCommitHash( + branch: config.coinsRepoBranch, + ); + CoinCIConfig configWithUpdatedCommit = config; + + if (config.updateCommitOnBuild) { + configWithUpdatedCommit = + config.copyWith(bundledCoinsRepoCommit: latestCommitHash); + await configWithUpdatedCommit.save( + assetPath: defaultBuildConfigPath, + originalBuildConfig: originalBuildConfig, + ); + } + + await downloader.download( + configWithUpdatedCommit.bundledCoinsRepoCommit, + configWithUpdatedCommit.mappedFiles, + configWithUpdatedCommit.mappedFolders, + ); + + final bool wasCommitHashUpdated = config.bundledCoinsRepoCommit != + configWithUpdatedCommit.bundledCoinsRepoCommit; + + if (wasCommitHashUpdated || !alreadyHadCoinAssets) { + const errorMessage = 'Coin assets have been updated. ' + 'Please re-run the build process for the changes to take effect.'; + + // If it's not a debug build and the commit hash was updated, throw an + // exception to indicate that the build process should be re-run. We can + // skip this check for debug builds if we already had coin assets. + if (!isDebugBuild || !alreadyHadCoinAssets) { + stderr.writeln(errorMessage); + receivePort?.close(); + throw StepCompletedWithChangesException(errorMessage); + } + + stdout.writeln('\n[WARN] $errorMessage\n'); + } + + receivePort?.close(); + stdout.writeln('\nCoin assets fetched successfully'); + } + + @override + Future canSkip() async { + final String latestCommitHash = await downloader.getLatestCommitHash( + branch: config.coinsRepoBranch, + ); + + if (latestCommitHash != config.bundledCoinsRepoCommit) { + return false; + } + + if (!await _canSkipMappedFiles(config.mappedFiles)) { + return false; + } + + if (!await _canSkipMappedFolders(config.mappedFolders)) { + return false; + } + + return true; + } + + @override + Future revert([Exception? e]) async { + if (e is StepCompletedWithChangesException) { + print( + 'Step not reverted because the build process was completed with changes', + ); + + return; + } + + // Try `git checkout` to revert changes instead of deleting all files + // because there may be mapped files/folders that are tracked by git + final List mappedFilePaths = config.mappedFiles.keys.toList(); + final List mappedFolderPaths = config.mappedFolders.keys.toList(); + + final Iterable> mappedFolderFilePaths = + mappedFolderPaths.map(_getFilesInFolder); + + final List allFiles = mappedFilePaths + + mappedFolderFilePaths.expand((List x) => x).toList(); + + await GitHubFileDownloader.revertOrDeleteGitFiles(allFiles); + } + + Future _canSkipMappedFiles(Map files) async { + for (final MapEntry mappedFile in files.entries) { + final GitHubFile remoteFile = await _fetchRemoteFileContent(mappedFile); + final Result canSkipFile = await _canSkipFile( + mappedFile.key, + remoteFile, + ); + if (!canSkipFile.success) { + print('Cannot skip build step: ${canSkipFile.error}'); + return false; + } + } + + return true; + } + + Future _canSkipMappedFolders(Map folders) async { + for (final MapEntry mappedFolder in folders.entries) { + final List remoteFolderContents = + await downloader.getGitHubDirectoryContents( + mappedFolder.value, + config.bundledCoinsRepoCommit, + ); + final Result canSkipFolder = await _canSkipDirectory( + mappedFolder.key, + remoteFolderContents, + ); + + if (!canSkipFolder.success) { + print('Cannot skip build step: ${canSkipFolder.error}'); + return false; + } + } + return true; + } + + Future _fetchRemoteFileContent( + MapEntry mappedFile, + ) async { + final Uri fileContentUrl = Uri.parse( + '${config.coinsRepoApiUrl}/contents/${mappedFile.value}?ref=${config.bundledCoinsRepoCommit}', + ); + final http.Response fileContentResponse = await http.get(fileContentUrl); + final GitHubFile fileContent = GitHubFile.fromJson( + jsonDecode(fileContentResponse.body) as Map, + ); + return fileContent; + } + + Future _canSkipFile( + String localFilePath, + GitHubFile remoteFile, + ) async { + final File localFile = File(localFilePath); + + if (!localFile.existsSync()) { + return Result.error( + '$localFilePath does not exist', + ); + } + + final int localFileSize = await localFile.length(); + if (remoteFile.size != localFileSize) { + return Result.error( + '$localFilePath size mismatch: ' + 'remote: ${remoteFile.size}, local: $localFileSize', + ); + } + + final String localFileSha = calculateGithubSha1(localFilePath); + if (localFileSha != remoteFile.sha) { + return Result.error( + '$localFilePath sha mismatch: ' + 'remote: ${remoteFile.sha}, local: $localFileSha', + ); + } + + return Result.success(); + } + + Future _canSkipDirectory( + String directory, + List remoteDirectoryContents, + ) async { + final Directory localFolder = Directory(directory); + + if (!localFolder.existsSync()) { + return Result.error('$directory does not exist'); + } + + for (final GitHubFile remoteFile in remoteDirectoryContents) { + final String localFilePath = path.join(directory, remoteFile.name); + final Result canSkipFile = await _canSkipFile( + localFilePath, + remoteFile, + ); + if (!canSkipFile.success) { + return Result.error('Cannot skip build step: ${canSkipFile.error}'); + } + } + + return Result.success(); + } + + List _getFilesInFolder(String folderPath) { + final Directory localFolder = Directory(folderPath); + final List localFolderContents = + localFolder.listSync(recursive: true); + return localFolderContents + .map((FileSystemEntity file) => file.path) + .toList(); + } +} + +void onProgressError(dynamic error) { + print('\nError: $error'); + + // throw Exception('An error occurred during the coin fetch build step'); +} + +void onProgressData(dynamic message, ReceivePort? recevePort) { + if (message is BuildProgressMessage) { + stdout.write( + '\r${message.message} - Progress: ${message.progress.toStringAsFixed(2)}% \x1b[K', + ); + + if (message.progress == 100 && message.finished) { + recevePort?.close(); + } + } +} + +class StepCompletedWithChangesException implements Exception { + StepCompletedWithChangesException(this.message); + + final String message; + + @override + String toString() => message; +} diff --git a/packages/komodo_wallet_build_transformer/lib/src/steps/fetch_defi_api_build_step.dart b/packages/komodo_wallet_build_transformer/lib/src/steps/fetch_defi_api_build_step.dart new file mode 100644 index 0000000000..5db7bf31e7 --- /dev/null +++ b/packages/komodo_wallet_build_transformer/lib/src/steps/fetch_defi_api_build_step.dart @@ -0,0 +1,533 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; +import 'package:crypto/crypto.dart'; +import 'package:html/parser.dart' as parser; +import 'package:http/http.dart' as http; +import 'package:komodo_wallet_build_transformer/src/build_step.dart'; +import 'package:path/path.dart' as path; + +class FetchDefiApiStep extends BuildStep { + factory FetchDefiApiStep.withBuildConfig(Map buildConfig) { + final apiConfig = buildConfig['api'] as Map; + return FetchDefiApiStep( + projectRoot: Directory.current.path, + apiCommitHash: apiConfig['api_commit_hash'], + platformsConfig: apiConfig['platforms'], + sourceUrls: List.from(apiConfig['source_urls']), + apiBranch: apiConfig['branch'], + enabled: apiConfig['fetch_at_build_enabled'], + ); + } + + FetchDefiApiStep({ + required this.projectRoot, + required this.apiCommitHash, + required this.platformsConfig, + required this.sourceUrls, + required this.apiBranch, + this.selectedPlatform, + this.forceUpdate = false, + this.enabled = true, + }); + + @override + final String id = idStatic; + + static const idStatic = 'fetch_defi_api'; + + final String projectRoot; + final String apiCommitHash; + final Map platformsConfig; + final List sourceUrls; + final String apiBranch; + String? selectedPlatform; + bool forceUpdate; + bool enabled; + + @override + Future build() async { + if (!enabled) { + _logMessage('API update is not enabled in the configuration.'); + return; + } + try { + await updateAPI(); + } catch (e) { + stderr.writeln('Error updating API: $e'); + rethrow; + } + } + + @override + Future canSkip() => Future.value(!enabled); + + @override + Future revert([Exception? e]) async { + _logMessage('Reverting changes made by UpdateAPIStep...'); + } + + Future updateAPI() async { + if (!enabled) { + _logMessage('API update is not enabled in the configuration.'); + return; + } + + final platformsToUpdate = selectedPlatform != null && + platformsConfig.containsKey(selectedPlatform) + ? [selectedPlatform!] + : platformsConfig.keys.toList(); + + for (final platform in platformsToUpdate) { + final progressString = + '${(platformsToUpdate.indexOf(platform) + 1)}/${platformsToUpdate.length}'; + stdout.writeln('====================='); + stdout.writeln('[$progressString] Updating $platform platform...'); + await _updatePlatform(platform, platformsConfig); + stdout.writeln('====================='); + } + _updateDocumentation(); + } + + static const String _overrideEnvName = 'OVERRIDE_DEFI_API_DOWNLOAD'; + + /// If set, the OVERRIDE_DEFI_API_DOWNLOAD environment variable will override + /// any default behavior/configuration. e.g. + /// `flutter build web --release --dart-define=OVERRIDE_DEFI_API_DOWNLOAD=true` + /// or `OVERRIDE_DEFI_API_DOWNLOAD=true && flutter build web --release` + /// + /// If set to true/TRUE/True, the API will be fetched and downloaded on every + /// build, even if it is already up-to-date with the configuration. + /// + /// If set to false/FALSE/False, the API fetching will be skipped, even if + /// the existing API is not up-to-date with the coniguration. + /// + /// If unset, the default behavior will be used. + /// + /// If both the system environment variable and the dart-defined environment + /// variable are set, the dart-defined variable will take precedence. + /// + /// NB! Setting the value to false is not the same as it being unset. + /// If the value is unset, the default behavior will be used. + /// Bear this in mind when setting the value as a system environment variable. + /// + /// See `BUILD_CONFIG_README.md` in `app_build/BUILD_CONFIG_README.md`. + bool? get overrideDefiApiDownload => + const bool.hasEnvironment(_overrideEnvName) + ? const bool.fromEnvironment(_overrideEnvName) + : Platform.environment[_overrideEnvName] != null + ? bool.tryParse(Platform.environment[_overrideEnvName]!, + caseSensitive: false) + : null; + + Future _updatePlatform( + String platform, Map config) async { + final updateMessage = overrideDefiApiDownload != null + ? '${overrideDefiApiDownload! ? 'FORCING' : 'SKIPPING'} update of $platform platform because OVERRIDE_DEFI_API_DOWNLOAD is set to $overrideDefiApiDownload' + : null; + + if (updateMessage != null) { + stdout.writeln(updateMessage); + } + + final destinationFolder = _getPlatformDestinationFolder(platform); + final isOutdated = + await _checkIfOutdated(platform, destinationFolder, config); + + if (!_shouldUpdate(isOutdated)) { + _logMessage('$platform platform is up to date.'); + await _postUpdateActions(platform, destinationFolder); + return; + } + + String? zipFilePath; + for (final sourceUrl in sourceUrls) { + try { + final zipFileUrl = await _findZipFileUrl(platform, config, sourceUrl); + zipFilePath = await _downloadFile(zipFileUrl, destinationFolder); + + if (await _verifyChecksum(zipFilePath, platform)) { + await _extractZipFile(zipFilePath, destinationFolder); + _updateLastUpdatedFile(platform, destinationFolder, zipFilePath); + _logMessage('$platform platform update completed.'); + break; // Exit loop if update is successful + } else { + stdout + .writeln('SHA256 Checksum verification failed for $zipFilePath'); + if (sourceUrl == sourceUrls.last) { + throw Exception( + 'API fetch failed for all source URLs: $sourceUrls', + ); + } + } + } catch (e) { + stdout.writeln('Error updating from source $sourceUrl: $e'); + if (sourceUrl == sourceUrls.last) { + rethrow; + } + } finally { + if (zipFilePath != null) { + try { + File(zipFilePath).deleteSync(); + _logMessage('Deleted zip file $zipFilePath'); + } catch (e) { + _logMessage('Error deleting zip file: $e', error: true); + } + } + } + } + + await _postUpdateActions(platform, destinationFolder); + } + + bool _shouldUpdate(bool isOutdated) { + return overrideDefiApiDownload == true || + (overrideDefiApiDownload != false && (forceUpdate || isOutdated)); + } + + Future _downloadFile(String url, String destinationFolder) async { + _logMessage('Downloading $url...'); + final response = await http.get(Uri.parse(url)); + _checkResponseSuccess(response); + + final zipFileName = path.basename(url); + final zipFilePath = path.join(destinationFolder, zipFileName); + + final directory = Directory(destinationFolder); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + + final zipFile = File(zipFilePath); + try { + await zipFile.writeAsBytes(response.bodyBytes); + } catch (e) { + _logMessage('Error writing file: $e', error: true); + rethrow; + } + + _logMessage('Downloaded $zipFileName'); + return zipFilePath; + } + + Future _verifyChecksum(String filePath, String platform) async { + final validChecksums = List.from( + platformsConfig[platform]['valid_zip_sha256_checksums'], + ); + + _logMessage('validChecksums: $validChecksums'); + + final fileBytes = await File(filePath).readAsBytes(); + final fileSha256Checksum = sha256.convert(fileBytes).toString(); + + if (validChecksums.contains(fileSha256Checksum)) { + stdout.writeln('Checksum validated for $filePath'); + return true; + } else { + stderr.writeln( + 'SHA256 Checksum mismatch for $filePath: expected any of ' + '$validChecksums, got $fileSha256Checksum', + ); + return false; + } + } + + void _updateLastUpdatedFile( + String platform, String destinationFolder, String zipFilePath) { + final lastUpdatedFile = + File(path.join(destinationFolder, '.api_last_updated_$platform')); + final currentTimestamp = DateTime.now().toIso8601String(); + final fileChecksum = + sha256.convert(File(zipFilePath).readAsBytesSync()).toString(); + lastUpdatedFile.writeAsStringSync(json.encode({ + 'api_commit_hash': apiCommitHash, + 'timestamp': currentTimestamp, + 'checksums': [fileChecksum] + })); + stdout.writeln('Updated last updated file for $platform.'); + } + + Future _checkIfOutdated(String platform, String destinationFolder, + Map config) async { + final lastUpdatedFilePath = + path.join(destinationFolder, '.api_last_updated_$platform'); + final lastUpdatedFile = File(lastUpdatedFilePath); + + if (!lastUpdatedFile.existsSync()) { + return true; + } + + try { + final lastUpdatedData = json.decode(lastUpdatedFile.readAsStringSync()); + if (lastUpdatedData['api_commit_hash'] == apiCommitHash) { + final storedChecksums = + List.from(lastUpdatedData['checksums'] ?? []); + final targetChecksums = + List.from(config[platform]['valid_zip_sha256_checksums']); + + if (storedChecksums.toSet().containsAll(targetChecksums)) { + _logMessage("version: $apiCommitHash and SHA256 checksum match."); + return false; + } + } + } catch (e) { + _logMessage( + 'Error reading or parsing .api_last_updated_$platform: $e', + error: true, + ); + lastUpdatedFile.deleteSync(); + rethrow; + } + + return true; + } + + Future _updateWebPackages() async { + _logMessage('Updating Web platform...'); + String npmPath = 'npm'; + if (Platform.isWindows) { + npmPath = path.join('C:', 'Program Files', 'nodejs', 'npm.cmd'); + _logMessage('Using npm path: $npmPath'); + } + final installResult = + await Process.run(npmPath, ['install'], workingDirectory: projectRoot); + if (installResult.exitCode != 0) { + throw Exception('npm install failed: ${installResult.stderr}'); + } + + final buildResult = await Process.run(npmPath, ['run', 'build'], + workingDirectory: projectRoot); + if (buildResult.exitCode != 0) { + throw Exception('npm run build failed: ${buildResult.stderr}'); + } + + _logMessage('Web platform updated successfully.'); + } + + Future _updateLinuxPlatform(String destinationFolder) async { + _logMessage('Updating Linux platform...'); + // Update the file permissions to make it executable. As part of the + // transition from mm2 naming to kdfi, update whichever file is present. + // ignore: unused_local_variable + final binaryNames = ['mm2', 'kdfi'] + .map((e) => path.join(destinationFolder, e)) + .where((filePath) => File(filePath).existsSync()); + if (!Platform.isWindows) { + for (var filePath in binaryNames) { + Process.run('chmod', ['+x', filePath]); + } + } + + _logMessage('Linux platform updated successfully.'); + } + + String _getPlatformDestinationFolder(String platform) { + if (platformsConfig.containsKey(platform)) { + return path.join(projectRoot, platformsConfig[platform]['path']); + } else { + throw ArgumentError('Invalid platform: $platform'); + } + } + + Future _findZipFileUrl( + String platform, Map config, String sourceUrl) async { + if (sourceUrl.startsWith('https://api.github.com/repos/')) { + return await _fetchFromGitHub(platform, config, sourceUrl); + } else { + return await _fetchFromBaseUrl(platform, config, sourceUrl); + } + } + + Future _fetchFromGitHub( + String platform, Map config, String sourceUrl) async { + final repoMatch = RegExp(r'^https://api\.github\.com/repos/([^/]+)/([^/]+)') + .firstMatch(sourceUrl); + if (repoMatch == null) { + throw ArgumentError('Invalid GitHub repository URL: $sourceUrl'); + } + + final owner = repoMatch.group(1)!; + final repo = repoMatch.group(2)!; + final releasesUrl = 'https://api.github.com/repos/$owner/$repo/releases'; + final response = await http.get(Uri.parse(releasesUrl)); + _checkResponseSuccess(response); + + final releases = json.decode(response.body) as List; + final apiVersionShortHash = apiCommitHash.substring(0, 7); + final matchingKeyword = config[platform]['matching_keyword']; + + for (final release in releases) { + final assets = release['assets'] as List; + for (final asset in assets) { + final url = asset['browser_download_url'] as String; + + if (url.contains(matchingKeyword) && + url.contains(apiVersionShortHash)) { + final commitHash = + await _getCommitHashForRelease(release['tag_name'], owner, repo); + if (commitHash == apiCommitHash) { + return url; + } + } + } + } + + throw Exception( + 'Zip file not found for platform $platform in GitHub releases'); + } + + Future _getCommitHashForRelease( + String tag, String owner, String repo) async { + final commitsUrl = 'https://api.github.com/repos/$owner/$repo/commits/$tag'; + final response = await http.get(Uri.parse(commitsUrl)); + _checkResponseSuccess(response); + + final commit = json.decode(response.body); + return commit['sha']; + } + + Future _fetchFromBaseUrl( + String platform, Map config, String sourceUrl) async { + final url = '$sourceUrl/$apiBranch/'; + final response = await http.get(Uri.parse(url)); + _checkResponseSuccess(response); + + final document = parser.parse(response.body); + final matchingKeyword = config[platform]['matching_keyword']; + final extensions = ['.zip']; + final apiVersionShortHash = apiCommitHash.substring(0, 7); + + for (final element in document.querySelectorAll('a')) { + final href = element.attributes['href']; + if (href != null && + href.contains(matchingKeyword) && + extensions.any((extension) => href.endsWith(extension)) && + href.contains(apiVersionShortHash)) { + return '$sourceUrl/$apiBranch/$href'; + } + } + + throw Exception('Zip file not found for platform $platform'); + } + + void _checkResponseSuccess(http.Response response) { + if (response.statusCode != 200) { + throw HttpException( + 'Failed to fetch data: ${response.statusCode} ${response.reasonPhrase}'); + } + } + + Future _postUpdateActions(String platform, String destinationFolder) { + if (platform == 'web') { + return _updateWebPackages(); + } else if (platform == 'linux') { + return _updateLinuxPlatform(destinationFolder); + } + return Future.value(); + } + + Future _extractZipFile( + String zipFilePath, String destinationFolder) async { + try { + // Determine the platform to use the appropriate extraction command + if (Platform.isMacOS || Platform.isLinux) { + // For macOS and Linux, use the `unzip` command + final result = + await Process.run('unzip', [zipFilePath, '-d', destinationFolder]); + if (result.exitCode != 0) { + throw Exception('Error extracting zip file: ${result.stderr}'); + } + } else if (Platform.isWindows) { + // For Windows, use PowerShell's Expand-Archive command + final result = await Process.run('powershell', [ + 'Expand-Archive', + '-Path', + zipFilePath, + '-DestinationPath', + destinationFolder + ]); + if (result.exitCode != 0) { + throw Exception('Error extracting zip file: ${result.stderr}'); + } + } else { + _logMessage( + 'Unsupported platform: ${Platform.operatingSystem}', + error: true, + ); + throw UnsupportedError('Unsupported platform'); + } + _logMessage('Extraction completed.'); + } catch (e) { + _logMessage('Failed to extract zip file: $e'); + } + } + + void _updateDocumentation() { + final documentationFile = File('$projectRoot/docs/UPDATE_API_MODULE.md'); + final content = documentationFile.readAsStringSync().replaceAllMapped( + RegExp(r'(Current api module version is) `([^`]+)`'), + (match) => '${match[1]} `$apiCommitHash`', + ); + documentationFile.writeAsStringSync(content); + _logMessage('Updated API version in documentation.'); + } +} + +late final ArgResults _argResults; + +void main(List arguments) async { + final parser = ArgParser() + ..addOption('platform', abbr: 'p', help: 'Specify the platform to update') + ..addOption('api-version', + abbr: 'a', help: 'Specify the API version to update to') + ..addFlag('force', + abbr: 'f', negatable: false, help: 'Force update the API module') + ..addFlag('help', + abbr: 'h', negatable: false, help: 'Display usage information'); + + _argResults = parser.parse(arguments); + + if (_argResults['help']) { + _logMessage('Usage: dart app_build/build_steps.dart [options]'); + _logMessage(parser.usage); + return; + } + + final projectRoot = Directory.current.path; + final configFile = File('$projectRoot/app_build/build_config.json'); + final config = json.decode(configFile.readAsStringSync()); + + final platform = _argResults.option('platform'); + final apiVersion = + _argResults.option('api-version') ?? config['api']['api_commit_hash']; + final forceUpdate = _argResults.flag('force'); + + final fetchDefiApiStep = FetchDefiApiStep( + projectRoot: projectRoot, + apiCommitHash: apiVersion, + platformsConfig: config['api']['platforms'], + sourceUrls: List.from(config['api']['source_urls']), + apiBranch: config['api']['branch'], + selectedPlatform: platform, + forceUpdate: forceUpdate, + enabled: true, + ); + + await fetchDefiApiStep.build(); + + if (_argResults.wasParsed('api-version')) { + config['api']['api_commit_hash'] = apiVersion; + configFile.writeAsStringSync(json.encode(config)); + } +} + +void _logMessage(String message, {bool error = false}) { + final prefix = error ? 'ERROR' : 'INFO'; + final output = '[$prefix]: $message'; + if (error) { + stderr.writeln(output); + } else { + stdout.writeln(output); + } +} diff --git a/packages/komodo_wallet_build_transformer/pubspec.lock b/packages/komodo_wallet_build_transformer/pubspec.lock new file mode 100644 index 0000000000..6a2de45493 --- /dev/null +++ b/packages/komodo_wallet_build_transformer/pubspec.lock @@ -0,0 +1,426 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "5aaf60d96c4cd00fe7f21594b5ad6a1b699c80a27420f8a837f4d68473ef09e3" + url: "https://pub.dev" + source: hosted + version: "68.0.0" + _macros: + dependency: transitive + description: dart + source: sdk + version: "0.1.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "21f1d3720fd1c70316399d5e2bccaebb415c434592d778cce8acb967b8578808" + url: "https://pub.dev" + source: hosted + version: "6.5.0" + args: + dependency: "direct main" + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + csslib: + dependency: transitive + description: + name: csslib + sha256: "706b5707578e0c1b4b7550f64078f0a0f19dec3f50a178ffae7006b0a9ca58fb" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + html: + dependency: "direct main" + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" + http: + dependency: "direct main" + description: + name: http + sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + url: "https://pub.dev" + source: hosted + version: "0.13.6" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: transitive + description: + name: js + sha256: c1b2e9b5ea78c45e1a0788d29606ba27dc5f71f019f32ca5140f61ef071838cf + url: "https://pub.dev" + source: hosted + version: "0.7.1" + lints: + dependency: "direct dev" + description: + name: lints + sha256: cbf8d4b858bb0134ef3ef87841abdf8d63bfc255c266b7bf6b39daa1085c4290 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + macros: + dependency: transitive + description: + name: macros + sha256: "12e8a9842b5a7390de7a781ec63d793527582398d16ea26c60fed58833c9ae79" + url: "https://pub.dev" + source: hosted + version: "0.1.0-main.0" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + meta: + dependency: transitive + description: + name: meta + sha256: bdb68674043280c3428e9ec998512fb681678676b3c54e773629ffe74419f8c7 + url: "https://pub.dev" + source: hosted + version: "1.15.0" + mime: + dependency: transitive + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + path: + dependency: "direct main" + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "073c147238594ecd0d193f3456a5fe91c4b0abbcc68bf5cd95b36c4e194ac611" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: d11b55850c68c1f6c0cf00eabded4e66c4043feaf6c0d7ce4a36785137df6331 + url: "https://pub.dev" + source: hosted + version: "1.25.5" + test_api: + dependency: transitive + description: + name: test_api + sha256: "2419f20b0c8677b2d67c8ac4d1ac7372d862dc6c460cdbb052b40155408cd794" + url: "https://pub.dev" + source: hosted + version: "0.7.1" + test_core: + dependency: transitive + description: + name: test_core + sha256: "4d070a6bc36c1c4e89f20d353bfd71dc30cdf2bd0e14349090af360a029ab292" + url: "https://pub.dev" + source: hosted + version: "0.6.2" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "7475cb4dd713d57b6f7464c0e13f06da0d535d8b2067e188962a59bac2cf280b" + url: "https://pub.dev" + source: hosted + version: "14.2.2" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "24301d8c293ce6fe327ffe6f59d8fd8834735f0ec36e4fd383ec7ff8a64aa078" + url: "https://pub.dev" + source: hosted + version: "0.1.5" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: a2d56211ee4d35d9b344d9d4ce60f362e4f5d1aafb988302906bd732bc731276 + url: "https://pub.dev" + source: hosted + version: "3.0.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.4.0 <4.0.0" diff --git a/packages/komodo_wallet_build_transformer/pubspec.yaml b/packages/komodo_wallet_build_transformer/pubspec.yaml new file mode 100644 index 0000000000..73d46c37bd --- /dev/null +++ b/packages/komodo_wallet_build_transformer/pubspec.yaml @@ -0,0 +1,21 @@ +name: komodo_wallet_build_transformer +description: A build transformer for Komodo Wallet used for managing all build-time dependencies. +version: 0.0.1 +# repository: https://github.com/my_org/my_repo +publish_to: "none" + +environment: + sdk: ^3.4.0 + +# Add regular dependencies here. +dependencies: + args: ^2.5.0 # dart.dev + http: 0.13.6 # dart.dev + crypto: 3.0.3 # dart.dev + path: ^1.9.0 + + html: ^0.15.4 + +dev_dependencies: + lints: ^3.0.0 + test: ^1.24.0 diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000000..250e705684 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,1350 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7" + url: "https://pub.dev" + source: hosted + version: "67.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "2350805d7afefb0efe7acd325cb19d3ae8ba4039b906eade3807ffb69938a01f" + url: "https://pub.dev" + source: hosted + version: "1.3.33" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + app_theme: + dependency: "direct main" + description: + path: app_theme + relative: true + source: path + version: "0.0.1" + args: + dependency: "direct main" + description: + name: args + sha256: "7cf60b9f0cc88203c5a190b4cd62a99feea42759a7fa695010eb5de1c0b2252a" + url: "https://pub.dev" + source: hosted + version: "2.5.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda" + url: "https://pub.dev" + source: hosted + version: "1.5.3" + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + badges: + dependency: "direct main" + description: + path: "." + ref: "69958a3a2d6d5dd108393832acde6bda06bd10bc" + resolved-ref: "69958a3a2d6d5dd108393832acde6bda06bd10bc" + url: "https://github.com/yako-dev/flutter_badges.git" + source: git + version: "3.1.1" + bip39: + dependency: "direct main" + description: + path: "." + ref: "3633daa2026b98c523ae9a091322be2903f7a8ab" + resolved-ref: "3633daa2026b98c523ae9a091322be2903f7a8ab" + url: "https://github.com/KomodoPlatform/bip39-dart.git" + source: git + version: "1.0.6" + bloc: + dependency: transitive + description: + name: bloc + sha256: "106842ad6569f0b60297619e9e0b1885c2fb9bf84812935490e6c5275777804e" + url: "https://pub.dev" + source: hosted + version: "8.1.4" + bloc_concurrency: + dependency: "direct main" + description: + name: bloc_concurrency + sha256: "456b7a3616a7c1ceb975c14441b3f198bf57d81cb95b7c6de5cb0c60201afcd8" + url: "https://pub.dev" + source: hosted + version: "0.2.5" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + charcode: + dependency: transitive + description: + name: charcode + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" + source: hosted + version: "1.3.1" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + convert: + dependency: transitive + description: + name: convert + sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592" + url: "https://pub.dev" + source: hosted + version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "3945034e86ea203af7a056d98e98e42a5518fff200d6e8e6647e1886b07e936e" + url: "https://pub.dev" + source: hosted + version: "1.8.0" + cross_file: + dependency: "direct main" + description: + name: cross_file + sha256: "0b0036e8cccbfbe0555fd83c1d31a6f30b77a96b598b35a5d36dd41f718695e9" + url: "https://pub.dev" + source: hosted + version: "0.3.3+4" + crypto: + dependency: "direct main" + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" + csslib: + dependency: transitive + description: + name: csslib + sha256: "831883fb353c8bdc1d71979e5b342c7d88acfbc643113c14ae51e2442ea0f20f" + url: "https://pub.dev" + source: hosted + version: "0.17.3" + desktop_webview_window: + dependency: "direct main" + description: + name: desktop_webview_window + sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0" + url: "https://pub.dev" + source: hosted + version: "0.2.3" + dragon_charts_flutter: + dependency: "direct main" + description: + name: dragon_charts_flutter + sha256: "663e73aeae425ec503942bde4ea40caa665c82250e760d20a1df2b89a16ffb3c" + url: "https://pub.dev" + source: hosted + version: "0.1.1-dev.1" + dragon_logs: + dependency: "direct main" + description: + name: dragon_logs + sha256: "00bec36566176f7f6c243142530a1abf22416d728a283949b92408a0d1f6a442" + url: "https://pub.dev" + source: hosted + version: "1.0.3" + easy_localization: + dependency: "direct main" + description: + name: easy_localization + sha256: fa59bcdbbb911a764aa6acf96bbb6fa7a5cf8234354fc45ec1a43a0349ef0201 + url: "https://pub.dev" + source: hosted + version: "3.0.7" + easy_logger: + dependency: transitive + description: + name: easy_logger + sha256: c764a6e024846f33405a2342caf91c62e357c24b02c04dbc712ef232bf30ffb7 + url: "https://pub.dev" + source: hosted + version: "0.0.2" + encrypt: + dependency: "direct main" + description: + path: "." + ref: "3a42d25b0c356606c26a238384b9f2189572d954" + resolved-ref: "3a42d25b0c356606c26a238384b9f2189572d954" + url: "https://github.com/KomodoPlatform/encrypt" + source: git + version: "5.0.2" + equatable: + dependency: "direct main" + description: + path: "." + ref: "2117551ff3054f8edb1a58f63ffe1832a8d25623" + resolved-ref: "2117551ff3054f8edb1a58f63ffe1832a8d25623" + url: "https://github.com/KomodoPlatform/equatable.git" + source: git + version: "2.0.5" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + file_picker: + dependency: "direct main" + description: + path: "." + ref: "85ecbae83eca8d200f869403928d2bf7e6806c67" + resolved-ref: "85ecbae83eca8d200f869403928d2bf7e6806c67" + url: "https://github.com/KomodoPlatform/flutter_file_picker.git" + source: git + version: "5.3.1" + file_system_access_api: + dependency: transitive + description: + name: file_system_access_api + sha256: bcbf061ce180dffcceed9faefab513e87bff1eef38c3ed99cf7c3bbbc65a34e1 + url: "https://pub.dev" + source: hosted + version: "1.1.0" + firebase_analytics: + dependency: "direct main" + description: + name: firebase_analytics + sha256: "51afa4751e8d17d1484c193b7e9759abbae324e1b8f5cc93e2a08daac4d55928" + url: "https://pub.dev" + source: hosted + version: "10.10.5" + firebase_analytics_platform_interface: + dependency: transitive + description: + name: firebase_analytics_platform_interface + sha256: ad7f6b70304e2b81c6079a5830355edc87496527d5b104d34c3e50b5b744da83 + url: "https://pub.dev" + source: hosted + version: "3.10.6" + firebase_analytics_web: + dependency: transitive + description: + name: firebase_analytics_web + sha256: "63ed03d229d1c2ec2b1be037cd4760c3516cc8ecf6598a6b2fb8ca29586bf464" + url: "https://pub.dev" + source: hosted + version: "0.5.7+5" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "372d94ced114b9c40cb85e18c50ac94a7e998c8eec630c50d7aec047847d27bf" + url: "https://pub.dev" + source: hosted + version: "2.31.0" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: c437ae5d17e6b5cc7981cf6fd458a5db4d12979905f9aafd1fea930428a9fe63 + url: "https://pub.dev" + source: hosted + version: "5.0.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: "43d9e951ac52b87ae9cc38ecdcca1e8fa7b52a1dd26a96085ba41ce5108db8e9" + url: "https://pub.dev" + source: hosted + version: "2.17.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_bloc: + dependency: "direct main" + description: + path: "packages/flutter_bloc" + ref: "32d5002fb8b8a1e548fe8021d8468327680875ff" + resolved-ref: "32d5002fb8b8a1e548fe8021d8468327680875ff" + url: "https://github.com/KomodoPlatform/bloc.git" + source: git + version: "8.1.1" + flutter_driver: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_inappwebview: + dependency: "direct main" + description: + name: flutter_inappwebview + sha256: "3e9a443a18ecef966fb930c3a76ca5ab6a7aafc0c7b5e14a4a850cf107b09959" + url: "https://pub.dev" + source: hosted + version: "6.0.0" + flutter_inappwebview_android: + dependency: transitive + description: + name: flutter_inappwebview_android + sha256: d247f6ed417f1f8c364612fa05a2ecba7f775c8d0c044c1d3b9ee33a6515c421 + url: "https://pub.dev" + source: hosted + version: "1.0.13" + flutter_inappwebview_internal_annotations: + dependency: transitive + description: + name: flutter_inappwebview_internal_annotations + sha256: "5f80fd30e208ddded7dbbcd0d569e7995f9f63d45ea3f548d8dd4c0b473fb4c8" + url: "https://pub.dev" + source: hosted + version: "1.1.1" + flutter_inappwebview_ios: + dependency: transitive + description: + name: flutter_inappwebview_ios + sha256: f363577208b97b10b319cd0c428555cd8493e88b468019a8c5635a0e4312bd0f + url: "https://pub.dev" + source: hosted + version: "1.0.13" + flutter_inappwebview_macos: + dependency: transitive + description: + name: flutter_inappwebview_macos + sha256: b55b9e506c549ce88e26580351d2c71d54f4825901666bd6cfa4be9415bb2636 + url: "https://pub.dev" + source: hosted + version: "1.0.11" + flutter_inappwebview_platform_interface: + dependency: transitive + description: + name: flutter_inappwebview_platform_interface + sha256: "545fd4c25a07d2775f7d5af05a979b2cac4fbf79393b0a7f5d33ba39ba4f6187" + url: "https://pub.dev" + source: hosted + version: "1.0.10" + flutter_inappwebview_web: + dependency: transitive + description: + name: flutter_inappwebview_web + sha256: d8c680abfb6fec71609a700199635d38a744df0febd5544c5a020bd73de8ee07 + url: "https://pub.dev" + source: hosted + version: "1.0.8" + flutter_lints: + dependency: "direct dev" + description: + name: flutter_lints + sha256: a25a15ebbdfc33ab1cd26c63a6ee519df92338a9c10f122adda92938253bef04 + url: "https://pub.dev" + source: hosted + version: "2.0.3" + flutter_localizations: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_markdown: + dependency: "direct main" + description: + name: flutter_markdown + sha256: "7b25c10de1fea883f3c4f9b8389506b54053cd00807beab69fd65c8653a2711f" + url: "https://pub.dev" + source: hosted + version: "0.6.14" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "8cf40eebf5dec866a6d1956ad7b4f7016e6c0cc69847ab946833b7d43743809f" + url: "https://pub.dev" + source: hosted + version: "2.0.19" + flutter_slidable: + dependency: "direct main" + description: + name: flutter_slidable + sha256: "673403d2eeef1f9e8483bd6d8d92aae73b1d8bd71f382bc3930f699c731bc27c" + url: "https://pub.dev" + source: hosted + version: "3.1.0" + flutter_svg: + dependency: "direct main" + description: + path: "packages/flutter_svg" + ref: d7b5c23a79dcb5425548879bdb79a5e7f5097ce5 + resolved-ref: d7b5c23a79dcb5425548879bdb79a5e7f5097ce5 + url: "https://github.com/dnfield/flutter_svg.git" + source: git + version: "2.0.7" + flutter_test: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + formz: + dependency: "direct main" + description: + name: formz + sha256: a58eb48d84685b7ffafac1d143bf47d585bf54c7db89fe81c175dfd6e53201c7 + url: "https://pub.dev" + source: hosted + version: "0.7.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + fuchsia_remote_debug_protocol: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: "0e7014b3b7d4dac1ca4d6114f82bf1782ee86745b9b42a92c9289c23d8a0ab63" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + hex: + dependency: transitive + description: + name: hex + sha256: "4e7cd54e4b59ba026432a6be2dd9d96e4c5205725194997193bf871703b82c4a" + url: "https://pub.dev" + source: hosted + version: "0.2.0" + hive: + dependency: "direct main" + description: + path: hive + ref: "470473ffc1ba39f6c90f31ababe0ee63b76b69fe" + resolved-ref: "470473ffc1ba39f6c90f31ababe0ee63b76b69fe" + url: "https://github.com/KomodoPlatform/hive.git" + source: git + version: "2.2.3" + hive_flutter: + dependency: "direct main" + description: + path: hive_flutter + ref: "0cbaab793be77b19b4740bc85d7ea6461b9762b4" + resolved-ref: "0cbaab793be77b19b4740bc85d7ea6461b9762b4" + url: "https://github.com/KomodoPlatform/hive.git" + source: git + version: "1.1.0" + html: + dependency: transitive + description: + name: html + sha256: "3a7812d5bcd2894edf53dfaf8cd640876cf6cef50a8f238745c8b8120ea74d3a" + url: "https://pub.dev" + source: hosted + version: "0.15.4" + http: + dependency: "direct main" + description: + name: http + sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2" + url: "https://pub.dev" + source: hosted + version: "0.13.6" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: "97486f20f9c2f7be8f514851703d0119c3596d14ea63227af6f7a481ef2b2f8b" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.dev" + source: hosted + version: "4.0.2" + integration_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + intl: + dependency: "direct main" + description: + name: intl + sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + url: "https://pub.dev" + source: hosted + version: "0.19.0" + io: + dependency: transitive + description: + name: io + sha256: "2ec25704aba361659e10e3e5f5d672068d332fc8ac516421d483a11e5cbd061e" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + js: + dependency: "direct main" + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" + komodo_cex_market_data: + dependency: "direct main" + description: + path: "packages/komodo_cex_market_data" + relative: true + source: path + version: "0.0.1" + komodo_coin_updates: + dependency: "direct main" + description: + path: "packages/komodo_coin_updates" + relative: true + source: path + version: "1.0.0" + komodo_persistence_layer: + dependency: "direct main" + description: + path: "packages/komodo_persistence_layer" + relative: true + source: path + version: "0.0.1" + komodo_ui_kit: + dependency: "direct main" + description: + path: "packages/komodo_ui_kit" + relative: true + source: path + version: "0.0.0" + komodo_wallet_build_transformer: + dependency: "direct dev" + description: + path: "packages/komodo_wallet_build_transformer" + relative: true + source: path + version: "0.0.1" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + lints: + dependency: transitive + description: + name: lints + sha256: "0a217c6c989d21039f1498c3ed9f3ed71b354e69873f13a8dfc3c9fe76f1b452" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + logging: + dependency: transitive + description: + name: logging + sha256: "623a88c9594aa774443aa3eb2d41807a48486b5613e67599fb4c41c0ad47c340" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + markdown: + dependency: transitive + description: + name: markdown + sha256: ef2a1298144e3f985cc736b22e0ccdaf188b5b3970648f2d9dc13efd1d9df051 + url: "https://pub.dev" + source: hosted + version: "7.2.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + meta: + dependency: transitive + description: + name: meta + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + mime: + dependency: transitive + description: + name: mime + sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + url: "https://pub.dev" + source: hosted + version: "1.0.5" + mobile_scanner: + dependency: "direct main" + description: + name: mobile_scanner + sha256: b8c0e9afcfd52534f85ec666f3d52156f560b5e6c25b1e3d4fe2087763607926 + url: "https://pub.dev" + source: hosted + version: "5.1.1" + nested: + dependency: transitive + description: + name: nested + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" + source: hosted + version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + package_config: + dependency: transitive + description: + name: package_config + sha256: "1c5b77ccc91e4823a5af61ee74e6b972db1ef98c2ff5a18d3161c982a55448bd" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + package_info_plus: + dependency: "direct main" + description: + path: "packages/package_info_plus/package_info_plus" + ref: "263469d796d769cb73b655fa8c97d4985bce5029" + resolved-ref: "263469d796d769cb73b655fa8c97d4985bce5029" + url: "https://github.com/KomodoPlatform/plus_plugins.git" + source: git + version: "4.0.2" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "9bc8ba46813a4cc42c66ab781470711781940780fd8beddd0c3da62506d3a6c6" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: e3e67b1629e6f7e8100b367d3db6ba6af4b1f0bb80f64db18ef1fbabd2fa9ccf + url: "https://pub.dev" + source: hosted + version: "1.0.1" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: a1aa8aaa2542a6bc57e381f132af822420216c80d4781f7aa085ca3229208aaa + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: a248d8146ee5983446bf03ed5ea8f6533129a12b11f12057ad1b4a67a2b3b41d + url: "https://pub.dev" + source: hosted + version: "2.2.4" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: f234384a3fdd67f989b4d54a5d73ca2a6c422fa55ae694381ae0f4375cd1ea16 + url: "https://pub.dev" + source: hosted + version: "2.4.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "94b1e0dd80970c1ce43d5d4e050a9918fce4f4a775e6142424c30a29a363265c" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" + url: "https://pub.dev" + source: hosted + version: "3.9.1" + pool: + dependency: transitive + description: + name: pool + sha256: "20fe868b6314b322ea036ba325e6fc0711a22948856475e2c2b6306e8ab39c2a" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + process: + dependency: transitive + description: + name: process + sha256: "21e54fd2faf1b5bdd5102afd25012184a6793927648ea81eea80552ac9405b32" + url: "https://pub.dev" + source: hosted + version: "5.0.2" + provider: + dependency: transitive + description: + name: provider + sha256: c8a055ee5ce3fd98d6fc872478b03823ffdb448699c6ebdbbc71d59b596fd48c + url: "https://pub.dev" + source: hosted + version: "6.1.2" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "40d3ab1bbd474c4c2328c91e3a7df8c6dd629b79ece4c4bd04bee496a224fb0c" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + qr: + dependency: transitive + description: + name: qr + sha256: "5c4208b4dc0d55c3184d10d83ee0ded6212dc2b5e2ba17c5a0c0aab279128d21" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + qr_flutter: + dependency: "direct main" + description: + path: "." + ref: e3f8d3d4bbe8661f6c941acde8c9815a876756a3 + resolved-ref: e3f8d3d4bbe8661f6c941acde8c9815a876756a3 + url: "https://github.com/KomodoPlatform/qr.flutter.git" + source: git + version: "4.0.0" + rational: + dependency: "direct main" + description: + path: "." + ref: "84d8fe00e33560405c6d72b22a6e9c5c468db058" + resolved-ref: "84d8fe00e33560405c6d72b22a6e9c5c468db058" + url: "https://github.com/KomodoPlatform/dart-rational.git" + source: git + version: "1.2.1" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: ed3fcea4f789ed95913328e629c0c53e69e80e08b6c24542f1b3576046c614e8 + url: "https://pub.dev" + source: hosted + version: "7.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "0c6e61471bd71b04a138b8b588fa388e66d8b005e6f2deda63371c5c505a0981" + url: "https://pub.dev" + source: hosted + version: "3.2.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "16d3fb6b3692ad244a695c0183fca18cf81fd4b821664394a781de42386bf022" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1ee8bf911094a1b592de7ab29add6f826a7331fb854273d55918693d5364a1f2" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "0a8a893bf4fd1152f93fec03a415d11c27c74454d96e2318a7ac38dd18683ab7" + url: "https://pub.dev" + source: hosted + version: "2.4.0" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: d4ec5fc9ebb2f2e056c617112aa75dcf92fc2e4faaf2ae999caa297473f75d8a + url: "https://pub.dev" + source: hosted + version: "2.3.1" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shelf: + dependency: transitive + description: + name: shelf + sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 + url: "https://pub.dev" + source: hosted + version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "9ca081be41c60190ebcb4766b2486a7d50261db7bd0f5d9615f2d653637a84c1" + url: "https://pub.dev" + source: hosted + version: "1.0.4" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + stream_transform: + dependency: transitive + description: + name: stream_transform + sha256: "14a00e794c7c11aa145a170587321aedce29769c08d7f58b1d141da75e3b1c6f" + url: "https://pub.dev" + source: hosted + version: "2.1.0" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + sync_http: + dependency: transitive + description: + name: sync_http + sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961" + url: "https://pub.dev" + source: hosted + version: "0.3.1" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test: + dependency: "direct dev" + description: + name: test + sha256: "7ee446762c2c50b3bd4ea96fe13ffac69919352bd3b4b17bac3f3465edc58073" + url: "https://pub.dev" + source: hosted + version: "1.25.2" + test_api: + dependency: transitive + description: + name: test_api + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "2bc4b4ecddd75309300d8096f781c0e3280ca1ef85beda558d33fcbedc2eead4" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" + universal_html: + dependency: "direct main" + description: + path: "." + ref: "6a1bc7d9e6ed735ab9f7b319f9eedb138ce8b0e5" + resolved-ref: "6a1bc7d9e6ed735ab9f7b319f9eedb138ce8b0e5" + url: "https://github.com/KomodoPlatform/universal_html.git" + source: git + version: "2.2.2" + universal_io: + dependency: transitive + description: + name: universal_io + sha256: "1722b2dcc462b4b2f3ee7d188dad008b6eb4c40bbd03a3de451d82c78bba9aad" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: eb1e00ab44303d50dd487aab67ebc575456c146c6af44422f9c13889984c00f3 + url: "https://pub.dev" + source: hosted + version: "6.1.11" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "507dc655b1d9cb5ebc756032eb785f114e415f91557b73bf60b7e201dfedeb2f" + url: "https://pub.dev" + source: hosted + version: "6.2.2" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "7068716403343f6ba4969b4173cbf3b84fc768042124bc2c011e5d782b24fe89" + url: "https://pub.dev" + source: hosted + version: "6.3.0" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: ab360eb661f8879369acac07b6bb3ff09d9471155357da8443fd5d3cf7363811 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "9a1a42d5d2d95400c795b2914c36fdcb525870c752569438e4ebb09a2b5d90de" + url: "https://pub.dev" + source: hosted + version: "3.2.0" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "4aca1e060978e19b2998ee28503f40b5ba6226819c2b5e3e4d1821e8ccd92198" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "8d9e750d8c9338601e709cd0885f95825086bd8b642547f26bda435aade95d8a" + url: "https://pub.dev" + source: hosted + version: "2.3.1" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: ecf9725510600aa2bb6d7ddabe16357691b6d2805f66216a97d1b881e21beff7 + url: "https://pub.dev" + source: hosted + version: "3.1.1" + uuid: + dependency: "direct main" + description: + name: uuid + sha256: "2469694ad079893e3b434a627970c33f2fa5adc46dfe03c9617546969a9a8afc" + url: "https://pub.dev" + source: hosted + version: "3.0.6" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "4ac59808bbfca6da38c99f415ff2d3a5d7ca0a6b4809c71d9cf30fba5daf9752" + url: "https://pub.dev" + source: hosted + version: "1.1.10+1" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: f3247e7ab0ec77dc759263e68394990edc608fb2b480b80db8aa86ed09279e33 + url: "https://pub.dev" + source: hosted + version: "1.1.10+1" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "18489bdd8850de3dd7ca8a34e0c446f719ec63e2bab2e7a8cc66a9028dd76c5a" + url: "https://pub.dev" + source: hosted + version: "1.1.10+1" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + video_player: + dependency: "direct main" + description: + name: video_player + sha256: "3fd106c74da32f336dc7feb65021da9b0207cb3124392935f1552834f7cce822" + url: "https://pub.dev" + source: hosted + version: "2.7.0" + video_player_android: + dependency: transitive + description: + name: video_player_android + sha256: "134e1ad410d67e18a19486ed9512c72dfc6d8ffb284d0e8f2e99e903d1ba8fa3" + url: "https://pub.dev" + source: hosted + version: "2.4.14" + video_player_avfoundation: + dependency: transitive + description: + name: video_player_avfoundation + sha256: d1e9a824f2b324000dc8fb2dcb2a3285b6c1c7c487521c63306cc5b394f68a7c + url: "https://pub.dev" + source: hosted + version: "2.6.1" + video_player_platform_interface: + dependency: transitive + description: + name: video_player_platform_interface + sha256: be72301bf2c0150ab35a8c34d66e5a99de525f6de1e8d27c0672b836fe48f73a + url: "https://pub.dev" + source: hosted + version: "6.2.1" + video_player_web: + dependency: transitive + description: + name: video_player_web + sha256: "41245cef5ef29c4585dbabcbcbe9b209e34376642c7576cabf11b4ad9289d6e4" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + url: "https://pub.dev" + source: hosted + version: "14.2.1" + watcher: + dependency: transitive + description: + name: watcher + sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" + url: "https://pub.dev" + source: hosted + version: "1.1.0" + web: + dependency: transitive + description: + name: web + sha256: "97da13628db363c635202ad97068d47c5b8aa555808e7a9411963c533b449b27" + url: "https://pub.dev" + source: hosted + version: "0.5.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: "58c6666b342a38816b2e7e50ed0f1e261959630becd4c879c4f26bfa14aa5a42" + url: "https://pub.dev" + source: hosted + version: "2.4.5" + webdriver: + dependency: transitive + description: + name: webdriver + sha256: "003d7da9519e1e5f329422b36c4dcdf18d7d2978d1ba099ea4e45ba490ed845e" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: "5a751eddf9db89b3e5f9d50c20ab8612296e4e8db69009788d6c8b060a84191c" + url: "https://pub.dev" + source: hosted + version: "4.1.4" + window_size: + dependency: "direct main" + description: + path: "plugins/window_size" + ref: "6c66ad23ee79749f30a8eece542cf54eaf157ed8" + resolved-ref: "6c66ad23ee79749f30a8eece542cf54eaf157ed8" + url: "https://github.com/KomodoPlatform/flutter-desktop-embedding" + source: git + version: "0.1.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" + yaml: + dependency: transitive + description: + name: yaml + sha256: "75769501ea3489fca56601ff33454fe45507ea3bfb014161abc3b43ae25989d5" + url: "https://pub.dev" + source: hosted + version: "3.1.2" +sdks: + dart: ">=3.4.0 <3.5.0" + flutter: ">=3.19.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000000..bb06fecda8 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,326 @@ +name: web_dex # Use `lowercase_with_underscores` for package names +description: komodo atomicDEX web wallet + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: "none" # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# 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.8.1+0 + +environment: + sdk: ">=3.4.0 <3.5.0" # The recent 3.5.0 breaks the build. We will resolve this after this release. + +dependencies: + ## ---- Flutter SDK + + flutter: + sdk: flutter + + flutter_localizations: + sdk: flutter + + ## ---- Local packages by Komodo team + + app_theme: + path: "./app_theme" + + komodo_ui_kit: + path: packages/komodo_ui_kit + + komodo_persistence_layer: + path: packages/komodo_persistence_layer + + komodo_coin_updates: + path: packages/komodo_coin_updates + + komodo_cex_market_data: + path: packages/komodo_cex_market_data + + ## ---- KomodoPlatform pub.dev packages (First-party) + + dragon_logs: 1.0.3 # Secure code review PR URL: TBD + + ## ---- Dart.dev, Flutter.dev + + args: 2.5.0 # dart.dev + flutter_markdown: 0.6.14 # flutter.dev + http: 0.13.6 # dart.dev + intl: 0.19.0 # dart.dev + js: 0.6.7 # dart.dev + shared_preferences: 2.1.1 # flutter.dev + url_launcher: 6.1.11 # flutter.dev + crypto: 3.0.3 # dart.dev + path_provider: 2.1.1 # flutter.dev + cross_file: 0.3.3+4 # flutter.dev + video_player: 2.7.0 # flutter.dev + + ## ---- google.com + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + window_size: + git: + url: https://github.com/KomodoPlatform/flutter-desktop-embedding + path: plugins/window_size + ref: 6c66ad23ee79749f30a8eece542cf54eaf157ed8 + + ## ---- firebase.google.com + + # Upgraded Firebase, needs secure code review + + firebase_analytics: 10.10.5 + firebase_core: 2.31.0 + + ## ---- Fluttercommunity.dev + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + equatable: + git: + url: https://github.com/KomodoPlatform/equatable.git + ref: 2117551ff3054f8edb1a58f63ffe1832a8d25623 #2.0.5 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + package_info_plus: + git: + url: https://github.com/KomodoPlatform/plus_plugins.git + path: packages/package_info_plus/package_info_plus/ + ref: 263469d796d769cb73b655fa8c97d4985bce5029 #4.0.2 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + share_plus: + 7.0.2 + # git: + # url: https://github.com/KomodoPlatform/plus_plugins.git + # path: packages/share_plus/share_plus/ + # ref: 052dcbb90aa2120c8f8384c05e17a82ad78a1758 #7.0.2 + + ## ---- 3d party + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + bip39: + git: + url: https://github.com/KomodoPlatform/bip39-dart.git + ref: 3633daa2026b98c523ae9a091322be2903f7a8ab #1.0.6 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + encrypt: + git: + url: https://github.com/KomodoPlatform/encrypt + ref: 3a42d25b0c356606c26a238384b9f2189572d954 #5.0.1 + + # Consider newly added, needs secure code review + flutter_svg: + git: + url: https://github.com/dnfield/flutter_svg.git + path: packages/flutter_svg/ + ref: d7b5c23a79dcb5425548879bdb79a5e7f5097ce5 #2.0.7 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + qr_flutter: + git: + url: https://github.com/KomodoPlatform/qr.flutter.git + ref: e3f8d3d4bbe8661f6c941acde8c9815a876756a3 #4.0.0 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + uuid: + 3.0.6 + # git: + # url: https://github.com/KomodoPlatform/dart-uuid.git + # ref: 832f38af9e4a676d1f47c302785e8a00d3fc72a9 #3.0.6 + + # Pending approval + easy_localization: 3.0.7 # last reviewed 3.0.2 via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + flutter_bloc: + git: + url: https://github.com/KomodoPlatform/bloc.git + path: packages/flutter_bloc/ + ref: 32d5002fb8b8a1e548fe8021d8468327680875ff # 8.1.1 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + rational: + git: + url: https://github.com/KomodoPlatform/dart-rational.git + ref: 84d8fe00e33560405c6d72b22a6e9c5c468db058 #1.2.1 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + universal_html: + git: + url: https://github.com/KomodoPlatform/universal_html.git + ref: 6a1bc7d9e6ed735ab9f7b319f9eedb138ce8b0e5 #2.2.2 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + file_picker: + git: + url: https://github.com/KomodoPlatform/flutter_file_picker.git + ref: 85ecbae83eca8d200f869403928d2bf7e6806c67 #5.3.1 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + hive: + git: + url: https://github.com/KomodoPlatform/hive.git + path: hive/ + ref: 470473ffc1ba39f6c90f31ababe0ee63b76b69fe #2.2.3 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + hive_flutter: + git: + url: https://github.com/KomodoPlatform/hive.git + path: hive_flutter/ + ref: 0cbaab793be77b19b4740bc85d7ea6461b9762b4 #1.1.0 + + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/1106 + badges: + git: + url: https://github.com/yako-dev/flutter_badges.git + ref: 69958a3a2d6d5dd108393832acde6bda06bd10bc + + flutter_slidable: # last reviewed 27bbe0dfa9866ae01e8001267e873221ef5fbd67 + ^3.1.0 + # git: + # url: https://github.com/KomodoPlatform/flutter_slidable.git + # ref: 175b0735f5577dd7d378e60cfe2fe1ca607df9fa #1.1.0 + + # Embedded web view + # Approved via https://github.com/KomodoPlatform/komodo-wallet/pull/3 + flutter_inappwebview: 6.0.0 # Android, iOS, macOS, Web (currently broke, open issue) + desktop_webview_window: 0.2.3 # Windows, Linux + + # Newly added, not yet reviewed + # TODO: review required + # MRC: At least 3.3.0 is needed for AGP 8.0+ compatibility on Android + mobile_scanner: ^5.1.0 + + # Newly added, not yet reviewed + formz: 0.7.0 + + # TODO: review required + dragon_charts_flutter: ^0.1.1-dev.1 + bloc_concurrency: ^0.2.5 + + +dev_dependencies: + integration_test: # SDK + sdk: flutter + test: ^1.24.1 # dart.dev + + komodo_wallet_build_transformer: + path: packages/komodo_wallet_build_transformer + + # The "flutter_lints" package below contains a set of recommended lints to + # encourage good coding practices. The lint set provided by the package is + # activated in the `analysis_options.yaml` file located at the root of your + # package. See that file for information about deactivating specific lint + # rules and activating additional ones. + flutter_lints: ^2.0.1 # flutter.dev + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + generate: true + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + assets: + - assets/ + - assets/config/ + - assets/coin_icons/ + - assets/custom_icons/16px/ + - assets/coin_icons/png/ + - assets/logo/ + - assets/fonts/ + - assets/flags/ + - assets/ui_icons/ + - assets/others/ + - assets/translations/ + - assets/nav_icons/mobile/ + - assets/nav_icons/desktop/dark/ + - assets/nav_icons/desktop/light/ + - assets/blockchain_icons/svg/32px/ + - assets/custom_icons/ + - assets/web_pages/ + - assets/fiat/fiat_icons_square/ + - assets/fiat/providers/ + - assets/packages/flutter_inappwebview_web/assets/web/ + + # Komodo Wallet build transformer configuration. This handles all build-time + # dependencies, such as fetching coin assets, platform-specific assets, and + # more. This replaces the complicated build process that was previously + # required running multiple scripts and manual steps. + - path: app_build/build_config.json + transformers: + - package: komodo_wallet_build_transformer + args: [ + # Uncomment any of the following options to disable specific build + # steps. They are executed in the order listed in `_build_steps` + # in `packages/komodo_wallet_build_transformer/bin/komodo_wallet_build_transformer.dart` + # Configure fetch_defi_api in `build_config.json` + --fetch_defi_api, + # Configure `fetch_coin_assets` in `build_config.json` + --fetch_coin_assets, + --copy_platform_assets, + # Uncomment the following option to enable concurrent build step + # execution. This is useful for reducing build time in development, + # but is not recommended for production builds. + # - --concurrent, + ] + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + + fonts: + - family: Manrope + fonts: + - asset: assets/fonts/Manrope-ExtraLight.ttf + weight: 200 + - asset: assets/fonts/Manrope-Light.ttf + weight: 300 + - asset: assets/fonts/Manrope-Regular.ttf + weight: 400 + - asset: assets/fonts/Manrope-Medium.ttf + weight: 500 + - asset: assets/fonts/Manrope-SemiBold.ttf + weight: 600 + - asset: assets/fonts/Manrope-Bold.ttf + weight: 700 + - asset: assets/fonts/Manrope-ExtraBold.ttf + weight: 800 + - family: Roboto + fonts: + - asset: assets/fallback_fonts/roboto/v20/KFOmCnqEu92Fr1Me5WZLCzYlKw.ttf + weight: 400 + + + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/roles/nginx/handlers/main.yml b/roles/nginx/handlers/main.yml new file mode 100644 index 0000000000..a4bc05820d --- /dev/null +++ b/roles/nginx/handlers/main.yml @@ -0,0 +1,3 @@ +--- +- name: restart nginx + include_tasks: tasks/restart_nginx.yml diff --git a/roles/nginx/tasks/deploy.yml b/roles/nginx/tasks/deploy.yml new file mode 100644 index 0000000000..e53b388605 --- /dev/null +++ b/roles/nginx/tasks/deploy.yml @@ -0,0 +1,137 @@ +--- +- name: Apt Upgrade and Install nginx + block: + + - name: Upgrade dist + become: true + apt: + upgrade: dist + update_cache: true + + - name: Install packages + become: true + apt: + update_cache: true + state: latest + pkg: + - ntp + - nginx + +- name: Setup Deploy configuration + block: + + - name: Set branch name + set_fact: + deploy_branch: "{{ lookup('env', 'HEAD') }}" + failed_when: deploy_branch == '' + + rescue: + + - name: Set branch name + set_fact: + deploy_branch: "dev" + +- name: Check for existing build from given branch + block: + + - name: Check memo + stat: + path: "{{ deploy_branch }}_conf.json" + register: stat_memo + + - name: Clear build + file: + path: /etc/nginx/sites-enabled/airdex_{{ deploy_branch }}.conf + state: absent + become: true + when: stat_memo.stat.exists + + - name: Force restart nginx step 1 + become: true + systemd: + name: nginx + daemon_reload: true + state: stopped + when: stat_memo.stat.exists + + - name: Force restart nginx step 2 + become: true + systemd: + name: nginx + enabled: true + state: started + when: stat_memo.stat.exists + +- name: Select available port + block: + + - name: Gather used ports + shell: netstat -nlt4 | grep -oP '(?<={{ allowed_ip }}:)(\d+)' + register: used_ports + + - name: Select deploy_port + set_fact: + deploy_port: "{{ allowed_ports | difference( used_ports.stdout_lines | map('int') | list) | first | default(0) }}" + failed_when: deploy_port | int == 0 + + - name: enable UFW + become: true + community.general.ufw: + state: enabled + + - name: allow ssh + become: true + community.general.ufw: + rule: allow + port: ssh + proto: tcp + + - name: allow selected port + become: true + community.general.ufw: + rule: allow + port: "{{ deploy_port }}" + proto: tcp + +- name: Setup Nginx proxy for air_dex + block: + + - name: Create memo + template: src=deploy.conf.j2 dest={{ deploy_branch }}_conf.json + + - name: Disable default site + become: true + file: + path: /etc/nginx/sites-enabled/default + state: absent + + - name: Copy Nginx config + template: src=airdex.conf.j2 dest=/etc/nginx/sites-available/airdex_{{ deploy_branch }}.conf + become: true + + - name: Create new demo directory + file: + path: /var/www/flutterapp/{{ deploy_branch }} + state: directory + mode: '0755' + + - name: Extract build + unarchive: + src: ./airdex-build.tar.gz + dest: "/var/www/flutterapp/{{ deploy_branch }}" + become: true + notify: restart nginx + + - name: Enable Nginx config + file: + src: /etc/nginx/sites-available/airdex_{{ deploy_branch }}.conf + dest: /etc/nginx/sites-enabled/airdex_{{ deploy_branch }}.conf + state: link + become: true + notify: restart nginx + + - name: Log new link + local_action: + module: template + src: demo_link.j2 + dest: demo_link diff --git a/roles/nginx/tasks/main.yml b/roles/nginx/tasks/main.yml new file mode 100644 index 0000000000..f97b807d07 --- /dev/null +++ b/roles/nginx/tasks/main.yml @@ -0,0 +1,8 @@ +--- +- name: Deploy or Update Nginx proxy + import_tasks: deploy.yml + when: remove != "true" + +- name: Clean up Nginx proxy configuration + import_tasks: remove.yml + when: remove == "true" diff --git a/roles/nginx/tasks/remove.yml b/roles/nginx/tasks/remove.yml new file mode 100644 index 0000000000..b42ca4bf2f --- /dev/null +++ b/roles/nginx/tasks/remove.yml @@ -0,0 +1,38 @@ +- name: Remove deployment by HEAD + block: + + - name: Set branch name + set_fact: + deploy_branch: "{{ lookup('env', 'HEAD') }}" + failed_when: deploy_branch == '' + + - name: Check if config exists + become: true + stat: + path: "{{ deploy_branch }}_conf.json" + register: nginx_set + failed_when: not nginx_set.stat.exists + + - name: Get used port from memo + shell: | + jq -r '."{{ deploy_branch }}"' "{{ deploy_branch }}_conf.json" + register: port_res + + - name: Close port + become: true + community.general.ufw: + rule: deny + port: "{{ port_res.stdout }}" + proto: tcp + + - name: Remove files + file: + path: "{{ item }}" + state: absent + with_items: + - "/etc/nginx/sites-enabled/airdex_{{ deploy_branch }}.conf" + - "/etc/nginx/sites-available/airdex_{{ deploy_branch }}.conf" + - "{{ deploy_branch }}_conf.json" + - "/var/www/flutterapp/{{ deploy_branch }}" + become: true + notify: restart nginx diff --git a/roles/nginx/templates/airdex.conf.j2 b/roles/nginx/templates/airdex.conf.j2 new file mode 100644 index 0000000000..51760a8624 --- /dev/null +++ b/roles/nginx/templates/airdex.conf.j2 @@ -0,0 +1,16 @@ +server +{ + listen 95.216.214.144:{{ deploy_port }} ssl; + server_name node.dragonhound.info airdex_{{ deploy_branch }}; + include mime.types; + types { + application/wasm wasm; + } + location / { + root /var/www/flutterapp/{{ deploy_branch }}/build/web/; + } + ssl_certificate /etc/letsencrypt/live/node.dragonhound.info/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/node.dragonhound.info/privkey.pem; + include /etc/letsencrypt/options-ssl-nginx.conf; + ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem; +} diff --git a/roles/nginx/templates/demo_link.j2 b/roles/nginx/templates/demo_link.j2 new file mode 100644 index 0000000000..1f97077039 --- /dev/null +++ b/roles/nginx/templates/demo_link.j2 @@ -0,0 +1 @@ +https://node.dragonhound.info:{{ deploy_port }} diff --git a/roles/nginx/templates/deploy.conf.j2 b/roles/nginx/templates/deploy.conf.j2 new file mode 100644 index 0000000000..64c9fde7cf --- /dev/null +++ b/roles/nginx/templates/deploy.conf.j2 @@ -0,0 +1,3 @@ +{ + "{{ deploy_branch }}": {{ deploy_port }} +} diff --git a/run_integration_tests.dart b/run_integration_tests.dart new file mode 100644 index 0000000000..ad676310d1 --- /dev/null +++ b/run_integration_tests.dart @@ -0,0 +1,269 @@ +// ignore_for_file: avoid_print, prefer_interpolation_to_compose_strings + +import 'dart:convert'; +import 'dart:io'; + +import 'package:args/args.dart'; + +// omit './test_integration/tests/' part of path to testfile +final List testsList = [ + 'suspended_assets_test/suspended_assets_test.dart', + 'wallets_tests/wallets_tests.dart', + 'wallets_manager_tests/wallets_manager_tests.dart', + 'dex_tests/dex_tests.dart', + 'misc_tests/misc_tests.dart' +]; + +//app data path for mac and linux +const String macAppData = '/Library/Containers/com.komodo.komodowallet'; +const String linuxAppData = '/.local/share/com.komodo.KomodoWallet'; +const String windowsAppData = '\\AppData\\Roaming\\com.komodo'; + +const String suspendedCoin = 'KMD'; +File? _configFile; + +Future main(List args) async { + // Configure CLI + final parser = ArgParser(); + parser.addFlag('help', + abbr: 'h', defaultsTo: false, help: 'Show help message and exit'); + parser.addOption('testToRun', + abbr: 't', + defaultsTo: '', + help: + 'Specify a single testfile to run, if option is not used, all available tests will be run instead; option usage example: -t "design_tests/theme_test.dart"'); + parser.addOption('browserDimension', + abbr: 'b', + defaultsTo: '1024,1400', + help: 'Set device window(screen) dimensions: height, width'); + parser.addOption('displayMode', + abbr: 'd', + defaultsTo: 'no-headless', + help: + 'Set to "headless" for headless mode usage, defaults to no-headless'); + parser.addOption('device', + abbr: 'D', defaultsTo: 'web-server', help: 'Set device to run tests on'); + parser.addOption('runMode', + abbr: 'm', + defaultsTo: 'profile', + help: 'App build mode selectrion', + allowed: ['release', 'debug', 'profile']); + parser.addOption('browser-name', + abbr: 'n', + defaultsTo: 'chrome', + help: 'Set browser to run tests on', + allowed: ['chrome', 'safari', 'firefox', 'edge']); + final ArgResults runArguments = parser.parse(args); + final String testToRunArg = runArguments['testToRun']; + final String browserDimensionArg = runArguments['browserDimension']; + final String displayArg = runArguments['displayMode']; + final String deviceArg = runArguments['device']; + final String runModeArg = runArguments['runMode']; + final bool runHelp = runArguments['help']; + final String browserNameArg = runArguments['browser-name']; + + // Coins config setup for suspended_assets_test + final Map originalConfig; + _configFile = await _findCoinsConfigFile(); + originalConfig = _readConfig(); + + // Show help message and exit + if (runHelp) { + print(parser.usage); + exit(0); + } + + // Run tests + if (testToRunArg.isNotEmpty) { + await _runTest(testToRunArg, browserDimensionArg, displayArg, deviceArg, + runModeArg, browserNameArg, originalConfig); + } else { + for (final String test in testsList) { + try { + await _runTest(test, browserDimensionArg, displayArg, deviceArg, + runModeArg, browserNameArg, originalConfig); + } catch (e) { + throw 'Caught error executing _runTest: ' + e.toString(); + } + } + } +} + +Future _runTest( + String test, + String browserDimentionFromArg, + String displayStateFromArg, + String deviceFromArg, + String runModeFromArg, + String browserNameArg, + Map originalConfigPassed, +) async { + print('Running test ' + test); + + if (test == 'suspended_assets_test/suspended_assets_test.dart') { + if (_configFile == null) { + throw 'Coins config file not found'; + } else { + print('Temporarily breaking $suspendedCoin electrum config' + ' in \'${_configFile!.path}\' to test suspended state.'); + } + _breakConfig(originalConfigPassed); + } + + print('Starting process for test: ' + test); + + ProcessResult result; + try { + if (deviceFromArg == 'web-server') { + //Run integration tests for web app + result = await Process.run( + 'flutter', + [ + 'drive', + '--dart-define=testing_mode=true', + '--driver=test_driver/integration_test.dart', + '--target=test_integration/tests/' + test, + '-d', + deviceFromArg, + '--browser-dimension', + browserDimentionFromArg, + '--' + displayStateFromArg, + '--' + runModeFromArg, + '--browser-name', + browserNameArg + ], + runInShell: true, + ); + } else { + //Clear app data before tests for Desktop native app + _clearNativeAppsData(); + + //Run integration tests for native apps (Linux, MacOS, Windows, iOS, Android) + result = await Process.run( + 'flutter', + [ + 'drive', + '--dart-define=testing_mode=true', + '--driver=test_driver/integration_test.dart', + '--target=test_integration/tests/' + test, + '-d', + deviceFromArg, + '--' + runModeFromArg + ], + runInShell: true, + ); + } + } catch (e) { + if (test == 'suspended_assets_test/suspended_assets_test.dart') { + _restoreConfig(originalConfigPassed); + print('Restored original coins configuration file.'); + } + throw 'Error running flutter drive Process: ' + e.toString(); + } + + stdout.write(result.stdout); + if (test == 'suspended_assets_test/suspended_assets_test.dart') { + _restoreConfig(originalConfigPassed); + print('Restored original coins configuration file.'); + } + // Flutter drive can return failed test results just as stdout message, + // we need to parse this message and detect test failure manually + if (result.stdout.toString().contains('failure')) { + throw ProcessException('flutter', ['test ' + test], + 'Failure details are in chromedriver output.\n', -1); + } + print('\n---\n'); +} + +Map _readConfig() { + Map json; + + try { + final String jsonStr = _configFile!.readAsStringSync(); + json = jsonDecode(jsonStr); + } catch (e) { + print('Unable to load json from ${_configFile!.path}:\n$e'); + rethrow; + } + + return json; +} + +void _writeConfig(Map config) { + final String spaces = ' ' * 4; + final JsonEncoder encoder = JsonEncoder.withIndent(spaces); + + _configFile!.writeAsStringSync(encoder.convert(config)); +} + +void _breakConfig(Map config) { + final Map broken = jsonDecode(jsonEncode(config)); + broken[suspendedCoin]['electrum'] = [ + { + 'url': 'broken.e1ectrum.net:10063', + 'ws_url': 'broken.e1ectrum.net:30063', + } + ]; + + _writeConfig(broken); +} + +void _restoreConfig(Map originalConfig) { + _writeConfig(originalConfig); +} + +Future _findCoinsConfigFile() async { + final config = File('assets/config/coins_config.json'); + + if (!config.existsSync()) { + throw Exception('Coins config file not found at ${config.path}'); + } + + return config; +} + +void _clearNativeAppsData() async { + ProcessResult deleteResult; + if (Platform.isWindows) { + var homeDir = Platform.environment['UserProfile']; + if (await Directory('$homeDir$windowsAppData').exists()) { + deleteResult = await Process.run( + 'rmdir', + ['/s', '/q', '$homeDir$windowsAppData'], + runInShell: true, + ); + if (deleteResult.exitCode == 0) { + print('Windows App data removed successfully.'); + } else { + print( + 'Failed to remove Windows app data. Error: ${deleteResult.stderr}'); + } + } else { + print("No need clean windows app data"); + } + } else if (Platform.isLinux) { + var homeDir = Platform.environment['HOME']; + deleteResult = await Process.run( + 'rm', + ['-rf', '$homeDir$linuxAppData'], + runInShell: true, + ); + if (deleteResult.exitCode == 0) { + print('Linux App data removed successfully.'); + } else { + print('Failed to remove Linux app data. Error: ${deleteResult.stderr}'); + } + } else if (Platform.isMacOS) { + var homeDir = Platform.environment['HOME']; + deleteResult = await Process.run( + 'rm', + ['-rf', '$homeDir$macAppData'], + runInShell: true, + ); + if (deleteResult.exitCode == 0) { + print('MacOS App data removed successfully.'); + } else { + print('Failed to remove MacOS app data. Error: ${deleteResult.stderr}'); + } + } +} diff --git a/tasks/restart_nginx.yml b/tasks/restart_nginx.yml new file mode 100644 index 0000000000..b0deffc9c6 --- /dev/null +++ b/tasks/restart_nginx.yml @@ -0,0 +1,17 @@ +--- +- name: restart nginx + block: + + - name: Reload daemon and stop service + become: true + systemd: + name: nginx + daemon_reload: true + state: stopped + + - name: Start service + become: true + systemd: + name: nginx + enabled: true + state: started \ No newline at end of file diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart new file mode 100644 index 0000000000..b38629cca9 --- /dev/null +++ b/test_driver/integration_test.dart @@ -0,0 +1,3 @@ +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/test_integration/common/goto.dart b/test_integration/common/goto.dart new file mode 100644 index 0000000000..2ad08b8daa --- /dev/null +++ b/test_integration/common/goto.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/common/screen_type.dart'; + +import 'tester_utils.dart'; + +Future walletPage(WidgetTester tester, {ScreenType? type}) async { + return await _go(find.byKey(const Key('main-menu-wallet')), tester); +} + +Future dexPage(WidgetTester tester, {ScreenType? type}) async { + return await _go(find.byKey(const Key('main-menu-dex')), tester); +} + +Future bridgePage(WidgetTester tester, {ScreenType? type}) async { + return await _go(find.byKey(const Key('main-menu-bridge')), tester); +} + +Future nftsPage(WidgetTester tester, {ScreenType? type}) async { + return await _go(find.byKey(const Key('main-menu-nft')), tester); +} + +Future settingsPage(WidgetTester tester, {ScreenType? type}) async { + await _go(find.byKey(const Key('main-menu-settings')), tester); +} + +Future supportPage(WidgetTester tester, {ScreenType? type}) async { + return await _go(find.byKey(const Key('main-menu-support')), tester); +} + +Future _go(Finder finder, WidgetTester tester) async { + expect(finder, findsOneWidget, reason: 'goto.dart _go($finder)'); + await testerTap(tester, finder); + await tester.pumpAndSettle(); +} diff --git a/test_integration/common/pause.dart b/test_integration/common/pause.dart new file mode 100644 index 0000000000..7e2c94378f --- /dev/null +++ b/test_integration/common/pause.dart @@ -0,0 +1,11 @@ +// ignore_for_file: avoid_print + +Future pause({int sec = 1, String msg = '', int msec = 0}) async { + if (msg.isNotEmpty) { + print('pause: $sec, $msg '); + } + if (msec > 0) { + return await Future.delayed(Duration(milliseconds: msec)); + } + await Future.delayed(Duration(seconds: sec)); +} diff --git a/test_integration/common/pump_and_settle.dart b/test_integration/common/pump_and_settle.dart new file mode 100644 index 0000000000..b89fcc6ffa --- /dev/null +++ b/test_integration/common/pump_and_settle.dart @@ -0,0 +1,22 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; + +Future pumpUntilDisappear( + WidgetTester tester, + Finder finder, { + Duration timeout = const Duration(seconds: 30), +}) async { + bool timerDone = false; + final timer = + Timer(timeout, () => throw TimeoutException("Pump until has timed out")); + while (timerDone != true) { + await tester.pumpAndSettle(); + + final found = tester.any(finder); + if (!found) { + timerDone = true; + } + } + timer.cancel(); +} diff --git a/test_integration/common/tester_utils.dart b/test_integration/common/tester_utils.dart new file mode 100644 index 0000000000..a58cd9f181 --- /dev/null +++ b/test_integration/common/tester_utils.dart @@ -0,0 +1,19 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'pause.dart'; + +Future testerTap(WidgetTester tester, Finder finder) async { + await tester.tap(finder); + await tester.pumpAndSettle(); + await pause(); +} + +Future isWidgetVisible(WidgetTester tester, Finder finder) async { + try { + await tester.pumpAndSettle(); + expect(finder, findsOneWidget); + return true; + } catch (e) { + return false; + } +} diff --git a/test_integration/helpers/accept_alpha_warning.dart b/test_integration/helpers/accept_alpha_warning.dart new file mode 100644 index 0000000000..2c306cae38 --- /dev/null +++ b/test_integration/helpers/accept_alpha_warning.dart @@ -0,0 +1,12 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future acceptAlphaWarning(WidgetTester tester) async { + final Finder button = find.byKey(const Key('accept-alpha-warning-button')); + final alphaWarningExists = tester.any(button); + if (alphaWarningExists) { + await tester.ensureVisible(button); + await tester.tap(button); + await tester.pumpAndSettle(); + } +} diff --git a/test_integration/helpers/connect_wallet.dart b/test_integration/helpers/connect_wallet.dart new file mode 100644 index 0000000000..0b2ec54bb2 --- /dev/null +++ b/test_integration/helpers/connect_wallet.dart @@ -0,0 +1,30 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/model/wallet.dart'; + +Future tapOnAppBarConnectWallet( + WidgetTester tester, WalletType walletType) async { + final Finder connectWallet = find.byKey(const Key('connect-wallet-header')); + final Finder connectAtomicDexWalletButton = + find.byKey(Key('wallet-type-list-item-${walletType.name}')); + await tester.ensureVisible(connectWallet); + await tester.tap(connectWallet); + await tester.pumpAndSettle(); + await tester.tap(connectAtomicDexWalletButton); + await tester.pumpAndSettle(); +} + +Future tapOnMobileConnectWallet( + WidgetTester tester, WalletType walletType) async { + final mainMenuDexForm = find.byKey(const Key('main-menu-dex')); + final Finder connectWallet = find.byKey(const Key('connect-wallet-dex')); + final Finder connectAtomicDexWalletButton = + find.byKey(Key('wallet-type-list-item-${walletType.name}')); + await tester.tap(mainMenuDexForm); + await tester.pumpAndSettle(); + await tester.ensureVisible(connectWallet); + await tester.tap(connectWallet); + await tester.pumpAndSettle(); + await tester.tap(connectAtomicDexWalletButton); + await tester.pumpAndSettle(); +} diff --git a/test_integration/helpers/get_funded_wif.dart b/test_integration/helpers/get_funded_wif.dart new file mode 100644 index 0000000000..955f9ccdad --- /dev/null +++ b/test_integration/helpers/get_funded_wif.dart @@ -0,0 +1,5019 @@ +import 'dart:math'; + +String getFundedWif() { + final List addresses = _fileJson.keys.toList(); + final String randomAddress = addresses[Random().nextInt(addresses.length)]; + final Map randomAddressData = _fileJson[randomAddress]; + + return randomAddressData['private_key']; +} + +String getRandomAddress() { + final List addresses = _fileJson.keys.toList(); + final String randomAddress = addresses[Random().nextInt(addresses.length)]; + + return randomAddress; +} + +final _fileJson = { + 'RPDPE1XqGuHXSJn9q6VAaGDoRVMEwAYjT3': { + 'private_key': 'UtNh1tePcAqtPppcTMUdW8qSw9tS9izvCcauMCB4UxTevzCqSFkj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQCyFA4cAyjpfzcCGnxNxC4YTQsRDVPzec': { + 'private_key': 'UwAEQTqbN5TQQhi33harSXp5K13ukaXVoyeTvDapoAymZjEvkXBu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKXzCCaT5ukqnyJBKTr9KyEpCBHR8itEFd': { + 'private_key': 'Uvc4wDXD2iLm6tP7FuyvFyAxCHro9R8v3qmkcAJdQJStenScnVpx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RD8WeYCaBQSx9e6mH5hX51uZ5FxNyirawj': { + 'private_key': 'Uvrfi6PxwKKD4ZupfAwtKGTpMhjBC2CSAGQ1BAH5xtWiRo9Jb9h5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVmiEScJqWchKNEq4noaznCVeMg4VRhDVB': { + 'private_key': 'Uu6WZimEEctFHBum1uW3cXokjkSXU7Z6tgcsUWaaJFnD13jQBKXi', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPBaCKmtaeg87hJaP4ebrQmVZBeebpPGip': { + 'private_key': 'UpgHNGM7GNZoMCwFaz9ZXCgqfg6ZZBiiz5iMGCtXibxu9Wtvzz5R', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RK5fL2NrKqFsNNv5eAtBPsHwUB7nS1pJLe': { + 'private_key': 'Usz25M5Aj1FTqzE4JMiGdkCsAop95oF3ei4fkn5oAGdePhrf9oRa', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYEDgkmmh1cRrhuTnYvKjJXasFNh2YQ8Mv': { + 'private_key': 'UrLmBJDRo9XJ6JMPeRzT6xwKEkqwevGpaCyhjnXwtM9nYbvx8fHh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDJarCHAWcCu3i81Jf3UVAH47LZMzMEgZW': { + 'private_key': 'UsPkKCMrSCi4Hky5ZCkFVfmiZgJDD79q1PhiYaAEuLB7x7ntMRXv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSicNrr9yhCeXWShcwun5TAs5NbWHm8rBo': { + 'private_key': 'Ux7ymirpeLDKEJdxgYmCFt11J4k3tzu7WvKx1BnqBPhtwgwbk4V9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9gHR7C3CDTKQfXtvJcRkf2mU1DhS3Ydsb': { + 'private_key': 'Utkx8HrmMB9pUhENHuKhnGej2cJ8qtW8K2vR5eWp14tHpH2gDwQ6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQaFegkCdRUpVT74GAeNsVuqygTC2cxoRH': { + 'private_key': 'UqG98bdSFXhPhF41fJn7cknEEYsvAzJwwrw6f8qtiN6AAdatvB1W', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLMqj85UKRJnmxGBYVJ7tmCputtzXjkm6H': { + 'private_key': 'UvWPoFgZFyqzz3khtNZbwDcj5KPasDQp4XawTiSya95F4ibY7z78', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBwGmCih6tqrLB2FeHc5MXQZmeC9BnJ76L': { + 'private_key': 'Ur4vnvbDkdZZWUXtXrYXXXR6YCvHN3T36hciZp65h5cjNp3gTsD9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBmUXpsdXcrzDkJbbPjSWMCSmLixWPp7sb': { + 'private_key': 'UsCUZEv1paEmxxr6MzV4R9qo6qLNwAWFozMVUA8pebVz16D1xprZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RH5q6iBqqBUwi3PntQtZdfzUT1gQMcQomN': { + 'private_key': 'Ur71NnLa5dvm2gaByer4v4fw6pkVDGDx6bCtj4ew1HVcV5xaZACp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFPJHDoS7MTPH3c7gjTjkDsMuevsiwFNDW': { + 'private_key': 'UsoLh8jCxAzHby22JwdS35hGFoSLGaBnkMfDfSbVF2veswPg3EeJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRA28xfJK4m7G264iskvVuG5ZNRFie4Wtc': { + 'private_key': 'Ur832LZ6rYXAE5bzB73TFif9AYf2KLrh6hkAnhpe1sScuQzGRZAh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9gGgXKxmGwrovF9jAy1groQL2euVBqKie': { + 'private_key': 'UqVYjX6YzE2zvfmPo2m9mX2Leb5ZnMz74QC78Jwt9Z1qhRsrNVih', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBGQbv78oHLXz7oAGu2UoS5SDMX73bdWhv': { + 'private_key': 'UwzibAPUanCDhLnf13FphpksLWDR9oL9cCeNAeHtz2B1LV1V5zK2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWnMitMKTdKQKwYjvQpCueckVgW51ZgCv1': { + 'private_key': 'UqoWNQE4m31vEhyAjtXH9mQNvU2Qbb8E9LQ4jTR3eo6EAp9kaSzL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX7Vyr83G1XV8napKp4U22qYNm2pRcdh75': { + 'private_key': 'UxFyBFsQ87BUXPDKiCWWXCBCxUUoPHxbssfgbHC7ZtzA7mHctiRm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWyPsb5UUzh9W81472wSDLPe6pRcFysG6e': { + 'private_key': 'UwofUdJDHQCzL9K11Utkdf6jGGxygcDfnymHRh22Gz2bbX3EoFKH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RF2cHEd9najxuT8DfsdQRAt2TZVcV2gGx9': { + 'private_key': 'UxZiFtWkLspmhbehzgxLme3oN7FDDy4gokQjLUr1tBJgB829GurX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTs7LGd1XFodBcgGPuofwgCLrrcdM4ma99': { + 'private_key': 'UxHryv4Pkrm63ZG67tD1c48iS1F2njU8Zdi4hDvs7amKePJt9AcA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLUG7UxTjhchVTBZn1GGz8mG2uWYbxJCdp': { + 'private_key': 'Up65dfesYjgX4Fc92L3An6VsAnTGE57hYuE582zMqKTSLWLsPVbu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RC6ZARt1zSxuk6VQLQ9YffMpVBo4aq3LeK': { + 'private_key': 'UpxrfRV6zpJrfQ8ryoC36avinCCPQSJ23LcJXvT4D9pBXswuse45', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNbe21STb73BhEsiWrWaJvFmiQ7YnmkkMi': { + 'private_key': 'Ux2NFrBNT2juBF41cRtSxyGeRo2GgvJbD2UvtWVeectL1GbqesZx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPBkfczaUwa6AqStH3qCuDbgC1WMarkxCa': { + 'private_key': 'UvQW97PvrqfTM1xvgWDbdRjKdbTpHXghGvmkKvLQ2BjCFs8FpEZP', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RECKnmr7QaCHPfr7jqHMRzqnSmyuK8wewx': { + 'private_key': 'UpfoBx8B7nWAxMDrz7CiPDDzn3B1YCTZHnoM4LV56KVgajNjcDsn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPQBBFWu3cXSaxsYhu6LK5xMhTwpRq3M5z': { + 'private_key': 'UuK6RJQWj8W6srWpLn7EQ88MKSnr5iXUJYaFa7qswx8phNW1cp9q', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWTp3ML2G5HX386YYKcZbJFVuWhRRAZnZj': { + 'private_key': 'UwrfH4fM8Z22fCovomZhy4rAZfqqWc9mtBgcmq7RFL8VJQxvZGYs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9dR7tKXrw1JqnquYYEQRxQNnKqLSGzEoD': { + 'private_key': 'Uu5xYB77UKaXkM9PBg6cEe6pZAVgjmBeax8ufUMgsXFLbXpzBZKS', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNoSHqYZDB5qxZHvNT3ZuZG5aZ2kVkAGfH': { + 'private_key': 'UunDWWy5Q3R2z61C1SnTeoB1WWoexNUx66NgcUNShu6GUJJESwSz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMDzApsu9cfFCW7LS5bqHMF9d21Epy439S': { + 'private_key': 'UtQFxJUNFWH5kjr4qm55z5bXRi9XxntjrTdxRQcfD2Y1juKxUEBB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REzj7H5USbZh4bDfgcsc3r32XSitNGcWrK': { + 'private_key': 'UuCYbnSFYS2EqLwEbZz8bKw8z6G1P11CsgQsbAEQv5xTeLYvLewU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKz62Cro7a5x5z6o3FhHFTUk4zYTzpxXNk': { + 'private_key': 'UruFcc7B1aumVhDrJthsNbyWMY4YGzxnHLH1PziCRJu9FCEGPJa5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RY9qsH3TTZLvUuvpMvDEN7EtNeWVScxwBz': { + 'private_key': 'UwKG7cL5ggxqPt6o4UwGaJnn9rLCczba3JpPDEYtajyGe8wSXe4t', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHcEDLRJ62EYLtPL3Q3Z3uaF8V6jKcja9M': { + 'private_key': 'UpUVaRd9VHW2qr5aKcNgXrJiS4vddRRueBSxkyT2mLtGAPVUbiHL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFFmQGguAiBXhmbDGz4eYxyuRp3TNah1Cq': { + 'private_key': 'UtfPXCoiNiUpn8TXeYbACejGwf3H7xpepyoV6GYCDpCjDpkVtX3u', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAAQhMUWULsqLZfRJhxQM2pUhYn3EfJidS': { + 'private_key': 'UuQ92LmQKR51PbYCviYX1NDgy1afNjTG2dApP4xHJYXqo94C9Jmh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTExQ2TS215yBwRJmgAoqqaP7M8e7L4mcs': { + 'private_key': 'UvjWbQn52hYujmV4mgaDuA7ovWeBUqgYAu8in5oMLaJmgPSKa3W9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RV1qs7mQoh1HVgXnXidVuAF8rfUsW3t3at': { + 'private_key': 'Uv1KzCzdmeLfCqKzJ3D4iHG2guyt3vqgsLfhEW7u2jfwPJAp3Lmk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQzLgPPTAsTo7uV9JpaG3vw24Ms4KisLBM': { + 'private_key': 'Uwu6NfSovWZPFwxG45F9FvMCKjrksCibYkBRXFaqKjDWshhJjURi', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHdmybVS4FKKJMsNKoPHiPsmJU2VMskcKx': { + 'private_key': 'UpYz7EdZ47Q8jtiP2tyiCR33fEGHzP6tFG4GfBmVxc2gBmHYdA4C', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKTVmnQjV22iV8wR4MDMN12NK2UVqT3k3T': { + 'private_key': 'UpnuEduBQtk42JsXk7dmoZ3gm37wPgdjqJPeNxbNGE5xCRVNP9tB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RG9bw1EW4PLCYstwPQcavhqyuJcoDxiban': { + 'private_key': 'UtHtRdRtPg13U5vGYp2NRrUS3TTL6EQkqmMqJ9n5RPrzWJfD54qx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTCTBgA3f5S54d8hCnJVNBst9qd2pHNQY4': { + 'private_key': 'UsQuR94RZjfDQPRCAtdroiFP76AzWYpNKn4Vg2LQUuzPNfnKnrH7', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNUMtsLfQXCqPyGWaAd2vGqSP1H8X4PtDe': { + 'private_key': 'Urus93XNabnVcHz5pZ9SE8K9tUKg1GEFzeYwtmA6K7C4SqDaFbgr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJTy11VQS434Jw93LFXxeUG79wp6fy7wvm': { + 'private_key': 'UpzBphRrduckP1RWovtEMM4yHY3uL1XMyQtZQtugrjk47FgXwYUZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTHtWSnRGAZgkT19kDEpVBkM9eHJfmmuoU': { + 'private_key': 'Uue2st6Ru12FBu8T3y1EzJzMrgWMhPxYr8DzTpPbGKFc1oUdNrxz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJc79S1jUKsDUcazJ5S4zYVS8VuaLivkox': { + 'private_key': 'UupMR7uGvi8Mowwj3HtaNazx2XTmcwzK92GEaqTE2QHTKBeub2Zo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMT1v7irBAu4iqeqxm11XkyCYYkhMdVHsf': { + 'private_key': 'UwQ6SoCk3juSmyk8CS5HrxeA9zjD9UwdLgk7irbFjdJcX1bLEEgk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RK844C6SCK9oqNUhEeqQiaWAGw2AcvR1w7': { + 'private_key': 'Upm1WTFfuuWSPAF1xQeo1dyTYxnd7GMM2NvHrvZKiAPxuEceswnp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNg7KWhrQKiXVubn1gGPXBksJRLsJFZvwQ': { + 'private_key': 'UpfoVXWKsxRQwwEQsYowq3KUtPFnFHaraqzZd1f9KatrDnjJRgGH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RE6yGW27Egq4LetXYWwEEmX8WBGeVHMseg': { + 'private_key': 'UrWzPkCmjyQKdFoP9WD8fPYDD7faoLsXMWwShEgpKpcxLQFcusmx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTYh1pGirA6e5PEyGKU5yG94dAqRXvMGhH': { + 'private_key': 'UvZjTD3ctdAkjnXwzCdALifDt6iZAVq88acyvLitYg4QsapX6Z75', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCVHbYudnAgg3h1Rhf8fHQ6efzWpwr9347': { + 'private_key': 'UuC6Ygt7HtHNpm4q3SfbkXsdzVhyqVpXfqmVJjcFUEEUSfoiU6MG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHvvm1gdPP1fBRD5wdyd6hTnMA5s22frKh': { + 'private_key': 'UtdEo515qQ8Ln262yjnhTBuHpzqDzAvjw5q7jNuBnVZB1pb4T6gQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL7EBz68RbG9vtcp4rkiYPzPR9eiZEZf2h': { + 'private_key': 'Us7hQdzim7WYAjrus4WKrpnae6aR2PoKRvm8Hh7P6FGYhXW4myi4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJGmBK331yv9if5S8TKcUykfgykNensfh4': { + 'private_key': 'Uu2zA5Fcj8hnAjnwkTM4uwbbmhPjWdNjmTUiTbvxjKMeBauJn2qe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RH9rrPkN537CjVLXSSit1SEB6JGVej8sAi': { + 'private_key': 'UpQZJbeLyjk94JXi2mRk1X2YQPgXvnHYUjRZ5qECz3yNZr73Ap7U', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMTj5K2RrADyouhUeb1PtUxfbQMJxgwBgy': { + 'private_key': 'UvH2CErXZGSXCwGqYMcSgHob3bgNpNfPGdqmW6rGoZDC3EuB2wdh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVMZnhnsvfdtyR6B1Sx6kifvsjBdWXU3xU': { + 'private_key': 'UpSG1uGCL8LwcmWmC5S3QS2igVyf5QX2yZoDGdeAUPfq9b7oq3N6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNcGhP2eaZrwi5NKEC3keC3GHUWnqpx3qn': { + 'private_key': 'UuzXDHWtr4BarebmJMWkaWhdxusKBrmC6KLAWAkvNBarCdmnc1y3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSmXX9oJFDNcUrQQt2Gwx6zi1rdLf3R6nq': { + 'private_key': 'Uv9YzbnJ4fqnsw4eKYUAYZAwPHjkpWeREoKinC4V6PTzCATmf2vM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REsqxajKCUqEmf99Ma5UwQcMELHh7ZUU46': { + 'private_key': 'UrrXGyRZHiTH2NsT2D9NBg8jHfd6xJpHAXnWe4KcGLiT8hqGfg5p', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDrg36M7tpH96ecX7k3CBzBChnpzQFFUsW': { + 'private_key': 'UwB6vbkKKrkEJsZQreEuTkapTTtYUzcNGUy2Eun7wY2qvWpfkm1v', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RY1KUgLrFTq9JZabddzC3Fh2TNuS2yEwC8': { + 'private_key': 'Uwkrn9u2BJiovCpgrVUC5V3RdHLLbfbigWBhYSFPGcS6o6CUndBb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNXQnHYqejD1HF8DpfqTw4XayJuJMaSvME': { + 'private_key': 'Uqtq9HqARFvmCNaYspQKNwrAVCELoeKLCwDXzS9fuG11uftKqG4j', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLHKznwCjdWbmmeTzXZvzgo8R8sGiBSxPt': { + 'private_key': 'UwtWmVKPYeBoSn5oNG7k7N2s5k9Jup6SheWxAWM2SS7qoxgrQzWp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAd4DhUCZhRLnGWAaNESV8XhWeMSQC5Kuj': { + 'private_key': 'UqetWf5Suoy2gXpYuZSr5TX4jJD8N5DrmrZETtvaEACvsviWthYs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGkUC2jKBrxRcNoj6QyxejDbhobaDsk5px': { + 'private_key': 'UsviBrVrwDCTwq6YcZudaLmGChKDDsGPEm5LoFQJjfc2gDp4Fe1k', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTzj6fUtSMKC8NY8igXXbbhA49Dd3HuMyH': { + 'private_key': 'UtBKmzcHF7wHhza7WTvoEaSHghDkpxjYFLmfvyiB6zWuEn8sq52i', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RY1L5UjEppK7hY8BcNsc6qxehpXuU5ptWy': { + 'private_key': 'Us6YJHdAq82u8rPqTXkfxJFFfjvsfeDNARLSLUW5WZyY9AcUvHG7', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RH3QkcKHgvpJGmrihphF6eu4d8jxd6nmmz': { + 'private_key': 'Ux6jHzpHvXssA4W8GNJnsHLEm4sY1utnq4zNEQc3ZKcWC5aKCGJU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMacy2pN97NEy755sZKk6joH5qANXd3RPe': { + 'private_key': 'UsgvJHrbJUt7UdtWgs6YJGHpT9FEAmiYZ3ZkJYBN3CB6sPpNm7JN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9xscBjNtiuSvZcadrgmmfFxn61fCAqiMT': { + 'private_key': 'UtnmQCT1Da5eg3YgRz43HDTNNNqLaLin7MCtqbkYWFV6an9dJEae', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSRJJEV5oms3J1pKqspfBN2QNxanHZX4Bi': { + 'private_key': 'UrRjEZBJhHk6xesvzvXo2cyEQCxtyte5gN7H8nNvGRT3X7mGaCdb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCekExwWhJZSAGrscPXQ91uHcKJ6hMoVm3': { + 'private_key': 'Uvos74Mi1kmRfAFcnEKWRRKgyiGQgu74dmDFRdPPmvuaWkSwoVDk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVPegtCb2UcFZ7NDPSBCcFSmRrGaYni2NR': { + 'private_key': 'UrRt3DtennbHY4Zf8HcjUJss4GhPwzBQgju3BgQSYqbqfxpuawZv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKuzbc4vavzUJrKB3uwk2eAWEZc6schcsW': { + 'private_key': 'UpfFK1JvT5YbEsNwUQ4PEk2UrmM3CgPKnJJXP5evphPpfKWERY6S', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKLdMAokS6iYvqkCi9sDxHSkc2wdSkcZvZ': { + 'private_key': 'Uuez8XPcmwZWqDDMJeYjUDEV5gY7hF7GmyBBHMyasifWzn2Qgg7C', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RA3pQoE8Zxnp9YFQVD8tkq4KqZ1w3etiEp': { + 'private_key': 'Uwq8b7Y25XgujowvG4A52a2MTrQHcwy6SqaqKietajEn8NUFdN31', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9ohtVXYkwTHopeZ7pX6TSuYm3fh13XcB5': { + 'private_key': 'UwZcMaLmTMEi84Z6pP8E7vtwqBwyHeqdZBdzkENHYQcKgnGRzSsz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQDWEg8XFRxmkY1sdMPscyYxrhdbFF29bU': { + 'private_key': 'Up9gR8kdsGSkabiKEaz4zapExbBRTQTvBbw49T1DazFw6pJsxZb4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUoNi581HY12rgQzH5r5RSAYk65Ym6QFS5': { + 'private_key': 'Uqmb4oHVoiStu61hyfR9orzPUfYnfrkPWMyY6DtH3qXhUSd5eZHm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RH4BZTUzQzg1JmguNoCYon3FxGy4oENtxP': { + 'private_key': 'Uw4EvwPXLG1x2QGRjLM2x9mZEYMsJSF8U2qJ2qLUeHDP6pKU93VB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RW9PqpfKaQrQFfW6U7XGGMYNi7t46yjVrr': { + 'private_key': 'UqM3djzMCu9jmY53gB9yC2S2UUxyM4XbWFxqT7kckX93xdAbF8ow', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLDyX66cxfEgR6m59Y9FNXrMNUCsrXwLpy': { + 'private_key': 'UsPKMN2Tneo2AY58jZfyxWyoMJQaqUBYBK7ZpjQBfzkV9pv7m168', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCmyApDtRdgfiQig6KDMSpEx1zqDbeL4VT': { + 'private_key': 'UxK5h11yuX9nPeZo9bMAycpw4V53yBc2LDbRdSAqUiuyKngskPtN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REk85VuLZAd9eQWgZuyHtv3Zh5P1nMAY6M': { + 'private_key': 'UrLgThachB6mzDg4cg4TwpqnMGUsUvSBxD61GLB4vZMXXYKK4vrP', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLrDjVgvKc3jVrhrqEqDFLR9bdm6tVZgVQ': { + 'private_key': 'UqA7RYdWHVwfND8NS3ZyAsgWS4o5gsPVuQhLJQSwy6hTDSbBDUCH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REsmUjnBxqUChtmVcRSSgJZSwtkG7smPgA': { + 'private_key': 'UwBJP5NKvNev8nyQZnZ5aisZpSX3g3RzDQ3anRZGL96hcGFzqJpw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNX7iEX4wjxD4JdTwwphbwBSJ2UeJf6T8v': { + 'private_key': 'UxFPoVfVjx1exi9UTfmoPm6vQbCwBioc5sWiFPkE1EsMEULEJTzF', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNZAxV269s8zr5GpmsZfYiCGU49hNtq57x': { + 'private_key': 'UuHhoWePDZErGBxxxVt5HH3yuKNEj6UpSEB5n8KBQt3iiaTUR5mL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKZPfgWRBWjPLVb697EuZCr4Zz2pDhxm51': { + 'private_key': 'UsAmzCyPZb4rTtuFFZU72B6wy3foiHJzMuQJWyLGJYRRa3mzqToX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLjegyFRkZrVVYQf1mmLstWaYBBHizKucD': { + 'private_key': 'UvwVF84epDvU8HXHeM5S4BQFNGLscHPnKxWUXkMntaZfJVGV9koG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJ7qH2wK5gXCL1hXhoQLCRrvRALjfr2TRG': { + 'private_key': 'UsY7jRpsVpHx1GTZT7n8vh8ewyY7jp85BxSgWM96vAwVwtZEQKji', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBaJYN5NrNRwhtcFGabjEqQSbhBk5QRG9i': { + 'private_key': 'UqcTPNZ7JhxiEAXvvKSroSgz1iCcp7JGXT8RTbY5xGEARyZ9JPqA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDnJh2tywczfckAb8DhvUA2gjbnNtvLRpP': { + 'private_key': 'Uu3ToDZeH4saahANZxGH8rAAUVAQMX2XeNt4yNowy4DaNqG7dyif', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRbjbSJuYX7BG3ymVD5pt3KZNfKcmjFCkC': { + 'private_key': 'UpxwN16eqN77jWJRDRBVdWtsjDX85RFSjEdJW1e9xrm9urDgTT6D', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHxJkD4gxQuk1A117w7485woLfKfk1GpYf': { + 'private_key': 'UsmqUdtaUQGnQTRVdCVUpoSkLTEf7zbDqdAzC7bLkiJSGaMKMwYY', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYLu5siXhf7Ycegr3uP67Kh8bvdJL2WvAn': { + 'private_key': 'UqfQXyV6SSqXoEvkT7WZx3acPE5coHPam38vwtDtzMgmxkK7WjPE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMZTbLrVfQuKj72pBAfNJGgvzrNuyaG8s6': { + 'private_key': 'UrCiPvcUqSsv9BdLKXHWgrJQUZZSvote5syHy53PqgctV5wDwtPw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHqz3Vv7PDpvSjB8dSyatAzS3CQaTom5ZD': { + 'private_key': 'UtEHAcuKu7G3Ge8Shd9Aw84AraZtRq6HCPYfgfmybK3zFL6iJXgk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLzMZtZr7GFvoPTA6TzEDyyAsB8u1cC3Y3': { + 'private_key': 'UviibQ8qZPv6W96L5MktKFZguTc2xciVfxJBT4x6odEy6pWebemx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSk8vyg4n9VJkubeYVVgq6Rd1PkTSRL2Xn': { + 'private_key': 'Uuao8ikzerXw8ZdYAQUBnv4MgDQ9yhbZZxXM5zEovV15Fw63DNar', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRtFyUT6VkSsgqeHyKv9otEwDHQ91HxrLM': { + 'private_key': 'UpPZZvkjnzdf9Ke7pMpS2MqQ44tLXH2crParUBi7PKPC4YnbgV1w', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBQBNkkLaQYXwDuNMkEHoWfY4ZzCaanmgf': { + 'private_key': 'UsN6PHSgNzfwGzCkcWTWF8dJCxiEWqRPGLxpmEr4JJRfw2HmTPSD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHPDRkWGXnf7jxVLpVQ6MdsggXbskbioDH': { + 'private_key': 'Us2nvt1Zuo4duBBeHYwTVmKgnNnAs3vFFPUi9NiuNrfLUTjcduLp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMNv33Zv9kAsyejmN5JXsgDsB7WxbNSey1': { + 'private_key': 'Uw5KxcYSMxfaopKqBrD3XBYFd79i258KT8ZCy8mU6JnRTtzuu9Dg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBCmc8L2QPfYp52tdWviWJ4C97PzrEi6AJ': { + 'private_key': 'UpcrQxoFnyxntQnHtmkGT4N2dHrnNkSzLeZev86uk7rvEvmNsDMQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQmSz9yNKiYTL93rBBdN1nje2co1i2P4ph': { + 'private_key': 'UpGr5tX813iJ8DkPvn8BoDd367kXEaWXGodeNHVNsJ56npLvE7F7', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGC3ujig5rkgVpquQgdgw4pBB46tyPDBod': { + 'private_key': 'Urozb7a6S8YwU6hpaAAvemgJtBHTCnATQXdMXgpFLCowwJHgRkao', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWxjMsoSLYhintUA4NY6EdXVfcUQwAQiJA': { + 'private_key': 'Uw4Xh7zNWa8P7tHybGHfUc1gUhsq2fNqFS2XSrXjYwBUnpk83yVE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLRrxxbQvrk2q6BDRTgN8obLC6ae3e5uxb': { + 'private_key': 'Uv5WibeixU9RJmxD9hsfokGUBjmapkaZ76AzEV9si5VtYa8vYrLt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REJj7Ayd8GeK74FgrtpNbV4UaqCdH4TQ6s': { + 'private_key': 'UtT2SzjDhsRQ84qKUtJ6yo4Tf6tmDE6zdixca2pSJgTaFRUMQ5tf', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTmuT7MjyExEbdrB4fhxRAnaVkH5oS7miy': { + 'private_key': 'UsTADjkwCrArXL9ZzA8MjH7Pf44kJR8JXRq324jEXaAvXqHTrib6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTyTB55yWVA3DDBhacmNCiMsrXc8Q3yG6Y': { + 'private_key': 'UtTWxTzmNR6E1aF38TEfAPf8yGDSY9YJpfqZ8CEw7ofy5N4ksTS4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RK8T7C4bt8uouFJEPJMUycPcHqtT8QFkiy': { + 'private_key': 'UrviES9g2ZP5Q3HhCoMw84caDGQ45EZ4mfvkiMTThwjap5ghmBwX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTYeTidUMyZ9wfD4bBbMVC1pymgmiNHsF7': { + 'private_key': 'UpV7rE7vEVp5fUbEgd9uRqnGFxawDFYpTNH5L2HZWuQC7RsmMsUQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJjWrez9QuGbgmyzRNkD7bL8QQ1CtkzzkU': { + 'private_key': 'UvscBXMsamfQsZYxbUhAMRHZYkcHg6V3tBTBA3sKiMMG4cwxdQyW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RS1Xw6v7cQuFHsHYhoR31BncWSHvTRyW53': { + 'private_key': 'UwNTiU1YSGPGGcuaY5rwDwe3URd4oCSqHznM51nnTrjDY7No2ew4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAk2jGoXjtNsKbEenHXMVsB9NWLSbHeUS8': { + 'private_key': 'UpJGcjcddf5vvSnVVjPeUU8tfLi2Pv3SmCCTtak59yQLmpGQAXXY', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RR3DJnnGtf73FnbfZbZSW1LLuvVxq4hzES': { + 'private_key': 'UrAukFVHDDs3tg42f3vsKCBaTwfjwcxzHjnNQExRWsEminHyg23q', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCc7zzsRsHCNfkyrmzvSPMj39N7ug2oBaZ': { + 'private_key': 'UsqoXUYGr5z51LNRMLD4jWNWGeztvTT1RPkaR9Huaz2sWJAbdDCz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RK1Q4sGTyPusRiMn1vVH1B4Stvf9tPqyv6': { + 'private_key': 'UxUCTn9H3EfjPpQSFDBAFNfdae3Z93RXM4EwTMrGjS49hF7uDN4e', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPgSDyJgSjV9L5NKHPnsR489V6mqpVsqDd': { + 'private_key': 'UpD5WzD4LUi9dB3ujsuYoezBjPVsAxjtS7t14188yhnVvdSRht4K', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQxaNKAKfghcsNVUegm9qDn6YctWrVDtip': { + 'private_key': 'Ur38vxEnfL8pd7snQnAffZ9kcRtDNfzNGmJ8tXnmSfXLZncsyeJj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTft38EwfGskVQBuN7ZzfdimNTNYPHCXhd': { + 'private_key': 'UtwPsbwASN5CieX1KhiiP4M286iqi1NhHax4fQkBnoTfx6QVTihi', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMgcVMW99Dqt8fVazu9XjyfZugi66XFTKK': { + 'private_key': 'Uqguw8Mkx7KvE5oi6o9Rcj68swUh5xM5x9pC9z4Kt8Ead5upfQ6N', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBELozudXkG7WGGppYh1rhGPbthm65sj1f': { + 'private_key': 'UvGEwc41xkTHvSCufxrdJ8ga7ijY39bez6TFmVbN4RwB8kr7KQN1', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTPH59tmRbEqMK8LouiqUaUNJfd5RguivF': { + 'private_key': 'Upyjk2cvzHLJbwHrPTbNJa7dRCBZ9f8qPXCjCLeJPDT8UpBZBWuV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAxsqqveHb4b7Se3kE3AEDiuiCG4PvD9YX': { + 'private_key': 'Us3BKm3S1Pps4QmbC3exbtrZouB3Fww5j1UZKYDQVc38qReJAp9x', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWtYPDzUYz9C2jzmPjnHzEWmHk8vLYc1Ay': { + 'private_key': 'UwUySCTu7uLGbuZs8PY9HCmUHB2qap5UGhv37vzYza7mGh4XUP79', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWeQVGKuNboRn7pitsnFJ8diVwC33yEdbj': { + 'private_key': 'UuYvjRheSB9WgCsNEFUbGsSnjkDrn1ozd8xY5u2owJ2svLRwuN4W', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBNfmWvrZoEQW3EuypeTA9eJSS9DR9Hkry': { + 'private_key': 'Utx9cLLTkAegrfdv5wb1SY9Yndq7yfAqqjdDqdHV9VWMSrJJ3pVz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCqN9KwfgkAy85sr9UfRRFE3FYi7YScY3c': { + 'private_key': 'UqCSoPiDE73zKVxXZePL4LnvpgUM9vo72WvTYjg7gDoVdapiJHik', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RV4zhu2LUJcgYpyx97PabSiLTdGDsSU715': { + 'private_key': 'UteYHnp3ZhevoW7ZRkR5153GAJ3czCv7k6TEjmzQKbhfZ7CwGoZd', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLSSi2Hb2E6evn1GNiwrjV7nzqEn8tPyTj': { + 'private_key': 'UqNQqUR4vvb2MG5iz4m7ydt3D1NJndevE8t8bzmzFuqaSD2cUqou', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDPq99CYf41tkQ3RZBNG9oX9sWxPUuYi4r': { + 'private_key': 'UthdESKiNbYrKQc4MmBZvteXvzoei7BgBFpTgj9SEcTDpGRh8GgG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKaxFB4vSdvoEzCq1hKiYkSEUCeW7uNtEK': { + 'private_key': 'UxCZCqssVot6shC6jbjVKzYtYgAASoapAw3gPf8x7TPbWgMsNV8d', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGTHJ92A1t8kfMtqDEGjzwX2PKGZZ3YeXx': { + 'private_key': 'UqGGfnsZ4vmt5wLRNAP8w2XbvXcfCsFXxgsHJUzpDkcF7PYUTYZZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGFGzgRE9UU24A7edHHC2wTHTLhCVN6qQg': { + 'private_key': 'UpkiaXTmp7YUcTiNqXMrjnzt6LktrQHsQPnSMENQkZ7VZdQRGtJV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDHxynC1ewSu9Ru8JsvC8XBNyqdHwfe8i7': { + 'private_key': 'UrAyaBzLNWdBPG9HpKe3b7APkAGP64p9skAjQSM7ZSo1mt77enZQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXRL3dvQC7uNT662j4RjgXbFf1wyaPDQ9r': { + 'private_key': 'UtFiC5HvU6XpxnYz6wjPTvUkDuBg1Kqx11DnJZEMS1UNqwTVsofC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXAuodFvbVkozoeHNrLXNLGQxmsq5CjDFh': { + 'private_key': 'Uv1wP5spba3xuvSWkZsLWpvhYw3pvGjbRPg8uERhrajhACPmANVF', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYKV3qwDcN3iuUSGjLaipTwMw8pavwPR4z': { + 'private_key': 'UrT1pbpD5X6e4rpyhSZkghVBfk9ujy61tLGiMmdFwc3NBsFMt63q', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9ptx9pFVDCy5ETbbyhmGQ6JngFXFGR364': { + 'private_key': 'UvHD5uAMQ29FG73QtYABN9KqJzEgZuy73X8y3TZgGKbu63moCsu6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAE5cuB3fohoxkLCMbuB8v61BV1GojbEAe': { + 'private_key': 'Us6HFtm6Y2M9ZCdn4cwWAgEdeY8wVoooCdweTw2hVMiv1kGj4rzo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RM4SsDarDNH9hEdb4e9rx9aUqd8qvumYB4': { + 'private_key': 'UrnQW3HVUphRwM5PbWRnPp1WKdbKWdTyfSNez2Gb32tUZq93Vfxm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAQJ6uimuxvRn1DTzit3cN4Bk3fmqXdbkk': { + 'private_key': 'UqYUXUsFTLMhM4U3mLf6JsvxozPGEAuvQWcHtFhDNqpB8sWeNmNx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNDDEc4oz7UPprRCd7kkw5vHMgR2DVyF9y': { + 'private_key': 'UwR5bpAavCqjb6r49eALV3DqUqPHzUjiXErZ9k9AvnDuHfbB6mbw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RY8iT7bxPvG3Z3PBAzM46pmVdqvRcuKywj': { + 'private_key': 'Ux7rACgP4jU8PeG9JcKQyMZd5WpTgFDKU2xSrdoLFemDECMgm7QN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFSHgvvrnpYR8WZnhGr1xPCZCAjuRqgaKx': { + 'private_key': 'Ut3tDP2EwjYKGkGkyWyQiqpwqYRBpU1GXnNmDYJKtnCc195Dn5W3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSYNKypFHWCWg4po9MfR3YCpYqtGBnAmHe': { + 'private_key': 'UtgL4q57vTosZ9NpdLcgnHqBNGkqAXvCdoyJy8CMY7Z6GBCdkQKw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDhaeQWUnmu8VBFauFWJy4vz7AAvdbKm3c': { + 'private_key': 'UqE6FcC3xrNnMcsstqFPHPXJErnjXxJqVZmx9BoSQtjo6zoFzNMu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAUQ3jQht8UD9LnRw7encE8LK4sESPKSC4': { + 'private_key': 'UpMWHGWctWnP6p9scdV4nvfw6gpQM1fNCf7szkZZJiV6AF2YeXjn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQjwissodJ82GGt7TxyqUD3ixjuxESZdDJ': { + 'private_key': 'UtSyc3nwsQAvkfxvm9JQqWjNSHRJmu83zuEyetGqYXCva9pTue3D', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTQvJmYbM5SNT7RTpAupcvBijXDDR63j78': { + 'private_key': 'UuCYAe3CqcxgWKDCbWmEtVkLxGEVr9RnMVdZGbAYLpo9Kayqbv4u', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RP2sQwS8auvz37nyhBapF6qZBZU7y4869W': { + 'private_key': 'UvV2teqpmej9HJHJHiEPV1giHgpWeudFALNWHw38tb9k93hHSD4r', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJhbN2psnpRVHq1CviPVWEG4o1twsrUnDV': { + 'private_key': 'UxFyn6fEA7YmHNaajjvrfxfPi9mjaEYnHQ9XvaBFXKZ3UodE6Kfy', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAB93n9tUsZD4YjzazeB8g4hkuGGzuGhbL': { + 'private_key': 'UraBpzfz3tT4qAxoB3BKtY94v9errwe6ZgFtdAqv8EuXvzPZxcqP', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVhtQFQwSTUFUWqGsRPcNUU8mRwkJcXdb6': { + 'private_key': 'Uuiuzib91BMCpfzB733uzBnG6wPk44CVtFjWhbkvcoz76kkXNhhy', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCAwQzHB95u6NJAGfofmwmPZQeW8z8G4ML': { + 'private_key': 'Uv4GZ82c83RAFvXpoaV46LQAE4hZpEwyen9tWa75tb1mZgTFzxsp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJ2V1yTU1bDfQ6TiXJt9r8JKZLKZ1cyjbr': { + 'private_key': 'Ux2oN3TENAoo5CLrvtu83Rvp1ecGhXo7jt7rqWUx5Zfbv7QDTf23', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RReidU859uTc2dbLwJjES4RWb6HMKJAz1s': { + 'private_key': 'Uv211CwWcMm6HE4i7chHfPrzNTFXosrBkz4dQDPuba5HAVGDHYom', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVq5kw4sUzcYbpgnX1oUyJAWYixRnfrD5M': { + 'private_key': 'Uwh8aVmDN5eJHKys5B9HFx6kZtqL7uP69NdGNyLFe21Fs7ECRXkk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RF5uSo3Sa2kG2voHNrikLHmjgqtXB79Qsk': { + 'private_key': 'Uv88HEVVn6pvE4FczQe4BoPxPuR5d74Y8WSpvqtZiN19dHmw397n', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RV12My8PxFEQnmgUPLGk7W2ADoNdsfa5HB': { + 'private_key': 'UweWV7mU1LQud6ix2sVJW2WC6Li2127ep5j3ufrXz3Run28Nzhsa', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBZuRWiB5zs5M2abbcpsyWFM2cvDi39FVs': { + 'private_key': 'UuzpxFYmqgfi8xkGW99tqTNLP2V9jp3Rk8K2RvWQZtru1jgkxadA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRxqQin7xfW3jFZZ2fr1FU1g1zcyHarWtP': { + 'private_key': 'UtiuG8bdjDBvi41NuEM3kB8sHfcCTEWkxGRQWaHgZ5Ne7zUeQp9z', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUNnxhVYaPmjB3kHnKQLwwW831H46Jjvgi': { + 'private_key': 'Uwbud58Cc2fNXFN78s9pLkeqH8JMmEpSKDHiuf2fEUhtr1HeHPnj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9MjvkEzjx23x1SJpcabZW53GXbpgvtQpU': { + 'private_key': 'UqgkUZkf4sAa5JHhtwgBrK3Kac55HvArv6ovoxdGTdN5m4DTkgvW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQgUUBhxoQJ8w6qeKahD4VF3xH6PPwJ7ye': { + 'private_key': 'UuzxzftC4Z9R52NCioBLcjKqhf1mu4ivxVd1Gu1apErANrmnksHB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWaHoXQn3vNroLvtN2mseMyquTGrvxMpj6': { + 'private_key': 'UpCVVTZw2c65m3sx3izDcxXvypaCntUmb3TJKWTSXECHvaxCe7X8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUfkQQ6Uy7MCqpkFRUDijBedwAiG13qywQ': { + 'private_key': 'UqoVBRT1vwUZho7s8H8PYksLJFeqRxLryQUEsWkdZGKyaUecKkWE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXCm1Z9ReatJ8VGkNeMQ9vHspXjCAiLtBx': { + 'private_key': 'UsT8jpnB6kFKrC3accmd26G2fePVsgFLMDin81WQsy8ZUsuTK8CE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVpVX7gTytPnQHmt8Ua4XZLm9nuVvMHeJy': { + 'private_key': 'UqacQnDvVQDo9XdMkU9zjnbAkfoitvNjJkGk4JpBxqszqVpZoPKq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTJhQFrK8RPFSjjmjcC82WcJpqtYt8s2Cw': { + 'private_key': 'UtEt4v8UnR6xKi8yLhNJrjdsps53yKYWg7gP7dAJ2La1xcbGgwbT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKJPiEG64X7d25iMkbmBnKWEDzL9NtvEQk': { + 'private_key': 'UqGvDtLsZQBFR2v8gbciPDXSkBiansVXzkZZMLGYEKYtjvkb2nty', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQdrAZBhVzPPeWDjXb7BCbC5RWQReRFDbr': { + 'private_key': 'UuiEitU3WQjrN4kVrYirErD7xPAGma5R3wLvLWbaanBjdiXp2jcp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBhytARCsKNhECp3494Wfkp4NcYaeuGBAF': { + 'private_key': 'Uu34oKdbjV8zwe7CQexHphPepYTkTsXeBvUTpYp7si9sZ1ALffCA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNSVg8hniKhkeeiKw82fhBp2P2UUoM3yZj': { + 'private_key': 'UpzVsqmf3J1xSU64WgthorR6CSwmzdGiovJcyjti75b2q3dWQvYi', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJrxSUNyqFZJMW9EffZS3q4rxqYKcdxRyL': { + 'private_key': 'UvprvwKr5My1k48gZ3sogBjSRTYVZvkZPiZtZXrZ7hYzLG4MiHj6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKNXzHeiTciZZUXjssQ7LbfsCcBQBUnqDn': { + 'private_key': 'Uq2WvzDXRqsMcUCWNmrkbEvhGLYkFr3C9NbHMraMaUuE1E9vQp4f', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYG8R28V2sv8DJKAPkBvzWog19vMLxNj4X': { + 'private_key': 'UrW8nDvxB16MLSPW7QiPcW92fWkwAo9Pb9BEVG7RBo85JkyBqfiX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNqvKoxkbfHFEv5pVY3CGm5tnck4o46FHn': { + 'private_key': 'UsJbrSAaJAFP8MSVPdenBui5bKxcgzfSbyPafm8EDWmBcEn18y12', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPt9G8HgWb8469M15YqRoqrjhSVzKLGuMZ': { + 'private_key': 'UpNxsrQhL7X9BjTP2y4GfW4ELQpcGWjD9Aat9kwh4F74D1EX8aZn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTG86Fyo6SqvhUK77z11qv8YUZPSqKYDnP': { + 'private_key': 'Ut1BnRYHCmpn3xGmQG2gsvN7CUPMXCyKzEC5R63KCniLivCgCuNc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDGWzevBCrXhq3wvJPRNyN5gyLHhmybDya': { + 'private_key': 'UsSpokCUvJZwuEt2CWKEGsWxfmjtdDqqf5tojvvpkJiToB3tFcD6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRCEGAdf7wMHc9nWLBcMMibQxaejb8A1Q4': { + 'private_key': 'Uv9A52Q56XZkKsTkmkj2dSrb9mgo7qkCQzWYbwpYEEfHj4w7xMsE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REvGTd6Dbpz2G5e8kuT1YpB9LRen4Qfkxu': { + 'private_key': 'UpE7UfXQvxC3poViS3VRPXtJcYi5yvgxwAVqt299carBt4DD2s9m', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGjNcSibAw3DGgKiL53ibeAbdQbJCMR6RX': { + 'private_key': 'UtYaRMC6Dr6iKwezv1z4JA9ZPdb6FB9eqAW146JigvYGWZzJRosc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RG8QYhjYULPTy3Eko8NwJ1dxuC7jyHJrPo': { + 'private_key': 'UwnyYgZRRGScVwvo2Btrkj4gBg1VJZdk71DbfrMhUshZiDMscRQx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPWqFZ3eR45i2pF56va7zRTMZ6pTw99eU4': { + 'private_key': 'UxLD214Z56eYbGqdBohM4Qqg2vTGyeMTCZa4QnNJFfTt4sZPvfck', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHcJYr3qQJ93zcfAexZk8Q2bw8ojBApFpz': { + 'private_key': 'Uv4US5DwwmacsUDBG1o1wCdPM4Ni4hPdpuXG7s4eVC36McUAQH3k', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RThKqk1r24wxyfDFDD3oEQRSXwNZmxdL2U': { + 'private_key': 'UrshYdaRXpqs34Jhhrj97YdRV7AoT4gAPdZocTeohqJnyV8hMb4R', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RU32P8sEJ8qzfDh15qvqUBabWAw5QbWBLA': { + 'private_key': 'UtLYfr8KEbJxC9TS1srWzUF8uGcn4UnuDUUd1vmNr1uMpVWx2V9Z', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWzsFtgFXuQz9AAhdFVwix8UM8MQn9uWxK': { + 'private_key': 'Up5YLaLTpyFzwRTKFpKCg3MtaaVKBLq7ZxPc2CumUdx8etGGdm8P', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTQ7ysgQVPYxztgVHcLX9sf1aMgNbqEfec': { + 'private_key': 'Ut3rqYkR5ZjR1d4NZkra9tBYW6gSCMhRS2uezZHcXBP8fsB3BrKp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTHXXfmtTDFzXvwCEmV9maqMiDz5njfZ3m': { + 'private_key': 'UryvXZHsVmSsGAL8RbpxBL1tYCfPgtjuuBhuER7hySuYoBPnQUMJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RB81RqJ7cJV4CJXupCxK3fTJuwXRnSEYJJ': { + 'private_key': 'UuBmYoJJNirb5LqxS3okvwZPH3ia1dZgEfDKCTLHwddERVwG8Xnb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGFnRRdyk9Wksjsyt8rN5us8YiBVbebwyU': { + 'private_key': 'UwKpYYPBm1WzFvc2E85gX9y8SuH13JMQPziPUr5QUURnuDzF2GqL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQae57ZDH5My4CRDeAn5HudkhwL4Fvba7r': { + 'private_key': 'UrCpiWDiYXvgHViFpHNWhEsGSKug3rxkZPQKRLfS6BHB6QMBoRMn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYMXwxfwHUMhHFtqELkA4pig9BWq9P1RwK': { + 'private_key': 'UrjVqgyGM1LkHqmYnpdEZJcjqauD7czyrMVHuyfXL5YCk4aoASkY', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNNw7D49Y94ua2xctisN3n4ezU4bfcwqK4': { + 'private_key': 'UteX6pyGS6EoAMgCo97Lzj9JdX6xENp9sQ5s7zbXrH6gvjbLoFsb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXnfyDoogbLk9iQNrTctFa93fdknpkf5qn': { + 'private_key': 'Uqe7zqrjYoWMGctCQ364cAff7oKx7rmVNEyMCdcUNGyauwcFUymP', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RD4KVfwa9n9BVABYrpQYsQQQSpXmPfJxfx': { + 'private_key': 'UtVz1whWofcQAqMACFtj9esMhDBTHmEjQWYKWuDhgf96x3ED6JMw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVLwuWUpgMeTUoaHvqoAgRyE3NSBvr2dLn': { + 'private_key': 'UqCbcTQWrMzQWgBBdLHMVXaThuEZUZGQCxPuxkwu4jkZxuj4Pp8x', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTp1UDpb55vfCFgtyzchdbELbMnKgzFdCF': { + 'private_key': 'UvADpw1vEV4MAWRv5wYTR62c2MM3bY7BQNvHg1vCEN5cC4HzbXYV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RU93wbpQJQuzL1o85CCJh5tCT3mrot3nmK': { + 'private_key': 'Uv6wyetLqNrvYvaGpwvt1tPN2aqsUpGnRFVvLU4WAtKsNSrnqrP4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMyrELaXJCtRDAckTowgi6JiJ5NuucC9GP': { + 'private_key': 'UtJ2v2ekVe7bjiLYUxawMyk8LPHZy6n2prppqQEgY5YpDqRLwdGj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXRru5fwjMwmU2mTut1tiKAX6w5NZNzw1V': { + 'private_key': 'UrN2fumMJLNWfAST5tvVeLU32t7AwqqXMFHPmLf1y6TuDb1VTLgT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RALkpHJiBiETA2m8ZuRfNo1BPhMvvJv6AR': { + 'private_key': 'UxFammr3J9KgXC6trYt14ACcHqXmNb3gNNhMX1RzJZNpH2aiWsxy', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHQEWxwMXfe31gJVR6EhR3yc62WEJCVbEg': { + 'private_key': 'UqPz3M5V99uCAacKwgrkG27U9e9bVo1MSSbf4FfjUq4pfs6DqUCt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDJCHbjXFkfrHdb6oAVetPPouVDwPPwCdE': { + 'private_key': 'Ur64JkdkiC7F1Ft7hGUYAtwrqGjrK4zVArRauxdeJQ5Ujc1VYBNH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXHuRotK6P6RfsLSJKx8uoHZhordGNEGSX': { + 'private_key': 'Uqr4NFCSHjHUkmukkY8f8wnvedmQ62j2ZkEvquSbbyqXJTPpYdcx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYXRUPAQv4Gi72QqrQw5X3rUSi8zub8WcY': { + 'private_key': 'UpXFfyDwCy9VwY1LmbW8UGL9y2HH9xabjrm7xDUmkZREfQoVgGAn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVdMvj1UgRP9ZVDrLS35WXe9p1xakAzMWL': { + 'private_key': 'Ux3WRbg6iG31GxUNCszgtzVqLefddm2Ef9PrLhHszgwrPHX3vaWK', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLiMJTYxDtHcy8cU5Gnbm2Ezm4ZHcPBLvv': { + 'private_key': 'UqKRSY9U7YQ8ajaGjtZvy9ADVyFkgBbXntxVuozVMLABoAGCW9KK', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHd7HLLbYA899Gqagy1EL23M3PW6ZXB1jV': { + 'private_key': 'Uq8BiqCLYuNjavP9yT1Ge2K1jpqw2R8Rg4jLDnz1NoBA9gNgnNCu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REHnSMQWSnm2kSW4SBuD5wActXhFU98u6W': { + 'private_key': 'Uw1VYb28tK2rPDBemWYPu43kGDmhkhUWdQ3T6WaduGry9UTLtT3z', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHG92ZZumqrhR3XYxfjZzsGMNA8gs6Zfok': { + 'private_key': 'Uqa5rHLhwAAZKyGjtsBb8fCZ4gkZH9CBEDbvf34VQXE2HCEnLPGp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTQ5tfvZ6he8hdVJCMjrHi2RPhqvy1rQHr': { + 'private_key': 'Uu2eGabUJ98s2QT3UMwvBecCKWkMPKmmDqptKjW5xhF5emkEADYk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJDc16UKQHFHkNDqZ8d1tGi6azaKujhTTj': { + 'private_key': 'UwBzynDW8UBJ6iHX8pCp3ViZPSHuBnrgzT2tyNFugs5f3kTw2eru', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REV9Gw1atbJVJN7wF6YMhTXsZcWagKSpRS': { + 'private_key': 'UviaJBBwmgot2fLFAiZJJhQbvfTRawR1S5jdmexsrnt3bgaqhJDL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQYhUpYyDXJoajcHJE5ayKZSMhv7RM3JuL': { + 'private_key': 'Us9mzvo9cXWdkakhnBowEUFWWoEJpvggd7zLsBGN8HhwEQHokjcz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RH9pbwrNNtJHfugD5EaegwRr4ccZCDhpys': { + 'private_key': 'UvNkgxFQvH7UZXt5J7gtYGd8J2CshW1RndN6Jr3hDeK2TmFdaZ1b', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQoAi296q1gC4vrGzC4dRp4NZszMS91v1c': { + 'private_key': 'UuwZditTuGX3CGJW3tGi7P49zXR6YqTtSihGeYjm8BEtu16eGZ7Q', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFDuxh1qanWMGDfE2VTHdvZpCwK513u87p': { + 'private_key': 'UvE8JimFuskJ1A9ENqGL2WJ6BW2HaUbvFRzHaqr7ZD5XDEsTccv2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPvCPyBVSRazBmWyxeDpX7aD3QM2zLsUh2': { + 'private_key': 'UsyR7thDmUy2dK2bsMKzVfnHuEj5JY6Rci1BbmyJfqrpKX1D12h9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDuRsnvzwzdqtiYW4mLrA6mD7JAmWPjimV': { + 'private_key': 'UuBnQCWULsQZKt2Z7GFkS3njbfVvuuNXeQx4AmHAYqLmerPF46FS', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJieqe36LbnPGjoAsdvdj8yWnE1woNrh9Z': { + 'private_key': 'Upjku4PKA88PZS2hWyFJVpcsrSAndMH1ErRJK168x8YECU1bLLkD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNPsgsmpT16woGxzLnaNZW7Zwbyet7igcP': { + 'private_key': 'UucBr8FkQNHGcAGx8RauXRnng7JPLyWAFPtVzGN3yJcCCko2KARG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9Ytup2862ToLoKvLsM9kDPPTcHHs5dJzf': { + 'private_key': 'UxDX46S5EC5TchKaPfizwWaMzHGWBUHE98UQgjasrTMyuJCeMiNs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCzH53kwRHBup18gbUBPif5mMpufM5twwz': { + 'private_key': 'UuYWZa7KeLfHsMVGPeQ2MpiCQJU3W5TmdTwqxdhsF18mnYZXhzT2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXhhDH9Svih4hYs9EHEazmr3G3CzVPcMkm': { + 'private_key': 'Uv9kxw9qU2JRuJjVWZj7JRJEnK4Xv5zA7Ldno4As29TJNDaXgaHT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKTwi9Jah9P7Ph4i8tkFv5ZkbwpV2qLzXL': { + 'private_key': 'Uttkv8su6huh3aZxemqxbsLP2pnY3Jvo8hd6hatXuAuXg4q4Xg8x', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFfuE3tzgHQL9VFYDs918LkoBSDgrDWwjN': { + 'private_key': 'UrRkxu98ixRKBN9TuuTD4okNHc2cU5wnT39LfpuPVrg7g2Hdz4M4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKFYuajpzUccnwfnU2Y6QbMyqKHoeDHRni': { + 'private_key': 'UvTW2WdtZqWbr5MfuuaZBGW6HYozHyXtJEsp5bwjF3YSdB8x4P1Q', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKFnCkSHb2PXSCUUxNXMFEVGhZpbnmbZAc': { + 'private_key': 'UqmW8JLSEiqMNAZgqQhhTDQRVDPMPPkBwWJS4mTqQ8XLTe1TXYTW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVjRXoiqeHHkdChwstGLXBVbtbZc29SwiL': { + 'private_key': 'Uqpy2DkbSC8imDe3HZK5tR3ZciepPk2rcL4mfmjvSfjFcUXJnvtt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNfeX5iDZ5ZyyTsbp5q1KamcycfDmBdUg2': { + 'private_key': 'UvQRy2QDabfv2VA3x5W3hcPSsvt3nNUX61L9cqWVWRGVMTLKwfLW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFNMysDs4vLhgyMqh2kjTHBzora4QMnoA1': { + 'private_key': 'UrEd92yv9CcTmhLA3bBLrSVuquD6v3tmVcGJdmcQfXj8YCmKgzbz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPe7xz44Dwo384g9eKefWE3tCsNp79ioLk': { + 'private_key': 'UqMWumbHYrtfSSEiuuts12AsoVw222n7J7xQeagbKypktKECjX26', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRtR2iaUg2Hjgfc52ykaJEj1F7uKxsLwQs': { + 'private_key': 'UrCMkVUgdNMjPLduZxqzTfhwD5Vv7A7Hg4e3CA3fCFHRBuXvfjnA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDC89rjtZ4MUXmvcpEvKpBwVyRsuCAd2zV': { + 'private_key': 'UxKJ9chaTwoi3B45mPcuzJ4NLAMruXw55TxX1WmpbF3W1N4ULRaA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBqyCswk3WLzRayLkxeJRE6358KuispExM': { + 'private_key': 'UvwNLk1YXF5mWjjx7SVL6nDw7f8vsjSBbtQMVxTQPkEQ6czbxkiF', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMxr5kfGJnqSNV3KpFWCJkSWMQgDg3A7uB': { + 'private_key': 'UsWntcQyR9n1Fp5ym3ox3pfKnqdWyHtKBWLg2MW9Refrh7TeoyiM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGTTM1VmoY7LgToCBzWVuARnhk61Tkvehy': { + 'private_key': 'Uqsp4E7qmEBbjLZSGQqKbXDcNunPijFfyDZVw2de94cCNpiZu79F', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQRzxGR8ioS6knynctDeQCGDSgUkepvyax': { + 'private_key': 'UpPMcuqfrtSEA3yyMjSLszirHjJTALYBrECyHcf13toHvXtZzyU9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RViwKiGN98Wyrwv2bg4mXhupA9ENxnMRRj': { + 'private_key': 'UvTyzbd6Gb9CqhYQgE5zCYM9LQ7PD6p1DB8M8D3W82W5JGAkos8J', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJs2ySQJxdx81qXkWRAHeKJ2G5xgpWBtvJ': { + 'private_key': 'UssAxKHcDZxKV8PoKXKEgzyaQLyKQ8xRASVWohaT5NfcR8Z1eoc9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNHqFDi18nLBmVrnQfBoxFpnjcgQwcNLMM': { + 'private_key': 'Uq6q6q9fSBSzizV8zk8xszfpM8fS22H6qysgsuUGny8XhGdR69j4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXGmV2A9TAm3xLrX5YhRsoKqEZDZSC3HH8': { + 'private_key': 'UsqdMiSpi5GN47YNS3t9N3uDeLMNm3FUckq2nZXSWzPkmk9fwzmK', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMjopm7azsF2kxxmJ1mFfcRNzKCFuzA8DA': { + 'private_key': 'UwcsjLzi5Bt3jzZYyKSDboDCKXd95fsRNH3zWEqBeikBx4ecFZJB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVrvwgFnQxT9isubSJArVvhRPXcSFqGvZf': { + 'private_key': 'Uv7axeocQD9DW1pjaBv2apf8Lv2P1PaLEjZdvCXBchjn4eBPnUrC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REx9MSo8cYiMWQbNxr8ZQjsyuwBJaVWHTy': { + 'private_key': 'UqKG69rprF9QWNBzbH8iNGgAWWLGpdGkKFK9JPXWRTF2Z4vRekuT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTSVZPBEL2FVJeGjQPTafeyt391ECExpYy': { + 'private_key': 'UvUFJeyvMW6GFSSazbug2R97wJZHin9Lnwyt4mzLakvWep8LjpWg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQB8Huw86TVZbhbdL2h6wTYDp45Wnp8rti': { + 'private_key': 'Uw8d4VD7YAxoUYTTG7cgGbWfgYWgUodwP5KeW8QbwgM12yqXkKrJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJx76J7myeS9dHzGcx8bDS6rjZb3FrCNWv': { + 'private_key': 'UviJwBwkVqJAszYUuxszTJXiWYGSNBv2yfFV5S25rpccaQcgLHgC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHgEHihdemr2WfPEA3GUjpvpjVsP3824Um': { + 'private_key': 'Usqt9s7Drn43udkNr5RDgwTnaiWsthAfWt9C62TJo2kL9bWtJ4TU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTA5DQCM9keuYkTGrLwiFahtYwfaFZRoyE': { + 'private_key': 'Ux16LHEsK5Ye27JMVAxgPw9U6QbBA4BXQWp4ToD482hCwCqHAdRq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMWm2jWdaZtECn6SZ75nYchzy6jWyjLvmW': { + 'private_key': 'Uv3iCL6vF1kMqaiC8Auh5TgCQDyBnrYyv8YDZb9BHdwUE7dzAj31', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPYDJbhU4SH7bSop5BExB2z8RuLw7mzVyo': { + 'private_key': 'UqDxT8j1cvXigGNNw2hFVwpuruyvHWGjR5zFPTe3CASoM1RVv4Du', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RR6mzsY5qw6PMApmq25S1fXG6hDhHnjbZi': { + 'private_key': 'UwL6VPQAtbwPT7KEGK3jg3T29AnpYqHq1TVcApgicGdYvxs3JMHk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFwqXQoYL9rLFjMjeUhoVNXNmG5UKP9jnb': { + 'private_key': 'Usfpj4Sn2UmySoYa74twCaRxDBsjLbkYjNT6mHQFuEr1utxSg6Ps', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUG8Afdg3tPVwJpiK24yKpTBaXo863kEoT': { + 'private_key': 'Ur7dzmyRuSeK6r5NdQWNpjvVpmh95eRcqKE5nVjhaGb2BzzazetC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RD3PtSPD2dHbjoWxTcyQW3sNWh1eLHnNbj': { + 'private_key': 'UtnJYDrM3graLtWKjonqvWwmrcWzKPLXApee6in6KiuBkhe65gYJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTHeMqQYXKYVp6rWm4qfwkNuD2CNCBakAq': { + 'private_key': 'UxKhyMUSGbp2cBLPLrwyFGvZKQgVadXXmfnZRjTDW4iq5hRYhT4b', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWLWN5k8oUEWC269qWKeDx7X4MFjvZ7PW6': { + 'private_key': 'UpPrWhhT7TB7KVuoXYFWnFCF5nUbXTLrKfdzY69GmvQmqQd2MhGC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RY4KpDXAvW9Eb7BEWb5L7TfoXoymAyxZFo': { + 'private_key': 'UtPzFYHrGGk2iKU2bUK7n4Qfe7uKRn2uvtjczpP22gX3XZ4Gq79F', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9ZHSjgVVP1WAdEEr57qjNVfShnsGrQoWU': { + 'private_key': 'UrvJXbXZy3EVNiyYdaV4vbkcNNuPM1zrNY1y84vyH5KoaK7uX41p', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9Wpzi1aiGSfqBszJo9o4FRARtjqpAmGYa': { + 'private_key': 'Up6cTHuxJ11qcAvbR5nbzZ8z3K3WLxqMmjz4NvZBvRDeZ3Egs7TK', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLSbjYKTvtL5xcyzMmd9xz3EWZvQVst7Ty': { + 'private_key': 'Uuy6HfVB2ghNg7Po4EaaxBNJ48HdeW2NhUg38zUgYREXQM3CiEXY', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RU2nz3qAgqpEUpScvpyNXXJ9QM5A9oFCcS': { + 'private_key': 'Uvy9AYkeKj52oqgsz5eh3EakmeEnuaQMAUNW6cmZUc4HTLuFbn95', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFAUCyHVJbxNyvinG5UwrjghyH3DgYRQNE': { + 'private_key': 'UxWsTrMuXnXLy5z6rzjrRepcLqkqWDW1iLSwX9dC8QTEuHs9NjAe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAMCY7kbVn55AGtZbARpDc3ezL9A9WrXhh': { + 'private_key': 'UvsC1oMvcsPtPLZL3YKaRXTixHrv1dioJZVEQAZtFgxrGe2cLGCi', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRJ1aTqAMExzvV3qjcw1iN6bpBwEksu8x9': { + 'private_key': 'UtukrZF3FogH8sbch2mC9AbwokxPvsBwK12RzE8cJFyTxFJ1JoTk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQzdXFJWnR5rHJ5uuno5dkxSGqGdi17x6c': { + 'private_key': 'UsmRR36P6Y78gVL374eJ2qC5UUw9J1zVdrtqAtJM8U3ZZ8pM4SAp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RURWN7Arjx8ET9mBwcfv4gJrT14xurPV7w': { + 'private_key': 'UuDHMSTKCwr8QQNmEg3wnYDnXdwuGDo2m3seDbhkh9fQJWjpzu4Q', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBKhHiGHpJ1WSZQgBoztnVSE9xZypKY35B': { + 'private_key': 'UpTQaXepYgWeUYC41E3c5pK9cDkPwfpUMRxEBdmsfzA7WnywVYTw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCCur9jzYVEdX6jR5HrcahmpdZYFoCWRnW': { + 'private_key': 'UwuErknt1xCseorj4rX2dt4o7NXPwasuxAqoKdSF9pVnkWEgnc8n', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAyexqiAEkmPR9kxi1XUmk3dzNrff7azeq': { + 'private_key': 'UuiKchebKEYLvpen6wnQgBRtQZFXCHxpX8CyMnnvX7Wuf9378tuk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXiUqUaDwVkG9gC1E3ewGzgCcrsHQ7iZRi': { + 'private_key': 'UtYTmfGfmrG1WjP6UTFBN6pr9cvE4hJVqKJPPh8P4T5FaPd7aQ69', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXWf8fdnJSg8aXtdLXP47vcz1UMvWEdDer': { + 'private_key': 'UxLvMy8F3WnFjYMAFjVZqD83brLu84cZi9YeE54K6Dj1LZu1Gaqt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RALy8rdhYreoEmebkBNHCNUWVu1Qea4Ygu': { + 'private_key': 'UpqJkdpKdCADrFHCo44A7CDTZwcX7oaMxSuYe688ZDbMG1CuzR6d', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHjGRyxRqvr1gJoFUx8TDZ7tKcdqw8DiiN': { + 'private_key': 'Uwi7raiGyuWB4As2FagoKGCLaZxMBNYpcKqRk3JoAkc7ytnM5SSx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLVisf1AF7NZbuXPVKcMdrjbqg6zvhajFL': { + 'private_key': 'Uri1gk5KejWiUsm4qHi5wSgemEZJytzdUuUoHXiawsuAxGvEZAmQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDWmYpkVYxQAnbnPoAMHYHaBaTt55tFLvg': { + 'private_key': 'UsK2A6qR8byckfRq1Zia1euP8ikTEFxcnBBevRxo44WepikFfiLb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REhi1WnqLLR5rBFkdfwQVoK3BzxbpDP3FP': { + 'private_key': 'UpZGf1mSXfMr4w9FsYQHnmCZxchzGDe5cAX8bRqWsMBB1LkUzfx1', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RS9QqpPRuhpH5YqEFcuw5eVwQ1MF6MND4J': { + 'private_key': 'UrzQLuEFZCp6xNuLj1eTKwSp36oBPv2EVSXxVM7goXWEAsV6UEsG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWeKeHY5Dk5k1oWEc6fZEEMEQSaQQZEXwn': { + 'private_key': 'UrCGebTkXjzJ4N1xyaG1Na7n1TmCamxY2fBxTjPT2StZEjk62p13', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQ3Y5R1QcDtY3yATwiYLT8WfD6iKw63prE': { + 'private_key': 'UpQFXRCssM4V9QZFuu81c8VoJdyaoznJfNYCMu25VN2hTX2ijEPD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQrHCfC6mSef4VXV3EAFMqPbmUtRdrY54n': { + 'private_key': 'UqSh4HqWAS97HeWeeA6XZryyALC64kRtzgAtkFyJcn3qarA7iJb5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFJgXFfgwJycs4VksgvmfhAuBoxqp7Wk7w': { + 'private_key': 'UwSyW5GsDpxUWT41N612Re4EyJrTXHPnqdFYXBeGr2F9xfkXo4fR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSUhAgcCHWtfspryyEHCZnF1sJcpLhzvcJ': { + 'private_key': 'UqcQ8GSjYRcio8paz3Zpyu6WEJN5ojDz7qrt4pqrPiJHVRJwtshx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXJhTeXWxc5sciohiiLLy7j7GTvF6pnhXX': { + 'private_key': 'UsH3F9MUvs242L6HWWrUN1dXXCUmchviWgKVaGeuLsBAnZdU1chg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVDLumUsxpX5rkA25bwpZo7TRvomYU7J7n': { + 'private_key': 'Uq6ESadc5wshH7pRB3d6WowEUbzcUwd66NQhUV4nPtoLSoFy8brG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRkyHR4dCu4N2796iVPEWN8AazLdGrGM7M': { + 'private_key': 'UwieMZAwqx3BPwNvvorKKAkdhUuw3Pzg7SaoedMyndGGpx4H6afs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPWxhb23obTFjDUVKztn3vUiPcZqAbpHeG': { + 'private_key': 'Uv2uTP4wYBJhg1Wz4gHXBkkEh1wnUaC7js1fBy68FPM1TCGVQMjD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUbDhgTunNHFnCor81ZgERe96KmTDj9gPy': { + 'private_key': 'Uukjwq41ghc3EaRhj9WtLXbpgK9vLAEJo77xK7truD7euqG5R3xc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9Uh7RobEMQqb7z9rQ9T1ywEmzeThD4Fny': { + 'private_key': 'Uq3Pf9UasQZUycsdSdisBNZZ2fSxDju4epVZys7kE18rnEwtmBVD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJYdXbXetA3yH5JEkCsjuPoAKHaauVfxtb': { + 'private_key': 'UpTnuh132mRzncpiTBH6SK9nYkp52SULqHHAfptDV6drbumGFbpR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJND13YvkDy3qEQ12rFLUEYu45JBYGogM9': { + 'private_key': 'UqtgpiFNtJaxbxEFzJxav6QXZXw1oGinizAVWeJsdxWRxdPPmZ2b', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REuxQNX6fMuPazSwgvDXTyfP8i4hKW6ASs': { + 'private_key': 'UqDzhm5vqUTeGW2vRqLmofUEL7DfHmWdxSoy95QqAdzfdHQKDcjM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RM2pFTAb55EFhCH1io497Vfyvv3TGPyqUv': { + 'private_key': 'Uq31Cy9Q4ok4CjoA3nQjCN3pebQ9g8J6tkYM1nz2EDw4kGeKEfPZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLU3iJXgiJqsATvWqPkECBCQim6rJokLbM': { + 'private_key': 'UtUwRGMX3EpztZmEDkSEFESpsEMkUyXN42GBz5cTWmQGYBaRuGxC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJQFCzuqxpLvjzJLxq7aTUhbgfmsrqzv5d': { + 'private_key': 'UxJjkgGDb2Ug9aphbkUTWy1pouxCx72nmq4oNAqTQAfraxi8Vt2P', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFqagsedW2BHx4P16gpX2B5skCeTA4yPDU': { + 'private_key': 'UtnsQsDuNgPd7BkKkrthvBQsQ4MD2A3zs8Y7APuXparJ9affoWFM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX53ppbg253assbvoxhQoDL6Sg3rCsrGGa': { + 'private_key': 'Ur7pM5hVn9wX5Zv83ujckCEhQqEjrH2FQd3aWmhVP1erSYwqD1ik', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRrpzQfaWFS2ogfV5D6r8P6Ls6YUV89eUd': { + 'private_key': 'UuYEYUDQVs161ieuWmiGQU7zNDAa9bnEdFKfk3XvdxYtGQz5U54V', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RU3Ptzk89uGza1xUcqjKDBwjW5Yy6zMfW1': { + 'private_key': 'UsJoFmqj1Rtm626feC1oftvHcf9gUw39pcamvVYw4qfckqM3MMZ3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RN8Cp7AZZZiXmuk8kJTnEoz9HpXBfhPAoJ': { + 'private_key': 'UsUyd3zgpUm76YQELhCtDC9gSVxzciR8nk2kSwxLipxFNSRNnByJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPtCKNqnwKg3rHfagYQJFbXzNpDJSPf4kr': { + 'private_key': 'UsFC5pFSc7yNp2kX1g7zPt3D9E3BGPZ8cJ7soz6KXFrUU4ARs8Vh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFC3N7JshGaeJcJd5Gv7c9qZ76WQgXNrEz': { + 'private_key': 'UpK5zpNVGyd3sPEWqS9TnGUhhkHfapB6Kh8fGTaRPBEhZYPgL7XZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHS1wPmy8BeLXboSf44mhQmUVsp2vQoqZD': { + 'private_key': 'Uw6rhpT2v9bfs4VCz3EmRWYmjhpqnhne7c4d4rJ4huGZ1XEi3jzH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REZ6asqaJiz32v3BummxHn2pkp8zkLS9sC': { + 'private_key': 'UuW7iRuv3w2uANu24YQ7MkmZmCYCaYJRXdDbzuwiQ2SeDxF9hML8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPmV6htsmEiafdWTHwYEiZ6WLqGNRnGJaK': { + 'private_key': 'Usjy52jDLi4f1L96sJhtZy9zMvsgHPxqiwxDGhgBRQUm5D9Y6eER', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKYjJErJizhNmoDpksWdUpNVT9imk3wMAJ': { + 'private_key': 'Uw4nm7psSLMvJaFvnXJ6Lem413NWb4VEGbJfbSVHoqJ8LiWDsUuJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJVpFq5EFErm7KdyDFHu9D2bfx68U169Bm': { + 'private_key': 'UrM4ZkkjB1ERUWVcRu1tHLpyyR9azuGbYmZgMDFyp2xUmttKow3Q', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLV43VnmFvbjXGRY4qXtMXcBeEgLxD98zx': { + 'private_key': 'Ut2onPTcwroWFqXKTmMh5uYoCy4xG1k2Lp4h367tgUnCfnUkP1AE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL8tfyMevqF9PmB1XHb9p33vDQJRcLnZiv': { + 'private_key': 'UrfrBZKBKnqdSTNwt7JdQ96eaWBrSEupRPTGoMw3VSuHQrPgBV3v', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJJuMs5KntvPQv9yQuWwk6rCDsJEvQx4hx': { + 'private_key': 'Uqe8kGZc9atHwWwzmtEna4ansuU6m7M4tPTjXFqLGzX13zhgy3gM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJZ9hBkJRxAKRkErWK1QSQJweeeMvcGxbc': { + 'private_key': 'UvXdAHB63aHLnqyHBswrCDghmmZ3cQfPPfC3wZmKbbm5qRVMBHqR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPS5zxDTzQtMa6rwQKwzLrFaJGjgRczt3D': { + 'private_key': 'UvCQrNv4M3mHhBjruz4QUaxjhFxEuddA1edfgPDM4Us3FXZHPY7E', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RA7ZQcwjeiVNAnKYCwn7yx4Gd217KbV68E': { + 'private_key': 'UsRDtTqkGccdbUmbKGUKyYqB8Dq8845R9y24NAYfRKSX3djRLGhD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSDoLKD6NTG36Viri717fZMrfiBejpwxhH': { + 'private_key': 'UrgyuQkeSDCeNg31vFZLKsqUXLcWbijvrWa5eRKFowEyCC87qhRv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RS5yGG9owG4m5AWmmEox6VRZ3RhykW173D': { + 'private_key': 'Urt9eXtxw5dwqPXsuvdXmGQfABTFegjYpk8oYGFbWtn3AEGF8MMq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVSDJzGMxFL9pxQTskbAUkarsa69GjKQ5e': { + 'private_key': 'Uv3NUjm1wSa77Z1MyiqL22tdqFzNhvHVsqMoyAWsCV2Mmn5vFjTD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGLwnB49JoQCvgUDRWcEEk5m4yYDnB7sDA': { + 'private_key': 'UwqihpWrxcDFPu955czhGzc1zHT1m3gQ25nvsr1ZuV57C9xuo5Fq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDxHWHL4mtt2zAL14ATg19YTNxdbnjAgv4': { + 'private_key': 'Upo2hpTDxAkePNxHfwrpEQXfwNP4iizszGiW1PN7se5rq3WuMCwJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHgYvC7bRMBeDzeucSgwCgFDZ8uVErFoJG': { + 'private_key': 'UsVY6WTQKbJVe5VYhQhr6Zyu5i9vv1FhEwMkfAQ1yBQDZpuZggef', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSXRAsaCL9hvpXjCmXPRsbF9NZgvzHcRYU': { + 'private_key': 'UpVjG9MfTPZNsV8ByZZh4sgRcCDJvNnhK2EqYKkpHEQ2NqirHTPp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RF2UogpuB453SmXcgjmC46kArzCMG1qPX4': { + 'private_key': 'UvZJcrmnrLSJHWByrrBQqh3heQ4Wb7PJt3s9Jux3EFNYHqiAzgTk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9s6GHviyBxZq6e13G1j6f2431nbkGmSk7': { + 'private_key': 'UwbUVwpTvAm8ubQFJavYJJyq2jTf4kuGe759mD1jvxY9LSt7Fc7a', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLoGtMzuze4VhL3L4DjDxQss9Nr23zqjeu': { + 'private_key': 'UrsmtUCofsSzcYjKiWBjPVoaDdwMSewBHQqqQEhMERsjB264NcKP', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFeEqzi9t9ceZjtXLkgtJMXazu6p724jdX': { + 'private_key': 'Uq7CHwn4rE8H8ozzwvS8SQqBZWCoPQv7xbpT2QHTK5uzf1fiRxJm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUQaMBo7JU9w36tiZZvTUqaZNJmTVR6nXX': { + 'private_key': 'UwqKtM8dtFdfw1FrDJvL78NxVTPFUFPQMrzqcR8pecCYPCPMjt5o', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRkx63ftnZCFDqU8rrQ62Scut3eX5U7grm': { + 'private_key': 'UuTDBcZEBSEUQgLFF3jQt4Nnt4XUjWj4VSsDA98wHLSsVNcqsmeA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPxXjnVTDi8ymbAsxBWeryNy7H5WdtMEcp': { + 'private_key': 'Uppnkb5mXvgLqAqbEKpaCPdZhzNB3QjyrHEcC1gj7Hkjkcb9V3cN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJxesPmfPQiUbLPuTSFTyWJwAZZz5RATTm': { + 'private_key': 'UqXEiGUZT3mBQgMsYK97SYFHfdgesrwNhGatk1iFRKUiYVC1qNrs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJ4eb4tGZQJirVKwEr8PZi1UZQcwSW6EmN': { + 'private_key': 'UwHaUq8pRBUby6JMNt2VL4Nfuo6zyxLJg4FN1JgCVd7pHTPBvcdk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRTq6joFvhRdk1BgLTPyz9jgEyKQ5zjtUX': { + 'private_key': 'UqKX1yoypvkivs9XNAEkcNkKVccjTdt5QAGEtiHTX6GRLXj4uBPA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGcCnghYPVvq8LkXEFKmgKKuUWzJWQwzdq': { + 'private_key': 'UuUuPxhY7rQib1x3FScLbwdCuy1qm1vigXXKVmRyB5Ep3ukqEz3R', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWiL5EXJHjkDJDsgJWMdvRbwrpzy7BfL7t': { + 'private_key': 'UpUWDtJpWxqApf31X4oNMSFLyWPfoXqhwAgqq2vzPc4ocYfVedbp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUz5znvs4F4af2ufiWhGhC6uDz3KhUsNyu': { + 'private_key': 'UsnXQhWRcR2zpptSEYDAzEFKdjE62zQSgtRkPDT1wLA8R5UH1NMG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXH2g88EZJP7doiovVYyaKaR3PDK2EqVVc': { + 'private_key': 'UtwAyp2AKXSQ9Kq8UAVUyrgGUgxpGJvc75dmWcb7w6VKFVfFm2Zt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVZtBpLNhumJGiWjq6kQbPNTSBFEoexb2n': { + 'private_key': 'Uw6RUEVkodU4Gyn6j9aoNgMKiwHaLVemGMeuk2KhwbWFGoSgTMAn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVR9mscqmnpDxBfo4UkbSu2PoTrZBU3X1a': { + 'private_key': 'Uu6BfVwYn9eUhsVnJpCsZ8jZo2L4p1jg5XKSvSQfNq43CxDMr4Fy', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGqFjYnQTNbY2MY9gbMZcAxx9zKaJtgHXP': { + 'private_key': 'UuX7dzLPCAWbNsbiKWmDgaciYvgoPLYDE3HrgeY22NqehkBsiGqr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDJwK8oVTMhQN9DcrQoCES5zmVZHedxqnW': { + 'private_key': 'UqJgSGZXNCTkW9kzqjTR4HP7gjXhnf2HgCNTuie23EVSiUCqdexm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTS7oJeSv7VUkZftiv2JNbQ7U4ii1e4ADn': { + 'private_key': 'UtPNx7bmff48MWoWuQXPg9TU2ZRV4R6cHqfUKfYCjJnVNiDgwhTM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLStmoUZkUfrr6irtM8Qih5V6sfDvmmRvd': { + 'private_key': 'UtA3FBs8RCzAAQj3Vvyp8AFfnjx5QEdvW8QbNiQ77oVxP5UrHLXd', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVdDfnXxXphRzC64zidz3u5uw6Kq4ghbjA': { + 'private_key': 'UqsbVRowiR2Kfgm4z9UtnGa9bqB52jyB7Rdzy39L32bt3fv35Yr8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTaGBfHua9GwXNzZNWvrPmH3xRxgzWNZZe': { + 'private_key': 'UwCv8SV9YyCYKaxfHQ3KQZv8Q4Qh1peEL8BrkuK5k5LXSmTf7mDJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RANvv7USsCZXwHu6CN9SNVQktJF92mKuBU': { + 'private_key': 'Uv6GGwuLkKjoXZwvdiYMVeYHaH9CDhSNSyuvdtQ9JEuYNUYgLNJL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RE6isnSavs7tHkzSm4Gt5Hik3pWbi5URTL': { + 'private_key': 'UwMTAnDm7E8DD8KPEJ9kjYQJbAeJofZoWniG7Fin5YwD4aiU9nfV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSSLV1Rqaas5HAAySKYXRtDfS2p2PA2rM3': { + 'private_key': 'Us8f7epDqg1e3dtHbVPE7aMAcpT4p1jSqTGnCv9SxcBVJT1L8zGW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNHUHNdzLs8x52nCF26YcyNA1snZYdTt1U': { + 'private_key': 'UvUpCCQTF915YjrffGveK3zGWn9juL2jUf54DYa2p8BcBK29ZLvH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBjg2FDdYPA1vrqqc4vxRkrmjsgks3KYAe': { + 'private_key': 'UqHB5C9rSLiKQsgG1nUCtp7zue3vDS7UmrYzZgDiPb8vCFjazi1N', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVv3SGfUf6cWBG7uhpZY3XLU3DdA7JUUNp': { + 'private_key': 'Ur1NwVCTGvQTtiqsUkJbihcq41jbDoqd87UiU4YJ5y5X3UyNBwd7', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPopcquv2KP6k9GPMhAGncoUicGMivyTtD': { + 'private_key': 'Usv4vLaxVC2c3HtGCZXZ9ocCKdJHzpz9M6C9n1otH2yqF7p67rx3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYK88XRvm5svRC94G4LnSJoM5WjZPiuXqU': { + 'private_key': 'Utd3VLQKR2w6hYDa1NFXLGxdZqEEzPECjgK7uZ8D2z16zxT97L6P', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RN4CHqoqWu2WmW99CCtUkqaVVUbtEuioTv': { + 'private_key': 'UsKSYbgE8Awawpk6EkaRNeTgTTRrQDp2EpXDLenGVGjVFKQz91tC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RD3SKZ92T2Nv8ZNoXekEBLWcpDVoWYhmQB': { + 'private_key': 'UqgSqxfWFkeyjj6KH7CkK2jNvT2iB6ig8UaGLEgmZMQXPu3KxFQt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWGvmb4njgtFqDpNtjk3Y9C1AyojRNT8q1': { + 'private_key': 'UwCfphHmfEk3EmbxCNeMupo7rGSsqHb6v8piYxKFDfg1M3PkS7h3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQNaWR5hZrD1c1F9ynEJ4MjNsvSVpAr4QA': { + 'private_key': 'UwwFr2wDQN3ZY7GJzGALptgUnfFTynHYp51HSjaDFmKymJfyqqTp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMZPXyUqesbPTndWSV5UvYdjtmc6RD1Dsq': { + 'private_key': 'Uvm2THLdki8TaDxz6J2NqZmXnG4W6okuapr3PyGjpuVfXM5SLRu9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RA11XHpSEshXrkVZDGkDNcvpHRQSJNvanr': { + 'private_key': 'UuJy9kmURU3kB2WjDFf9LsjbPwg1c53pUffEnBbrY95SruypJqiT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RG4ZRzcwt5bXiGqndr9vPWMhjG71DqymmC': { + 'private_key': 'Uv6mtk5XkNzfc2QTnuJThQp3yWZuR6TwjXwt5BBX91qRZL7S6r26', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUeHP2bdaTABCQn1m33YtE8YqaVZ5hc7NM': { + 'private_key': 'UuiZga6FSSwdyZDMYhM3EdUU2Ry6sUBUwzpEEUmsPjnAYzoCSi1u', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJdrmjaZ6TG7JcUVbHgWYz1FjFzbJxpM9d': { + 'private_key': 'UtYJJ1HhHgK2mjpYtEJe6kD72QD8d2TDEV8E7JD9yU6tF8AzYoue', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RU2rdb3wvXANgTfP6zKWMU4nbAj2nJWC9F': { + 'private_key': 'Uv8zjaZYM8657CcFkJB4Qwr6bPVXP5LcBUoAdUEFs3dpAMpSxJcw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX8SW6u92iKbDoivVV3DzT78AoNbxG3zTY': { + 'private_key': 'Uu5cBdKj6MQqHemtLtR4vDv4hXPfL5gLSnTGdRvrBVvxm67Smr9R', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLjyUuz16aHDCgmaK3LyKBjTrB6DEsa8Ns': { + 'private_key': 'UqsQYAEdAETY9icdTQ6ojV1Rzn22uJ68ibmfQMrTLn93s9LrsGrc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RM4SENRXmkRLmBaNN3ASGaxLFXKZVqcqik': { + 'private_key': 'UtiPd5NndtFPgmTGaHVDFPh29LAfE18swj4tbx3pnU4sYqzc4nLA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFP7N54MQFQEiNhj2v3wC3VvzhGvpcawq7': { + 'private_key': 'UsF76289r3vUF9eqRqFbrvFKMAQz2jWAAPjjxrc2NAnXhFzMhStt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDY4mktBdgQw8vDbpYBWC48Vgwia2C4XM4': { + 'private_key': 'UsAqKofkJw5a5ZGQzirkjX5oG2zdm7Pn4wKYB2No7mBzshX8RkyT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RD4bEjNbLzw6e6AN4nfpsyqRGC38yHmjRT': { + 'private_key': 'UubymcbiLQJ2wvHry7WFTjrvRXNUUwsLHRc9C1wY1smiT9vSCKPK', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKDtkJCj4YeJLo8pQx6FfMRWZTs17GuP4Z': { + 'private_key': 'UpjQVFj8ci4WN87bpfP8JykkH3TKpbxRti1TbUuACULosnbKBZu5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDZRNWMGii1LFvxfG8TjoQLsL3W6qkoCZH': { + 'private_key': 'UtNELB2pVgGM4kiKiVtaK8mTiqg9DbXNP6ujqcoTpq31hQh6qT5Q', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMBXzs7QmnNMJvNJettEhh3yw8wVb6fRvg': { + 'private_key': 'Uso245vbbteKwyyPz9ykAQmXGF3gqVUdSfrDxfua4WRcHfstAPnc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFfsPE3J7kvxnkoVQrGQrf2CwBK7NGA5a6': { + 'private_key': 'UsDXZ4bnQe1pCqvxqvyNZr4pxTsa8mGUeAJkeqZ2dXR6fUhJtF27', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REpVpqXfbUD7c4LBHjMQhzQJqnADEekQyn': { + 'private_key': 'Uq46YcrRdY7G9HKTU3fTWqYRPAvqaB7ajhU2Gk4MkiZ4Jcukir6W', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWsVn5vzTvEj6ErhhGLax69KHDc1Mf7usG': { + 'private_key': 'UwdB2xT6mpxP7eJo2SQkjrDvT11basx2ybNSGpQdZgK6PDjCQUA6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFkiGFMD4tgzLGB6J92r8ngGo9ZbHFBByy': { + 'private_key': 'UvCoJDUBn2JHY6cSkBMVa4yaPsxWYLFQ63rgfHjJZ6Ej8Dau3d1g', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWdJSGYHfRVBreQ7i9gQyZr4sWjQXPYLbZ': { + 'private_key': 'UthtKv64iY3viUkKnvw6pwFRyizHSij1vPKR71uYfhHSC4HWBPik', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGiEZPDpT7jE4dFYQ5cerL7gZwX9ssHx9T': { + 'private_key': 'UwQWeMWbKwmqvyoTdgEFxcXZtQ7HRkGrVc9WtrnQbRNJ68YsiBaE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQi9vU5wm1uYuehSkY8NpUSjS2w7eYiYDD': { + 'private_key': 'UpNXHno9Z6gvemsEojj9kUKfuxSNFeXZnP261eR7HrgeV6wH9dkU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRg2o9P5gu5NAfdRHKtgdLyxvA9ATMxMNZ': { + 'private_key': 'Ux4mAdUVFaD1j8bEPm3BBxa1opcseS9GzSHzfRaDWf862BdYai33', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQ6evbLPXgqFXzGvvPEmbrcqtspa3iXwut': { + 'private_key': 'Ux8mVJXqmW74ApwuMUpWV97cGvF7Uvmfo436pooa4ZrZUjScEXMt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RS7vbK3VyYo32X75AE3XYhTYYA3ghxA7bU': { + 'private_key': 'UtZ8YpKJFy7wj5tqo5s6K4sJfuzYUP6AQiq5T94qLTzde4m6nmmA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLWd9bhRebws7FC96KvfHMuQ7s5kz4MFky': { + 'private_key': 'UqSCocYuNPwgL2jBX83rbRUbEHZCv9QTncRD2gbdJ4oYRAoRNYMc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQwm5fa3xay9YPgj4mfn46waUKay52hnt1': { + 'private_key': 'UvGkJcucGfeCV8cPWC4D1FzKupAS8CytwzWZUj2wSCurBLnY6uXd', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX44uRvbQ3ZuQrf6AateS2fRZSxHHy8YGm': { + 'private_key': 'UtsesqSjXH5UhyepKJNPasMUURWmrTPJCPF7X2oq1BCuxjmDsxu2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RR2MW2Cm2bay5FPRgQeZ1PCPpkLD5ELsjT': { + 'private_key': 'UszGE9ovoSPv3vroRrwP41tqzoBmwCXy7T1p9QyhCVnNHuAqf5TR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRRgbKGDAEtXYUPFyJ6vftGrhh56etwPG7': { + 'private_key': 'Uwgt2iiTTtmVjeJYZ4BYjRZdo3WXZpQ3i1go36Lbn4Pp79BXN8Qo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBA8gfRbEBmwztkfHSiew4MfpRdecYRD9a': { + 'private_key': 'UwY1iFYSTD77oDkYG7cYmYHFBNnFi6jXpYBy23V1pqfSnnCu1HyF', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNMcxp5kQDKPsotJSefeApre8vD12SRVHn': { + 'private_key': 'UvT6DVEuRfRH7q8qqJHoSL3q7SD4vGt3vjtpBym9G84LMN2jzMp9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RD2MR7NBo18bScVxbokgzGEenDTi66dCaH': { + 'private_key': 'UqinBFte1jsb9UpCHLaPCCXxHpiw7vNHzCeGskZffPr4n4MCFiqH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFz43w5YseZvZJBZkq3dp5ku4Sa8JBtiTq': { + 'private_key': 'UxBV7u65Y3KWvif549yEqU12iui6DYMKdibDGmNNnr3NkYDPgvNL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJfzmAA2fd2jNfaHfhCZc8XKWDfocJ1Eo3': { + 'private_key': 'Uvo6Rd7dcJo6LYC6DqFTVYZ1LjhV6J62urTGg2GgA18mrZsWXe6L', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REfRQaABMWkFunmgnhNizMSmjrh287mLa7': { + 'private_key': 'UsHpuzstTamsPmFa9cN4CjAMgZiw6KpEaqx3GxF4WSsvjozuaudN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REhhWD6S4cmugrch5c4yo5m6Dh2Wk3zZYa': { + 'private_key': 'Uqk4dYifJpu9WWaQ7gDWjqHh3CL4mtAgV4XsLgij7sjHbDZkjxtv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBZHkzH2uJCyfDKyvnvg15EhP18CxScPj8': { + 'private_key': 'UuLVFDSt7zg3gibBcd7LhQJjZuKCX6P4kobf5DN3KKh8KfeX1XGG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRhxqwY3dzijcgu3iTvB7aCx5a6L24sxn1': { + 'private_key': 'UpvjHdGKEw36qL6m1zsZWxfi54vUWFw5AjurjwDGwYQJFj6hx7ex', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWHY2n7frU38wpDnCSJYeGNWS9fsZPRf2P': { + 'private_key': 'Uwp1QB3qxQCyiM7RqUiau7s4Xy3MeM2kBPuwuMMbtNAWh9eA7DJm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWJh4JhubVxYxDPKXJhVBgqifHnVFDC8Cp': { + 'private_key': 'Uq84eToVXbh7RKyHt6YmjzanjfGztvxnRf5LK1tKYDgkGv5VBQ81', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSrDTSCUVisuYaowkRYpanYZa94YDreLcG': { + 'private_key': 'UxV24ecb3sYGeSzkpqMhWSygA3zJKD618VLKdGJ9dtfWqJDYTXvf', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRrPjMtAxUTqomFPweZ8xgbV8a7YE6mYNJ': { + 'private_key': 'UwKHaDesBhoiHn4sjPX5rdCP4BX89bnbDRwy5ZXHdc2eU65EhAnV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RS6B9HPX9aiLhCubPnarS22PcrYgD5Wqb8': { + 'private_key': 'Uq7Kk2sqEYxZHnMfdTdZAFQ9LtTaY1aqBCijYyiBAd734v2G2Rfe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAgT5Je9vQUYjdPUqsoXe1DMqNHQpYrZYs': { + 'private_key': 'UqgoVna4jrWir1F3oNsSKNhGuP8Z5PZiPjBMh6oTDuogSJhfTFPp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL6uXGJgQE2VHwWurcaJi24boVjMtfZUeq': { + 'private_key': 'Uq3CHT23aNZrwoeDFfoMZKRAs6xqvZVgrE8No4Kd8kPgTVFV4D8s', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQ8LrcMQrX287wgHUGSp8NWKtuxN85qFaH': { + 'private_key': 'Ur39kUCn2G77DDSQgjKVD91frK4yckv4J4tu6mKAgpwxLfXECN42', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHMi5uWqUnTjZoFJNDJPVanD49yqxMzHim': { + 'private_key': 'UskQpxNtY2kwj9QUmYKDtbEB4mwWD5ku3xVGqRG1XCgDqRVTqVTN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAWUhzqFeQukfaNcXKD1GBVinFHkS2UA8y': { + 'private_key': 'Ut6TqTqKJsCDn9RS4vJx6jBkicFkdU7wSd1ncWDxhndhcSTCM7qB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSyEWcnxBJnSLUHBHj4D3LHgAX4YHCtzwe': { + 'private_key': 'UxHCHWpqnowpVdcUMcqaYaSTvRVwmwrKP4YzA23CNA9G3kF8ZW3D', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMr2r2H3Uxsq2Q3ubLnB9cuHLCwk8SRvmo': { + 'private_key': 'UxJUMwBChN1HrvNVdeP5p2RtKe47MhtXaNcyFbgHeF3natStmGnW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKGS6WucsjbE3DRe3kZrwg8hAE2K2jrPeK': { + 'private_key': 'UtFAeEs2K3gjQMpRhQGsRwsBskCaLYtErHbHFw3B8y1FURbZ8xww', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPHnXhYD3qQkn3QKkHhRHsRn88wXQXDHRr': { + 'private_key': 'UtBHbuKMoPE47H5RJ6Y4LngNtDzZxVpqXef1q2K51GjjTXvFoAis', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLSP8JweRVZLH6RkHSUYBmUeKXXfUzYzkm': { + 'private_key': 'UsVint5FAg2i2NowxnsH1JSZaSYFfc47ZF6CbBwAoeWz1Mor8Z8Y', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RV36Z3kys6bcB3PTTm9vZGqSGB1kbJM2C5': { + 'private_key': 'UxEi1ejyG1PEjDC8HhDGkAjJZro56R1CkVsfqV2zxegSLx59aKRa', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9p4BzqfueEpsC6JimteUfHYArX6JJy3by': { + 'private_key': 'UtCjtP5SnXsMEXsrBQGFnBhEX3XSDZpsAcbmH6qMqtkwXBjWNdPb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUwRdf1n3JnZzdv4mVS7LDF1o24XoNL9ne': { + 'private_key': 'UsxTmXxm5TAGFmt2X6LuTQRFcw3T4NVJaPi6kctV5pWPwd6hKyDm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUfkF8G7VkwG28kGnsWpCtfzYoMLvVHPph': { + 'private_key': 'UvdCu7Nm8NbjVUAx6jizucLzF58EvAWUc6nnXaYWeFKvNasFZ9ua', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAimLeRT2KB4gnMSp7WntCnsUBQZWaZN9P': { + 'private_key': 'Us9WueQ1oQRfNrbXmZpXCj3A1ZSJFBYZ2RBSpKsabTdeauYpbV6s', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVp5F4kbhWAWfbvgawp8VPooLsWc7EEvWG': { + 'private_key': 'UsPdRaWfuzuYtDKJS7n5kyyQeSwLraBCwfVEwR4jj9foitPneZAp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RT6QJVFHwy46YQF8u3RDKGnzD5DCNYV4Xt': { + 'private_key': 'UupABaQT4uRKKs7mXzXRL7tjqzL3Qx9t6nM1EDdgNwsrdUV2CLcg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSYwjtsjiCn2Z2r3vP4kSrig3SJogYJyRw': { + 'private_key': 'Ux15xQ3CYYYA87wtzzzn3oJ6fbHjBgTbocJJg5adJWp57YzScr5n', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REmfnjtHSbGvVqUPUHXxgWRyX8tNoL5Bmb': { + 'private_key': 'UthNvuYyqM9Pv66i3jh8C3ybrTSXgyQd8rMJHzxnTfYGCGR1nJHb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDPskCz4LqAuv4WbvhQGiVw9ai2NAbKiTG': { + 'private_key': 'UrWAgqpyQDpxMyJ8ZwrT6tKqTvPrcwuq1XaZ4yFKrUmWiR8C94Zz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RB2pUKfZZaLaJfYSuXBiqDDdJrtRLjrnf6': { + 'private_key': 'UsU2bfEw2zezAR6ykPigKT5PfDCriTWkoYoeMjZWYt6LPFNi1z7D', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX5z6W18SxL2zegQNBWuoGEdSpg4DEoiAU': { + 'private_key': 'UsLdnJJtYBToHvP74fCVzTQTzNx8jQmFZwzMZFY9FHspxZ346Ji1', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVfmz5zgQFdxWJmsD6CFx1ZRumq3DmPmwM': { + 'private_key': 'Ur2S53Uh5dmJAyjxC8zGTJxpKEJ9GCFvzZ6xn8tKaYm2MbttgrC1', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJv62TGM581bZbYhkmCGbj4AZjBsnCY8wG': { + 'private_key': 'Ur4FxnYNmoA5pYNgMrrDkh9tDiRgrxSLdFGAgdLXxbYEAarjUT3Y', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJBrb3VLKmxpj9EkyB1z5wnaUcVDCXssAn': { + 'private_key': 'UvMzvvpvWA5azUevtzDGC5UkGRonEsoxEnUUMs57iLCFpQxFSHv4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVJRZ4V9ZspHJezYKWi8HXhURZ7Bq6W2rW': { + 'private_key': 'UxTfBrrwJ9x7K9bfk3Gjiz14QDfAxjFWokuF7bzfbMMFnNsd3xeZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRsEE7Puyph1XXroyi1Hp9HkHArhecnDRv': { + 'private_key': 'Us8hzMxrQHyH2ck3DuC89CPjzhRiLfqVhutjSAGZmj4CXfNnD1bg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RM6gnMAS8KpnWSvJ4gqrV1XpyFeTBzjub6': { + 'private_key': 'Uu5uAnyZxwrrepPHirSRiZBGUFTLUTaoNzemJXE3WpjjZcHoh9wV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLwnhdMXkWsnL3gRfkM6UQYdBv3qseyWA6': { + 'private_key': 'UwobaUvU1y7NKYND6PduofHv2JxHs1447MU7FAff7U866gPjuNDu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQEHJB9fAHaKCUDSpePa6bUffT5xdzQDC6': { + 'private_key': 'UsoL77jQEcxMGdGsTtBCnAMGY4hYpQXghPbyUnHichVCFzZup7h4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJZ7BWDpiHtjv2Y4steiDDfYgTLBwh1D1z': { + 'private_key': 'UwX8PiqGZfj6eieUWRBkzdsYDuYqUsutrQE5xgdc4n1m4wcBVMyA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDto6NZdHe7iL9QnfQQ27Ydtm2meU3dHVB': { + 'private_key': 'Uv8FsvtNu8WVPsPjVHNAm9YchWdUyWn3urnrYofstxg9r6gACdqc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAcd8TMVFjkWs5sE7Ci2ZgcTe6UCJGTEVT': { + 'private_key': 'Upjg3yFt4SxsRT3EbxcyWJRRHxGJAQCevpjT1qKRp1ZWLfLSBfeL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDz639s2dRTeCDHe7qdKtH6djFn5N7uDfA': { + 'private_key': 'UtXuEnPYL1zwaAMgHHdd4V2rPNzPRM4cDpFT4JXS7CQ1p91KEh9d', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWwNqSA2LHHGaND57Ena1QvqSRj626n5UU': { + 'private_key': 'UranqSXkeBABtoGuLJs4bkRYUiMZHHtJP64YhEuUnUDQb7qJtJVD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAJnrKSckdzme6kH5vBrSd9mEsrfpZzkLU': { + 'private_key': 'UrM1kN9ckesBdMou1E1fgrQDqdGwCtw4HtSLrXEGrEsYZPkx7rpt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLKXTUf1eP2w8VpLfr3HiCAgndGm9brUQX': { + 'private_key': 'UpKA3wectkA9pwayjmCN7NPBxwTGudPj7BNgstspEtd9iFQMK9sM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLpKxNPEfM35fyatEVYwjo2Btw8ewtueBJ': { + 'private_key': 'UtkChzJGpY2jURc3WijGbz9uojtXB86PKgUxTkQcC4LNLvRaKL9q', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RT9tZ9ppkgG8UMg7hVphYpYWfofZNjobM9': { + 'private_key': 'UrfQkeqF4GufoqPFpEkj95HUryt9DQQx66wSgbQQkHnD2Jm1Wcpz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUHm5EPT3oTAv8skcV7gAJnzt6gKezA82P': { + 'private_key': 'UvYMvXbwaxVRrsP26N2Nz4gWEzVy2gJQjqHkgMGJz4nrj4QNpWwt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL7tJmW6rM2MvikRnS3vGBUDEJaGmscHfj': { + 'private_key': 'UvGZm3JqPskfWXYZ1xA7hSjsohgWMyfncUqRY6d3pn1Z47J1kNMm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMjTmTQBMrmVNSmZ22Mkf4ADoRwnLNkuUD': { + 'private_key': 'UwhJHZE55dakecZx9xjfnMou5VujsqDYdFrdDXcvP4NQnppPUSHW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RE5oHCtFKvKvD47J41PyuvbdUAFJfULyCK': { + 'private_key': 'UvERdqbRSJW39M37pkec3d6JqNhYtKG8ZUtfuPkAdMjYj9M1wLNH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RADXZyc6NmqzWQGKnFzGDCBuKxdEjc4qm8': { + 'private_key': 'UtsUUNFBFxtFyiwcdAUdJgqv77X8zosa42QbrgPPTnuuPuYCxjiR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUKVgyBwSkBaYSftsfYGzFBKBRrt3v5GJF': { + 'private_key': 'UspTdUYLJaLcBX1xgDokaqNn7fhVRgvQCAnSutWRWwp4wnZVvCEA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCZ621vb2TPQ9xpwtPo4Cnf8bRbQG3RGFM': { + 'private_key': 'UwUHFDRNtcDeCui7K7Q3iEkQYzNG9mUorLq1KGPURtxc8WmmXRxs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXytavPsTFAFY6afEzqsLQf65TirrzpVB6': { + 'private_key': 'Uph2w31yVP6HEfSRkSX4ek6mRswbyS4Xt5tWZzD7E34AUw7ippR1', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKtBFpvMVbpMT25bgcLLA9Ek1M2V4KVDeP': { + 'private_key': 'UtRXpDYD9eUpsaRZ5R47Y7H6cn3DrWn1mQchpDpp1yLswrDmmMUs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RF52oCuFyQdB6bPsU7AEN2BF3kiLY1r1Wa': { + 'private_key': 'Uppfd9VS65xqRQPVZefcJ74gx2z8TNGN3nGdFzZcGKFYdMVndXhw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNwAiK3x3XXrqEb8AEW6zRXCJMSEw8tcPX': { + 'private_key': 'Uwtxb5ssTk6o6jKQGFGgoFJQWo29vB1THLgci1N4HPfMt2ezwDWx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RP3ZUnYvebAuw7FdXN3YByT4ouJX733B8Z': { + 'private_key': 'UxXCB5tYFcNKjaonABwM8B9KRvmAzHuL3Gmn1jn7osXDzi97VYeL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9L4jgYgMH5THDmhUcLYVmB4kyLxh1VWhD': { + 'private_key': 'UwVhfjWsp4QvrDcB2sqfW2LueAKtVyEag8Pd1LA2kp1yqEu16brK', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSuBXBdJ9A1RJ7DFerHFV8z6pLiASvU8zT': { + 'private_key': 'UvxWid52YU4g4hDLo1cHfdtC6EebZWgtiP62XysB7XKUYANaYR16', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLEh8Rn918czr8rqENM3qcL9Kyn2SynXYX': { + 'private_key': 'UsvhGEaNMaPAy7coaBj4cRviRcbmSW2nYABEoLGNSf3htL13gZLa', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUNaYoc7z7QL7SzX54V6DfcoTdDZQXb3iE': { + 'private_key': 'UtqHWS2iqBp59jzKECvvDYjhcKGc5CY2XGWaJYNnJVRdubhimPGT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVg5R8SK26GG3Dt4U3f6hCTrXcn1YaigPJ': { + 'private_key': 'Urof3srKLTYX96zraF45mtmUmK8dTF6GiRDHqgccTPR8xXHLzQ9N', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHfjxTjp39wB9RoWhp6kBS6abmYU8WSZrS': { + 'private_key': 'UukwRGDNhQBZqRsiuYxBfnABmo9MiTsyCSKnn7GUq6pSyjFWLy6n', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWaGuRgoM9dK87WnXMkSdSFdYSgd1sAQVy': { + 'private_key': 'Ux8Gia6JoJN4PZuSoy9A68qR7HF9biiLwZLtFMhxh6f1Epft9tAq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RM1SMYRbTeGTVK6auayC5HFA8CxgrPjq1P': { + 'private_key': 'UxPLYJqQ2hhNu3X8NFySzdaBSodEztCdPCPGcdyC6SgGwcoLSeBU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBmys1CZLnD6toSmPbSzUK3eaG4tduixDb': { + 'private_key': 'UvCK4Xy76YNeQX7mZfDQ76gZzwWfaFvSmv3zqoPJQCsLdQaEQkQy', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDApaN96ohbQqCeNdTPUdKUUTX7eHtFcJi': { + 'private_key': 'UuYhUiNUy2hNZhf8LKefPhjGRjWDGjDjs5C7mhCZB1rTpKhHcGXd', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBT6vzpiXGGTQSY7qzVEf1LP1ymag7mN8b': { + 'private_key': 'Uw5Udu5DdtzPP998DwY5g76oF5VYTkyuBbAmd6FNbDf6uyVSyR92', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSYtF31dQkVE2FgfCHfiEEZfK6aPybqn4e': { + 'private_key': 'Uw1hKShf2VGqTXZXax1Arz2oG6zY2Ht6JY4zNkfYjC46thbqQrmT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXXMvp1fUzZQAnhE1kStqFLEnCCNZTdUZL': { + 'private_key': 'UuQXQp8T9ktHvFjLLqErdF2ew5rSPpkyYkMrzNBkHznEM724ao66', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJzAQJYmHuUV1DnCjuTBSCod6mAYahneYd': { + 'private_key': 'Uu3NqzQKssEqrrZLNbaGSfFeA44UxTTPao7fm3PiYipnyWx2JxB6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKU7X3aUDXN3eS93GendSE774pstgiYYS3': { + 'private_key': 'Up9DbEup5QBZon2FGSviPNdsE6k9pkdAJGmnLexaQm4UsNyrzRX6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSkKZFAZVmgkQNyhoZGEYUCD9GqRnfmrcr': { + 'private_key': 'UrxrydR9W6iYvmPQTMw83kmjaUj4KAAxhCESJk3ikEsALZFySvQV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNENdbg1rb3vBqyb4Xy1De7L3BrdQtEy4p': { + 'private_key': 'UsQFPdDciMscXXKkq3LcmCHtEoCkpWtwffhft3NriDkhs5WrEyEh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKKHBChvzYTkmeZa63bKJjKHAUxXL8fbVp': { + 'private_key': 'UvSPyjqRwKmzppZtU9iV1zYCjAkss3oH5xEo6nCeJpQsfThcMcaV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKMwpr6oqSekbaif3W8nvVNMScG8hXXgzn': { + 'private_key': 'UuX7wyHRUUyVLbiFFndcmPWbKwT8TuYHszAbVPLC46qDUYKCiKZQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPN9aj4FHaFfCuSkysXi6MzgNVv5TCTs2Z': { + 'private_key': 'Uu7no9PqjskS4N4MkJm7UsJErwZW6e5W6iYNvjThuzG1tZ6UsEN9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RK5vK2JGu3toEhGLSUWN3TSodgbbefNfEo': { + 'private_key': 'Ux9viQbUgSUuQgX7sEG2Bom2JGvoZZ4yCM6GNX9yBUGCEUJAkDLL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RP3DpR9dTvEfh5X2uW1aKjornR6eF29p5e': { + 'private_key': 'Uq9Knjmw5GTuFJ4vs6kdx1JKFnHqzpZTeDhV7jkJ1h3R1usTYPGq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVNkSAvYZSDiczDUGaqUQQd7sedwhSqDK5': { + 'private_key': 'UueG4PKzBAJfKCYQc2M8v7a8p6A2UmbwjUdcwZxmAwbwao3uLd1w', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RV45Xntj9qorNuXV4XJqw48JTZr6j24Nb6': { + 'private_key': 'Uuzrn548faM89ZS1J84TYk1bo2PcuU84j3aFr7U4GrJsXU72E7Vn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWGnFPM7kzeG7hNfPKHZ4eHZPAxQGA7Bth': { + 'private_key': 'UshG48d2HPfsuZDWJQL8u32SU34m8srud9SHVAtE41Sn9G7BjEJ4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RR4zWCTFxhFmXawx9QvpgKJNRqLhmjpWuf': { + 'private_key': 'UtkGwNvvP3GwHaF6ttixyFpScHmFZqKAUiBTLfLXvBHFZhM9EE3n', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9y4uZmbYcPQPoenRYKLL58ZDG9mjjKzZG': { + 'private_key': 'UtsoVeZYgz3J3VTp1fKeCBzVx1buKgc1yQWZQSpFLEW64RmiSruF', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RH7Je258aKCHcsGk2NEC3AJx1KhMYjjeiH': { + 'private_key': 'Up1j9ut1YZvU5JpivQ2E4qoF8SBaw6DoDfwUt4rzF3JhrT8VkHZW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGpnHrYpUdimjuWSZ56Eb6hfhJjgZTgV2k': { + 'private_key': 'Utep4MxK1Ra3SWuYzrSvgR1PiWsYERpesRf1mma88PjvPqFtjHoK', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTUmqZNEwBGritUyyZxhUBv2MkMLatPKqc': { + 'private_key': 'UrTJov8TvUkDhG6oV21nw1f4PZgmUcyV3Bmo4semL48K6H4aoWt8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGdgA2fyGkiN9ZKyPsGWUeZd8EgvNV8unG': { + 'private_key': 'Usc2QKxyazM6TcmDTNoXbmNMW8z8c4gUwEXY8Q6E4kASKgDhaYiF', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RW4Dm6td7cJLWKLdM6b5EwQZyYRgh3KNZC': { + 'private_key': 'UtMAqcgfPDFVLeKw8ekvvvzNURLCqNYjSSvuuvRHxFvE1yAU5ZCB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRnbxgxXN5p1S9Yw1W8PjcU6JVmP9GzRNb': { + 'private_key': 'UvgrF5uUDLjR4gPUmhKNhMuxdPJM4BEgVzDT1Q8afhzpfj1x6Sxo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKNz7Y6xndxdfPTxvXM91vCMpUmiBQwN6C': { + 'private_key': 'UxWFQRbDCjMxrYJg8xu4RNME8Qmvyniburw9AmCqCdNyFMogpyeJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REqsqGJmhdecYqbQT2smaL41dcqXxAzW6F': { + 'private_key': 'UpiBVs8w33vqvDMdeSeMEygAFnhdJi9RBDeoAETpMjiVv7HZ6dWL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQceBQhMtjPzmquNPehQg65DqMvkrxwwpB': { + 'private_key': 'UtBZJhVPRwGQt35Uk13oGip5jZF7z7DVQP32LWtBuCMpasRU2RCQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUWt8Rc1RawFgpyETKB889q6cjuddpB1kj': { + 'private_key': 'UqqT2MyPp5bnSooXCdf2siFF47AWSEca3JpDSBX6edVNyRPqX7mN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RE23WToZNyfscqPpVQFM51gnkDLriEvguA': { + 'private_key': 'Uvomxsk5K2y7XUeJyBkjN3q3kmT6G5DiF5LAqcr6FasSDqyJ3sxG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBhgZ8wpU13jHVRyNjCNJzzrJDmag8Qebq': { + 'private_key': 'UrDzFiaQmEjbsNQk3xtnDEHcMbw1WJaa9Hi8JpgxMtMjV3D9yLUp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQbsnfmWTWb4jEyYoS3995Wp7yU8UXRKwq': { + 'private_key': 'UwfHfSxCTTzTYrLwiw54cf8n4rfPPWSQDHEgsNr1tCrCXrxAmGTf', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWBZnDt1Zixp1Hm65ZepT2itf9pKo4mBhX': { + 'private_key': 'Utb2he1dpHizbov1uTqMyzTkgDjotw277DM548N1aSmThh51iDFh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RB33kB9u2yiP5tWGMo5NXPmfqCo374FLfT': { + 'private_key': 'Ux5MEC6HZUPUFGsATfSGBDjnGvaBVMqwbdnPwLb9FjzURMxwrK8r', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDCXCxcQcWwvqhfL6ncxRwfqibKtSto765': { + 'private_key': 'Utfo965eyfp4srPDjdRLrR1AcSPmo7X996egXuEg1FpdAQQ5LUJt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTovQCS2rVjJrkoxipDHXb43uTnaAAMM1g': { + 'private_key': 'UsXJhaWE1GhhxiYAu83UqSvN1rmmEb58QyuavzSGB4ai6v8eWvbo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLDHxwz8jcxrS5Mj9L3YL2X2SJ6ESo1qw3': { + 'private_key': 'UuLuRxy3e27tqk22wZq9ewYeiryFJ9hUF1BNYPsg7WXpVSFwq5Dr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REdj5phyBFN4XBgzJ2bcXN8cjCgTuyhyuH': { + 'private_key': 'UuMRkdULqJ5HEm8DQehzLexLoDALW1wjLEHPDtquTts5WpQxGagx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJGghX4yD2ic2Aa5VvTATkdZycMiwxUYK6': { + 'private_key': 'UucYJ3CJk2fr4tpQgJwStpL9at4n6azZhFC62K9hDAHUjjgV1RCN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGJL3d5cDFNBKcUnyzcjhNwVw6AfKSYeu9': { + 'private_key': 'UqryFMCRkJEB8YseUEU3vH7Q1UPkwWx46n4WGzRxFULDoAPokPb9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSwVZjSyRsVJGrMEMzNEokP9pUzPRJi7x9': { + 'private_key': 'UpgME4uAaHEBF94DwGFSLnFPAofLahRofaGcR9L2f3BkqQD1d8Ti', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQEPh87sGz6DgoYqxpVucFkzy5xUXaFPjn': { + 'private_key': 'UvinQCwn44hcD4RC32MjqiAezspV12HitWyCVYTeY1owxBJD3iwo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDqzJtPY7K3USmvuk1dasMJ7JiL3d2pwtu': { + 'private_key': 'UrszaLomizBHRDcHfy2bUNznsaTusa2YG9aSs8p4k7nUYmFaZRag', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVBdE6fdHeP11Kgwzuq7uTqRuhwpAuNhh3': { + 'private_key': 'UwTEeYdENt7XgtvneQmybDyp2kgE2anRAGkX72q4XxMzVFgStLs7', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDv5NqWVreeLbf9qV9wnT8hYdYoN5N6jdv': { + 'private_key': 'UpeqaRu4R2LDJnmhmEFKZqjKzxbdWP5adTSVrKcZNCSoSqvQoAwE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWL9DU415ehHEBoo9oz4BBLt8gFQZsRmUe': { + 'private_key': 'UrjCwgFbFCcGda8dbgmAszDVgGA7zBsAiuKyStnfTQYh3qRgfi2Y', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNZZzCBUtFWz33EWmcQX2yyuyMYXCgzRLe': { + 'private_key': 'UqUeLqJE4jZ7vaJGVq3xnq328BFzbU8EAncPrBA5FhtvfSoToPoB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAEhcyFK7jVX9uFGkrqAzbqmWSw1aWEmkM': { + 'private_key': 'UtWfbBGJMJZKMz6WKB7NSCQEY9SKXZn6Ukxhit16DfbRCYupVzZh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPqD4qg7zzX6kXqnTVEMZt4KdkgXx9AWky': { + 'private_key': 'UvsEnuwDBvCKkTEr8AnAnksuZJQcFoFxke6DLbNC1MuLGr86MvFC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJbYEutEVNHsNiB5rqkiTh4vNMkHt6NQUz': { + 'private_key': 'UsfvXag6tbBUsVRizGmQz7rWr2RL69cyBsDZSKK3tPWfxke5F8DL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RM6kv8Amr89zWxSftKvXTieJA92JySHXRr': { + 'private_key': 'UtzEWEahYE3jMp4GnxXDxBxff76cLx8gX3JTCg2LHuoMGBgH8ZDV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUbp52E1GJrm2UpZcwHwPQmvHYBhZ3Nrdp': { + 'private_key': 'UsPXcEDuLwfjLEaKKrn9uBkEpYorez9DpjVLfdiNos4pHC6Guuvq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMogpS7KGJvrZwZnyrBmf44ttfq7QV6Y7P': { + 'private_key': 'UxEhB7yzpPfMk2mJ4d8rqfuhyKGXPr5RY3KC7FMJBhi4rJgDMLjq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKEC1RFMaNFjeDaiHHPsEDm4ZGQKssPwj2': { + 'private_key': 'UqZd6WK6mtKghCQuNhhd5Zg9mNYX5xq3kDqxHJeDksLQonaeNoeq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJWor1uEWQzWFoU3JbR23jheDBjmNyKFX4': { + 'private_key': 'Uqhpyo63Bgc9s7xA17uMLjkcCxeijEJR1343xsZHhRwYkbMYMnam', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQNWV6KDX9j94uQic1jyVTravp8wL1esGq': { + 'private_key': 'UrSyScyZNpy5oJxmbYVYdPskUZvZJghjLgT6PuSrgmRqgHkEHpRv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RU8J2bwuz5pBCJ7ouUVqaLqpoWQFizVZN3': { + 'private_key': 'Ux2b52cj1oai496Rmn2YnSMiYDcKNxrzoM9A9KPwYC81dRussczX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSpyUD3BVoshqxdoomN2RMAwmu7Qmuy9UR': { + 'private_key': 'UuYM669jfD4ce7rAzCGM2ZvCKQR4vwpoWz9aswis43K6UGrc9kh3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRStbVz89yhMvjwqP4BA9Uka26Bi1apMDU': { + 'private_key': 'UqanM7jVwxPQfJfn9u5t9nZAVPhZT3eRSw5LNap1anrJ6znfRvdB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMmZANaM1iAk756wRpdzh466Zx4YWkq3e1': { + 'private_key': 'UxZ5arLkdFKf5ex4MhnhK6TzvrqmKzpArdA46gBvarzrkmZeQiAn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTKNhSkSRsgJx4a3y5bLPC5gfJAWPaTAao': { + 'private_key': 'UtPkFsaJH6ViM4G81GCPnyBxm3J69ULrc9ULvkWhbB2Bm6ZHGQ8Z', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRto58cmfyVCTzSjmXkJJDcF9ehYCT5Svu': { + 'private_key': 'UrNawYh6fVvisnX6FsJUFuFS7sx6CySEyBiH6Yh8uaGfjQQiehAG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RESDBKW16Taj5xQp5Qd7zhXgTYPvcvNRrs': { + 'private_key': 'UsBJrcw3m7xG3DSJxDYqkZKUC5Gj2hsZeFbL8yfyPAjodbkFt7Pr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXbHC7NbNfzLmXpjnm5i2jVAv7ghWFRzD4': { + 'private_key': 'UwGkzm36JfM5c39j2LTmp1AXsVtn5kr7QyNHzfZs1ou2vX79swnV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDesUhSZ447167NDtxvJKprvNN1bCPVSyt': { + 'private_key': 'UpAqfSeAjRpU1vpsGTbxBNCZgbkoUSWkpCULsuiEpzqxNtKfVYo3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFwC2tH3PJbTAJTGnhTR5Xy27dZaVPSCsA': { + 'private_key': 'UtygeP5ZNopXg4kznHu2khgak4bpBtHD7QMQw2K42pNr7CWr7d3k', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMGfMSLu9J85LGVgh6vJZLHQ4Y6nBsLT7z': { + 'private_key': 'Uu17FHaGLxP7co1EPDwbG3AxbsVvY981EcvcKW4FitGQfg7RgZyp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REYeXgNezudB5LhVQHdV22DxZwVR5EEAcr': { + 'private_key': 'UvmJ3V4mhESGFoqHUGrZVgjQWNF5PZziFd1UvAyyrKzgHRX3TCgs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RW8P5ADQMvznmWoogsYaUVfNb5oRjUmiN9': { + 'private_key': 'UtxkkBMmdA2hXinn1CBFyNXQp9ik5iw2wJp9jmZrQfyPQgFe5qnZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRZepuDTkxYPz4VitMbN2Y1P93b8r85Bik': { + 'private_key': 'UueKRnbUPZgyddA1E6VgsidY1qVasCydAYoMohwF644XbCnQddxg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBgnvh921aP5E7sfB4N557PgSXH9Hnk4qs': { + 'private_key': 'UpGk147tpuGrM9yFhWNHf64iCTwLHCeuSFxq5kh2nG5r2uAbxycZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSKje57hUVwRFSsXUscv74KzEz2sQau4zT': { + 'private_key': 'UvNLdFLR5x5C8mV7RX39sujE6EY4mywtAZ4iQvPFzYZB3k68CVDz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLQKC4AV4eiygZ6skiU7nA7VdL3TvGye4X': { + 'private_key': 'UpiKrrLmS2nLLBp2AH9oU5xETeewm7BoyB96Gw2NziCZHgzxUFM2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVwCzpDSYf5T5Wg6gKkbdcUeMW2g1dTYmt': { + 'private_key': 'UtC6UJvcEGLyPnKY9MdZDVNADacKvkg39eJLUtQDKHyA7DA5Y7Se', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDkH5F8ASXccr2qVGGovYUqp1LWLK9sY3z': { + 'private_key': 'UtHCPU1qyXeU9RfJM8oKXjxENN9zSRGpt5WYbUm6YAv2NtzLT2mm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSgt4LLgbjiD2kyztzfhhxNSsVpCvJCfBf': { + 'private_key': 'UpRWhpJa4VjqsgJ2dUGUPFry87jfW2FhtDe1mZCScAHo76tde2sV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLkNmWpzk7Dz8PUxLK2PF7hSFat6s7NnDP': { + 'private_key': 'UrfPRsDHxYev9a6Zx7M86kpK8maC4csnwRYWmXpX1bcf5UtedRDj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMQ6q2xWt4HLsbBca1PqGd3oqYcb5y8W4c': { + 'private_key': 'UwyHCz6bPa5skkBB7CDFfGG8k6dieoo7LEt8Ajxc2ZmyKN12s7SV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAFQALVvDvrKza939GVhDFwqwN3HKDYqQD': { + 'private_key': 'UuoaG5RUSB2hcMhXoh1AZBDA1NZz15yZMaKVEwhCwo17ybVveLbz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNgbbv8sBpQtCZBfyfJfvrhJEYLbaz5nJe': { + 'private_key': 'Uq5VVnQpvVdzF3NhivBS2bQtdsd6DRcewMz8jZ7nC1P6JEC1CQsZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RE81Q4SozqQHqq93LoDFmWmW7dvJTazGLg': { + 'private_key': 'UtuspyCJ3PyRaGQNcELUQkZBPwLby1ZSvry7zJmjsMjH26fzLf7x', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RStfawdKivntdyhvzP3TZfai5PuNJ1jfgg': { + 'private_key': 'Ux3mmRTPUMW4zVdvZjbLXREsDfQzWzVgQ1rxNAuqzcxHExWQo99Y', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJefapWQViuZMbYZ7QWGJm7ydkmYjvQe7E': { + 'private_key': 'UxPqZzC7p4Z6LffS6U9hfKP1VdRxHHQfndReod39AFaFGXa65Wqt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWrWk9eXA8uMLFeS6THQdK6WjUCMVMA5z3': { + 'private_key': 'UqvRf4DKiaiAg92B6mgiMNVEGGd69qEFRaw78jR2T8CeRkuCKHSy', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCWvFgaYGgMk3FL97Q2WuXEj5wEK8k9HGT': { + 'private_key': 'Ut69Coxgwsxf5Yp3S1iQhgVRiDXnXhvyQnpBfenJ5GN8dVQpHAev', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RK6kXcaT3RUzV3fj1os8iEPNh3Z9CBSme9': { + 'private_key': 'UpLqJV88RF333F9KaQWayWMCABe4LxsCt6urnvKW3gAiUmfVjKCz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBCvdLS1FyeG4YyybDz2kBLfBNLy7Jze6u': { + 'private_key': 'UsPwt1QjwBSfgnAXCVVzfUHKfLD9Qy9DeMUYH7ybi7zvKzr3dMxo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RR7peMBmRccWBjaDxbm7nwURda65WssjEx': { + 'private_key': 'UvZ5v61dYZjKsQXujxjMooTepwCWrEP3Frd7vAjZ5ogqzD4K9yGA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBH5jhVVWTc4zoSLmrQwQFe9uh5vC643LL': { + 'private_key': 'UxQqUxXyvWLN519bjU55rP8WpmkhDSgo7Zg5T6ekezu6HCLnR1g3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSeFEfgWr411BqahcRkVUebVxyhVTQLCmw': { + 'private_key': 'UryEJtjoJh6G2nssvfxJxErP9sC1a9TsPxFQLfpoUdYXNLqEwgtu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYEVyo6UyWe1oSvsTNHvvHZy6Uk811Zkob': { + 'private_key': 'UphPvPV4hecY1rYQz8gQJJaYr5S2AGQLfnNE4x7oGdvSt3aB7CJi', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJfeMChkQfuRef8c63Z5znuY67ZT27siyc': { + 'private_key': 'Uq2fuhSjNxyhvACuvAHBx3Fih2n64FfV4Eo4gAFSL9fJZcJLTnv3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNnVrWpGtXJUVPf6snon35pVcTnCFLtXGU': { + 'private_key': 'UpCqMvKbdyLYRTtZeuB4UkqbGosh4edw7NSVEadZo9XNHd8PzjgV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRj3brBaqi9nf8Xd7L2jSs2cBoSXkWpEMX': { + 'private_key': 'UpPytJAhGrCJaS75bB1BGPgBSVLNvcjcr18iybQR4RgN25J65DfC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RA9YWxib1XpGCXd85dVPfgN7ZgdgtzoqQx': { + 'private_key': 'Ur8xknr4kWC6a6wwPD8NJ9GsjEVqwEmGwtbwcQnLFQPV7nnSGfgo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REMkETvEEAx84qyagsD4ZYsjCnVvHua5K2': { + 'private_key': 'UpYj8nWvr99TtV8Mfkm4fTMxrirVALSZfbGkyf6b25nUFaA6yreJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNfng2niEDCxr7nz4hGnV7MG73NqindJLS': { + 'private_key': 'UurfyxCa81nx4dxJjUD1PvZwJfazfRYmuRmjtGvg92HrpjcJZb8Y', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX7g17RnzMejwcoUGZiNP6GQEQXp52QQXw': { + 'private_key': 'UpiftVDXkoN2y96VKjrhv5cJ98XENP2tvchKVkVMKMTDCykrf9MA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9UJy5F3Fq4pW3R2YDqXpbNn2WPgoQT3d5': { + 'private_key': 'UwzFM1HxAXDRnEvh1Uj9CqmHkrKvXDMmoPniVfHMWzBukBQe4TZg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVBrKu4em8wwn2GrNDkkBTnSqVxiZzdgvi': { + 'private_key': 'UxCd1mJEtQ3XjA4ij72Rxnv7USb61TPsUNSPR4v119uV6iHts5Jr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRHgDZYvJBJXRGyAYP1wD9sEd4z7Jzd1Lg': { + 'private_key': 'UpCJvYTwCpYUogaQsv2VXzsnXe4JsXYE1CzhDQTgrLG2DhGb6mQv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RB4asFq4NRQyzdLkPSL1ExSDXxCAysLEgr': { + 'private_key': 'Uv38HHUbfpqwD2YgkdjG8eJHE5DisPgUCs7WF6iRtYZ2YbHdQbcQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRUh8ARCkKKm8hqV3PwbLkuX92vesSubGH': { + 'private_key': 'UuGDiNgVx6c7KhvRJkFn7Mq2JaMHEkzD1HLB31RwVzmG9adpZqaE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWVjPCycNDBW6EKzkzrzYtvARj1QCA7mZZ': { + 'private_key': 'Us3mSVBDV6UJ1w5LUfjSgUPXv2tNKPvv4PYkXcoYr8nzjukQnq79', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWi36xqXyNbptdAFmKFTDbeoAanWduZui6': { + 'private_key': 'UuQnGBcAHN1ZYzZEHh51GWH38S9UmHxRMP3yhvD5dRPDcR9dXTXL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHzmvS2XkkchdtJZb7Y4oTYdFtNtFo9UYJ': { + 'private_key': 'Usza3KvPHz1uf2iLoekMuHeXQtqbm7V8HK2mS9wx6CXqWG1dUmky', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQ9h8PvFZtdqszu8FpFdf3KRoSY2fiL9KF': { + 'private_key': 'Uq7vaB1u2rdnK3Js2eQKFBCzQxtDMktYZLM5Uw8kCucqSZwUy1Q4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCytHaNWAsHortKCy7oWb2aCEQNNsFbzTd': { + 'private_key': 'UrKDgyaQXvpoodbtq3WoFtxdWJjPifp5UhYm55T2ASztBTdxMrtU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQ5qacDCM2YPJoHYpb41vLrot4EJuax9Tn': { + 'private_key': 'Uq2irfrY6jzDfW7epN3PRJBBRXx6xFG1NtL3UJFNkv2SGBvgJxMs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNzi5NaEYQtiSSydNMEeHochRVjjyzN7vC': { + 'private_key': 'UxLT14RinqwZxzyHEmFi636WU8JbZnt1BHEDfaC42NDS3ncTN4VV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX9hZQ1X4YeFtgonSx4JD4sVzCtZdnb8Kj': { + 'private_key': 'UtA5pbDB3EXWr4sRRytY9TsDnZhwoqMDj85mKZg7afSAgwrZKRwB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJY9aVbU4PEz6N4YHGKZKfdsHon1zdXw6f': { + 'private_key': 'Uv9pYKyCfQUnt4euoDummAvhPdti2vC8PEXes8LJkqwouwZb9yvx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHpsza99tGdNk2qU52nCKhVJMaqtQ7Zz5p': { + 'private_key': 'Uq5uvNUSvRrs5qD4hWh4D4tMYD7CUw3ZH484DdDLkJ4thvb5Pcjx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGneXScKReVV4uaKu1YphpFuXPWdDcuqyh': { + 'private_key': 'UwrE39BAHntC2N3JhM9bNVGnMY6Nn5NsJBobkrqHGoCgPWGc7hLL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTaGYeoru496bERJWrTV2kwj4ugCrN14nJ': { + 'private_key': 'Uu6Jf9m4WLWQatxz7UXiM6aLryifms4RBSnvrQFBRkzKmVP4sZZn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMriY5RRoiVTgEJUrxvg7auvy4Xof3EtkG': { + 'private_key': 'UpTEqgrDDWTDVn41Z4rmY77JBCpnZnqrubNdypiywQrUUwUzJw9z', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9UVgnavsWURsH7cjQt8jNjFS5rgTC9MWx': { + 'private_key': 'Uth5dchjPARPSyxdZvwHTR2vYaZmLfwAB3Up7okSE3UrFqBxCa6G', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVt5hLLRxmMRkzHMcSASocXTTT7jVmoh67': { + 'private_key': 'UtmMQyKA94u5UYv53chYxAxK9JY8eiCdXxBLLEF7AkJA9zCszZ3f', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPiBMX6AuR7yM6EYWpuJRK8T5zhniXgVYV': { + 'private_key': 'Ux4pGDepFyWBp1hczuKLPsbwvwKGW1NLZAzcA5PeYeTxzD53PU2k', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHZ5wt7wTnikx5Ye53cT7inSygP4NZNESH': { + 'private_key': 'UtGiuzTzQXnUBP1XQP2wzvCEQ4UThbSpcHaEFGgjvA488Q5jWLDU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKZKZDRd9eZMRtvNhJgwfi1NdG9WawKB9i': { + 'private_key': 'UpJwTfKdCqrwiLDkvDf736TiF9RiMh2DXCwtkRTwY5NuCX87yZAM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGCMhRZsJa5FEPEPARdzxScv8s9ooYr5dL': { + 'private_key': 'UpLdUoEuhfhZP5bWYyvMiEAnoHi8y3qWMaeRopXj7RUD54Dx1BZN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RF4H6YY6xPuxUCsFY9bvSk4p4ymWdfznii': { + 'private_key': 'Ur2JrFgo6X55TpybGAqzfCqaUyLGRJS7ZGxL1kUJU8e5P5KZqNzW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXYECT17ZbQARQsvvieWChP2VffQHhVRm3': { + 'private_key': 'UqJEwtNcgnt2W3S53sjXPxdt7WyPQNn1QZ8nYQf2nurV9eHSo58Q', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RA8kVDBwkJysyGGnxhiiudzXHkpocAZo4T': { + 'private_key': 'UugzK7zr2TvP6V6fDjsr4pJw6GzGb8vpwafWze4P9AjzXxDn86F4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKM95KX8cCJAjuG3e6ZznNhH3vgyLmH7Ef': { + 'private_key': 'Uu3P1N9GbuQe2DGEca79DszfenZ2DCYf7WLB3Zw1bwCRcJpicJ9Y', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKvFL7XXCvzpXg8j6Zg8jb2brjkvS8D4M8': { + 'private_key': 'UqoBNh9fx33zSfx7wfKoDutmJgaQDupN2Vo78a2279SHzBNnjwLp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWNT168FyJPrxFQjinCrMKvGPsQ4RM7iqJ': { + 'private_key': 'UtcUNPUXpaTMuyMvAL2T9AL6Y3Esq34dvevxYYE2xhnkrURsFKeT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSqyT4m1bXUGgHfLTthUFFQN2d2zYPSeMj': { + 'private_key': 'UuPKojRiCzMozuhYTBaShpFniy3G1RjDoC2o9W57L7pQFz87o5gc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RB9AWMCozkkegkSCrvL4f479BkYacLAd5W': { + 'private_key': 'UqrtfCkbznbpBTdhDWQFJMihATf5Bupg7j5aMqXihPEJwzUEs6H9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMDRKwKLHQLgNwR2jrLsUUNvY1JrqUSvsu': { + 'private_key': 'Uqae4sbGXuFmDoxJo3U2qtmhT8NWo4hWZiVGkR7vxejmFeLre3hT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNCJRfpJQT3JLDJPHcN4rXzcyesGPdB3zx': { + 'private_key': 'UrJfDingw7xm1tmTCdbd74s5mMjGvECptAg9yEhyfMFRQX7rkwzM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL1PtoN21nepPPWfAkCzWRbJdCPA6JE22X': { + 'private_key': 'Uue8MgTFysQjvNAsbezVcfp4FDA981VL3jMeztexS9gRK3QEsKC6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAYhMfe9DqK9Mm3AjxK4p6WEiGww7PDcN1': { + 'private_key': 'UvcL2Pp3Zuei6Rz7pQvhQ92gBSHvrcNkrSYtuHVxuuvu6qGDy4L9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGxTXo9LJQspYVJsVgUc4vNe4cdYLyahiF': { + 'private_key': 'UqmQB6y7HjeyYC5vL7ppmiZPrTfWRCBnum9JKkiuvW7xpKWbcpy9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUBJiz2tYMT1t8dXmdW2Yp2uJ833u4mbjU': { + 'private_key': 'UqKjf3NLg6yUV4E4ieKkiFrgeksWULXxSL5KdJWEVzbzo25XZRXz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSdMBGf6tWLu2KCvFtnYvvwdoo39iZ9b8h': { + 'private_key': 'Uv6SyuobC5tNhFHfftasKQAgQjmqr5jww1jSyfVwNSqcCHYMyhe5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQPRV5Zd8a7t9Vx9y64L4JT3h3BYodR8VL': { + 'private_key': 'UtdjDV4Le9Njx7EEjp8y5ud9rWqqo36sDzFqowKZPPuTP5P1eFFU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDUnuAYtmNtoxDRrjwQsNx4RMiidszmwyv': { + 'private_key': 'UtKbiUCgABHCLdQXtgjyE9jSKHJz9m3WAUsHiB8g9xPcNgbfDEjP', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL4i5QZAFhPA3D3i59XaCxju5x2BN3dTmZ': { + 'private_key': 'Ux91vvrHtsmE6a4xrBsd58r27FTp3sLt6zyCxBph9jFoUFvdt8ji', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLXJPkJa1yUGWyWHXyTc1qo2n1P6oHNGHU': { + 'private_key': 'Uv7qvVVRFNPaffrLM2cDm6samz4C9H4iLbcBZHq95AhaKhTKSNuM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKrCf8zTFdarHMHs5GvnWM96iSWGG8wySj': { + 'private_key': 'UsE6a9L5D8F1JdfFoHgWHq2HWT9DXsKc4Zge13gs7UU6b6TzPvre', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVbLEtWtpVuUSooobcL6P8UMe9mrVw1CCB': { + 'private_key': 'UrEGqtRMNTH7RYKnLk5vkAKfeqVc4rbT7sDk7kHK7rNyvLJHq1EP', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFG9zArG3D82cRn7DJ43dN85bjCHft6cuR': { + 'private_key': 'UpxGdB3fu8FhsXj7PDhkWCSdzMcJJVugcZna2jAaSW4qRRkrEdnR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX2LBmi7UiPwFYnaMFK8tSSrAQQiKp5zKH': { + 'private_key': 'Urgy3RSurcP2Qk4uogahvRXGFoJ2G9eYUdNCcjHU8NHrdFUHpYwi', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REQRRPaB5GUncjY68DwmczuD4Sdn2dYP5h': { + 'private_key': 'UutSEZjP3jR7rppiiuYMPSFDAsPkU5ARJiCxoRcg4ob6UzJ8skVr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJVWRyiGokrj98ZLauBXL7NJT1a3krPBqf': { + 'private_key': 'UpoBCeLkcT2aVpY8tFsDkpZzDKdTABbHpMbUkFKBCdC4FXuimbs9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RY76Lsx4iasYykg81qdAzUvN9HDWpWez6S': { + 'private_key': 'UwRJa3T8PTYG5YevNHJPu2XSBR63HeQfsDmB5ZBM5HUgKf6ZSGtv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMYxq5MTAGtpus8mkize59fz66jwEkxRd3': { + 'private_key': 'UsyMBvsGNDYnuzmBAomawLzRMztsuTCLXtsYQyfBS9vFceZQi5zQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRkhjJm7KwxG6bCuLxbmjLZeKQS4RQeuPC': { + 'private_key': 'UpRbReMTXVXbSGmH9LQ6x3uLPc41Y3WXvNThGfSkWzcksgmjfmFc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBoqJU1cokiEZuyxSXrJvZQhpvfArN2qeb': { + 'private_key': 'Uv5nRzyhTKtt9qW4uvpUjhfygSCJQoe7K64VSNNTe5AHFi9Zn1zC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RB1NAjwDvr423VHKkBusWhHyStQhVvf8t1': { + 'private_key': 'Up3HJJd7GYaugsC2YxqfcpjWtxCFFDqQW1M3T4dXsnYNQeY6xRUe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RT2zwHDJodkA2ceyuT7dDDMutv2WAitK8c': { + 'private_key': 'UqR5BrHYCwdM2QgupStqL5R2psTE6mXeUoywYWLLWke4Aawoc4mq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPpecS2cEKTcvsfcE5iB9Jdc2LUbM8jBfu': { + 'private_key': 'UqVTb6B2pxDcHpQXLXQRYoHKkZZcGpS9QE3wASWyjsKv2eVsiqZa', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFkMcjDqPKqrWV89wvxCqAqkEDKEfdiaeX': { + 'private_key': 'UrNmRDNzZdMMmSHbUC9shRLUx5N3evWtMJTha6TnP5bQE8FWxymj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGKqF859zdkmr4P13NGi3cdtyZfNG2b9u2': { + 'private_key': 'UuVoCPijWB59TyFCdpwRuw8hR4bzws2w6DTkqETCiUhyuGenmADJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9vqucgPBmyx8j8rqnFM9bjgkjseqmXCuj': { + 'private_key': 'UuP3GyGsrit4hK9eGWfFGDvXpbQB6PnLv2EhhxJ4aTcnKnvxDDpM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSpWv68ScJRfci7nq1LoKKkLshyvKheJ74': { + 'private_key': 'UubayWjVXmXS5CAW6RHQxLBT6wr12ajzmE2sn2dXgTZ4yKRdunZW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHeVXrH6BEq1sYmKmLJRSqMdJi3ADWwtm5': { + 'private_key': 'Uqu5eCfmtjmpDayhfAhh27MdkNdensnFvkT8JZeKgrzQXGn6ZA7h', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNh66bTfriMZcD4rd5FYSUBWPi1fyqn3ej': { + 'private_key': 'UsfiJU1qEGaK4tJiYspkX9EiZJRHu1b1H3BPYjVC2jbYC17xiMwM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUfJe8xFgsmnyFvfkco6MH3MJbzD1v5Jsh': { + 'private_key': 'UtJizZP2TKDAKFUEwjwVL5NS84BemveRxfEsSz4aywYVXXQ9MMVA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTm2sVfojWVv727b9wAPfigji4dpi6fFL3': { + 'private_key': 'UsNQhpGnFPxLMKW63qcH356nSHyrix6iQ4DURsd1QVGFkUqGR4qk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSoVwik7RgJud6fdd8LNSBSWTWCtdN1vbU': { + 'private_key': 'Upwidg1He39eghoCwKug2dtR9VyQ4pjhmzkib63yc7NEaHwXEMZZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RD8wWdsx5LR8TYSN4EaxTBakFnfx1toSzB': { + 'private_key': 'UwYi8hMbgU2NC8Xyd1NjqxrXBVQuQdNhrUoc7atWea2eCNfTskBA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTRDV6vNfCZvnBMDG37PFeMi7KfQLcopup': { + 'private_key': 'UxNn4Ka2UAEYu5AFq45JscQZL21NaDSqt3cHt16pavGnVNhRA6p1', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXERysRdeQDMnZ6TvoLjowm46Kij3XC13X': { + 'private_key': 'Ux3ecJwW37Jb8kSpNBCJZYnCt9FSGKkq26UBq7LVhDRrxFRMdWyC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGgQMeo3GRj6iU5aRJjQfDAZcqMkrF3ukh': { + 'private_key': 'UxBi1CY1PpZErXYRD6SSPnwb32hzBANGfJUnRfxDLAKUfzzZD1CH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXu5ZP7Yq6NZnuNaJba8T8PHLpeLrLCcfQ': { + 'private_key': 'Us7M47gpKXTrjfRvtZpgvbc8c2pqKMeAk17tahu81QaqWFaLkad6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RG5qMpDbSbJXrehvYHevGmMJoReirRUcot': { + 'private_key': 'Urb77HQFYi7EqPpF2eHMa13yjCjSGPVNVUuLBYEtF6ubwFNVW9C9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RD6L7NbUeuwHMSCMDmYns2PtwBJN3kTVVQ': { + 'private_key': 'UrALQQWiBJTFnA7ZSuDBQ9AXPAJvonu7xMbQQtpf8ypgf7HSYwpu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REm7f5qHzT6BjYWsoy45mD1r184dgq6mAU': { + 'private_key': 'UpFynYKLZpRHytFGi9PiKcxPK4NmikkAMk8rGgkZhbn4uGxHXNSV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RS3ZRUaP9FE2ji5tsns99ohScpAJzgjfir': { + 'private_key': 'Ux4sxLnQ9fpnpt8wxsgBVPZXASDR6DhFNVg871uEK7Ca3rE19vGT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRsSiftmhgyzjPdAerXgfyXDFgPZ7FWj5g': { + 'private_key': 'Us6k7mWB6WoziiPhi2MkDQgadPyYmFeFJ4aMeeEtcHcPfpmVhNmx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHWMXAYCER3qEVkBRsWZ7emUedDR8FpTxH': { + 'private_key': 'UwU8kRzgAjXuXWripKSrnqE4F96fgZ2rbWx3oxUgeU8wVFGHq9tT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAR5aE1f5b6vqjeRbtHen5ZJVEzGYuY4gv': { + 'private_key': 'UqSDXLHG13oEJP9eThzWZoDPEGxp7yebfx1i1AY4jBYB4V7EM7Jh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWZzEfa6TJMZ8UADcSE9dLaagL7ApDRzAh': { + 'private_key': 'UtCDvvCVPtBrE8XZyyCfPBZ5GvJx6zFxsXccNjyfCrN6BVVmn8fs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUiepgkhpsBRWfgPhb2auHzYp9HagC1xBv': { + 'private_key': 'Up9NvHW7BkMBvknpXsEaYdy72bWzSxZycWAGQTKsZ2AKspmGiqcJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAjrnXa82QY4j55v5uBxXuTYRchgMD5S4w': { + 'private_key': 'UtaHwNTvJs8Z8NqR4wH7amWaCH1EZTDNeS4crFQUywx9u2u2S7Mb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYKAZxfmEyLHKiVN2QrWR4sayg3A6hBzXo': { + 'private_key': 'UucScDbbkna8hMVhmQkaSKhVhYm9pD2RzWteabJNhiJhuxHBxZ9v', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPC2MUAZnCgHQiY6UZDvGkuZxDnBbPmU6u': { + 'private_key': 'UrRDUjaw5etLkFVjgBFc5JxWmJjqsJsxWGbw58ND1ZBkvk92KDwU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVfSXoDSasjhkU5ezQykJX5q7EisZcPrpY': { + 'private_key': 'Uqj1tLxfjMN2Prjda3z7Dv2KwzsMg3JhEt2z6Udzdn4MPqDzCFrG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKoHCNVYSjxZV8kZd9mX39d2V7W11Z286L': { + 'private_key': 'UpwuXJxDMjxTekmHP5yzbWgfH237BbHxaJEzV65FatohJ8AYpEN2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPek55V9bbYmYqYy1ywaFqrScPjKf4sMJm': { + 'private_key': 'Uq4VJQcQ6gXCf5MJrVygWNvXs2dxAY6ZpVqyvbUGyySh1HB1EDZx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX7avRpkF6DPctSQf7t4AJxQAHjwjrqoku': { + 'private_key': 'Us1XLvaMz9QpCnYwZ3j7AJub2GzNXbfSnMsfrxUz9PkBBZyezQxV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RV2aa7e24mPrwxpTbz4Rp2XLbH6byk2fEE': { + 'private_key': 'Upu5UEwC9jcvY7U58UagErhMzEa5nPLT155JgP9Atmy3L3SLGH9K', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQmqhzybUgHYKdBL5KdmKBNkxLVGQyzRdX': { + 'private_key': 'UuoxVxKeSG8hDAnnzdutaDM2F7LRYmBfrfrPPafAmxM648jhCLNf', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLjn4nadTEK9r5FFqkd7GVB3wRMniLfHPv': { + 'private_key': 'Urkcn7BxDgdWXKTV1gBDe1PxMWo6zuAYbAV79tj1rgaKVGDPxNL9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9coXHteu1PxpGWGT5PbciBs5QH45reN9f': { + 'private_key': 'Uu2efFpHxaG6R2egGdKMiRsegSTWGnX7WuuXFBywZRf3hLjs5E1f', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHVUvA1w9a6eyM7uNaXyqEHY4zymhdRgEA': { + 'private_key': 'Us8Y6rfQMpDn4PiGRptpDkZTSWsWYcuoaFFQPviifRviK4UCipw6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKjdYJzGRQapMSHHJPWHbVeEWvpfRaGi4s': { + 'private_key': 'UryzDg3jXWJwJHfZy5SCU3dBAr4AbiR8sECsN9RuMmTYT5eBJu2c', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCno152Zh7KyS98p9WmpriBqw8FLK4Tb2Y': { + 'private_key': 'Ut87r4iwLuU4SKWn8MwAbnaDuyTxh2tonzF6LcyhwnnxFXryhxQz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMaQBdKr7ZVN4tdduV3WKPuYPmwUVxJtuv': { + 'private_key': 'UrQ2kmZuHzC68kfminVVdRoXtPePb3ss7oYcFnwKKqfmrixCFjzg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAVtJPbJBvsZeVENGJa7ATY2ubXsNCkAbC': { + 'private_key': 'Uwsmm1CtSVmt6Mq9zP8ETTTBpFYY4VjKztukyvLkd1kuSUpdr4HN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRsNHTNVGSh81QNnFyikasdZYJDaeSVB2f': { + 'private_key': 'UuMyWUF8BGVA9n5cqgZMRUd4nj5yoFe8ShEpA3H8MicVNfFe7Y6W', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJQmavoDyXoYJWx1DxJWUxnXBcicjqr4zo': { + 'private_key': 'UuseTG5S3VecFMeKtoskhQN7W1agBwrDYzZWRzXLVLP8g6LUKHxE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSa8Dza1DWVEPW6rMLCa8V235fmLMT5iFm': { + 'private_key': 'UxF9XBcJARKLvG3NM4dotUfvd1JLoAjcXMPoyafb85TLsAMqKytP', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RShDkVTDPQHNz8CiiC9YGvQNqjd7n19TFs': { + 'private_key': 'Up9eAFTEtzypCDpBSTVWaMEeBq8eZedeoC4yYHPGurum5Ebjxq6t', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQvSunDoHKoCG3gxjHtDSRMCmGon6yrZWr': { + 'private_key': 'Uu7MVsX8sKqBAzgcvmvbwgYn7zfyq7KHvbW929zkmUpMD5JLq3Yo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL7xeym2eTxWmk5cWdtT4E73cahiWER4Ui': { + 'private_key': 'UwycACqLFqJu5GCDuTncG5ncwp978krwZzKJJNzbyL8J4xt9eRTm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQJD59Qmqazg1ovE3i64AZAsgybY2B8Bsr': { + 'private_key': 'UwfWVERxViJMQHGvti3tpSpHn7EPnvVxwdkqArzgMo2Cgk853TCD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9SWYBqr3fhYXNhRZohJYrAnv4zx5gGfJB': { + 'private_key': 'UtVwKnNZhYY2w4EW36vyDX2dt3TyYoaPK4i8teMjgcqAAE4TLzNo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKpzYgKDLshxFeMPbELb8qspCt5kGaAgjR': { + 'private_key': 'UuUNPWRF8mXBkaPSAcnCRzjSDNBtPyKVusxLYMZwCdXwvxTW6KP5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWPYuzhQBrUohr7FGLwBc2mRHo9u96D8qb': { + 'private_key': 'Ur9oKUvSec27gPWVKejEPFEVHMhdu1MtoXHDQZ6nVGTxyrFfuNiJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSpFF5GgGfTPMbnJ6FqNceTFjSmdXiKm4i': { + 'private_key': 'UrSns7G7kkMfixkgVMZspycQzKjyLYmmQ8nsYHRK43kfSZ9kz9pH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVsVouKknXNZku8nce8MubNYTQvinKVJNJ': { + 'private_key': 'UumsuufFxgfte68sZNmPErdXkZixb79czLU6sqYwByNdS7EiCsJN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAgy9V6uDfC3bCnufb9YHojRgDNo8D9sht': { + 'private_key': 'Uw8TxDwdAdMvNzbai8LSKDXWdMSeaWkRtKYBJccPgze7vLQt62Pn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXVJLPXazpR4hhSxhTLw4eYa2hkrvXRVZT': { + 'private_key': 'UvdEbwhKj4ULRRQCiBphj5LkC9qgiqQ1XHd6AZtWsyN4iQQ9k9Sq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REpa7u4epsNXdRMxrDoq9GFM3GAJexVi5A': { + 'private_key': 'UxFsBVFMQkZu7phC2QVFEnx2hvtKueDKHUAoCwMszdrvF7pudV6T', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTrSvevpmWo3NGX7KHGQQw5WVsL25ayF66': { + 'private_key': 'UwsfJcAth4KZnLeaW4hNHAFGFJy9XotsgoXKsX1Sz3xRmJKyCb8W', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVuJZ9oveegva5CyTj8LUKBYwhYpqDCjEM': { + 'private_key': 'UxBGAJxgeWut4qT5V5vBVpgUX8YP5G7K2yWrfiDviZ99FBh2sWqb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGst8psRQ948gvVnc86YjCznaL2hGFz4Cx': { + 'private_key': 'UtpvEFfZ1cGLbg8nytRMcY9tShLQ2RUoib5Hr22Az8PMgHJWJFH5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RU8G4DQ8aUEMc3GEAUWoTjqUdQqBeVh5HH': { + 'private_key': 'UwjYnhVrXiT7qLWxsB6MAkfkyy2qtTpnH9YKqtZLRxjT5iUpHJMH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKYW9xn1mpGbvsXWAp1oVXi4cfHnaTKYHB': { + 'private_key': 'UpLbkcwLymRnskqn91rkjzcNqM2ByfFjUhWrpQm1vwhuyVJPQbAg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFukrGjyjtggXgMVFvz69iQP6hGARub3mQ': { + 'private_key': 'UpA3wbt47BDCNtbQy5YXZzomfjcfUe77xcgficEJ6c529TrjtTA5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RK7hfafDqVcqKzy4JWpixPaT6UYnwSkFbF': { + 'private_key': 'UwSPdndfbFFGJ9y1AtwemXApNXBE6vduDq9VYPnZeDxRHmFk8BAo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQSW5ocodG6FAUspspdaf15X3ndYsRwbni': { + 'private_key': 'UqDrtpUyGPGZA28JD7wzQud9iYSnfwvgMeXcEsNXnvZSnSRFjYQ5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGhAvQFn3V574eJDFZ1mZL881hgpNfHKUq': { + 'private_key': 'Uu85CKk4usXMUU1oZTpBvxh3HCxsurqJuMyiy7iGeeyEeeyToaMw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBp1HpPZoL5PbnPVgPTJRR3JSkLjish4Qe': { + 'private_key': 'UpxqmHK6JrU8Syq2osKv96mwbP2Rkw9hJnkbTdJqEqCDViAEP9r6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVbWobxWDCvVoovF1FvRLe1iRnrmmXBd5p': { + 'private_key': 'UwaXFHSRxjtg8R82d3NumF7EqWVgK9JJNyFSohqDCypJUjSx75fv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RM6ifAsdFkUZvKJUDaUL1Qv4fTcLsXEAXP': { + 'private_key': 'UqmXtYqa7WPST6rp2kK7LSvz7AUgimMmsqDG8r6CR6LCFsuotNLh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWFjP7KaqFkXP9h5Sc4jzFFjWnNb9hiWfL': { + 'private_key': 'UqjfytyS1cVv4nE5JXjfpF3YSDx9NZTVxFF1nH3WVSuac5RwCeS9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRuNDwjkHR9Vueh4qgy1qRCXzDy3DDrm6S': { + 'private_key': 'UshKhVZEGUDMieLbTmHgmBP6KHq6mTTo9B1L9LXRn8ByncyPYsNw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYZvNK9qAWiNg97i8YbuzndB63Q3MHB6af': { + 'private_key': 'UsV4s3HJk6Br86H9nbKGsbU7nL7dzPUTihy74Xt6QR6T81zzGMW3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9wo3Tsb4UKMgumh1iyQTcLNxrWifpuhnx': { + 'private_key': 'UsVuQRUFnwqGovBG1VhFXiHZjMBqvRGhcTZryQZMfyvYrPXGeoTC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9doxTzyfUjVDupPVfo3LnwQ8JF8M94DAr': { + 'private_key': 'UtFAXJrEvznDcJuJ5Uu1a8DdWfHGHWNAoNZbNQPdTzUKE44KQ4Fc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMdPeBL8Nx7XLesTgQpKuEsUFdJJo5DUeg': { + 'private_key': 'UucAptirZw4V8Afnn9KLpTNaDmLnfUzsViCMHUoJsE4M2vfTt5ZA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCs2TQ3A6Dk49rtjSnBVPVzixHF4pe4mpj': { + 'private_key': 'UtnkSLPJw49kiVM2hNA6FvkCtk5MukZ4CNXSgn8HFhnZUfy9Eiyn', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAxmWXLTCJRABsC1eDtgjGBshtyE2jvTFW': { + 'private_key': 'UwmnPUTParxeMBEio5QZn5E9iP9Y2uwoHNNTo3uiaDpciGHPdbyV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX12UMcr5HMeuFHDqnAkWgJWDaFTJskm7p': { + 'private_key': 'Usdyi2Agox9N9kXQmyJR5RLSa59r2nTQspo44pNDCzNjnt7kvCP4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWXmBiixv6DAmyuoososb2YXWgpGVVzfHd': { + 'private_key': 'UtzHh8oZvfaaRzRPzXZGXWFVP7oGygc5iMzg4XyVVmVwV7Pe2iRU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBpreA6raqZbyyJWg4nncXhURaHK3FvKNZ': { + 'private_key': 'UwijscFz2hXLUXjz2y4VicxTkBJr7ihbcHVxeWJ8skowFo6Jrq9P', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPoFddG7YE3tFkmMHWYqM6rssqdVNhJJbD': { + 'private_key': 'UvMiy5nSuY2TFwEDcjDKshBAiQVDYt8hzrn1CSWQA7Ym99f74UWU', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXUqiXeG6RWQq2zYe9nkKeiqAeqLasH2Qj': { + 'private_key': 'UpuQubudHF1bzgAx1zW38ksHpggLKFYz1u4gFmvNLKb83x5eysx6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKm5Pes3dMeufkYkA3zneWsb93KnUBBbkY': { + 'private_key': 'UpFEn1Sew4Sh8xmJp5YKZpsan2JfyxxRpwuhaxhDqTTWEXz8QDiZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVv4Qd6vDCiEjes9tYkURDgzQcnbcNyXW9': { + 'private_key': 'Up68i1XaniuYnkpAFiAxFgxmXA4xGXKquKhGmVD4vhVYQhS1KUum', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RC7rqG1iR15mEP6DCq5ScrKW6MuZxXuV4A': { + 'private_key': 'UvGEaTW6HhYjQkz5HT1B775nsvxqakxDFKkvRoRwVTYc2KL44gBH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJmjE2ErsgA6b4xkfqtgHpn4KxsuGvYQTD': { + 'private_key': 'Usyfo9vZM2taRo3QXFFwU82p2PoWT4LJBiT7C3RCWaiLtaNvKwTa', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RM9VuHLBWngCSjpo6u72TiUDr9kD3sitMa': { + 'private_key': 'Uvbr1WXPx8NJTmaVGxaoMgJbS6wGCDR3jpv6MkobfZp1AdBTrBeD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHMJcR366szYHVmbpTqCL2rSg2rEZWNEeF': { + 'private_key': 'UsLBJ3BWkBZKcob74Kmm2khDT7Wa8dGTKKUmK4nQZZrCwX6fRTh8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNFDUd7SeC1T9f8w7oCEk7PHK9WwL7J8sk': { + 'private_key': 'UtjwW1gLoNKuGdWVsUK3PvxXqJ7LcbbaZ2j9ezhRRuSz95o23Wzw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDr6rkcgxvFS4Gee6wZKEmEpaoA7qqmCo4': { + 'private_key': 'UxYrfyhxB4yP5ZpU2p5o89NQavNYRwGoMmdj9i7LtmR8xyejhXLp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSL4KCfiuJsr7fyKvDcRR6uZBkFaUttL1W': { + 'private_key': 'Uw7M7wa6bbzpAugFWpubi1u4DNSm6aQMRc72YZh1pP3YVikdSHnu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVfi8PQGCoAseK3ck9YtNwmxMmLzo1MWHn': { + 'private_key': 'UrH4oTxtMuo1fZA8ewujcjrnCbzCyFDbVYNQviwNCSHtoDaTYsza', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVCcx98GSpgjnKBGn4mChQ1NhszVCo4qKp': { + 'private_key': 'UrsfXhLePCLnDK6EbDQquwLZUh7JqHmhy1Ho9hoHYanpwfyZScE8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RW4Dc9YM2F8AphG3cvDBtRDQNK58ZQLTCX': { + 'private_key': 'Uteterv6JyA9AVagYC6c5nNJvYvvQDVEKEnA9RuBQJX4tJzmVC3F', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQpNAzMAmVQ42k9NJh95ccfAN4Wt1ucqgs': { + 'private_key': 'Uqif3uSdSY3nU5L5n9244GvrYbJxtvZfQqxRm1bGSywGog5Mhz88', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAHw9cFbPDFPBuPaaFdjY6yuPnzKd7ri4f': { + 'private_key': 'UsU1imxcXeq75QECxJjNUr4DPF39rEd7QpvCiCUrkXGnjSQku7xe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGT4jNwCGvXY9YmWNsUe1o5gDRpBnJ2NY2': { + 'private_key': 'UrRTuK2gPQuHkspGLzQwWq5He82CQvTZWZU2c2KuGD4fJ8tcdpvE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RB9gqsYRmEqWzU6WvsQDv9ueovT2VXFfto': { + 'private_key': 'UtmidAwE392iZkkmyCBi37unxTGSXuJxcW3HGahpD5UV2pZf9K1X', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RToceFS3WvJu5j3MRCeqYUadhZyjZcjpMB': { + 'private_key': 'Ur5c2aNitz7bgZqMpFj2hShdKqXoPR8rdCDKYnf2WALPeMA5RZi6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSQx7XkVE7SRUZNVTnLr9ora52dbCjMjrE': { + 'private_key': 'UtcyeTrsECojcdCANjronDPWPwqD4HqPe3atESiMCdvj2qfmwzJu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9yoDQjGzwv83RVp6gxXFzhMNVdNUN85P5': { + 'private_key': 'UujuSQfp1VyBmVU9zezPZsRaFLRwmqoAZX9w1hcDvuDfMVjqDgWb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPQXbRYhPVMQeEqXFw8rpmPi5x1Xg6ght7': { + 'private_key': 'Ux3HTAyPU8hVkLX2X24cpUXURht5DaD7qxjrrEurWDZuWc6uA9XM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBNYMBzg4aBRrF8po6v6H8hRgV5idd51ye': { + 'private_key': 'UpZWcgA9bUBXMfavpnS5j5wSLMUBUJGZQ7FMCNTkMkTyFTBbJJq4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFCr6Mg7HpfT133htMMviaqLPbPZxN7Gz4': { + 'private_key': 'UqAHtzDDBKDvyWTP18dYVFjGuqwn2NXjCFdSnAvjq3vecXbHBXyH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RExJBCrVy3j4q94ATKMQtGD3YuYspCPHdi': { + 'private_key': 'UrgFhjQrWBDr1JQgfz7mrzo3nWrYUEqZj78msEJW39HFWBYYcghq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVViaFJSevoNk784bpkGW1fdz1ctb1Pfcs': { + 'private_key': 'UqYLxQFQkwB9mB19gDfFPfjD8z8LPB4aDofjgfKxRmanXmhu2GwN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAoCMWaTcqFV7b5Phbig56kp2pEwg54vxP': { + 'private_key': 'UsSHbqkxVsvBDXZFc3Nyf4gRLpG2DJ9T7RGWCwG8QR657khdTgQg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RR64mkaVY2ZLxWbJwMT2L5gq2YpKGpNn4J': { + 'private_key': 'Ut846ZkxRDU4M37BNMg11w5daMa2Repowdo8HagZcMQLB9X9vvpN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RF2WLdK9RbAujDz6iZCChpmcm4mDZGB1td': { + 'private_key': 'UxFxWRTsTC8PRCCmictewaST8svfGb6vtNaTWXvKfrAuKgegwjcb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAqt6z3tBwNLqj4QX3idpAa8a8SDdL3ypB': { + 'private_key': 'UwSdt9q6R7xzHgz6hYKgcodmAJ3TcvprP2URUDy5xre25nFCeKZA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RF5s6QKcfnH4iCG9296zZNnt4gMRtNDo2R': { + 'private_key': 'UxU9CFR54bg3NKryY38vMEqTpmwAysCMySHmYfnDAv6LMfv9pk9r', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXzhbJBKqQ7LbVSp4DYTFY8fnwqcREZVu1': { + 'private_key': 'UrMBtS1ruNQhRAQgP6kA4F9493z2EEdhkHBrSMX6i863VuSL8oug', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAGCaFT79Rnueoh7A9bSecHUfERJvFVZVT': { + 'private_key': 'UpefRepCDMry46MNkWmM4PmeHRnyDLXnX6L9UHp8y2CTUxmgkaWh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RML7JrggsYiBivFXGUaNpddoTjWskuuZSq': { + 'private_key': 'Up1k18rMv2itEwtjMrfuvf23KTtxDP73DRfRCTPy2bTFpcfsE3zV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKRatszXBKjjJEau22SPnjmtAT9jFEhF88': { + 'private_key': 'UtGtXduoZae9nhQDXwx8hSvgTvSG3gxGCtuss3EeDkoAhWsq51yL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMg87HNMvkkfoPs9Nurc33FVvpTtN4EQMk': { + 'private_key': 'Uqp5mQ5jiLkXYC9ybmcZ4QVG5Ek81hSt1hCvaP8kaojPx687fdes', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAtnYsL6b5ifBk4gqmm3JyR8ZH8MdmGvxc': { + 'private_key': 'UqDyhYkyXd4vcMinRksBXEU1QQhmpaN37guy6ByuGGwWWeVFRATi', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQeTcB92UGEvMiyhzGsjJAJAEMXKj3SBnQ': { + 'private_key': 'UvigpbSXHpjBjXdsdvMHXEppyMY4X9WNw1utjgMTaFTMffeoykpC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRHC1qQ3U7qqwS4QFf2wszerbvXXw8r7W3': { + 'private_key': 'Ut6WKZdsXpxMG2381sJ7jdojavfDK418P4fXctY3Z5iH51uKTXuR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNJzodR32d5YaeA3rB6cjwLXvJw8bejfgB': { + 'private_key': 'UwQdo7VS3b3oLc1qrr9FmviQqxYMtqVUAKLczTYsWkURswjq5LYN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCpdz9WDaHabbrzrmjxfJR1rR1GDK1ULFi': { + 'private_key': 'UwhPiLfh4ohrLtR22TYm39CZ3UZTxFjecaGd1NgNGY3WxAi6xMiW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL5rFmaSsoHVReYWqgxMivDoXYgJJeQeLV': { + 'private_key': 'UxD5zsKSuU6132RoxVCXz6xPe48o88cdM63BPZKfVHYQUUnfDZ1Z', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFxzvASFbdTTKiWj96uMyecz2LGuCszjVP': { + 'private_key': 'UroU3EoDbCemzFTEdrfY9SLoAgWYbsUYJGHQVX9LxKVsmZopuPf5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSJSDoAtsh6Q13jacDBfN7pyPxBhA8h1zA': { + 'private_key': 'UtjTvc5UTinsDqsTAYHbhNVvTRFhs7Uf2tKZfyMY7DwBaHBQQXSh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVsYSzfu5dgtBYWx3kEyHnQuo4ESmQz42q': { + 'private_key': 'UsR87L6DybTck4b4SDuaspspPessXhHBNv9pr6uX7RM3grVRR6Co', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRR4gXUkikpx4Q1d4NWXWPA7dpjqHZnRsF': { + 'private_key': 'Uqiz6pm1DruQfRDuyonfrtnaGH9fPtGypzxHKRgUWrxF3Q4jNGSs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDVKcBjZsP4fk7YmqFsLhqYKa1CruLfQbJ': { + 'private_key': 'UuSew2MTxLkYXvqaVcv8xxpQXhGriFR6sJJmzvbved6N83MpzCgP', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTCjwMrjYSYtgMzPNCchNJMCXxB6qCFgBp': { + 'private_key': 'UqtSHPR2wsYvf2Nd8KNvfedq5RnjrXf7sEpmQ8MLEotf9f1LH77y', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RC1drSzZkP5beh6bixPwo69kEA1SiKmW3n': { + 'private_key': 'UwNc85qw63jZFyzMzYoFD9btRD9yL7iMC3973DN6c2vVK7qCLUzs', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKsjWqMdkqwn6gG4qf3Cxyb4axeMJdW3K4': { + 'private_key': 'UsM94nRCzy3zC7JaUm5KGQFFakBVdLxQbHPsf8LKW7RvXvpwtmZ5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REU6ZCj5h23zgyWzfnAw22JXa1fqbktEKu': { + 'private_key': 'Uu8pAxEq1SWbMubUKsCKaGWGb14LitaRDViPd1EkgrPtTwtoPhag', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REWpaMk3LYYZ1DvRkDneAM2xjWWFVYeHEz': { + 'private_key': 'UtD65z4A8D6EtZdq6C4vCw73QzByAkf9uRTCzSChFmLx12GEdnE6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRJ6HW1YBdPFPNjjhNFD74Kc3pwNo8gCUX': { + 'private_key': 'UpLsTeFXviqonDnnqF35r2Toa7q6h9iNYUw8dS5kV3peQ8GfBfHw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNCUQjS6SgyqZsXyWQGn7WXhLeTcR94yxA': { + 'private_key': 'Ura3DPwRAt5g1iG2HEBPDrftMFKUxvQA4tY7UQNQcgavYJzaB1pE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJihy2b3m36HHa9ysL66NAbPhFKQ5feHLR': { + 'private_key': 'UtXy7dpuNNwthKwJPLkJ5ED7AdB4LpAJnBdRZA9UKWP65wTPgeSG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REmJCCMJFdJX34mV26h4R3jWsZeAkaYgwX': { + 'private_key': 'Uqye13S8m9Z5wEX1QvLbiDDs73J3t1XEPQCxhq9f38n6PZ8Mgd17', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXAku56DySSBRUGf7UPMSgUgdGsi4fXTAN': { + 'private_key': 'UweQPKDeFQaKRbqXnfdxsGwhpXGBGWr9jrbz6Behk34rZQjhoXuG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXNk1LukwJ7EQLbcVpXPcCfsYgECgnvMp5': { + 'private_key': 'UqycWD2zgEVpZewiEdHpNG5MbZM46BUR9ScdvqppWzJ57czTtXfx', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPoVgWCJGFerfrgoPmtdwfijcofuGDTtKB': { + 'private_key': 'UtXezXa7xJqcVXBPG5ZXF2tPu1Ty7y5ASZuMJrJh4THnnmJybctL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPfiwyquTzdL6B1CjXe7eRkiSynosSBYPJ': { + 'private_key': 'UspaiPHkcPEt3dJv5BHHQxQ5Vo9EGtH2pNcrFhZvGkJvk1CN7bvu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFM6aaM6MU7er7Ao9edxfVfVWWGjKbBKzJ': { + 'private_key': 'Ut89Gnh41vizs644XWMY2CP4yaUwYBvpGKkcmiSFWt77vFEk2kPF', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYAYHmSW84jyHGWDr5J4Cu5nQzAqBTs2NV': { + 'private_key': 'UtGVfSGHCRsJuBoKMZt9kRj5QbopEzhqhj7nSvurqroYZep1A9oK', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9PBa6gSS6nAvZ7APad4fH1uX6xViPjKcH': { + 'private_key': 'UpfbEAyjKYAGMDytnf6Gkmh2ThDPkKVugWQ92nUHHFYxCKJGq84v', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGAJjYAJ1zGa7ixXmsQT2ZEUXMadJQyBLa': { + 'private_key': 'UrLkyyUqKs49WPA8BVzeUL3a7dEp6cj15mBEHQhDrJVUmRcv9Y6s', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RD7CZ5Fz7YjimvFLUJdkB1aEW74ZUWCg8X': { + 'private_key': 'UrBnnhYHrEdtRN3gVvvqK9V2cWc9gLfnKYrWqK9NpvxZAEeUqEdE', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDNv5ws2KoUsiRNYSPnKgjRRFLxv5eJYsM': { + 'private_key': 'UtinDYDMjfM6apv8fvzRQoignWJkDjygDx41zvYFVwL1UDvDCcnW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQNbodo8yYxoTmmvwecNs4Nq7GXEs7ZaM4': { + 'private_key': 'UvgpPZNHsvTwnSx64BBvvordPxns6JDTcVoMHczEBpPinv9EaDhG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAHA8SMpZcDCDnMAqX4QtcbtYkmKrzogHN': { + 'private_key': 'Uu2zUCCFCyQDBtH5eQsgoZvEG9F16dqWsw3wYSfLx3GhQtb9S8Mg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9feVjfq44rkfkMgSXgzGwTJiXuaoR8gpe': { + 'private_key': 'UwiHVeANneLQq3JqESVU3iRCuic2jYg4rcL5PBFjsJrSNPEqfFz5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPafdpFP73ZLdZTSSVvuLNrRuSNEpUP1qU': { + 'private_key': 'Uuv6w2HKMuB7Q98XStJkDd5guDNKbR68yrUb4ewDkySfNEXjTZ4n', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RM1msbWwsYFN4d3acLnQ8wNGMpmQXmeASx': { + 'private_key': 'UrB6UEmoYhYR1kjno9ZdYgX3HuZgJtNDfaPgS6fdVCNcSxiCx37x', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQP2GJcbHAJqbgizX36y2Ugb2MofXi6YTD': { + 'private_key': 'Ut7FUA2Dyag6GPga4hAsdbipEcPSHaADT1ax9hjQ8ZvFvR9Rpyny', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSQYecP88NcYVFWpCn6YBk1yWeYgrQZ1eN': { + 'private_key': 'UwtkPk3wH3Ub3pZ5tCjeg9CkwiMkAqhsBLJoEcDkgSTKPfRKeXSL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKkPH4z1dzv3DHr46A9HD8PFx78taqCHBc': { + 'private_key': 'UqD59YNtXCN8DVqw7NPECNPDsVSV57nhLdyzYgwsDZbB4wZEFca8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMvPVx3EoACKujPvoewdqAkPU3inkKk3wc': { + 'private_key': 'UvvKURtAPptghaonNgipZXjDposBL7gAfRMHBmx4RXVod4NVDJQ2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTddvjPACMUDnxHYoan11hN5hvo7YQxqFL': { + 'private_key': 'UvDv611AfGt23K9hCwwpWJssNMnBPtfkvNRy8Ff3BaPjSj6BsXyp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFPhASvp9Q2MdNDYEQKTp3K2FGD2aJ8FKd': { + 'private_key': 'UsJNEqyC2khx59NLucANDnD8xLPbm4X5yogdYJHt7iApCtXhfx3Z', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCFY9C3KHoz5xsUek4TKStHu4rU7eDvkw9': { + 'private_key': 'UpENYrhmmBEjvRNo5et1m21oh29vD6SeJgXYCa5aJAAnYezon6Fe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYE5qzxMvZu6Lh2mTaYWfftvNwsk3ZEdJe': { + 'private_key': 'Ux1otMXEH12oMP2yafWVJo4vMkZ9hfeX38mgmsFDftRcJRdxL45P', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWDDkssPd4DW5dGnTWLAfmABA5D2ET9Bgj': { + 'private_key': 'Ur7rtci351JedNrtuf9oiGNaD4unoAkHAv2VFZZJATJxm3QpoRhm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RN9vNG4SwgisbqcF1zF4gfRKbxdf6dgKm9': { + 'private_key': 'UqfxwcvbqfvEHhzaENuZgjarXvhznBkbNWrtqQNyEB8i26kqomA6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL6ZTa4MqZR5ikYuKGtsc4VcMERXAhKv3U': { + 'private_key': 'Uw7FvxAv1TvurtBZ4cJ2vvyZuBuzpsK9qEzGo2t2bSdktVKomFai', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGs8M67qfpWjrKbbKQvNUdgdto3Cq2HWP9': { + 'private_key': 'Up91WHNfiDvgCpT8guNZbbmG9YzJ5JSoz4x6VnoQM7Jhbqec3bQo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9nC6HPf7WAza2LjRtVMSPCBywgZtRRkmQ': { + 'private_key': 'UqNp6fuRVok9vuGjYucXCyMgrSNLP7ZHPHdKXY7iBBNnnLJyumH1', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSrMoi5fq77Phhk33w2ztgZv2fxHhZmPWq': { + 'private_key': 'UtzVGkAvC8AKCZqRoZzjWZht4Xx7WHzeFFrqkcuTzo4L4iRt4ZoR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHetc5qcCzUCxbw59QV7zc6bqvSSHpxeP8': { + 'private_key': 'Uqjm1yQ3MYipP2ikcyZG4RZLEKEC2uJLjr8Xchqq2Ndbe81wWwdj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJhcG2Ct6vrZHr9Scnk1ApJRpVLoJjDruC': { + 'private_key': 'UpuwrydqYbgQq9whifvytgWSWXtRVs9W3ovZG86UZmZxPNRCqpNG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSTpf7KNcHwe8JHBRX7CcJZdMaSvUBra6W': { + 'private_key': 'UqD9dLPnHKhnZXYN8fWVr8YjYzYXvmfCHxSVK5qU8Cim1bGVS9ih', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFcuz415tbG1AEcAJE11eV4bB4k8WiRkaD': { + 'private_key': 'Uu5RG6PThZvTE2L2rNaNSpEKjCnx9wDrwPjJaXGgTuKpkVMhrQiS', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYFxFWjn7Aw6T5p4CB2VrKGoUZtQMtHbYS': { + 'private_key': 'UpsMRJPtixs9STUbc24SSxDgE8D9tDvf4XJUcLJ45vznbE1fMEBT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMUbBK85Ku9FPDhJeJ4k8jGKnZAo6KaEfV': { + 'private_key': 'UrGAR86cE8LUVrLwUZLCw7Z7aEhwr6SfF2SYHC4BrLCLvmnPH6ze', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHFkKfvL3xurMaHaqQi2PzZjEWcu17FuLD': { + 'private_key': 'UuVcRbGK8dV2kje4ZsiV33CqWuYo9y2Y2jnCPZX9uXMvuqimZMtZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVGH1L9XKC3FsjXse7szz4bvxnrGCfmdDC': { + 'private_key': 'UqrvXdqNL1xS9DbXwX4ihNm9qnxWYgyS7nmwF9nahz5ooTBL8Lhw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REUMFLZfFMeq4veYFnGe7iLbZMy47Z4kVX': { + 'private_key': 'UvAw5ECnu4ufv59ReojszkDqEQmPPffHtp3y5hkQ67hrFxc3tvcu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTEkZTtJZxPtSbw3VGjzz3SvQQpnFRWQ9L': { + 'private_key': 'UrGTEYAR2DdmdDxjF2AS7GgY7cqt7yFu3fs7JDqC94W3X92aWJ1T', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNnxMgy9iVGF3er3TYUoi92BWvvMn1ZMNH': { + 'private_key': 'Uvz8NKWFdER7EgWqsKrJ2bZ2SBbJrV5F8CU7CRxutA1UhogtZQfc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQuLezCCuNthXoNPkVNp1quUGZv9rUatjK': { + 'private_key': 'UukbMoRX9fK8RP5dUk8QpbLGQUBVP4E2Y1ty1tmoKoZqW9R4ZPca', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDRejANpYngz9tqzRK4h3xtii6jPHeS3qo': { + 'private_key': 'Uvp9nakmJYW2FZVoCQHDc6kZFLH8vYGFgAsxQMaqFoYWvk4WoA6V', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWSqTri9uqhJF8Qi1B1WDTcH5EzizpuyCB': { + 'private_key': 'UsZ4D3TWjpz4XdF43sjiRKiRNJQ6phBCjHpwcPLhHELqkjDvUdUa', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBCmarrfftsFFAyqFCrSj13xmk3q3fgdfX': { + 'private_key': 'UxLscbB8yyD8uNuELLaJNtVvN73c542mrLdFaVoxZnj1W2b4522m', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTsu1MZxiMzS2D4gt4qywZibHckdJFtknQ': { + 'private_key': 'UpCjZiQt4yte8PNGq87EaxefqCfkmMi6UyoGxgabCuDSgNUvXH2t', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSzwWjAdeQyeqLrW714kU47yZZ2VnguwJi': { + 'private_key': 'UtMdJ7iQKZ1mDyFBj7R7wRYvn32hNY58pkGzbuWFwYMj7Yni3NeY', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBTaoHb29DAxBneymWgQN8hyisAYEu57RG': { + 'private_key': 'Ux5eq6czX5cfm6wdbdAmuqgY5mpMxJSYhWW5bPNgPcKqvU72f3pW', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPrzda8Xtvu1qv5VzpNtEAriMD3Psw1JxD': { + 'private_key': 'Uq2zdnguyhrXsXkPK8W3wQGMJFhsqFeV6zBn1fGAMwzmHitw3ZAo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMH9khRL8trUyNbbEcZ4iNuUvDPxr1Aomw': { + 'private_key': 'UwAdUPkRuhczoe3wTAqN7rRnTGwx2QH5euya3wtWebTHWw7kTMgb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDw3DPaNx9GGPNNctXjV1aJoEbvLioozaC': { + 'private_key': 'UsGSH4cPgzmgTz9N4XUUD2rMAKQvzFUFmma8QdbnmouUYQuyVdB9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKw8UtbMygRti4Ph8dSsqAzPAm2CgnH81H': { + 'private_key': 'UpncYnRBqkLb7CXN2wMvZ4PFv1egcD3urcKm5xKYHscpfMms4aDA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGM7z67RFLMJWZTBbn5mwhuDZA5hQHAmjv': { + 'private_key': 'UunT9PprbRqQJ8UbJ54Ur9vAbQDyPtCkEaJQLJ5tzXvgSWgd9fiu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFuCWYUyGSFVPDU85iTiqqcEgQTGuzJmm7': { + 'private_key': 'Uqq82UrR6XHMtN4DFxumgownkB9CJob2Akpm2ZSQx8vJcCSD5ByC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJmfLQjAp7v6sdarRxGwWYSDMFYn8Re3AH': { + 'private_key': 'Urdrn7zL5Eih78eHKCjd9mNksf4enHFj1rYVGLgFzMm1jDRcjFMz', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9sUTGUCcX2KuRQGfia6wegnQzqgo69mBz': { + 'private_key': 'UvwgzyWdq7LZ9KT19rncxkkM9nMa5f9JEfNqSeSpcNQRT5BYFJuX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXRM3T8XZKwqGtS7HRZHgnCHPtHccELfwP': { + 'private_key': 'UrxZCuPerEDZjrAyLhcbNpCZtoBCSpLirkkAoTfks19mTYPpMbK8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFnd4wMWKunh5om937KZCmBFJY8PaAXhfs': { + 'private_key': 'UuEzQK4kebpYEuQ1SYgdeEDLt8ng75WFh1W5oZF8WzQUsz5pdDTt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUW4iwKjy1mwdNzrehEG1gMd8DUPUzPT1H': { + 'private_key': 'UsDg1BPfWs1gNia2z55178XsNdMUKRcAXHLbqusuWpBCcNfvjXFd', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBhjBUywL2nZz6hX3AAHK9imy8F1zAX1w9': { + 'private_key': 'Usf8pAULSetPa1yEiQemH6E8mYJmo2vpPA9QSF13FnvhfCv3WRkN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRLg8U8P3fAH1kuER5ZxxNFLY1GUCXQ5Yu': { + 'private_key': 'UtpRhVMvXukvAusfXWoAFkE9vkt4ejZAwmPYNA1vtjWgf9F6SPrj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJKUjiF6j8zY55CVoemde9eDRJ1Bayz6i5': { + 'private_key': 'UtqQ9LSDD72fBZSDTyNLCjUyF3XegXPAD591itwvFFfhb815LnsK', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDiKhrneU2QnD9EuLFSaHvu85oZ3AGQTJM': { + 'private_key': 'UwdNtkquQRkYjCN2BVfNnbQA1wG8QB51mb1zVygaeB6Nt316sjiM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RT3yb87GgYVE7az8uaczsGPftiiXbDvzsB': { + 'private_key': 'UrYVSi7tQLJwUcjciKpRNwL7fhgMjUX73f4f8sGKNTtbACqtR9ck', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RY7QVbzraovRagP8U4f1KvnviDxW27pXhx': { + 'private_key': 'UwBXYAzqTy9c7TQyZtzHRR3MeVZzvE6hXdFVTZ74DE6ujpUMzdU6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCVqxcQC8VMuBQ392R7Qxwz5SHZzfYbwVx': { + 'private_key': 'UqV4gVq84BfFoL6hGwkXebWSktNEE6mBPGvk25GNtHhte8J9FbE9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RE6VUSetZDW4WFURgLaknBPqo5ceEsE1Ud': { + 'private_key': 'Uvu4ZLKSrMHgVhuAfFARxDtRcPtEMaiSQT2JL93Haa9o5t25GWiG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVZN5R8hzHtpmHdwPPMTCyRZyYV4oqPvew': { + 'private_key': 'Uv41Vd5FYsDLnsZ4VypM5Q2fNSbYwjsskuRPp6GQCdAEYA2JDsUL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQWjgiZg6K2w4M17uegZ8jZGftXxuf6o56': { + 'private_key': 'UuYGHV9Hd7rGUH4gVePdRi5d4d45WjVg27LpGogD5zbuDEE4TEtH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RF6WnZwC4bhkruPB3nFo4DiVbR4rBPuGBS': { + 'private_key': 'UqE2MsVa6jKw5TxtsQC2PHZoFW8i42NdTeirG8YqhgdJomb8BxvB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAitkifyB1Amvd8ZgYSn3UXRNJYqUjvdUh': { + 'private_key': 'UppXFaP2j9TZbCDbvhSvFc1jsHFdcMAk8b85tp3Cra6ggTsbetPu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RC6jLKB93sQPu9yNQhQ99sncbTnfQwQz62': { + 'private_key': 'UxJriFuo9Cb4vJmvKfzWKiKxEMRdMFzK4fLKcB7qNGf6Pvi6Gc37', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPB7nuMzspmX1TuyZHFeeKdc51ZeHNJWWA': { + 'private_key': 'UtehnUdpyrv6VULeVaWGgPJgMrPP2CtqjCMoqFKXAhXPq1xVkoo5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQ7gSMjG6nE8UaH1SHrxcuBDARBKmiBTVo': { + 'private_key': 'UwRn96ESm2Btup4k5WP2U9x1RaPFZFQTEiwPHpa19YBSUzY4UegB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAjWB7MtLxKKGf9Urse68HXHtpH7duP3od': { + 'private_key': 'UvXcL2wLBnx9mPMBEhUijRPT3Ne4kunKPtcfVTo4i38pScnAwtbJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGU7iviESriBPZBzDNEzJz8pHKWTmd7WCF': { + 'private_key': 'UxXMQQG7Kt4fdoQUDrmBEZR5oWchv5gv7FtPWVgikTf4LZgNUaef', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCPUFQspXoKeQRUe49zyCk1zVF74jhN6Z6': { + 'private_key': 'UwYxyVAKvMKJuUtykk4vQPLhrVbrj7LwVz5FcKiqpg6RkH7p6crY', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQdqCjvqonGZRW4qKTmM5FoN3utmr9iSwc': { + 'private_key': 'UsQDu9cdJ47ow9LzzijLNKUvaQJNf6jv9QZj8fHdQytYgVKp8q5K', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJcxjny86ufTNKDAFhwmVTMikrME5tB3Lq': { + 'private_key': 'Us7T5cMvVZ2ikN9iGEnZrYLMgFfPwStEUREmjMpcuyguLMJfVg54', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REbhhN2S8aTsALZZJ8Yo5WHnfSkCoHuzaN': { + 'private_key': 'Us385sDyVtMCrAL4UYE1SRHiwJgY1QQsxt69gS95D8jJNY3uRLuj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPJCTgu2cTf3tUssHtwXWyEJNPXGZMh5AS': { + 'private_key': 'Uqe9mYKk6uWjNJiYuFnsYgVMuGpUv517wkpWxdXEiEk9KvQktoUe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGcyAkbpTXrNNaqNGHCdvo2Paa1HGtbeTv': { + 'private_key': 'Uw9frG5yQ9yvTg7HDMccJp6v5ttNVp2dDwubFUPPZp9JE9cNbkiT', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGRm81yvj2YoK4rtZfUFXJnYtjP59bFtqH': { + 'private_key': 'UpbTKPHrsqsU8ti5DHLdWgACZ6mgaXVGpk24ZdWNdkD3EcjGwmpC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RE2uSWFpv23kRvYpRybKpNpmzgpnSW71tr': { + 'private_key': 'Uqw1D45JN1caPcgtMqTPUL73vjxvYQjtQjQyG1e3Kessyd8CzeNo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCEUEKXaFtAH1z5D1pqNr1R21kzSSSiY7x': { + 'private_key': 'UptSAADvQzBMGALpzaAA7JRLS7mHaGqs8qWx7UcDEw3j1yQ9xWd8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSdyxFf9tBX2N4bbJWXT4u8BmnGuQAbptq': { + 'private_key': 'Uu2qrJ9geGBY8WpFR6UGGFpWScVGtPS9mtjJE5w9vGr3CPEWXbjG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGetUCyZmp1s8Vr2nxMYJXDbRkRTgJcmUu': { + 'private_key': 'Uvoqbj5qcmC2mkq3Ma1uvN31JUkHCAzMn6xG38E1rbj1t6nWqX7U', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RE9nuPJ4dXBsvHmkvUBEFLVaUCemRPkcbZ': { + 'private_key': 'UwCK2yaaWnXuDEvHozxTB9NeyE355KAPmfhGzSh8HmPzeikoqruA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPHQRotnWgcWHy6o9Lm7ypZAus8Lw6evrK': { + 'private_key': 'UsEEpMtyTNZUG8236JFuGnFSQdNYNDPmMhxhLdgbH5T6ZMheMFY1', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLALFepyNJ6xKYeVvUiMvgbRFYuqNVP2vi': { + 'private_key': 'Ux5ArCpuzkPP9qdFB6kTofHQvnsUMYE3nX5meMCRg2BBjkC5tTY2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RB7PiwtkFCvabHAcPWzQffGKGYTuGzHKFV': { + 'private_key': 'UpHWoP66XeYkPu9Sjf2QgTqxFB5FSVEL8Qn4shcGDaSiEiQohrxR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNB4yT27X7uQVyHZMZipnwuPMhwMFG97Ak': { + 'private_key': 'UveVA44WiEgfUYeKACRKx89Wu7AvKDtuBhZRHktuFe7VpF1N9pFv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWsMWr3F3771NEKT18XKi8H3i9M9ABfVN1': { + 'private_key': 'UwJSv6zcdvEiSg2XQJmzNN8ah4DYEiFGCnPEvdbWfUQc7GEs7ufh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGJE1him7XLhdmTdjV1EwELXA4sHoPrEgv': { + 'private_key': 'UuemfEwEpGX1jEEgcjuKhf2mw8wXHLyMhg7VcgHjEiFcm33Nsmmj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCCYsqqA7keN7D5K8211RFmmaHYfMBSgzG': { + 'private_key': 'UwhijF7kmXwjDgdDLrKTgjKvh7xcnbGTxPbkFENvCmjkZF7EtXG1', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPs7iF6szuuFLnU9Ur7ZxuGeUgTVXiKW6F': { + 'private_key': 'UpNVTZUJxygZtbbWkGU8jHvFhDDm34ym85VPkpKbPPkUPMPzmh34', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RC3Wjg4zPfz37CPodgdmw2ccwSLSV9kXu5': { + 'private_key': 'Upxs8RpvzvJnmnskTQEmtzNDqE4GZix5zKxpgAYtPvTzWyeCcdVX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVLBkncc3u1rxX7SxVgwF3LYMbSZdSUCWS': { + 'private_key': 'Uwqo3mrVcpz7xT1hKoQo9UyR4uSUmMYweFEBW5yoCYvTzaYTDdb3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSA5kwLyDu2JR41EYLd7EFJFEtDaAUYgxc': { + 'private_key': 'UuLEWQun5Rd96M1Xtozs2tVTdFm79sayut6jei6ewrnffQwG3Fr2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWb7dqWaghGx1LiauXdT4aPP9CsvvSESaH': { + 'private_key': 'UtKGCwpr8v1dpgouxFJR6oCq341zuQqni4us9Tg8VPQfKEtjt7JA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKd2Z7nsrTvpcu5igsq4qBmnoJYZRQLgQU': { + 'private_key': 'Uvk1MmbFwCioDSNCohTsX8GG3g4pDWKt7eg5cwqsvEEeEE6MBsJZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RD8jSMv1xTp7fPTmNNhRbeoBuCXEJjG7Z1': { + 'private_key': 'UvXTMpkBLUtNDTEsMqYx2gNiwK1a1VYX74oid8cR3kJFAKL1V6CS', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWN1YBhsuw7bbP3meZzd4na1A3FbJNjPQ1': { + 'private_key': 'UwzfmQY733WSGf81ajpNMzh5Wgzy7XXmPZRwpue7SHA6t4PfXGCY', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRACe5hYEdNjsjuQEFx56cF3ui7PSBSWCh': { + 'private_key': 'UqdXbQyw3h69kGKhAsD9g1qfEaCR9AY9mDWDpWFDK9hhGxdV6k9s', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGsBxRtMDbdjhGaPpe3V3szXWe1YgyezbK': { + 'private_key': 'UspntvDyGgCtBbjbxrpP443QkyC4jCeq5UCYBK5MYGqHDaRQzoxQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHktZdGKXF13vEPgwZQ67SfaBzKst2zszr': { + 'private_key': 'UsG4YgmHuzoQy42Vb4FpV8x24meJDPUbHmsTeMAJg2knt3FYMuzM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWYBNbsbjFgNPZUbTu7L9RSpEDYyTb8T4R': { + 'private_key': 'UqaK9sq2Gx1hCH3E13BmMxFEtLiL22Q9a71xhtetWeajGuz1cmDp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKvSRKwe9D9SHbP1dEeZmRG4t7qvyeVp7q': { + 'private_key': 'UuZGyMZ8eXfpuES4ZjD1cscCeDGyuAcydq8i2gazKzpMs2KvQTHQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKM8rwbHKUbTNXaGn53wiDxVSH2h96vBdo': { + 'private_key': 'UsTAcndx2T5bd7pwcGLtFMNSZDzteEENDNnE6W6Mzb3xRtt9Eo2T', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMvuxU7dFB2YARosm53JpshXNZgzgpvxxY': { + 'private_key': 'UwFCsusnbhLc375x8HGpVJfjyX75NFvcUWt1FBouzuNov2UhhtR3', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RP9FxfiTTxKujRj8RbBNnatK526NZcFUpu': { + 'private_key': 'UwkivccHNFawchNX4WnRt5362MhbXUqauWggUvkTEFQU7pp1cdgq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RP78kUN6etJRGXTRtxRsUc1pznj3pmh15L': { + 'private_key': 'Uws5ceoZVgWXD8RsxWqxAPhJomQdrbduNWLcT1DZAD9ABiWmaj6p', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRHesw9ycD66JBYKeDeDUBU6V4473ua2Vi': { + 'private_key': 'UqiWfKAH5eTEuGqWf5f8QjTLoDjAUzJJ6bMfhGWy61XC8mTeiKQB', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRtRvoLFCyG9h4rpK4keLGutwGTKsuVU7m': { + 'private_key': 'UsnLZY2PsNK7LTesE6mHRbSoK34MGZDok7Q2KHmaC9L8qNDoktkA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCRZc9rkzrmxsCJRNabnrmuv77XC6BjfGq': { + 'private_key': 'Uv6XC61mLowcrWdVJBG8xqvYg13s1B1351e2WvTXdAZw9qmocZbQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPvZMNuAUbW6gtyev9pk5MHqC7HR21VWY7': { + 'private_key': 'UreiNtRzGySQ5CTKr3pTAnrX6QrjEUoiMBb8D4f5hkDQuT76yuWi', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RReQe1EV5KBTFb6eK2JJwCNCFwqKY9zu4K': { + 'private_key': 'UwK3W3vDNcGb6V8wSe9jULa4XL7XKTQGGdATq448czpKtPjSWnha', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHb8JkDU5DR2Sz2SQymPureexEdPJGqU8k': { + 'private_key': 'Uv2cMGk5f1Y8fsgXu7VygnncVAHAy7KfbCZX9ihfuZxtbatWFpq7', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGoqN6pSvdH9is9A1omN1Dn2QxhQWisYL9': { + 'private_key': 'UxLYYryyX8Wg3iP2YR73su8owfuSo7hPo8ThDuyKQNxJ3yqMfkjq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQnc1vyA6uX5Dauo1iEJoNiTbqhUD4Lz3S': { + 'private_key': 'UpcoTHvYq46JW4tPrgxP6XPU1unm2RDCGhzMYwyg4NJYG8e2LLqu', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWqxUcyafGjFnyihyPw2asPuRzQ8iznE1r': { + 'private_key': 'Uuq4PX4YHK3X2G7XKujREcyAmm2hn7Y2BGCZeeXpXaH9315fsU83', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFymzNsvC3XwxVtj5jHnQh9iQ47oogg7fe': { + 'private_key': 'UrSDBC3tpBGvPG6iXX7ARifxuVn2yuTf2Gq3jkrCpgKEsACmkyQd', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJaRx1pJzVnwxEzsqPtKmbVLphrKgqze1p': { + 'private_key': 'UwjkfRtLf72jUNERHHWQ7t8Q8nbDepgC9unKjmtE3otThMbtjbNX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RN6egeykXCVRUKxAWLPBXPyka83GtkCaLT': { + 'private_key': 'UsUWggm4bwFtNrkLc2hMNN4sohsVburUeA3kA6TDSppBkns4Updb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKs6WeSwmN5HfeBid9eXDdceuhZv6oiArG': { + 'private_key': 'UqJ9uHhGwXZ3xwZQUfBb87zwvY8mLEtVputRuzMCRQfExJad2NR2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTE2m9Qu8CvX5XpMWMDqkWHJtmAiBpQwzZ': { + 'private_key': 'UtNWWwLL74jmsXstTKVDBSA2qCc6GVKFni1Wvtydyu7FaiKaJASe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPaiZ2ZtTp6K6Wo4Dctkw1ddRhfw4r8ttA': { + 'private_key': 'UwLj4XNR8sPAdY6A6t45RrGBVTtomEqAYAU7qL8MgE8Tw4aDLShq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJtK2Ak4Jt9JZYH6p76RTYbm1eZovpDJc3': { + 'private_key': 'UuckCW5SeAa8cSdod3eFcJ3kSzDposQCURV7y8J3bzaWMJ8fsFBR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RULuDHZEAz2znhYqwoBeN29usjJLVwMyTa': { + 'private_key': 'UqX5jsMDLPw6phVWE2xEGP5p8h85dkcAZ17J8n8qheS2xT1vaeLg', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL8FGK4rB55d8FRYFU2yfQnHBpRWFbfX47': { + 'private_key': 'UtRojzPYbQsjyQSQWJUnmv4JjkK2HGVpG739Yqkvt6gLZB9qjYtv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJgUk12RkLTZiS43sPKDty2V4sNsABUakC': { + 'private_key': 'Urtqj16N69AGScQAyssjqcz7ZMoLCL6VVoJtXKA1Mz5DQwaTuvDJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSWP83BbWWNfmAcjxrMV149w9E834cTHcN': { + 'private_key': 'UukZ4ZHQeQ65oiD2jucxMquAqCp5xXNDrjHUm9ieFHAd56DqZYZJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDgiGKEZ6qEyFMZKe5jfehwQ361rbaXbDr': { + 'private_key': 'UvrXwpyKZE39pdcdR2Tvjc4y4Sk93FZwPuNFSaL5mtTDaZjF5daa', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNkiRsfar5r2LJ8CzJ2W6donxJFiDp3Wn8': { + 'private_key': 'UqKqXthJXyyHNkiVV2zT7ByKsTbRqH4vyxXFT4TzyYYUhp4RirAr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWgajuPZQHg31KL8x5GHi8hxn9MSXjgKk1': { + 'private_key': 'UqJvoTUo9dQaxN3kZ11UcwfdN8PNPePKFxARW1GnAZYvaHyegZBh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPZrhrzYPeUGsBqk2dB32XC2SVf53jXsvG': { + 'private_key': 'UwGKGTvRaqpKip6gEX1yvCBjb799CzAFX2DUfHGe7D7eF5vLNMzH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNTNBAQ6YTtWPLgJmescAhKLgzqgDLDjbM': { + 'private_key': 'UxXhENSxofmGEowVBhmFMnmMP2AVjDNQA4T7Kz8bVHPMK85jcrHb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXvdrdpBYCWaQf36s8gpVLVgDptfSkNdJn': { + 'private_key': 'UxHz173FXvkkgPmEBaDizG5bxFrg4hns7NTB7RHP1xNyTH3NgtaZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJHg1RNgrP3rAU4fTARZbtkjrbtppLcVm3': { + 'private_key': 'UsSyt3GxuW5UyH51d5igUWLo8AWcGm9UEActS3zbpBv1XMNxpDRd', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RA7xgJWap7HPNG2qKtntLP65AgvQj58WiQ': { + 'private_key': 'UpQAq9QKDYDu3FcAkNEgwPe994MobdhK6ZaFGoqBFy2wFNZpawTy', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKWwC59detBvYNXLL9e88tYywgwUAfh68a': { + 'private_key': 'Uvv9ZoEjayCyBHVXedkwD4dbdnPhCQMzFW1RUHuMqFFGF59UnbS6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVtn9JAqcQgeoiG9VyUbh9PxGe4QR1Vv1i': { + 'private_key': 'UsfUPVm1USbzms2eL6btj8h51a5A6dNYHpkzqgQFAU8Bu9FmixY9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCxQcGebui62AJ5hMgnjUb3Ue7csAW35ht': { + 'private_key': 'Uw59pKXETaBhHc564DxhZLVh4EKSnCPkS89JEUdGyiXzhsFV5CbN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMZARbAs3P3i1RhTUEqTZf8eHHKdA2ger3': { + 'private_key': 'UxMNypUJjgPLRq8Zt3QJgKHpeTb7rnijw3M1SxzYNR6tRcxuXWq2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFxso2QKsLiUQmpP5SYzJwCUVznoGpWCYG': { + 'private_key': 'UqPxLsAfQK99yqZ49mVcLoUG5mchfYgUVVfEen7z9EKEa3ByDYS2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REdkhF7mtcgyC3iZz5dyKYbwuTGifs1vYT': { + 'private_key': 'UtGSLApNcAiHmqTrDk7Dpb2rLkMSR32dqV9xJFBZ7SQks7k3gH4V', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLS384FLfpPrQWYm9xvQ65u7W3dz8QrT1h': { + 'private_key': 'UpWmw3EvPh83TkHtZA4czWCUvpmk7FrNsT3tmXMHoYETNkLASn2N', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPZHKbkkmi2JhCy7zsKXn3vKb144hqcgJ5': { + 'private_key': 'UvWCADdfuyUEwz4hM6N8yUXEPNDthNvDJfDMdohteZ1xXDjmvb7v', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RPg34hiFaPYjZ7Cr53F2aqLWE8pEt7yJgJ': { + 'private_key': 'UuNdLZo4pGyJt2rdESVWGoZxJyQYxbadgu8YCS8Jr6YPeAmV3zaA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQh8GjoVqG6bp9GGq3wss7oaeqkFzzuMiC': { + 'private_key': 'UwPNJu6MWH3oqwHYFRVMTAWWsfyw2ADAKbUAtzB3kB9A9YTvLZBm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFGbTJyGmv3vcMd9QkRZEebHRqtiH4dEHK': { + 'private_key': 'Utr4E8gaGYeUFtmeQ8tGbzaogyUgY9Zhf1bA5Sr3HA8s6WvMhVpr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RM5rvunSto3TW5uhQgNhLPHPuonWoiai38': { + 'private_key': 'UpDSSSoBPW1boA7UeD56QDe8MDeVioNaXviEEb2eiicNYvHyU3MX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKJEFCD8B9D8nfaz21HKxMYEEQYxAHNUgH': { + 'private_key': 'UwDkDAg9whW4phhy1JSqbDpptGx2bgaVcaST6CuCcaFhwENnteHF', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RTLBBrao8ctfDuNvcVe9DwqVHb3U8vcfDH': { + 'private_key': 'UuU5Zy6gaEqqy1bVZStmt7pfuShose8tdWJipBp28AS74eKH6NPf', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYQ5FKrKbAqoKSWETsa2Y1VjGZ9ttEteDV': { + 'private_key': 'UtAgmoxRHfHipy7BaStTQ7tG5Gg13igjwAu9n7K6TzaAhzUwYzV5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMnyTchDCLBioB3whSB23LYa7ZJXa2Mpbe': { + 'private_key': 'UuvZA6a4yHGpUvdCZ5maXYHqA9LoXGDAjymwyqxa1GXUoBmyoM37', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWRr1d7bixgRsk67MD9Bgvb6eh12AaZyjZ': { + 'private_key': 'UvsuRiPgZ8yiVyNNmmozPZYYaYQr1brg2Ly1FborTENRVZQ9LRXb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXEqNdD2qX36E9Knv9dEa93WHjm25da8nc': { + 'private_key': 'UqubMw4YHRfWEhxZu9pb9fGN2pPV8tVu8FGwAQBCom34LYAfEDo5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBS8Xm1asm4kA6P2zsibxdunweHqLrsPj1': { + 'private_key': 'UqzcqtnhupYcvE66vtbS4fQkUWc6APjNhd6Wet5ECzD9AwdGDuhY', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAbd77RxDNafRBnWxqie2KEQvDizHighHR': { + 'private_key': 'Uw786oACNtktYSL4jLWzfeU9m4Mrg4MxdvbisJCnXibU8oTjZwJr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCXL8DF6tcfEUFtjD5D2XftnMP4VD2PSmL': { + 'private_key': 'UvXH5y8k7xjixmdq9v4egmLoXdY5jwGoPPcmXFz9tfbTdukwuZfk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDSqqqkAmjy4YmcFN5rRxw9c59XqYfQi9R': { + 'private_key': 'Up1Zxodv1PdRayGaK1PX5V87MkYBN1zmRJm5usGpnAanuhCXt4vS', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNwC5NrVogrwqwDTrLGpL8PPSPDEiAvQdE': { + 'private_key': 'UsJz4FR4wR3DDeHwyZccJMv7qTADR8AEcrc62w3Vk14m4a445NTp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REt6SiJ9in9zHk4LWGtA8oJ3aVVFam98TB': { + 'private_key': 'UujqSgJsRktwWxHCtxcHDVYSRxkDWpfdaL72u1GTkHrifWa74ieJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFYpV1xpSJ3Fy24U3E1fbxK21k1yxQPStr': { + 'private_key': 'UsCaLm9yP7H119yeNxF4etc6wYSLoJUcmSsUDHkPMbDHqwCDveDX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGgi2Pz4GspvK5QActRVDmXwWU4K2VJhqn': { + 'private_key': 'UxQGiYfM17Zj5WzweC1NisAVXJ6pxa2U5EzVATyQ2EU4amw6ZJP2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RULTpG7sXBLZU61JXgKbAM5Y8Q6YU2do8q': { + 'private_key': 'UtjD3A9TowVKFee1LTKi3qjkHrW44ZQ459n6rtuZi5NHuRM66d6t', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'R9ZRCMqU4qv1suwgWHR9SnS5Gq4PcamiPo': { + 'private_key': 'UtweBtRZAxkDbEnpdddZAY1uhGHcYZ2Hg1DknhW4D9u6WT32L2rM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVMP76bdR5L28kxBBJJ68QgntqvnKpqSCX': { + 'private_key': 'UskD6eSvNLKRWtBYmG94hukFsCwTtaZ1bNSzcYGJf7KSZKhb8im2', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAHZbF9zr3mrxhNFekzUPhnvaQXoy1ssC3': { + 'private_key': 'UuCiaKBf1W37c1RPhrAsDZgm8v2yy6NJamX4zMhjQNhs3MDdnxdM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMozrZdWudHLbHZwpjcZ5aTZKkxLCLSva4': { + 'private_key': 'UpNdRxuk6XxPPCkjsxYYTVPvqXzsXSiPesrFjyyXqFCdVR2PsBUR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJNEWo3i4BYVfPJ96ZMkAMEP4iRr1o27Gb': { + 'private_key': 'UsMvXa8RKNbWTzvNR9Nm14a45Gm9nzjAr8hYzgJEcuP6v8Z1ekP4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RR7TLS3i3ThQ8upv1ZxaUpN2qmVvt2T7Gq': { + 'private_key': 'Usem9mHXYD4vEeCmAQ4xToZy3sNjyvC26GeryckEispNaiAdM476', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNTkZ3yFZeXsEM26PxvGb35nun1JSi1JBV': { + 'private_key': 'UuxGN58U9ov2DppUyzws64RKSoPia34EB6cMhy34nsmbnFcT4Lfp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDydLD3RTP5m6Wp6LrYTFMaEdogX37DPVK': { + 'private_key': 'UvoJEKEQXiYcajoxASgtf7v2BTiNYsjJxdLoC3qCjWEBVgnpBMcF', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUv7ZaPFtruAtZRK5aYitEGwpCx3SHGxfp': { + 'private_key': 'UtqK7xbha7TFzYCkRYcRh4V2zWsbjs3oqrJgPjnCXnJmXi2Lh198', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RG3fJP5N8Ws5rHevM1pJzL3dH7iyMeHmtt': { + 'private_key': 'UwAEoa5gRv9GKTFk263HW7cs33ijVHBLC8tYfXvhzWDdhrCqCXQZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RMLkar7jefc9Jize7JZNLMTCduNbAd8Gpn': { + 'private_key': 'Ur7uyCu6jvyEPUykkzY9Xus4md4uJTsHGnhGVwisRx2Q9KXbLZiC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RAiaRnit6pJUbt9KF4KrFj7FUvFsEeW6TG': { + 'private_key': 'UsuULbr4FmoF3BkuKrUQabS2QHS4VeRYuUbwKiZRuZaubXGWknD4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJUfv2DFUUBS4TEd7MrWH7H4mvPwBkXT9g': { + 'private_key': 'UrHBR2HSZejqyxdErE2XkdTEkxa5rfGtg6jrc28BmLR2iGq8j259', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUn5SPJWrQ1z5tPUSHoRXDoiekKphNKMCV': { + 'private_key': 'UsChGiZj2szQsz3rfSzYss4nHF4u63frArkGyVuqWsfxhdhwZTJ8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQzTM1WCwFvA5tMaZ6kQzwimiRLCp9SBh3': { + 'private_key': 'Usi9CgXWFbAuQYYMxgtPw9mQ5q9eurUFDaWZoR1q88FJjB2pqSNX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL3yVXZcwHru4afbCcThqjEdByQx18EtD1': { + 'private_key': 'UqKbabzGV6DyDTsqLS81kndyfEAVhYpn1CL2SZpoojGYgtwRfprL', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX9LWeXyqxbt6DWqwSdFuxdt51REj9x7Hg': { + 'private_key': 'UpGhuvQFgMeL1KxV58JHCNQwqn5vPdV9FxFyWG64HXTnSU9oBxpm', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RRS9wZpSRCJTJgAP1UUb5BHmQAR36RokCa': { + 'private_key': 'UrAhvvCtQjfwMU9skqPhVUKsGaW7BgVV9Fm47TxBW6rVbzMQp8Bk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJWjLZMJJyEEreUuQtbCySxLUS871mpdAz': { + 'private_key': 'UvCGBmqNuQLi6VG4Nnq8xoWraAzm2yh81DsXZuMpwYeb9w6hrp5i', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSfu5E6B75VyYnK1ftBfN5JPWijfoCzgpQ': { + 'private_key': 'UvxgVXT3fuUmDEhe9cTf2ejnT6hYEngrnWQhUULE7smbzNxuLCFR', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX7pUVmbyVcCXnAPQCkqTsyU7VLuxuRPuQ': { + 'private_key': 'UxHATt65WkH7urMPFu46huG5DDjhiUurY4ZutGMD3yvThkbEjXDh', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RYW2AHjJRj5GTMsYYy3BUV3jgtcZLy1VN3': { + 'private_key': 'Ut88PbKCqv5zymn91S4nFEJuJ97q3D9FZXPuLddBA7jw1TNhwCmk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUeCRUWsKqHeyPLMdKEutwYHmCeMe7Z2u3': { + 'private_key': 'UtREbnKCKGAMW8MzDWmv4eh7U5Ue77cNoNhwgSLGpKkvjNecVBR8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKLhcHtGjgVsqoQH7yoiRTnxzTqiw2hqAz': { + 'private_key': 'UvxXnvJkX43dkQYJB53vAFum9T3WiCiX1BwatTdG4afVF6YJgJF1', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REwATRrvgxVYhupzbm3EHTzrJ9iaDTbE4e': { + 'private_key': 'UwLdN3q8rpX75HMEs7nbCquqRBKwcKkWb3GHaewZWyZL8dtbH6c9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHiuxfVvCJw3WkC8QhcMJyaz8R3H6bDvrT': { + 'private_key': 'Usmtdc9BAX6JWje8X1tdMubRDGJMHv3tgQPkkrtjuccFRoajizmk', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWGdCfrxXPcZn5acvfH6aheWkBzizAchze': { + 'private_key': 'UtP2HgnCq8XfLMct55zPN4qrYzn5ALEazxPTqRHW114hqdS18on4', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RG8pRqR2h7aYJbLVL2TMQXa3kWa4acVRnb': { + 'private_key': 'UvpZ3Pc5GHPRNSUXcPPHu6cZ3G9qPm6D59aXi9knN1cyMqjjqL9k', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCKdLhkqDX7FWuqtncgSUjHFc4KutHSCk7': { + 'private_key': 'Ut7ACkQNeeoi4vtdDw4ZjWGwX53KcBUJ8LuVx5sSvVxxqQhzUTdM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RT2D2WY5kGetdTKXvS4V5d1S6X5WmWhf3B': { + 'private_key': 'UsQj6QFPnB2u9tad3jLGrxB5bzerZVQPwgN8cinuL7qvu1VA3jRN', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGzUR9zsiXLGg5teRcFovVSYq4Jm6RTLdQ': { + 'private_key': 'UsJmRR9LN5D3WJJHGLMG9dYfBhqEjc5x98stmCq2Pr7VbwKfVarv', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RBe57PjiYx1zcVzXQGRMCVWnVqqNER6o8b': { + 'private_key': 'UwqqKwtnyHwm25ZUvWqva8mbpjonczmz8a8gwnfnpu7eQLbe7vBc', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RJAV3gDm3LyqwDCPRr285at15JPEQ6Erq1': { + 'private_key': 'Uq27DebHcmRHpQafFTSGhY9U973fHQRhGQKRJ3cdphKc2X2UmBzV', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RFxp1aaaNjm8srJn6kjxE64thyqTQ9q4um': { + 'private_key': 'Ur19mZFLpDmxUYdQdhy2AL6gQEjWmqu1FhWLGkvXcCGBL2iHFAMM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDgZnvGpWFtG1vUCbP87Wt1VTPF3nfmB3t': { + 'private_key': 'UueznYXoN3VBK6gNYTYiWe9zuGZNXjW4FyPQncVx4DLH7FMe6An7', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RX7hWWSNPw1rRrK5XF4VpsnZCyMt7taff3': { + 'private_key': 'UtogFbsDpiBy2c38zYwgyv6xk6KPSPC79mMbPEvuDfTRUAxbnKWH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REiPyGKcZ7xWo3cL9kGM6EUkHZ8o4Ysn4w': { + 'private_key': 'Uuk6eriYrb6tpNxLxCjaRdzry6wKUikysVCbL7HDy2fkNvx6P4GA', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RV1NWzWHNzQ4rhNUdtXW6wmo5UxHq8Qqzb': { + 'private_key': 'UvYt8paPnAXuH4uUAnEAsu966QDomLXnorhFGokDhepczA5bbHLp', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REThVeXoEfyfU8a988FGccxVRswzzfHX13': { + 'private_key': 'UrHYX3aXN56chCPC6JmyBgeg5YxMByiNNn3FGX8Cd7dViiHhfEsP', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGoETmF7PAcCU4qe54hYxZxhezrSFDTenE': { + 'private_key': 'UqYPCu8snp4cLbyrAWwzZj2BSSja5ueFmA69dsgn2BXp9iNJz58W', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKzk42LVW6p9wz7vdamRgZfohKJqKjY8dU': { + 'private_key': 'Uw2sTCBnA1zvHKpumAFJVSaEgspYYv2rS9pp5o74rushbJS1fwJ6', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKbLbZLEL2PXrqUXgU3yGAUBgRnxMHYDYQ': { + 'private_key': 'UsZC835JC9tjD9n89MWfre1P7yKRbyrpkDuAQe7soEe43ocD6ZGH', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNuKrQf6TSL5nof2NRs5VhAbyZtAs9qVrk': { + 'private_key': 'UtYDYPbNrQiVpqr6aKq66RBSaZQrDXEvcc88QpjrGZWwwwjPnmRX', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RQJHZjcGviQxaJxxvQsywesUqMYk4jgKGY': { + 'private_key': 'UqPZVYHR6ASg54ynL1QUmHsqd4EZ1abzwMTCHo7jq58FGtAFETdC', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RT8vihmqz6AU6cdWNtqiF15edapcEeypie': { + 'private_key': 'UtTTnsd7b9crHa825sc7Tjn8zjMR5YFJqqCHfHruAZWkjBUjBWEM', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RP4FJFz4X4Utpphqddew4SCaTa2DoLsyuW': { + 'private_key': 'UrK4kKw1CwbAY5yckN6Yy7YrXd89e8e31szvKKLvAWsVwGp839My', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKHHme1crs4Wd3KHCjfCVfHPwCAnjgND3k': { + 'private_key': 'Us4VrBFpB17bs8YURvRfdusBSbNYr3aEoctjkmXjx8cUqNvpJdvb', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RECjyaXbRueGHbXLCyrcs6pfwmJ7zqSxeh': { + 'private_key': 'UwFyBb21BiYLz5QuMHtaF54sJyh1SSTBoUSRDBFFhLnD2GLxDXo8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNnHFBVHAXbsWoJqZbx95CHBi2JiaHBeq6': { + 'private_key': 'UwskLnsFTu82jq3UMxbWXSp24YzLMjwuGo22ikQrKuSXYhUyjrqq', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RES33w8KADMJnAuuA9zQLQyyLb6Bg3hk6i': { + 'private_key': 'UtF9cQSGXVazS5MC7FSzo89MX3BhZL2qasF8VsHTtPBcL8u9Byqo', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUiC86Lpgoi2u9QgDczXUYKh7guuuppNqG': { + 'private_key': 'UuemhNXpQkhi8yCHCUvNA6DcJAGsNyuj6fE9qPSwcFVwUoXskJ9M', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHRQNZW8HLcvdngs3hm9o4onSKBiMJ9RhT': { + 'private_key': 'UvmNMbUEeEbA3REqZBpGrcZorvwfgnUNheGpwAxL4cfJao4RRyhG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RL29oXDVVyQkcGdFHqzWQ5Wzy6Xha92P4b': { + 'private_key': 'UpxEEpT8SdDocdkjCU1TCAmimESFjDiqNnkL3JnTyXsFvuDfQQoe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUXBad3dA5G452N7C8pqxTFfYsU2Q8qCfQ': { + 'private_key': 'UpjGQupHxZs4uNDhVaBNFuzi3FXnS9Yb2dkfnrJLpkstYZsWTZmZ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKBB4y6sr3yHRnNCoYqtapGg3QkqdkBxxh': { + 'private_key': 'UtTEnDHS4EMGEWKewMcfEckbyC3Fg1UptEHax58WjrKNBARBcxT5', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RR7SGmCH6n8hFNbLMmkSfiGQsh8UB17AXe': { + 'private_key': 'Us6Y5jBkAgk2eSWsdmGBsFoL1r4w8JB6uHR4xrNcX79cVhVQeMDe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNoBHxwjx5M6bRt1riMBwig82XEQyVXHLN': { + 'private_key': 'Uw67cuUJDF87b5kbrJVK33ZQMjx35M4SBerYstCYh6hqaXAfb5oG', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RXo7Q88iSrmXq1zVCPqczR2sEUqxu34aNU': { + 'private_key': 'UrBKt2cxLNcJWwEtipw7bDS6pvFjzCk7axqh7wrQ92tSEsKyyRWJ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RKJAgvuMprfXLKhTppxirU7w7kuEmdsR6q': { + 'private_key': 'Up8Knr9FKRF8rR3yAcK1cBBYNwzAhLPWcdKx8MwqE67drTMwAHE8', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RSX1gDWPmWw7Rws47pSuQXFfAibAXZ4RcD': { + 'private_key': 'UqGs3VzCocFzWdfktcYxZPFUAkur986LPMQyaSd7REG2fD6bipT7', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RGccKWsSgECvCEf5fQn3ZHdUq6oHgFLSHq': { + 'private_key': 'UsnwDhonaworxMiKAm3Q2LXKQzhUgDtiarg89TMZxMgFpvAXeR8f', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RNZL5vmoZWkziwyWkkBxQDUaYwu6a4eDUX': { + 'private_key': 'Us9H7hgbFFyJFwjesdadXDVhng5Uf5ihZ6NDieX4YLbjHy3WP9YD', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHpoPmXUMnZ4snTtHQoNmLfwAf4BbbjMyg': { + 'private_key': 'UuYhCvgYgd96Z7fmjFye2nyMz5nasuvoBsFStzEZYgP3EPRfLhwr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVRVev8VdxLvJ7R7Bg43E5Mk4YLmuqTjLY': { + 'private_key': 'UuDXjDT8uU5NhRkteTSEkBDAEfLthXAJ3dv9TidHrEG31w1wgDSj', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDoHwtx14PW8rUdJMgoH4RUSoSZpRRvqV9': { + 'private_key': 'UsC1HJ5oeywtJoUfrW5pCHqhs3zNePZRG9GAR24LT1E4vAgZdpmQ', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RWQZeYUkpAsTxvUqXXQ2XLi1mKo44ENqLm': { + 'private_key': 'UrDQ4n9ug8Emx2VEMkPbXKLwqnwuWkqJvJJEFWESioSvpS1oabcy', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RCreATuAUnVGgrajtamcCVkz1f7TUZ42NT': { + 'private_key': 'UrkASMqCNsi2CPezgRDAMiSnxCK4qAGWd9ci4s4caTgnh5EirqLF', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RLBXz3NcSphG2uvGEsMHkPDNE9w5G64xf3': { + 'private_key': 'UvAjyN6V4AkKxpfxQhtrsTUzePzbjNWYeVKLZwz3J8fjeh6jcDpe', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RVoysqQre1A6bmJst1kbVDeTgracCGYGgo': { + 'private_key': 'Uq8bWb2Sn9gwwwqUCPVgTVJsJAcRhSPkhCnaBA1a9hMGmesmZVZt', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RF26XBT2xWD49Qh1ySbg8nu9Z1rQ7GySCL': { + 'private_key': 'UpTxAys1YMYvjmd64TNsXgrAfm2QuFW8YUhrCn2ADCeq6Z2Wygkw', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'REoVKVD84dhbiNnBm44jsFVPthRgRoTJgd': { + 'private_key': 'Uv8vibPKcrqxwqRgdGCLrs4M5mJqxQNNMtpZ7NcowRj6xALMpeJr', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RUGWKBozdTNJBnkyWZVnZksfLMrThat2P1': { + 'private_key': 'UpaoZFaV1N5HhBaJmLge6eHsQPNEK749VW6s2DJ2RgcMbDhrQ1bd', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RDvCrWVTRMdGhF9WeDSKCKaNJz3y3Ec5mn': { + 'private_key': 'UpGMk97MW7zu8E89UBybwswGwrtP6LxBnFp4mtm4ubftfP4vAeP9', + 'is_funded_rick': true, + 'is_funded_morty': true + }, + 'RHbbaaxhWSEtesWDs8bRiVvsveU3g7AYHx': { + 'private_key': 'Ur5W4FWXJ6uK7giFFJpXtwuaPF8cgQJZk1GjvfABCAi2JKbGnQZ6', + 'is_funded_rick': true, + 'is_funded_morty': true + } +}; diff --git a/test_integration/helpers/log_out.dart b/test_integration/helpers/log_out.dart new file mode 100644 index 0000000000..fd01ad0c3e --- /dev/null +++ b/test_integration/helpers/log_out.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future logOut(WidgetTester tester) async { + final Finder settingsMenu = find.byKey(const Key('main-menu-settings')); + final Finder logOutButton = find.byKey(const Key('settings-logout-button')); + final Finder confirmLogoutButton = + find.byKey(const Key('popup-confirm-logout-button')); + + await tester.tap(settingsMenu); + await tester.pumpAndSettle(); + await tester.tap(logOutButton); + await tester.pumpAndSettle(); + await tester.tap(confirmLogoutButton); + await tester.pumpAndSettle(); +} diff --git a/test_integration/helpers/open_coins_manager.dart b/test_integration/helpers/open_coins_manager.dart new file mode 100644 index 0000000000..42a7f45cac --- /dev/null +++ b/test_integration/helpers/open_coins_manager.dart @@ -0,0 +1,37 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future openAddAssetsView(WidgetTester tester) async { + await tester.pumpAndSettle(); + + final Finder addAssetsButton = find.byKey(const Key('add-assets-button')); + await tester.tap(addAssetsButton); + await tester.pumpAndSettle(); + + final Finder searchCoinsField = + find.byKey(const Key('coins-manager-search-field')); + expect( + searchCoinsField, + findsOneWidget, + reason: + 'Test error: \'Add assets\' button pressed, but coins manager didn\'t open', + ); +} + +Future openRemoveAssetsView(WidgetTester tester) async { + await tester.pumpAndSettle(); + + final Finder removeAssetsButton = + find.byKey(const Key('remove-assets-button')); + await tester.tap(removeAssetsButton); + await tester.pumpAndSettle(); + + final Finder searchCoinsField = + find.byKey(const Key('coins-manager-search-field')); + expect( + searchCoinsField, + findsOneWidget, + reason: + 'Test error: \'Remove assets\' button pressed, but coins manager didn\'t open', + ); +} diff --git a/test_integration/helpers/open_wallet_section.dart b/test_integration/helpers/open_wallet_section.dart new file mode 100644 index 0000000000..6713aa5153 --- /dev/null +++ b/test_integration/helpers/open_wallet_section.dart @@ -0,0 +1,23 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +Future openWalletSection(WidgetTester tester) async { + await tester.pumpAndSettle(); + + final Finder walletMenuItem = find.byKey(const Key('main-menu-wallet')); + expect( + walletMenuItem, + findsOneWidget, + reason: 'Test error: Wallet main menu item not found', + ); + await tester.tap(walletMenuItem); + await tester.pumpAndSettle(); + + final Finder totalAmount = find.byKey(const Key('overview-total-balance')); + expect( + totalAmount, + findsOneWidget, + reason: + 'Test error: Wallet main menu item pressed, but no Total balance shown', + ); +} diff --git a/test_integration/helpers/restore_wallet.dart b/test_integration/helpers/restore_wallet.dart new file mode 100644 index 0000000000..5e756438a6 --- /dev/null +++ b/test_integration/helpers/restore_wallet.dart @@ -0,0 +1,79 @@ +import 'package:bip39/bip39.dart' as bip39; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/model/wallet.dart'; + +import '../common/pump_and_settle.dart'; +import '../helpers/get_funded_wif.dart'; +import 'connect_wallet.dart'; + +Future restoreWalletToTest(WidgetTester tester) async { + // Restores wallet to be used in following tests + final String testSeed = getFundedWif(); + const String walletName = 'my-wallet'; + const String password = 'pppaaasssDDD555444@@@'; + final Finder importWalletButton = + find.byKey(const Key('import-wallet-button')); + final Finder nameField = find.byKey(const Key('name-wallet-field')); + final Finder passwordField = find.byKey(const Key('create-password-field')); + final Finder passwordConfirmField = + find.byKey(const Key('create-password-field-confirm')); + final Finder importSeedField = find.byKey(const Key('import-seed-field')); + final Finder importConfirmButton = + find.byKey(const Key('confirm-seed-button')); + final Finder eulaCheckBox = find.byKey(const Key('checkbox-eula')); + final Finder tocCheckBox = find.byKey(const Key('checkbox-toc')); + final Finder walletsManagerWrapper = + find.byKey(const Key('wallets-manager-wrapper')); + final Finder allowCustomSeedCheckbox = + find.byKey(const Key('checkbox-custom-seed')); + final Finder customSeedDialogInput = + find.byKey(const Key('custom-seed-dialog-input')); + final Finder customSeedDialogOkButton = + find.byKey(const Key('custom-seed-dialog-ok-button')); + const String confirmCustomSeedText = 'I understand'; + + await tester.pumpAndSettle(); + isMobile + ? await tapOnMobileConnectWallet(tester, WalletType.iguana) + : await tapOnAppBarConnectWallet(tester, WalletType.iguana); + await tester.ensureVisible(importWalletButton); + await tester.tap(importWalletButton); + await tester.pumpAndSettle(); + + await tester.tap(nameField); + await tester.enterText(nameField, walletName); + await tester.enterText(importSeedField, testSeed); + + if (!bip39.validateMnemonic(testSeed)) { + await tester.tap(allowCustomSeedCheckbox); + await tester.pumpAndSettle(); + await tester.enterText(customSeedDialogInput, confirmCustomSeedText); + await tester.pumpAndSettle(); + await tester.tap(customSeedDialogOkButton); + await tester.pumpAndSettle(); + } + + await tester.tap(eulaCheckBox); + await tester.pumpAndSettle(); + await tester.tap(tocCheckBox); + await tester.dragUntilVisible( + importConfirmButton, + walletsManagerWrapper, + const Offset(0, -15), + ); + await tester.pumpAndSettle(); + await tester.tap(importConfirmButton); + await tester.pumpAndSettle(); + await tester.enterText(passwordField, password); + await tester.enterText(passwordConfirmField, password); + await tester.dragUntilVisible( + importConfirmButton, + walletsManagerWrapper, + const Offset(0, -15), + ); + await tester.pumpAndSettle(); + await tester.tap(importConfirmButton); + await pumpUntilDisappear(tester, walletsManagerWrapper); +} diff --git a/test_integration/helpers/switch_coins_active_state.dart b/test_integration/helpers/switch_coins_active_state.dart new file mode 100644 index 0000000000..6c5d493791 --- /dev/null +++ b/test_integration/helpers/switch_coins_active_state.dart @@ -0,0 +1,45 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Activate/deactivate [coins], +/// depending on which coins manager view +/// is currently open (Add assets or Remove assets) +Future switchCoinsActiveState( + WidgetTester tester, + List coins, +) async { + await tester.pumpAndSettle(); + + final Finder searchCoinsField = + find.byKey(const Key('coins-manager-search-field')); + + for (String coin in coins) { + final List coinData = coin.split(':'); + final String abbr = coinData.first; + final String searchTerms = coinData.last; + + print('Test: trying to find and select $abbr in coins manager.'); + + await tester.enterText(searchCoinsField, searchTerms); + await tester.pumpAndSettle(const Duration(milliseconds: 250)); + final Finder inactiveCoinItem = + find.byKey(Key('coins-manager-list-item-${abbr.toLowerCase()}')); + expect( + inactiveCoinItem, + findsOneWidget, + reason: + 'Test error: searching coins manager for $abbr, but nothing found', + ); + await tester.tap(inactiveCoinItem); + await tester.pumpAndSettle(); + } + + await tester.pumpAndSettle(const Duration(milliseconds: 250)); + + final Finder switchButton = + find.byKey(const Key('coins-manager-switch-button')); + await tester.tap(switchButton); + await tester.pumpAndSettle(); +} diff --git a/test_integration/tests/dex_tests/dex_tests.dart b/test_integration/tests/dex_tests/dex_tests.dart new file mode 100644 index 0000000000..30a392e093 --- /dev/null +++ b/test_integration/tests/dex_tests/dex_tests.dart @@ -0,0 +1,27 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import './maker_orders_test.dart'; +import './taker_orders_test.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run DEX tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await tester.pumpAndSettle(); + await testMakerOrder(tester); + await tester.pumpAndSettle(); + await testTakerOrder(tester); + + print('END DEX TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/dex_tests/maker_orders_test.dart b/test_integration/tests/dex_tests/maker_orders_test.dart new file mode 100644 index 0000000000..2ec907215c --- /dev/null +++ b/test_integration/tests/dex_tests/maker_orders_test.dart @@ -0,0 +1,116 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; +import 'package:web_dex/shared/widgets/focusable_widget.dart'; +import 'package:web_dex/views/dex/entities_list/orders/order_item.dart'; + +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testMakerOrder(WidgetTester tester) async { + const String sellCoin = 'DOC'; + const String sellAmount = '0.012345'; + const String buyCoin = 'MARTY'; + const String buyAmount = '0.023456'; + + String? truncatedUuid; + + final Finder dexSectionButton = find.byKey(const Key('main-menu-dex')); + final Finder makeOrderTab = find.byKey(const Key('make-order-tab')); + final Finder sellCoinSelectButton = + find.byKey(const Key('maker-form-sell-switcher')); + final Finder sellCoinSearchField = find.descendant( + of: find.byKey(const Key('maker-sell-coins-table')), + matching: find.byKey(const Key('search-field')), + ); + final Finder sellCoinItem = + find.byKey(const Key('coin-table-item-$sellCoin')); + final Finder sellAmountField = + find.byKey(const Key('maker-sell-amount-input')); + final Finder buyCoinSelectButton = + find.byKey(const Key('maker-form-buy-switcher')); + final Finder buyCoinSearchField = find.descendant( + of: find.byKey(const Key('maker-buy-coins-table')), + matching: find.byKey(const Key('search-field')), + ); + final Finder buyCoinItem = find.byKey(const Key('coin-table-item-$buyCoin')); + final Finder buyAmountField = find.byKey(const Key('maker-buy-amount-input')); + final Finder makeOrderButton = find.byKey(const Key('make-order-button')); + final Finder makeOrderConfirmButton = + find.byKey(const Key('make-order-confirm-button')); + final Finder orderListItem = find.byType(OrderItem); + final Finder orderUuidWidget = find.byKey(const Key('maker-order-uuid')); + + // Open maker order form + await tester.tap(dexSectionButton); + await tester.pumpAndSettle(); + await tester.tap(makeOrderTab); + await tester.pumpAndSettle(); + + // Select sell coin, enter sell amount + await tester.tap(sellCoinSelectButton); + await tester.pumpAndSettle(); + await tester.enterText(sellCoinSearchField, sellCoin); + await tester.pumpAndSettle(); + await tester.tap(sellCoinItem); + await tester.pumpAndSettle(); + await tester.enterText(sellAmountField, sellAmount); + await tester.pumpAndSettle(); + + // Select buy coin, enter buy amount + await tester.tap(buyCoinSelectButton); + await tester.pumpAndSettle(); + await tester.enterText(buyCoinSearchField, buyCoin); + await tester.pumpAndSettle(); + await tester.tap(buyCoinItem); + await tester.pumpAndSettle(); + await tester.enterText(buyAmountField, buyAmount); + await tester.pumpAndSettle(); + + // Create order + await tester.dragUntilVisible( + makeOrderButton, + find.byKey(const Key('maker-form-layout-scroll')), + const Offset(0, -100), + ); + await tester.tap(makeOrderButton); + await tester.pumpAndSettle(); + + await tester.dragUntilVisible( + makeOrderConfirmButton, + find.byKey(const Key('maker-order-conformation-scroll')), + const Offset(0, -100), + ); + await tester.tap(makeOrderConfirmButton); + await tester.pumpAndSettle(); + + // Open order details page + expect(orderListItem, findsOneWidget); + await tester.tap(find.descendant( + of: orderListItem, matching: find.byType(FocusableWidget))); + await tester.pumpAndSettle(); + + // Find order UUID on maker order details page + expect(orderUuidWidget, findsOneWidget); + truncatedUuid = (orderUuidWidget.evaluate().single.widget as Text).data; + expect(truncatedUuid != null, isTrue); + expect(truncatedUuid?.isNotEmpty, isTrue); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run maker order tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await tester.pumpAndSettle(); + await testMakerOrder(tester); + + print('END MAKER ORDER TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/dex_tests/taker_orders_test.dart b/test_integration/tests/dex_tests/taker_orders_test.dart new file mode 100644 index 0000000000..3ce6b46313 --- /dev/null +++ b/test_integration/tests/dex_tests/taker_orders_test.dart @@ -0,0 +1,218 @@ +// ignore_for_file: avoid_print + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; +import 'package:web_dex/shared/widgets/copied_text.dart'; +import 'package:web_dex/views/dex/entities_list/history/history_item.dart'; + +import '../../common/pause.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testTakerOrder(WidgetTester tester) async { + final String sellCoin = Random().nextDouble() > 0.5 ? 'DOC' : 'MARTY'; + const String sellAmount = '0.01'; + final String buyCoin = sellCoin == 'DOC' ? 'MARTY' : 'DOC'; + + final Finder dexSectionButton = find.byKey(const Key('main-menu-dex')); + final Finder dexSectionSwapTab = find.byKey(const Key('dex-swap-tab')); + final Finder sellCoinSelectButton = find.byKey( + const Key('taker-form-sell-switcher'), + ); + final Finder sellCoinSearchField = find.descendant( + of: find.byKey(const Key('taker-sell-coins-table')), + matching: find.byKey(const Key('search-field')), + ); + final Finder sellCoinItem = find.byKey(Key('coin-table-item-$sellCoin')); + final Finder sellAmountField = find.descendant( + of: find.byKey(const Key('taker-sell-amount')), + matching: find.byKey(const Key('amount-input')), + ); + final Finder buyCoinSelectButton = + find.byKey(const Key('taker-form-buy-switcher')); + final Finder buyCoinSearchField = find.descendant( + of: find.byKey(const Key('taker-orders-table')), + matching: find.byKey(const Key('search-field')), + ); + final Finder buyCoinItem = find.byKey(Key('orders-table-item-$buyCoin')); + + const String infiniteBidVolume = '2.00'; + final bidsTable = find.byKey(const Key('orderbook-bids-list')); + bool infiniteBidPredicate(Widget widget) { + if (widget is Text) { + return widget.data?.contains(infiniteBidVolume) ?? false; + } + + return false; + } + + final infiniteBids = find.descendant( + of: bidsTable, + matching: find.byWidgetPredicate(infiniteBidPredicate), + ); + + final Finder takeOrderButton = find.byKey(const Key('take-order-button')); + final Finder takeOrderConfirmButton = + find.byKey(const Key('take-order-confirm-button')); + final Finder tradingDetailsScrollable = find.byType(Scrollable); + final Finder takerFeeSentEventStep = + find.byKey(const Key('swap-details-step-TakerFeeSent')); + final Finder makerPaymentReceivedEventStep = + find.byKey(const Key('swap-details-step-MakerPaymentReceived')); + final Finder takerPaymentSentEventStep = + find.byKey(const Key('swap-details-step-TakerPaymentSent')); + final Finder takerPaymentSpentEventStep = + find.byKey(const Key('swap-details-step-TakerPaymentSpent')); + final Finder makerPaymentSpentEventStep = + find.byKey(const Key('swap-details-step-MakerPaymentSpent')); + final Finder swapSuccess = find.byKey(const Key('swap-status-success')); + final Finder backButton = find.byKey(const Key('return-button')); + final Finder historyTab = find.byKey(const Key('dex-history-tab')); + + // Open taker order form + await tester.tap(dexSectionButton); + await tester.pumpAndSettle(); + await tester.tap(dexSectionSwapTab); + await tester.pumpAndSettle(); + + // Select sell coin, enter sell amount + await tester.tap(sellCoinSelectButton); + await tester.pumpAndSettle(); + await tester.enterText(sellCoinSearchField, sellCoin); + await tester.pumpAndSettle(); + await tester.tap(sellCoinItem); + await tester.pumpAndSettle(); + await tester.enterText(sellAmountField, sellAmount); + await tester.pumpAndSettle(); + + // Select buy coin + await tester.tap(buyCoinSelectButton); + await tester.pumpAndSettle(); + await tester.enterText(buyCoinSearchField, buyCoin); + await tester.pumpAndSettle(); + await tester.tap(buyCoinItem); + await tester.pumpAndSettle(); + + await pause(); + + // Select infinite bid if it exists + if (infiniteBids.evaluate().isNotEmpty) { + await tester.tap(infiniteBids.first); + await tester.pumpAndSettle(); + } + + // Create order + await tester.dragUntilVisible( + takeOrderButton, + find.byKey(const Key('taker-form-layout-scroll')), + const Offset(0, -150), + ); + await tester.tap(takeOrderButton); + await tester.pumpAndSettle(); + + await tester.dragUntilVisible( + takeOrderConfirmButton, + find.byKey(const Key('taker-order-confirmation-scroll')), + const Offset(0, -150), + ); + await tester.tap(takeOrderConfirmButton); + await tester.pumpAndSettle().timeout( + const Duration(minutes: 10), + onTimeout: () { + throw 'Test error: DOC->MARTY taker Swap took more than 10 minutes'; + }, + ); + + expect( + swapSuccess, + findsOneWidget, + reason: 'Test error: Taker Swap was not successful (probably failed)', + ); + + expect( + find.descendant( + of: takerFeeSentEventStep, + matching: find.byType(CopiedText), + ), + findsOneWidget, + reason: 'Test error: \'takerFeeSent\' event tx copied text not found'); + expect( + find.descendant( + of: makerPaymentReceivedEventStep, + matching: find.byType(CopiedText), + ), + findsOneWidget, + reason: + 'Test error: \'makerPaymentReceived\' event tx copied text not found'); + + await tester.dragUntilVisible( + takerPaymentSentEventStep, + tradingDetailsScrollable, + const Offset(0, -10), + ); + expect( + find.descendant( + of: takerPaymentSentEventStep, matching: find.byType(CopiedText)), + findsOneWidget, + reason: + 'Test error: \'takerPaymentSent\' event tx copied text not found'); + + await tester.dragUntilVisible( + takerPaymentSpentEventStep, + tradingDetailsScrollable, + const Offset(0, -10), + ); + expect( + find.descendant( + of: takerPaymentSpentEventStep, matching: find.byType(CopiedText)), + findsOneWidget, + reason: + 'Test error: \'takerPaymentSpent\' event tx copied text not found'); + + await tester.dragUntilVisible( + makerPaymentSpentEventStep, + tradingDetailsScrollable, + const Offset(0, -10), + ); + expect( + find.descendant( + of: makerPaymentSpentEventStep, matching: find.byType(CopiedText)), + findsOneWidget, + reason: + 'Test error: \'makerPaymentSpent\' event tx copied text not found'); + + await tester.dragUntilVisible( + backButton, + tradingDetailsScrollable, + const Offset(0, 10), + ); + + await tester.tap(backButton); + await tester.pumpAndSettle(); + await tester.tap(historyTab); + await tester.pump((const Duration(milliseconds: 1000))); + expect( + find.byType(HistoryItem), + findsOneWidget, + reason: 'Test error: Swap history item not found', + ); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run taker order tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await tester.pumpAndSettle(); + await testTakerOrder(tester); + + print('END TAKER ORDER TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/misc_tests/feedback_tests.dart b/test_integration/tests/misc_tests/feedback_tests.dart new file mode 100644 index 0000000000..e276000bdd --- /dev/null +++ b/test_integration/tests/misc_tests/feedback_tests.dart @@ -0,0 +1,39 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/goto.dart' as goto; +import '../../common/pause.dart'; +import '../../common/tester_utils.dart'; +import '../../helpers/accept_alpha_warning.dart'; + +Future testFeedbackForm(WidgetTester tester) async { + await goto.settingsPage(tester); + await tester.pumpAndSettle(); + await testerTap(tester, find.byKey(const Key('settings-menu-item-feedback'))); + await tester.pumpAndSettle(); + tester.ensureVisible(find.byKey(const Key('feedback-email-field'))); + tester.ensureVisible(find.byKey(const Key('feedback-message-field'))); + tester.ensureVisible(find.byKey(const Key('feedback-submit-button'))); + await pause(msg: 'END TEST FEEDBACK'); + await tester.pumpAndSettle(); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Run feedback tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + await acceptAlphaWarning(tester); + print('ACCEPT ALPHA WARNING'); + await tester.pumpAndSettle(); + await testFeedbackForm(tester); + + print('END FEEDBACK FORM TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/misc_tests/menu_tests.dart b/test_integration/tests/misc_tests/menu_tests.dart new file mode 100644 index 0000000000..7d678f53f0 --- /dev/null +++ b/test_integration/tests/misc_tests/menu_tests.dart @@ -0,0 +1,71 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/goto.dart' as goto; +import '../../common/pause.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testMainMenu(WidgetTester tester) async { + final Finder general = find.byKey( + const Key('settings-menu-item-general'), + ); + final Finder security = find.byKey( + const Key('settings-menu-item-security'), + ); + final Finder feedback = find.byKey( + const Key('settings-menu-item-feedback'), + ); + + await goto.walletPage(tester); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('wallet-page-coins-list')), findsOneWidget); + + await goto.dexPage(tester); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('dex-page')), findsOneWidget); + + await goto.bridgePage(tester); + await tester.pumpAndSettle(); + expect( + find.byKey(const Key('bridge-page')), + findsOneWidget, + reason: 'bridge-page key not found', + ); + + await goto.nftsPage(tester); + await tester.pumpAndSettle(); + expect(find.byKey(const Key('nft-page')), findsOneWidget); + + await goto.settingsPage(tester); + await tester.pumpAndSettle(); + expect(general, findsOneWidget); + expect(security, findsOneWidget); + expect(feedback, findsOneWidget); + + await goto.supportPage(tester); + await tester.pumpAndSettle(); + + await pause(msg: 'END TEST MENU'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Run menu tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + await acceptAlphaWarning(tester); + print('ACCEPT ALPHA WARNING'); + await restoreWalletToTest(tester); + await testMainMenu(tester); + await tester.pumpAndSettle(); + + print('END MAIN MENU TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/misc_tests/misc_tests.dart b/test_integration/tests/misc_tests/misc_tests.dart new file mode 100644 index 0000000000..89009e265f --- /dev/null +++ b/test_integration/tests/misc_tests/misc_tests.dart @@ -0,0 +1,30 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import './feedback_tests.dart'; +import './menu_tests.dart'; +import './theme_test.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run misc tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + await acceptAlphaWarning(tester); + await tester.pumpAndSettle(); + await testThemeSwitcher(tester); + await tester.pumpAndSettle(); + await testFeedbackForm(tester); + await tester.pumpAndSettle(); + await restoreWalletToTest(tester); + await testMainMenu(tester); + + print('END MISC TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/misc_tests/theme_test.dart b/test_integration/tests/misc_tests/theme_test.dart new file mode 100644 index 0000000000..b8698170cd --- /dev/null +++ b/test_integration/tests/misc_tests/theme_test.dart @@ -0,0 +1,56 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/goto.dart' as goto; +import '../../helpers/accept_alpha_warning.dart'; + +Future testThemeSwitcher(WidgetTester tester) async { + final themeSwitcherFinder = find.byKey(const Key('theme-switcher')); + final themeSettingsSwitcherLight = + find.byKey(const Key('theme-settings-switcher-Light')); + final themeSettingsSwitcherDark = + find.byKey(const Key('theme-settings-switcher-Dark')); + + // Check default theme (dark) + checkTheme(tester, themeSwitcherFinder, Brightness.dark); + + await tester.tap(themeSwitcherFinder); + await tester.pumpAndSettle(); + checkTheme(tester, themeSwitcherFinder, Brightness.light); + + await goto.settingsPage(tester); + await tester.tap(themeSettingsSwitcherDark); + await tester.pumpAndSettle(); + checkTheme(tester, themeSwitcherFinder, Brightness.dark); + + await tester.pumpAndSettle(); + await tester.tap(themeSettingsSwitcherLight); + await tester.pumpAndSettle(); + checkTheme(tester, themeSwitcherFinder, Brightness.light); +} + +dynamic checkTheme( + WidgetTester tester, Finder testElement, Brightness brightnessExpected) { + expect(Theme.of(tester.element(testElement)).brightness, + equals(brightnessExpected)); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Run design tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + await acceptAlphaWarning(tester); + print('ACCEPT ALPHA WARNING'); + await tester.pumpAndSettle(); + await testThemeSwitcher(tester); + + print('END THEME SWITCH TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/nfts_tests/nft_details.dart b/test_integration/tests/nfts_tests/nft_details.dart new file mode 100644 index 0000000000..e0568ab15d --- /dev/null +++ b/test_integration/tests/nfts_tests/nft_details.dart @@ -0,0 +1,28 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testNftDetails(WidgetTester tester) async { + print('TEST NFT DEAILS'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run NFT details tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testNftDetails(tester); + await tester.pumpAndSettle(); + + print('END NFT DETAILS TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/nfts_tests/nft_networks.dart b/test_integration/tests/nfts_tests/nft_networks.dart new file mode 100644 index 0000000000..90daf95483 --- /dev/null +++ b/test_integration/tests/nfts_tests/nft_networks.dart @@ -0,0 +1,28 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testNftNetworks(WidgetTester tester) async { + print('TEST NFT NETWORKS'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run NFT networs tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testNftNetworks(tester); + await tester.pumpAndSettle(); + + print('END NFT NETWORKS TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/nfts_tests/nft_receive.dart b/test_integration/tests/nfts_tests/nft_receive.dart new file mode 100644 index 0000000000..91176a7097 --- /dev/null +++ b/test_integration/tests/nfts_tests/nft_receive.dart @@ -0,0 +1,28 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testNftReceive(WidgetTester tester) async { + print('TEST NFT RECEIVE'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run NFT receive tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testNftReceive(tester); + await tester.pumpAndSettle(); + + print('END NFT RECEIVE TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/nfts_tests/nft_send.dart b/test_integration/tests/nfts_tests/nft_send.dart new file mode 100644 index 0000000000..5809220360 --- /dev/null +++ b/test_integration/tests/nfts_tests/nft_send.dart @@ -0,0 +1,28 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testNftSend(WidgetTester tester) async { + print('TEST NFT SEND'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run NFT send tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testNftSend(tester); + await tester.pumpAndSettle(); + + print('END NFT SEND TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/nfts_tests/nft_transactions.dart b/test_integration/tests/nfts_tests/nft_transactions.dart new file mode 100644 index 0000000000..f513b170e5 --- /dev/null +++ b/test_integration/tests/nfts_tests/nft_transactions.dart @@ -0,0 +1,28 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testNftTransactions(WidgetTester tester) async { + print('TEST NFT TRANSACTIONS'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run NFT Transactions tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testNftTransactions(tester); + await tester.pumpAndSettle(); + + print('END NFT TRANSACTIONS TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/nfts_tests/nfts_tests.dart b/test_integration/tests/nfts_tests/nfts_tests.dart new file mode 100644 index 0000000000..16ce45a686 --- /dev/null +++ b/test_integration/tests/nfts_tests/nfts_tests.dart @@ -0,0 +1,25 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import './nft_networks.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run NFT tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await tester.pumpAndSettle(); + await testNftNetworks(tester); + await tester.pumpAndSettle(); + + print('END NFT TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/no_login_tests/no_login_taker_form_test.dart b/test_integration/tests/no_login_tests/no_login_taker_form_test.dart new file mode 100644 index 0000000000..8c9fe2873c --- /dev/null +++ b/test_integration/tests/no_login_tests/no_login_taker_form_test.dart @@ -0,0 +1,55 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testNoLoginTakerForm(WidgetTester tester) async { + print('TEST DOGE SELECTION CRASH'); + const String dogeByName = 'doge'; + final mainMenuDexForm = find.byKey(const Key('main-menu-dex')); + final takerFormBuySwitcher = find.byKey(const Key('taker-form-buy-switcher')); + final searchTakerCoinField = find.byKey(const Key('search-field')); + final tableItemDoge = find.byKey(const Key('orders-table-item-DOGE')); + + await tester.tap(mainMenuDexForm); + await tester.pumpAndSettle(); + await tester.tap(takerFormBuySwitcher); + await tester.pumpAndSettle(); + await tester.tap(searchTakerCoinField); + await tester.enterText(searchTakerCoinField, dogeByName); + await tester.pumpAndSettle(); + await tester.tap(tableItemDoge); + await tester.pumpAndSettle(); + + print('DOGE COIN SELECTED, CHECK ORDERBOOK IS LOADED'); + final orderbooksTableContainer = + find.byKey(const Key('orderbook-asks-bids-container')); + final mainMenuWallet = find.byKey(const Key('main-menu-wallet')); + + await tester.ensureVisible(orderbooksTableContainer); + await tester.tap(mainMenuWallet); + + print('TRY TO LOGIN'); + // Ensure wasm module is running in the background + // We are loggin in, thus this test should be always last one in the group + await restoreWalletToTest(tester); + await tester.pumpAndSettle(); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run no login taker form tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await testNoLoginTakerForm(tester); + await tester.pumpAndSettle(); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/no_login_tests/no_login_tests.dart b/test_integration/tests/no_login_tests/no_login_tests.dart new file mode 100644 index 0000000000..45eaf216d6 --- /dev/null +++ b/test_integration/tests/no_login_tests/no_login_tests.dart @@ -0,0 +1,31 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/pause.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import 'no_login_taker_form_test.dart'; +import 'no_login_wallet_access_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Run no login mode tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + + await pause(msg: 'START NO LOGIN MODE TESTS'); + await testNoLoginWalletAccess(tester); + // No Login taker form test should be always ran last here + await testNoLoginTakerForm(tester); + + await pause(sec: 5, msg: 'END NO LOGIN MODE TESTS'); + await Future.delayed(const Duration(seconds: 5)); + await tester.pumpAndSettle(); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/no_login_tests/no_login_wallet_access_test.dart b/test_integration/tests/no_login_tests/no_login_wallet_access_test.dart new file mode 100644 index 0000000000..f59b630158 --- /dev/null +++ b/test_integration/tests/no_login_tests/no_login_wallet_access_test.dart @@ -0,0 +1,150 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/main.dart' as app; +import 'package:web_dex/model/settings_menu_value.dart'; + +import '../../helpers/accept_alpha_warning.dart'; + +Future testNoLoginWalletAccess(WidgetTester tester) async { + final Finder walletsManagerWrapper = + find.byKey(const Key('wallets-manager-wrapper')); + + // App bar + print('TEST ACCESS FROM APP BAR'); + final Finder connectWalletButton = isMobile + ? find.byKey(const Key('connect-wallet-dex')) + : find.byKey(const Key('connect-wallet-header')); + final Finder appBarTotalBalance = + find.byKey(const Key('app-bar-total-balance')); + final Finder appBarAccountButton = + find.byKey(const Key('app-bar-account-button')); + + expect(connectWalletButton, findsOneWidget); + await _openWalletManagerPopupByKey(connectWalletButton, tester); + expect(walletsManagerWrapper, findsOneWidget); + await _closeWalletManagerPopup(tester); + expect(walletsManagerWrapper, findsNothing); + + expect(appBarTotalBalance, findsNothing); + expect(appBarAccountButton, findsNothing); + + // Wallet page + print('TEST ACCESS FROM WALLET PAGE'); + final Finder walletMenuButton = find.byKey(const Key('main-menu-wallet')); + final Finder coinsWithBalanceCheckbox = + find.byKey(const Key('coins-with-balance-checkbox')); + final Finder addAssetsButton = find.byKey(const Key('add-assets-button')); + final Finder removeAssetsButton = + find.byKey(const Key('remove-assets-button')); + final coinsList = find.byKey(const Key('wallet-page-coins-list')); + final Finder coinListItemKmd = + find.byKey(const Key('wallet-coin-list-item-kmd')); + + await tester.tap(walletMenuButton); + await tester.pumpAndSettle(); + + expect(coinsWithBalanceCheckbox, findsNothing); + expect(addAssetsButton, findsNothing); + expect(removeAssetsButton, findsNothing); + await tester.dragUntilVisible( + coinListItemKmd, + coinsList, + const Offset(0, -15), + ); + await tester.pumpAndSettle(); + await tester.tap(coinListItemKmd); + await tester.pumpAndSettle(); + expect(walletsManagerWrapper, findsOneWidget); + await _closeWalletManagerPopup(tester); + expect(walletsManagerWrapper, findsNothing); + + // Dex page + print('TEST ACCESS FROM DEX PAGE'); + final Finder connectWalletMakerForm = + find.byKey(const Key('connect-wallet-maker-form')); + final Finder connectWalletTakerForm = + find.byKey(const Key('connect-wallet-taker-form')); + final Finder dexMenuButton = find.byKey(const Key('main-menu-dex')); + final Finder makeOrderTab = find.byKey(const Key('make-order-tab')); + final Finder takeOrderTab = find.byKey(const Key('take-order-tab')); + final Finder dexPageTabBar = find.byKey(const Key('dex-page-tab-bar')); + + await tester.tap(dexMenuButton); + await tester.pumpAndSettle(); + + expect(dexPageTabBar, findsNothing); + await tester.tap(takeOrderTab); + await tester.pumpAndSettle(); + + expect(connectWalletTakerForm, findsOneWidget); + await _openWalletManagerPopupByKey(connectWalletTakerForm, tester); + expect(walletsManagerWrapper, findsOneWidget); + await _closeWalletManagerPopup(tester); + expect(walletsManagerWrapper, findsNothing); + + await tester.tap(makeOrderTab); + await tester.pumpAndSettle(); + + expect(connectWalletMakerForm, findsOneWidget); + await _openWalletManagerPopupByKey(connectWalletMakerForm, tester); + expect(walletsManagerWrapper, findsOneWidget); + await _closeWalletManagerPopup(tester); + expect(walletsManagerWrapper, findsNothing); + + // Bridge page + print('TEST ACCESS FROM BRIDGE PAGE'); + final Finder connectWalletBridge = + find.byKey(const Key('connect-wallet-bridge')); + final Finder bridgeMenuButton = find.byKey(const Key('main-menu-bridge')); + final Finder bridgePageTabBar = find.byKey(const Key('bridge-page-tab-bar')); + + await tester.tap(bridgeMenuButton); + await tester.pumpAndSettle(); + + expect(connectWalletBridge, findsOneWidget); + + await _openWalletManagerPopupByKey(connectWalletBridge, tester); + expect(walletsManagerWrapper, findsOneWidget); + await _closeWalletManagerPopup(tester); + expect(walletsManagerWrapper, findsNothing); + + expect(bridgePageTabBar, findsNothing); + + // Settings page + print('TEST ACCESS TO SETTINGS PAGE'); + final Finder settingsMenuButton = find.byKey(const Key('main-menu-settings')); + final Finder settingsMenuItemSecurity = find.byKey( + Key('settings-menu-item-${SettingsMenuValue.security.toString()}'), + ); + await tester.tap(settingsMenuButton); + await tester.pumpAndSettle(); + expect(settingsMenuItemSecurity, findsNothing); +} + +Future _openWalletManagerPopupByKey( + Finder finder, WidgetTester tester) async { + await tester.tap(finder); + await tester.pumpAndSettle(); +} + +Future _closeWalletManagerPopup(WidgetTester tester) async { + await tester.tapAt(const Offset(10.0, 10.0)); + await tester.pumpAndSettle(); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run wallets create tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await testNoLoginWalletAccess(tester); + await tester.pumpAndSettle(); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/suspended_assets_test/runner.dart b/test_integration/tests/suspended_assets_test/runner.dart new file mode 100644 index 0000000000..333b35cda5 --- /dev/null +++ b/test_integration/tests/suspended_assets_test/runner.dart @@ -0,0 +1,93 @@ +// ignore_for_file: avoid_print, prefer_interpolation_to_compose_strings + +import 'dart:convert'; +import 'dart:io'; + +import '../../../run_integration_tests.dart'; + +File? _configFile; + +void main() async { + _configFile = await _findCoinsConfigFile(); + if (_configFile == null) { + throw 'Coins config file not found'; + } else { + print('Temporarily breaking $suspendedCoin electrum config' + ' in \'${_configFile!.path}\' to test suspended state.'); + } + + final Map originalConfig = _readConfig(); + _breakConfig(originalConfig); + + Process.run( + 'flutter', + [ + 'drive', + '--driver=test_driver/integration_test.dart', + '--target=test_integration/tests/suspended_assets_test/suspended_assets_test.dart', + '-d', + 'chrome', + '--profile' + ], + runInShell: true, + ).then((result) { + stdout.write(result.stdout); + _restoreConfig(originalConfig); + }).catchError((dynamic e) { + stdout.write(e); + _restoreConfig(originalConfig); + throw e; + }); +} + +Map _readConfig() { + Map json; + + try { + final String jsonStr = _configFile!.readAsStringSync(); + json = jsonDecode(jsonStr); + } catch (e) { + print('Unable to load json from ${_configFile!.path}:\n$e'); + rethrow; + } + + return json; +} + +void _writeConfig(Map config) { + final String spaces = ' ' * 4; + final JsonEncoder encoder = JsonEncoder.withIndent(spaces); + + _configFile!.writeAsStringSync(encoder.convert(config)); +} + +void _breakConfig(Map config) { + final Map broken = jsonDecode(jsonEncode(config)); + broken[suspendedCoin]['electrum'] = [ + { + 'url': 'broken.e1ectrum.net:10063', + 'ws_url': 'broken.e1ectrum.net:30063', + } + ]; + + _writeConfig(broken); +} + +void _restoreConfig(Map originalConfig) { + _writeConfig(originalConfig); +} + +// coins_config.json path contains version number, so can't be constant +Future _findCoinsConfigFile() async { + final List assets = + await Directory('assets').list().toList(); + + for (FileSystemEntity entity in assets) { + if (entity is! Directory) continue; + + final config = File(entity.path + '/config/coins_config.json'); + if (config.existsSync()) return config; + } + + return null; +} diff --git a/test_integration/tests/suspended_assets_test/suspended_assets_test.dart b/test_integration/tests/suspended_assets_test/suspended_assets_test.dart new file mode 100644 index 0000000000..9b58f1e3d4 --- /dev/null +++ b/test_integration/tests/suspended_assets_test/suspended_assets_test.dart @@ -0,0 +1,44 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../../run_integration_tests.dart'; +import '../../common/goto.dart' as goto; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Run suspended asset tests:', (WidgetTester tester) async { + const String suspendedAsset = 'KMD'; + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + + await acceptAlphaWarning(tester); + + print('RESTORE WALLET TO TEST'); + await restoreWalletToTest(tester); + await tester.pumpAndSettle(); + + await goto.walletPage(tester); + final Finder searchCoinsField = + find.byKey(const Key('wallet-page-search-field')); + await tester.enterText(searchCoinsField, suspendedAsset); + await tester.pumpAndSettle(); + final Finder suspendedCoinLabel = isMobile + ? find.byKey(const Key('retry-suspended-asset-$suspendedCoin')) + : find.byKey(const Key('suspended-asset-message-$suspendedCoin')); + expect( + suspendedCoinLabel, + findsOneWidget, + reason: 'Test error: $suspendedCoin should be suspended,' + ' but corresponding label was not found.', + ); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/wallets_manager_tests/wallets_manager_create_test.dart b/test_integration/tests/wallets_manager_tests/wallets_manager_create_test.dart new file mode 100644 index 0000000000..30264005db --- /dev/null +++ b/test_integration/tests/wallets_manager_tests/wallets_manager_create_test.dart @@ -0,0 +1,71 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/main.dart' as app; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/views/common/header/actions/account_switcher.dart'; + +import '../../common/pump_and_settle.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/connect_wallet.dart'; + +Future testCreateWallet(WidgetTester tester) async { + const String walletName = 'my-wallet-name'; + const String password = 'pppaaasssDDD555444@@@'; + final Finder createWalletButton = + find.byKey(const Key('create-wallet-button')); + final Finder nameField = find.byKey(const Key('name-wallet-field')); + final Finder passwordField = find.byKey(const Key('create-password-field')); + final Finder passwordConfirmField = + find.byKey(const Key('create-password-field-confirm')); + final Finder confirmButton = find.byKey(const Key('confirm-password-button')); + final Finder eulaCheckBox = find.byKey(const Key('checkbox-eula')); + final Finder tocCheckBox = find.byKey(const Key('checkbox-toc')); + final Finder authorizedWalletButton = + find.widgetWithText(AccountSwitcher, walletName); + final Finder walletsManagerWrapper = + find.byKey(const Key('wallets-manager-wrapper')); + + await tester.pumpAndSettle(); + await tapOnMobileConnectWallet(tester, WalletType.iguana); + + // New wallet test + expect(createWalletButton, findsOneWidget); + await tester.tap(createWalletButton); + await tester.pumpAndSettle(); + + // Wallet creation step + expect(find.byKey(const Key('wallet-creation')), findsOneWidget); + await tester.tap(nameField); + await tester.enterText(nameField, walletName); + await tester.enterText(passwordField, password); + await tester.enterText(passwordConfirmField, password); + await tester.pumpAndSettle(); + await tester.tap(eulaCheckBox); + await tester.pumpAndSettle(); + await tester.tap(tocCheckBox); + await tester.pumpAndSettle(); + await tester.tap(confirmButton); + await pumpUntilDisappear(tester, walletsManagerWrapper); + if (!isMobile) { + expect(authorizedWalletButton, findsOneWidget); + } +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run Wallet Creation tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await testCreateWallet(tester); + await tester.pumpAndSettle(); + + print('END WALLET CREATION TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/wallets_manager_tests/wallets_manager_import_test.dart b/test_integration/tests/wallets_manager_tests/wallets_manager_import_test.dart new file mode 100644 index 0000000000..c1a1976945 --- /dev/null +++ b/test_integration/tests/wallets_manager_tests/wallets_manager_import_test.dart @@ -0,0 +1,91 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/common/screen.dart'; +import 'package:web_dex/main.dart' as app; +import 'package:web_dex/model/wallet.dart'; +import 'package:web_dex/views/common/header/actions/account_switcher.dart'; + +import '../../common/pump_and_settle.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/connect_wallet.dart'; + +Future testImportWallet(WidgetTester tester) async { + const String walletName = 'my-wallet-restored'; + const String password = 'pppaaasssDDD555444@@@'; + const String customSeed = 'my-custom-seed'; + final Finder importWalletButton = + find.byKey(const Key('import-wallet-button')); + final Finder nameField = find.byKey(const Key('name-wallet-field')); + final Finder passwordField = find.byKey(const Key('create-password-field')); + final Finder passwordConfirmField = + find.byKey(const Key('create-password-field-confirm')); + final Finder importSeedField = find.byKey(const Key('import-seed-field')); + final Finder importConfirmButton = + find.byKey(const Key('confirm-seed-button')); + final Finder allowCustomSeedCheckbox = + find.byKey(const Key('checkbox-custom-seed')); + final Finder customSeedDialogInput = + find.byKey(const Key('custom-seed-dialog-input')); + final Finder customSeedDialogOkButton = + find.byKey(const Key('custom-seed-dialog-ok-button')); + const String confirmCustomSeedText = 'I understand'; + final Finder eulaCheckbox = find.byKey(const Key('checkbox-eula')); + final Finder tocCheckbox = find.byKey(const Key('checkbox-toc')); + final Finder authorizedWalletButton = + find.widgetWithText(AccountSwitcher, walletName); + final Finder walletsManagerWrapper = + find.byKey(const Key('wallets-manager-wrapper')); + + await tester.pumpAndSettle(); + await tapOnMobileConnectWallet(tester, WalletType.iguana); + + // New wallet test + expect(importWalletButton, findsOneWidget); + await tester.tap(importWalletButton); + await tester.pumpAndSettle(); + + // Wallet creation step + await tester.tap(nameField); + await tester.enterText(nameField, walletName); + await tester.enterText(importSeedField, customSeed); + await tester.pumpAndSettle(); + await tester.tap(allowCustomSeedCheckbox); + await tester.pumpAndSettle(); + await tester.enterText(customSeedDialogInput, confirmCustomSeedText); + await tester.pumpAndSettle(); + await tester.tap(customSeedDialogOkButton); + await tester.pumpAndSettle(); + await tester.tap(eulaCheckbox); + await tester.pumpAndSettle(); + await tester.tap(tocCheckbox); + await tester.pumpAndSettle(); + await tester.tap(importConfirmButton); + await tester.pumpAndSettle(); + + // Enter password step + await tester.enterText(passwordField, password); + await tester.enterText(passwordConfirmField, password); + await tester.tap(importConfirmButton); + await pumpUntilDisappear(tester, walletsManagerWrapper); + if (!isMobile) { + expect(authorizedWalletButton, findsOneWidget); + } +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run Wallet Import tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await testImportWallet(tester); + await tester.pumpAndSettle(); + + print('END WALLET IMPORT TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/wallets_manager_tests/wallets_manager_tests.dart b/test_integration/tests/wallets_manager_tests/wallets_manager_tests.dart new file mode 100644 index 0000000000..97829e8e2d --- /dev/null +++ b/test_integration/tests/wallets_manager_tests/wallets_manager_tests.dart @@ -0,0 +1,28 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import './wallets_manager_create_test.dart'; +import './wallets_manager_import_test.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/log_out.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run wallet manager tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + await acceptAlphaWarning(tester); + await tester.pumpAndSettle(); + await testCreateWallet(tester); + await tester.pumpAndSettle(); + await logOut(tester); + await tester.pumpAndSettle(); + await testImportWallet(tester); + + print('END WALLET MANAGER TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/wallets_tests/test_activate_coins.dart b/test_integration/tests/wallets_tests/test_activate_coins.dart new file mode 100644 index 0000000000..d2ef49ca24 --- /dev/null +++ b/test_integration/tests/wallets_tests/test_activate_coins.dart @@ -0,0 +1,83 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/goto.dart' as goto; +import '../../common/pause.dart'; +import '../../common/tester_utils.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; +import 'wallet_tools.dart'; + +Future testActivateCoins(WidgetTester tester) async { + await pause(sec: 2, msg: 'TEST COINS ACTIVATION'); + + const String ethByTicker = 'ETH'; + const String dogeByName = 'gecoi'; + const String kmdBep20ByTicker = 'KMD'; + + final Finder totalAmount = find.byKey( + const Key('overview-total-balance'), + ); + final Finder ethCoinItem = find.byKey( + const Key('coins-manager-list-item-eth'), + ); + final Finder dogeCoinItem = find.byKey( + const Key('coins-manager-list-item-doge'), + ); + final Finder kmdBep20CoinItem = find.byKey( + const Key('coins-manager-list-item-kmd-bep20'), + ); + + await goto.walletPage(tester); + expect(totalAmount, findsOneWidget); + + await _testNoneExistCoin(tester); + await addAsset(tester, asset: dogeCoinItem, search: dogeByName); + await addAsset(tester, asset: kmdBep20CoinItem, search: kmdBep20ByTicker); + await removeAsset(tester, asset: ethCoinItem, search: ethByTicker); + await removeAsset(tester, asset: dogeCoinItem, search: dogeByName); + await removeAsset(tester, asset: kmdBep20CoinItem, search: kmdBep20ByTicker); + await goto.dexPage(tester); + await goto.walletPage(tester); + await pause(msg: 'END TEST COINS ACTIVATION'); +} + +// Try to find non-existent coin +Future _testNoneExistCoin(WidgetTester tester) async { + final Finder addAssetsButton = find.byKey( + const Key('add-assets-button'), + ); + final Finder searchCoinsField = find.byKey( + const Key('coins-manager-search-field'), + ); + final Finder ethCoinItem = find.byKey( + const Key('coins-manager-list-item-eth'), + ); + + await goto.walletPage(tester); + await testerTap(tester, addAssetsButton); + expect(searchCoinsField, findsOneWidget); + + await enterText(tester, finder: searchCoinsField, text: 'NOSUCHCOINEVER'); + expect(ethCoinItem, findsNothing); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run coins activation tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testActivateCoins(tester); + await tester.pumpAndSettle(); + + print('END COINS ACTIVATION TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/wallets_tests/test_bitrefill_integration.dart b/test_integration/tests/wallets_tests/test_bitrefill_integration.dart new file mode 100644 index 0000000000..aa49679239 --- /dev/null +++ b/test_integration/tests/wallets_tests/test_bitrefill_integration.dart @@ -0,0 +1,72 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/goto.dart' as goto; +import '../../common/pause.dart'; +import '../../common/tester_utils.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; +import 'wallet_tools.dart'; + +Future testBitrefillIntegration(WidgetTester tester) async { + await pause(sec: 2, msg: 'TEST BITREFILL INTEGRATION'); + + const String ltcSearchTerm = 'litecoin'; + + final Finder totalAmount = find.byKey( + const Key('overview-total-balance'), + ); + final Finder ltcActiveCoinItem = find.byKey( + const Key('active-coin-item-ltc-segwit'), + ); + final Finder ltcCoinSearchItem = find.byKey( + const Key('coins-manager-list-item-ltc-segwit'), + ); + final Finder bitrefillButton = find.byKey( + const Key('coin-details-bitrefill-button-ltc-segwit'), + ); + + await goto.walletPage(tester); + expect(totalAmount, findsOneWidget); + + final bool isLtcVisible = await isWidgetVisible(tester, ltcActiveCoinItem); + if (!isLtcVisible) { + await addAsset(tester, asset: ltcCoinSearchItem, search: ltcSearchTerm); + await goto.dexPage(tester); + await goto.walletPage(tester); + } + + await tester.pumpAndSettle(); + expect(ltcActiveCoinItem, findsOneWidget); + await testerTap(tester, ltcActiveCoinItem); + await tester.pumpAndSettle(); + + expect(bitrefillButton, findsOneWidget); + await testerTap(tester, bitrefillButton); + + await pause(msg: 'END TEST BITREFILL INTEGRATION'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets( + 'Run bitrefill integration tests:', + (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testBitrefillIntegration(tester); + await tester.pumpAndSettle(); + + print('END BITREFILL INTEGRATION TESTS'); + }, + semanticsEnabled: false, + ); +} diff --git a/test_integration/tests/wallets_tests/test_cex_prices.dart b/test_integration/tests/wallets_tests/test_cex_prices.dart new file mode 100644 index 0000000000..9adf6cc060 --- /dev/null +++ b/test_integration/tests/wallets_tests/test_cex_prices.dart @@ -0,0 +1,110 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../common/goto.dart' as goto; +import '../../common/pause.dart'; +import '../../common/tester_utils.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; +import 'wallet_tools.dart'; + +Future testCexPrices(WidgetTester tester) async { + print('TEST CEX PRICES'); + + const String docByTicker = 'DOC'; + const String kmdBep20ByTicker = 'KMD'; + + final Finder totalAmount = find.byKey( + const Key('overview-total-balance'), + ); + final Finder coinDetailsReturnButton = find.byKey( + const Key('back-button'), + ); + final Finder docCoinActive = find.byKey( + const Key('active-coin-item-doc'), + ); + final Finder kmdBep20CoinActive = find.byKey( + const Key('active-coin-item-kmd-bep20'), + ); + final Finder kmdBep20Price = find.byKey( + const Key('fiat-price-kmd-bep20'), + ); + final Finder docPrice = find.byKey( + const Key('fiat-price-doc'), + ); + final Finder list = find.byKey( + const Key('wallet-page-coins-list'), + ); + final Finder page = find.byKey( + const Key('wallet-page'), + ); + final Finder kmdBep20Item = find.byKey( + const Key('coins-manager-list-item-kmd-bep20'), + ); + final Finder docItem = find.byKey( + const Key('coins-manager-list-item-doc'), + ); + final Finder searchCoinsField = find.byKey( + const Key('wallet-page-search-field'), + ); + + await goto.bridgePage(tester); + // Enter Wallet View + await goto.walletPage(tester); + expect(page, findsOneWidget); + expect(totalAmount, findsOneWidget); + + await addAsset(tester, asset: docItem, search: docByTicker); + await addAsset(tester, asset: kmdBep20Item, search: kmdBep20ByTicker); + + try { + expect(list, findsOneWidget); + } on TestFailure { + print('**Error** testCexPrices() list: $list'); + } + + // Check KMD-BEP20 cex price + final hasKmdBep20 = await filterAsset(tester, + asset: kmdBep20CoinActive, + text: kmdBep20ByTicker, + searchField: searchCoinsField); + + if (hasKmdBep20) { + await testerTap(tester, kmdBep20CoinActive); + final Text text = kmdBep20Price.evaluate().single.widget as Text; + final String? priceStr = text.data; + final double? priceDouble = double.tryParse(priceStr ?? ''); + expect(priceDouble != null && priceDouble > 0, true); + await testerTap(tester, coinDetailsReturnButton); + } + + // Check DOC cex price (does not exist) + await testerTap(tester, docCoinActive); + expect(docPrice, findsNothing); + + await goto.walletPage(tester); + + await removeAsset(tester, asset: docItem, search: docByTicker); + await removeAsset(tester, asset: kmdBep20Item, search: kmdBep20ByTicker); + await pause(msg: 'END TEST CEX PRICES'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run cex prices tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testCexPrices(tester); + await tester.pumpAndSettle(); + + print('END CEX PRICES TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/wallets_tests/test_coin_assets.dart b/test_integration/tests/wallets_tests/test_coin_assets.dart new file mode 100644 index 0000000000..f721a7b84d --- /dev/null +++ b/test_integration/tests/wallets_tests/test_coin_assets.dart @@ -0,0 +1,79 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/app_config/app_config.dart'; +import 'package:web_dex/main.dart' as app; +import 'package:web_dex/shared/utils/utils.dart'; +import 'package:web_dex/shared/widgets/coin_icon.dart'; + +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testCoinIcons(WidgetTester tester) async { + final Finder walletTab = find.byKey(const Key('main-menu-wallet')); + final Finder addAssetsButton = find.byKey(const Key('add-assets-button')); + + await tester.tap(walletTab); + await tester.pumpAndSettle(); + await tester.tap(addAssetsButton); + await tester.pumpAndSettle(); + + final listFinder = find.byKey(const Key('coins-manager-list')); + + // Get the size of the list + bool keepScrolling = true; + // Scroll down the list until we reach the end + while (keepScrolling) { + // Check the icons before scrolling + final coinIcons = find + .descendant(of: listFinder, matching: find.byType(CoinIcon)) + .evaluate() + .map((e) => e.widget as CoinIcon); + + for (final coinIcon in coinIcons) { + final coinAbr = abbr2Ticker(coinIcon.coinAbbr).toLowerCase(); + final assetPath = '$assetsPath/coin_icons/png/$coinAbr.png'; + final assetExists = await canLoadAsset(assetPath); + expect(assetExists, true, reason: 'Asset $assetPath does not exist'); + } + + // Scoll the list + await tester.drag(listFinder, const Offset(0, -500)); + await tester.pumpAndSettle(); + + // Check if we reached the end of the list + final scrollable = listFinder.evaluate().first.widget as ListView; + final currentPosition = scrollable.controller!.position.pixels; + final maxScrollExtent = scrollable.controller!.position.maxScrollExtent; + keepScrolling = currentPosition < maxScrollExtent; + } +} + +Future canLoadAsset(String assetPath) async { + bool assetExists = true; + try { + final _ = await rootBundle.load(assetPath); + } catch (e) { + assetExists = false; + } + return assetExists; +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run coin icons tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testCoinIcons(tester); + await tester.pumpAndSettle(); + + print('END COINS ICONS TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/wallets_tests/test_filters.dart b/test_integration/tests/wallets_tests/test_filters.dart new file mode 100644 index 0000000000..8d0d69a612 --- /dev/null +++ b/test_integration/tests/wallets_tests/test_filters.dart @@ -0,0 +1,58 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; + +Future testFilters(WidgetTester tester) async { + final Finder walletTab = find.byKey(const Key('main-menu-wallet')); + final Finder addAssetsButton = find.byKey(const Key('add-assets-button')); + final coinsManagerList = find.byKey(const Key('coins-manager-list')); + final Finder filtersButton = find.byKey(const Key('filters-dropdown')); + final Finder utxoFilterItem = find.byKey(const Key('filter-item-utxo')); + final Finder erc20FilterItem = find.byKey(const Key('filter-item-erc20')); + final utxoItems = + find.descendant(of: coinsManagerList, matching: find.text('Native')); + final bep20Items = + find.descendant(of: coinsManagerList, matching: find.text('BEP-20')); + final erc20Items = + find.descendant(of: coinsManagerList, matching: find.text('ERC-20')); + + await tester.tap(walletTab); + await tester.pumpAndSettle(); + await tester.tap(addAssetsButton); + await tester.pumpAndSettle(); + await tester.tap(filtersButton); + await tester.pumpAndSettle(); + await tester.tap(utxoFilterItem); + await tester.pumpAndSettle(); + expect(bep20Items, findsNothing); + expect(erc20Items, findsNothing); + expect(utxoItems, findsWidgets); + await tester.tap(utxoFilterItem); + await tester.tap(erc20FilterItem); + await tester.pumpAndSettle(); + expect(bep20Items, findsNothing); + expect(utxoItems, findsNothing); + expect(erc20Items, findsWidgets); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run fliters tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testFilters(tester); + await tester.pumpAndSettle(); + + print('END FILTERS TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/wallets_tests/test_withdraw.dart b/test_integration/tests/wallets_tests/test_withdraw.dart new file mode 100644 index 0000000000..eb3ad068c7 --- /dev/null +++ b/test_integration/tests/wallets_tests/test_withdraw.dart @@ -0,0 +1,127 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; +import 'package:web_dex/shared/widgets/auto_scroll_text.dart'; + +import '../../common/tester_utils.dart'; +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/get_funded_wif.dart'; +import '../../helpers/restore_wallet.dart'; +import 'wallet_tools.dart'; + +Future testWithdraw(WidgetTester tester) async { + print('TEST WITHDRAW'); + + final Finder martyCoinItem = find.byKey( + const Key('coins-manager-list-item-marty'), + ); + final Finder martyCoinActive = find.byKey( + const Key('active-coin-item-marty'), + ); + final Finder coinBalance = find.byKey( + const Key('coin-details-balance'), + ); + final Finder sendButton = find.byKey( + const Key('coin-details-send-button'), + ); + final Finder addressInput = find.byKey( + const Key('withdraw-recipient-address-input'), + ); + final Finder amountInput = find.byKey( + const Key('enter-form-amount-input'), + ); + final Finder sendEnterButton = find.byKey( + const Key('send-enter-button'), + ); + final Finder confirmBackButton = find.byKey( + const Key('confirm-back-button'), + ); + final Finder confirmAgreeButton = find.byKey( + const Key('confirm-agree-button'), + ); + final Finder completeButtons = find.byKey( + const Key('complete-buttons'), + ); + final Finder viewOnExplorerButton = find.byKey( + const Key('send-complete-view-on-explorer'), + ); + final Finder doneButton = find.byKey( + const Key('send-complete-done'), + ); + final Finder exitButton = find.byKey( + const Key('back-button'), + ); + final Finder receiveButton = find.byKey( + const Key('coin-details-receive-button'), + ); + final Finder copyAddressButton = find.byKey( + const Key('coin-details-address-field'), + ); + + await addAsset(tester, asset: martyCoinItem, search: 'marty'); + + expect(martyCoinActive, findsOneWidget); + await testerTap(tester, martyCoinActive); + + expect(coinBalance, findsOneWidget); + + final AutoScrollText text = + coinBalance.evaluate().single.widget as AutoScrollText; + + final String priceStr = text.text; + final double? priceDouble = double.tryParse(priceStr); + expect(priceDouble != null && priceDouble > 0, true); + expect(receiveButton, findsOneWidget); + + await testerTap(tester, receiveButton); + expect(copyAddressButton, findsOneWidget); + expect(copyAddressButton, findsOneWidget); + + await testerTap(tester, exitButton); + expect(sendButton, findsOneWidget); + + await testerTap(tester, sendButton); + expect(addressInput, findsOneWidget); + expect(amountInput, findsOneWidget); + expect(sendEnterButton, findsOneWidget); + + await testerTap(tester, addressInput); + await enterText(tester, finder: addressInput, text: getRandomAddress()); + await enterText(tester, finder: amountInput, text: '0.01'); + await testerTap(tester, sendEnterButton); + + expect(confirmBackButton, findsOneWidget); + expect(confirmAgreeButton, findsOneWidget); + await testerTap(tester, confirmAgreeButton); + + expect(completeButtons, findsOneWidget); + expect(viewOnExplorerButton, findsOneWidget); + expect(doneButton, findsOneWidget); + await testerTap(tester, doneButton); + + expect(exitButton, findsOneWidget); + await testerTap(tester, exitButton); + + await removeAsset(tester, asset: martyCoinItem, search: 'marty'); + + print('END TEST WITHDRAW'); +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + testWidgets('Run withdraw tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + print('ACCEPT ALPHA WARNING'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await testWithdraw(tester); + await tester.pumpAndSettle(); + + print('END WITHDARW TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_integration/tests/wallets_tests/wallet_tools.dart b/test_integration/tests/wallets_tests/wallet_tools.dart new file mode 100644 index 0000000000..38c039039a --- /dev/null +++ b/test_integration/tests/wallets_tests/wallet_tools.dart @@ -0,0 +1,131 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../common/goto.dart' as goto; +import '../../common/pause.dart'; +import '../../common/tester_utils.dart'; + +Future removeAsset(WidgetTester tester, + {required Finder asset, required String search}) async { + final Finder removeAssetsButton = find.byKey( + const Key('remove-assets-button'), + ); + final Finder list = find.byKey( + const Key('coins-manager-list'), + ); + final Finder switchButton = find.byKey( + const Key('coins-manager-switch-button'), + ); + final Finder searchCoinsField = find.byKey( + const Key('coins-manager-search-field'), + ); + + await goto.walletPage(tester); + + await testerTap(tester, removeAssetsButton); + expect(list, findsOneWidget); + + try { + expect(searchCoinsField, findsOneWidget); + } on TestFailure { + print('**Error** addAsset() no searchCoinsField'); + } + + await enterText(tester, finder: searchCoinsField, text: search); + + try { + expect(asset, findsOneWidget); + } on TestFailure { + print('**Error** removeAsset([$asset])'); + await tester.dragUntilVisible(asset, list, const Offset(0, -5)); + await tester.pumpAndSettle(); + } + + await testerTap(tester, asset); + + try { + expect(switchButton, findsOneWidget); + } on TestFailure { + print('**Error** removeAsset(): switchButton: $switchButton'); + } + await testerTap(tester, switchButton); + await pause(sec: 5); +} + +Future addAsset(WidgetTester tester, + {required Finder asset, required String search}) async { + final Finder list = find.byKey( + const Key('coins-manager-list'), + ); + final Finder addAssetsButton = find.byKey( + const Key('add-assets-button'), + ); + final Finder searchCoinsField = find.byKey( + const Key('coins-manager-search-field'), + ); + final Finder switchButton = find.byKey( + const Key('coins-manager-switch-button'), + ); + + await goto.walletPage(tester); + + try { + expect(asset, findsNothing); + } on TestFailure { + // asset already created + return; + } + + await testerTap(tester, addAssetsButton); + try { + expect(searchCoinsField, findsOneWidget); + } on TestFailure { + print('**Error** addAsset() no searchCoinsField'); + } + + await enterText(tester, finder: searchCoinsField, text: search); + + await tester.dragUntilVisible( + asset, + list, + const Offset(-250, 0), + ); + await tester.pumpAndSettle(); + await testerTap(tester, asset); + + try { + expect(switchButton, findsOneWidget); + } on TestFailure { + print('**Error** addAsset(): switchButton: $switchButton'); + } + + await testerTap(tester, switchButton); + await tester.pumpAndSettle(); +} + +Future filterAsset( + WidgetTester tester, { + required Finder asset, + required String text, + required Finder searchField, +}) async { + await enterText(tester, finder: searchField, text: text); + await tester.pumpAndSettle(); + + try { + expect(asset, findsOneWidget); + } on TestFailure { + await pause(msg: '**Error** filterAsset([$asset, $text])'); + return false; + } + return true; +} + +Future enterText(WidgetTester tester, + {required Finder finder, required String text}) async { + await tester.enterText(finder, text); + await tester.pumpAndSettle(); + await pause(); +} diff --git a/test_integration/tests/wallets_tests/wallets_tests.dart b/test_integration/tests/wallets_tests/wallets_tests.dart new file mode 100644 index 0000000000..14b41f6be0 --- /dev/null +++ b/test_integration/tests/wallets_tests/wallets_tests.dart @@ -0,0 +1,39 @@ +// ignore_for_file: avoid_print + +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:web_dex/main.dart' as app; + +import '../../helpers/accept_alpha_warning.dart'; +import '../../helpers/restore_wallet.dart'; +import 'test_activate_coins.dart'; +import 'test_cex_prices.dart'; +import 'test_coin_assets.dart'; +import 'test_filters.dart'; +import 'test_withdraw.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('Run wallet tests:', (WidgetTester tester) async { + tester.testTextInput.register(); + await app.main(); + await tester.pumpAndSettle(); + + print('RESTORE WALLET TO TEST'); + await acceptAlphaWarning(tester); + await restoreWalletToTest(tester); + await tester.pumpAndSettle(); + await testCoinIcons(tester); + await tester.pumpAndSettle(); + await testActivateCoins(tester); + await tester.pumpAndSettle(); + await testCexPrices(tester); + await tester.pumpAndSettle(); + await testWithdraw(tester); + await tester.pumpAndSettle(); + await testFilters(tester); + + print('END WALLET TESTS'); + }, semanticsEnabled: false); +} diff --git a/test_units/main.dart b/test_units/main.dart new file mode 100644 index 0000000000..088b076f64 --- /dev/null +++ b/test_units/main.dart @@ -0,0 +1,87 @@ +import 'package:test/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'; +import 'tests/formatter/duration_format_test.dart'; +import 'tests/formatter/format_amount_test.dart'; +import 'tests/formatter/format_amount_test_alt.dart'; +import 'tests/formatter/format_dex_amt_tests.dart'; +import 'tests/formatter/formatted_date_test.dart'; +import 'tests/formatter/leading_zeros_test.dart'; +import 'tests/formatter/number_without_exponent_test.dart'; +import 'tests/formatter/text_input_formatter_test.dart'; +import 'tests/formatter/truncate_hash_test.dart'; +import 'tests/helpers/calculate_buy_amount_test.dart'; +import 'tests/helpers/get_sell_amount_test.dart'; +import 'tests/helpers/max_min_rational_test.dart'; +import 'tests/helpers/total_24_change_test.dart'; +import 'tests/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/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'; + +/// Run in terminal flutter test test_units/main.dart +/// More info at documentation "Unit and Widget testing" section +void main() { + group('Formatters:', () { + testCutTrailingZeros(); + testFormatAmount(); + testToStringAmount(); + testLeadingZeros(); + testFormatDexAmount(); + testDecimalTextInputFormatter(); + testDurationFormat(); + testNumberWithoutExponent(); + testCompareToCex(); + testTruncateHash(); + testFormattedDate(); + //testTruncateDecimal(); + }); + + group('Password:', () { + testValidateRPCPassword(); + testValidatePassword(); + }); + + group('Sorting:', () { + testSorting(); + }); + + group('Utils:', () { + testUsdBalanceFormatter(); + testGetFiatAmount(); + testCustomDoubleToString(); + testRatToFracAndViseVersa(); + + testDoubleToString(); + }); + + group('Helpers: ', () { + testMaxMinRational(); + testCalculateBuyAmount(); + testGetTotal24Change(); + testGetTotalFee(); + testGetSellAmount(); + testUpdateSellAmount(); + }); + + group('Crypto:', () { + testEncryptDataTool(); + }); + + group('CexMarketData: ', () { + testCharts(); + testProfitLossRepository(); + testGenerateDemoData(); + }); +} diff --git a/test_units/tests/cex_market_data/charts_test.dart b/test_units/tests/cex_market_data/charts_test.dart new file mode 100644 index 0000000000..f6f09e1638 --- /dev/null +++ b/test_units/tests/cex_market_data/charts_test.dart @@ -0,0 +1,348 @@ +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 new file mode 100644 index 0000000000..d236b2b973 --- /dev/null +++ b/test_units/tests/cex_market_data/generate_demo_data_test.dart @@ -0,0 +1,85 @@ +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'; + +void testGenerateDemoData() { + late DemoDataGenerator generator; + late BinanceRepository binanceRepository; + + setUp(() { + binanceRepository = BinanceRepository( + binanceProvider: const BinanceProvider(), + ); + generator = DemoDataGenerator( + binanceRepository, + randomSeed: 42, + ); + }); + + group('DemoDataGenerator', () { + test('generateTransactions returns correct number of transactions', + () async { + final transactions = + await generator.generateTransactions('KMD', 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('KMD', PerformanceMode.good); + final badTransactions = + await generator.generateTransactions('KMD', PerformanceMode.veryBad); + + double goodBalance = generator.initialBalance; + double badBalance = generator.initialBalance; + + for (var tx in goodTransactions) { + goodBalance += double.parse(tx.myBalanceChange); + } + + for (var tx in badTransactions) { + badBalance += double.parse(tx.myBalanceChange); + } + + expect(goodBalance, greaterThan(badBalance)); + }); + + test('generateTransactions produces valid transaction objects', () async { + final transactions = + await generator.generateTransactions('KMD', PerformanceMode.mediocre); + + for (var tx in transactions) { + expect(tx.coin, equals('KMD')); + expect(tx.confirmations, inInclusiveRange(1, 3)); + expect(tx.feeDetails.coin, equals('USDT')); + expect(tx.from, isNotEmpty); + expect(tx.to, isNotEmpty); + expect(tx.internalId, isNotEmpty); + expect(tx.txHash, isNotEmpty); + expect(double.tryParse(tx.myBalanceChange), isNotNull); + expect(double.tryParse(tx.totalAmount), isNotNull); + } + }); + + test('fetchOhlcData returns data for all specified coin pairs', () async { + final ohlcData = await generator.fetchOhlcData(); + + for (var coinPair in generator.coinPairs) { + expect(ohlcData[coinPair], isNotNull); + expect(ohlcData[coinPair]!, isNotEmpty); + } + }); + }); +} 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 new file mode 100644 index 0000000000..5f03d19a99 --- /dev/null +++ b/test_units/tests/cex_market_data/profit_loss_repository_test.dart @@ -0,0 +1,252 @@ +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 '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 { + // TODO: Implement a mock CexRepository + cexRepository = BinanceRepository( + binanceProvider: const BinanceProvider(), + ); + 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) - (51288.42 * 1.0); + + expect(result.length, 1); + expect(result[0].profitLoss, closeTo(expectedProfitLoss, 100)); + }); + + 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) - (51288.42 * 1.0); + + const t2CostBasis = 51288.42 * 0.5; + const t2SaleProceeds = 63866 * 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, 100), + ); + expect( + result[1].profitLoss, + closeTo(expectedTotalProfitLoss, 100), + ); + }); + + 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) - (51288.42 * 1.0); + + const t3LeftoverBalance = 0.5; + const t3CostBasis = 51288.42 * t3LeftoverBalance; + const t3SaleProceeds = 63866 * 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, 100), + ); + expect( + result[1].profitLoss, + closeTo(expectedTotalProfitLoss, 100), + ); + }); + + test('should zero same day transfer of balance without fees', () async { + final transactions = [ + createBuyTransaction(1.0, timeStamp: 1708646400), + 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 { + // TODO: Implement a mock CexRepository + cexRepository = BinanceRepository( + binanceProvider: const BinanceProvider(), + ); + profitLossRepository = RealisedProfitLossCalculator( + cexRepository, + ); + }); + + 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 = 51288.42 * 0.5; + const t2SaleProceeds = 63866 * 0.5; + const expectedRealizedProfitLoss = t2SaleProceeds - t2CostBasis; + + expect(result.length, 2); + expect( + result[1].profitLoss, + closeTo(expectedRealizedProfitLoss, 100), + ); + }); + + 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 = 51288.42 * t3LeftoverBalance; + const t3SaleProceeds = 63866 * 0.5; + const t3RealizedProfitLoss = t3SaleProceeds - t3CostBasis; + + expect(result.length, 2); + expect( + result[1].profitLoss, + closeTo(t3RealizedProfitLoss, 100), + ); + }); + + test('should zero same day transfer of balance without fees', () async { + final transactions = [ + createBuyTransaction(1.0, timeStamp: 1708646400), + 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 new file mode 100644 index 0000000000..3a209e60d2 --- /dev/null +++ b/test_units/tests/cex_market_data/transaction_generation.dart @@ -0,0 +1,55 @@ +import 'package:web_dex/mm2/mm2_api/rpc/my_tx_history/transaction.dart'; +import 'package:web_dex/model/withdraw_details/fee_details.dart'; + +// TODO: copy over the mock transaction data generator from lib + +Transaction createBuyTransaction( + double balanceChange, { + int timeStamp = 1708646400, +}) { + final String value = balanceChange.toString(); + return Transaction( + blockHeight: 10000, + coin: 'BTC', + confirmations: 6, + feeDetails: FeeDetails(type: 'utxo', coin: 'BTC'), + from: ['1ABC...'], + internalId: 'internal1', + myBalanceChange: value, + receivedByMe: value, + spentByMe: '0.0', + timestamp: timeStamp, // $50,740.50 usd + to: ['1XYZ...'], + totalAmount: value, + txHash: 'hash1', + txHex: 'hex1', + memo: 'Buy 1 BTC', + ); +} + +Transaction createSellTransaction( + double balanceChange, { + int timeStamp = 1714435200, +}) { + if (!balanceChange.isNegative) { + balanceChange = -balanceChange; + } + final String value = balanceChange.toString(); + return Transaction( + blockHeight: 100200, + coin: 'BTC', + confirmations: 6, + feeDetails: FeeDetails(type: 'utxo', coin: 'BTC'), + from: ['1XYZ...'], + internalId: 'internal3', + myBalanceChange: value, + receivedByMe: '0.0', + spentByMe: balanceChange.abs().toString(), + timestamp: timeStamp, // $60,666.60 usd + to: ['1GHI...'], + totalAmount: value, + txHash: 'hash3', + txHex: 'hex3', + memo: 'Sell 0.5 BTC', + ); +} diff --git a/test_units/tests/encryption/encrypt_data_test.dart b/test_units/tests/encryption/encrypt_data_test.dart new file mode 100644 index 0000000000..35f9043325 --- /dev/null +++ b/test_units/tests/encryption/encrypt_data_test.dart @@ -0,0 +1,33 @@ +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/encryption_tool.dart'; + +void testEncryptDataTool() { + test('Test that algorithm is consistent', () async { + // Should produce the same text after every encryption + final tool = EncryptionTool(); + const password = '123'; + const data = 'Hello my friend'; + + expect( + await tool.decryptData( + password, await tool.encryptData(password, data)), + data); + }); + + test('Test that algorithm encrypt every time diff result', () async { + // For security reasons, should produce different ciphertext after every encryption + // But decryption should produce the same plaintext + final tool = EncryptionTool(); + const password = '123'; + const data = 'Hello my friend'; + + final cipherText1 = await tool.encryptData(password, data); + final cipherText2 = await tool.encryptData(password, data); + + expect(cipherText1 != cipherText2, true); + + final plainText1 = await tool.decryptData(password, cipherText1); + final plainText2 = await tool.decryptData(password, cipherText2); + expect(plainText1, plainText2); + }); +} diff --git a/test_units/tests/formatter/compare_dex_to_cex_tests.dart b/test_units/tests/formatter/compare_dex_to_cex_tests.dart new file mode 100644 index 0000000000..dc98f11e77 --- /dev/null +++ b/test_units/tests/formatter/compare_dex_to_cex_tests.dart @@ -0,0 +1,9 @@ +import 'package:rational/rational.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +void testCompareToCex() { + test('compare different prices', () { + expect(compareToCex(1, 2, Rational.one), 100); + }); +} diff --git a/test_units/tests/formatter/cut_trailing_zeros_test.dart b/test_units/tests/formatter/cut_trailing_zeros_test.dart new file mode 100644 index 0000000000..10ca62ef27 --- /dev/null +++ b/test_units/tests/formatter/cut_trailing_zeros_test.dart @@ -0,0 +1,24 @@ +// ignore_for_file: avoid_print + +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +void testCutTrailingZeros() { + test('remove trailing zeros in string with zeros tests:', () { + expect(cutTrailingZeros('0'), '0'); + expect(cutTrailingZeros('0.0'), '0'); + expect(cutTrailingZeros('000.000'), '000'); + expect(cutTrailingZeros('0.0000'), '0'); + expect(cutTrailingZeros('00000.0'), '00000'); + }); + + test('remove trailing zeros in string with digits tests:', () { + expect(cutTrailingZeros('123'), '123'); + expect(cutTrailingZeros('123.123'), '123.123'); + expect(cutTrailingZeros('1.01'), '1.01'); + expect(cutTrailingZeros('1.01000000'), '1.01'); + expect(cutTrailingZeros('1.010000001'), '1.010000001'); + expect(cutTrailingZeros('1.01000010'), '1.0100001'); + expect(cutTrailingZeros('0001.0100000'), '0001.01'); + }); +} diff --git a/test_units/tests/formatter/duration_format_test.dart b/test_units/tests/formatter/duration_format_test.dart new file mode 100644 index 0000000000..3c0f3630f4 --- /dev/null +++ b/test_units/tests/formatter/duration_format_test.dart @@ -0,0 +1,53 @@ +// ignore_for_file: avoid_print + +import 'package:test/test.dart'; +import 'package:web_dex/generated/codegen_loader.g.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +const ms = LocaleKeys.milliseconds; +const s = LocaleKeys.seconds; +const m = LocaleKeys.minutes; +const h = LocaleKeys.hours; + +final locale = DurationLocalization( + milliseconds: ms, + seconds: s, + minutes: m, + hours: h, +); + +void testDurationFormat() { + test('formatting duration to String', () { + expect(durationFormat(const Duration(milliseconds: 1), locale), '1$ms'); + expect(durationFormat(const Duration(milliseconds: 999), locale), '999$ms'); + expect(durationFormat(const Duration(milliseconds: 1000), locale), '1$s'); + expect(durationFormat(const Duration(milliseconds: 59000), locale), '59$s'); + expect(durationFormat(const Duration(seconds: 5), locale), '5$s'); + expect(durationFormat(const Duration(seconds: 61), locale), '1$m 1$s'); + expect(durationFormat(const Duration(minutes: 1), locale), '1$m 0$s'); + expect(durationFormat(const Duration(minutes: 59), locale), '59$m 0$s'); + expect(durationFormat(const Duration(milliseconds: 119000), locale), + '1$m 59$s'); + expect(durationFormat(const Duration(milliseconds: 987654321), locale), + '274$h 20$m 54$s'); + expect(durationFormat(const Duration(minutes: 60), locale), '1$h 0$m 0$s'); + expect(durationFormat(const Duration(minutes: 61), locale), '1$h 1$m 0$s'); + expect( + durationFormat(const Duration(seconds: 8000), locale), '2$h 13$m 20$s'); + expect(durationFormat(const Duration(seconds: 60000232), locale), + '16666$h 43$m 52$s'); + expect( + durationFormat(const Duration(minutes: 176), locale), '2$h 56$m 0$s'); + expect(durationFormat(const Duration(hours: 2), locale), '2$h 0$m 0$s'); + expect(durationFormat(const Duration(milliseconds: 7200000), locale), + '2$h 0$m 0$s'); + expect(durationFormat(const Duration(hours: 1, seconds: 1), locale), + '1$h 0$m 1$s'); + expect( + durationFormat( + const Duration(hours: 1, minutes: 1, seconds: 1), locale), + '1$h 1$m 1$s'); + expect(durationFormat(const Duration(days: 1, seconds: 1), locale), + '24$h 0$m 1$s'); + }); +} diff --git a/test_units/tests/formatter/format_amount_test.dart b/test_units/tests/formatter/format_amount_test.dart new file mode 100644 index 0000000000..ca8aaf0e2e --- /dev/null +++ b/test_units/tests/formatter/format_amount_test.dart @@ -0,0 +1,38 @@ +// ignore_for_file: avoid_print + +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +void testFormatAmount() { + test('formatting amount tests:', () { + expect(formatAmt(0), '0.00'); + expect(formatAmt(-12.3456), '-12.35'); + expect(formatAmt(12.3456), '12.35'); + expect(formatAmt(1.23456), '1.23'); + expect(formatAmt(0.00999), '0.01'); + expect(formatAmt(0.010011), '0.01'); + expect(formatAmt(0.12345), '0.12'); + + expect(formatAmt(0.012345), '0.012'); + expect(formatAmt(0.0012345), '0.0012'); + expect(formatAmt(0.00012345), '0.00012'); + + expect(formatAmt(0.09876543), '0.099'); + expect(formatAmt(0.009876543), '0.0099'); + expect(formatAmt(0.0009876543), '0.00099'); + expect(formatAmt(0.00009876543), '0.000099'); + + expect(formatAmt(123456789012345678.023), '1.23456789012e+17'); + expect(formatAmt(-123456789012345678.023), '-1.23456789012e+17'); + + // From top to bottom + expect(formatAmt(123456789012), '123456789012'); // 12 digits is max + expect(formatAmt(1234567890123), '1.23456789012e+12'); // 13 digits is max + expect(formatAmt(12345678901234), '1.23456789012e+13'); // 14 digits is max + expect(formatAmt(123456789012345), '1.23456789012e+14'); // 15 digits is max + expect( + formatAmt(123456789012345.1), '1.23456789012e+14'); // 15 digits is max + expect(formatAmt(123456789012345.123456789), + '1.23456789012e+14'); // 15 digits is max + }); +} diff --git a/test_units/tests/formatter/format_amount_test_alt.dart b/test_units/tests/formatter/format_amount_test_alt.dart new file mode 100644 index 0000000000..081d9a6f6d --- /dev/null +++ b/test_units/tests/formatter/format_amount_test_alt.dart @@ -0,0 +1,66 @@ +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +void testToStringAmount() { + test('formatting amount to String tests:', () { + expect(toStringAmount(1000000000000.0), '1 trillion'); + expect(toStringAmount(1000000000001.0), '1 trillion'); + expect(toStringAmount(1000000100000.0), '1 trillion'); + expect(toStringAmount(1123456789000.0), '1.12 trillion'); + expect(toStringAmount(1988000000000.0), '1.99 trillion'); + + expect(toStringAmount(198800000000.0), '199 billion'); + expect(toStringAmount(194856600000.0), '195 billion'); + + expect(toStringAmount(19485660000.0), '19.5 billion'); + expect(toStringAmount(19055660000.0), '19.1 billion'); + + expect(toStringAmount(1905566000.0), '1,905,566,000'); + expect(toStringAmount(4915566000.0), '4,915,566,000'); + expect(toStringAmount(9915566000.0), '9,915,566,000'); + expect(toStringAmount(9915566001.0), '9,915,566,001'); + expect(toStringAmount(9915566001.1), '9,915,566,001'); + + expect(toStringAmount(100000000.0), '100,000,000'); + + expect(toStringAmount(10000002.3), '10,000,002'); + + expect(toStringAmount(1050002.9), '1,050,003'); + + expect(toStringAmount(105002.6), '105,003'); + + expect(toStringAmount(10502.6), '10,503'); + + expect(toStringAmount(1502.6), '1,503'); + expect(toStringAmount(1502.4), '1,502'); + + expect(toStringAmount(999.6), '999.60'); + expect(toStringAmount(990.612), '990.61'); + expect(toStringAmount(951.619), '951.62'); + expect(toStringAmount(12.009), '12.01'); + expect(toStringAmount(12.009), '12.01'); + expect(toStringAmount(1.0), '1.00'); + + expect(toStringAmount(0.9999999999), '1.00'); + expect(toStringAmount(0.7999999999), '0.80'); + expect(toStringAmount(0.0199999999), '0.02'); + expect(toStringAmount(1.123e-1), '0.1123'); + expect(toStringAmount(0.14567e-1), '0.014567'); + + expect(toStringAmount(0.00114359999), '0.0011436'); + expect(toStringAmount(0.00001), '0.00001'); + expect(toStringAmount(0.00001001), '0.00001001'); + expect(toStringAmount(0.000010010001), '0.00001001'); + expect(toStringAmount(0.000010017001), '0.00001002'); + expect(toStringAmount(0.000010010999), '0.00001001'); + expect(toStringAmount(0.000000010999), '0.00000001'); + expect(toStringAmount(1.23e-7), '0.00000012'); + expect(toStringAmount(1.23434e-6), '0.00000123'); + + expect(toStringAmount(0.000000000999), '9.990e-10'); + expect(toStringAmount(0.000000000001), '1.000e-12'); + expect(toStringAmount(0.00000000000001), '1.000e-14'); + expect(toStringAmount(0.00000000085001), '8.500e-10'); + expect(toStringAmount(1.23e-10), '1.230e-10'); + }); +} diff --git a/test_units/tests/formatter/format_dex_amt_tests.dart b/test_units/tests/formatter/format_dex_amt_tests.dart new file mode 100644 index 0000000000..21eb48dce2 --- /dev/null +++ b/test_units/tests/formatter/format_dex_amt_tests.dart @@ -0,0 +1,50 @@ +// ignore_for_file: avoid_print + +import 'package:rational/rational.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +void testFormatDexAmount() { + test('formatting double DEX amount tests:', () { + expect(formatDexAmt(0.0), '0'); + expect(formatDexAmt(0.00), '0'); + expect(formatDexAmt(0.000), '0'); + expect(formatDexAmt(0.0000), '0'); + expect(formatDexAmt(0.1), '0.1'); + expect(formatDexAmt(0.100), '0.1'); + expect(formatDexAmt(0.101), '0.101'); + expect(formatDexAmt(0.1010), '0.101'); + expect(formatDexAmt(0.00000001), '0.00000001'); + expect(formatDexAmt(0.000000001), '0'); + expect(formatDexAmt(000.0), '0'); + expect(formatDexAmt(000.1), '0.1'); + expect(formatDexAmt(000.01), '0.01'); + }); + + test('formatting Rational DEX amount tests:', () { + expect(formatDexAmt(Rational.parse('0.0')), '0'); + expect(formatDexAmt(Rational.parse('0.00')), '0'); + expect(formatDexAmt(Rational.parse('0.000')), '0'); + expect( + formatDexAmt(Rational(BigInt.from(1), BigInt.from(100000))), '0.00001'); + expect(formatDexAmt(Rational(BigInt.from(001), BigInt.from(100000))), + '0.00001'); + expect(formatDexAmt(Rational(BigInt.from(101), BigInt.from(100000))), + '0.00101'); + }); + + test('formatting int DEX amount tests:', () { + expect(formatDexAmt(0), '0'); + expect(formatDexAmt(1), '1'); + expect(formatDexAmt(100), '100'); + expect(formatDexAmt(00100), '100'); + }); + + test('formatting String DEX amount tests:', () { + expect(formatDexAmt('0.00'), '0'); + expect(formatDexAmt('000.0'), '0'); + expect(formatDexAmt('000.0001'), '0.0001'); + expect(formatDexAmt('0.00000001'), '0.00000001'); + expect(formatDexAmt('0.000000001'), '0'); + }); +} diff --git a/test_units/tests/formatter/formatted_date_test.dart b/test_units/tests/formatter/formatted_date_test.dart new file mode 100644 index 0000000000..b9b3197ac0 --- /dev/null +++ b/test_units/tests/formatter/formatted_date_test.dart @@ -0,0 +1,33 @@ +// ignore_for_file: avoid_print + +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +void testFormattedDate() { + const maxTimestampSecond = 8640000000000; + const minTimestampSecond = -8639999999999; + final date = DateTime.now(); + final timezone = date.timeZoneOffset; + int timestampOffset(int timestamp) => + (timestamp * 1000 - timezone.inMilliseconds) ~/ 1000; + + test('formatting date from timestamp', () { + expect(getFormattedDate(1, true), '01 Jan 1970, 00:00'); + expect(getFormattedDate(1687427558, true), '22 Jun 2023, 09:52'); + expect(getFormattedDate(1692102558, true), '15 Aug 2023, 12:29'); + expect(getFormattedDate(-1500000, true), '14 Dec 1969, 15:20'); + expect(getFormattedDate(-2000000000, true), '16 Aug 1906, 20:26'); + // Maximum value + expect(getFormattedDate(maxTimestampSecond, true), '13 Sep 275760, 00:00'); + // Minimal value + expect( + getFormattedDate(minTimestampSecond, true), '20 Apr 271821, 00:00 BC'); + }); + + test('negative tests for formatting date', () { + expect(getFormattedDate(timestampOffset(9650000000000)), + 'Date is out of the range'); + expect(getFormattedDate(timestampOffset(-9650000000000)), + 'Date is out of the range'); + }); +} diff --git a/test_units/tests/formatter/leading_zeros_test.dart b/test_units/tests/formatter/leading_zeros_test.dart new file mode 100644 index 0000000000..f291a7f20e --- /dev/null +++ b/test_units/tests/formatter/leading_zeros_test.dart @@ -0,0 +1,26 @@ +// ignore_for_file: avoid_print + +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +void testLeadingZeros() { + test('get amount of leading zeros tests:', () { + expect(getLeadingZeros(0.00012002), 3, reason: '0.00012002'); + expect(getLeadingZeros(22), -2, reason: '22'); + expect(getLeadingZeros(333), -3, reason: '33'); + expect(getLeadingZeros(0.12002), 0, reason: '0.12002'); + expect(getLeadingZeros(0.009999), 2, reason: '0.009999'); + expect(getLeadingZeros(0.0002), 3, reason: '0.0002'); + expect(getLeadingZeros(0.0001), 3, reason: '0.0001'); + expect(getLeadingZeros(0.00001), 4, reason: '0.00001'); + expect(getLeadingZeros(0.000001), 5, reason: '0.000001'); + expect(getLeadingZeros(0.0000001), 6, reason: '0.0000001'); + expect(getLeadingZeros(0.00000001), 7, reason: '0.00000001'); + expect(getLeadingZeros(0.000000001), 8, reason: '0.000000001'); + expect(getLeadingZeros(0.0000000001), 9, reason: '0.0000000001'); + expect(getLeadingZeros(0.00000000001), 10, reason: '0.00000000001'); + expect(getLeadingZeros(0.000000000001), 11, reason: '0.000000000001'); + expect(getLeadingZeros(123456789012345), -15, reason: '123456789012345'); + expect(getLeadingZeros(999999999999999), -15, reason: '999999999999999'); + }); +} diff --git a/test_units/tests/formatter/number_without_exponent_test.dart b/test_units/tests/formatter/number_without_exponent_test.dart new file mode 100644 index 0000000000..65a79e5ae6 --- /dev/null +++ b/test_units/tests/formatter/number_without_exponent_test.dart @@ -0,0 +1,61 @@ +// ignore_for_file: avoid_print + +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +void testNumberWithoutExponent() { + test('convert number from e-notation to simple view tests:', () { + const one = 1; + expect(getNumberWithoutExponent(one.toString()), '1'); + const million = 1000000; + expect(getNumberWithoutExponent(million.toString()), '1000000'); + const e1 = 0.1; // 0.1 + expect(getNumberWithoutExponent(e1.toString()), '0.1'); + const e2 = 0.01; // 0.01 + expect(getNumberWithoutExponent(e2.toString()), '0.01'); + const e3 = 0.001; // 0.001 + expect(getNumberWithoutExponent(e3.toString()), '0.001'); + const e4 = 0.0001; // 0.0001 + expect(getNumberWithoutExponent(e4.toString()), '0.0001'); + const e5 = 0.00001; // 0.00001 + expect(getNumberWithoutExponent(e5.toString()), '0.00001'); + const e6 = 0.000001; // 0.000001 + expect(getNumberWithoutExponent(e6.toString()), '0.000001'); + const e7 = 0.0000001; // 1e-7 + expect(getNumberWithoutExponent(e7.toString()), '0.0000001'); + const e8 = 0.00000001; // 1e-8 + expect(getNumberWithoutExponent(e8.toString()), '0.00000001'); + const e9 = 0.000000001; // 1e-9 + expect(getNumberWithoutExponent(e9.toString()), '0.000000001'); + expect(getNumberWithoutExponent('1e-9'), '0.000000001'); + const e1Alt = 0.5; // 0.1 + expect(getNumberWithoutExponent(e1Alt.toString()), '0.5'); + const e7Alt = 0.000000123; // 1.23e-7 + expect(getNumberWithoutExponent(e7Alt.toString()), '0.000000123'); + expect(getNumberWithoutExponent('1.23e-7'), '0.000000123'); + const e8Alt = 0.000000056; // 5.6e-8 + expect(getNumberWithoutExponent(e8Alt.toString()), '0.000000056'); + expect(getNumberWithoutExponent('5.6e-8'), '0.000000056'); + }); + + test('convert number from +e-notation to simple view tests:', () { + expect(getNumberWithoutExponent("1.23e+2"), "123"); + expect(getNumberWithoutExponent("1e+3"), "1000"); + expect(getNumberWithoutExponent("1.2340e+3"), "1234"); + expect(getNumberWithoutExponent("1.2341e+3"), "1234.1"); + + expect(getNumberWithoutExponent('1e+20'), '100000000000000000000'); + expect(getNumberWithoutExponent('-1e+19'), '-10000000000000000000'); + expect(getNumberWithoutExponent('1e+21'), '1000000000000000000000'); + expect(getNumberWithoutExponent('1e+50'), + '100000000000000000000000000000000000000000000000000'); + expect(getNumberWithoutExponent('1e+100'), + '10000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000'); + + expect(getNumberWithoutExponent('-1.2354e+3'), '-1235.4'); + expect(getNumberWithoutExponent('-1.235400002e+5'), '-123540.0002'); + expect(getNumberWithoutExponent('1.235400002e+8'), '123540000.2'); + expect(getNumberWithoutExponent('1.235400002e+9'), '1235400002'); + expect(getNumberWithoutExponent('1.235400002e+10'), '12354000020'); + }); +} diff --git a/test_units/tests/formatter/text_input_formatter_test.dart b/test_units/tests/formatter/text_input_formatter_test.dart new file mode 100644 index 0000000000..63b6593a5b --- /dev/null +++ b/test_units/tests/formatter/text_input_formatter_test.dart @@ -0,0 +1,43 @@ +// ignore_for_file: avoid_print + +import 'package:flutter/material.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +void testDecimalTextInputFormatter() { + test('formatter of decimal inputs', () { + final simple1 = _testItemDecimalRange8('123', '123', '123'); + expect(simple1.$1, simple1.$2); + final comma2dot = _testItemDecimalRange8('123', '123,', '123.'); + expect(comma2dot.$1, comma2dot.$2); + final dot2dot = _testItemDecimalRange8('123', '123.', '123.'); + expect(dot2dot.$1, dot2dot.$2); + final decimalRange8 = + _testItemDecimalRange8('123.12345678', '123.123456789', '123.12345678'); + expect(decimalRange8.$1, decimalRange8.$2); + // @todo: DmitriiP : Is it expected behavior? + final notOnlyDigits = _testItemDecimalRange8('123', '123M', '123M'); + expect(notOnlyDigits.$1, notOnlyDigits.$2); + final addLeadingZero = _testItemDecimalRange8('', ',', '0.'); + expect(addLeadingZero.$1, addLeadingZero.$2); + }); +} + +final formatter = DecimalTextInputFormatter(decimalRange: 8); + +(TextEditingValue, TextEditingValue) _testItemDecimalRange8( + String oldValueText, String newValueText, String matcherText) { + final TextEditingValue oldValue = TextEditingValue( + text: oldValueText, + selection: TextSelection.collapsed(offset: oldValueText.length)); + final TextEditingValue newValue = TextEditingValue( + text: newValueText, + selection: TextSelection.collapsed(offset: newValueText.length)); + final TextEditingValue matcher = TextEditingValue( + text: matcherText, + selection: TextSelection.collapsed(offset: matcherText.length), + ); + + final result = formatter.formatEditUpdate(oldValue, newValue); + return (result, matcher); +} diff --git a/test_units/tests/formatter/truncate_decimal_test.dart b/test_units/tests/formatter/truncate_decimal_test.dart new file mode 100644 index 0000000000..5cc1290607 --- /dev/null +++ b/test_units/tests/formatter/truncate_decimal_test.dart @@ -0,0 +1,43 @@ +// ignore_for_file: avoid_print + +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +void testTruncateDecimal() { + test('truncate decimal according to decimalRange param', () { + expect(truncateDecimal('0.01', 0), '0'); + expect(truncateDecimal('0.01', 1), '0.0'); + expect(truncateDecimal('0.01', 2), '0.01'); + expect(truncateDecimal('0.01', 3), '0.01'); + // @todo: DmitriiP: Is it expected behavior? + expect( + truncateDecimal('0.00000000000000000001', 19), '0.0000000000000000000'); + expect(truncateDecimal('0.00000000000000000001', 20), + '0.00000000000000000001'); + expect(truncateDecimal('0.00000000000000000001', 21), + '0.00000000000000000001'); + expect(truncateDecimal('0.123456789', 8), '0.12345678'); + expect(truncateDecimal('0.123456789', 1), '0.1'); + // todo: Is it expected behavior? + expect(truncateDecimal('0.1234567099', 8), '0.12345670'); + }); + + test('truncateDecimal should truncate decimal part correctly', () { + // Test cases where decimalRange >= 0 + expect(truncateDecimal("3.14159", 0), "3"); + expect(truncateDecimal("3.14159", 2), "3.14"); + expect(truncateDecimal("3.14159", 5), "3.14159"); + expect(truncateDecimal("123.456789", 2), "123.45"); + expect(truncateDecimal("123.456789", 8), "123.456789"); + + // Test cases where decimalRange < 0 + expect(truncateDecimal("3.14159", -1), "3.14159"); + expect(truncateDecimal("123.456789", -5), "123.456789"); + }); + + test('truncateDecimal should return original value if no decimal part', () { + expect(truncateDecimal("42", 2), "42"); + expect(truncateDecimal("1000", 0), "1000"); + expect(truncateDecimal("0", 5), "0"); + }); +} diff --git a/test_units/tests/formatter/truncate_hash_test.dart b/test_units/tests/formatter/truncate_hash_test.dart new file mode 100644 index 0000000000..44194ffd82 --- /dev/null +++ b/test_units/tests/formatter/truncate_hash_test.dart @@ -0,0 +1,46 @@ +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/formatters.dart'; + +void testTruncateHash() { + test('Truncate string in the middle with default params', () { + expect( + truncateMiddleSymbols( + '6d6a62bfbe161a06e1c87bc83ac14f9385ced623d93cec3ad32c0a9be1bb324e'), + '6d6a...1bb324e'); + expect( + truncateMiddleSymbols( + '0x6d6a62bfbe161a06e1c87bc83ac14f9385ced623d93cec3ad32c0a9be1bb324e'), + '0x6d6a...1bb324e'); + expect(truncateMiddleSymbols('0x6d624e'), '0x6d624e'); + expect(truncateMiddleSymbols('0x6d624f9385c'), '0x6d624f9385c'); + expect(truncateMiddleSymbols('0x6d624f9385c123'), '0x6d624f9385c123'); + expect(truncateMiddleSymbols('0x6d624f9385c1234'), '0x6d62...85c1234'); + expect(truncateMiddleSymbols('1'), '1'); + expect(truncateMiddleSymbols(''), ''); + }); + + test('Truncate string in the middle with different params', () { + expect( + truncateMiddleSymbols( + '6d6a62bfbe161a06e1c87bc83ac14f9385ced623d93cec3ad32c0a9be1bb324e', + 1, + 1), + '6...e'); + expect( + truncateMiddleSymbols( + '6d6a62bfbe161a06e1c87bc83ac14f9385ced623d93cec3ad32c0a9be1bb324e', + 10, + 10), + '6d6a62bfbe...9be1bb324e'); + expect( + truncateMiddleSymbols( + '6d6a62bfbe161a06e1c87bc83ac14f9385ced623d93cec3ad32c0a9be1bb324e', + 0, + 10), + '...9be1bb324e'); + expect(truncateMiddleSymbols('', 0, 10), ''); + expect(truncateMiddleSymbols('1234567890', 0, 10), '1234567890'); + expect(truncateMiddleSymbols('1234567890ABC', 0, 10), '1234567890ABC'); + expect(truncateMiddleSymbols('1234567890ABCD', 0, 10), '...567890ABCD'); + }); +} diff --git a/test_units/tests/helpers/calculate_buy_amount_test.dart b/test_units/tests/helpers/calculate_buy_amount_test.dart new file mode 100644 index 0000000000..79a62a7d86 --- /dev/null +++ b/test_units/tests/helpers/calculate_buy_amount_test.dart @@ -0,0 +1,95 @@ +import 'package:rational/rational.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/mm2/mm2_api/rpc/best_orders/best_orders.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +void testCalculateBuyAmount() { + test('Calculation sellAmount on selectedOrder', () { + final BestOrder bestOrder = BestOrder( + price: Rational.fromInt(2), + maxVolume: Rational.fromInt(3), + address: '', + coin: 'KMD', + minVolume: Rational.fromInt(1), + uuid: '', + ); + + expect( + calculateBuyAmount( + sellAmount: Rational.fromInt(2), selectedOrder: bestOrder), + Rational.fromInt(4), + ); + expect( + calculateBuyAmount( + sellAmount: Rational.parse('0.1'), selectedOrder: bestOrder), + Rational.parse('0.2'), + ); + expect( + calculateBuyAmount( + sellAmount: Rational.parse('1e-30'), selectedOrder: bestOrder), + Rational.parse('2e-30'), + ); + + final BestOrder bestOrder2 = BestOrder( + price: Rational.parse('1e-30'), + maxVolume: Rational.fromInt(100), + address: '', + coin: 'KMD', + minVolume: Rational.fromInt(1), + uuid: '', + ); + expect( + calculateBuyAmount( + sellAmount: Rational.parse('1e-30'), selectedOrder: bestOrder2), + Rational.parse('1e-60'), + ); + expect( + calculateBuyAmount( + sellAmount: Rational.parse('1e70'), selectedOrder: bestOrder2), + Rational.parse('1e40'), + ); + expect( + calculateBuyAmount( + sellAmount: Rational.parse('123456789012345678901234567890'), + selectedOrder: bestOrder2), + Rational.parse('0.123456789012345678901234567890'), + ); + final BestOrder bestOrder3 = BestOrder( + price: Rational.parse('1e10'), + maxVolume: Rational.fromInt(100), + address: '', + coin: 'KMD', + minVolume: Rational.fromInt(1), + uuid: '', + ); + expect( + calculateBuyAmount( + sellAmount: Rational.parse('12345678901234567890123456789'), + selectedOrder: bestOrder3), + Rational.parse('12345678901234567890123456789e10'), + ); + expect( + calculateBuyAmount( + sellAmount: Rational.parse('12345678901234567890123456789e20'), + selectedOrder: bestOrder3), + Rational.parse('12345678901234567890123456789e30'), + ); + }); + test('Negative tests', () { + final BestOrder bestOrder = BestOrder( + price: Rational.fromInt(2), + maxVolume: Rational.fromInt(3), + address: '', + coin: 'KMD', + minVolume: Rational.fromInt(1), + uuid: '', + ); + expect(calculateBuyAmount(sellAmount: null, selectedOrder: null), isNull); + expect( + calculateBuyAmount( + sellAmount: Rational.fromInt(2), selectedOrder: null), + isNull); + expect( + calculateBuyAmount(sellAmount: null, selectedOrder: bestOrder), isNull); + }); +} diff --git a/test_units/tests/helpers/get_sell_amount_test.dart b/test_units/tests/helpers/get_sell_amount_test.dart new file mode 100644 index 0000000000..604b0b2e02 --- /dev/null +++ b/test_units/tests/helpers/get_sell_amount_test.dart @@ -0,0 +1,35 @@ +import 'package:rational/rational.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +void testGetSellAmount() { + test('getSellAmount calculates sell amount main cases', () { + Rational maxSellAmount = Rational.fromInt(100); + double fraction = 0.75; + Rational result = getFractionOfAmount(maxSellAmount, fraction); + expect(result, equals(Rational.fromInt(75))); + + Rational maxSellAmount2 = Rational.fromInt(100); + double fraction2 = 0.0; + Rational result2 = getFractionOfAmount(maxSellAmount2, fraction2); + expect(result2, Rational.zero); + + Rational maxSellAmount3 = Rational.fromInt(100); + double fraction3 = 1.5; + Rational result3 = getFractionOfAmount(maxSellAmount3, fraction3); + Rational expected = Rational.fromInt(150); + expect(result3, expected); + }); + + test('getSellAmount and strange inputs', () { + Rational maxSellAmount = Rational.zero; + double fraction = 0.75; + Rational? result = getFractionOfAmount(maxSellAmount, fraction); + expect(result, Rational.zero); + + Rational maxSellAmount2 = Rational.parse('123e53'); + double fraction2 = 1e-53; + Rational? result2 = getFractionOfAmount(maxSellAmount2, fraction2); + expect(result2, Rational.parse('123')); + }); +} diff --git a/test_units/tests/helpers/max_min_rational_test.dart b/test_units/tests/helpers/max_min_rational_test.dart new file mode 100644 index 0000000000..952bc44a89 --- /dev/null +++ b/test_units/tests/helpers/max_min_rational_test.dart @@ -0,0 +1,39 @@ +import 'package:rational/rational.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +void testMaxMinRational() { + test('max rational test', () { + final List list = [ + Rational.fromInt(1), + Rational.fromInt(2), + Rational.fromInt(3), + ]; + expect(maxRational(list), Rational.fromInt(3)); + expect(minRational(list), Rational.fromInt(1)); + + final List list2 = [ + Rational.fromInt(-1), + Rational.fromInt(0), + Rational.fromInt(1), + ]; + expect(maxRational(list2), Rational.fromInt(1)); + expect(minRational(list2), Rational.fromInt(-1)); + + final List list3 = [ + Rational.fromInt(-1), + Rational.fromInt(-2), + Rational.fromInt(-3), + ]; + expect(maxRational(list3), Rational.fromInt(-1)); + expect(minRational(list3), Rational.fromInt(-3)); + + final List list4 = [ + Rational.fromInt(1000000000000), + Rational.fromInt(2000000000000), + Rational.fromInt(1999999999999), + ]; + expect(maxRational(list4), Rational.fromInt(2000000000000)); + expect(minRational(list4), Rational.fromInt(1000000000000)); + }); +} diff --git a/test_units/tests/helpers/total_24_change_test.dart b/test_units/tests/helpers/total_24_change_test.dart new file mode 100644 index 0000000000..d726278718 --- /dev/null +++ b/test_units/tests/helpers/total_24_change_test.dart @@ -0,0 +1,166 @@ +import 'package:test/test.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/views/wallet/common/wallet_helper.dart'; + +import '../utils/test_util.dart'; + +void testGetTotal24Change() { + test('getTotal24Change calculates total change', () { + List coins = [ + setCoin( + balance: 1.0, + usdPrice: 10, + change24h: 0.05, + ), + ]; + + double? result = getTotal24Change(coins); + 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); + // -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); + 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); + 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); + // -1.7543478260869563 if use double + expect(result, equals(-1.7543478260869565)); + }); + + test('getTotal24Change returns null for empty or null input', () { + double? resultEmpty = getTotal24Change([]); + double? resultNull = getTotal24Change(null); + + 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); + 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); + expect(resultNoChangeFor24h, 0); + }); +} diff --git a/test_units/tests/helpers/total_fee_test.dart b/test_units/tests/helpers/total_fee_test.dart new file mode 100644 index 0000000000..5479ab1ec8 --- /dev/null +++ b/test_units/tests/helpers/total_fee_test.dart @@ -0,0 +1,88 @@ +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 '../utils/test_util.dart'; + +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'); + }); + + 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'); + + 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'); + }); +} diff --git a/test_units/tests/helpers/update_sell_amount_test.dart b/test_units/tests/helpers/update_sell_amount_test.dart new file mode 100644 index 0000000000..d85b581787 --- /dev/null +++ b/test_units/tests/helpers/update_sell_amount_test.dart @@ -0,0 +1,64 @@ +import 'package:rational/rational.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/views/dex/dex_helpers.dart'; + +void testUpdateSellAmount() { + test('updateSellAmount updates buyAmount correctly when price is provided', + () { + final sellAmount = Rational.fromInt(100); + final price = Rational.fromInt(2); + var result = processBuyAmountAndPrice(sellAmount, price, null); + expect(result, equals((Rational.fromInt(200), price))); + + final sellAmount2 = Rational.parse('1e10'); + final price2 = Rational.parse('1e40'); + var result2 = processBuyAmountAndPrice(sellAmount2, price2, null); + expect(result2, equals((Rational.parse('1e50'), price2))); + + final sellAmount3 = Rational.parse('1e-65'); + final price3 = Rational.parse('1e-5'); + var result3 = processBuyAmountAndPrice(sellAmount3, price3, null); + expect(result3, equals((Rational.parse('1e-70'), price3))); + }); + + test('updateSellAmount updates values correctly when buyAmount is provided', + () { + Rational? sellAmount = Rational.fromInt(100); + Rational? buyAmount = Rational.fromInt(2000); + + var result = processBuyAmountAndPrice(sellAmount, null, buyAmount); + expect(result, equals((buyAmount, Rational.fromInt(20)))); + + Rational? sellAmount2 = Rational.parse('1e10'); + Rational? buyAmount2 = Rational.parse('1e40'); + + var result2 = processBuyAmountAndPrice(sellAmount2, null, buyAmount2); + expect(result2, equals((buyAmount2, Rational.parse('1e30')))); + }); + + test('updateSellAmount returns null when input is null', () { + Rational? sellAmount; + Rational? price = Rational.fromInt(2); + Rational? buyAmount = Rational.fromInt(200); + + var result = processBuyAmountAndPrice(sellAmount, price, buyAmount); + expect(result, isNull); + + Rational? sellAmount2 = Rational.fromInt(100); + Rational? price2; + Rational? buyAmount2; + + var result2 = processBuyAmountAndPrice(sellAmount2, price2, buyAmount2); + expect(result2, isNull); + }); + + test('updateSellAmount handles division by zero when buyAmount is provided', + () { + Rational? sellAmount = Rational.fromInt(0); + Rational? price; + Rational? buyAmount = Rational.fromInt(200); + + var result = processBuyAmountAndPrice(sellAmount, price, buyAmount); + expect(result, equals((Rational.fromInt(200), null))); + }); +} diff --git a/test_units/tests/password/validate_password_test.dart b/test_units/tests/password/validate_password_test.dart new file mode 100644 index 0000000000..b9bc2f8b42 --- /dev/null +++ b/test_units/tests/password/validate_password_test.dart @@ -0,0 +1,18 @@ +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/validators.dart'; + +void testValidatePassword() { + test('Get from Coin usd price and return formatted string', () { + const errorMsg = 'Error'; + expect(validatePassword('passwordwith Space', errorMsg), errorMsg); + expect(validatePassword('passwordwith Space!', errorMsg), null); + expect(validatePassword('passwordwith_Space', errorMsg), null); + expect(validatePassword('passwordwith_Space!', errorMsg), null); + expect(validatePassword('ABCdec123123!', errorMsg), null); + expect(validatePassword('123123', errorMsg), errorMsg); + expect(validatePassword('ABCDEF', errorMsg), errorMsg); + expect(validatePassword('abcdef', errorMsg), errorMsg); + expect(validatePassword('!@#%', errorMsg), errorMsg); + expect(validatePassword('', errorMsg), errorMsg); + }); +} diff --git a/test_units/tests/password/validate_rpc_password_test.dart b/test_units/tests/password/validate_rpc_password_test.dart new file mode 100644 index 0000000000..b73c81efeb --- /dev/null +++ b/test_units/tests/password/validate_rpc_password_test.dart @@ -0,0 +1,10 @@ +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/password.dart'; + +void testValidateRPCPassword() { + test('validate password', () { + expect(validateRPCPassword('123'), false); + expect(validateRPCPassword(''), false); + expect(validateRPCPassword('OneTwoThree123?'), true); + }); +} diff --git a/test_units/tests/sorting/sorting_test.dart b/test_units/tests/sorting/sorting_test.dart new file mode 100644 index 0000000000..e0d83b1223 --- /dev/null +++ b/test_units/tests/sorting/sorting_test.dart @@ -0,0 +1,9 @@ +import 'package:komodo_ui_kit/komodo_ui_kit.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/sorting.dart'; + +void testSorting() { + test('Sort 2 numbers in desired direction', () { + expect(sortByDouble(1, 2, SortDirection.decrease), 1); + }); +} diff --git a/test_units/tests/utils/convert_double_to_string_test.dart b/test_units/tests/utils/convert_double_to_string_test.dart new file mode 100644 index 0000000000..4b66fa091a --- /dev/null +++ b/test_units/tests/utils/convert_double_to_string_test.dart @@ -0,0 +1,31 @@ +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +void testDoubleToString() { + test('Convert double to string with default fractions', () { + expect(doubleToString(0), '0'); + expect(doubleToString(1), '1'); + expect(doubleToString(0.0), '0'); + expect(doubleToString(0.000000001), '0'); + expect(doubleToString(0.00000001), '0.00000001'); + expect(doubleToString(100.000000004), '100'); + expect(doubleToString(100.000000005), '100.00000001'); + expect(doubleToString(-1.232e-5), '-0.00001232'); + expect(doubleToString(-1.0023999e-5), '-0.00001002'); + expect(doubleToString(-1.0025999e-5), '-0.00001003'); + }); + + test('Convert double to string with custom fractions', () { + expect(doubleToString(0, 2), '0'); + expect(doubleToString(0.0001, 2), '0'); + expect(doubleToString(1, 2), '1'); + expect(doubleToString(1.00, 2), '1'); + expect(doubleToString(1.01, 2), '1.01'); + expect(doubleToString(1.001, 2), '1'); + expect(doubleToString(1.005, 100), '1.005'); + expect(doubleToString(1.005230020234030434, 17), '1.0052300202340305'); + expect(doubleToString(0.0100, 4), '0.01'); + expect(doubleToString(-0.0100, 4), '-0.01'); + expect(doubleToString(-1.0100e-6, 4), '0'); + }); +} diff --git a/test_units/tests/utils/convert_fract_rat_test.dart b/test_units/tests/utils/convert_fract_rat_test.dart new file mode 100644 index 0000000000..4955892a10 --- /dev/null +++ b/test_units/tests/utils/convert_fract_rat_test.dart @@ -0,0 +1,37 @@ +import 'package:rational/rational.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +void testRatToFracAndViseVersa() { + test('fract2rat and rat2fract converts valid map', () { + Map validFract = {'numer': '3', 'denom': '4'}; + Rational? result = fract2rat(validFract, false); + expect(result, isNotNull); + expect(result!.numerator, equals(BigInt.from(3))); + expect(result.denominator, equals(BigInt.from(4))); + + final fract = rat2fract(result, false); + expect(fract, validFract); + expect(fract!['numer'], '3'); + expect(fract['denom'], '4'); + + final Rational result2 = Rational.parse('0.25'); + final fract2 = rat2fract(result2, false); + expect(fract2!['numer'], '1'); + expect(fract2['denom'], '4'); + }); + + test('fract2rat returns null for null input', () { + Rational? result = fract2rat(null, false); + expect(result, isNull); + + final fract = rat2fract(null, false); + expect(fract, isNull); + }); + + test('fract2rat returns null for invalid input', () { + Map invalidFract = {'numer': 'abc', 'denom': 'xyz'}; + Rational? result = fract2rat(invalidFract, false); + expect(result, isNull); + }); +} diff --git a/test_units/tests/utils/double_to_string_test.dart b/test_units/tests/utils/double_to_string_test.dart new file mode 100644 index 0000000000..9e6eebcc29 --- /dev/null +++ b/test_units/tests/utils/double_to_string_test.dart @@ -0,0 +1,43 @@ +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/utils.dart'; + +void testCustomDoubleToString() { + test('doubleToString formats whole number without decimal places', () { + double value = 12345.0; + expect(doubleToString(value), equals('12345')); + + value = 0.0; + expect(doubleToString(value), equals('0')); + }); + + test('doubleToString formats with specified decimal places', () { + double value = 12.3456789; + expect(doubleToString(value, 3), equals('12.346')); + + value = 0.123456789; + expect(doubleToString(value, 5), equals('0.12346')); + }); + + test('doubleToString caps decimal places to 20', () { + double value = 0.123456789012345678901234567890123456789; + expect(doubleToString(value, 25).length, equals(19)); + }); + + test('doubleToString formats with reduced decimal places', () { + double value = 123.400; + expect(doubleToString(value, 5), equals('123.4')); + expect(doubleToString(value, 6), equals('123.4')); + + value = 9876.00001; + expect(doubleToString(value, 10), equals('9876.00001')); + expect(doubleToString(value, 3), equals('9876')); + }); + + test('doubleToString removes trailing zeros and dot if necessary', () { + double value = 123.45000; + expect(doubleToString(value), equals('123.45')); + + value = 987600.00000; + expect(doubleToString(value), equals('987600')); + }); +} diff --git a/test_units/tests/utils/get_fiat_amount_tests.dart b/test_units/tests/utils/get_fiat_amount_tests.dart new file mode 100644 index 0000000000..88ec119028 --- /dev/null +++ b/test_units/tests/utils/get_fiat_amount_tests.dart @@ -0,0 +1,48 @@ +import 'package:rational/rational.dart'; +import 'package:test/test.dart'; +import 'package:web_dex/shared/utils/balances_formatter.dart'; + +import 'test_util.dart'; + +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); + expect( + 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); + expect( + 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); + // Amount of atoms in the universe is ~10^80 + expect( + 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 new file mode 100644 index 0000000000..982a4f7bad --- /dev/null +++ b/test_units/tests/utils/get_usd_balance_test.dart @@ -0,0 +1,19 @@ +import 'package:test/test.dart'; + +import 'test_util.dart'; + +void testUsdBalanceFormatter() { + test('Get from Coin usd price and return formatted string', () { + expect( + setCoin(usdPrice: 10.12, balance: 1).getFormattedUsdBalance, '\$10.12'); + expect(setCoin(usdPrice: 0, balance: 1).getFormattedUsdBalance, '\$0.00'); + expect( + setCoin(usdPrice: null, balance: 1).getFormattedUsdBalance, '\$0.00'); + expect(setCoin(usdPrice: 0.0000001, balance: 1).getFormattedUsdBalance, + '\$0.0000001'); + expect(setCoin(usdPrice: 123456789, balance: 1).getFormattedUsdBalance, + '\$123456789.00'); + expect(setCoin(usdPrice: 123456789, balance: 0).getFormattedUsdBalance, + '\$0.00'); + }); +} diff --git a/test_units/tests/utils/test_util.dart b/test_units/tests/utils/test_util.dart new file mode 100644 index 0000000000..4977e49596 --- /dev/null +++ b/test_units/tests/utils/test_util.dart @@ -0,0 +1,46 @@ +import 'package:web_dex/model/cex_price.dart'; +import 'package:web_dex/model/coin.dart'; +import 'package:web_dex/model/coin_type.dart'; + +Coin setCoin( + {double? usdPrice, double? change24h, String? coinAbbr, double? balance}) { + final coin = Coin( + abbr: coinAbbr ?? 'KMD', + accounts: null, + activeByDefault: true, + bchdUrls: [], + coingeckoId: "komodo", + coinpaprikaId: "kmd-komodo", + derivationPath: "m/44'/141'/0'", + electrum: [], + explorerUrl: "https://kmdexplorer.io/address/", + explorerAddressUrl: "address/", + explorerTxUrl: "tx/", + fallbackSwapContract: null, + isTestCoin: false, + mode: CoinMode.standard, + name: 'Komodo', + nodes: [], + priority: 30, + protocolData: null, + protocolType: 'UTXO', + parentCoin: null, + rpcUrls: [], + state: CoinState.inactive, + swapContractAddress: null, + type: CoinType.smartChain, + walletOnly: false, + usdPrice: usdPrice != null + ? CexPrice( + price: usdPrice, + change24h: change24h ?? 0.0, + volume24h: 0.0, + ticker: 'USD', + ) + : null, + ); + if (balance != null) { + coin.balance = balance; + } + return coin; +} diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000000..06a97a40cf --- /dev/null +++ b/web/README.md @@ -0,0 +1,10 @@ +NB! The index.html is generated automatically as part of the build process in `./packages/komodo_wallet_build_transformer`. Do not edit it manually. +Changes applied to `template.html` will be reflected in the generated `index.html` file. + +If you need to manually rebuild `index.html`, you can run the following command: + +```bash +npm install && npm run build +``` + +If `index.html` is not present after running `flutter build`/`flutter run`, it means that the build steps have not been run. Please run `flutter clean && flutter pub get` and then try again. If issues persist, please ensure you are using the latest Flutter SDK and have set up your environment correctly as per our [project setup docs](/docs/PROJECT_SETUP.md). \ No newline at end of file diff --git a/web/assets/template.html b/web/assets/template.html new file mode 100644 index 0000000000..a9ef9f74b1 --- /dev/null +++ b/web/assets/template.html @@ -0,0 +1,198 @@ +<%= htmlWebpackPlugin.options.warning %> + + + + + + + + + + Komodo Wallet | Non-Custodial Multi-Coin Wallet & DEX + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + Komodo Wallet is starting... Please wait. +
+ + + + + + + + \ No newline at end of file diff --git a/web/favicon-16x16.png b/web/favicon-16x16.png new file mode 100644 index 0000000000..f00f40f453 Binary files /dev/null and b/web/favicon-16x16.png differ diff --git a/web/favicon-32x32.png b/web/favicon-32x32.png new file mode 100644 index 0000000000..791786d38b Binary files /dev/null and b/web/favicon-32x32.png differ diff --git a/web/favicon.ico b/web/favicon.ico new file mode 100644 index 0000000000..f31732bc6f Binary files /dev/null and b/web/favicon.ico differ diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000000..791786d38b Binary files /dev/null and b/web/favicon.png differ diff --git a/web/flutter_bootstrap.js b/web/flutter_bootstrap.js new file mode 100644 index 0000000000..fc8ba6f6d3 --- /dev/null +++ b/web/flutter_bootstrap.js @@ -0,0 +1,24 @@ +{{flutter_js}} +{{flutter_build_config}} + +_flutter.loader.load({ + serviceWorkerSettings: { + serviceWorkerVersion: {{flutter_service_worker_version}}, + }, + config: { + 'hostElement': document.querySelector('#main-content'), + canvasKitBaseUrl: "/canvaskit/", + fontFallbackBaseUrl: "/assets/fallback_fonts/", + }, + onEntrypointLoaded: async function (engineInitializer) { + console.log('Flutter entrypoint loaded'); + const appRunner = await engineInitializer.initializeEngine(); + document.querySelector('#loading')?.classList.add('main_done'); + + return appRunner.runApp(); + + // NB: The code to remove the loading spinner is in the Flutter app. + // This allows the Flutter app to control the timing of the spinner removal. + + } +}); diff --git a/web/icons/android-chrome-192x192.png b/web/icons/android-chrome-192x192.png new file mode 100644 index 0000000000..cd2c0c7788 Binary files /dev/null and b/web/icons/android-chrome-192x192.png differ diff --git a/web/icons/android-chrome-512x512.png b/web/icons/android-chrome-512x512.png new file mode 100644 index 0000000000..14d01e5c2a Binary files /dev/null and b/web/icons/android-chrome-512x512.png differ diff --git a/web/icons/android-chrome-96x96.png b/web/icons/android-chrome-96x96.png new file mode 100644 index 0000000000..07a99ab2ec Binary files /dev/null and b/web/icons/android-chrome-96x96.png differ diff --git a/web/icons/apple-touch-icon.png b/web/icons/apple-touch-icon.png new file mode 100644 index 0000000000..aef3525368 Binary files /dev/null and b/web/icons/apple-touch-icon.png differ diff --git a/web/icons/browserconfig.xml b/web/icons/browserconfig.xml new file mode 100644 index 0000000000..ac86b84264 --- /dev/null +++ b/web/icons/browserconfig.xml @@ -0,0 +1,9 @@ + + + + + + #0175c2 + + + diff --git a/web/icons/logo_icon.png b/web/icons/logo_icon.png new file mode 100644 index 0000000000..445e2ca862 Binary files /dev/null and b/web/icons/logo_icon.png differ diff --git a/web/icons/mstile-150x150.png b/web/icons/mstile-150x150.png new file mode 100644 index 0000000000..e937d9290f Binary files /dev/null and b/web/icons/mstile-150x150.png differ diff --git a/web/icons/safari-pinned-tab.svg b/web/icons/safari-pinned-tab.svg new file mode 100644 index 0000000000..ba0af486d3 --- /dev/null +++ b/web/icons/safari-pinned-tab.svg @@ -0,0 +1,42 @@ + + + + +Created by potrace 1.14, written by Peter Selinger 2001-2017 + + + + + + diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000000..7aa020ed53 --- /dev/null +++ b/web/index.html @@ -0,0 +1,12 @@ +NB! This file is generated automatically as part of the build process in `./packages/komodo_wallet_build_transformer`. +Do not edit it manually. + +If you need to manually rebuild the file, you can run the following command: + +```bash +npm install && npm run build +``` + +If you are seeing this message in your browser, it means that the build steps have not been run. Please run `flutter +clean && flutter pub get` and then try again. If issues persist, please ensure you are using the latest Flutter SDK and +have set up your environment correctly as per our docs. \ No newline at end of file diff --git a/web/manifest.json b/web/manifest.json new file mode 100644 index 0000000000..b9289c5f76 --- /dev/null +++ b/web/manifest.json @@ -0,0 +1,29 @@ +{ + "name": "Komodo Web Wallet", + "short_name": "KomodoWebWallet", + "start_url": ".", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#ffffff", + "description": "Non-Custodial Multi-Coin Wallet & DEX.", + "orientation": "portrait-primary", + "prefer_related_applications": false, + "related_applications": [ + { + "platform": "play", + "url": "https://play.google.com/store/apps/details?id=com.komodoplatform.atomicdex" + } + ], + "icons": [ + { + "src": "/icons/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + }, + { + "src": "/icons/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + } + ] +} \ No newline at end of file diff --git a/web/robots.txt b/web/robots.txt new file mode 100644 index 0000000000..c2a49f4fb8 --- /dev/null +++ b/web/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / diff --git a/web/src/index.js b/web/src/index.js new file mode 100644 index 0000000000..1e6ae09152 --- /dev/null +++ b/web/src/index.js @@ -0,0 +1,91 @@ +// @ts-check +// Use ES module import syntax to import functionality from the module +// that we have compiled. +// +// Note that the `default` import is an initialization function which +// will "boot" the module and make it ready to use. Currently browsers +// don't support natively imported WebAssembly as an ES module, but +// eventually the manual initialization won't be required! +import init, { LogLevel, Mm2MainErr, Mm2RpcErr, mm2_main, mm2_main_status, mm2_rpc, mm2_version } from "./mm2/kdflib.js"; +import './services/theme_checker/theme_checker.js'; +import zip from './services/zip/zip.js'; + +const LOG_LEVEL = LogLevel.Info; + +// Loads the wasm file, so we use the +// default export to inform it where the wasm file is located on the +// server, and then we wait on the returned promise to wait for the +// wasm to be loaded. +// @ts-ignore +window.init_wasm = async function () { + await init(); +} + +// @ts-ignore +window.run_mm2 = async function (params, handle_log) { + let config = { + conf: JSON.parse(params), + log_level: LOG_LEVEL, + } + + // run an MM2 instance + try { + mm2_main(config, handle_log); + } catch (e) { + switch (e) { + case Mm2MainErr.AlreadyRuns: + alert("MM2 already runs, please wait..."); + break; + case Mm2MainErr.InvalidParams: + alert("Invalid config"); + break; + case Mm2MainErr.NoCoinsInConf: + alert("No 'coins' field in config"); + break; + default: + alert(`Oops: ${e}`); + break; + } + handle_log(LogLevel.Error, JSON.stringify(e)) + } +} +// @ts-ignore +window.rpc_request = async function (request_js) { + try { + let reqJson = JSON.parse(request_js); + const response = await mm2_rpc(reqJson); + return JSON.stringify(response); + } catch (e) { + switch (e) { + case Mm2RpcErr.NotRunning: + alert("MM2 is not running yet"); + break; + case Mm2RpcErr.InvalidPayload: + alert(`Invalid payload: ${request_js}`); + break; + case Mm2RpcErr.InternalError: + alert(`An MM2 internal error`); + break; + default: + alert(`Unexpected error: ${e}`); + break; + } + throw(e); + } +} + + +// @ts-ignore +window.mm2_version = () => mm2_version().result; + +// @ts-ignore +window.mm2_status = function () { + return mm2_main_status(); +} +// @ts-ignore +window.reload_page = function () { + window.location.reload(); +} + +// @ts-ignore +window.zip_encode = zip.encode; \ No newline at end of file diff --git a/web/src/services/theme_checker/theme_checker.js b/web/src/services/theme_checker/theme_checker.js new file mode 100644 index 0000000000..286855f5c7 --- /dev/null +++ b/web/src/services/theme_checker/theme_checker.js @@ -0,0 +1,36 @@ +(() => { + let themeIndex = +localStorage.getItem('flutter.themeMode'); + themeIndex = themeIndex === 0 ? 2 : themeIndex; + if (themeIndex === 2) { + const loading = document.querySelector('.loading'); + if (loading != null) { loading.classList.add('loading_dark'); } + } + const lightBackgroundColor = '#f5f5f5'; + const darkBackgroundColor = '#121420'; + const headTag = document.querySelector('head'); + const lightThemeColorMetaTag = document.createElement('meta'); + lightThemeColorMetaTag.setAttribute('media', '(prefers-color-scheme: light)'); + lightThemeColorMetaTag.setAttribute('name', 'theme-color'); + const darkThemeColorMetaTag = document.createElement('meta'); + darkThemeColorMetaTag.setAttribute('media', '(prefers-color-scheme: dark)'); + darkThemeColorMetaTag.setAttribute('name', 'theme-color'); + + if (themeIndex === 1) { + lightThemeColorMetaTag.setAttribute('content', lightBackgroundColor); + darkThemeColorMetaTag.setAttribute('content', lightBackgroundColor); + } else if (themeIndex === 2) { + lightThemeColorMetaTag.setAttribute('content', darkBackgroundColor); + darkThemeColorMetaTag.setAttribute('content', darkBackgroundColor); + } + headTag.appendChild(lightThemeColorMetaTag); + headTag.appendChild(darkThemeColorMetaTag); + window.changeTheme = (themeIndex) => { + if (themeIndex === 1) { + lightThemeColorMetaTag.setAttribute('content', lightBackgroundColor); + darkThemeColorMetaTag.setAttribute('content', lightBackgroundColor); + } else if (themeIndex === 2) { + lightThemeColorMetaTag.setAttribute('content', darkBackgroundColor); + darkThemeColorMetaTag.setAttribute('content', darkBackgroundColor); + } + } +})(); \ No newline at end of file diff --git a/web/src/services/zip/zip.js b/web/src/services/zip/zip.js new file mode 100644 index 0000000000..7d4acbd024 --- /dev/null +++ b/web/src/services/zip/zip.js @@ -0,0 +1,27 @@ +// @ts-check +class Zip { + constructor() { + this.encode = this.encode.bind(this); + this.worker = new Worker(new URL('./zip_worker.js', import.meta.url)); + } + + /** + * @param {String} fileName + * @param {String} fileContent + * @returns {Promise} + */ + async encode(fileName, fileContent) { + /** @type {Worker} */ + return new Promise((resolve, reject) => { + this.worker.postMessage({ fileContent, fileName}); + this.worker.onmessage = (event) => resolve(event.data); + this.worker.onerror = (e) => reject(e); + }).catch((e) => { + return null; + }); + } + } + /** @type {Zip} */ + const zip = new Zip(); + + export default zip; \ No newline at end of file diff --git a/web/src/services/zip/zip_worker.js b/web/src/services/zip/zip_worker.js new file mode 100644 index 0000000000..d6d74824b9 --- /dev/null +++ b/web/src/services/zip/zip_worker.js @@ -0,0 +1,19 @@ +// @ts-check +import 'jszip'; +import JSZip from 'jszip'; +/** @param {MessageEvent<{fileContent: String, fileName: String}>} event */ +onmessage = async (event) => { + const zip = new JSZip(); + const textContent = event.data.fileContent; + const fileName = event.data.fileName; + + zip.file(fileName, textContent); + const compressed = await zip.generateAsync({ + type: "base64", + compression: "DEFLATE", + compressionOptions: { + level: 9 + }, + }); + postMessage(compressed); +}; \ No newline at end of file diff --git a/web/thumbnail.jpg b/web/thumbnail.jpg new file mode 100644 index 0000000000..e872cc0049 Binary files /dev/null and b/web/thumbnail.jpg differ diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000000..df340f2a9f --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,33 @@ +const path = require('path'); +const HtmlWebpackPlugin = require('html-webpack-plugin'); +const { CleanWebpackPlugin } = require('clean-webpack-plugin'); + +module.exports = (env, argv) => { + return { + entry: './web/src/index.js', + output: { + path: path.resolve(__dirname, 'web/dist'), + filename: 'script.[contenthash].js', + }, + plugins: [ + new HtmlWebpackPlugin({ + template: path.resolve(__dirname, 'web/assets/template.html'), + filename: '../index.html', + warning: '\n\n', + + minify: { + collapseWhitespace: true, + keepClosingSlash: true, + removeRedundantAttributes: true, + removeScriptTypeAttributes: true, + removeStyleLinkTypeAttributes: true, + useShortDoctype: true, + minifyCSS: true, + minifyJS: true, + }, + }), + new CleanWebpackPlugin(), + ], + devtool: argv.mode == 'development' ? 'source-map' : false, + } +}; \ No newline at end of file diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 0000000000..4532e05119 --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,19 @@ +flutter/ephemeral/ + +# Visual Studio user-specific files. +*.suo +*.user +*.userosscache +*.sln.docstates + +# Visual Studio build-related files. +x64/ +x86/ + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +runner/exe/*.dll \ No newline at end of file diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt new file mode 100644 index 0000000000..7b16ba5ed6 --- /dev/null +++ b/windows/CMakeLists.txt @@ -0,0 +1,101 @@ +# Project-level configuration. +cmake_minimum_required(VERSION 3.14) +project(komodowallet LANGUAGES CXX) + +# The name of the executable created for the application. Change this to change +# the on-disk name of your application. +set(BINARY_NAME "komodowallet") + +# Explicitly opt in to modern CMake behaviors to avoid warnings with recent +# versions of CMake. +cmake_policy(SET CMP0063 NEW) + +# Define build configuration option. +get_property(IS_MULTICONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) +if(IS_MULTICONFIG) + set(CMAKE_CONFIGURATION_TYPES "Debug;Profile;Release" + CACHE STRING "" FORCE) +else() + if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE "Debug" CACHE + STRING "Flutter build mode" FORCE) + set_property(CACHE CMAKE_BUILD_TYPE PROPERTY STRINGS + "Debug" "Profile" "Release") + endif() +endif() +# Define settings for the Profile build mode. +set(CMAKE_EXE_LINKER_FLAGS_PROFILE "${CMAKE_EXE_LINKER_FLAGS_RELEASE}") +set(CMAKE_SHARED_LINKER_FLAGS_PROFILE "${CMAKE_SHARED_LINKER_FLAGS_RELEASE}") +set(CMAKE_C_FLAGS_PROFILE "${CMAKE_C_FLAGS_RELEASE}") +set(CMAKE_CXX_FLAGS_PROFILE "${CMAKE_CXX_FLAGS_RELEASE}") + +# Use Unicode for all projects. +add_definitions(-DUNICODE -D_UNICODE) + +# Compilation settings that should be applied to most targets. +# +# Be cautious about adding new options here, as plugins use this function by +# default. In most cases, you should add new options to specific targets instead +# of modifying this function. +function(APPLY_STANDARD_SETTINGS TARGET) + target_compile_features(${TARGET} PUBLIC cxx_std_17) + target_compile_options(${TARGET} PRIVATE /W4 /WX /wd"4100") + target_compile_options(${TARGET} PRIVATE /EHsc) + target_compile_definitions(${TARGET} PRIVATE "_HAS_EXCEPTIONS=0") + target_compile_definitions(${TARGET} PRIVATE "$<$:_DEBUG>") +endfunction() + +# Flutter library and tool build rules. +set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") +add_subdirectory(${FLUTTER_MANAGED_DIR}) + +# Application build; see runner/CMakeLists.txt. +add_subdirectory("runner") + +# Generated plugin build rules, which manage building the plugins and adding +# them to the application. +include(flutter/generated_plugins.cmake) + + +# === Installation === +# Support files are copied into place next to the executable, so that it can +# run in place. This is done instead of making a separate bundle (as on Linux) +# so that building and running from within Visual Studio will work. +set(BUILD_BUNDLE_DIR "$") +# Make the "install" step default, as it's required to run. +set(CMAKE_VS_INCLUDE_INSTALL_TO_DEFAULT_BUILD 1) +if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT) + set(CMAKE_INSTALL_PREFIX "${BUILD_BUNDLE_DIR}" CACHE PATH "..." FORCE) +endif() + +set(INSTALL_BUNDLE_DATA_DIR "${CMAKE_INSTALL_PREFIX}/data") +set(INSTALL_BUNDLE_LIB_DIR "${CMAKE_INSTALL_PREFIX}") + +install(TARGETS ${BINARY_NAME} RUNTIME DESTINATION "${CMAKE_INSTALL_PREFIX}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_ICU_DATA_FILE}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + COMPONENT Runtime) + +install(FILES "${FLUTTER_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) + +if(PLUGIN_BUNDLED_LIBRARIES) + install(FILES "${PLUGIN_BUNDLED_LIBRARIES}" + DESTINATION "${INSTALL_BUNDLE_LIB_DIR}" + COMPONENT Runtime) +endif() + +# Fully re-copy the assets directory on each build to avoid having stale files +# from a previous install. +set(FLUTTER_ASSET_DIR_NAME "flutter_assets") +install(CODE " + file(REMOVE_RECURSE \"${INSTALL_BUNDLE_DATA_DIR}/${FLUTTER_ASSET_DIR_NAME}\") + " COMPONENT Runtime) +install(DIRECTORY "${PROJECT_BUILD_DIR}/${FLUTTER_ASSET_DIR_NAME}" + DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" COMPONENT Runtime) + +# Install the AOT library on non-Debug builds only. +install(FILES "${AOT_LIBRARY}" DESTINATION "${INSTALL_BUNDLE_DATA_DIR}" + CONFIGURATIONS Profile;Release + COMPONENT Runtime) diff --git a/windows/flutter/CMakeLists.txt b/windows/flutter/CMakeLists.txt new file mode 100644 index 0000000000..3f71e1736d --- /dev/null +++ b/windows/flutter/CMakeLists.txt @@ -0,0 +1,104 @@ +# This file controls Flutter-level build steps. It should not be edited. +cmake_minimum_required(VERSION 3.14) + +set(EPHEMERAL_DIR "${CMAKE_CURRENT_SOURCE_DIR}/ephemeral") + +# Configuration provided via flutter tool. +include(${EPHEMERAL_DIR}/generated_config.cmake) + +# TODO: Move the rest of this into files in ephemeral. See +# https://github.com/flutter/flutter/issues/57146. +set(WRAPPER_ROOT "${EPHEMERAL_DIR}/cpp_client_wrapper") + +# === Flutter Library === +set(FLUTTER_LIBRARY "${EPHEMERAL_DIR}/flutter_windows.dll") + +# Published to parent scope for install step. +set(FLUTTER_LIBRARY ${FLUTTER_LIBRARY} PARENT_SCOPE) +set(FLUTTER_ICU_DATA_FILE "${EPHEMERAL_DIR}/icudtl.dat" PARENT_SCOPE) +set(PROJECT_BUILD_DIR "${PROJECT_DIR}/build/" PARENT_SCOPE) +set(AOT_LIBRARY "${PROJECT_DIR}/build/windows/app.so" PARENT_SCOPE) + +list(APPEND FLUTTER_LIBRARY_HEADERS + "flutter_export.h" + "flutter_windows.h" + "flutter_messenger.h" + "flutter_plugin_registrar.h" + "flutter_texture_registrar.h" +) +list(TRANSFORM FLUTTER_LIBRARY_HEADERS PREPEND "${EPHEMERAL_DIR}/") +add_library(flutter INTERFACE) +target_include_directories(flutter INTERFACE + "${EPHEMERAL_DIR}" +) +target_link_libraries(flutter INTERFACE "${FLUTTER_LIBRARY}.lib") +add_dependencies(flutter flutter_assemble) + +# === Wrapper === +list(APPEND CPP_WRAPPER_SOURCES_CORE + "core_implementations.cc" + "standard_codec.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_CORE PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_PLUGIN + "plugin_registrar.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_PLUGIN PREPEND "${WRAPPER_ROOT}/") +list(APPEND CPP_WRAPPER_SOURCES_APP + "flutter_engine.cc" + "flutter_view_controller.cc" +) +list(TRANSFORM CPP_WRAPPER_SOURCES_APP PREPEND "${WRAPPER_ROOT}/") + +# Wrapper sources needed for a plugin. +add_library(flutter_wrapper_plugin STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} +) +apply_standard_settings(flutter_wrapper_plugin) +set_target_properties(flutter_wrapper_plugin PROPERTIES + POSITION_INDEPENDENT_CODE ON) +set_target_properties(flutter_wrapper_plugin PROPERTIES + CXX_VISIBILITY_PRESET hidden) +target_link_libraries(flutter_wrapper_plugin PUBLIC flutter) +target_include_directories(flutter_wrapper_plugin PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_plugin flutter_assemble) + +# Wrapper sources needed for the runner. +add_library(flutter_wrapper_app STATIC + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_APP} +) +apply_standard_settings(flutter_wrapper_app) +target_link_libraries(flutter_wrapper_app PUBLIC flutter) +target_include_directories(flutter_wrapper_app PUBLIC + "${WRAPPER_ROOT}/include" +) +add_dependencies(flutter_wrapper_app flutter_assemble) + +# === Flutter tool backend === +# _phony_ is a non-existent file to force this command to run every time, +# since currently there's no way to get a full input/output list from the +# flutter tool. +set(PHONY_OUTPUT "${CMAKE_CURRENT_BINARY_DIR}/_phony_") +set_source_files_properties("${PHONY_OUTPUT}" PROPERTIES SYMBOLIC TRUE) +add_custom_command( + OUTPUT ${FLUTTER_LIBRARY} ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} + ${PHONY_OUTPUT} + COMMAND ${CMAKE_COMMAND} -E env + ${FLUTTER_TOOL_ENVIRONMENT} + "${FLUTTER_ROOT}/packages/flutter_tools/bin/tool_backend.bat" + windows-x64 $ + VERBATIM +) +add_custom_target(flutter_assemble DEPENDS + "${FLUTTER_LIBRARY}" + ${FLUTTER_LIBRARY_HEADERS} + ${CPP_WRAPPER_SOURCES_CORE} + ${CPP_WRAPPER_SOURCES_PLUGIN} + ${CPP_WRAPPER_SOURCES_APP} +) diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000000..3e41859bd6 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.cc @@ -0,0 +1,26 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include +#include +#include + +void RegisterPlugins(flutter::PluginRegistry* registry) { + DesktopWebviewWindowPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("DesktopWebviewWindowPlugin")); + FirebaseCorePluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); + SharePlusWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); + UrlLauncherWindowsRegisterWithRegistrar( + registry->GetRegistrarForPlugin("UrlLauncherWindows")); + WindowSizePluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("WindowSizePlugin")); +} diff --git a/windows/flutter/generated_plugin_registrant.h b/windows/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000000..dc139d85a9 --- /dev/null +++ b/windows/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void RegisterPlugins(flutter::PluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake new file mode 100644 index 0000000000..b36f8e33be --- /dev/null +++ b/windows/flutter/generated_plugins.cmake @@ -0,0 +1,28 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + desktop_webview_window + firebase_core + share_plus + url_launcher_windows + window_size +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/windows plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/windows plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/windows/runner/CMakeLists.txt b/windows/runner/CMakeLists.txt new file mode 100644 index 0000000000..dced3b4adf --- /dev/null +++ b/windows/runner/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.14) +project(runner LANGUAGES CXX) + +# Define the application target. To change its name, change BINARY_NAME in the +# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer +# work. +# +# Any new source files that you add to the application should be added here. +add_executable(${BINARY_NAME} WIN32 + "flutter_window.cpp" + "main.cpp" + "utils.cpp" + "win32_window.cpp" + "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" + "Runner.rc" + "runner.exe.manifest" +) + +add_custom_command(TARGET ${BINARY_NAME} POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_CURRENT_SOURCE_DIR}/exe/mm2.exe" + "$/mm2.exe") + +# Apply the standard set of build settings. This can be removed for applications +# that need different build settings. +apply_standard_settings(${BINARY_NAME}) + +# Add preprocessor definitions for the build version. +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION=\"${FLUTTER_VERSION}\"") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MAJOR=${FLUTTER_VERSION_MAJOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_MINOR=${FLUTTER_VERSION_MINOR}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_PATCH=${FLUTTER_VERSION_PATCH}") +target_compile_definitions(${BINARY_NAME} PRIVATE "FLUTTER_VERSION_BUILD=${FLUTTER_VERSION_BUILD}") + +# Disable Windows macros that collide with C++ standard library functions. +target_compile_definitions(${BINARY_NAME} PRIVATE "NOMINMAX") + +# Add dependency libraries and include directories. Add any application-specific +# dependencies here. +target_link_libraries(${BINARY_NAME} PRIVATE flutter flutter_wrapper_app) +target_link_libraries(${BINARY_NAME} PRIVATE "dwmapi.lib") +target_include_directories(${BINARY_NAME} PRIVATE "${CMAKE_SOURCE_DIR}") + +# Run the Flutter tool portions of the build. This must not be removed. +add_dependencies(${BINARY_NAME} flutter_assemble) diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc new file mode 100644 index 0000000000..beb0669525 --- /dev/null +++ b/windows/runner/Runner.rc @@ -0,0 +1,121 @@ +// Microsoft Visual C++ generated resource script. +// +#pragma code_page(65001) +#include "resource.h" + +#define APSTUDIO_READONLY_SYMBOLS +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 2 resource. +// +#include "winres.h" + +///////////////////////////////////////////////////////////////////////////// +#undef APSTUDIO_READONLY_SYMBOLS + +///////////////////////////////////////////////////////////////////////////// +// English (United States) resources + +#if !defined(AFX_RESOURCE_DLL) || defined(AFX_TARG_ENU) +LANGUAGE LANG_ENGLISH, SUBLANG_ENGLISH_US + +#ifdef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// TEXTINCLUDE +// + +1 TEXTINCLUDE +BEGIN + "resource.h\0" +END + +2 TEXTINCLUDE +BEGIN + "#include ""winres.h""\r\n" + "\0" +END + +3 TEXTINCLUDE +BEGIN + "\r\n" + "\0" +END + +#endif // APSTUDIO_INVOKED + + +///////////////////////////////////////////////////////////////////////////// +// +// Icon +// + +// Icon with lowest ID value placed first to ensure application icon +// remains consistent on all systems. +IDI_APP_ICON ICON "resources\\app_icon.ico" + + +///////////////////////////////////////////////////////////////////////////// +// +// Version +// + +#if defined(FLUTTER_VERSION_MAJOR) && defined(FLUTTER_VERSION_MINOR) && defined(FLUTTER_VERSION_PATCH) && defined(FLUTTER_VERSION_BUILD) +#define VERSION_AS_NUMBER FLUTTER_VERSION_MAJOR,FLUTTER_VERSION_MINOR,FLUTTER_VERSION_PATCH,FLUTTER_VERSION_BUILD +#else +#define VERSION_AS_NUMBER 1,0,0,0 +#endif + +#if defined(FLUTTER_VERSION) +#define VERSION_AS_STRING FLUTTER_VERSION +#else +#define VERSION_AS_STRING "1.0.0" +#endif + +VS_VERSION_INFO VERSIONINFO + FILEVERSION VERSION_AS_NUMBER + PRODUCTVERSION VERSION_AS_NUMBER + FILEFLAGSMASK VS_FFI_FILEFLAGSMASK +#ifdef _DEBUG + FILEFLAGS VS_FF_DEBUG +#else + FILEFLAGS 0x0L +#endif + FILEOS VOS__WINDOWS32 + FILETYPE VFT_APP + FILESUBTYPE 0x0L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904e4" + BEGIN + VALUE "CompanyName", "com.komodo" "\0" + VALUE "FileDescription", "Komodo Wallet" "\0" + VALUE "FileVersion", VERSION_AS_STRING "\0" + VALUE "InternalName", "komodowallet" "\0" + VALUE "LegalCopyright", "Copyright (C) 2023 com.komodo. All rights reserved." "\0" + VALUE "OriginalFilename", "komodowallet.exe" "\0" + VALUE "ProductName", "Komodo Wallet" "\0" + VALUE "ProductVersion", VERSION_AS_STRING "\0" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1252 + END +END + +#endif // English (United States) resources +///////////////////////////////////////////////////////////////////////////// + + + +#ifndef APSTUDIO_INVOKED +///////////////////////////////////////////////////////////////////////////// +// +// Generated from the TEXTINCLUDE 3 resource. +// + + +///////////////////////////////////////////////////////////////////////////// +#endif // not APSTUDIO_INVOKED diff --git a/windows/runner/exe/.gitkeep b/windows/runner/exe/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/windows/runner/flutter_window.cpp b/windows/runner/flutter_window.cpp new file mode 100644 index 0000000000..490813deb2 --- /dev/null +++ b/windows/runner/flutter_window.cpp @@ -0,0 +1,66 @@ +#include "flutter_window.h" + +#include + +#include "flutter/generated_plugin_registrant.h" + +FlutterWindow::FlutterWindow(const flutter::DartProject& project) + : project_(project) {} + +FlutterWindow::~FlutterWindow() {} + +bool FlutterWindow::OnCreate() { + if (!Win32Window::OnCreate()) { + return false; + } + + RECT frame = GetClientArea(); + + // The size here must match the window dimensions to avoid unnecessary surface + // creation / destruction in the startup path. + flutter_controller_ = std::make_unique( + frame.right - frame.left, frame.bottom - frame.top, project_); + // Ensure that basic setup of the controller was successful. + if (!flutter_controller_->engine() || !flutter_controller_->view()) { + return false; + } + RegisterPlugins(flutter_controller_->engine()); + SetChildContent(flutter_controller_->view()->GetNativeWindow()); + + flutter_controller_->engine()->SetNextFrameCallback([&]() { + this->Show(); + }); + + return true; +} + +void FlutterWindow::OnDestroy() { + if (flutter_controller_) { + flutter_controller_ = nullptr; + } + + Win32Window::OnDestroy(); +} + +LRESULT +FlutterWindow::MessageHandler(HWND hwnd, UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + // Give Flutter, including plugins, an opportunity to handle window messages. + if (flutter_controller_) { + std::optional result = + flutter_controller_->HandleTopLevelWindowProc(hwnd, message, wparam, + lparam); + if (result) { + return *result; + } + } + + switch (message) { + case WM_FONTCHANGE: + flutter_controller_->engine()->ReloadSystemFonts(); + break; + } + + return Win32Window::MessageHandler(hwnd, message, wparam, lparam); +} diff --git a/windows/runner/flutter_window.h b/windows/runner/flutter_window.h new file mode 100644 index 0000000000..28c23839b9 --- /dev/null +++ b/windows/runner/flutter_window.h @@ -0,0 +1,33 @@ +#ifndef RUNNER_FLUTTER_WINDOW_H_ +#define RUNNER_FLUTTER_WINDOW_H_ + +#include +#include + +#include + +#include "win32_window.h" + +// A window that does nothing but host a Flutter view. +class FlutterWindow : public Win32Window { + public: + // Creates a new FlutterWindow hosting a Flutter view running |project|. + explicit FlutterWindow(const flutter::DartProject& project); + virtual ~FlutterWindow(); + + protected: + // Win32Window: + bool OnCreate() override; + void OnDestroy() override; + LRESULT MessageHandler(HWND window, UINT const message, WPARAM const wparam, + LPARAM const lparam) noexcept override; + + private: + // The project to run. + flutter::DartProject project_; + + // The Flutter instance hosted by this window. + std::unique_ptr flutter_controller_; +}; + +#endif // RUNNER_FLUTTER_WINDOW_H_ diff --git a/windows/runner/main.cpp b/windows/runner/main.cpp new file mode 100644 index 0000000000..c930171ac7 --- /dev/null +++ b/windows/runner/main.cpp @@ -0,0 +1,43 @@ +#include +#include +#include + +#include "flutter_window.h" +#include "utils.h" + +int APIENTRY wWinMain(_In_ HINSTANCE instance, _In_opt_ HINSTANCE prev, + _In_ wchar_t *command_line, _In_ int show_command) { + // Attach to console when present (e.g., 'flutter run') or create a + // new console when running with a debugger. + if (!::AttachConsole(ATTACH_PARENT_PROCESS) && ::IsDebuggerPresent()) { + CreateAndAttachConsole(); + } + + // Initialize COM, so that it is available for use in the library and/or + // plugins. + ::CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED); + + flutter::DartProject project(L"data"); + + std::vector command_line_arguments = + GetCommandLineArguments(); + + project.set_dart_entrypoint_arguments(std::move(command_line_arguments)); + + FlutterWindow window(project); + Win32Window::Point origin(10, 10); + Win32Window::Size size(1280, 720); + if (!window.Create(L"komodowallet", origin, size)) { + return EXIT_FAILURE; + } + window.SetQuitOnClose(true); + + ::MSG msg; + while (::GetMessage(&msg, nullptr, 0, 0)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + } + + ::CoUninitialize(); + return EXIT_SUCCESS; +} diff --git a/windows/runner/resource.h b/windows/runner/resource.h new file mode 100644 index 0000000000..ddc7f3efc0 --- /dev/null +++ b/windows/runner/resource.h @@ -0,0 +1,16 @@ +//{{NO_DEPENDENCIES}} +// Microsoft Visual C++ generated include file. +// Used by Runner.rc +// +#define IDI_APP_ICON 101 + +// Next default values for new objects +// +#ifdef APSTUDIO_INVOKED +#ifndef APSTUDIO_READONLY_SYMBOLS +#define _APS_NEXT_RESOURCE_VALUE 102 +#define _APS_NEXT_COMMAND_VALUE 40001 +#define _APS_NEXT_CONTROL_VALUE 1001 +#define _APS_NEXT_SYMED_VALUE 101 +#endif +#endif diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico new file mode 100644 index 0000000000..37f54d367a Binary files /dev/null and b/windows/runner/resources/app_icon.ico differ diff --git a/windows/runner/runner.exe.manifest b/windows/runner/runner.exe.manifest new file mode 100644 index 0000000000..157e871fe8 --- /dev/null +++ b/windows/runner/runner.exe.manifest @@ -0,0 +1,20 @@ + + + + + PerMonitorV2 + + + + + + + + + + + + + + + diff --git a/windows/runner/utils.cpp b/windows/runner/utils.cpp new file mode 100644 index 0000000000..92ed54721f --- /dev/null +++ b/windows/runner/utils.cpp @@ -0,0 +1,64 @@ +#include "utils.h" + +#include +#include +#include +#include + +#include + +void CreateAndAttachConsole() { + if (::AllocConsole()) { + FILE *unused; + if (freopen_s(&unused, "CONOUT$", "w", stdout)) { + _dup2(_fileno(stdout), 1); + } + if (freopen_s(&unused, "CONOUT$", "w", stderr)) { + _dup2(_fileno(stdout), 2); + } + std::ios::sync_with_stdio(); + FlutterDesktopResyncOutputStreams(); + } +} + +std::vector GetCommandLineArguments() { + // Convert the UTF-16 command line arguments to UTF-8 for the Engine to use. + int argc; + wchar_t** argv = ::CommandLineToArgvW(::GetCommandLineW(), &argc); + if (argv == nullptr) { + return std::vector(); + } + + std::vector command_line_arguments; + + // Skip the first argument as it's the binary name. + for (int i = 1; i < argc; i++) { + command_line_arguments.push_back(Utf8FromUtf16(argv[i])); + } + + ::LocalFree(argv); + + return command_line_arguments; +} + +std::string Utf8FromUtf16(const wchar_t* utf16_string) { + if (utf16_string == nullptr) { + return std::string(); + } + int target_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, nullptr, 0, nullptr, nullptr); + std::string utf8_string; + if (target_length == 0 || target_length > utf8_string.max_size()) { + return utf8_string; + } + utf8_string.resize(target_length); + int converted_length = ::WideCharToMultiByte( + CP_UTF8, WC_ERR_INVALID_CHARS, utf16_string, + -1, utf8_string.data(), + target_length, nullptr, nullptr); + if (converted_length == 0) { + return std::string(); + } + return utf8_string; +} diff --git a/windows/runner/utils.h b/windows/runner/utils.h new file mode 100644 index 0000000000..3f0e05cba3 --- /dev/null +++ b/windows/runner/utils.h @@ -0,0 +1,19 @@ +#ifndef RUNNER_UTILS_H_ +#define RUNNER_UTILS_H_ + +#include +#include + +// Creates a console for the process, and redirects stdout and stderr to +// it for both the runner and the Flutter library. +void CreateAndAttachConsole(); + +// Takes a null-terminated wchar_t* encoded in UTF-16 and returns a std::string +// encoded in UTF-8. Returns an empty std::string on failure. +std::string Utf8FromUtf16(const wchar_t* utf16_string); + +// Gets the command line arguments passed in as a std::vector, +// encoded in UTF-8. Returns an empty std::vector on failure. +std::vector GetCommandLineArguments(); + +#endif // RUNNER_UTILS_H_ diff --git a/windows/runner/win32_window.cpp b/windows/runner/win32_window.cpp new file mode 100644 index 0000000000..2f91cbb360 --- /dev/null +++ b/windows/runner/win32_window.cpp @@ -0,0 +1,288 @@ +#include "win32_window.h" + +#include +#include + +#include "resource.h" + +namespace { + +/// Window attribute that enables dark mode window decorations. +/// +/// Redefined in case the developer's machine has a Windows SDK older than +/// version 10.0.22000.0. +/// See: https://docs.microsoft.com/windows/win32/api/dwmapi/ne-dwmapi-dwmwindowattribute +#ifndef DWMWA_USE_IMMERSIVE_DARK_MODE +#define DWMWA_USE_IMMERSIVE_DARK_MODE 20 +#endif + +constexpr const wchar_t kWindowClassName[] = L"FLUTTER_RUNNER_WIN32_WINDOW"; + +/// Registry key for app theme preference. +/// +/// A value of 0 indicates apps should use dark mode. A non-zero or missing +/// value indicates apps should use light mode. +constexpr const wchar_t kGetPreferredBrightnessRegKey[] = + L"Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize"; +constexpr const wchar_t kGetPreferredBrightnessRegValue[] = L"AppsUseLightTheme"; + +// The number of Win32Window objects that currently exist. +static int g_active_window_count = 0; + +using EnableNonClientDpiScaling = BOOL __stdcall(HWND hwnd); + +// Scale helper to convert logical scaler values to physical using passed in +// scale factor +int Scale(int source, double scale_factor) { + return static_cast(source * scale_factor); +} + +// Dynamically loads the |EnableNonClientDpiScaling| from the User32 module. +// This API is only needed for PerMonitor V1 awareness mode. +void EnableFullDpiSupportIfAvailable(HWND hwnd) { + HMODULE user32_module = LoadLibraryA("User32.dll"); + if (!user32_module) { + return; + } + auto enable_non_client_dpi_scaling = + reinterpret_cast( + GetProcAddress(user32_module, "EnableNonClientDpiScaling")); + if (enable_non_client_dpi_scaling != nullptr) { + enable_non_client_dpi_scaling(hwnd); + } + FreeLibrary(user32_module); +} + +} // namespace + +// Manages the Win32Window's window class registration. +class WindowClassRegistrar { + public: + ~WindowClassRegistrar() = default; + + // Returns the singleton registar instance. + static WindowClassRegistrar* GetInstance() { + if (!instance_) { + instance_ = new WindowClassRegistrar(); + } + return instance_; + } + + // Returns the name of the window class, registering the class if it hasn't + // previously been registered. + const wchar_t* GetWindowClass(); + + // Unregisters the window class. Should only be called if there are no + // instances of the window. + void UnregisterWindowClass(); + + private: + WindowClassRegistrar() = default; + + static WindowClassRegistrar* instance_; + + bool class_registered_ = false; +}; + +WindowClassRegistrar* WindowClassRegistrar::instance_ = nullptr; + +const wchar_t* WindowClassRegistrar::GetWindowClass() { + if (!class_registered_) { + WNDCLASS window_class{}; + window_class.hCursor = LoadCursor(nullptr, IDC_ARROW); + window_class.lpszClassName = kWindowClassName; + window_class.style = CS_HREDRAW | CS_VREDRAW; + window_class.cbClsExtra = 0; + window_class.cbWndExtra = 0; + window_class.hInstance = GetModuleHandle(nullptr); + window_class.hIcon = + LoadIcon(window_class.hInstance, MAKEINTRESOURCE(IDI_APP_ICON)); + window_class.hbrBackground = 0; + window_class.lpszMenuName = nullptr; + window_class.lpfnWndProc = Win32Window::WndProc; + RegisterClass(&window_class); + class_registered_ = true; + } + return kWindowClassName; +} + +void WindowClassRegistrar::UnregisterWindowClass() { + UnregisterClass(kWindowClassName, nullptr); + class_registered_ = false; +} + +Win32Window::Win32Window() { + ++g_active_window_count; +} + +Win32Window::~Win32Window() { + --g_active_window_count; + Destroy(); +} + +bool Win32Window::Create(const std::wstring& title, + const Point& origin, + const Size& size) { + Destroy(); + + const wchar_t* window_class = + WindowClassRegistrar::GetInstance()->GetWindowClass(); + + const POINT target_point = {static_cast(origin.x), + static_cast(origin.y)}; + HMONITOR monitor = MonitorFromPoint(target_point, MONITOR_DEFAULTTONEAREST); + UINT dpi = FlutterDesktopGetDpiForMonitor(monitor); + double scale_factor = dpi / 96.0; + + HWND window = CreateWindow( + window_class, title.c_str(), WS_OVERLAPPEDWINDOW, + Scale(origin.x, scale_factor), Scale(origin.y, scale_factor), + Scale(size.width, scale_factor), Scale(size.height, scale_factor), + nullptr, nullptr, GetModuleHandle(nullptr), this); + + if (!window) { + return false; + } + + UpdateTheme(window); + + return OnCreate(); +} + +bool Win32Window::Show() { + return ShowWindow(window_handle_, SW_SHOWNORMAL); +} + +// static +LRESULT CALLBACK Win32Window::WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + if (message == WM_NCCREATE) { + auto window_struct = reinterpret_cast(lparam); + SetWindowLongPtr(window, GWLP_USERDATA, + reinterpret_cast(window_struct->lpCreateParams)); + + auto that = static_cast(window_struct->lpCreateParams); + EnableFullDpiSupportIfAvailable(window); + that->window_handle_ = window; + } else if (Win32Window* that = GetThisFromHandle(window)) { + return that->MessageHandler(window, message, wparam, lparam); + } + + return DefWindowProc(window, message, wparam, lparam); +} + +LRESULT +Win32Window::MessageHandler(HWND hwnd, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept { + switch (message) { + case WM_DESTROY: + window_handle_ = nullptr; + Destroy(); + if (quit_on_close_) { + PostQuitMessage(0); + } + return 0; + + case WM_DPICHANGED: { + auto newRectSize = reinterpret_cast(lparam); + LONG newWidth = newRectSize->right - newRectSize->left; + LONG newHeight = newRectSize->bottom - newRectSize->top; + + SetWindowPos(hwnd, nullptr, newRectSize->left, newRectSize->top, newWidth, + newHeight, SWP_NOZORDER | SWP_NOACTIVATE); + + return 0; + } + case WM_SIZE: { + RECT rect = GetClientArea(); + if (child_content_ != nullptr) { + // Size and position the child window. + MoveWindow(child_content_, rect.left, rect.top, rect.right - rect.left, + rect.bottom - rect.top, TRUE); + } + return 0; + } + + case WM_ACTIVATE: + if (child_content_ != nullptr) { + SetFocus(child_content_); + } + return 0; + + case WM_DWMCOLORIZATIONCOLORCHANGED: + UpdateTheme(hwnd); + return 0; + } + + return DefWindowProc(window_handle_, message, wparam, lparam); +} + +void Win32Window::Destroy() { + OnDestroy(); + + if (window_handle_) { + DestroyWindow(window_handle_); + window_handle_ = nullptr; + } + if (g_active_window_count == 0) { + WindowClassRegistrar::GetInstance()->UnregisterWindowClass(); + } +} + +Win32Window* Win32Window::GetThisFromHandle(HWND const window) noexcept { + return reinterpret_cast( + GetWindowLongPtr(window, GWLP_USERDATA)); +} + +void Win32Window::SetChildContent(HWND content) { + child_content_ = content; + SetParent(content, window_handle_); + RECT frame = GetClientArea(); + + MoveWindow(content, frame.left, frame.top, frame.right - frame.left, + frame.bottom - frame.top, true); + + SetFocus(child_content_); +} + +RECT Win32Window::GetClientArea() { + RECT frame; + GetClientRect(window_handle_, &frame); + return frame; +} + +HWND Win32Window::GetHandle() { + return window_handle_; +} + +void Win32Window::SetQuitOnClose(bool quit_on_close) { + quit_on_close_ = quit_on_close; +} + +bool Win32Window::OnCreate() { + // No-op; provided for subclasses. + return true; +} + +void Win32Window::OnDestroy() { + // No-op; provided for subclasses. +} + +void Win32Window::UpdateTheme(HWND const window) { + DWORD light_mode; + DWORD light_mode_size = sizeof(light_mode); + LSTATUS result = RegGetValue(HKEY_CURRENT_USER, kGetPreferredBrightnessRegKey, + kGetPreferredBrightnessRegValue, + RRF_RT_REG_DWORD, nullptr, &light_mode, + &light_mode_size); + + if (result == ERROR_SUCCESS) { + BOOL enable_dark_mode = light_mode == 0; + DwmSetWindowAttribute(window, DWMWA_USE_IMMERSIVE_DARK_MODE, + &enable_dark_mode, sizeof(enable_dark_mode)); + } +} diff --git a/windows/runner/win32_window.h b/windows/runner/win32_window.h new file mode 100644 index 0000000000..90a704367f --- /dev/null +++ b/windows/runner/win32_window.h @@ -0,0 +1,102 @@ +#ifndef RUNNER_WIN32_WINDOW_H_ +#define RUNNER_WIN32_WINDOW_H_ + +#include + +#include +#include +#include + +// A class abstraction for a high DPI-aware Win32 Window. Intended to be +// inherited from by classes that wish to specialize with custom +// rendering and input handling +class Win32Window { + public: + struct Point { + unsigned int x; + unsigned int y; + Point(unsigned int x, unsigned int y) : x(x), y(y) {} + }; + + struct Size { + unsigned int width; + unsigned int height; + Size(unsigned int width, unsigned int height) + : width(width), height(height) {} + }; + + Win32Window(); + virtual ~Win32Window(); + + // Creates a win32 window with |title| that is positioned and sized using + // |origin| and |size|. New windows are created on the default monitor. Window + // sizes are specified to the OS in physical pixels, hence to ensure a + // consistent size this function will scale the inputted width and height as + // as appropriate for the default monitor. The window is invisible until + // |Show| is called. Returns true if the window was created successfully. + bool Create(const std::wstring& title, const Point& origin, const Size& size); + + // Show the current window. Returns true if the window was successfully shown. + bool Show(); + + // Release OS resources associated with window. + void Destroy(); + + // Inserts |content| into the window tree. + void SetChildContent(HWND content); + + // Returns the backing Window handle to enable clients to set icon and other + // window properties. Returns nullptr if the window has been destroyed. + HWND GetHandle(); + + // If true, closing this window will quit the application. + void SetQuitOnClose(bool quit_on_close); + + // Return a RECT representing the bounds of the current client area. + RECT GetClientArea(); + + protected: + // Processes and route salient window messages for mouse handling, + // size change and DPI. Delegates handling of these to member overloads that + // inheriting classes can handle. + virtual LRESULT MessageHandler(HWND window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Called when CreateAndShow is called, allowing subclass window-related + // setup. Subclasses should return false if setup fails. + virtual bool OnCreate(); + + // Called when Destroy is called. + virtual void OnDestroy(); + + private: + friend class WindowClassRegistrar; + + // OS callback called by message pump. Handles the WM_NCCREATE message which + // is passed when the non-client area is being created and enables automatic + // non-client DPI scaling so that the non-client area automatically + // responsponds to changes in DPI. All other messages are handled by + // MessageHandler. + static LRESULT CALLBACK WndProc(HWND const window, + UINT const message, + WPARAM const wparam, + LPARAM const lparam) noexcept; + + // Retrieves a class instance pointer for |window| + static Win32Window* GetThisFromHandle(HWND const window) noexcept; + + // Update the window frame's theme to match the system theme. + static void UpdateTheme(HWND const window); + + bool quit_on_close_ = false; + + // window handle for top level window. + HWND window_handle_ = nullptr; + + // window handle for hosted content. + HWND child_content_ = nullptr; +}; + +#endif // RUNNER_WIN32_WINDOW_H_