diff --git a/.github/workflows/_build.yml b/.github/workflows/_build.yml new file mode 100644 index 0000000..33b1ad3 --- /dev/null +++ b/.github/workflows/_build.yml @@ -0,0 +1,31 @@ +name: Build + +on: + workflow_call: + +jobs: + build: + name: Build ${{ matrix.target }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + target: [web] #TODO: Add android, ios, linux, macos, windows. + steps: + - name: Set up JDK 11 + uses: actions/setup-java@v3 + with: + java-version: 11 + distribution: temurin + # Set up Flutter. + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + # flutter-version: '' TODO: Pin this to a version. + channel: stable + - run: flutter doctor -v + - name: Checkout + uses: actions/checkout@v3 + - run: flutter pub get + - run: flutter packages pub run build_runner build --delete-conflicting-outputs + - run: flutter build ${{ matrix.target }} diff --git a/.github/workflows/_docker_publish_private.yml b/.github/workflows/_docker_publish_private.yml new file mode 100644 index 0000000..a3791f3 --- /dev/null +++ b/.github/workflows/_docker_publish_private.yml @@ -0,0 +1,66 @@ +name: Publish Docker Images (Private) + +on: + workflow_call: + inputs: + registry-auth-location: + description: 'Name of the GCP managed Artifact Registry.' + default: "us-central1-docker.pkg.dev" + required: false + type: string + remote-docker-image: + description: 'Remote Docker image. ex: toplprotocol/faucet' + default: "us-central1-docker.pkg.dev/topl-shared-project-dev/topl-artifacts-dev/faucet" + required: false + type: string + +jobs: + publish_docker_images: + name: Publish Docker Images + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Checkout current branch + uses: actions/checkout@v3 + with: + fetch-depth: 0 + submodules: true + + - id: 'auth' + name: Authenticate to Google Cloud + uses: google-github-actions/auth@v1 + with: + workload_identity_provider: ${{ secrets.GCP_OIDC_PROVIDER_NAME }} + service_account: ${{ secrets.GCP_OIDC_SERVICE_ACCOUNT_EMAIL }} + + - name: Set up gcloud + uses: google-github-actions/setup-gcloud@v1 + + - name: Auth Artifact Registry + run: gcloud auth configure-docker ${{ inputs.registry-auth-location }} --quiet + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4.4.0 + with: + images: ${{ inputs.remote-docker-image }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=ref,event=pr + type=sha,enable=true,prefix=, + + - name: Echo metadata + run: | + echo "tags: ${{ steps.meta.outputs.tags }}" + echo "labels: ${{ steps.meta.outputs.labels }}" + + - name: Build and push Docker image + uses: docker/build-push-action@v4.0.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/_docker_publish_public.yml b/.github/workflows/_docker_publish_public.yml new file mode 100644 index 0000000..9ffde3a --- /dev/null +++ b/.github/workflows/_docker_publish_public.yml @@ -0,0 +1,57 @@ +name: Publish Docker Images (Public) + +on: + workflow_call: + inputs: + remote-docker-image: + description: 'Remote Docker image. ex: toplprotocol/faucet' + default: "toplprotocol/faucet" + required: false + type: string + +jobs: + publish_docker_images: + name: Publish Docker Images + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + packages: write + steps: + - name: Checkout current branch + uses: actions/checkout@v3 + with: + fetch-depth: 0 + submodules: true + + - name: Log in to Docker Hub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Log in to the Github Container registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v4.4.0 + with: + images: ${{ inputs.remote-docker-image }} + + - name: Echo metadata + run: | + echo "tags: ${{ steps.meta.outputs.tags }}" + echo "labels: ${{ steps.meta.outputs.labels }}" + + - name: Build and push Docker image + uses: docker/build-push-action@v4.0.0 + with: + context: . + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..930463c --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,7 @@ +name: PR +on: + pull_request: + +jobs: + build: + uses: ./.github/workflows/_build.yml diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml new file mode 100644 index 0000000..e965bb4 --- /dev/null +++ b/.github/workflows/push.yml @@ -0,0 +1,27 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# GitHub recommends pinning actions to a commit SHA. +# To get a newer version, you will need to update the SHA. +# You can also reference a tag or branch, but the action may change without warning. + +name: CI + +on: + push: + branches: + - main + - dev + +jobs: + build: + uses: ./.github/workflows/_build.yml + + publish_private: + uses: ./.github/workflows/_docker_publish_private.yml + needs: [build] + secrets: inherit + with: + remote-docker-image: "us-central1-docker.pkg.dev/topl-shared-project-dev/topl-artifacts-dev/faucet" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a451a5f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +# GitHub recommends pinning actions to a commit SHA. +# To get a newer version, you will need to update the SHA. +# You can also reference a tag or branch, but the action may change without warning. + +name: Release + +on: + push: + tags: ['*'] + +jobs: + build: + uses: ./.github/workflows/_build.yml + + publish_dockerhub: + uses: ./.github/workflows/_docker_publish_public.yml + needs: [build] + secrets: inherit + with: + remote-docker-image: "toplprotocol/faucet" + + publish_ghcr: + uses: ./.github/workflows/_docker_publish_public.yml + needs: [build] + secrets: inherit + with: + remote-docker-image: "ghcr.io/topl/faucet" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8cb8f42 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,27 @@ +# Stage 1: Build the Flutter app +FROM ghcr.io/cirruslabs/flutter:3.10.6 AS build + +# Set the working directory to /app +WORKDIR /app + +# Copy the entire Flutter project to the container +COPY . . + +# Run the necessary Flutter commands to build the app +RUN flutter pub get && \ + flutter packages pub run build_runner build --delete-conflicting-outputs && \ + flutter build web --release + +# Stage 2: Create a minimal image to run the built app +FROM nginx:stable-alpine3.17 + +USER nginx + +# Copy the built app from the previous stage to the nginx html directory +COPY --from=build --chown=nginx:nginx /app/build/web /usr/share/nginx/html + +# Expose port 80 for the web server to listen on +EXPOSE 80 + +# Start Nginx +CMD ["nginx", "-g", "daemon off;"] diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8a5d98b --- /dev/null +++ b/Makefile @@ -0,0 +1,111 @@ +all: lint format + +# Adding a help file: https://gist.github.com/prwhite/8168133#gistcomment-1313022 +help: ## This help dialog. + @IFS=$$'\n' ; \ + help_lines=(`fgrep -h "##" $(MAKEFILE_LIST) | fgrep -v fgrep | sed -e 's/\\$$//'`); \ + for help_line in $${help_lines[@]}; do \ + IFS=$$'#' ; \ + help_split=($$help_line) ; \ + help_command=`echo $${help_split[0]} | sed -e 's/^ *//' -e 's/ *$$//'` ; \ + help_info=`echo $${help_split[2]} | sed -e 's/^ *//' -e 's/ *$$//'` ; \ + printf "%-30s %s\n" $$help_command $$help_info ; \ + done + +build_web: + @flutter clean + @npm install + @npm run build + @flutter pub get + @flutter build web --web-renderer html --csp --release + +run_unit: ## Runs unit tests + @echo "╠ Running the tests" + @flutter test || (echo "Error while running tests"; exit 1) + +clean: ## Cleans the environment + @echo "╠ Cleaning the project..." + @rm -rf pubspec.lock + @flutter clean + @flutter pub get + +fix_warnings: ## fix any warnings + @echo "╠ Attempting to fix warnings..." + @dart fix --dry-run + @dart fix --apply +watch: ## Watches the files for changes + @echo "╠ Watching the project..." + @flutter pub run build_runner watch --delete-conflicting-outputs + + +gen: ## Generates the assets + @echo "╠ Generating the assets..." + @flutter pub get + @flutter packages pub run build_runner build --delete-conflicting-outputs + +format: ## Formats the code + @echo "╠ Formatting the code" + @dart format lib . -l 120 + @flutter pub run import_sorter:main + @dart format . -l 120 + +lint: ## Lints the code + @echo "╠ Verifying code..." + @dart analyze . || (echo "Error in project"; exit 1) + +upgrade: clean ## Upgrades dependencies + @echo "╠ Upgrading dependencies..." + @flutter pub upgrade + +commit: format lint run_unit + @echo "╠ Committing..." + git add . + git commit + +analyze: + flutter run dart_code_metrics:metrics analyze lib + +ditto: + echo "hello world" + +validate_packages: + @echo "╠ Validating packages..." + @flutter pub get + @flutter pub run dependency_validator + +arm_mac_hard_clean: + flutter clean && \ + flutter pub get && \ + cd ios && \ + sudo rm -r Pods/ && \ + rm -r .symlinks/ && \ + rm Podfile.lock && \ + sudo arch -x86_64 gem install ffi && \ + arch -x86_64 pod install && \ + cd .. + +file_test: + @reset + @flutter test test/middleware_test.dart + +nuclear_clean: + @echo "╠ Nuking pubcache completely, this might take a while...." + @flutter clean + @flutter pub cache repair + @flutter pub get + +test_coverage: + @flutter test --coverage + @genhtml coverage/lcov.info -o coverage/html + @open coverage/html/index.html + +web_profile: + @flutter run -d chrome --profile + +web_build_and_host: + @flutter build web --web-renderer html --csp --release + @cd build/web && python3 -m http.server 8000 + +file_test: + @reset + @flutter test test/search/search_test.dart \ No newline at end of file diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6840415..9c52faa 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,7 +1,11 @@ + + + + + + + diff --git a/assets/icons/dark-block-icon.png b/assets/icons/dark-block-icon.png new file mode 100644 index 0000000..6cf3932 Binary files /dev/null and b/assets/icons/dark-block-icon.png differ diff --git a/assets/icons/facebook.svg b/assets/icons/facebook.svg new file mode 100644 index 0000000..a0aa02c --- /dev/null +++ b/assets/icons/facebook.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/github.svg b/assets/icons/github.svg new file mode 100644 index 0000000..6483c65 --- /dev/null +++ b/assets/icons/github.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/instagram.svg b/assets/icons/instagram.svg new file mode 100644 index 0000000..76e6af2 --- /dev/null +++ b/assets/icons/instagram.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/leaves-logo.png b/assets/icons/leaves-logo.png new file mode 100644 index 0000000..d884543 Binary files /dev/null and b/assets/icons/leaves-logo.png differ diff --git a/assets/icons/linkedin.svg b/assets/icons/linkedin.svg new file mode 100644 index 0000000..4dfb207 --- /dev/null +++ b/assets/icons/linkedin.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/logo.svg b/assets/icons/logo.svg new file mode 100644 index 0000000..bd3a458 --- /dev/null +++ b/assets/icons/logo.svg @@ -0,0 +1,8 @@ + + + + diff --git a/assets/icons/logo_dark.svg b/assets/icons/logo_dark.svg new file mode 100644 index 0000000..fd7e954 --- /dev/null +++ b/assets/icons/logo_dark.svg @@ -0,0 +1,8 @@ + + + + diff --git a/assets/icons/medium.svg b/assets/icons/medium.svg new file mode 100644 index 0000000..7b929dc --- /dev/null +++ b/assets/icons/medium.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/topl-logo-main.svg b/assets/icons/topl-logo-main.svg new file mode 100644 index 0000000..d09aa11 --- /dev/null +++ b/assets/icons/topl-logo-main.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/assets/icons/twitter.svg b/assets/icons/twitter.svg new file mode 100644 index 0000000..5e9739a --- /dev/null +++ b/assets/icons/twitter.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/vector.svg b/assets/icons/vector.svg new file mode 100644 index 0000000..abcb99d --- /dev/null +++ b/assets/icons/vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/wallet.svg b/assets/icons/wallet.svg new file mode 100644 index 0000000..1e8fa4c --- /dev/null +++ b/assets/icons/wallet.svg @@ -0,0 +1,13 @@ + + + + + + + diff --git a/assets/webpages/index.html b/assets/webpages/index.html new file mode 100644 index 0000000..67c5859 --- /dev/null +++ b/assets/webpages/index.html @@ -0,0 +1,33 @@ + + + Faucet + + + + + + +
+ + diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 8764345..3cf1040 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -47,5 +47,7 @@ UIApplicationSupportsIndirectInputEvents + io.flutter.embedded_views_preview + diff --git a/lib/blocks/models/block.dart b/lib/blocks/models/block.dart new file mode 100644 index 0000000..b327f60 --- /dev/null +++ b/lib/blocks/models/block.dart @@ -0,0 +1,53 @@ +// Flutter imports: +import 'package:flutter/foundation.dart'; + +// Package imports: +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:topl_common/proto/genus/genus_rpc.pb.dart'; + +import '../../shared/utils/decode_id.dart'; + +part 'block.freezed.dart'; +part 'block.g.dart'; + +@freezed +class Block with _$Block { + const factory Block({ + /// String representing block header. + required String header, + + /// An epoch is a unit of time when validators of the network remain constant. + /// It is measured in blocks + required int epoch, + + /// The size of the block + required double size, + + /// The number of blocks preceding the current block + required int height, + + /// The slot in which the block was forged. + required int slot, + + /// Timestamp of block forging + required int timestamp, + + /// The number of transactions + required int transactionNumber, + }) = _Block; + + factory Block.fromBlockRes({required BlockResponse blockRes, required int epochLength}) { + final block = Block( + header: decodeId(blockRes.block.header.headerId.value), + epoch: blockRes.block.header.slot.toInt() ~/ epochLength, + size: blockRes.writeToBuffer().lengthInBytes.toDouble(), + height: blockRes.block.header.height.toInt(), + slot: blockRes.block.header.slot.toInt(), + timestamp: blockRes.block.header.timestamp.toInt(), + transactionNumber: blockRes.block.fullBody.transactions.length, + ); + return block; + } + + factory Block.fromJson(Map json) => _$BlockFromJson(json); +} diff --git a/lib/blocks/models/block_details.dart b/lib/blocks/models/block_details.dart new file mode 100644 index 0000000..763610e --- /dev/null +++ b/lib/blocks/models/block_details.dart @@ -0,0 +1,29 @@ + +class BlockDetails { + final int height; + final int time; + final String hash; + final String epoch; + final String header; + final int size; + + BlockDetails({ + required this.hash, + required this.height, + required this.time, + required this.epoch, + required this.header, + required this.size + }); + + factory BlockDetails.fromJson(Map json) { + return BlockDetails( + hash: json['hash'], + height: json['height'], + time: json['time'], + size: json['size'], + epoch: json['epoch'], + header: json['header'], + ); + } +} \ No newline at end of file diff --git a/lib/blocks/providers/block_provider.dart b/lib/blocks/providers/block_provider.dart new file mode 100644 index 0000000..ac9f109 --- /dev/null +++ b/lib/blocks/providers/block_provider.dart @@ -0,0 +1,363 @@ +import 'package:faucet/blocks/models/block.dart'; +import 'package:faucet/blocks/utils/utils.dart'; +import 'package:faucet/chain/models/chains.dart'; +import 'package:faucet/chain/providers/selected_chain_provider.dart'; +import 'package:faucet/chain/utils/constants.dart'; +import 'package:faucet/shared/providers/config_provider.dart'; +import 'package:faucet/shared/providers/genus_provider.dart'; +import 'package:faucet/transactions/utils/utils.dart'; +import 'package:topl_common/proto/node/services/bifrost_rpc.pb.dart'; +import 'package:topl_common/proto/genus/genus_rpc.pb.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Returns a block at the depth +/// +/// IMPORTANT: Only use this provider AFTER the blockProvider has been initialized +/// Ex. +/// ``` +/// final blockProvider = ref.watch(blockProvider); +/// blockProvider.when( +/// data: (data) { +/// // Use data here +/// ref.watch(blockStateAtDepthProvider(index)); +/// ... +/// }, +/// ... +/// ); +/// ``` +final blockStateAtDepthProvider = FutureProvider.family((ref, depth) async { + return ref.watch(blockProvider.notifier).getBlockFromStateAtDepth(depth); +}); + +/// Returns a block at the height +/// +/// IMPORTANT: Only use this provider AFTER the blockProvider has been initialized +/// Ex. +/// ``` +/// final blockProvider = ref.watch(blockProvider); +/// blockProvider.when( +/// data: (data) { +/// // Use data here +/// ref.watch(blockStateAtHeightProvider(index)); +/// ... +/// }, +/// ... +/// ); +/// ``` +final blockStateAtHeightProvider = FutureProvider.family((ref, height) async { + return ref.watch(blockProvider.notifier).getBlockFromStateAtHeight(height); +}); + +/// Returns a list of blocks since decentralization +/// +/// The function calculates the spacing between elements and creates a list of +/// heights to get the blocks from. It then loops through the list and gets the +/// blocks at each height. If an error occurs, the loop is broken and the +/// function returns the blocks that were successfully retrieved. +/// +/// The function requires a [Ref] object to be passed in. +Future> getBlocksSinceDecentralization({ + required Ref ref, +}) async { + // Get the block at depth 0 to determine the current height + final depth0Block = await ref.read(blockStateAtDepthProvider(0).future); + final height = depth0Block.height; + + // Calculate the spacing between elements + double spacing = height / (minimumResults - 1); + + // Create the list and add elements from quack to 0 + List myList = List.generate(minimumResults, (index) { + if (index == 0) { + return height; + } else { + int value = (height - (spacing * index)).round(); + return value >= 0 ? value : 0; + } + }); + + List blocks = []; + + // Loop through the list and get the blocks + for (int i = 0; i < myList.length - 1; i++) { + try { + final currentHeight = myList[i]; + final block = await ref.read(blockStateAtHeightProvider(currentHeight).future); + blocks.add(block); + } catch (e) { + break; + } + } + + return blocks; +} + +final getBlockByIdProvider = FutureProvider.family((ref, header) async { + return ref.read(blockProvider.notifier).getBlockFromStateById(header); +}); + +final blockProvider = StateNotifierProvider>>((ref) { + /// Notes: + /// We'll need to watch for the selectedChain here since we will need to know which + /// instance of Genus to target + final selectedChain = ref.watch(selectedChainProvider); + final config = ref.watch(configProvider.future); + + return BlockNotifier( + ref, + selectedChain, + config, + ); +}); + +class BlockNotifier extends StateNotifier>> { + final Chains selectedChain; + final Ref ref; + final Future config; + BlockNotifier( + this.ref, + this.selectedChain, + this.config, + ) : super( + const AsyncLoading(), + ) { + getLatestBlocks(setState: true); + } + + /// It takes a bool [setState] + /// + /// If [setState] is true, it will update the state of the provider + /// If [setState] is false, it will not update the state of the provider + Future> getLatestBlocks({bool setState = false}) async { + if (selectedChain == const Chains.mock()) { + if (setState) state = const AsyncLoading(); + final List blocks = List.generate(100, (index) => getMockBlock()); + if (setState) { + state = AsyncData( + blocks.asMap(), + ); + } + return blocks; + } + + try { + final genusClient = ref.read(genusProvider(selectedChain)); + + if (setState) state = const AsyncLoading(); + //futures + final List blocks = []; + const pageLimit = 10; + final presentConfig = await config; + + final block0Res = await genusClient.getBlockByDepth(depth: 0); + blocks.add( + Block.fromBlockRes( + blockRes: block0Res, + epochLength: presentConfig.config.epochLength.toInt(), + ), + ); + + //fetch more blocks with block0 as the reference for a fixed depth + for (int i = 1; i < pageLimit; i++) { + final blockRes = await genusClient.getBlockByHeight(height: blocks[0].height - i); + blocks.add( + Block.fromBlockRes( + blockRes: blockRes, + epochLength: presentConfig.config.epochLength.toInt(), + ), + ); + } + + // Adding delay here to simulate API call + if (setState) { + state = AsyncData(blocks.asMap()); + } + + return blocks; + } catch (e) { + throw Exception('Error in blockProvider: $e'); + } + } + + /// This method is used to get a block at a specific depth + /// If the block is not in the state, it will fetch the block from Genus + /// It takes an [index] as a parameter + /// + /// It returns a [Future] + Future getBlockFromStateAtDepth(int depth) async { + var blocks = state.asData?.value; + + if (blocks == null) { + throw Exception('Error in blockProvider: blocks are null'); + } + + // If the index is less than the length of the list, return the block at that index + if (blocks[depth] != null) { + return blocks[depth]!; + } else { + blocks = {...blocks}; + // Get the next block by height from Genus + final genusClient = ref.read(genusProvider(selectedChain)); + + //convert depth to height (depth is fixed in reference to block0) + final blockAtDepth0 = blocks[0]; + if (blockAtDepth0 == null) { + throw Exception('Error in blockProvider: blockAtDepth0 is null'); + } + final desiredHeight = blockAtDepth0.height - depth; + + final blockRes = await genusClient.getBlockByHeight(height: desiredHeight); + final presentConfig = await config; + // Add that block to state's list + var newBlock = Block.fromBlockRes( + blockRes: blockRes, + epochLength: presentConfig.config.epochLength.toInt(), + ); + blocks[depth] = newBlock; + + // Sort the blocks by depth so that they are in order + final sortedBlocks = sortBlocksByDepth(blocks: blocks); + state = AsyncData(sortedBlocks); + + // Return that block + return newBlock; + } + } + + /// This will get a block at a specific height + /// If the block is not in the state, it will fetch the block from Genus + /// It takes an [index] as a parameter + /// + /// It returns a [Future] + Future getBlockFromStateAtHeight(int height) async { + var blocks = state.asData?.value; + + if (blocks == null) { + throw Exception('Error in blockProvider: blocks are null'); + } + + final blockAtDepth0 = blocks[0]; + + if (blockAtDepth0 == null) { + throw Exception('Error in blockProvider: blockAtDepth0 is null'); + } + + if (blockAtDepth0.height < height) { + throw Exception('Error in blockProvider: height is greater than the height of the latest block'); + } + + final depth = blockAtDepth0.height - height; + + // If the index is less than the length of the list, return the block at that index + if (blocks[depth] != null) { + return blocks[depth]!; + } else { + blocks = {...blocks}; + // Get the next block by height from Genus + final genusClient = ref.read(genusProvider(selectedChain)); + final blockRes = await genusClient.getBlockByHeight(height: height); + final presentConfig = await config; + // Add that block to state's list + var newBlock = Block.fromBlockRes( + blockRes: blockRes, + epochLength: presentConfig.config.epochLength.toInt(), + ); + blocks[depth] = newBlock; + + // Sort the blocks by depth so that they are in order + final sortedBlocks = sortBlocksByDepth(blocks: blocks); + state = AsyncData(sortedBlocks); + + // Return that block + return newBlock; + } + } + + //TODO: figure out a better way since a ton of empty blocks means this is taking too long + Future getFirstPopulatedBlock() async { + int depth = 0; + final genusClient = ref.read(genusProvider(selectedChain)); + var nextBlock = await genusClient.getBlockByDepth(depth: depth); + + //check that block has transactions + while (!nextBlock.block.fullBody.hasField(1)) { + depth++; + nextBlock = await genusClient.getBlockByDepth(depth: depth); + } + + return nextBlock; + } + + Future getNextPopulatedBlock({required int height}) async { + var blocks = state.asData?.value; + if (blocks == null) { + throw Exception('Error in blockProvider: blocks are null'); + } + + final genusClient = ref.read(genusProvider(selectedChain)); + var nextBlock = await genusClient.getBlockByHeight(height: height); + //check that block has transactions + while (!nextBlock.block.fullBody.hasField(1)) { + height--; + nextBlock = await genusClient.getBlockByHeight(height: height); + } + + final blockAtDepth0 = blocks[0]; + if (blockAtDepth0 == null) { + throw Exception('Error in blockProvider: blockAtDepth0 is null'); + } + + final depth = blockAtDepth0.height - height; + + //not in state + if (blocks[depth] == null) { + blocks = {...blocks}; + final sortedBlocks = sortBlocksByDepth(blocks: blocks); + state = AsyncData(sortedBlocks); + } + + return nextBlock; + } + + Future getBlockFromStateById(String header) async { + var blocks = state.asData?.value; + + if (blocks == null) { + throw Exception('Error in block provider: blocks are null'); + } + + // If the state contains the block, return it + + try { + return blocks.values.firstWhere((element) => element.header == header); + } catch (e) { + final genusClient = ref.read(genusProvider(selectedChain)); + + final config = ref.read(configProvider.future); + final presentConfig = await config; + + var blockRes = await genusClient.getBlockById(blockIdString: header); + + final block = Block.fromBlockRes( + blockRes: blockRes, + epochLength: presentConfig.config.epochLength.toInt(), + ); + + // Set the state + blocks = {...blocks}; + // Get blocks depth + final depth = blocks[0]!.height - block.height; + if (depth < 0) { + blocks[0] = block; + } else { + blocks[depth] = block; + } + final sortedBlocks = sortBlocksByDepth(blocks: blocks); + state = AsyncData(sortedBlocks); + + return block; + + // Set the state to the new block + } + } +} diff --git a/lib/blocks/utils/extensions.dart b/lib/blocks/utils/extensions.dart new file mode 100644 index 0000000..8040bd3 --- /dev/null +++ b/lib/blocks/utils/extensions.dart @@ -0,0 +1,9 @@ +import 'package:faucet/blocks/models/block.dart'; +import 'package:faucet/blocks/utils/utils.dart'; +import 'package:topl_common/proto/genus/genus_rpc.pbgrpc.dart'; + +extension BlockResponseExtension on BlockResponse { + Block toBlock() { + return getMockBlock(); + } +} diff --git a/lib/blocks/utils/utils.dart b/lib/blocks/utils/utils.dart new file mode 100644 index 0000000..db1258a --- /dev/null +++ b/lib/blocks/utils/utils.dart @@ -0,0 +1,13 @@ +import 'package:faucet/blocks/models/block.dart'; + +Block getMockBlock() { + return const Block( + header: "vytVMYVjgHDHAc7AwA2Qu7JE3gPHddaTPbFWvqb2gZu", + epoch: 243827, + size: 5432.2, + height: 1000 + 1, + slot: 10, + timestamp: 1683494060, + transactionNumber: 200, + ); +} diff --git a/lib/chain/models/chain.dart b/lib/chain/models/chain.dart new file mode 100644 index 0000000..50418b7 --- /dev/null +++ b/lib/chain/models/chain.dart @@ -0,0 +1,57 @@ +// Flutter imports: +import 'package:flutter/foundation.dart'; + +// Package imports: +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chain.freezed.dart'; + +part 'chain.g.dart'; + +@freezed +class Chain with _$Chain { + const factory Chain({ + /// The current data throughput for the network in kilobytes per second. + required double dataThroughput, + + /// The average transaction fee for the network in LVL + required double averageTransactionFee, + + /// The ratio of unique active addresses to total addresses. + required int uniqueActiveAddresses, + + /// A unit of time to define a specific length in the blockchain. + required int eon, + + /// A unit of time to define a specific set of rule or parameters in the blockchain. + required int era, + + /// An epoch is a unit of time when validators of the network remain constant. + /// It is measured in blocks + required int epoch, + + /// The total number of transactions in current epoch + /// Also known as Txn + required int totalTransactionsInEpoch, + + /// The number of blocks preceding the current block + required int height, + + /// Average Time for a block to be added to a blockchain + required int averageBlockTime, + + /// The percentage of staked cypto + required double totalStake, + + /// Stakes that have been officially recorded on the blockchain + required int registeredStakes, + + /// Portion of a cryptocurrency that is currently being staked + required int activeStakes, + + /// Portion of a cryptocurrency that is not staked + required int inactiveStakes, + }) = _Chain; + + factory Chain.fromJson(Map json) => _$ChainFromJson(json); +} diff --git a/lib/chain/models/chains.dart b/lib/chain/models/chains.dart new file mode 100644 index 0000000..f8c665b --- /dev/null +++ b/lib/chain/models/chains.dart @@ -0,0 +1,82 @@ +// Package imports: +import 'package:faucet/chain/models/currency.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'chains.freezed.dart'; + +part 'chains.g.dart'; + +@freezed +sealed class Chains with _$Chains { + const factory Chains.topl_mainnet({ + @Default('Toplnet') String networkName, + @Default('mainnet.topl.co') String hostUrl, + @Default(443) int port, + }) = ToplMainnet; + const factory Chains.valhalla_testnet({ + @Default('Valhalla') String networkName, + @Default('testnet.topl.network') String hostUrl, + @Default(50051) int port, + }) = ValhallaTestNet; + const factory Chains.private_network({ + @Default('Private') String networkName, + @Default('localhost') String hostUrl, + @Default(8080) int port, + }) = PrivateNetwork; + const factory Chains.dev_network({ + @Default('Development') String networkName, + @Default('testnet.topl.co') String hostUrl, + @Default(443) int port, + }) = DevNetwork; + const factory Chains.mock({ + @Default('Mock') String networkName, + @Default('mock') String hostUrl, + @Default(0000) int port, + }) = MockNetwork; + const factory Chains.custom({ + required String chainId, + required String networkName, + required String hostUrl, + required int port, + required Currency currency, + String? blockExplorerUrl, + }) = CustomNetwork; + + factory Chains.fromJson(Map json) => _$ChainsFromJson(json); +} + +List splitUrl({required String completeUrl}) { + // '' + final splitUrl = completeUrl.split(':'); + + if (splitUrl.length > 2) { + throw Exception('Invalid Url'); + } + + var port = int.tryParse(splitUrl[1]); + if (port == null) { + throw Exception('Invalid port'); + } + + return [splitUrl[0], port]; +} + +void validateCustomChain(CustomNetwork chain) { + if (chain.chainId.isEmpty) { + throw Exception('CustomNetwork: chainId cannot be empty'); + } + if (chain.networkName.isEmpty) { + throw Exception('CustomNetwork: networkName cannot be empty'); + } + if (chain.hostUrl.isEmpty) { + throw Exception('CustomNetwork: hostUrl cannot be empty'); + } + if (chain.port <= 0) { + throw Exception('CustomNetwork: invalid port'); + } + //currency is handled by CustomNetwork constructor + //check for empty string only if blockExplorerUrl is not null + if (chain.blockExplorerUrl?.isEmpty ?? false) { + throw Exception('CustomNetwork: blockExplorerUrl cannot be empty'); + } +} diff --git a/lib/chain/models/chart_option.dart b/lib/chain/models/chart_option.dart new file mode 100644 index 0000000..4500812 --- /dev/null +++ b/lib/chain/models/chart_option.dart @@ -0,0 +1,17 @@ +enum ChartOption { + averageTransactionFee( + name: 'Average Transaction Fee', + ), + dataThroughput( + name: 'Data Throughput', + ), + averageBlockTime( + name: 'Average Block Time', + ); + + const ChartOption({ + required this.name, + }); + + final String name; +} diff --git a/lib/chain/models/chart_result.dart b/lib/chain/models/chart_result.dart new file mode 100644 index 0000000..ee22732 --- /dev/null +++ b/lib/chain/models/chart_result.dart @@ -0,0 +1,11 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:flutter/foundation.dart'; + +part 'chart_result.freezed.dart'; + +@freezed +class ChartResult with _$ChartResult { + const factory ChartResult({ + required Map results, + }) = _ChartResult; +} diff --git a/lib/chain/models/currency.dart b/lib/chain/models/currency.dart new file mode 100644 index 0000000..4ec6661 --- /dev/null +++ b/lib/chain/models/currency.dart @@ -0,0 +1,21 @@ +enum Currency { + lvl(type: 'LVL'), + usd(type: 'USD'), + eur(type: 'EUR'), + gbp(type: 'GBP'), + zar(type: 'ZAR'), + aud(type: 'AUD'); + + const Currency({ + required this.type, + }); + + final String type; +} + +List currencies = Currency.values.map((currency) => currency.type).toList(); + +Currency convertStringToCurrency({required String currencyString}) { + final String currencyStringLC = currencyString.toLowerCase(); + return Currency.values.firstWhere((e) => e.toString() == 'Currency.$currencyStringLC'); +} diff --git a/lib/chain/models/time_frame.dart b/lib/chain/models/time_frame.dart new file mode 100644 index 0000000..a1cf368 --- /dev/null +++ b/lib/chain/models/time_frame.dart @@ -0,0 +1,32 @@ +enum TimeFrame { + day( + name: '24h', + ), + week( + name: '1w', + ), + twoWeeks( + name: '2w', + ), + month( + name: '1m', + ), + threeMonths( + name: '3m', + ), + sixMonths( + name: '6m', + ), + year( + name: '1y', + ), + all( + name: 'All', + ); + + const TimeFrame({ + required this.name, + }); + + final String name; +} diff --git a/lib/chain/providers/chain_provider.dart b/lib/chain/providers/chain_provider.dart new file mode 100644 index 0000000..a6664cf --- /dev/null +++ b/lib/chain/providers/chain_provider.dart @@ -0,0 +1,147 @@ +import 'package:faucet/chain/models/chain.dart'; +import 'package:faucet/chain/models/chains.dart'; +import 'package:faucet/chain/utils/chain_utils.dart'; +import 'package:faucet/shared/providers/genus_provider.dart'; +import 'package:faucet/shared/providers/node_provider.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:topl_common/genus/data_extensions.dart'; +import 'package:topl_common/genus/services/node_grpc.dart'; +import 'package:topl_common/genus/services/transaction_grpc.dart'; +import 'package:topl_common/proto/consensus/models/block_header.pb.dart'; +import 'package:topl_common/proto/node/services/bifrost_rpc.pbgrpc.dart'; +import 'selected_chain_provider.dart'; + +final last10BlockHeadersProvider = FutureProvider>((ref) async { + final selectedChain = ref.watch(selectedChainProvider); + final GenusGRPCService genusClient = ref.read(genusProvider(selectedChain)); + + final List last10Blocks = []; + + for (int i = 0; i < 10; i++) { + final block = await genusClient.getBlockByDepth(depth: i); + final blockHeader = block.block.header; + last10Blocks.add(blockHeader); + } + + return last10Blocks; +}); + +final chainProvider = StateNotifierProvider>((ref) { + /// Adding some dev notes here + /// + /// THIS STILL NEEDS TO BE TESTED! + /// + /// Watching selected chain here and providing to the notifier + /// Ideally, when the chain is changed, the notifier should be updated + /// Since Annulus will support the ability to add new chains, we cant have pre-defined providers for each chain + /// + /// In Ribn we have a similar use case where we have a selected chain + /// but we have individual providers for each chain since it currently only supports 3 chains + final selectedChain = ref.watch(selectedChainProvider); + + return ChainNotifier( + ref, + selectedChain, + ); +}); + +class ChainNotifier extends StateNotifier> { + final Chains selectedChain; + final Ref ref; + + final GenusGRPCService genusClient; + final NodeGRPCService nodeClient; + + ChainNotifier( + this.ref, + this.selectedChain, + ) : genusClient = ref.read(genusProvider(selectedChain)), + nodeClient = ref.read(nodeProvider(selectedChain)), + super( + const AsyncLoading(), + ) { + getSelectedChain(setState: true); + } + + /// TODO: Implements with dart gRPC client + /// + /// It takes a bool [setState] + /// + /// If [setState] is true, it will update the state of the provider + /// If [setState] is false, it will not update the state of the provider + Future getSelectedChain({bool setState = false}) async { + if (setState) state = const AsyncLoading(); + + final Chain chain = selectedChain == Chains.mock ? getMockChain() : await _getLiveChain(); + + // Adding delay here to simulate API call + if (setState) { + state = AsyncData(chain); + } + + return chain; + } + + Future _getLiveChain() async { + try { + final FetchEpochDataRes chainData = await nodeClient.fetchEpochData(epoch: 1); + + //////// Data Throughput //////// + final int dataBytes = chainData.epochData.dataBytes.toInt(); + + final int startTimestamp = chainData.epochData.startTimestamp.toInt(); + + final int currentTimestamp = DateTime.now().millisecondsSinceEpoch; + + final double dataThroughput = + double.parse((dataBytes / ((currentTimestamp - startTimestamp) / 1000)).toStringAsFixed(2)); + + //////// Average Transaction fee //////// + final int totalTransactionsInEpoch = chainData.epochData.transactionCount.toInt(); + final totalTransactionReward = chainData.epochData.totalTransactionReward.toBigInt(); + + final double averageTransactionFee = totalTransactionReward.toInt() / totalTransactionsInEpoch; + + //////// Average Block Time //////// + + final int totalBlocks = chainData.epochData.endHeight.toInt(); + + final int averageBlockTime = ((currentTimestamp - startTimestamp) / 1000 / totalBlocks).round(); + + //////// Others //////// + final int eon = chainData.epochData.eon.toInt(); + + final int era = chainData.epochData.era.toInt(); + + final int epoch = chainData.epochData.epoch.toInt(); + + final int endHeight = chainData.epochData.endHeight.toInt(); + + final activeStakes = chainData.epochData.activeStake.toBigInt().toInt(); + + final inactiveStakes = chainData.epochData.inactiveStake.toBigInt().toInt(); + + final totalStakes = activeStakes + inactiveStakes; + + final Chain chain = Chain( + dataThroughput: dataThroughput, + averageTransactionFee: averageTransactionFee, + // TODO: This is not yet implemented in the API + uniqueActiveAddresses: 0, + eon: eon, + era: era, + epoch: epoch, + totalTransactionsInEpoch: totalTransactionsInEpoch, + height: endHeight, + averageBlockTime: averageBlockTime, + totalStake: activeStakes / totalStakes, + registeredStakes: totalStakes, + activeStakes: activeStakes, + inactiveStakes: inactiveStakes, + ); + return chain; + } catch (e) { + throw ('Error retrieving chain data $e'); + } + } +} diff --git a/lib/chain/providers/selected_chain_provider.dart b/lib/chain/providers/selected_chain_provider.dart new file mode 100644 index 0000000..38018fe --- /dev/null +++ b/lib/chain/providers/selected_chain_provider.dart @@ -0,0 +1,115 @@ +import 'package:faucet/chain/models/chains.dart'; +import 'package:faucet/chain/utils/chain_utils.dart'; +import 'package:faucet/shared/services/hive/hive_service.dart'; +import 'package:faucet/shared/services/hive/hives.dart'; +import 'package:faucet/shared/utils/hive_utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final selectedChainProvider = StateProvider((ref) { + return getDefaultChain(); +}); + +final chainsProvider = StateNotifierProvider>>((ref) { + return ChainsNotifier(ref); +}); + +class ChainsNotifier extends StateNotifier>> { + final Ref ref; + ChainsNotifier(this.ref) : super(const AsyncLoading()) { + _getAvailableChains(setState: true); + } + + /// It takes a bool [setState] + /// + /// If [setState] is true, it will update the state of the provider + /// If [setState] is false, it will not update the state of the provider + Future> _getAvailableChains({ + bool setState = false, + }) async { + final List customChains = []; + final Iterable hiveData = await ref.read(hiveProvider).getAllItems(boxType: HivesBox.customChains); + hiveData.toList().forEach((element) { + final Map hiveJson = convertHashMapToMap(element); + try { + customChains.add(Chains.fromJson(hiveJson)); + } catch (e) { + print('Error parsing custom chain: $e'); + } + }); + + // dev notes: This will have to be updated when we change the predetermined networks + final List standardChains = [ + const Chains.topl_mainnet(), + const Chains.valhalla_testnet(), + const Chains.private_network(), + const Chains.dev_network(), + const Chains.mock(), + ]; + + //state holds both standard and custom chains + final allChains = [...standardChains, ...customChains]; + + if (setState) { + state = AsyncData(allChains); + } + + return allChains; + } + + test() { + switch (Chains) { + case Chains.topl_mainnet: + break; + case Chains.valhalla_testnet: + break; + case Chains.private_network: + break; + case Chains.dev_network: + break; + case Chains.mock: + break; + case Chains.custom: + break; + } + } + + /// Add ad custom chain + /// Make sure to add to state + /// And make sure the input is a CustomNetwork (See the chains class) + Future addCustomChain({required CustomNetwork chain}) async { + final chains = state.asData?.value; + + if (chains == null) { + throw Exception('Error in chainsProvider: chains are null'); + } + + //validate chain + validateCustomChain(chain); + + //search for duplicate + // final dupeIndex = chains.indexWhere((element) => element.hostUrl == chain.hostUrl && element.port == chain.port); + // if (dupeIndex >= 0) { + // throw Exception('Cannot add a duplicate chain'); + // } + + //add to cache + await ref.read(hiveProvider).putItem(boxType: HivesBox.customChains, key: chain.chainId, value: chain.toJson()); + + //add new custom chain to state + state = AsyncData([...chains, chain]); + } + + Future removeCustomChain({required String chainId}) async { + final chains = state.asData?.value; + + if (chains == null) { + throw Exception('Error in chainsProvider: chains are null'); + } + + await ref.read(hiveProvider).deleteItem(boxType: HivesBox.customChains, key: chainId); + + chains.removeWhere((element) => element is CustomNetwork && element.chainId == chainId); + state = AsyncData(chains); + ref.read(selectedChainProvider.notifier).state = getDefaultChain(); + } +} diff --git a/lib/chain/sections/add_new_network.dart b/lib/chain/sections/add_new_network.dart new file mode 100644 index 0000000..ca5fb28 --- /dev/null +++ b/lib/chain/sections/add_new_network.dart @@ -0,0 +1,350 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:faucet/chain/providers/selected_chain_provider.dart'; +import 'package:faucet/shared/constants/strings.dart'; +import 'package:faucet/shared/theme.dart'; +import 'package:faucet/transactions/widgets/custom_transaction_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import '../models/chains.dart'; +import '../../shared/utils/theme_color.dart'; +import '../models/currency.dart'; + +/// A widget that displays a dropdown button for selecting a chain name. +class AddNewNetworkContainer extends ConsumerStatefulWidget { + const AddNewNetworkContainer({Key? key, required this.colorTheme}) : super(key: key); + final ThemeMode colorTheme; + @override + _AddNewNetworkState createState() => _AddNewNetworkState(); +} + +class _AddNewNetworkState extends ConsumerState { + /// isCDropDownOpen is used to check if the dropdown is open or not + bool isCDropDownOpen = false; + + /// validate is used to validate the input field + bool validate = false; + + /// selectedCurrencyValue is used to store the selected value from the dropdown + String? selectedCurrencyValue = 'LVL'; + + Map textEditingControllers = { + 'networkName': TextEditingController(text: 'Topl Mainnet'), + 'networkUrl': TextEditingController(text: ''), + 'chainId': TextEditingController(text: '192.158.0.0'), + 'explorerUrl': TextEditingController(text: ''), + }; + final TextEditingController textEditingController = TextEditingController(); + final toast = FToast(); + @override + void initState() { + super.initState(); + toast.init(context); + } + + @override + Widget build(BuildContext context) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + + return Container( + decoration: BoxDecoration(color: getSelectedColor(widget.colorTheme, 0xFFFFFFFF, 0xFF282A2C)), + child: Padding( + padding: EdgeInsets.all(isMobile ? 16.0 : 40.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(Strings.addNewNetwork, style: headlineLarge(context)), + const SizedBox( + height: 48, + ), + Container( + height: 64, + width: 560, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: const Color.fromRGBO(112, 64, 236, 0.04), + border: Border.all( + color: getSelectedColor(widget.colorTheme, 0xFFE0E0E0, 0xFF858E8E), + ), + ), + child: Row( + children: [ + const SizedBox(width: 16), + Icon( + Icons.warning_amber, + color: getSelectedColor(widget.colorTheme, 0xFF7040EC, 0xFF858E8E), + size: 24, + ), + const SizedBox(width: 16), + Expanded( + child: SizedBox( + width: 450, + child: Text(Strings.networkInfoMessage, + style: bodySmall(context)?.copyWith( + color: getSelectedColor(widget.colorTheme, 0xFF7040EC, 0xFF858E8E), + )), + ), + ), + ], + ), + ), + const SizedBox( + height: 48, + ), + TextField( + controller: textEditingControllers['networkName'], + style: customTextFieldStyle(), + decoration: InputDecoration( + labelText: Strings.networkName, + labelStyle: customTextStyle(), + border: customOutlineInputBorder(), + enabledBorder: customOutlineInputBorder(), + focusedBorder: customOutlineInputBorder(), + ), + ), + const SizedBox( + height: 24, + ), + TextField( + controller: textEditingControllers['networkUrl'], + style: customTextFieldStyle(), + decoration: InputDecoration( + labelText: Strings.rpcUrl, + labelStyle: bodySmall(context), + border: customOutlineInputBorder(), + enabledBorder: customOutlineInputBorder(), + focusedBorder: customOutlineInputBorder(), + ), + ), + const SizedBox( + height: 8, + ), + SizedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + validate ? "This field is required" : '', + style: const TextStyle(fontSize: 14, fontFamily: 'Rational Display', color: Color(0xFFF07575)), + ), + ], + ), + ), + const SizedBox( + height: 16, + ), + TextField( + controller: textEditingControllers['chainId'], + style: customTextFieldStyle(), + decoration: InputDecoration( + labelText: Strings.chainId, + suffixIcon: Tooltip( + message: Strings.chainIdIdentifierMsg, + showDuration: const Duration(seconds: 10), + decoration: BoxDecoration( + color: Colors.blue.withOpacity(0.9), + borderRadius: const BorderRadius.all(Radius.circular(4)), + ), + child: const Icon( + Icons.info_outline, + color: Color(0xFF858E8E), + ), + ), + labelStyle: customTextStyle(), + border: customOutlineInputBorder(), + enabledBorder: customOutlineInputBorder(), + focusedBorder: customOutlineInputBorder(), + ), + ), + const SizedBox( + height: 24, + ), + DropdownButton2( + hint: Text( + Strings.selectANetwork, + style: bodyMedium(context), + ), + style: customTextStyle(), + underline: Container( + height: 0, + ), + buttonStyleData: ButtonStyleData( + height: 56, + width: 560, + padding: const EdgeInsets.only(left: 14, right: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: getSelectedColor(widget.colorTheme, 0xFFC0C4C4, 0xFF4B4B4B), + ), + color: getSelectedColor(widget.colorTheme, 0xFFFEFEFE, 0xFF282A2C), + ), + ), + dropdownStyleData: DropdownStyleData( + maxHeight: 200, + decoration: BoxDecoration( + color: getSelectedColor(widget.colorTheme, 0xFFFEFEFE, 0xFF282A2C), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8.0), + bottomRight: Radius.circular(8.0), + ), + ), + ), + menuItemStyleData: const MenuItemStyleData( + height: 40, + ), + iconStyleData: IconStyleData( + icon: isCDropDownOpen + ? const Icon( + Icons.keyboard_arrow_up, + color: Color(0xFF858E8E), + ) + : const Icon( + Icons.keyboard_arrow_down, + color: Color(0xFF858E8E), + ), + iconSize: 20, + ), + value: selectedCurrencyValue, + // Array list of items + items: currencies.map((String items) { + return DropdownMenuItem( + value: items, + child: Text(items), + ); + }).toList(), + onChanged: (value) { + setState(() { + selectedCurrencyValue = value as String; + }); + }, + onMenuStateChange: (isOpen) { + setState(() { + isCDropDownOpen = !isCDropDownOpen; + }); + }, + ), + const SizedBox( + height: 24, + ), + TextField( + controller: textEditingControllers['explorerUrl'], + style: customTextFieldStyle(), + decoration: InputDecoration( + hintText: Strings.blockExplorerUrl, + hintStyle: customTextStyle(), + border: customOutlineInputBorder(), + enabledBorder: customOutlineInputBorder(), + focusedBorder: customOutlineInputBorder(), + ), + ), + SizedBox( + height: !isMobile ? 48 : null, + ), + Container( + padding: const EdgeInsets.only(top: 64.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: SizedBox( + width: isMobile ? 100 : 272, + height: 56, + child: TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text(Strings.cancelText, style: bodyMedium(context)), + ), + ), + ), + const SizedBox( + width: 16, + ), + Expanded( + child: SizedBox( + width: isMobile ? 100 : 272, + height: 56, + child: ElevatedButton( + onPressed: () async { + final urlList = splitUrl(completeUrl: textEditingControllers['networkUrl']!.text); + Currency currencyEnum = convertStringToCurrency(currencyString: selectedCurrencyValue!); + final newChain = Chains.custom( + chainId: textEditingControllers['chainId']!.text, + networkName: textEditingControllers['networkName']!.text, + hostUrl: urlList[0], + port: urlList[1], + currency: currencyEnum, + ); + + await ref.read(chainsProvider.notifier).addCustomChain(chain: newChain as CustomNetwork); + + ref.read(selectedChainProvider.notifier).state = newChain; + toast.showToast( + child: CustomToast( + colorTheme: widget.colorTheme, isSuccess: true, cancel: () => Fluttertoast.cancel()), + toastDuration: const Duration(seconds: 4), + positionedToastBuilder: (context, child) => Positioned( + top: 30, + left: isTablet ? 70 : 0, + right: 0, + child: child, + )); + if (!mounted) return; + //remove dropdown from screen + final nav = Navigator.of(context); + nav.pop(); + nav.pop(); + }, + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all(const Color(0xFF0DC8D4)), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + child: Text(Strings.addText, style: bodyMedium(context)), + ), + ), + ), + ], + ), + ) + ], + ), + ), + ); + } + + TextStyle customTextFieldStyle() { + return TextStyle( + fontSize: 16, + fontFamily: 'Rational Display', + color: getSelectedColor(widget.colorTheme, 0xFF535757, 0xFF858E8E)); + } + + TextStyle customTextStyle() { + return const TextStyle( + fontSize: 16, + fontFamily: 'Rational Display', + color: Color(0xFF858E8E), + ); + } + + OutlineInputBorder customOutlineInputBorder() { + return OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: outlineBorder(), + ); + } + + BorderSide outlineBorder() { + return BorderSide( + color: getSelectedColor(widget.colorTheme, 0xFFC0C4C4, 0xFF858E8E), + ); + } +} diff --git a/lib/chain/sections/chainname_dropdown.dart b/lib/chain/sections/chainname_dropdown.dart new file mode 100644 index 0000000..ead064e --- /dev/null +++ b/lib/chain/sections/chainname_dropdown.dart @@ -0,0 +1,430 @@ +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:faucet/chain/models/chains.dart'; +import 'package:faucet/chain/providers/selected_chain_provider.dart'; +import 'package:faucet/chain/sections/add_new_network.dart'; +import 'package:faucet/shared/constants/strings.dart'; +import 'package:faucet/shared/constants/ui.dart'; +import 'package:faucet/shared/theme.dart'; +import 'package:faucet/shared/utils/theme_color.dart'; +import 'package:faucet/transactions/utils/utils.dart'; +import 'package:faucet/transactions/widgets/custom_transaction_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:modal_side_sheet/modal_side_sheet.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; + +class ChainNameDropDown extends HookConsumerWidget { + final ThemeMode colorTheme; + final void Function()? onItemSelected; + + ChainNameDropDown({ + Key? key, + this.colorTheme = ThemeMode.light, + this.onItemSelected, + }) : super(key: key); + + final isDropDownOpen = useState(false); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final Chains selectedChain = ref.watch(selectedChainProvider); + final isResponsive = ResponsiveBreakpoints.of(context).smallerThan(DESKTOP); + final AsyncValue> allChains = ref.watch(chainsProvider); + + return allChains.when( + data: (List chains) { + return isResponsive + ? _ResponsiveDropDown( + onItemSelected: onItemSelected, + chains: chains, + selectedChain: selectedChain, + colorTheme: colorTheme, + setSelectedChain: (Chains chain) { + ref.read(selectedChainProvider.notifier).state = chain; + }, + removeCustomChain: (String chainId) async { + await ref.read(chainsProvider.notifier).removeCustomChain(chainId: chainId); + }, + isDropDownOpen: isDropDownOpen, + ) + : _DesktopDropdown( + chains: chains, + selectedChain: selectedChain, + colorTheme: colorTheme, + setSelectedChain: (Chains chain) { + ref.read(selectedChainProvider.notifier).state = chain; + }, + removeCustomChain: (String chainId) async { + await ref.read(chainsProvider.notifier).removeCustomChain(chainId: chainId); + }, + isDropDownOpen: isDropDownOpen, + ); + }, + error: (error, stack) => const Text('Oops, something unexpected happened'), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + ); + } +} + +class _ResponsiveDropDown extends StatelessWidget { + final List chains; + final Chains selectedChain; + final ThemeMode colorTheme; + final Function(Chains) setSelectedChain; + final Function(String) removeCustomChain; + final ValueNotifier isDropDownOpen; + final void Function()? onItemSelected; + + const _ResponsiveDropDown({ + required this.chains, + required this.selectedChain, + required this.colorTheme, + required this.setSelectedChain, + required this.removeCustomChain, + required this.isDropDownOpen, + required this.onItemSelected, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text( + Strings.network, + style: bodyMedium(context), + ), + const Spacer(), + DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + hint: const CustomTextWidget(), + items: [ + ...chains + .map((Chains chain) => DropdownMenuItem( + value: chain, + child: Row( + children: [ + Expanded( + child: Text( + shortenNetwork(chain), + style: bodyMedium(context), + ), + ), + if (selectedChain == chain) + const Icon( + Icons.check, + color: Color(0xFF7040EC), + size: 24, + ), + if (chain is CustomNetwork) + IconButton( + icon: const Icon(Icons.delete), + tooltip: Strings.removeCustomNetwork, + iconSize: 24, + onPressed: () async { + await removeCustomChain(chain.chainId); + if (context.mounted) { + Navigator.of(context).pop(); + } else { + return; + } + }, + ) + ], + ), + )) + .toList(), + DropdownMenuItem( + value: Strings.addNew, + child: Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide( + color: getSelectedColor( + colorTheme, + 0xFF535757, + 0xFF858E8E, + ), + width: 0.2, + ), + ), + ), + child: TextButton( + onPressed: () { + Navigator.of(context).pop(); + showModalSideSheet( + context: context, + ignoreAppBar: true, + width: 640, + barrierColor: Colors.white.withOpacity(barrierOpacity), + // with blur, + barrierDismissible: true, + body: AddNewNetworkContainer( + colorTheme: colorTheme, + )); + + onItemSelected?.call(); + }, + child: Padding( + padding: const EdgeInsets.only(top: 10.0, bottom: 10), + child: Row( + children: [ + const Icon(Icons.add, color: Color(0xFF535757), size: 20), + const SizedBox(width: 4), + Text( + Strings.addNew, + style: bodyMedium(context), + ), + ], + ), + )), + ), + ) + ], + value: selectedChain, + selectedItemBuilder: (context) => chains + .map((Chains chain) => Row( + children: [ + CustomItem( + name: chain.networkName, + ), + ], + )) + .toList(), + onChanged: (value) { + if (value is Chains) setSelectedChain(value); + }, + buttonStyleData: ButtonStyleData( + height: 40, + width: 160, + padding: const EdgeInsets.only(left: 16, right: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: getSelectedColor(colorTheme, 0x809E9E9E, 0xFF4B4B4B), + ), + color: getSelectedColor(colorTheme, 0xFFF5F5F5, 0xFF4B4B4B), + ), + ), + dropdownStyleData: DropdownStyleData( + maxHeight: 260, + width: 345, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(14), + color: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + ), + offset: const Offset(-185, -6), + scrollbarTheme: ScrollbarThemeData( + radius: const Radius.circular(40), + thickness: MaterialStateProperty.all(6), + thumbVisibility: MaterialStateProperty.all(true), + )), + menuItemStyleData: const MenuItemStyleData( + height: 40, + ), + iconStyleData: IconStyleData( + icon: isDropDownOpen.value + ? const Icon( + Icons.keyboard_arrow_up, + color: Color(0xFF858E8E), + ) + : const Icon( + Icons.keyboard_arrow_down, + color: Color(0xFF858E8E), + ), + iconSize: 20, + ), + onMenuStateChange: (isOpen) { + isDropDownOpen.value = !isDropDownOpen.value; + }, + )), + ], + ); + } +} + +class _DesktopDropdown extends StatelessWidget { + final List chains; + final Chains selectedChain; + final ThemeMode colorTheme; + final Function(Chains) setSelectedChain; + final Function(String) removeCustomChain; + final ValueNotifier isDropDownOpen; + static final FToast toast = FToast(); + + const _DesktopDropdown({ + required this.chains, + required this.selectedChain, + required this.colorTheme, + required this.setSelectedChain, + required this.removeCustomChain, + required this.isDropDownOpen, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + toast.init(context); + return Center( + child: DropdownButtonHideUnderline( + child: DropdownButton2( + isExpanded: true, + hint: const CustomTextWidget(), + items: [ + ...chains + .map( + (Chains chain) => DropdownMenuItem( + value: chain, + child: Padding( + padding: const EdgeInsets.only(right: 8.0), + child: Row( + children: [ + Tooltip( + message: chain.networkName.length > 8 ? chain.networkName : '', + child: CustomItem( + name: shortenNetwork(chain), + )), + const Spacer(), + Icon( + Icons.check, + color: const Color(0xFF7040EC), + size: selectedChain == chain ? 24 : 0, + ), + if (chain is CustomNetwork) + IconButton( + icon: const Icon(Icons.delete), + tooltip: Strings.removeCustomNetwork, + iconSize: 24, + onPressed: () async { + await removeCustomChain(chain.chainId); + toast.showToast( + child: RemoveNetworkToast( + colorTheme: colorTheme, isSuccess: true, cancel: () => Fluttertoast.cancel()), + toastDuration: const Duration(seconds: 4), + positionedToastBuilder: (context, child) => Positioned( + top: 30, + left: ResponsiveBreakpoints.of(context).equals(TABLET) ? 70 : 0, + right: 0, + child: child, + )); + if (context.mounted) { + Navigator.of(context).pop(); + } else { + return; + } + }, + ), + ], + ), + ), + ), + ) + .toList(), + ], + value: selectedChain, + selectedItemBuilder: (context) => chains + .map((Chains chain) => Row( + children: [ + Text( + shortenNetwork(chain), + style: titleMedium(context), + ), + ], + )) + .toList(), + onChanged: (value) { + if (value is Chains) setSelectedChain(value); + }, + buttonStyleData: ButtonStyleData( + height: 40, + width: 160, + padding: const EdgeInsets.only(left: 14, right: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: getSelectedColor( + colorTheme, + 0xFFC0C4C4, + 0xFF4B4B4B, + ), + ), + color: getSelectedColor( + colorTheme, + 0xFFFEFEFE, + 0xFF282A2C, + ), + ), + ), + dropdownStyleData: DropdownStyleData( + maxHeight: 260, + decoration: BoxDecoration( + color: getSelectedColor( + colorTheme, + 0xFFFEFEFE, + 0xFF282A2C, + ), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8.0), + bottomRight: Radius.circular(8.0), + ), + ), + ), + menuItemStyleData: const MenuItemStyleData( + height: 40, + ), + iconStyleData: IconStyleData( + icon: isDropDownOpen.value + ? const Icon( + Icons.keyboard_arrow_up, + color: Color(0xFF858E8E), + ) + : const Icon( + Icons.keyboard_arrow_down, + color: Color(0xFF858E8E), + ), + iconSize: 20, + ), + onMenuStateChange: (isOpen) { + isDropDownOpen.value = !isDropDownOpen.value; + }, + ), + ), + ); + } +} + +class CustomItem extends StatelessWidget { + const CustomItem({ + super.key, + required this.name, + }); + + final String name; + + @override + Widget build(BuildContext context) { + return Text( + name, + style: bodyMedium(context), + ); + } +} + +class CustomTextWidget extends StatelessWidget { + const CustomTextWidget({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return Text( + 'Chain Name', + style: bodyMedium(context), + ); + } +} diff --git a/lib/chain/utils/chain_utils.dart b/lib/chain/utils/chain_utils.dart new file mode 100644 index 0000000..a6521b0 --- /dev/null +++ b/lib/chain/utils/chain_utils.dart @@ -0,0 +1,26 @@ +import 'package:faucet/chain/models/chain.dart'; +import 'package:flutter/foundation.dart'; + +import '../models/chains.dart'; + +Chain getMockChain() { + return const Chain( + dataThroughput: 39.887, + averageTransactionFee: 3.71, + uniqueActiveAddresses: 2076, + eon: 2, + era: 5, + epoch: 72109, + totalTransactionsInEpoch: 266, + height: 22100762, + averageBlockTime: 127, + totalStake: .77, + registeredStakes: 519, + activeStakes: 453, + inactiveStakes: 66, + ); +} + +Chains getDefaultChain() { + return kDebugMode ? const Chains.private_network() : const Chains.topl_mainnet(); +} diff --git a/lib/chain/utils/constants.dart b/lib/chain/utils/constants.dart new file mode 100644 index 0000000..9fc3b6c --- /dev/null +++ b/lib/chain/utils/constants.dart @@ -0,0 +1,47 @@ +const int minimumResults = 50; + +// Day +const int chartDaySkipAmount = 10; +final DateTime chartDayEndTime = DateTime.now().subtract( + const Duration(days: 1), +); + +// Week +const int chartWeekSkipAmount = 20; +final DateTime chartWeekEndTime = DateTime.now().subtract( + const Duration(days: 7), +); + +// Two Weeks +const int chartTwoWeekSkipAmount = 40; +final DateTime chartTwoWeekEndTime = DateTime.now().subtract( + const Duration(days: 14), +); + +// Month +const int chartMonthSkipAmount = 80; +final DateTime chartMonthEndTime = DateTime.now().subtract( + const Duration(days: 30), +); + +// Three Months +const int chartThreeMonthSkipAmount = 160; +final DateTime chartThreeMonthEndTime = DateTime.now().subtract( + const Duration(days: 90), +); + +// Six Months +const int chartSixMonthSkipAmount = 320; +final DateTime chartSixMonthEndTime = DateTime.now().subtract( + const Duration(days: 180), +); + +// Year +const int chartYearSkipAmount = 640; +final DateTime chartYearEndTime = DateTime.now().subtract( + const Duration(days: 365), +); + +// All +const int chartAllSkipAmount = 1280; +final DateTime chartAllEndTime = DateTime.fromMillisecondsSinceEpoch(0); diff --git a/lib/home/screens/home_screen.dart b/lib/home/screens/home_screen.dart index 2757449..6176071 100644 --- a/lib/home/screens/home_screen.dart +++ b/lib/home/screens/home_screen.dart @@ -1,11 +1,40 @@ +import 'package:faucet/chain/sections/chainname_dropdown.dart'; +import 'package:faucet/shared/providers/app_theme_provider.dart'; +import 'package:faucet/shared/utils/theme_color.dart'; +import 'package:faucet/shared/widgets/footer.dart'; +import 'package:faucet/shared/widgets/header.dart'; +import 'package:faucet/shared/widgets/layout.dart'; +import 'package:faucet/transactions/sections/transaction_table.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -class HomeScreen extends StatelessWidget { +class HomeScreen extends HookConsumerWidget { static const route = '/'; - const HomeScreen({Key? key}) : super(key: key); + const HomeScreen({Key? key, required this.colorTheme}) : super(key: key); + final ThemeMode colorTheme; @override - Widget build(BuildContext context) { - return Container(); + Widget build(BuildContext context, WidgetRef ref) { + final colorTheme = ref.watch(appThemeColorProvider); + return CustomLayout( + header: Header( + logoAsset: colorTheme == ThemeMode.light ? 'assets/icons/logo.svg' : 'assets/icons/logo_dark.svg', + onSearch: () {}, + onDropdownChanged: (String value) {}, + ), + mobileHeader: ChainNameDropDown( + colorTheme: colorTheme, + ), + content: Container( + decoration: BoxDecoration( + color: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + ), + child: const SizedBox(child: TransactionTableScreen()), + ), + footer: Container( + color: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + child: const Footer(), + ), + ); } } diff --git a/lib/home/sections/get_test_tokens.dart b/lib/home/sections/get_test_tokens.dart new file mode 100644 index 0000000..090c6c2 --- /dev/null +++ b/lib/home/sections/get_test_tokens.dart @@ -0,0 +1,411 @@ +import 'package:faucet/requests/models/request.dart'; +import 'package:faucet/shared/constants/strings.dart'; +import 'package:faucet/shared/theme.dart'; +import 'package:faucet/shared/utils/theme_color.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:easy_web_view/easy_web_view.dart'; + +import '../../requests/providers/requests_provider.dart'; +import '../../shared/constants/network_name.dart'; +import '../../shared/constants/status.dart'; + +List networks = [ + 'Valhalla', + 'Topl Testnet', +]; + +class GetTestTokens extends HookConsumerWidget { + GetTestTokens({Key? key, required this.colorTheme}) : super(key: key); + final TextEditingController textWalletEditingController = TextEditingController(); + final ThemeMode colorTheme; + final toast = FToast(); + + String? selectedNetwork = 'Valhalla'; + + bool isCDropDownOpen = false; + + /// validate is used to validate the input field + bool validate = false; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + final notifier = ref.watch(requestProvider.notifier); + + return Container( + decoration: BoxDecoration(color: getSelectedColor(colorTheme, 0xFFFFFFFF, 0xFF282A2C)), + child: Padding( + padding: EdgeInsets.all(isMobile ? 16.0 : 40.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Strings.getTestNetwork, + style: headlineLarge(context), + ), + const SizedBox( + height: 48, + ), + Container( + height: 64, + width: 560, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: const Color.fromRGBO(112, 64, 236, 0.04), + ), + child: Row( + children: [ + const SizedBox(width: 16), + Icon( + Icons.warning_amber, + color: getSelectedColor(colorTheme, 0xFF7040EC, 0xFF7040EC), + size: 24, + ), + const SizedBox(width: 16), + Expanded( + child: SizedBox( + width: 450, + child: Text( + 'Confirm details before submitting', + style: TextStyle( + fontSize: 14, + fontFamily: 'Rational Display', + fontWeight: FontWeight.w300, + color: getSelectedColor(colorTheme, 0xFF7040EC, 0xFF7040EC), + ), + ), + ), + ), + ], + ), + ), + const SizedBox( + height: 48, + ), + TextField( + controller: TextEditingController(text: 'LVL'), + enabled: false, + style: bodyMedium(context), + decoration: InputDecoration( + labelText: 'Tokens', + labelStyle: bodyMedium(context), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: getSelectedColor(colorTheme, 0xFFC0C4C4, 0xFF858E8E), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: getSelectedColor(colorTheme, 0xFFC0C4C4, 0xFF858E8E), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: getSelectedColor(colorTheme, 0xFFC0C4C4, 0xFF858E8E), + ), + ), + ), + ), + const SizedBox( + height: 24, + ), + DropdownButton2( + hint: const Text( + 'Select a Network', + style: TextStyle( + fontSize: 16, + fontFamily: 'Rational Display', + color: Color(0xFF858E8E), + ), + ), + style: bodyMedium(context), + underline: Container( + height: 0, + ), + buttonStyleData: ButtonStyleData( + height: 56, + width: 560, + padding: const EdgeInsets.only(left: 14, right: 14), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8.0), + border: Border.all( + color: getSelectedColor(colorTheme, 0xFFC0C4C4, 0xFF4B4B4B), + ), + color: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + ), + ), + dropdownStyleData: DropdownStyleData( + maxHeight: 200, + decoration: BoxDecoration( + color: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + borderRadius: const BorderRadius.only( + bottomLeft: Radius.circular(8.0), + bottomRight: Radius.circular(8.0), + ), + ), + ), + menuItemStyleData: const MenuItemStyleData( + height: 40, + ), + iconStyleData: IconStyleData( + icon: isCDropDownOpen + ? const Icon( + Icons.keyboard_arrow_up, + color: Color(0xFF858E8E), + ) + : const Icon( + Icons.keyboard_arrow_down, + color: Color(0xFF858E8E), + ), + iconSize: 20, + ), + value: selectedNetwork, + // Array list of items + items: networks.map((String items) { + return DropdownMenuItem( + value: items, + child: Text(items), + ); + }).toList(), + onChanged: (value) { + selectedNetwork = value as String; + }, + onMenuStateChange: (isOpen) { + isCDropDownOpen = !isCDropDownOpen; + }, + ), + const SizedBox( + height: 24, + ), + TextField( + // TODO: Add to accept the address format only + controller: textWalletEditingController, + style: bodyMedium(context), + decoration: InputDecoration( + labelText: 'Wallet Address', + labelStyle: bodyMedium(context), + hintText: '0xxxxxxxxxxxxxxxxxxxxxxxxx', + suffix: TextButton( + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFC0C4C4), + padding: const EdgeInsets.all(16.0), + textStyle: const TextStyle(fontSize: 16), + ), + onPressed: () async { + final copiedData = await Clipboard.getData('text/plain'); + textWalletEditingController.value = TextEditingValue( + text: copiedData?.text ?? '', + selection: TextSelection.fromPosition( + TextPosition(offset: copiedData?.text?.length ?? 0), + ), + ); + }, + child: const Text('Paste'), + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: getSelectedColor(colorTheme, 0xFFC0C4C4, 0xFF858E8E), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: getSelectedColor(colorTheme, 0xFFC0C4C4, 0xFF858E8E), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide( + color: getSelectedColor(colorTheme, 0xFFC0C4C4, 0xFF858E8E), + ), + ), + ), + ), + const SizedBox( + height: 8, + ), + Stack(children: [ + SizedBox( + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Text( + validate ? "This field is required" : '', + style: titleSmall(context), + ), + ], + ), + ), + SizedBox( + height: !isMobile ? 30 : null, + ), + const SizedBox( + height: 200, + child: Positioned( + key: Key('recaptcha-widget'), + left: 0, + top: 0, + child: EasyWebView( + src: + 'assets/webpages/index.html', //TODO: direct asset for testing use: http://localhost:PORT/assets/webpages/index.html + key: Key('recaptcha-widget'), + convertToMarkdown: false, + isMarkdown: false, // Use markdown syntax + convertToWidgets: false, // Try to convert to flutter widgets + height: 150, + ))), + ]), + Padding( + padding: const EdgeInsets.only(top: 64.0), + child: Row( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Expanded( + child: SizedBox( + width: isMobile ? 100 : 272, + height: 56, + child: TextButton( + onPressed: () { + Navigator.pop(context); + }, + child: Text( + 'Cancel', + style: titleSmall(context), + )), + ), + ), + const SizedBox( + width: 16, + ), + Expanded( + child: SizedBox( + width: isMobile ? 100 : 272, + height: 56, + child: ElevatedButton( + onPressed: () { + notifier.makeRequest( + context, + Request( + network: NetworkName.testnet, + walletAddress: textWalletEditingController.text, + status: Status.confirmed, + dateTime: DateTime.now(), + tokensDisbursed: 100, + ), + ); + }, + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + const Color(0xFF0DC8D4), + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + child: Text( + Strings.getLVL, + style: titleSmall(context)!.copyWith(color: Colors.white), + ), + ), + ), + ), + ], + ), + ) + ], + ), + ), + ); + } +} + +class CustomToast extends StatelessWidget { + const CustomToast({ + Key? key, + required this.widget, + required this.cancel, + required this.isSuccess, + }) : super(key: key); + + final GetTestTokens widget; + final VoidCallback cancel; + final bool isSuccess; + + @override + Widget build(BuildContext context) { + final isTablet = ResponsiveBreakpoints.of(context).between(TABLET, DESKTOP); + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + + return Container( + height: 64, + width: isTablet ? 500 : 345, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: getSelectedColor(widget.colorTheme, 0xFFFEFEFE, 0xFF282A2C), + border: Border.all( + color: getSelectedColor(widget.colorTheme, 0xFFE0E0E0, 0xFF858E8E), + ), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + spreadRadius: 2, + blurRadius: 5, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + const SizedBox(width: 16), + isSuccess + ? const Icon( + Icons.check, + color: Colors.green, + size: 24, + ) + : const Icon( + Icons.warning_amber, + color: Colors.red, + size: 24, + ), + const SizedBox(width: 16), + Expanded( + child: SizedBox( + width: 450, + child: Text( + isSuccess + ? "Network was added ${isMobile ? '\n' : ""} successfully" + : "Something went wrong... ${isMobile ? '\n' : ""} Please try again later", + style: bodyMedium(context), + ), + ), + ), + const SizedBox( + width: 20, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: cancel, + ), + ], + ), + ); + } +} + +// TODO: Recapcha implementation +//https://pub.dev/packages/easy_web_view +//https://developers.google.com/recaptcha/docs/v3 diff --git a/lib/main.dart b/lib/main.dart index eef1a71..ebdcd79 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,18 +1,40 @@ import 'package:faucet/home/screens/home_screen.dart'; +import 'package:faucet/shared/constants/ui.dart'; import 'package:faucet/shared/providers/app_theme_provider.dart'; import 'package:faucet/shared/theme.dart'; import 'package:flutter/material.dart'; +import 'package:hive_flutter/hive_flutter.dart'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:vrouter/vrouter.dart'; +import 'package:responsive_framework/responsive_framework.dart'; + +void main() async { + await Hive.initFlutter(); -void main() { runApp( const ProviderScope( - child: FaucetRouter(), + child: ResponsiveBreakPointsWrapper(), ), ); } +class ResponsiveBreakPointsWrapper extends StatelessWidget { + const ResponsiveBreakPointsWrapper({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + return ResponsiveBreakpoints.builder( + child: const FaucetRouter(), + breakpoints: const [ + Breakpoint(start: 0, end: mobileBreak, name: MOBILE), + Breakpoint(start: mobileBreak + 1, end: tabletBreak, name: TABLET), + Breakpoint(start: tabletBreak + 1, end: double.infinity, name: DESKTOP), + ], + ); + } +} + class FaucetRouter extends HookConsumerWidget { const FaucetRouter({ Key? key, @@ -20,19 +42,24 @@ class FaucetRouter extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - return VRouter( - debugShowCheckedModeBanner: false, - title: 'Faucet', - initialUrl: HomeScreen.route, - theme: lightTheme(context: context), - darkTheme: darkTheme(context: context), - themeMode: ref.watch(appThemeColorProvider), - routes: [ - VWidget( - path: HomeScreen.route, - widget: const HomeScreen(), - ), - ], - ); + final breakPoints = ResponsiveBreakpoints.of(context).breakpoints; + return breakPoints.isEmpty + ? Container() + : VRouter( + debugShowCheckedModeBanner: false, + title: 'Faucet', + initialUrl: HomeScreen.route, + theme: lightTheme(context: context), + darkTheme: darkTheme(context: context), + themeMode: ref.watch(appThemeColorProvider), + routes: [ + VWidget( + path: HomeScreen.route, + widget: HomeScreen( + colorTheme: ref.watch(appThemeColorProvider), + ), + ), + ], + ); } } diff --git a/lib/requests/models/request.dart b/lib/requests/models/request.dart new file mode 100644 index 0000000..e697593 --- /dev/null +++ b/lib/requests/models/request.dart @@ -0,0 +1,36 @@ +// Flutter imports +import 'package:flutter/foundation.dart'; + +// Package imports: +import 'package:freezed_annotation/freezed_annotation.dart'; + +import 'package:faucet/shared/constants/status.dart'; +import 'package:faucet/shared/constants/network_name.dart'; + +part 'request.freezed.dart'; +part 'request.g.dart'; + +@freezed +class Request with _$Request { + const factory Request({ + /// String representing the transactionId + String? transactionId, + + /// String representing the network. + required NetworkName network, + + /// A String representing the wallet address a request was made to + required String walletAddress, + + /// Enum representing status of request + required Status status, + + /// DateTime representing when the request was created + required DateTime dateTime, + + /// double representing the amount of tokens in a request + required double tokensDisbursed, + }) = _Request; + + factory Request.fromJson(Map json) => _$RequestFromJson(json); +} diff --git a/lib/requests/providers/graph_provider.dart b/lib/requests/providers/graph_provider.dart new file mode 100644 index 0000000..e69de29 diff --git a/lib/requests/providers/rate_limit_provider.dart b/lib/requests/providers/rate_limit_provider.dart new file mode 100644 index 0000000..9f26d7a --- /dev/null +++ b/lib/requests/providers/rate_limit_provider.dart @@ -0,0 +1,57 @@ +import 'package:faucet/shared/services/hive/hive_service.dart'; +import 'package:faucet/shared/services/hive/hives.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +const rateLimitDuration = Duration(minutes: 30); + +/// A provider that calculates the remaining time until the rate limit resets. +/// +/// Returns the remaining time until the rate limit resets as a [Duration] object. +/// If the remaining time is negative, returns null to indicate that the rate limit has already reset. +final remainingRateLimitTimeProvider = Provider((ref) { + // Get the current rate limit from the rateLimitProvider. + final DateTime rateLimit = ref.watch(rateLimitProvider); + + // Calculate the remaining time until the rate limit resets. + final Duration remainingRateLimitTime = rateLimitDuration - DateTime.now().difference(rateLimit); + + // If the remaining time is negative, return null to indicate that the rate limit has already reset. + if (remainingRateLimitTime.isNegative) { + return null; + } + + // Return the remaining time until the rate limit resets. + return remainingRateLimitTime; +}); + +/// A [StateNotifierProvider] that provides the current rate limit as a [DateTime] object. +final rateLimitProvider = StateNotifierProvider((ref) { + return RateLimitNotifier(ref); +}); + +class RateLimitNotifier extends StateNotifier { + final Ref ref; + + static const String rateLimitHiveKey = 'rateLimitHiveKey'; + + RateLimitNotifier(this.ref) : super(DateTime.now()) { + _getRateLimitFromCache(); + } + + /// Retrieves the rate limit from the cache and updates the state if it exists. + Future _getRateLimitFromCache() async { + final rateLimit = await ref.read(hiveProvider).getItem(key: rateLimitHiveKey, boxType: HivesBox.customChains); + if (rateLimit != null) { + state = rateLimit; + } + } + + /// Updates the rate limit in the cache and sets the state to the current time. + Future setRateLimit({ + DateTime? rateLimit, + }) async { + rateLimit ??= DateTime.now(); + await ref.read(hiveProvider).putItem(key: rateLimitHiveKey, value: rateLimit, boxType: HivesBox.customChains); + state = DateTime.now(); + } +} diff --git a/lib/requests/providers/requests_provider.dart b/lib/requests/providers/requests_provider.dart new file mode 100644 index 0000000..7bce581 --- /dev/null +++ b/lib/requests/providers/requests_provider.dart @@ -0,0 +1,278 @@ +import 'package:faucet/shared/constants/strings.dart'; +import 'package:flutter/material.dart'; +import 'package:faucet/requests/models/request.dart'; +import 'package:faucet/requests/providers/rate_limit_provider.dart'; +import 'package:flutter/services.dart'; +import 'package:fluttertoast/fluttertoast.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'package:faucet/requests/utils/get_mock_requests.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; +import 'package:responsive_framework/responsive_row_column.dart'; + +import '../../shared/theme.dart'; + +//Future provider used for pagination of requests +final requestStateAtIndexProvider = FutureProvider.family((ref, index) async { + return ref.watch(requestProvider.notifier).getRequestFromStateAtIndex(index); +}); + +final requestProvider = StateNotifierProvider>>((ref) { + return RequestNotifier( + ref, + ); +}); + +class RequestNotifier extends StateNotifier>> { + final Ref ref; + DateTime? lastRequestTime; + + RequestNotifier( + this.ref, + ) : super( + const AsyncLoading(), + ) { + getRequests(setState: true); + } + + /// It takes a bool [setState] + /// + /// If [setState] is true, it will update the state of the provider + /// If [setState] is false, it will not update the state of the provider + Future> getRequests({bool setState = false}) async { + if (setState) state = const AsyncLoading(); + + //get mock requests + List requests = getMockRequests(10); + + // Adding delay here to simulate API call + if (setState) { + Future.delayed( + const Duration(seconds: 1), + () { + // Do API call here + state = AsyncData(requests); + }, + ); + } + + return requests; + } + + /// This method is used to get a request at a specific index + /// If the request is not in the state, it will fetch the request + /// It takes an [index] as a parameter + /// + /// It returns a [Future] + Future getRequestFromStateAtIndex(int index) async { + final requests = state.asData?.value; + + if (requests == null) { + throw Exception('Error in requestProvider: requests are null'); + } + + // If the index is less than the length of the list, return the request at that index + if (index <= requests.length) { + return requests[index]; + } else { + throw Exception('Error in requestProvider: no more requests'); + } + } + + /// This method is used to get a request at a specific index + /// If the request is not in the state, it will fetch the request + /// It takes an [index] as a parameter + /// + /// It returns a [Future] + Future makeRequest(BuildContext context, Request requestToMake) async { + try { + // Check if rate limit is reached + if (lastRequestTime != null) { + final currentTime = DateTime.now(); + final timeElapsed = currentTime.difference(lastRequestTime!); + if (timeElapsed < const Duration(minutes: 30)) { + throw Exception('Rate limit reached. Please try again in 30 minutes.'); + } + } + lastRequestTime = DateTime.now(); // Update the last request timestamp + + final requests = state.asData?.value; + if (requests == null) { + throw Exception('Error in requestProvider: requests are null'); + } + + //make request using provided parameters + var submittedRequest = requestToMake.copyWith(transactionId: '28EhwUBiHJ3evyGidV1WH8QMfrLF6N8UDze9Yw7jqi6w'); + requests.add(submittedRequest); + state = AsyncData([...requests]); + + ref.read(rateLimitProvider.notifier).setRateLimit(); + _successDialogBuilder(context); + return submittedRequest; + } catch (e) { + errorDialogBuilder(context, e.toString()); + throw Exception(e); + } + } +} + +Future errorDialogBuilder(BuildContext context, String message) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: ResponsiveRowColumn( + layout: isMobile ? ResponsiveRowColumnType.COLUMN : ResponsiveRowColumnType.ROW, + rowMainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ResponsiveRowColumnItem( + child: Text( + 'Something went wrong...', + style: titleLarge(context), + ), + ), + ResponsiveRowColumnItem( + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ], + ), + content: SizedBox( + width: 400.0, + child: Text( + message, + style: bodyMedium(context), + ), + ), + actionsPadding: const EdgeInsets.all(16), + ); + }, + ); +} + +Future _successDialogBuilder(BuildContext context) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + String transactionHash = 'a1075db55d416d3ca199f55b6e2115b9345e16c5cf302fc80'; + + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + title: ResponsiveRowColumn( + layout: isMobile ? ResponsiveRowColumnType.COLUMN : ResponsiveRowColumnType.ROW, + rowMainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ResponsiveRowColumnItem( + child: Text( + '${Strings.statusConfirmed}!', + style: titleLarge(context), + ), + ), + ResponsiveRowColumnItem( + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ], + ), + content: SizedBox( + width: 550.0, + height: 300.0, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Strings.requestSuccessful, + style: bodyMedium(context), + ), + const SizedBox(height: 30), + Text( + Strings.txnHash, + style: titleSmall(context), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 20), + width: double.infinity, + alignment: Alignment.centerLeft, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: const Color.fromRGBO(112, 64, 236, 0.04), + ), + child: ResponsiveRowColumn( + layout: isTablet ? ResponsiveRowColumnType.COLUMN : ResponsiveRowColumnType.ROW, + children: [ + ResponsiveRowColumnItem( + child: SelectableText( + transactionHash, + style: bodyMedium(context), + ), + ), + const ResponsiveRowColumnItem( + child: SizedBox(width: 8), + ), + ResponsiveRowColumnItem( + child: IconButton( + icon: const Icon( + Icons.copy, + color: Color(0xFF858E8E), + ), + onPressed: () { + Clipboard.setData( + ClipboardData(text: transactionHash), + ); + FToast().showToast( + child: const Text(Strings.copyToClipboard), + gravity: ToastGravity.BOTTOM, + toastDuration: const Duration(seconds: 2), + ); + }, + ), + ) + ], + ), + ), + const SizedBox(height: 30), + SizedBox( + width: double.infinity, + child: TextButton( + onPressed: () { + // TODO: Add link to explorer using url_launcher + }, + style: ButtonStyle( + padding: MaterialStateProperty.all( + const EdgeInsets.symmetric(horizontal: 24, vertical: 24), + ), + backgroundColor: MaterialStateProperty.all( + const Color(0xFF0DC8D4), + ), + shape: MaterialStateProperty.all( + RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + ), + ), + child: Text( + Strings.viewExplorer, + style: titleSmall(context)!.copyWith(color: Colors.white), + ), + ), + ) + ], + ), + ), + actionsPadding: const EdgeInsets.all(4), + ); + }, + ); +} diff --git a/lib/requests/utils/get_mock_requests.dart b/lib/requests/utils/get_mock_requests.dart new file mode 100644 index 0000000..ac894fa --- /dev/null +++ b/lib/requests/utils/get_mock_requests.dart @@ -0,0 +1,21 @@ +import 'package:faucet/requests/models/request.dart'; +import 'package:faucet/shared/constants/status.dart'; +import 'package:faucet/shared/constants/network_name.dart'; + +List getMockRequests(int reqNumber) { + List requests = []; + + for (int i = 0; i < reqNumber; i++) { + requests.add( + Request( + network: NetworkName.testnet, + walletAddress: '${i.toString()}8EhwUBiHJ3evyGidV1WH8QMfrLF6N8UDze9Yw7jqi6w', + status: Status.confirmed, + dateTime: DateTime.now(), + tokensDisbursed: i.toDouble(), + ), + ); + } + + return requests; +} diff --git a/lib/search/models/search_result.dart b/lib/search/models/search_result.dart new file mode 100644 index 0000000..06ee4ca --- /dev/null +++ b/lib/search/models/search_result.dart @@ -0,0 +1,23 @@ +import 'package:faucet/blocks/models/block.dart'; +import 'package:faucet/transactions/models/transaction.dart'; +import 'package:faucet/transactions/models/utxo.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'search_result.freezed.dart'; + +@freezed +class SearchResult with _$SearchResult { + const factory SearchResult.transaction( + Transaction transaction, + String id, + ) = TransactionResult; + const factory SearchResult.block( + Block block, + String id, + ) = BlockResult; + + const factory SearchResult.uTxO( + UTxO utxo, + String id, + ) = UTxOResult; +} diff --git a/lib/search/providers/search_provider.dart b/lib/search/providers/search_provider.dart new file mode 100644 index 0000000..d2a1dc8 --- /dev/null +++ b/lib/search/providers/search_provider.dart @@ -0,0 +1,174 @@ +import 'package:faucet/blocks/models/block.dart'; +import 'package:faucet/blocks/providers/block_provider.dart'; +import 'package:faucet/blocks/utils/extensions.dart'; +import 'package:faucet/blocks/utils/utils.dart'; +import 'package:faucet/chain/models/chains.dart'; +import 'package:faucet/chain/providers/selected_chain_provider.dart'; +import 'package:faucet/search/models/search_result.dart'; +import 'package:faucet/shared/models/logger.dart'; +import 'package:faucet/shared/providers/genus_provider.dart'; +import 'package:faucet/shared/providers/logger_provider.dart'; +import 'package:faucet/transactions/models/transaction.dart'; +import 'package:faucet/transactions/models/utxo.dart'; +import 'package:faucet/transactions/providers/transactions_provider.dart'; +import 'package:faucet/transactions/providers/utxo_provider.dart'; +import 'package:faucet/transactions/utils/extension.dart'; +import 'package:faucet/transactions/utils/utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:topl_common/proto/genus/genus_rpc.pbgrpc.dart'; + +/// This provider is used to determine if the RPC search results are loading. +final isLoadingRpcSearchResultsProvider = StateProvider((ref) { + return true; +}); + +/// This provider is used to search for a [SearchResult] by an ID. +/// It returns a [List] of [SearchResult] objects. +final searchProvider = StateNotifierProvider>((ref) { + final selectedChain = ref.watch(selectedChainProvider); + return SearchNotifier(ref, selectedChain); +}); + +class SearchNotifier extends StateNotifier> { + final Ref ref; + final Chains selectedChain; + SearchNotifier( + this.ref, + this.selectedChain, + ) : super(const []); + + /// This method is used to search for a [SearchResult] by an ID. + /// It first searches the current state for a match, then it searches the RPC. + Future searchById(String id) async { + _searchState(id); + await _searchRpc(id); + } + + /// Searches the current state for matching blocks and transactions. + _searchState(String id) { + final currentBlocks = ref.read(blockProvider).asData?.value; + final blockResults = []; + if (currentBlocks != null) { + final matchingCurrentBlocks = + currentBlocks.values.where((Block element) => element.header.toLowerCase().contains(id.toLowerCase())); + if (matchingCurrentBlocks.isNotEmpty) { + blockResults.addAll(matchingCurrentBlocks + .map((Block block) => BlockResult( + block, + block.header, + )) + .toList()); + } + } + + final currentTransactions = ref.read(transactionsProvider).asData?.value; + final transactionResults = []; + if (currentTransactions != null) { + final matchingCurrentTransactions = currentTransactions + .where((Transaction element) => element.transactionId.toLowerCase().contains(id.toLowerCase())); + if (matchingCurrentTransactions.isNotEmpty) { + transactionResults.addAll(matchingCurrentTransactions + .map((Transaction transaction) => TransactionResult( + transaction, + transaction.transactionId, + )) + .toList()); + } + } + + state = [...blockResults, ...transactionResults]; + } + + /// Searches for a block, transaction, and UTxO by ID using RPC. + Future _searchRpc(String id) async { + ref.read(isLoadingRpcSearchResultsProvider.notifier).state = true; + final List results = []; + + final BlockResult? block = await _searchForBlockById(id); + final TransactionResult? transaction = await _searchForTransactionById(id); + final UTxOResult? utxo = await _searchForUTxOById(id); + + results.addAll([if (block != null) block, if (transaction != null) transaction, if (utxo != null) utxo]); + ref.read(isLoadingRpcSearchResultsProvider.notifier).state = false; + + state = [...state, ...results]; + } + + Future _searchForBlockById(String id) async { + try { + if (selectedChain == Chains.mock) { + return Future.delayed(const Duration(milliseconds: 250), () { + return BlockResult( + getMockBlock(), + getMockBlock().header, + ); + }); + } else { + final BlockResponse blockResponse = + await ref.read(genusProvider(selectedChain)).getBlockById(blockIdString: id); + return BlockResult(blockResponse.toBlock(), blockResponse.toBlock().header); + } + } catch (e) { + ref.read(loggerProvider).log( + logLevel: LogLevel.Warning, + loggerClass: LoggerClass.ApiError, + message: 'Block not found for id: $id', + ); + + return null; + } + } + + Future _searchForTransactionById(String id) async { + try { + if (selectedChain == Chains.mock) { + return Future.delayed(const Duration(milliseconds: 250), () { + return TransactionResult( + getMockTransaction(), + getMockTransaction().transactionId, + ); + }); + } else { + final TransactionResponse response = + await ref.read(genusProvider(selectedChain)).getTransactionById(transactionIdString: id); + + return TransactionResult( + response.toTransaction(), + response.toTransaction().transactionId, + ); + } + } catch (e) { + ref.read(loggerProvider).log( + logLevel: LogLevel.Warning, + loggerClass: LoggerClass.ApiError, + message: 'Transaction not found for id: $id', + ); + + return null; + } + } + + // TODO: Are we sure that the id is a int? + Future _searchForUTxOById(String id) async { + try { + final UTxO utxo = await ref.read(utxoByIdProvider( + id, + ).future); + + return UTxOResult(utxo, utxo.utxoId); + } catch (e) { + ref.read(loggerProvider).log( + logLevel: LogLevel.Warning, + loggerClass: LoggerClass.ApiError, + message: 'UTxO not found for id: $id', + ); + + return null; + } + } + + /// Clears the search results by setting the [state] to an empty list. + void clearSearch() { + state = []; + } +} diff --git a/lib/search/sections/custom_search_bar.dart b/lib/search/sections/custom_search_bar.dart new file mode 100644 index 0000000..176b73c --- /dev/null +++ b/lib/search/sections/custom_search_bar.dart @@ -0,0 +1,176 @@ +import 'dart:async'; + +import 'package:faucet/search/models/search_result.dart'; +import 'package:faucet/search/sections/search_results.dart'; +import 'package:faucet/shared/constants/strings.dart'; +import 'package:faucet/shared/theme.dart'; +import 'package:faucet/shared/utils/nav_utils.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../providers/search_provider.dart'; +import '../../shared/utils/debouncer.dart'; +import '../../shared/utils/theme_color.dart'; + +/// A custom search bar widget that displays a search bar and a list of +class CustomSearchBar extends HookConsumerWidget { + static const searchKey = Key('searchKey'); + const CustomSearchBar({ + Key? key, + required this.onSearch, + required this.colorTheme, + }) : super(key: key); + + final VoidCallback onSearch; + final ThemeMode colorTheme; + + /// Shows the overlay or dropdown on the search bar + void showOverlay( + BuildContext context, + ValueNotifier entry, + Function(SearchResult) resultSelected, + ) { + final overlay = Overlay.of(context); + final renderBox = context.findRenderObject() as RenderBox; + final size = renderBox.size; + final offset = renderBox.localToGlobal(Offset.zero); + entry.value = OverlayEntry( + builder: (context) => Positioned( + left: offset.dx, + top: offset.dy + size.height, + width: size.width, + child: SearchResults( + resultSelected: resultSelected, + ), + ), + ); + + overlay.insert(entry.value!); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final layerLink = LayerLink(); + + /// The controller used to control the search text field. + final searchController = useTextEditingController(); + final searchText = useState(''); + + final ValueNotifier entry = useState(null); + + /// This is used to show or hide the search results. + /// + /// When the search text field is not empty and the old value is empty, + /// the search results are shown. + /// + /// When the search text field is empty and the old value is not empty, + /// the search results are hidden. + useValueChanged(searchText.value, (oldValue, oldResult) { + if (oldValue.isEmpty && searchText.value.isNotEmpty) { + Future.delayed(Duration.zero, () { + showOverlay(context, entry, (SearchResult result) { + entry.value?.remove(); + entry.value = null; + searchText.value = ''; + searchController.clear(); + result.map( + transaction: (transaction) { + goToTransactionDetails( + context: context, + transaction: transaction.transaction, + ); + }, + block: (block) { + goToBlockDetails( + context: context, + block: block.block, + colorTheme: colorTheme, + ); + }, + uTxO: (uTxO) { + goToUtxoDetails(); + }, + ); + }); + }); + } else if (oldValue.isNotEmpty && searchText.value.isEmpty) { + entry.value?.remove(); + } + return searchText.value; + }); + + /// The debouncer used to debounce the search text field. + final Debouncer searchDebouncer = Debouncer(milliseconds: 200); + + final searchNotifier = ref.read(searchProvider.notifier); + + // Performs a search by calling `searchNotifier.searchById` with the given ID, + // processes the results, and updates relevant values and lists. + Future performSearch(String id) async { + searchNotifier.searchById(id); + } + + /// Runs the search debouncer with the given ID and prints the results. + Future searchByIdAndPrintResults(String id) async { + searchDebouncer.run(() => performSearch(id)); + } + + /// The focus node used to control the search text field. + /// When the focus node loses focus, the search text field is cleared. + final searchFocusNode = useFocusNode(); + + useEffect(() { + searchFocusNode.addListener(() {}); + return null; + }, []); + + return CompositedTransformTarget( + link: layerLink, + child: Row( + children: [ + Expanded( + child: TextField( + key: searchKey, + style: bodyMedium(context), + controller: searchController, + onSubmitted: (_) { + searchFocusNode.requestFocus(); + }, + focusNode: searchFocusNode, + onChanged: (value) { + searchText.value = value; + if (value.isNotEmpty) { + searchByIdAndPrintResults(value); + } + }, + decoration: InputDecoration( + hintText: Strings.searchHintText, + hintStyle: bodyMedium(context), + prefixIcon: Icon( + Icons.search, + color: getSelectedColor(colorTheme, 0xFFC0C4C4, 0xFF4B4B4B), + ), + enabledBorder: OutlineInputBorder( + borderSide: BorderSide( + color: getSelectedColor(colorTheme, 0xFFC0C4C4, 0xFF4B4B4B), + width: 1.0, + ), + borderRadius: BorderRadius.circular(8.0), + ), + border: const OutlineInputBorder(), + focusColor: const Color(0xFF4B4B4B), + focusedBorder: OutlineInputBorder( + borderSide: BorderSide( + color: getSelectedColor(colorTheme, 0xFF4B4B4B, 0xFF858E8E), + width: 1.0, + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/search/sections/search_results.dart b/lib/search/sections/search_results.dart new file mode 100644 index 0000000..9d5ddcc --- /dev/null +++ b/lib/search/sections/search_results.dart @@ -0,0 +1,95 @@ +import 'package:faucet/search/models/search_result.dart'; +import 'package:faucet/search/providers/search_provider.dart'; +import 'package:faucet/shared/constants/strings.dart'; +import 'package:faucet/shared/providers/app_theme_provider.dart'; +import 'package:faucet/shared/theme.dart'; +import 'package:faucet/shared/utils/theme_color.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +class SearchResults extends HookConsumerWidget { + static const searchResultsKey = Key('searchResultsKey'); + final Function(SearchResult) resultSelected; + const SearchResults({ + required this.resultSelected, + Key key = searchResultsKey, + }) : super(key: key); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorTheme = ref.watch(appThemeColorProvider); + List results = ref.watch(searchProvider); + bool isLoading = ref.watch(isLoadingRpcSearchResultsProvider); + return Material( + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: const Color.fromARGB(255, 206, 206, 206), + width: 1.0, + ), + borderRadius: BorderRadius.circular(8.0), + ), + child: SizedBox( + height: 200, + child: ListView( + children: [ + // List items from results + for (SearchResult suggestion in results) + _SearchResultItem( + suggestion: suggestion, + colorTheme: colorTheme, + resultSelected: resultSelected, + ), + if (isLoading) + const Center( + child: CircularProgressIndicator(), + ), + if (results.isEmpty && !isLoading) + const Center( + child: Padding( + padding: EdgeInsets.only(top: 10.0), + child: Text(Strings.noResultsFound), + ), + ), + ], + ), + ), + ), + ); + } +} + +class _SearchResultItem extends StatelessWidget { + final SearchResult suggestion; + final ThemeMode colorTheme; + final Function(SearchResult) resultSelected; + const _SearchResultItem({ + required this.suggestion, + required this.colorTheme, + required this.resultSelected, + Key? key, + }) : super(key: key); + + String itemType() { + return suggestion.map( + transaction: (_) => 'Transaction', + block: (_) => 'Block', + uTxO: (_) => 'Utxo', + ); + } + + @override + Widget build(BuildContext context) { + return ListTile( + title: Text('${itemType()} ${suggestion.id}', style: bodyMedium(context)), + textColor: getSelectedColor( + colorTheme, + 0xFF4B4B4B, + 0xFF858E8E, + ), + onTap: () { + resultSelected(suggestion); + }, + ); + } +} diff --git a/lib/shared/constants/network_name.dart b/lib/shared/constants/network_name.dart new file mode 100644 index 0000000..b37ba47 --- /dev/null +++ b/lib/shared/constants/network_name.dart @@ -0,0 +1,7 @@ +enum NetworkName { + mainnet('Mainnet'), + testnet('Testnet'); + + const NetworkName(this.value); + final String value; +} diff --git a/lib/shared/constants/numbers.dart b/lib/shared/constants/numbers.dart new file mode 100644 index 0000000..604f9f4 --- /dev/null +++ b/lib/shared/constants/numbers.dart @@ -0,0 +1,7 @@ +/// All the numbers that are being used throughout the app. + +class Numbers { + Numbers._(); + + static const int textLength = 30; +} diff --git a/lib/shared/constants/status.dart b/lib/shared/constants/status.dart new file mode 100644 index 0000000..892ca0b --- /dev/null +++ b/lib/shared/constants/status.dart @@ -0,0 +1,8 @@ +enum Status { + pending('pending'), + confirmed('confirmed'), + failed('failed'); + + const Status(this.value); + final String value; +} diff --git a/lib/shared/constants/strings.dart b/lib/shared/constants/strings.dart new file mode 100644 index 0000000..7a324d5 --- /dev/null +++ b/lib/shared/constants/strings.dart @@ -0,0 +1,154 @@ +/// All the strings that are being used throughout the app. +class Strings { + Strings._(); + + static const String appName = 'FAUCET'; + static const String topl = 'TOPL'; + static const String submitForm = 'Submit Form'; + static const String latestBlocks = 'Latest Blocks'; + static const String multipleChainEmbedding = 'Multiple-chain Embedding'; + static const String noBlocksLoaded = 'No blocks loaded'; + static const String blockDetails = 'Block Details'; + static const String tableHeaderType = 'TYPE'; + static const String tableHeaderBlock = 'BLOCK'; + static const String tableHeaderTxnHashId = 'TXN HASH/ID'; + static const String tableHeaderSummary = 'SUMMARY'; + static const String tableHeaderFee = 'FEE'; + static const String epoch = 'Epoch'; + static const String header = 'Header'; + static const String utc = 'UTC'; + static const String secAgo = 'sec ago'; + static const String height = 'Height'; + static const String seeAllTransactions = 'See All Transactions'; + static const String tableTokens = 'TOKENS'; + static const String tableNetwork = 'NETWORK'; + static const String tableWallet = 'WALLET'; + static const String tableDateAndTime = 'DATE AND TIME'; + static const String tableStatus = 'STATUS'; + static const String feeAcronym = 'LVL'; + static const String bobs = 'BOBS'; + static const String slot = 'Slot'; + static const String email = 'Email'; + static const String emailHint = 'Enter your email'; + static const String emailError = 'Please enter a valid email'; + static const String subscribe = 'Subscribe'; + static const String tableHeaderStatus = 'STATUS'; + static const String statusPending = 'Pending'; + static const String statusConfirmed = 'Confirmed'; + static const String statusFailed = 'Failed'; + static const String footerColumn1Header = 'Protocol'; + static const String footerColumn2Header = 'Solutions'; + static const String footerColumn3Header = 'Blockchain'; + static const String footerColumn4Header = 'About'; + static const String footerColumn5Header = 'Subscribe to Our Newsletter'; + static const String footerColumn5Button = 'Submit'; + static const String footerColumn1Item1 = 'Energy Efficient Regularized PoS'; + static const String footerColumn1Item2 = 'UTxO Ledger and Achieving Scalability'; + static const String footerColumn1Item3 = 'User Ecosystem, Standards and Development'; + static const String footerColumn1Item4 = 'Tokenomics'; + static const String footerColumn1Item5 = 'Governance'; + static const String footerColumn1Item6 = 'Ribn'; + static const String footerColumn2Item1 = 'ToplTrax'; + static const String footerColumn2Item2 = 'Traceable Journey'; + static const String footerColumn2Item3 = 'Smart Labels'; + static const String footerColumn2Item4 = 'Impact NFTs'; + static const String footerColumn2Item5 = 'Blockchain-As-A-Service'; + static const String footerColumn3Item1 = 'Buid'; + static const String footerColumn3Item2 = 'Brambl'; + static const String footerColumn3Item3 = 'Grant Programs'; + static const String footerColumn3Item4 = 'Node Setup'; + static const String footerColumn3Item5 = 'Knowledge Base'; + static const String footerColumn4Item1 = 'Team'; + static const String footerColumn4Item2 = 'Community'; + static const String footerColumn4Item3 = 'Careers'; + static const String footerColumn4Item4 = 'Press and Media'; + static const String footerColumn4Item5 = 'Contact Us'; + static const String footerPrivacyPolicy = 'Topl Privacy Policy'; + static const String footerTermsOfUse = 'Terms of Use'; + static const String footerCookiePolicy = 'Use of Cookies'; + static const String footerCookiePreferences = 'Cookie Preferences'; + static const String footerRightsReserved = '2023 © All rights reserved'; + static const String latestBlocksHeader = 'Latest Blocks'; + static const String eonTooltipText = ''; + static const String eraTooltipText = 'An era is a period during which a specific set of active validators exists.'; + static const String epochTooltipText = + 'An epoch or session is one-sixth of an era. \nDuring the last epoch, the active set of the next era is elected. \nAnd after the end of each era, the rewards are calculated and are ready to be distributed to the validators and nominators.'; + static const String totalTransactionTooltipText = + 'Indicates the total number of transactions on the Topl blockchain.'; + static const String heightTooltipText = + 'Indicates the number of blocks that have been confirmed in the entire history of the Topl blockchain.'; + static const String averageBlockTimeTooltipText = 'The average time taken to generate a new block.'; + static const String totalStakeTooltipText = + 'Indicates the total percentage of users that have staked on the Topl blockchain to confirm transactions.'; + static const String registeredStakesTooltipText = + 'Indicates the total numbers of registered stakes on the Topl blockchain.'; + static const String activeStakesTooltipText = + 'Indicates the total validators actively being nominated for the current era. \nA single validator is nominated in each era with the full stake. \nThis validator will pay rewards once the payout has been activated at the end of that era.'; + static const String invalidStakesTooltipText = + 'Indicates the validators in the active validator set and in the list of nominations. \nHowever, they are not actively being nominated for for this era, and they will not give rewards for this era.'; + static const String status = 'Status'; + static const String time = 'Time'; + static const String id = 'ID'; + static const String numberOfTransactions = 'Number of Transactions'; + static const String numberOfWithdrawals = 'Number of Withdrawals'; + static const String block = 'Block'; + static const String blockId = 'Block ID'; + static const String timeStamp = 'Time Stamp'; + static const String size = 'Size'; + static const String transactionDetailsHeader = 'Transaction Details'; + static const String broadcastTimestamp = 'Broadcast Timestamp'; + static const String confirmedTimestamp = 'Confirmed Timestamp'; + static const String type = 'Type'; + static const String amount = 'Amount'; + static const String txnFee = 'Txn Fee'; + static const String fromAddress = 'From'; + static const String toAddress = 'To'; + static const String transactionSize = 'Size of Txn'; + static const String backText = 'Back'; + static const String transactionHash = 'Txn Hash/ID'; + static const String senderAddress = 'Sender Address'; + static const String receiverAddress = 'Sender Address'; + static const String changeBackAddress = 'Change back address'; + static const String inputAmount = 'Input amount'; + static const String outputAmount = 'Output amount'; + static const String feeAmount = 'Fee amount'; + static const String proposition = 'Proposition'; + static const String quantity = 'Quantity'; + static const String name = 'Name'; + static const String searchHintText = 'Search'; + static const String averageTxnFee = 'Average Transaction Fee'; + static const String averageTxnFees = 'Average Transaction Fees'; + static const String addNewNetwork = 'Add New Network'; + static const String networkInfoMessage = + 'Some network providers may monitor your network activity. Please add only trusted networks.'; + static const String toplMainet = 'Topl Mainnet'; + static const String networkName = 'Network Name'; + static const String network = 'Network'; + static const String rpcUrl = 'New RPC URL'; + static const String requiredField = 'This field is required'; + static const String chainId = 'Chain ID'; + static const String chainIdPlaceHolder = '192.158.0.0'; + static const String chainIdIdentifierMsg = 'The Chain ID is a unique identifier for the network'; + static const String selectNetwork = 'Select Network'; + static const String blockExplorerUrl = 'Block Explorer URL (Optional)'; + static const String cancelText = 'Cancel'; + static const String addText = 'Add'; + static const String rationalDisplayFont = 'Rational Display '; + static const String rationalDisplayFontMedium = 'Medium'; + static const String currencyValue = 'LVL'; + static const String modalSideSheetDemo = 'Modal Side sheet Demo'; + static const String standardSideSheet = 'Show Standard Side Sheet'; + static const String summary = 'Summary'; + static const String transactions = 'Transactions'; + static const String removeCustomNetwork = 'Remove custom network'; + static const String addNew = 'Add New'; + static const String noResultsFound = 'No results found'; + static const String copyToClipboard = 'Copied to Clipboard'; + static const String selectANetwork = 'Select a Network'; + static const String requestTokens = 'Request Tokens'; + static const String getTestNetwork = 'Get Test Network'; + static const String viewExplorer = 'View in Annulus Topl Explorer'; + static const String requestSuccessful = 'Your request was successful.'; + static const String txnHash = 'Your txn hash'; + static const String getLVL = 'Get 100 LVL'; +} diff --git a/lib/shared/constants/ui.dart b/lib/shared/constants/ui.dart new file mode 100644 index 0000000..06c3b9a --- /dev/null +++ b/lib/shared/constants/ui.dart @@ -0,0 +1,3 @@ +const double tabletBreak = 820; +const double mobileBreak = 550; +const double barrierOpacity = 0.64; diff --git a/lib/shared/extensions/extensions.dart b/lib/shared/extensions/extensions.dart new file mode 100644 index 0000000..d650994 --- /dev/null +++ b/lib/shared/extensions/extensions.dart @@ -0,0 +1,156 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:convert/convert.dart'; + +extension StringExtension on String { + /// Converts string to a UTF-8 [Uint8List]. + Uint8List toUtf8Uint8List() { + final encoder = Utf8Encoder(); + return Uint8List.fromList(encoder.convert(this)); + } + + (String, String) splitAt(int index) => (substring(0, index), substring(index)); + + /// Converts List to a hex encoded [Uint8List]. + Uint8List toHexUint8List() => Uint8List.fromList(hex.decode(this)); +} + +extension StringListExtension on List { + /// Converts List to a UTF-8 List of [Uint8List]. + List toUtf8Uint8List() { + final encoder = Utf8Encoder(); + return map((e) => Uint8List.fromList(encoder.convert(e))).toList(); + } +} + +extension BigIntExtensions on BigInt { + /// Converts a [BigInt] to a [Uint8List] + Uint8List toUint8List() => toByteData().buffer.asUint8List(); + + /// Converts a [BigInt] to a [ByteData] + ByteData toByteData() { + final data = ByteData((bitLength / 8).ceil()); + var bigInt = this; + + for (var i = 1; i <= data.lengthInBytes; i++) { + data.setUint8(data.lengthInBytes - i, bigInt.toUnsigned(8).toInt()); + bigInt = bigInt >> 8; + } + + return data; + } +} + +extension IntExtensions on int { + Uint8List get toBytes => Uint8List.fromList([this]); +} + +extension Uint8ListExtension on Uint8List { + /// Converts a [Uint8List] to a hex string. + String toHexString() { + return hex.encode(this); + } + + BigInt fromLittleEndian() { + final reversed = this.reversed.toList(); + final hex = reversed.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + return BigInt.parse(hex, radix: 16); + } + + BigInt fromBigEndian() { + final hex = map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + return BigInt.parse(hex, radix: 16); + } + + Int8List toSigned() { + return Int8List.fromList(this); + } + + Uint8List pad(int targetSize) { + if (length >= targetSize) { + return this; + } + final paddingSize = targetSize - length; + final padding = Uint8List(paddingSize); + return Uint8List.fromList([...this, ...padding]); + } +} + +extension Int8ListExtension on Int8List { + /// Converts a [Int8List] to a hex string. + String toHexString() { + return hex.encode(this); + } + + BigInt fromLittleEndian() { + final reversed = this.reversed.toList(); + final hex = reversed.map((byte) => byte.toRadixString(16).padLeft(2, '0')).join(); + return BigInt.parse(hex, radix: 16); + } +} + +extension IntExtension on int { + /// converts an Int from Bytes to bits + int get toBits => this * 8; +} + +extension IntList on List { + /// Converts a List to a [Uint8List]. + Uint8List toUint8List() { + return Uint8List.fromList(this); + } + + Int8List toInt8List() { + return Int8List.fromList(this); + } + + toHexString() { + return hex.encode(this); + } + + BigInt get toBigInt { + final data = Int8List.fromList(this).buffer.asByteData(); + BigInt bigInt = BigInt.zero; + + for (var i = 0; i < data.lengthInBytes; i++) { + bigInt = (bigInt << 8) | BigInt.from(data.getUint8(i)); + } + return bigInt; + } +} + +extension IterableExtensions on Iterable { + Iterable> buffered(int size) sync* { + final buffer = []; + for (final element in this) { + buffer.add(element); + if (buffer.length == size) { + yield buffer.toList(); + buffer.clear(); + } + } + if (buffer.isNotEmpty) { + yield buffer.toList(); + } + } + + Iterable> grouped(int size) sync* { + final iterator = this.iterator; + while (iterator.moveNext()) { + final chunk = [iterator.current]; + for (var i = 1; i < size && iterator.moveNext(); i++) { + chunk.add(iterator.current); + } + yield chunk; + } + } +} + +extension ListExtensions on List { + (List, List) splitAt(int index) { + final first = sublist(0, index); + final second = sublist(index); + return (first, second); + } +} diff --git a/lib/shared/extensions/widget_extensions.dart b/lib/shared/extensions/widget_extensions.dart new file mode 100644 index 0000000..e150640 --- /dev/null +++ b/lib/shared/extensions/widget_extensions.dart @@ -0,0 +1,8 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +abstract class HookConsumerScreenWidget extends HookConsumerWidget { + const HookConsumerScreenWidget({ + Key? key, + }) : super(key: key); +} diff --git a/lib/shared/models/logger.dart b/lib/shared/models/logger.dart new file mode 100644 index 0000000..37885bd --- /dev/null +++ b/lib/shared/models/logger.dart @@ -0,0 +1,10 @@ +enum LoggerClass { + ApiError('ApiError'), + Analytics('Analytics'), + Navigation('Navigation'); + + const LoggerClass(this.string); + final String string; +} + +enum LogLevel { Info, Warning, Severe, Shout } diff --git a/lib/shared/providers/config_provider.dart b/lib/shared/providers/config_provider.dart new file mode 100644 index 0000000..20511fd --- /dev/null +++ b/lib/shared/providers/config_provider.dart @@ -0,0 +1,14 @@ +import 'package:faucet/chain/providers/selected_chain_provider.dart'; +import 'package:faucet/shared/providers/node_provider.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:topl_common/proto/node/services/bifrost_rpc.pb.dart'; + +final configProvider = StreamProvider((ref) async* { + final selectedChain = ref.watch(selectedChainProvider); + final nodeClient = ref.watch(nodeProvider(selectedChain)); + final configStream = nodeClient.fetchNodeConfig(); + + await for (var value in configStream) { + yield value; + } +}); diff --git a/lib/shared/providers/genus_provider.dart b/lib/shared/providers/genus_provider.dart new file mode 100644 index 0000000..a70d0bf --- /dev/null +++ b/lib/shared/providers/genus_provider.dart @@ -0,0 +1,9 @@ +import 'package:faucet/chain/models/chains.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:topl_common/genus/services/transaction_grpc.dart'; + +final genusProvider = Provider.family((ref, chain) { + final GenusGRPCService service = GenusGRPCService(host: chain.hostUrl, port: chain.port); + + return service; +}); diff --git a/lib/shared/providers/logger_provider.dart b/lib/shared/providers/logger_provider.dart new file mode 100644 index 0000000..8b52875 --- /dev/null +++ b/lib/shared/providers/logger_provider.dart @@ -0,0 +1,47 @@ +// Package imports: +import 'package:faucet/shared/models/logger.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:logging/logging.dart'; + +final loggerPackageProvider = Provider((ref) { + return (String loggerClass) => Logger(loggerClass); +}); + +final loggerProvider = Provider((ref) { + return LoggerNotifier(ref); +}); + +class LoggerNotifier { + final Ref ref; + + LoggerNotifier(this.ref) { + Logger.root.level = Level.ALL; // defaults to Level.INFO + Logger.root.onRecord.listen((record) { + print('${record.level.name}: ${record.time}: ${record.message}'); + }); + } + + void log({ + required LogLevel logLevel, + required LoggerClass loggerClass, + required String message, + Object? error, + StackTrace? stackTrace, + }) { + final Logger logger = ref.read(loggerPackageProvider)(loggerClass.string); + switch (logLevel) { + case LogLevel.Info: + logger.info(message, error, stackTrace); + break; + case LogLevel.Warning: + logger.warning(message, error, stackTrace); + break; + case LogLevel.Severe: + logger.severe(message, error, stackTrace); + break; + case LogLevel.Shout: + logger.shout(message, error, stackTrace); + break; + } + } +} diff --git a/lib/shared/providers/mock_state_provider.dart b/lib/shared/providers/mock_state_provider.dart new file mode 100644 index 0000000..3ba1863 --- /dev/null +++ b/lib/shared/providers/mock_state_provider.dart @@ -0,0 +1,6 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// Returns [bool] depending on whether to to mock state or use API +final mockStateProvider = Provider((ref) { + return true; +}); diff --git a/lib/shared/providers/node_provider.dart b/lib/shared/providers/node_provider.dart new file mode 100644 index 0000000..2550d5c --- /dev/null +++ b/lib/shared/providers/node_provider.dart @@ -0,0 +1,9 @@ +import 'package:faucet/chain/models/chains.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:topl_common/genus/services/node_grpc.dart'; + +final nodeProvider = Provider.family((ref, chain) { + final NodeGRPCService service = NodeGRPCService(host: chain.hostUrl, port: chain.port); + + return service; +}); diff --git a/lib/shared/services/hive/hive_service.dart b/lib/shared/services/hive/hive_service.dart new file mode 100644 index 0000000..3688562 --- /dev/null +++ b/lib/shared/services/hive/hive_service.dart @@ -0,0 +1,62 @@ +import 'package:faucet/shared/services/hive/hives.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final hivePackageProvider = Provider((ref) { + return Hive; +}); + +final hiveProvider = Provider((ref) { + return HiveService(ref); +}); + +class HiveService { + final Ref ref; + HiveService(this.ref); + Future> _openBox(String box) async { + return await ref.read(hivePackageProvider).openBox(box); + } + + Future putItem({ + required HivesBox boxType, + required String key, + required dynamic value, + }) async { + final box = await _openBox(boxType.id); + await box.put(key, value); + } + + Future getItem({ + required HivesBox boxType, + required String key, + }) async { + final box = await _openBox(boxType.id); + return box.get(key); + } + + Future> getAllItems({ + required HivesBox boxType, + }) async { + final box = await _openBox(boxType.id); + return box.values; + } + + Future deleteItem({ + required HivesBox boxType, + required String key, + }) async { + final box = await _openBox(boxType.id); + await box.delete(key); + } + + Future deleteBox({ + required HivesBox boxType, + }) async { + final box = await _openBox(boxType.id); + await box.deleteFromDisk(); + } + + Future deleteAll() async { + await Hive.deleteFromDisk(); + } +} diff --git a/lib/shared/services/hive/hives.dart b/lib/shared/services/hive/hives.dart new file mode 100644 index 0000000..7989c24 --- /dev/null +++ b/lib/shared/services/hive/hives.dart @@ -0,0 +1,12 @@ +enum HivesBox { + customChains( + id: 'customChains', + ), + rateLimit( + id: 'rateLimit', + ); + + const HivesBox({required this.id}); + + final String id; +} diff --git a/lib/shared/theme.dart b/lib/shared/theme.dart index 4f04a4b..b5d01a1 100644 --- a/lib/shared/theme.dart +++ b/lib/shared/theme.dart @@ -104,6 +104,13 @@ TextTheme _textTheme({ color: textColor, fontFamily: "Rational Display", ), + headlineMedium: const TextStyle( + color: Color(0xFF0DC8D4), + fontWeight: FontWeight.w600, + fontSize: 19, + fontFamily: 'Rational Display', + fontStyle: FontStyle.normal, + ), /// Title titleLarge: TextStyle( @@ -132,6 +139,12 @@ TextTheme _textTheme({ color: altTextColor, fontFamily: "Rational Display", ), + labelSmall: TextStyle( + fontSize: 12.0, + fontWeight: FontWeight.w500, + color: textColor, + fontFamily: "Rational Display", + ), /// Body bodyMedium: TextStyle( @@ -155,6 +168,11 @@ TextStyle? headlineLarge(BuildContext context) { return Theme.of(context).textTheme.headlineLarge; } +/// Returns [Theme.of(context).textTheme.headlineMedium] +TextStyle? headlineMedium(BuildContext context) { + return Theme.of(context).textTheme.headlineMedium; +} + /// Returns [Theme.of(context).textTheme.titleLarge] TextStyle? titleLarge(BuildContext context) { return Theme.of(context).textTheme.titleLarge; @@ -175,6 +193,11 @@ TextStyle? labelLarge(BuildContext context) { return Theme.of(context).textTheme.labelLarge; } +/// Returns [Theme.of(context).textTheme.labelSmall] +TextStyle? labelSmall(BuildContext context) { + return Theme.of(context).textTheme.labelSmall; +} + /// Returns [Theme.of(context).textTheme.bodyMedium] TextStyle? bodyMedium(BuildContext context) { return Theme.of(context).textTheme.bodyMedium; diff --git a/lib/shared/utils/debouncer.dart b/lib/shared/utils/debouncer.dart new file mode 100644 index 0000000..9341e0c --- /dev/null +++ b/lib/shared/utils/debouncer.dart @@ -0,0 +1,18 @@ +import 'dart:async'; +import 'dart:ui'; + +/// Debouncer class to prevent multiple calls to the API when the user is typing +class Debouncer { + final int milliseconds; + VoidCallback? action; + Timer? _timer; + + Debouncer({required this.milliseconds}); + + void run(VoidCallback action) { + if (_timer != null) { + _timer!.cancel(); + } + _timer = Timer(Duration(milliseconds: milliseconds), action); + } +} diff --git a/lib/shared/utils/decode_id.dart b/lib/shared/utils/decode_id.dart new file mode 100644 index 0000000..456d189 --- /dev/null +++ b/lib/shared/utils/decode_id.dart @@ -0,0 +1,20 @@ +import 'dart:math'; + +import 'package:fast_base58/fast_base58.dart'; + +String decodeId(List byteArray) { + return Base58Encode(byteArray); +} + +List encodeId(String id) { + List bytes = Base58Decode(id); + return bytes; +} + +// Create a random base58 safe ID + +String createId() { + final random = Random.secure(); + final bytes = List.generate(32, (_) => random.nextInt(256)); + return Base58Encode(bytes); +} diff --git a/lib/shared/utils/hive_utils.dart b/lib/shared/utils/hive_utils.dart new file mode 100644 index 0000000..97919b2 --- /dev/null +++ b/lib/shared/utils/hive_utils.dart @@ -0,0 +1,6 @@ +// Dart imports: +import 'dart:convert'; + +Map convertHashMapToMap(hashMap) { + return json.decode(json.encode(hashMap)) as Map; +} diff --git a/lib/shared/utils/nav_utils.dart b/lib/shared/utils/nav_utils.dart new file mode 100644 index 0000000..41aa94e --- /dev/null +++ b/lib/shared/utils/nav_utils.dart @@ -0,0 +1,50 @@ +import 'package:faucet/blocks/models/block.dart'; +import 'package:faucet/shared/constants/ui.dart'; +import 'package:faucet/shared/utils/theme_color.dart'; +import 'package:faucet/transactions/models/transaction.dart'; +import 'package:flutter/material.dart'; +import 'package:modal_side_sheet/modal_side_sheet.dart'; +import 'package:responsive_framework/responsive_framework.dart'; +import 'package:vrouter/vrouter.dart'; + +goToBlockDetails({ + required BuildContext context, + required Block block, + required ThemeMode colorTheme, +}) { + final isDesktop = ResponsiveBreakpoints.of(context).equals(DESKTOP); + isDesktop + ? showModalSideSheet( + context: context, + ignoreAppBar: false, + width: 640, + barrierColor: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF353739).withOpacity(0.64), + barrierDismissible: true, + //TODO: Replace once Product has designs + body: const Text('Drawer to appear on search clicked')) //BlockDetailsDrawer(block: block)) + : context.vRouter.to('/block_details'); +} + +goToTransactionDetails({ + required BuildContext context, + required Transaction transaction, +}) { + final isDesktop = ResponsiveBreakpoints.of(context).equals(DESKTOP); + if (isDesktop) { + showModalSideSheet( + context: context, + ignoreAppBar: true, + width: 640, + barrierColor: Colors.white.withOpacity(barrierOpacity), + // with blur, + barrierDismissible: true, + //TODO: Replace once Product has designs + body: const Text('Drawer to appear on search clicked'), + ); + } else { + //context.vRouter.to(TransactionDetailsPage.transactionDetailsPath(transaction.transactionId)); + } +} + +// TODO: Implement this +goToUtxoDetails() {} diff --git a/lib/shared/utils/theme_color.dart b/lib/shared/utils/theme_color.dart new file mode 100644 index 0000000..468a817 --- /dev/null +++ b/lib/shared/utils/theme_color.dart @@ -0,0 +1,16 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +/// function to get selected color based on theme selected (dark or light) +/// QQQQ deleting this will lead to most the places you need to change to use theme +Color getSelectedColor(ThemeMode colorMode, int primaryColor, int secondaryColor) { + switch (colorMode) { + case ThemeMode.light: + return Color(primaryColor); + case ThemeMode.dark: + return Color(secondaryColor); + default: + return const Color(0xFFFEFEFE); + } +} diff --git a/lib/shared/utils/transitions.dart b/lib/shared/utils/transitions.dart new file mode 100644 index 0000000..87aae25 --- /dev/null +++ b/lib/shared/utils/transitions.dart @@ -0,0 +1,9 @@ +import 'package:flutter/material.dart'; + +SlideTransition slideLeftTransition(Animation animation, Widget child) => SlideTransition( + position: Tween( + begin: const Offset(1, 0), + end: Offset.zero, + ).animate(animation), + child: child, + ); diff --git a/lib/shared/widgets/copy_to_clipboard.dart b/lib/shared/widgets/copy_to_clipboard.dart new file mode 100644 index 0000000..c6c0513 --- /dev/null +++ b/lib/shared/widgets/copy_to_clipboard.dart @@ -0,0 +1,26 @@ +import 'package:faucet/shared/constants/strings.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +class CopyToClipboard extends StatelessWidget { + const CopyToClipboard({ + super.key, + required this.rightText, + }); + + final String rightText; + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: rightText)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text(Strings.copyToClipboard), + ), + ); + }, + child: const Icon(Icons.copy)); + } +} diff --git a/lib/shared/widgets/custom_shared.dart b/lib/shared/widgets/custom_shared.dart new file mode 100644 index 0000000..ae1c8b5 --- /dev/null +++ b/lib/shared/widgets/custom_shared.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; +import 'package:responsive_framework/responsive_row_column.dart'; + +import '../constants/strings.dart'; +import '../utils/theme_color.dart'; + +class FooterContent extends StatelessWidget { + const FooterContent({ + super.key, + required this.colorTheme, + }); + + final ThemeMode colorTheme; + + @override + Widget build(BuildContext context) { + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 15), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + Strings.footerColumn5Header, + textAlign: TextAlign.left, + style: TextStyle( + color: getSelectedColor(colorTheme, 0xFF282A2C, 0xFFC0C4C4), + fontSize: 16, + fontFamily: Strings.rationalDisplayFont, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 14), + Row( + mainAxisAlignment: MainAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + isTablet + ? CustomFooterTextField(isTablet: isTablet) + : Expanded( + child: CustomFooterTextField(isTablet: isTablet), + ), + const SizedBox(width: 8), + SizedBox( + height: 40, + // width: 102, + child: TextButton( + style: ButtonStyle( + backgroundColor: MaterialStateProperty.all( + getSelectedColor(colorTheme, 0xFFE2E3E3, 0xFF434648), + ), + ), + onPressed: null, + child: Text( + Strings.subscribe, + style: TextStyle( + color: getSelectedColor(colorTheme, 0xFF000000, 0xFFFEFEFE), + fontSize: 14, + fontFamily: Strings.rationalDisplayFont, + ), + ), + ), + ), + ], + ), + ], + ), + ); + } +} + +class CustomFooterTextField extends StatelessWidget { + const CustomFooterTextField({ + super.key, + required this.isTablet, + }); + + final bool isTablet; + + @override + Widget build(BuildContext context) { + return SizedBox( + width: isTablet ? 200 : double.infinity, + height: 40, + child: TextField( + controller: TextEditingController(text: ''), + decoration: const InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + borderSide: BorderSide(color: Color(0xFFE2E3E3), width: 1), + ), + hintText: Strings.email, + ), + ), + ); + } +} + +class ResponsiveFooter extends StatelessWidget { + const ResponsiveFooter({ + Key? key, + required this.colorTheme, + required this.rowIcons, + }) : super(key: key); + + final ThemeMode colorTheme; + final Widget rowIcons; + + @override + Widget build(BuildContext context) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + return ResponsiveRowColumn( + layout: ResponsiveBreakpoints.of(context).smallerOrEqualTo(MOBILE) + ? ResponsiveRowColumnType.COLUMN + : ResponsiveRowColumnType.ROW, + children: [ + ResponsiveRowColumnItem( + rowFlex: 1, + child: isMobile + ? Column( + children: [ + FooterContent(colorTheme: colorTheme), + const SizedBox(height: 30), + Container(width: 400, padding: const EdgeInsets.only(left: 10), child: rowIcons), + ], + ) + : Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + FooterContent(colorTheme: colorTheme), + Container(padding: const EdgeInsets.only(right: 35), child: rowIcons), + ], + ), + ), + ], + ); + } +} diff --git a/lib/shared/widgets/footer.dart b/lib/shared/widgets/footer.dart new file mode 100644 index 0000000..373c4ca --- /dev/null +++ b/lib/shared/widgets/footer.dart @@ -0,0 +1,241 @@ +import 'package:faucet/shared/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; +import 'package:responsive_framework/responsive_framework.dart'; +import 'package:url_launcher/url_launcher.dart'; +import '../constants/strings.dart'; +import '../providers/app_theme_provider.dart'; +import '../utils/theme_color.dart'; +import 'custom_shared.dart'; + +/// Footer Widget +class Footer extends HookConsumerWidget { + const Footer({ + super.key, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isDesktop = ResponsiveBreakpoints.of(context).equals(DESKTOP); + final isDesktopAndTab = ResponsiveBreakpoints.of(context).between(TABLET, DESKTOP); + + final colorTheme = ref.watch(appThemeColorProvider); + return Column( + children: [ + const SizedBox( + height: 20, + ), + if (!isDesktop) + ResponsiveFooter( + colorTheme: colorTheme, + rowIcons: const RowIcons(), + ), + if (!isDesktop) + const SizedBox( + height: 20, + ), + const Divider( + color: Color(0xFFE7E8E8), + height: 1, + ), + const SizedBox( + height: 20, + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 40), + width: MediaQuery.of(context).size.width, + child: Stack( + children: [ + if (isDesktopAndTab) + const Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: 350, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + FooterBottomLinks(text: Strings.footerPrivacyPolicy), + SizedBox(width: 15), + FooterBottomLinks(text: Strings.footerTermsOfUse), + SizedBox(width: 15), + FooterBottomLinks(text: Strings.footerCookiePolicy), + SizedBox(width: 15), + FooterBottomLinks(text: Strings.footerCookiePreferences), + ], + ), + ), + ), + ), + Align( + alignment: Alignment.centerRight, + child: SizedBox( + width: 160, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const FooterBottomLinks(text: Strings.footerRightsReserved), + const SizedBox(width: 15), + SvgPicture.asset( + colorTheme == ThemeMode.light ? 'assets/icons/logo.svg' : 'assets/icons/logo_dark.svg', + width: 32, + height: 20, + ), + ], + ), + ), + ), + ), + ], + ), + ), + const SizedBox( + height: 20, + ) + ], + ); + } +} + +class RowIcons extends StatelessWidget { + const RowIcons({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return const RowIconsFooter( + svgIcons: [ + {'icon': 'assets/icons/linkedin.svg', 'url': 'https://www.linkedin.com/company/topl/'}, + {'icon': 'assets/icons/github.svg', 'url': 'https://github.com/Topl'}, + {'icon': 'assets/icons/instagram.svg', 'url': 'https://www.instagram.com/topl_protocol/'}, + {'icon': 'assets/icons/medium.svg', 'url': 'https://medium.com/topl-blog'}, + {'icon': 'assets/icons/twitter.svg', 'url': 'https://twitter.com/topl_protocol'} + ], + ); + } +} + +class FooterBottomLinks extends HookConsumerWidget { + const FooterBottomLinks({ + super.key, + required this.text, + }); + + final String text; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Text( + text, + style: bodySmall(context), + textAlign: TextAlign.center, + ); + } +} + +/// Icon Footer Column +class RowIconsFooter extends HookConsumerWidget { + const RowIconsFooter({ + super.key, + required this.svgIcons, + }); + + final List svgIcons; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorTheme = ref.watch(appThemeColorProvider); + final isResponsive = ResponsiveBreakpoints.of(context).between(MOBILE, TABLET); + + return Row( + children: [ + ...svgIcons + .map( + (svgIcon) => Row( + children: [ + Container( + width: isResponsive ? 32 : 42, + height: 40, + decoration: BoxDecoration( + color: getSelectedColor(colorTheme, 0xFFE2E3E3, 0xFF434648), + borderRadius: BorderRadius.circular(8), + ), + child: IconButton( + onPressed: () { + //TODO: P.S line only applicable to web version + launchUrl(svgIcon['url']); + }, + icon: SvgPicture.asset( + svgIcon['icon'], + color: getSelectedColor(colorTheme, 0xFF535757, 0xFFC0C4C4), + ), + ), + ), + SizedBox( + width: svgIcon == svgIcons.last ? 0 : 12, + ), + ], + ), + ) + .toList(), + ], + ); + } +} + +/// Footer Column Widget +class FooterColumn extends HookConsumerWidget { + const FooterColumn({ + super.key, + required this.footerLinks, + required this.footerTitle, + }); + final List footerLinks; + final String footerTitle; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorTheme = ref.watch(appThemeColorProvider); + return Column( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + footerTitle, + style: const TextStyle( + color: Color(0xFF858E8E), + fontSize: 16, + fontFamily: Strings.rationalDisplayFont, + fontWeight: FontWeight.w600), + ), + const SizedBox( + height: 16, + ), + ...footerLinks + .map((text) => Column( + children: [ + Text( + text, + style: TextStyle( + color: getSelectedColor(colorTheme, 0xFF535757, 0xFFC0C4C4), + fontSize: 14, + fontFamily: Strings.rationalDisplayFont), + ), + const SizedBox( + height: 16, + ), + ], + )) + .toList() + ], + ); + } +} diff --git a/lib/shared/widgets/header.dart b/lib/shared/widgets/header.dart new file mode 100644 index 0000000..fd7d282 --- /dev/null +++ b/lib/shared/widgets/header.dart @@ -0,0 +1,305 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:responsive_framework/responsive_framework.dart'; +import '../../chain/sections/chainname_dropdown.dart'; +import '../../search/sections/custom_search_bar.dart'; +import '../providers/app_theme_provider.dart'; +import '../theme.dart'; +import '../utils/theme_color.dart'; + +/// Header widget that displays the logo, search bar and dropdown. +class Header extends HookConsumerWidget { + final String logoAsset; + final VoidCallback onSearch; + final ValueChanged onDropdownChanged; + + const Header({ + super.key, + required this.logoAsset, + required this.onSearch, + required this.onDropdownChanged, + }); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeMode colorTheme = ref.watch(appThemeColorProvider); + final isMobile = ResponsiveBreakpoints.of(context).isMobile; + + final isSmallerThanOrEqualToTablet = ResponsiveBreakpoints.of(context).smallerOrEqualTo(TABLET); + return Container( + width: double.infinity, + padding: EdgeInsets.symmetric(horizontal: isMobile ? 20 : 40, vertical: 20), + decoration: BoxDecoration( + color: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + ), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Text('Faucet', style: headlineMedium(context)), + Text('by Topl', style: labelSmall(context)), + ]), + ), + isMobile + ? const SizedBox() + : SizedBox( + width: 400, + child: CustomSearchBar( + onSearch: onSearch, + colorTheme: colorTheme, + ), + ), + isSmallerThanOrEqualToTablet + ? SizedBox( + child: IconButton( + onPressed: () { + // toggle between light and dark theme + showGeneralDialog( + context: context, + pageBuilder: (context, _, __) => MobileMenu( + onSwitchChange: () { + ref.read(appThemeColorProvider.notifier).toggleTheme(); + }, + ), + barrierDismissible: true, + transitionDuration: const Duration(milliseconds: 250), + barrierLabel: MaterialLocalizations.of(context).dialogLabel, + barrierColor: Colors.black.withOpacity(0.5), + transitionBuilder: (context, animation, secondaryAnimation, child) { + return SlideTransition( + position: CurvedAnimation(parent: animation, curve: Curves.easeOutCubic).drive( + Tween(begin: const Offset(0, -1.0), end: Offset.zero), + ), + child: MaterialConsumer( + child: child, + )); + }, + ); + }, + icon: const Icon( + Icons.menu, + size: 24.0, + ), + ), + ) + : Row( + children: [ + Container( + decoration: BoxDecoration( + border: Border.all( + color: getSelectedColor(colorTheme, 0xFFC0C4C4, 0xFF4B4B4B), + // Set border color here + width: 1, // Set border width here + ), + borderRadius: BorderRadius.circular(12.0), + ), + child: IconButton( + onPressed: () { + // toggle between light and dark theme + ref.read(appThemeColorProvider.notifier).toggleTheme(); + }, + icon: colorTheme == ThemeMode.light + ? const Icon( + Icons.light_mode, + color: Color(0xFF858E8E), + size: 20.0, + ) + : const Icon( + Icons.dark_mode, + color: Color(0xFF858E8E), + size: 20.0, + ), + ), + ), + const SizedBox( + width: 10, + ), + ChainNameDropDown( + colorTheme: colorTheme, + ) + ], + ), + ], + ), + const SizedBox( + height: 20, + ), + isMobile + ? SizedBox( + width: double.infinity, + child: CustomSearchBar( + onSearch: () { + // TODO: implement search + print("search"); + }, + colorTheme: colorTheme, + ), + ) + : const SizedBox(), + ], + ), + ); + } +} + +/// MaterialConsumer widget that displays the menu items. +class MaterialConsumer extends HookConsumerWidget { + const MaterialConsumer({Key? key, required this.child}) : super(key: key); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeMode colorTheme = ref.watch(appThemeColorProvider); + + return Column( + children: [ + Material( + color: colorTheme == ThemeMode.light + ? const Color.fromRGBO(254, 254, 254, 0.96) + : const Color.fromRGBO(53, 55, 57, 0.96), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: double.infinity, + child: child, + ) + ], + ), + ) + ], + ); + } +} + +/// MobileMenu widget that displays the menu items. +class MobileMenu extends HookConsumerWidget { + MobileMenu({super.key, required this.onSwitchChange}); + + final VoidCallback onSwitchChange; + + final List footerLinks = [ + 'Topl Privacy Policy', + 'Terms of Use', + 'Use of Cookies', + 'Cookie Preferences', + ]; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final ThemeMode colorTheme = ref.watch(appThemeColorProvider); + return Column( + children: [ + Container( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Align( + alignment: Alignment.bottomRight, + child: Container( + padding: const EdgeInsets.all(8.0), + child: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon( + Icons.close, + size: 24.0, + color: Color(0xFF858E8E), + ), + ), + ), + ), + const SizedBox( + height: 20, + ), + ChainNameDropDown( + colorTheme: colorTheme, + ), + const SizedBox( + height: 20, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Dark Mode', + style: bodyMedium(context), + ), + ThemeModeSwitch( + onPressed: () { + // toggle between light and dark theme + onSwitchChange(); + }, + ) + ], + ), + const SizedBox( + height: 20, + ), + ], + ), + ), + const Divider( + color: Color(0xFFC0C4C4), + thickness: 1, + ), + Container( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ...footerLinks + .map( + (text) => Container( + margin: const EdgeInsets.only(bottom: 32), + child: Text( + text, + style: bodyMedium(context), + ), + ), + ) + .toList() + ], + ), + ) + ], + ); + } +} + +/// ThemeModeSwitch widget that displays the switch button. +class ThemeModeSwitch extends StatefulWidget { + const ThemeModeSwitch({Key? key, required this.onPressed}) : super(key: key); + final Function onPressed; + + @override + State createState() => _ThemeModeSwitchState(); +} + +/// ThemeModeSwitch widget state. +class _ThemeModeSwitchState extends State { + bool darkMode = false; + + @override + Widget build(BuildContext context) { + return Switch( + value: darkMode, + activeColor: const Color(0xFF7040EC), + onChanged: (bool value) { + // This is called when the user toggles the switch. + setState(() { + darkMode = value; + }); + widget.onPressed(); + }, + ); + } +} diff --git a/lib/shared/widgets/layout.dart b/lib/shared/widgets/layout.dart new file mode 100644 index 0000000..1bdbf34 --- /dev/null +++ b/lib/shared/widgets/layout.dart @@ -0,0 +1,106 @@ +import 'package:faucet/chain/sections/chainname_dropdown.dart'; +import 'package:faucet/shared/providers/app_theme_provider.dart'; +import 'package:faucet/shared/utils/theme_color.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; + +/// This is a custom layout widget that displays a header, content and footer. +class CustomLayout extends HookConsumerWidget { + final Widget header; + final Widget content; + final Widget footer; + final Widget? mobileHeader; + + const CustomLayout({ + Key? key, + required this.header, + required this.content, + required this.footer, + this.mobileHeader, + }) : super(key: key); + + Size get preferredSize => const Size.fromHeight(60); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + final ThemeMode colorTheme = ref.watch(appThemeColorProvider); + final GlobalKey scaffoldKey = GlobalKey(); + + return Scaffold( + key: scaffoldKey, + endDrawer: Material( + child: Container( + height: 500, + margin: EdgeInsets.only(bottom: isTablet ? 520 : 320), + color: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + child: Align( + alignment: Alignment.bottomCenter, + child: Drawer( + backgroundColor: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + width: double.infinity, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 20), + child: ListTile( + title: Align( + alignment: Alignment.centerRight, + child: IconButton( + icon: const Icon(Icons.close), + onPressed: () { + Navigator.of(context).pop(); + }, + ), + ), + ), + ), + Expanded( + child: Center( + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.only(top: 30), + children: [ + ListTile( + title: ChainNameDropDown( + colorTheme: colorTheme, + onItemSelected: () => Navigator.of(context).pop(), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ), + appBar: null, + body: SafeArea( + child: Column( + children: [ + // Header widget + Container(color: Colors.white, child: header), + // Content widget + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + content, + Container( + color: Colors.white, + child: SingleChildScrollView(child: footer), + ) + ], + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/shared/widgets/paginated_table.dart b/lib/shared/widgets/paginated_table.dart new file mode 100644 index 0000000..3aa6c59 --- /dev/null +++ b/lib/shared/widgets/paginated_table.dart @@ -0,0 +1,407 @@ +import 'dart:math' as math; +import 'package:flutter/gestures.dart' show DragStartBehavior; +import 'package:flutter/material.dart'; + +/// +/// This Paginated Table comes from the Material UI PaginateDataTable +/// And has been customized and expanded in order to fit our Design need. +/// + +class PaginatedTable extends StatefulWidget { + final Widget? footerButton; + + PaginatedTable({ + super.key, + this.header, + this.actions, + this.footerButton, + required this.columns, + this.sortColumnIndex, + this.sortAscending = true, + this.onSelectAll, + double? dataRowHeight, + double? dataRowMinHeight, + double? dataRowMaxHeight, + this.headingRowHeight = 56.0, + this.horizontalMargin = 24.0, + this.columnSpacing = 56.0, + this.showCheckboxColumn = true, + this.showFirstLastButtons = false, + this.initialFirstRowIndex = 0, + this.onPageChanged, + this.rowsPerPage = defaultRowsPerPage, + this.availableRowsPerPage = const [ + defaultRowsPerPage, + defaultRowsPerPage * 2, + defaultRowsPerPage * 5, + defaultRowsPerPage * 10 + ], + this.onRowsPerPageChanged, + this.dragStartBehavior = DragStartBehavior.start, + this.arrowHeadColor, + required this.source, + this.checkboxHorizontalMargin, + this.controller, + this.primary, + }) : assert(actions == null || (header != null)), + assert(columns.isNotEmpty), + assert(sortColumnIndex == null || (sortColumnIndex >= 0 && sortColumnIndex < columns.length)), + assert(dataRowMinHeight == null || dataRowMaxHeight == null || dataRowMaxHeight >= dataRowMinHeight), + assert(dataRowHeight == null || (dataRowMinHeight == null && dataRowMaxHeight == null), + 'dataRowHeight ($dataRowHeight) must not be set if dataRowMinHeight ($dataRowMinHeight) or dataRowMaxHeight ($dataRowMaxHeight) are set.'), + dataRowMinHeight = dataRowHeight ?? dataRowMinHeight ?? kMinInteractiveDimension, + dataRowMaxHeight = dataRowHeight ?? dataRowMaxHeight ?? kMinInteractiveDimension, + assert(rowsPerPage > 0), + assert(() { + if (onRowsPerPageChanged != null) { + assert(availableRowsPerPage.contains(rowsPerPage)); + } + return true; + }()), + assert( + !(controller != null && (primary ?? false)), + 'Primary ScrollViews obtain their ScrollController via inheritance from a PrimaryScrollController widget. ' + 'You cannot both set primary to true and pass an explicit controller.', + ); + + final Widget? header; + final List? actions; + final List columns; + final int? sortColumnIndex; + final bool sortAscending; + final ValueSetter? onSelectAll; + double? get dataRowHeight => dataRowMinHeight == dataRowMaxHeight ? dataRowMinHeight : null; + final double dataRowMinHeight; + final double dataRowMaxHeight; + final double headingRowHeight; + final double horizontalMargin; + final double columnSpacing; + final bool showCheckboxColumn; + final bool showFirstLastButtons; + final int? initialFirstRowIndex; + final ValueChanged? onPageChanged; + final int rowsPerPage; + static const int defaultRowsPerPage = 10; + final List availableRowsPerPage; + final ValueChanged? onRowsPerPageChanged; + final DataTableSource source; + + final DragStartBehavior dragStartBehavior; + final double? checkboxHorizontalMargin; + final Color? arrowHeadColor; + final ScrollController? controller; + final bool? primary; + + @override + PaginatedDataTableState createState() => PaginatedDataTableState(); +} + +class PaginatedDataTableState extends State { + late int _firstRowIndex; + late int _rowCount; + late bool _rowCountApproximate; + int _selectedRowCount = 0; + final Map _rows = {}; + + @override + void initState() { + super.initState(); + _firstRowIndex = PageStorage.maybeOf(context)?.readState(context) as int? ?? widget.initialFirstRowIndex ?? 0; + widget.source.addListener(_handleDataSourceChanged); + _handleDataSourceChanged(); + } + + @override + void didUpdateWidget(PaginatedTable oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.source != widget.source) { + oldWidget.source.removeListener(_handleDataSourceChanged); + widget.source.addListener(_handleDataSourceChanged); + _handleDataSourceChanged(); + } + } + + @override + void dispose() { + widget.source.removeListener(_handleDataSourceChanged); + super.dispose(); + } + + void _handleDataSourceChanged() { + setState(() { + _rowCount = widget.source.rowCount; + _rowCountApproximate = widget.source.isRowCountApproximate; + _selectedRowCount = widget.source.selectedRowCount; + _rows.clear(); + }); + } + + void pageTo(int rowIndex) { + final int oldFirstRowIndex = _firstRowIndex; + setState(() { + final int rowsPerPage = widget.rowsPerPage; + _firstRowIndex = (rowIndex ~/ rowsPerPage) * rowsPerPage; + }); + if ((widget.onPageChanged != null) && (oldFirstRowIndex != _firstRowIndex)) { + widget.onPageChanged!(_firstRowIndex); + } + } + + DataRow _getBlankRowFor(int index) { + return DataRow.byIndex( + index: index, + cells: widget.columns.map((DataColumn column) => DataCell.empty).toList(), + ); + } + + DataRow _getProgressIndicatorRowFor(int index) { + bool haveProgressIndicator = false; + final List cells = widget.columns.map((DataColumn column) { + if (!column.numeric) { + haveProgressIndicator = true; + return const DataCell(CircularProgressIndicator()); + } + return DataCell.empty; + }).toList(); + if (!haveProgressIndicator) { + haveProgressIndicator = true; + cells[0] = const DataCell(CircularProgressIndicator()); + } + return DataRow.byIndex( + index: index, + cells: cells, + ); + } + + List _getRows(int firstRowIndex, int rowsPerPage) { + final List result = []; + final int nextPageFirstRowIndex = firstRowIndex + rowsPerPage; + bool haveProgressIndicator = false; + for (int index = firstRowIndex; index < nextPageFirstRowIndex; index += 1) { + DataRow? row; + if (index < _rowCount || _rowCountApproximate) { + row = _rows.putIfAbsent(index, () => widget.source.getRow(index)); + if (row == null && !haveProgressIndicator) { + row ??= _getProgressIndicatorRowFor(index); + haveProgressIndicator = true; + } + } + row ??= _getBlankRowFor(index); + result.add(row); + } + return result; + } + + void _handleFirst() { + pageTo(0); + } + + void _handlePrevious() { + pageTo(math.max(_firstRowIndex - widget.rowsPerPage, 0)); + } + + void _handleNext() { + pageTo(_firstRowIndex + widget.rowsPerPage); + } + + void _handleLast() { + pageTo(((_rowCount - 1) / widget.rowsPerPage).floor() * widget.rowsPerPage); + } + + bool _isNextPageUnavailable() => !_rowCountApproximate && (_firstRowIndex + widget.rowsPerPage >= _rowCount); + + final GlobalKey _tableKey = GlobalKey(); + + @override + Widget build(BuildContext context) { + double containerWidth; + double screenWidth = MediaQuery.of(context).size.width; + + if (screenWidth > 1200) { + containerWidth = 600.0; + } else if (screenWidth > 800) { + containerWidth = 500.0; + } else { + containerWidth = 300.0; + } + assert(debugCheckHasMaterialLocalizations(context)); + final ThemeData themeData = Theme.of(context); + final MaterialLocalizations localizations = MaterialLocalizations.of(context); + final List headerWidgets = []; + if (_selectedRowCount == 0 && widget.header != null) { + headerWidgets.add(Expanded(child: widget.header!)); + } else if (widget.header != null) { + headerWidgets.add(Expanded( + child: Text(localizations.selectedRowCountTitle(_selectedRowCount)), + )); + } + if (widget.actions != null) { + headerWidgets.addAll( + widget.actions!.map((Widget action) { + return Padding( + padding: const EdgeInsetsDirectional.only(start: 24.0 - 8.0 * 2.0), + child: action, + ); + }).toList(), + ); + } + + // FOOTER + final TextStyle? footerTextStyle = themeData.textTheme.bodySmall; + final List footerWidgets = []; + if (widget.onRowsPerPageChanged != null) { + final List availableRowsPerPage = widget.availableRowsPerPage + .where((int value) => value <= _rowCount || value == widget.rowsPerPage) + .map>((int value) { + return DropdownMenuItem( + value: value, + child: Text('$value'), + ); + }).toList(); + footerWidgets.addAll([ + Container(width: 14.0), + Text(localizations.rowsPerPageTitle), + ConstrainedBox( + constraints: const BoxConstraints(minWidth: 64.0), + child: Align( + alignment: AlignmentDirectional.centerEnd, + child: DropdownButtonHideUnderline( + child: DropdownButton( + items: availableRowsPerPage.cast>(), + value: widget.rowsPerPage, + onChanged: widget.onRowsPerPageChanged, + style: footerTextStyle, + ), + ), + ), + ), + ]); + } + footerWidgets.addAll([ + Container(width: 32.0), + Text( + localizations.pageRowsInfoTitle( + _firstRowIndex + 1, + _firstRowIndex + widget.rowsPerPage, + _rowCount, + _rowCountApproximate, + ), + ), + Container(width: 32.0), + if (widget.showFirstLastButtons) + IconButton( + icon: Icon(Icons.skip_previous, color: widget.arrowHeadColor), + padding: EdgeInsets.zero, + tooltip: localizations.firstPageTooltip, + onPressed: _firstRowIndex <= 0 ? null : _handleFirst, + ), + IconButton( + icon: Icon(Icons.chevron_left, color: widget.arrowHeadColor), + padding: EdgeInsets.zero, + tooltip: localizations.previousPageTooltip, + onPressed: _firstRowIndex <= 0 ? null : _handlePrevious, + ), + Container(width: 24.0), + IconButton( + icon: Icon(Icons.chevron_right, color: widget.arrowHeadColor), + padding: EdgeInsets.zero, + tooltip: localizations.nextPageTooltip, + onPressed: _isNextPageUnavailable() ? null : _handleNext, + ), + if (widget.showFirstLastButtons) + IconButton( + icon: Icon(Icons.skip_next, color: widget.arrowHeadColor), + padding: EdgeInsets.zero, + tooltip: localizations.lastPageTooltip, + onPressed: _isNextPageUnavailable() ? null : _handleLast, + ), + Container( + width: containerWidth, + ), + if (widget.footerButton != null) widget.footerButton!, + ]); + + return Card( + semanticContainer: false, + child: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (headerWidgets.isNotEmpty) + Semantics( + container: true, + child: DefaultTextStyle( + style: _selectedRowCount > 0 + ? themeData.textTheme.titleMedium!.copyWith(color: themeData.colorScheme.secondary) + : themeData.textTheme.titleLarge!.copyWith(fontWeight: FontWeight.w400), + child: IconTheme.merge( + data: const IconThemeData( + opacity: 0.54, + ), + child: Ink( + height: 64.0, + color: _selectedRowCount > 0 ? themeData.secondaryHeaderColor : null, + child: Padding( + padding: const EdgeInsetsDirectional.only(start: 24, end: 14.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: headerWidgets, + ), + ), + ), + ), + ), + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + primary: widget.primary, + controller: widget.controller, + dragStartBehavior: widget.dragStartBehavior, + child: ConstrainedBox( + constraints: BoxConstraints(minWidth: constraints.minWidth), + child: DataTable( + key: _tableKey, + columns: widget.columns, + sortColumnIndex: widget.sortColumnIndex, + sortAscending: widget.sortAscending, + onSelectAll: widget.onSelectAll, + decoration: const BoxDecoration(), + dataRowMinHeight: widget.dataRowMinHeight, + dataRowMaxHeight: widget.dataRowMaxHeight, + headingRowHeight: widget.headingRowHeight, + horizontalMargin: widget.horizontalMargin, + checkboxHorizontalMargin: widget.checkboxHorizontalMargin, + columnSpacing: widget.columnSpacing, + showCheckboxColumn: widget.showCheckboxColumn, + showBottomBorder: true, + rows: _getRows(_firstRowIndex, widget.rowsPerPage), + ), + ), + ), + DefaultTextStyle( + style: footerTextStyle!, + child: IconTheme.merge( + data: const IconThemeData( + opacity: 0.54, + ), + child: SizedBox( + height: 56.0, + child: SingleChildScrollView( + dragStartBehavior: widget.dragStartBehavior, + scrollDirection: Axis.horizontal, + reverse: true, + child: Row( + children: footerWidgets, + ), + ), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/lib/transactions/models/transaction.dart b/lib/transactions/models/transaction.dart new file mode 100644 index 0000000..1238df8 --- /dev/null +++ b/lib/transactions/models/transaction.dart @@ -0,0 +1,85 @@ +// Flutter imports: +import 'package:faucet/blocks/models/block.dart'; +import 'package:faucet/shared/utils/decode_id.dart'; +import 'package:faucet/transactions/models/transaction_status.dart'; +import 'package:faucet/transactions/models/transaction_type.dart'; +import 'package:faucet/transactions/utils/utils.dart'; +import 'package:flutter/foundation.dart'; +import 'package:topl_common/proto/genus/genus_rpc.pb.dart'; + +// Package imports: +import 'package:freezed_annotation/freezed_annotation.dart'; +part 'transaction.freezed.dart'; +part 'transaction.g.dart'; + +@freezed +class Transaction with _$Transaction { + const factory Transaction({ + /// The unique identifier of the transaction + required String transactionId, + + /// The status of the transaction + required TransactionStatus status, + + /// The block number of the transaction + required Block block, + + /// The time the transaction was broadcasted + required int broadcastTimestamp, + + /// The time the transaction was confirmed + required int confirmedTimestamp, + + /// The type of transaction + required TransactionType transactionType, + + /// The amount of the transaction + required double amount, + + /// The fee of the transaction + required double transactionFee, + + /// The address of the sender + required List senderAddress, + + /// The address of the receiver + required List receiverAddress, + + /// The size of the transaction + required double transactionSize, + + /// The quantity of the transaction + required double quantity, + + /// The name of the asset + required String name, + }) = _Transaction; + + factory Transaction.fromBlockRes({required BlockResponse blockRes, required int index, required Block block}) { + final outputList = blockRes.block.fullBody.transactions[index].outputs.toList(); + final inputList = blockRes.block.fullBody.transactions[index].inputs.toList(); + final txAmount = calculateAmount(outputs: outputList).toDouble(); + final txFees = calculateFees(inputs: inputList, outputs: outputList).toDouble(); + + final transaction = Transaction( + transactionId: decodeId(blockRes.block.fullBody.transactions[index].transactionId.value), + status: TransactionStatus.pending, + block: block, + broadcastTimestamp: block.timestamp, + confirmedTimestamp: 0, + transactionType: TransactionType.transfer, + amount: txAmount, + transactionFee: txFees, + senderAddress: + blockRes.block.fullBody.transactions[index].inputs.map((e) => decodeId(e.address.id.value)).toList(), + receiverAddress: + blockRes.block.fullBody.transactions[index].outputs.map((e) => decodeId(e.address.id.value)).toList(), + transactionSize: blockRes.block.fullBody.transactions[index].writeToBuffer().lengthInBytes.toDouble(), + quantity: txAmount, + name: blockRes.block.fullBody.transactions[index].inputs[0].value.hasLvl() ? 'Lvl' : 'Topl'); + + return transaction; + } + + factory Transaction.fromJson(Map json) => _$TransactionFromJson(json); +} diff --git a/lib/transactions/models/transaction_status.dart b/lib/transactions/models/transaction_status.dart new file mode 100644 index 0000000..6ebe9f9 --- /dev/null +++ b/lib/transactions/models/transaction_status.dart @@ -0,0 +1,17 @@ +enum TransactionStatus { + pending( + string: 'Pending', + ), + confirmed( + string: 'Confirmed', + ), + failed( + string: 'Failed', + ); + + const TransactionStatus({ + required this.string, + }); + + final String string; +} diff --git a/lib/transactions/models/transaction_type.dart b/lib/transactions/models/transaction_type.dart new file mode 100644 index 0000000..e7484bf --- /dev/null +++ b/lib/transactions/models/transaction_type.dart @@ -0,0 +1,11 @@ +enum TransactionType { + transfer( + string: 'Transfer', + ); + + const TransactionType({ + required this.string, + }); + + final String string; +} diff --git a/lib/transactions/models/utxo.dart b/lib/transactions/models/utxo.dart new file mode 100644 index 0000000..23dc61e --- /dev/null +++ b/lib/transactions/models/utxo.dart @@ -0,0 +1,22 @@ +// Package imports: +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'utxo.freezed.dart'; + +part 'utxo.g.dart'; + +@freezed +class UTxO with _$UTxO { + const factory UTxO({ + // The unique identifier for this unspent transaction output. + required String utxoId, + + // The amount of the unspent transaction output. + required double utxoAmount, + + // The transactionId of the transaction that contains this unspent transaction output. + required String transactionId, + }) = _UTxO; + + factory UTxO.fromJson(Map json) => _$UTxOFromJson(json); +} diff --git a/lib/transactions/providers/transactions_provider.dart b/lib/transactions/providers/transactions_provider.dart new file mode 100644 index 0000000..da350ed --- /dev/null +++ b/lib/transactions/providers/transactions_provider.dart @@ -0,0 +1,289 @@ +import 'package:faucet/blocks/models/block.dart'; +import 'package:faucet/blocks/providers/block_provider.dart'; +import 'package:faucet/chain/models/chains.dart'; +import 'package:faucet/chain/providers/selected_chain_provider.dart'; +import 'package:faucet/shared/providers/config_provider.dart'; +import 'package:faucet/shared/providers/genus_provider.dart'; +import 'package:faucet/shared/utils/decode_id.dart'; +import 'package:faucet/transactions/models/transaction.dart'; +import 'package:faucet/transactions/models/transaction_status.dart'; +import 'package:faucet/transactions/models/transaction_type.dart'; +import 'package:faucet/transactions/utils/utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +final transactionStateAtIndexProvider = FutureProvider.family((ref, index) async { + return ref.watch(transactionsProvider.notifier).getTransactionFromStateAtIndex(index); +}); + +final getTransactionByIdProvider = FutureProvider.family((ref, transactionId) async { + return ref.watch(transactionsProvider.notifier).getTransactionById(transactionId: transactionId); +}); + +final getTransactionsByDepthProvider = FutureProvider.family, int>((ref, depth) async { + final List transactions = []; + //get most recent block + final selectedChain = ref.read(selectedChainProvider.notifier).state; + final genusClient = ref.read(genusProvider(selectedChain)); + + var blockRes = await genusClient.getBlockByDepth(depth: depth); + final config = ref.read(configProvider.future); + final presentConfig = await config; + + //check that fullBody is not empty + if (blockRes.block.fullBody.hasField(1)) { + int transactionCount = blockRes.block.fullBody.transactions.length; + + var latestBlock = Block( + header: decodeId(blockRes.block.header.headerId.value), + epoch: blockRes.block.header.slot.toInt() ~/ presentConfig.config.epochLength.toInt(), + size: blockRes.writeToBuffer().lengthInBytes.toDouble(), + height: blockRes.block.header.height.toInt(), + slot: blockRes.block.header.slot.toInt(), + timestamp: blockRes.block.header.timestamp.toInt(), + transactionNumber: transactionCount, + ); + + //continue going through transactions + for (int i = 0; i < transactionCount; i++) { + //calculate transaction amount + var inputList = blockRes.block.fullBody.transactions[i].inputs.toList(); + var outputList = blockRes.block.fullBody.transactions[i].outputs.toList(); + var txAmount = calculateAmount(outputs: outputList); + var txFees = calculateFees(inputs: inputList, outputs: outputList); + + transactions.add( + Transaction( + transactionId: decodeId(blockRes.block.fullBody.transactions[i].transactionId.value), + status: TransactionStatus.pending, + block: latestBlock, + broadcastTimestamp: latestBlock.timestamp, + confirmedTimestamp: 0, //for the latest block, it will never be confirmed (confirmation depth is 5) + transactionType: TransactionType.transfer, + amount: txAmount.toDouble(), + quantity: txAmount.toDouble(), + transactionFee: txFees.toDouble(), + senderAddress: + blockRes.block.fullBody.transactions[i].inputs.map((e) => decodeId(e.address.id.value)).toList(), + receiverAddress: + blockRes.block.fullBody.transactions[i].outputs.map((e) => decodeId(e.address.id.value)).toList(), + transactionSize: blockRes.block.fullBody.transactions[i].writeToBuffer().lengthInBytes.toDouble(), + name: blockRes.block.fullBody.transactions[i].inputs[0].value.hasLvl() ? 'Lvl' : 'Topl', + ), + ); + } + } + + return transactions; +}); + +/// It is also untested, so it may not work in practice +final transactionsProvider = StateNotifierProvider>>((ref) { + final selectedChain = ref.watch(selectedChainProvider); + return TransactionsNotifier( + ref, + selectedChain, + ); +}); + +class TransactionsNotifier extends StateNotifier>> { + final Ref ref; + final Chains selectedChain; + TransactionsNotifier(this.ref, this.selectedChain) : super(const AsyncLoading()) { + getTransactions(setState: true); + } + + /// Gets a transaction from genus and adds it to state + Future getTransactionById({ + required String transactionId, + }) async { + final transactions = state.asData?.value; + + if (transactions == null) { + throw Exception('Transactions are null'); + } + + // If the list of transactions already contains the transaction, return it + if (transactions.any((element) => element.transactionId == transactionId)) { + return transactions.firstWhere((element) => element.transactionId == transactionId); + } else { + final selectedChain = ref.watch(selectedChainProvider); + final genusClient = ref.read(genusProvider(selectedChain)); + + final config = ref.read(configProvider.future); + final presentConfig = await config; + + var transactionRes = await genusClient.getTransactionById(transactionIdString: transactionId); + + var blockRes = await genusClient.getBlockById( + blockIdBytes: transactionRes.transactionReceipt.blockId.value, + ); + + var block = Block( + header: decodeId(blockRes.block.header.headerId.value), + epoch: blockRes.block.header.slot.toInt() ~/ presentConfig.config.epochLength.toInt(), + size: blockRes.writeToBuffer().lengthInBytes.toDouble(), + height: blockRes.block.header.height.toInt(), + slot: blockRes.block.header.slot.toInt(), + timestamp: blockRes.block.header.timestamp.toInt(), + transactionNumber: blockRes.block.fullBody.transactions.length, + ); + + //calculate values for fields + var inputList = transactionRes.transactionReceipt.transaction.inputs.toList(); + var outputList = transactionRes.transactionReceipt.transaction.outputs.toList(); + var txAmount = calculateAmount(outputs: outputList); + var txFees = calculateFees(inputs: inputList, outputs: outputList); + + final Transaction transaction = Transaction( + transactionId: decodeId(transactionRes.transactionReceipt.transaction.transactionId.value), + status: TransactionStatus.confirmed, + block: block, + broadcastTimestamp: transactionRes.transactionReceipt.transaction.datum.event.schedule.timestamp.toInt(), + confirmedTimestamp: block.timestamp, + transactionType: TransactionType.transfer, + amount: txAmount.toDouble(), + quantity: txAmount.toDouble(), + transactionFee: txFees.toDouble(), + senderAddress: + transactionRes.transactionReceipt.transaction.inputs.map((e) => decodeId(e.address.id.value)).toList(), + receiverAddress: + transactionRes.transactionReceipt.transaction.outputs.map((e) => decodeId(e.address.id.value)).toList(), + transactionSize: transactionRes.writeToBuffer().lengthInBytes.toDouble(), + name: transactionRes.transactionReceipt.transaction.inputs[0].value.hasLvl() ? 'Lvl' : 'Topl'); + + state = AsyncData([...transactions, transaction]); + return transaction; + } + } + + /// This method is used to get the list of transactions + /// and update the state of the provider + /// + /// It takes a bool [setState] + /// + /// If [setState] is true, it will update the state of the provider + /// If [setState] is false, it will not update the state of the provider + Future> getTransactions({ + bool setState = false, + }) async { + if (selectedChain == const Chains.mock()) { + final transactions = List.generate(100, (index) => getMockTransaction()); + if (setState) state = AsyncData(transactions); + return transactions; + } else { + if (setState) state = const AsyncLoading(); + final List transactions = []; + //get first populated block + + var latestBlockRes = await ref.read(blockProvider.notifier).getFirstPopulatedBlock(); + + final config = ref.read(configProvider.future); + final presentConfig = await config; + + int transactionCount = latestBlockRes.block.fullBody.transactions.length; + + var latestBlock = Block( + header: decodeId(latestBlockRes.block.header.headerId.value), + epoch: latestBlockRes.block.header.slot.toInt() ~/ presentConfig.config.epochLength.toInt(), + size: latestBlockRes.writeToBuffer().lengthInBytes.toDouble(), + height: latestBlockRes.block.header.height.toInt(), + slot: latestBlockRes.block.header.slot.toInt(), + timestamp: latestBlockRes.block.header.timestamp.toInt(), + transactionNumber: transactionCount, + ); + + //continue going through transactions + for (int i = 0; i < transactionCount; i++) { + //calculate transaction amount + var outputList = latestBlockRes.block.fullBody.transactions[i].outputs.toList(); + var inputList = latestBlockRes.block.fullBody.transactions[i].inputs.toList(); + var txAmount = calculateAmount(outputs: outputList); + var txFees = calculateFees(inputs: inputList, outputs: outputList); + + transactions.add( + Transaction( + transactionId: decodeId(latestBlockRes.block.fullBody.transactions[i].transactionId.value), + status: TransactionStatus.pending, + block: latestBlock, + broadcastTimestamp: latestBlock.timestamp, + confirmedTimestamp: 0, //for the latest block, it will never be confirmed (confirmation depth is 5) + transactionType: TransactionType.transfer, + amount: txAmount.toDouble(), + quantity: txAmount.toDouble(), + transactionFee: txFees.toDouble(), + senderAddress: + latestBlockRes.block.fullBody.transactions[i].inputs.map((e) => decodeId(e.address.id.value)).toList(), + receiverAddress: + latestBlockRes.block.fullBody.transactions[i].outputs.map((e) => decodeId(e.address.id.value)).toList(), + transactionSize: latestBlockRes.block.fullBody.transactions[i].writeToBuffer().lengthInBytes.toDouble(), + name: latestBlockRes.block.fullBody.transactions[i].inputs[0].value.hasLvl() ? 'Lvl' : 'Topl', + ), + ); + } + if (setState) { + Future.delayed(const Duration(seconds: 1), () { + state = AsyncData(transactions); + }); + } + return transactions; + } + } + + /// This method is used to get a transaction at a specific index + /// If the transaction is not in the state, it will fetch the transaction from Genus + /// It takes an [index] as a parameter + /// + /// It returns a [Future] + Future getTransactionFromStateAtIndex(int index) async { + final transactions = state.asData?.value; + + if (transactions == null) { + throw Exception('Error in transactionsProvider: transactions are null'); + } + + // If the index is less than the length of the list, return the transaction at that index + if (index < transactions.length) { + return transactions[index]; + } else { + Transaction newTransaction; + final genusClient = ref.read(genusProvider(selectedChain)); + //get final transaction in state + Transaction lastTransaction = transactions.last; + //get block that contains the last transaction + final lastTransactionBlock = await genusClient.getBlockById(blockIdString: lastTransaction.block.header); + int transactionsInLastBlock = lastTransactionBlock.block.fullBody.transactions.length; + //find transaction in block + int indexInBlock = lastTransactionBlock.block.fullBody.transactions.indexWhere((transaction) { + return decodeId(transaction.transactionId.value) == lastTransaction.transactionId; + }); + + if (indexInBlock < transactionsInLastBlock - 1) { + //use next transaction in block + newTransaction = Transaction.fromBlockRes( + blockRes: lastTransactionBlock, index: indexInBlock + 1, block: lastTransaction.block); + } else { + //fetch new block descending in height + final config = ref.read(configProvider.future); + final presentConfig = await config; + + final nextBlockRes = + await ref.read(blockProvider.notifier).getNextPopulatedBlock(height: lastTransaction.block.height - 1); + var nextBlock = Block( + header: decodeId(nextBlockRes.block.header.headerId.value), + epoch: nextBlockRes.block.header.slot.toInt() ~/ presentConfig.config.epochLength.toInt(), + size: nextBlockRes.writeToBuffer().lengthInBytes.toDouble(), + height: nextBlockRes.block.header.height.toInt(), + slot: nextBlockRes.block.header.slot.toInt(), + timestamp: nextBlockRes.block.header.timestamp.toInt(), + transactionNumber: nextBlockRes.block.fullBody.transactions.length, + ); + + newTransaction = Transaction.fromBlockRes(blockRes: nextBlockRes, index: 0, block: nextBlock); + } + + transactions.add(newTransaction); + state = AsyncData([...transactions]); + return newTransaction; + } + } +} diff --git a/lib/transactions/providers/utxo_provider.dart b/lib/transactions/providers/utxo_provider.dart new file mode 100644 index 0000000..b653b60 --- /dev/null +++ b/lib/transactions/providers/utxo_provider.dart @@ -0,0 +1,34 @@ +import 'package:faucet/chain/models/chains.dart'; +import 'package:faucet/chain/providers/selected_chain_provider.dart'; +import 'package:faucet/transactions/models/utxo.dart'; +import 'package:faucet/transactions/utils/utxo_utils.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// The [utxoByIdProvider] retrieves the [UTxO] by its [utxoId]. +/// If [mockStateProvider] is true, the function returns a mock [UTxO] after a delay of 1 second. +/// Otherwise, it throws an exception and still needs to be implemented +/// +/// Example usage: +/// +/// ``` +/// final UTxO utxo = await ref.read(utxoByIdProvider('utxoId').future); +/// +/// final AsyncValue utxoAsyncValue = ref.watch(utxoByIdProvider('utxoId')); +/// +/// return utxoAsyncValue.when( +/// data: (utxo) => Text('UTxO: $utxo'), +/// loading: () => CircularProgressIndicator(), +/// error: (error, stackTrace) => Text('Error: $error'), +/// ); +/// ``` +final utxoByIdProvider = FutureProvider.family.autoDispose((ref, utxoId) async { + final selectedChain = ref.watch(selectedChainProvider); + if (selectedChain == Chains.mock) { + return Future.delayed( + const Duration(seconds: 1), + () => getMockUTxO(), + ); + } else { + throw Exception('UTxO RPC calls not yet implemented'); + } +}); diff --git a/lib/transactions/sections/transaction_row_item.dart b/lib/transactions/sections/transaction_row_item.dart new file mode 100644 index 0000000..2957d2e --- /dev/null +++ b/lib/transactions/sections/transaction_row_item.dart @@ -0,0 +1,172 @@ +import 'package:faucet/shared/constants/strings.dart'; +import 'package:faucet/transactions/widgets/custom_transaction_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:faucet/transactions/models/transaction.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; + +/// A widget to display the list of transactions. +class TransactionTableRow extends HookConsumerWidget { + const TransactionTableRow({Key? key, required this.transactions, this.count = 0}) : super(key: key); + final int count; + final List transactions; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final Transaction transaction = transactions[count]; + final isDesktop = ResponsiveBreakpoints.of(context).equals(DESKTOP); + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + final isResponsive = ResponsiveBreakpoints.of(context).smallerOrEqualTo(TABLET); + + return GestureDetector( + onTap: () { + // Add what you want to do on tap + }, + child: Row( + mainAxisAlignment: isDesktop ? MainAxisAlignment.spaceEvenly : MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: isTablet + ? 130 + : isMobile + ? 170 + : 450, + child: TransactionColumnText( + isTransactionTable: false, + textTop: transaction.transactionId.replaceRange( + isTablet + ? 7 + : isDesktop + ? 38 + : 16, + transaction.transactionId.length, + "..."), + textBottom: "49 ${Strings.secAgo}", + isSmallFont: true, + ), + ), + if (isMobile) + const SizedBox( + width: 30, + ), + SizedBox( + width: isResponsive + ? 100 + : isDesktop + ? 150 + : 200, + child: TransactionColumnText( + isTransactionTable: false, + textTop: '${Strings.height}: ${transaction.block.height}', + textBottom: '${Strings.slot}: ${transaction.block.slot}', + ), + ), + if (!isMobile) + SizedBox( + width: isTablet + ? 110 + : isDesktop + ? 150 + : 200, + child: TransactionColumnText( + isTransactionTable: false, + textTop: transaction.transactionType.string, + textBottom: "", + isBottomTextRequired: false, + ), + ), + if (!isMobile) + SizedBox( + width: isTablet + ? 90 + : isDesktop + ? 150 + : 200, + child: TransactionColumnText( + isTransactionTable: false, + textTop: '${transaction.quantity} ${Strings.topl}', + textBottom: '${transaction.amount} ${Strings.bobs}'), + ), + if (!isMobile) + SizedBox( + width: isTablet ? 110 : 150, + child: TransactionColumnText( + isTransactionTable: false, + textTop: '${transaction.transactionFee} ${Strings.feeAcronym}', + textBottom: "", + isBottomTextRequired: false, + ), + ), + if (!isMobile) + SizedBox( + width: isTablet ? 85 : 300, + child: StatusButton( + isTransactionTable: true, + status: transaction.status.string, + hideArrowIcon: false, + )), + ])); + } +} + +/// Data source class for obtaining row data for PaginatedDataTable. +class RowDataSource extends DataTableSource { + RowDataSource(this.data, this.context, this.clr); + + BuildContext context; + List data; + Color clr; + + @override + DataRow? getRow(int index) { + final row = data[index]; + if (index < data.length) { + return DataRow(color: MaterialStateProperty.all(clr), cells: [ + DataCell(GestureDetector( + child: const TransactionColumnText( + isTransactionTable: true, + textTop: '50 ${Strings.feeAcronym}', + textBottom: "", + ), + )), + const DataCell(TransactionColumnText( + isTransactionTable: true, + textTop: 'Valhalla', + textBottom: '', + isBottomTextRequired: false, + )), + const DataCell(TransactionColumnText( + isTransactionTable: true, + textTop: '0x2345...0nM987', + textBottom: "", + isBottomTextRequired: false, + )), + const DataCell( + TransactionColumnText(isTransactionTable: true, textTop: '06/29/2023', textBottom: '12:33:51 PM')), + DataCell(StatusButton( + isTransactionTable: true, + status: row.status.string, + hideArrowIcon: false, + )), + ]); + } else { + return null; + } + } + + @override + bool get isRowCountApproximate => false; + + @override + int get rowCount => data.length; + + @override + int get selectedRowCount => 0; + + @override + void notifyListeners() { + super.notifyListeners(); + } +} diff --git a/lib/transactions/sections/transaction_table.dart b/lib/transactions/sections/transaction_table.dart new file mode 100644 index 0000000..dd4c578 --- /dev/null +++ b/lib/transactions/sections/transaction_table.dart @@ -0,0 +1,185 @@ +import 'package:faucet/home/sections/get_test_tokens.dart'; +import 'package:faucet/shared/constants/strings.dart'; +import 'package:faucet/shared/constants/ui.dart'; +import 'package:faucet/shared/providers/app_theme_provider.dart'; +import 'package:faucet/shared/utils/theme_color.dart'; +import 'package:faucet/shared/widgets/paginated_table.dart'; +import 'package:faucet/transactions/models/transaction.dart'; +import 'package:faucet/transactions/providers/transactions_provider.dart'; +import 'package:flutter/material.dart'; +import 'package:faucet/transactions/sections/transaction_row_item.dart'; +import 'package:faucet/transactions/widgets/custom_transaction_widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:modal_side_sheet/modal_side_sheet.dart'; +import 'package:responsive_framework/responsive_framework.dart'; + +/// This is a custom widget that shows the transaction table screen +class TransactionTableScreen extends StatefulHookConsumerWidget { + const TransactionTableScreen({Key? key}) : super(key: key); + static const String route = '/transactions'; + @override + _TransactionTableScreenState createState() => _TransactionTableScreenState(); +} + +class _TransactionTableScreenState extends ConsumerState { + bool viewAll = false; + var _rowsPerPage = 10; //PaginatedDataTable.defaultRowsPerPage; + @override + Widget build(BuildContext context) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + final isBiggerTablet = MediaQuery.of(context).size.width == 1024; + final isBiggerScreen = MediaQuery.of(context).size.width == 1920; + + final colorTheme = ref.watch(appThemeColorProvider); + final AsyncValue> transactionsInfo = ref.watch(transactionsProvider); + + return transactionsInfo.when( + data: (transactions) { + return Container( + color: colorTheme == ThemeMode.light ? const Color(0xFFFEFEFE) : const Color(0xFF282A2C), + child: Column( + children: [ + Wrap( + children: [ + Container( + margin: EdgeInsets.only( + left: isMobile ? 16.0 : 40.0, right: isMobile ? 0 : 40.0, top: 8.0, bottom: 80.0), + height: isTablet ? MediaQuery.of(context).size.height - 435 : null, + child: SingleChildScrollView( + child: Theme( + data: Theme.of(context).copyWith( + cardColor: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + ), + child: PaginatedTable( + showCheckboxColumn: false, + headingRowHeight: 80, + footerButton: Row( + children: [ + const SizedBox(width: 180), + Padding( + padding: const EdgeInsets.all(8.0), + child: Container( + height: 50.0, + width: 150.0, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10.0), + color: const Color(0xFF0DC8D4), + ), + child: TextButton( + onPressed: () { + Navigator.of(context).pop(); + showModalSideSheet( + context: context, + ignoreAppBar: true, + width: 640, + barrierColor: Colors.white.withOpacity(barrierOpacity), + barrierDismissible: true, + body: GetTestTokens( + colorTheme: colorTheme, + ), + ); + }, + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular( + 20.0, + ), + ), + ), + child: const Text( + Strings.requestTokens, + style: TextStyle( + color: Colors.white, + ), + ), + ), + ), + ), + ], + ), + columnSpacing: isBiggerTablet + ? 55 + : isTablet + ? 50 + : isBiggerScreen + ? 133 + : 150, + arrowHeadColor: getSelectedColor(colorTheme, 0xFF282A2C, 0xFFFEFEFE), + source: RowDataSource( + transactions, context, getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C)), + showFirstLastButtons: true, + rowsPerPage: _rowsPerPage, + dataRowHeight: 80, + availableRowsPerPage: const [1, 5, 10, 50], + onRowsPerPageChanged: (newRowsPerPage) { + if (newRowsPerPage != null) { + // setState(() { + // _rowsPerPage = newRowsPerPage; + // }); + } + }, + onPageChanged: (int? n) { + /// value of n is the number of rows displayed so far + setState(() { + if (n != null) { + final source = RowDataSource( + transactions, context, getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C)); + + /// Update rowsPerPage if the remaining count is less than the default rowsPerPage + if (source.rowCount - n < _rowsPerPage) { + _rowsPerPage = source.rowCount - n; + } else { + _rowsPerPage = PaginatedDataTable.defaultRowsPerPage; + } + } else { + _rowsPerPage = 0; + } + }); + }, + columns: [ + DataColumn( + label: Padding( + padding: EdgeInsets.only(left: isTablet ? 2.0 : 40.0), + child: const SizedBox( + child: TableHeaderText(name: Strings.tableTokens), + ), + ), + ), + DataColumn( + label: Padding( + padding: EdgeInsets.only(left: isTablet ? 2.0 : 40.0), + child: const TableHeaderText(name: Strings.tableNetwork), + )), + DataColumn( + label: Padding( + padding: EdgeInsets.only(left: isTablet ? 2.0 : 40.0), + child: const TableHeaderText(name: Strings.tableWallet), + )), + DataColumn( + label: Padding( + padding: EdgeInsets.only(left: isTablet ? 2.0 : 40.0), + child: const TableHeaderText(name: Strings.tableDateAndTime), + )), + const DataColumn( + label: Padding( + padding: EdgeInsets.only(left: 2.0), + child: TableHeaderText(name: Strings.tableHeaderStatus), + )), + ], + ), + ), + ), + ), + ], + ) + ], + ), + ); + }, + error: (error, stack) => const Text('Oops, something unexpected happened'), + loading: () => const Center( + child: CircularProgressIndicator(), + )); + } +} diff --git a/lib/transactions/sections/transactions.dart b/lib/transactions/sections/transactions.dart new file mode 100644 index 0000000..8189ff9 --- /dev/null +++ b/lib/transactions/sections/transactions.dart @@ -0,0 +1,194 @@ +import 'package:faucet/shared/constants/strings.dart'; +import 'package:faucet/shared/utils/theme_color.dart'; +import 'package:faucet/transactions/models/transaction.dart'; +import 'package:faucet/transactions/widgets/custom_transaction_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:faucet/shared/providers/app_theme_provider.dart'; +import 'package:faucet/shared/theme.dart'; +import 'package:faucet/transactions/providers/transactions_provider.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; + +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +/// A widget to display the list of transactions. +class Transactions extends HookConsumerWidget { + const Transactions({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final AsyncValue> transactionsInfo = ref.watch(transactionsProvider); + final colorTheme = ref.watch(appThemeColorProvider); + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + final isDesktop = ResponsiveBreakpoints.of(context).equals(DESKTOP); + + final List columnHeaders = [ + Strings.tableTokens, + Strings.tableNetwork, + ...isMobile + ? [] + : [ + Strings.tableWallet, + Strings.tableDateAndTime, + Strings.tableHeaderStatus, + ], + ]; + + return transactionsInfo.when( + data: (transactions) => Container( + margin: EdgeInsets.only(top: 20.0, bottom: 20.0, left: isMobile ? 16.0 : 40.0, right: isMobile ? 16.0 : 40.0), + padding: const EdgeInsets.only(top: 20.0, bottom: 20.0, left: 0.0, right: 0.0), + decoration: BoxDecoration( + color: getSelectedColor(colorTheme, 0xFFFFFFFF, 0xFF282A2C), + borderRadius: BorderRadius.circular(!isMobile ? 10.0 : 0.0), + border: !isMobile + ? Border.all( + color: getSelectedColor(colorTheme, 0xFFE7E8E8, 0xFF4B4B4B), style: BorderStyle.solid, width: 1.0) + : null, + ), + child: Column( + children: [ + SizedBox( + width: double.infinity, + child: DataTable( + horizontalMargin: 0, + columnSpacing: 0, + border: TableBorder.symmetric( + inside: BorderSide( + color: getSelectedColor(colorTheme, 0xFFE7E8E8, 0xFF4B4B4B), + width: 1, + style: BorderStyle.none, + ), + ), + columns: columnHeaders + .map( + (columnHeader) => DataColumn( + label: Padding( + padding: EdgeInsets.only( + left: isMobile + ? 0 + : isTablet + ? 25.0 + : 40.0, + bottom: 16, + top: 16), + child: Text( + columnHeader, + style: labelLarge(context), + ), + ), + ), + ) + .toList(), + dataRowMinHeight: 80.0, + dataRowMaxHeight: 100.0, + showCheckboxColumn: false, + rows: transactions + .map( + (transaction) => DataRow( + onSelectChanged: (value) { + //handles on click + }, + cells: [ + DataCell(Container( + transform: isTablet ? Matrix4.translationValues(-15, 0, 0) : null, + padding: EdgeInsets.only(top: isDesktop ? 10.0 : 0.0), + child: TransactionColumnText( + textTop: transaction.transactionId + .replaceRange(isTablet ? 7 : 16, transaction.transactionId.length, "..."), + textBottom: "49 ${Strings.secAgo}", + ), + )), + DataCell(Container( + transform: isTablet ? Matrix4.translationValues(-15, 0, 0) : null, + padding: EdgeInsets.only(top: isDesktop ? 10.0 : 0.0), + child: TransactionColumnText( + textTop: '${Strings.height}: ${transaction.block.height}', + textBottom: '${Strings.slot}: ${transaction.block.slot}', + ), + )), + if (!isMobile) + DataCell(Container( + transform: isTablet ? Matrix4.translationValues(-15, 0, 0) : null, + padding: EdgeInsets.only(top: isDesktop ? 10.0 : 0.0), + child: TransactionColumnText( + textTop: transaction.transactionType.string, + textBottom: "", + isBottomTextRequired: false, + ), + )), + if (!isMobile) + DataCell(Container( + transform: isTablet ? Matrix4.translationValues(-15, 0, 0) : null, + padding: EdgeInsets.only(top: isDesktop ? 10.0 : 0.0), + child: TransactionColumnText( + textTop: '${transaction.quantity} ${Strings.topl}', + textBottom: '${transaction.amount} ${Strings.bobs}', + ), + )), + if (!isMobile) + DataCell(Padding( + padding: const EdgeInsets.only( + left: 30, + ), + child: StatusButton(status: transaction.status.string, hideArrowIcon: false), + )), + ], + ), + ) + .toList(), + ), + ), + const SizedBox(height: 20.0), + ], + ), + ), + error: (error, stack) => const Text('Oops, something unexpected happened'), + loading: () => const Center( + child: CircularProgressIndicator(), + ), + ); + } +} + +class Spacing extends StatelessWidget { + const Spacing({ + super.key, + }); + + @override + Widget build(BuildContext context) { + return const SizedBox( + width: 60, + height: 44, + ); + } +} + +class CustomTextRight extends HookConsumerWidget { + const CustomTextRight({ + super.key, + required this.desc, + }); + + final String desc; + + @override + Widget build(BuildContext context, WidgetRef ref) { + return Text(desc, style: bodyMedium(context)); + } +} + +class CustomTextLeft extends StatelessWidget { + const CustomTextLeft({ + super.key, + required this.desc, + }); + + final String desc; + + @override + Widget build(BuildContext context) { + return Text(desc, style: labelLarge(context)); + } +} diff --git a/lib/transactions/utils/extension.dart b/lib/transactions/utils/extension.dart new file mode 100644 index 0000000..fa3e80e --- /dev/null +++ b/lib/transactions/utils/extension.dart @@ -0,0 +1,11 @@ +import 'package:faucet/transactions/models/transaction.dart'; +import 'package:topl_common/proto/genus/genus_rpc.pbgrpc.dart'; + +import 'utils.dart'; + +extension TransactionResponseExtension on TransactionResponse { + // TODO: Implement once response models are finalized + Transaction toTransaction() { + return getMockTransaction(); + } +} diff --git a/lib/transactions/utils/utils.dart b/lib/transactions/utils/utils.dart new file mode 100644 index 0000000..efccb30 --- /dev/null +++ b/lib/transactions/utils/utils.dart @@ -0,0 +1,73 @@ +import 'package:faucet/blocks/utils/utils.dart'; +import 'package:faucet/chain/models/chains.dart'; +import 'package:faucet/shared/extensions/extensions.dart'; +import 'package:faucet/transactions/models/transaction.dart'; +import 'package:faucet/transactions/models/transaction_status.dart'; +import 'package:faucet/transactions/models/transaction_type.dart'; +import 'package:topl_common/proto/brambl/models/transaction/spent_transaction_output.pb.dart'; +import 'package:topl_common/proto/brambl/models/transaction/unspent_transaction_output.pb.dart'; + +import '../../blocks/models/block.dart'; + +Transaction getMockTransaction() { + return Transaction( + transactionId: "8EhwUBiHJ3evyGidV1WH8Q8EhwUBiHJ3evyGidV1WH8Q", + status: TransactionStatus.confirmed, + block: getMockBlock(), + broadcastTimestamp: DateTime.now().millisecondsSinceEpoch, + confirmedTimestamp: DateTime.now().millisecondsSinceEpoch, + transactionType: TransactionType.transfer, + amount: 1, + quantity: 10, + transactionFee: 1, + senderAddress: ['1234567890123456789012345678901234567890'], + receiverAddress: ['1234567890123456789012345678901234567890'], + transactionSize: 1, + name: '1234567890', + ); +} + +List getInputBigInts({required List inputs}) { + List inputLvls = inputs.where((element) { + return element.value.hasLvl(); + }).toList(); + + return inputLvls.map((e) { + return e.value.lvl.quantity.value.toBigInt; + }).toList(); +} + +List getOutputBigInts({required List outputs}) { + List outputLvls = outputs.where((element) { + return element.value.hasLvl(); + }).toList(); + + return outputLvls.map((e) { + return e.value.lvl.quantity.value.toBigInt; + }).toList(); +} + +BigInt calculateAmount({required List outputs}) { + List outputBigInts = getOutputBigInts(outputs: outputs); + return outputBigInts.reduce((value, element) => value + element); +} + +BigInt calculateFees({required List inputs, required List outputs}) { + List inputBigInts = getInputBigInts(inputs: inputs); + List outputBigInts = getOutputBigInts(outputs: outputs); + + BigInt inputSum = inputBigInts.reduce((value, element) => value + element); + BigInt outputSum = outputBigInts.reduce((value, element) => value + element); + + return inputSum - outputSum; +} + +Map sortBlocksByDepth({required Map blocks}) { + List> sortedBlocks = blocks.entries.toList(); + sortedBlocks.sort((a, b) => b.key.compareTo(a.key)); + return {...Map.fromEntries(sortedBlocks)}; +} + +String shortenNetwork(Chains chain) { + return chain.networkName.length > 8 ? '${chain.networkName.substring(0, 7)}...' : chain.networkName; +} diff --git a/lib/transactions/utils/utxo_utils.dart b/lib/transactions/utils/utxo_utils.dart new file mode 100644 index 0000000..ac4e4b2 --- /dev/null +++ b/lib/transactions/utils/utxo_utils.dart @@ -0,0 +1,9 @@ +import 'package:faucet/transactions/models/utxo.dart'; + +UTxO getMockUTxO() { + return const UTxO( + utxoId: '0x5be9d701Byd24neQfY1vXa987a', + utxoAmount: 0.63, + transactionId: '503a2f166a3bb0b467b8ffaec53a2b4fffef33', + ); +} diff --git a/lib/transactions/widgets/custom_transaction_widgets.dart b/lib/transactions/widgets/custom_transaction_widgets.dart new file mode 100644 index 0000000..6ec1742 --- /dev/null +++ b/lib/transactions/widgets/custom_transaction_widgets.dart @@ -0,0 +1,523 @@ +import 'package:faucet/shared/constants/strings.dart'; +import 'package:faucet/shared/providers/app_theme_provider.dart'; +import 'package:faucet/shared/theme.dart'; +import 'package:faucet/shared/widgets/copy_to_clipboard.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; +import 'package:responsive_framework/responsive_row_column.dart'; + +import '../../shared/utils/theme_color.dart'; +import '../sections/transactions.dart'; +import 'package:flutter/services.dart'; + +/// Custom Status Button Widget +class StatusButton extends ConsumerWidget { + const StatusButton({super.key, this.status = "pending", this.hideArrowIcon = true, this.isTransactionTable = true}); + + final String status; + final bool hideArrowIcon; + final bool isTransactionTable; + + /// Function to return color based on status + int _color(String statusSelected) { + if (statusSelected == "Pending") { + return 0xFF7040EC; + } else if (statusSelected == "Confirmed") { + return 0xFF4BBF6B; + } else { + return 0xFFF07575; + } + } + + // Function to return icon based on status + IconData _icon(String statusSelected) { + if (statusSelected == "Pending") { + return Icons.access_time; + } else if (statusSelected == "Confirmed") { + return Icons.check; + } else { + return Icons.clear; + } + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorTheme = ref.watch(appThemeColorProvider); + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + + return Padding( + padding: EdgeInsets.only( + left: hideArrowIcon && !isTablet && !isTransactionTable ? 40.0 : 0, bottom: 16, right: 40, top: 16), + child: Row( + children: [ + SizedBox( + height: 40, + width: isTablet ? 120 : 160, + child: TextButton( + onPressed: () {}, + style: TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8.0), + ), + backgroundColor: Color(_color(status)).withOpacity(0.04), + // add opacity to the color + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + children: [ + Icon( + _icon(status), + color: Color(_color(status)), + ), + const SizedBox( + width: 8.0, + ), + Expanded( + child: Text( + status, + style: TextStyle( + fontSize: isTablet ? 10 : 14, + fontFamily: Strings.rationalDisplayFont, + fontWeight: FontWeight.w500, + color: Color(_color(status)), + ), + ), + ), + ], + ), + ), + ), + SizedBox( + width: isTablet ? 3.0 : 45.0, + ), + hideArrowIcon + ? Expanded( + child: Icon( + Icons.arrow_forward_ios, + color: getSelectedColor(colorTheme, 0xFF858E8E, 0xFFC0C4C4), + size: 14, + )) + : const SizedBox(), + ], + ), + ); + } +} + +/// Custom Widgets - Table Header Text Widget +class TableHeaderText extends StatelessWidget { + const TableHeaderText({ + super.key, + required this.name, + }); + + final String name; + + @override + Widget build(BuildContext context) { + return Text( + name, + style: labelLarge(context), + ); + } +} + +/// Custom Transaction Column Text Widget +class TransactionColumnText extends ConsumerWidget { + const TransactionColumnText({ + super.key, + required this.textTop, + required this.textBottom, + this.isBottomTextRequired = true, + this.isTransactionTable = false, + this.isSmallFont = false, + }); + + final String textTop; + final String textBottom; + final bool isBottomTextRequired; + final bool isTransactionTable; + final bool isSmallFont; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + + return Padding( + padding: EdgeInsets.only(left: isMobile || isTablet && isTransactionTable ? 0 : 40.0, bottom: 16, top: 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + textTop, + style: bodyMedium(context), + ), + const SizedBox(height: 5), + isBottomTextRequired + ? Text( + textBottom, + overflow: TextOverflow.ellipsis, + style: isSmallFont ? bodySmall(context) : bodyMedium(context), + ) + : const SizedBox(height: 0), + ], + ), + ); + } +} + +class CustomContainer extends HookConsumerWidget { + const CustomContainer({ + super.key, + required this.child, + }); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final colorTheme = ref.watch(appThemeColorProvider); + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + return Container( + margin: EdgeInsets.only(top: 20.0, bottom: 20.0, left: isMobile ? 20.0 : 40.0, right: isMobile ? 20.0 : 40.0), + padding: const EdgeInsets.only( + top: 20.0, + bottom: 30.0, + left: 0.0, + right: 0.0, + ), + width: isMobile ? 670 : null, + decoration: BoxDecoration( + color: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + borderRadius: BorderRadius.circular(10.0), + border: Border.all( + color: getSelectedColor(colorTheme, 0xFFE7E8E8, 0xFF4B4B4B), + style: BorderStyle.solid, + width: 1.0, + ), + ), + child: child, + ); + } +} + +/// Widget for Transaction Status +class CustomStatusWidget extends StatelessWidget { + final String status; + + const CustomStatusWidget({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); +// ResponsiveColumn + + return ResponsiveRowColumn( + layout: isMobile ? ResponsiveRowColumnType.COLUMN : ResponsiveRowColumnType.ROW, + children: [ + ResponsiveRowColumnItem( + child: isMobile + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 25, + ), + const SizedBox( + width: 172, + child: CustomTextLeft(desc: 'Status'), + ), + const SizedBox( + height: 6, + ), + StatusButton( + status: status, + hideArrowIcon: false, + ), + ], + ) + : const Row( + children: [ + Spacing(), + SizedBox( + width: 172, + child: CustomTextLeft(desc: 'Status'), + ), + SizedBox( + width: 14, + ), + StatusButton( + status: 'Confirmed', + hideArrowIcon: false, + ), + ], + )) + ]); + } +} + +/// widget for Responsive Column row +class CustomResponsiveRowColumn extends StatelessWidget { + final List children; + + const CustomResponsiveRowColumn({ + Key? key, + required this.children, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + + final layout = isMobile ? ResponsiveRowColumnType.COLUMN : ResponsiveRowColumnType.ROW; + + return ResponsiveRowColumn( + layout: layout, + children: children.map((child) { + return ResponsiveRowColumnItem( + rowFlex: isMobile ? 3 : 2, + child: Padding( + padding: isMobile ? const EdgeInsets.only(top: 0, bottom: 0, left: 10, right: 10) : EdgeInsets.zero, + child: child, + ), + ); + }).toList(), + ); + } +} + +/// widget for Custom Row With Text +class CustomRowWithText extends StatelessWidget { + final String rightText; + final String leftText; + final bool hasIcon; + const CustomRowWithText({Key? key, required this.leftText, required this.rightText, this.hasIcon = false}) + : super(key: key); + + @override + Widget build(BuildContext context) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + + return Row( + children: [ + const Spacing(), + SizedBox( + width: 172, + child: CustomTextLeft(desc: leftText), + ), + const SizedBox( + width: 24, + ), + Expanded(child: CustomTextRight(desc: rightText)), + Padding( + padding: !isMobile ? const EdgeInsets.only(top: 0, bottom: 0, left: 10, right: 0) : EdgeInsets.zero, + child: hasIcon + ? GestureDetector( + onTap: () { + Clipboard.setData(ClipboardData(text: rightText)); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text(Strings.copyToClipboard), + ), + ); + }, + child: const Icon(Icons.copy)) + : null), + ], + ); + } +} + +/// widget for Custom Column With Text +class CustomColumnWithText extends StatelessWidget { + final String leftText; + final String rightText; + final bool hasIcon; + + const CustomColumnWithText({ + Key? key, + required this.leftText, + required this.rightText, + this.hasIcon = false, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox( + height: 15, + ), + SizedBox( + width: 172, + child: CustomTextLeft(desc: leftText), + ), + const SizedBox( + height: 14, + ), + Row( + children: [ + Expanded( + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), // Adjust the vertical padding as needed + child: CustomTextRight(desc: rightText), + ), + ), + ), + Padding( + padding: isMobile ? const EdgeInsets.only(top: 0, bottom: 0, left: 10, right: 0) : EdgeInsets.zero, + child: hasIcon ? CopyToClipboard(rightText: rightText) : null), + ], + ), + ], + ); + } +} + +/// widget for custom padding +class CustomPadding extends StatelessWidget { + final Widget child; + + const CustomPadding({ + Key? key, + required this.child, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + + return Padding( + padding: isMobile ? const EdgeInsets.only(top: 0, bottom: 0, left: 10, right: 10) : EdgeInsets.zero, + child: child, + ); + } +} + +class CustomToast extends StatelessWidget { + const CustomToast({ + Key? key, + required this.colorTheme, + required this.cancel, + required this.isSuccess, + }) : super(key: key); + + final ThemeMode colorTheme; + final VoidCallback cancel; + final bool isSuccess; + + @override + Widget build(BuildContext context) { + final isTablet = ResponsiveBreakpoints.of(context).between(TABLET, DESKTOP); + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + + return generateContainer( + colorTheme: colorTheme, + context: context, + isTablet: isTablet, + isMobile: isMobile, + isSuccess: isSuccess, + cancel: cancel, + message: "Network was added ${isMobile ? '\n' : ""}successfully", + ); + } +} + +class RemoveNetworkToast extends StatelessWidget { + const RemoveNetworkToast({ + Key? key, + required this.colorTheme, + required this.cancel, + required this.isSuccess, + }) : super(key: key); + + final ThemeMode colorTheme; + final VoidCallback cancel; + final bool isSuccess; + + @override + Widget build(BuildContext context) { + final isTablet = ResponsiveBreakpoints.of(context).between(TABLET, DESKTOP); + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + + return generateContainer( + colorTheme: colorTheme, + context: context, + isTablet: isTablet, + isMobile: isMobile, + isSuccess: isSuccess, + cancel: cancel, + message: "Network was removed ${isMobile ? '\n' : ""}successfully", + ); + } +} + +Container generateContainer({ + required ThemeMode colorTheme, + required BuildContext context, + required bool isTablet, + required bool isMobile, + required bool isSuccess, + required Function() cancel, + required String message, +}) { + return Container( + height: 64, + width: isTablet ? 500 : 345, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(8), + color: getSelectedColor(colorTheme, 0xFFFEFEFE, 0xFF282A2C), + border: Border.all( + color: getSelectedColor(colorTheme, 0xFFE0E0E0, 0xFF858E8E), + ), + boxShadow: [ + BoxShadow( + color: Colors.grey.withOpacity(0.5), + spreadRadius: 2, + blurRadius: 5, + offset: const Offset(0, 3), + ), + ], + ), + child: Row( + children: [ + const SizedBox(width: 16), + isSuccess + ? const Icon( + Icons.check, + color: Colors.green, + size: 24, + ) + : const Icon( + Icons.warning_amber, + color: Colors.red, + size: 24, + ), + const SizedBox(width: 16), + Expanded( + child: SizedBox( + width: 450, + child: Text( + isSuccess ? message : "Something went wrong... ${isMobile ? '\n' : ""}Please try again later", + style: bodyMedium(context), + ), + ), + ), + const SizedBox( + width: 20, + ), + IconButton( + icon: const Icon(Icons.close), + onPressed: cancel, + ), + ], + ), + ); +} diff --git a/lib/transactions/widgets/paginated_transaction_table.dart b/lib/transactions/widgets/paginated_transaction_table.dart new file mode 100644 index 0000000..f8eb97b --- /dev/null +++ b/lib/transactions/widgets/paginated_transaction_table.dart @@ -0,0 +1,258 @@ +import 'package:faucet/shared/constants/strings.dart'; +import 'package:faucet/shared/constants/ui.dart'; +import 'package:faucet/shared/providers/app_theme_provider.dart'; +import 'package:faucet/shared/utils/theme_color.dart'; +import 'package:faucet/transactions/models/transaction.dart'; +import 'package:faucet/transactions/widgets/custom_transaction_widgets.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:modal_side_sheet/modal_side_sheet.dart'; +import 'package:responsive_framework/responsive_breakpoints.dart'; + +class PaginatedTransactionTable extends HookConsumerWidget { + final List transactions; + final bool showAllColumns; + final bool isTabView; + const PaginatedTransactionTable({ + required this.transactions, + required this.showAllColumns, + this.isTabView = false, + Key? key, + }) : super(key: key); + static const availableRowsPerPage = [5, 10, 15, 20]; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final isBiggerTablet = MediaQuery.of(context).size.width == 1024; + final isBiggerScreen = MediaQuery.of(context).size.width == 1920; + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + final isMobile = ResponsiveBreakpoints.of(context).equals(MOBILE); + + final colorTheme = ref.watch(appThemeColorProvider); + + final rowsPerPage = useState(availableRowsPerPage[0]); + + final double columnSpacing = isBiggerTablet + ? 70 + : isTablet + ? 24 + : isBiggerScreen + ? 133 + : isMobile + ? 0 + : 35; + return PaginatedDataTable( + showCheckboxColumn: false, + headingRowHeight: 50, + columnSpacing: columnSpacing, + arrowHeadColor: getSelectedColor(colorTheme, 0xFF282A2C, 0xFFFEFEFE), + source: RowDataSource( + data: transactions, + context: context, + clr: getSelectedColor( + colorTheme, + 0xFFFEFEFE, + 0xFF282A2C, + ), + showAllColumns: showAllColumns, + ), + showFirstLastButtons: true, + rowsPerPage: rowsPerPage.value, + dataRowMaxHeight: 80, + dataRowMinHeight: 80, + availableRowsPerPage: availableRowsPerPage, + onRowsPerPageChanged: (int? newRowsPerPage) { + if (newRowsPerPage != null) { + rowsPerPage.value = newRowsPerPage; + } + }, + columns: _dataColumns( + showAllColumns: showAllColumns, + isTablet: isTablet, + isTabView: isTabView, + ), + ); + } +} + +List _dataColumns({ + required bool showAllColumns, + required bool isTablet, + required bool isTabView, +}) { + if (showAllColumns) { + return [ + DataColumn( + label: Padding( + padding: EdgeInsets.only(left: isTablet ? 2.0 : 40.0), + child: const SizedBox( + child: TableHeaderText(name: Strings.tableHeaderTxnHashId), + ), + ), + ), + DataColumn( + label: Padding( + padding: EdgeInsets.only(left: isTablet ? 2.0 : 40.0), + child: const TableHeaderText(name: Strings.tableHeaderBlock), + )), + DataColumn( + label: Padding( + padding: EdgeInsets.only(left: isTablet ? 2.0 : 40.0), + child: const TableHeaderText(name: Strings.tableHeaderType), + )), + DataColumn( + label: Padding( + padding: EdgeInsets.only(left: isTablet ? 2.0 : 40.0), + child: const TableHeaderText(name: Strings.tableHeaderSummary), + )), + DataColumn( + label: Padding( + padding: EdgeInsets.only(left: isTablet ? 2.0 : 40.0), + child: const TableHeaderText(name: Strings.tableHeaderFee), + )), + const DataColumn( + label: Padding( + padding: EdgeInsets.only(left: 2.0), + child: TableHeaderText(name: Strings.tableHeaderStatus), + ), + ), + ]; + } else { + EdgeInsets padding = isTabView ? const EdgeInsets.only(left: 40.0) : const EdgeInsets.only(right: 60.0); + return [ + DataColumn( + label: Padding( + padding: padding, + child: const SizedBox( + child: TableHeaderText(name: Strings.tableHeaderTxnHashId), + ), + ), + ), + DataColumn( + label: Padding( + padding: padding, + child: const TableHeaderText(name: Strings.tableHeaderBlock), + ), + ), + DataColumn( + label: Padding( + padding: padding, + child: const TableHeaderText(name: Strings.tableHeaderType), + ), + ), + DataColumn( + label: Padding( + padding: padding, + child: const TableHeaderText(name: Strings.tableHeaderFee), + ), + ), + ]; + } +} + +/// Data source class for obtaining row data for PaginatedDataTable. +class RowDataSource extends DataTableSource { + final BuildContext context; + final List data; + final Color clr; + final bool showAllColumns; + RowDataSource({ + required this.data, + required this.context, + required this.showAllColumns, + required this.clr, + }); + + @override + DataRow? getRow(int index) { + final isDesktop = ResponsiveBreakpoints.of(context).equals(DESKTOP); + final isTablet = ResponsiveBreakpoints.of(context).equals(TABLET); + + final row = data[index]; + if (index < data.length) { + return DataRow( + color: MaterialStateProperty.all(clr), + onSelectChanged: (value) { + if (isDesktop) { + showModalSideSheet( + context: context, + ignoreAppBar: true, + width: 640, + barrierColor: Colors.white.withOpacity(barrierOpacity), + barrierDismissible: true, + // ignore: todo + //TODO: Replace once Product has designs + body: const Text('Drawer opened'), + ); + } else { + // context.vRouter.to(TransactionDetailsPage.transactionDetailsPath(row.transactionId)); + } + }, + cells: showAllColumns + ? [ + DataCell(GestureDetector( + child: TransactionColumnText( + isTransactionTable: true, + textTop: isTablet ? row.transactionId.substring(0, 9) : row.transactionId.substring(0, 38), + textBottom: "49 ${Strings.secAgo}", + ), + )), + DataCell(TransactionColumnText( + isTransactionTable: true, + textTop: '${Strings.height}: ${row.block.height}', + textBottom: '${Strings.slot}: ${row.block.slot}', + )), + DataCell(TransactionColumnText( + isTransactionTable: true, + textTop: row.transactionType.string, + textBottom: "", + isBottomTextRequired: false, + )), + const DataCell(TransactionColumnText( + isTransactionTable: true, textTop: '3 ${Strings.topl}', textBottom: '44 ${Strings.bobs}')), + DataCell(TransactionColumnText( + isTransactionTable: true, + textTop: '${row.transactionFee} ${Strings.feeAcronym}', + textBottom: "", + isBottomTextRequired: false, + )), + DataCell(StatusButton(isTransactionTable: true, status: row.status.string)), + ] + : [ + const DataCell(TransactionColumnText( + textTop: 'row.', + textBottom: "49 ${Strings.secAgo}", + )), + DataCell(TransactionColumnText( + textTop: '${Strings.height}: ${row.block.height}', + textBottom: '${Strings.slot}: ${row.block.slot}', + )), + DataCell(TransactionColumnText( + textTop: '${row.amount} ${Strings.topl}', textBottom: '${row.quantity} ${Strings.bobs}')), + DataCell(TransactionColumnText( + textTop: '${row.transactionFee} ${Strings.feeAcronym}', + textBottom: "", + isBottomTextRequired: false, + )), + ], + ); + } else { + return null; + } + } + + @override + bool get isRowCountApproximate => false; + + @override + int get rowCount => data.length; + + @override + int get selectedRowCount => 0; + + @override + void notifyListeners() { + super.notifyListeners(); + } +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index a2e67f7..45df0ec 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 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 */; }; + 4DB4F7D6215E233C280A241E /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0BB42A22AD0D4ACD1AD0B4C2 /* Pods_RunnerTests.framework */; }; + 9159757274B4A4FD765F4719 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 26430644F33124B08FFE84F5 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -60,11 +62,13 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0BB42A22AD0D4ACD1AD0B4C2 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 26430644F33124B08FFE84F5 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D5294CF71000263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 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 /* faucet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "faucet.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* faucet.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = faucet.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 = ""; }; @@ -76,8 +80,14 @@ 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 = ""; }; + 743D1E3B81027D9545297968 /* 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 = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 835374F830E1BCDD37976046 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + 8A57EDE08739598A01F0303D /* 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 = ""; }; + 969EA515D485011EBCADB026 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + B22B536DE9B09BB2BAE73D86 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + F0C30B379049076D66F42611 /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 4DB4F7D6215E233C280A241E /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,6 +103,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 9159757274B4A4FD765F4719 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -125,6 +137,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 99D4505BDAEC7D7941859F68 /* Pods */, ); sourceTree = ""; }; @@ -172,9 +185,25 @@ path = Runner; sourceTree = ""; }; + 99D4505BDAEC7D7941859F68 /* Pods */ = { + isa = PBXGroup; + children = ( + F0C30B379049076D66F42611 /* Pods-Runner.debug.xcconfig */, + 743D1E3B81027D9545297968 /* Pods-Runner.release.xcconfig */, + 8A57EDE08739598A01F0303D /* Pods-Runner.profile.xcconfig */, + 969EA515D485011EBCADB026 /* Pods-RunnerTests.debug.xcconfig */, + B22B536DE9B09BB2BAE73D86 /* Pods-RunnerTests.release.xcconfig */, + 835374F830E1BCDD37976046 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 26430644F33124B08FFE84F5 /* Pods_Runner.framework */, + 0BB42A22AD0D4ACD1AD0B4C2 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + CD4BE87F1D0EF204F75387B9 /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + F97736236F173E0A083EEF4F /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + 4DD331F812918B7E94393E21 /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -227,7 +259,7 @@ isa = PBXProject; attributes = { LastSwiftUpdateCheck = 0920; - LastUpgradeCheck = 1300; + LastUpgradeCheck = 1430; ORGANIZATIONNAME = ""; TargetAttributes = { 331C80D4294CF70F00263BE5 = { @@ -328,6 +360,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 4DD331F812918B7E94393E21 /* [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; + }; + CD4BE87F1D0EF204F75387B9 /* [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-RunnerTests-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; + }; + F97736236F173E0A083EEF4F /* [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; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -379,6 +472,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 969EA515D485011EBCADB026 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -393,6 +487,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = B22B536DE9B09BB2BAE73D86 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -407,6 +502,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 835374F830E1BCDD37976046 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index a8c80d4..b088de2 100644 --- a/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -1,6 +1,6 @@ + + diff --git a/pubspec.yaml b/pubspec.yaml index 38e91a9..a585f6a 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,12 +33,24 @@ dependencies: hooks_riverpod: ^2.3.6 flutter_hooks: ^0.18.6 vrouter: ^1.2.1 - responsive_framework: ^1.1.0 - + responsive_framework: ^1.1.1 + modal_side_sheet: ^0.0.1 + fluttertoast: ^8.2.2 + dropdown_button2: ^2.1.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.2 + flutter_svg: ^1.0.7 + freezed_annotation: ^2.4.1 + topl_common: ^2.0.2 + json_annotation: ^4.8.1 + hive: ^2.2.3 + hive_flutter: ^1.1.0 + easy_web_view: ^1.6.0 + url_launcher: ^6.1.14 + fixnum: ^1.1.0 + dev_dependencies: flutter_test: @@ -50,6 +62,11 @@ dev_dependencies: # package. See that file for information about deactivating specific lint # rules and activating additional ones. flutter_lints: ^2.0.0 + json_serializable: ^6.6.1 + build_runner: ^2.4.6 + freezed: ^2.4.1 + hive_generator: ^2.0.0 + mockito: ^5.4.2 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec @@ -62,6 +79,13 @@ flutter: # the material Icons class. uses-material-design: true + # To add assets to your application, add an assets section, like this: + assets: + - assets/icons/ + - assets/fonts/ + - assets/webpages/ + - assets/ + # To add assets to your application, add an assets section, like this: # assets: # - images/a_dot_burr.jpeg @@ -78,6 +102,21 @@ flutter: # "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: Rational Display + fonts: + - asset: assets/fonts/RationalDisplay-Book.otf + + - family: Rational Display Light + fonts: + - asset: assets/fonts/RationalDisplay-Light.otf + + - family: Rational Display Medium + fonts: + - asset: assets/fonts/RationalDisplay-Medium.otf + - family: Rational Display SemiBold + fonts: + - asset: assets/fonts/RationalDisplay-SemiBold.otf # fonts: # - family: Schyler # fonts: diff --git a/pull_request_template.md b/pull_request_template.md new file mode 100644 index 0000000..1817fbf --- /dev/null +++ b/pull_request_template.md @@ -0,0 +1,33 @@ +## Description + +Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. +Fixes # (issue) + +## Type of change + +Please delete options that are not relevant. + +- [ ] Bug fix (non-breaking change which fixes an issue) +- [ ] New feature (non-breaking change which adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] This change requires a documentation update + +## How Has This Been Tested? + +Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration + +- [ ] Test A +- [ ] Test B + +## Checklist: + +- [ ] My code follows the style guidelines of this project +- [ ] I have performed a self-review of my own code +- [ ] I have commented my code, particularly in hard-to-understand areas +- [ ] I have made corresponding changes to the documentation +- [ ] My changes generate no new warnings +- [ ] I have added tests that prove my fix is effective or that my feature works +- [ ] New and existing unit tests pass locally with my changes +- [ ] Any dependent changes have been merged and published in downstream modules +- [ ] I have checked my code and corrected any misspellings +- [ ] I have added screenshots diff --git a/test/essential_test_provider_widget.dart b/test/essential_test_provider_widget.dart new file mode 100644 index 0000000..b46dec9 --- /dev/null +++ b/test/essential_test_provider_widget.dart @@ -0,0 +1,67 @@ +// Dart imports: +import 'dart:convert'; + +// Flutter imports: +import 'package:faucet/main.dart'; +import 'package:faucet/shared/providers/genus_provider.dart'; +import 'package:faucet/shared/providers/node_provider.dart'; +import 'package:faucet/shared/services/hive/hive_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +// Package imports: +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import 'mocks/genus_mocks.dart'; +import 'mocks/hive_mocks.dart'; +import 'mocks/node_config_mocks.dart'; + +// Project imports: + +class TestAssetBundle extends CachingAssetBundle { + @override + Future loadString(String key, {bool cache = true}) async { + final ByteData data = await load(key); + return utf8.decode(data.buffer.asUint8List()); + } + + @override + Future load(String key) async => rootBundle.load(key); +} + +Future main({ + List overrides = const [], +}) async { + runApp(DefaultAssetBundle( + bundle: TestAssetBundle(), + child: await essentialTestProviderWidget(overrides: overrides), + )); +} + +/// The entire application, wrapped in a [ProviderScope]. +/// This function exposts a named parameter called [overrides] +/// which is fed forward to the [ProviderScope]. +Future essentialTestProviderWidget({ + List overrides = const [], +}) async { + overrides = [ + genusProvider.overrideWith((ref, arg) => getMockGenus()), + hivePackageProvider.overrideWithValue(getMockHive()), + nodeProvider.overrideWith((ref, arg) => getMockNodeGRPCService()), + ...overrides, + ]; + TestWidgetsFlutterBinding.ensureInitialized(); + + return ProviderScope( + overrides: overrides, + // child: const ResponsiveBreakPointsWrapper(), + child: MaterialApp( + debugShowCheckedModeBanner: false, + home: DefaultAssetBundle( + bundle: TestAssetBundle(), + child: const ResponsiveBreakPointsWrapper(), + ), + ), + ); +} diff --git a/test/example_test.dart b/test/example_test.dart new file mode 100644 index 0000000..77843f4 --- /dev/null +++ b/test/example_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'essential_test_provider_widget.dart'; +import 'utils/tester_utils.dart'; + +void main() { + testWidgets( + 'Example test', + (WidgetTester tester) async { + await tester.pumpWidget( + await essentialTestProviderWidget(), + ); + await tester.pump(); + await pendingTimersFix(tester); + }, + ); +} diff --git a/test/mocks/genus_mocks.dart b/test/mocks/genus_mocks.dart new file mode 100644 index 0000000..b5c7581 --- /dev/null +++ b/test/mocks/genus_mocks.dart @@ -0,0 +1,32 @@ +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:topl_common/genus/services/transaction_grpc.dart'; +import '../utils/block_utils.dart'; +import 'genus_mocks.mocks.dart'; + +@GenerateMocks([GenusGRPCService]) +GenusGRPCService getMockGenus() { + MockGenusGRPCService mockGenus = MockGenusGRPCService(); + + when(mockGenus.getBlockByDepth( + confidence: anyNamed('confidence'), + depth: anyNamed('depth'), + options: anyNamed('options'), + )).thenAnswer( + (realInvocation) async { + return getMockBlockResponse(); + }, + ); + + when(mockGenus.getBlockByHeight( + confidence: anyNamed('confidence'), + height: anyNamed('height'), + options: anyNamed('options'), + )).thenAnswer( + (realInvocation) async { + return getMockBlockResponse(); + }, + ); + + return mockGenus; +} diff --git a/test/mocks/hive_mocks.dart b/test/mocks/hive_mocks.dart new file mode 100644 index 0000000..26e6c98 --- /dev/null +++ b/test/mocks/hive_mocks.dart @@ -0,0 +1,52 @@ +import 'package:faucet/shared/services/hive/hives.dart'; +import 'package:hive_flutter/hive_flutter.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'hive_mocks.mocks.dart'; + +@GenerateMocks([HiveInterface, Box]) +HiveInterface getMockHive() { + MockHiveInterface mockHive = MockHiveInterface(); + + HivesBox.values.forEach((element) { + switch (element) { + case HivesBox.customChains: + when(mockHive.openBox(HivesBox.customChains.id)).thenAnswer((_) async { + return getMockCustomChains(); + }); + break; + case HivesBox.rateLimit: + when(mockHive.openBox(HivesBox.rateLimit.id)).thenAnswer((_) async { + return getMockRateLimit(); + }); + break; + } + + when(mockHive.openBox(any)).thenAnswer((_) async { + return MockBox(); + }); + }); + + return mockHive; +} + +Box getMockCustomChains() { + MockBox mockCustomChainsBox = MockBox(); + + when(mockCustomChainsBox.values).thenAnswer((realInvocation) { + return []; + }); + + return mockCustomChainsBox; +} + +Box getMockRateLimit() { + MockBox mockRateLimitBox = MockBox(); + + when(mockRateLimitBox.values).thenAnswer((realInvocation) { + return []; + }); + + return mockRateLimitBox; +} diff --git a/test/mocks/node_config_mocks.dart b/test/mocks/node_config_mocks.dart new file mode 100644 index 0000000..5ec1c37 --- /dev/null +++ b/test/mocks/node_config_mocks.dart @@ -0,0 +1,24 @@ +import 'package:fixnum/fixnum.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:topl_common/genus/services/node_grpc.dart'; +import 'package:topl_common/proto/node/models/node_config.pb.dart'; +import 'package:topl_common/proto/node/services/bifrost_rpc.pb.dart'; + +import 'node_config_mocks.mocks.dart'; + +@GenerateMocks([NodeGRPCService]) +MockNodeGRPCService getMockNodeGRPCService() { + final mockNodeService = MockNodeGRPCService(); + + when(mockNodeService.fetchNodeConfig()).thenAnswer((_) async* { + yield FetchNodeConfigRes( + config: NodeConfig( + epochLength: Int64(1), + slot: Int64(1), + slotDurationMillis: Int64(1), + ), + ); + }); + return mockNodeService; +} diff --git a/test/search/search_test.dart b/test/search/search_test.dart new file mode 100644 index 0000000..381631a --- /dev/null +++ b/test/search/search_test.dart @@ -0,0 +1,33 @@ +import 'package:faucet/search/sections/custom_search_bar.dart'; +import 'package:faucet/search/sections/search_results.dart'; +import 'package:faucet/shared/providers/genus_provider.dart'; +import 'package:faucet/shared/utils/decode_id.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../essential_test_provider_widget.dart'; +import 'utils/genus_mock_utils.dart'; + +void main() { + group('Search Tests', () { + testWidgets('Should show Search Results when text is entered', (WidgetTester tester) async { + final blockId = createId(); + final transactionId = createId(); + await tester.pumpWidget( + await essentialTestProviderWidget( + overrides: [ + genusProvider.overrideWith((ref, arg) => getMockSearchGenus(blockId: blockId, transactionId: transactionId)) + ], + ), + ); + await tester.pumpAndSettle(); + + expect(find.byKey(SearchResults.searchResultsKey), findsNothing); + + await tester.enterText(find.byKey(CustomSearchBar.searchKey), blockId); + + await tester.pumpAndSettle(); + + expect(find.byKey(SearchResults.searchResultsKey), findsOneWidget); + }); + }); +} diff --git a/test/search/utils/genus_mock_utils.dart b/test/search/utils/genus_mock_utils.dart new file mode 100644 index 0000000..3f4b988 --- /dev/null +++ b/test/search/utils/genus_mock_utils.dart @@ -0,0 +1,34 @@ +import 'package:mockito/mockito.dart'; +import 'package:topl_common/genus/services/transaction_grpc.dart'; + +import '../../mocks/genus_mocks.mocks.dart'; +import '../../utils/block_utils.dart'; + +GenusGRPCService getMockSearchGenus({ + required String blockId, + required String transactionId, +}) { + MockGenusGRPCService mockGenus = MockGenusGRPCService(); + + when(mockGenus.getBlockByDepth( + confidence: anyNamed('confidence'), + depth: anyNamed('depth'), + options: anyNamed('options'), + )).thenAnswer( + (realInvocation) async { + return getMockBlockResponse(blockId: blockId, transactionId: transactionId); + }, + ); + + when(mockGenus.getBlockByHeight( + confidence: anyNamed('confidence'), + height: anyNamed('height'), + options: anyNamed('options'), + )).thenAnswer( + (realInvocation) async { + return getMockBlockResponse(blockId: blockId, transactionId: transactionId); + }, + ); + + return mockGenus; +} diff --git a/test/utils/block_utils.dart b/test/utils/block_utils.dart new file mode 100644 index 0000000..ab3df3a --- /dev/null +++ b/test/utils/block_utils.dart @@ -0,0 +1,45 @@ +import 'package:faucet/shared/utils/decode_id.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:topl_common/proto/consensus/models/block_header.pb.dart'; +import 'package:topl_common/proto/consensus/models/block_id.pb.dart'; +import 'package:topl_common/proto/consensus/models/eligibility_certificate.pb.dart'; +import 'package:topl_common/proto/consensus/models/operational_certificate.pb.dart'; +import 'package:topl_common/proto/consensus/models/staking.pb.dart'; +import 'package:topl_common/proto/genus/genus_rpc.pb.dart'; +import 'package:topl_common/proto/node/models/block.pb.dart'; + +import 'transaction_utils.dart'; + +getMockBlockResponse({ + String? blockId, + String? transactionId, +}) { + blockId ??= createId(); + transactionId ??= createId(); + return BlockResponse( + block: FullBlock( + header: BlockHeader( + address: StakingAddress(value: [1, 2, 3, 4, 5, 6, 7, 8]), + height: Int64(1), + bloomFilter: [], + eligibilityCertificate: EligibilityCertificate(), + headerId: BlockId(value: encodeId(blockId)), + metadata: [], + operationalCertificate: OperationalCertificate(), + parentHeaderId: BlockId(value: [1, 2, 3, 4, 5, 6, 7, 8]), + parentSlot: Int64(1), + slot: Int64(1), + timestamp: Int64(DateTime.now().millisecondsSinceEpoch), + txRoot: [], + ), + fullBody: FullBlockBody( + transactions: List.generate( + 10, + (index) => getMockIoTransaction( + id: '${blockId}transaction$transactionId', + ), + ), + ), + ), + ); +} diff --git a/test/utils/tester_utils.dart b/test/utils/tester_utils.dart new file mode 100644 index 0000000..9d14981 --- /dev/null +++ b/test/utils/tester_utils.dart @@ -0,0 +1,49 @@ +import 'package:flutter_test/flutter_test.dart'; + +/// This will call [tester.pump] for a duration and loops. Useful for when [pumpAndSettle] is timing out +/// +/// Optional Parameters [loops] and [duration can be supplied] +/// +/// [loops] is defaulted to 5 +/// +/// [duration] is defaulted to 0 and is in seconds +Future pumpTester(WidgetTester tester, {int loops = 5, int duration = 0}) async { + try { + for (int i = 0; i <= loops; i++) { + await tester.pump( + Duration(seconds: duration), + ); + } + } catch (e) { + await tester.pumpAndSettle(); + } +} + +/// [delayDuration] is in milliseconds +/// [loopDuration] is in seconds +Future customRunAsync( + WidgetTester tester, { + required Future Function() test, + int delayDuration = 500, + int loops = 5, + int loopDuration = 0, +}) async { + await tester.runAsync(() async { + await test(); + await Future.delayed(Duration(milliseconds: delayDuration)); + await pumpTester( + tester, + duration: loopDuration, + loops: loops, + ); + }); +} + +// Timers Pending fix +Future pendingTimersFix(WidgetTester tester) async { + await customRunAsync(tester, test: () async { + await Future.delayed(Duration(milliseconds: 500)); + await pumpTester(tester, duration: 10, loops: 100); + await Future.delayed(Duration(milliseconds: 500)); + }); +} diff --git a/test/utils/transaction_utils.dart b/test/utils/transaction_utils.dart new file mode 100644 index 0000000..1924da8 --- /dev/null +++ b/test/utils/transaction_utils.dart @@ -0,0 +1,79 @@ +import 'package:faucet/shared/utils/decode_id.dart'; +import 'package:fixnum/fixnum.dart'; +import 'package:topl_common/proto/brambl/models/address.pb.dart'; +import 'package:topl_common/proto/brambl/models/box/value.pb.dart'; +import 'package:topl_common/proto/brambl/models/datum.pb.dart'; +import 'package:topl_common/proto/brambl/models/event.pb.dart'; +import 'package:topl_common/proto/brambl/models/identifier.pb.dart'; +import 'package:topl_common/proto/brambl/models/transaction/io_transaction.pb.dart'; +import 'package:topl_common/proto/brambl/models/transaction/schedule.pb.dart'; +import 'package:topl_common/proto/brambl/models/transaction/spent_transaction_output.pb.dart'; +import 'package:topl_common/proto/brambl/models/transaction/unspent_transaction_output.pb.dart'; +import 'package:topl_common/proto/quivr/models/shared.pb.dart'; + +getMockIoTransaction({ + String id = '1', +}) { + return IoTransaction( + datum: Datum_IoTransaction( + event: Event_IoTransaction( + schedule: Schedule( + max: Int64(2), + min: Int64(1), + timestamp: Int64( + DateTime.now().millisecondsSinceEpoch, + ), + ), + metadata: SmallData( + value: [], + ), + ), + ), + inputs: [ + _getSpentTransactionOutput(id: id), + ], + outputs: [ + _getUnspentTransactionOutput(), + ], + transactionId: _getTransactionId(id), + ); +} + +TransactionId _getTransactionId(String id) { + return TransactionId( + value: encodeId(id), + ); +} + +UnspentTransactionOutput _getUnspentTransactionOutput() { + return UnspentTransactionOutput( + address: LockAddress.getDefault(), + value: Value( + lvl: Value_LVL( + quantity: Int128( + value: [100], + ), + ), + ), + ); +} + +SpentTransactionOutput _getSpentTransactionOutput({ + required String id, +}) { + return SpentTransactionOutput( + address: TransactionOutputAddress( + id: TransactionId(value: encodeId(id)), + index: 1, + ledger: 1, + network: 1, + ), + value: Value( + lvl: Value_LVL( + quantity: Int128( + value: [100], + ), + ), + ), + ); +} diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 88b22e5..7f66257 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,7 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST + permission_handler_windows url_launcher_windows + webview_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST