diff --git a/.github/workflows/syrius_builder.yml b/.github/workflows/syrius_builder.yml index 5ece9d21..368959b4 100644 --- a/.github/workflows/syrius_builder.yml +++ b/.github/workflows/syrius_builder.yml @@ -3,16 +3,16 @@ name: Build and release syrius on: push: branches-ignore: - - master - pull_request: - branches: - - dev - # Allows you to run this workflow manually from the Actions tab + - master + tags: + - '*' workflow_dispatch: +env: + FLUTTER_VERSION: "3.10.x" + jobs: build-macos: - environment: wallet-connect env: WALLET_CONNECT_PROJECT_ID: ${{ secrets.WC_PROJECT_ID }} runs-on: macos-12 @@ -28,8 +28,8 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2.10.0 with: + flutter-version: ${{env.FLUTTER_VERSION}} channel: "stable" - flutter-version: "3.10.2" - name: Check flutter version run: flutter --version - name: Build syrius desktop @@ -57,7 +57,6 @@ jobs: name: macos-artifacts path: syrius-alphanet-macos-universal.dmg build-windows: - environment: wallet-connect env: WALLET_CONNECT_PROJECT_ID: ${{ secrets.WC_PROJECT_ID }} runs-on: windows-latest @@ -67,8 +66,8 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2.10.0 with: + flutter-version: ${{env.FLUTTER_VERSION}} channel: "stable" - flutter-version: "3.10.2" - name: Check flutter version run: flutter --version - name: Build syrius desktop @@ -84,7 +83,6 @@ jobs: name: windows-artifacts path: syrius-alphanet-windows-amd64.zip build-linux: - environment: wallet-connect env: WALLET_CONNECT_PROJECT_ID: ${{ secrets.WC_PROJECT_ID }} runs-on: ubuntu-20.04 @@ -98,8 +96,8 @@ jobs: - name: Setup Flutter uses: subosito/flutter-action@v2.10.0 with: + flutter-version: ${{env.FLUTTER_VERSION}} channel: "stable" - flutter-version: "3.10.2" - name: Set permissions run: | sudo chmod -R 777 linux/ @@ -123,19 +121,18 @@ jobs: make-release: needs: [build-macos, build-windows, build-linux] runs-on: ubuntu-latest - if: github.event_name != 'pull_request' + if: startsWith(github.ref, 'refs/tags/v') steps: - name: Checkout uses: actions/checkout@v3 - name: Set variables run: | - SYRIUS="v$(cat pubspec.yaml | grep version | sed 's/version://' | xargs)" - echo "Syrius Version: $SYRIUS" - echo "SYRIUS_VERSION=$SYRIUS" >> $GITHUB_ENV + echo "SYRIUS_VERSION=${{ github.ref }}" >> $GITHUB_ENV + echo "Syrius Version: $SYRIUS_VERSION" GOZENON=$(curl -s https://raw.githubusercontent.com/zenon-network/go-zenon/master/metadata/version.go | grep Version | awk -F '"' '{print $2}') echo "Go-Zenon Version: $GOZENON" BODY=$(cat < + + + diff --git a/assets/svg/ic_unsuccessful_symbol.svg b/assets/svg/ic_unsuccessful_symbol.svg new file mode 100644 index 00000000..acc6cb5f --- /dev/null +++ b/assets/svg/ic_unsuccessful_symbol.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/docs/p2p_swaps.md b/docs/p2p_swaps.md new file mode 100644 index 00000000..99f00e26 --- /dev/null +++ b/docs/p2p_swaps.md @@ -0,0 +1,110 @@ +# P2P Swaps in Syrius + +The P2P Swap feature in Syrius offers users an easy and censorship resistant way of exchanging value, peer-to-peer, without intermediaries on Network of Momentum. With no fees, users can trade any ZTS token with any counterparty. + +P2P Swaps use HTLCs to facilitate the swaps. + +## Conducting a P2P Swap in Syrius +P2P Swaps have two parties, the starting party and the joining party. The two parties will first have to find each other (in a chatroom for example) and agree upon the amounts they want to swap. After this, they can use Syrius to conduct a trustless swap with no fees directly with each other. + +### Example + +Alice is the starting party for the swap and she wants to swap 100 ZNN for 1,000 QSR. Bob is the joining party and has agreed to be Alice's counterparty for the swap. Bob has provided Alice with his NoM address. + +1. Alice starts a P2P swap in Syrius and deposits 100 ZNN for Bob. She then sends her deposit's ID to Bob via a messaging service (e.g. Telegram). +2. Bob uses the deposit ID to get the swap's details in Syrius and he can see that Alice has deposited 100 ZNN for him. +3. Bob joins the swap by depositing 1,000 QSR for Alice. +4. Alice sees that Bob has deposited the QSR and proceeds to complete the swap. Once Alice completes the swap, she receives 1,000 QSR. +5. Bob's Syrius sees that Alice has completed the swap and Syrius automatically proceeds to unlock the 100 ZNN For Bob. +6. The swap has now been successfuly completed. + +The deposits are timelocked, so if either party backs out of the swap before it is completed, both parties can reclaim their deposits. + +## Technical overview + +The P2P swaps use the embedded HTLC contract to facilitate the swaps with HTLCs. A swap requires two HTLCs - the initial HTLC and the counter HTLC. + +The following constants are used for HTLC based P2P swaps in Syrius: + +``` +kInitialHtlcDuration = Duration(hours: 8) +kCounterHtlcDuration = Duration(hours: 1) +kMaxAllowedInitialHtlcDuration = Duration(hours: 24) +kMinSafeTimeToFindPreimage = Duration(hours: 6) +kMinSafeTimeToCompleteSwap = Duration(minutes: 10) +``` + +### Starting the swap + +When the starting party starts a swap in Syrius, depositing 100 ZNN, an HTLC is created with the following inputs: + +``` +hashLocked: ${joiningPartyAddress} +expirationTime: ${frontierMomentumTime} + ${kInitialHtlcDuration} +tokenStandard: zts1znnxxxxxxxxxxxxx9z4ulx +amount: 100000000000 +hashType: 0 +keyMaxSize: 255 +hashLock: [A 32-byte hash of a preimage generated by Syrius] +``` +The initial HTLC's expiration time is always set to 8 hours into the future. The preimage is stored into an encrypted database locally. Syrius hashes the preimage with the SHA3-256 hash function, so the `hashType` parameter is set to `0`. + +### Joining the swap +The joining party has 1 hour to join the swap. The `kMinSafeTimeToFindPreimage` ensures that the joining party will have at least 6 hours to find the swap's preimage. Since the `kCounterHtlcDuration` makes sure that the counter HTLC's duration is 1 hour, the user cannot join the swap if the initial HTLC's time until expiration is less than the combined duration of `kMinSafeTimeToFindPreimage` and `kCounterHtlcDuration`. + +Syrius will not allow the user to join a swap if the initial HTLC's duration exceeds the `kMaxAllowedInitialHtlcDuration` constant. + +When the joining party joins the swap, depositing 1,000 QSR, an HTLC is created with the following inputs: +``` +hashLocked: ${startingPartyAddress} +expirationTime: ${frontierMomentumTime} + ${kCounterHtlcDuration} +tokenStandard: zts1qsrxxxxxxxxxxxxxmrhjll +amount: 1000000000000 +hashType: 0 +keyMaxSize: 255 +hashLock: ${initialHtlcHashlock} +``` + +In case the joining party does not join the swap, the starting party will have to wait until the initial HTLC expires. After the HTLC has expired, the funds can be reclaimed. + +### Completing the swap +After both parties have deposited their funds, the swap can be completed. + +#### The starting party + +When the starting party completes the swap, the embedded HTLC contract's `Unlock` method is called with the following inputs: +``` +id: ${counterHtlcId} +preimage: ${preimageFromLocalStorage} +``` + +The starting party has 50 minutes to complete the swap. Although the counter HTLCs duration is 1 hour, the unlock transaction sending has to be started at least 10 minutes (`kMinSafeTimeToCompleteSwap`) before the counter HTLC expires. This is to ensure that the transaction has enough time to get published and processed by the network so that the counter HTLC does not expire in between the time the user sends the transaction and the HTLC contract processes the transaction. This could lead to a situation where the preimage is published on-chain, but the starting party doesn't have access to the hashlocked funds anymore. + +If the starting party does not complete the swap, both parties will have to wait for their HTLCs to expire to reclaim their funds. + +#### The joining party +Once the starting party has unlocked the counter HTLC, the joining party's Syrius will actively monitor the chain to find the preimage the starting party published on chain when calling the HTLC contract's `Unlock` method. + +The joining party should keep Syrius running until either the swap is completed, or the counter HTLC expires. This is to ensure that Syrius can find the preimage and unlock the funds deposited to the joining party. If Syrius is closed during this time, the joining party has at least 6 hours (`kMinSafeTimeToFindPreimage`) to reopen Syrius, so that the preimage can be found. + +Once the preimage has been found, the embedded HTLC contract's `Unlock` method is automatically called by Syrius with the following inputs: + +``` +id: ${initialHtlcId} +preimage: ${preimageFoundOnChain} +``` + +### What if the joining party fails to find the preimage? +If the joining party fails to find the preimage and unlock the initial HTLC before it expires, access to the funds will be lost and the starting party will be able to reclaim the funds. + +To reduce the risk of this happening, Syrius enables the computer's wakelock, so that the computer doesn't go into sleep mode while Syrius is monitoring the chain for the preimage after the joining party has joined the swap. Since the counter HTLC's duration is 1 hour, that is the maximum time that the joining party has to stay vigiliant during the swap. If Syrius is not running during this time and the starting party completes the swap, the joining party will have at least 6 hours to reopen Syrius. + +[HTLC Watchtowers](https://github.com/hypercore-one/htlc-watchtower) can also be deployed by community members to further reduce the chance of users losing funds. + + +## The future of P2P swaps +HTLCs are the only way right now to facilitate trustless trading on Network of Momentum, but they are not necessarily the most convenient way to facilitate same chain P2P swaps. Superior ways to facilitate trading will hopefully be available in the future. + +The Syrius implementation for P2P swaps has been designed in such a way, that the underlying primitives that the swaps are based upon can be changed, but the experience for the user can remain more or less the same. + +While only ZTS to ZTS swaps are currently supported, HTLCs can be used to facilitate cross-chain swaps as well and supporting cross-chain swaps could be a future goal. \ No newline at end of file diff --git a/lib/blocs/accelerator/project_list_bloc.dart b/lib/blocs/accelerator/project_list_bloc.dart index b5e82ea1..17514b6f 100644 --- a/lib/blocs/accelerator/project_list_bloc.dart +++ b/lib/blocs/accelerator/project_list_bloc.dart @@ -129,8 +129,8 @@ class ProjectListBloc with RefreshBlocMixin { /* This method filters the projects according to the following rule: - if a user doesn't have a Pillar, then we only show him the active - projects + if a user doesn't have a Pillar, only show the active + projects or all owned projects */ Future> _filterProjectsAccordingToPillarInfo( Set projectList) async { @@ -142,7 +142,7 @@ class ProjectListBloc with RefreshBlocMixin { .where( (project) => project.status == AcceleratorProjectStatus.active || - project.owner.toString() == kSelectedAddress, + kDefaultAddressList.contains(project.owner.toString()), ) .toList(); if (activeProjects.isNotEmpty) { diff --git a/lib/blocs/auto_receive_tx_worker.dart b/lib/blocs/auto_receive_tx_worker.dart index 48c109f1..6d8cbe54 100644 --- a/lib/blocs/auto_receive_tx_worker.dart +++ b/lib/blocs/auto_receive_tx_worker.dart @@ -3,6 +3,7 @@ import 'dart:collection'; import 'package:json_rpc_2/json_rpc_2.dart'; import 'package:logging/logging.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/auto_unlock_htlc_worker.dart'; import 'package:zenon_syrius_wallet_flutter/blocs/blocs.dart'; import 'package:zenon_syrius_wallet_flutter/main.dart'; import 'package:zenon_syrius_wallet_flutter/model/model.dart'; @@ -14,7 +15,6 @@ import 'package:znn_sdk_dart/znn_sdk_dart.dart'; class AutoReceiveTxWorker extends BaseBloc { static AutoReceiveTxWorker? _instance; Queue pool = Queue(); - HashSet processedHashes = HashSet(); bool running = false; static AutoReceiveTxWorker getInstance() { @@ -23,10 +23,11 @@ class AutoReceiveTxWorker extends BaseBloc { } Future autoReceive() async { - if (pool.isNotEmpty && !running) { + // Make sure that AutoUnlockHtlcWorker is not running since it should be + // given priority to send transactions. + if (pool.isNotEmpty && !running && !sl().running) { running = true; Hash currentHash = pool.first; - pool.removeFirst(); try { String toAddress = (await zenon!.ledger.getAccountBlockByHash(currentHash))! @@ -45,17 +46,22 @@ class AutoReceiveTxWorker extends BaseBloc { blockSigningKey: keyPair, waitForRequiredPlasma: true, ); + pool.removeFirst(); _sendSuccessNotification(response, toAddress); } on RpcException catch (e, stackTrace) { _sendErrorNotification(e.toString()); Logger('AutoReceiveTxWorker') .log(Level.WARNING, 'autoReceive', e, stackTrace); - if (e.message.compareTo('account-block from-block already received') != + if (e.message.compareTo('account-block from-block already received') == 0) { - pool.addFirst(currentHash); + pool.removeFirst(); } else { _sendErrorNotification(e.toString()); } + } catch (e, stackTrace) { + Logger('AutoReceiveTxWorker') + .log(Level.WARNING, 'autoReceive', e, stackTrace); + _sendErrorNotification(e.toString()); } running = false; } @@ -85,15 +91,16 @@ class AutoReceiveTxWorker extends BaseBloc { } Future addHash(Hash hash) async { - if (!processedHashes.contains(hash)) { + if (!pool.contains(hash)) { zenon!.stats.syncInfo().then((syncInfo) { - if (!processedHashes.contains(hash) && + // Verify that the pool does not already contain the hash after the + // asynchronous request has completed and that the node is in sync. + if (!pool.contains(hash) && (syncInfo.state == SyncState.syncDone || (syncInfo.targetHeight > 0 && syncInfo.currentHeight > 0 && (syncInfo.targetHeight - syncInfo.currentHeight) < 3))) { pool.add(hash); - processedHashes.add(hash); } }); } diff --git a/lib/blocs/auto_unlock_htlc_worker.dart b/lib/blocs/auto_unlock_htlc_worker.dart new file mode 100644 index 00000000..1abb70a4 --- /dev/null +++ b/lib/blocs/auto_unlock_htlc_worker.dart @@ -0,0 +1,125 @@ +import 'dart:async'; +import 'dart:collection'; + +import 'package:json_rpc_2/json_rpc_2.dart'; +import 'package:logging/logging.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/base_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/model/database/notification_type.dart'; +import 'package:zenon_syrius_wallet_flutter/model/database/wallet_notification.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/account_block_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/address_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/format_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/global.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class AutoUnlockHtlcWorker extends BaseBloc { + static AutoUnlockHtlcWorker? _instance; + Queue pool = Queue(); + HashSet processedHashes = HashSet(); + bool running = false; + + static AutoUnlockHtlcWorker getInstance() { + _instance ??= AutoUnlockHtlcWorker(); + return _instance!; + } + + Future autoUnlock() async { + if (pool.isNotEmpty && !running && kKeyStore != null) { + running = true; + Hash currentHash = pool.first; + try { + final htlc = await zenon!.embedded.htlc.getById(currentHash); + final swap = htlcSwapsService! + .getSwapByHashLock(FormatUtils.encodeHexString(htlc.hashLock)); + if (swap == null || swap.preimage == null) { + throw 'Invalid swap'; + } + if (!kDefaultAddressList.contains(htlc.hashLocked.toString())) { + throw 'Swap address not in default addresses. Please add the address in the addresses list.'; + } + KeyPair? keyPair = kKeyStore!.getKeyPair( + kDefaultAddressList.indexOf(htlc.hashLocked.toString()), + ); + AccountBlockTemplate transactionParams = zenon!.embedded.htlc + .unlock(htlc.id, FormatUtils.decodeHexString(swap.preimage!)); + AccountBlockTemplate response = + await AccountBlockUtils.createAccountBlock( + transactionParams, + 'complete swap', + blockSigningKey: keyPair, + waitForRequiredPlasma: true, + ); + _sendSuccessNotification(response, htlc.hashLocked.toString()); + } on RpcException catch (e, stackTrace) { + Logger('AutoUnlockHtlcWorker') + .log(Level.WARNING, 'autoUnlock', e, stackTrace); + if (!e.message.contains('data non existent')) { + _sendErrorNotification(e.toString()); + } + } catch (e, stackTrace) { + Logger('AutoUnlockHtlcWorker') + .log(Level.WARNING, 'autoUnlock', e, stackTrace); + _sendErrorNotification(e.toString()); + } finally { + pool.removeFirst(); + _removeHashFromHashSetAfterDelay(currentHash); + running = false; + } + } + } + + void _sendErrorNotification(String errorText) { + addEvent( + WalletNotification( + title: 'Failed to complete swap', + timestamp: DateTime.now().millisecondsSinceEpoch, + details: 'Failed to complete the swap: $errorText', + type: NotificationType.error, + ), + ); + } + + void _sendSuccessNotification(AccountBlockTemplate block, String toAddress) { + addEvent( + WalletNotification( + title: + 'Transaction received on ${ZenonAddressUtils.getLabel(toAddress)}', + timestamp: DateTime.now().millisecondsSinceEpoch, + details: 'Transaction hash: ${block.hash}', + type: NotificationType.paymentReceived, + ), + ); + } + + void addHash(Hash hash) { + if (!processedHashes.contains(hash)) { + zenon!.stats.syncInfo().then((syncInfo) { + if (!processedHashes.contains(hash) && + (syncInfo.state == SyncState.syncDone || + (syncInfo.targetHeight > 0 && + syncInfo.currentHeight > 0 && + (syncInfo.targetHeight - syncInfo.currentHeight) < 3))) { + pool.add(hash); + processedHashes.add(hash); + } + }).onError( + (e, stackTrace) { + Logger('AutoUnlockHtlcWorker') + .log(Level.WARNING, 'addHash', e, stackTrace); + }, + ); + } + } + + // Remove the hash from the processedHashes hash set after a delay, because + // if the node shuts down immediately after the unlock transactions has been + // sent, the transaction may not actually be published. By removing the hash + // from processedHashes, it can be re-added to the pool and retried. + // The delay gives the network time to process the transaction, before + // allowing for it to be retried. + void _removeHashFromHashSetAfterDelay(Hash hash) { + Future.delayed( + const Duration(minutes: 2), () => processedHashes.remove(hash)); + } +} diff --git a/lib/blocs/p2p_swap/htlc_swap/complete_htlc_swap_bloc.dart b/lib/blocs/p2p_swap/htlc_swap/complete_htlc_swap_bloc.dart new file mode 100644 index 00000000..d37dda75 --- /dev/null +++ b/lib/blocs/p2p_swap/htlc_swap/complete_htlc_swap_bloc.dart @@ -0,0 +1,54 @@ +import 'package:zenon_syrius_wallet_flutter/blocs/base_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/htlc_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class CompleteHtlcSwapBloc extends BaseBloc { + Future completeHtlcSwap({ + required HtlcSwap swap, + }) async { + try { + addEvent(null); + final htlcId = swap.direction == P2pSwapDirection.outgoing + ? swap.counterHtlcId! + : swap.initialHtlcId; + + // Make sure that the HTLC exists and has a safe amount of time left + // until expiration. + final htlc = await zenon!.embedded.htlc.getById(Hash.parse(htlcId)); + if (htlc.expirationTime <= + DateTimeUtils.unixTimeNow + kMinSafeTimeToCompleteSwap.inSeconds) { + throw 'The swap will expire too soon for a safe swap.'; + } + + if (htlc.keyMaxSize < + FormatUtils.decodeHexString(swap.preimage!).length) { + throw 'The swap secret size exceeds the maximum allowed size.'; + } + + AccountBlockTemplate transactionParams = zenon!.embedded.htlc.unlock( + Hash.parse(htlcId), FormatUtils.decodeHexString(swap.preimage!)); + KeyPair blockSigningKeyPair = kKeyStore!.getKeyPair( + kDefaultAddressList.indexOf(swap.selfAddress.toString()), + ); + AccountBlockUtils.createAccountBlock(transactionParams, 'complete swap', + blockSigningKey: blockSigningKeyPair, waitForRequiredPlasma: true) + .then( + (response) async { + swap.state = P2pSwapState.completed; + await htlcSwapsService!.storeSwap(swap); + ZenonAddressUtils.refreshBalance(); + addEvent(swap); + }, + ).onError( + (error, stackTrace) { + addError(error.toString(), stackTrace); + }, + ); + } catch (e, stackTrace) { + addError(e, stackTrace); + } + } +} diff --git a/lib/blocs/p2p_swap/htlc_swap/htlc_swap_bloc.dart b/lib/blocs/p2p_swap/htlc_swap/htlc_swap_bloc.dart new file mode 100644 index 00000000..058e4fb8 --- /dev/null +++ b/lib/blocs/p2p_swap/htlc_swap/htlc_swap_bloc.dart @@ -0,0 +1,27 @@ +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/periodic_p2p_swap_base_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/htlc_swap.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class HtlcSwapBloc extends PeriodicP2pSwapBaseBloc { + final String swapId; + + HtlcSwapBloc(this.swapId); + + @override + HtlcSwap makeCall() { + try { + if (zenon!.wsClient.isClosed()) { + throw noConnectionException; + } + final swap = htlcSwapsService!.getSwapById(swapId); + if (swap != null) { + return swap; + } else { + throw 'Swap does not exist'; + } + } catch (e) { + rethrow; + } + } +} diff --git a/lib/blocs/p2p_swap/htlc_swap/initial_htlc_for_swap_bloc.dart b/lib/blocs/p2p_swap/htlc_swap/initial_htlc_for_swap_bloc.dart new file mode 100644 index 00000000..406ee369 --- /dev/null +++ b/lib/blocs/p2p_swap/htlc_swap/initial_htlc_for_swap_bloc.dart @@ -0,0 +1,88 @@ +import 'dart:async'; + +import 'package:collection/collection.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/base_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class InitialHtlcForSwapBloc extends BaseBloc { + final _minimumRequiredDuration = + kMinSafeTimeToFindPreimage + kCounterHtlcDuration; + Future getInitialHtlc(Hash id) async { + try { + final htlc = await zenon!.embedded.htlc.getById(id); + final hashlock = FormatUtils.encodeHexString(htlc.hashLock); + if (!kDefaultAddressList.contains(htlc.hashLocked.toString())) { + throw 'This deposit is not intended for you.'; + } + if (kDefaultAddressList.contains(htlc.timeLocked.toString())) { + throw 'Cannot join a swap that you have started.'; + } + if (htlcSwapsService!.getSwapByHtlcId(htlc.id.toString()) != null) { + throw 'This deposit is already used in another swap.'; + } + if (htlcSwapsService!.getSwapByHashLock(hashlock) != null) { + throw 'The deposit\'s hashlock is already used in another swap.'; + } + final remainingDuration = + Duration(seconds: htlc.expirationTime - DateTimeUtils.unixTimeNow); + if (remainingDuration < _minimumRequiredDuration) { + if (remainingDuration.inSeconds <= 0) { + throw 'This deposit has expired.'; + } + throw 'This deposit will expire too soon for a safe swap.'; + } + if (remainingDuration > kMaxAllowedInitialHtlcDuration) { + throw 'The deposit\'s duration is too long. Expected ${kMaxAllowedInitialHtlcDuration.inHours} hours at most.'; + } + final creationBlock = await zenon!.ledger.getAccountBlockByHash(htlc.id); + if (htlc.expirationTime - + creationBlock!.confirmationDetail!.momentumTimestamp > + kMaxAllowedInitialHtlcDuration.inSeconds) { + throw 'The deposit was created too long ago.'; + } + + // Verify that the hashlock has not been used in another currently active + // HTLC. + final htlcBlocks = await AccountBlockUtils.getAccountBlocksAfterTime( + htlcAddress, + htlc.expirationTime - kMaxAllowedInitialHtlcDuration.inSeconds); + if (htlcBlocks.firstWhereOrNull((block) => + !_isInitialHtlcBlock(block, htlc.id) && + _hasMatchingHashlock(block, hashlock)) != + null) { + throw 'The hashlock is not unique.'; + } + addEvent(htlc); + } catch (e, stackTrace) { + addError(e, stackTrace); + } + } +} + +bool _isInitialHtlcBlock(AccountBlock block, Hash initialHtlcId) { + if (block.blockType != BlockTypeEnum.contractReceive.index) { + return false; + } + return block.pairedAccountBlock!.hash == initialHtlcId; +} + +bool _hasMatchingHashlock(AccountBlock block, String hashlock) { + if (block.blockType != BlockTypeEnum.contractReceive.index) { + return false; + } + + final blockData = AccountBlockUtils.getDecodedBlockData( + Definitions.htlc, block.pairedAccountBlock!.data); + + if (blockData == null || blockData.function != 'Create') { + return false; + } + + if (!blockData.params.containsKey('hashLock')) { + return false; + } + + return FormatUtils.encodeHexString(blockData.params['hashLock']) == hashlock; +} diff --git a/lib/blocs/p2p_swap/htlc_swap/join_htlc_swap_bloc.dart b/lib/blocs/p2p_swap/htlc_swap/join_htlc_swap_bloc.dart new file mode 100644 index 00000000..181ed084 --- /dev/null +++ b/lib/blocs/p2p_swap/htlc_swap/join_htlc_swap_bloc.dart @@ -0,0 +1,80 @@ +import 'package:zenon_syrius_wallet_flutter/blocs/base_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/htlc_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/account_block_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/address_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/date_time_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/format_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/global.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class JoinHtlcSwapBloc extends BaseBloc { + void joinHtlcSwap({ + required HtlcInfo initialHtlc, + required Token fromToken, + required Token toToken, + required BigInt fromAmount, + required P2pSwapType swapType, + required P2pSwapChain fromChain, + required P2pSwapChain toChain, + required int counterHtlcExpirationTime, + }) { + try { + addEvent(null); + AccountBlockTemplate transactionParams = zenon!.embedded.htlc.create( + fromToken, + fromAmount, + initialHtlc.timeLocked, + counterHtlcExpirationTime, + initialHtlc.hashType, + initialHtlc.keyMaxSize, + initialHtlc.hashLock, + ); + KeyPair blockSigningKeyPair = kKeyStore!.getKeyPair( + kDefaultAddressList.indexOf(initialHtlc.hashLocked.toString()), + ); + AccountBlockUtils.createAccountBlock(transactionParams, 'join swap', + blockSigningKey: blockSigningKeyPair, waitForRequiredPlasma: true) + .then( + (response) async { + final swap = HtlcSwap( + id: initialHtlc.id.toString(), + chainId: response.chainIdentifier, + type: swapType, + direction: P2pSwapDirection.incoming, + selfAddress: initialHtlc.hashLocked.toString(), + counterHtlcId: response.hash.toString(), + counterHtlcExpirationTime: counterHtlcExpirationTime, + counterpartyAddress: initialHtlc.timeLocked.toString(), + state: P2pSwapState.active, + startTime: DateTimeUtils.unixTimeNow, + initialHtlcId: initialHtlc.id.toString(), + initialHtlcExpirationTime: initialHtlc.expirationTime, + fromAmount: fromAmount, + fromTokenStandard: fromToken.tokenStandard.toString(), + fromDecimals: fromToken.decimals, + fromSymbol: fromToken.symbol, + fromChain: fromChain, + toAmount: initialHtlc.amount, + toTokenStandard: toToken.tokenStandard.toString(), + toDecimals: toToken.decimals, + toSymbol: toToken.symbol, + toChain: toChain, + hashLock: FormatUtils.encodeHexString(initialHtlc.hashLock), + hashType: initialHtlc.hashType, + ); + await htlcSwapsService!.storeSwap(swap); + ZenonAddressUtils.refreshBalance(); + addEvent(swap); + }, + ).onError( + (error, stackTrace) { + addError(error.toString(), stackTrace); + }, + ); + } catch (e, stackTrace) { + addError(e, stackTrace); + } + } +} diff --git a/lib/blocs/p2p_swap/htlc_swap/reclaim_htlc_swap_funds_bloc.dart b/lib/blocs/p2p_swap/htlc_swap/reclaim_htlc_swap_funds_bloc.dart new file mode 100644 index 00000000..0dd86440 --- /dev/null +++ b/lib/blocs/p2p_swap/htlc_swap/reclaim_htlc_swap_funds_bloc.dart @@ -0,0 +1,37 @@ +import 'package:zenon_syrius_wallet_flutter/blocs/base_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/account_block_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/address_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/global.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class ReclaimHtlcSwapFundsBloc extends BaseBloc { + void reclaimFunds({ + required Hash htlcId, + required Address selfAddress, + }) { + try { + addEvent(null); + AccountBlockTemplate transactionParams = + zenon!.embedded.htlc.reclaim(htlcId); + KeyPair blockSigningKeyPair = kKeyStore!.getKeyPair( + kDefaultAddressList.indexOf(selfAddress.toString()), + ); + AccountBlockUtils.createAccountBlock( + transactionParams, 'reclaim swap funds', + blockSigningKey: blockSigningKeyPair, waitForRequiredPlasma: true) + .then( + (response) { + ZenonAddressUtils.refreshBalance(); + addEvent(response); + }, + ).onError( + (error, stackTrace) { + addError(error.toString(), stackTrace); + }, + ); + } catch (e, stackTrace) { + addError(e, stackTrace); + } + } +} diff --git a/lib/blocs/p2p_swap/htlc_swap/recover_htlc_swap_funds_bloc.dart b/lib/blocs/p2p_swap/htlc_swap/recover_htlc_swap_funds_bloc.dart new file mode 100644 index 00000000..7b418fda --- /dev/null +++ b/lib/blocs/p2p_swap/htlc_swap/recover_htlc_swap_funds_bloc.dart @@ -0,0 +1,27 @@ +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/htlc_swap/reclaim_htlc_swap_funds_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/constants.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/date_time_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/format_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/global.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class RecoverHtlcSwapFundsBloc extends ReclaimHtlcSwapFundsBloc { + void recoverFunds({required Hash htlcId}) async { + try { + final htlc = await zenon!.embedded.htlc.getById(htlcId); + + if (!kDefaultAddressList.contains(htlc.timeLocked.toString())) { + throw 'The deposit does not belong to you.'; + } + + if (htlc.expirationTime - DateTimeUtils.unixTimeNow > 0) { + throw 'The deposit is locked until ${FormatUtils.formatDate(htlc.expirationTime * 1000, dateFormat: kDefaultDateTimeFormat)}.'; + } + + reclaimFunds(htlcId: htlcId, selfAddress: htlc.timeLocked); + } catch (e, stackTrace) { + addError(e, stackTrace); + } + } +} diff --git a/lib/blocs/p2p_swap/htlc_swap/start_htlc_swap_bloc.dart b/lib/blocs/p2p_swap/htlc_swap/start_htlc_swap_bloc.dart new file mode 100644 index 00000000..d7950869 --- /dev/null +++ b/lib/blocs/p2p_swap/htlc_swap/start_htlc_swap_bloc.dart @@ -0,0 +1,100 @@ +import 'dart:math'; + +import 'package:zenon_syrius_wallet_flutter/blocs/base_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/htlc_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/account_block_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/address_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/date_time_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/format_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/global.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class StartHtlcSwapBloc extends BaseBloc { + void startHtlcSwap({ + required Address selfAddress, + required Address counterpartyAddress, + required Token fromToken, + required BigInt fromAmount, + required int hashType, + required P2pSwapType swapType, + required P2pSwapChain fromChain, + required P2pSwapChain toChain, + required int initialHtlcDuration, + }) async { + try { + addEvent(null); + final preimage = _generatePreimage(); + final hashLock = await _getHashLock(hashType, preimage); + final expirationTime = await _getExpirationTime(initialHtlcDuration); + AccountBlockTemplate transactionParams = zenon!.embedded.htlc.create( + fromToken, + fromAmount, + counterpartyAddress, + expirationTime, + hashType, + htlcPreimageMaxLength, + hashLock.getBytes(), + ); + KeyPair blockSigningKeyPair = kKeyStore!.getKeyPair( + kDefaultAddressList.indexOf(selfAddress.toString()), + ); + AccountBlockUtils.createAccountBlock(transactionParams, 'start swap', + blockSigningKey: blockSigningKeyPair, waitForRequiredPlasma: true) + .then( + (response) async { + final swap = HtlcSwap( + id: response.hash.toString(), + chainId: response.chainIdentifier, + type: swapType, + direction: P2pSwapDirection.outgoing, + selfAddress: selfAddress.toString(), + counterpartyAddress: counterpartyAddress.toString(), + state: P2pSwapState.pending, + startTime: DateTimeUtils.unixTimeNow, + initialHtlcId: response.hash.toString(), + initialHtlcExpirationTime: expirationTime, + fromAmount: fromAmount, + fromTokenStandard: fromToken.tokenStandard.toString(), + fromDecimals: fromToken.decimals, + fromSymbol: fromToken.symbol, + fromChain: fromChain, + toChain: toChain, + hashLock: hashLock.toString(), + preimage: FormatUtils.encodeHexString(preimage), + hashType: hashType, + ); + await htlcSwapsService!.storeSwap(swap); + ZenonAddressUtils.refreshBalance(); + addEvent(swap); + }, + ).onError( + (error, stackTrace) { + addError(error.toString(), stackTrace); + }, + ); + } catch (e, stackTrace) { + addError(e, stackTrace); + } + } + + List _generatePreimage() { + const maxInt = 256; + return List.generate( + htlcPreimageDefaultLength, (i) => Random.secure().nextInt(maxInt)); + } + + Future _getHashLock(int hashType, List preimage) async { + if (hashType == htlcHashTypeSha3) { + return Hash.digest(preimage); + } else if (hashType == htlcHashTypeSha256) { + return Hash.fromBytes(await Crypto.sha256Bytes(preimage)); + } + throw UnimplementedError('Hash type not implemented'); + } + + Future _getExpirationTime(int duration) async { + return (await zenon!.ledger.getFrontierMomentum()).timestamp + duration; + } +} diff --git a/lib/blocs/p2p_swap/p2p_swaps_list_bloc.dart b/lib/blocs/p2p_swap/p2p_swaps_list_bloc.dart new file mode 100644 index 00000000..084b5ad5 --- /dev/null +++ b/lib/blocs/p2p_swap/p2p_swaps_list_bloc.dart @@ -0,0 +1,29 @@ +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/periodic_p2p_swap_base_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; + +class P2pSwapsListBloc extends PeriodicP2pSwapBaseBloc> { + @override + List makeCall() { + try { + return _getSwaps(); + } catch (e) { + rethrow; + } + } + + void getData() { + try { + final data = _getSwaps(); + addEvent(data); + } catch (e, stackTrace) { + addError(e, stackTrace); + } + } + + List _getSwaps() { + final swaps = htlcSwapsService!.getAllSwaps(); + swaps.sort((a, b) => b.startTime.compareTo(a.startTime)); + return swaps; + } +} diff --git a/lib/blocs/p2p_swap/periodic_p2p_swap_base_bloc.dart b/lib/blocs/p2p_swap/periodic_p2p_swap_base_bloc.dart new file mode 100644 index 00000000..c75b23c5 --- /dev/null +++ b/lib/blocs/p2p_swap/periodic_p2p_swap_base_bloc.dart @@ -0,0 +1,40 @@ +import 'dart:async'; + +import 'package:zenon_syrius_wallet_flutter/blocs/base_bloc.dart'; + +abstract class PeriodicP2pSwapBaseBloc extends BaseBloc { + final _refreshInterval = const Duration(seconds: 5); + + Timer? _autoRefresher; + + T makeCall(); + + void getDataPeriodically() { + try { + T data = makeCall(); + addEvent(data); + } catch (e, stackTrace) { + addError(e, stackTrace); + } finally { + if (_autoRefresher == null) { + _autoRefresher = _getAutoRefreshTimer(); + } else if (!_autoRefresher!.isActive) { + _autoRefresher = _getAutoRefreshTimer(); + } + } + } + + Timer _getAutoRefreshTimer() => Timer( + _refreshInterval, + () { + _autoRefresher!.cancel(); + getDataPeriodically(); + }, + ); + + @override + void dispose() { + _autoRefresher?.cancel(); + super.dispose(); + } +} diff --git a/lib/embedded_node/blobs/libznn.dll b/lib/embedded_node/blobs/libznn.dll index d3f1a204..d70ba78b 100755 Binary files a/lib/embedded_node/blobs/libznn.dll and b/lib/embedded_node/blobs/libznn.dll differ diff --git a/lib/embedded_node/blobs/libznn.dylib b/lib/embedded_node/blobs/libznn.dylib index 314df7db..6886747d 100755 Binary files a/lib/embedded_node/blobs/libznn.dylib and b/lib/embedded_node/blobs/libznn.dylib differ diff --git a/lib/embedded_node/blobs/libznn.so b/lib/embedded_node/blobs/libznn.so index e7ffee19..9d8bb6bb 100755 Binary files a/lib/embedded_node/blobs/libznn.so and b/lib/embedded_node/blobs/libznn.so differ diff --git a/lib/handlers/htlc_swaps_handler.dart b/lib/handlers/htlc_swaps_handler.dart new file mode 100644 index 00000000..5181de93 --- /dev/null +++ b/lib/handlers/htlc_swaps_handler.dart @@ -0,0 +1,293 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:collection/collection.dart'; +import 'package:logging/logging.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/auto_unlock_htlc_worker.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/model/block_data.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/htlc_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class HtlcSwapsHandler { + static HtlcSwapsHandler? _instance; + + bool _isRunning = false; + + static HtlcSwapsHandler getInstance() { + _instance ??= HtlcSwapsHandler(); + return _instance!; + } + + void start() { + if (!_isRunning) { + _runPeriodically(); + } + } + + bool get hasActiveIncomingSwaps => + htlcSwapsService!.getSwapsByState([P2pSwapState.active]).firstWhereOrNull( + (e) => e.direction == P2pSwapDirection.incoming) != + null; + + Future _runPeriodically() async { + try { + _isRunning = true; + await _enableWakelockIfNeeded(); + if (!zenon!.wsClient.isClosed()) { + final unresolvedSwaps = htlcSwapsService!.getSwapsByState([ + P2pSwapState.pending, + P2pSwapState.active, + P2pSwapState.reclaimable + ]); + if (unresolvedSwaps.isNotEmpty) { + if (await _areThereNewHtlcBlocks()) { + final newBlocks = await _getNewHtlcBlocks(unresolvedSwaps); + await _goThroughHtlcBlocks(newBlocks); + } + await _checkForExpiredSwaps(); + _checkForAutoUnlockableSwaps(); + } + sl().autoUnlock(); + } + } catch (e) { + Logger('HtlcSwapsHandler').log(Level.WARNING, '_runPeriodically', e); + } finally { + Future.delayed(const Duration(seconds: 5), () => _runPeriodically()); + } + } + + Future _enableWakelockIfNeeded() async { + if (hasActiveIncomingSwaps) { + try { + await WakelockPlus.enable(); + } catch (e) { + Logger('HtlcSwapsHandler') + .log(Level.WARNING, '_enableWakelockIfNeeded', e); + } + } + } + + Future _getHtlcFrontierHeight() async { + try { + final frontier = await zenon!.ledger.getFrontierAccountBlock(htlcAddress); + return frontier?.height; + } catch (e, stackTrace) { + Logger('HtlcSwapsHandler') + .log(Level.WARNING, '_getHtlcFrontierHeight', e, stackTrace); + } + return null; + } + + Future _areThereNewHtlcBlocks() async { + final frontier = await _getHtlcFrontierHeight(); + return frontier != null && + frontier > htlcSwapsService!.getLastCheckedHtlcBlockHeight(); + } + + Future> _getNewHtlcBlocks(List swaps) async { + final lastCheckedHeight = htlcSwapsService!.getLastCheckedHtlcBlockHeight(); + final oldestSwapStartTime = _getOldestSwapStartTime(swaps) ?? 0; + int lastCheckedBlockTime = 0; + + if (lastCheckedHeight > 0) { + try { + lastCheckedBlockTime = + (await AccountBlockUtils.getTimeForAccountBlockHeight( + htlcAddress, lastCheckedHeight)) ?? + lastCheckedBlockTime; + } catch (e, stackTrace) { + Logger('HtlcSwapsHandler') + .log(Level.WARNING, '_getNewHtlcBlocks', e, stackTrace); + return []; + } + } + + try { + return await AccountBlockUtils.getAccountBlocksAfterTime( + htlcAddress, max(oldestSwapStartTime, lastCheckedBlockTime)); + } catch (e, stackTrace) { + Logger('HtlcSwapsHandler') + .log(Level.WARNING, '_getNewHtlcBlocks', e, stackTrace); + return []; + } + } + + Future _goThroughHtlcBlocks(List blocks) async { + for (final block in blocks) { + await _extractSwapDataFromBlock(block); + await htlcSwapsService!.storeLastCheckedHtlcBlockHeight(block.height); + } + } + + Future _extractSwapDataFromBlock(AccountBlock htlcBlock) async { + if (htlcBlock.blockType != BlockTypeEnum.contractReceive.index) { + return; + } + + final pairedBlock = htlcBlock.pairedAccountBlock!; + final blockData = AccountBlockUtils.getDecodedBlockData( + Definitions.htlc, pairedBlock.data); + + if (blockData == null) { + return; + } + + final swap = _tryGetSwapFromBlockData(blockData); + if (swap == null) { + return; + } + + if (swap.chainId != pairedBlock.chainIdentifier) { + return; + } + + switch (blockData.function) { + case 'Create': + if (swap.state == P2pSwapState.pending) { + swap.state = P2pSwapState.active; + await htlcSwapsService!.storeSwap(swap); + } else if (swap.state == P2pSwapState.active && + pairedBlock.hash.toString() != swap.initialHtlcId && + swap.counterHtlcId == null) { + if (!_isValidCounterHtlc(pairedBlock, blockData, swap)) { + return; + } + swap.counterHtlcId = pairedBlock.hash.toString(); + swap.toAmount = pairedBlock.amount; + swap.toTokenStandard = pairedBlock.token!.tokenStandard.toString(); + swap.toDecimals = pairedBlock.token!.decimals; + swap.toSymbol = pairedBlock.token!.symbol; + swap.counterHtlcExpirationTime = + blockData.params['expirationTime'].toInt(); + await htlcSwapsService!.storeSwap(swap); + } + return; + case 'Unlock': + if (htlcBlock.descendantBlocks.isEmpty) { + return; + } + if (swap.preimage == null) { + if (!blockData.params.containsKey('preimage')) { + return; + } + swap.preimage = + FormatUtils.encodeHexString(blockData.params['preimage']); + await htlcSwapsService!.storeSwap(swap); + } + + if (swap.direction == P2pSwapDirection.incoming && + blockData.params['id'].toString() == swap.initialHtlcId) { + swap.state = P2pSwapState.completed; + await htlcSwapsService!.storeSwap(swap); + } + + // Handle the situation where the counter HTLC of an outgoing swap + // has been unlocked by someone else. + if (swap.direction == P2pSwapDirection.outgoing && + swap.state == P2pSwapState.active && + blockData.params['id'].toString() == swap.counterHtlcId) { + swap.state = P2pSwapState.completed; + await htlcSwapsService!.storeSwap(swap); + } + return; + case 'Reclaim': + if (htlcBlock.descendantBlocks.isEmpty) { + return; + } + bool isSelfReclaim = false; + if (swap.direction == P2pSwapDirection.outgoing && + blockData.params['id'].toString() == swap.initialHtlcId) { + isSelfReclaim = true; + } else if (swap.direction == P2pSwapDirection.incoming && + blockData.params['id'].toString() == swap.counterHtlcId) { + isSelfReclaim = true; + } + if (isSelfReclaim) { + swap.state = P2pSwapState.unsuccessful; + await htlcSwapsService!.storeSwap(swap); + } + return; + } + } + + HtlcSwap? _tryGetSwapFromBlockData(BlockData data) { + HtlcSwap? swap; + if (data.params.containsKey('id')) { + swap = htlcSwapsService!.getSwapByHtlcId(data.params['id'].toString()); + } + if (data.params.containsKey('hashLock') && swap == null) { + swap = htlcSwapsService!.getSwapByHashLock( + Hash.fromBytes(data.params['hashLock']).toString()); + } + return swap; + } + + bool _isValidCounterHtlc(AccountBlock block, BlockData data, HtlcSwap swap) { + // Verify that the recipient is the initiator's address + if (!data.params.containsKey('hashLocked') || + data.params['hashLocked'] != Address.parse(swap.selfAddress)) { + return false; + } + + // Verify that the creator is the counterparty. + if (block.address != Address.parse(swap.counterpartyAddress)) { + return false; + } + + // Verify that the hash types match. + if (!data.params.containsKey('hashType') || + data.params['hashType'].toInt() != swap.hashType) { + return false; + } + + // Verify that block data contains an expiration time parameter. + if (!data.params.containsKey('expirationTime')) { + return false; + } + + return true; + } + + Future _checkForExpiredSwaps() async { + final swaps = htlcSwapsService! + .getSwapsByState([P2pSwapState.pending, P2pSwapState.active]); + final now = DateTimeUtils.unixTimeNow; + for (final swap in swaps) { + if (swap.initialHtlcExpirationTime < now || + (swap.counterHtlcExpirationTime != null && + swap.counterHtlcExpirationTime! - + kMinSafeTimeToCompleteSwap.inSeconds < + now)) { + swap.state = P2pSwapState.reclaimable; + await htlcSwapsService!.storeSwap(swap); + } + } + } + + void _checkForAutoUnlockableSwaps() { + // It is important to check swaps that are in reclaimable state as well, + // since the counterparty may have published the preimage at the last moment + // before the HTLC would have expired. In this situation the swap's state + // may have already been changed to reclaimable. + final swaps = htlcSwapsService! + .getSwapsByState([P2pSwapState.active, P2pSwapState.reclaimable]); + for (final swap in swaps) { + if (swap.direction == P2pSwapDirection.incoming && + swap.preimage != null) { + sl().addHash(Hash.parse(swap.initialHtlcId)); + } + } + } + + int? _getOldestSwapStartTime(List swaps) { + return swaps.isNotEmpty + ? swaps + .reduce((e1, e2) => e1.startTime > e2.startTime ? e1 : e2) + .startTime + : null; + } +} diff --git a/lib/main.dart b/lib/main.dart index d593018a..5550c81e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,11 +15,14 @@ import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; import 'package:tray_manager/tray_manager.dart'; import 'package:window_manager/window_manager.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/auto_unlock_htlc_worker.dart'; import 'package:zenon_syrius_wallet_flutter/blocs/blocs.dart'; +import 'package:zenon_syrius_wallet_flutter/handlers/htlc_swaps_handler.dart'; import 'package:zenon_syrius_wallet_flutter/blocs/wallet_connect/wallet_connect_pairings_bloc.dart'; import 'package:zenon_syrius_wallet_flutter/blocs/wallet_connect/wallet_connect_sessions_bloc.dart'; import 'package:zenon_syrius_wallet_flutter/model/model.dart'; import 'package:zenon_syrius_wallet_flutter/screens/screens.dart'; +import 'package:zenon_syrius_wallet_flutter/services/htlc_swaps_service.dart'; import 'package:zenon_syrius_wallet_flutter/services/shared_prefs_service.dart'; import 'package:zenon_syrius_wallet_flutter/services/wallet_connect_service.dart'; import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; @@ -28,6 +31,7 @@ import 'package:znn_sdk_dart/znn_sdk_dart.dart'; Zenon? zenon; SharedPrefsService? sharedPrefsService; +HtlcSwapsService? htlcSwapsService; final sl = GetIt.instance; @@ -89,6 +93,8 @@ main() async { await sharedPrefsService!.checkIfBoxIsOpen(); } + htlcSwapsService ??= sl.get(); + windowManager.waitUntilReadyToShow().then((_) async { await windowManager.setTitle('s y r i u s'); await windowManager.setMinimumSize(const Size(1200, 600)); @@ -159,8 +165,14 @@ void setup() { zenon = sl(); sl.registerLazySingletonAsync( (() => SharedPrefsService.getInstance().then((value) => value!))); + sl.registerSingleton(HtlcSwapsService.getInstance()); + sl.registerLazySingleton(() => WalletConnectService()); sl.registerSingleton(AutoReceiveTxWorker.getInstance()); + sl.registerSingleton( + AutoUnlockHtlcWorker.getInstance()); + + sl.registerSingleton(HtlcSwapsHandler.getInstance()); sl.registerSingleton(ReceivePort(), instanceName: 'embeddedStoppedPort'); diff --git a/lib/model/basic_dropdown_item.dart b/lib/model/basic_dropdown_item.dart new file mode 100644 index 00000000..c8bf5aa4 --- /dev/null +++ b/lib/model/basic_dropdown_item.dart @@ -0,0 +1,9 @@ +class BasicDropdownItem { + final String label; + final T value; + + BasicDropdownItem({ + required this.label, + required this.value, + }); +} diff --git a/lib/model/block_data.dart b/lib/model/block_data.dart new file mode 100644 index 00000000..d8eeae0f --- /dev/null +++ b/lib/model/block_data.dart @@ -0,0 +1,9 @@ +class BlockData { + final String function; + final Map params; + + BlockData({ + required this.function, + required this.params, + }); +} diff --git a/lib/model/model.dart b/lib/model/model.dart index 1e49dae3..d43634a3 100644 --- a/lib/model/model.dart +++ b/lib/model/model.dart @@ -1,4 +1,5 @@ export 'account_chain_stats.dart'; +export 'block_data.dart'; export 'database/notification_type.dart'; export 'database/wallet_notification.dart'; export 'general_stats.dart'; @@ -6,3 +7,5 @@ export 'navigation_arguments.dart'; export 'new_token_data.dart'; export 'pillars_qsr_info.dart'; export 'plasma_info_wrapper.dart'; +export 'p2p_swap/p2p_swap.dart'; +export 'p2p_swap/htlc_swap.dart'; diff --git a/lib/model/p2p_swap/htlc_swap.dart b/lib/model/p2p_swap/htlc_swap.dart new file mode 100644 index 00000000..941198a5 --- /dev/null +++ b/lib/model/p2p_swap/htlc_swap.dart @@ -0,0 +1,82 @@ +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; + +class HtlcSwap extends P2pSwap { + final String hashLock; + final String initialHtlcId; + final int initialHtlcExpirationTime; + final int hashType; + String? counterHtlcId; + int? counterHtlcExpirationTime; + String? preimage; + + HtlcSwap({ + required id, + required chainId, + required type, + required direction, + required selfAddress, + required counterpartyAddress, + required fromAmount, + required fromTokenStandard, + required fromSymbol, + required fromDecimals, + required fromChain, + required toChain, + required startTime, + required state, + toAmount, + toTokenStandard, + toSymbol, + toDecimals, + required this.hashLock, + required this.initialHtlcId, + required this.initialHtlcExpirationTime, + required this.hashType, + this.counterHtlcId, + this.counterHtlcExpirationTime, + this.preimage, + }) : super( + id: id, + chainId: chainId, + type: type, + mode: P2pSwapMode.htlc, + direction: direction, + selfAddress: selfAddress, + counterpartyAddress: counterpartyAddress, + fromAmount: fromAmount, + fromTokenStandard: fromTokenStandard, + fromSymbol: fromSymbol, + fromDecimals: fromDecimals, + fromChain: fromChain, + toChain: toChain, + startTime: startTime, + state: state, + toAmount: toAmount, + toTokenStandard: toTokenStandard, + toSymbol: toSymbol, + toDecimals: toDecimals, + ); + + HtlcSwap.fromJson(Map json) + : hashLock = json['hashLock'], + initialHtlcId = json['initialHtlcId'], + initialHtlcExpirationTime = json['initialHtlcExpirationTime'], + hashType = json['hashType'], + counterHtlcId = json['counterHtlcId'], + counterHtlcExpirationTime = json['counterHtlcExpirationTime'], + preimage = json['preimage'], + super.fromJson(json); + + @override + Map toJson() { + final data = super.toJson(); + data['hashLock'] = hashLock; + data['initialHtlcId'] = initialHtlcId; + data['initialHtlcExpirationTime'] = initialHtlcExpirationTime; + data['hashType'] = hashType; + data['counterHtlcId'] = counterHtlcId; + data['counterHtlcExpirationTime'] = counterHtlcExpirationTime; + data['preimage'] = preimage; + return data; + } +} diff --git a/lib/model/p2p_swap/p2p_swap.dart b/lib/model/p2p_swap/p2p_swap.dart new file mode 100644 index 00000000..b06ffd9b --- /dev/null +++ b/lib/model/p2p_swap/p2p_swap.dart @@ -0,0 +1,114 @@ +enum P2pSwapType { + native, + crosschain, +} + +enum P2pSwapState { + pending, + active, + completed, + reclaimable, + unsuccessful, + error, +} + +enum P2pSwapMode { + htlc, +} + +enum P2pSwapDirection { + outgoing, + incoming, +} + +enum P2pSwapChain { + nom, + btc, + other, +} + +class P2pSwap { + final String id; + final int chainId; + final P2pSwapType type; + final P2pSwapMode mode; + final P2pSwapDirection direction; + final String selfAddress; + final String counterpartyAddress; + final BigInt fromAmount; + final String fromTokenStandard; + final String fromSymbol; + final int fromDecimals; + final P2pSwapChain fromChain; + final P2pSwapChain toChain; + final int startTime; + P2pSwapState state; + BigInt? toAmount; + String? toTokenStandard; + String? toSymbol; + int? toDecimals; + + P2pSwap( + {required this.id, + required this.chainId, + required this.type, + required this.mode, + required this.direction, + required this.selfAddress, + required this.counterpartyAddress, + required this.fromAmount, + required this.fromTokenStandard, + required this.fromSymbol, + required this.fromDecimals, + required this.fromChain, + required this.toChain, + required this.startTime, + required this.state, + this.toAmount, + this.toTokenStandard, + this.toSymbol, + this.toDecimals}); + + P2pSwap.fromJson(Map json) + : id = json['id'], + chainId = json['chainId'], + type = P2pSwapType.values.byName(json['type']), + mode = P2pSwapMode.values.byName(json['mode']), + direction = P2pSwapDirection.values.byName(json['direction']), + selfAddress = json['selfAddress'], + counterpartyAddress = json['counterpartyAddress'], + fromAmount = BigInt.parse(json['fromAmount'].toString()), + fromTokenStandard = json['fromTokenStandard'], + fromSymbol = json['fromSymbol'], + fromDecimals = json['fromDecimals'], + fromChain = P2pSwapChain.values.byName(json['fromChain']), + toChain = P2pSwapChain.values.byName(json['toChain']), + startTime = json['startTime'], + state = P2pSwapState.values.byName(json['state']), + toAmount = BigInt.tryParse(json['toAmount'].toString()), + toTokenStandard = json['toTokenStandard'], + toSymbol = json['toSymbol'], + toDecimals = json['toDecimals']; + + Map toJson() => { + 'id': id, + 'chainId': chainId, + 'type': type.name, + 'mode': mode.name, + 'direction': direction.name, + 'selfAddress': selfAddress, + 'counterpartyAddress': counterpartyAddress, + 'fromAmount': fromAmount.toString(), + 'fromTokenStandard': fromTokenStandard, + 'fromSymbol': fromSymbol, + 'fromDecimals': fromDecimals, + 'fromChain': fromChain.name, + 'toChain': toChain.name, + 'startTime': startTime, + 'state': state.name, + 'toAmount': toAmount?.toString(), + 'toTokenStandard': toTokenStandard, + 'toSymbol': toSymbol, + 'toDecimals': toDecimals, + }; +} diff --git a/lib/services/htlc_swaps_service.dart b/lib/services/htlc_swaps_service.dart new file mode 100644 index 00000000..2aefc1a5 --- /dev/null +++ b/lib/services/htlc_swaps_service.dart @@ -0,0 +1,127 @@ +import 'dart:convert'; + +import 'package:collection/collection.dart'; +import 'package:hive/hive.dart'; +import 'package:zenon_syrius_wallet_flutter/model/model.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/constants.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/global.dart'; + +class HtlcSwapsService { + static Box? _htlcSwapsBox; + static Box? _lastCheckedHtlcBlockHeightBox; + + static HtlcSwapsService? _instance; + + static HtlcSwapsService getInstance() { + _instance ??= HtlcSwapsService(); + return _instance!; + } + + bool get isMaxSwapsReached => _htlcSwapsBox!.length >= kMaxP2pSwapsToStore; + + Future openBoxes(String htlcSwapsBoxSuffix, List cipherKey) async { + if (_htlcSwapsBox == null || !_htlcSwapsBox!.isOpen) { + _htlcSwapsBox = await Hive.openBox('${kHtlcSwapsBox}_$htlcSwapsBoxSuffix', + encryptionCipher: HiveAesCipher(cipherKey)); + } + + if (_lastCheckedHtlcBlockHeightBox == null || + !_lastCheckedHtlcBlockHeightBox!.isOpen) { + _lastCheckedHtlcBlockHeightBox = await Hive.openBox( + kLastCheckedHtlcBlockBox, + encryptionCipher: HiveAesCipher(cipherKey)); + } + } + + List getAllSwaps() { + return _swapsForCurrentChainId; + } + + List getSwapsByState(List states) { + return _swapsForCurrentChainId + .where((e) => states.contains(e.state)) + .toList(); + } + + HtlcSwap? getSwapByHashLock(String hashLock) { + try { + return _swapsForCurrentChainId + .firstWhereOrNull((e) => e.hashLock == hashLock); + } on HiveError { + return null; + } + } + + HtlcSwap? getSwapByHtlcId(String htlcId) { + try { + return _swapsForCurrentChainId.firstWhereOrNull( + (e) => e.initialHtlcId == htlcId || e.counterHtlcId == htlcId); + } on HiveError { + return null; + } + } + + HtlcSwap? getSwapById(String id) { + try { + return _swapsForCurrentChainId.firstWhereOrNull((e) => e.id == id); + } on HiveError { + return null; + } + } + + int getLastCheckedHtlcBlockHeight() { + return _lastCheckedHtlcBlockHeightBox! + .get(kLastCheckedHtlcBlockKey, defaultValue: 0); + } + + Future storeSwap(HtlcSwap swap) async => await _htlcSwapsBox! + .put( + swap.id, + jsonEncode(swap.toJson()), + ) + .then((_) async => await _pruneSwapsHistoryIfNeeded()); + + Future storeLastCheckedHtlcBlockHeight(int height) async => + await _lastCheckedHtlcBlockHeightBox! + .put(kLastCheckedHtlcBlockKey, height); + + Future deleteSwap(String swapId) async => + await _htlcSwapsBox!.delete(swapId); + + Future deleteInactiveSwaps() async => + await _htlcSwapsBox!.deleteAll(_swapsForCurrentChainId + .where((e) => [ + P2pSwapState.completed, + P2pSwapState.unsuccessful, + P2pSwapState.error + ].contains(e.state)) + .map((e) => e.id)); + + List get _swapsForCurrentChainId { + return kNodeChainId != null + ? _htlcSwapsBox!.values + .where( + (e) => HtlcSwap.fromJson(jsonDecode(e)).chainId == kNodeChainId) + .map((e) => HtlcSwap.fromJson(jsonDecode(e))) + .toList() + : []; + } + + HtlcSwap? _getOldestPrunableSwap() { + final swaps = getAllSwaps() + .where((e) => [P2pSwapState.completed, P2pSwapState.unsuccessful] + .contains(e.state)) + .toList(); + swaps.sort((a, b) => b.startTime.compareTo(a.startTime)); + return swaps.isNotEmpty ? swaps.last : null; + } + + Future _pruneSwapsHistoryIfNeeded() async { + if (_htlcSwapsBox!.length > kMaxP2pSwapsToStore) { + final toBePruned = _getOldestPrunableSwap(); + if (toBePruned != null) { + await deleteSwap(toBePruned.id); + } + } + } +} diff --git a/lib/utils/account_block_utils.dart b/lib/utils/account_block_utils.dart index cd9f27e8..31237735 100644 --- a/lib/utils/account_block_utils.dart +++ b/lib/utils/account_block_utils.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:collection/collection.dart'; import 'package:zenon_syrius_wallet_flutter/blocs/blocs.dart'; import 'package:zenon_syrius_wallet_flutter/main.dart'; import 'package:zenon_syrius_wallet_flutter/model/model.dart'; @@ -94,6 +95,85 @@ class AccountBlockUtils { } } + static BlockData? getDecodedBlockData(Abi abi, List encodedData) { + if (encodedData.length < AbiFunction.encodedSignLength) { + return null; + } + final eq = const ListEquality().equals; + try { + for (final entry in abi.entries) { + if (eq(AbiFunction.extractSignature(entry.encodeSignature()), + AbiFunction.extractSignature(encodedData))) { + final decoded = + AbiFunction(entry.name!, entry.inputs!).decode(encodedData); + final Map params = {}; + for (int i = 0; i < entry.inputs!.length; i += 1) { + params[entry.inputs![i].name!] = decoded[i]; + } + return BlockData(function: entry.name!, params: params); + } + } + } catch (e) { + rethrow; + } + return null; + } + + // Returns a list of AccountBlocks that are newer than a given timestamp. + // The list is returned in ascending order. + static Future> getAccountBlocksAfterTime( + Address address, int time) async { + final List blocks = []; + int pageIndex = 0; + try { + while (true) { + final fetched = await zenon!.ledger.getAccountBlocksByPage(address, + pageIndex: pageIndex, pageSize: 100); + + final lastBlockConfirmation = fetched.list!.last.confirmationDetail; + if (lastBlockConfirmation == null || + lastBlockConfirmation.momentumTimestamp <= time) { + for (final block in fetched.list!) { + final confirmation = block.confirmationDetail; + if (confirmation == null || + confirmation.momentumTimestamp <= time) { + break; + } + blocks.add(block); + } + break; + } + + blocks.addAll(fetched.list!); + + if (fetched.more == null || !fetched.more!) { + break; + } + + pageIndex += 1; + } + } catch (e) { + rethrow; + } + return blocks.reversed.toList(); + } + + static Future getTimeForAccountBlockHeight( + Address address, int height) async { + if (height >= 1) { + try { + final block = + await zenon!.ledger.getAccountBlocksByHeight(address, height, 1); + if (block.count != null && block.count! > 0) { + return block.list?.first.confirmationDetail?.momentumTimestamp; + } + } catch (e) { + rethrow; + } + } + return null; + } + static void _addEventToPowGeneratingStatusBloc(PowStatus event) => sl.get().addEvent(event); } diff --git a/lib/utils/clipboard_utils.dart b/lib/utils/clipboard_utils.dart index 2d1d4dfb..718f8c01 100644 --- a/lib/utils/clipboard_utils.dart +++ b/lib/utils/clipboard_utils.dart @@ -1,13 +1,10 @@ import 'package:clipboard_watcher/clipboard_watcher.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:zenon_syrius_wallet_flutter/blocs/blocs.dart'; import 'package:zenon_syrius_wallet_flutter/main.dart'; -import 'package:zenon_syrius_wallet_flutter/model/model.dart'; import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; class ClipboardUtils { - static void toggleClipboardWatcherStatus() { final enableClipboardWatcher = sharedPrefsService!.get( kEnableClipboardWatcherKey, @@ -26,14 +23,8 @@ class ClipboardUtils { ClipboardData( text: stringValue, ), - ).then((value) => - sl.get().addNotification(WalletNotification( - timestamp: DateTime.now().millisecondsSinceEpoch, - title: 'Successfully copied to clipboard', - details: 'Successfully copied $stringValue to clipboard', - type: NotificationType.copiedToClipboard, - id: null, - ))); + ).then((_) => + ToastUtils.showToast(context, 'Copied', color: AppColors.znnColor)); } static void pasteToClipboard( diff --git a/lib/utils/constants.dart b/lib/utils/constants.dart index d7690841..1cb2c11e 100644 --- a/lib/utils/constants.dart +++ b/lib/utils/constants.dart @@ -28,12 +28,8 @@ const SizedBox kSpacingBetweenActionButtons = SizedBox( ); const Size kAcceleratorProgressBarSize = Size(300.0, 10.0); -const List kBridgeNetworks = [ - 'The ZNN to wZNN bridge is unavailable. A new bridge will be available soon.', -]; - // Wallet version -const String kWalletVersion = '0.0.7'; +const String kWalletVersion = '0.1.0'; // Boxes constants const String kFavoriteTokensBox = 'favourite_tokens_box'; @@ -44,6 +40,8 @@ const String kRecipientAddressBox = 'recipient_address_box'; const String kSharedPrefsBox = 'shared_prefs_box'; const String kNodesBox = 'nodes_box'; const String kKeyStoreBox = 'key_store_box'; +const String kHtlcSwapsBox = 'htlc_swaps_box'; +const String kLastCheckedHtlcBlockBox = 'last_checked_htlc_block_box'; const List kCacheBoxesToBeDeleted = [ kFavoriteTokensBox, @@ -53,6 +51,7 @@ const List kCacheBoxesToBeDeleted = [ kRecipientAddressBox, kSharedPrefsBox, kNodesBox, + kLastCheckedHtlcBlockBox ]; // Wallet file name @@ -65,6 +64,7 @@ const String kGithubReleasesLink = const String kIncorrectPasswordNotificationTitle = 'Incorrect password'; const String kUnlockFailedNotificationTitle = 'Unlock failed'; const String kDefaultDateFormat = 'dd MMMM, yyyy'; +const String kDefaultDateTimeFormat = 'yyyy-MM-dd hh:mm a'; // Key-value store const String kTextScalingKey = 'text_scaling_key'; @@ -78,6 +78,10 @@ const String kWindowSizeHeightKey = 'window_size_height_key'; const String kWindowPositionXKey = 'window_position_x_key'; const String kWindowPositionYKey = 'window_position_y_key'; const String kWindowMaximizedKey = 'window_maximized_key'; +const String kP2pSwapsKey = 'p2p_swaps_key'; +const String kP2pAtomicUnlockKey = 'p2p_atomic_unlock_key'; +const String kP2pAutoReclaimKey = 'p2p_auto_reclaim_key'; +const String kLastCheckedHtlcBlockKey = 'last_checked_htlc_block_key'; const double kDefaultBorderOutlineWidth = 1.0; const double kStandardChartNumDays = 7; @@ -103,6 +107,7 @@ const int kIssueTokenPlasmaAmountNeeded = 189000; const int kAmountInputMaxCharacterLength = 21; const int kSecondsPerMomentum = 10; +const int kMaxP2pSwapsToStore = 500; final List kNormalUsersPlasmaRequirements = [ kStakePlasmaAmountNeeded, @@ -206,4 +211,16 @@ const List kTabsWithTextTitles = [ Tabs.staking, Tabs.plasma, Tabs.tokens, + Tabs.p2pSwap, ]; + +// P2P swap constants +const Duration kInitialHtlcDuration = Duration(hours: 8); +const Duration kCounterHtlcDuration = Duration(hours: 1); +const Duration kMaxAllowedInitialHtlcDuration = Duration(hours: 24); +const Duration kMinSafeTimeToFindPreimage = Duration(hours: 6); +const Duration kMinSafeTimeToCompleteSwap = Duration(minutes: 10); +const String kHasReadP2pSwapWarningKey = 'has_read_p2p_swap_warning'; +const bool kHasReadP2pSwapWarningDefaultValue = false; +const String kP2pSwapTutorialLink = + 'https://medium.com/@vilkris/p2p-swap-tutorial-3805f10d2d21'; diff --git a/lib/utils/date_time_utils.dart b/lib/utils/date_time_utils.dart new file mode 100644 index 00000000..b945bce0 --- /dev/null +++ b/lib/utils/date_time_utils.dart @@ -0,0 +1,3 @@ +class DateTimeUtils { + static int get unixTimeNow => DateTime.now().millisecondsSinceEpoch ~/ 1000; +} diff --git a/lib/utils/extensions.dart b/lib/utils/extensions.dart index d2dd34ba..80cd685a 100644 --- a/lib/utils/extensions.dart +++ b/lib/utils/extensions.dart @@ -1,3 +1,4 @@ +import 'dart:math' show pow; import 'package:big_decimal/big_decimal.dart'; extension StringExtensions on String { @@ -9,6 +10,9 @@ extension StringExtensions on String { BigInt extractDecimals(int decimals) { if (!contains('.')) { + if (decimals == 0 && isEmpty) { + return BigInt.zero; + } return BigInt.parse(this + ''.padRight(decimals, '0')); } List parts = split('.'); @@ -18,11 +22,16 @@ extension StringExtensions on String { ? parts[1].substring(0, decimals) : parts[1].padRight(decimals, '0'))); } - //BigInt.parse(num.parse(this).toStringAsFixed(decimals).replaceAll('.', '')); String abs() => this; } +extension FixedNumDecimals on double { + String toStringFixedNumDecimals(int numDecimals) { + return '${(this * pow(10, numDecimals)).truncate() / pow(10, numDecimals)}'; + } +} + extension BigIntExtensions on BigInt { String addDecimals(int decimals) { return BigDecimal.createAndStripZerosForScale(this, decimals, 0) diff --git a/lib/utils/format_utils.dart b/lib/utils/format_utils.dart index 3379f589..384ef371 100644 --- a/lib/utils/format_utils.dart +++ b/lib/utils/format_utils.dart @@ -28,6 +28,7 @@ class FormatUtils { ), ]; + static String encodeHexString(List input) => HEX.encode(input); static List decodeHexString(String input) => HEX.decode(input); static String formatDate(int timestampMillis, diff --git a/lib/utils/global.dart b/lib/utils/global.dart index 86f221a6..611ee340 100644 --- a/lib/utils/global.dart +++ b/lib/utils/global.dart @@ -13,6 +13,7 @@ String? kKeyStorePath; String? kLocalIpAddress; int? kAutoLockWalletMinutes; +int? kNodeChainId; int? kNumFailedUnlockAttempts; double? kAutoEraseWalletLimit; @@ -42,7 +43,6 @@ int? kNumOfPillars; bool kEmbeddedNodeRunning = false; final List kTabsWithIconTitles = [ - Tabs.bridge, if (kWcProjectId.isNotEmpty) Tabs.walletConnect, Tabs.accelerator, Tabs.help, diff --git a/lib/utils/init_utils.dart b/lib/utils/init_utils.dart index b8996667..527eefec 100644 --- a/lib/utils/init_utils.dart +++ b/lib/utils/init_utils.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; +import 'package:zenon_syrius_wallet_flutter/handlers/htlc_swaps_handler.dart'; import 'package:zenon_syrius_wallet_flutter/main.dart'; import 'package:zenon_syrius_wallet_flutter/services/shared_prefs_service.dart'; import 'package:zenon_syrius_wallet_flutter/services/wallet_connect_service.dart'; @@ -83,6 +84,10 @@ class InitUtils { await _openRecipientBox(); await NodeUtils.initWebSocketClient(); await _setWalletVersion(); + final baseAddress = await kKeyStore!.getKeyPair(0).address; + await htlcSwapsService!.openBoxes( + baseAddress.toString(), kKeyStore!.getKeyPair(0).getPrivateKey()!); + sl().start(); kWalletInitCompleted = true; } diff --git a/lib/utils/input_validators.dart b/lib/utils/input_validators.dart index 3d5478ea..2e847934 100644 --- a/lib/utils/input_validators.dart +++ b/lib/utils/input_validators.dart @@ -2,6 +2,7 @@ import 'package:logging/logging.dart'; import 'package:validators/validators.dart'; import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; import 'package:znn_sdk_dart/znn_sdk_dart.dart'; +import 'package:convert/convert.dart'; class InputValidators { static String kEVMAddressRegex = r'^(0x)([a-fA-F0-9]){40}$'; @@ -191,4 +192,32 @@ class InputValidators { } return 'Invalid URL'; } + + static String? checkHash(String? value) { + try { + if (Hash.parse(value!).runtimeType == Hash) { + return null; + } + } catch (e) { + return 'Invalid hash'; + } + return 'Invalid hash'; + } + + static Future checkSecret(HtlcInfo htlc, String? value) async { + if (value != null) { + try { + final preimageCheck = htlc.hashType == htlcHashTypeSha3 + ? Hash.digest(hex.decode(value)) + : Hash.fromBytes(await Crypto.sha256Bytes(hex.decode(value))); + + if (preimageCheck == Hash.fromBytes(htlc.hashLock)) { + return null; + } + } catch (e) { + return 'Invalid secret'; + } + } + return 'Invalid secret'; + } } diff --git a/lib/utils/node_utils.dart b/lib/utils/node_utils.dart index 294540ec..947ccd69 100644 --- a/lib/utils/node_utils.dart +++ b/lib/utils/node_utils.dart @@ -10,6 +10,7 @@ import 'package:zenon_syrius_wallet_flutter/embedded_node/embedded_node.dart'; import 'package:zenon_syrius_wallet_flutter/main.dart'; import 'package:zenon_syrius_wallet_flutter/model/model.dart'; import 'package:zenon_syrius_wallet_flutter/utils/constants.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/date_time_utils.dart'; import 'package:zenon_syrius_wallet_flutter/utils/global.dart'; import 'package:zenon_syrius_wallet_flutter/utils/notification_utils.dart'; import 'package:znn_sdk_dart/znn_sdk_dart.dart'; @@ -95,6 +96,7 @@ class NodeUtils { static Future addOnWebSocketConnectedCallback() async { zenon!.wsClient .addOnConnectionEstablishedCallback((allResponseBroadcaster) async { + kNodeChainId = await getNodeChainIdentifier(); await _getSubscriptionForMomentums(); await _getSubscriptionForAllAccountEvents(); await getUnreceivedTransactions(); @@ -130,6 +132,32 @@ class NodeUtils { } } + static Future checkForLocalTimeDiscrepancy( + String warningMessage) async { + const maxAllowedDiscrepancy = Duration(minutes: 5); + try { + final syncInfo = await zenon!.stats.syncInfo(); + bool nodeIsSynced = (syncInfo.state == SyncState.syncDone || + (syncInfo.targetHeight > 0 && + syncInfo.currentHeight > 0 && + (syncInfo.targetHeight - syncInfo.currentHeight) < 20)); + if (nodeIsSynced) { + final frontierTime = + (await zenon!.ledger.getFrontierMomentum()).timestamp; + final timeDifference = (frontierTime - DateTimeUtils.unixTimeNow).abs(); + if (timeDifference > maxAllowedDiscrepancy.inSeconds) { + NotificationUtils.sendNotificationError( + Exception('Local time discrepancy detected.'), + warningMessage, + ); + } + } + } catch (e, stackTrace) { + Logger('NodeUtils') + .log(Level.WARNING, 'checkForLocalTimeDiscrepancy', e, stackTrace); + } + } + static void _initListenForUnreceivedAccountBlocks(Stream broadcaster) { broadcaster.listen( (event) { diff --git a/lib/utils/toast_utils.dart b/lib/utils/toast_utils.dart new file mode 100644 index 00000000..e51e34bb --- /dev/null +++ b/lib/utils/toast_utils.dart @@ -0,0 +1,52 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class ToastUtils { + static Timer? _timer; + + static void showToast(BuildContext context, String message, {Color? color}) { + if (_timer == null || !_timer!.isActive) { + final overlay = _getOverlayEntry(message, color); + Overlay.of(context).insert(overlay); + _timer = Timer(const Duration(seconds: 3), () { + overlay.remove(); + }); + } + } + + static OverlayEntry _getOverlayEntry(String message, Color? color) { + return OverlayEntry( + builder: (_) => TweenAnimationBuilder( + duration: const Duration(milliseconds: 200), + tween: Tween(begin: 0.0, end: 1.0), + builder: (_, double opacity, __) { + return Opacity( + opacity: opacity, + child: Padding( + padding: const EdgeInsets.only(bottom: 50.0), + child: Container( + alignment: Alignment.bottomCenter, + child: Material( + elevation: 6.0, + color: color, + surfaceTintColor: Colors.black, + borderRadius: BorderRadius.circular(50.0), + child: Padding( + padding: const EdgeInsets.fromLTRB(20.0, 15.0, 20.0, 15.0), + child: Text( + message, + textAlign: TextAlign.center, + style: + const TextStyle(fontSize: 14.0, color: Colors.white), + ), + ), + ), + ), + ), + ); + }, + ), + ); + } +} diff --git a/lib/utils/utils.dart b/lib/utils/utils.dart index 742acb01..d3d75599 100644 --- a/lib/utils/utils.dart +++ b/lib/utils/utils.dart @@ -5,6 +5,7 @@ export 'app_theme.dart'; export 'clipboard_utils.dart'; export 'color_utils.dart'; export 'constants.dart'; +export 'date_time_utils.dart'; export 'device_utils.dart'; export 'extensions.dart'; export 'file_utils.dart'; @@ -18,6 +19,7 @@ export 'network_utils.dart'; export 'node_utils.dart'; export 'notification_utils.dart'; export 'pair.dart'; +export 'toast_utils.dart'; export 'utils.dart'; export 'widget_utils.dart'; export 'zts_utils.dart'; diff --git a/lib/utils/zts_utils.dart b/lib/utils/zts_utils.dart index ac3fe228..2e027256 100644 --- a/lib/utils/zts_utils.dart +++ b/lib/utils/zts_utils.dart @@ -1,6 +1,7 @@ import 'dart:ui'; -import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:hive/hive.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; import 'package:znn_sdk_dart/znn_sdk_dart.dart'; final List kDualCoin = [ @@ -39,3 +40,11 @@ final Map kCoinIdColor = { kZnnCoin.tokenStandard: AppColors.znnColor, kQsrCoin.tokenStandard: AppColors.qsrColor, }; + +bool isTrustedToken(String tokenStandard) { + return [ + znnTokenStandard, + qsrTokenStandard, + ...Hive.box(kFavoriteTokensBox).values + ].contains(tokenStandard); +} diff --git a/lib/widgets/charts/realtime_txs_chart.dart b/lib/widgets/charts/realtime_txs_chart.dart index 2d994960..ca7d24af 100644 --- a/lib/widgets/charts/realtime_txs_chart.dart +++ b/lib/widgets/charts/realtime_txs_chart.dart @@ -33,6 +33,15 @@ class RealtimeTxsChartState extends State { _qsrSpots = _generateQsrSpots(); } + @override + void didUpdateWidget(RealtimeTxsChart oldWidget) { + // The spots variables must be generated before + // calling super.didUpdateWidget(oldWidget). + _znnSpots = _generateZnnSpots(); + _qsrSpots = _generateQsrSpots(); + super.didUpdateWidget(oldWidget); + } + @override Widget build(BuildContext context) { return StandardChart( diff --git a/lib/widgets/main_app_container.dart b/lib/widgets/main_app_container.dart index 96780db4..55e4615e 100644 --- a/lib/widgets/main_app_container.dart +++ b/lib/widgets/main_app_container.dart @@ -13,6 +13,7 @@ import 'package:provider/provider.dart'; import 'package:wallet_connect_uri_validator/wallet_connect_uri_validator.dart'; import 'package:window_manager/window_manager.dart'; import 'package:zenon_syrius_wallet_flutter/blocs/blocs.dart'; +import 'package:zenon_syrius_wallet_flutter/handlers/htlc_swaps_handler.dart'; import 'package:zenon_syrius_wallet_flutter/main.dart'; import 'package:zenon_syrius_wallet_flutter/model/model.dart'; import 'package:zenon_syrius_wallet_flutter/services/wallet_connect_service.dart'; @@ -41,8 +42,8 @@ enum Tabs { staking, plasma, tokens, + p2pSwap, resyncWallet, - bridge, accelerator, walletConnect, } @@ -99,11 +100,7 @@ class _MainAppContainerState extends State _netSyncStatusBloc.getDataPeriodically(); - _transferTabChild = TransferTabChild( - navigateToBridgeTab: () { - _navigateTo(Tabs.bridge); - }, - ); + _transferTabChild = TransferTabChild(); _initTabController(); _animationController = AnimationController( vsync: this, @@ -209,7 +206,7 @@ class _MainAppContainerState extends State ), child: Padding( padding: const EdgeInsets.symmetric( - horizontal: 20.0, + horizontal: 10.0, ), child: Focus( focusNode: _focusNode, @@ -287,24 +284,16 @@ class _MainAppContainerState extends State List _getTextTabs() { return kTabsWithTextTitles .map( - (e) => Tab( - text: FormatUtils.extractNameFromEnum(e).capitalize(), - ), + (e) => e == Tabs.p2pSwap + ? const Tab(text: 'P2P Swap') + : Tab( + text: FormatUtils.extractNameFromEnum(e).capitalize()), ) .toList(); } List _getIconTabs() { return [ - Tab( - child: Icon( - MaterialCommunityIcons.bridge, - size: 24.0, - color: _isTabSelected(Tabs.bridge) - ? AppColors.znnColor - : Theme.of(context).iconTheme.color, - ), - ), if (kWcProjectId.isNotEmpty) Tab( child: SvgPicture.asset( @@ -470,7 +459,10 @@ class _MainAppContainerState extends State onStepperNotificationSeeMorePressed: () => _navigateTo(Tabs.notifications), ), - const BridgeTabChild(), + P2pSwapTabChild( + onStepperNotificationSeeMorePressed: () => + _navigateTo(Tabs.notifications), + ), if (kWcProjectId.isNotEmpty) const WalletConnectTabChild(), AcceleratorTabChild( onStepperNotificationSeeMorePressed: () => @@ -495,10 +487,7 @@ class _MainAppContainerState extends State } Future _mainLockCallback(String password) async { - _navigateToLockTimer = Timer.periodic( - Duration(minutes: kAutoLockWalletMinutes!), - (timer) => _lockBloc.addEvent(LockEvent.navigateToLock), - ); + _navigateToLockTimer = _createAutoLockTimer(); if (kLastWalletConnectUriNotifier.value != null) { _tabController!.animateTo(_getTabChildIndex(Tabs.walletConnect)); } else { @@ -534,12 +523,7 @@ class _MainAppContainerState extends State } void _afterAppInitCallback() { - _navigateToLockTimer = Timer.periodic( - Duration( - minutes: kAutoLockWalletMinutes!, - ), - (timer) => _lockBloc.addEvent(LockEvent.navigateToLock), - ); + _navigateToLockTimer = _createAutoLockTimer(); if (kLastWalletConnectUriNotifier.value != null) { _tabController!.animateTo(_getTabChildIndex(Tabs.walletConnect)); } else { @@ -645,10 +629,7 @@ class _MainAppContainerState extends State switch (event) { case LockEvent.countDown: if (kCurrentPage != Tabs.lock) { - _navigateToLockTimer = Timer.periodic( - Duration(minutes: kAutoLockWalletMinutes!), - (timer) => _lockBloc.addEvent(LockEvent.navigateToLock), - ); + _navigateToLockTimer = _createAutoLockTimer(); } break; case LockEvent.navigateToDashboard: @@ -669,10 +650,7 @@ class _MainAppContainerState extends State case LockEvent.resetTimer: if (_navigateToLockTimer != null && _navigateToLockTimer!.isActive) { _navigateToLockTimer?.cancel(); - _navigateToLockTimer = Timer.periodic( - Duration(minutes: kAutoLockWalletMinutes!), - (timer) => _lockBloc.addEvent(LockEvent.navigateToLock), - ); + _navigateToLockTimer = _createAutoLockTimer(); } break; case LockEvent.navigateToPreviousTab: @@ -685,6 +663,14 @@ class _MainAppContainerState extends State } } + Timer _createAutoLockTimer() { + return Timer.periodic(Duration(minutes: kAutoLockWalletMinutes!), (timer) { + if (!sl().hasActiveIncomingSwaps) { + _lockBloc.addEvent(LockEvent.navigateToLock); + } + }); + } + void _handleIncomingLinks() async { if (!kIsWeb) { _incomingLinkSubscription = diff --git a/lib/widgets/modular_widgets/bridge_widgets/bridge_widgets.dart b/lib/widgets/modular_widgets/bridge_widgets/bridge_widgets.dart deleted file mode 100644 index f3fab9de..00000000 --- a/lib/widgets/modular_widgets/bridge_widgets/bridge_widgets.dart +++ /dev/null @@ -1 +0,0 @@ -export 'swap_card.dart'; diff --git a/lib/widgets/modular_widgets/bridge_widgets/swap_card.dart b/lib/widgets/modular_widgets/bridge_widgets/swap_card.dart deleted file mode 100644 index 08f7f8ce..00000000 --- a/lib/widgets/modular_widgets/bridge_widgets/swap_card.dart +++ /dev/null @@ -1,279 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:zenon_syrius_wallet_flutter/blocs/blocs.dart'; -import 'package:zenon_syrius_wallet_flutter/main.dart'; -import 'package:zenon_syrius_wallet_flutter/model/model.dart'; -import 'package:zenon_syrius_wallet_flutter/utils/constants.dart'; -import 'package:zenon_syrius_wallet_flutter/utils/format_utils.dart'; -import 'package:zenon_syrius_wallet_flutter/utils/global.dart'; -import 'package:zenon_syrius_wallet_flutter/utils/input_validators.dart'; -import 'package:zenon_syrius_wallet_flutter/utils/notification_utils.dart'; -import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; -import 'package:zenon_syrius_wallet_flutter/utils/zts_utils.dart'; -import 'package:zenon_syrius_wallet_flutter/widgets/widgets.dart'; -import 'package:znn_sdk_dart/znn_sdk_dart.dart'; - -class SwapCard extends StatefulWidget { - const SwapCard({Key? key}) : super(key: key); - - @override - State createState() => _SwapCardState(); -} - -class _SwapCardState extends State { - String? _selectedSelfAddress = kSelectedAddress; - - TextEditingController _amountController = TextEditingController(); - TextEditingController _evmAddressController = TextEditingController(); - - GlobalKey _amountKey = GlobalKey(); - GlobalKey _evmAddressKey = GlobalKey(); - final GlobalKey _swapButtonKey = GlobalKey(); - - final SendPaymentBloc _sendPaymentBloc = SendPaymentBloc(); - bool? _userHasEnoughBnbBalance = false; - - String? _selectedBridge = kBridgeNetworks.first; - final bool _bridgeStatus = false; - - final ScrollController _scrollController = ScrollController(); - - @override - void initState() { - super.initState(); - sl.get().getBalanceForAllAddresses(); - _sendPaymentBloc.stream.listen( - (event) { - if (event is AccountBlockTemplate) { - _sendConfirmationNotification(); - setState(() { - _swapButtonKey.currentState?.animateReverse(); - _amountController = TextEditingController(); - _evmAddressController = TextEditingController(); - _amountKey = GlobalKey(); - _evmAddressKey = GlobalKey(); - }); - } - }, - onError: (error) { - _swapButtonKey.currentState?.animateReverse(); - NotificationUtils.sendNotificationError(error, - 'Couldn\'t send ${_amountController.text} ${kZnnCoin.symbol}'); - }, - ); - } - - @override - Widget build(BuildContext context) { - return CardScaffold( - title: 'Swap', - description: - 'Bidirectional swap between Alphanet and other networks\nZNN => wZNN (wrapped ZNN): swap fee of 1% + 0.1 ZNN\nwZNN => ZNN: feeless (no swap fee)', - childBuilder: () => _getBalanceStreamBuilder(), - ); - } - - Widget _getBalanceStreamBuilder() { - return StreamBuilder?>( - stream: sl.get().stream, - builder: (_, snapshot) { - if (snapshot.hasError) { - return SyriusErrorWidget(snapshot.error!); - } - if (snapshot.connectionState == ConnectionState.active) { - if (snapshot.hasData) { - return _getWidgetBody( - snapshot.data![_selectedSelfAddress!]!, - ); - } - return const SyriusLoadingWidget(); - } - return const SyriusLoadingWidget(); - }, - ); - } - - Widget _getWidgetBody(AccountInfo accountInfo) { - return Scrollbar( - controller: _scrollController, - child: Container( - margin: const EdgeInsets.all(20.0), - child: ListView( - controller: _scrollController, - children: [ - _getInputFields(accountInfo), - ], - ), - ), - ); - } - - Column _getInputFields(AccountInfo accountInfo) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - BridgeNetworkDropdown( - _selectedBridge, - (value) => setState(() { - _selectedBridge = value; - }), - ), - Padding( - padding: const EdgeInsets.only( - left: 10.0, - bottom: 5.0, - top: 5.0, - ), - child: Text( - 'Send from', - style: Theme.of(context).inputDecorationTheme.hintStyle, - ), - ), - AddressesDropdown( - _selectedSelfAddress, - (address) => setState(() { - _selectedSelfAddress = address; - sl.get().getBalanceForAllAddresses(); - }), - ), - Padding( - padding: const EdgeInsets.only( - left: 10.0, - top: 5.0, - bottom: 5.0, - ), - child: AvailableBalance( - kZnnCoin, - accountInfo, - ), - ), - Form( - key: _amountKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: InputField( - controller: _amountController, - hintText: 'Amount', - onChanged: (value) { - setState(() {}); - }, - inputFormatters: FormatUtils.getAmountTextInputFormatters( - _amountController.text, - ), - validator: (value) => InputValidators.correctValue( - value, - accountInfo.getBalance( - kZnnCoin.tokenStandard, - ), - kZnnCoin.decimals, - BigInt.zero, - canBeEqualToMin: false, - ), - suffixIcon: AmountSuffixWidgets( - kZnnCoin, - onMaxPressed: () => _onMaxPressed(accountInfo), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(left: 10.0, bottom: 5.0, top: 5.0), - child: Text( - 'Receive to', - style: Theme.of(context).inputDecorationTheme.hintStyle, - ), - ), - Form( - key: _evmAddressKey, - autovalidateMode: AutovalidateMode.onUserInteraction, - child: InputField( - controller: _evmAddressController, - hintText: 'BNB Smart Chain address', - validator: InputValidators.evmAddress, - onChanged: (value) => setState(() {}), - ), - ), - ], - ); - } - - Widget _getCheckBox() { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - SyriusCheckbox( - context: context, - value: _userHasEnoughBnbBalance, - onChanged: (value) { - setState(() { - _userHasEnoughBnbBalance = value; - }); - }, - ), - Text( - 'I have enough funds to cover the gas fees in order to complete the swap', - style: Theme.of(context).textTheme.bodyMedium, - ) - ], - ); - } - - void _sendSwapBlock() { - _swapButtonKey.currentState?.animateForward(); - } - - void _onMaxPressed(AccountInfo accountInfo) => setState(() { - _amountController.text = accountInfo - .getBalance( - kZnnCoin.tokenStandard, - ) - .addDecimals(coinDecimals); - }); - - bool _isInputValid(AccountInfo accountInfo) => - InputValidators.correctValue( - _amountController.text, - accountInfo.getBalance( - kZnnCoin.tokenStandard, - ), - kZnnCoin.decimals, - BigInt.zero, - canBeEqualToMin: false, - ) == - null && - InputValidators.evmAddress(_evmAddressController.text) == null; - - void _sendConfirmationNotification() { - sl.get().addNotification( - WalletNotification( - title: 'Sent ${_amountController.text} ${kZnnCoin.symbol}', - timestamp: DateTime.now().millisecondsSinceEpoch, - details: 'Sent ${_amountController.text} ${kZnnCoin.symbol}', - type: NotificationType.paymentSent, - id: null, - ), - ); - } - - void _onSwapButtonPressed() { - showDialogWithNoAndYesOptions( - isBarrierDismissible: true, - context: context, - title: 'Swap', - description: 'Are you sure you want to swap ${_amountController.text} ' - '${kZnnCoin.symbol} ?', - onYesButtonPressed: () { - _sendSwapBlock(); - }, - ); - } - - List _decodeEvmAddress() { - String hexCharacters = _evmAddressController.text.split('0x')[1]; - return FormatUtils.decodeHexString(hexCharacters); - } - - @override - void dispose() { - _amountController.dispose(); - _evmAddressController.dispose(); - super.dispose(); - } -} diff --git a/lib/widgets/modular_widgets/modular_widgets.dart b/lib/widgets/modular_widgets/modular_widgets.dart index 6f10e0ac..23ab6e71 100644 --- a/lib/widgets/modular_widgets/modular_widgets.dart +++ b/lib/widgets/modular_widgets/modular_widgets.dart @@ -1,5 +1,4 @@ export 'accelerator_widgets/accelerator_widgets.dart'; -export 'bridge_widgets/bridge_widgets.dart'; export 'dashboard_widgets/dashboard_widgets.dart'; export 'help_widgets/help_widgets.dart'; export 'pillars_widgets/pillars_widgets.dart'; diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/detail_row.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/detail_row.dart new file mode 100644 index 00000000..b06ee378 --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/detail_row.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/icons/copy_to_clipboard_icon.dart'; + +class DetailRow extends StatelessWidget { + final String label; + final String value; + final String? valueToShow; + final Widget? prefixWidget; + final bool canBeCopied; + + const DetailRow({ + required this.label, + required this.value, + this.valueToShow, + this.prefixWidget, + this.canBeCopied = true, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, + style: const TextStyle( + fontSize: 12.0, color: AppColors.subtitleColor)), + Row( + children: [ + prefixWidget ?? Container(), + if (prefixWidget != null) + const SizedBox( + width: 5.0, + ), + Text(valueToShow ?? value, + style: const TextStyle( + fontSize: 12.0, color: AppColors.subtitleColor)), + Visibility( + visible: canBeCopied, + child: CopyToClipboardIcon( + value, + iconColor: AppColors.subtitleColor, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + padding: const EdgeInsets.only(left: 8.0), + ), + ), + ], + ) + ], + ); + } +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/htlc_card.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/htlc_card.dart new file mode 100644 index 00000000..c2435c80 --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/htlc_card.dart @@ -0,0 +1,336 @@ +import 'package:flutter/material.dart'; +import 'package:hive/hive.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/htlc_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/address_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/color_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/constants.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/date_time_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/extensions.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/format_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/detail_row.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/loading_info_text.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class HtlcCard extends StatefulWidget { + final String title; + final String sender; + final String? htlcId; + final String? hashLock; + final int? expirationTime; + final String? recipient; + final BigInt? amount; + final String? tokenStandard; + final int? tokenDecimals; + final String? tokenSymbol; + + const HtlcCard({ + required this.title, + required this.sender, + required this.htlcId, + required this.hashLock, + required this.expirationTime, + required this.recipient, + required this.amount, + required this.tokenStandard, + required this.tokenDecimals, + required this.tokenSymbol, + Key? key, + }) : super(key: key); + + factory HtlcCard.sending({ + required HtlcSwap swap, + }) => + HtlcCard( + title: 'You are sending', + sender: swap.selfAddress, + htlcId: swap.direction == P2pSwapDirection.outgoing + ? swap.initialHtlcId + : swap.counterHtlcId, + hashLock: swap.hashLock, + expirationTime: swap.direction == P2pSwapDirection.outgoing + ? swap.initialHtlcExpirationTime + : swap.counterHtlcExpirationTime, + recipient: swap.counterpartyAddress, + amount: swap.fromAmount, + tokenStandard: swap.fromTokenStandard, + tokenDecimals: swap.fromDecimals, + tokenSymbol: swap.fromSymbol, + ); + + factory HtlcCard.receiving({ + required HtlcSwap swap, + }) => + HtlcCard( + title: 'You are receiving', + sender: swap.counterpartyAddress, + htlcId: swap.direction == P2pSwapDirection.outgoing + ? swap.counterHtlcId + : swap.initialHtlcId, + hashLock: swap.hashLock, + expirationTime: swap.direction == P2pSwapDirection.outgoing + ? swap.counterHtlcExpirationTime + : swap.initialHtlcExpirationTime, + recipient: swap.selfAddress, + amount: swap.toAmount, + tokenStandard: swap.toTokenStandard, + tokenDecimals: swap.toDecimals, + tokenSymbol: swap.toSymbol, + ); + + factory HtlcCard.fromHtlcInfo({ + required String title, + required HtlcInfo htlc, + required Token token, + }) => + HtlcCard( + title: title, + sender: htlc.timeLocked.toString(), + htlcId: htlc.id.toString(), + hashLock: FormatUtils.encodeHexString(htlc.hashLock), + expirationTime: htlc.expirationTime, + recipient: htlc.hashLocked.toString(), + amount: htlc.amount, + tokenStandard: token.tokenStandard.toString(), + tokenDecimals: token.decimals, + tokenSymbol: token.symbol, + ); + + @override + State createState() => _HtlcCardState(); +} + +class _HtlcCardState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _animationController; + + final Duration _animationDuration = const Duration(milliseconds: 100); + final Cubic _animationCurve = Curves.easeInOut; + + bool _areDetailsExpanded = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: _animationDuration, + vsync: this, + ); + } + + @override + Widget build(BuildContext context) { + return AnimatedSize( + duration: _animationDuration, + curve: _animationCurve, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + child: widget.htlcId == null ? _getWaitingBody() : _getWidgetBody(), + ), + ); + } + + Widget _getWaitingBody() { + return const SizedBox( + height: 94.0, + child: LoadingInfoText( + text: 'Waiting for the counterparty to join the swap.', + ), + ); + } + + Widget _getWidgetBody() { + return Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.title, + style: + const TextStyle(fontSize: 14.0, color: AppColors.subtitleColor), + ), + const SizedBox( + height: 10, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Row( + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 280), + child: Text( + widget.amount!.addDecimals(widget.tokenDecimals!), + style: const TextStyle(fontSize: 18.0), + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + ), + Container( + constraints: const BoxConstraints(maxWidth: 150), + child: Text( + ' ${widget.tokenSymbol!}', + style: const TextStyle(fontSize: 18.0), + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + ), + const SizedBox( + width: 8.0, + ), + Container( + height: 6.0, + width: 6.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorUtils.getTokenColor( + TokenStandard.parse(widget.tokenStandard!)), + ), + ), + ], + ), + _getDetailsButton() + ], + ), + _getDetailsSection(), + ], + ), + ); + } + + Widget _getDetailsButton() { + return InkWell( + onTap: () { + setState(() { + _areDetailsExpanded = !_areDetailsExpanded; + _areDetailsExpanded + ? _animationController.forward() + : _animationController.reverse(); + }); + }, + child: RotationTransition( + turns: Tween(begin: 0.0, end: 0.5).animate(_animationController), + child: const Icon(Icons.keyboard_arrow_down, size: 22.0), + ), + ); + } + + Widget _getDetailsSection() { + return AnimatedSize( + duration: _animationDuration, + curve: _animationCurve, + child: Visibility( + visible: _areDetailsExpanded, + child: Column( + children: [ + const SizedBox(height: 20.0), + Divider(color: Colors.white.withOpacity(0.1)), + const SizedBox(height: 20.0), + _getDetailsList(), + ], + ), + ), + ); + } + + Widget _getDetailsList() { + final List children = []; + final htlcId = Hash.parse(widget.htlcId!); + final hashLock = Hash.parse(widget.hashLock!); + children.add(_getExpirationRow(widget.expirationTime!)); + children.add( + DetailRow( + label: 'Deposit ID', + value: htlcId.toString(), + valueToShow: htlcId.toShortString()), + ); + children.add( + DetailRow( + label: 'Token standard', + value: widget.tokenStandard!, + prefixWidget: _getTokenStandardTooltip(widget.tokenStandard ?? ''), + ), + ); + children.add( + DetailRow( + label: 'Sender', + value: widget.sender, + valueToShow: ZenonAddressUtils.getLabel(widget.sender)), + ); + children.add( + DetailRow( + label: 'Recipient', + value: widget.recipient!, + valueToShow: ZenonAddressUtils.getLabel(widget.recipient!)), + ); + children.add( + DetailRow( + label: 'Hashlock', + value: hashLock.toString(), + valueToShow: hashLock.toShortString()), + ); + return Column( + children: children.zip( + List.generate( + children.length - 1, + (index) => const SizedBox( + height: 15.0, + ), + ), + ), + ); + } + + Widget? _getTokenStandardTooltip(String tokenStandard) { + var message = 'This token is not in your favorites.'; + var icon = Icons.help; + var iconColor = AppColors.errorColor; + if ([znnTokenStandard, qsrTokenStandard].contains(tokenStandard)) { + message = 'This token is verified.'; + icon = Icons.check_circle_outline; + iconColor = AppColors.znnColor; + } else if (Hive.box(kFavoriteTokensBox).values.contains(tokenStandard)) { + message = 'This token is in your favorites.'; + icon = Icons.star; + iconColor = AppColors.znnColor; + } else {} + return Tooltip( + message: message, + child: Padding( + padding: const EdgeInsets.only(top: 1.0), + child: Icon( + icon, + color: iconColor, + size: 14.0, + ), + ), + ); + } + + Widget _getExpirationRow(int expirationTime) { + final duration = + Duration(seconds: expirationTime - DateTimeUtils.unixTimeNow); + if (duration.isNegative) { + return const DetailRow( + label: 'Expires in', value: 'Expired', canBeCopied: false); + } + return TweenAnimationBuilder( + duration: duration, + tween: Tween(begin: duration, end: Duration.zero), + builder: (_, Duration d, __) { + return DetailRow( + label: 'Expires in', + value: d.toString().split('.').first, + canBeCopied: false); + }, + ); + } +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/htlc_swap_details_widget.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/htlc_swap_details_widget.dart new file mode 100644 index 00000000..8a5cc11c --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/htlc_swap_details_widget.dart @@ -0,0 +1,164 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/htlc_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/address_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/extensions.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/detail_row.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class HtlcSwapDetailsWidget extends StatefulWidget { + final HtlcSwap swap; + + const HtlcSwapDetailsWidget({ + required this.swap, + Key? key, + }) : super(key: key); + + @override + State createState() => _HtlcSwapDetailsWidgetState(); +} + +class _HtlcSwapDetailsWidgetState extends State + with SingleTickerProviderStateMixin { + final Duration _animationDuration = const Duration(milliseconds: 100); + + late final AnimationController _animationController; + + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _animationController = AnimationController( + duration: _animationDuration, + vsync: this, + ); + } + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + InkWell( + borderRadius: BorderRadius.circular(4), + hoverColor: Colors.transparent, + onTap: () { + setState(() { + _isExpanded = !_isExpanded; + _isExpanded + ? _animationController.forward() + : _animationController.reverse(); + }); + }, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + _isExpanded ? 'Hide details' : 'Show details', + style: const TextStyle( + fontSize: 14.0, + color: AppColors.subtitleColor, + ), + ), + const SizedBox( + width: 3.0, + ), + RotationTransition( + turns: + Tween(begin: 0.0, end: 0.5).animate(_animationController), + child: const Icon(Icons.keyboard_arrow_down, size: 18.0), + ), + ], + ), + ), + ), + AnimatedSize( + duration: _animationDuration, + curve: Curves.easeInOut, + child: Visibility( + visible: _isExpanded, + child: Column( + children: [ + const SizedBox(height: 20.0), + Divider(color: Colors.white.withOpacity(0.1)), + const SizedBox(height: 20.0), + _getDetailsList(widget.swap) + ], + ), + ), + ), + ], + ); + } + + Widget _getDetailsList(HtlcSwap swap) { + final List children = []; + final yourDepositId = swap.direction == P2pSwapDirection.outgoing + ? swap.initialHtlcId + : swap.counterHtlcId!; + final counterpartyDepositId = swap.direction == P2pSwapDirection.incoming + ? swap.initialHtlcId + : swap.counterHtlcId; + children.add( + DetailRow( + label: 'Your address', + value: swap.selfAddress, + valueToShow: ZenonAddressUtils.getLabel(swap.selfAddress), + ), + ); + children.add( + DetailRow( + label: 'Counterparty address', + value: swap.counterpartyAddress, + valueToShow: ZenonAddressUtils.getLabel(swap.counterpartyAddress), + ), + ); + children.add( + DetailRow( + label: 'Your deposit ID', + value: yourDepositId, + valueToShow: Hash.parse(yourDepositId).toShortString(), + ), + ); + if (counterpartyDepositId != null) { + children.add( + DetailRow( + label: 'Counterparty deposit ID', + value: counterpartyDepositId, + valueToShow: Hash.parse(counterpartyDepositId).toShortString(), + ), + ); + } + children.add( + DetailRow( + label: 'Hashlock', + value: swap.hashLock, + valueToShow: Hash.parse(swap.hashLock).toShortString()), + ); + if (swap.preimage != null) { + children.add( + DetailRow( + label: 'Swap secret', + value: swap.preimage!, + valueToShow: Hash.parse(swap.preimage!).toShortString(), + ), + ); + } + + return Column( + children: children.zip( + List.generate( + children.length - 1, + (index) => const SizedBox( + height: 15.0, + ), + ), + ), + ); + } +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/modals/join_native_swap_modal.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/modals/join_native_swap_modal.dart new file mode 100644 index 00000000..b6cd633c --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/modals/join_native_swap_modal.dart @@ -0,0 +1,438 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:flutter_vector_icons/flutter_vector_icons.dart'; +import 'package:stacked/stacked.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/dashboard/balance_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/htlc_swap/initial_htlc_for_swap_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/htlc_swap/join_htlc_swap_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/htlc_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/clipboard_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/constants.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/date_time_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/extensions.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/input_validators.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/toast_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/zts_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/htlc_card.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/bullet_point_card.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/buttons/instruction_button.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/error_widget.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/exchange_rate_widget.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/important_text_container.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/input_fields/input_fields.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/loading_widget.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/modals/base_modal.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class JoinNativeSwapModal extends StatefulWidget { + final Function(String) onJoinedSwap; + + const JoinNativeSwapModal({ + required this.onJoinedSwap, + Key? key, + }) : super(key: key); + + @override + State createState() => _JoinNativeSwapModalState(); +} + +class _JoinNativeSwapModalState extends State { + final TextEditingController _addressController = TextEditingController(); + final TextEditingController _amountController = TextEditingController(); + final TextEditingController _depositIdController = TextEditingController(); + + late String _selfAddress; + + HtlcInfo? _initialHltc; + String? _initialHtlcError; + int? _safeExpirationTime; + StreamSubscription? _safeExpirationSubscription; + + Token _selectedToken = kZnnCoin; + bool _isAmountValid = false; + bool _isLoading = false; + + @override + void initState() { + super.initState(); + sl.get().getBalanceForAllAddresses(); + _safeExpirationSubscription = + Stream.periodic(const Duration(seconds: 5)).listen((_) { + if (_initialHltc != null) { + _safeExpirationTime = + _calculateSafeExpirationTime(_initialHltc!.expirationTime); + setState(() {}); + } + }); + } + + @override + void dispose() { + _amountController.dispose(); + _safeExpirationSubscription?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BaseModal( + title: 'Join swap', + child: _initialHltc == null + ? _getSearchView() + : FutureBuilder( + future: + zenon!.embedded.token.getByZts(_initialHltc!.tokenStandard), + builder: (_, snapshot) { + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.all(20.0), + child: SyriusErrorWidget(snapshot.error!), + ); + } else if (snapshot.hasData) { + return _getContent(snapshot.data!); + } + return const Padding( + padding: EdgeInsets.all(50.0), + child: SyriusLoadingWidget(), + ); + }, + ), + ); + } + + Widget _getSearchView() { + return Column( + children: [ + const SizedBox( + height: 20.0, + ), + Form( + autovalidateMode: AutovalidateMode.onUserInteraction, + child: InputField( + onChanged: (value) { + setState(() {}); + }, + validator: (value) => InputValidators.checkHash(value), + controller: _depositIdController, + suffixIcon: RawMaterialButton( + shape: const CircleBorder(), + onPressed: () => ClipboardUtils.pasteToClipboard( + context, + (String value) { + _depositIdController.text = value; + setState(() {}); + }, + ), + child: const Icon( + Icons.content_paste, + color: AppColors.darkHintTextColor, + size: 15.0, + ), + ), + suffixIconConstraints: const BoxConstraints( + maxWidth: 45.0, + maxHeight: 20.0, + ), + hintText: 'Deposit ID provided by the counterparty', + contentLeftPadding: 10.0, + ), + ), + const SizedBox( + height: 25.0, + ), + Visibility( + visible: _initialHtlcError != null, + child: Column( + children: [ + ImportantTextContainer( + text: _initialHtlcError ?? '', + showBorder: true, + ), + const SizedBox( + height: 20.0, + ), + ], + ), + ), + _getInitialHtlcViewModel(), + ], + ); + } + + _getInitialHtlcViewModel() { + return ViewModelBuilder.reactive( + onViewModelReady: (model) { + model.stream.listen( + (event) async { + if (event is HtlcInfo) { + _initialHltc = event; + _isLoading = false; + _addressController.text = event.hashLocked.toString(); + _selfAddress = event.hashLocked.toString(); + _safeExpirationTime = + _calculateSafeExpirationTime(event.expirationTime); + _initialHtlcError = null; + setState(() {}); + } + }, + onError: (error) { + setState(() { + _initialHtlcError = error.toString(); + _isLoading = false; + }); + }, + ); + }, + builder: (_, model, __) => _getContinueButton(model), + viewModelBuilder: () => InitialHtlcForSwapBloc(), + ); + } + + Widget _getContinueButton(InitialHtlcForSwapBloc model) { + return InstructionButton( + text: 'Continue', + loadingText: 'Searching', + instructionText: 'Input the deposit ID', + isEnabled: _isHashValid(), + isLoading: _isLoading, + onPressed: () => _onContinueButtonPressed(model), + ); + } + + void _onContinueButtonPressed(InitialHtlcForSwapBloc model) async { + setState(() { + _isLoading = true; + _initialHtlcError = null; + }); + model.getInitialHtlc(Hash.parse(_depositIdController.text)); + } + + Widget _getContent(Token tokenToReceive) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 20.0), + Row( + children: [ + Expanded( + child: LabeledInputContainer( + labelText: 'Your address', + helpText: 'You will receive the swapped funds to this address.', + inputWidget: DisabledAddressField( + _addressController, + contentLeftPadding: 10.0, + ), + ), + ), + ], + ), + const SizedBox(height: 20.0), + Divider(color: Colors.white.withOpacity(0.1)), + const SizedBox(height: 20.0), + LabeledInputContainer( + labelText: 'You are sending', + inputWidget: Flexible( + child: StreamBuilder?>( + stream: sl.get().stream, + builder: (_, snapshot) { + if (snapshot.hasError) { + return SyriusErrorWidget(snapshot.error!); + } + if (snapshot.connectionState == ConnectionState.active) { + if (snapshot.hasData) { + return AmountInputField( + controller: _amountController, + accountInfo: (snapshot.data![_selfAddress]!), + valuePadding: 10.0, + textColor: Theme.of(context).colorScheme.inverseSurface, + initialToken: _selectedToken, + hintText: '0.0', + onChanged: (token, isValid) { + setState(() { + _selectedToken = token; + _isAmountValid = isValid; + }); + }, + ); + } else { + return const SyriusLoadingWidget(); + } + } else { + return const SyriusLoadingWidget(); + } + }, + ), + ), + ), + kVerticalSpacing, + const Icon( + AntDesign.arrowdown, + color: Colors.white, + size: 20, + ), + kVerticalSpacing, + HtlcCard.fromHtlcInfo( + title: 'You are receiving', + htlc: _initialHltc!, + token: tokenToReceive, + ), + const SizedBox(height: 20.0), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Exchange Rate', + style: + TextStyle(fontSize: 14.0, color: AppColors.subtitleColor), + ), + _getExchangeRateWidget(tokenToReceive), + ], + ), + ), + const SizedBox(height: 20.0), + Divider(color: Colors.white.withOpacity(0.1)), + if (_safeExpirationTime != null) const SizedBox(height: 20.0), + if (_safeExpirationTime != null) + BulletPointCard( + bulletPoints: [ + RichText( + text: BulletPointCard.textSpan( + 'You have ', + children: [ + TextSpan( + text: + '${(((_initialHltc!.expirationTime - kMinSafeTimeToFindPreimage.inSeconds - kCounterHtlcDuration.inSeconds) - DateTimeUtils.unixTimeNow) / 60).ceil()} minutes', + style: const TextStyle( + fontSize: 14.0, color: Colors.white)), + BulletPointCard.textSpan(' left to join the swap.'), + ], + ), + ), + RichText( + text: BulletPointCard.textSpan( + 'The counterparty will have ', + children: [ + TextSpan( + text: '~${(kCounterHtlcDuration).inHours} hour', + style: const TextStyle( + fontSize: 14.0, color: Colors.white)), + BulletPointCard.textSpan(' to complete the swap.'), + ], + ), + ), + RichText( + text: BulletPointCard.textSpan( + 'You can reclaim your funds if the counterparty fails to complete the swap. ', + ), + ), + ], + ), + const SizedBox(height: 20.0), + _safeExpirationTime != null + ? Column(children: [ + Visibility( + visible: + !isTrustedToken(tokenToReceive.tokenStandard.toString()), + child: Padding( + padding: const EdgeInsets.only(bottom: 20.0), + child: ImportantTextContainer( + text: + '''You are receiving a token that is not in your favorites. ''' + '''Please verify that the token standard is correct: ${tokenToReceive.tokenStandard.toString()}''', + isSelectable: true, + ), + ), + ), + _getJoinSwapViewModel(tokenToReceive), + ]) + : const ImportantTextContainer( + text: + 'Cannot join swap. The swap will expire too soon for a safe swap.', + showBorder: true, + ) + ], + ); + } + + _getJoinSwapViewModel(Token tokenToReceive) { + return ViewModelBuilder.reactive( + onViewModelReady: (model) { + model.stream.listen( + (event) async { + if (event is HtlcSwap) { + widget.onJoinedSwap.call(event.id.toString()); + } + }, + onError: (error) { + setState(() { + _isLoading = false; + }); + ToastUtils.showToast(context, error.toString()); + }, + ); + }, + builder: (_, model, __) => _getJoinSwapButton(model, tokenToReceive), + viewModelBuilder: () => JoinHtlcSwapBloc(), + ); + } + + Widget _getJoinSwapButton(JoinHtlcSwapBloc model, Token tokenToReceive) { + return InstructionButton( + text: 'Join swap', + instructionText: 'Input an amount to send', + loadingText: 'Sending transaction', + isEnabled: _isInputValid(), + isLoading: _isLoading, + onPressed: () => _onJoinButtonPressed(model, tokenToReceive), + ); + } + + void _onJoinButtonPressed( + JoinHtlcSwapBloc model, Token tokenToReceive) async { + setState(() { + _isLoading = true; + }); + model.joinHtlcSwap( + initialHtlc: _initialHltc!, + fromToken: _selectedToken, + toToken: tokenToReceive, + fromAmount: + _amountController.text.extractDecimals(_selectedToken.decimals), + swapType: P2pSwapType.native, + fromChain: P2pSwapChain.nom, + toChain: P2pSwapChain.nom, + counterHtlcExpirationTime: _safeExpirationTime!); + } + + int? _calculateSafeExpirationTime(int initialHtlcExpiration) { + final minNeededRemainingTime = + kMinSafeTimeToFindPreimage + kCounterHtlcDuration; + final now = DateTimeUtils.unixTimeNow; + final remaining = Duration(seconds: initialHtlcExpiration - now); + return remaining >= minNeededRemainingTime + ? now + kCounterHtlcDuration.inSeconds + : null; + } + + Widget _getExchangeRateWidget(Token tokenToReceive) { + return ExchangeRateWidget( + fromAmount: + _amountController.text.extractDecimals(_selectedToken.decimals), + fromDecimals: _selectedToken.decimals, + fromSymbol: _selectedToken.symbol, + toAmount: _initialHltc!.amount, + toDecimals: tokenToReceive.decimals, + toSymbol: tokenToReceive.symbol); + } + + bool _isInputValid() => _isAmountValid; + + bool _isHashValid() => + InputValidators.checkHash(_depositIdController.text) == null; +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/modals/native_p2p_swap_modal.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/modals/native_p2p_swap_modal.dart new file mode 100644 index 00000000..fb9cd5fd --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/modals/native_p2p_swap_modal.dart @@ -0,0 +1,615 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:flutter_vector_icons/flutter_vector_icons.dart'; +import 'package:stacked/stacked.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/htlc_swap/reclaim_htlc_swap_funds_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/htlc_swap/complete_htlc_swap_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/htlc_swap/htlc_swap_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/htlc_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/htlc_card.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/htlc_swap_details_widget.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/buttons/elevated_button.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/buttons/instruction_button.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/error_widget.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/exchange_rate_widget.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/important_text_container.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/loading_info_text.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/loading_widget.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/modals/base_modal.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class NativeP2pSwapModal extends StatefulWidget { + final String swapId; + final Function(String)? onSwapStarted; + + const NativeP2pSwapModal({ + required this.swapId, + this.onSwapStarted, + Key? key, + }) : super(key: key); + + @override + State createState() => _NativeP2pSwapModalState(); +} + +class _NativeP2pSwapModalState extends State { + late final HtlcSwapBloc _htlcSwapBloc; + + String _swapCompletedText = 'Swap completed.'; + + bool _isSendingTransaction = false; + bool _shouldShowIncorrectAmountInstructions = false; + + @override + void initState() { + super.initState(); + _htlcSwapBloc = HtlcSwapBloc(widget.swapId); + _htlcSwapBloc.getDataPeriodically(); + } + + @override + void dispose() { + _htlcSwapBloc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return StreamBuilder( + stream: _htlcSwapBloc.stream, + builder: (_, snapshot) { + if (snapshot.hasData) { + return BaseModal( + title: _getTitle(snapshot.data!), + child: _getContent(snapshot.data!), + ); + } else if (snapshot.hasError) { + return BaseModal( + title: '', + child: Padding( + padding: const EdgeInsets.all(20.0), + child: SyriusErrorWidget(snapshot.error!), + ), + ); + } + return const SyriusLoadingWidget(); + }, + ); + } + + String _getTitle(HtlcSwap swap) { + return swap.state == P2pSwapState.active ? 'Active swap' : ''; + } + + Widget _getContent(HtlcSwap swap) { + switch (swap.state) { + case P2pSwapState.pending: + return _getPendingView(); + case P2pSwapState.active: + return _getActiveView(swap); + case P2pSwapState.completed: + return _getCompletedView(swap); + case P2pSwapState.reclaimable: + case P2pSwapState.unsuccessful: + return _getUnsuccessfulView(swap); + default: + return Container(); + } + } + + Widget _getPendingView() { + return const SizedBox( + height: 215.0, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Starting swap. This will take a moment.', + style: TextStyle( + fontSize: 16.0, + ), + ), + SizedBox(height: 25.0), + SyriusLoadingWidget() + ], + ), + ); + } + + Widget _getActiveView(HtlcSwap swap) { + return Column( + children: [ + const SizedBox( + height: 20.0, + ), + HtlcCard.sending(swap: swap), + const SizedBox( + height: 15.0, + ), + const Icon( + AntDesign.arrowdown, + color: Colors.white, + size: 20, + ), + const SizedBox( + height: 15.0, + ), + HtlcCard.receiving(swap: swap), + const SizedBox( + height: 25, + ), + _getBottomSection(swap), + ], + ); + } + + Widget _getCompletedView(HtlcSwap swap) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + height: 10.0, + ), + Container( + width: 72.0, + height: 72.0, + color: Colors.transparent, + child: SvgPicture.asset( + 'assets/svg/ic_completed_symbol.svg', + colorFilter: + const ColorFilter.mode(AppColors.znnColor, BlendMode.srcIn), + ), + ), + const SizedBox( + height: 30.0, + ), + Text( + _swapCompletedText, + style: const TextStyle( + fontSize: 16.0, + ), + ), + const SizedBox(height: 25.0), + Container( + decoration: const BoxDecoration( + color: Color(0xff282828), + borderRadius: BorderRadius.all(Radius.circular(8.0))), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'From', + style: TextStyle( + fontSize: 14.0, color: AppColors.subtitleColor), + ), + _getAmountAndSymbolWidget( + swap.fromAmount.addDecimals(swap.fromDecimals), + swap.fromSymbol), + ], + ), + const SizedBox( + height: 15.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'To', + style: TextStyle( + fontSize: 14.0, color: AppColors.subtitleColor), + ), + _getAmountAndSymbolWidget( + swap.toAmount!.addDecimals(swap.toDecimals!), + swap.toSymbol!), + ], + ), + const SizedBox( + height: 15.0, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Exchange Rate', + style: TextStyle( + fontSize: 14.0, color: AppColors.subtitleColor), + ), + _getExchangeRateWidget(swap), + ], + ), + ], + ), + ), + ), + const SizedBox( + height: 20, + ), + HtlcSwapDetailsWidget(swap: swap), + ], + ); + } + + Widget _getUnsuccessfulView(HtlcSwap swap) { + final expiration = swap.direction == P2pSwapDirection.outgoing + ? swap.initialHtlcExpirationTime + : swap.counterHtlcExpirationTime; + final remainingDuration = + Duration(seconds: (expiration ?? 0) - DateTimeUtils.unixTimeNow); + final isReclaimable = remainingDuration.inSeconds <= 0 && + swap.state == P2pSwapState.reclaimable; + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + height: 10.0, + ), + Container( + width: 72.0, + height: 72.0, + color: Colors.transparent, + child: SvgPicture.asset( + 'assets/svg/ic_unsuccessful_symbol.svg', + colorFilter: + const ColorFilter.mode(AppColors.errorColor, BlendMode.srcIn), + ), + ), + const SizedBox( + height: 30.0, + ), + Text( + isReclaimable || swap.state == P2pSwapState.unsuccessful + ? 'The swap was unsuccessful.' + : 'The swap was unsuccessful.\nPlease wait for your deposit to expire to reclaim your funds.', + style: const TextStyle( + fontSize: 16.0, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 25.0), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(8.0))), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + children: [ + if (remainingDuration.inSeconds > 0) + TweenAnimationBuilder( + duration: remainingDuration, + tween: Tween(begin: remainingDuration, end: Duration.zero), + onEnd: () => setState(() {}), + builder: (_, Duration d, __) { + return Visibility( + visible: d.inSeconds > 0, + child: Padding( + padding: const EdgeInsets.only(bottom: 15.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Deposit expires in', + style: TextStyle( + fontSize: 14.0, + color: AppColors.subtitleColor), + ), + Text( + d.toString().split('.').first, + style: const TextStyle( + fontSize: 14.0, + color: AppColors.subtitleColor), + ), + ], + ), + ), + ); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + swap.state == P2pSwapState.reclaimable + ? 'Deposited amount' + : 'Deposited amount (reclaimed)', + style: const TextStyle( + fontSize: 14.0, color: AppColors.subtitleColor), + ), + _getAmountAndSymbolWidget( + swap.fromAmount.addDecimals(swap.fromDecimals), + swap.fromSymbol), + ], + ), + ], + ), + ), + ), + if (isReclaimable) + Padding( + padding: const EdgeInsets.fromLTRB(0.0, 20.0, 0.0, 5.0), + child: _getReclaimButton(swap), + ), + const SizedBox( + height: 25, + ), + HtlcSwapDetailsWidget(swap: swap), + ], + ); + } + + Widget _getBottomSection(HtlcSwap swap) { + if (swap.counterHtlcId == null) { + return Column( + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 10.0), + child: Text( + 'Send your deposit ID to the counterparty via a messaging service so that they can join the swap.', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16.0, + ), + ), + ), + const SizedBox( + height: 25, + ), + SyriusElevatedButton( + text: 'Copy deposit ID', + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF333333), + ), + onPressed: () => + ClipboardUtils.copyToClipboard(swap.initialHtlcId, context), + icon: const Icon( + Icons.copy, + color: Colors.white, + size: 18.0, + ), + ) + ], + ); + } else { + return Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + const Text( + 'Exchange Rate', + style: TextStyle( + fontSize: 14.0, + color: AppColors.subtitleColor, + ), + ), + _getExchangeRateWidget(swap), + ], + ), + ), + const SizedBox( + height: 25, + ), + Visibility( + visible: swap.direction == P2pSwapDirection.outgoing, + child: Column( + children: [ + Visibility( + visible: !isTrustedToken(swap.toTokenStandard ?? ''), + child: Padding( + padding: const EdgeInsets.only(bottom: 25.0), + child: ImportantTextContainer( + text: + '''You are receiving a token that is not in your favorites. ''' + '''Please verify that the token standard is correct: ${swap.toTokenStandard ?? ''}''', + isSelectable: true, + ), + ), + ), + _getExpirationWarningForOutgoingSwap(swap), + _getSwapButtonViewModel(swap), + const SizedBox( + height: 25, + ), + _getIncorrectAmountButton(swap), + ], + ), + ), + Visibility( + visible: swap.direction == P2pSwapDirection.incoming, + child: const Padding( + padding: EdgeInsets.symmetric(vertical: 15.0), + child: LoadingInfoText( + text: + 'Waiting for the counterparty. Please keep Syrius running.', + tooltipText: + 'Your wallet will not be auto-locked while the swap is in progress.', + ), + ), + ), + ], + ); + } + } + + Widget _getExpirationWarningForOutgoingSwap(HtlcSwap swap) { + const warningThreshold = Duration(minutes: 10); + final timeToCompleteSwap = Duration( + seconds: + swap.counterHtlcExpirationTime! - DateTimeUtils.unixTimeNow) - + kMinSafeTimeToCompleteSwap; + return TweenAnimationBuilder( + duration: timeToCompleteSwap, + tween: Tween(begin: timeToCompleteSwap, end: Duration.zero), + onEnd: () => setState(() {}), + builder: (_, Duration d, __) { + return Visibility( + visible: timeToCompleteSwap <= warningThreshold, + child: Padding( + padding: const EdgeInsets.only(bottom: 25.0), + child: ImportantTextContainer( + text: 'The swap will expire in ${d.toString().split('.').first}', + ), + ), + ); + }, + ); + } + + Widget _getSwapButtonViewModel(HtlcSwap swap) { + return ViewModelBuilder.reactive( + onViewModelReady: (model) { + model.stream.listen( + (event) async { + if (event is HtlcSwap) { + setState(() { + _swapCompletedText = + 'Swap completed. You will receive the funds shortly.'; + }); + } + }, + onError: (error) { + setState(() { + _isSendingTransaction = false; + }); + ToastUtils.showToast(context, error.toString()); + }, + ); + }, + builder: (_, model, __) => InstructionButton( + text: 'Swap', + isEnabled: true, + isLoading: _isSendingTransaction, + loadingText: 'Swapping', + onPressed: () { + setState(() { + _isSendingTransaction = true; + }); + model.completeHtlcSwap(swap: swap); + }, + ), + viewModelBuilder: () => CompleteHtlcSwapBloc(), + ); + } + + Widget _getIncorrectAmountButton(HtlcSwap swap) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 10.0), + child: SizedBox( + width: double.infinity, + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 50), + firstCurve: Curves.easeInOut, + firstChild: InkWell( + onTap: () => setState(() { + _shouldShowIncorrectAmountInstructions = true; + }), + child: const Center( + child: Text( + 'I\'m receiving the wrong token or amount.', + style: TextStyle( + fontSize: 14.0, + color: AppColors.subtitleColor, + ), + ), + ), + ), + secondChild: + _getIncorrectAmountInstructions(swap.initialHtlcExpirationTime), + crossFadeState: _shouldShowIncorrectAmountInstructions + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + ), + ), + ); + } + + Widget _getIncorrectAmountInstructions(int expirationTime) { + return Text( + 'If the token or the amount you are receiving is not what you have agreed upon, wait until your deposit expires to reclaim your funds.\nYour deposit will expire at ${FormatUtils.formatDate(expirationTime * 1000, dateFormat: kDefaultDateTimeFormat)}.', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14.0, + ), + ); + } + + Widget _getReclaimButton(HtlcSwap swap) { + return ViewModelBuilder.reactive( + onViewModelReady: (model) { + model.stream.listen( + null, + onError: (error) { + setState(() { + _isSendingTransaction = false; + }); + }, + ); + }, + builder: (_, model, __) => InstructionButton( + text: 'Reclaim funds', + isEnabled: true, + isLoading: _isSendingTransaction, + loadingText: 'Reclaiming. This will take a moment.', + onPressed: () { + setState(() { + _isSendingTransaction = true; + }); + model.reclaimFunds( + htlcId: swap.direction == P2pSwapDirection.outgoing + ? Hash.parse(swap.initialHtlcId) + : Hash.parse(swap.counterHtlcId!), + selfAddress: Address.parse(swap.selfAddress), + ); + }, + ), + viewModelBuilder: () => ReclaimHtlcSwapFundsBloc(), + ); + } + + Widget _getExchangeRateWidget(HtlcSwap swap) { + return ExchangeRateWidget( + fromAmount: swap.fromAmount, + fromDecimals: swap.fromDecimals, + fromSymbol: swap.fromSymbol, + toAmount: swap.toAmount!, + toDecimals: swap.toDecimals!, + toSymbol: swap.toSymbol!); + } + + Widget _getAmountAndSymbolWidget(String amount, String symbol) { + return Row( + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 150), + child: Text( + amount, + style: + const TextStyle(fontSize: 14.0, color: AppColors.subtitleColor), + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + ), + Container( + constraints: const BoxConstraints(maxWidth: 100), + child: Text( + ' $symbol', + style: + const TextStyle(fontSize: 14.0, color: AppColors.subtitleColor), + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false, + ), + ), + ], + ); + } +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/modals/p2p_swap_warning_modal.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/modals/p2p_swap_warning_modal.dart new file mode 100644 index 00000000..ccf3e562 --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/modals/p2p_swap_warning_modal.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/modals/base_modal.dart'; + +class P2PSwapWarningModal extends StatefulWidget { + final Function() onAccepted; + + const P2PSwapWarningModal({ + required this.onAccepted, + Key? key, + }) : super(key: key); + + @override + State createState() => _P2PSwapWarningModalState(); +} + +class _P2PSwapWarningModalState extends State { + @override + Widget build(BuildContext context) { + return BaseModal( + title: 'Before continuing', + child: _getContent(), + ); + } + + Widget _getContent() { + return Column( + children: [ + const SizedBox( + height: 20.0, + ), + const Text( + '''Please note that the P2P swap is an experimental feature and may result in funds being lost.\n\n''' + '''Use the feature with caution and consider splitting large swaps into multiple smaller ones.''', + style: TextStyle( + fontSize: 14.0, + ), + ), + const SizedBox( + height: 30.0, + ), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: () => widget.onAccepted.call(), + child: Text( + 'Continue', + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/modals/recover_deposit_modal.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/modals/recover_deposit_modal.dart new file mode 100644 index 00000000..bb584119 --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/modals/recover_deposit_modal.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; +import 'package:stacked/stacked.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/dashboard/balance_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/htlc_swap/recover_htlc_swap_funds_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/buttons/instruction_button.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/important_text_container.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/input_fields/input_fields.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/modals/base_modal.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class RecoverDepositModal extends StatefulWidget { + const RecoverDepositModal({ + Key? key, + }) : super(key: key); + + @override + State createState() => _RecoverDepositModalState(); +} + +class _RecoverDepositModalState extends State { + final TextEditingController _depositIdController = TextEditingController(); + + String? _errorText; + + bool _isLoading = false; + bool _isPendingFunds = false; + + @override + void initState() { + super.initState(); + sl.get().getBalanceForAllAddresses(); + } + + @override + void dispose() { + _depositIdController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BaseModal( + title: _getTitle(), + child: _getContent(), + ); + } + + String _getTitle() { + return _isPendingFunds ? '' : 'Recover deposit'; + } + + Widget _getContent() { + return _isPendingFunds ? _getPendingFundsView() : _getSearchView(); + } + + Widget _getPendingFundsView() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox( + height: 10.0, + ), + Container( + width: 72.0, + height: 72.0, + color: Colors.transparent, + child: SvgPicture.asset( + 'assets/svg/ic_completed_symbol.svg', + colorFilter: + const ColorFilter.mode(AppColors.znnColor, BlendMode.srcIn), + ), + ), + const SizedBox( + height: 30.0, + ), + const Text( + 'Recovery transaction sent. You will receive the funds shortly.', + style: TextStyle( + fontSize: 16.0, + ), + ), + const SizedBox( + height: 30.0, + ), + ], + ); + } + + Widget _getSearchView() { + return Column( + children: [ + const SizedBox( + height: 20.0, + ), + const Text( + 'If you have lost access to the machine that a swap was started on, the deposited funds can be recovered with the deposit ID.\n\nIf you don\'t have the deposit ID, please refer to the swap tutorial for instructions on how to recover it using a block explorer.', + style: TextStyle( + fontSize: 14.0, + ), + ), + const SizedBox( + height: 20.0, + ), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + child: const Row( + children: [ + Text( + 'View swap tutorial', + style: TextStyle( + color: AppColors.subtitleColor, + fontSize: 14.0, + ), + ), + SizedBox( + width: 3.0, + ), + Icon( + Icons.open_in_new, + size: 18.0, + color: AppColors.subtitleColor, + ), + ], + ), + onTap: () => NavigationUtils.openUrl(kP2pSwapTutorialLink), + ), + ), + const SizedBox( + height: 25.0, + ), + Form( + autovalidateMode: AutovalidateMode.onUserInteraction, + child: InputField( + onChanged: (value) { + setState(() {}); + }, + validator: (value) => InputValidators.checkHash(value), + controller: _depositIdController, + suffixIcon: RawMaterialButton( + shape: const CircleBorder(), + onPressed: () => ClipboardUtils.pasteToClipboard( + context, + (String value) { + _depositIdController.text = value; + setState(() {}); + }, + ), + child: const Icon( + Icons.content_paste, + color: AppColors.darkHintTextColor, + size: 15.0, + ), + ), + suffixIconConstraints: const BoxConstraints( + maxWidth: 45.0, + maxHeight: 20.0, + ), + hintText: 'Deposit ID', + contentLeftPadding: 10.0, + ), + ), + const SizedBox( + height: 25.0, + ), + Visibility( + visible: _errorText != null, + child: Column( + children: [ + ImportantTextContainer( + text: _errorText ?? '', + showBorder: true, + ), + const SizedBox( + height: 20.0, + ), + ], + ), + ), + _getRecoverButton(), + ], + ); + } + + Widget _getRecoverButton() { + return ViewModelBuilder.reactive( + onViewModelReady: (model) { + model.stream.listen( + (event) async { + if (event is AccountBlockTemplate) { + setState(() { + _isPendingFunds = true; + }); + } + }, + onError: (error) { + setState(() { + _errorText = error.toString(); + _isLoading = false; + }); + }, + ); + }, + builder: (_, model, __) => InstructionButton( + text: 'Recover deposit', + isEnabled: _isHashValid(), + isLoading: _isLoading, + loadingText: 'Sending transaction', + instructionText: 'Input the deposit ID', + onPressed: () { + setState(() { + _isLoading = true; + _errorText = null; + }); + model.recoverFunds(htlcId: Hash.parse(_depositIdController.text)); + }, + ), + viewModelBuilder: () => RecoverHtlcSwapFundsBloc(), + ); + } + + bool _isHashValid() => + InputValidators.checkHash(_depositIdController.text) == null; +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/modals/start_native_swap_modal.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/modals/start_native_swap_modal.dart new file mode 100644 index 00000000..4d529be6 --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/modals/start_native_swap_modal.dart @@ -0,0 +1,270 @@ +import 'package:flutter/material.dart'; +import 'package:stacked/stacked.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/dashboard/balance_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/htlc_swap/start_htlc_swap_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/htlc_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/clipboard_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/constants.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/extensions.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/global.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/input_validators.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/toast_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/zts_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/bullet_point_card.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/buttons/instruction_button.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/dropdown/addresses_dropdown.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/error_widget.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/input_fields/amount_input_field.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/input_fields/input_field.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/input_fields/labeled_input_container.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/loading_widget.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/modals/base_modal.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class StartNativeSwapModal extends StatefulWidget { + final Function(String) onSwapStarted; + + const StartNativeSwapModal({ + required this.onSwapStarted, + Key? key, + }) : super(key: key); + + @override + State createState() => _StartNativeSwapModalState(); +} + +class _StartNativeSwapModalState extends State { + Token _selectedToken = kZnnCoin; + String? _selectedSelfAddress = kSelectedAddress; + bool _isAmountValid = false; + + final TextEditingController _counterpartyAddressController = + TextEditingController(); + final TextEditingController _amountController = TextEditingController(); + + bool _isLoading = false; + + @override + void initState() { + super.initState(); + sl.get().getBalanceForAllAddresses(); + } + + @override + void dispose() { + _counterpartyAddressController.dispose(); + _amountController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BaseModal( + title: 'Start swap', + child: _getContent(), + ); + } + + Widget _getContent() { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const SizedBox(height: 20.0), + Row( + children: [ + Expanded( + child: LabeledInputContainer( + labelText: 'Your address', + inputWidget: AddressesDropdown( + _selectedSelfAddress, + (address) => setState(() { + _selectedSelfAddress = address; + sl.get().getBalanceForAllAddresses(); + }), + ), + ), + ), + const SizedBox( + width: 20.0, + ), + Expanded( + child: LabeledInputContainer( + labelText: 'Counterparty address', + helpText: 'The address of the trading partner for the swap.', + inputWidget: Form( + autovalidateMode: AutovalidateMode.onUserInteraction, + child: InputField( + onChanged: (value) { + setState(() {}); + }, + enabled: !_isLoading, + validator: (value) => _validateCounterpartyAddress(value), + controller: _counterpartyAddressController, + suffixIcon: RawMaterialButton( + shape: const CircleBorder(), + onPressed: () { + ClipboardUtils.pasteToClipboard(context, + (String value) { + _counterpartyAddressController.text = value; + setState(() {}); + }); + }, + child: const Icon( + Icons.content_paste, + color: AppColors.darkHintTextColor, + size: 15.0, + ), + ), + suffixIconConstraints: const BoxConstraints( + maxWidth: 45.0, + maxHeight: 20.0, + ), + hintText: 'Enter NoM address', + contentLeftPadding: 10.0, + ), + ), + ), + ), + ], + ), + kVerticalSpacing, + LabeledInputContainer( + labelText: 'You are sending', + inputWidget: Flexible( + child: StreamBuilder?>( + stream: sl.get().stream, + builder: (_, snapshot) { + if (snapshot.hasError) { + return SyriusErrorWidget(snapshot.error!); + } + if (snapshot.connectionState == ConnectionState.active) { + if (snapshot.hasData) { + return AmountInputField( + controller: _amountController, + enabled: !_isLoading, + accountInfo: (snapshot.data![_selectedSelfAddress]!), + valuePadding: 10.0, + textColor: Theme.of(context).colorScheme.inverseSurface, + initialToken: _selectedToken, + hintText: '0.0', + onChanged: (token, isValid) { + if (!_isLoading) { + setState(() { + _selectedToken = token; + _isAmountValid = isValid; + }); + } + }, + ); + } else { + return const SyriusLoadingWidget(); + } + } else { + return const SyriusLoadingWidget(); + } + }, + ), + ), + ), + const SizedBox(height: 20.0), + BulletPointCard( + bulletPoints: [ + RichText( + text: BulletPointCard.textSpan( + 'After starting the swap, wait for the counterparty to join the swap with the agreed upon amount.'), + ), + RichText( + text: BulletPointCard.textSpan( + '''You can reclaim your funds in ''', + children: [ + TextSpan( + text: '${kInitialHtlcDuration.inHours} hours', + style: + const TextStyle(fontSize: 14.0, color: Colors.white)), + BulletPointCard.textSpan( + ' if the counterparty fails to join the swap.'), + ], + ), + ), + RichText( + text: BulletPointCard.textSpan( + 'The swap must be completed on this machine.'), + ), + ], + ), + const SizedBox(height: 20.0), + _getStartSwapViewModel(), + ], + ); + } + + _getStartSwapViewModel() { + return ViewModelBuilder.reactive( + onViewModelReady: (model) { + model.stream.listen( + (event) async { + if (event is HtlcSwap) { + widget.onSwapStarted.call(event.id); + } + }, + onError: (error) { + setState(() { + _isLoading = false; + }); + ToastUtils.showToast(context, error.toString()); + }, + ); + }, + builder: (_, model, __) => _getStartSwapButton(model), + viewModelBuilder: () => StartHtlcSwapBloc(), + ); + } + + Widget _getStartSwapButton(StartHtlcSwapBloc model) { + return InstructionButton( + text: 'Start swap', + instructionText: 'Fill in the swap details', + loadingText: 'Sending transaction', + isEnabled: _isInputValid(), + isLoading: _isLoading, + onPressed: () => _onStartButtonPressed(model), + ); + } + + void _onStartButtonPressed(StartHtlcSwapBloc model) async { + setState(() { + _isLoading = true; + }); + model.startHtlcSwap( + selfAddress: Address.parse(_selectedSelfAddress!), + counterpartyAddress: Address.parse(_counterpartyAddressController.text), + fromToken: _selectedToken, + fromAmount: + _amountController.text.extractDecimals(_selectedToken.decimals), + hashType: htlcHashTypeSha3, + swapType: P2pSwapType.native, + fromChain: P2pSwapChain.nom, + toChain: P2pSwapChain.nom, + initialHtlcDuration: kInitialHtlcDuration.inSeconds); + } + + bool _isInputValid() => + _validateCounterpartyAddress(_counterpartyAddressController.text) == + null && + _isAmountValid; + + String? _validateCounterpartyAddress(String? address) { + String? result = InputValidators.checkAddress(address); + if (result != null) { + return result; + } else { + return kDefaultAddressList.contains(address) + ? 'Cannot swap with your own address' + : null; + } + } +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swap_options_button.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swap_options_button.dart new file mode 100644 index 00000000..b5ed14fc --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swap_options_button.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; + +class P2pSwapOptionsButton extends StatefulWidget { + final VoidCallback onClick; + final String primaryText; + final String secondaryText; + + const P2pSwapOptionsButton({ + Key? key, + required this.primaryText, + required this.secondaryText, + required this.onClick, + }) : super(key: key); + + @override + State createState() => _P2pSwapOptionsButtonState(); +} + +class _P2pSwapOptionsButtonState extends State { + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.primaryContainer, + borderRadius: BorderRadius.circular( + 8.0, + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () { + setState(() { + widget.onClick.call(); + }); + }, + child: Container( + padding: const EdgeInsets.all(20.0), + decoration: BoxDecoration( + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.02), + offset: const Offset(0.0, 4), + blurRadius: 6, + spreadRadius: 8.0, + ), + ], + borderRadius: BorderRadius.circular( + 8.0, + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + widget.primaryText, + textAlign: TextAlign.left, + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox( + height: 8.0, + ), + Text( + widget.secondaryText, + textAlign: TextAlign.left, + style: const TextStyle( + color: AppColors.subtitleColor, + fontSize: 14.0, + ), + ), + ], + ), + ), + const SizedBox( + width: 15.0, + ), + const Column( + children: [ + Icon(Icons.keyboard_arrow_right, size: 18.0), + ], + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swap_options_card.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swap_options_card.dart new file mode 100644 index 00000000..1ec9bfa6 --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swap_options_card.dart @@ -0,0 +1,193 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/pow_generating_status_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/modals/join_native_swap_modal.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/modals/native_p2p_swap_modal.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/modals/recover_deposit_modal.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/modals/start_native_swap_modal.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/modals/p2p_swap_warning_modal.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/p2p_swap_options_button.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/dialogs.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/error_widget.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/layout_scaffold/card_scaffold.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class P2pSwapOptionsCard extends StatefulWidget { + const P2pSwapOptionsCard({ + Key? key, + }) : super(key: key); + + @override + State createState() => _P2pSwapOptionsCardState(); +} + +class _P2pSwapOptionsCardState extends State { + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return CardScaffold( + title: 'P2P Swap Options', + description: 'Starting and joining P2P swaps can be done from this card.', + childBuilder: () => _getWidgetBody(context), + customItem: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () { + showCustomDialog( + context: context, + content: const RecoverDepositModal(), + ); + }, + child: Row( + children: [ + const Icon( + Icons.refresh, + color: AppColors.znnColor, + size: 20.0, + ), + const SizedBox( + width: 5.0, + height: 38.0, + ), + Expanded( + child: Text( + 'Recover deposit', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ), + ), + ); + } + + Widget _getWidgetBody(BuildContext context) { + return StreamBuilder( + stream: sl.get().stream, + builder: (_, snapshot) { + if (snapshot.hasError) { + return SyriusErrorWidget(snapshot.error!); + } + final isGeneratingPlasma = _isGeneratingPlasma(snapshot.data); + return Container( + margin: const EdgeInsets.all(20.0), + child: _getNativeOptions(isGeneratingPlasma), + ); + }, + ); + } + + void _showUserWarningModalIfNeeded({required Function() onContinue}) { + final hasReadWarning = sharedPrefsService!.get( + kHasReadP2pSwapWarningKey, + defaultValue: kHasReadP2pSwapWarningDefaultValue, + ); + if (!hasReadWarning) { + showCustomDialog( + context: context, + content: P2PSwapWarningModal(onAccepted: () { + Navigator.pop(context); + sharedPrefsService!.put(kHasReadP2pSwapWarningKey, true); + Timer.run(onContinue); + }), + ); + } else { + onContinue(); + } + } + + void _showNativeSwapModal(String swapId) { + Navigator.pop(context); + Timer.run( + () => showCustomDialog( + context: context, + content: NativeP2pSwapModal( + swapId: swapId, + ), + ), + ); + } + + Column _getNativeOptions(bool isGeneratingPlasma) { + return Column( + children: [ + P2pSwapOptionsButton( + primaryText: 'Start swap', + secondaryText: 'Start a native swap with a counterparty.', + onClick: () => isGeneratingPlasma + ? _showGeneratingPlasmaToast() + : _showUserWarningModalIfNeeded( + onContinue: () => showCustomDialog( + context: context, + content: StartNativeSwapModal( + onSwapStarted: _showNativeSwapModal), + )), + ), + const SizedBox( + height: 25.0, + ), + P2pSwapOptionsButton( + primaryText: 'Join swap', + secondaryText: 'Join a native swap started by a counterparty.', + onClick: () => isGeneratingPlasma + ? _showGeneratingPlasmaToast() + : _showUserWarningModalIfNeeded( + onContinue: () => showCustomDialog( + context: context, + content: + JoinNativeSwapModal(onJoinedSwap: _showNativeSwapModal), + ), + ), + ), + const SizedBox( + height: 40.0, + ), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + child: const Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'View swap tutorial', + style: TextStyle( + color: AppColors.subtitleColor, + fontSize: 14.0, + ), + ), + SizedBox( + width: 3.0, + ), + Icon( + Icons.open_in_new, + size: 18.0, + color: AppColors.subtitleColor, + ), + ], + ), + onTap: () => NavigationUtils.openUrl(kP2pSwapTutorialLink), + ), + ), + const SizedBox( + height: 40.0, + ), + ], + ); + } + + bool _isGeneratingPlasma(PowStatus? status) { + return status != null && status == PowStatus.generating; + } + + void _showGeneratingPlasmaToast() { + ToastUtils.showToast(context, 'Please wait while Plasma is generated'); + } +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swaps_card.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swaps_card.dart new file mode 100644 index 00000000..a41d09a7 --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swaps_card.dart @@ -0,0 +1,232 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/blocs/p2p_swap/p2p_swaps_list_bloc.dart'; +import 'package:zenon_syrius_wallet_flutter/main.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/modals/native_p2p_swap_modal.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/p2p_swaps_list_item.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/widgets.dart'; + +class P2pSwapsCard extends StatefulWidget { + final VoidCallback onStepperNotificationSeeMorePressed; + + const P2pSwapsCard({ + required this.onStepperNotificationSeeMorePressed, + Key? key, + }) : super(key: key); + + @override + State createState() => _P2pSwapsCardState(); +} + +class _P2pSwapsCardState extends State { + final ScrollController _scrollController = ScrollController(); + final P2pSwapsListBloc _p2pSwapsListBloc = P2pSwapsListBloc(); + + bool _isListScrolled = false; + + @override + void initState() { + super.initState(); + _p2pSwapsListBloc.getDataPeriodically(); + _scrollController.addListener(() { + if (_scrollController.position.pixels > 0 && !_isListScrolled) { + setState(() { + _isListScrolled = true; + }); + } else if (_scrollController.position.pixels == 0) { + setState(() { + _isListScrolled = false; + }); + } + }); + } + + @override + void dispose() { + _scrollController.dispose(); + _p2pSwapsListBloc.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return CardScaffold>( + title: 'P2P Swaps', + childStream: _p2pSwapsListBloc.stream, + onCompletedStatusCallback: (data) => data.isEmpty + ? const SyriusErrorWidget('No P2P swaps') + : _getTable(data), + onRefreshPressed: () => _p2pSwapsListBloc.getData(), + description: + 'This card displays a list of P2P swaps that have been conducted ' + 'with this wallet.', + customItem: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: _onDeleteSwapHistoryTapped, + child: Row( + children: [ + const Icon( + Icons.delete, + color: AppColors.znnColor, + size: 20.0, + ), + const SizedBox( + width: 5.0, + height: 38.0, + ), + Expanded( + child: Text( + 'Delete swap history', + style: Theme.of(context).textTheme.bodyLarge, + ), + ), + ], + ), + ), + ), + ); + } + + void _onSwapTapped(String swapId) { + showCustomDialog( + context: context, + content: NativeP2pSwapModal( + swapId: swapId, + ), + ); + } + + Future _onDeleteSwapTapped(P2pSwap swap) async { + showDialogWithNoAndYesOptions( + context: context, + isBarrierDismissible: true, + title: 'Delete swap', + description: + 'Are you sure you want to delete this swap? This action cannot be undone.', + onYesButtonPressed: () async { + if (swap.mode == P2pSwapMode.htlc) { + await htlcSwapsService!.deleteSwap(swap.id); + } + _p2pSwapsListBloc.getData(); + }); + } + + Future _onDeleteSwapHistoryTapped() async { + showDialogWithNoAndYesOptions( + context: context, + isBarrierDismissible: true, + title: 'Delete swap history', + description: + 'Are you sure you want to delete your swap history? Active swaps cannot be deleted.', + onYesButtonPressed: () async { + await htlcSwapsService!.deleteInactiveSwaps(); + _p2pSwapsListBloc.getData(); + }); + } + + Widget _getTable(List swaps) { + return Padding( + padding: const EdgeInsets.all(15.0), + child: Column( + children: [ + _getHeader(), + const SizedBox( + height: 15.0, + ), + Visibility( + visible: _isListScrolled, + child: const Divider(), + ), + Expanded( + child: Scrollbar( + controller: _scrollController, + child: ListView.separated( + controller: _scrollController, + cacheExtent: 1000, + itemCount: swaps.length, + separatorBuilder: (_, __) { + return const SizedBox( + height: 15.0, + ); + }, + itemBuilder: (_, index) { + return P2pSwapsListItem( + key: ValueKey(swaps.elementAt(index).id), + swap: swaps.elementAt(index), + onTap: _onSwapTapped, + onDelete: _onDeleteSwapTapped, + ); + }), + ), + ), + ], + ), + ); + } + + Widget _getHeader() { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + children: [ + Expanded( + flex: 20, + child: _getHeaderItem('Status'), + ), + Expanded( + flex: 20, + child: _getHeaderItem('From'), + ), + Expanded( + flex: 20, + child: _getHeaderItem('To'), + ), + Expanded( + flex: 20, + child: _getHeaderItem('Started'), + ), + Expanded( + flex: 20, + child: Visibility( + visible: htlcSwapsService!.isMaxSwapsReached, + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + _getHeaderItem( + 'Swap history is full', + textColor: AppColors.errorColor, + textHeight: 1.0, + ), + const SizedBox( + width: 5.0, + ), + const Tooltip( + message: + 'The oldest swap entry will be deleted when a new swap is started.', + child: Padding( + padding: EdgeInsets.only(top: 3.0), + child: Icon( + Icons.info, + color: AppColors.errorColor, + size: 12.0, + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } + + Widget _getHeaderItem(String text, {Color? textColor, double? textHeight}) { + return Text( + text, + style: TextStyle(fontSize: 12.0, height: textHeight, color: textColor), + ); + } +} diff --git a/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swaps_list_item.dart b/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swaps_list_item.dart new file mode 100644 index 00000000..eac438f3 --- /dev/null +++ b/lib/widgets/modular_widgets/p2p_swap_widgets/p2p_swaps_list_item.dart @@ -0,0 +1,253 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/model/p2p_swap/p2p_swap.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/color_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/extensions.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/format_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/loading_widget.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class P2pSwapsListItem extends StatefulWidget { + final P2pSwap swap; + final Function(String) onTap; + final Function(P2pSwap) onDelete; + + const P2pSwapsListItem({ + required this.swap, + required this.onTap, + required this.onDelete, + Key? key, + }) : super(key: key); + + @override + State createState() => _P2pSwapsListItemState(); +} + +class _P2pSwapsListItemState extends State { + bool _isDeleteIconHovered = false; + + @override + Widget build(BuildContext context) { + return Material( + color: Theme.of(context).colorScheme.primaryContainer, + elevation: 1.0, + borderRadius: BorderRadius.circular( + 8.0, + ), + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => widget.onTap.call(widget.swap.id), + child: Container( + height: 56.0, + padding: const EdgeInsets.symmetric(horizontal: 20.0), + child: Row( + children: [ + Expanded( + flex: 20, + child: Row( + children: [ + _getStatusWidget(), + const SizedBox( + width: 8.0, + ), + _getStatusText() + ], + ), + ), + Expanded( + flex: 20, + child: _getAmountWidget( + widget.swap.fromAmount, + widget.swap.fromDecimals, + widget.swap.fromTokenStandard, + widget.swap.fromSymbol), + ), + Expanded( + flex: 20, + child: widget.swap.state == P2pSwapState.completed + ? _getAmountWidget( + widget.swap.toAmount, + widget.swap.toDecimals, + widget.swap.toTokenStandard, + widget.swap.toSymbol) + : _getTextWidget('-'), + ), + Expanded( + flex: 20, + child: _getTextWidget( + _formatTime(widget.swap.startTime * 1000), + ), + ), + Expanded( + flex: 20, + child: _getActionButton(), + ), + ], + ), + ), + ), + ); + } + + Widget _getStatusWidget() { + const size = 16.0; + switch (widget.swap.state) { + case P2pSwapState.pending: + case P2pSwapState.active: + return const SyriusLoadingWidget( + size: 12.0, + strokeWidth: 2.0, + padding: 2.0, + ); + case P2pSwapState.completed: + return const Icon(Icons.check_circle_outline, + color: AppColors.znnColor, size: size); + default: + return const Icon(Icons.cancel_outlined, + color: AppColors.errorColor, size: size); + } + } + + Widget _getStatusText() { + late final String text; + switch (widget.swap.state) { + case P2pSwapState.pending: + text = 'Starting'; + break; + case P2pSwapState.active: + text = 'Active'; + break; + case P2pSwapState.completed: + text = 'Completed'; + break; + default: + text = 'Unsuccessful'; + } + return _getTextWidget(text); + } + + Widget _getTextWidget(String text) { + return Text(text, + style: const TextStyle( + fontSize: 12.0, height: 1, color: AppColors.subtitleColor), + overflow: TextOverflow.ellipsis, + maxLines: 1, + softWrap: false); + } + + Widget _getAmountWidget( + BigInt? amount, int? decimals, String? tokenStandard, String? symbol) { + if (amount == null || + decimals == null || + tokenStandard == null || + symbol == null) { + return _getTextWidget('-'); + } + return Row( + children: [ + Row( + children: [ + Container( + constraints: const BoxConstraints(maxWidth: 70), + child: _getTextWidget(amount.addDecimals(decimals)), + ), + Container( + constraints: const BoxConstraints(maxWidth: 50), + child: _getTextWidget(' $symbol'), + ), + ], + ), + const SizedBox( + width: 6.0, + ), + Container( + height: 6.0, + width: 6.0, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: ColorUtils.getTokenColor( + TokenStandard.parse(tokenStandard), + ), + ), + ), + ], + ); + } + + Widget _getActionButton() { + switch (widget.swap.state) { + case P2pSwapState.completed: + case P2pSwapState.unsuccessful: + return Align( + alignment: Alignment.centerRight, + child: FittedBox( + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + 4.0, + ), + color: const Color(0xff333333), + ), + child: MouseRegion( + cursor: SystemMouseCursors.click, + onEnter: (_) => setState(() => _isDeleteIconHovered = true), + onExit: (_) => setState(() => _isDeleteIconHovered = false), + child: GestureDetector( + onTap: () => widget.onDelete.call(widget.swap), + child: Icon( + Icons.delete, + color: _isDeleteIconHovered + ? Colors.white + : AppColors.subtitleColor, + size: 18.0, + ), + ), + ), + ), + ), + ); + case P2pSwapState.reclaimable: + return Align( + alignment: Alignment.centerRight, + child: FittedBox( + fit: BoxFit.scaleDown, + child: SizedBox( + height: 32.0, + child: ElevatedButton( + onPressed: () => widget.onTap.call(widget.swap.id), + child: const Text( + 'Reclaim funds', + style: TextStyle(fontSize: 12.0, color: Colors.white), + ), + ), + ), + ), + ); + default: + return Container(); + } + } + + String _formatTime(int transactionMillis) { + int currentMillis = DateTime.now().millisecondsSinceEpoch; + if (currentMillis - transactionMillis <= + const Duration(days: 1).inMilliseconds) { + return _formatTimeShort(currentMillis - transactionMillis); + } + return FormatUtils.formatDate(transactionMillis, + dateFormat: 'MM/dd/yyyy hh:mm a'); + } + + String _formatTimeShort(int i) { + Duration duration = Duration(milliseconds: i); + if (duration.inHours > 0) { + return '${duration.inHours} h ago'; + } + if (duration.inMinutes > 0) { + return '${duration.inMinutes} min ago'; + } + return '${duration.inSeconds} s ago'; + } +} diff --git a/lib/widgets/modular_widgets/plasma_widgets/plasma_options/plasma_options.dart b/lib/widgets/modular_widgets/plasma_widgets/plasma_options/plasma_options.dart index 32be63f7..27b411fb 100644 --- a/lib/widgets/modular_widgets/plasma_widgets/plasma_options/plasma_options.dart +++ b/lib/widgets/modular_widgets/plasma_widgets/plasma_options/plasma_options.dart @@ -131,6 +131,8 @@ class _PlasmaOptionsState extends State { void _beneficiaryAddressListener() { _beneficiaryAddressController.text = _plasmaBeneficiaryAddress!.getBeneficiaryAddress()!; + // Notify internal state has changed. + setState(() { }); } Widget _getWidgetBody(AccountInfo? accountInfo) { diff --git a/lib/widgets/modular_widgets/settings_widgets/node_management.dart b/lib/widgets/modular_widgets/settings_widgets/node_management.dart index 07bbc1d7..e57451e6 100644 --- a/lib/widgets/modular_widgets/settings_widgets/node_management.dart +++ b/lib/widgets/modular_widgets/settings_widgets/node_management.dart @@ -147,6 +147,8 @@ class _NodeManagementState extends State { } } if (isConnectionEstablished) { + kNodeChainId = await NodeUtils.getNodeChainIdentifier(); + await htlcSwapsService!.storeLastCheckedHtlcBlockHeight(0); if (await _checkForChainIdMismatch()) { await sharedPrefsService!.put( kSelectedNodeKey, diff --git a/lib/widgets/modular_widgets/transfer_widgets/send/send_medium.dart b/lib/widgets/modular_widgets/transfer_widgets/send/send_medium.dart index ffe7b738..82029410 100644 --- a/lib/widgets/modular_widgets/transfer_widgets/send/send_medium.dart +++ b/lib/widgets/modular_widgets/transfer_widgets/send/send_medium.dart @@ -18,11 +18,9 @@ import 'package:znn_sdk_dart/znn_sdk_dart.dart'; class SendMediumCard extends StatefulWidget { final VoidCallback onExpandClicked; - final VoidCallback onOkBridgeWarningDialogPressed; const SendMediumCard({ required this.onExpandClicked, - required this.onOkBridgeWarningDialogPressed, Key? key, }) : super(key: key); diff --git a/lib/widgets/reusable_widgets/bullet_point_card.dart b/lib/widgets/reusable_widgets/bullet_point_card.dart new file mode 100644 index 00000000..f2f5914d --- /dev/null +++ b/lib/widgets/reusable_widgets/bullet_point_card.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/extensions.dart'; + +class BulletPointCard extends StatelessWidget { + final List bulletPoints; + + const BulletPointCard({ + required this.bulletPoints, + Key? key, + }) : super(key: key); + + static TextSpan textSpan(String text, {List? children}) { + return TextSpan( + text: text, + style: const TextStyle(fontSize: 14.0, color: AppColors.subtitleColor), + children: children); + } + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).hoverColor, + borderRadius: const BorderRadius.all(Radius.circular(8.0)), + ), + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: bulletPoints + .map((e) => Row( + children: [ + const Text('●', + style: TextStyle( + fontSize: 14.0, color: AppColors.subtitleColor)), + const SizedBox(width: 10.0), + Expanded(child: e) + ], + )) + .toList() + .zip( + List.generate( + bulletPoints.length - 1, + (index) => const SizedBox( + height: 15.0, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/reusable_widgets/buttons/elevated_button.dart b/lib/widgets/reusable_widgets/buttons/elevated_button.dart index 711bda21..b2753d8e 100644 --- a/lib/widgets/reusable_widgets/buttons/elevated_button.dart +++ b/lib/widgets/reusable_widgets/buttons/elevated_button.dart @@ -6,13 +6,15 @@ class SyriusElevatedButton extends StatefulWidget { final String text; final Color initialFillColor; final VoidCallback? onPressed; - final Widget icon; + final Widget? icon; + final ButtonStyle? style; const SyriusElevatedButton({ required this.text, required this.onPressed, - required this.icon, + this.icon, this.initialFillColor = AppColors.qsrColor, + this.style, Key? key, }) : super(key: key); @@ -42,17 +44,24 @@ class _SyriusElevatedButtonState extends State { Widget build(BuildContext context) { return ElevatedButton( onPressed: widget.onPressed, + style: widget.style, child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - alignment: Alignment.center, - height: 20.0, - width: 20.0, - child: widget.icon, + Visibility( + visible: widget.icon != null, + child: Container( + alignment: Alignment.center, + height: 20.0, + width: 20.0, + child: widget.icon, + ), ), - const SizedBox( - width: 5.0, + Visibility( + visible: widget.icon != null, + child: const SizedBox( + width: 5.0, + ), ), Text( widget.text, diff --git a/lib/widgets/reusable_widgets/buttons/instruction_button.dart b/lib/widgets/reusable_widgets/buttons/instruction_button.dart new file mode 100644 index 00000000..f56b652a --- /dev/null +++ b/lib/widgets/reusable_widgets/buttons/instruction_button.dart @@ -0,0 +1,63 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/loading_info_text.dart'; + +class InstructionButton extends StatefulWidget { + final String text; + final bool isEnabled; + final bool isLoading; + final VoidCallback onPressed; + final String? instructionText; + final String? loadingText; + + const InstructionButton({ + required this.text, + required this.isEnabled, + required this.isLoading, + required this.onPressed, + this.instructionText, + this.loadingText, + Key? key, + }) : super(key: key); + + @override + State createState() => _InstructionButtonState(); +} + +class _InstructionButtonState extends State { + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: + (widget.isEnabled && !widget.isLoading) ? widget.onPressed : null, + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.znnColor, + disabledBackgroundColor: AppColors.znnColor.withOpacity(0.1), + ), + child: AnimatedCrossFade( + duration: Duration(milliseconds: widget.isLoading ? 1000 : 10), + firstCurve: Curves.easeInOut, + firstChild: Visibility( + visible: !widget.isLoading, + child: Opacity( + opacity: widget.isEnabled ? 1.0 : 0.3, + child: Text( + widget.isEnabled ? widget.text : (widget.instructionText ?? ''), + style: Theme.of(context).textTheme.headlineSmall, + ), + ), + ), + secondChild: SizedBox( + width: double.infinity, + child: LoadingInfoText(text: widget.loadingText ?? ''), + ), + crossFadeState: widget.isLoading + ? CrossFadeState.showSecond + : CrossFadeState.showFirst, + ), + ), + ); + } +} diff --git a/lib/widgets/reusable_widgets/dialogs.dart b/lib/widgets/reusable_widgets/dialogs.dart index 59d6eb20..4a087eaa 100644 --- a/lib/widgets/reusable_widgets/dialogs.dart +++ b/lib/widgets/reusable_widgets/dialogs.dart @@ -97,3 +97,16 @@ showDialogWithNoAndYesOptions({ ], ), ); + +showCustomDialog({required BuildContext context, required Widget content}) => + showGeneralDialog( + context: context, + barrierLabel: '', + barrierDismissible: true, + pageBuilder: (context, Animation animation, + Animation secondaryAnimation) => + Center( + child: ClipRRect( + borderRadius: BorderRadius.circular(15.0), child: content), + ), + ); diff --git a/lib/widgets/reusable_widgets/dropdown/addresses_dropdown.dart b/lib/widgets/reusable_widgets/dropdown/addresses_dropdown.dart index 74a1835e..65546f19 100644 --- a/lib/widgets/reusable_widgets/dropdown/addresses_dropdown.dart +++ b/lib/widgets/reusable_widgets/dropdown/addresses_dropdown.dart @@ -34,6 +34,9 @@ class AddressesDropdown extends StatelessWidget { margin: const EdgeInsets.symmetric( horizontal: 10.0, ), + padding: const EdgeInsets.only( + right: 7.5, + ), child: Icon( SimpleLineIcons.arrow_down, size: 10.0, diff --git a/lib/widgets/reusable_widgets/dropdown/basic_dropdown.dart b/lib/widgets/reusable_widgets/dropdown/basic_dropdown.dart new file mode 100644 index 00000000..b8b5bc37 --- /dev/null +++ b/lib/widgets/reusable_widgets/dropdown/basic_dropdown.dart @@ -0,0 +1,67 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_vector_icons/flutter_vector_icons.dart'; +import 'package:zenon_syrius_wallet_flutter/model/basic_dropdown_item.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; + +class BasicDropdown extends StatelessWidget { + final String _hint; + final BasicDropdownItem? _selectedValue; + final List> _items; + final Function(BasicDropdownItem?)? onChangedCallback; + + const BasicDropdown( + this._hint, + this._selectedValue, + this._items, + this.onChangedCallback, { + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.only(left: 10.0), + decoration: BoxDecoration( + color: Theme.of(context).inputDecorationTheme.fillColor, + borderRadius: BorderRadius.circular(5.0), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton>( + hint: Text( + _hint, + style: Theme.of(context).inputDecorationTheme.hintStyle, + ), + icon: Container( + margin: const EdgeInsets.fromLTRB(10.0, 0.0, 17.5, 0.0), + child: Icon( + SimpleLineIcons.arrow_down, + size: 10.0, + color: _selectedValue != null + ? AppColors.znnColor + : AppColors.lightSecondary, + ), + ), + value: _selectedValue, + items: _items.map( + (BasicDropdownItem item) { + return DropdownMenuItem>( + value: item, + child: Text( + item.label, + style: Theme.of(context).textTheme.bodyText2!.copyWith( + color: _selectedValue == item + ? onChangedCallback != null + ? AppColors.znnColor + : AppColors.lightSecondary + : null, + ), + ), + ); + }, + ).toList(), + onChanged: onChangedCallback, + ), + ), + ); + } +} diff --git a/lib/widgets/reusable_widgets/dropdown/bridge_network_dropdown.dart b/lib/widgets/reusable_widgets/dropdown/bridge_network_dropdown.dart deleted file mode 100644 index 9734e317..00000000 --- a/lib/widgets/reusable_widgets/dropdown/bridge_network_dropdown.dart +++ /dev/null @@ -1,70 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_vector_icons/flutter_vector_icons.dart'; -import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; -import 'package:zenon_syrius_wallet_flutter/utils/constants.dart'; - -class BridgeNetworkDropdown extends StatelessWidget { - final Function(String?)? onChangedCallback; - final String? _selectedNetwork; - - const BridgeNetworkDropdown( - this._selectedNetwork, - this.onChangedCallback, { - Key? key, - }) : super(key: key); - - @override - Widget build(BuildContext context) { - return Tooltip( - message: _selectedNetwork!, - child: FocusableActionDetector( - mouseCursor: SystemMouseCursors.click, - child: Container( - padding: const EdgeInsets.only( - left: 10.0, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(5.0), - color: Theme.of(context).inputDecorationTheme.fillColor, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton( - isExpanded: true, - icon: Container( - margin: const EdgeInsets.symmetric( - horizontal: 10.0, - ), - child: Icon( - SimpleLineIcons.arrow_down, - size: 10.0, - color: onChangedCallback != null - ? AppColors.znnColor - : AppColors.lightSecondary, - ), - ), - value: _selectedNetwork, - items: kBridgeNetworks.map( - (String value) { - return DropdownMenuItem( - value: value, - child: Text( - value, - style: Theme.of(context).textTheme.bodyMedium!.copyWith( - color: _selectedNetwork == value - ? onChangedCallback != null - ? AppColors.znnColor - : AppColors.lightSecondary - : null, - ), - ), - ); - }, - ).toList(), - onChanged: onChangedCallback, - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/reusable_widgets/dropdown/dropdown.dart b/lib/widgets/reusable_widgets/dropdown/dropdown.dart index d96b47e1..bad2a9ea 100644 --- a/lib/widgets/reusable_widgets/dropdown/dropdown.dart +++ b/lib/widgets/reusable_widgets/dropdown/dropdown.dart @@ -1,3 +1,2 @@ export 'addresses_dropdown.dart'; -export 'bridge_network_dropdown.dart'; export 'coin_dropdown.dart'; diff --git a/lib/widgets/reusable_widgets/exchange_rate_widget.dart b/lib/widgets/reusable_widgets/exchange_rate_widget.dart new file mode 100644 index 00000000..9e230865 --- /dev/null +++ b/lib/widgets/reusable_widgets/exchange_rate_widget.dart @@ -0,0 +1,81 @@ +import 'package:big_decimal/big_decimal.dart'; +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/extensions.dart'; + +class ExchangeRateWidget extends StatefulWidget { + final BigInt fromAmount; + final int fromDecimals; + final String fromSymbol; + final BigInt toAmount; + final int toDecimals; + final String toSymbol; + + const ExchangeRateWidget({ + required this.fromAmount, + required this.fromDecimals, + required this.fromSymbol, + required this.toAmount, + required this.toDecimals, + required this.toSymbol, + Key? key, + }) : super(key: key); + + @override + State createState() => _ExchangeRateWidgetState(); +} + +class _ExchangeRateWidgetState extends State { + bool _isToggled = false; + + @override + Widget build(BuildContext context) { + return Visibility( + visible: widget.fromAmount > BigInt.zero && widget.toAmount > BigInt.zero, + child: Row( + children: [ + Text( + _getFormattedRate(), + style: + const TextStyle(fontSize: 14.0, color: AppColors.subtitleColor), + ), + const SizedBox( + width: 5.0, + ), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => setState(() { + _isToggled = !_isToggled; + }), + child: const Icon( + Icons.swap_horiz, + color: AppColors.subtitleColor, + size: 22.0, + ), + ), + ), + ], + ), + ); + } + + String _getFormattedRate() { + if (widget.fromAmount <= BigInt.zero || widget.toAmount <= BigInt.zero) { + return '-'; + } + final fromAmountWithDecimals = BigDecimal.createAndStripZerosForScale( + widget.fromAmount, widget.fromDecimals, widget.fromDecimals); + final toAmountWithDecimals = BigDecimal.createAndStripZerosForScale( + widget.toAmount, widget.toDecimals, widget.toDecimals); + if (_isToggled) { + final rate = (fromAmountWithDecimals.divide(toAmountWithDecimals, + roundingMode: RoundingMode.DOWN)); + return '1 ${widget.toSymbol} = ${rate.toDouble().toStringFixedNumDecimals(5)} ${widget.fromSymbol}'; + } else { + final rate = (toAmountWithDecimals.divide(fromAmountWithDecimals, + roundingMode: RoundingMode.DOWN)); + return '1 ${widget.fromSymbol} = ${rate.toDouble().toStringFixedNumDecimals(5)} ${widget.toSymbol}'; + } + } +} diff --git a/lib/widgets/reusable_widgets/icons/copy_to_clipboard_icon.dart b/lib/widgets/reusable_widgets/icons/copy_to_clipboard_icon.dart index b35d6da7..4bf0638b 100644 --- a/lib/widgets/reusable_widgets/icons/copy_to_clipboard_icon.dart +++ b/lib/widgets/reusable_widgets/icons/copy_to_clipboard_icon.dart @@ -1,34 +1,65 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; import 'package:zenon_syrius_wallet_flutter/utils/clipboard_utils.dart'; -class CopyToClipboardIcon extends StatelessWidget { +class CopyToClipboardIcon extends StatefulWidget { final String? textToBeCopied; final Color iconColor; final Color? hoverColor; final MaterialTapTargetSize materialTapTargetSize; + final IconData icon; + final EdgeInsets padding; const CopyToClipboardIcon( this.textToBeCopied, { this.iconColor = AppColors.znnColor, this.hoverColor, this.materialTapTargetSize = MaterialTapTargetSize.padded, + this.icon = Icons.content_copy, + this.padding = const EdgeInsets.all(8.0), Key? key, }) : super(key: key); + @override + State createState() => _CopyToClipboardIcon(); +} + +class _CopyToClipboardIcon extends State { + final _iconSize = 15.0; + bool _isCopied = false; + @override Widget build(BuildContext context) { return RawMaterialButton( - materialTapTargetSize: materialTapTargetSize, - hoverColor: hoverColor, + materialTapTargetSize: widget.materialTapTargetSize, + hoverColor: widget.hoverColor, constraints: const BoxConstraints.tightForFinite(), - padding: const EdgeInsets.all(8.0), + padding: widget.padding, shape: const CircleBorder(), - onPressed: () => ClipboardUtils.copyToClipboard(textToBeCopied!, context), - child: Icon( - Icons.content_copy, - color: iconColor, - size: 15.0, + onPressed: () { + if (!_isCopied) { + Timer(const Duration(seconds: 3), () { + if (mounted) { + setState(() { + _isCopied = false; + }); + } + }); + setState(() { + _isCopied = true; + }); + } + ClipboardUtils.copyToClipboard(widget.textToBeCopied!, context); + }, + child: AnimatedCrossFade( + duration: const Duration(milliseconds: 100), + firstChild: Icon(widget.icon, color: widget.iconColor, size: _iconSize), + secondChild: + Icon(Icons.check, color: AppColors.znnColor, size: _iconSize), + crossFadeState: + _isCopied ? CrossFadeState.showSecond : CrossFadeState.showFirst, ), ); } diff --git a/lib/widgets/reusable_widgets/important_text_container.dart b/lib/widgets/reusable_widgets/important_text_container.dart new file mode 100644 index 00000000..31fc8981 --- /dev/null +++ b/lib/widgets/reusable_widgets/important_text_container.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; + +class ImportantTextContainer extends StatelessWidget { + final String text; + final bool showBorder; + final bool isSelectable; + + const ImportantTextContainer({ + required this.text, + this.showBorder = false, + this.isSelectable = false, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + border: showBorder + ? Border.all( + width: 1.0, + color: AppColors.errorColor, + ) + : null, + borderRadius: const BorderRadius.all( + Radius.circular(8.0), + ), + ), + child: Padding( + padding: const EdgeInsets.fromLTRB(15.0, 20.0, 15.0, 20.0), + child: Row( + children: [ + const Icon( + Icons.info, + size: 20.0, + color: Colors.white, + ), + const SizedBox( + width: 15.0, + ), + Expanded( + child: isSelectable + ? SelectableText(text, style: const TextStyle(fontSize: 14.0)) + : Text(text, style: const TextStyle(fontSize: 14.0)), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/reusable_widgets/input_fields/amount_input_field.dart b/lib/widgets/reusable_widgets/input_fields/amount_input_field.dart new file mode 100644 index 00000000..439bf688 --- /dev/null +++ b/lib/widgets/reusable_widgets/input_fields/amount_input_field.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/constants.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/extensions.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/format_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/input_validators.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/zts_utils.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/dropdown/coin_dropdown.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/input_fields/amount_suffix_widgets.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/input_fields/input_field.dart'; +import 'package:znn_sdk_dart/znn_sdk_dart.dart'; + +class AmountInputField extends StatefulWidget { + final TextEditingController controller; + final AccountInfo accountInfo; + final void Function(Token, bool)? onChanged; + final double? valuePadding; + final Color? textColor; + final Token? initialToken; + final String hintText; + final bool enabled; + + const AmountInputField({ + required this.controller, + required this.accountInfo, + this.onChanged, + this.valuePadding, + this.textColor, + this.initialToken, + this.hintText = 'Amount', + this.enabled = true, + Key? key, + }) : super(key: key); + + @override + State createState() { + return _AmountInputFieldState(); + } +} + +class _AmountInputFieldState extends State { + final List _tokensWithBalance = []; + Token? _selectedToken; + + @override + void initState() { + super.initState(); + _tokensWithBalance.addAll(kDualCoin); + _addTokensWithBalance(); + _selectedToken = widget.initialToken ?? kDualCoin.first; + } + + @override + Widget build(BuildContext context) { + return Form( + key: widget.key, + autovalidateMode: AutovalidateMode.onUserInteraction, + child: InputField( + onChanged: (value) { + setState(() {}); + }, + inputFormatters: FormatUtils.getAmountTextInputFormatters( + widget.controller.text, + ), + validator: (value) => InputValidators.correctValue( + value, + widget.accountInfo.getBalance( + _selectedToken!.tokenStandard, + ), + _selectedToken!.decimals, + BigInt.zero, + ), + controller: widget.controller, + suffixIcon: _getAmountSuffix(), + hintText: widget.hintText, + contentLeftPadding: widget.valuePadding ?? kContentPadding, + enabled: widget.enabled, + ), + onChanged: () => (widget.onChanged != null) + ? widget.onChanged!(_selectedToken!, (_isInputValid()) ? true : false) + : null, + ); + } + + Widget _getAmountSuffix() { + return Row( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + _getCoinDropdown(), + const SizedBox( + width: 5.0, + ), + AmountSuffixMaxWidget( + onPressed: () => _onMaxPressed(), + context: context, + ), + const SizedBox( + width: 5.0, + ), + ], + ); + } + + void _onMaxPressed() => setState(() { + final maxBalance = widget.accountInfo.getBalance( + _selectedToken!.tokenStandard, + ); + widget.controller.text = + maxBalance.addDecimals(_selectedToken!.decimals).toString(); + }); + + Widget _getCoinDropdown() => CoinDropdown( + _tokensWithBalance, + _selectedToken!, + (value) { + if (_selectedToken != value) { + setState( + () { + _selectedToken = value!; + _isInputValid(); + widget.onChanged!(_selectedToken!, _isInputValid()); + }, + ); + } + }, + ); + + void _addTokensWithBalance() { + for (var balanceInfo in widget.accountInfo.balanceInfoList!) { + if (balanceInfo.balance! > BigInt.zero && + !_tokensWithBalance.contains(balanceInfo.token)) { + _tokensWithBalance.add(balanceInfo.token); + } + } + } + + bool _isInputValid() => + InputValidators.correctValue( + widget.controller.text, + widget.accountInfo.getBalance( + _selectedToken!.tokenStandard, + ), + _selectedToken!.decimals, + BigInt.zero, + ) == + null; + + @override + void dispose() { + super.dispose(); + } +} diff --git a/lib/widgets/reusable_widgets/input_fields/input_fields.dart b/lib/widgets/reusable_widgets/input_fields/input_fields.dart index 539fbd4d..a5733c5b 100644 --- a/lib/widgets/reusable_widgets/input_fields/input_fields.dart +++ b/lib/widgets/reusable_widgets/input_fields/input_fields.dart @@ -1,4 +1,6 @@ +export 'amount_input_field.dart'; export 'amount_suffix_widgets.dart'; export 'disabled_address_field.dart'; export 'input_field.dart'; +export 'labeled_input_container.dart'; export 'password_input_field.dart'; diff --git a/lib/widgets/reusable_widgets/input_fields/labeled_input_container.dart b/lib/widgets/reusable_widgets/input_fields/labeled_input_container.dart new file mode 100644 index 00000000..6ebd72a3 --- /dev/null +++ b/lib/widgets/reusable_widgets/input_fields/labeled_input_container.dart @@ -0,0 +1,54 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; + +class LabeledInputContainer extends StatelessWidget { + final String labelText; + final Widget inputWidget; + final String? helpText; + + const LabeledInputContainer({ + required this.labelText, + required this.inputWidget, + this.helpText, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + labelText, + style: const TextStyle( + fontSize: 14.0, + color: AppColors.darkHintTextColor, + ), + ), + const SizedBox(width: 3.0), + Visibility( + visible: helpText != null, + child: Tooltip( + message: helpText ?? '', + child: const Padding( + padding: EdgeInsets.only(top: 3.0), + child: Icon( + Icons.help, + color: AppColors.darkHintTextColor, + size: 12.0, + ), + ), + ), + ), + ], + ), + const SizedBox(height: 3.0), + inputWidget + ], + ); + } +} diff --git a/lib/widgets/reusable_widgets/layout_scaffold/card_scaffold.dart b/lib/widgets/reusable_widgets/layout_scaffold/card_scaffold.dart index 18850757..406f8480 100644 --- a/lib/widgets/reusable_widgets/layout_scaffold/card_scaffold.dart +++ b/lib/widgets/reusable_widgets/layout_scaffold/card_scaffold.dart @@ -21,6 +21,7 @@ class CardScaffold extends StatefulWidget { final Widget Function(T)? onCompletedStatusCallback; final double? titleFontSize; final Widget? titleIcon; + final Widget? customItem; const CardScaffold({ required this.title, @@ -31,6 +32,7 @@ class CardScaffold extends StatefulWidget { this.onCompletedStatusCallback, this.titleFontSize, this.titleIcon, + this.customItem, Key? key, }) : super(key: key); @@ -212,6 +214,7 @@ class _CardScaffoldState extends State> { ], ), ), + if (widget.customItem != null) widget.customItem! ], ), ); diff --git a/lib/widgets/reusable_widgets/loading_info_text.dart b/lib/widgets/reusable_widgets/loading_info_text.dart new file mode 100644 index 00000000..f0666093 --- /dev/null +++ b/lib/widgets/reusable_widgets/loading_info_text.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/reusable_widgets/loading_widget.dart'; + +class LoadingInfoText extends StatelessWidget { + final String text; + final String? tooltipText; + + const LoadingInfoText({ + required this.text, + this.tooltipText, + Key? key, + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SyriusLoadingWidget( + size: 16.0, + strokeWidth: 2.0, + ), + const SizedBox( + width: 10.0, + ), + Text( + text, + style: + const TextStyle(fontSize: 14.0, color: AppColors.subtitleColor), + ), + Visibility( + visible: tooltipText != null, + child: const SizedBox( + width: 5.0, + ), + ), + Visibility( + visible: tooltipText != null, + child: Tooltip( + message: tooltipText ?? '', + child: const Padding( + padding: EdgeInsets.only(top: 1.0), + child: Icon( + Icons.help, + color: AppColors.subtitleColor, + size: 14.0, + ), + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/reusable_widgets/loading_widget.dart b/lib/widgets/reusable_widgets/loading_widget.dart index 09dd14d9..4a66ece8 100644 --- a/lib/widgets/reusable_widgets/loading_widget.dart +++ b/lib/widgets/reusable_widgets/loading_widget.dart @@ -4,17 +4,19 @@ import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; class SyriusLoadingWidget extends StatelessWidget { final double size; final double strokeWidth; + final double padding; const SyriusLoadingWidget({ this.size = 50.0, this.strokeWidth = 4.0, + this.padding = 4.0, Key? key, }) : super(key: key); @override Widget build(BuildContext context) { return Padding( - padding: const EdgeInsets.all(4.0), + padding: EdgeInsets.all(padding), child: Container( alignment: Alignment.center, height: size, diff --git a/lib/widgets/reusable_widgets/modals/base_modal.dart b/lib/widgets/reusable_widgets/modals/base_modal.dart new file mode 100644 index 00000000..955d4826 --- /dev/null +++ b/lib/widgets/reusable_widgets/modals/base_modal.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/app_colors.dart'; + +class BaseModal extends StatelessWidget { + final String title; + final String subtitle; + final Widget child; + + const BaseModal({ + Key? key, + required this.title, + required this.child, + this.subtitle = '', + }) : super(key: key); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.8, + ), + child: SingleChildScrollView( + child: Material( + child: AnimatedSize( + duration: const Duration(milliseconds: 100), + curve: Curves.easeInOut, + child: Container( + width: 570.0, + color: Theme.of(context).colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(25.0), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Column( + children: [ + Text( + title, + style: const TextStyle( + fontSize: 18.0, + ), + ), + Visibility( + visible: subtitle.isNotEmpty, + child: const SizedBox( + height: 3.0, + ), + ), + Visibility( + visible: subtitle.isNotEmpty, + child: Text(subtitle), + ), + ], + ), + MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => Navigator.of(context).pop(), + child: const Icon( + Icons.clear, + color: AppColors.lightSecondary, + size: 22.0, + ), + ), + ), + ], + ), + child + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/widgets/reusable_widgets/reusable_widgets.dart b/lib/widgets/reusable_widgets/reusable_widgets.dart index cc0c70c4..85559c6b 100644 --- a/lib/widgets/reusable_widgets/reusable_widgets.dart +++ b/lib/widgets/reusable_widgets/reusable_widgets.dart @@ -19,6 +19,7 @@ export 'error_widget.dart'; export 'formatted_amount_with_tooltip.dart'; export 'infinite_scroll_table.dart'; export 'loading_widget.dart'; +export 'modals/base_modal.dart'; export 'notification_widget.dart'; export 'number_animation.dart'; export 'plasma_icon.dart'; diff --git a/lib/widgets/reusable_widgets/tag_widget.dart b/lib/widgets/reusable_widgets/tag_widget.dart index 89193ed5..4aca3b00 100644 --- a/lib/widgets/reusable_widgets/tag_widget.dart +++ b/lib/widgets/reusable_widgets/tag_widget.dart @@ -7,12 +7,14 @@ class TagWidget extends StatelessWidget { final VoidCallback? onPressed; final String text; final String? hexColorCode; + final Color? textColor; const TagWidget({ required this.text, this.hexColorCode, this.onPressed, this.iconData, + this.textColor, Key? key, }) : super(key: key); @@ -51,7 +53,7 @@ class TagWidget extends StatelessWidget { Text( text, style: kBodySmallTextStyle.copyWith( - color: Colors.white, + color: textColor ?? Colors.white, ), ), ], diff --git a/lib/widgets/tab_children_widgets/bridge_tab_child.dart b/lib/widgets/tab_children_widgets/bridge_tab_child.dart deleted file mode 100644 index 477850a1..00000000 --- a/lib/widgets/tab_children_widgets/bridge_tab_child.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:zenon_syrius_wallet_flutter/widgets/widgets.dart'; - -class BridgeTabChild extends StatelessWidget { - const BridgeTabChild({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - return const SwapCard(); - } -} diff --git a/lib/widgets/tab_children_widgets/p2p_swap_tab_child.dart b/lib/widgets/tab_children_widgets/p2p_swap_tab_child.dart new file mode 100644 index 00000000..ac20e942 --- /dev/null +++ b/lib/widgets/tab_children_widgets/p2p_swap_tab_child.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:layout/layout.dart'; +import 'package:provider/provider.dart'; +import 'package:zenon_syrius_wallet_flutter/utils/utils.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/p2p_swaps_card.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/modular_widgets/p2p_swap_widgets/p2p_swap_options_card.dart'; +import 'package:zenon_syrius_wallet_flutter/widgets/widgets.dart'; + +class P2pSwapTabChild extends StatefulWidget { + final VoidCallback onStepperNotificationSeeMorePressed; + + const P2pSwapTabChild({ + required this.onStepperNotificationSeeMorePressed, + Key? key, + }) : super(key: key); + + @override + State createState() => P2pSwapTabChildState(); +} + +class P2pSwapTabChildState extends State { + @override + void initState() { + super.initState(); + NodeUtils.checkForLocalTimeDiscrepancy( + '''Local time discrepancy detected. Please confirm your operating ''' + '''system's time is correct before conducting P2P swaps.'''); + } + + @override + Widget build(BuildContext context) { + return _getLayout(context); + } + + StandardFluidLayout _getLayout(BuildContext context) { + return StandardFluidLayout( + children: [ + FluidCell( + height: kStaggeredNumOfColumns / 2, + width: context.layout.value( + xl: kStaggeredNumOfColumns ~/ 3, + lg: kStaggeredNumOfColumns ~/ 3, + md: kStaggeredNumOfColumns ~/ 3, + sm: kStaggeredNumOfColumns, + xs: kStaggeredNumOfColumns, + ), + child: const P2pSwapOptionsCard(), + ), + FluidCell( + height: kStaggeredNumOfColumns / 2, + width: context.layout.value( + xl: kStaggeredNumOfColumns ~/ 1.5, + lg: kStaggeredNumOfColumns ~/ 1.5, + md: kStaggeredNumOfColumns ~/ 1.5, + sm: kStaggeredNumOfColumns, + xs: kStaggeredNumOfColumns, + ), + child: Consumer( + builder: (_, __, ___) => P2pSwapsCard( + onStepperNotificationSeeMorePressed: + widget.onStepperNotificationSeeMorePressed, + ), + ), + ), + ], + ); + } +} diff --git a/lib/widgets/tab_children_widgets/tab_children_widgets.dart b/lib/widgets/tab_children_widgets/tab_children_widgets.dart index 75d2bb11..ef80719d 100644 --- a/lib/widgets/tab_children_widgets/tab_children_widgets.dart +++ b/lib/widgets/tab_children_widgets/tab_children_widgets.dart @@ -1,11 +1,11 @@ export 'accelerator_tab_child.dart'; -export 'bridge_tab_child.dart'; export 'dashboard_tab_child.dart'; export 'help_tab_child.dart'; export 'lock_tab_child.dart'; export 'notifications_tab_child.dart'; export 'pillars_tab_child.dart'; export 'plasma_tab_child.dart'; +export 'p2p_swap_tab_child.dart'; export 'sentinels_tab_child.dart'; export 'settings_tab_child.dart'; export 'staking_tab_child.dart'; diff --git a/lib/widgets/tab_children_widgets/transfer_tab_child.dart b/lib/widgets/tab_children_widgets/transfer_tab_child.dart index dfc12a9a..f16c61b9 100644 --- a/lib/widgets/tab_children_widgets/transfer_tab_child.dart +++ b/lib/widgets/tab_children_widgets/transfer_tab_child.dart @@ -7,10 +7,8 @@ enum DimensionCard { small, medium, large } class TransferTabChild extends StatefulWidget { DimensionCard sendCard; DimensionCard receiveCard; - final void Function() navigateToBridgeTab; TransferTabChild({ - required this.navigateToBridgeTab, Key? key, this.sendCard = DimensionCard.medium, this.receiveCard = DimensionCard.medium, @@ -53,10 +51,7 @@ class _TransferTabChildState extends State { FluidCell _getSendCard() => widget.sendCard == DimensionCard.medium ? _getMediumFluidCell( - SendMediumCard( - onExpandClicked: _onExpandSendCard, - onOkBridgeWarningDialogPressed: widget.navigateToBridgeTab, - ), + SendMediumCard(onExpandClicked: _onExpandSendCard), ) : widget.sendCard == DimensionCard.small ? _getSmallFluidCell(SendSmallCard(_onCollapse)) diff --git a/linux/create-git-metadata.sh b/linux/create-git-metadata.sh index 4711295d..f0b955b2 100755 --- a/linux/create-git-metadata.sh +++ b/linux/create-git-metadata.sh @@ -1,14 +1,18 @@ +escape () { + echo $1 | sed "s/'/'\"'\"r'/g" +} + GIT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD) GIT_COMMIT_HASH=$(git rev-parse HEAD) GIT_COMMIT_MESSAGE=$(git log -1 --pretty=%s) -GIT_COMMIT_DATE=$(git --no-pager log -1 --format="%ai") +GIT_COMMIT_DATE=$(git --no-pager log -1 --format="%as") GIT_ORIGIN_URL=$(git config --get remote.origin.url) GIT_COMMIT_FILE="../lib/utils/metadata.dart" sed --i '1,5d' $GIT_COMMIT_FILE -echo "const String gitBranchName = '$GIT_BRANCH_NAME';" >> $GIT_COMMIT_FILE -echo "const String gitCommitHash = '$GIT_COMMIT_HASH';" >> $GIT_COMMIT_FILE -echo "const String gitCommitMessage = '$GIT_COMMIT_MESSAGE';" >> $GIT_COMMIT_FILE -echo "const String gitCommitDate = '$GIT_COMMIT_DATE';" >> $GIT_COMMIT_FILE -echo "const String gitOriginUrl = '$GIT_ORIGIN_URL';" >> $GIT_COMMIT_FILE +echo "const String gitBranchName = r'$(escape $GIT_BRANCH_NAME)';" >> $GIT_COMMIT_FILE +echo "const String gitCommitHash = r'$GIT_COMMIT_HASH';" >> $GIT_COMMIT_FILE +echo "const String gitCommitMessage = r'$(escape $GIT_COMMIT_MESSAGE)';" >> $GIT_COMMIT_FILE +echo "const String gitCommitDate = r'$GIT_COMMIT_DATE';" >> $GIT_COMMIT_FILE +echo "const String gitOriginUrl = r'$(escape $GIT_ORIGIN_URL)';" >> $GIT_COMMIT_FILE \ No newline at end of file diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index 6fb19089..42db3b89 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -360,7 +360,7 @@ ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "GIT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)\nGIT_COMMIT_HASH=$(git rev-parse HEAD)\nGIT_COMMIT_MESSAGE=$(git log -1 --pretty=%s)\nGIT_COMMIT_DATE=$(git --no-pager log -1 --format=\"%ai\")\nGIT_ORIGIN_URL=$(git config --get remote.origin.url)\nGIT_COMMIT_FILE=\"../lib/utils/metadata.dart\"\n\nsed -i '' '1,5d' $GIT_COMMIT_FILE\necho \"const String gitBranchName = '$GIT_BRANCH_NAME';\" >> $GIT_COMMIT_FILE\necho \"const String gitCommitHash = '$GIT_COMMIT_HASH';\" >> $GIT_COMMIT_FILE\necho \"const String gitCommitMessage = '$GIT_COMMIT_MESSAGE';\" >> $GIT_COMMIT_FILE\necho \"const String gitCommitDate = '$GIT_COMMIT_DATE';\" >> $GIT_COMMIT_FILE\necho \"const String gitOriginUrl = '$GIT_ORIGIN_URL';\" >> $GIT_COMMIT_FILE\n\n\n"; + shellScript = "escape () {\n echo $1 | sed \"s/'/'\\\"'\\\"r' / g\"\n}\n\nGIT_BRANCH_NAME=$(git rev-parse --abbrev-ref HEAD)\nGIT_COMMIT_HASH=$(git rev-parse HEAD)\nGIT_COMMIT_MESSAGE=$(git log -1 --pretty=%s)\nGIT_COMMIT_DATE=$(git --no-pager log -1 --format=\"%as\")\nGIT_ORIGIN_URL=$(git config --get remote.origin.url)\nGIT_COMMIT_FILE=\"../lib/utils/metadata.dart\"\n\nsed -i '' '1,5d' $GIT_COMMIT_FILE\n\necho \"const String gitBranchName = r'$(escape $GIT_BRANCH_NAME)';\" >> $GIT_COMMIT_FILE\necho \"const String gitCommitHash = r'$GIT_COMMIT_HASH';\" >> $GIT_COMMIT_FILE\necho \"const String gitCommitMessage = r'$(escape $GIT_COMMIT_MESSAGE)';\" >> $GIT_COMMIT_FILE\necho \"const String gitCommitDate = r'$GIT_COMMIT_DATE';\" >> $GIT_COMMIT_FILE\necho \"const String gitOriginUrl = r'$(escape $GIT_ORIGIN_URL)';\" >> $GIT_COMMIT_FILE\n\n\n"; }; 96DA24F0296EB70E00545E88 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; @@ -481,7 +481,7 @@ "$(PROJECT_DIR)", ); MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = "v0.0.7-alphanet"; + MARKETING_VERSION = "v0.1.0-alphanet"; PRODUCT_BUNDLE_IDENTIFIER = network.zenon.syrius; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; @@ -618,7 +618,7 @@ "$(PROJECT_DIR)", ); MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = "v0.0.7-alphanet"; + MARKETING_VERSION = "v0.1.0-alphanet"; PRODUCT_BUNDLE_IDENTIFIER = network.zenon.syrius; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; @@ -645,7 +645,7 @@ "$(PROJECT_DIR)", ); MACOSX_DEPLOYMENT_TARGET = "$(RECOMMENDED_MACOSX_DEPLOYMENT_TARGET)"; - MARKETING_VERSION = "v0.0.7-alphanet"; + MARKETING_VERSION = "v0.1.0-alphanet"; PRODUCT_BUNDLE_IDENTIFIER = network.zenon.syrius; PROVISIONING_PROFILE_SPECIFIER = ""; SWIFT_VERSION = 5.0; diff --git a/pubspec.lock b/pubspec.lock index d3b9a74b..b9f0c1c6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -149,10 +149,10 @@ packages: dependency: transitive description: name: build - sha256: "43865b79fbb78532e4bff7c33087aa43b1d488c4fdef014eaef568af6d8016dc" + sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.4.1" build_config: dependency: transitive description: @@ -173,18 +173,18 @@ packages: dependency: transitive description: name: build_resolvers - sha256: db49b8609ef8c81cca2b310618c3017c00f03a92af44c04d310b907b2d692d95 + sha256: "6c4dd11d05d056e76320b828a1db0fc01ccd376922526f8e9d6c796a5adbac20" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.2.1" build_runner: dependency: "direct dev" description: name: build_runner - sha256: "5e1929ad37d48bd382b124266cb8e521de5548d406a45a5ae6656c13dab73e37" + sha256: "10c6bcdbf9d049a0b666702cf1cee4ddfdc38f02a19d35ae392863b47519848b" url: "https://pub.dev" source: hosted - version: "2.4.5" + version: "2.4.6" build_runner_core: dependency: transitive description: @@ -258,13 +258,13 @@ packages: source: hosted version: "4.5.0" collection: - dependency: transitive + dependency: "direct main" description: name: collection - sha256: "4a07be6cb69c84d677a6c3096fcf960cc3285a8330b4603e0d463d15d9bd934c" + sha256: f092b211a4319e98e5ff58223576de6c2803db36221657b46c82574721240687 url: "https://pub.dev" source: hosted - version: "1.17.1" + version: "1.17.2" convert: dependency: transitive description: @@ -301,10 +301,10 @@ packages: dependency: transitive description: name: dart_style - sha256: f4f1f73ab3fd2afcbcca165ee601fe980d966af6a21b5970c6c9376955c528ad + sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.3.2" dbus: dependency: transitive description: @@ -421,10 +421,18 @@ packages: dependency: "direct main" description: name: file_selector - sha256: a96fa9e956c1045c1879164b09f62918d827f6757a55d0ed2aa29c4fa2370c0b + sha256: "1d2fde93dddf634a9c3c0faa748169d7ac0d83757135555707e52f02c017ad4f" url: "https://pub.dev" source: hosted - version: "0.9.3" + version: "0.9.5" + file_selector_android: + dependency: transitive + description: + name: file_selector_android + sha256: "43e5c719f671b9181bef1bf2851135c3ad993a9a6c804a4ccb07579cfee84e34" + url: "https://pub.dev" + source: hosted + version: "0.5.0+2" file_selector_ios: dependency: transitive description: @@ -437,42 +445,42 @@ packages: dependency: transitive description: name: file_selector_linux - sha256: d17c5e450192cdc40b718804dfb4eaf79a71bed60ee9530703900879ba50baa3 + sha256: "770eb1ab057b5ae4326d1c24cc57710758b9a46026349d021d6311bd27580046" url: "https://pub.dev" source: hosted - version: "0.9.1+3" + version: "0.9.2" file_selector_macos: dependency: transitive description: name: file_selector_macos - sha256: "6290eec24fc4cc62535fe609e0c6714d3c1306191dc8c3b0319eaecc09423a3a" + sha256: "4ada532862917bf16e3adb3891fe3a5917a58bae03293e497082203a80909412" url: "https://pub.dev" source: hosted - version: "0.9.2" + version: "0.9.3+1" file_selector_platform_interface: dependency: transitive description: name: file_selector_platform_interface - sha256: "2a7f4bbf7bd2f022ecea85bfb1754e87f7dd403a9abc17a84a4fa2ddfe2abc0a" + sha256: "412705a646a0ae90f33f37acfae6a0f7cbc02222d6cd34e479421c3e74d3853c" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" file_selector_web: dependency: transitive description: name: file_selector_web - sha256: dd961a76a1954225b407313356f41cc106ccffc764a1c30764cc6fe09b109932 + sha256: e292740c469df0aeeaba0895bf622bea351a05e87d22864c826bf21c4780e1d7 url: "https://pub.dev" source: hosted - version: "0.9.0+4" + version: "0.9.2" file_selector_windows: dependency: transitive description: name: file_selector_windows - sha256: ef246380b66d1fb9089fc65622c387bf3780bca79f533424c31d07f12c2c7fd8 + sha256: "1372760c6b389842b77156203308940558a2817360154084368608413835fc26" url: "https://pub.dev" source: hosted - version: "0.9.2" + version: "0.9.3" fixnum: dependency: transitive description: @@ -506,10 +514,10 @@ packages: dependency: "direct dev" description: name: flutter_lints - sha256: aeb0b80a8b3709709c9cc496cdc027c5b3216796bc0af0ce1007eaf24464fd4c + sha256: "2118df84ef0c3ca93f96123a616ae8540879991b8b57af2f81b76a7ada49b2a4" url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" flutter_staggered_grid_view: dependency: "direct main" description: @@ -743,10 +751,10 @@ packages: dependency: "direct main" description: name: lottie - sha256: f461105d3a35887b27089abf9c292334478dd292f7b47ecdccb6ae5c37a22c80 + sha256: "0793a5866062e5cc8a8b24892fa94c3095953ea914a7fdf790f550dd7537fe60" url: "https://pub.dev" source: hosted - version: "2.4.0" + version: "2.5.0" marquee_widget: dependency: "direct main" description: @@ -927,10 +935,10 @@ packages: dependency: transitive description: name: path_provider_foundation - sha256: "1995d88ec2948dac43edf8fe58eb434d35d22a2940ecee1a9fefcd62beee6eb3" + sha256: "916731ccbdce44d545414dd9961f26ba5fbaa74bcbb55237d8e65a623a8c7297" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "2.2.4" path_provider_linux: dependency: transitive description: @@ -975,10 +983,10 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: "6a2128648c854906c53fa8e33986fc0247a1116122f9534dd20e3ab9e16a32bc" + sha256: "43798d895c929056255600343db8f049921cbec94d31ec87f1dc5c16c01935dd" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.1.5" pointycastle: dependency: transitive description: @@ -1011,14 +1019,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" - process: - dependency: transitive - description: - name: process - sha256: "53fd8db9cec1d37b0574e12f07520d582019cb6c44abf5479a01505099a34a09" - url: "https://pub.dev" - source: hosted - version: "4.2.4" provider: dependency: "direct main" description: @@ -1063,10 +1063,10 @@ packages: dependency: "direct main" description: name: screen_capturer - sha256: "5a1b6ec7f71cc928d62f1fb120ba1f08071ffe7f52f57f6b47ecfdfa05e133a9" + sha256: "39001271f34eca330a1a59b735da0a0f546fc6f9f38b68554d6d2478baeb4025" url: "https://pub.dev" source: hosted - version: "0.1.5" + version: "0.1.6" screen_retriever: dependency: transitive description: @@ -1119,58 +1119,58 @@ packages: dependency: transitive description: name: shared_preferences - sha256: "396f85b8afc6865182610c0a2fc470853d56499f75f7499e2a73a9f0539d23d0" + sha256: "0344316c947ffeb3a529eac929e1978fcd37c26be4e8468628bac399365a3ca1" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.2.0" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "6478c6bbbecfe9aced34c483171e90d7c078f5883558b30ec3163cf18402c749" + sha256: fe8401ec5b6dcd739a0fe9588802069e608c3fdbfd3c3c93e546cf2f90438076 url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" shared_preferences_foundation: dependency: transitive description: name: shared_preferences_foundation - sha256: e014107bb79d6d3297196f4f2d0db54b5d1f85b8ea8ff63b8e8b391a02700feb + sha256: f39696b83e844923b642ce9dd4bd31736c17e697f6731a5adf445b1274cf3cd4 url: "https://pub.dev" source: hosted - version: "2.2.2" + version: "2.3.2" shared_preferences_linux: dependency: transitive description: name: shared_preferences_linux - sha256: "9d387433ca65717bbf1be88f4d5bb18f10508917a8fa2fb02e0fd0d7479a9afa" + sha256: "71d6806d1449b0a9d4e85e0c7a917771e672a3d5dc61149cc9fac871115018e1" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" shared_preferences_platform_interface: dependency: transitive description: name: shared_preferences_platform_interface - sha256: fb5cf25c0235df2d0640ac1b1174f6466bd311f621574997ac59018a6664548d + sha256: "23b052f17a25b90ff2b61aad4cc962154da76fb62848a9ce088efe30d7c50ab1" url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" shared_preferences_web: dependency: transitive description: name: shared_preferences_web - sha256: "74083203a8eae241e0de4a0d597dbedab3b8fef5563f33cf3c12d7e93c655ca5" + sha256: "7347b194fb0bbeb4058e6a4e87ee70350b6b2b90f8ac5f8bd5b3a01548f6d33a" url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.2.0" shared_preferences_windows: dependency: transitive description: name: shared_preferences_windows - sha256: "5e588e2efef56916a3b229c3bfe81e6a525665a454519ca51dbcc4236a274173" + sha256: f95e6a43162bce43c9c3405f3eb6f39e5b5d11f65fab19196cf8225e2777624d url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.3.0" shelf: dependency: transitive description: @@ -1212,26 +1212,26 @@ packages: dependency: transitive description: name: sliver_tools - sha256: ccdc502098a8bfa07b3ec582c282620031481300035584e1bb3aca296a505e8c + sha256: eae28220badfb9d0559207badcbbc9ad5331aac829a88cb0964d330d2a4636a6 url: "https://pub.dev" source: hosted - version: "0.2.10" + version: "0.2.12" source_gen: dependency: transitive description: name: source_gen - sha256: "373f96cf5a8744bc9816c1ff41cf5391bbdbe3d7a96fe98c622b6738a8a7bd33" + sha256: fc0da689e5302edb6177fdd964efcb7f58912f43c28c2047a808f5bfff643d16 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" source_helper: dependency: transitive description: name: source_helper - sha256: "3b67aade1d52416149c633ba1bb36df44d97c6b51830c2198e934e3fca87ca1f" + sha256: "6adebc0006c37dd63fe05bca0a929b99f06402fc95aa35bf36d67f5c06de01fd" url: "https://pub.dev" source: hosted - version: "1.3.3" + version: "1.3.4" source_span: dependency: transitive description: @@ -1252,20 +1252,20 @@ packages: dependency: "direct main" description: name: stacked - sha256: f06ba899fd9cdddbd8c89ef8751dae2a9f2dac4c34ca6cf97d8819bc613ffe35 + sha256: "125f96248c6a33fa29a4e622e9b57ee5f222289475292dd1b9fa266cc218a2db" url: "https://pub.dev" source: hosted - version: "3.4.0" + version: "3.4.1" stacked_shared: dependency: transitive description: name: stacked_shared - sha256: "502f2b95d3857b0a0e7ebb77fef8c6ca8123c96fe67086ba3f6e66cbe695e703" + sha256: e6bc2921eb59b7c741c551fbb4060f22a543ea9c2d9351315fb58aa055b535f3 url: "https://pub.dev" source: hosted - version: "1.3.2" + version: "1.4.0" stream_channel: - dependency: transitive + dependency: "direct overridden" description: name: stream_channel sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 @@ -1356,18 +1356,18 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: eb1e00ab44303d50dd487aab67ebc575456c146c6af44422f9c13889984c00f3 + sha256: "781bd58a1eb16069412365c98597726cd8810ae27435f04b3b4d3a470bacd61e" url: "https://pub.dev" source: hosted - version: "6.1.11" + version: "6.1.12" url_launcher_android: dependency: transitive description: name: url_launcher_android - sha256: eed4e6a1164aa9794409325c3b707ff424d4d1c2a785e7db67f8bbda00e36e51 + sha256: "78cb6dea3e93148615109e58e42c35d1ffbf5ef66c44add673d0ab75f12ff3af" url: "https://pub.dev" source: hosted - version: "6.0.35" + version: "6.0.37" url_launcher_ios: dependency: transitive description: @@ -1388,10 +1388,10 @@ packages: dependency: transitive description: name: url_launcher_macos - sha256: "91ee3e75ea9dadf38036200c5d3743518f4a5eb77a8d13fda1ee5764373f185e" + sha256: "1c4fdc0bfea61a70792ce97157e5cc17260f61abbe4f39354513f39ec6fd73b1" url: "https://pub.dev" source: hosted - version: "3.0.5" + version: "3.0.6" url_launcher_platform_interface: dependency: transitive description: @@ -1404,18 +1404,18 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "6bb1e5d7fe53daf02a8fee85352432a40b1f868a81880e99ec7440113d5cfcab" + sha256: cc26720eefe98c1b71d85f9dc7ef0cada5132617046369d9dc296b3ecaa5cbb4 url: "https://pub.dev" source: hosted - version: "2.0.17" + version: "2.0.18" url_launcher_windows: dependency: transitive description: name: url_launcher_windows - sha256: "254708f17f7c20a9c8c471f67d86d76d4a3f9c1591aad1e15292008aceb82771" + sha256: "7967065dd2b5fccc18c653b97958fdf839c5478c28e767c61ee879f4e7882422" url: "https://pub.dev" source: hosted - version: "3.0.6" + version: "3.0.7" uuid: dependency: transitive description: @@ -1548,10 +1548,10 @@ packages: dependency: "direct main" description: name: window_manager - sha256: "95096fede562cbb65f30d38b62d819a458f59ba9fe4a317f6cee669710f6676b" + sha256: "9eef00e393e7f9308309ce9a8b2398c9ee3ca78b50c96e8b4f9873945693ac88" url: "https://pub.dev" source: hosted - version: "0.3.4" + version: "0.3.5" x25519: dependency: transitive description: @@ -1564,10 +1564,10 @@ packages: dependency: transitive description: name: xdg_directories - sha256: ee1505df1426458f7f60aac270645098d318a8b4766d85fde75f76f2e21807d1 + sha256: e0b1147eec179d3911f1f19b59206448f78195ca1d20514134e10641b7d7fbff url: "https://pub.dev" source: hosted - version: "1.0.0" + version: "1.0.1" xml: dependency: transitive description: @@ -1588,11 +1588,11 @@ packages: dependency: "direct main" description: path: "." - ref: master - resolved-ref: f3adff23fa6af3882b25bfb8a1d157f04f8a7b5d + ref: d960c0bdc6dc553eaa75d5ddddb34cab530578b8 + resolved-ref: d960c0bdc6dc553eaa75d5ddddb34cab530578b8 url: "https://github.com/zenon-network/znn_sdk_dart.git" source: git - version: "0.0.4" + version: "0.0.5" zxing2: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index a6f27b5b..df746ec1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,5 @@ name: zenon_syrius_wallet_flutter description: Zenon cross-platform non-custodial wallet -version: 0.0.7 publish_to: none environment: @@ -23,7 +22,7 @@ dependencies: flip_card: ^0.7.0 auto_size_text: ^3.0.0 window_manager: ^0.3.4 - stacked: ^3.1.0 + stacked: ^3.1.2 path_provider: ^2.0.8 rxdart: ^0.27.3 hive: ^2.0.5 @@ -45,7 +44,7 @@ dependencies: znn_sdk_dart: git: url: https://github.com/zenon-network/znn_sdk_dart.git - ref: master + ref: d960c0bdc6dc553eaa75d5ddddb34cab530578b8 json_rpc_2: ^3.0.2 path: ^1.8.2 ffi: ^2.0.1 @@ -55,8 +54,9 @@ dependencies: tray_manager: ^0.2.0 open_filex: ^4.3.2 launch_at_startup: ^0.2.1 - app_links: ^3.4.3 logging: ^1.2.0 + collection: ^1.17.0 + app_links: ^3.4.3 walletconnect_flutter_v2: ^2.0.14 preference_list: ^0.0.1 screen_capturer: ^0.1.2 @@ -67,7 +67,6 @@ dependencies: wallet_connect_uri_validator: ^0.1.0 big_decimal: ^0.5.0 ai_barcode_scanner: ^0.0.7 - dev_dependencies: build_runner: ^2.1.7 hive_generator: ^2.0.0 @@ -82,4 +81,9 @@ flutter: - assets/images/ - assets/i18n/ - assets/lottie/ - - assets/svg/ \ No newline at end of file + - assets/svg/ + +# TODO: Are these really needed? +dependency_overrides: + stream_channel: ^2.1.2 + collection: ^1.17.2 \ No newline at end of file diff --git a/windows/CMakeLists.txt b/windows/CMakeLists.txt index c8b15803..04781fcc 100644 --- a/windows/CMakeLists.txt +++ b/windows/CMakeLists.txt @@ -121,4 +121,4 @@ endforeach(znn_library) find_program(POWERSHELL_PATH NAMES powershell) add_custom_target("git_metadata" ALL - COMMAND ${POWERSHELL_PATH} "${CMAKE_HOME_DIRECTORY}/CreateGitMetadata.ps1") \ No newline at end of file + COMMAND ${POWERSHELL_PATH} -ExecutionPolicy Bypass "${CMAKE_HOME_DIRECTORY}/CreateGitMetadata.ps1") \ No newline at end of file diff --git a/windows/CreateGitMetadata.ps1 b/windows/CreateGitMetadata.ps1 index e376da88..c0de1182 100644 --- a/windows/CreateGitMetadata.ps1 +++ b/windows/CreateGitMetadata.ps1 @@ -1,14 +1,18 @@ +Function Escape($String) { + Return $String.Replace("'", "'`"'`"r'"); +} + $GIT_BRANCH_NAME = git rev-parse --abbrev-ref HEAD $GIT_COMMIT_HASH = git rev-parse HEAD $GIT_COMMIT_MESSAGE = git log -1 --pretty=%s -$GIT_COMMIT_DATE = git --no-pager log -1 --format="%ai" +$GIT_COMMIT_DATE = git --no-pager log -1 --format="%as" $GIT_ORIGIN_URL = git config --get remote.origin.url $GIT_COMMIT_FILE = "${PSScriptRoot}\..\lib\utils\metadata.dart" Clear-Content $GIT_COMMIT_FILE -Force -Add-Content $GIT_COMMIT_FILE "const String gitBranchName = '$GIT_BRANCH_NAME';" -Add-Content $GIT_COMMIT_FILE "const String gitCommitHash = '$GIT_COMMIT_HASH';" -Add-Content $GIT_COMMIT_FILE "const String gitCommitMessage = '$GIT_COMMIT_MESSAGE';" -Add-Content $GIT_COMMIT_FILE "const String gitCommitDate = '$GIT_COMMIT_DATE';" -Add-Content $GIT_COMMIT_FILE "const String gitOriginUrl = '$GIT_ORIGIN_URL';" \ No newline at end of file +Add-Content $GIT_COMMIT_FILE "const String gitBranchName = r'$(Escape $GIT_BRANCH_NAME)';" +Add-Content $GIT_COMMIT_FILE "const String gitCommitHash = r'$GIT_COMMIT_HASH';" +Add-Content $GIT_COMMIT_FILE "const String gitCommitMessage = r'$(Escape $GIT_COMMIT_MESSAGE)';" +Add-Content $GIT_COMMIT_FILE "const String gitCommitDate = r'$GIT_COMMIT_DATE';" +Add-Content $GIT_COMMIT_FILE "const String gitOriginUrl = r'$(Escape $GIT_ORIGIN_URL)';" \ No newline at end of file diff --git a/windows/runner/Runner.rc b/windows/runner/Runner.rc index 2a4633a8..bb7996d9 100644 --- a/windows/runner/Runner.rc +++ b/windows/runner/Runner.rc @@ -63,13 +63,13 @@ IDI_APP_ICON ICON "resources\\app_icon.ico" #ifdef FLUTTER_BUILD_NUMBER #define VERSION_AS_NUMBER FLUTTER_BUILD_NUMBER #else -#define VERSION_AS_NUMBER 0,0,6 +#define VERSION_AS_NUMBER 0,1,0 #endif #ifdef FLUTTER_BUILD_NAME #define VERSION_AS_STRING #FLUTTER_BUILD_NAME #else -#define VERSION_AS_STRING "0.0.7-alphanet" +#define VERSION_AS_STRING "v0.1.0-alphanet" #endif VS_VERSION_INFO VERSIONINFO @@ -90,7 +90,7 @@ BEGIN BLOCK "040904e4" BEGIN VALUE "CompanyName", "network.zenon.syrius" "\0" - VALUE "FileDescription", "s y r i u s (v0.0.7-alphanet)" "\0" + VALUE "FileDescription", "s y r i u s (" VERSION_AS_STRING ")" "\0" VALUE "FileVersion", VERSION_AS_STRING "\0" VALUE "InternalName", "syrius" "\0" VALUE "LegalCopyright", "(C) 2023 Zenon Network" "\0"