From b8ffd47f0624a35b987038e6217c3ea140a9d694 Mon Sep 17 00:00:00 2001 From: Elias Rad <146735585+nnsW3@users.noreply.github.com> Date: Mon, 4 Nov 2024 04:51:19 +0200 Subject: [PATCH 1/8] Docs spelling issues (#1789) * Update PRIVACY.md * Update README.md * Update SECURITY.md * Update build-guide-linux.md --- PRIVACY.md | 4 ++-- README.md | 6 +++--- SECURITY.md | 2 +- build-guide-linux.md | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/PRIVACY.md b/PRIVACY.md index 76cfcc4d3..a5c8eddfb 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -5,7 +5,7 @@ Last modified: January 24, 2024 Introduction ============ - Cake Labs LLC ("Cake Labs", "Company", or "We") respect your privacy and are committed to protecting it through our compliance with this policy. + Cake Labs LLC ("Cake Labs", "Company", or "We") respects your privacy and are committed to protecting it through our compliance with this policy. This policy describes the types of information we may collect from you or that you may provide when you use the App (our "App") and our practices for collecting, using, maintaining, protecting, and disclosing that information. @@ -13,7 +13,7 @@ Introduction - On this App. - In email, text, and other electronic messages between you and this App. It does not apply to information collected by: - - Us offline or through any other means, including on any other App operated by Company or any third party (including our affiliates and subsidiaries); or + - Us offline or through any other means, including on any other App operated by the Company or any third party (including our affiliates and subsidiaries); or - Any third party (including our affiliates and subsidiaries), including through any application or content (including advertising) that may link to or be accessible from or on the App. Please read this policy carefully to understand our policies and practices regarding your information and how we will treat it. If you do not agree with our policies and practices, you have the choice to not use the App. By accessing or using this App, you agree to this privacy policy. This policy may change from time to time. Your continued use of this App after we make changes is deemed to be acceptance of those changes, so please check the policy periodically for updates. diff --git a/README.md b/README.md index ca3061e61..078c4437e 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ # Cake Wallet -[Cake Wallet](https://cakewallet.com) is an open source, non-custodial, and private multi-currency crypto wallet for Android, iOS, macOS, and Linux. +[Cake Wallet](https://cakewallet.com) is an open-source, non-custodial, and private multi-currency crypto wallet for Android, iOS, macOS, and Linux. Cake Wallet includes support for several cryptocurrencies, including: * Monero (XMR) @@ -44,7 +44,7 @@ Cake Wallet includes support for several cryptocurrencies, including: * Create several wallets * Select your own custom nodes/servers * Address book -* Backup to external location or iCloud +* Backup to an external location or iCloud * Send to OpenAlias, Unstoppable Domains, Yats, and FIO Crypto Handles * Set desired network fee level * Store local transaction notes @@ -161,7 +161,7 @@ The only parts to be translated, if needed, are the values m and s after the var 4. Add the language to `lib/entities/language_service.dart` under both `supportedLocales` and `localeCountryCode`. Use the name of the language in the local language and in English in parentheses after for `supportedLocales`. Use the [ISO 3166-1 alpha-3 code](https://en.wikipedia.org/wiki/ISO_3166-1_alpha-3) for `localeCountryCode`. You must choose one country, so choose the country with the most native speakers of this language or is otherwise best associated with this language. -5. Add a relevant flag to `assets/images/flags/XXXX.png`, replacing XXXX with the 3 letters localeCountryCode. The image must be 42x26 pixels with a 3 pixels of transparent margin on all 4 sides. You can resize the flag with [paint.net](https://www.getpaint.net/) to 36x20 pixels, expand the canvas to 42x26 pixels with the flag anchored in the middle, and then manually delete the 3 pixels on each side to make transparent. Or you can use another program like Photoshop. +5. Add a relevant flag to `assets/images/flags/XXXX.png`, replacing XXXX with the 3 letters localeCountryCode. The image must be 42x26 pixels with 3 pixels of transparent margin on all 4 sides. You can resize the flag with [paint.net](https://www.getpaint.net/) to 36x20 pixels, expand the canvas to 42x26 pixels with the flag anchored in the middle, and then manually delete the 3 pixels on each side to make it transparent. Or you can use another program like Photoshop. 6. Add the new language code to `tool/utils/translation/translation_constants.dart` diff --git a/SECURITY.md b/SECURITY.md index a1b489b76..e7c6baa02 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,4 +9,4 @@ If you need to report a vulnerability, please either: ## Supported Versions -As we don't maintain prevoius versions of the app, only the latest release for each platform is supported and any updates will bump the version number. +As we don't maintain previous versions of the app, only the latest release for each platform is supported and any updates will bump the version number. diff --git a/build-guide-linux.md b/build-guide-linux.md index 50ecc76fe..55264fbfa 100644 --- a/build-guide-linux.md +++ b/build-guide-linux.md @@ -55,7 +55,7 @@ Need to install flutter. For this please check section [How to install flutter o ### 3. Verify Installations -Verify that the Flutter have been correctly installed on your system with the following command: +Verify that the Flutter has been correctly installed on your system with the following command: `$ flutter doctor` @@ -163,7 +163,7 @@ And then export bundle: `$ flatpak build-bundle export cake_wallet.flatpak com.cakewallet.CakeWallet` -Result file: `cake_wallet.flatpak` should be generated in current directory. +Result file: `cake_wallet.flatpak` should be generated in the current directory. For install generated flatpak file use: From 459f0d352d92150a3b6b96cf94106d060ebd92dd Mon Sep 17 00:00:00 2001 From: Serhii Date: Tue, 5 Nov 2024 11:33:24 +0200 Subject: [PATCH 2/8] Cw 805 fix bch refund address (#1782) * fix bch address format for exolix, stealth, thorchain, simpleswap * exclude SP and MWEB from the exchange address book * address pr review comments --- lib/core/address_validator.dart | 3 +-- .../provider/exolix_exchange_provider.dart | 7 +++++-- .../provider/simpleswap_exchange_provider.dart | 7 +++++-- .../provider/stealth_ex_exchange_provider.dart | 7 +++++-- .../provider/thorchain_exchange.provider.dart | 11 ++++++----- .../contact_list/contact_list_view_model.dart | 15 ++++++++++----- 6 files changed, 32 insertions(+), 18 deletions(-) diff --git a/lib/core/address_validator.dart b/lib/core/address_validator.dart index 21726fab8..9ca8a41ad 100644 --- a/lib/core/address_validator.dart +++ b/lib/core/address_validator.dart @@ -106,8 +106,7 @@ class AddressValidator extends TextValidator { case CryptoCurrency.wow: pattern = '[0-9a-zA-Z]+'; case CryptoCurrency.bch: - pattern = - '(?!bitcoincash:)[0-9a-zA-Z]*|(?!bitcoincash:)q|p[0-9a-zA-Z]{41}|(?!bitcoincash:)q|p[0-9a-zA-Z]{42}|bitcoincash:q|p[0-9a-zA-Z]{41}|bitcoincash:q|p[0-9a-zA-Z]{42}'; + pattern = '^(bitcoincash:)?(q|p)[0-9a-zA-Z]{41,42}'; case CryptoCurrency.bnb: pattern = '[0-9a-zA-Z]+'; case CryptoCurrency.hbar: diff --git a/lib/exchange/provider/exolix_exchange_provider.dart b/lib/exchange/provider/exolix_exchange_provider.dart index 8f2d5c241..5eeb6f9cf 100644 --- a/lib/exchange/provider/exolix_exchange_provider.dart +++ b/lib/exchange/provider/exolix_exchange_provider.dart @@ -141,8 +141,8 @@ class ExolixExchangeProvider extends ExchangeProvider { 'coinTo': _normalizeCurrency(request.toCurrency), 'networkFrom': _networkFor(request.fromCurrency), 'networkTo': _networkFor(request.toCurrency), - 'withdrawalAddress': request.toAddress, - 'refundAddress': request.refundAddress, + 'withdrawalAddress': _normalizeAddress(request.toAddress), + 'refundAddress': _normalizeAddress(request.refundAddress), 'rateType': _getRateType(isFixedRateMode), 'apiToken': apiKey, }; @@ -275,4 +275,7 @@ class ExolixExchangeProvider extends ExchangeProvider { return tag; } } + + String _normalizeAddress(String address) => + address.startsWith('bitcoincash:') ? address.replaceFirst('bitcoincash:', '') : address; } diff --git a/lib/exchange/provider/simpleswap_exchange_provider.dart b/lib/exchange/provider/simpleswap_exchange_provider.dart index be52b73fe..2a72ac793 100644 --- a/lib/exchange/provider/simpleswap_exchange_provider.dart +++ b/lib/exchange/provider/simpleswap_exchange_provider.dart @@ -129,8 +129,8 @@ class SimpleSwapExchangeProvider extends ExchangeProvider { "currency_to": _normalizeCurrency(request.toCurrency), "amount": request.fromAmount, "fixed": isFixedRateMode, - "user_refund_address": request.refundAddress, - "address_to": request.toAddress + "user_refund_address": _normalizeAddress(request.refundAddress), + "address_to": _normalizeAddress(request.toAddress) }; final uri = Uri.https(apiAuthority, createExchangePath, params); @@ -243,4 +243,7 @@ class SimpleSwapExchangeProvider extends ExchangeProvider { return currency.title.toLowerCase(); } } + + String _normalizeAddress(String address) => + address.startsWith('bitcoincash:') ? address.replaceFirst('bitcoincash:', '') : address; } diff --git a/lib/exchange/provider/stealth_ex_exchange_provider.dart b/lib/exchange/provider/stealth_ex_exchange_provider.dart index 601735595..123f759ef 100644 --- a/lib/exchange/provider/stealth_ex_exchange_provider.dart +++ b/lib/exchange/provider/stealth_ex_exchange_provider.dart @@ -129,8 +129,8 @@ class StealthExExchangeProvider extends ExchangeProvider { if (isFixedRateMode) 'rate_id': rateId, 'amount': isFixedRateMode ? double.parse(request.toAmount) : double.parse(request.fromAmount), - 'address': request.toAddress, - 'refund_address': request.refundAddress, + 'address': _normalizeAddress(request.toAddress), + 'refund_address': _normalizeAddress(request.refundAddress), 'additional_fee_percent': _additionalFeePercent, }; @@ -296,4 +296,7 @@ class StealthExExchangeProvider extends ExchangeProvider { return currency.tag!.toLowerCase(); } + + String _normalizeAddress(String address) => + address.startsWith('bitcoincash:') ? address.replaceFirst('bitcoincash:', '') : address; } diff --git a/lib/exchange/provider/thorchain_exchange.provider.dart b/lib/exchange/provider/thorchain_exchange.provider.dart index 897c2fdb9..99b9dcf9f 100644 --- a/lib/exchange/provider/thorchain_exchange.provider.dart +++ b/lib/exchange/provider/thorchain_exchange.provider.dart @@ -116,9 +116,7 @@ class ThorChainExchangeProvider extends ExchangeProvider { required bool isFixedRateMode, required bool isSendAll, }) async { - String formattedToAddress = request.toAddress.startsWith('bitcoincash:') - ? request.toAddress.replaceFirst('bitcoincash:', '') - : request.toAddress; + final formattedFromAmount = double.parse(request.fromAmount); @@ -126,11 +124,11 @@ class ThorChainExchangeProvider extends ExchangeProvider { 'from_asset': _normalizeCurrency(request.fromCurrency), 'to_asset': _normalizeCurrency(request.toCurrency), 'amount': _doubleToThorChainString(formattedFromAmount), - 'destination': formattedToAddress, + 'destination': _normalizeAddress(request.toAddress), 'affiliate': _affiliateName, 'affiliate_bps': _affiliateBps, 'refund_address': - isRefundAddressSupported.contains(request.fromCurrency) ? request.refundAddress : '', + isRefundAddressSupported.contains(request.fromCurrency) ? _normalizeAddress(request.refundAddress) : '', }; final responseJSON = await _getSwapQuote(params); @@ -288,4 +286,7 @@ class ThorChainExchangeProvider extends ExchangeProvider { return currentState; } + + String _normalizeAddress(String address) => + address.startsWith('bitcoincash:') ? address.replaceFirst('bitcoincash:', '') : address; } diff --git a/lib/view_model/contact_list/contact_list_view_model.dart b/lib/view_model/contact_list/contact_list_view_model.dart index 4089d988b..89192583e 100644 --- a/lib/view_model/contact_list/contact_list_view_model.dart +++ b/lib/view_model/contact_list/contact_list_view_model.dart @@ -99,18 +99,23 @@ abstract class ContactListViewModelBase with Store { Future delete(ContactRecord contact) async => contact.original.delete(); - @computed - List get contactsToShow => - contacts.where((element) => _isValidForCurrency(element)).toList(); + ObservableList get contactsToShow => + ObservableList.of(contacts.where((element) => _isValidForCurrency(element))); @computed List get walletContactsToShow => walletContacts.where((element) => _isValidForCurrency(element)).toList(); bool _isValidForCurrency(ContactBase element) { + if (element.name.contains('Silent Payments')) return false; + if (element.name.contains('MWEB')) return false; + return _currency == null || element.type == _currency || - element.type.title == _currency!.tag || - element.type.tag == _currency!.tag; + (element.type.tag != null && + _currency?.tag != null && + element.type.tag == _currency?.tag) || + _currency?.toString() == element.type.tag || + _currency?.tag == element.type.toString(); } } From 109d9b458eac30534dd93492173145a3fc48b948 Mon Sep 17 00:00:00 2001 From: Serhii Date: Thu, 7 Nov 2024 03:26:14 +0200 Subject: [PATCH 3/8] Cw 514 add sort functionality for addressbook mywallets and contacts (#1309) * add sort function to contact list * fix UI * prevent duplicate contact names * dispose contact source subscription * fix custom order issue * update the address book UI * fix saving custom order * fix merge conflict issue * review fixes [skip ci] * revert to single scroll for entire page * tabBarView address book --------- Co-authored-by: Omar Hatem --- lib/entities/contact.dart | 9 +- lib/entities/contact_record.dart | 23 +- lib/entities/preferences_key.dart | 2 + lib/entities/wallet_list_order_types.dart | 10 +- .../screens/contact/contact_list_page.dart | 388 ++++++++++++++---- .../dashboard/widgets/filter_list_widget.dart | 26 +- .../screens/wallet_list/filtered_list.dart | 46 ++- .../screens/wallet_list/wallet_list_page.dart | 2 +- .../widgets/collapsible_standart_list.dart | 39 -- lib/store/settings_store.dart | 40 +- .../contact_list/contact_list_view_model.dart | 84 +++- .../contact_list/contact_view_model.dart | 24 +- .../wallet_list/wallet_list_view_model.dart | 14 +- res/values/strings_ar.arb | 5 +- res/values/strings_bg.arb | 5 +- res/values/strings_cs.arb | 5 +- res/values/strings_en.arb | 5 +- res/values/strings_es.arb | 3 +- res/values/strings_fr.arb | 5 +- res/values/strings_ha.arb | 5 +- res/values/strings_hi.arb | 5 +- res/values/strings_hr.arb | 5 +- res/values/strings_id.arb | 5 +- res/values/strings_it.arb | 5 +- res/values/strings_ja.arb | 5 +- res/values/strings_ko.arb | 5 +- res/values/strings_my.arb | 5 +- res/values/strings_nl.arb | 5 +- res/values/strings_pl.arb | 5 +- res/values/strings_pt.arb | 2 +- res/values/strings_ru.arb | 5 +- res/values/strings_th.arb | 5 +- res/values/strings_tl.arb | 2 +- res/values/strings_tr.arb | 5 +- res/values/strings_uk.arb | 5 +- res/values/strings_ur.arb | 5 +- res/values/strings_yo.arb | 5 +- res/values/strings_zh.arb | 5 +- 38 files changed, 588 insertions(+), 236 deletions(-) delete mode 100644 lib/src/widgets/collapsible_standart_list.dart diff --git a/lib/entities/contact.dart b/lib/entities/contact.dart index cd4fa55a2..901993f43 100644 --- a/lib/entities/contact.dart +++ b/lib/entities/contact.dart @@ -7,7 +7,8 @@ part 'contact.g.dart'; @HiveType(typeId: Contact.typeId) class Contact extends HiveObject with Keyable { - Contact({required this.name, required this.address, CryptoCurrency? type}) { + Contact({required this.name, required this.address, CryptoCurrency? type, DateTime? lastChange}) + : lastChange = lastChange ?? DateTime.now() { if (type != null) { raw = type.raw; } @@ -25,6 +26,9 @@ class Contact extends HiveObject with Keyable { @HiveField(2, defaultValue: 0) late int raw; + @HiveField(3) + DateTime lastChange; + CryptoCurrency get type => CryptoCurrency.deserialize(raw: raw); @override @@ -36,6 +40,5 @@ class Contact extends HiveObject with Keyable { @override int get hashCode => key.hashCode; - void updateCryptoCurrency({required CryptoCurrency currency}) => - raw = currency.raw; + void updateCryptoCurrency({required CryptoCurrency currency}) => raw = currency.raw; } diff --git a/lib/entities/contact_record.dart b/lib/entities/contact_record.dart index 50a432727..4b0e120ba 100644 --- a/lib/entities/contact_record.dart +++ b/lib/entities/contact_record.dart @@ -1,22 +1,21 @@ +import 'package:cake_wallet/entities/contact.dart'; +import 'package:cake_wallet/entities/contact_base.dart'; +import 'package:cake_wallet/entities/record.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; -import 'package:cake_wallet/entities/contact.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cake_wallet/entities/record.dart'; -import 'package:cake_wallet/entities/contact_base.dart'; part 'contact_record.g.dart'; class ContactRecord = ContactRecordBase with _$ContactRecord; -abstract class ContactRecordBase extends Record - with Store - implements ContactBase { +abstract class ContactRecordBase extends Record with Store implements ContactBase { ContactRecordBase(Box source, Contact original) : name = original.name, address = original.address, type = original.type, - super(source, original); + lastChange = original.lastChange, + super(source, original); @override @observable @@ -30,14 +29,14 @@ abstract class ContactRecordBase extends Record @observable CryptoCurrency type; + DateTime? lastChange; + @override void toBind(Contact original) { reaction((_) => name, (String name) => original.name = name); reaction((_) => address, (String address) => original.address = address); - reaction( - (_) => type, - (CryptoCurrency currency) => - original.updateCryptoCurrency(currency: currency)); + reaction((_) => type, + (CryptoCurrency currency) => original.updateCryptoCurrency(currency: currency)); } @override diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index 4fbe358e5..73556452c 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -25,7 +25,9 @@ class PreferencesKey { static const disableBulletinKey = 'disable_bulletin'; static const defaultBuyProvider = 'default_buy_provider'; static const walletListOrder = 'wallet_list_order'; + static const contactListOrder = 'contact_list_order'; static const walletListAscending = 'wallet_list_ascending'; + static const contactListAscending = 'contact_list_ascending'; static const currentFiatApiModeKey = 'current_fiat_api_mode'; static const failedTotpTokenTrials = 'failed_token_trials'; static const disableExchangeKey = 'disable_exchange'; diff --git a/lib/entities/wallet_list_order_types.dart b/lib/entities/wallet_list_order_types.dart index f848170f4..751569e9e 100644 --- a/lib/entities/wallet_list_order_types.dart +++ b/lib/entities/wallet_list_order_types.dart @@ -1,6 +1,6 @@ import 'package:cake_wallet/generated/i18n.dart'; -enum WalletListOrderType { +enum FilterListOrderType { CreationDate, Alphabetical, GroupByType, @@ -9,13 +9,13 @@ enum WalletListOrderType { @override String toString() { switch (this) { - case WalletListOrderType.CreationDate: + case FilterListOrderType.CreationDate: return S.current.creation_date; - case WalletListOrderType.Alphabetical: + case FilterListOrderType.Alphabetical: return S.current.alphabetical; - case WalletListOrderType.GroupByType: + case FilterListOrderType.GroupByType: return S.current.group_by_type; - case WalletListOrderType.Custom: + case FilterListOrderType.Custom: return S.current.custom_drag; } } diff --git a/lib/src/screens/contact/contact_list_page.dart b/lib/src/screens/contact/contact_list_page.dart index 99f2aa251..130380b09 100644 --- a/lib/src/screens/contact/contact_list_page.dart +++ b/lib/src/screens/contact/contact_list_page.dart @@ -1,21 +1,24 @@ import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/entities/contact_base.dart'; import 'package:cake_wallet/entities/contact_record.dart'; +import 'package:cake_wallet/entities/wallet_list_order_types.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/screens/dashboard/widgets/filter_list_widget.dart'; +import 'package:cake_wallet/src/screens/wallet_list/filtered_list.dart'; +import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; +import 'package:cake_wallet/src/widgets/standard_list.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; import 'package:cake_wallet/themes/extensions/exchange_page_theme.dart'; import 'package:cake_wallet/utils/show_bar.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:flutter_slidable/flutter_slidable.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; -import 'package:cake_wallet/src/widgets/collapsible_standart_list.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_slidable/flutter_slidable.dart'; class ContactListPage extends BasePage { ContactListPage(this.contactListViewModel, this.authService); @@ -74,45 +77,101 @@ class ContactListPage extends BasePage { } @override - Widget body(BuildContext context) { - return Container( - padding: EdgeInsets.all(20.0), - child: Observer(builder: (_) { - final contacts = contactListViewModel.contactsToShow; - final walletContacts = contactListViewModel.walletContactsToShow; - return CollapsibleSectionList( - sectionCount: 2, - sectionTitleBuilder: (int sectionIndex) { - var title = S.current.contact_list_contacts; + Widget body(BuildContext context) => ContactPageBody(contactListViewModel: contactListViewModel); +} - if (sectionIndex == 0) { - title = S.current.contact_list_wallets; - } +class ContactPageBody extends StatefulWidget { + const ContactPageBody({required this.contactListViewModel}); - return Container( - padding: EdgeInsets.only(bottom: 10), - child: Text(title, style: TextStyle(fontSize: 36))); - }, - itemCounter: (int sectionIndex) => - sectionIndex == 0 ? walletContacts.length : contacts.length, - itemBuilder: (int sectionIndex, index) { - if (sectionIndex == 0) { - final walletInfo = walletContacts[index]; - return generateRaw(context, walletInfo); - } + final ContactListViewModel contactListViewModel; - final contact = contacts[index]; - final content = generateRaw(context, contact); - return contactListViewModel.isEditable - ? Slidable( - key: Key('${contact.key}'), - endActionPane: _actionPane(context, contact), - child: content, - ) - : content; - }, - ); - })); + @override + State createState() => _ContactPageBodyState(); +} + +class _ContactPageBodyState extends State with SingleTickerProviderStateMixin { + late TabController _tabController; + + @override + void initState() { + super.initState(); + _tabController = TabController(length: 2, vsync: this); + } + + @override + void dispose() { + _tabController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(left: 24), + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: TabBar( + controller: _tabController, + splashFactory: NoSplash.splashFactory, + indicatorSize: TabBarIndicatorSize.label, + isScrollable: true, + labelStyle: TextStyle( + fontSize: 18, + fontFamily: 'Lato', + fontWeight: FontWeight.w600, + color: Theme.of(context).appBarTheme.titleTextStyle!.color, + ), + unselectedLabelStyle: TextStyle( + fontSize: 18, + fontFamily: 'Lato', + fontWeight: FontWeight.w600, + color: Theme.of(context).appBarTheme.titleTextStyle!.color?.withOpacity(0.5)), + labelColor: Theme.of(context).appBarTheme.titleTextStyle!.color, + indicatorColor: Theme.of(context).appBarTheme.titleTextStyle!.color, + indicatorPadding: EdgeInsets.zero, + labelPadding: EdgeInsets.only(right: 24), + tabAlignment: TabAlignment.center, + dividerColor: Colors.transparent, + padding: EdgeInsets.zero, + tabs: [ + Tab(text: S.of(context).wallets), + Tab(text: S.of(context).contact_list_contacts), + ], + ), + ), + Expanded( + child: TabBarView( + controller: _tabController, + children: [ + _buildWalletContacts(context), + ContactListBody( + contactListViewModel: widget.contactListViewModel, + tabController: _tabController), + ], + ), + ), + ], + ), + ); + } + + Widget _buildWalletContacts(BuildContext context) { + final walletContacts = widget.contactListViewModel.walletContactsToShow; + + return ListView.builder( + shrinkWrap: true, + itemCount: walletContacts.length * 2, + itemBuilder: (context, index) { + if (index.isOdd) { + return StandardListSeparator(); + } else { + final walletInfo = walletContacts[index ~/ 2]; + return generateRaw(context, walletInfo); + } + }, + ); } Widget generateRaw(BuildContext context, ContactBase contact) { @@ -123,7 +182,7 @@ class ContactListPage extends BasePage { return GestureDetector( onTap: () async { - if (!contactListViewModel.isEditable) { + if (!widget.contactListViewModel.isEditable) { Navigator.of(context).pop(contact); return; } @@ -143,8 +202,7 @@ class ContactListPage extends BasePage { mainAxisAlignment: MainAxisAlignment.start, children: [ currencyIcon, - Expanded( - child: Padding( + Padding( padding: EdgeInsets.only(left: 12), child: Text( contact.name, @@ -154,13 +212,215 @@ class ContactListPage extends BasePage { color: Theme.of(context).extension()!.titleColor, ), ), - )) + ), ], ), ), ); } + Future showNameAndAddressDialog(BuildContext context, String name, String address) async { + return await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithTwoActions( + alertTitle: name, + alertContent: address, + rightButtonText: S.of(context).copy, + leftButtonText: S.of(context).cancel, + actionRightButton: () => Navigator.of(context).pop(true), + actionLeftButton: () => Navigator.of(context).pop(false)); + }) ?? + false; + } +} + +class ContactListBody extends StatefulWidget { + ContactListBody({required this.contactListViewModel, required this.tabController}); + + final ContactListViewModel contactListViewModel; + final TabController tabController; + + @override + State createState() => _ContactListBodyState(); +} + +class _ContactListBodyState extends State { + bool _isContactsTabActive = false; + + @override + void initState() { + super.initState(); + widget.tabController.addListener(_handleTabChange); + } + + void _handleTabChange() { + setState(() { + _isContactsTabActive = widget.tabController.index == 1; + }); + } + + @override + void dispose() { + widget.tabController.removeListener(_handleTabChange); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final contacts = widget.contactListViewModel.contacts; + return Scaffold( + body: Container( + child: FilteredList( + list: contacts, + updateFunction: widget.contactListViewModel.reorderAccordingToContactList, + canReorder: widget.contactListViewModel.isEditable, + shrinkWrap: true, + itemBuilder: (context, index) { + final contact = contacts[index]; + final contactContent = + generateContactRaw(context, contact, contacts.length == index + 1); + return GestureDetector( + key: Key('${contact.name}'), + onTap: () async { + if (!widget.contactListViewModel.isEditable) { + Navigator.of(context).pop(contact); + return; + } + + final isCopied = + await showNameAndAddressDialog(context, contact.name, contact.address); + + if (isCopied) { + await Clipboard.setData(ClipboardData(text: contact.address)); + await showBar(context, S.of(context).copied_to_clipboard); + } + }, + behavior: HitTestBehavior.opaque, + child: widget.contactListViewModel.isEditable + ? Slidable( + key: Key('${contact.key}'), + endActionPane: _actionPane(context, contact), + child: contactContent) + : contactContent, + ); + }, + ), + ), + floatingActionButton: + _isContactsTabActive ? filterButtonWidget(context, widget.contactListViewModel) : null, + ); + } + + Widget generateContactRaw(BuildContext context, ContactRecord contact, bool isLast) { + final image = contact.type.iconPath; + final currencyIcon = image != null + ? Image.asset(image, height: 24, width: 24) + : const SizedBox(height: 24, width: 24); + return Column( + children: [ + Container( + key: Key('${contact.name}'), + padding: const EdgeInsets.only(top: 16, bottom: 16, right: 24), + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + currencyIcon, + Expanded( + child: Padding( + padding: EdgeInsets.only(left: 12), + child: Text( + contact.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: Theme.of(context).extension()!.titleColor, + ), + ), + )) + ], + ), + ), + StandardListSeparator() + ], + ); + } + + ActionPane _actionPane(BuildContext context, ContactRecord contact) => ActionPane( + motion: const ScrollMotion(), + extentRatio: 0.4, + children: [ + SlidableAction( + onPressed: (_) async => await Navigator.of(context) + .pushNamed(Routes.addressBookAddContact, arguments: contact), + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + icon: Icons.edit, + label: S.of(context).edit, + ), + SlidableAction( + onPressed: (_) async { + final isDelete = await showAlertDialog(context); + + if (isDelete) { + await widget.contactListViewModel.delete(contact); + } + }, + backgroundColor: Colors.red, + foregroundColor: Colors.white, + icon: CupertinoIcons.delete, + label: S.of(context).delete, + ), + ], + ); + + Widget filterButtonWidget(BuildContext context, ContactListViewModel contactListViewModel) { + final filterIcon = Image.asset('assets/images/filter_icon.png', + color: Theme.of(context).appBarTheme.titleTextStyle!.color); + return MergeSemantics( + child: SizedBox( + height: 58, + width: 58, + child: ButtonTheme( + minWidth: double.minPositive, + child: Semantics( + container: true, + child: GestureDetector( + onTap: () async { + await showPopUp( + context: context, + builder: (context) => FilterListWidget( + initalType: contactListViewModel.orderType, + initalAscending: contactListViewModel.ascending, + onClose: (bool ascending, FilterListOrderType type) async { + contactListViewModel.setAscending(ascending); + await contactListViewModel.setOrderType(type); + }, + ), + ); + }, + child: Semantics( + label: 'Transaction Filter', + button: true, + enabled: true, + child: Container( + height: 36, + width: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).extension()!.buttonBackgroundColor, + ), + child: filterIcon, + ), + ), + ), + ), + ), + ), + ); + } + Future showAlertDialog(BuildContext context) async { return await showPopUp( context: context, @@ -190,32 +450,4 @@ class ContactListPage extends BasePage { }) ?? false; } - - ActionPane _actionPane(BuildContext context, ContactRecord contact) => ActionPane( - motion: const ScrollMotion(), - extentRatio: 0.4, - children: [ - SlidableAction( - onPressed: (_) async => await Navigator.of(context) - .pushNamed(Routes.addressBookAddContact, arguments: contact), - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - icon: Icons.edit, - label: S.of(context).edit, - ), - SlidableAction( - onPressed: (_) async { - final isDelete = await showAlertDialog(context); - - if (isDelete) { - await contactListViewModel.delete(contact); - } - }, - backgroundColor: Colors.red, - foregroundColor: Colors.white, - icon: CupertinoIcons.delete, - label: S.of(context).delete, - ), - ], - ); } diff --git a/lib/src/screens/dashboard/widgets/filter_list_widget.dart b/lib/src/screens/dashboard/widgets/filter_list_widget.dart index cda4f5f10..8e95de2af 100644 --- a/lib/src/screens/dashboard/widgets/filter_list_widget.dart +++ b/lib/src/screens/dashboard/widgets/filter_list_widget.dart @@ -18,9 +18,9 @@ class FilterListWidget extends StatefulWidget { required this.onClose, }); - final WalletListOrderType? initalType; + final FilterListOrderType? initalType; final bool initalAscending; - final Function(bool, WalletListOrderType) onClose; + final Function(bool, FilterListOrderType) onClose; @override FilterListWidgetState createState() => FilterListWidgetState(); @@ -28,7 +28,7 @@ class FilterListWidget extends StatefulWidget { class FilterListWidgetState extends State { late bool ascending; - late WalletListOrderType? type; + late FilterListOrderType? type; @override void initState() { @@ -37,7 +37,7 @@ class FilterListWidgetState extends State { type = widget.initalType; } - void setSelectedOrderType(WalletListOrderType? orderType) { + void setSelectedOrderType(FilterListOrderType? orderType) { setState(() { type = orderType; }); @@ -72,7 +72,7 @@ class FilterListWidgetState extends State { ), ), ), - if (type != WalletListOrderType.Custom) ...[ + if (type != FilterListOrderType.Custom) ...[ sectionDivider, SettingsChoicesCell( ChoicesListItem( @@ -89,10 +89,10 @@ class FilterListWidgetState extends State { ], sectionDivider, RadioListTile( - value: WalletListOrderType.CreationDate, + value: FilterListOrderType.CreationDate, groupValue: type, title: Text( - WalletListOrderType.CreationDate.toString(), + FilterListOrderType.CreationDate.toString(), style: TextStyle( color: Theme.of(context).extension()!.titleColor, fontSize: 16, @@ -104,10 +104,10 @@ class FilterListWidgetState extends State { activeColor: Theme.of(context).primaryColor, ), RadioListTile( - value: WalletListOrderType.Alphabetical, + value: FilterListOrderType.Alphabetical, groupValue: type, title: Text( - WalletListOrderType.Alphabetical.toString(), + FilterListOrderType.Alphabetical.toString(), style: TextStyle( color: Theme.of(context).extension()!.titleColor, fontSize: 16, @@ -119,10 +119,10 @@ class FilterListWidgetState extends State { activeColor: Theme.of(context).primaryColor, ), RadioListTile( - value: WalletListOrderType.GroupByType, + value: FilterListOrderType.GroupByType, groupValue: type, title: Text( - WalletListOrderType.GroupByType.toString(), + FilterListOrderType.GroupByType.toString(), style: TextStyle( color: Theme.of(context).extension()!.titleColor, fontSize: 16, @@ -134,10 +134,10 @@ class FilterListWidgetState extends State { activeColor: Theme.of(context).primaryColor, ), RadioListTile( - value: WalletListOrderType.Custom, + value: FilterListOrderType.Custom, groupValue: type, title: Text( - WalletListOrderType.Custom.toString(), + FilterListOrderType.Custom.toString(), style: TextStyle( color: Theme.of(context).extension()!.titleColor, fontSize: 16, diff --git a/lib/src/screens/wallet_list/filtered_list.dart b/lib/src/screens/wallet_list/filtered_list.dart index 63a1ae392..5316c8472 100644 --- a/lib/src/screens/wallet_list/filtered_list.dart +++ b/lib/src/screens/wallet_list/filtered_list.dart @@ -7,13 +7,17 @@ class FilteredList extends StatefulWidget { required this.list, required this.itemBuilder, required this.updateFunction, + this.canReorder = true, this.shrinkWrap = false, + this.physics, }); final ObservableList list; final Widget Function(BuildContext, int) itemBuilder; final Function updateFunction; + final bool canReorder; final bool shrinkWrap; + final ScrollPhysics? physics; @override FilteredListState createState() => FilteredListState(); @@ -22,21 +26,31 @@ class FilteredList extends StatefulWidget { class FilteredListState extends State { @override Widget build(BuildContext context) { - return Observer( - builder: (_) => ReorderableListView.builder( - shrinkWrap: widget.shrinkWrap, - physics: const BouncingScrollPhysics(), - itemBuilder: widget.itemBuilder, - itemCount: widget.list.length, - onReorder: (int oldIndex, int newIndex) { - if (oldIndex < newIndex) { - newIndex -= 1; - } - final dynamic item = widget.list.removeAt(oldIndex); - widget.list.insert(newIndex, item); - widget.updateFunction(); - }, - ), - ); + if (widget.canReorder) { + return Observer( + builder: (_) => ReorderableListView.builder( + shrinkWrap: widget.shrinkWrap, + physics: widget.physics ?? const BouncingScrollPhysics(), + itemBuilder: widget.itemBuilder, + itemCount: widget.list.length, + onReorder: (int oldIndex, int newIndex) { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final dynamic item = widget.list.removeAt(oldIndex); + widget.list.insert(newIndex, item); + widget.updateFunction(); + }, + ), + ); + } else { + return Observer( + builder: (_) => ListView.builder( + physics: widget.physics ?? const BouncingScrollPhysics(), + itemBuilder: widget.itemBuilder, + itemCount: widget.list.length, + ), + ); + } } } diff --git a/lib/src/screens/wallet_list/wallet_list_page.dart b/lib/src/screens/wallet_list/wallet_list_page.dart index d17534f6b..afb86cf0a 100644 --- a/lib/src/screens/wallet_list/wallet_list_page.dart +++ b/lib/src/screens/wallet_list/wallet_list_page.dart @@ -59,7 +59,7 @@ class WalletListPage extends BasePage { builder: (context) => FilterListWidget( initalType: walletListViewModel.orderType, initalAscending: walletListViewModel.ascending, - onClose: (bool ascending, WalletListOrderType type) async { + onClose: (bool ascending, FilterListOrderType type) async { walletListViewModel.setAscending(ascending); await walletListViewModel.setOrderType(type); }, diff --git a/lib/src/widgets/collapsible_standart_list.dart b/lib/src/widgets/collapsible_standart_list.dart deleted file mode 100644 index 83e4daee2..000000000 --- a/lib/src/widgets/collapsible_standart_list.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:cake_wallet/src/widgets/standard_list.dart'; -import 'package:flutter/material.dart'; - -class CollapsibleSectionList extends SectionStandardList { - CollapsibleSectionList( - {required int sectionCount, - required int Function(int sectionIndex) itemCounter, - required Widget Function(int sectionIndex, int itemIndex) itemBuilder, - Widget Function(int sectionIndex)? sectionTitleBuilder, - bool hasTopSeparator = false}) - : super( - hasTopSeparator: hasTopSeparator, - sectionCount: sectionCount, - itemCounter: itemCounter, - itemBuilder: itemBuilder, - sectionTitleBuilder: sectionTitleBuilder); - - @override - Widget buildTitle(List items, int sectionIndex) { - if (sectionTitleBuilder == null) { - throw Exception('Cannot to build title. sectionTitleBuilder is null'); - } - return sectionTitleBuilder!.call(sectionIndex); - } - - @override - List buildSection(int itemCount, List items, int sectionIndex) { - final List section = []; - - for (var itemIndex = 0; itemIndex < itemCount; itemIndex++) { - final item = itemBuilder(sectionIndex, itemIndex); - - section.add(StandardListSeparator()); - - section.add(item); - } - return section; - } -} diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index 9f03c95c3..2eadbd8a9 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -62,9 +62,11 @@ abstract class SettingsStoreBase with Store { required bool initialAppSecure, required bool initialDisableBuy, required bool initialDisableSell, + required FilterListOrderType initialWalletListOrder, + required FilterListOrderType initialContactListOrder, required bool initialDisableBulletin, - required WalletListOrderType initialWalletListOrder, required bool initialWalletListAscending, + required bool initialContactListAscending, required FiatApiMode initialFiatMode, required bool initialAllowBiometricalAuthentication, required String initialTotpSecretKey, @@ -149,7 +151,9 @@ abstract class SettingsStoreBase with Store { disableSell = initialDisableSell, disableBulletin = initialDisableBulletin, walletListOrder = initialWalletListOrder, + contactListOrder = initialContactListOrder, walletListAscending = initialWalletListAscending, + contactListAscending = initialContactListAscending, shouldShowMarketPlaceInDashboard = initialShouldShowMarketPlaceInDashboard, exchangeStatus = initialExchangeStatus, currentTheme = initialTheme, @@ -324,14 +328,24 @@ abstract class SettingsStoreBase with Store { reaction( (_) => walletListOrder, - (WalletListOrderType walletListOrder) => + (FilterListOrderType walletListOrder) => sharedPreferences.setInt(PreferencesKey.walletListOrder, walletListOrder.index)); + reaction( + (_) => contactListOrder, + (FilterListOrderType contactListOrder) => + sharedPreferences.setInt(PreferencesKey.contactListOrder, contactListOrder.index)); + reaction( (_) => walletListAscending, (bool walletListAscending) => sharedPreferences.setBool(PreferencesKey.walletListAscending, walletListAscending)); + reaction( + (_) => contactListAscending, + (bool contactListAscending) => + sharedPreferences.setBool(PreferencesKey.contactListAscending, contactListAscending)); + reaction( (_) => autoGenerateSubaddressStatus, (AutoGenerateSubaddressStatus autoGenerateSubaddressStatus) => sharedPreferences.setInt( @@ -645,15 +659,21 @@ abstract class SettingsStoreBase with Store { @observable bool disableSell; + @observable + FilterListOrderType contactListOrder; + @observable bool disableBulletin; @observable - WalletListOrderType walletListOrder; + FilterListOrderType walletListOrder; @observable bool walletListAscending; + @observable + bool contactListAscending; + @observable bool allowBiometricalAuthentication; @@ -907,9 +927,13 @@ abstract class SettingsStoreBase with Store { final disableSell = sharedPreferences.getBool(PreferencesKey.disableSellKey) ?? false; final disableBulletin = sharedPreferences.getBool(PreferencesKey.disableBulletinKey) ?? false; final walletListOrder = - WalletListOrderType.values[sharedPreferences.getInt(PreferencesKey.walletListOrder) ?? 0]; + FilterListOrderType.values[sharedPreferences.getInt(PreferencesKey.walletListOrder) ?? 0]; + final contactListOrder = + FilterListOrderType.values[sharedPreferences.getInt(PreferencesKey.contactListOrder) ?? 0]; final walletListAscending = sharedPreferences.getBool(PreferencesKey.walletListAscending) ?? true; + final contactListAscending = + sharedPreferences.getBool(PreferencesKey.contactListAscending) ?? true; final currentFiatApiMode = FiatApiMode.deserialize( raw: sharedPreferences.getInt(PreferencesKey.currentFiatApiModeKey) ?? FiatApiMode.enabled.raw); @@ -1200,6 +1224,8 @@ abstract class SettingsStoreBase with Store { initialDisableBulletin: disableBulletin, initialWalletListOrder: walletListOrder, initialWalletListAscending: walletListAscending, + initialContactListOrder: contactListOrder, + initialContactListAscending: contactListAscending, initialFiatMode: currentFiatApiMode, initialAllowBiometricalAuthentication: allowBiometricalAuthentication, initialCake2FAPresetOptions: selectedCake2FAPreset, @@ -1348,9 +1374,11 @@ abstract class SettingsStoreBase with Store { disableBulletin = sharedPreferences.getBool(PreferencesKey.disableBulletinKey) ?? disableBulletin; walletListOrder = - WalletListOrderType.values[sharedPreferences.getInt(PreferencesKey.walletListOrder) ?? 0]; + FilterListOrderType.values[sharedPreferences.getInt(PreferencesKey.walletListOrder) ?? 0]; + contactListOrder = + FilterListOrderType.values[sharedPreferences.getInt(PreferencesKey.contactListOrder) ?? 0]; walletListAscending = sharedPreferences.getBool(PreferencesKey.walletListAscending) ?? true; - + contactListAscending = sharedPreferences.getBool(PreferencesKey.contactListAscending) ?? true; shouldShowMarketPlaceInDashboard = sharedPreferences.getBool(PreferencesKey.shouldShowMarketPlaceInDashboard) ?? shouldShowMarketPlaceInDashboard; diff --git a/lib/view_model/contact_list/contact_list_view_model.dart b/lib/view_model/contact_list/contact_list_view_model.dart index 89192583e..0015463a5 100644 --- a/lib/view_model/contact_list/contact_list_view_model.dart +++ b/lib/view_model/contact_list/contact_list_view_model.dart @@ -1,18 +1,20 @@ import 'dart:async'; + import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart'; +import 'package:cake_wallet/entities/contact.dart'; import 'package:cake_wallet/entities/contact_base.dart'; +import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/wallet_contact.dart'; +import 'package:cake_wallet/entities/wallet_list_order_types.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cake_wallet/utils/mobx.dart'; +import 'package:collection/collection.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; -import 'package:cake_wallet/entities/contact_record.dart'; -import 'package:cake_wallet/entities/contact.dart'; -import 'package:cake_wallet/utils/mobx.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:collection/collection.dart'; part 'contact_list_view_model.g.dart'; @@ -75,6 +77,8 @@ abstract class ContactListViewModelBase with Store { _subscription = contactSource.bindToListWithTransform( contacts, (Contact contact) => ContactRecord(contactSource, contact), initialFire: true); + + setOrderType(settingsStore.contactListOrder); } String _createName(String walletName, String label, {int? key = null}) { @@ -93,6 +97,10 @@ abstract class ContactListViewModelBase with Store { bool get isEditable => _currency == null; + FilterListOrderType? get orderType => settingsStore.contactListOrder; + + bool get ascending => settingsStore.contactListAscending; + @computed bool get shouldRequireTOTP2FAForAddingContacts => settingsStore.shouldRequireTOTP2FAForAddingContacts; @@ -118,4 +126,70 @@ abstract class ContactListViewModelBase with Store { _currency?.toString() == element.type.tag || _currency?.tag == element.type.toString(); } + + void dispose() async { + _subscription?.cancel(); + final List contactsSourceCopy = contacts.map((e) => e.original).toList(); + await reorderContacts(contactsSourceCopy); + } + + void reorderAccordingToContactList() => + settingsStore.contactListOrder = FilterListOrderType.Custom; + + Future reorderContacts(List contactCopy) async { + await contactSource.deleteAll(contactCopy.map((e) => e.key).toList()); + await contactSource.addAll(contactCopy); + } + + Future sortGroupByType() async { + List contactsSourceCopy = contactSource.values.toList(); + + contactsSourceCopy.sort((a, b) => ascending + ? a.type.toString().compareTo(b.type.toString()) + : b.type.toString().compareTo(a.type.toString())); + + await reorderContacts(contactsSourceCopy); + } + + Future sortAlphabetically() async { + List contactsSourceCopy = contactSource.values.toList(); + + contactsSourceCopy + .sort((a, b) => ascending ? a.name.compareTo(b.name) : b.name.compareTo(a.name)); + + await reorderContacts(contactsSourceCopy); + } + + Future sortByCreationDate() async { + List contactsSourceCopy = contactSource.values.toList(); + + contactsSourceCopy.sort((a, b) => + ascending ? a.lastChange.compareTo(b.lastChange) : b.lastChange.compareTo(a.lastChange)); + + await reorderContacts(contactsSourceCopy); + } + + void setAscending(bool ascending) => settingsStore.contactListAscending = ascending; + + Future setOrderType(FilterListOrderType? type) async { + if (type == null) return; + + settingsStore.contactListOrder = type; + + switch (type) { + case FilterListOrderType.CreationDate: + await sortByCreationDate(); + break; + case FilterListOrderType.Alphabetical: + await sortAlphabetically(); + break; + case FilterListOrderType.GroupByType: + await sortGroupByType(); + break; + case FilterListOrderType.Custom: + default: + reorderAccordingToContactList(); + break; + } + } } diff --git a/lib/view_model/contact_list/contact_view_model.dart b/lib/view_model/contact_list/contact_view_model.dart index 053cfe4c5..93abfb11c 100644 --- a/lib/view_model/contact_list/contact_view_model.dart +++ b/lib/view_model/contact_list/contact_view_model.dart @@ -2,7 +2,7 @@ import 'package:cake_wallet/entities/contact_record.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cw_core/wallet_base.dart'; +import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/entities/contact.dart'; import 'package:cw_core/crypto_currency.dart'; @@ -17,7 +17,9 @@ abstract class ContactViewModelBase with Store { _contact = contact, name = contact?.name ?? '', address = contact?.address ?? '', - currency = contact?.type; + currency = contact?.type, + lastChange = contact?.lastChange; + @observable ExecutionState state; @@ -31,6 +33,8 @@ abstract class ContactViewModelBase with Store { @observable CryptoCurrency? currency; + DateTime? lastChange; + @computed bool get isReady => name.isNotEmpty && @@ -51,20 +55,32 @@ abstract class ContactViewModelBase with Store { Future save() async { try { state = IsExecutingState(); + final now = DateTime.now(); + + if (doesContactNameExist(name)) { + state = FailureState(S.current.contact_name_exists); + return; + } if (_contact != null && _contact!.original.isInBox) { _contact?.name = name; _contact?.address = address; _contact?.type = currency!; + _contact?.lastChange = now; await _contact?.save(); } else { await _contacts - .add(Contact(name: name, address: address, type: currency!)); + .add(Contact(name: name, address: address, type: currency!, lastChange: now)); } + lastChange = now; state = ExecutedSuccessfullyState(); } catch (e) { state = FailureState(e.toString()); } } -} + + bool doesContactNameExist(String name) { + return _contacts.values.any((contact) => contact.name == name); + } +} \ No newline at end of file diff --git a/lib/view_model/wallet_list/wallet_list_view_model.dart b/lib/view_model/wallet_list/wallet_list_view_model.dart index 0ae6b75a5..725af843f 100644 --- a/lib/view_model/wallet_list/wallet_list_view_model.dart +++ b/lib/view_model/wallet_list/wallet_list_view_model.dart @@ -76,7 +76,7 @@ abstract class WalletListViewModelBase with Store { await _appStore.changeCurrentWallet(wallet); } - WalletListOrderType? get orderType => _appStore.settingsStore.walletListOrder; + FilterListOrderType? get orderType => _appStore.settingsStore.walletListOrder; bool get ascending => _appStore.settingsStore.walletListAscending; @@ -108,7 +108,7 @@ abstract class WalletListViewModelBase with Store { return; } - _appStore.settingsStore.walletListOrder = WalletListOrderType.Custom; + _appStore.settingsStore.walletListOrder = FilterListOrderType.Custom; // make a copy of the walletInfoSource: List walletInfoSourceCopy = _walletInfoSource.values.toList(); @@ -186,22 +186,22 @@ abstract class WalletListViewModelBase with Store { _appStore.settingsStore.walletListAscending = ascending; } - Future setOrderType(WalletListOrderType? type) async { + Future setOrderType(FilterListOrderType? type) async { if (type == null) return; _appStore.settingsStore.walletListOrder = type; switch (type) { - case WalletListOrderType.CreationDate: + case FilterListOrderType.CreationDate: await sortByCreationDate(); break; - case WalletListOrderType.Alphabetical: + case FilterListOrderType.Alphabetical: await sortAlphabetically(); break; - case WalletListOrderType.GroupByType: + case FilterListOrderType.GroupByType: await sortGroupByType(); break; - case WalletListOrderType.Custom: + case FilterListOrderType.Custom: default: await reorderAccordingToWalletList(); break; diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index 81fe3cc2c..c2be1504a 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -937,5 +937,6 @@ "you_pay": "انت تدفع", "you_will_get": "حول الى", "you_will_send": "تحويل من", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": " .ﻒﻠﺘﺨﻣ ﻢﺳﺍ ﺭﺎﻴﺘﺧﺍ ءﺎﺟﺮﻟﺍ .ﻞﻌﻔﻟﺎﺑ ﺓﺩﻮﺟﻮﻣ ﻢﺳﻻﺍ ﺍﺬﻬﺑ ﻝﺎﺼﺗﺍ ﺔﻬﺟ" +} diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index 50db1610a..8b68d0d1e 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -937,5 +937,6 @@ "you_pay": "Вие плащате", "you_will_get": "Обръщане в", "you_will_send": "Обръщане от", - "yy": "гг" -} \ No newline at end of file + "yy": "гг", + "contact_name_exists": "Вече съществува контакт с това име. Моля, изберете друго име." +} diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index ddc91340b..9b48ebdc7 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -937,5 +937,6 @@ "you_pay": "Zaplatíte", "you_will_get": "Směnit na", "you_will_send": "Směnit z", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "Kontakt s tímto jménem již existuje. Vyberte prosím jiný název." +} diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index d6a0ee9af..4f5fd23b3 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -940,5 +940,6 @@ "you_pay": "You Pay", "you_will_get": "Convert to", "you_will_send": "Convert from", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "A contact with that name already exists. Please choose a different name." +} diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 25c9f95c1..fd20a8b30 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -938,5 +938,6 @@ "you_pay": "Tú pagas", "you_will_get": "Convertir a", "you_will_send": "Convertir de", - "yy": "YY" + "yy": "YY", + "contact_name_exists": "Ya existe un contacto con ese nombre. Elija un nombre diferente." } diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index be5b48dd8..50aceb2f1 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -937,5 +937,6 @@ "you_pay": "Vous payez", "you_will_get": "Convertir vers", "you_will_send": "Convertir depuis", - "yy": "AA" -} \ No newline at end of file + "yy": "AA", + "contact_name_exists": "Un contact portant ce nom existe déjà. Veuillez choisir un autre nom." +} diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index 4deb0df1d..e66859531 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -939,5 +939,6 @@ "you_pay": "Ka Bayar", "you_will_get": "Maida zuwa", "you_will_send": "Maida daga", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "An riga an sami lamba tare da wannan sunan. Da fatan za a zaɓi suna daban." +} diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index 5161250fc..bc8b81211 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -939,5 +939,6 @@ "you_pay": "आप भुगतान करते हैं", "you_will_get": "में बदलें", "you_will_send": "से रूपांतरित करें", - "yy": "वाईवाई" -} \ No newline at end of file + "yy": "वाईवाई", + "contact_name_exists": "उस नाम का एक संपर्क पहले से मौजूद है. कृपया कोई भिन्न नाम चुनें." +} diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index 8ef92aaf0..9ff57707d 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -937,5 +937,6 @@ "you_pay": "Vi plaćate", "you_will_get": "Razmijeni u", "you_will_send": "Razmijeni iz", - "yy": "GG" -} \ No newline at end of file + "yy": "GG", + "contact_name_exists": "Kontakt s tim imenom već postoji. Odaberite drugo ime." +} diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index 5f93082ec..2858fa193 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -940,5 +940,6 @@ "you_pay": "Anda Membayar", "you_will_get": "Konversi ke", "you_will_send": "Konversi dari", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "Kontak dengan nama tersebut sudah ada. Silakan pilih nama lain." +} diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index 08ae928af..034376a6c 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -940,5 +940,6 @@ "you_pay": "Tu paghi", "you_will_get": "Converti a", "you_will_send": "Conveti da", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "Esiste già un contatto con quel nome. Scegli un nome diverso." +} diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index d70eca31b..7f0792063 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -938,5 +938,6 @@ "you_pay": "あなたが支払う", "you_will_get": "に変換", "you_will_send": "から変換", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "その名前の連絡先はすでに存在します。別の名前を選択してください。" +} diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index 133ca1838..6ba402ce3 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -939,5 +939,6 @@ "you_will_get": "로 변환하다", "you_will_send": "다음에서 변환", "YY": "YY", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "해당 이름을 가진 연락처가 이미 존재합니다. 다른 이름을 선택하세요." +} diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 1727f0d71..69b888b5a 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -937,5 +937,6 @@ "you_pay": "သင်ပေးချေပါ။", "you_will_get": "သို့ပြောင်းပါ။", "you_will_send": "မှပြောင်းပါ။", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "ထိုအမည်နှင့် အဆက်အသွယ်တစ်ခု ရှိနှင့်ပြီးဖြစ်သည်။ အခြားအမည်တစ်ခုကို ရွေးပါ။" +} diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index 3f2df531b..2c259bf80 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -938,5 +938,6 @@ "you_pay": "U betaalt", "you_will_get": "Converteren naar", "you_will_send": "Converteren van", - "yy": "JJ" -} \ No newline at end of file + "yy": "JJ", + "contact_name_exists": "Er bestaat al een contact met die naam. Kies een andere naam." +} diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index 91b265144..936339708 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -937,5 +937,6 @@ "you_pay": "Płacisz", "you_will_get": "Konwertuj na", "you_will_send": "Konwertuj z", - "yy": "RR" -} \ No newline at end of file + "yy": "RR", + "contact_name_exists": "Kontakt o tej nazwie już istnieje. Proszę wybrać inną nazwę." +} diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index 524dbcace..0cc0cc00a 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -941,4 +941,4 @@ "you_will_get": "Converter para", "you_will_send": "Converter de", "yy": "aa" -} \ No newline at end of file +} diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index 1a8c2447f..8aeb56a86 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -938,5 +938,6 @@ "you_pay": "Вы платите", "you_will_get": "Конвертировать в", "you_will_send": "Конвертировать из", - "yy": "ГГ" -} \ No newline at end of file + "yy": "ГГ", + "contact_name_exists": "Контакт с таким именем уже существует. Пожалуйста, выберите другое имя." +} diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index 213f74530..736eb8762 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -937,5 +937,6 @@ "you_pay": "คุณจ่าย", "you_will_get": "แปลงเป็น", "you_will_send": "แปลงจาก", - "yy": "ปี" -} \ No newline at end of file + "yy": "ปี", + "contact_name_exists": "มีผู้ติดต่อชื่อนั้นอยู่แล้ว โปรดเลือกชื่ออื่น" +} diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index 0ca8ee665..e68bd0b22 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -938,4 +938,4 @@ "you_will_get": "I-convert sa", "you_will_send": "I-convert mula sa", "yy": "YY" -} \ No newline at end of file +} diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index b23f64d60..83e8f6cbc 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -937,5 +937,6 @@ "you_pay": "Şu kadar ödeyeceksin: ", "you_will_get": "Biçimine dönüştür:", "you_will_send": "Biçiminden dönüştür:", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "Bu isimde bir kişi zaten mevcut. Lütfen farklı bir ad seçin." +} diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index 79dc0543f..909d9c01f 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -938,5 +938,6 @@ "you_pay": "Ви платите", "you_will_get": "Конвертувати в", "you_will_send": "Конвертувати з", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "Контакт із такою назвою вже існує. Виберіть інше ім'я." +} diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 0a136d140..2dbc97102 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -939,5 +939,6 @@ "you_pay": "تم ادا کرو", "you_will_get": "میں تبدیل کریں۔", "you_will_send": "سے تبدیل کریں۔", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": " ۔ﮟﯾﺮﮐ ﺐﺨﺘﻨﻣ ﻡﺎﻧ ﻒﻠﺘﺨﻣ ﮏﯾﺍ ﻡﺮﮐ ﮦﺍﺮﺑ ۔ﮯﮨ ﺩﻮﺟﻮﻣ ﮯﺳ ﮯﻠﮩﭘ ﮧﻄﺑﺍﺭ ﮏﯾﺍ ﮫﺗﺎﺳ ﮯﮐ ﻡﺎﻧ ﺱﺍ" +} diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index 14270120c..03c7c95ef 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -938,5 +938,6 @@ "you_pay": "Ẹ sàn", "you_will_get": "Ṣe pàṣípààrọ̀ sí", "you_will_send": "Ṣe pàṣípààrọ̀ láti", - "yy": "Ọd" -} \ No newline at end of file + "yy": "Ọd", + "contact_name_exists": "Olubasọrọ pẹlu orukọ yẹn ti wa tẹlẹ. Jọwọ yan orukọ ti o yatọ." +} diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index 65047b4fe..5c8d708f4 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -937,5 +937,6 @@ "you_pay": "你付钱", "you_will_get": "转换到", "you_will_send": "转换自", - "yy": "YY" -} \ No newline at end of file + "yy": "YY", + "contact_name_exists": "已存在具有该名称的联系人。请选择不同的名称。" +} From c8cfc2cff1026c4c433d0f9e683a2c4cbb4023eb Mon Sep 17 00:00:00 2001 From: Matthew Fosse Date: Wed, 6 Nov 2024 18:57:36 -0800 Subject: [PATCH 4/8] Mweb enhancements 4 (#1768) * [skip-ci] show mweb confirmations, show last mweb balance while syncing * potential send-all fix * [skip-ci] undo fix that didn't work * [skip-ci] undo unnecessary changes * [skip ci] add export mweb logs screen * [skip ci] cleanup * confirmation fixes * catch electrum call errors * [skip ci] undo some changes * potential electrum fixes + mweb logs display only last 10000 characters * Add question mark and link to MWEB card * updates * show negative unconfirmed mweb balanaces + other fixes [skip ci] * error handling * [skip ci] [wip] check if node supports mweb * check fee before building tx * [skip ci] minor * [skip ci] minor * mweb node setting [wip] [skip ci] * prioritize mweb coins when selecting inputs from the pool * potential connection edgecase fix * translations + mweb node fixes * don't use mweb for exchange refund address * add peg in / out labels and make 6 confs only show up for peg in / out * bump bitcoin_base version to v9 * [skip ci] fix logs page * don't fetch txinfo for non-mweb addresses [skip ci] * fix non-mweb confirmations * rename always scan to enable mweb * Update litecoin_wallet_addresses.dart Co-authored-by: Omar Hatem * Update cw_mweb.dart Co-authored-by: Omar Hatem * [skip ci] review updates pt.1 * [skip ci] minor code cleanup * [skip ci] use exception handler * exception handling [skip ci] * [skip ci] exception handling * trigger build * pegout label fixes * fix showing change transactions on peg-out * minor code cleanup and minor peg-out fix * final balance fixes * non-mweb confirmations potential fix * [skip ci] wip * trigger build --------- Co-authored-by: tuxpizza Co-authored-by: Omar Hatem --- cw_bitcoin/lib/electrum.dart | 2 + cw_bitcoin/lib/electrum_balance.dart | 12 +- cw_bitcoin/lib/electrum_transaction_info.dart | 9 +- cw_bitcoin/lib/electrum_wallet.dart | 130 +++++--- cw_bitcoin/lib/litecoin_wallet.dart | 310 ++++++++++++------ cw_bitcoin/lib/litecoin_wallet_addresses.dart | 17 +- cw_bitcoin/lib/litecoin_wallet_service.dart | 4 + .../lib/pending_bitcoin_transaction.dart | 3 +- cw_bitcoin/pubspec.yaml | 2 +- cw_bitcoin_cash/pubspec.yaml | 2 +- cw_core/lib/mweb_utxo.dart | 4 + cw_core/lib/node.dart | 3 + cw_core/lib/transaction_info.dart | 3 +- cw_mweb/lib/cw_mweb.dart | 28 +- lib/di.dart | 6 + lib/entities/preferences_key.dart | 1 + lib/router.dart | 10 + lib/routes.dart | 2 + .../screens/dashboard/pages/balance_page.dart | 42 ++- lib/src/screens/settings/mweb_logs_page.dart | 127 +++++++ lib/src/screens/settings/mweb_node_page.dart | 56 ++++ lib/src/screens/settings/mweb_settings.dart | 10 +- lib/store/settings_store.dart | 25 +- .../dashboard/balance_view_model.dart | 2 +- .../dashboard/transaction_list_item.dart | 52 ++- .../settings/mweb_settings_view_model.dart | 50 +++ pubspec_base.yaml | 2 +- res/values/strings_ar.arb | 4 + res/values/strings_bg.arb | 4 + res/values/strings_cs.arb | 4 + res/values/strings_de.arb | 6 +- res/values/strings_en.arb | 4 + res/values/strings_es.arb | 4 + res/values/strings_fr.arb | 4 + res/values/strings_ha.arb | 4 + res/values/strings_hi.arb | 4 + res/values/strings_hr.arb | 4 + res/values/strings_hy.arb | 4 + res/values/strings_id.arb | 4 + res/values/strings_it.arb | 4 + res/values/strings_ja.arb | 4 + res/values/strings_ko.arb | 4 + res/values/strings_my.arb | 4 + res/values/strings_nl.arb | 4 + res/values/strings_pl.arb | 4 + res/values/strings_pt.arb | 4 + res/values/strings_ru.arb | 4 + res/values/strings_th.arb | 4 + res/values/strings_tl.arb | 4 + res/values/strings_tr.arb | 4 + res/values/strings_uk.arb | 4 + res/values/strings_ur.arb | 4 + res/values/strings_vi.arb | 4 + res/values/strings_yo.arb | 4 + res/values/strings_zh.arb | 4 + 55 files changed, 821 insertions(+), 207 deletions(-) create mode 100644 lib/src/screens/settings/mweb_logs_page.dart create mode 100644 lib/src/screens/settings/mweb_node_page.dart diff --git a/cw_bitcoin/lib/electrum.dart b/cw_bitcoin/lib/electrum.dart index a18c038fa..df8e14119 100644 --- a/cw_bitcoin/lib/electrum.dart +++ b/cw_bitcoin/lib/electrum.dart @@ -124,6 +124,7 @@ class ElectrumClient { final errorMsg = error.toString(); print(errorMsg); unterminatedString = ''; + socket = null; }, onDone: () { print("SOCKET CLOSED!!!!!"); @@ -132,6 +133,7 @@ class ElectrumClient { if (host == socket?.address.host || socket == null) { _setConnectionStatus(ConnectionStatus.disconnected); socket?.destroy(); + socket = null; } } catch (e) { print("onDone: $e"); diff --git a/cw_bitcoin/lib/electrum_balance.dart b/cw_bitcoin/lib/electrum_balance.dart index 4e37f40b1..ebd2f06ae 100644 --- a/cw_bitcoin/lib/electrum_balance.dart +++ b/cw_bitcoin/lib/electrum_balance.dart @@ -24,9 +24,12 @@ class ElectrumBalance extends Balance { final decoded = json.decode(jsonSource) as Map; return ElectrumBalance( - confirmed: decoded['confirmed'] as int? ?? 0, - unconfirmed: decoded['unconfirmed'] as int? ?? 0, - frozen: decoded['frozen'] as int? ?? 0); + confirmed: decoded['confirmed'] as int? ?? 0, + unconfirmed: decoded['unconfirmed'] as int? ?? 0, + frozen: decoded['frozen'] as int? ?? 0, + secondConfirmed: decoded['secondConfirmed'] as int? ?? 0, + secondUnconfirmed: decoded['secondUnconfirmed'] as int? ?? 0, + ); } int confirmed; @@ -36,8 +39,7 @@ class ElectrumBalance extends Balance { int secondUnconfirmed = 0; @override - String get formattedAvailableBalance => - bitcoinAmountToString(amount: confirmed - frozen); + String get formattedAvailableBalance => bitcoinAmountToString(amount: confirmed - frozen); @override String get formattedAdditionalBalance => bitcoinAmountToString(amount: unconfirmed); diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index 1ab7799e3..7a8b3b951 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -41,6 +41,7 @@ class ElectrumTransactionInfo extends TransactionInfo { String? to, this.unspents, this.isReceivedSilentPayment = false, + Map? additionalInfo, }) { this.id = id; this.height = height; @@ -54,6 +55,7 @@ class ElectrumTransactionInfo extends TransactionInfo { this.isReplaced = isReplaced; this.confirmations = confirmations; this.to = to; + this.additionalInfo = additionalInfo ?? {}; } factory ElectrumTransactionInfo.fromElectrumVerbose(Map obj, WalletType type, @@ -212,6 +214,7 @@ class ElectrumTransactionInfo extends TransactionInfo { BitcoinSilentPaymentsUnspent.fromJSON(null, unspent as Map)) .toList(), isReceivedSilentPayment: data['isReceivedSilentPayment'] as bool? ?? false, + additionalInfo: data['additionalInfo'] as Map?, ); } @@ -246,7 +249,8 @@ class ElectrumTransactionInfo extends TransactionInfo { isReplaced: isReplaced ?? false, inputAddresses: inputAddresses, outputAddresses: outputAddresses, - confirmations: info.confirmations); + confirmations: info.confirmations, + additionalInfo: additionalInfo); } Map toJson() { @@ -265,10 +269,11 @@ class ElectrumTransactionInfo extends TransactionInfo { m['inputAddresses'] = inputAddresses; m['outputAddresses'] = outputAddresses; m['isReceivedSilentPayment'] = isReceivedSilentPayment; + m['additionalInfo'] = additionalInfo; return m; } String toString() { - return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses)'; + return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses, additionalInfo: $additionalInfo)'; } } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index c05095cf1..7988a4479 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -5,6 +5,7 @@ import 'dart:isolate'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cw_bitcoin/bitcoin_wallet.dart'; +import 'package:cw_bitcoin/litecoin_wallet.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:collection/collection.dart'; @@ -52,10 +53,9 @@ part 'electrum_wallet.g.dart'; class ElectrumWallet = ElectrumWalletBase with _$ElectrumWallet; -abstract class ElectrumWalletBase extends WalletBase< - ElectrumBalance, - ElectrumTransactionHistory, - ElectrumTransactionInfo> with Store, WalletKeysFile { +abstract class ElectrumWalletBase + extends WalletBase + with Store, WalletKeysFile { ElectrumWalletBase({ required String password, required WalletInfo walletInfo, @@ -71,8 +71,8 @@ abstract class ElectrumWalletBase extends WalletBase< ElectrumBalance? initialBalance, CryptoCurrency? currency, this.alwaysScan, - }) : accountHD = getAccountHDWallet( - currency, network, seedBytes, xpub, walletInfo.derivationInfo), + }) : accountHD = + getAccountHDWallet(currency, network, seedBytes, xpub, walletInfo.derivationInfo), syncStatus = NotConnectedSyncStatus(), _password = password, _feeRates = [], @@ -107,12 +107,8 @@ abstract class ElectrumWalletBase extends WalletBase< sharedPrefs.complete(SharedPreferences.getInstance()); } - static Bip32Slip10Secp256k1 getAccountHDWallet( - CryptoCurrency? currency, - BasedUtxoNetwork network, - Uint8List? seedBytes, - String? xpub, - DerivationInfo? derivationInfo) { + static Bip32Slip10Secp256k1 getAccountHDWallet(CryptoCurrency? currency, BasedUtxoNetwork network, + Uint8List? seedBytes, String? xpub, DerivationInfo? derivationInfo) { if (seedBytes == null && xpub == null) { throw Exception( "To create a Wallet you need either a seed or an xpub. This should not happen"); @@ -123,9 +119,8 @@ abstract class ElectrumWalletBase extends WalletBase< case CryptoCurrency.btc: case CryptoCurrency.ltc: case CryptoCurrency.tbtc: - return Bip32Slip10Secp256k1.fromSeed(seedBytes, getKeyNetVersion(network)) - .derivePath(_hardenedDerivationPath( - derivationInfo?.derivationPath ?? electrum_path)) + return Bip32Slip10Secp256k1.fromSeed(seedBytes, getKeyNetVersion(network)).derivePath( + _hardenedDerivationPath(derivationInfo?.derivationPath ?? electrum_path)) as Bip32Slip10Secp256k1; case CryptoCurrency.bch: return bitcoinCashHDWallet(seedBytes); @@ -134,13 +129,11 @@ abstract class ElectrumWalletBase extends WalletBase< } } - return Bip32Slip10Secp256k1.fromExtendedKey( - xpub!, getKeyNetVersion(network)); + return Bip32Slip10Secp256k1.fromExtendedKey(xpub!, getKeyNetVersion(network)); } static Bip32Slip10Secp256k1 bitcoinCashHDWallet(Uint8List seedBytes) => - Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/145'/0'") - as Bip32Slip10Secp256k1; + Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/44'/145'/0'") as Bip32Slip10Secp256k1; static int estimatedTransactionSize(int inputsCount, int outputsCounts) => inputsCount * 68 + outputsCounts * 34 + 10; @@ -250,7 +243,7 @@ abstract class ElectrumWalletBase extends WalletBase< } if (tip > walletInfo.restoreHeight) { - _setListeners(walletInfo.restoreHeight, chainTipParam: _currentChainTip); + _setListeners(walletInfo.restoreHeight, chainTipParam: currentChainTip); } } else { alwaysScan = false; @@ -265,23 +258,23 @@ abstract class ElectrumWalletBase extends WalletBase< } } - int? _currentChainTip; + int? currentChainTip; Future getCurrentChainTip() async { - if ((_currentChainTip ?? 0) > 0) { - return _currentChainTip!; + if ((currentChainTip ?? 0) > 0) { + return currentChainTip!; } - _currentChainTip = await electrumClient.getCurrentBlockChainTip() ?? 0; + currentChainTip = await electrumClient.getCurrentBlockChainTip() ?? 0; - return _currentChainTip!; + return currentChainTip!; } Future getUpdatedChainTip() async { final newTip = await electrumClient.getCurrentBlockChainTip(); - if (newTip != null && newTip > (_currentChainTip ?? 0)) { - _currentChainTip = newTip; + if (newTip != null && newTip > (currentChainTip ?? 0)) { + currentChainTip = newTip; } - return _currentChainTip ?? 0; + return currentChainTip ?? 0; } @override @@ -357,7 +350,7 @@ abstract class ElectrumWalletBase extends WalletBase< isSingleScan: doSingleScan ?? false, )); - _receiveStream?.cancel(); + await _receiveStream?.cancel(); _receiveStream = receivePort.listen((var message) async { if (message is Map) { for (final map in message.entries) { @@ -618,7 +611,7 @@ abstract class ElectrumWalletBase extends WalletBase< bool spendsUnconfirmedTX = false; int leftAmount = credentialsAmount; - final availableInputs = unspentCoins.where((utx) { + var availableInputs = unspentCoins.where((utx) { if (!utx.isSending || utx.isFrozen) { return false; } @@ -634,6 +627,9 @@ abstract class ElectrumWalletBase extends WalletBase< }).toList(); final unconfirmedCoins = availableInputs.where((utx) => utx.confirmations == 0).toList(); + // sort the unconfirmed coins so that mweb coins are first: + availableInputs.sort((a, b) => a.bitcoinAddressRecord.type == SegwitAddresType.mweb ? -1 : 1); + for (int i = 0; i < availableInputs.length; i++) { final utx = availableInputs[i]; if (!spendsUnconfirmedTX) spendsUnconfirmedTX = utx.confirmations == 0; @@ -652,9 +648,8 @@ abstract class ElectrumWalletBase extends WalletBase< ECPrivate? privkey; bool? isSilentPayment = false; - final hd = utx.bitcoinAddressRecord.isHidden - ? walletAddresses.sideHd - : walletAddresses.mainHd; + final hd = + utx.bitcoinAddressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd; if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord; @@ -1233,8 +1228,7 @@ abstract class ElectrumWalletBase extends WalletBase< } } - void setLedgerConnection(ledger.LedgerConnection connection) => - throw UnimplementedError(); + void setLedgerConnection(ledger.LedgerConnection connection) => throw UnimplementedError(); Future buildHardwareWalletTransaction({ required List outputs, @@ -1593,9 +1587,7 @@ abstract class ElectrumWalletBase extends WalletBase< final btcAddress = RegexUtils.addressTypeFromStr(addressRecord.address, network); final privkey = generateECPrivate( - hd: addressRecord.isHidden - ? walletAddresses.sideHd - : walletAddresses.mainHd, + hd: addressRecord.isHidden ? walletAddresses.sideHd : walletAddresses.mainHd, index: addressRecord.index, network: network); @@ -1777,8 +1769,7 @@ abstract class ElectrumWalletBase extends WalletBase< if (height != null) { if (time == null && height > 0) { - time = (getDateByBitcoinHeight(height).millisecondsSinceEpoch / 1000) - .round(); + time = (getDateByBitcoinHeight(height).millisecondsSinceEpoch / 1000).round(); } if (confirmations == null) { @@ -1847,6 +1838,7 @@ abstract class ElectrumWalletBase extends WalletBase< .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); } else if (type == WalletType.litecoin) { await Future.wait(LITECOIN_ADDRESS_TYPES + .where((type) => type != SegwitAddresType.mweb) .map((type) => fetchTransactionsForAddressType(historiesWithDetails, type))); } @@ -1958,6 +1950,20 @@ abstract class ElectrumWalletBase extends WalletBase< // Got a new transaction fetched, add it to the transaction history // instead of waiting all to finish, and next time it will be faster + + if (this is LitecoinWallet) { + // if we have a peg out transaction with the same value + // that matches this received transaction, mark it as being from a peg out: + for (final tx2 in transactionHistory.transactions.values) { + final heightDiff = ((tx2.height ?? 0) - (tx.height ?? 0)).abs(); + // this isn't a perfect matching algorithm since we don't have the right input/output information from these transaction models (the addresses are in different formats), but this should be more than good enough for now as it's extremely unlikely a user receives the EXACT same amount from 2 different sources and one of them is a peg out and the other isn't WITHIN 5 blocks of each other + if (tx2.additionalInfo["isPegOut"] == true && + tx2.amount == tx.amount && + heightDiff <= 5) { + tx.additionalInfo["fromPegOut"] = true; + } + } + } transactionHistory.addOne(tx); await transactionHistory.save(); } @@ -1984,18 +1990,28 @@ abstract class ElectrumWalletBase extends WalletBase< if (_isTransactionUpdating) { return; } - await getCurrentChainTip(); + currentChainTip = await getUpdatedChainTip(); + bool updated = false; transactionHistory.transactions.values.forEach((tx) { - if (tx.unspents != null && - tx.unspents!.isNotEmpty && - tx.height != null && - tx.height! > 0 && - (_currentChainTip ?? 0) > 0) { - tx.confirmations = _currentChainTip! - tx.height! + 1; + if ((tx.height ?? 0) > 0 && (currentChainTip ?? 0) > 0) { + var confirmations = currentChainTip! - tx.height! + 1; + if (confirmations < 0) { + // if our chain tip is outdated then it could lead to negative confirmations so this is just a failsafe: + confirmations = 0; + } + if (confirmations != tx.confirmations) { + updated = true; + tx.confirmations = confirmations; + transactionHistory.addOne(tx); + } } }); + if (updated) { + await transactionHistory.save(); + } + _isTransactionUpdating = true; await fetchTransactions(); walletAddresses.updateReceiveAddresses(); @@ -2043,6 +2059,8 @@ abstract class ElectrumWalletBase extends WalletBase< library: this.runtimeType.toString(), )); } + }, onError: (e, s) { + print("sub_listen error: $e $s"); }); })); } @@ -2092,6 +2110,13 @@ abstract class ElectrumWalletBase extends WalletBase< final balances = await Future.wait(balanceFutures); + if (balances.isNotEmpty && balances.first['confirmed'] == null) { + // if we got null balance responses from the server, set our connection status to lost and return our last known balance: + print("got null balance responses from the server, setting connection status to lost"); + syncStatus = LostConnectionSyncStatus(); + return balance[currency] ?? ElectrumBalance(confirmed: 0, unconfirmed: 0, frozen: 0); + } + for (var i = 0; i < balances.length; i++) { final addressRecord = addresses[i]; final balance = balances[i]; @@ -2197,10 +2222,10 @@ abstract class ElectrumWalletBase extends WalletBase< Future _setInitialHeight() async { if (_chainTipUpdateSubject != null) return; - _currentChainTip = await getUpdatedChainTip(); + currentChainTip = await getUpdatedChainTip(); - if ((_currentChainTip == null || _currentChainTip! == 0) && walletInfo.restoreHeight == 0) { - await walletInfo.updateRestoreHeight(_currentChainTip!); + if ((currentChainTip == null || currentChainTip! == 0) && walletInfo.restoreHeight == 0) { + await walletInfo.updateRestoreHeight(currentChainTip!); } _chainTipUpdateSubject = electrumClient.chainTipSubscribe(); @@ -2209,7 +2234,7 @@ abstract class ElectrumWalletBase extends WalletBase< final height = int.tryParse(event['height'].toString()); if (height != null) { - _currentChainTip = height; + currentChainTip = height; if (alwaysScan == true && syncStatus is SyncedSyncStatus) { _setListeners(walletInfo.restoreHeight); @@ -2223,7 +2248,6 @@ abstract class ElectrumWalletBase extends WalletBase< @action void _onConnectionStatusChange(ConnectionStatus status) { - switch (status) { case ConnectionStatus.connected: if (syncStatus is NotConnectedSyncStatus || @@ -2270,8 +2294,6 @@ abstract class ElectrumWalletBase extends WalletBase< Timer(Duration(seconds: 5), () { if (this.syncStatus is NotConnectedSyncStatus || this.syncStatus is LostConnectionSyncStatus) { - if (node == null) return; - this.electrumClient.connectToUri( node!.uri, useSSL: node!.useSSL ?? false, diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index 1fb39c878..86228fc83 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -9,6 +9,7 @@ import 'package:crypto/crypto.dart'; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_core/cake_hive.dart'; import 'package:cw_core/mweb_utxo.dart'; +import 'package:cw_core/node.dart'; import 'package:cw_mweb/mwebd.pbgrpc.dart'; import 'package:fixnum/fixnum.dart'; import 'package:bip39/bip39.dart' as bip39; @@ -47,6 +48,7 @@ import 'package:cw_mweb/cw_mweb.dart'; import 'package:bitcoin_base/src/crypto/keypair/sign_utils.dart'; import 'package:pointycastle/ecc/api.dart'; import 'package:pointycastle/ecc/curves/secp256k1.dart'; +import 'package:shared_preferences/shared_preferences.dart'; part 'litecoin_wallet.g.dart'; @@ -85,8 +87,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { alwaysScan: alwaysScan, ) { if (seedBytes != null) { - mwebHd = Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath( - "m/1000'") as Bip32Slip10Secp256k1; + mwebHd = + Bip32Slip10Secp256k1.fromSeed(seedBytes).derivePath("m/1000'") as Bip32Slip10Secp256k1; mwebEnabled = alwaysScan ?? false; } else { mwebHd = null; @@ -287,6 +289,16 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { await (walletAddresses as LitecoinWalletAddresses).ensureMwebAddressUpToIndexExists(1020); } + @action + @override + Future connectToNode({required Node node}) async { + await super.connectToNode(node: node); + + final prefs = await SharedPreferences.getInstance(); + final mwebNodeUri = prefs.getString("mwebNodeUri") ?? "ltc-electrum.cakewallet.com:9333"; + await CwMweb.setNodeUriOverride(mwebNodeUri); + } + @action @override Future startSync() async { @@ -349,6 +361,9 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { return; } + // update the current chain tip so that confirmation calculations are accurate: + currentChainTip = nodeHeight; + final resp = await CwMweb.status(StatusRequest()); try { @@ -361,22 +376,46 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { } else if (resp.mwebUtxosHeight < nodeHeight) { mwebSyncStatus = SyncingSyncStatus(1, 0.999); } else { + bool confirmationsUpdated = false; if (resp.mwebUtxosHeight > walletInfo.restoreHeight) { await walletInfo.updateRestoreHeight(resp.mwebUtxosHeight); await checkMwebUtxosSpent(); // update the confirmations for each transaction: - for (final transaction in transactionHistory.transactions.values) { - if (transaction.isPending) continue; - int txHeight = transaction.height ?? resp.mwebUtxosHeight; - final confirmations = (resp.mwebUtxosHeight - txHeight) + 1; - if (transaction.confirmations == confirmations) continue; - if (transaction.confirmations == 0) { - updateBalance(); + for (final tx in transactionHistory.transactions.values) { + if (tx.height == null || tx.height == 0) { + // update with first confirmation on next block since it hasn't been confirmed yet: + tx.height = resp.mwebUtxosHeight; + continue; } - transaction.confirmations = confirmations; - transactionHistory.addOne(transaction); + + final confirmations = (resp.mwebUtxosHeight - tx.height!) + 1; + + // if the confirmations haven't changed, skip updating: + if (tx.confirmations == confirmations) continue; + + + // if an outgoing tx is now confirmed, delete the utxo from the box (delete the unspent coin): + if (confirmations >= 2 && + tx.direction == TransactionDirection.outgoing && + tx.unspents != null) { + for (var coin in tx.unspents!) { + final utxo = mwebUtxosBox.get(coin.address); + if (utxo != null) { + print("deleting utxo ${coin.address} @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@"); + await mwebUtxosBox.delete(coin.address); + } + } + } + + tx.confirmations = confirmations; + tx.isPending = false; + transactionHistory.addOne(tx); + confirmationsUpdated = true; + } + if (confirmationsUpdated) { + await transactionHistory.save(); + await updateTransactions(); } - await transactionHistory.save(); } // prevent unnecessary reaction triggers: @@ -501,13 +540,12 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { outputAddresses: [utxo.outputId], isReplaced: false, ); - } - - // don't update the confirmations if the tx is updated by electrum: - if (tx.confirmations == 0 || utxo.height != 0) { - tx.height = utxo.height; - tx.isPending = utxo.height == 0; - tx.confirmations = confirmations; + } else { + if (tx.confirmations != confirmations || tx.height != utxo.height) { + tx.height = utxo.height; + tx.confirmations = confirmations; + tx.isPending = utxo.height == 0; + } } bool isNew = transactionHistory.transactions[tx.id] == null; @@ -557,56 +595,88 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { if (responseStream == null) { throw Exception("failed to get utxos stream!"); } - _utxoStream = responseStream.listen((Utxo sUtxo) async { - // we're processing utxos, so our balance could still be innacurate: - if (mwebSyncStatus is! SyncronizingSyncStatus && mwebSyncStatus is! SyncingSyncStatus) { - mwebSyncStatus = SyncronizingSyncStatus(); - processingUtxos = true; - _processingTimer?.cancel(); - _processingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { - processingUtxos = false; - timer.cancel(); - }); - } - - final utxo = MwebUtxo( - address: sUtxo.address, - blockTime: sUtxo.blockTime, - height: sUtxo.height, - outputId: sUtxo.outputId, - value: sUtxo.value.toInt(), - ); - - if (mwebUtxosBox.containsKey(utxo.outputId)) { - // we've already stored this utxo, skip it: - // but do update the utxo height if it's somehow different: - final existingUtxo = mwebUtxosBox.get(utxo.outputId); - if (existingUtxo!.height != utxo.height) { - print( - "updating utxo height for $utxo.outputId: ${existingUtxo.height} -> ${utxo.height}"); - existingUtxo.height = utxo.height; - await mwebUtxosBox.put(utxo.outputId, existingUtxo); + _utxoStream = responseStream.listen( + (Utxo sUtxo) async { + // we're processing utxos, so our balance could still be innacurate: + if (mwebSyncStatus is! SyncronizingSyncStatus && mwebSyncStatus is! SyncingSyncStatus) { + mwebSyncStatus = SyncronizingSyncStatus(); + processingUtxos = true; + _processingTimer?.cancel(); + _processingTimer = Timer.periodic(const Duration(seconds: 2), (timer) async { + processingUtxos = false; + timer.cancel(); + }); } - return; - } - await updateUnspent(); - await updateBalance(); + final utxo = MwebUtxo( + address: sUtxo.address, + blockTime: sUtxo.blockTime, + height: sUtxo.height, + outputId: sUtxo.outputId, + value: sUtxo.value.toInt(), + ); - final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs; + if (mwebUtxosBox.containsKey(utxo.outputId)) { + // we've already stored this utxo, skip it: + // but do update the utxo height if it's somehow different: + final existingUtxo = mwebUtxosBox.get(utxo.outputId); + if (existingUtxo!.height != utxo.height) { + print( + "updating utxo height for $utxo.outputId: ${existingUtxo.height} -> ${utxo.height}"); + existingUtxo.height = utxo.height; + await mwebUtxosBox.put(utxo.outputId, existingUtxo); + } + return; + } - // don't process utxos with addresses that are not in the mwebAddrs list: - if (utxo.address.isNotEmpty && !mwebAddrs.contains(utxo.address)) { - return; - } + await updateUnspent(); + await updateBalance(); - await mwebUtxosBox.put(utxo.outputId, utxo); + final mwebAddrs = (walletAddresses as LitecoinWalletAddresses).mwebAddrs; - await handleIncoming(utxo); - }); + // don't process utxos with addresses that are not in the mwebAddrs list: + if (utxo.address.isNotEmpty && !mwebAddrs.contains(utxo.address)) { + return; + } + + await mwebUtxosBox.put(utxo.outputId, utxo); + + await handleIncoming(utxo); + }, + onError: (error) { + print("error in utxo stream: $error"); + mwebSyncStatus = FailedSyncStatus(error: error.toString()); + }, + cancelOnError: true, + ); + } + + Future deleteSpentUtxos() async { + print("deleteSpentUtxos() called!"); + final chainHeight = await electrumClient.getCurrentBlockChainTip(); + final status = await CwMweb.status(StatusRequest()); + if (chainHeight == null || status.blockHeaderHeight != chainHeight) return; + if (status.mwebUtxosHeight != chainHeight) return; // we aren't synced + + // delete any spent utxos with >= 2 confirmations: + final spentOutputIds = mwebUtxosBox.values + .where((utxo) => utxo.spent && (chainHeight - utxo.height) >= 2) + .map((utxo) => utxo.outputId) + .toList(); + + if (spentOutputIds.isEmpty) return; + + final resp = await CwMweb.spent(SpentRequest(outputId: spentOutputIds)); + final spent = resp.outputId; + if (spent.isEmpty) return; + + for (final outputId in spent) { + await mwebUtxosBox.delete(outputId); + } } Future checkMwebUtxosSpent() async { + print("checkMwebUtxosSpent() called!"); if (!mwebEnabled) { return; } @@ -620,15 +690,17 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { updatedAny = await isConfirmed(tx) || updatedAny; } + await deleteSpentUtxos(); + // get output ids of all the mweb utxos that have > 0 height: - final outputIds = - mwebUtxosBox.values.where((utxo) => utxo.height > 0).map((utxo) => utxo.outputId).toList(); + final outputIds = mwebUtxosBox.values + .where((utxo) => utxo.height > 0 && !utxo.spent) + .map((utxo) => utxo.outputId) + .toList(); final resp = await CwMweb.spent(SpentRequest(outputId: outputIds)); final spent = resp.outputId; - if (spent.isEmpty) { - return; - } + if (spent.isEmpty) return; final status = await CwMweb.status(StatusRequest()); final height = await electrumClient.getCurrentBlockChainTip(); @@ -739,7 +811,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { mwebUtxosBox.keys.forEach((dynamic oId) { final String outputId = oId as String; final utxo = mwebUtxosBox.get(outputId); - if (utxo == null) { + if (utxo == null || utxo.spent) { return; } if (utxo.address.isEmpty) { @@ -789,15 +861,23 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { int unconfirmedMweb = 0; try { mwebUtxosBox.values.forEach((utxo) { - if (utxo.height > 0) { + bool isConfirmed = utxo.height > 0; + + print( + "utxo: ${isConfirmed ? "confirmed" : "unconfirmed"} ${utxo.spent ? "spent" : "unspent"} ${utxo.outputId} ${utxo.height} ${utxo.value}"); + + if (isConfirmed) { confirmedMweb += utxo.value.toInt(); - } else { + } + + if (isConfirmed && utxo.spent) { + unconfirmedMweb -= utxo.value.toInt(); + } + + if (!isConfirmed && !utxo.spent) { unconfirmedMweb += utxo.value.toInt(); } }); - if (unconfirmedMweb > 0) { - unconfirmedMweb = -1 * (confirmedMweb - unconfirmedMweb); - } } catch (_) {} for (var addressRecord in walletAddresses.allAddresses) { @@ -829,7 +909,6 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { // update the txCount for each address using the tx history, since we can't rely on mwebd // to have an accurate count, we should just keep it in sync with what we know from the tx history: for (final tx in transactionHistory.transactions.values) { - // if (tx.isPending) continue; if (tx.inputAddresses == null || tx.outputAddresses == null) { continue; } @@ -908,7 +987,26 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { // https://github.com/ltcmweb/mwebd?tab=readme-ov-file#fee-estimation final preOutputSum = outputs.fold(BigInt.zero, (acc, output) => acc + output.toOutput.amount); - final fee = utxos.sumOfUtxosValue() - preOutputSum; + var fee = utxos.sumOfUtxosValue() - preOutputSum; + + // determines if the fee is correct: + BigInt _sumOutputAmounts(List outputs) { + BigInt sum = BigInt.zero; + for (final e in outputs) { + sum += e.amount; + } + return sum; + } + + final sum1 = _sumOutputAmounts(outputs.map((e) => e.toOutput).toList()) + fee; + final sum2 = utxos.sumOfUtxosValue(); + if (sum1 != sum2) { + print("@@@@@ WE HAD TO ADJUST THE FEE! @@@@@@@@"); + final diff = sum2 - sum1; + // add the difference to the fee (abs value): + fee += diff.abs(); + } + final txb = BitcoinTransactionBuilder(utxos: utxos, outputs: outputs, fee: fee, network: network); final resp = await CwMweb.create(CreateRequest( @@ -949,8 +1047,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { if (!mwebEnabled) { tx.changeAddressOverride = - (await (walletAddresses as LitecoinWalletAddresses) - .getChangeAddress(isPegIn: false)) + (await (walletAddresses as LitecoinWalletAddresses).getChangeAddress(isPegIn: false)) .address; return tx; } @@ -969,15 +1066,25 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { bool hasMwebInput = false; bool hasMwebOutput = false; + bool hasRegularOutput = false; for (final output in transactionCredentials.outputs) { - if (output.extractedAddress?.toLowerCase().contains("mweb") ?? false) { + final address = output.address.toLowerCase(); + final extractedAddress = output.extractedAddress?.toLowerCase(); + + if (address.contains("mweb")) { hasMwebOutput = true; - break; } - if (output.address.toLowerCase().contains("mweb")) { - hasMwebOutput = true; - break; + if (!address.contains("mweb")) { + hasRegularOutput = true; + } + if (extractedAddress != null && extractedAddress.isNotEmpty) { + if (extractedAddress.contains("mweb")) { + hasMwebOutput = true; + } + if (!extractedAddress.contains("mweb")) { + hasRegularOutput = true; + } } } @@ -989,11 +1096,11 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { } bool isPegIn = !hasMwebInput && hasMwebOutput; + bool isPegOut = hasMwebInput && hasRegularOutput; bool isRegular = !hasMwebInput && !hasMwebOutput; - tx.changeAddressOverride = - (await (walletAddresses as LitecoinWalletAddresses) - .getChangeAddress(isPegIn: isPegIn || isRegular)) - .address; + tx.changeAddressOverride = (await (walletAddresses as LitecoinWalletAddresses) + .getChangeAddress(isPegIn: isPegIn || isRegular)) + .address; if (!hasMwebInput && !hasMwebOutput) { tx.isMweb = false; return tx; @@ -1046,8 +1153,11 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { final addresses = {}; transaction.inputAddresses?.forEach((id) async { final utxo = mwebUtxosBox.get(id); - await mwebUtxosBox.delete(id); // gets deleted in checkMwebUtxosSpent + // await mwebUtxosBox.delete(id); // gets deleted in checkMwebUtxosSpent if (utxo == null) return; + // mark utxo as spent so we add it to the unconfirmed balance (as negative): + utxo.spent = true; + await mwebUtxosBox.put(id, utxo); final addressRecord = walletAddresses.allAddresses .firstWhere((addressRecord) => addressRecord.address == utxo.address); if (!addresses.contains(utxo.address)) { @@ -1056,7 +1166,9 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { addressRecord.balance -= utxo.value.toInt(); }); transaction.inputAddresses?.addAll(addresses); - + print("isPegIn: $isPegIn, isPegOut: $isPegOut"); + transaction.additionalInfo["isPegIn"] = isPegIn; + transaction.additionalInfo["isPegOut"] = isPegOut; transactionHistory.addOne(transaction); await updateUnspent(); await updateBalance(); @@ -1240,8 +1352,8 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { @override void setLedgerConnection(LedgerConnection connection) { _ledgerConnection = connection; - _litecoinLedgerApp = - LitecoinLedgerApp(_ledgerConnection!, derivationPath: walletInfo.derivationInfo!.derivationPath!); + _litecoinLedgerApp = LitecoinLedgerApp(_ledgerConnection!, + derivationPath: walletInfo.derivationInfo!.derivationPath!); } @override @@ -1277,19 +1389,17 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { if (maybeChangePath != null) changePath ??= maybeChangePath.derivationPath; } - final rawHex = await _litecoinLedgerApp!.createTransaction( - inputs: readyInputs, - outputs: outputs - .map((e) => TransactionOutput.fromBigInt( - (e as BitcoinOutput).value, Uint8List.fromList(e.address.toScriptPubKey().toBytes()))) - .toList(), - changePath: changePath, - sigHashType: 0x01, - additionals: ["bech32"], - isSegWit: true, - useTrustedInputForSegwit: true - ); + inputs: readyInputs, + outputs: outputs + .map((e) => TransactionOutput.fromBigInt((e as BitcoinOutput).value, + Uint8List.fromList(e.address.toScriptPubKey().toBytes()))) + .toList(), + changePath: changePath, + sigHashType: 0x01, + additionals: ["bech32"], + isSegWit: true, + useTrustedInputForSegwit: true); return BtcTransaction.fromRaw(rawHex); } diff --git a/cw_bitcoin/lib/litecoin_wallet_addresses.dart b/cw_bitcoin/lib/litecoin_wallet_addresses.dart index c55f5fc76..062c590ba 100644 --- a/cw_bitcoin/lib/litecoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/litecoin_wallet_addresses.dart @@ -16,11 +16,9 @@ import 'package:mobx/mobx.dart'; part 'litecoin_wallet_addresses.g.dart'; -class LitecoinWalletAddresses = LitecoinWalletAddressesBase - with _$LitecoinWalletAddresses; +class LitecoinWalletAddresses = LitecoinWalletAddressesBase with _$LitecoinWalletAddresses; -abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses - with Store { +abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with Store { LitecoinWalletAddressesBase( WalletInfo walletInfo, { required super.mainHd, @@ -46,8 +44,7 @@ abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses List mwebAddrs = []; bool generating = false; - List get scanSecret => - mwebHd!.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw; + List get scanSecret => mwebHd!.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw; List get spendPubkey => mwebHd!.childKey(Bip32KeyIndex(0x80000001)).publicKey.pubKey.compressed; @@ -203,4 +200,12 @@ abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses return super.getChangeAddress(); } + + @override + String get addressForExchange { + // don't use mweb addresses for exchange refund address: + final addresses = receiveAddresses + .where((element) => element.type == SegwitAddresType.p2wpkh && !element.isUsed); + return addresses.first.address; + } } diff --git a/cw_bitcoin/lib/litecoin_wallet_service.dart b/cw_bitcoin/lib/litecoin_wallet_service.dart index 7cc266f5b..d519f4d0a 100644 --- a/cw_bitcoin/lib/litecoin_wallet_service.dart +++ b/cw_bitcoin/lib/litecoin_wallet_service.dart @@ -112,6 +112,7 @@ class LitecoinWalletService extends WalletService< File neturinoDb = File('$appDirPath/neutrino.db'); File blockHeaders = File('$appDirPath/block_headers.bin'); File regFilterHeaders = File('$appDirPath/reg_filter_headers.bin'); + File mwebdLogs = File('$appDirPath/logs/debug.log'); if (neturinoDb.existsSync()) { neturinoDb.deleteSync(); } @@ -121,6 +122,9 @@ class LitecoinWalletService extends WalletService< if (regFilterHeaders.existsSync()) { regFilterHeaders.deleteSync(); } + if (mwebdLogs.existsSync()) { + mwebdLogs.deleteSync(); + } } } diff --git a/cw_bitcoin/lib/pending_bitcoin_transaction.dart b/cw_bitcoin/lib/pending_bitcoin_transaction.dart index 5ed84dbf4..140965655 100644 --- a/cw_bitcoin/lib/pending_bitcoin_transaction.dart +++ b/cw_bitcoin/lib/pending_bitcoin_transaction.dart @@ -118,8 +118,7 @@ class PendingBitcoinTransaction with PendingTransaction { Future _ltcCommit() async { try { - final stub = await CwMweb.stub(); - final resp = await stub.broadcast(BroadcastRequest(rawTx: BytesUtils.fromHexString(hex))); + final resp = await CwMweb.broadcast(BroadcastRequest(rawTx: BytesUtils.fromHexString(hex))); idOverride = resp.txid; } on GrpcError catch (e) { throw BitcoinTransactionCommitFailed(errorMessage: e.message); diff --git a/cw_bitcoin/pubspec.yaml b/cw_bitcoin/pubspec.yaml index 9f1cee67d..7a1576a98 100644 --- a/cw_bitcoin/pubspec.yaml +++ b/cw_bitcoin/pubspec.yaml @@ -64,7 +64,7 @@ dependency_overrides: bitcoin_base: git: url: https://github.com/cake-tech/bitcoin_base - ref: cake-update-v8 + ref: cake-update-v9 pointycastle: 3.7.4 ffi: 2.1.0 diff --git a/cw_bitcoin_cash/pubspec.yaml b/cw_bitcoin_cash/pubspec.yaml index cd1e52f51..14ff407e2 100644 --- a/cw_bitcoin_cash/pubspec.yaml +++ b/cw_bitcoin_cash/pubspec.yaml @@ -42,7 +42,7 @@ dependency_overrides: bitcoin_base: git: url: https://github.com/cake-tech/bitcoin_base - ref: cake-update-v8 + ref: cake-update-v9 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/cw_core/lib/mweb_utxo.dart b/cw_core/lib/mweb_utxo.dart index f8dfab395..5eab11734 100644 --- a/cw_core/lib/mweb_utxo.dart +++ b/cw_core/lib/mweb_utxo.dart @@ -11,6 +11,7 @@ class MwebUtxo extends HiveObject { required this.address, required this.outputId, required this.blockTime, + this.spent = false, }); static const typeId = MWEB_UTXO_TYPE_ID; @@ -30,4 +31,7 @@ class MwebUtxo extends HiveObject { @HiveField(4) int blockTime; + + @HiveField(5, defaultValue: false) + bool spent; } diff --git a/cw_core/lib/node.dart b/cw_core/lib/node.dart index e19d2a54b..1094b6402 100644 --- a/cw_core/lib/node.dart +++ b/cw_core/lib/node.dart @@ -79,6 +79,9 @@ class Node extends HiveObject with Keyable { @HiveField(9) bool? supportsSilentPayments; + @HiveField(10) + bool? supportsMweb; + bool get isSSL => useSSL ?? false; bool get useSocksProxy => socksProxyAddress == null ? false : socksProxyAddress!.isNotEmpty; diff --git a/cw_core/lib/transaction_info.dart b/cw_core/lib/transaction_info.dart index 9d0c968d8..3e34af75f 100644 --- a/cw_core/lib/transaction_info.dart +++ b/cw_core/lib/transaction_info.dart @@ -25,6 +25,5 @@ abstract class TransactionInfo extends Object with Keyable { @override dynamic get keyIndex => id; - late Map additionalInfo; + Map additionalInfo = {}; } - diff --git a/cw_mweb/lib/cw_mweb.dart b/cw_mweb/lib/cw_mweb.dart index a1a592fb8..75cc0bcca 100644 --- a/cw_mweb/lib/cw_mweb.dart +++ b/cw_mweb/lib/cw_mweb.dart @@ -13,8 +13,18 @@ class CwMweb { static RpcClient? _rpcClient; static ClientChannel? _clientChannel; static int? _port; - static const TIMEOUT_DURATION = Duration(seconds: 5); + static const TIMEOUT_DURATION = Duration(seconds: 15); static Timer? logTimer; + static String? nodeUriOverride; + + + static Future setNodeUriOverride(String uri) async { + nodeUriOverride = uri; + if (_rpcClient != null) { + await stop(); + // will be re-started automatically when the next rpc call is made + } + } static void readFileWithTimer(String filePath) { final file = File(filePath); @@ -47,7 +57,7 @@ class CwMweb { String debugLogPath = "${appDir.path}/logs/debug.log"; readFileWithTimer(debugLogPath); - _port = await CwMwebPlatform.instance.start(appDir.path, ltcNodeUri); + _port = await CwMwebPlatform.instance.start(appDir.path, nodeUriOverride ?? ltcNodeUri); if (_port == null || _port == 0) { throw Exception("Failed to start server"); } @@ -197,4 +207,18 @@ class CwMweb { } return null; } + + static Future broadcast(BroadcastRequest request) async { + log("mweb.broadcast() called"); + try { + _rpcClient = await stub(); + return await _rpcClient!.broadcast(request, options: CallOptions(timeout: TIMEOUT_DURATION)); + } on GrpcError catch (e) { + log('Caught grpc error: ${e.message}'); + throw "error from broadcast mweb: $e"; + } catch (e) { + log("Error getting create: $e"); + rethrow; + } + } } diff --git a/lib/di.dart b/lib/di.dart index 13ffd839e..14fdbd56d 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -35,6 +35,8 @@ import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/entities/wallet_edit_page_arguments.dart'; import 'package:cake_wallet/entities/wallet_manager.dart'; import 'package:cake_wallet/src/screens/receive/address_list_page.dart'; +import 'package:cake_wallet/src/screens/settings/mweb_logs_page.dart'; +import 'package:cake_wallet/src/screens/settings/mweb_node_page.dart'; import 'package:cake_wallet/view_model/link_view_model.dart'; import 'package:cake_wallet/tron/tron.dart'; import 'package:cake_wallet/src/screens/transaction_details/rbf_details_page.dart'; @@ -945,6 +947,10 @@ Future setup({ getIt.registerFactory(() => MwebSettingsPage(getIt.get())); + getIt.registerFactory(() => MwebLogsPage(getIt.get())); + + getIt.registerFactory(() => MwebNodePage(getIt.get())); + getIt.registerFactory(() => OtherSettingsPage(getIt.get())); getIt.registerFactory(() => NanoChangeRepPage( diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index 73556452c..8e8bfb6ed 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -54,6 +54,7 @@ class PreferencesKey { static const mwebEnabled = 'mwebEnabled'; static const hasEnabledMwebBefore = 'hasEnabledMwebBefore'; static const mwebAlwaysScan = 'mwebAlwaysScan'; + static const mwebNodeUri = 'mwebNodeUri'; static const shouldShowReceiveWarning = 'should_show_receive_warning'; static const shouldShowYatPopup = 'should_show_yat_popup'; static const shouldShowRepWarning = 'should_show_rep_warning'; diff --git a/lib/router.dart b/lib/router.dart index 3b4c38546..6c234ff80 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -72,6 +72,8 @@ import 'package:cake_wallet/src/screens/settings/desktop_settings/desktop_settin import 'package:cake_wallet/src/screens/settings/display_settings_page.dart'; import 'package:cake_wallet/src/screens/settings/domain_lookups_page.dart'; import 'package:cake_wallet/src/screens/settings/manage_nodes_page.dart'; +import 'package:cake_wallet/src/screens/settings/mweb_logs_page.dart'; +import 'package:cake_wallet/src/screens/settings/mweb_node_page.dart'; import 'package:cake_wallet/src/screens/settings/mweb_settings.dart'; import 'package:cake_wallet/src/screens/settings/other_settings_page.dart'; import 'package:cake_wallet/src/screens/settings/privacy_page.dart'; @@ -461,6 +463,14 @@ Route createRoute(RouteSettings settings) { return CupertinoPageRoute( fullscreenDialog: true, builder: (_) => getIt.get()); + case Routes.mwebLogs: + return CupertinoPageRoute( + fullscreenDialog: true, builder: (_) => getIt.get()); + + case Routes.mwebNode: + return CupertinoPageRoute( + fullscreenDialog: true, builder: (_) => getIt.get()); + case Routes.connectionSync: return CupertinoPageRoute( fullscreenDialog: true, builder: (_) => getIt.get()); diff --git a/lib/routes.dart b/lib/routes.dart index 0529d7c6f..5f11b11a3 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -74,6 +74,8 @@ class Routes { static const webViewPage = '/web_view_page'; static const silentPaymentsSettings = '/silent_payments_settings'; static const mwebSettings = '/mweb_settings'; + static const mwebLogs = '/mweb_logs'; + static const mwebNode = '/mweb_node'; static const connectionSync = '/connection_sync_page'; static const securityBackupPage = '/security_and_backup_page'; static const privacyPage = '/privacy_page'; diff --git a/lib/src/screens/dashboard/pages/balance_page.dart b/lib/src/screens/dashboard/pages/balance_page.dart index 028b3cfcf..b16a7b090 100644 --- a/lib/src/screens/dashboard/pages/balance_page.dart +++ b/lib/src/screens/dashboard/pages/balance_page.dart @@ -886,17 +886,37 @@ class BalanceRowWidget extends StatelessWidget { Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - '${secondAvailableBalanceLabel}', - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 12, - fontFamily: 'Lato', - fontWeight: FontWeight.w400, - color: Theme.of(context) - .extension()! - .labelTextColor, - height: 1, + GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => launchUrl( + Uri.parse( + "https://guides.cakewallet.com/docs/cryptos/litecoin/#mweb"), + mode: LaunchMode.externalApplication, + ), + child: Row( + children: [ + Text( + '${secondAvailableBalanceLabel}', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + fontFamily: 'Lato', + fontWeight: FontWeight.w400, + color: Theme.of(context) + .extension()! + .labelTextColor, + height: 1, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.help_outline, + size: 16, + color: Theme.of(context) + .extension()! + .labelTextColor), + ) + ], ), ), SizedBox(height: 8), diff --git a/lib/src/screens/settings/mweb_logs_page.dart b/lib/src/screens/settings/mweb_logs_page.dart new file mode 100644 index 000000000..3c5470214 --- /dev/null +++ b/lib/src/screens/settings/mweb_logs_page.dart @@ -0,0 +1,127 @@ +import 'dart:io'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/utils/exception_handler.dart'; +import 'package:cake_wallet/utils/share_util.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/view_model/settings/mweb_settings_view_model.dart'; +import 'package:cw_core/root_dir.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:path_provider/path_provider.dart'; + +class MwebLogsPage extends BasePage { + MwebLogsPage(this.mwebSettingsViewModelBase); + + final MwebSettingsViewModelBase mwebSettingsViewModelBase; + + @override + String get title => S.current.litecoin_mweb_logs; + + @override + Widget body(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + FutureBuilder( + future: mwebSettingsViewModelBase.getAbbreviatedLogs(), + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return Center(child: CircularProgressIndicator()); + } else if (snapshot.hasError || !snapshot.hasData || snapshot.data!.isEmpty) { + return Center(child: Text('No logs found')); + } else { + return SingleChildScrollView( + child: Padding( + padding: EdgeInsets.all(16.0), + child: Text( + snapshot.data!, + style: TextStyle(fontFamily: 'Monospace'), + ), + ), + ); + } + }, + ), + Positioned( + child: LoadingPrimaryButton( + onPressed: () => onExportLogs(context), + text: S.of(context).export_logs, + color: Theme.of(context).primaryColor, + textColor: Colors.white, + ), + bottom: 24, + left: 24, + right: 24, + ) + ], + ); + } + + void onExportLogs(BuildContext context) { + if (Platform.isAndroid) { + onExportAndroid(context); + } else if (Platform.isIOS) { + share(context); + } else { + _saveFile(); + } + } + + void onExportAndroid(BuildContext context) { + showPopUp( + context: context, + builder: (dialogContext) { + return AlertWithTwoActions( + alertTitle: S.of(context).export_backup, + alertContent: S.of(context).select_destination, + rightButtonText: S.of(context).save_to_downloads, + leftButtonText: S.of(context).share, + actionRightButton: () async { + const downloadDirPath = "/storage/emulated/0/Download"; + final filePath = downloadDirPath + "/debug.log"; + await mwebSettingsViewModelBase.saveLogsLocally(filePath); + Navigator.of(dialogContext).pop(); + }, + actionLeftButton: () async { + Navigator.of(dialogContext).pop(); + try { + await share(context); + } catch (e, s) { + ExceptionHandler.onError(FlutterErrorDetails( + exception: e, + stack: s, + library: "Export Logs", + )); + } + }); + }); + } + + Future share(BuildContext context) async { + final filePath = (await getAppDir()).path + "/debug.log"; + bool success = await mwebSettingsViewModelBase.saveLogsLocally(filePath); + if (!success) return; + await ShareUtil.shareFile(filePath: filePath, fileName: "debug.log", context: context); + await mwebSettingsViewModelBase.removeLogsLocally(filePath); + } + + Future _saveFile() async { + String? outputFile = await FilePicker.platform + .saveFile(dialogTitle: 'Save Your File to desired location', fileName: "debug.log"); + + try { + final filePath = (await getApplicationSupportDirectory()).path + "/debug.log"; + File debugLogFile = File(filePath); + await debugLogFile.copy(outputFile!); + } catch (exception, stackTrace) { + ExceptionHandler.onError(FlutterErrorDetails( + exception: exception, + stack: stackTrace, + library: "Export Logs", + )); + } + } +} diff --git a/lib/src/screens/settings/mweb_node_page.dart b/lib/src/screens/settings/mweb_node_page.dart new file mode 100644 index 000000000..801ab3ac7 --- /dev/null +++ b/lib/src/screens/settings/mweb_node_page.dart @@ -0,0 +1,56 @@ +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/view_model/settings/mweb_settings_view_model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; + +class MwebNodePage extends BasePage { + MwebNodePage(this.mwebSettingsViewModelBase) + : _nodeUriController = TextEditingController(text: mwebSettingsViewModelBase.mwebNodeUri), + super(); + + final MwebSettingsViewModelBase mwebSettingsViewModelBase; + final TextEditingController _nodeUriController; + + @override + String get title => S.current.litecoin_mweb_node; + + @override + Widget body(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + Container( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Row( + children: [ + Expanded( + child: BaseTextFormField(controller: _nodeUriController), + ) + ], + ), + ), + Positioned( + child: Observer( + builder: (_) => LoadingPrimaryButton( + onPressed: () => save(context), + text: S.of(context).save, + color: Theme.of(context).primaryColor, + textColor: Colors.white, + ), + ), + bottom: 24, + left: 24, + right: 24, + ) + ], + ); + } + + void save(BuildContext context) { + mwebSettingsViewModelBase.setMwebNodeUri(_nodeUriController.text); + Navigator.pop(context); + } +} diff --git a/lib/src/screens/settings/mweb_settings.dart b/lib/src/screens/settings/mweb_settings.dart index 722ffa9aa..e78fdf596 100644 --- a/lib/src/screens/settings/mweb_settings.dart +++ b/lib/src/screens/settings/mweb_settings.dart @@ -31,7 +31,7 @@ class MwebSettingsPage extends BasePage { }, ), SettingsSwitcherCell( - title: S.current.litecoin_mweb_always_scan, + title: S.current.litecoin_mweb_enable, value: _mwebSettingsViewModel.mwebEnabled, onValueChange: (_, bool value) { _mwebSettingsViewModel.setMwebEnabled(value); @@ -41,6 +41,14 @@ class MwebSettingsPage extends BasePage { title: S.current.litecoin_mweb_scanning, handler: (BuildContext context) => Navigator.of(context).pushNamed(Routes.rescan), ), + SettingsCellWithArrow( + title: S.current.litecoin_mweb_logs, + handler: (BuildContext context) => Navigator.of(context).pushNamed(Routes.mwebLogs), + ), + SettingsCellWithArrow( + title: S.current.litecoin_mweb_node, + handler: (BuildContext context) => Navigator.of(context).pushNamed(Routes.mwebNode), + ), ], ), ); diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index 2eadbd8a9..c966420db 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -120,6 +120,7 @@ abstract class SettingsStoreBase with Store { required this.mwebCardDisplay, required this.mwebEnabled, required this.hasEnabledMwebBefore, + required this.mwebNodeUri, TransactionPriority? initialBitcoinTransactionPriority, TransactionPriority? initialMoneroTransactionPriority, TransactionPriority? initialWowneroTransactionPriority, @@ -358,8 +359,8 @@ abstract class SettingsStoreBase with Store { reaction( (_) => bitcoinSeedType, - (BitcoinSeedType bitcoinSeedType) => sharedPreferences.setInt( - PreferencesKey.bitcoinSeedType, bitcoinSeedType.raw)); + (BitcoinSeedType bitcoinSeedType) => + sharedPreferences.setInt(PreferencesKey.bitcoinSeedType, bitcoinSeedType.raw)); reaction( (_) => nanoSeedType, @@ -442,8 +443,10 @@ abstract class SettingsStoreBase with Store { reaction((_) => useTronGrid, (bool useTronGrid) => _sharedPreferences.setBool(PreferencesKey.useTronGrid, useTronGrid)); - reaction((_) => useMempoolFeeAPI, - (bool useMempoolFeeAPI) => _sharedPreferences.setBool(PreferencesKey.useMempoolFeeAPI, useMempoolFeeAPI)); + reaction( + (_) => useMempoolFeeAPI, + (bool useMempoolFeeAPI) => + _sharedPreferences.setBool(PreferencesKey.useMempoolFeeAPI, useMempoolFeeAPI)); reaction((_) => defaultNanoRep, (String nanoRep) => _sharedPreferences.setString(PreferencesKey.defaultNanoRep, nanoRep)); @@ -591,6 +594,11 @@ abstract class SettingsStoreBase with Store { (bool hasEnabledMwebBefore) => _sharedPreferences.setBool(PreferencesKey.hasEnabledMwebBefore, hasEnabledMwebBefore)); + reaction( + (_) => mwebNodeUri, + (String mwebNodeUri) => + _sharedPreferences.setString(PreferencesKey.mwebNodeUri, mwebNodeUri)); + this.nodes.observe((change) { if (change.newValue != null && change.key != null) { _saveCurrentNode(change.newValue!, change.key!); @@ -822,6 +830,9 @@ abstract class SettingsStoreBase with Store { @observable bool hasEnabledMwebBefore; + @observable + String mwebNodeUri; + final SecureStorage _secureStorage; final SharedPreferences _sharedPreferences; final BackgroundTasks _backgroundTasks; @@ -988,6 +999,8 @@ abstract class SettingsStoreBase with Store { final mwebEnabled = sharedPreferences.getBool(PreferencesKey.mwebEnabled) ?? false; final hasEnabledMwebBefore = sharedPreferences.getBool(PreferencesKey.hasEnabledMwebBefore) ?? false; + final mwebNodeUri = sharedPreferences.getString(PreferencesKey.mwebNodeUri) ?? + "ltc-electrum.cakewallet.com:9333"; // If no value if (pinLength == null || pinLength == 0) { @@ -1259,6 +1272,7 @@ abstract class SettingsStoreBase with Store { mwebAlwaysScan: mwebAlwaysScan, mwebCardDisplay: mwebCardDisplay, mwebEnabled: mwebEnabled, + mwebNodeUri: mwebNodeUri, hasEnabledMwebBefore: hasEnabledMwebBefore, initialMoneroTransactionPriority: moneroTransactionPriority, initialWowneroTransactionPriority: wowneroTransactionPriority, @@ -1686,7 +1700,8 @@ abstract class SettingsStoreBase with Store { deviceName = windowsInfo.productName; } catch (e) { print(e); - print('likely digitalProductId is null wait till https://github.com/fluttercommunity/plus_plugins/pull/3188 is merged'); + print( + 'likely digitalProductId is null wait till https://github.com/fluttercommunity/plus_plugins/pull/3188 is merged'); deviceName = "Windows Device"; } } diff --git a/lib/view_model/dashboard/balance_view_model.dart b/lib/view_model/dashboard/balance_view_model.dart index 56a5935c9..20dca292c 100644 --- a/lib/view_model/dashboard/balance_view_model.dart +++ b/lib/view_model/dashboard/balance_view_model.dart @@ -381,7 +381,7 @@ abstract class BalanceViewModelBase with Store { bool _hasSecondAdditionalBalanceForWalletType(WalletType type) { if (wallet.type == WalletType.litecoin) { - if ((wallet.balance[CryptoCurrency.ltc]?.secondAdditional ?? 0) > 0) { + if ((wallet.balance[CryptoCurrency.ltc]?.secondAdditional ?? 0) != 0) { return true; } } diff --git a/lib/view_model/dashboard/transaction_list_item.dart b/lib/view_model/dashboard/transaction_list_item.dart index 47fc32ab6..b218c8e9f 100644 --- a/lib/view_model/dashboard/transaction_list_item.dart +++ b/lib/view_model/dashboard/transaction_list_item.dart @@ -56,25 +56,53 @@ class TransactionListItem extends ActionListItem with Keyable { } String get formattedPendingStatus { - if (balanceViewModel.wallet.type == WalletType.monero || - balanceViewModel.wallet.type == WalletType.haven) { - if (transaction.confirmations >= 0 && transaction.confirmations < 10) { - return ' (${transaction.confirmations}/10)'; - } - } else if (balanceViewModel.wallet.type == WalletType.wownero) { - if (transaction.confirmations >= 0 && transaction.confirmations < 3) { - return ' (${transaction.confirmations}/3)'; - } + switch (balanceViewModel.wallet.type) { + case WalletType.monero: + case WalletType.haven: + if (transaction.confirmations >= 0 && transaction.confirmations < 10) { + return ' (${transaction.confirmations}/10)'; + } + break; + case WalletType.wownero: + if (transaction.confirmations >= 0 && transaction.confirmations < 3) { + return ' (${transaction.confirmations}/3)'; + } + break; + case WalletType.litecoin: + bool isPegIn = (transaction.additionalInfo["isPegIn"] as bool?) ?? false; + bool isPegOut = (transaction.additionalInfo["isPegOut"] as bool?) ?? false; + bool fromPegOut = (transaction.additionalInfo["fromPegOut"] as bool?) ?? false; + String str = ''; + if (transaction.confirmations <= 0) { + str = S.current.pending; + } + if ((isPegOut || fromPegOut) && transaction.confirmations >= 0 && transaction.confirmations < 6) { + str = " (${transaction.confirmations}/6)"; + } + if (isPegIn) { + str += " (Peg In)"; + } + if (isPegOut) { + str += " (Peg Out)"; + } + return str; + default: + return ''; } + return ''; } String get formattedStatus { - if (balanceViewModel.wallet.type == WalletType.monero || - balanceViewModel.wallet.type == WalletType.wownero || - balanceViewModel.wallet.type == WalletType.haven) { + if ([ + WalletType.monero, + WalletType.haven, + WalletType.wownero, + WalletType.litecoin, + ].contains(balanceViewModel.wallet.type)) { return formattedPendingStatus; } + return transaction.isPending ? S.current.pending : ''; } diff --git a/lib/view_model/settings/mweb_settings_view_model.dart b/lib/view_model/settings/mweb_settings_view_model.dart index c6370e23f..11e4c8177 100644 --- a/lib/view_model/settings/mweb_settings_view_model.dart +++ b/lib/view_model/settings/mweb_settings_view_model.dart @@ -1,7 +1,12 @@ +import 'dart:io'; + import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cake_wallet/utils/exception_handler.dart'; import 'package:cw_core/wallet_base.dart'; +import 'package:flutter/widgets.dart'; import 'package:mobx/mobx.dart'; +import 'package:path_provider/path_provider.dart'; part 'mweb_settings_view_model.g.dart'; @@ -22,15 +27,60 @@ abstract class MwebSettingsViewModelBase with Store { @observable late bool mwebEnabled; + @computed + String get mwebNodeUri => _settingsStore.mwebNodeUri; + @action void setMwebCardDisplay(bool value) { _settingsStore.mwebCardDisplay = value; } + @action + void setMwebNodeUri(String value) { + _settingsStore.mwebNodeUri = value; + } + @action void setMwebEnabled(bool value) { mwebEnabled = value; bitcoin!.setMwebEnabled(_wallet, value); _settingsStore.mwebAlwaysScan = value; } + + Future saveLogsLocally(String filePath) async { + try { + final appSupportPath = (await getApplicationSupportDirectory()).path; + final logsFile = File("$appSupportPath/logs/debug.log"); + if (!logsFile.existsSync()) { + throw Exception('Logs file does not exist'); + } + await logsFile.copy(filePath); + return true; + } catch (e, s) { + ExceptionHandler.onError(FlutterErrorDetails( + exception: e, + stack: s, + library: "Export Logs", + )); + return false; + } + } + + Future getAbbreviatedLogs() async { + final appSupportPath = (await getApplicationSupportDirectory()).path; + final logsFile = File("$appSupportPath/logs/debug.log"); + if (!logsFile.existsSync()) { + return ""; + } + final logs = logsFile.readAsStringSync(); + // return last 10000 characters: + return logs.substring(logs.length > 10000 ? logs.length - 10000 : 0); + } + + Future removeLogsLocally(String filePath) async { + final logsFile = File(filePath); + if (logsFile.existsSync()) { + await logsFile.delete(); + } + } } diff --git a/pubspec_base.yaml b/pubspec_base.yaml index d5fce76e9..2abd8401a 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -134,7 +134,7 @@ dependency_overrides: bitcoin_base: git: url: https://github.com/cake-tech/bitcoin_base - ref: cake-update-v8 + ref: cake-update-v9 ffi: 2.1.0 flutter_icons: diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index c2be1504a..0db2031dd 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -295,6 +295,7 @@ "expiresOn": "ﻲﻓ ﻪﺘﻴﺣﻼﺻ ﻲﻬﺘﻨﺗ", "expiry_and_validity": "انتهاء الصلاحية والصلاحية", "export_backup": "تصدير نسخة احتياطية", + "export_logs": "سجلات التصدير", "extra_id": "معرف إضافي:", "extracted_address_content": "سوف ترسل الأموال إلى\n${recipient_name}", "failed_authentication": "${state_error} فشل المصادقة.", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB هو بروتوكول جديد يجلب معاملات أسرع وأرخص وأكثر خصوصية إلى Litecoin", "litecoin_mweb_dismiss": "رفض", "litecoin_mweb_display_card": "عرض بطاقة mweb", + "litecoin_mweb_enable": "تمكين MWEB", "litecoin_mweb_enable_later": "يمكنك اختيار تمكين MWEB مرة أخرى ضمن إعدادات العرض.", + "litecoin_mweb_logs": "سجلات MWEB", + "litecoin_mweb_node": "عقدة MWEB", "litecoin_mweb_pegin": "ربط في", "litecoin_mweb_pegout": "ربط", "litecoin_mweb_scanning": "MWEB المسح الضوئي", diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index 8b68d0d1e..7e78dd69a 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -295,6 +295,7 @@ "expiresOn": "Изтича на", "expiry_and_validity": "Изтичане и валидност", "export_backup": "Експортиране на резервно копие", + "export_logs": "Експортни дневници", "extra_id": "Допълнително ID:", "extracted_address_content": "Ще изпратите средства на \n${recipient_name}", "failed_authentication": "Неуспешно удостоверяване. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWeb е нов протокол, който носи по -бърз, по -евтин и повече частни транзакции на Litecoin", "litecoin_mweb_dismiss": "Уволнение", "litecoin_mweb_display_card": "Показване на MWEB карта", + "litecoin_mweb_enable": "Активирайте MWeb", "litecoin_mweb_enable_later": "Можете да изберете да активирате MWEB отново под настройките на дисплея.", + "litecoin_mweb_logs": "MWeb logs", + "litecoin_mweb_node": "MWEB възел", "litecoin_mweb_pegin": "PEG в", "litecoin_mweb_pegout": "PEG OUT", "litecoin_mweb_scanning": "Сканиране на MWEB", diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index 9b48ebdc7..f17466a9f 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -295,6 +295,7 @@ "expiresOn": "Vyprší dne", "expiry_and_validity": "Vypršení a platnost", "export_backup": "Exportovat zálohu", + "export_logs": "Vývozní protokoly", "extra_id": "Extra ID:", "extracted_address_content": "Prostředky budete posílat na\n${recipient_name}", "failed_authentication": "Ověřování selhalo. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB je nový protokol, který do Litecoin přináší rychlejší, levnější a více soukromých transakcí", "litecoin_mweb_dismiss": "Propustit", "litecoin_mweb_display_card": "Zobrazit kartu MWeb", + "litecoin_mweb_enable": "Povolit mWeb", "litecoin_mweb_enable_later": "V nastavení zobrazení můžete vybrat znovu povolit MWeb.", + "litecoin_mweb_logs": "Protokoly mWeb", + "litecoin_mweb_node": "Uzel mWeb", "litecoin_mweb_pegin": "Peg in", "litecoin_mweb_pegout": "Zkrachovat", "litecoin_mweb_scanning": "Skenování mWeb", diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 2ec59f349..cb0a59574 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -295,6 +295,7 @@ "expiresOn": "Läuft aus am", "expiry_and_validity": "Ablauf und Gültigkeit", "export_backup": "Sicherung exportieren", + "export_logs": "Exportprotokolle", "extra_id": "Extra ID:", "extracted_address_content": "Sie senden Geld an\n${recipient_name}", "failed_authentication": "Authentifizierung fehlgeschlagen. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWWB ist ein neues Protokoll, das schnellere, billigere und privatere Transaktionen zu Litecoin bringt", "litecoin_mweb_dismiss": "Zurückweisen", "litecoin_mweb_display_card": "MWEB-Karte anzeigen", + "litecoin_mweb_enable": "Aktivieren Sie MWeb", "litecoin_mweb_enable_later": "Sie können MWEB unter Anzeigeeinstellungen erneut aktivieren.", + "litecoin_mweb_logs": "MWEB -Protokolle", + "litecoin_mweb_node": "MWEB -Knoten", "litecoin_mweb_pegin": "Peg in", "litecoin_mweb_pegout": "Abstecken", "litecoin_mweb_scanning": "MWEB Scanning", @@ -941,4 +945,4 @@ "you_will_get": "Konvertieren zu", "you_will_send": "Konvertieren von", "yy": "YY" -} +} \ No newline at end of file diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 4f5fd23b3..40be8fd08 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -295,6 +295,7 @@ "expiresOn": "Expires on", "expiry_and_validity": "Expiry and Validity", "export_backup": "Export backup", + "export_logs": "Export logs", "extra_id": "Extra ID:", "extracted_address_content": "You will be sending funds to\n${recipient_name}", "failed_authentication": "Failed authentication. ${state_error}", @@ -373,7 +374,10 @@ "litecoin_mweb_description": "MWEB is a new protocol that brings faster, cheaper, and more private transactions to Litecoin", "litecoin_mweb_dismiss": "Dismiss", "litecoin_mweb_display_card": "Show MWEB card", + "litecoin_mweb_enable": "Enable MWEB", "litecoin_mweb_enable_later": "You can choose to enable MWEB again under Display Settings.", + "litecoin_mweb_logs": "MWEB Logs", + "litecoin_mweb_node": "MWEB Node", "litecoin_mweb_pegin": "Peg In", "litecoin_mweb_pegout": "Peg Out", "litecoin_mweb_scanning": "MWEB Scanning", diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index fd20a8b30..5a693cf32 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -295,6 +295,7 @@ "expiresOn": "Expira el", "expiry_and_validity": "Vencimiento y validez", "export_backup": "Exportar copia de seguridad", + "export_logs": "Registros de exportación", "extra_id": "ID adicional:", "extracted_address_content": "Enviará fondos a\n${recipient_name}", "failed_authentication": "Autenticación fallida. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "Mweb es un nuevo protocolo que trae transacciones más rápidas, más baratas y más privadas a Litecoin", "litecoin_mweb_dismiss": "Despedir", "litecoin_mweb_display_card": "Mostrar tarjeta MWEB", + "litecoin_mweb_enable": "Habilitar mweb", "litecoin_mweb_enable_later": "Puede elegir habilitar MWEB nuevamente en la configuración de visualización.", + "litecoin_mweb_logs": "Registros de mweb", + "litecoin_mweb_node": "Nodo mweb", "litecoin_mweb_pegin": "Convertir", "litecoin_mweb_pegout": "Recuperar", "litecoin_mweb_scanning": "Escaneo mweb", diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index 50aceb2f1..1865e41f2 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -295,6 +295,7 @@ "expiresOn": "Expire le", "expiry_and_validity": "Expiration et validité", "export_backup": "Exporter la sauvegarde", + "export_logs": "Journaux d'exportation", "extra_id": "ID supplémentaire :", "extracted_address_content": "Vous allez envoyer des fonds à\n${recipient_name}", "failed_authentication": "Échec d'authentification. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB est un nouveau protocole qui apporte des transactions plus rapides, moins chères et plus privées à Litecoin", "litecoin_mweb_dismiss": "Rejeter", "litecoin_mweb_display_card": "Afficher la carte MWeb", + "litecoin_mweb_enable": "Activer Mweb", "litecoin_mweb_enable_later": "Vous pouvez choisir d'activer à nouveau MWEB sous Paramètres d'affichage.", + "litecoin_mweb_logs": "Journaux MWEB", + "litecoin_mweb_node": "Node MWEB", "litecoin_mweb_pegin": "Entraver", "litecoin_mweb_pegout": "Crever", "litecoin_mweb_scanning": "Scann mweb", diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index e66859531..52cf14128 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -295,6 +295,7 @@ "expiresOn": "Yana ƙarewa", "expiry_and_validity": "Karewa da inganci", "export_backup": "Ajiyayyen fitarwa", + "export_logs": "Injin fitarwa", "extra_id": "Karin ID:", "extracted_address_content": "Za ku aika da kudade zuwa\n${recipient_name}", "failed_authentication": "Binne wajen shiga. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "Mweb shine sabon tsarin yarjejeniya da ya kawo da sauri, mai rahusa, da kuma ma'amaloli masu zaman kansu zuwa Litecoin", "litecoin_mweb_dismiss": "Tuɓe \\ sallama", "litecoin_mweb_display_card": "Nuna katin Mweb", + "litecoin_mweb_enable": "Kunna Mweb", "litecoin_mweb_enable_later": "Kuna iya zaɓar kunna Mweb kuma a ƙarƙashin saitunan nuni.", + "litecoin_mweb_logs": "Jagoran Mweb", + "litecoin_mweb_node": "Mweb Node", "litecoin_mweb_pegin": "Peg in", "litecoin_mweb_pegout": "Peg fita", "litecoin_mweb_scanning": "Mweb scanning", diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index bc8b81211..64f112a42 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -295,6 +295,7 @@ "expiresOn": "पर समय सीमा समाप्त", "expiry_and_validity": "समाप्ति और वैधता", "export_backup": "निर्यात बैकअप", + "export_logs": "निर्यात लॉग", "extra_id": "अतिरिक्त आईडी:", "extracted_address_content": "आपको धनराशि भेजी जाएगी\n${recipient_name}", "failed_authentication": "प्रमाणीकरण विफल. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB एक नया प्रोटोकॉल है जो लिटकोइन के लिए तेजी से, सस्ता और अधिक निजी लेनदेन लाता है", "litecoin_mweb_dismiss": "नकार देना", "litecoin_mweb_display_card": "MWEB कार्ड दिखाएं", + "litecoin_mweb_enable": "MWEB सक्षम करें", "litecoin_mweb_enable_later": "आप प्रदर्शन सेटिंग्स के तहत फिर से MWEB को सक्षम करने के लिए चुन सकते हैं।", + "litecoin_mweb_logs": "MWEB लॉग", + "litecoin_mweb_node": "MWEB नोड", "litecoin_mweb_pegin": "खूंटी", "litecoin_mweb_pegout": "मरना", "litecoin_mweb_scanning": "MWEB स्कैनिंग", diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index 9ff57707d..4bbf4f141 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -295,6 +295,7 @@ "expiresOn": "Istječe", "expiry_and_validity": "Istek i valjanost", "export_backup": "Izvezi sigurnosnu kopiju", + "export_logs": "Izvozni trupci", "extra_id": "Dodatni ID:", "extracted_address_content": "Poslat ćete sredstva primatelju\n${recipient_name}", "failed_authentication": "Autentifikacija neuspješna. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB je novi protokol koji u Litecoin donosi brže, jeftinije i privatnije transakcije", "litecoin_mweb_dismiss": "Odbaciti", "litecoin_mweb_display_card": "Prikaži MWeb karticu", + "litecoin_mweb_enable": "Omogući MWeb", "litecoin_mweb_enable_later": "Možete odabrati da MWEB ponovo omogućite pod postavkama zaslona.", + "litecoin_mweb_logs": "MWEB trupci", + "litecoin_mweb_node": "MWEB čvor", "litecoin_mweb_pegin": "Uvući se", "litecoin_mweb_pegout": "Odapeti", "litecoin_mweb_scanning": "MWEB skeniranje", diff --git a/res/values/strings_hy.arb b/res/values/strings_hy.arb index 40ed1e116..cbaf3a548 100644 --- a/res/values/strings_hy.arb +++ b/res/values/strings_hy.arb @@ -295,6 +295,7 @@ "expiresOn": "Վավերականությունը լրանում է", "expiry_and_validity": "Վավերականություն և լրացում", "export_backup": "Արտահանել կրկնօրինակը", + "export_logs": "Արտահանման տեղեկամատյաններ", "extra_id": "Լրացուցիչ ID", "extracted_address_content": "Դուք կուղարկեք գումար ${recipient_name}", "failed_authentication": "Վավերացումը ձախողվեց. ${state_error}", @@ -367,7 +368,10 @@ "light_theme": "Լուսավոր", "litecoin_mweb_description": "Mweb- ը նոր արձանագրություն է, որը բերում է ավելի արագ, ավելի էժան եւ ավելի մասնավոր գործարքներ դեպի LITECOIN", "litecoin_mweb_dismiss": "Հեռացնել", + "litecoin_mweb_enable": "Միացնել Mweb- ը", "litecoin_mweb_enable_later": "Կարող եք ընտրել Mweb- ը կրկին միացնել ցուցադրման պարամետրերը:", + "litecoin_mweb_logs": "Mweb տեղեկամատյաններ", + "litecoin_mweb_node": "Mweb հանգույց", "litecoin_mweb_pegin": "Peg in", "litecoin_mweb_pegout": "Հափշտակել", "live_fee_rates": "Ապակի վարձավճարներ API- ի միջոցով", diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index 2858fa193..273087eec 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -295,6 +295,7 @@ "expiresOn": "Kadaluarsa pada", "expiry_and_validity": "Kedaluwarsa dan validitas", "export_backup": "Ekspor cadangan", + "export_logs": "Log ekspor", "extra_id": "ID tambahan:", "extracted_address_content": "Anda akan mengirim dana ke\n${recipient_name}", "failed_authentication": "Otentikasi gagal. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB adalah protokol baru yang membawa transaksi yang lebih cepat, lebih murah, dan lebih pribadi ke Litecoin", "litecoin_mweb_dismiss": "Membubarkan", "litecoin_mweb_display_card": "Tunjukkan kartu mWeb", + "litecoin_mweb_enable": "Aktifkan MWEB", "litecoin_mweb_enable_later": "Anda dapat memilih untuk mengaktifkan MWEB lagi di bawah pengaturan tampilan.", + "litecoin_mweb_logs": "Log MWeb", + "litecoin_mweb_node": "Node MWEB", "litecoin_mweb_pegin": "Pasak masuk", "litecoin_mweb_pegout": "Mati", "litecoin_mweb_scanning": "Pemindaian MWEB", diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index 034376a6c..917315aa6 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -296,6 +296,7 @@ "expiresOn": "Scade il", "expiry_and_validity": "Scadenza e validità", "export_backup": "Esporta backup", + "export_logs": "Registri di esportazione", "extra_id": "Extra ID:", "extracted_address_content": "Invierai i tuoi fondi a\n${recipient_name}", "failed_authentication": "Autenticazione fallita. ${state_error}", @@ -372,7 +373,10 @@ "litecoin_mweb_description": "MWeb è un nuovo protocollo che porta transazioni più veloci, più economiche e più private a Litecoin", "litecoin_mweb_dismiss": "Congedare", "litecoin_mweb_display_card": "Mostra la scheda MWeb", + "litecoin_mweb_enable": "Abilita mWeb", "litecoin_mweb_enable_later": "È possibile scegliere di abilitare nuovamente MWeb nelle impostazioni di visualizzazione.", + "litecoin_mweb_logs": "Registri mWeb", + "litecoin_mweb_node": "Nodo MWeb", "litecoin_mweb_pegin": "Piolo in", "litecoin_mweb_pegout": "PEG OUT", "litecoin_mweb_scanning": "Scansione MWeb", diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index 7f0792063..ae3e32ea8 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -295,6 +295,7 @@ "expiresOn": "有効期限は次のとおりです", "expiry_and_validity": "有効期限と有効性", "export_backup": "バックアップのエクスポート", + "export_logs": "ログをエクスポートします", "extra_id": "追加ID:", "extracted_address_content": "に送金します\n${recipient_name}", "failed_authentication": "認証失敗. ${state_error}", @@ -372,7 +373,10 @@ "litecoin_mweb_description": "MWEBは、Litecoinにより速く、より安価で、よりプライベートなトランザクションをもたらす新しいプロトコルです", "litecoin_mweb_dismiss": "却下する", "litecoin_mweb_display_card": "MWEBカードを表示します", + "litecoin_mweb_enable": "MWEBを有効にします", "litecoin_mweb_enable_later": "表示設定の下で、MWEBを再度有効にすることを選択できます。", + "litecoin_mweb_logs": "MWEBログ", + "litecoin_mweb_node": "MWEBノード", "litecoin_mweb_pegin": "ペグイン", "litecoin_mweb_pegout": "ペグアウト", "litecoin_mweb_scanning": "MWEBスキャン", diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index 6ba402ce3..2dc1a2dfc 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -295,6 +295,7 @@ "expiresOn": "만료 날짜", "expiry_and_validity": "만료와 타당성", "export_backup": "백업 내보내기", + "export_logs": "내보내기 로그", "extra_id": "추가 ID:", "extracted_address_content": "당신은에 자금을 보낼 것입니다\n${recipient_name}", "failed_authentication": "인증 실패. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB는 Litecoin에 더 빠르고 저렴하며 개인 거래를 제공하는 새로운 프로토콜입니다.", "litecoin_mweb_dismiss": "해고하다", "litecoin_mweb_display_card": "mweb 카드를 보여주십시오", + "litecoin_mweb_enable": "mweb 활성화", "litecoin_mweb_enable_later": "디스플레이 설정에서 MWEB를 다시 활성화하도록 선택할 수 있습니다.", + "litecoin_mweb_logs": "mweb 로그", + "litecoin_mweb_node": "mweb 노드", "litecoin_mweb_pegin": "페그를 입력하십시오", "litecoin_mweb_pegout": "죽다", "litecoin_mweb_scanning": "mweb 스캔", diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 69b888b5a..f42af6863 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -295,6 +295,7 @@ "expiresOn": "သက်တမ်းကုန်သည်။", "expiry_and_validity": "သက်တမ်းကုန်ဆုံးခြင်းနှင့်တရားဝင်မှု", "export_backup": "အရန်ကူးထုတ်ရန်", + "export_logs": "ပို့ကုန်မှတ်တမ်းများ", "extra_id": "အပို ID-", "extracted_address_content": "သင်သည် \n${recipient_name} သို့ ရန်ပုံငွေများ ပေးပို့ပါမည်", "failed_authentication": "အထောက်အထားစိစစ်ခြင်း မအောင်မြင်ပါ။. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "Mweb သည် Protocol အသစ်ဖြစ်ပြီး LitCoin သို့ပိုမိုဈေးချိုသာသော, စျေးသက်သက်သာသာသုံးခြင်းနှင့်ပိုမိုများပြားသောပုဂ္ဂလိကငွေပို့ဆောင်မှုများကိုဖြစ်ပေါ်စေသည်", "litecoin_mweb_dismiss": "ထုတ်ပစ်", "litecoin_mweb_display_card": "MweB ကဒ်ကိုပြပါ", + "litecoin_mweb_enable": "mweb enable", "litecoin_mweb_enable_later": "သင် MweB ကို display settings အောက်ရှိ ထပ်မံ. ခွင့်ပြုရန်ရွေးချယ်နိုင်သည်။", + "litecoin_mweb_logs": "Mweb မှတ်တမ်းများ", + "litecoin_mweb_node": "mweb node ကို", "litecoin_mweb_pegin": "တံစို့", "litecoin_mweb_pegout": "တံစို့", "litecoin_mweb_scanning": "mweb scanning", diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index 2c259bf80..73e3df6b6 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -295,6 +295,7 @@ "expiresOn": "Verloopt op", "expiry_and_validity": "Vervallen en geldigheid", "export_backup": "Back-up exporteren", + "export_logs": "Exporteer logboeken", "extra_id": "Extra ID:", "extracted_address_content": "U stuurt geld naar\n${recipient_name}", "failed_authentication": "Mislukte authenticatie. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB is een nieuw protocol dat snellere, goedkopere en meer privé -transacties naar Litecoin brengt", "litecoin_mweb_dismiss": "Afwijzen", "litecoin_mweb_display_card": "Toon MWEB -kaart", + "litecoin_mweb_enable": "MWEB inschakelen", "litecoin_mweb_enable_later": "U kunt ervoor kiezen om MWeb opnieuw in te schakelen onder weergave -instellingen.", + "litecoin_mweb_logs": "MWEB -logboeken", + "litecoin_mweb_node": "MWEB -knooppunt", "litecoin_mweb_pegin": "Vastmaken", "litecoin_mweb_pegout": "Uithakken", "litecoin_mweb_scanning": "MWEB -scanning", diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index 936339708..aaa82a3bf 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -295,6 +295,7 @@ "expiresOn": "Upływa w dniu", "expiry_and_validity": "Wygaśnięcie i ważność", "export_backup": "Eksportuj kopię zapasową", + "export_logs": "Dzienniki eksportu", "extra_id": "Dodatkowy ID:", "extracted_address_content": "Wysyłasz środki na\n${recipient_name}", "failed_authentication": "Nieudane uwierzytelnienie. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB to nowy protokół, który przynosi szybciej, tańsze i bardziej prywatne transakcje do Litecoin", "litecoin_mweb_dismiss": "Odrzucać", "litecoin_mweb_display_card": "Pokaż kartę MWEB", + "litecoin_mweb_enable": "Włącz MWEB", "litecoin_mweb_enable_later": "Możesz ponownie włączyć MWEB w ustawieniach wyświetlania.", + "litecoin_mweb_logs": "Dzienniki MWEB", + "litecoin_mweb_node": "Węzeł MWEB", "litecoin_mweb_pegin": "Kołek", "litecoin_mweb_pegout": "Palikować", "litecoin_mweb_scanning": "Skanowanie MWEB", diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index 0cc0cc00a..194307a3d 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -295,6 +295,7 @@ "expiresOn": "Expira em", "expiry_and_validity": "Expiração e validade", "export_backup": "Backup de exportação", + "export_logs": "Exportar logs", "extra_id": "ID extra:", "extracted_address_content": "Você enviará fundos para\n${recipient_name}", "failed_authentication": "Falha na autenticação. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB é um novo protocolo que traz transações mais rápidas, baratas e mais privadas para o Litecoin", "litecoin_mweb_dismiss": "Liberar", "litecoin_mweb_display_card": "Mostre o cartão MWEB", + "litecoin_mweb_enable": "Ativar Mweb", "litecoin_mweb_enable_later": "Você pode optar por ativar o MWEB novamente em Configurações de exibição.", + "litecoin_mweb_logs": "Logs MWeb", + "litecoin_mweb_node": "Nó MWeb", "litecoin_mweb_pegin": "Peg in", "litecoin_mweb_pegout": "Peg fora", "litecoin_mweb_scanning": "MWEB Scanning", diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index 8aeb56a86..f42c28c15 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -295,6 +295,7 @@ "expiresOn": "Годен до", "expiry_and_validity": "Истечение и достоверность", "export_backup": "Экспорт резервной копии", + "export_logs": "Экспортные журналы", "extra_id": "Дополнительный ID:", "extracted_address_content": "Вы будете отправлять средства\n${recipient_name}", "failed_authentication": "Ошибка аутентификации. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB - это новый протокол, который приносит быстрее, дешевле и более частные транзакции в Litecoin", "litecoin_mweb_dismiss": "Увольнять", "litecoin_mweb_display_card": "Показать карту MWEB", + "litecoin_mweb_enable": "Включить MWEB", "litecoin_mweb_enable_later": "Вы можете снова включить MWEB в настройках отображения.", + "litecoin_mweb_logs": "MWEB журналы", + "litecoin_mweb_node": "Узел MWEB", "litecoin_mweb_pegin": "Внедрять", "litecoin_mweb_pegout": "Выкрикивать", "litecoin_mweb_scanning": "MWEB сканирование", diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index 736eb8762..9d8fb0af2 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -295,6 +295,7 @@ "expiresOn": "หมดอายุวันที่", "expiry_and_validity": "หมดอายุและถูกต้อง", "export_backup": "ส่งออกข้อมูลสำรอง", + "export_logs": "บันทึกการส่งออก", "extra_id": "ไอดีเพิ่มเติม:", "extracted_address_content": "คุณกำลังจะส่งเงินไปยัง\n${recipient_name}", "failed_authentication": "การยืนยันสิทธิ์ล้มเหลว ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB เป็นโปรโตคอลใหม่ที่นำการทำธุรกรรมที่เร็วกว่าราคาถูกกว่าและเป็นส่วนตัวมากขึ้นไปยัง Litecoin", "litecoin_mweb_dismiss": "อนุญาตให้ออกไป", "litecoin_mweb_display_card": "แสดงการ์ด mweb", + "litecoin_mweb_enable": "เปิดใช้งาน mweb", "litecoin_mweb_enable_later": "คุณสามารถเลือกเปิดใช้งาน MWEB อีกครั้งภายใต้การตั้งค่าการแสดงผล", + "litecoin_mweb_logs": "บันทึก MWEB", + "litecoin_mweb_node": "โหนด MWEB", "litecoin_mweb_pegin": "หมุด", "litecoin_mweb_pegout": "ตรึง", "litecoin_mweb_scanning": "การสแกน MWEB", diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index e68bd0b22..ca35e7997 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -295,6 +295,7 @@ "expiresOn": "Mag-e-expire sa", "expiry_and_validity": "Pag-expire at Bisa", "export_backup": "I-export ang backup", + "export_logs": "Mga log ng pag -export", "extra_id": "Dagdag na ID:", "extracted_address_content": "Magpapadala ka ng pondo sa\n${recipient_name}", "failed_authentication": "Nabigo ang pagpapatunay. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "Ang MWeb ay isang bagong protocol na nagdadala ng mas mabilis, mas mura, at mas maraming pribadong mga transaksyon sa Litecoin", "litecoin_mweb_dismiss": "Tanggalin", "litecoin_mweb_display_card": "Ipakita ang MWEB Card", + "litecoin_mweb_enable": "Paganahin ang MWeb", "litecoin_mweb_enable_later": "Maaari kang pumili upang paganahin muli ang MWeb sa ilalim ng mga setting ng pagpapakita.", + "litecoin_mweb_logs": "MWEB log", + "litecoin_mweb_node": "Mweb node", "litecoin_mweb_pegin": "Peg in", "litecoin_mweb_pegout": "Peg out", "litecoin_mweb_scanning": "Pag -scan ng Mweb", diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index 83e8f6cbc..1fee66369 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -295,6 +295,7 @@ "expiresOn": "Tarihinde sona eriyor", "expiry_and_validity": "Sona erme ve geçerlilik", "export_backup": "Yedeği dışa aktar", + "export_logs": "Dışa aktarma günlükleri", "extra_id": "Ekstra ID:", "extracted_address_content": "Parayı buraya gönderceksin:\n${recipient_name}", "failed_authentication": "Doğrulama başarısız oldu. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB, Litecoin'e daha hızlı, daha ucuz ve daha fazla özel işlem getiren yeni bir protokoldür", "litecoin_mweb_dismiss": "Azletmek", "litecoin_mweb_display_card": "MWEB kartını göster", + "litecoin_mweb_enable": "MWEB'i etkinleştir", "litecoin_mweb_enable_later": "Ekran ayarlarının altında MWEB'yi tekrar etkinleştirmeyi seçebilirsiniz.", + "litecoin_mweb_logs": "MWEB günlükleri", + "litecoin_mweb_node": "MWEB düğümü", "litecoin_mweb_pegin": "Takılmak", "litecoin_mweb_pegout": "Çiğnemek", "litecoin_mweb_scanning": "MWEB taraması", diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index 909d9c01f..5d9a56c1e 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -295,6 +295,7 @@ "expiresOn": "Термін дії закінчується", "expiry_and_validity": "Закінчення та обгрунтованість", "export_backup": "Експортувати резервну копію", + "export_logs": "Експортні журнали", "extra_id": "Додатковий ID:", "extracted_address_content": "Ви будете відправляти кошти\n${recipient_name}", "failed_authentication": "Помилка аутентифікації. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB - це новий протокол, який приносить швидкі, дешевші та більш приватні транзакції Litecoin", "litecoin_mweb_dismiss": "Звільнити", "litecoin_mweb_display_card": "Показати карту MWeb", + "litecoin_mweb_enable": "Увімкнути mweb", "litecoin_mweb_enable_later": "Ви можете знову ввімкнути MWEB в налаштуваннях дисплея.", + "litecoin_mweb_logs": "Журнали MWeb", + "litecoin_mweb_node": "Вузол MWeb", "litecoin_mweb_pegin": "Подякувати", "litecoin_mweb_pegout": "Подякувати", "litecoin_mweb_scanning": "Сканування Mweb", diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 2dbc97102..106f00f96 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -295,6 +295,7 @@ "expiresOn": "ﺩﺎﻌﯿﻣ ﯽﻣﺎﺘﺘﺧﺍ", "expiry_and_validity": "میعاد ختم اور صداقت", "export_backup": "بیک اپ برآمد کریں۔", + "export_logs": "نوشتہ جات برآمد کریں", "extra_id": "اضافی ID:", "extracted_address_content": "آپ فنڈز بھیج رہے ہوں گے\n${recipient_name}", "failed_authentication": "ناکام تصدیق۔ ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB ایک نیا پروٹوکول ہے جو لیٹیکوئن میں تیز ، سستا اور زیادہ نجی لین دین لاتا ہے", "litecoin_mweb_dismiss": "خارج", "litecoin_mweb_display_card": "MWEB کارڈ دکھائیں", + "litecoin_mweb_enable": "MWEB کو فعال کریں", "litecoin_mweb_enable_later": "آپ ڈسپلے کی ترتیبات کے تحت MWEB کو دوبارہ فعال کرنے کا انتخاب کرسکتے ہیں۔", + "litecoin_mweb_logs": "MWEB لاگز", + "litecoin_mweb_node": "MWEB نوڈ", "litecoin_mweb_pegin": "پیگ میں", "litecoin_mweb_pegout": "پیگ آؤٹ", "litecoin_mweb_scanning": "MWEB اسکیننگ", diff --git a/res/values/strings_vi.arb b/res/values/strings_vi.arb index 8d28d48a2..a95f32aaa 100644 --- a/res/values/strings_vi.arb +++ b/res/values/strings_vi.arb @@ -296,6 +296,7 @@ "expiresOn": "Hết hạn vào", "expiry_and_validity": "Hạn và hiệu lực", "export_backup": "Xuất sao lưu", + "export_logs": "Nhật ký xuất khẩu", "extra_id": "ID bổ sung:", "extracted_address_content": "Bạn sẽ gửi tiền cho\n${recipient_name}", "failed_authentication": "Xác thực không thành công. ${state_error}", @@ -368,7 +369,10 @@ "light_theme": "Chủ đề sáng", "litecoin_mweb_description": "MWEB là một giao thức mới mang lại các giao dịch nhanh hơn, rẻ hơn và riêng tư hơn cho Litecoin", "litecoin_mweb_dismiss": "Miễn nhiệm", + "litecoin_mweb_enable": "Bật MWEB", "litecoin_mweb_enable_later": "Bạn có thể chọn bật lại MWEB trong cài đặt hiển thị.", + "litecoin_mweb_logs": "Nhật ký MWEB", + "litecoin_mweb_node": "Nút MWEB", "litecoin_mweb_pegin": "Chốt vào", "litecoin_mweb_pegout": "Chốt ra", "live_fee_rates": "Tỷ lệ phí hiện tại qua API", diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index 03c7c95ef..1a1583da5 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -296,6 +296,7 @@ "expiresOn": "Ipari lori", "expiry_and_validity": "Ipari ati idaniloju", "export_backup": "Sún ẹ̀dà nípamọ́ síta", + "export_logs": "Wọle si okeere", "extra_id": "Àmì ìdánimọ̀ tó fikún:", "extracted_address_content": "Ẹ máa máa fi owó ránṣẹ́ sí\n${recipient_name}", "failed_authentication": "Ìfẹ̀rílàdí pipòfo. ${state_error}", @@ -372,7 +373,10 @@ "litecoin_mweb_description": "Mweb jẹ ilana ilana tuntun ti o mu iyara wa yiyara, din owo, ati awọn iṣowo ikọkọ diẹ sii si Livcoin", "litecoin_mweb_dismiss": "Tuka", "litecoin_mweb_display_card": "Fihan kaadi Mweb", + "litecoin_mweb_enable": "Mu mweb", "litecoin_mweb_enable_later": "O le yan lati ṣiṣẹ Mweb lẹẹkansi labẹ awọn eto ifihan.", + "litecoin_mweb_logs": "MTweb logs", + "litecoin_mweb_node": "Alweb joko", "litecoin_mweb_pegin": "Peg in", "litecoin_mweb_pegout": "Peg jade", "litecoin_mweb_scanning": "Mweb scanning", diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index 5c8d708f4..79fe8faa5 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -295,6 +295,7 @@ "expiresOn": "到期", "expiry_and_validity": "到期和有效性", "export_backup": "导出备份", + "export_logs": "导出日志", "extra_id": "额外ID:", "extracted_address_content": "您将汇款至\n${recipient_name}", "failed_authentication": "身份验证失败. ${state_error}", @@ -371,7 +372,10 @@ "litecoin_mweb_description": "MWEB是一项新协议,它将更快,更便宜和更多的私人交易带给Litecoin", "litecoin_mweb_dismiss": "解雇", "litecoin_mweb_display_card": "显示MWEB卡", + "litecoin_mweb_enable": "启用MWEB", "litecoin_mweb_enable_later": "您可以选择在显示设置下再次启用MWEB。", + "litecoin_mweb_logs": "MWEB日志", + "litecoin_mweb_node": "MWEB节点", "litecoin_mweb_pegin": "钉进", "litecoin_mweb_pegout": "昏倒", "litecoin_mweb_scanning": "MWEB扫描", From 48457fdd6d3b15beacdbe170117dcbc2cbce25dc Mon Sep 17 00:00:00 2001 From: Serhii Date: Thu, 7 Nov 2024 07:50:48 +0200 Subject: [PATCH 5/8] cake pay country picker (#1779) * cake pay country picker * UI changes * hide picker after the country is set * Revert "hide picker after the country is set" This reverts commit cce358b94f55c22e0807b4d3641a13ebd299106c. * show country picker initially * flags * minor ui fixes * Update lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart --------- Co-authored-by: tuxpizza Co-authored-by: Omar Hatem --- assets/images/flags/abw.png | Bin 0 -> 607 bytes assets/images/flags/afg.png | Bin 0 -> 1848 bytes assets/images/flags/ago.png | Bin 0 -> 1159 bytes assets/images/flags/aia.png | Bin 0 -> 1357 bytes assets/images/flags/and.png | Bin 0 -> 1966 bytes assets/images/flags/asm.png | Bin 0 -> 503 bytes assets/images/flags/atf.png | Bin 0 -> 1311 bytes assets/images/flags/atg.png | Bin 0 -> 963 bytes assets/images/flags/aut.png | Bin 0 -> 125 bytes assets/images/flags/aze.png | Bin 0 -> 717 bytes assets/images/flags/bel.png | Bin 0 -> 127 bytes assets/images/flags/bes.png | Bin 0 -> 236 bytes assets/images/flags/bhr.png | Bin 0 -> 567 bytes assets/images/flags/blz.png | Bin 0 -> 1777 bytes assets/images/flags/bmu.png | Bin 0 -> 1518 bytes assets/images/flags/bol.png | Bin 0 -> 668 bytes assets/images/flags/brn.png | Bin 0 -> 1957 bytes assets/images/flags/btn.png | Bin 0 -> 4013 bytes assets/images/flags/bvt.png | Bin 0 -> 147 bytes assets/images/flags/bwa.png | Bin 0 -> 134 bytes assets/images/flags/cck.png | Bin 0 -> 1139 bytes assets/images/flags/cmr.png | Bin 0 -> 402 bytes assets/images/flags/cok.png | Bin 0 -> 1416 bytes assets/images/flags/cpv.png | Bin 0 -> 297 bytes assets/images/flags/cri.png | Bin 0 -> 136 bytes assets/images/flags/cuw.png | Bin 0 -> 481 bytes assets/images/flags/cxr.png | Bin 0 -> 1202 bytes assets/images/flags/cyp.png | Bin 0 -> 1128 bytes assets/images/flags/dji.png | Bin 0 -> 387 bytes assets/images/flags/dma.png | Bin 0 -> 1224 bytes assets/images/flags/dza.png | Bin 0 -> 518 bytes assets/images/flags/ecu.png | Bin 0 -> 1238 bytes assets/images/flags/est.png | Bin 0 -> 130 bytes assets/images/flags/eth.png | Bin 0 -> 1584 bytes assets/images/flags/fin.png | Bin 0 -> 131 bytes assets/images/flags/fji.png | Bin 0 -> 1571 bytes assets/images/flags/flk.png | Bin 0 -> 1591 bytes assets/images/flags/fro.png | Bin 0 -> 147 bytes assets/images/flags/fsm.png | Bin 0 -> 340 bytes assets/images/flags/gab.png | Bin 0 -> 130 bytes assets/images/flags/geo.png | Bin 0 -> 187 bytes assets/images/flags/ggi.png | Bin 0 -> 892 bytes assets/images/flags/ggy.png | Bin 0 -> 192 bytes assets/images/flags/glp.png | Bin 0 -> 1820 bytes assets/images/flags/gmb.png | Bin 0 -> 278 bytes assets/images/flags/grc.png | Bin 0 -> 436 bytes assets/images/flags/grd.png | Bin 0 -> 892 bytes assets/images/flags/grl.png | Bin 0 -> 407 bytes assets/images/flags/guf.png | Bin 0 -> 581 bytes assets/images/flags/gum.png | Bin 0 -> 1259 bytes assets/images/flags/guy.png | Bin 0 -> 635 bytes assets/images/flags/hmd.png | Bin 0 -> 780 bytes assets/images/flags/iot.png | Bin 0 -> 2415 bytes assets/images/flags/irl.png | Bin 0 -> 127 bytes assets/images/flags/jam.png | Bin 0 -> 526 bytes assets/images/flags/jey.png | Bin 0 -> 914 bytes assets/images/flags/jor.png | Bin 0 -> 3131 bytes assets/images/flags/kaz.png | Bin 0 -> 1740 bytes assets/images/flags/ken.png | Bin 0 -> 2074 bytes assets/images/flags/kir.png | Bin 0 -> 2113 bytes assets/images/flags/kwt.png | Bin 0 -> 372 bytes assets/images/flags/lbn.png | Bin 0 -> 960 bytes assets/images/flags/lie.png | Bin 0 -> 844 bytes assets/images/flags/lka.png | Bin 0 -> 1757 bytes assets/images/flags/ltu.png | Bin 0 -> 130 bytes assets/images/flags/lux.png | Bin 0 -> 130 bytes assets/images/flags/lva.png | Bin 0 -> 124 bytes assets/images/flags/mco.png | Bin 0 -> 204 bytes assets/images/flags/mlt.png | Bin 0 -> 485 bytes assets/images/flags/mnp.png | Bin 0 -> 2078 bytes assets/images/flags/mrt.png | Bin 0 -> 509 bytes assets/images/flags/msr.png | Bin 0 -> 1502 bytes assets/images/flags/mtq.png | Bin 0 -> 1512 bytes assets/images/flags/mwi.png | Bin 0 -> 754 bytes assets/images/flags/myt.png | Bin 0 -> 2003 bytes assets/images/flags/ner.png | Bin 0 -> 494 bytes assets/images/flags/nfk.png | Bin 0 -> 1422 bytes assets/images/flags/niu.png | Bin 0 -> 1095 bytes assets/images/flags/omn.png | Bin 0 -> 711 bytes assets/images/flags/per.png | Bin 0 -> 123 bytes assets/images/flags/plw.png | Bin 0 -> 608 bytes assets/images/flags/pri.png | Bin 0 -> 1880 bytes assets/images/flags/pyf.png | Bin 0 -> 1256 bytes assets/images/flags/qat.png | Bin 0 -> 2021 bytes assets/images/flags/slb.png | Bin 0 -> 403 bytes assets/images/flags/slv.png | Bin 0 -> 642 bytes assets/images/flags/svk.png | Bin 0 -> 521 bytes assets/images/flags/svn.png | Bin 0 -> 1963 bytes assets/images/flags/tkm.png | Bin 0 -> 1620 bytes assets/images/flags/ton.png | Bin 0 -> 139 bytes assets/images/flags/tuv.png | Bin 0 -> 1456 bytes assets/images/flags/ury.png | Bin 0 -> 1017 bytes assets/images/flags/vat.png | Bin 0 -> 851 bytes assets/images/flags/vir.png | Bin 0 -> 1987 bytes assets/images/flags/vut.png | Bin 0 -> 1267 bytes lib/cake_pay/cake_pay_api.dart | 10 +- lib/cake_pay/cake_pay_service.dart | 3 +- lib/di.dart | 3 +- lib/entities/country.dart | 386 ++++++++++++++++++ lib/entities/preferences_key.dart | 1 + .../cake_pay/cards/cake_pay_cards_page.dart | 156 +++++-- .../screens/cake_pay/widgets/card_item.dart | 2 +- lib/store/settings_store.dart | 25 +- .../cake_pay_cards_list_view_model.dart | 65 +-- res/values/strings_ar.arb | 1 + res/values/strings_bg.arb | 1 + res/values/strings_cs.arb | 1 + res/values/strings_de.arb | 1 + res/values/strings_en.arb | 1 + res/values/strings_es.arb | 1 + res/values/strings_fr.arb | 1 + res/values/strings_ha.arb | 1 + res/values/strings_hi.arb | 1 + res/values/strings_hr.arb | 1 + res/values/strings_hy.arb | 1 + res/values/strings_id.arb | 1 + res/values/strings_it.arb | 1 + res/values/strings_ja.arb | 1 + res/values/strings_ko.arb | 1 + res/values/strings_my.arb | 1 + res/values/strings_nl.arb | 1 + res/values/strings_pl.arb | 1 + res/values/strings_pt.arb | 1 + res/values/strings_ru.arb | 1 + res/values/strings_th.arb | 1 + res/values/strings_tl.arb | 1 + res/values/strings_tr.arb | 1 + res/values/strings_uk.arb | 1 + res/values/strings_ur.arb | 1 + res/values/strings_vi.arb | 1 + res/values/strings_yo.arb | 1 + res/values/strings_zh.arb | 1 + 132 files changed, 601 insertions(+), 78 deletions(-) create mode 100644 assets/images/flags/abw.png create mode 100644 assets/images/flags/afg.png create mode 100644 assets/images/flags/ago.png create mode 100644 assets/images/flags/aia.png create mode 100644 assets/images/flags/and.png create mode 100644 assets/images/flags/asm.png create mode 100644 assets/images/flags/atf.png create mode 100644 assets/images/flags/atg.png create mode 100644 assets/images/flags/aut.png create mode 100644 assets/images/flags/aze.png create mode 100644 assets/images/flags/bel.png create mode 100644 assets/images/flags/bes.png create mode 100644 assets/images/flags/bhr.png create mode 100644 assets/images/flags/blz.png create mode 100644 assets/images/flags/bmu.png create mode 100644 assets/images/flags/bol.png create mode 100644 assets/images/flags/brn.png create mode 100644 assets/images/flags/btn.png create mode 100644 assets/images/flags/bvt.png create mode 100644 assets/images/flags/bwa.png create mode 100644 assets/images/flags/cck.png create mode 100644 assets/images/flags/cmr.png create mode 100644 assets/images/flags/cok.png create mode 100644 assets/images/flags/cpv.png create mode 100644 assets/images/flags/cri.png create mode 100644 assets/images/flags/cuw.png create mode 100644 assets/images/flags/cxr.png create mode 100644 assets/images/flags/cyp.png create mode 100644 assets/images/flags/dji.png create mode 100644 assets/images/flags/dma.png create mode 100644 assets/images/flags/dza.png create mode 100644 assets/images/flags/ecu.png create mode 100644 assets/images/flags/est.png create mode 100644 assets/images/flags/eth.png create mode 100644 assets/images/flags/fin.png create mode 100644 assets/images/flags/fji.png create mode 100644 assets/images/flags/flk.png create mode 100644 assets/images/flags/fro.png create mode 100644 assets/images/flags/fsm.png create mode 100644 assets/images/flags/gab.png create mode 100644 assets/images/flags/geo.png create mode 100644 assets/images/flags/ggi.png create mode 100644 assets/images/flags/ggy.png create mode 100644 assets/images/flags/glp.png create mode 100644 assets/images/flags/gmb.png create mode 100644 assets/images/flags/grc.png create mode 100644 assets/images/flags/grd.png create mode 100644 assets/images/flags/grl.png create mode 100644 assets/images/flags/guf.png create mode 100644 assets/images/flags/gum.png create mode 100644 assets/images/flags/guy.png create mode 100644 assets/images/flags/hmd.png create mode 100644 assets/images/flags/iot.png create mode 100644 assets/images/flags/irl.png create mode 100644 assets/images/flags/jam.png create mode 100644 assets/images/flags/jey.png create mode 100644 assets/images/flags/jor.png create mode 100644 assets/images/flags/kaz.png create mode 100644 assets/images/flags/ken.png create mode 100644 assets/images/flags/kir.png create mode 100644 assets/images/flags/kwt.png create mode 100644 assets/images/flags/lbn.png create mode 100644 assets/images/flags/lie.png create mode 100644 assets/images/flags/lka.png create mode 100644 assets/images/flags/ltu.png create mode 100644 assets/images/flags/lux.png create mode 100644 assets/images/flags/lva.png create mode 100644 assets/images/flags/mco.png create mode 100644 assets/images/flags/mlt.png create mode 100644 assets/images/flags/mnp.png create mode 100644 assets/images/flags/mrt.png create mode 100644 assets/images/flags/msr.png create mode 100644 assets/images/flags/mtq.png create mode 100644 assets/images/flags/mwi.png create mode 100644 assets/images/flags/myt.png create mode 100644 assets/images/flags/ner.png create mode 100644 assets/images/flags/nfk.png create mode 100644 assets/images/flags/niu.png create mode 100644 assets/images/flags/omn.png create mode 100644 assets/images/flags/per.png create mode 100644 assets/images/flags/plw.png create mode 100644 assets/images/flags/pri.png create mode 100644 assets/images/flags/pyf.png create mode 100644 assets/images/flags/qat.png create mode 100644 assets/images/flags/slb.png create mode 100644 assets/images/flags/slv.png create mode 100644 assets/images/flags/svk.png create mode 100644 assets/images/flags/svn.png create mode 100644 assets/images/flags/tkm.png create mode 100644 assets/images/flags/ton.png create mode 100644 assets/images/flags/tuv.png create mode 100644 assets/images/flags/ury.png create mode 100644 assets/images/flags/vat.png create mode 100644 assets/images/flags/vir.png create mode 100644 assets/images/flags/vut.png create mode 100644 lib/entities/country.dart diff --git a/assets/images/flags/abw.png b/assets/images/flags/abw.png new file mode 100644 index 0000000000000000000000000000000000000000..d049d3a4348416d1826a4236ce7218e6807669b9 GIT binary patch literal 607 zcmV-l0-*hgP)$j``PLRzf4Wb7x>|$0RDQWte!7{p*jKX1y z!eETUQhm8ug1qOjw|tetVvE63e!A+Xt%jGuPJ_X$nzmViy#HBMiJ-}ap2@Ys++&Nu zRerfmg223(uWY5+R)D*=n6PD$#{X1MPJq6;oU~JZxowfd{3IdfdxA`a!svN_{Uab) zjmYwqn2w;w+J%r#e!RED+=Q&xZIs8unyrzTzW+x_fAgsl$B42#r8-@ z@F^?$ARf(;q3|6Z?~3@MEG(l&J43E%0P!`5PIB zRyK|R0004WQchC08VAA(3FhmG%rRM6L~Q{W+swdz@lLj7?mEu=wz&>AjC^nAnI9$#f7*U zs>^V(k{b(-zHSD(s+6Py2Tg57HcAX&la>;uC?#?V3h+>vow+G3511)6fPK_}fir-E t0x(Dn7>$L|SQs^cN(PLAQ7{UC0055fRc;z8VCw(?002ovPDHLkV1l;f6z%{3 literal 0 HcmV?d00001 diff --git a/assets/images/flags/afg.png b/assets/images/flags/afg.png new file mode 100644 index 0000000000000000000000000000000000000000..f2ea25144b2c5bf758a134ae00ca05e99cc9581b GIT binary patch literal 1848 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 literal 0 HcmV?d00001 diff --git a/assets/images/flags/atf.png b/assets/images/flags/atf.png new file mode 100644 index 0000000000000000000000000000000000000000..af77e45d56cffeac7204d977b30278c46b2335de GIT binary patch literal 1311 zcmV+)1>pLLP)Px%Pf$!$MF0Q*03wtCB9#Cllu&20l$4Z|86yA_QvelR z?PzlV6H@>plmH@?03($EBa{FmlmH@>03wqBB9#CYT>v7K02_AzB9#Cll>i)i03nh9 z9DV>Il>j1?03wnAB9s6kl>j4@02*@uAB6xSlmH`?03wwD9D4vBf&e0u03Cmll9QB^ zlQ2JT?P+uXB9xRxOHpXEi+_cbA1IS6GX)x7X>f!9BbDy%?(Hc#|NsBFz}m*n=efb! z?ZU+dD3<{wmHz(zfRf7Y@b^AhuPjNYJXfx~#M=TTmKQgk5i*<h(HRt!#k8=IQj8rqoVmwXeF}9Xz45zTZ7pt{OU^2r8IIV6rw* ztMT*s3@w`NYIW_<(^6@(%h2H%IG@qj?7_A+Q>^3X^Cm>4h?&ieozLj&^>v8G zg_g{Gj>u$rzOlUCKw7VPjK|N`>yDk!&eiLzxZF);wN`GqbB4w9_4<&X(E0lOzQ^P- zO{vAr=fTP4yTaNWJD?djpSi-}Cq$(aG@W;e#<#)YCq<sX{{dkJUc8SJsg2Oykt!I3|MqB#U0000s zbW%=J0Qc1X`y3n`9QzZAiTL#Rnq~A8W*qnFBw;0*n_*$<>KrB1X8s%{VH_MB9R2DX z{U07593FzAMaBRC0xU^HK~z}7V_+BsqW}(sIXF0Uv{<1)Tb~UIjEoIUO*pUuVT5jV zX+1L_U@?aSP763Nw=%;4&7%yJA2qC#fm8(_w# z$-rYqi2-KZ3`~?6z{J2ynF0I^W|SFVHfq4A0mIJ#1B3y(NCvbLZ9odbfFvXX+z1$8 z0|%)H1Cnjw0AWC`H4>NuH^7__fE(cHocAItzt%k7b7X0D-@8EM3Op~SZvNG5^}*s=34 zkYU#2lS#Z6>|N~x&CEPDc|yVH>X#cJQmJy^Iu-tOv<|xYqOCUPvwas!VsRk@uVu=Dm z=aOi^?65dHJG;d|tqveYNZ2fr0}%w~2U%Lq!w%<8 zI2i``7vvY#n3)B8n#G1hG}d~NY=C{9Wn~wH*;MCH4R--A(IymCbO6anU{TWEQWXtv zU@#GFfQOk!pPAXrNoHnV0cNnItc=viA_h!}n%X~Y0!kxGQHUfH{4Im*kehjG3=ERY zq!=)H#>DAJ4NVQ?rY=#y-OMZi&Jh!VG?pa<#LdV63IbAy20Ir!$$)FLlQaqt001|j VkQQ>_vPJ*^002ovPDHLkV1h&LS9<^e literal 0 HcmV?d00001 diff --git a/assets/images/flags/atg.png b/assets/images/flags/atg.png new file mode 100644 index 0000000000000000000000000000000000000000..d9a3d9f9e106261f8cd7e740df225aa703bf0b5d GIT binary patch literal 963 zcmV;!13dhRP)z=0m{{G%#aL*Vl_0rY=H($pPBvS?thYJ`A00PxBMf~#f zuMQw40|$pPT?%Zy0CB~~6f09og(hF8uOB@M2mt)i75mT?J23@CIR+050pYq6o`Vey z3jwhpJ%O&*CMQLJp=33jsVb1riSdIxq!oS_w!z2I|5T5e@;Mg%0b(6l_@upo9&b zf(@L34Si<{NjwJ5s}h-i4bQ6*d}j)qfDLqD3UgozpMwpRdkx>Y6PA1pp@j|)4FREs z4i8Tz9RL6T3UpFVQvjcpm6h+G@1LKF$KMzL00H+&L_t(o!|m2rPXa+0g<*RcKSx(U z?1%``?O0Gj#g4uA-n;()#05cicG?1WhI6qA4D&*sD~gH=E9{@Fq$-bsyvKqf#j4Bx7cwfF*>pGOPvHTA{uFa@9uPz;Z+U&w=3f=f%m z73@L2zzq$c4Nwddk+64h8GmE^iI*3>?~w@_fCx|wQ&IQFDo$?o%^jU$0FVIEBvLAb1mlYUv-wgl&002ovPDHLkV1lw*cfSAt literal 0 HcmV?d00001 diff --git a/assets/images/flags/aut.png b/assets/images/flags/aut.png new file mode 100644 index 0000000000000000000000000000000000000000..1be1ff483baf9d14483c3c5fea30ca5c21c6b406 GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSoB=)|uI>dsZ#6Cd|NsB@@7-pg zAY)08UoeBivm0qZj*_Q~V~EA+zxp00i_>zopr0P6cAp#T5? literal 0 HcmV?d00001 diff --git a/assets/images/flags/aze.png b/assets/images/flags/aze.png new file mode 100644 index 0000000000000000000000000000000000000000..834b1e696beb647bb499338ce6c9c8a0fa0e7aae GIT binary patch literal 717 zcmV;;0y6!HP)I66G1ULWx{{Wc7-~coLu4U>0HR%8?=>Rb4059nPH0S^=>H|0F050kR zHt7H?>H{_D04w#5rRxqp{`dLm12p~a@#+FJ>>Ehw0W|MFX7h24>=#7q6-DU-Ht#)T z>IFFYw8QEFH0laG?lN2a-sSUZhw2YL@J(*&05b1FY5d&e??h_p0y6lmz4xQG_^!X{ z04nJPH~;?r^^K$G04Mc?o$o_v@nM1ODpmZ~;O;wN^LUf$05R$bIp_c^>l#S$Sa<3Q zI_dy3>?Km`6GQyc-2VCd>IppR2R7;hGU^FC>>W$;VuJa&$LRw%@m_uF6G8s=`055Z z?m1xhp|tm)wD*~<`L)FDC{+8_-v9mm``6$0l&bZEoazTT=>RD0CsOo%m-1?e^L3H> z!_WNU=k<)E@mziFELZxy%I-8-@m6>Gz|HPBT>RbS_^!VC%hl~CQ^!7EaR2}S0d!JM zQvg8b*k%9#0TxL_K~z}7?b1b0LqQP0@$SCu0MCUA6fH%HyL(HK;!xb(t-$AJX-Ieh z60)z?`Jd*n`#Vg==fg1UtAad2009JCK;h5)xz@CV-YXg9epkPq{td9th_baiH`?7; zMc8)&08vj4VRdS(y){Ir7r?myxO5yg@o&gO=iiY9M|#E3Q2)fTU< zEQ@$MWyYeG5v$h?o&5_GaVl00000NkvXXu0mjfDHme4 literal 0 HcmV?d00001 diff --git a/assets/images/flags/bel.png b/assets/images/flags/bel.png new file mode 100644 index 0000000000000000000000000000000000000000..1c06c5fa7ddf179f6268274f97472fd677063e2f GIT binary patch literal 127 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSJOMr-uI>dsK<1yjj$bs)bJzm? zfgHw?AirP+hi5m^fE-0n7sn8b)5!@EZVjd)9E-vj5B~b^@F6iIAtb?oi$|G(VfNX+ V2HlN6-GGW2JYD@<);T3K0RT*xB8UJ0 literal 0 HcmV?d00001 diff --git a/assets/images/flags/bes.png b/assets/images/flags/bes.png new file mode 100644 index 0000000000000000000000000000000000000000..b00bfb1f57e3f511ccbc3f1ad8fff2ba78c98185 GIT binary patch literal 236 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|e&H|6fVg?4jBOuH;Rhv&5C} z^WVRJKXodQP~q;|2o(1Aba4#vIG&s!!TN>ijaP*uBlGFJJqHp)82^eCJPhB^(D+$4 z&Y-dJt6rRd!K#KjE{lJUS$TQRq<>&)4rFciV4STGH^Ct4>IGf~1~dC>r_YBR*#tC} N!PC{xWt~$(698IAS2_Ry literal 0 HcmV?d00001 diff --git a/assets/images/flags/bhr.png b/assets/images/flags/bhr.png new file mode 100644 index 0000000000000000000000000000000000000000..135c254cb496638361bf40c707acac022ac8ab8f GIT binary patch literal 567 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0XBj({-ZRBb+Kpy2ZW zpAc8~0-tk&YM)P>{dD-m|NsC0{rd+bfg%v{ilOsW6E_I^=hNr!x9on}-1p<|gL5Jp z->%)fWy3ealf9wJTIpG_s5SP_aEH}PrKzGf5pJ*l7{uu#-4LRYL6>hFKgPI z6H@>8>-Y2SsTY;Z?#1W+`}6lsXv!5mhYNB>=Y%zWK7RJFu=ZK! zH|_lM$AG)^9s6se4hy9O$IGvDu%Go&vh|`;FU=tK09Vm;U|v z^ViGQ@78R2w|2`-_wbwU5$8p<&WUQhm^kaj#M#zeY!855<|zsC3ub8R2ZFZ#_wW1r zb(*VPf#UZ(T^vIq4!@mtl#khv$7Q>Z-%KDVJpK3o`^<0G!fZ=SCsv*O-4K{`{zjNc zr;8HD>Yt+B7gH}cHgey1Irn2pY3lw*Ka>sx%6t1XWNf~7UXju2j9SNmvh57}3}og` zRQh&{_n1KAy9L)$5~QQ&TD@Ri^-VkQ47rL(#%M$D zJ|C&x;)|0%G#9FsYku`-{J@1-%e3%~b@QLIsIp{1Z_xe`S&IN^R z;j=rQcC9P_+9xCN=g7{NT91xTOf7n4yGcc;Q(#^LQ(g0PnUW7cpE7v5`njxgN@xNA DZE7OE literal 0 HcmV?d00001 diff --git a/assets/images/flags/blz.png b/assets/images/flags/blz.png new file mode 100644 index 0000000000000000000000000000000000000000..06b23f161a3adf4e224848390dae6b9cb6ced71e GIT binary patch literal 1777 zcmV(w@aFU6^7-06m86?Cj#{@=SNC`swWG?D_BV_hp@2+{eSyzq#4r?^Kjbp{I%2 z(a59Fq@c;1fxLmR)2`&<+)jo&QIJQ`-s-cvro6$bzTmokqGwW@Hw;CKx5(asvU@^- zHNxV-daiQ7%;a8*O2MX)jKqy+69|O0fAHqunrkd%1d(Tr+NvpK(rK%@oAus=+{3V_f@n*QLekdXm&%rCfj;io$Gfqh|N8!?i-J6)084ro%FD~; z+}W0=fi8v&tijaP)#<{*)||Ci~ozh3Rn zKf`}f-sJw7oqed!%vhbhS!$on!jGn~%dM-IX`I}-+pB1F8(*t9C5KIBu-T^3nWM?O zc)C_DYyg|D-tf0e>dUcWtVdu?K(J*D&H#Z3aD@OvrvO8z_SF0^00001bW%=J06^y0 zW&i*KpGibPR9M4fU>F6XU=%ez+(Ult0=SJf<#?`#8N?KQC1dG4Pa!F zv{g{%8lb_eC@-ZJmJ^`F_36{6D5VfCH7`FtPDNe~A`M{e#pmB&Ddaf@c8VTx7Edcs_mt;oLPu4fK`%n#?ux~&S}3l+AZBM z^L}0MnS|y@&W;c-A5KYD!UkxF8cFp<^K(TetotDm6UoU|X3FQn zOUM8Xjbc@=cz#Y!wrX$FirQ?op36O#0=>&QIXPwhOq2vfH3%5M!Xy`A5F^jY$tA(f z-Qe9NbY;TDUOhbtE})SzZc@5MEQAf<3^!-v7_xUc%8R$Z6GP$;ZjV%_B59ZNZ$vth96q9-s^O0(R>V6^+cCGD#M~ z9S4N>i#a`iv3Bj7kFlHA0HdWNs6v{Ph=imeDkoe%S2^nRky+>Ntk2KS|FHh+yIE6B z*f>4h%ygMph{{Y{UO7sq&MuBhN!_w-+m_`iU*6Al6t*-xz*)jdOm=n_kTs4A;N)Dm zs9^Hj?c1d%7Zfbw;#6I#&Z)JFg{T5Wn@w1)Pev;1+8lND?9JJQao@7m@Y@?(@aHry6I(XE&xyi8rb#y9oRwIL zWt_#jATZk6ryH2Z$H;i3CtFu@@w15#U#x>F7HuO=DPjLft5s~~saeT5a0={XWFf6; zbCwhUmcV340Gzvz+C;$Thy0{&V TYF3V$00000NkvXXu0mjfnM18k literal 0 HcmV?d00001 diff --git a/assets/images/flags/bmu.png b/assets/images/flags/bmu.png new file mode 100644 index 0000000000000000000000000000000000000000..6e253a8e64a0ccb0499c4f7a901aa0f1416afb1a GIT binary patch literal 1518 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 literal 0 HcmV?d00001 diff --git a/assets/images/flags/btn.png b/assets/images/flags/btn.png new file mode 100644 index 0000000000000000000000000000000000000000..962e5e5bb46e90ea42ebceaeb754a7c7ad57b26b GIT binary patch literal 4013 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 KreCb*6#g%F1Oa{k literal 0 HcmV?d00001 diff --git a/assets/images/flags/bvt.png b/assets/images/flags/bvt.png new file mode 100644 index 0000000000000000000000000000000000000000..4acde7fbf2e00cbbbd9de5ff2716f3428bfd3fe2 GIT binary patch literal 147 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSJOMr-uI>ds|NsAIP)q)zVSa4) zIZvQCV@Z%-FoVOh8)-m}si%u$h{fsT1PQkWpu+!&tC#(2Z{j~3-C$~BBBCL{#meZy qQLxsDkvl`#q3y6+*co1B1_s~BH_RBfiG%dsrK@fOdH?_aS1!Ha z4rDQw1o;IsI6S+N2IOdXx;TbdoK8-VaBDCXVP)wtVEp%A;{h}CbuEz%2@6;ZWXujJ cXGt(JaPDB{xf%0S45){})78&qol`;+0JQidng9R* literal 0 HcmV?d00001 diff --git a/assets/images/flags/cck.png b/assets/images/flags/cck.png new file mode 100644 index 0000000000000000000000000000000000000000..d255ab91ab79c85cdfa733e57159c80c0a231976 GIT binary patch literal 1139 zcmYk4c{Cda0LH(F7>UHHT0vP|sbeeReAabX(0FD+>KWFiuA#0{Gu?Yc#1$E#bDu53 zv^qMp9d)1MIa+a4jBV}Owc5>E+q`+R^uPV_{eIv3{{HegKAu#vE*SuoeTwC$u*>VH z5*4hymAR#mTH>jXlL6Z3>kvvUX?lQ?GRK?i?wRPlzX@U{l=wg*py2-sfsbb^KnB#Z zU?|2L0t`<r477pw5E$K448UV%XYs=kA1A#RW1 zk2O4AMpO%A^LV)iBQe}6aOno#DnQRDI)@PCB zzQx`TI9U#-QVdPv^LAKBKoR1DN=#0}@hjXM!S)(PrV!HtmkRKj@JS%5!5O%`41Z2OQRK-ci7yW$vvA@3`mAaph@taBTt|36!XR}s!3Au{5|(^Le0)2n4koxfUFNen15aZseZEWlEG zMpO1YBG!#;$F{d;pLtt1Vm^dJ5@kh{BuCOyY?*Dt+zYZrCbR8v?ia~dqRS7RF7c#h zxk(600sf5C3$~)|p+$Qp(}63nqrB{}G1eQbkyHs{_(sO`SA11!1yjw#!_eF;*n(xE zdCuihPKA%Mh%n=Nv^`GW#m$n^CK@O#UQm^q+B!MYrqpbrx1-E`9kg3--N`QQbfHOG z1;w2Pzx=+!8gB~UOLOJ0RilJG$4|iJB>$hBFRd)Zy!eNjb5qkEC6=M#cH>gd#i0Y9 zpLr0+#A3O(HBCb!L_WJz=R_1{rdy1Z8Sm`m+49?!pD2_3yiC+NG&@&cdPO`bZBnsv z^#dnWgsdW>X9Kl^?L!%JPF!PDAoG*jmio6RY# s^rJ`S0|#S3d6&eG=t{FAdkmTJi8!kslkBA(6{0u@*zP{88*V(of5vX4od5s; literal 0 HcmV?d00001 diff --git a/assets/images/flags/cmr.png b/assets/images/flags/cmr.png new file mode 100644 index 0000000000000000000000000000000000000000..2bc6ad13c4606109b4ab2a5e128aebc378c477c5 GIT binary patch literal 402 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0XBj({-ZRBb+KpkP;k zPl&5~fzKI1HTy`PHxs4*TogMesCG_Jm7yvQ$Oe)S21Fdlc8K(OJ5~CEwCcO*QhzRr zo)=U7eL?Jwzrr~Y)z`h!=Y&;1ERy_rOzgU)(#sZ^Tds<~&x)OwR6Q@D`m{*)X`$@% zYT5Ims?Td=KQ5E}xK#3SmfYh^xj#S?d=&3^E5^lII{{tAQxfDC%rGAe-oKweb?Vu) z1v7x+(>z@qLn02py=KmT$Uva&;f33hLesyvy?FcozwIVNnaTc19QUm&Hp$(brV}fm zU^3yF_Pq*|z(9m$FlmKhxoYyHYLsrlN@g)y)7 zK3X4q`J#ho=jN~b4!w3fX3Lr%@V(kcmtobjvV5(2h22xUrd~fI-LdF=*Lxm@1M&=y e9EBG;eqr0w+_Y1+?$Q#V$qb&ZelF{r5}E)C5~Z^M literal 0 HcmV?d00001 diff --git a/assets/images/flags/cok.png b/assets/images/flags/cok.png new file mode 100644 index 0000000000000000000000000000000000000000..49386516dc346dd150a2175918135f2fd6621b29 GIT binary patch literal 1416 zcmV;31$X+1P)OH~=4Q&lD@q6e|E9Z2%;F03>}DH;B+2 zG7v0)@YvqdWq2n>k>6%@zQNZMG=>~Ji_zET>YAkg|NrX2%KrWR)ig-iNLtO*hsepKlCPa`n zQkdM~?Q(^;fsDBJ)7R|p_0=^=dW^f(lcYXboQ|HwGEbM1p~nFweb(ISLtUQ~HHWpp z*(*ttNn@g=veB`;*N~vb)7j|m@b*Vxp{%#mTyw1b{r;!3(&_B_Vo8ZTAlOe>TQ9vPG_Zyo5YEl!*7GNPiUoh zi@MI$=G)-xb&0u?qR1FHiK4I19zKj_e6e$fxLtItvc1<>aH@vYNpTD=Iq4G z@3XquMpzv^jDnB5_xSqy>Fd}-RpaOJ?WnQt-sJb&-qnnq>6oJZ`T5N&Lgj&t-(_^* zYI*Ibu;1hE$j#uZ;Pt2g001F$QchC)x0X@SjC(mW5o{T zh6OrE2CymN1rUWR6QdNDwZ&QmD+XJ{#w=2tA74tE0g-j};npz6=*%@)Sl0;h`pk1U`9Ru@vP?ogGv4VaoH1f$l~z0LUvw7y#35uK`U+ zrYR^!1~CG}HUmO* z01*QjAl`NSh&?pXhV=US`u5vEIHoBF_U`BcN1}lph5@!#Q;-4?KPZ~vVv8YYST#L> zNCONaXS6K95Z$=8Aj*nJ7bwPrM`MUZ>uw<03yL-dThRTGWD{aSya5JHo0HIlB37@n zB;Ek6?aGabC?X~6m75k5Z$LuARz*cal)_18O=3Lp1}G|;*lybtRs`kD4vVX{GeIsB zi2(7H%0ZT;`QeKCI@LOP%5gS|BpRStGpklH$3I9hFD^zgs$z~JsRmdED+XxKP&QJW zq+BqyRWX2M1HfS7gzV@rqioE2fmmPyih2Wm@(sXIwNYvS3p)jXWwd!T3P!;wfCB(7 W4pLx#hQQ4L0000Gs5=OO&R*(zj=6)nKxez}Sb@%V z-(P-4_0spsfto#70)A}VUZG}Qff@#Phno2WJ7cT8Ld`5eo55cM_z%i=qS->}jTGCA v{zfvJF{P2rW=w0OV0J(d&Mw)B0Dy}VO50DxlNzXC00000NkvXXu0mjfuugvy literal 0 HcmV?d00001 diff --git a/assets/images/flags/cri.png b/assets/images/flags/cri.png new file mode 100644 index 0000000000000000000000000000000000000000..029bbfc4981c51c45fa2c46419cffc3eff64a0f2 GIT binary patch literal 136 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSJOMr-uI>ds|NsAI(5^oxsFtat ze-S9oSQ6wH%;50sMjDW#<>}%WVsSb-LBg#eTa+upL7nkhfQW#DG~>Vj8V?SC5L@6s ggD;>-(v5*3dclmcD_e?Wfw~wxUHx3vIVCg!02gv5{r~^~ literal 0 HcmV?d00001 diff --git a/assets/images/flags/cuw.png b/assets/images/flags/cuw.png new file mode 100644 index 0000000000000000000000000000000000000000..92a36b728ad8bfae0f3b1e5cbcbaa61322b18f3c GIT binary patch literal 481 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0XBj({-ZRBb+Kpx~|m zpAc8~0-qnxMJ}FH`T0VGLA%~JHI`Ak4#)r^s7PAurBf>Z|Nr;OSW?ur!#rZvzkmM} zT>F3i`m^ukJ!aj8_n*GA=ruM?Kfq<&a{l^rDTki$;#F)0O^iDAs$LUCZ9D6x>^pn) zX?fo+*OY~VmhEN{v(&sM#Z;{2GjF^5=-t_?&o1435nr|L>$hK36ZSCaG?e!26t?b| zy8PtS<)<`#Cttt&YWA8lv)7z`{NiKvq`e;Li@$#VRX=54qEpK(pf9*eg8YIR-oO7p z_5J(#@84@2bFu)6ZufL?42d}W_L@6ivw?_fpn^lgk!9Dv+^f$2eLrlD?uQ8-7xp{z z_x;g5y-6i7#5L%(!zu(hN~^&fgV%{@6hMg6vP9EhV4&4elC==`cvcl^NYti21 VoW(DSeSltI@O1TaS?83{1ORaM)>i-k literal 0 HcmV?d00001 diff --git a/assets/images/flags/cxr.png b/assets/images/flags/cxr.png new file mode 100644 index 0000000000000000000000000000000000000000..e644a49ea96be08fe5e2865520ecd6583398964b GIT binary patch literal 1202 zcmV;j1Wo&iP)>U2gy(tQ?9$034Sbib4P!mmG>h03odiJ#`z2 zLL7=h03oga#yJ#hU>k`-1~--d#W@Zwu@*G6ABsUflR$W)J{^id%D6l7!Z|FCK^}@h zytF%Tpg#Bb`~oAbAU(G~Six$2&rO#=9Ew3BKe*k!I(DHxCX7L$tvptlKME(PP*sR? zp+1IpZ&z7`J~NO(FONaZxH?prKcukXil;qKX2!|4JHxg+ zQfbHg`~4+Bxg0sRc8AhVmq4w!GAXW zCyYTuTf(!kJPawP{KYv)l|U|zLF>6fU0s5hsy)ZIJJ!28Dvd$WxjIjmKZd3~zqLE} z!#S+5JgToe<-a=GygDU}L5Ze4jeKl{r9Hj0JGHVqA8%h4LXKi!fRBA^ji^2S#W~`> zI>oj-!nHf}!#PQnK%1*PnyWnOxkBl;LT{fwTADwXsy&vfJ@~{qIFUedpgym#JfN&R z*t%(S%0ue@Nbn?wK zlbFHRj>Q1AjO={EMra%I@l93dYs6xJQf8VWnn$F?wY0>g(bAF0G(J8)DN#%V3JbDC zP{Tq_iVvh#4$To=Ku7SUdzYx9he1v*YH*qgf%Gq1A!~~64Uo9HPz9D)Kn9B}B|!>T zTCBF5h-!d1P*}*Tln1*334y{8#$vt&^CXwA-OaOgl^%+I(PE(_Uoq_I00=^Z;vkGA zd}d%pd$tR$LDnzk9}tF?7n+0-`V~^;ed58ax$~FK5d)^09ejLy5!yYYB$Lszvs!bx zBEp2&kYEU-7Zd_k+jw^J@onI-Fsi9iQ7LkFcJ)AZ4a9)9mNF%%ZYLzabo78s*t}~C z-@1hYJgU`|dK!6dGN{1`^+LTeRF9*B3z7-O=0F2BZR9gI#+f?N15wz{8aX_5>p*T; zuZuf@qZ?prV~-LeItKds20D0hD&B;I2{4Ty(Ev;%NHzf72vh?iL`XIQ#Q>4;#7I(I zfnq>Rio74G9zpejax}?dK}H~=8bOf(AS1Ye2C%YI0Juh*N26dAi~=|S0A|UcQ9Kn$ Q#Q*>R07*qoM6N<$g2g8g?f?J) literal 0 HcmV?d00001 diff --git a/assets/images/flags/cyp.png b/assets/images/flags/cyp.png new file mode 100644 index 0000000000000000000000000000000000000000..ba5246809c95c86c26bf5a529ca841abdb95e69d GIT binary patch literal 1128 zcmV-u1eg1XP)wLt#r$^EuN{`TqpvN`_u>;1Jt{{H>`;3{^Pj)$8Y_@WB%Qz{^!BV(Zl}k)&10x{`l_w#AyEe_5I0p{l#hi z*P8viQU2(~{QUfnoPT$QWY5*fo2QMx#VlAQ z`TF>Tl5$#YL&VFxgpzZLn0n*p-+GB>hLm;B)W~;-WXaINuD7CvlXUR%?zX{e+$`}_KNiD&!!`}X(sn52r8p@w&d zW&O!>{neEIfnz(I=cv}DE1XcQ3+QVd9n&yr3-6C=R@?g}oh zSSe<54X9U8sU)i!$5>xNupD`dAC<3JV2AZ~;M0Ee|tE z26%CyfD9Sz%5WQCg=#<)Gw}v^*cWn27x$GQCYNFwps@w&6>qdq=S?Ke zfXzF$?F4%z1U38cbvF@b0I!a~R&5M*tCp=%n?$4m`}ZE)wFh0@ytz7a)QJtmeOd?B z&hJ4I;9sq-wQ?!(v9M|Tx<$QxC2;;Mm4ypbmkW>(jr>d6+xS$K%L;+)jw!RJPo23~ zdj&H|B~n*z9$#reQ9d*O6aRXx%jDk@xTmS%+KWr^)MU)Ky0000|3jrLNoB?9;hH{h<8@D}s-&dkmq&eN=L?AQX(u2Ad&CN` zM_7P6!U1?ATtJ9>y%S)J2mpLU1fU~g034A5pb<#`8Ib|#BXR(FL>?fH@PYRW9^nJi z1dnh4Wz0Mqqp%SU(2a7**kL2Q6SR~%e9GIg*^CFY!_Wr*A1)0YP`dB6Lh1wHX+PBZ z@C5Aoe*mR^1EAIC0b+d)AlGLAdVLZ=>Qewz9|K^01fcZ+fY;S+002ovPDHLkV1iqMu^RvY literal 0 HcmV?d00001 diff --git a/assets/images/flags/dma.png b/assets/images/flags/dma.png new file mode 100644 index 0000000000000000000000000000000000000000..7f61af95e6fadfa4470974e4d8458453e325d924 GIT binary patch literal 1224 zcmV;(1ULJMP)%^EE!Pc+jOFx4C{*cvOs7%SNoG13|@!5Az1&=&sv{o5BX z+!!w2MONMyF5O5fvK%KhN;6eUIkg}zt{f~$LNY8)G}{_59#b{g88D+IF3TG(#TF~Y z7c0&jF3=e*)*3JXT`-Uvtov9W1UL zCcqRf%^EMj5+qnYG5FKh>2D;`9WWeYJ;)j@)(|Gw882fwFeYI@dNMF~Gcd#xFZZw- z)e|v6Q#&M5HHs}S+dwN)O*z6GEwvvlSv)b`88H3#_%mBRUP(FZi}N>Do56e=!UJRoC0`pg$dQ99=q zFa7%ZEnPnKts9&tF1H{q-%Vck&(qZuDdu~I-9Rh!rySB1E6y1%=W-*}7ctlrFQp_d z))gwTA}-ArF6V`i))g_)6)NRwC9xbVv>+}mQ8la_CdL{qp(ZZc8Zf3ME~+Fhh%PVJ z8ZN9PF47w<))**AMKX{oEQu~K(;6zeCrj5ED%Be?pHr-zD=*O9y&T-cE}zt(j6!VSuP7!I7CoeSW|D;8#1Lf5W^!Tq$V$!C@#G#Qg}sf0An)U z8Zet6Fq$?CtRgO)Bro7WC2cqvYDX;ADk>3SFTHIOg)=J0t`THh4;frq1YR}YH;W#Kk}6AMsOmt*3Y&b@W}mJK_X@w9Vu zDaR;cQ-{+4XE}d)CLY!u2lj7UZ!XQnlpP-tPLcsG?wUc&OsqEh4((pIREAeXC@Rbc zrvdC(fSZS|BO|Nyl3klESFhq_<&F-O*TAL@rvVDSf%0su((_mBIcB*sk(F(x3Y!9H z2C!b#XJchuu*Tu|1zj~(Hhyk4S&|IU5lPE6WMy4;&f&ySTTx!d5HC?zoCdI90jcQ* zLd?9ZCvDE(w>L26DNfMybi$?%rvVvaIohmT%TFJw~sw&|$0Bag8SD86w;aPsOo7ZkwUD;$bQ!SX(%v34dYp_^=_v$67 zi!&sRgkwq1&RWcU##|!Sl6*X7ZN(|%6ewja6Quah*zt=ssV0+Gq*b&|(l9ld++3J6 mS}%+mFcb_J1*2dT0098Pi?3eDJ`{TZ0000s`{{8*@@bUin`Sr}v{rLFfd4&4u>+Pwr z<9CD9EkNF1ZPO|}AZj`E%FaquHTBKW&lf9lKrr~*-YI1`&lD--dx!0%t?vi9 zE009=`@&?SiWI0&-+QM}!3pwk{^%>lK4T@605)LiPrcT?em0Va zZ6xVdmVZpr&zbK*eGe4NmKkXs!I1{&zMG^!6AAt!AP9nU1$0JHKlNx0jsO4v07*qo IM6N<$g1J%n$N&HU literal 0 HcmV?d00001 diff --git a/assets/images/flags/ecu.png b/assets/images/flags/ecu.png new file mode 100644 index 0000000000000000000000000000000000000000..efb3acb43c117fc506f992468e2cc69f46b666bc GIT binary patch literal 1238 zcmV;{1S$K8P)dsQudj`q=^f+yL&= z3k*`922Y{!&;SuspdwtIo`x0xN~aH0qaItN07a}raC-tyqv*^J|K

P@?tK1Fw)N z_t^q58yxJ>3aYC!07S0;?EuT3708|y@z(~~rx2`H2ASU($Qd}6MzFw4Rlb(ELC z!6q3|kif$vRV#4mG%jE)ZNXwiE5x0?tkVu^w-R9J#wZaZ8zYJN_AWsiF< zx0xzbKP56{m~e?$T0&pR@T_Uc95t(Hkes@3T%@u zc7`kfhCi=u4O=%Zo}^HMXF;k{N*8aSwU#Sghhs--a0FmyxvEu1n6<~mA3vS39dw;R zev{rMDt2W`u2f1PGm#*Dq&k;+I)9X{a!*ZvieRNcVyaNZ!yyuMlZR<(YmlD2m=_^_ zrbCW`NtT&-P$N?^a%EkVMuC&jA1ffN);5o@XsAfDR#_jf+*Gbms*ee4$V9DHTt78> z%NsHed7P7!L-t+&6$=Z92lV_qbYL=akj4rOc4RL^E!?hERsTogV zgkP~xGloHvZfItxiBqG4X@iF%TV_sumR_j5Lz=nk_xt1kqK;>r8vp!ZsNRS75V~?sqStzEzcw`lU5bvqB3>8O)# zfK0yT)XnoI85%Cwy0A$zUWRlR$SO3NOw?=d)0^L_q99AM3z)>DqjOst>T2uTn)70P zrNx*?G=Nc`Wp!)Ibd2qyUT>Flqqx3>XEYU=#oW066Ei|7lqACjbBd07*qoM6N<$g8Y*s AD*ylh literal 0 HcmV?d00001 diff --git a/assets/images/flags/est.png b/assets/images/flags/est.png new file mode 100644 index 0000000000000000000000000000000000000000..c2e417b93016d9acfdc636f69f9065c42e6e8dd2 GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSJOMr-uI>ds3`OVv|NjpZ&^JC0 z(!f{}Cdiu z!FT|n=>VYaRJWVP(mv7EGn2+=z|cdB#BGYhavQ6~0CZLWY(Wd6*TGCe5~<7pa7_S` zst}&f0Fk8toWTI5+yJNG0H)hvyNw2>+dQ(a52(>hwxCJ1pmB(aPxs_A6nMSpw zp2=NwzkhAMh0xSC2&UGD#BapXK7+z^y3a^4u(VvclrFEgDyzjOuDoHokebL~0H5)C zz@XFMdCgv$I&{+&^;=zxd4!%HL|cn zwx$51*Z^`KU0HWg-smwpKs4K0#cE5c(vaNK!f*z~Hcffrd zs>uPP->Az|U%8ZOy@!Irba=pfF0Zx0(LV^K+Op0|m&ap8w5B7izp2Yrn8#wf&qu7x zQct&?ki}_Iww=?}F^a@(=h-9#rQJKQv`)93L9?q%ww`>!ddSi}hQfB~+9SEnNj9#z zrOH?!tHsUJIr-icS-6;XzkVyNzCE(6alM5gtixx!jsD&c+?Dr800001bW%=J06^y0 zW&i*J-bqA3R9M4fU>F6XU=%QL+9DJkh$SySyujsYzbw+l&aXJz%0 z64K~j=18gmi#>#xS>QlQ$jy~B1LjMrGl6un35GL*XeOa04x||1I?tO$2V{b1)Lv1L zejQ;cmPy1L&?D*1!nXCS7z=BRvY{L|3*+YXTrA8T6+{~_TbqT2d*88>0w+#=N(d5R z(lXfsbdI{4Ds}@n(ZLJ}u=i81U*eIuaPUq>=i=j24x8@u4`AgDBFcaWJ^@+Z{la{FHk;Ib7yW;& zcGi-OpJlgin1Dz#Q3fpMW#JZ+XXQ=(#`UWxBIIT^zhNR5tGoy>AuS>H07pYrIkP&m7dN*Lv$|&8WEBol4dCEVDXwo@>h9iER$8KhOATHFhykMp iP|JW(FbYNi5C8z!Lg2S@fuHpN0000ds3?`NT|NjqhzIPEQ z$XF8O7tG-B>_!@pqv7e|7-DfcIYGj$A^Ym8P;ckOVU7ozCh8rK>SAJI^GYy@3t(*% aWMHs&2+T0Q%Hj{y!{F)a=d#Wzp$PznE+jet literal 0 HcmV?d00001 diff --git a/assets/images/flags/fji.png b/assets/images/flags/fji.png new file mode 100644 index 0000000000000000000000000000000000000000..4700ad579f01011218eca0ade84690d701f28496 GIT binary patch literal 1571 zcmV+;2Hg3HP)gp#_sRK2d%m4t-4-Zhf=25la05h5Y|Nk9IrO*N>QnumI11bIf{?Z*C;)javsHo8h zE!s?QEI<*caJ zJSE#NB;lpA)fg*xlgQacMbQ`-a8`Clu;9N6EJ?fTO}pq;p~7dQy}UaqU8m7avfXT@ z#7?{CPpi~`tGCzK*`B=2Yk!{Y=;pW4*yQEpeP?OXyZr<+9j^=-!Q-(}Rxe_Vw(Qt?7`V=UNf^|Nr{<$G9Cb>)A=}*gE_6 zx7}oT-s!vWsR`dj43NIix+pKw03YUTijcX?k-g7$Rd~M(EZKFc z`^W;lK__-=huBCX@BH!Pa)y=XMzFqOj_&x$Kgq-1+S9 zw!pO5zR#SVy3n+*)wtc8jf=pO4`d8Vkxa_*+ODs915*I#?&bLX^Xj#=*Nc^OQg(NB zir`~Y;(2UfgIDJ0)nSif>FU^*v6$Xi8|I)2*$NcX7c$!^D)Phu^}hq|xC7apsmG6) z-e`epk7|9XhSiUlM}AM`ix6y%YeIQTY?*ZMtGLzGx?!Jl^po)=00001bW%=J06^y0 zW&i*J(Md!>R9M4fU>F6XU=%=sHlv6rGY1riv$AkpA76i7u=m<-QBcKLAtp`dG zX8^(xU<2UR6K??A5g-F{;ntI40LT&f5}ces1M=Y36K4P?3IG~_0-!2Q*ie88vH>0} z_y9zqqN$penwplLo}L;I=&70VAQ^yHzXwF2q}8st)YQ0x`?sw2_wOt(Dv-qK1r}Bm zt~k_yoso9-uToO%f&&5r1G+67B{2h0Nu7^TI1g-qM822?GfE&v9eSMk`sdsDGYJQh z_ihaC!D4{4Jf|SPKrYAtQ4vNN~ z00)x_r-TC35fFu%58q_vyvxc-Pq(+XJ0Y~Gr5M!!i1kt&91sIIm=TVEC=8AH`ziZ# z_7@?W*o&9Xo!%4Ei);YQdJeb&90*516zZov|MTPb|L;cJ;kS~KuAkbhi(~-YdSnB@ zj?e}=0%pL|mtTKdE2 z$*5AVifD_BmhMdEcF6A1z^;G;bXuk7zLvM2mss+ V--yx6DwF^K002ovPDHLkV1k_c9MS*) literal 0 HcmV?d00001 diff --git a/assets/images/flags/flk.png b/assets/images/flags/flk.png new file mode 100644 index 0000000000000000000000000000000000000000..66ff172c28c6efd1a7afd7396d809392bcedb848 GIT binary patch literal 1591 zcmV-72FUq|P)goeEngBDI|NsBY0001L!V`SR(j6V*hl=i~sQmr@-BnfGQBnE& z{d<+l;Bax#6&3HUu4aY85j>nWUa!|VIlrsIAx);(;P1+(xCuI)$<*o8CntoR&VG=? z19Qy)ZNeLS!2oW=)y~O9jlr&eY7ltGO_tC%S*H|1pyH^83pbexd&vM^yVcRn0A|57 zQlUs_v6OUcRh`tGgn9Y-`TYF+?(6DLk-*v9-R9rhq+&j%kB8^#_1D$Yg;_!51z`nf5shOUJ zgp{no|Ns2&_4l8EW7xf}caz8fK^SM2vDrmMh@_0chG#g8&oppL8$T7hp_3_gtEP)d z!O7a|#;sO#e#w$?_1MSc!8hErYHe$Ab!<~?eXd7hrz1$8vV&>9v9e)!$T5}Mij=+?VmSar7P7|HpT5|CtIgJoU_NS&W}nU4o;7ri zb==Ff%b#Djx1QFaRO7f{)x4D4lxJOudu)uDzjaF8n{q&Ilc=DXUW%-#zs$sSR?Mt& zGG2kiZ8*f(=s0JMpv2;ld~F6XU=%=s0IQHN8#@#*vv6`S!Z_l*JbdhU0oVW$Q85k$CQJil zq@@H|`H3)qNlp>r2!sJ3>p5g4i7U4b>JF zYbJ4lfT}82hyjuT5uEq{)PUIw?2K)UV`FSJHEq?@Rdpng48W^D0&0L+(Uv7?Huf6! ziN={_YDp*tpgNh81%=BDHDIoxNo?YBW-De6Yikcp6CFLwK$KVFXBBJ)8z3esqRfUG zh;y{LmTB1KTUq71TBoI&L@{C*ASuhi$HUtIGC){}RX|aW3B`a};asU2u7!opM=G41 z)smvnT>!RTN?L{yWB@yp0)``WqgBJVRCraNaIf}qS5XZ~Ks5kjy*N8N!~k|S3`gij ztGju+H+i~wcAY)wR+5e!jd1JP;Rdi{IAW=x%I?OKzUOb=@@@1zRFaMy3vla^4M2B< z+2%d9EgkKh^+4iU?OqGyFo0W+VgSq$P)-UkN-uwU-1}@o{|g|~3E2g3>(LB=I0EK^ zV}5NPCw+eM@agkO589481!1NG{2=R`k-U&87J-`2W_`KKGuL&dF0EurorTz9^xpv-f9IhTN*- pds|NsAIC_46DTX*}y z6|q2Z#*!evU0d rhromX{Y-Wj+!}b=d)Lg6c4J`ZpBJ#t;AhndpaBe?u6{1-oD!M<#Fj2C literal 0 HcmV?d00001 diff --git a/assets/images/flags/fsm.png b/assets/images/flags/fsm.png new file mode 100644 index 0000000000000000000000000000000000000000..b8aedd34e42d168ffc18f294864d9a2292e97a96 GIT binary patch literal 340 zcmV-a0jvIrP)doWx`TPBiz~j5w?sc-=|NsBU;PL44`HH^cxYzCU`TT;p;Dx*4wAJmO%IBWR z=lcBq=M0Rc00001bW%=J06^y0W&i*H#z{m$R9M69mdOr;AP_`VwgzPX|9>5v%w^1& zbSw`}q>cn$=!I&gX=rHZBLnCW0Hr{H)*!$+05DrCh=jHC*&8g9y5w>`1mF6359AdsR;5S&TohxNmyp}@ zSpX=`SQ6wH%;50sMjDW#;_2cTVsSb-LBg%!zdhFvgEcHW!WK5L9`q$7;KatO6&;`f3E#oP{{H@- z00^ZC63&vAwEzkc0065wKrH|QtpEwM9ww3i2!a3yK>!1l00=q&11SIkt}Qg+#m2P) z49G%BivS0?dVa~CpsNZJ0RRBW5*#%E0?)Csh5!dY00YK^hsYBh%9WU~MogzGGR~^3 z#Day<$H%+?3akJLzyJ!>01I>g1|t9gxg83*p$e%^3AKX?$2JSVF$&WF3(NouyhaMF zSqaD#3$7yxzCQ}q01LMr3bHQ=y)p{X0Sm8C3Df`!y*Uc9O$oO~3eExxd;kWSQV6(A z3XMSrqDLrqKnt?~34|;SQ8fgaJtK@D2bLrVK_>$*1p+Pu0{0lA6aWAK3UpFVQvmPp z|L^O#cyEpJDNm2{ zPe59BVpFM9evL-+c+YOgx-ZGnx=Jz>Ds_OdWweRowDu(P(ZR28Ui6i~hRJQ5QV`|T6pBV05k SBomYX0000mdKI;Vst0C7?}y#N3J literal 0 HcmV?d00001 diff --git a/assets/images/flags/glp.png b/assets/images/flags/glp.png new file mode 100644 index 0000000000000000000000000000000000000000..8bd0a69bf663de4fba86d0c6bbd02078e59f5281 GIT binary patch literal 1820 zcmZ8iSyWSp7Ckrj=H?nA2tfiw1w<4KB!nPDDabqo41<9L34&n^vkIa-RA8ZivG1U}{l7ZK<}6r9Q#8n6$-O%8S46WADAzUi)LOv(8zkBuMD9l4?u^ zfR%o}o*^1_{R{%ta6@PQJB_T!@D1M&pxyr&NHt5F34lUSfXK@yBLK{cfK1n&7orp#yq!sw<_PQnhCU=l zpk=w_`7z>f8rFG@rN=#TuXWSGT>seA(Ax^jw#zox8*P7n$N)Zo1ppbq3}PkdyYC4L zGvvcFV(~{p*@zoE=0KQcKrr)^03nz=9{?$sFcH-)Q!*KL`jIdc))<05?{HW1=H{=t z5eea8G18ioP8{!yz}-<*70y1An2!@w<#nt0bW!(?P)o^W%tMV@q0 zZl1%BAHbR_$agN{kO22CHbw*7KZjW zQ^NdFh6Kh2DCTC!TNUwclxR3XexRUiwm?Kj(a2y_{eZ9&A+NrpT)s}W%|?P#NmC<4 z+Zp`TAf={~?C(xuTOu1>Ga()r432qYz_@GH{OyK6`8 ztRqqAdt)$4ICMATkOtbj;hB=+kc;TXKv6ZOvmGD?to#shB?<@N>Uq1!5VYqi{#a>} zd(@BRu`lPaps(Gc>`T|(a>>O8`;rp(AN!4FMs?D&f`+@y90OAPcF7!;TLLvpPt6Pw z?u40{<(ca0bLVD^&tG5GTu6(br<+L97ZYtrZj7!42W=`aN5CMB?^{0UO=+m&7XFj# z-Wnc6s~!3@VZ3850htR=bRX>}>tz_uR+T0@)K9sv*-rKs0@&<`HF$2H{V!UV5ILV# ztFE1ED9sMLq^5PHw8^A6tS@fnw^_&4 zR}A~|K5EOT+T6Fn)Z*gVj(UEUP&HN(b~nQ;nLPb|YZ+BWUErd@p4MC|7dP70+n)U! zHw*->qPrQ;{e4da7HVA>bPxJgroXG=(>BQt5BiCmnjGDK*-uR%g{H@z!^h8py7kb)kDC{__QPS~n5sIxZO|H>`E& zBOr|DkHmSCEBB~ERc4ijYNDqkDeu%x*2yMryHXx&zJE*mRU{by&lSeC0|&ionV#^deB5Oi^@ zX~X_WviS5BdH3R@o4n`th6iWo_aO+UMkigeqtwie(N(uNNtNUNR4wxGt1mcHeaA5E zluf-_k5y^&AF8j-zuXvDkRG{U;Ny_Tx*@MxXL1BxT&x|p51n9-KN7p-?tSyeYW*eY zx&gbw#^BR|UIKOM#B1^WwdDu9M|y@k#zS=rpWY`~9m(25zxO8n>`Q)W8l)ei{aT1+ z7XG_T&^bGe>#%7RVx`CNRz#&Tp3LV=ItinVO)J`8oH9gz`g@6UZX!Kj?wVgo<8g6t zyYPN+vKF6J4227tkcelTK6SyZ+;~}JkM-+rqfwY~_VQ%*blRLZj9i|p%2)cwUd$@y zn)W`t(?NR)bE8Ga+NujJs_hpeMMQUAU3hw*P_*Pz?ss4m{X0>T>w6OszMPpw($9d`FROF74Fdm{{ycnsXzb# literal 0 HcmV?d00001 diff --git a/assets/images/flags/gmb.png b/assets/images/flags/gmb.png new file mode 100644 index 0000000000000000000000000000000000000000..fa641ca1a08d685d06cf8097e9a342c6912f44f7 GIT binary patch literal 278 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0XBj({-ZRBb+KprBKL zPl&5~fsa+W#yLT?qMBVml1HZJ-@ktl5g_^W?W2OK-9H{YJ}0COb?F4|xq z;v6W=UZAx3|Ed*9jDdd_n;%#ka>8U0d`TOq=U3psN_fN`m}?8Q#BtKXv~5`9SLZd>{bP@81io zoz4POvE9?fF(l&f+iS=9nhkgw65X7bO85_Dcz>_@&%S19(-me9zgmXKpLvdt>=T+l z*c?xETCx6Sh?nI|ZEYqdtL!(uhP%)7$rUef=&WLPZx#w-{;0updRJj1cmKB!X9B|n zI;=0N|B-0tZ<%sHjH%oCG0*qAw+`=ix%nYt$-4uqogz2?Q+mjp`P*Tgz>e^;OZUnT zJ~(bsqoBz->AvP4=g%B0mW~IKC)Ri!Xm`8Je#$$G;`Yz$5^7VCHuo@uC{sOd-w_CetV+ z?2jAQHzP8OP$Y&_!WSvBw<{-yQ(~M#i>fvce_DvCH?_Db41Qanu`r#mF#vaA&=@6~ zD>Tw0B%UfW&>AK3s2S2BCG@cw@0%OsZXWiy7+;%1T$)4xd0q#6TzPvuKaflUdR`ZU zR|b7t6nxG?yG_5u^BKZ;a1i6r1pp7=Y5VkKYk6Mu z^o66*LLnLr_i_RJ@#nLf`@nLw*|=Tyc{jfOpBhyP&ksyMZ%$RJ^(Ddug=a@95EjX^b}Wm0>~HR z0)SBr(cmo|q-?fykh}&SfOwQ`*6O2dwdwf*W+&+~InA7acv9{rUHcMnDWUF)lzh9dC6m)5S^Tgk=6jn(aFharadr}7s@}pP!uUrB-B4`pq_X(g-_Q&*|NsB;yT;;YfaG(D(-}VV!_D7VbMUjj>zJwBNM-!; z^xjr+(iJ-KwZZ-N_|_{;>5ic0eUka$<@)C8)g?&DmectHCw4a6nZ` zKv_{h3$d00Mj03>K*@ljfL;cA3TR}YIRIW?b@I-3W_JTjGB8oV!MoEGFv!3_0ksU& z6p*CSe40=IVhM=B0bXvN&pi@=3QhB&xZ^tj(Y&9ZBk%yC-9Lo#ya|BXoI`%_MvVZx z`0X~+*uz<1-G_S6Q`b#;s1x7m% zW<8-My{91}-w$*Dy3F~03jgK{s|1I!N6z|J68108q#`LZEjoYr;}%LOz#}CRnxP`+X~Cz-4{3@Bg*w z{{m3Q7fQEbGkv!1|Fh=&WHfxV?Ei8tZ(TNmEJdfh>;Dc+zP^5!u^dKvEo`G8O1bU- zN3d$dF-yE4Kf2)fUAu|Cz1YXE#X_xQ|L{z?@c*&#|F7u$SFvnGK9m_swZ9)ay&gI~ zK$E7h}O!$gPy+6=Dg77r{L(oNM63f+Al+;X1|uh zAURBlIsfTXgVDc*&%AG{eUR4ByeK%!Cpf>{@~xqoV={f0B~$+8fXNGC_D zhbd#mAU8j(VZ|akE|fL6_y50qlUlihytT`Q&$_V2*I2fHfiG#i)a$zH|Deb%{6$&e|JiK0+#Tx#DQwLuey&fz`|_2*y{(7?3UU5((wP2 zyONH&ldZ9^ZL+EblIa(Y-YkW*?ezb<=K686s#9-+0EpvYevciK-$kOoYOJ77lb@bb z1i8w6L8F6XU=%!j6uAbJFs@j$ zY2ET^s-ol>klw4geT()s4L!|F8FCB|R+68%Wc6n4g_AoIorTFTz>ZP9MMGuP#;F}S zA&#bG7{DvflG5C_etLUu1dAmv=>~|HScu7HG)-vBS8)+DH#Q>K03K#fR`KA3th_K; zaUnitLy`@U;1*<+RQ3-D3iOi{66BU3*#Is9HqV%(qT z?10vW`eYAVev%E4@?m40dFbrrUpLhk*Ri=FMh)El-su*#OS2 z*@w^Hc=z`Ea|46@I|?{SHb748$hjxae!u+j`qi7OyVT@JPl+>*-?@GF=Zi02KVLgF zSCNdQ+`aqQgNN5YKK=CQ^t{SQaRAm003O^ Vz<1{)+#3J@002ovPDHLkV1oWGr`iAj literal 0 HcmV?d00001 diff --git a/assets/images/flags/guy.png b/assets/images/flags/guy.png new file mode 100644 index 0000000000000000000000000000000000000000..5845c6db9c8281966eb57e50b531404cb9be61ba GIT binary patch literal 635 zcmV->0)+jEP)(C5hi}lqk+$*|M%Jc^u_(?iT&k!{MR@9)hy@0 z6om^JxDO&h1q=)U0>ls`QU?zk0tJ=~9ITHI6bu4VKnSLa4igLlPdx|f>goak0GSOO z6b}!suC5dg52&c9L%4Z7wRFMf(>1bh`270${rgF}eZJ<II2byhjHoh9fI7lz7y*3*Yyf~^1YkgbafHc$0Y2jKo`A;fsU4sr zUNLT869O7%mq%8h^F)Cg1F*O-$AI&b3k$HePCwz{4opv89a}%4vw`v6E|{LF+kYaf z-)~iDv!uLsfqpw`1_BcRk<(wVHP z!0Z3~(h`u>|Ni)<1;FaRQtxyDWcA%%Kw5g@^wW^E0J^=fbOF4)*T;M2#fuk@-ai;= VSDSEy8L$8V002ovPDHLkV1oHp5p8zwN|NsBY000$4p)*{qCsL^$N~H-poyO7Vftt;4jK#gm z<#mzB*x>Kthl8+)+{a`u%&A%4vtg;BayN{{Qc;u4aY8HeRpSIXSC`7Dgq_ZQki)dY;fkTqDpjh$&F1#_``uSp-BeWG@ZoZ7)GO$ zsniBIn+!dkmaEm7t=5O1&#u1T%+~Ae@%TAmu+I(-cazB3MMcpV7(8RKKV`C4dAwSC zy^y4r%K!iX0d!JMQvg8b*k%9#0p&?VK~z}7?UmV9gD?<=MV3hcTic>m(Q2)B0g6lA zx7PLjA4w7*Y#~kY#tZ(#4IJj1Kbgs7W208BT9Gc~{R0Ui9c?zyR{V0jv%3de5y0VD zAC1f+V0b#{$*KUrJRc_}5&%w*tP=qMNYlhb3<%RtnjiuI2uxgtCL(}P`V=5BK>(ce zioym^6Bw{8O|Nygx4Rk}fK7b#5efkB<*RQa)Gjzc83llhGVw-BRa-_LuxRj45^EU?SVXw3-A!)29K% z#6@f(-@>$-1VGbg0I>;n3unqm)8_!8i9!qCE6P2y54cLT@XgiL&H8{;3(v2w=j#K~ z`uqYOAUASA{47MW?dkv>Pa(&_ovl{_+|67?dVQ4?cXn5837(H;D0qCY>K@qP6tOFB z;+v1-{kPSVgbDDT<3aZ9t(qh(fbsD7V&QP>rTi43loFp9P6rsm(tfruFns0JUn>3iJyVE?uFkcDWJ&0000< KMNUMnLSTY}s&oth literal 0 HcmV?d00001 diff --git a/assets/images/flags/iot.png b/assets/images/flags/iot.png new file mode 100644 index 0000000000000000000000000000000000000000..2863320f58639484d1bce5ae2d1829c8012b5d79 GIT binary patch literal 2415 zcmds3=Q|s07Y|i6S|zm}t7_GzT2!lQlc?Aut@V`CaG3i86&4Tw>v40RRA(K!&>JG^_cqFrB06 zoF>F3%`kfzLSF#@Y`y;qU5X4FKLBtJVEVvP&%o;el}a^ylEiV-4 z;)vs-x5osOuInfKIX)592~Ey#p_S_B#(l>Te`OZ)YoSh0DWp$r;-G}quXD?5J5f1b zDw=%Df4-&- zXXR5qII%i1xi*uK-#xT+RWDxcVYa?=As*rw829;2Y^$ij2mgph32%1QoDSjo1=4jxqP`C1FC11Hg8&G&}M)5 zrn0eT7Ma{RXYJC5U49wfFh29Q(mN%=070MdMvxCm(m096)xpqMnHi@M>d|i7mz2`M z_`-g(bhE8}(!wf<#wQ}SWkN<1kIFinz>V?WP`A&a9R8+JJ*f=h*N#o@N?zYOm`p0} zz;(L7>kI40o))+;c`!=^OA2U&y9T0`p1-k(_K`A0t)~}i#%bkNW04I={}R;em^@wI z5PJvjuhXMe{#o`JFI2*`?cI_3PMOnF*bx|OM@L6@sxa1q3H+B4R+<4nSBcP; z|HP4l2LPZ0_?km303;ZU5C+`SL2I=X%5|hwWfei|T_6V4PRD zr~sl_MQJZ{omiW$v7ZP{!?wxptp8HOtl0?#v^GaG+4+v@0A~g-FtFcXEh;MF5m$7` z&24j+a^a|HvQA-BtiCQQ=Om!Vu%MpH=n5XXKZ=85Fu@F3Ba}-V-eAO;a%EB*jG%KEd zTBak#TK5K9);Tv-b~=sWO1xle*#X*#sw2m6x?W`&Ii_zp(Iq_z#x=)x_%c5sIDaU-;6ugLN1&rcuXM9my zyMV=Vx3BC6=atvBGT89jIrUo2gBeP|o3m|T>MYpW6>m(Z;)cidi~^McB8*c+4VMS1konBjlMNdi6H;yq!h-Zf7&#*H5bd0GbFyFg5Z&`<9X_ z<=;37dY#EETO!7{xH}Z(*xx^+S2+&CWOXAWv$>O?Lrofu-IiH>mN*pu)eZAcf~sGK_Ml|fCboOC^^DX zOUya>a?t>9?qHCWPS@ zRh_cGj3i6--p*DIb}!t9Qfb+g?t(;g3W8$Nrq5+h+AfpHDSTJ z@_nGujUX$S2tBTDUeZ_2r6{J(>bdF#A-R_s^X{bOepxcXI!NNa%gmcsj`GT8$yt&= zW!Q-($$MOe+s3k0EIaIkI%_qN-w*j&GHw?MO*xfx+*wyOtP+|wuCZaXw}kL*u7~gd zx3syN9TGJfHT|bbb4FGu7_2&>^LP&Qwp+JjW$?u^Jidn`&WnM-d2#L8@MBX^GVOu_ Nfb<}`)!L84{s9N8KobA} literal 0 HcmV?d00001 diff --git a/assets/images/flags/irl.png b/assets/images/flags/irl.png new file mode 100644 index 0000000000000000000000000000000000000000..2126054d32f58c1d32626bfecece011150e4f8b3 GIT binary patch literal 127 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSJOMr-uI>ds|NsAInC1DulHs(s z(_f%CV@Z%-FoVOh8)-m}qNj^vh{fsT1PQl>Y*7InUe=}`{~bOgh9ra}7;y0@GcY`V W(${cKziSszF@vY8pUXO@geCwr?Y5v>H%eT4Qx}dY6h<5>pg*ijdfg*+m z4jXpHxeJO|0ys78XQ~T2C|+1Txxr1rlGEm0ChwB<+m#nQVQFLt+IN343&)9{s;Ae9 zFfwH6*{O6HFc>fA=vQm#R%+lFGy2}* z?uM1$7&H3b;o~q!?uL~0w!h;qNA7DQAtM1-x@#dH9 z+EMZ4jq~0{=yM?ESR?Z7%<=8a_TS?2-Q4oQ$M)dk)j&7oQ)J~HIP}}z@7?0wAjggMR0bj@~RN_1)m@tr^;J9ottf*hMAfA354yY1Xv6?XDT%Bq`M(G1`(D z$b2B#9WL5lAnu;2+$t>Fa~;k+BI1ab)<7i3WhvzzD&3GB=zfjmh#b*ICEGkC-;^EB zP9NWBAI3^A;;I+Ws3PB&8P{kY<(eAgcZk53Bg{-I;~gsG6)V+m9nexA)JZ18Ml0SM zE!(0P;zA|e9WKpo8qY)_MjZ%~0000LbW%=J0PpXQkB0A$?~jjYS z2><{AtVu*cR9M69mQ{PgFcgM?0i$zE=@|3j&Rm(hySuw@KYyj{S=*442H9QDRpsQ( z_a!G{FzBI&9(f82$CVeE3o{OLSp~;gM1YkS!_kPy;dTQ*oVCezRuNE@eBc|J?W8nc zFz6$Fx7XK6&1ytIrG(=7);0ml>Tr1jkx0PnYR7&EElsI54x~PgE}@{{mD{Meh90_2cu_;t)~2B=--1h(5NT?ryFwHphtS zks3hsar<)i_WE%?iog^lfZ*fc{_ElNdv64RBGrJjl=a=qllT3#VFV5n2Eav5tR8!X%22l6_ z)zbm8587g<0%RYQ?dD$rIaVglu|EobV0F>EMK z`jlyHX|%y+*i`A$t-Y;lsMk7J7}V2_Hb(W};c8@6Px4VM0!-}c(|wpUuQ(b$tf@;@ oNkKN3tm4wtB})%I^vFy60K~=CZtW2sApigX07*qoM6N<$f{GH)!vFvP literal 0 HcmV?d00001 diff --git a/assets/images/flags/jor.png b/assets/images/flags/jor.png new file mode 100644 index 0000000000000000000000000000000000000000..6e5d2bbb897ac0929d76c6582445690f40258eac GIT binary patch literal 3131 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 Ef@dU`QUCw| literal 0 HcmV?d00001 diff --git a/assets/images/flags/kir.png b/assets/images/flags/kir.png new file mode 100644 index 0000000000000000000000000000000000000000..c8b69d702b5f4f1a399441f6f4bcd0be6396e8fe GIT binary patch literal 2113 zcmd^Bi96Jb0{tD+zZi}boD@0f}8zQ4dk69OI-)N$vQq(kE(!0zVY z*n-JFA8`>zat^n6ALk0|xko9C?QPk}YaWD6lJBM7xuHh=@Q3|BcANZojmjb83X zLrNwf)(EuFTk1mbh4UL<=JMy=!EVDPXkmn{=vg?42bis80Z#a@G6eq@^OA|e6;NL$Taayv#9!C8rju5`tzV?SZQ+cuJha&^gkD!d zPaFo^W5~AMGf;>GFKIvyx+A5iu|n)X7G@wGFUUiQYBl;#=#4cYz0z?xo?-_yWO>Hm)bnB)@b zcXmPS;o=l{uJc#2rs7{@tfQRmWg4nT-xt>9vJ}ZUuDRsm>Z+&^NxgCV2rVFarL7Lu zmP#ZZT1@>jJh;&6nay<6>_@;c_&n1fYn_ZOFh#J!C0?af%-`?aRrYI0XFwO;>1>UU1o+aq~F zCkhS@jF7v!}J%*%nhGp6OJH#r08zR;98Z z>w5FNK6ABi2r1D-O`+isLU-kH+N&hyL+4mq0UXILvXl)M3mJ^tPgkGi@!nvJA>dR= zVfcd#yh601a;)Ms*Dj@WJpUEgrFre3p@UNkpqD*a(rhkJL*felK5j~G$P2Q; zoc19Ygmps2Y zui=vYpG8m4I!SB)2z5y5Yh>ofr7$*38Q0F(XUaK8i9)wB@BAkFXzh815(_t+BsAoqYQ)%&?Sr+X?P6V{i;+V3klio?zyGOm?$lSI#0*s{u`kGE`D7d z$e`MpScU}zBYMs%e0?m{S1ORy)mdIgoqd~H$+56XE2diMkLz~K9)S_snauKVQzBmu zO@UpZWvX&0>Ed8V>d6Fqe2$z=I~2U1nRmStH%Ce|!{MBc>@y7uHrvhg`>r5?lQcRu zjk*J2ntB&#wgwkkTF#Jc>FZ<9sYxq=f%fJ&m#alB30H|m5lKZ0$sz-ZY@Q)be4Cii zk{Dj!FPLDfUU}3~dglpV*)7ZU#KYRiO5igqT7I4M2-%kJ+={-Thp7c@Xty-b$qe#R zTwGncB$!P7mu5-fxDu0+{+u$C-X&jD?{hz-A=MU7P~E4BbXwQl)ux;?esdx|y+p^rwFIg5hKM#E0}e+r-g+N#dKVF8p_JQa|h48AALhiFa$fO4^B~%NjK$=!C)wP z>aj@sL7*8P78NCm{oG;RK-A6+gU_&-hKgPHYjEk>$gn^&b`OWsQ>ir-i6#)zyOQK2 zKH=0*w>|pfE-X48#7`$4RwT4@j^D+oH3bJ}4-Hvct_v;gtulGp6E8F16E5-_Lg7Z4 n_@J7;hChoIV5xFS4haB-O`b-I-J>%**8uQv^>wLnCTIK?y8eos literal 0 HcmV?d00001 diff --git a/assets/images/flags/kwt.png b/assets/images/flags/kwt.png new file mode 100644 index 0000000000000000000000000000000000000000..f21563b5f414f41b7fa843432667a3992114654a GIT binary patch literal 372 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!S^|7RT-^(N_6sOWFtOBdi8IVB z=VxTLVCDM%|36UvoS@oLKDpxps%ad;>dfryjEtN46({mY2e9)qI2tjuW-zd_Fj#0a zOe$p1S79iQV(83b5a4By7G-EiW?*My$O&PXQp}L-%b+385aq@YXwOg?#~>!ipdiV> z%f&FgjKRy2frXi&FMqfE)@Y!8j3q&S!3+-1ZlnP@^E_P~Ln02pz39$&C_sQEu$l92 zLQ$XE?1KNl8wI2fi+|d&J(#^>c9+um&vw4cwqDxHILBY9;ddZQ!(S(s3AwHe63dht zu12vK*mW=%RCh2;D;8wP3l}_)s={>RjSEAD*f~aanIFt0$zpmeA<vdp(FYAUo+>Fbr84r}b7x(aBHMswq>CN{~4xQDN2R`o!_y4um`S}M1TTP%# O89ZJ6T-G@yGywompK)OT literal 0 HcmV?d00001 diff --git a/assets/images/flags/lbn.png b/assets/images/flags/lbn.png new file mode 100644 index 0000000000000000000000000000000000000000..2a9ae1dd0e14b5a158493bb2cf3a35fa918274dd GIT binary patch literal 960 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 literal 0 HcmV?d00001 diff --git a/assets/images/flags/ltu.png b/assets/images/flags/ltu.png new file mode 100644 index 0000000000000000000000000000000000000000..dec6babea8788cd571a499454b5ed876cb19487f GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSJOMr-uI>ds2i0{MvRwY|6gFyl z!v+**ED7=pW^j0RBMrz=@pN$vu{fQaAmP^V-=6D-!5WqwVGA2r4|_G3im|$LBo6=p literal 0 HcmV?d00001 diff --git a/assets/images/flags/lux.png b/assets/images/flags/lux.png new file mode 100644 index 0000000000000000000000000000000000000000..9cc1c65b5fda9060c2b7b60596700a01192a3a7f GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSJOMr-uI>ds|NsAISn%MBhB^Dk zN$Y^(j3q&S!3+-1ZlnP@DxNNmAr_~T6C~Uk{@Zi?Fj&J9;T-62V1v+us8%L1p8Uo{ a76uv9z6R0XeoKMs89ZJ6T-G@yGywqDC?ds|NsAAY+!L}3tt&f zkg+7lFPOpM*^M+HN72*8F~s6@a)N|gL$)Yagux}&2pk5pe;TP!i-9%&>p|`}dnSzkh#hs)jF6(8kln zF~s9|a)N|pgDk&tj~{egs#gDaX6S2TnQ f=yOOYe$`^g`ZJ%o=$rI6pg9blu6{1-oD!MQ&lD@l$;tE0((b{>-K?_m&C=biv-8Z+-K(+YwY$Z{ z#PZA0->|mdvbX8Ezr?+~->m z#J#=4wzmBB_UXUG>Au9nx47iEzU{!q?!U(2v$^=}?a0H#%%7srqNS&#p~%F<%ATOs zqo=sIx5&lCvaztvo}tQ~p|h^4jDUHrtg6Ju#jI4!@ zNklJ+@VKp7rLdg@sO7GIa+=Mz zLUWJ+XQY0fjymf}+GnK>F*80cdI`=NYqE&3?jR8K_*sC(W;Cz>_A>zh000000Dl7@ bgg(6i&?iv9w}`s+00000NkvXXu0mjf1E?3O literal 0 HcmV?d00001 diff --git a/assets/images/flags/mnp.png b/assets/images/flags/mnp.png new file mode 100644 index 0000000000000000000000000000000000000000..3e6c538e43fd388aefbf9c9ad5e6f170e50813ec GIT binary patch literal 2078 zcmV+(2;ujMP)bWK0CBwlZoU9=z5sE&|NsAtii`kgxd3gs0B5!VXRiQjw*YIr0A#Zxb**HU zxd3p#jD&*s_45E|vjA?retmxhYq0=guZf3;r-`>H$x{Qg6+uPlAp}`bpp)Y!`b#!)wfPs{Xh`o{Eb)2~X zWUE7Mh_uAd9dWA)WTB*`q37o3Kzpk(f3l3Hz8iJ2n2(GLY_2M4lU8qnrcE1eYi$5v zrZs1Ze|UD5t-P{T4BzDEla!KeoV##$ejaV5;MvmXbprL~8v#l@TK zszMH9P7k8F%dxz}>9sEZ>8Z?#8Qav!$*F6XU=%b^OTxrh{iY_gL{N^Rp0X0E;lU z;EvSJ-b9xP6HXho?)&BC;uV*q7W|Q$jVJ?Hxb1Bce*8Re=1{77|3nV0?(XJ}GhRpc zPE0<`FHVF3EL?dB#vi&8SEsg3JoY!a#i!az?qpKO>4^P0-zx=K2^zq{ZL_K7@qyhj z{ju@Y?fQpJvv}J>8c&@}`sURY(-tDYLdXDCmGbb1E_-(+=R^q#WSW71yQP$Gw0Uys z?xWT%zLG5X4PcQp;aKbPXiulNz5t`8p@V~mh=YTln6iLHXuo>ge)X@#JopXZDUDq^ zp}wOnR6(4DO+kWL)yhXYeWn;23oCD!+OGOfpTAhJ;4^?ts$ff8L~Hy)dthj>N&2Nr z=x7;=Ol1POO+YYqUHw*dJCtBRH-JT@MaCs^MU<2;nGeEc6^!5Z{%uVu4{ih4c$UTX-nkvd192sj z!jv2ij!BcnVKMw}d*nuAB}X=#1_-BZjY*6v6BK3<=K=!$Oc7BI4$X^F{6LPfFuSmS zd20zrk11{g`0Txw3#+M#D6)QUMFC-U7Ujwl zUeAw(@!)nrg|TshPB9l zd5Csd6H{<5WZ{kCXnei7f*Yp+%DPJ))XCk1W*AmIS%?8jZcNa0YR<8}b`u^0xJuV8 zZqN~J=ZEk@pdJu~CO#IGX!Y7{Yix1H0vq3=eYZ4Z6qO+kHU~Lh9Y~l!f{jO4Uajb9 z4ITqn_&gUE$%!g~13_2?=!0{rKGr}#@Unn?5XI45+prc-N@N$eT~V}vLr#&K1r+2Q z>XRh2RSnfRL@iiBWnBn|%#&sg8*V(gQCvS@$%1(rnwH|g>^n`yM}nD?Q(MAHUY7@$ z6a1sC^XKY-4a1$C`4n@smdk1e39<@Xc+Qjt>gVKSHn2%80It2L=Zl{z6q;UrDa1QbW$po}YKNHrNH6 zMox@eDoUcVRj;f)Rm2I^3*v6(0jnzKFTJ6dH(M2AfQY-}tY{AP!YUb^G*-ejllZkj zt?WW?4UPa;NEmQ>_|?sjwa#tgkkb<Ydd-LoMLyWw-$b*{cKXgjB?dZ(xXvrOoly zh8bY!dTE+2FBc0bEff}Eu`Fi~ZBAxpP&$~RAjZc=Y8#1#No=Z!k%+31k)g9C6AKxQ zEOs_-C-)RRcPBkQCbC*-AV&x@GBPnS;cn%zk2aD=!6<+M0MnyPZ;HOVtpET307*qo IM6N<$g2i$GIRF3v literal 0 HcmV?d00001 diff --git a/assets/images/flags/mrt.png b/assets/images/flags/mrt.png new file mode 100644 index 0000000000000000000000000000000000000000..0fd8d757ed1598b116b13ff2a14935240c2843df GIT binary patch literal 509 zcmV-rVx0L8+$zql>h($0d!JMQvg8b*k%9#0V+vEK~z}7?UiY7 zgD?z+3rWZu?yK}z*ZcneKXAYZ9Rh8pl_pin`vu#PA1RJ)CKDqgqek)t8Nho0;`l|t zdi^3`w|fHc`QrvaJb3Uc;N$y~?isrP7smE!=io1ZavQ^%qSn-E0j_+K*@e z8)~>~6lhm)z=i<~v?3-zK9rvWv}aU6cI@XLGoYZgatL_6GAIuSiL>CB-NQHC*oK!u zpE}O<3^>6f5I9vtkoUpXu7@a`sil-lH4AIk-qII};^>L1F&n8WSy!GKx6&-p%@@|_ zo#?L~=_~)KrMj4l2#CadVg8K<@DBhZBcoDZ3e`to#GLn%00000NkvXXu0mjfyDigb literal 0 HcmV?d00001 diff --git a/assets/images/flags/msr.png b/assets/images/flags/msr.png new file mode 100644 index 0000000000000000000000000000000000000000..2d2af3aef0785bc9e2d99bb772716267a512bf4f GIT binary patch literal 1502 zcmV<41tI#0P)goeEnguqR0HVDBp1aQv4*)Zn|NsBY001XasU1qCRj9oOHKb6&1>-xCuI)racGA)aleGCxo5OevrdfslEm_l>s!F-B(utqP_sA#0E5%0yLRa zsJ#j~mnTR63fr00Dwx5Dgd0jq(Kk?D4Lo}TuA_+yIh%50HD7Bbcl40kxg+@ zkGik`d=UVo$UItF7=(;fPfQS~#0EMHX;L0yeGLFM2p^uKK{+L#iafQflTj4_O=t*Z ziCQ{ND;|p>QQ&1|++&23SW#J$MrpVI9Eg7LOT->e@n#!ZBb2q+-Q%n9u^yg_XtV z`jZzgpTE7i*NBBx7sCKK6%IjufoPBcq9TkoT54Pv286$U_W0rb_m6IeV;TUqUQtPz z8)N`GmnMglDw-ptSy@8DH-36|@8g|t77PO**2}W9LkwVN!gK`C02Y>OpFh01dwG{3 zngKBD+2IDTV>&_?WI)J`r?;+bTL&~i8p!~-^~eT*9btgv2(SxSHeS87dBZB80rE%& zz^z9y0Okmo0j6LBj_o_Qd$lDCssV88(F}k%0>ywm2acXPxWyXH1?)rtkO7BIpFMHh zHgx@Tatz3@v|7C5;*tFq!h+ZKT3Ke2Y(PO%&%EV351%<17PMk=S9KoA26*`Tb8hj0CKX<%~H=me*gdg07*qoM6N<$ Eg5{WNbN~PV literal 0 HcmV?d00001 diff --git a/assets/images/flags/mtq.png b/assets/images/flags/mtq.png new file mode 100644 index 0000000000000000000000000000000000000000..1897c94e7fba5ea55209a8eef53a28a736083a42 GIT binary patch literal 1512 zcmVP)C3&_i3I`~3YAFpFt>wPty<=<4$wIglnn zlp#HmQfR6-P@TQS-u(Uj^7Q!X?DV9s((?28)7k0${r=wK@1Cm9Hcy;yfw7%8>RWHFB|wyimc(6guP8&6`T6{VlfpGlo8jf~w7uGmo5yyCybmmiJXD}y zbFh`7%WHkMEJl}^rOiZJqs`Oi#LMDJVWuufnC9v7ikQV8I+2&8%z}}@+1~6%U8Hb> zx$Exr7c-28mBXT~(ck0mrm@p!dbIHJ_l1+{^;?fw1z!N}l1SfS6==k@pcfsw(Do5r}o+|}CZkDbU&Vy9trvC!7&yTjer z-0R2AWuy%y9$j;=5mc+is-y=VhevZGLsm|i&@sXa%bcVap zI(#+&000$qQchCVv7lH{{*{yDC?sY! z7u<1pZ+4UY_Bm%}GV{g6WFs3{Co$VX&7K{bNp|m~SC|pNlKC6Qv>>L|o>_@4G_n2n zh1}1PslC0b!g4k6^f45v^Xx4;5Yhv-dLM2Y1|06Qp-NHa+~F$~lp zw47lo6?EV#Vk;RY(3Ju>mGBuRcpgWofKM{1m~e%NG6(8-qm*kBN!*cim=0WvLzodj zien5ND2J zUp#R#1gMa~V({Bo51J{!3}#qNwr9(5LI9hy8ec8oORumd zfDP6V8vdS4h&w^CLW#&XHloUXS zL@3fv2e;ye4&b0^+5E4-l=`$SLcc!Xujy58(ZHL0ax)C64j`^LQW9CyOMdA?Q%OtF z3Y^jh#207%>hF2uHR966?>gtS)4ei;OF{+MzU-{UC1KyW#`v11|Aye;egz0E<%K9K zq%lLSWnubrzz<)&z=l@gGA}~ELJgR1rDprKO(eVa(ko0i{-|$cz2py>q(XXyhR6*7 O0000=yb95e(8n++VB4INkq5J?6O zRtFFu0tRad6kZ4s7y<>83>%3H8K4avRR<3t0|ucD9i9yxatRet2M;j>2_6CkbO{v` z0RvnI5l{vXGXx1D0|qPv2nqoLq7EK`3m9$*6nhF6;}a^03>l9M8Y=?`mkk_M2M}Ng z63`JQOa=}w1PCbv2vP?R+Y%_;5-GnBBU=a&|2oX623&#*7zz`%H0tI9U5{e8NgbNwX z5hf-B2a60EO9l>g3KiE9C{G3tnG76P2M{?03O)r3iVGTh3KlvA3yTXHnhYF#3KvNR z4Lt=50{{VO2^5wL8-EKIfD0IC2osJA8j%bedQ95&00001bW%=J06^y0W&i*I6iGxu zR9M69)5TW9P!xsXMs9A1*WDd2?(XjHP~6@1#bMCJ>WrI);rSon8|`f}K@gM+8|W`U zOQ~u~D-`fOeY|n$H23~;WiJ3&h|2e2|JSGDc?|``fo8pcy0&tD_mD_NV#&nK^(ty_ z)CUYINIAKEq)nWoDFvgdVry$khr@=W4G`a4bpgi(j^DDCUU^Of%Z4OIJ-XP zWLf9V)C_iv%c!-M7>H1NE-yab9%U>%Z?TO`FJnhvCsjuZRC7H`L40;P$LC_$UM#fv zJ!OPIh7yN;hX)M*o2|dwU2m=^B?GMH@|p$-O|W@&v`AHzCB?MfDU*GHHxK{-0FVb1 k83+IX0QgS;f}o$i0h%dmrr`Jm$^ZZW07*qoM6N<$f-M~1c>n+a literal 0 HcmV?d00001 diff --git a/assets/images/flags/myt.png b/assets/images/flags/myt.png new file mode 100644 index 0000000000000000000000000000000000000000..c149a2a7915e8f29470c49d149045f3ec42d7d58 GIT binary patch literal 2003 zcmV;^2Q2uBP)Uev5w^Cr%F4>z+}!>5_WJtz{QUgv?Cih4zuVi}{{8*9xVZQC_qw{e z&d$!7nwrwm(yFSe>!6eo5pvDV&7z{BrlzK!pP#w8xvZ?L+1c6C)6>Vt$FHxiySuxt zuCBtu!knC(!^6Y&_VyMQkroz|z`($yq@;_Bi=m;R%gf7=k&)u!;_B+^%*@Q!KPBE^ zOV~pyzP`S-wY8(8qp7K>yu7>;5puDyv8AP^`sd@9mzTuE#Gam>*+?+f*4Fyy<_$jHd%=H~nR`;Co_?4+3b z>*({y!T$RC@VBk+wXF8n(%oA`?Ca~?RzTlnP}J1a+fh3CG#>x_SMhT zHXGt~W5X^H@Vc?@v8VXm+4j`W<%4pkH4fQFF1Ich;cZ&RE)}~w5AU?9;c#8p#>2&t zh>wr6lasY1ByU?=msM4iCMJ7@g`*xGe06oDLqm%oAbMnEm&wV`dwZBPG=SOJ+Z-Hu zp`oy=tE}#=qvwo${rLDtN09Qrx3X47M@NUXudb+}pF%>9r(_QN^YYCs5yis7h=_=@ zP!F?Z4^dH!vS3r~sh!q5BDQW~vrawjsGYtu8@)0RmUb50(a)}d52$nwm23^fGZ4K* z5W+1Gu!j$}IV7We7n+e)x-AvhK`6$Sjox8P+fzK(KPK|SyZ`?FtYQzlgb<%<4%}Ho z-e5|(P7tqJ52#ZP!!{7Qc5B_%)sSx!vpp%gM-aJH5S)@wzk(3Rnv(e7+_W?v;n&v3 zqMXR2o{|`S{@e%AH36I9LhB7XgraA;{_4G=vc}b?vPv|DdaTFW5RBZ*Ir& z&o3DS6g@OKHelU4EKq{5?p9bqd7Ro4SKvpRDhs3C;p&%n9ivdVjyVRGGg(FWebK2}#Q>I>a>C`E+ zW>1?bKt*^~if`%iglGVcG*i%(Y7LKI1~EOEs2WRD5y{hu87Zj9TIhn5ndJBZ3UNN= zfHdu`2B2;ZK@ek!fozTtfNC#ILkE>FR`3A=>1mus1%*pAa`XvTpmtYHLfPdgU5P$H zu2~|4Dh-#G9^e5e*;J?z=d9&&!0A zU!k=zE)LMkP`m4HS$xBdi;=Ue4sb5Urd9YUm^n|*7X!8iLdxg#uAvn`dsn#Qo7BqN zs*v-55h&<2UZ;q4W` zi!4x=&jTRc5?JHV7m}1mKQ3<_Y8L^7v$Ffg={*!#sCU#riJRmB$k-&U#Ga`4d#I!m z!82Igy2l?zqj+%TO6(q6NfPDp>l5t~Z4xWvn+4pvFf-MuO%xe9ntluW`m?v+eB*_e zUwq{;Y{P1rGl~+m&eY6>UOd`vl=a!c{8F<{xZ+*xo9}=ADz5C4cV2xB+jyT)XD*!& z?0vGKoCg!lf(+ZY*pK^u`Dx>hFF)P)I`-s683OafA)E(vttq_o#ZE!?0TjB!;UFGH zQQWaw;EYt5YUyZzRaSMiIsBd`Tkr$6;SlzFS=<5a(0c6oN3u0xfH|PHQc(bm5iF`l zHr^EUeD~`g$BuXW^=IGlV}0+xL_$+Ms;FNCj7$`ONO#Iw3vfJSBW=1+D71>=I7tx4 z3#_N=bfPE#(h@Net9|f{3jXcct&)hfkWm0~l|=z?t~d|vrpRpL4Uw&jqX%_fF^2<% zMJ10m3jrV7n6`ik1O!6!a+Ti1y4l3cbm60E@w7U?EJ$l)eSuiVdN3$g*R6nDEmcx# zV%NIOjcy28j$({*c`zs&(vM}XExTmy`6)*3f)0hEqn^uVs89--ku~%`lki^_T~K_w l+>!n}VAvPU5sm1K&`I417b_B9>`DLt002ovPDHLkV1neT8R-B3 literal 0 HcmV?d00001 diff --git a/assets/images/flags/ner.png b/assets/images/flags/ner.png new file mode 100644 index 0000000000000000000000000000000000000000..87bb8211f7ac853a62d28d6b0cf4f93cf28eb1f1 GIT binary patch literal 494 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0XBj({-ZRBb+Kpy0*; zpAc8~0-yh2aKxRJcZ2qWAT}W4Y*B!4feZ-y_uoH%|NVWEqy6vi-+%x9J;~8g+GeY~ z-43V~s2-?MahuJPY^{Gl>2IH(<{LZ+<$79V^7rSr2O*pvHuU}d`tk4A4^J{R-Y>0t zoGkz6)0+>g+y4VqfBW<}Md5L}%DV;Szn)ZL74FnnzFID%?Wof^6sk;6@`{@QD<xp;u(ENVJ;r39^r>c<1P|*1mE9UY_h{6*H|B($W3+knrSQGK#DpXJ zTU&Wp-uyfjE|9SI=>Z|8A2X-Fo5heYnNf$sp-g6jqj!(RgT{sjf9@}2w)SW@*!#bP zWuD8Sb>i1I*{Yupe5bun^7Ar@FDwa0*K@QO*3L`|Wf5?2;3|=twe{n72JMjeFITor Ua`K*e8|WJbPgg&ebxsLQ0Iw|U=Kufz literal 0 HcmV?d00001 diff --git a/assets/images/flags/nfk.png b/assets/images/flags/nfk.png new file mode 100644 index 0000000000000000000000000000000000000000..6e68aee4f51473519b3cd5c1a72425ad9eb9f7d6 GIT binary patch literal 1422 zcmV;91#$X`P)LI8R_|NsB>`u7TdKmd3*|Ns3BfI$FyI}?OOK$>0#eLnd6 z_yKx6-RezV=kn;w)Y$}>(l7hv)8>+q;Tx@?Bej^2!23d zt8|gWpvU3MtkbvX^XmY1IY*vk4uC!2@8bOZ`^DhN@%Zvys&pELM1Z`JCX7x>pJXYI zQCO#NVyk&Ilv!)Ac@=|00C+Pum0c2mKOKlm41Yi$iAR0Aj0k)(IUtBgJC{== zi%4y;fq}e<8-_qZnqXb2Yb%XTERau7qG|d3`CzDdZL)>{a4h5S;{5&jkXEzTya01E zYOsHe!kpFV+Eb%!c(sWsjYwv%f^oBiW375^vVw@fn8Dt~Sfp^U)wJB~-dU)21$#M` z$f1hCo}d4qhxxygBgWL zK9y2xu6t0TVqvLwE00iQt#>DiKLB$sM4VbBi%LP7S0IQ&Xb8?z00001bW%=J06^y0 zW&i*JJV``BR9M4fU>F6XU=%b7wybxdv!tMgGYwcze%* zTmwWCzx>O|NN?6B(*X4kzyAxQt4Mw{CewfiU%waTsebp7VQJc-SZQ~R6yGxg~S zvJ5C+{4BpvA?=4+*4hKJ^~f+l=d7Gc_9rR+gd};zN#wX-{?X{X>{st!^D|kwO=u_G z0PUq-+z}5Y0%c0NrH8XyvKSNhg**F$XL^6PhaDooQN)qsj(Df)s zcxJ{urvA*Tp=);i0~)Hmk#Zx!AUEo=c(uM#_%K!SbiAW&M_gtx4P9 zwPKMFX$D-ncHUjdU+xGS6BDzKQ=-Sh#iSWve?vJaC&DB^kexSBm^H=IaKQ` zcU6&>H7d$Yf}hQriIrb2ZtCP-OX3aS*?0X4TRhvD-MgoB^0o;x2}DXtI7(`h-~y3@ zH!muMgu7U=x3F_FciT*K?QpC$A|(vEb_w_e@QQOa3k%oO8|sR*Hkla|kzzpKl9lr; zY(;eK1?zYys@g;atIH`eK*OX;i!uYaxHKp-prW!=lOh9jN{aO8hj03A)(@>(8DN&o-=07*qoM6N<$f^I;i3;+NC literal 0 HcmV?d00001 diff --git a/assets/images/flags/niu.png b/assets/images/flags/niu.png new file mode 100644 index 0000000000000000000000000000000000000000..acb36780d835e0a31b796d3ee1abde693b0b20d4 GIT binary patch literal 1095 zcmV-N1i1T&P)B;66F76yBRJ96YAkf{r&XF#?>@P z*+^RL@bnHeaLv`>SaO{2w7heLsr=Cv04QkB6f5wxyxB)t|Ns97EoINv;#YB-`|R%i z`}@=|L=iS}8$WmqGH(qtaPq^+2{3I9GjD>Hwea%x+D>2Cke>ej{@aY2c!{goke>0p z!_zB3+fQKg#LDOE@!VEs^vlqCkFd}gDtU>k?C_>6oGsHEU&kq1?+)X6kVOHN@R@~w0_S4teL?zWPC*ECFhnl$EPbA@ABHmOZf|0T1fsN1_D%d+F z;A2+TI40j&Bj4ogDN2AHKzaMk7W(Py>FV&}-Q(CtS=~}3I6QB_6)y!VW>qUrs+ure zUP)anODsBY0x4%qhW7tVQ!!b0!Dgj z&1$-aV78g9mA<8krV3ub0W?6_&j)UVkzU)17O(+O?VfIg4PZ45gBxL_w`B7gpaF30 z#;QaZpsETtqDwDw({kNTgmzUT3{cbof|8?Jq zk~#)_f4NFeEY;L=$O1X=pKsnly8P z%Dl;QLCmVqb~WM*kktT!^6)Ycr7>~g?5Q*QL9CkaRH#&Y=DRg1ps6K$EX3L28RBB003-GJDtd6z_b7W N002ovPDHLkV1h723KReU literal 0 HcmV?d00001 diff --git a/assets/images/flags/omn.png b/assets/images/flags/omn.png new file mode 100644 index 0000000000000000000000000000000000000000..6b6cd8b3b898d8791457cf9979f55756f568e8aa GIT binary patch literal 711 zcmV;&0yzDNP)vwtGBq-h`D(GNi*$WWpW@z3hEazHW^sB7g9U|*^dh(v2+Y=b^mzeH~i}kRv z;4d@ZDlYcCz1a&9-6JRCMM&vwaNQpy@}Q#PJ3ibVBiRZM-6AI4Bq``&WA?tj;W<6p z4HNUFrtzJh*$NQ;>FD4yH`oUa_rbx~0|@3+SM#T+*$ooe2oCR&lJ1F%`_$9yczW6m z65AOb?~sw-C@lHS%;;rk?R|dXIzIf~-Tvq1``Fm)dVJn0FWC(f=1^1G4ioX4o$!{J z?SO*&+uHin)9{j%;4CobSX=wZ$Kf?N)&Kzh<>d3KtK1kJ`o6vSzPThxQy1D$y$okLE<3vaDoSo}-dG@li^|G_`q^0`3 zyz6*-I-G?z00001bW%=J06^y0W&i*I0ZBwbR9M4fU>F6XU=%Nrpia27aRkV^0EpN!aW4Y^8!~e zOIl88MM;#X8y7hSu!zNrWfm4^r=}-+8t{-|fWEVTWOZeJM!86Ur4BP02C$mA`o!j@ zRKNdw{LaC{%*aNL0px;F14a!P tds|NsBLB_#FOEh-l% z$XF8O7tG-B>_!@pqu}Y{7-DfcIYGj$AzM^HhnKZULm>O$iUTVSB#213F)-)|`$*)Z Sd(Q)^W$<+Mb6Mw<&;$T7r69Bb literal 0 HcmV?d00001 diff --git a/assets/images/flags/plw.png b/assets/images/flags/plw.png new file mode 100644 index 0000000000000000000000000000000000000000..6f6ff993a1377710bb411730b1bf47ece87c3571 GIT binary patch literal 608 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 DVNT%K literal 0 HcmV?d00001 diff --git a/assets/images/flags/slb.png b/assets/images/flags/slb.png new file mode 100644 index 0000000000000000000000000000000000000000..d63061a0bdbae2d2f63096e88d2884ac3022baef GIT binary patch literal 403 zcmV;E0c`$>P)o!F8e9JbKE0r`%ME z(JgSsgsa~0_51Vp{C?QMe*gdg0d!JMQvg8b*k%9#0Q^ZrK~z}7?UTt4!ypVqyUL%d z&Pabdo;+ZD8}k~MEuFFcZteO7y)nJt(;4b50d(`+`8Vhc^%U^@Jxk~e^%NlNKh`q< z-2Yae01Evl`t%D>|BOBbfc`ms3dr`)>mvZXg^WG|fLqAw2>{(fUT-D0fb^1Efco++ z6!grmEG=IIDC;pmphp0a9sp$eEI_Ky0mOP9P@!i5HF^e6r%wTu`YoVZzX8r4-UHNn0?_F_prxhdn-4c(NzPq;_HO_H002ovPDHLkV1h~uug(Af literal 0 HcmV?d00001 diff --git a/assets/images/flags/slv.png b/assets/images/flags/slv.png new file mode 100644 index 0000000000000000000000000000000000000000..e597e45b35c16ab3347d0ba96a8c69c1023aa335 GIT binary patch literal 642 zcmV-|0)737P)Puy|M}x;e5?QY;{Wo%`|rB{`s)Ajy#Mvj|M=Yh?yUCfxc~g| z|MSNF`R0$Qhwtjcw#2Rf@U_6ty8r$8|M}tZ>Aknfwg2&mT$mcE>(!ghk0*v_w(9@1_A000VfQchC<@9+QbXlQS5?|*P*1ONa4 z)Ja4^R9M4fU>F6XU=$1%z{WuVU;`S!$xH#@95rCnfI(vbBR4+4!-H3v5oiDl7f!&d z?c!yn&yQOH7Ykkk1T>tvqWpOUCCN2F*VQ{EGuGE!Pl8MX9Nf~PQlnFp$-;4~h@8G*%QL zGZ6WtwQSvO4CF=m$uxjVSW4Z*NLgGEe>z~omyqEE4!|^89*i0=d<S&V0B1l$f=6x+FaQ7m07*qoM6N<$g4exNn*aa+ literal 0 HcmV?d00001 diff --git a/assets/images/flags/svk.png b/assets/images/flags/svk.png new file mode 100644 index 0000000000000000000000000000000000000000..06bed756e409718851e95508f471888f930c72bd GIT binary patch literal 521 zcmV+k0`~ohP)nzjTZO05?HnWuPNDXWlI|QO|Ns9BPNMEAGWe>k?J3Id8PlA~gNuw@7h4NKg z?$_RYHDg0Ug0jcrHARY}yxjV@yYATD?8?wMZ?oRa*i&1T@kdVIA1rM+YUhHCDP*l* zJ8~vSkt$@aScbp%oS*Y!Xzw^bVs7Wy00001bW%=J06^y0W&i*I14%?dR9M69(%Ejp zFc3gd?Ao5#VF_iYrDbiQ3v_|5|Nmcg9Ml99AY)$VDpKafCnGDR5=Bwlf-@q3fLws7 z-Ozv%F2!1Yz_|9oqq@jyms^_s;1*zINc zoa}#DhLhz1P=2V?WTQI<@B{!R)7v7SKMdF{0L}w|00OW8MNzgt4R%dpI>-2;00000 LNkvXXu0mjf!>IX0 literal 0 HcmV?d00001 diff --git a/assets/images/flags/svn.png b/assets/images/flags/svn.png new file mode 100644 index 0000000000000000000000000000000000000000..a791163bdb7a8b51b179b37b375547fd808b8496 GIT binary patch literal 1963 zcmV;c2UPfpP)+Dlv;GEz~G4 z)F&;~D>Bt8G4}TM)F>|0FgDXCEz~6}(=0O6DKOI{E72k;)GjsDJ3-SlI=>zy&mky& zB`6(boXjF9HhQirZlKd5DbzMR&K@Pl93#FPAtr31)GRYCaH7&BEYw9x(JnRFb$P=d zB^+RsB5I%@XrC)@qb6&h#UCcfASNDTmnCVO)IvzqIX}}dH#K;yPmR7(kG}#oUw0A~ z&L%A)Xr0F-D#8Pe#WXr9Y@Na>FU`%(!W<$; zhqpk1v$!iUDQ=_CB`eY>FVHG5J%F;$Ei*ZNu*@qlAZ41(ASXkFwbU~?)l^xEDJ|G@ zcho;bz9J|*ez7EGm-Y4ay)rn`H$A%@BLXs4Xb=-pjJX3nX<3uLOoq4d@$pNDxuV3} z9Ala|e6QDHXM`s!)>~l7Hayp6YRfG%#w|0#A}AYOkjTi$RgJ;HGdRo3%TtWRx-vI! z4->pMJkdHozrVlHGB`VYuXPU-F><3}mcau=b^}6l13F}V5fw~}yq3Asf)WY{Br44}J=0B6*J^LfIzYZAEGTH1(??8Xl(%~#B{X!V#l^+K!oqkRATw*1 zEM}7H@APaB6Gnx#M1QQiySs6Wu|asJNP)5gKyL#-ZPDNAU~-#1dZ~PFkkURwn1`T; zeVx3(&vvBBz0~Gjm&49MMy8&(c&5Cjl&guWon@QHn7O}eoUxR(wS1+dS(3DXrl7b! zLMda9w4AUvZ<)WLv9`9hI&qt7nx@0U!$NYSLw>JsZIU^7r^~3c14emYXp(eun2&;= zTX&*sd7*=uxtpH1j;F-R-RZK^<#u+PugBV;zShCh;^*@9qA%9v0000AbW%=J0RR90 z1SJ0uFbp-3Onv|W1W-vtK~z}7?bcUZQ&$|oaT^c{xk+lZ)eZ+O6%rB%C4^uggfu0P zm@q;#1PCL55KY1gQwUIzy@w#JD1urj%Z1xo>bBO=z4zXG+jH*;^3Z;~*ZAT?z7NU$ zeD44J&bi6UoioSI&JJ>PxCM?N;Nn0fJ2~BP)0~{hR0kIja9%?u*{~iINaQunXkar! zc6G9WEBTfK)Xi966OBr8wE>BWSOXS>OtJwPp=`neM`i=G^9^sk_U1byFP$J1Kw#jg zg+e7mulDr3`*zA(bXQ5G`PloGjYp&MT51*poqgg0hvB0Nw6BZiuZSC;T$au|A z{V5tg4-0Y<3+$+#&dOSZ`SHxy_&9BJH1_93__C&JzQY1LtMvs11&dKXT%GVh{qg5w zdgV#0F5a_#a0e0qH~44gYNP*#?my$ty3a>Llz-y;pbU08-E zJG*S4dM6gx_`H(W)RddM6u9uq@BjRDzTx7)Qari2O}zS0LKNDwWO#j+akG%4F-L>+U8D42Q@fzuAQao;Y4!9vK-Y3keCik03BA zlSP&vwg#S+DDv~;;=;qj?M3;24@1@5``k48xaxl06}1so3D@zu7ZINySpV4 zMI)EXT}TjjA-AznA_;|o)mt(;J3Dg3Y&!j3qUdySPDkg9&tQS=TQV{dw*1fAkV}UK1h&|$u#A~XY|9sQ%~+sO-+nfn@lXX<f6h7$!BEE^FYqwSInKE9C(kA0N?*72e)p$kWs9zdSv`9d8lfBtU?{0NXQ#LN*)xu^4a#el~Cl2D%t5HwVb&E5l|28`f3{1ew7JNkM2q zBr+OVOeQ-f=1~-%&&R%F*lZ?~Wi%Q%9FEzXln@-8nJKs)5TJqJq@W-W0Q<9qbb2A5 z9~~X-3xO+sVIe(+4FX_$a{^c&oGB29x6hmaJVKZ}z|1iiz;dy;u(Hy(v@|Lz>M;mh zmHPTtR&EoEO=|VzBzpcZKfkJ(6MRrn(VLdmwtIJf>47~(MXjyP?d^M$lS@iU9*0Vj zlau$hH#fHy743Ot-@dlCw6wI|-inF?FmUXUzrTMQ`t6&Gu@+Lo4-loWr= xp<`&kW$v9en~T2YIl4I8%*E0EqHcGa_!q@Z;1>BkgQ5Tc002ovPDHLkV1j#on!*48 literal 0 HcmV?d00001 diff --git a/assets/images/flags/tkm.png b/assets/images/flags/tkm.png new file mode 100644 index 0000000000000000000000000000000000000000..0c3ff87556a0be9719ba1a55b8f806c673345076 GIT binary patch literal 1620 zcmV-a2CMmrP)EOS?lS zqZ~DyNiN4%9N5i+$NGPM;m>1PbZS8k~BIoGB#d9?|=$w zK0dQGD~V??YDqk^F(Pw9H_j?3WlbrlEj{>s33E##$3`J?9w>=BHriDgb2crfCpWqt zFw8S0lQ%5HE+l>#CygOBa6(JBB`cm`GR;UJe`Z5JQ%_NMP~1x!WLZ?sa)9E9df9m@ z+o_}3z^dx5a{uSZ?7(^dtu%A3op!IFiMpVmeZ-RtPl<=~{sz~b)fkV`SG zGck=QJx*3WdOI zLfeg#SXxkdPcnxvH_v)5?0`yxD>95eKCF9Th)o@5DkyzTBTzXnP&h2JCq2|jRmVqN z&r&?xmY=^&WPUP6a&kR*T{gsH8jyK8t5+&kOisE-DYa%Lr#~i;V=S9EE2bDWWqwe; zKqE+OUyE*1qjE>HJyqFqYSmy7#Bnm-m^HLcN`+r!%~D9&Um3@FZozduZ(~mFu(5zm zRkVyZL2y{PC@70WOX#1a+-WJmK2*haVU%EN!fjW=VMx44LBfuNy@i6iU@_E;INNv& z-?FZvTTDF6XU=%VhI5bkO&JaNd~ZsbiI$97qEzlu>dJ97Ghl> z(tiI*$D?Ou*VY#bfeheQn8L%wYG{Jj1Y{S8*4};b_(50C!O&c=7g*#i#7vDDqbG3_ z8;F{XPsQ(a9@?v;%my@oi_65^NWp?DCf1yXI4^j3=>!Wa3JPk-D%-NNSewN}Pv+ra zWoIgcfz4objSU}ZHn111D+3#(bgd4a>J^~9Nrx+Ujy*NWJH zQz91+3oD~hoRtZYf#~j|d!(hksZDKNniM!1#Vicvqh?wuFcKM!eEETgTSA%|gk9~} zzy=sana0kJGiM|=C8~;R1p5a02I|Q$LGl7SkF~rQ4-e6qNmWyhU!9kiA83FSRGNjA zm4xifSFK%VuxEFvwid(-Bxh%L|DF0y+qO#Tub3-EjsfgG6+0yNZ9h=It74uFIRy%b zk6*E*Uikj{0zYsUH`C1_t`2+KvJoWR;1UyxM+}lB@lJ*_o^|(ZfrY9|XYJ znH(>OiU$Y_@(K!9$tsf-jqH4`-qLx?^Q5JH6NJc02ht1Nay$z>b37Nh2#^(x4(Zm} zOP4LlUT7OnRyyExPn)0VR_vCUUY1N=H1g%EyLfN(cHwm;FAzc5Sy(VISU3ruok=x7 zQ%(_BJ1c6*Doc@7JM&c+zW+Vp$Bi&8GE-vB)!zP|@a~B>C6dX?&Kx4|`hI==_VZug zEnXpVT#(9l{nNz1-@o+tzFlKamH{3`5g$KK`17GMM2##1n5Bzk6eA-e6}475l4Ssg zhds|Ns9#$iPsxcz!5Q zkg+7lFPOpM*^M+H$H3FYF~s6@a)N|ggQ*BpnBxJaHB5o4SLjb*U9$XAOoN)Ax=2#Y j>VN|)7(`jx1Q{59v1QBn&Na~j>SgeB^>bP0l+XkKsbnVU literal 0 HcmV?d00001 diff --git a/assets/images/flags/tuv.png b/assets/images/flags/tuv.png new file mode 100644 index 0000000000000000000000000000000000000000..15478f1914ef86b45bace3e01667bafe221e5617 GIT binary patch literal 1456 zcmV;h1yA~kP)T#*z4nd&Kb6%~Z8#eJ;McBC`7Dgq_ZQki#}!uXCy2d#uxO zso;65)qJecajD{+wxNEk&v&cXYp3drvAT+|ywk-~bE(~Rs@iO)>2IjykFvITtJZ3# z>vyWy?#(K2spHJUTx_T4=F2ydvazwdg_5(eYp3bi$4HvBrvK9php)o_(+khUS7@g1 zX{YSnS6AIsRE4j^fvw7ovACGDsF1R>|JDGTwWRXTBD}wHgRaNW#a3>p<+r_R|J4J^ z!eF<(d$zrO!og~YufX}y7I>@IskxA-xRP_K-SEyOptqZ(xR&C|JDjzn_s|=+y?m*; zk7uUv@y;fdv#q1Im*B}hqqmsv_4{{|$k|0j(HI!fVQ1i|uBNz@fv(Ez^ZGq!w1Te4 zv%G)*(hH>H66wq^!@+3n%`CdUcI3=5_Rk;M z$U(QgdgseDqPLlIs^9$47j3BL+Q&%x&>C;44ZFkG+u(HEVzqL{R){?ZYkx196P8i=sKth$T#&>h&vNyH!~!W0_M4i0qa4c`C& z00DGTPE!Ct=GbNc00SgRL_t(o!|m1ATiQ?<25@6MIe}CKTntBDfZ!GZaqm5G@BO&< zXsxSi_p-Zo?`3QEu;YJg24E1Op_hGlZ+Ol{e&2hNli=p&iYu;|(!}=<@Wf3y?C!zy zGB1LCeNIA$p@EP{886n`76=au_2Zx60C>m5?UmRA$nk-^h_f63+%sXXgc+d62gOGp z;sW3}S)!RKVFH-(i8yW_K-s`v2^uhu#~lDztAq_$N>IR>dAtLl|H+^s4PygTi47T# zM*{2S@pb`BQ*rCr=6!h*4e+c&0_+?=xPY)@Yhdgl05tI1!$E!>3;^Qky$50evjs=j zd;GMyR)P|QFsnDEzcSARSZ~3@-ToE;gb0Ft|CT->MMQwWteyfM9UsK^Tc-ipMo5C^ z0XMO5Cs~l%xUjqXC2ik9B9j=w3-s|_qksT^esf%mw+*mUGif-gsQsZQf@EK%xkB{3$0%!%Xl_GmuQNMdCz`*a$1Meq5uSdT1gQzU>R=( zm`Yr>J_M-<^NXNF*LV_O0L%D&0A1q5zL&ssAo9iHGL=HZ9A)Ti0Kl|8MHjkD&Y@fZ zAc6#HULZ;d6c$%^PU{HB5kTebIeBdYW6QJ6jE7{J_ zC~{I;G0qLrFLhq;!ssU;T`H@qdp9aTvoREM1g3PuC66+J9D?d$62jfZKk zu(70|li}do>gwvcx2^g4`QhT^>FVsUuA{rPpZ50n`1$*_v!=eeqW1Rns-uUkshW+3 zX_|*Ok%(jH=ik@Qs-BB7@9*%Nn2Ooez@v{g;M~aK-_)3gJF~2g>+9;?*uUlD*p`xh z=H%Yh&$yJ@k z+`7cMhTYe{y{uxup%$KuklE6)%BK;hk~PSv6Q+$GqlXO1rxWSI6u6lY>%$er zx{k)VgXH1Q%D;==*S)!!59Q#@!J{3gjULCR6QhO(Nd%?1Bx|Lh?e{wOPqA zz=EeFJEovCKCvN^M~8F+fd0;it4L~0NvqC@4Rh9zCe;8<*QU&3zJ#ju@*=+IdUrE9 zl3n0b)12EW(9_>5Ak>oTMS3i#+eZj?uMiTRBfNLUge*@ZB@zu_V|J_MUnU|UI!jby z$25L#6J|D&(?OTuqP6S9c8MK6v}ax)ISFaOhV>%i+qP^HKX_p0R2NlJ1JTg8ec|er zYnE@`zi;=}`2m))q#D3v%-*_m)siVQj~v}T-H)uKtk2!Cc=p_hlQvEc7GtFXwhuuDk}Xpu&q0UQ*7Q3FsD(%`7t nMibI#LK?~jjDk@x3V;9rg1U^iIWS=v00000NkvXXu0mjfiLp5y literal 0 HcmV?d00001 diff --git a/assets/images/flags/vat.png b/assets/images/flags/vat.png new file mode 100644 index 0000000000000000000000000000000000000000..d6c99cc1fa8f9c2e59007f7eea3372c248f0fb70 GIT binary patch literal 851 zcmV-Z1FZasP)eBe^-_gE@_~o(q z`T6ta!`P-#^y99?U|_(&!0oy*w0mvnwzSUB(DK^a&8kc8#ZlP2YvRq3%yV<-)3wgF zXt=el-=~-M=eF_Hf#lYJy6w0lxw4(*%#GN+ZvOuM-MypTo}Tpd_3zGY?#5Ql zcqHVqx8KW~*S&GnwtM5sfVjH5(yK${t*yVq#=EGd@bdKKv9$B>FVpp$jRThlK%et;;Kmd^yL5i{_V|f*T0SY{r|_7Ec5W_>&%$@@zcDxtKg%zwG!aUJ?d(G0(-kg-F6XU=&~g4lIBj3m7$E)PSLEfQ$rX25=hcaZ+M{k(-Z$fg-sE2(y{7 zL@_ZrvB(K>lVyOaF>6>%L2R%qtAY?&2AH}>#^o?GCj~j{OOj)NidA?*c2!$NZeobF z5=_f5P46o5*CxvV&-D7vnUg2=mn7TClVyN~7kgv(lD^_J zc1;no43IMS&aCSxO|j7s9FVF(zeEEfO(002ovPDHLkV1io13+?~_ literal 0 HcmV?d00001 diff --git a/assets/images/flags/vir.png b/assets/images/flags/vir.png new file mode 100644 index 0000000000000000000000000000000000000000..a57f924b23e263813f2978b22e903aaaca667425 GIT binary patch literal 1987 zcmV;!2R!(RP)Px&08mU+MF0Q*?d|RD?d^??jl#mh?Ck9A?d^qyg_M+( z?d|RD?d|RD?d|RD!^6Yt>+9|9?d|RD>FMd!)z!_-&F=2*^u|3GCTIYG$L{X!?(XiV z05jd4u=B<}^Ts~y?d{KXkdVXP^UG7bKV|Oi>9;Ui@8iGp%~|uwOYh^n^3P|v*5&Wg z+wj_$^UPQ7?C0+7>;!|y^U6^4;pfZV=K^3ZFq7)IRX>+b5@#MFezB({{Z&0O-% zWAW90@8!ty&}#A1dGXeUX<=IN)`w4})Tz(nX|dR`(&SaB)$rb>9FEIMqto!-s)W7V z!Pvy{*^}Vs;nLyhwAAD&l+HYtNM@{WtIn`fqFqCv(l?buLYhrKnM=-dkMQ5E@8rYt z%vzPj-?FvF@Z6&C+na*C-0$GCFPF~o+Lqtv>wvu6ioo4` z$#b^aC6dh{kj(Gmxs7{Ja<$p<*pKkVJ>2Epr_8Krt#R_fJ&?ebMVwG$s%*B?x?``` zY_E0ixZ7Q?yVm7) zx7tHSUnVhRy}-|AaG60(fFm(*tIy+&ep|fK(E@?Rqs!svuzFdgW4h4J^T$Hmze>SL zQ|!N#fT)bk-{|h?*>7iCN1RfP!QRNxpyTG+^V-@VAQl`|Z2{-uOkoet;*nvUz56 z(#m0L(|udaaEbB5J@y_-ApigX6m(KfQvmn$6C7ol92|-G)aoQ*Vd-HdC8$~st^fc8 zK}keGR9M4fU>F6X01lWrNCwOZ-NI}$`ta-c)T%j3~0V1mXq6navrV2H~!VjcU8RG85SxqtsMY1^} z$Oed~e~6fy1dttvDh-btxB(&- zt-8o6T3P~?gGASDShHRxLBwR|7Gwj|b$moH4FE>EA3{y*CNY=QYd4Ak!H(^__lWJ% zkcG?1AO$Om0U|zKa79V`#85!0C)@zHSP@(Xh$u%Y!j#QGGXU=V1RD`_1B|t_x<%yd zw6si$3(c(X8{nq_)9I_VRSs&v=83sLrlokFNJROZ3#!&&m9N80U4SqQOofG=fg~Oc zp!FsY1A4@_!M%`auvtW8ig?M*>*aIjiR6I8@`JROt(3NsytImulyh+XHLwBpB61=Q z=O6}nh%3MiuoDjv5h-&NiMny?%0f92O_17es0O?T`(NMA*d#9m(*U_?p(3Zm3q&Se zt(fPuK*Xj^T~;*U7m5KZfZFXJ%Y_;*5%?f93~)@;z0p`a!U2ph+K;cf`dri8VlEQX#n1m=FNg zuL-n2!vJVNAJGQDfKI%qsIDf^{!);B@hp-J5Ye~R1y|0SeGNLr4OWtF00Lw*+vQ;s z2RAT?1P)llxF~PpP~6C(xS7WwNQnV_)M)B5Fz~Vokk{B2Mr!V}lMI+gJ4vGe0RTEy VuPKmh{mcLW002ovPDHLkV1je&7L@=1 literal 0 HcmV?d00001 diff --git a/assets/images/flags/vut.png b/assets/images/flags/vut.png new file mode 100644 index 0000000000000000000000000000000000000000..3c4d6e42954adcf6210e991cba9fdba76a392691 GIT binary patch literal 1267 zcmWlZdo)xD9LIn6UNd**y7L|(gci+Y3ibKmPZu_`Z6BAy zzv-(IW}xE}xTh(FQHO9*FBLjvlBz)IdYrpE2SsxWTd&ZIRP0Ofc%LFjzrv zC}8o~2u=rA2c08ICKE^YZD;J?#-IiT4q~0hSJ`(g$~dg5rwZ|B1J$tNZ_2k8Q7fFlDv_vWmys`D^vu01D-Wc?E8c`y$M$kS zI4LOW&c9WGnTaI@qQt9Nn_MCNbarV?yHk9z+r6pK?LXM4c6g^|lpf5J#U;4EZgPp* zqb&~VIy91-ZQfyG8@(BO-V(nixU zxpFdpSDmrm`_6C7{X70>N<{i{`kEj3eUKRj(@@RirIw)P~pqZS+-7+R&Vy#$>*7L+&N#8hNl& zfvq}r9RqF*Hca|fco$XK2;W4@`>SkX#mQ}TiT^rAJ{87M+K@e!FR$>jGSJ=PDz)U; z%vq}xs2%_pnl6cI< zd3`&@bq}fP1cW_oo%#sV)8R=v%H43$)c;P2i>szYesk)hCc^8kZocT9565oIhf@>r bAk!rUX)G!uoFj`v|9>fk+(%aB5uW-V^z7Ux literal 0 HcmV?d00001 diff --git a/lib/cake_pay/cake_pay_api.dart b/lib/cake_pay/cake_pay_api.dart index f403ebc63..1f91b338d 100644 --- a/lib/cake_pay/cake_pay_api.dart +++ b/lib/cake_pay/cake_pay_api.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:cake_wallet/cake_pay/cake_pay_order.dart'; import 'package:cake_wallet/cake_pay/cake_pay_user_credentials.dart'; import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart'; +import 'package:cake_wallet/entities/country.dart'; import 'package:http/http.dart' as http; class CakePayApi { @@ -171,7 +172,7 @@ class CakePayApi { } /// Get Countries - Future> getCountries( + Future> getCountries( {required String CSRFToken, required String authorization}) async { final uri = Uri.https(baseCakePayUri, countriesPath); @@ -188,8 +189,11 @@ class CakePayApi { } final bodyJson = json.decode(response.body) as List; - - return bodyJson.map((country) => country['name'] as String).toList(); + return bodyJson + .map((country) => country['name'] as String) + .map((name) => Country.fromCakePayName(name)) + .whereType() + .toList(); } /// Get Vendors diff --git a/lib/cake_pay/cake_pay_service.dart b/lib/cake_pay/cake_pay_service.dart index be8e1d189..39f2ca77a 100644 --- a/lib/cake_pay/cake_pay_service.dart +++ b/lib/cake_pay/cake_pay_service.dart @@ -3,6 +3,7 @@ import 'package:cake_wallet/cake_pay/cake_pay_api.dart'; import 'package:cake_wallet/cake_pay/cake_pay_order.dart'; import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart'; import 'package:cake_wallet/core/secure_storage.dart'; +import 'package:cake_wallet/entities/country.dart'; class CakePayService { CakePayService(this.secureStorage, this.cakePayApi); @@ -23,7 +24,7 @@ class CakePayService { final CakePayApi cakePayApi; /// Get Available Countries - Future> getCountries() async => + Future> getCountries() async => await cakePayApi.getCountries(CSRFToken: CSRFToken, authorization: authorization); /// Get Vendors diff --git a/lib/di.dart b/lib/di.dart index 14fdbd56d..540a546fd 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -1271,7 +1271,8 @@ Future setup({ () => CakePayService(getIt.get(), getIt.get())); getIt.registerFactory( - () => CakePayCardsListViewModel(cakePayService: getIt.get())); + () => CakePayCardsListViewModel(cakePayService: getIt.get(), + settingsStore: getIt.get())); getIt.registerFactory(() => CakePayAuthViewModel(cakePayService: getIt.get())); diff --git a/lib/entities/country.dart b/lib/entities/country.dart new file mode 100644 index 000000000..63eee9a18 --- /dev/null +++ b/lib/entities/country.dart @@ -0,0 +1,386 @@ +import 'package:cw_core/enumerable_item.dart'; +import 'package:collection/collection.dart'; + +class Country extends EnumerableItem with Serializable { + const Country({required String code, required this.fullName, required this.countryCode}) + : super(title: fullName, raw: code); + + final String fullName; + final String countryCode; + + static List get all => _all.values.toList(); + + static const afghanistan = Country(code: 'afg', countryCode: 'AF', fullName: "Afghanistan"); + static const andorra = Country(code: 'and', countryCode: 'AD', fullName: "Andorra"); + static const angola = Country(code: 'ago', countryCode: 'AO', fullName: "Angola"); + static const anguilla = Country(code: 'aia', countryCode: 'AI', fullName: "Anguilla"); + static const antigua_and_barbuda = + Country(code: 'atg', countryCode: 'AG', fullName: "Antigua and Barbuda"); + static const are = Country(code: 'are', countryCode: 'AE', fullName: "United Arab Emirates"); + static const arg = Country(code: 'arg', countryCode: 'AR', fullName: "Argentina"); + static const arm = Country(code: 'arm', countryCode: 'AM', fullName: "Armenia"); + static const aruba = Country(code: 'abw', countryCode: 'AW', fullName: "Aruba"); + static const aus = Country(code: 'aus', countryCode: 'AU', fullName: "Australia"); + static const aut = Country(code: 'aut', countryCode: 'AT', fullName: "Austria"); + static const aze = Country(code: 'aze', countryCode: 'AZ', fullName: "Azerbaijan"); + static const belize = Country(code: 'blz', countryCode: 'BZ', fullName: "Belize"); + static const bfa = Country(code: 'bfa', countryCode: 'BF', fullName: "Burkina Faso"); + static const bel = Country(code: 'bel', countryCode: 'BE', fullName: "Belgium"); + static const bgd = Country(code: 'bgd', countryCode: 'BD', fullName: "Bangladesh"); + static const bhr = Country(code: 'bhr', countryCode: 'BH', fullName: "Bahrain"); + static const bhs = Country(code: 'bhs', countryCode: 'BS', fullName: "Bahamas"); + static const bhutan = Country(code: 'btn', countryCode: 'BT', fullName: "Bhutan"); + static const bol = Country(code: 'bol', countryCode: 'BO', fullName: "Bolivia"); + static const bra = Country(code: 'bra', countryCode: 'BR', fullName: "Brazil"); + static const brn = Country(code: 'brn', countryCode: 'BN', fullName: "Brunei"); + static const bwa = Country(code: 'bwa', countryCode: 'BW', fullName: "Botswana"); + static const cad = Country(code: 'cad', countryCode: 'CA', fullName: "Canada"); + static const che = Country(code: 'che', countryCode: 'CH', fullName: "Switzerland"); + static const chl = Country(code: 'chl', countryCode: 'CL', fullName: "Chile"); + static const chn = Country(code: 'chn', countryCode: 'CN', fullName: "China"); + static const col = Country(code: 'col', countryCode: 'CO', fullName: "Colombia"); + static const cri = Country(code: 'cri', countryCode: 'CR', fullName: "Costa Rica"); + static const cyp = Country(code: 'cyp', countryCode: 'CY', fullName: "Cyprus"); + static const czk = Country(code: 'czk', countryCode: 'CZ', fullName: "Czech Republic"); + static const deu = Country(code: 'deu', countryCode: 'DE', fullName: "Germany"); + static const dji = Country(code: 'dji', countryCode: 'DJ', fullName: "Djibouti"); + static const dnk = Country(code: 'dnk', countryCode: 'DK', fullName: "Denmark"); + static const dza = Country(code: 'dza', countryCode: 'DZ', fullName: "Algeria"); + static const ecu = Country(code: 'ecu', countryCode: 'EC', fullName: "Ecuador"); + static const egy = Country(code: 'egy', countryCode: 'EG', fullName: "Egypt"); + static const esp = Country(code: 'esp', countryCode: 'ES', fullName: "Spain"); + static const est = Country(code: 'est', countryCode: 'EE', fullName: "Estonia"); + static const eur = Country(code: 'eur', countryCode: 'EU', fullName: "European Union"); + static const fin = Country(code: 'fin', countryCode: 'FI', fullName: "Finland"); + static const fji = Country(code: 'fji', countryCode: 'FJ', fullName: "Fiji"); + static const flk = Country(code: 'flk', countryCode: 'FK', fullName: "Falkland Islands"); + static const fra = Country(code: 'fra', countryCode: 'FR', fullName: "France"); + static const fsm = Country(code: 'fsm', countryCode: 'FM', fullName: "Micronesia"); + static const gab = Country(code: 'gab', countryCode: 'GA', fullName: "Gabon"); + static const gbr = Country(code: 'gbr', countryCode: 'GB', fullName: "United Kingdom"); + static const geo = Country(code: 'geo', countryCode: 'GE', fullName: "Georgia"); + static const gha = Country(code: 'gha', countryCode: 'GH', fullName: "Ghana"); + static const grc = Country(code: 'grc', countryCode: 'GR', fullName: "Greece"); + static const grd = Country(code: 'grd', countryCode: 'GD', fullName: "Grenada"); + static const grl = Country(code: 'grl', countryCode: 'GL', fullName: "Greenland"); + static const gtm = Country(code: 'gtm', countryCode: 'GT', fullName: "Guatemala"); + static const guy = Country(code: 'guy', countryCode: 'GY', fullName: "Guyana"); + static const hkg = Country(code: 'hkg', countryCode: 'HK', fullName: "Hong Kong"); + static const hrv = Country(code: 'hrv', countryCode: 'HR', fullName: "Croatia"); + static const hun = Country(code: 'hun', countryCode: 'HU', fullName: "Hungary"); + static const idn = Country(code: 'idn', countryCode: 'ID', fullName: "Indonesia"); + static const ind = Country(code: 'ind', countryCode: 'IN', fullName: "India"); + static const irl = Country(code: 'irl', countryCode: 'IE', fullName: "Ireland"); + static const irn = Country(code: 'irn', countryCode: 'IR', fullName: "Iran"); + static const isl = Country(code: 'isl', countryCode: 'IS', fullName: "Iceland"); + static const isr = Country(code: 'isr', countryCode: 'IL', fullName: "Israel"); + static const ita = Country(code: 'ita', countryCode: 'IT', fullName: "Italy"); + static const jam = Country(code: 'jam', countryCode: 'JM', fullName: "Jamaica"); + static const jor = Country(code: 'jor', countryCode: 'JO', fullName: "Jordan"); + static const jpn = Country(code: 'jpn', countryCode: 'JP', fullName: "Japan"); + static const kaz = Country(code: 'kaz', countryCode: 'KZ', fullName: "Kazakhstan"); + static const ken = Country(code: 'ken', countryCode: 'KE', fullName: "Kenya"); + static const kir = Country(code: 'kir', countryCode: 'KI', fullName: "Kiribati"); + static const kor = Country(code: 'kor', countryCode: 'KR', fullName: "South Korea"); + static const kwt = Country(code: 'kwt', countryCode: 'KW', fullName: "Kuwait"); + static const lie = Country(code: 'lie', countryCode: 'LI', fullName: "Liechtenstein"); + static const lka = Country(code: 'lka', countryCode: 'LK', fullName: "Sri Lanka"); + static const ltu = Country(code: 'ltu', countryCode: 'LT', fullName: "Lithuania"); + static const lux = Country(code: 'lux', countryCode: 'LU', fullName: "Luxembourg"); + static const lva = Country(code: 'lva', countryCode: 'LV', fullName: "Latvia"); + static const mar = Country(code: 'mar', countryCode: 'MA', fullName: "Morocco"); + static const mex = Country(code: 'mex', countryCode: 'MX', fullName: "Mexico"); + static const mlt = Country(code: 'mlt', countryCode: 'MT', fullName: "Malta"); + static const mnp = Country(code: 'mnp', countryCode: 'MP', fullName: "Northern Mariana Islands"); + static const mtq = Country(code: 'mtq', countryCode: 'MQ', fullName: "Martinique"); + static const mys = Country(code: 'mys', countryCode: 'MY', fullName: "Malaysia"); + static const mwi = Country(code: 'mwi', countryCode: 'MW', fullName: "Malawi"); + static const nga = Country(code: 'nga', countryCode: 'NG', fullName: "Nigeria"); + static const niu = Country(code: 'niu', countryCode: 'NU', fullName: "Niue"); + static const nld = Country(code: 'nld', countryCode: 'NL', fullName: "Netherlands"); + static const nor = Country(code: 'nor', countryCode: 'NO', fullName: "Norway"); + static const nzl = Country(code: 'nzl', countryCode: 'NZ', fullName: "New Zealand"); + static const omn = Country(code: 'omn', countryCode: 'OM', fullName: "Oman"); + static const pak = Country(code: 'pak', countryCode: 'PK', fullName: "Pakistan"); + static const per = Country(code: 'per', countryCode: 'PE', fullName: "Peru"); + static const phl = Country(code: 'phl', countryCode: 'PH', fullName: "Philippines"); + static const pol = Country(code: 'pol', countryCode: 'PL', fullName: "Poland"); + static const pri = Country(code: 'pri', countryCode: 'PR', fullName: "Puerto Rico"); + static const prt = Country(code: 'prt', countryCode: 'PT', fullName: "Portugal"); + static const qat = Country(code: 'qat', countryCode: 'QA', fullName: "Qatar"); + static const rou = Country(code: 'rou', countryCode: 'RO', fullName: "Romania"); + static const rus = Country(code: 'rus', countryCode: 'RU', fullName: "Russia"); + static const saf = Country(code: 'saf', countryCode: 'ZA', fullName: "South Africa"); + static const sau = Country(code: 'sau', countryCode: 'SA', fullName: "Saudi Arabia"); + static const sgp = Country(code: 'sgp', countryCode: 'SG', fullName: "Singapore"); + static const slb = Country(code: 'slb', countryCode: 'SB', fullName: "Solomon Islands"); + static const svk = Country(code: 'svk', countryCode: 'SK', fullName: "Slovakia"); + static const svn = Country(code: 'svn', countryCode: 'SI', fullName: "Slovenia"); + static const swe = Country(code: 'swe', countryCode: 'SE', fullName: "Sweden"); + static const tha = Country(code: 'tha', countryCode: 'TH', fullName: "Thailand"); + static const tkm = Country(code: 'tkm', countryCode: 'TM', fullName: "Turkmenistan"); + static const ton = Country(code: 'ton', countryCode: 'TO', fullName: "Tonga"); + static const tur = Country(code: 'tur', countryCode: 'TR', fullName: "Turkey"); + static const tuv = Country(code: 'tuv', countryCode: 'TV', fullName: "Tuvalu"); + static const twn = Country(code: 'twn', countryCode: 'TW', fullName: "Taiwan"); + static const ukr = Country(code: 'ukr', countryCode: 'UA', fullName: "Ukraine"); + static const ury = Country(code: 'ury', countryCode: 'UY', fullName: "Uruguay"); + static const usa = Country(code: 'usa', countryCode: 'US', fullName: "USA"); + static const ven = Country(code: 'ven', countryCode: 'VE', fullName: "Venezuela"); + static const vnm = Country(code: 'vnm', countryCode: 'VN', fullName: "Vietnam"); + static const vut = Country(code: 'vut', countryCode: 'VU', fullName: "Vanuatu"); + static const btn = Country(code: 'btn', countryCode: 'BT', fullName: "Bhutan"); + static const bgr = Country(code: 'bgr', countryCode: 'BG', fullName: "Bulgaria"); + static const guf = Country(code: 'guf', countryCode: 'GF', fullName: "French Guiana"); + static const bes = Country(code: 'bes', countryCode: 'BQ', fullName: "Caribbean Netherlands"); + static const fro = Country(code: 'fro', countryCode: 'FO', fullName: "Faroe Islands"); + static const cuw = Country(code: 'cuw', countryCode: 'CW', fullName: "Curacao"); + static const msr = Country(code: 'msr', countryCode: 'MS', fullName: "Montserrat"); + static const cpv = Country(code: 'cpv', countryCode: 'CV', fullName: "Cabo Verde"); + static const nfk = Country(code: 'nfk', countryCode: 'NF', fullName: "Norfolk Island"); + static const bmu = Country(code: 'bmu', countryCode: 'BM', fullName: "Bermuda"); + static const vat = Country(code: 'vat', countryCode: 'VA', fullName: "Vatican City"); + static const aia = Country(code: 'aia', countryCode: 'AI', fullName: "Anguilla"); + static const gum = Country(code: 'gum', countryCode: 'GU', fullName: "Guam"); + static const myt = Country(code: 'myt', countryCode: 'YT', fullName: "Mayotte"); + static const mrt = Country(code: 'mrt', countryCode: 'MR', fullName: "Mauritania"); + static const ggy = Country(code: 'ggy', countryCode: 'GG', fullName: "Guernsey"); + static const cck = Country(code: 'cck', countryCode: 'CC', fullName: "Cocos (Keeling) Islands"); + static const blz = Country(code: 'blz', countryCode: 'BZ', fullName: "Belize"); + static const cxr = Country(code: 'cxr', countryCode: 'CX', fullName: "Christmas Island"); + static const mco = Country(code: 'mco', countryCode: 'MC', fullName: "Monaco"); + static const ner = Country(code: 'ner', countryCode: 'NE', fullName: "Niger"); + static const jey = Country(code: 'jey', countryCode: 'JE', fullName: "Jersey"); + static const asm = Country(code: 'asm', countryCode: 'AS', fullName: "American Samoa"); + static const gmb = Country(code: 'gmb', countryCode: 'GM', fullName: "Gambia"); + static const dma = Country(code: 'dma', countryCode: 'DM', fullName: "Dominica"); + static const glp = Country(code: 'glp', countryCode: 'GP', fullName: "Guadeloupe"); + static const ggi = Country(code: 'ggi', countryCode: 'GI', fullName: "Gibraltar"); + static const cmr = Country(code: 'cmr', countryCode: 'CM', fullName: "Cameroon"); + static const atg = Country(code: 'atg', countryCode: 'AG', fullName: "Antigua and Barbuda"); + static const slv = Country(code: 'slv', countryCode: 'SV', fullName: "El Salvador"); + static const pyf = Country(code: 'pyf', countryCode: 'PF', fullName: "French Polynesia"); + static const iot = + Country(code: 'iot', countryCode: 'IO', fullName: "British Indian Ocean Territory"); + static const vir = Country(code: 'vir', countryCode: 'VI', fullName: "Virgin Islands (U.S.)"); + static const abw = Country(code: 'abw', countryCode: 'AW', fullName: "Aruba"); + static const ago = Country(code: 'ago', countryCode: 'AO', fullName: "Angola"); + static const afg = Country(code: 'afg', countryCode: 'AF', fullName: "Afghanistan"); + static const lbn = Country(code: 'lbn', countryCode: 'LB', fullName: "Lebanon"); + static const hmd = + Country(code: 'hmd', countryCode: 'HM', fullName: "Heard Island and McDonald Islands"); + static const cok = Country(code: 'cok', countryCode: 'CK', fullName: "Cook Islands"); + static const bvt = Country(code: 'bvt', countryCode: 'BV', fullName: "Bouvet Island"); + static const atf = + Country(code: 'atf', countryCode: 'TF', fullName: "French Southern Territories"); + static const eth = Country(code: 'eth', countryCode: 'ET', fullName: "Ethiopia"); + static const plw = Country(code: 'plw', countryCode: 'PW', fullName: "Palau"); + static const ata = Country(code: 'ata', countryCode: 'AQ', fullName: "Antarctica"); + + static final _all = { + Country.afghanistan.raw: Country.afghanistan, + Country.andorra.raw: Country.andorra, + Country.angola.raw: Country.angola, + Country.anguilla.raw: Country.anguilla, + Country.antigua_and_barbuda.raw: Country.antigua_and_barbuda, + Country.are.raw: Country.are, + Country.arg.raw: Country.arg, + Country.arm.raw: Country.arm, + Country.aruba.raw: Country.aruba, + Country.aus.raw: Country.aus, + Country.aut.raw: Country.aut, + Country.aze.raw: Country.aze, + Country.belize.raw: Country.belize, + Country.bfa.raw: Country.bfa, + Country.bel.raw: Country.bel, + Country.bgd.raw: Country.bgd, + Country.bhr.raw: Country.bhr, + Country.bhs.raw: Country.bhs, + Country.bhutan.raw: Country.bhutan, + Country.bol.raw: Country.bol, + Country.bra.raw: Country.bra, + Country.brn.raw: Country.brn, + Country.bwa.raw: Country.bwa, + Country.cad.raw: Country.cad, + Country.che.raw: Country.che, + Country.chl.raw: Country.chl, + Country.chn.raw: Country.chn, + Country.col.raw: Country.col, + Country.cri.raw: Country.cri, + Country.cyp.raw: Country.cyp, + Country.czk.raw: Country.czk, + Country.deu.raw: Country.deu, + Country.dji.raw: Country.dji, + Country.dnk.raw: Country.dnk, + Country.dza.raw: Country.dza, + Country.ecu.raw: Country.ecu, + Country.egy.raw: Country.egy, + Country.esp.raw: Country.esp, + Country.est.raw: Country.est, + Country.eur.raw: Country.eur, + Country.fin.raw: Country.fin, + Country.fji.raw: Country.fji, + Country.flk.raw: Country.flk, + Country.fra.raw: Country.fra, + Country.fsm.raw: Country.fsm, + Country.gab.raw: Country.gab, + Country.gbr.raw: Country.gbr, + Country.geo.raw: Country.geo, + Country.gha.raw: Country.gha, + Country.grc.raw: Country.grc, + Country.grd.raw: Country.grd, + Country.grl.raw: Country.grl, + Country.gtm.raw: Country.gtm, + Country.guy.raw: Country.guy, + Country.hkg.raw: Country.hkg, + Country.hrv.raw: Country.hrv, + Country.hun.raw: Country.hun, + Country.idn.raw: Country.idn, + Country.ind.raw: Country.ind, + Country.irl.raw: Country.irl, + Country.irn.raw: Country.irn, + Country.isl.raw: Country.isl, + Country.isr.raw: Country.isr, + Country.ita.raw: Country.ita, + Country.jam.raw: Country.jam, + Country.jor.raw: Country.jor, + Country.jpn.raw: Country.jpn, + Country.kaz.raw: Country.kaz, + Country.ken.raw: Country.ken, + Country.kir.raw: Country.kir, + Country.kor.raw: Country.kor, + Country.kwt.raw: Country.kwt, + Country.lie.raw: Country.lie, + Country.lka.raw: Country.lka, + Country.ltu.raw: Country.ltu, + Country.lux.raw: Country.lux, + Country.lva.raw: Country.lva, + Country.mar.raw: Country.mar, + Country.mex.raw: Country.mex, + Country.mlt.raw: Country.mlt, + Country.mnp.raw: Country.mnp, + Country.mtq.raw: Country.mtq, + Country.mys.raw: Country.mys, + Country.mwi.raw: Country.mwi, + Country.nga.raw: Country.nga, + Country.niu.raw: Country.niu, + Country.nld.raw: Country.nld, + Country.nor.raw: Country.nor, + Country.nzl.raw: Country.nzl, + Country.omn.raw: Country.omn, + Country.pak.raw: Country.pak, + Country.per.raw: Country.per, + Country.phl.raw: Country.phl, + Country.pol.raw: Country.pol, + Country.pri.raw: Country.pri, + Country.prt.raw: Country.prt, + Country.qat.raw: Country.qat, + Country.rou.raw: Country.rou, + Country.rus.raw: Country.rus, + Country.saf.raw: Country.saf, + Country.sau.raw: Country.sau, + Country.sgp.raw: Country.sgp, + Country.slb.raw: Country.slb, + Country.svk.raw: Country.svk, + Country.svn.raw: Country.svn, + Country.swe.raw: Country.swe, + Country.tha.raw: Country.tha, + Country.tkm.raw: Country.tkm, + Country.ton.raw: Country.ton, + Country.tur.raw: Country.tur, + Country.tuv.raw: Country.tuv, + Country.twn.raw: Country.twn, + Country.ukr.raw: Country.ukr, + Country.ury.raw: Country.ury, + Country.usa.raw: Country.usa, + Country.ven.raw: Country.ven, + Country.vnm.raw: Country.vnm, + Country.vut.raw: Country.vut, + Country.btn.raw: Country.btn, + Country.bgr.raw: Country.bgr, + Country.guf.raw: Country.guf, + Country.bes.raw: Country.bes, + Country.fro.raw: Country.fro, + Country.cuw.raw: Country.cuw, + Country.msr.raw: Country.msr, + Country.cpv.raw: Country.cpv, + Country.nfk.raw: Country.nfk, + Country.bmu.raw: Country.bmu, + Country.vat.raw: Country.vat, + Country.aia.raw: Country.aia, + Country.gum.raw: Country.gum, + Country.myt.raw: Country.myt, + Country.mrt.raw: Country.mrt, + Country.ggy.raw: Country.ggy, + Country.cck.raw: Country.cck, + Country.blz.raw: Country.blz, + Country.cxr.raw: Country.cxr, + Country.mco.raw: Country.mco, + Country.ner.raw: Country.ner, + Country.jey.raw: Country.jey, + Country.asm.raw: Country.asm, + Country.gmb.raw: Country.gmb, + Country.dma.raw: Country.dma, + Country.glp.raw: Country.glp, + Country.ggi.raw: Country.ggi, + Country.cmr.raw: Country.cmr, + Country.atg.raw: Country.atg, + Country.slv.raw: Country.slv, + Country.pyf.raw: Country.pyf, + Country.iot.raw: Country.iot, + Country.vir.raw: Country.vir, + Country.abw.raw: Country.abw, + Country.ago.raw: Country.ago, + Country.afg.raw: Country.afg, + Country.lbn.raw: Country.lbn, + Country.hmd.raw: Country.hmd, + Country.cok.raw: Country.cok, + Country.bvt.raw: Country.bvt, + Country.atf.raw: Country.atf, + Country.eth.raw: Country.eth, + Country.plw.raw: Country.plw, + Country.ata.raw: Country.ata, + }; + + static final Map _cakePayNames = { + 'Slovak Republic': 'Slovakia', + 'Brunei Darussalam': 'Brunei', + 'Federated States of Micronesia': 'Micronesia', + 'Sri lanka': 'Sri Lanka', + 'UAE': 'United Arab Emirates', + 'UK': 'United Kingdom', + 'Curaçao': "Curacao", + }; + + static Country deserialize({required String raw}) => _all[raw]!; + + static final Map countryByName = { + for (var country in _all.values) country.fullName: country, + }; + + static Country? fromCakePayName(String name) { + final normalizedName = _cakePayNames[name] ?? name; + return countryByName[normalizedName]; + } + + static String getCakePayName(Country country) { + return _cakePayNames.entries + .firstWhere( + (entry) => entry.value == country.fullName, + orElse: () => MapEntry(country.fullName, country.fullName), + ) + .key; + } + + static Country? fromCode(String countryCode) { + return _all.values.firstWhereOrNull((element) => element.raw == countryCode.toLowerCase()); + } + + @override + bool operator ==(Object other) => other is Country && other.raw == raw; + + @override + int get hashCode => raw.hashCode ^ title.hashCode; + + String get iconPath => "assets/images/flags/$raw.png"; +} diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index 8e8bfb6ed..0bb526e5d 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -12,6 +12,7 @@ class PreferencesKey { static const currentBananoNodeIdKey = 'current_node_id_banano'; static const currentBananoPowNodeIdKey = 'current_node_id_banano_pow'; static const currentFiatCurrencyKey = 'current_fiat_currency'; + static const currentCakePayCountry = 'current_cake_pay_country'; static const currentBitcoinCashNodeIdKey = 'current_node_id_bch'; static const currentSolanaNodeIdKey = 'current_node_id_sol'; static const currentTronNodeIdKey = 'current_node_id_trx'; diff --git a/lib/src/screens/cake_pay/cards/cake_pay_cards_page.dart b/lib/src/screens/cake_pay/cards/cake_pay_cards_page.dart index 35a58ce0a..31eaa23ff 100644 --- a/lib/src/screens/cake_pay/cards/cake_pay_cards_page.dart +++ b/lib/src/screens/cake_pay/cards/cake_pay_cards_page.dart @@ -1,5 +1,6 @@ import 'package:cake_wallet/cake_pay/cake_pay_states.dart'; import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart'; +import 'package:cake_wallet/entities/country.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; @@ -8,6 +9,7 @@ import 'package:cake_wallet/src/screens/cake_pay/widgets/card_menu.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/filter_widget.dart'; import 'package:cake_wallet/src/widgets/cake_scrollbar.dart'; import 'package:cake_wallet/src/widgets/gradient_background.dart'; +import 'package:cake_wallet/src/widgets/picker.dart'; import 'package:cake_wallet/themes/extensions/balance_page_theme.dart'; import 'package:cake_wallet/themes/extensions/dashboard_page_theme.dart'; import 'package:cake_wallet/themes/extensions/exchange_page_theme.dart'; @@ -20,6 +22,7 @@ import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/cake_pay/cake_pay_cards_list_view_model.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:mobx/mobx.dart'; class CakePayCardsPage extends BasePage { CakePayCardsPage(this._cardsListViewModel) : searchFocusNode = FocusNode() { @@ -80,9 +83,25 @@ class CakePayCardsPage extends BasePage { @override Widget body(BuildContext context) { + + if (_cardsListViewModel.settingsStore.selectedCakePayCountry == null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + reaction((_) => _cardsListViewModel.shouldShowCountryPicker, (bool shouldShowCountryPicker) async { + if (shouldShowCountryPicker) { + _cardsListViewModel.storeInitialFilterStates(); + await showCountryPicker(context, _cardsListViewModel); + if (_cardsListViewModel.hasFiltersChanged) { + _cardsListViewModel.resetLoadingNextPageState(); + _cardsListViewModel.getVendors(); + } + } + }); + }); + } + final filterButton = Semantics( label: S.of(context).filter_by, - child: InkWell( + child: GestureDetector( onTap: () async { _cardsListViewModel.storeInitialFilterStates(); await showFilterWidget(context); @@ -92,50 +111,87 @@ class CakePayCardsPage extends BasePage { } }, child: Container( - width: 32, - padding: EdgeInsets.all(8), - decoration: BoxDecoration( - color: Theme.of(context).extension()!.syncedBackgroundColor, - border: Border.all( - color: Colors.white.withOpacity(0.2), + width: 32, + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).extension()!.syncedBackgroundColor, + border: Border.all( + color: Colors.transparent, + ), + borderRadius: BorderRadius.circular(10), ), - borderRadius: BorderRadius.circular(10), + child: Image.asset( + 'assets/images/filter.png', + color: Theme.of(context).extension()!.iconColor, + ))), + ); + final _countryPicker = Semantics( + label: S.of(context).filter_by, + child: GestureDetector( + onTap: () async { + _cardsListViewModel.storeInitialFilterStates(); + await showCountryPicker(context, _cardsListViewModel); + if (_cardsListViewModel.hasFiltersChanged) { + _cardsListViewModel.resetLoadingNextPageState(); + _cardsListViewModel.getVendors(); + } + }, + child: Container( + padding: EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: Theme.of(context).extension()!.syncedBackgroundColor, + border: Border.all(color: Colors.transparent), + borderRadius: BorderRadius.circular(10), + ), + child: Container( + margin: EdgeInsets.symmetric(vertical: 2), + child: Row( + children: [ + Image.asset( + _cardsListViewModel.selectedCountry.iconPath, + width: 24, + height: 24, + errorBuilder: (context, error, stackTrace) => Container( + width: 24, + height: 24, + ), + ), + SizedBox(width: 6), + Text( + _cardsListViewModel.selectedCountry.countryCode, + style: TextStyle( + color: Theme.of(context).extension()!.textColor, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ], ), - child: Image.asset( - 'assets/images/filter.png', - color: Theme.of(context).extension()!.iconColor, - ), - )), + ), + ), + ), ); return Padding( - padding: const EdgeInsets.all(14.0), - child: Column( - children: [ + padding: const EdgeInsets.all(14.0), + child: Column(children: [ Container( - padding: EdgeInsets.only(left: 2, right: 22), - height: 32, - child: Row( - children: [ + padding: EdgeInsets.only(left: 2, right: 22), + height: 32, + child: Row(children: [ Expanded( child: _SearchWidget( controller: _searchController, focusNode: searchFocusNode, )), - SizedBox(width: 10), - filterButton - ], - ), - ), + SizedBox(width: 5), + filterButton, + SizedBox(width: 5), + _countryPicker + ])), SizedBox(height: 8), - Expanded( - child: CakePayCardsPageBody( - cardsListViewModel: _cardsListViewModel, - ), - ), - ], - ), - ); + Expanded(child: CakePayCardsPageBody(cardsListViewModel: _cardsListViewModel)) + ])); } Future showFilterWidget(BuildContext context) async { @@ -148,6 +204,32 @@ class CakePayCardsPage extends BasePage { } } + +Future showCountryPicker(BuildContext context, CakePayCardsListViewModel cardsListViewModel) async { + await showPopUp( + context: context, + builder: (_) => Picker( + title: S.of(context).select_your_country, + items: cardsListViewModel.availableCountries, + images: cardsListViewModel.availableCountries + .map((e) => Image.asset( + e.iconPath, + errorBuilder: (context, error, stackTrace) => Container( + width: 58, + height: 58, + ), + )) + .toList(), + selectedAtIndex: cardsListViewModel.availableCountries + .indexOf(cardsListViewModel.selectedCountry), + onItemSelected: (Country country) => + cardsListViewModel.setSelectedCountry(country), + isSeparated: false, + hintText: S.of(context).search, + matchingCriteria: (Country country, String searchText) => + country.fullName.toLowerCase().contains(searchText.toLowerCase()))); +} + class CakePayCardsPageBody extends StatefulWidget { const CakePayCardsPageBody({ Key? key, @@ -304,15 +386,9 @@ class _SearchWidget extends StatelessWidget { alignLabelWithHint: true, floatingLabelBehavior: FloatingLabelBehavior.never, suffixIcon: searchIcon, - border: OutlineInputBorder( - borderSide: BorderSide( - color: Colors.white.withOpacity(0.2), - ), - borderRadius: BorderRadius.circular(10), - ), enabledBorder: OutlineInputBorder( borderSide: BorderSide( - color: Colors.white.withOpacity(0.2), + color: Colors.transparent, ), borderRadius: BorderRadius.circular(10), ), diff --git a/lib/src/screens/cake_pay/widgets/card_item.dart b/lib/src/screens/cake_pay/widgets/card_item.dart index ce804adc2..1234c0a1f 100644 --- a/lib/src/screens/cake_pay/widgets/card_item.dart +++ b/lib/src/screens/cake_pay/widgets/card_item.dart @@ -9,7 +9,7 @@ class CardItem extends StatelessWidget { required this.backgroundColor, required this.titleColor, required this.subtitleColor, - this.hideBorder = false, + this.hideBorder = true, this.discount = 0.0, this.isAmount = false, this.discountBackground, diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index c966420db..50d51d2ed 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -9,6 +9,7 @@ import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart'; import 'package:cake_wallet/entities/background_tasks.dart'; import 'package:cake_wallet/entities/balance_display_mode.dart'; import 'package:cake_wallet/entities/cake_2fa_preset_options.dart'; +import 'package:cake_wallet/entities/country.dart'; import 'package:cake_wallet/entities/exchange_api_mode.dart'; import 'package:cake_wallet/entities/fiat_api_mode.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; @@ -128,7 +129,8 @@ abstract class SettingsStoreBase with Store { TransactionPriority? initialLitecoinTransactionPriority, TransactionPriority? initialEthereumTransactionPriority, TransactionPriority? initialPolygonTransactionPriority, - TransactionPriority? initialBitcoinCashTransactionPriority}) + TransactionPriority? initialBitcoinCashTransactionPriority, + Country? initialCakePayCountry}) : nodes = ObservableMap.of(nodes), powNodes = ObservableMap.of(powNodes), _secureStorage = secureStorage, @@ -213,6 +215,10 @@ abstract class SettingsStoreBase with Store { priority[WalletType.bitcoinCash] = initialBitcoinCashTransactionPriority; } + if (initialCakePayCountry != null) { + selectedCakePayCountry = initialCakePayCountry; + } + initializeTrocadorProviderStates(); WalletType.values.forEach((walletType) { @@ -244,6 +250,15 @@ abstract class SettingsStoreBase with Store { (FiatCurrency fiatCurrency) => sharedPreferences.setString( PreferencesKey.currentFiatCurrencyKey, fiatCurrency.serialize())); + reaction( + (_) => selectedCakePayCountry, + (Country? country) { + if (country != null) { + sharedPreferences.setString( + PreferencesKey.currentCakePayCountry, country.raw); + } + }); + reaction( (_) => shouldShowYatPopup, (bool shouldShowYatPopup) => @@ -625,6 +640,9 @@ abstract class SettingsStoreBase with Store { @observable FiatCurrency fiatCurrency; + @observable + Country? selectedCakePayCountry; + @observable bool shouldShowYatPopup; @@ -880,6 +898,10 @@ abstract class SettingsStoreBase with Store { final backgroundTasks = getIt.get(); final currentFiatCurrency = FiatCurrency.deserialize( raw: sharedPreferences.getString(PreferencesKey.currentFiatCurrencyKey)!); + final savedCakePayCountryRaw = sharedPreferences.getString(PreferencesKey.currentCakePayCountry); + final currentCakePayCountry = savedCakePayCountryRaw != null + ? Country.deserialize(raw: savedCakePayCountryRaw) + : null; TransactionPriority? moneroTransactionPriority = monero?.deserializeMoneroTransactionPriority( raw: sharedPreferences.getInt(PreferencesKey.moneroTransactionPriority)!); @@ -1225,6 +1247,7 @@ abstract class SettingsStoreBase with Store { deviceName: deviceName, isBitcoinBuyEnabled: isBitcoinBuyEnabled, initialFiatCurrency: currentFiatCurrency, + initialCakePayCountry: currentCakePayCountry, initialBalanceDisplayMode: currentBalanceDisplayMode, initialSaveRecipientAddress: shouldSaveRecipientAddress, initialAutoGenerateSubaddressStatus: autoGenerateSubaddressStatus, diff --git a/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart b/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart index d0483596e..8585da9da 100644 --- a/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart +++ b/lib/view_model/cake_pay/cake_pay_cards_list_view_model.dart @@ -1,8 +1,10 @@ import 'package:cake_wallet/cake_pay/cake_pay_service.dart'; import 'package:cake_wallet/cake_pay/cake_pay_states.dart'; import 'package:cake_wallet/cake_pay/cake_pay_vendor.dart'; +import 'package:cake_wallet/entities/country.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/view_model/dashboard/dropdown_filter_item.dart'; +import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/view_model/dashboard/filter_item.dart'; import 'package:mobx/mobx.dart'; @@ -13,11 +15,11 @@ class CakePayCardsListViewModel = CakePayCardsListViewModelBase with _$CakePayCa abstract class CakePayCardsListViewModelBase with Store { CakePayCardsListViewModelBase({ required this.cakePayService, + required this.settingsStore, }) : cardState = CakePayCardsStateNoCards(), cakePayVendors = [], availableCountries = [], page = 1, - selectedCountry = 'USA', displayPrepaidCards = true, displayGiftCards = true, displayDenominationsCards = true, @@ -30,13 +32,20 @@ abstract class CakePayCardsListViewModelBase with Store { initialization(); } + static Country _getInitialCountry(FiatCurrency fiatCurrency) { + if (fiatCurrency.countryCode == 'eur') { + return Country.deu; + } + return Country.fromCode(fiatCurrency.countryCode) ?? Country.usa; + } + void initialization() async { await getCountries(); - selectedCountry = availableCountries.first; getVendors(); } final CakePayService cakePayService; + final SettingsStore settingsStore; List CakePayVendorList; @@ -61,28 +70,15 @@ abstract class CakePayCardsListViewModelBase with Store { caption: S.current.custom_value, onChanged: toggleCustomValueCards), ], - S.current.countries: [ - DropdownFilterItem( - items: availableCountries, - caption: '', - selectedItem: selectedCountry, - onItemSelected: (String value) => setSelectedCountry(value), - ), - ] }; String searchString; - int page; - late String _initialSelectedCountry; - + late Country _initialSelectedCountry; late bool _initialDisplayPrepaidCards; - late bool _initialDisplayGiftCards; - late bool _initialDisplayDenominationsCards; - late bool _initialDisplayCustomValueCards; @observable @@ -107,7 +103,7 @@ abstract class CakePayCardsListViewModelBase with Store { List cakePayVendors; @observable - List availableCountries; + List availableCountries; @observable bool displayPrepaidCards; @@ -121,15 +117,22 @@ abstract class CakePayCardsListViewModelBase with Store { @observable bool displayCustomValueCards; - @observable - String selectedCountry; + @computed + Country get selectedCountry => + settingsStore.selectedCakePayCountry ?? _getInitialCountry(settingsStore.fiatCurrency); + + @computed + bool get shouldShowCountryPicker => settingsStore.selectedCakePayCountry == null && availableCountries.isNotEmpty; + + + bool get hasFiltersChanged { + return selectedCountry != _initialSelectedCountry || + displayPrepaidCards != _initialDisplayPrepaidCards || + displayGiftCards != _initialDisplayGiftCards || + displayDenominationsCards != _initialDisplayDenominationsCards || + displayCustomValueCards != _initialDisplayCustomValueCards; + } - bool get hasFiltersChanged => - selectedCountry != _initialSelectedCountry || - displayPrepaidCards != _initialDisplayPrepaidCards || - displayGiftCards != _initialDisplayGiftCards || - displayDenominationsCards != _initialDisplayDenominationsCards || - displayCustomValueCards != _initialDisplayCustomValueCards; Future getCountries() async { availableCountries = await cakePayService.getCountries(); @@ -143,7 +146,7 @@ abstract class CakePayCardsListViewModelBase with Store { vendorsState = CakePayVendorLoadingState(); searchString = text ?? ''; var newVendors = await cakePayService.getVendors( - country: selectedCountry, + country: Country.getCakePayName(selectedCountry), page: currentPage ?? page, search: searchString, giftCards: displayGiftCards, @@ -152,20 +155,20 @@ abstract class CakePayCardsListViewModelBase with Store { onDemand: displayDenominationsCards); cakePayVendors = CakePayVendorList = newVendors; - vendorsState = CakePayVendorLoadedState(); } @action Future fetchNextPage() async { - if (vendorsState is CakePayVendorLoadingState || !hasMoreDataToFetch || isLoadingNextPage) + if (vendorsState is CakePayVendorLoadingState || !hasMoreDataToFetch || isLoadingNextPage) { return; + } isLoadingNextPage = true; page++; try { var newVendors = await cakePayService.getVendors( - country: selectedCountry, + country: Country.getCakePayName(selectedCountry), page: page, search: searchString, giftCards: displayGiftCards, @@ -201,7 +204,7 @@ abstract class CakePayCardsListViewModelBase with Store { } @action - void setSelectedCountry(String country) => selectedCountry = country; + void setSelectedCountry(Country country) => settingsStore.selectedCakePayCountry = country; @action void togglePrepaidCards() => displayPrepaidCards = !displayPrepaidCards; diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index 0db2031dd..b8c933a2e 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "حدد مزود شراء أعلاه. يمكنك تخطي هذه الشاشة عن طريق تعيين مزود شراء الافتراضي في إعدادات التطبيق.", "select_destination": ".ﻲﻃﺎﻴﺘﺣﻻﺍ ﺦﺴﻨﻟﺍ ﻒﻠﻣ ﺔﻬﺟﻭ ﺪﻳﺪﺤﺗ ءﺎﺟﺮﻟﺍ", "select_sell_provider_notice": ".ﻖﻴﺒﻄﺘﻟﺍ ﺕﺍﺩﺍﺪﻋﺇ ﻲﻓ ﻚﺑ ﺹﺎﺨﻟﺍ ﻲﺿﺍﺮﺘﻓﻻﺍ ﻊﻴﺒﻟﺍ ﺩﻭﺰﻣ ﻦﻴﻴﻌﺗ ﻖﻳﺮﻃ ﻦﻋ ﺔﺷﺎﺸﻟﺍ ﻩﺬﻫ ﻲﻄﺨﺗ", + "select_your_country": "الرجاء تحديد بلدك", "sell": "بيع", "sell_alert_content": ".ﺎﻬﻴﻟﺇ ﻞﻳﺪﺒﺘﻟﺍ ﻭﺃ Litecoin ﻭﺃ Ethereum ﻭﺃ Bitcoin ﺔﻈﻔﺤﻣ ءﺎﺸﻧﺇ ﻰﺟﺮﻳ .Litecoin ﻭ", "sell_monero_com_alert_content": "بيع Monero غير مدعوم حتى الآن", diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index 7e78dd69a..2fd644d01 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "Изберете доставчик на покупка по -горе. Можете да пропуснете този екран, като зададете вашия доставчик по подразбиране по подразбиране в настройките на приложението.", "select_destination": "Моля, изберете дестинация за архивния файл.", "select_sell_provider_notice": "Изберете доставчик на продажба по-горе. Можете да пропуснете този екран, като зададете своя доставчик на продажба по подразбиране в настройките на приложението.", + "select_your_country": "Моля, изберете вашата страна", "sell": "Продаване", "sell_alert_content": "В момента поддържаме само продажбата на Bitcoin, Ethereum и Litecoin. Моля, създайте или превключете към своя портфейл Bitcoin, Ethereum или Litecoin.", "sell_monero_com_alert_content": "Продажбата на Monero все още не се поддържа", diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index f17466a9f..8f3b5bf28 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "Vyberte výše uvedeného poskytovatele nákupu. Tuto obrazovku můžete přeskočit nastavením výchozího poskytovatele nákupu v nastavení aplikace.", "select_destination": "Vyberte cíl pro záložní soubor.", "select_sell_provider_notice": "Výše vyberte poskytovatele prodeje. Tuto obrazovku můžete přeskočit nastavením výchozího poskytovatele prodeje v nastavení aplikace.", + "select_your_country": "Vyberte prosím svou zemi", "sell": "Prodat", "sell_alert_content": "V současné době podporujeme pouze prodej bitcoinů, etherea a litecoinů. Vytvořte nebo přepněte na svou bitcoinovou, ethereum nebo litecoinovou peněženku.", "sell_monero_com_alert_content": "Prodej Monero zatím není podporován", diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index cb0a59574..77a531d1b 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -635,6 +635,7 @@ "select_buy_provider_notice": "Wählen Sie oben einen Anbieter kaufen. Sie können diese Seite überspringen, indem Sie Ihren Standard-Kaufanbieter in den App-Einstellungen festlegen.", "select_destination": "Bitte wählen Sie das Ziel für die Sicherungsdatei aus.", "select_sell_provider_notice": "Wählen Sie oben einen Verkaufsanbieter aus. Sie können diesen Bildschirm überspringen, indem Sie in den App-Einstellungen Ihren Standard-Verkaufsanbieter festlegen.", + "select_your_country": "Bitte wählen Sie Ihr Land aus", "sell": "Verkaufen", "sell_alert_content": "Wir unterstützen derzeit nur den Verkauf von Bitcoin, Ethereum und Litecoin. Bitte erstellen Sie Ihr Bitcoin-, Ethereum- oder Litecoin-Wallet oder wechseln Sie zu diesem.", "sell_monero_com_alert_content": "Der Verkauf von Monero wird noch nicht unterstützt", diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 40be8fd08..4fe375ff9 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -636,6 +636,7 @@ "select_buy_provider_notice": "Select a buy provider above. You can skip this screen by setting your default buy provider in app settings.", "select_destination": "Please select destination for the backup file.", "select_sell_provider_notice": "Select a sell provider above. You can skip this screen by setting your default sell provider in app settings.", + "select_your_country": "Please select your country", "sell": "Sell", "sell_alert_content": "We currently only support the sale of Bitcoin, Ethereum and Litecoin. Please create or switch to your Bitcoin, Ethereum or Litecoin wallet.", "sell_monero_com_alert_content": "Selling Monero is not supported yet", diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 5a693cf32..31498df2d 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -635,6 +635,7 @@ "select_buy_provider_notice": "Selecciona un proveedor de compra arriba. Puede omitir esta pantalla configurando su proveedor de compra predeterminado en la configuración de la aplicación.", "select_destination": "Selecciona el destino del archivo de copia de seguridad.", "select_sell_provider_notice": "Selecciona un proveedor de venta arriba. Puede omitir esta pantalla configurando su proveedor de venta predeterminado en la configuración de la aplicación.", + "select_your_country": "Seleccione su país", "sell": "Vender", "sell_alert_content": "Actualmente solo admitimos la venta de Bitcoin, Ethereum y Litecoin. Cree o cambie a su billetera Bitcoin, Ethereum o Litecoin.", "sell_monero_com_alert_content": "Aún no se admite la venta de Monero", diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index 1865e41f2..f96e9e304 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "Sélectionnez un fournisseur d'achat ci-dessus. Vous pouvez ignorer cet écran en définissant votre fournisseur d'achat par défaut dans les paramètres de l'application.", "select_destination": "Veuillez sélectionner la destination du fichier de sauvegarde.", "select_sell_provider_notice": "Sélectionnez un fournisseur de vente ci-dessus. Vous pouvez ignorer cet écran en définissant votre fournisseur de vente par défaut dans les paramètres de l'application.", + "select_your_country": "Veuillez sélectionner votre pays", "sell": "Vendre", "sell_alert_content": "Nous ne prenons actuellement en charge que la vente de Bitcoin, Ethereum et Litecoin. Veuillez créer ou basculer vers votre portefeuille Bitcoin, Ethereum ou Litecoin.", "sell_monero_com_alert_content": "La vente de Monero n'est pas encore prise en charge", diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index 52cf14128..5fc64e0b3 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -636,6 +636,7 @@ "select_buy_provider_notice": "Zaɓi mai ba da kyauta a sama. Zaka iya tsallake wannan allon ta hanyar saita mai ba da isasshen busasshen mai ba da isasshen busasshiyar saiti.", "select_destination": "Da fatan za a zaɓi wurin da za a yi wa madadin fayil ɗin.", "select_sell_provider_notice": "Zaɓi mai bada siyarwa a sama. Kuna iya tsallake wannan allon ta saita mai bada siyar da ku a cikin saitunan app.", + "select_your_country": "Da fatan za a zabi ƙasarku", "sell": "sayar", "sell_alert_content": "A halin yanzu muna tallafawa kawai siyar da Bitcoin, Ethereum da Litecoin. Da fatan za a ƙirƙiri ko canza zuwa walat ɗin ku na Bitcoin, Ethereum ko Litecoin.", "sell_monero_com_alert_content": "Selling Monero bai sami ƙarshen mai bukatar samun ba", diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index 64f112a42..fb3b78900 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -636,6 +636,7 @@ "select_buy_provider_notice": "ऊपर एक खरीद प्रदाता का चयन करें। आप इस स्क्रीन को ऐप सेटिंग्स में अपना डिफ़ॉल्ट बाय प्रदाता सेट करके छोड़ सकते हैं।", "select_destination": "कृपया बैकअप फ़ाइल के लिए गंतव्य का चयन करें।", "select_sell_provider_notice": "ऊपर एक विक्रय प्रदाता का चयन करें। आप ऐप सेटिंग में अपना डिफ़ॉल्ट विक्रय प्रदाता सेट करके इस स्क्रीन को छोड़ सकते हैं।", + "select_your_country": "कृपया अपने देश का चयन करें", "sell": "बेचना", "sell_alert_content": "हम वर्तमान में केवल बिटकॉइन, एथेरियम और लाइटकॉइन की बिक्री का समर्थन करते हैं। कृपया अपना बिटकॉइन, एथेरियम या लाइटकॉइन वॉलेट बनाएं या उसमें स्विच करें।", "sell_monero_com_alert_content": "मोनेरो बेचना अभी तक समर्थित नहीं है", diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index 4bbf4f141..230aa955b 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "Odaberite gornji davatelj kupnje. Ovaj zaslon možete preskočiti postavljanjem zadanog davatelja usluga kupnje u postavkama aplikacija.", "select_destination": "Odaberite odredište za datoteku sigurnosne kopije.", "select_sell_provider_notice": "Gore odaberite pružatelja usluga prodaje. Ovaj zaslon možete preskočiti postavljanjem zadanog pružatelja usluga prodaje u postavkama aplikacije.", + "select_your_country": "Odaberite svoju zemlju", "sell": "Prodavati", "sell_alert_content": "Trenutno podržavamo samo prodaju Bitcoina, Ethereuma i Litecoina. Izradite ili prijeđite na svoj Bitcoin, Ethereum ili Litecoin novčanik.", "sell_monero_com_alert_content": "Prodaja Monera još nije podržana", diff --git a/res/values/strings_hy.arb b/res/values/strings_hy.arb index cbaf3a548..63a986103 100644 --- a/res/values/strings_hy.arb +++ b/res/values/strings_hy.arb @@ -626,6 +626,7 @@ "select_buy_provider_notice": "Ընտրեք գնման մատակարարը վերևում։ Դուք կարող եք բաց թողնել այս էկրանը ձեր լռելայն գնման մատակարարը հավելվածի կարգավորումներում սահմանելով", "select_destination": "Խնդրում ենք ընտրել կրկնօրինակ ֆայլի նպատակակետը", "select_sell_provider_notice": "Ընտրեք վաճառքի մատակարարը վերևում։ Դուք կարող եք բաց թողնել այս էկրանը ձեր լռելայն վաճառքի մատակարարը հավելվածի կարգավորումներում սահմանելով", + "select_your_country": "Խնդրում ենք ընտրել ձեր երկիրը", "sell": "Ծախել", "sell_alert_content": "Մենք ներկայումս պաշտպանում ենք միայն Bitcoin, Ethereum և Litecoin վաճառքը։ Խնդրում ենք ստեղծել կամ միացնել ձեր Bitcoin, Ethereum կամ Litecoin դրամապանակը", "sell_monero_com_alert_content": "Monero-ի վաճառքը դեռ չի պաշտպանվում", diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index 273087eec..3fa3a958f 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -637,6 +637,7 @@ "select_buy_provider_notice": "Pilih penyedia beli di atas. Anda dapat melewatkan layar ini dengan mengatur penyedia pembelian default Anda di pengaturan aplikasi.", "select_destination": "Silakan pilih tujuan untuk file cadangan.", "select_sell_provider_notice": "Pilih penyedia jual di atas. Anda dapat melewati layar ini dengan mengatur penyedia penjualan default Anda di pengaturan aplikasi.", + "select_your_country": "Pilih negara Anda", "sell": "Jual", "sell_alert_content": "Saat ini kami hanya mendukung penjualan Bitcoin, Ethereum, dan Litecoin. Harap buat atau alihkan ke dompet Bitcoin, Ethereum, atau Litecoin Anda.", "sell_monero_com_alert_content": "Menjual Monero belum didukung", diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index 917315aa6..aab18d434 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -636,6 +636,7 @@ "select_buy_provider_notice": "Seleziona un fornitore di acquisto sopra. È possibile saltare questa schermata impostando il provider di acquisto predefinito nelle impostazioni dell'app.", "select_destination": "Seleziona la destinazione per il file di backup.", "select_sell_provider_notice": "Seleziona un fornitore di vendita sopra. Puoi saltare questa schermata impostando il tuo fornitore di vendita predefinito nelle impostazioni dell'app.", + "select_your_country": "Seleziona il tuo paese", "sell": "Vendere", "sell_alert_content": "Al momento supportiamo solo la vendita di Bitcoin, Ethereum e Litecoin. Crea o passa al tuo portafoglio Bitcoin, Ethereum o Litecoin.", "sell_monero_com_alert_content": "La vendita di Monero non è ancora supportata", diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index ae3e32ea8..f1fd0acd3 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -635,6 +635,7 @@ "select_buy_provider_notice": "上記の購入プロバイダーを選択してください。デフォルトの購入プロバイダーをアプリ設定で設定して、この画面をスキップできます。", "select_destination": "バックアップファイルの保存先を選択してください。", "select_sell_provider_notice": "上記の販売プロバイダーを選択してください。アプリ設定でデフォルトの販売プロバイダーを設定することで、この画面をスキップできます。", + "select_your_country": "あなたの国を選択してください", "sell": "売る", "sell_alert_content": "現在、ビットコイン、イーサリアム、ライトコインの販売のみをサポートしています。ビットコイン、イーサリアム、またはライトコインのウォレットを作成するか、これらのウォレットに切り替えてください。", "sell_monero_com_alert_content": "モネロの販売はまだサポートされていません", diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index 2dc1a2dfc..7e3467cf2 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -635,6 +635,7 @@ "select_buy_provider_notice": "위의 구매 제공자를 선택하십시오. 앱 설정에서 기본 구매 제공자를 설정 하여이 화면을 건너 뛸 수 있습니다.", "select_destination": "백업 파일의 대상을 선택하십시오.", "select_sell_provider_notice": "위에서 판매 공급자를 선택하세요. 앱 설정에서 기본 판매 공급자를 설정하면 이 화면을 건너뛸 수 있습니다.", + "select_your_country": "국가를 선택하십시오", "sell": "팔다", "sell_alert_content": "현재 Bitcoin, Ethereum 및 Litecoin의 판매만 지원합니다. Bitcoin, Ethereum 또는 Litecoin 지갑을 생성하거나 전환하십시오.", "sell_monero_com_alert_content": "지원되지 않습니다.", diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index f42af6863..10c13cfef 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "အပေါ်ကဝယ်သူတစ် ဦး ကိုရွေးချယ်ပါ။ သင်၏ default 0 ယ်သူအား app settings တွင် setting လုပ်ခြင်းဖြင့်ဤ screen ကိုကျော်သွားနိုင်သည်။", "select_destination": "အရန်ဖိုင်အတွက် ဦးတည်ရာကို ရွေးပါ။", "select_sell_provider_notice": "အထက်ဖော်ပြပါ အရောင်းဝန်ဆောင်မှုပေးသူကို ရွေးပါ။ အက်ပ်ဆက်တင်များတွင် သင်၏မူလရောင်းချပေးသူကို သတ်မှတ်ခြင်းဖြင့် ဤစခရင်ကို ကျော်နိုင်သည်။", + "select_your_country": "ကျေးဇူးပြု. သင့်နိုင်ငံကိုရွေးချယ်ပါ", "sell": "ရောင်း", "sell_alert_content": "ကျွန်ုပ်တို့သည် လက်ရှိတွင် Bitcoin၊ Ethereum နှင့် Litecoin ရောင်းချခြင်းကိုသာ ပံ့ပိုးပေးပါသည်။ သင်၏ Bitcoin၊ Ethereum သို့မဟုတ် Litecoin ပိုက်ဆံအိတ်ကို ဖန်တီးပါ သို့မဟုတ် ပြောင်းပါ။", "sell_monero_com_alert_content": "Monero ရောင်းချခြင်းကို မပံ့ပိုးရသေးပါ။", diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index 73e3df6b6..9552f2439 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "Selecteer hierboven een koopprovider. U kunt dit scherm overslaan door uw standaard kopenprovider in te stellen in app -instellingen.", "select_destination": "Selecteer de bestemming voor het back-upbestand.", "select_sell_provider_notice": "Selecteer hierboven een verkoopaanbieder. U kunt dit scherm overslaan door uw standaardverkoopprovider in te stellen in de app-instellingen.", + "select_your_country": "Selecteer uw land", "sell": "Verkopen", "sell_alert_content": "We ondersteunen momenteel alleen de verkoop van Bitcoin, Ethereum en Litecoin. Maak of schakel over naar uw Bitcoin-, Ethereum- of Litecoin-portemonnee.", "sell_monero_com_alert_content": "Het verkopen van Monero wordt nog niet ondersteund", diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index aaa82a3bf..f3c3e4810 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "Wybierz powyższe dostawcę zakupu. Możesz pominąć ten ekran, ustawiając domyślnego dostawcę zakupu w ustawieniach aplikacji.", "select_destination": "Wybierz miejsce docelowe dla pliku kopii zapasowej.", "select_sell_provider_notice": "Wybierz dostawcę sprzedaży powyżej. Możesz pominąć ten ekran, ustawiając domyślnego dostawcę sprzedaży w ustawieniach aplikacji.", + "select_your_country": "Wybierz swój kraj", "sell": "Sprzedać", "sell_alert_content": "Obecnie obsługujemy tylko sprzedaż Bitcoin, Ethereum i Litecoin. Utwórz lub przełącz się na swój portfel Bitcoin, Ethereum lub Litecoin.", "sell_monero_com_alert_content": "Sprzedaż Monero nie jest jeszcze obsługiwana", diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index 194307a3d..410e8cb1c 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -636,6 +636,7 @@ "select_buy_provider_notice": "Selecione um provedor de compra acima. Você pode pular esta tela definindo seu provedor de compra padrão nas configurações de aplicativos.", "select_destination": "Selecione o destino para o arquivo de backup.", "select_sell_provider_notice": "Selecione um fornecedor de venda acima. Você pode pular esta tela definindo seu provedor de venda padrão nas configurações do aplicativo.", + "select_your_country": "Selecione seu país", "sell": "Vender", "sell_alert_content": "Atualmente, oferecemos suporte apenas à venda de Bitcoin, Ethereum e Litecoin. Crie ou troque para sua carteira Bitcoin, Ethereum ou Litecoin.", "sell_monero_com_alert_content": "A venda de Monero ainda não é suportada", diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index f42c28c15..a8ee5a309 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -635,6 +635,7 @@ "select_buy_provider_notice": "Выберите поставщика покупки выше. Вы можете пропустить этот экран, установив поставщика покупки по умолчанию в настройках приложения.", "select_destination": "Пожалуйста, выберите место для файла резервной копии.", "select_sell_provider_notice": "Выберите поставщика услуг продажи выше. Вы можете пропустить этот экран, установив поставщика услуг продаж по умолчанию в настройках приложения.", + "select_your_country": "Пожалуйста, выберите свою страну", "sell": "Продавать", "sell_alert_content": "В настоящее время мы поддерживаем только продажу биткойнов, эфириума и лайткойна. Пожалуйста, создайте или переключитесь на свой кошелек Bitcoin, Ethereum или Litecoin.", "sell_monero_com_alert_content": "Продажа Monero пока не поддерживается", diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index 9d8fb0af2..5102e150c 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "เลือกผู้ให้บริการซื้อด้านบน คุณสามารถข้ามหน้าจอนี้ได้โดยการตั้งค่าผู้ให้บริการซื้อเริ่มต้นในการตั้งค่าแอป", "select_destination": "โปรดเลือกปลายทางสำหรับไฟล์สำรอง", "select_sell_provider_notice": "เลือกผู้ให้บริการการขายด้านบน คุณสามารถข้ามหน้าจอนี้ได้โดยการตั้งค่าผู้ให้บริการการขายเริ่มต้นในการตั้งค่าแอป", + "select_your_country": "กรุณาเลือกประเทศของคุณ", "sell": "ขาย", "sell_alert_content": "ขณะนี้เรารองรับการขาย Bitcoin, Ethereum และ Litecoin เท่านั้น โปรดสร้างหรือเปลี่ยนเป็นกระเป๋าเงิน Bitcoin, Ethereum หรือ Litecoin ของคุณ", "sell_monero_com_alert_content": "ยังไม่รองรับการขาย Monero", diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index ca35e7997..eb78c8d3c 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "Pumili ng provider ng pagbili sa itaas. Maaari mong laktawan ang screen na ito sa pamamagitan ng pagtatakda ng iyong default na provider ng pagbili sa mga setting ng app.", "select_destination": "Mangyaring piliin ang patutunguhan para sa backup na file.", "select_sell_provider_notice": "Pumili ng provider ng nagbebenta sa itaas. Maaari mong laktawan ang screen na ito sa pamamagitan ng pagtatakda ng iyong default na sell provider sa mga setting ng app.", + "select_your_country": "Mangyaring piliin ang iyong bansa", "sell": "Ibenta", "sell_alert_content": "Kasalukuyan lamang naming sinusuportahan ang pagbebenta ng Bitcoin, Ethereum at Litecoin. Mangyaring lumikha o lumipat sa iyong Bitcoin, Ethereum o Litecoin wallet.", "sell_monero_com_alert_content": "Ang pagbebenta ng Monero ay hindi pa suportado", diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index 1fee66369..0b4700397 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "Yukarıda bir satın alma sağlayıcısı seçin. App ayarlarında varsayılan satın alma sağlayıcınızı ayarlayarak bu ekranı atlayabilirsiniz.", "select_destination": "Lütfen yedekleme dosyası için hedef seçin.", "select_sell_provider_notice": "Yukarıdan bir satış sağlayıcısı seçin. Uygulama ayarlarında varsayılan satış sağlayıcınızı ayarlayarak bu ekranı atlayabilirsiniz.", + "select_your_country": "Lütfen ülkenizi seçin", "sell": "Satış", "sell_alert_content": "Şu anda yalnızca Bitcoin, Ethereum ve Litecoin satışını destekliyoruz. Lütfen Bitcoin, Ethereum veya Litecoin cüzdanınızı oluşturun veya cüzdanınıza geçin.", "sell_monero_com_alert_content": "Monero satışı henüz desteklenmiyor", diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index 5d9a56c1e..c9afde7be 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -635,6 +635,7 @@ "select_buy_provider_notice": "Виберіть постачальника купівлі вище. Ви можете пропустити цей екран, встановивши свого постачальника купівлі за замовчуванням у налаштуваннях додатків.", "select_destination": "Виберіть місце призначення для файлу резервної копії.", "select_sell_provider_notice": "Виберіть вище постачальника послуг продажу. Ви можете пропустити цей екран, встановивши постачальника послуг продажу за умовчанням у налаштуваннях програми.", + "select_your_country": "Будь ласка, виберіть свою країну", "sell": "Продати", "sell_alert_content": "Наразі ми підтримуємо лише продаж Bitcoin, Ethereum і Litecoin. Створіть або перейдіть на свій гаманець Bitcoin, Ethereum або Litecoin.", "sell_monero_com_alert_content": "Продаж Monero ще не підтримується", diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 106f00f96..50c6f1889 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -636,6 +636,7 @@ "select_buy_provider_notice": "اوپر خریدنے والا خریدنے والا منتخب کریں۔ آپ ایپ کی ترتیبات میں اپنے پہلے سے طے شدہ خریدنے والے کو ترتیب دے کر اس اسکرین کو چھوڑ سکتے ہیں۔", "select_destination": "۔ﮟﯾﺮﮐ ﺏﺎﺨﺘﻧﺍ ﺎﮐ ﻝﺰﻨﻣ ﮯﯿﻟ ﮯﮐ ﻞﺋﺎﻓ ﭖﺍ ﮏﯿﺑ ﻡﺮﮐ ﮦﺍﺮﺑ", "select_sell_provider_notice": "۔ﮟﯿﮨ ﮯﺘﮑﺳ ﮌﻮﮭﭼ ﻮﮐ ﻦﯾﺮﮑﺳﺍ ﺱﺍ ﺮﮐ ﮮﺩ ﺐﯿﺗﺮﺗ ﻮﮐ ﮦﺪﻨﻨﮐ ﻢﮨﺍﺮﻓ ﻞﯿﺳ ﭧﻟﺎﻔﯾﮈ ﮯﻨﭘﺍ ﮟﯿﻣ ﺕﺎﺒ", + "select_your_country": "براہ کرم اپنے ملک کو منتخب کریں", "sell": "بیچنا", "sell_alert_content": "۔ﮟﯾﺮﮐ ﭻﺋﻮﺳ ﺮﭘ ﺱﺍ ﺎﯾ ﮟﯿﺋﺎﻨﺑ ﭧﯿﻟﺍﻭ Litecoin ﺎﯾ Bitcoin، Ethereum ﺎﻨﭘﺍ ﻡﺮﮐ ﮦﺍﺮﺑ ۔", "sell_monero_com_alert_content": "Monero فروخت کرنا ابھی تک تعاون یافتہ نہیں ہے۔", diff --git a/res/values/strings_vi.arb b/res/values/strings_vi.arb index a95f32aaa..e3ffbef3e 100644 --- a/res/values/strings_vi.arb +++ b/res/values/strings_vi.arb @@ -627,6 +627,7 @@ "select_buy_provider_notice": "Chọn nhà cung cấp mua ở trên. Bạn có thể bỏ qua màn hình này bằng cách thiết lập nhà cung cấp mua mặc định trong cài đặt ứng dụng.", "select_destination": "Vui lòng chọn đích cho tệp sao lưu.", "select_sell_provider_notice": "Chọn nhà cung cấp bán ở trên. Bạn có thể bỏ qua màn hình này bằng cách thiết lập nhà cung cấp bán mặc định trong cài đặt ứng dụng.", + "select_your_country": "Vui lòng chọn quốc gia của bạn", "sell": "Bán", "sell_alert_content": "Hiện tại chúng tôi chỉ hỗ trợ bán Bitcoin, Ethereum và Litecoin. Vui lòng tạo hoặc chuyển sang ví Bitcoin, Ethereum hoặc Litecoin của bạn.", "sell_monero_com_alert_content": "Bán Monero chưa được hỗ trợ", diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index 1a1583da5..19dc9f110 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -635,6 +635,7 @@ "select_buy_provider_notice": "Yan olupese Ra loke. O le skii iboju yii nipa ṣiṣeto olupese rẹ ni awọn eto App.", "select_destination": "Jọwọ yan ibi ti o nlo fun faili afẹyinti.", "select_sell_provider_notice": "Yan olupese ti o ta loke. O le foju iboju yii nipa tito olupese iṣẹ tita aiyipada rẹ ni awọn eto app.", + "select_your_country": "Jọwọ yan orilẹ-ede rẹ", "sell": "Tà", "sell_alert_content": "Lọwọlọwọ a ṣe atilẹyin tita Bitcoin, Ethereum ati Litecoin nikan. Jọwọ ṣẹda tabi yipada si Bitcoin, Ethereum tabi apamọwọ Litecoin rẹ.", "sell_monero_com_alert_content": "Kọ ju lọwọ Monero ko ṣe ni ibamu", diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index 79fe8faa5..009a31d86 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -634,6 +634,7 @@ "select_buy_provider_notice": "在上面选择买入提供商。您可以通过在应用程序设置中设置默认的购买提供商来跳过此屏幕。", "select_destination": "请选择备份文件的目的地。", "select_sell_provider_notice": "选择上面的销售提供商。您可以通过在应用程序设置中设置默认销售提供商来跳过此屏幕。", + "select_your_country": "请选择你的国家", "sell": "卖", "sell_alert_content": "我们目前仅支持比特币、以太坊和莱特币的销售。请创建或切换到您的比特币、以太坊或莱特币钱包。", "sell_monero_com_alert_content": "尚不支持出售门罗币", From 0fcfd76afd75b91665e25804859497aa46a051aa Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Thu, 7 Nov 2024 15:46:08 +0100 Subject: [PATCH 6/8] Automated Integration Tests Flows (#1686) * feat: Integration tests setup and tests for Disclaimer, Welcome and Setup Pin Code pages * feat: Integration test flow from start to restoring a wallet successfully done * test: Dashboard view test and linking to flow * feat: Testing the Exchange flow section, selecting sending and receiving currencies * test: Successfully create an exchange section * feat: Implement flow up to sending section * test: Complete Exchange flow * fix dependency issue * test: Final cleanups * feat: Add CI to run automated integration tests withan android emulator * feat: Adjust Automated integration test CI to run on ubuntu 20.04-a * fix: Move integration test CI into PR test build CI * ci: Add automated test ci which is a streamlined replica of pr test build ci * ci: Re-add step to access branch name * ci: Add KVM * ci: Add filepath to trigger the test run from * ci: Add required key * ci: Add required key * ci: Add missing secret key * ci: Add missing secret key * ci: Add nano secrets to workflow * ci: Switch step to free space on runner * ci: Remove timeout from workflow * ci: Confirm impact that removing copy_monero_deps would have on entire workflow time * ci: Update CI and temporarily remove cache related to emulator * ci: Remove dynamic java version * ci: Temporarily switch CI * ci: Switch to 11.x jdk * ci: Temporarily switch CI * ci: Revert ubuntu version * ci: Add more api levels * ci: Add more target options * ci: Settled on stable emulator matrix options * ci: Add more target options * ci: Modify flow * ci: Streamline api levels to 28 and 29 * ci: One more trial * ci: Switch to flutter drive * ci: Reduce options * ci: Remove haven from test * ci: Check for solana in list * ci: Adjust amounts and currencies for exchange flow * ci: Set write response on failure to true * ci: Split ci to funds and non funds related tests * test: Test for Send flow scenario and minor restructuring for test folders and files * chore: cleanup * ci: Pause CI for now * ci: Pause CI for now * ci: Pause CI for now * test: Restore wallets integration automated tests * Fix: Add keys back to currency amount textfield widget * fix: Switch variable name * fix: remove automation for now * tests: Automated tests for Create wallets flow * tests: Further optimize common flows * tests: Add missing await for call * tests: Confirm Seeds Display Properly WIP * tests: Confirm Seeds Display Correctly Automated Tests * fix: Add missing pubspec params for bitcoin and bitcoin_cash * feat: Automated Tests for Transaction History Flow * fix: Add missing pubspec parameter * feat: Automated Integration Tests for Transaction History flow * test: Updating send page robot and also syncing branch with main * test: Modifying tests to flow with wallet grouping implementation * fix: Issue with transaction history test * fix: Modifications to the PR and add automated confirmation for checking that all wallet types are restored or created correctly * test: Attempting automation for testing * fix: Issue from merge conflicts * test: Remove automation of test in this PR --------- Co-authored-by: OmarHatem --- cw_bitcoin/pubspec.yaml | 1 + cw_bitcoin_cash/pubspec.yaml | 1 + cw_core/pubspec.yaml | 1 + .../components/common_test_cases.dart | 121 ++++- .../components/common_test_constants.dart | 2 +- .../components/common_test_flows.dart | 291 +++++++++++- integration_test/funds_related_tests.dart | 9 +- .../robots/dashboard_menu_widget_robot.dart | 39 ++ .../robots/dashboard_page_robot.dart | 62 ++- .../robots/exchange_page_robot.dart | 4 +- .../robots/new_wallet_page_robot.dart | 35 ++ .../robots/pin_code_widget_robot.dart | 11 +- .../robots/pre_seed_page_robot.dart | 20 + .../restore_from_seed_or_key_robot.dart | 17 + .../robots/restore_options_page_robot.dart | 6 +- .../security_and_backup_page_robot.dart | 24 + integration_test/robots/send_page_robot.dart | 9 +- .../robots/transactions_page_robot.dart | 286 +++++++++++ .../wallet_group_description_page_robot.dart | 32 ++ .../robots/wallet_keys_robot.dart | 162 +++++++ .../robots/wallet_list_page_robot.dart | 27 ++ .../robots/wallet_seed_page_robot.dart | 57 +++ .../test_suites/confirm_seeds_flow_test.dart | 107 +++++ .../test_suites/create_wallet_flow_test.dart | 57 +++ .../test_suites/exchange_flow_test.dart | 61 +-- ...estore_wallet_through_seeds_flow_test.dart | 63 +++ .../test_suites/send_flow_test.dart | 37 +- .../transaction_history_flow_test.dart | 70 +++ ios/Podfile.lock | 12 +- lib/src/screens/InfoPage.dart | 13 +- lib/src/screens/dashboard/dashboard_page.dart | 2 +- .../dashboard/pages/nft_details_page.dart | 5 +- .../dashboard/pages/transactions_page.dart | 214 +++++---- .../widgets/anonpay_transaction_row.dart | 1 + .../dashboard/widgets/date_section_raw.dart | 41 +- .../screens/dashboard/widgets/header_row.dart | 3 +- .../dashboard/widgets/menu_widget.dart | 3 +- .../screens/dashboard/widgets/order_row.dart | 78 ++- .../screens/dashboard/widgets/trade_row.dart | 1 + .../dashboard/widgets/transaction_raw.dart | 103 ++-- .../screens/new_wallet/new_wallet_page.dart | 18 +- .../wallet_group_description_page.dart | 2 + .../screens/restore/restore_options_page.dart | 38 +- .../wallet_restore_from_seed_form.dart | 2 + lib/src/screens/seed/pre_seed_page.dart | 10 +- lib/src/screens/seed/wallet_seed_page.dart | 95 ++-- .../settings/security_backup_page.dart | 50 +- .../widgets/settings_cell_with_arrow.dart | 7 +- .../widgets/settings_picker_cell.dart | 24 +- .../widgets/settings_switcher_cell.dart | 3 +- .../setup_2fa/setup_2fa_info_page.dart | 8 +- .../blockexplorer_list_item.dart | 9 +- .../rbf_details_list_fee_picker_item.dart | 26 +- .../standart_list_item.dart | 7 +- .../textfield_list_item.dart | 10 +- .../transaction_details_list_item.dart | 7 +- .../transaction_details_page.dart | 60 +-- .../transaction_expandable_list_item.dart | 9 +- .../widgets/textfield_list_row.dart | 14 +- .../screens/wallet_keys/wallet_keys_page.dart | 36 +- .../screens/wallet_list/wallet_list_page.dart | 2 + lib/src/widgets/blockchain_height_widget.dart | 3 + lib/src/widgets/dashboard_card_widget.dart | 1 + lib/src/widgets/option_tile.dart | 18 +- lib/src/widgets/picker.dart | 3 + lib/src/widgets/seed_language_selector.dart | 11 +- lib/src/widgets/setting_actions.dart | 12 + lib/src/widgets/standard_list.dart | 8 +- .../anonpay/anonpay_transactions_store.dart | 6 +- lib/store/dashboard/orders_store.dart | 24 +- lib/store/dashboard/trades_store.dart | 19 +- lib/utils/date_formatter.dart | 26 +- lib/utils/image_utill.dart | 4 + .../dashboard/action_list_item.dart | 5 + .../anonpay_transaction_list_item.dart | 2 +- .../dashboard/dashboard_view_model.dart | 106 +++-- .../dashboard/date_section_item.dart | 2 +- .../dashboard/formatted_item_list.dart | 15 +- lib/view_model/dashboard/order_list_item.dart | 4 +- lib/view_model/dashboard/trade_list_item.dart | 6 +- .../dashboard/transaction_list_item.dart | 8 +- .../transaction_details_view_model.dart | 449 ++++++++++++++---- lib/view_model/wallet_keys_view_model.dart | 181 ++++--- tool/utils/secret_key.dart | 23 + 84 files changed, 2716 insertions(+), 745 deletions(-) create mode 100644 integration_test/robots/dashboard_menu_widget_robot.dart create mode 100644 integration_test/robots/new_wallet_page_robot.dart create mode 100644 integration_test/robots/pre_seed_page_robot.dart create mode 100644 integration_test/robots/security_and_backup_page_robot.dart create mode 100644 integration_test/robots/transactions_page_robot.dart create mode 100644 integration_test/robots/wallet_group_description_page_robot.dart create mode 100644 integration_test/robots/wallet_keys_robot.dart create mode 100644 integration_test/robots/wallet_list_page_robot.dart create mode 100644 integration_test/robots/wallet_seed_page_robot.dart create mode 100644 integration_test/test_suites/confirm_seeds_flow_test.dart create mode 100644 integration_test/test_suites/create_wallet_flow_test.dart create mode 100644 integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart create mode 100644 integration_test/test_suites/transaction_history_flow_test.dart diff --git a/cw_bitcoin/pubspec.yaml b/cw_bitcoin/pubspec.yaml index 7a1576a98..bff6104ac 100644 --- a/cw_bitcoin/pubspec.yaml +++ b/cw_bitcoin/pubspec.yaml @@ -73,6 +73,7 @@ dependency_overrides: # The following section is specific to Flutter. flutter: + uses-material-design: true # To add assets to your package, add an assets section, like this: # assets: diff --git a/cw_bitcoin_cash/pubspec.yaml b/cw_bitcoin_cash/pubspec.yaml index 14ff407e2..9a5c4f14f 100644 --- a/cw_bitcoin_cash/pubspec.yaml +++ b/cw_bitcoin_cash/pubspec.yaml @@ -49,6 +49,7 @@ dependency_overrides: # The following section is specific to Flutter packages. flutter: + uses-material-design: true # To add assets to your package, add an assets section, like this: # assets: diff --git a/cw_core/pubspec.yaml b/cw_core/pubspec.yaml index 070779caa..6e32c2ba1 100644 --- a/cw_core/pubspec.yaml +++ b/cw_core/pubspec.yaml @@ -47,6 +47,7 @@ dependency_overrides: # The following section is specific to Flutter. flutter: + uses-material-design: true # To add assets to your package, add an assets section, like this: # assets: diff --git a/integration_test/components/common_test_cases.dart b/integration_test/components/common_test_cases.dart index 2e2991804..83bbb0449 100644 --- a/integration_test/components/common_test_cases.dart +++ b/integration_test/components/common_test_cases.dart @@ -10,10 +10,16 @@ class CommonTestCases { hasType(); } - Future tapItemByKey(String key, {bool shouldPumpAndSettle = true}) async { + Future tapItemByKey( + String key, { + bool shouldPumpAndSettle = true, + int pumpDuration = 100, + }) async { final widget = find.byKey(ValueKey(key)); await tester.tap(widget); - shouldPumpAndSettle ? await tester.pumpAndSettle() : await tester.pump(); + shouldPumpAndSettle + ? await tester.pumpAndSettle(Duration(milliseconds: pumpDuration)) + : await tester.pump(); } Future tapItemByFinder(Finder finder, {bool shouldPumpAndSettle = true}) async { @@ -31,6 +37,11 @@ class CommonTestCases { expect(typeWidget, findsOneWidget); } + bool isKeyPresent(String key) { + final typeWidget = find.byKey(ValueKey(key)); + return typeWidget.tryEvaluate(); + } + void hasValueKey(String key) { final typeWidget = find.byKey(ValueKey(key)); expect(typeWidget, findsOneWidget); @@ -53,33 +64,86 @@ class CommonTestCases { await tester.pumpAndSettle(); } - Future scrollUntilVisible(String childKey, String parentScrollableKey, - {double delta = 300}) async { - final scrollableWidget = find.descendant( - of: find.byKey(Key(parentScrollableKey)), + Future dragUntilVisible(String childKey, String parentKey) async { + await tester.pumpAndSettle(); + + final itemFinder = find.byKey(ValueKey(childKey)); + final listFinder = find.byKey(ValueKey(parentKey)); + + // Check if the widget is already in the widget tree + if (tester.any(itemFinder)) { + // Widget is already built and in the tree + tester.printToConsole('Child is already present'); + return; + } + + // We can adjust this as needed + final maxScrolls = 200; + + int scrolls = 0; + bool found = false; + + // We start by scrolling down + bool scrollDown = true; + + // Flag to check if we've already reversed direction + bool reversedDirection = false; + + // Find the Scrollable associated with the Parent Ad + final scrollableFinder = find.descendant( + of: listFinder, matching: find.byType(Scrollable), ); - final isAlreadyVisibile = isWidgetVisible(find.byKey(ValueKey(childKey))); - - if (isAlreadyVisibile) return; - - await tester.scrollUntilVisible( - find.byKey(ValueKey(childKey)), - delta, - scrollable: scrollableWidget, + // Ensure that the Scrollable is found + expect( + scrollableFinder, + findsOneWidget, + reason: 'Scrollable descendant of the Parent Widget not found.', ); - } - bool isWidgetVisible(Finder finder) { - try { - final Element element = finder.evaluate().single; - final RenderBox renderBox = element.renderObject as RenderBox; - return renderBox.paintBounds - .shift(renderBox.localToGlobal(Offset.zero)) - .overlaps(tester.binding.renderViews.first.paintBounds); - } catch (e) { - return false; + // Get the initial scroll position + final scrollableState = tester.state(scrollableFinder); + double previousScrollPosition = scrollableState.position.pixels; + + while (!found && scrolls < maxScrolls) { + tester.printToConsole('Scrolling ${scrollDown ? 'down' : 'up'}, attempt $scrolls'); + + // Perform the drag in the current direction + await tester.drag( + scrollableFinder, + scrollDown ? const Offset(0, -100) : const Offset(0, 100), + ); + await tester.pumpAndSettle(); + scrolls++; + + // Update the scroll position after the drag + final currentScrollPosition = scrollableState.position.pixels; + + if (currentScrollPosition == previousScrollPosition) { + // Cannot scroll further in this direction + if (reversedDirection) { + // We've already tried both directions + tester.printToConsole('Cannot scroll further in both directions. Widget not found.'); + break; + } else { + // Reverse the scroll direction + scrollDown = !scrollDown; + reversedDirection = true; + tester.printToConsole('Reached the end, reversing direction'); + } + } else { + // Continue scrolling in the current direction + previousScrollPosition = currentScrollPosition; + } + + // Check if the widget is now in the widget tree + found = tester.any(itemFinder); + } + + if (!found) { + tester.printToConsole('Widget not found after scrolling in both directions.'); + return; } } @@ -91,6 +155,15 @@ class CommonTestCases { await tester.pumpAndSettle(); } + void findWidgetViaDescendant({ + required FinderBase of, + required FinderBase matching, + }) { + final textWidget = find.descendant(of: of, matching: matching); + + expect(textWidget, findsOneWidget); + } + Future defaultSleepTime({int seconds = 2}) async => await Future.delayed(Duration(seconds: seconds)); } diff --git a/integration_test/components/common_test_constants.dart b/integration_test/components/common_test_constants.dart index d8381973e..302d52189 100644 --- a/integration_test/components/common_test_constants.dart +++ b/integration_test/components/common_test_constants.dart @@ -9,5 +9,5 @@ class CommonTestConstants { static final String testWalletName = 'Integrated Testing Wallet'; static final CryptoCurrency testReceiveCurrency = CryptoCurrency.sol; static final CryptoCurrency testDepositCurrency = CryptoCurrency.usdtSol; - static final String testWalletAddress = 'An2Y2fsUYKfYvN1zF89GAqR1e6GUMBg3qA83Y5ZWDf8L'; + static final String testWalletAddress = '5v9gTW1yWPffhnbNKuvtL2frevAf4HpBMw8oYnfqUjhm'; } diff --git a/integration_test/components/common_test_flows.dart b/integration_test/components/common_test_flows.dart index 807509de9..82f714da0 100644 --- a/integration_test/components/common_test_flows.dart +++ b/integration_test/components/common_test_flows.dart @@ -1,41 +1,65 @@ -import 'package:flutter/foundation.dart'; +import 'package:cake_wallet/entities/seed_type.dart'; +import 'package:cake_wallet/reactions/bip39_wallet_utils.dart'; +import 'package:cake_wallet/wallet_types.g.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; import 'package:cake_wallet/main.dart' as app; +import '../robots/dashboard_page_robot.dart'; import '../robots/disclaimer_page_robot.dart'; +import '../robots/new_wallet_page_robot.dart'; import '../robots/new_wallet_type_page_robot.dart'; +import '../robots/pre_seed_page_robot.dart'; import '../robots/restore_from_seed_or_key_robot.dart'; import '../robots/restore_options_page_robot.dart'; import '../robots/setup_pin_code_robot.dart'; +import '../robots/wallet_group_description_page_robot.dart'; +import '../robots/wallet_list_page_robot.dart'; +import '../robots/wallet_seed_page_robot.dart'; import '../robots/welcome_page_robot.dart'; import 'common_test_cases.dart'; +import 'package:cake_wallet/.secrets.g.dart' as secrets; + import 'common_test_constants.dart'; class CommonTestFlows { CommonTestFlows(this._tester) : _commonTestCases = CommonTestCases(_tester), _welcomePageRobot = WelcomePageRobot(_tester), + _preSeedPageRobot = PreSeedPageRobot(_tester), _setupPinCodeRobot = SetupPinCodeRobot(_tester), + _dashboardPageRobot = DashboardPageRobot(_tester), + _newWalletPageRobot = NewWalletPageRobot(_tester), _disclaimerPageRobot = DisclaimerPageRobot(_tester), + _walletSeedPageRobot = WalletSeedPageRobot(_tester), + _walletListPageRobot = WalletListPageRobot(_tester), _newWalletTypePageRobot = NewWalletTypePageRobot(_tester), _restoreOptionsPageRobot = RestoreOptionsPageRobot(_tester), - _restoreFromSeedOrKeysPageRobot = RestoreFromSeedOrKeysPageRobot(_tester); + _restoreFromSeedOrKeysPageRobot = RestoreFromSeedOrKeysPageRobot(_tester), + _walletGroupDescriptionPageRobot = WalletGroupDescriptionPageRobot(_tester); final WidgetTester _tester; final CommonTestCases _commonTestCases; final WelcomePageRobot _welcomePageRobot; + final PreSeedPageRobot _preSeedPageRobot; final SetupPinCodeRobot _setupPinCodeRobot; + final NewWalletPageRobot _newWalletPageRobot; + final DashboardPageRobot _dashboardPageRobot; final DisclaimerPageRobot _disclaimerPageRobot; + final WalletSeedPageRobot _walletSeedPageRobot; + final WalletListPageRobot _walletListPageRobot; final NewWalletTypePageRobot _newWalletTypePageRobot; final RestoreOptionsPageRobot _restoreOptionsPageRobot; final RestoreFromSeedOrKeysPageRobot _restoreFromSeedOrKeysPageRobot; + final WalletGroupDescriptionPageRobot _walletGroupDescriptionPageRobot; + //* ========== Handles flow to start the app afresh and accept disclaimer ============= Future startAppFlow(Key key) async { await app.main(topLevelKey: ValueKey('send_flow_test_app_key')); - + await _tester.pumpAndSettle(); // --------- Disclaimer Page ------------ @@ -46,56 +70,275 @@ class CommonTestFlows { await _disclaimerPageRobot.tapAcceptButton(); } - Future restoreWalletThroughSeedsFlow() async { - await _welcomeToRestoreFromSeedsPath(); - await _restoreFromSeeds(); + //* ========== Handles flow from welcome to creating a new wallet =============== + Future welcomePageToCreateNewWalletFlow( + WalletType walletTypeToCreate, + List walletPin, + ) async { + await _welcomeToCreateWalletPath(walletTypeToCreate, walletPin); + + await _generateNewWalletDetails(); + + await _confirmPreSeedInfo(); + + await _confirmWalletDetails(); } - Future restoreWalletThroughKeysFlow() async { - await _welcomeToRestoreFromSeedsPath(); + //* ========== Handles flow from welcome to restoring wallet from seeds =============== + Future welcomePageToRestoreWalletThroughSeedsFlow( + WalletType walletTypeToRestore, + String walletSeed, + List walletPin, + ) async { + await _welcomeToRestoreFromSeedsOrKeysPath(walletTypeToRestore, walletPin); + await _restoreFromSeeds(walletTypeToRestore, walletSeed); + } + + //* ========== Handles flow from welcome to restoring wallet from keys =============== + Future welcomePageToRestoreWalletThroughKeysFlow( + WalletType walletTypeToRestore, + List walletPin, + ) async { + await _welcomeToRestoreFromSeedsOrKeysPath(walletTypeToRestore, walletPin); await _restoreFromKeys(); } - Future _welcomeToRestoreFromSeedsPath() async { - // --------- Welcome Page --------------- - await _welcomePageRobot.navigateToRestoreWalletPage(); + //* ========== Handles switching to wallet list or menu from dashboard =============== + Future switchToWalletMenuFromDashboardPage() async { + _tester.printToConsole('Switching to Wallet Menu'); + await _dashboardPageRobot.openDrawerMenu(); - // ----------- Restore Options Page ----------- - // Route to restore from seeds page to continue flow - await _restoreOptionsPageRobot.navigateToRestoreFromSeedsPage(); + await _dashboardPageRobot.dashboardMenuWidgetRobot.navigateToWalletMenu(); + } + void confirmAllAvailableWalletTypeIconsDisplayCorrectly() { + for (var walletType in availableWalletTypes) { + final imageUrl = walletTypeToCryptoCurrency(walletType).iconPath; + + final walletIconFinder = find.image( + Image.asset( + imageUrl!, + width: 32, + height: 32, + ).image, + ); + + expect(walletIconFinder, findsAny); + } + } + + //* ========== Handles creating new wallet flow from wallet list/menu =============== + Future createNewWalletFromWalletMenu(WalletType walletTypeToCreate) async { + _tester.printToConsole('Creating ${walletTypeToCreate.name} Wallet'); + await _walletListPageRobot.navigateToCreateNewWalletPage(); + await _commonTestCases.defaultSleepTime(); + + await _selectWalletTypeForWallet(walletTypeToCreate); + await _commonTestCases.defaultSleepTime(); + + // ---- Wallet Group/New Seed Implementation Comes here + await _walletGroupDescriptionPageFlow(true, walletTypeToCreate); + + await _generateNewWalletDetails(); + + await _confirmPreSeedInfo(); + + await _confirmWalletDetails(); + await _commonTestCases.defaultSleepTime(); + } + + Future _walletGroupDescriptionPageFlow(bool isNewSeed, WalletType walletType) async { + if (!isBIP39Wallet(walletType)) return; + + await _walletGroupDescriptionPageRobot.isWalletGroupDescriptionPage(); + + if (isNewSeed) { + await _walletGroupDescriptionPageRobot.navigateToCreateNewSeedPage(); + } else { + await _walletGroupDescriptionPageRobot.navigateToChooseWalletGroup(); + } + } + + //* ========== Handles restore wallet flow from wallet list/menu =============== + Future restoreWalletFromWalletMenu(WalletType walletType, String walletSeed) async { + _tester.printToConsole('Restoring ${walletType.name} Wallet'); + await _walletListPageRobot.navigateToRestoreWalletOptionsPage(); + await _commonTestCases.defaultSleepTime(); + + await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); + await _commonTestCases.defaultSleepTime(); + + await _selectWalletTypeForWallet(walletType); + await _commonTestCases.defaultSleepTime(); + + await _restoreFromSeeds(walletType, walletSeed); + await _commonTestCases.defaultSleepTime(); + } + + //* ========== Handles setting up pin code for wallet on first install =============== + Future setupPinCodeForWallet(List pin) async { // ----------- SetupPinCode Page ------------- // Confirm initial defaults - Widgets to be displayed etc await _setupPinCodeRobot.isSetupPinCodePage(); - await _setupPinCodeRobot.enterPinCode(CommonTestConstants.pin, true); - await _setupPinCodeRobot.enterPinCode(CommonTestConstants.pin, false); + await _setupPinCodeRobot.enterPinCode(pin); + await _setupPinCodeRobot.enterPinCode(pin); await _setupPinCodeRobot.tapSuccessButton(); + } + Future _welcomeToCreateWalletPath( + WalletType walletTypeToCreate, + List pin, + ) async { + await _welcomePageRobot.navigateToCreateNewWalletPage(); + + await setupPinCodeForWallet(pin); + + await _selectWalletTypeForWallet(walletTypeToCreate); + } + + Future _welcomeToRestoreFromSeedsOrKeysPath( + WalletType walletTypeToRestore, + List pin, + ) async { + await _welcomePageRobot.navigateToRestoreWalletPage(); + + await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); + + await setupPinCodeForWallet(pin); + + await _selectWalletTypeForWallet(walletTypeToRestore); + } + + //* ============ Handles New Wallet Type Page ================== + Future _selectWalletTypeForWallet(WalletType type) async { // ----------- NewWalletType Page ------------- // Confirm scroll behaviour works properly - await _newWalletTypePageRobot - .findParticularWalletTypeInScrollableList(CommonTestConstants.testWalletType); + await _newWalletTypePageRobot.findParticularWalletTypeInScrollableList(type); // Select a wallet and route to next page - await _newWalletTypePageRobot.selectWalletType(CommonTestConstants.testWalletType); + await _newWalletTypePageRobot.selectWalletType(type); await _newWalletTypePageRobot.onNextButtonPressed(); } - Future _restoreFromSeeds() async { + //* ============ Handles New Wallet Page ================== + Future _generateNewWalletDetails() async { + await _newWalletPageRobot.isNewWalletPage(); + + await _newWalletPageRobot.generateWalletName(); + + await _newWalletPageRobot.onNextButtonPressed(); + } + + //* ============ Handles Pre Seed Page ===================== + Future _confirmPreSeedInfo() async { + await _preSeedPageRobot.isPreSeedPage(); + + await _preSeedPageRobot.onConfirmButtonPressed(); + } + + //* ============ Handles Wallet Seed Page ================== + Future _confirmWalletDetails() async { + await _walletSeedPageRobot.isWalletSeedPage(); + + _walletSeedPageRobot.confirmWalletDetailsDisplayCorrectly(); + + _walletSeedPageRobot.confirmWalletSeedReminderDisplays(); + + await _walletSeedPageRobot.onCopySeedsButtonPressed(); + + await _walletSeedPageRobot.onNextButtonPressed(); + + await _walletSeedPageRobot.onConfirmButtonOnSeedAlertDialogPressed(); + } + + //* Main Restore Actions - On the RestoreFromSeed/Keys Page - Restore from Seeds Action + Future _restoreFromSeeds(WalletType type, String walletSeed) async { // ----------- RestoreFromSeedOrKeys Page ------------- - await _restoreFromSeedOrKeysPageRobot.enterWalletNameText(CommonTestConstants.testWalletName); - await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(secrets.solanaTestWalletSeeds); + + await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions(); + await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(walletSeed); + + final numberOfWords = walletSeed.split(' ').length; + + if (numberOfWords == 25 && (type == WalletType.monero)) { + await _restoreFromSeedOrKeysPageRobot + .chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType.legacy); + + // Using a constant value of 2831400 for the blockheight as its the restore blockheight for our testing wallet + await _restoreFromSeedOrKeysPageRobot + .enterBlockHeightForWalletRestore(secrets.moneroTestWalletBlockHeight); + } + await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); } + //* Main Restore Actions - On the RestoreFromSeed/Keys Page - Restore from Keys Action Future _restoreFromKeys() async { await _commonTestCases.swipePage(); await _commonTestCases.defaultSleepTime(); - await _restoreFromSeedOrKeysPageRobot.enterWalletNameText(CommonTestConstants.testWalletName); + await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions( + isSeedFormEntry: false, + ); await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(''); await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); } + + //* ====== Utility Function to get test wallet seeds for each wallet type ======== + String getWalletSeedsByWalletType(WalletType walletType) { + switch (walletType) { + case WalletType.monero: + return secrets.moneroTestWalletSeeds; + case WalletType.bitcoin: + return secrets.bitcoinTestWalletSeeds; + case WalletType.ethereum: + return secrets.ethereumTestWalletSeeds; + case WalletType.litecoin: + return secrets.litecoinTestWalletSeeds; + case WalletType.bitcoinCash: + return secrets.bitcoinCashTestWalletSeeds; + case WalletType.polygon: + return secrets.polygonTestWalletSeeds; + case WalletType.solana: + return secrets.solanaTestWalletSeeds; + case WalletType.tron: + return secrets.tronTestWalletSeeds; + case WalletType.nano: + return secrets.nanoTestWalletSeeds; + case WalletType.wownero: + return secrets.wowneroTestWalletSeeds; + default: + return ''; + } + } + + //* ====== Utility Function to get test receive address for each wallet type ======== + String getReceiveAddressByWalletType(WalletType walletType) { + switch (walletType) { + case WalletType.monero: + return secrets.moneroTestWalletReceiveAddress; + case WalletType.bitcoin: + return secrets.bitcoinTestWalletReceiveAddress; + case WalletType.ethereum: + return secrets.ethereumTestWalletReceiveAddress; + case WalletType.litecoin: + return secrets.litecoinTestWalletReceiveAddress; + case WalletType.bitcoinCash: + return secrets.bitcoinCashTestWalletReceiveAddress; + case WalletType.polygon: + return secrets.polygonTestWalletReceiveAddress; + case WalletType.solana: + return secrets.solanaTestWalletReceiveAddress; + case WalletType.tron: + return secrets.tronTestWalletReceiveAddress; + case WalletType.nano: + return secrets.nanoTestWalletReceiveAddress; + case WalletType.wownero: + return secrets.wowneroTestWalletReceiveAddress; + default: + return ''; + } + } } diff --git a/integration_test/funds_related_tests.dart b/integration_test/funds_related_tests.dart index 9d97d47f8..db24fbc0b 100644 --- a/integration_test/funds_related_tests.dart +++ b/integration_test/funds_related_tests.dart @@ -9,6 +9,7 @@ import 'robots/dashboard_page_robot.dart'; import 'robots/exchange_confirm_page_robot.dart'; import 'robots/exchange_page_robot.dart'; import 'robots/exchange_trade_page_robot.dart'; +import 'package:cake_wallet/.secrets.g.dart' as secrets; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -32,7 +33,11 @@ void main() { await commonTestFlows.startAppFlow(ValueKey('funds_exchange_test_app_key')); - await commonTestFlows.restoreWalletThroughSeedsFlow(); + await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( + CommonTestConstants.testWalletType, + secrets.solanaTestWalletSeeds, + CommonTestConstants.pin, + ); // ----------- RestoreFromSeedOrKeys Page ------------- await dashboardPageRobot.navigateToExchangePage(); @@ -59,7 +64,7 @@ void main() { final onAuthPage = authPageRobot.onAuthPage(); if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin, false); + await authPageRobot.enterPinCode(CommonTestConstants.pin); } // ----------- Exchange Confirm Page ------------- diff --git a/integration_test/robots/dashboard_menu_widget_robot.dart b/integration_test/robots/dashboard_menu_widget_robot.dart new file mode 100644 index 000000000..f48033dda --- /dev/null +++ b/integration_test/robots/dashboard_menu_widget_robot.dart @@ -0,0 +1,39 @@ +import 'package:cake_wallet/src/screens/dashboard/widgets/menu_widget.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../components/common_test_cases.dart'; + +class DashboardMenuWidgetRobot { + DashboardMenuWidgetRobot(this.tester) : commonTestCases = CommonTestCases(tester); + + final WidgetTester tester; + late CommonTestCases commonTestCases; + + Future hasMenuWidget() async { + commonTestCases.hasType(); + } + + void displaysTheCorrectWalletNameAndSubName() { + final menuWidgetState = tester.state(find.byType(MenuWidget)); + + final walletName = menuWidgetState.widget.dashboardViewModel.name; + commonTestCases.hasText(walletName); + + final walletSubName = menuWidgetState.widget.dashboardViewModel.subname; + if (walletSubName.isNotEmpty) { + commonTestCases.hasText(walletSubName); + } + } + + Future navigateToWalletMenu() async { + await commonTestCases.tapItemByKey('dashboard_page_menu_widget_wallet_menu_button_key'); + await commonTestCases.defaultSleepTime(); + } + + Future navigateToSecurityAndBackupPage() async { + await commonTestCases.tapItemByKey( + 'dashboard_page_menu_widget_security_and_backup_button_key', + ); + await commonTestCases.defaultSleepTime(); + } +} diff --git a/integration_test/robots/dashboard_page_robot.dart b/integration_test/robots/dashboard_page_robot.dart index fc917c3b2..bc5f411ad 100644 --- a/integration_test/robots/dashboard_page_robot.dart +++ b/integration_test/robots/dashboard_page_robot.dart @@ -1,20 +1,44 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/screens/dashboard/dashboard_page.dart'; +import 'package:cake_wallet/src/screens/dashboard/pages/balance_page.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter_test/flutter_test.dart'; import '../components/common_test_cases.dart'; +import 'dashboard_menu_widget_robot.dart'; class DashboardPageRobot { - DashboardPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); + DashboardPageRobot(this.tester) + : commonTestCases = CommonTestCases(tester), + dashboardMenuWidgetRobot = DashboardMenuWidgetRobot(tester); final WidgetTester tester; + final DashboardMenuWidgetRobot dashboardMenuWidgetRobot; late CommonTestCases commonTestCases; Future isDashboardPage() async { await commonTestCases.isSpecificPage(); } + Future confirmWalletTypeIsDisplayedCorrectly( + WalletType type, { + bool isHaven = false, + }) async { + final cryptoBalanceWidget = + tester.widget(find.byType(CryptoBalanceWidget)); + final hasAccounts = cryptoBalanceWidget.dashboardViewModel.balanceViewModel.hasAccounts; + + if (hasAccounts) { + final walletName = cryptoBalanceWidget.dashboardViewModel.name; + commonTestCases.hasText(walletName); + } else { + final walletName = walletTypeToString(type); + final assetName = isHaven ? '$walletName Assets' : walletName; + commonTestCases.hasText(assetName); + } + await commonTestCases.defaultSleepTime(seconds: 5); + } + void confirmServiceUpdateButtonDisplays() { commonTestCases.hasValueKey('dashboard_page_services_update_button_key'); } @@ -27,30 +51,40 @@ class DashboardPageRobot { commonTestCases.hasValueKey('dashboard_page_wallet_menu_button_key'); } - Future confirmRightCryptoAssetTitleDisplaysPerPageView(WalletType type, - {bool isHaven = false}) async { + Future confirmRightCryptoAssetTitleDisplaysPerPageView( + WalletType type, { + bool isHaven = false, + }) async { //Balance Page - final walletName = walletTypeToString(type); - final assetName = isHaven ? '$walletName Assets' : walletName; - commonTestCases.hasText(assetName); + await confirmWalletTypeIsDisplayedCorrectly(type, isHaven: isHaven); // Swipe to Cake features Page - await commonTestCases.swipeByPageKey(key: 'dashboard_page_view_key', swipeRight: false); - await commonTestCases.defaultSleepTime(); + await swipeDashboardTab(false); commonTestCases.hasText('Cake ${S.current.features}'); // Swipe back to balance - await commonTestCases.swipeByPageKey(key: 'dashboard_page_view_key'); - await commonTestCases.defaultSleepTime(); + await swipeDashboardTab(true); // Swipe to Transactions Page - await commonTestCases.swipeByPageKey(key: 'dashboard_page_view_key'); - await commonTestCases.defaultSleepTime(); + await swipeDashboardTab(true); commonTestCases.hasText(S.current.transactions); // Swipe back to balance - await commonTestCases.swipeByPageKey(key: 'dashboard_page_view_key', swipeRight: false); - await commonTestCases.defaultSleepTime(seconds: 5); + await swipeDashboardTab(false); + await commonTestCases.defaultSleepTime(seconds: 3); + } + + Future swipeDashboardTab(bool swipeRight) async { + await commonTestCases.swipeByPageKey( + key: 'dashboard_page_view_key', + swipeRight: swipeRight, + ); + await commonTestCases.defaultSleepTime(); + } + + Future openDrawerMenu() async { + await commonTestCases.tapItemByKey('dashboard_page_wallet_menu_button_key'); + await commonTestCases.defaultSleepTime(); } Future navigateToBuyPage() async { diff --git a/integration_test/robots/exchange_page_robot.dart b/integration_test/robots/exchange_page_robot.dart index b439e4791..e01b2df9c 100644 --- a/integration_test/robots/exchange_page_robot.dart +++ b/integration_test/robots/exchange_page_robot.dart @@ -123,7 +123,7 @@ class ExchangePageRobot { return; } - await commonTestCases.scrollUntilVisible( + await commonTestCases.dragUntilVisible( 'picker_items_index_${depositCurrency.name}_button_key', 'picker_scrollbar_key', ); @@ -149,7 +149,7 @@ class ExchangePageRobot { return; } - await commonTestCases.scrollUntilVisible( + await commonTestCases.dragUntilVisible( 'picker_items_index_${receiveCurrency.name}_button_key', 'picker_scrollbar_key', ); diff --git a/integration_test/robots/new_wallet_page_robot.dart b/integration_test/robots/new_wallet_page_robot.dart new file mode 100644 index 000000000..f8deb00ae --- /dev/null +++ b/integration_test/robots/new_wallet_page_robot.dart @@ -0,0 +1,35 @@ +import 'package:cake_wallet/src/screens/new_wallet/new_wallet_page.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../components/common_test_cases.dart'; + +class NewWalletPageRobot { + NewWalletPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); + + final WidgetTester tester; + late CommonTestCases commonTestCases; + + Future isNewWalletPage() async { + await commonTestCases.isSpecificPage(); + } + + Future enterWalletName(String walletName) async { + await commonTestCases.enterText( + walletName, + 'new_wallet_page_wallet_name_textformfield_key', + ); + await commonTestCases.defaultSleepTime(); + } + + Future generateWalletName() async { + await commonTestCases.tapItemByKey( + 'new_wallet_page_wallet_name_textformfield_generate_name_button_key', + ); + await commonTestCases.defaultSleepTime(); + } + + Future onNextButtonPressed() async { + await commonTestCases.tapItemByKey('new_wallet_page_confirm_button_key'); + await commonTestCases.defaultSleepTime(); + } +} diff --git a/integration_test/robots/pin_code_widget_robot.dart b/integration_test/robots/pin_code_widget_robot.dart index b6805e9e0..18dc5fba4 100644 --- a/integration_test/robots/pin_code_widget_robot.dart +++ b/integration_test/robots/pin_code_widget_robot.dart @@ -24,13 +24,12 @@ class PinCodeWidgetRobot { commonTestCases.hasValueKey('pin_code_button_0_key'); } - Future pushPinButton(int index) async { - await commonTestCases.tapItemByKey('pin_code_button_${index}_key'); - } - - Future enterPinCode(List pinCode, bool isFirstEntry) async { + Future enterPinCode(List pinCode, {int pumpDuration = 100}) async { for (int pin in pinCode) { - await pushPinButton(pin); + await commonTestCases.tapItemByKey( + 'pin_code_button_${pin}_key', + pumpDuration: pumpDuration, + ); } await commonTestCases.defaultSleepTime(); diff --git a/integration_test/robots/pre_seed_page_robot.dart b/integration_test/robots/pre_seed_page_robot.dart new file mode 100644 index 000000000..01be1249c --- /dev/null +++ b/integration_test/robots/pre_seed_page_robot.dart @@ -0,0 +1,20 @@ +import 'package:cake_wallet/src/screens/seed/pre_seed_page.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../components/common_test_cases.dart'; + +class PreSeedPageRobot { + PreSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); + + final WidgetTester tester; + late CommonTestCases commonTestCases; + + Future isPreSeedPage() async { + await commonTestCases.isSpecificPage(); + } + + Future onConfirmButtonPressed() async { + await commonTestCases.tapItemByKey('pre_seed_page_button_key'); + await commonTestCases.defaultSleepTime(); + } +} diff --git a/integration_test/robots/restore_from_seed_or_key_robot.dart b/integration_test/robots/restore_from_seed_or_key_robot.dart index 43a65095d..9d973061b 100644 --- a/integration_test/robots/restore_from_seed_or_key_robot.dart +++ b/integration_test/robots/restore_from_seed_or_key_robot.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/entities/seed_type.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/screens/restore/wallet_restore_page.dart'; import 'package:cake_wallet/src/widgets/validable_annotated_editable_text.dart'; @@ -70,6 +71,22 @@ class RestoreFromSeedOrKeysPageRobot { await tester.pumpAndSettle(); } + Future enterBlockHeightForWalletRestore(String blockHeight) async { + await commonTestCases.enterText( + blockHeight, + 'wallet_restore_from_seed_blockheight_textfield_key', + ); + await tester.pumpAndSettle(); + } + + Future chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType selectedType) async { + await commonTestCases.tapItemByKey('wallet_restore_from_seed_seedtype_picker_button_key'); + + await commonTestCases.defaultSleepTime(); + + await commonTestCases.tapItemByKey('picker_items_index_${selectedType.title}_button_key'); + } + Future onPasteSeedPhraseButtonPressed() async { await commonTestCases.tapItemByKey('wallet_restore_from_seed_wallet_seeds_paste_button_key'); } diff --git a/integration_test/robots/restore_options_page_robot.dart b/integration_test/robots/restore_options_page_robot.dart index b3cefc90c..cd1919609 100644 --- a/integration_test/robots/restore_options_page_robot.dart +++ b/integration_test/robots/restore_options_page_robot.dart @@ -14,14 +14,14 @@ class RestoreOptionsPageRobot { } void hasRestoreOptionsButton() { - commonTestCases.hasValueKey('restore_options_from_seeds_button_key'); + commonTestCases.hasValueKey('restore_options_from_seeds_or_keys_button_key'); commonTestCases.hasValueKey('restore_options_from_backup_button_key'); commonTestCases.hasValueKey('restore_options_from_hardware_wallet_button_key'); commonTestCases.hasValueKey('restore_options_from_qr_button_key'); } - Future navigateToRestoreFromSeedsPage() async { - await commonTestCases.tapItemByKey('restore_options_from_seeds_button_key'); + Future navigateToRestoreFromSeedsOrKeysPage() async { + await commonTestCases.tapItemByKey('restore_options_from_seeds_or_keys_button_key'); await commonTestCases.defaultSleepTime(); } diff --git a/integration_test/robots/security_and_backup_page_robot.dart b/integration_test/robots/security_and_backup_page_robot.dart new file mode 100644 index 000000000..eb7c1bc87 --- /dev/null +++ b/integration_test/robots/security_and_backup_page_robot.dart @@ -0,0 +1,24 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/settings/security_backup_page.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../components/common_test_cases.dart'; + +class SecurityAndBackupPageRobot { + SecurityAndBackupPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); + + final WidgetTester tester; + final CommonTestCases commonTestCases; + + Future isSecurityAndBackupPage() async { + await commonTestCases.isSpecificPage(); + } + + void hasTitle() { + commonTestCases.hasText(S.current.security_and_backup); + } + + Future navigateToShowKeysPage() async { + await commonTestCases.tapItemByKey('security_backup_page_show_keys_button_key'); + } +} diff --git a/integration_test/robots/send_page_robot.dart b/integration_test/robots/send_page_robot.dart index 971556620..20cef948d 100644 --- a/integration_test/robots/send_page_robot.dart +++ b/integration_test/robots/send_page_robot.dart @@ -84,7 +84,7 @@ class SendPageRobot { return; } - await commonTestCases.scrollUntilVisible( + await commonTestCases.dragUntilVisible( 'picker_items_index_${receiveCurrency.name}_button_key', 'picker_scrollbar_key', ); @@ -117,7 +117,7 @@ class SendPageRobot { return; } - await commonTestCases.scrollUntilVisible( + await commonTestCases.dragUntilVisible( 'picker_items_index_${priority.title}_button_key', 'picker_scrollbar_key', ); @@ -198,7 +198,7 @@ class SendPageRobot { tester.printToConsole('Starting inner _handleAuth loop checks'); try { - await authPageRobot.enterPinCode(CommonTestConstants.pin, false); + await authPageRobot.enterPinCode(CommonTestConstants.pin, pumpDuration: 500); tester.printToConsole('Auth done'); await tester.pump(); @@ -213,6 +213,7 @@ class SendPageRobot { } Future handleSendResult() async { + await tester.pump(); tester.printToConsole('Inside handle function'); bool hasError = false; @@ -287,6 +288,8 @@ class SendPageRobot { // Loop to wait for the operation to commit transaction await _waitForCommitTransactionCompletion(); + await tester.pump(); + await commonTestCases.defaultSleepTime(seconds: 4); } else { await commonTestCases.defaultSleepTime(); diff --git a/integration_test/robots/transactions_page_robot.dart b/integration_test/robots/transactions_page_robot.dart new file mode 100644 index 000000000..40a49928f --- /dev/null +++ b/integration_test/robots/transactions_page_robot.dart @@ -0,0 +1,286 @@ +import 'dart:async'; + +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart'; +import 'package:cake_wallet/utils/date_formatter.dart'; +import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; +import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; +import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; +import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; +import 'package:cake_wallet/view_model/dashboard/order_list_item.dart'; +import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart'; +import 'package:cake_wallet/view_model/dashboard/transaction_list_item.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/sync_status.dart'; +import 'package:cw_core/transaction_direction.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:intl/intl.dart'; + +import '../components/common_test_cases.dart'; + +class TransactionsPageRobot { + TransactionsPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); + + final WidgetTester tester; + late CommonTestCases commonTestCases; + + Future isTransactionsPage() async { + await commonTestCases.isSpecificPage(); + } + + Future confirmTransactionsPageConstantsDisplayProperly() async { + await commonTestCases.defaultSleepTime(); + + final transactionsPage = tester.widget(find.byType(TransactionsPage)); + final dashboardViewModel = transactionsPage.dashboardViewModel; + if (dashboardViewModel.status is SyncingSyncStatus) { + commonTestCases.hasValueKey('transactions_page_syncing_alert_card_key'); + commonTestCases.hasText(S.current.syncing_wallet_alert_title); + commonTestCases.hasText(S.current.syncing_wallet_alert_content); + } + + commonTestCases.hasValueKey('transactions_page_header_row_key'); + commonTestCases.hasText(S.current.transactions); + commonTestCases.hasValueKey('transactions_page_header_row_transaction_filter_button_key'); + } + + Future confirmTransactionHistoryListDisplaysCorrectly(bool hasTxHistoryWhileSyncing) async { + // Retrieve the TransactionsPage widget and its DashboardViewModel + final transactionsPage = tester.widget(find.byType(TransactionsPage)); + final dashboardViewModel = transactionsPage.dashboardViewModel; + + // Define a timeout to prevent infinite loops + // Putting at one hour for cases like monero that takes time to sync + final timeout = Duration(hours: 1); + final pollingInterval = Duration(seconds: 2); + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + final isSynced = dashboardViewModel.status is SyncedSyncStatus; + final itemsLoaded = dashboardViewModel.items.isNotEmpty; + + // Perform item checks if items are loaded + if (itemsLoaded) { + await _performItemChecks(dashboardViewModel); + } else { + // Verify placeholder when items are not loaded + _verifyPlaceholder(); + } + + // Determine if we should exit the loop + if (_shouldExitLoop(hasTxHistoryWhileSyncing, isSynced, itemsLoaded)) { + break; + } + + // Pump the UI and wait for the next polling interval + await tester.pump(pollingInterval); + } + + // After the loop, verify that both status is synced and items are loaded + if (!_isFinalStateValid(dashboardViewModel)) { + throw TimeoutException('Dashboard did not sync and load items within the allotted time.'); + } + } + + bool _shouldExitLoop(bool hasTxHistoryWhileSyncing, bool isSynced, bool itemsLoaded) { + if (hasTxHistoryWhileSyncing) { + // When hasTxHistoryWhileSyncing is true, exit when status is synced + return isSynced; + } else { + // When hasTxHistoryWhileSyncing is false, exit when status is synced and items are loaded + return isSynced && itemsLoaded; + } + } + + void _verifyPlaceholder() { + commonTestCases.hasValueKey('transactions_page_placeholder_transactions_text_key'); + commonTestCases.hasText(S.current.placeholder_transactions); + } + + bool _isFinalStateValid(DashboardViewModel dashboardViewModel) { + final isSynced = dashboardViewModel.status is SyncedSyncStatus; + final itemsLoaded = dashboardViewModel.items.isNotEmpty; + return isSynced && itemsLoaded; + } + + Future _performItemChecks(DashboardViewModel dashboardViewModel) async { + List items = dashboardViewModel.items; + for (var item in items) { + final keyId = (item.key as ValueKey).value; + tester.printToConsole('\n'); + tester.printToConsole(keyId); + + await commonTestCases.dragUntilVisible(keyId, 'transactions_page_list_view_builder_key'); + await tester.pump(); + + final isWidgetVisible = tester.any(find.byKey(ValueKey(keyId))); + if (!isWidgetVisible) { + tester.printToConsole('Moving to next visible item on list'); + continue; + } + ; + await tester.pump(); + + if (item is DateSectionItem) { + await _verifyDateSectionItem(item); + } else if (item is TransactionListItem) { + tester.printToConsole(item.formattedTitle); + tester.printToConsole(item.formattedFiatAmount); + tester.printToConsole('\n'); + await _verifyTransactionListItemDisplay(item, dashboardViewModel); + } else if (item is AnonpayTransactionListItem) { + await _verifyAnonpayTransactionListItemDisplay(item); + } else if (item is TradeListItem) { + await _verifyTradeListItemDisplay(item); + } else if (item is OrderListItem) { + await _verifyOrderListItemDisplay(item); + } + } + } + + Future _verifyDateSectionItem(DateSectionItem item) async { + final title = DateFormatter.convertDateTimeToReadableString(item.date); + tester.printToConsole(title); + await tester.pump(); + + commonTestCases.findWidgetViaDescendant( + of: find.byKey(item.key), + matching: find.text(title), + ); + } + + Future _verifyTransactionListItemDisplay( + TransactionListItem item, + DashboardViewModel dashboardViewModel, + ) async { + final keyId = + '${dashboardViewModel.type.name}_transaction_history_item_${item.transaction.id}_key'; + + if (item.hasTokens && item.assetOfTransaction == null) return; + + //* ==============Confirm it has the right key for this item ======== + commonTestCases.hasValueKey(keyId); + + //* ======Confirm it displays the properly formatted amount========== + commonTestCases.findWidgetViaDescendant( + of: find.byKey(ValueKey(keyId)), + matching: find.text(item.formattedCryptoAmount), + ); + + //* ======Confirm it displays the properly formatted title=========== + final transactionType = dashboardViewModel.getTransactionType(item.transaction); + + final title = item.formattedTitle + item.formattedStatus + transactionType; + + commonTestCases.findWidgetViaDescendant( + of: find.byKey(ValueKey(keyId)), + matching: find.text(title), + ); + + //* ======Confirm it displays the properly formatted date============ + final formattedDate = DateFormat('HH:mm').format(item.transaction.date); + commonTestCases.findWidgetViaDescendant( + of: find.byKey(ValueKey(keyId)), + matching: find.text(formattedDate), + ); + + //* ======Confirm it displays the properly formatted fiat amount===== + final formattedFiatAmount = + dashboardViewModel.balanceViewModel.isFiatDisabled ? '' : item.formattedFiatAmount; + if (formattedFiatAmount.isNotEmpty) { + commonTestCases.findWidgetViaDescendant( + of: find.byKey(ValueKey(keyId)), + matching: find.text(formattedFiatAmount), + ); + } + + //* ======Confirm it displays the right image based on the transaction direction===== + final imageToUse = item.transaction.direction == TransactionDirection.incoming + ? 'assets/images/down_arrow.png' + : 'assets/images/up_arrow.png'; + + find.widgetWithImage(Container, AssetImage(imageToUse)); + } + + Future _verifyAnonpayTransactionListItemDisplay(AnonpayTransactionListItem item) async { + final keyId = 'anonpay_invoice_transaction_list_item_${item.transaction.invoiceId}_key'; + + //* ==============Confirm it has the right key for this item ======== + commonTestCases.hasValueKey(keyId); + + //* ==============Confirm it displays the correct provider ========================= + commonTestCases.hasText(item.transaction.provider); + + //* ===========Confirm it displays the properly formatted amount with currency ======== + final currency = item.transaction.fiatAmount != null + ? item.transaction.fiatEquiv ?? '' + : CryptoCurrency.fromFullName(item.transaction.coinTo).name.toUpperCase(); + + final amount = + item.transaction.fiatAmount?.toString() ?? (item.transaction.amountTo?.toString() ?? ''); + + final amountCurrencyText = amount + ' ' + currency; + + commonTestCases.hasText(amountCurrencyText); + + //* ======Confirm it displays the properly formatted date================= + final formattedDate = DateFormat('HH:mm').format(item.transaction.createdAt); + commonTestCases.hasText(formattedDate); + + //* ===============Confirm it displays the right image==================== + find.widgetWithImage(ClipRRect, AssetImage('assets/images/trocador.png')); + } + + Future _verifyTradeListItemDisplay(TradeListItem item) async { + final keyId = 'trade_list_item_${item.trade.id}_key'; + + //* ==============Confirm it has the right key for this item ======== + commonTestCases.hasValueKey(keyId); + + //* ==============Confirm it displays the correct provider ========================= + final conversionFlow = '${item.trade.from.toString()} → ${item.trade.to.toString()}'; + + commonTestCases.hasText(conversionFlow); + + //* ===========Confirm it displays the properly formatted amount with its crypto tag ======== + + final amountCryptoText = item.tradeFormattedAmount + ' ' + item.trade.from.toString(); + + commonTestCases.hasText(amountCryptoText); + + //* ======Confirm it displays the properly formatted date================= + final createdAtFormattedDate = + item.trade.createdAt != null ? DateFormat('HH:mm').format(item.trade.createdAt!) : null; + + if (createdAtFormattedDate != null) { + commonTestCases.hasText(createdAtFormattedDate); + } + + //* ===============Confirm it displays the right image==================== + commonTestCases.hasValueKey(item.trade.provider.image); + } + + Future _verifyOrderListItemDisplay(OrderListItem item) async { + final keyId = 'order_list_item_${item.order.id}_key'; + + //* ==============Confirm it has the right key for this item ======== + commonTestCases.hasValueKey(keyId); + + //* ==============Confirm it displays the correct provider ========================= + final orderFlow = '${item.order.from!} → ${item.order.to}'; + + commonTestCases.hasText(orderFlow); + + //* ===========Confirm it displays the properly formatted amount with its crypto tag ======== + + final amountCryptoText = item.orderFormattedAmount + ' ' + item.order.to!; + + commonTestCases.hasText(amountCryptoText); + + //* ======Confirm it displays the properly formatted date================= + final createdAtFormattedDate = DateFormat('HH:mm').format(item.order.createdAt); + + commonTestCases.hasText(createdAtFormattedDate); + } +} diff --git a/integration_test/robots/wallet_group_description_page_robot.dart b/integration_test/robots/wallet_group_description_page_robot.dart new file mode 100644 index 000000000..57500dc3c --- /dev/null +++ b/integration_test/robots/wallet_group_description_page_robot.dart @@ -0,0 +1,32 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/new_wallet/wallet_group_description_page.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../components/common_test_cases.dart'; + +class WalletGroupDescriptionPageRobot { + WalletGroupDescriptionPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); + + final WidgetTester tester; + final CommonTestCases commonTestCases; + + Future isWalletGroupDescriptionPage() async { + await commonTestCases.isSpecificPage(); + } + + void hasTitle() { + commonTestCases.hasText(S.current.wallet_group); + } + + Future navigateToCreateNewSeedPage() async { + await commonTestCases.tapItemByKey( + 'wallet_group_description_page_create_new_seed_button_key', + ); + } + + Future navigateToChooseWalletGroup() async { + await commonTestCases.tapItemByKey( + 'wallet_group_description_page_choose_wallet_group_button_key', + ); + } +} diff --git a/integration_test/robots/wallet_keys_robot.dart b/integration_test/robots/wallet_keys_robot.dart new file mode 100644 index 000000000..f6aeb3a66 --- /dev/null +++ b/integration_test/robots/wallet_keys_robot.dart @@ -0,0 +1,162 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/reactions/wallet_connect.dart'; +import 'package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart'; +import 'package:cake_wallet/store/app_store.dart'; +import 'package:cw_core/monero_wallet_keys.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:cw_monero/monero_wallet.dart'; +import 'package:cw_wownero/wownero_wallet.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:polyseed/polyseed.dart'; + +import '../components/common_test_cases.dart'; + +class WalletKeysAndSeedPageRobot { + WalletKeysAndSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); + + final WidgetTester tester; + final CommonTestCases commonTestCases; + + Future isWalletKeysAndSeedPage() async { + await commonTestCases.isSpecificPage(); + } + + void hasTitle() { + final walletKeysPage = tester.widget(find.byType(WalletKeysPage)); + final walletKeysViewModel = walletKeysPage.walletKeysViewModel; + commonTestCases.hasText(walletKeysViewModel.title); + } + + void hasShareWarning() { + commonTestCases.hasText(S.current.do_not_share_warning_text.toUpperCase()); + } + + Future confirmWalletCredentials(WalletType walletType) async { + final walletKeysPage = tester.widget(find.byType(WalletKeysPage)); + final walletKeysViewModel = walletKeysPage.walletKeysViewModel; + + final appStore = walletKeysViewModel.appStore; + final walletName = walletType.name; + bool hasSeed = appStore.wallet!.seed != null; + bool hasHexSeed = appStore.wallet!.hexSeed != null; + bool hasPrivateKey = appStore.wallet!.privateKey != null; + + if (walletType == WalletType.monero) { + final moneroWallet = appStore.wallet as MoneroWallet; + final lang = PolyseedLang.getByPhrase(moneroWallet.seed); + final legacySeed = moneroWallet.seedLegacy(lang.nameEnglish); + + _confirmMoneroWalletCredentials( + appStore, + walletName, + moneroWallet.seed, + legacySeed, + ); + } + + if (walletType == WalletType.wownero) { + final wowneroWallet = appStore.wallet as WowneroWallet; + final lang = PolyseedLang.getByPhrase(wowneroWallet.seed); + final legacySeed = wowneroWallet.seedLegacy(lang.nameEnglish); + + _confirmMoneroWalletCredentials( + appStore, + walletName, + wowneroWallet.seed, + legacySeed, + ); + } + + if (walletType == WalletType.bitcoin || + walletType == WalletType.litecoin || + walletType == WalletType.bitcoinCash) { + commonTestCases.hasText(appStore.wallet!.seed!); + tester.printToConsole('$walletName wallet has seeds properly displayed'); + } + + if (isEVMCompatibleChain(walletType) || + walletType == WalletType.solana || + walletType == WalletType.tron) { + if (hasSeed) { + commonTestCases.hasText(appStore.wallet!.seed!); + tester.printToConsole('$walletName wallet has seeds properly displayed'); + } + if (hasPrivateKey) { + commonTestCases.hasText(appStore.wallet!.privateKey!); + tester.printToConsole('$walletName wallet has private key properly displayed'); + } + } + + if (walletType == WalletType.nano || walletType == WalletType.banano) { + if (hasSeed) { + commonTestCases.hasText(appStore.wallet!.seed!); + tester.printToConsole('$walletName wallet has seeds properly displayed'); + } + if (hasHexSeed) { + commonTestCases.hasText(appStore.wallet!.hexSeed!); + tester.printToConsole('$walletName wallet has hexSeed properly displayed'); + } + if (hasPrivateKey) { + commonTestCases.hasText(appStore.wallet!.privateKey!); + tester.printToConsole('$walletName wallet has private key properly displayed'); + } + } + + await commonTestCases.defaultSleepTime(seconds: 5); + } + + void _confirmMoneroWalletCredentials( + AppStore appStore, + String walletName, + String seed, + String legacySeed, + ) { + final keys = appStore.wallet!.keys as MoneroWalletKeys; + + final hasPublicSpendKey = commonTestCases.isKeyPresent( + '${walletName}_wallet_public_spend_key_item_key', + ); + final hasPrivateSpendKey = commonTestCases.isKeyPresent( + '${walletName}_wallet_private_spend_key_item_key', + ); + final hasPublicViewKey = commonTestCases.isKeyPresent( + '${walletName}_wallet_public_view_key_item_key', + ); + final hasPrivateViewKey = commonTestCases.isKeyPresent( + '${walletName}_wallet_private_view_key_item_key', + ); + final hasSeeds = seed.isNotEmpty; + final hasSeedLegacy = Polyseed.isValidSeed(seed); + + if (hasPublicSpendKey) { + commonTestCases.hasText(keys.publicSpendKey); + tester.printToConsole('$walletName wallet has public spend key properly displayed'); + } + if (hasPrivateSpendKey) { + commonTestCases.hasText(keys.privateSpendKey); + tester.printToConsole('$walletName wallet has private spend key properly displayed'); + } + if (hasPublicViewKey) { + commonTestCases.hasText(keys.publicViewKey); + tester.printToConsole('$walletName wallet has public view key properly displayed'); + } + if (hasPrivateViewKey) { + commonTestCases.hasText(keys.privateViewKey); + tester.printToConsole('$walletName wallet has private view key properly displayed'); + } + if (hasSeeds) { + commonTestCases.hasText(seed); + tester.printToConsole('$walletName wallet has seeds properly displayed'); + } + if (hasSeedLegacy) { + commonTestCases.hasText(legacySeed); + tester.printToConsole('$walletName wallet has legacy seeds properly displayed'); + } + } + + Future backToDashboard() async { + tester.printToConsole('Going back to dashboard from credentials page'); + await commonTestCases.goBack(); + await commonTestCases.goBack(); + } +} diff --git a/integration_test/robots/wallet_list_page_robot.dart b/integration_test/robots/wallet_list_page_robot.dart new file mode 100644 index 000000000..b46d4ca95 --- /dev/null +++ b/integration_test/robots/wallet_list_page_robot.dart @@ -0,0 +1,27 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../components/common_test_cases.dart'; + +class WalletListPageRobot { + WalletListPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); + + final WidgetTester tester; + late CommonTestCases commonTestCases; + + Future isWalletListPage() async { + await commonTestCases.isSpecificPage(); + } + + void displaysCorrectTitle() { + commonTestCases.hasText(S.current.wallets); + } + + Future navigateToCreateNewWalletPage() async { + commonTestCases.tapItemByKey('wallet_list_page_create_new_wallet_button_key'); + } + + Future navigateToRestoreWalletOptionsPage() async { + commonTestCases.tapItemByKey('wallet_list_page_restore_wallet_button_key'); + } +} diff --git a/integration_test/robots/wallet_seed_page_robot.dart b/integration_test/robots/wallet_seed_page_robot.dart new file mode 100644 index 000000000..d52f3b1ec --- /dev/null +++ b/integration_test/robots/wallet_seed_page_robot.dart @@ -0,0 +1,57 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/seed/wallet_seed_page.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../components/common_test_cases.dart'; + +class WalletSeedPageRobot { + WalletSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); + + final WidgetTester tester; + late CommonTestCases commonTestCases; + + Future isWalletSeedPage() async { + await commonTestCases.isSpecificPage(); + } + + Future onNextButtonPressed() async { + await commonTestCases.tapItemByKey('wallet_seed_page_next_button_key'); + await commonTestCases.defaultSleepTime(); + } + + Future onConfirmButtonOnSeedAlertDialogPressed() async { + await commonTestCases.tapItemByKey('wallet_seed_page_seed_alert_confirm_button_key'); + await commonTestCases.defaultSleepTime(); + } + + Future onBackButtonOnSeedAlertDialogPressed() async { + await commonTestCases.tapItemByKey('wallet_seed_page_seed_alert_back_button_key'); + await commonTestCases.defaultSleepTime(); + } + + void confirmWalletDetailsDisplayCorrectly() { + final walletSeedPage = tester.widget(find.byType(WalletSeedPage)); + + final walletSeedViewModel = walletSeedPage.walletSeedViewModel; + + final walletName = walletSeedViewModel.name; + final walletSeeds = walletSeedViewModel.seed; + + commonTestCases.hasText(walletName); + commonTestCases.hasText(walletSeeds); + } + + void confirmWalletSeedReminderDisplays() { + commonTestCases.hasText(S.current.seed_reminder); + } + + Future onSaveSeedsButtonPressed() async { + await commonTestCases.tapItemByKey('wallet_seed_page_save_seeds_button_key'); + await commonTestCases.defaultSleepTime(); + } + + Future onCopySeedsButtonPressed() async { + await commonTestCases.tapItemByKey('wallet_seed_page_copy_seeds_button_key'); + await commonTestCases.defaultSleepTime(); + } +} diff --git a/integration_test/test_suites/confirm_seeds_flow_test.dart b/integration_test/test_suites/confirm_seeds_flow_test.dart new file mode 100644 index 000000000..bf6fd5a5f --- /dev/null +++ b/integration_test/test_suites/confirm_seeds_flow_test.dart @@ -0,0 +1,107 @@ +import 'package:cake_wallet/wallet_types.g.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../components/common_test_constants.dart'; +import '../components/common_test_flows.dart'; +import '../robots/auth_page_robot.dart'; +import '../robots/dashboard_page_robot.dart'; +import '../robots/security_and_backup_page_robot.dart'; +import '../robots/wallet_keys_robot.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + AuthPageRobot authPageRobot; + CommonTestFlows commonTestFlows; + DashboardPageRobot dashboardPageRobot; + WalletKeysAndSeedPageRobot walletKeysAndSeedPageRobot; + SecurityAndBackupPageRobot securityAndBackupPageRobot; + + testWidgets( + 'Confirm if the seeds display properly', + (tester) async { + authPageRobot = AuthPageRobot(tester); + commonTestFlows = CommonTestFlows(tester); + dashboardPageRobot = DashboardPageRobot(tester); + walletKeysAndSeedPageRobot = WalletKeysAndSeedPageRobot(tester); + securityAndBackupPageRobot = SecurityAndBackupPageRobot(tester); + + // Start the app + await commonTestFlows.startAppFlow( + ValueKey('confirm_creds_display_correctly_flow_app_key'), + ); + + await commonTestFlows.welcomePageToCreateNewWalletFlow( + WalletType.solana, + CommonTestConstants.pin, + ); + + await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); + + await _confirmSeedsFlowForWalletType( + WalletType.solana, + authPageRobot, + dashboardPageRobot, + securityAndBackupPageRobot, + walletKeysAndSeedPageRobot, + tester, + ); + + // Do the same for other available wallet types + for (var walletType in availableWalletTypes) { + if (walletType == WalletType.solana) { + continue; + } + + await commonTestFlows.switchToWalletMenuFromDashboardPage(); + + await commonTestFlows.createNewWalletFromWalletMenu(walletType); + + await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); + + await _confirmSeedsFlowForWalletType( + walletType, + authPageRobot, + dashboardPageRobot, + securityAndBackupPageRobot, + walletKeysAndSeedPageRobot, + tester, + ); + } + + await Future.delayed(Duration(seconds: 15)); + }, + ); +} + +Future _confirmSeedsFlowForWalletType( + WalletType walletType, + AuthPageRobot authPageRobot, + DashboardPageRobot dashboardPageRobot, + SecurityAndBackupPageRobot securityAndBackupPageRobot, + WalletKeysAndSeedPageRobot walletKeysAndSeedPageRobot, + WidgetTester tester, +) async { + await dashboardPageRobot.openDrawerMenu(); + await dashboardPageRobot.dashboardMenuWidgetRobot.navigateToSecurityAndBackupPage(); + + await securityAndBackupPageRobot.navigateToShowKeysPage(); + + final onAuthPage = authPageRobot.onAuthPage(); + if (onAuthPage) { + await authPageRobot.enterPinCode(CommonTestConstants.pin); + } + + await tester.pumpAndSettle(); + + await walletKeysAndSeedPageRobot.isWalletKeysAndSeedPage(); + walletKeysAndSeedPageRobot.hasTitle(); + walletKeysAndSeedPageRobot.hasShareWarning(); + + walletKeysAndSeedPageRobot.confirmWalletCredentials(walletType); + + await walletKeysAndSeedPageRobot.backToDashboard(); +} diff --git a/integration_test/test_suites/create_wallet_flow_test.dart b/integration_test/test_suites/create_wallet_flow_test.dart new file mode 100644 index 000000000..2a50dbbe8 --- /dev/null +++ b/integration_test/test_suites/create_wallet_flow_test.dart @@ -0,0 +1,57 @@ +import 'package:cake_wallet/wallet_types.g.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../components/common_test_constants.dart'; +import '../components/common_test_flows.dart'; +import '../robots/dashboard_page_robot.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + CommonTestFlows commonTestFlows; + DashboardPageRobot dashboardPageRobot; + + testWidgets( + 'Create Wallet Flow', + (tester) async { + commonTestFlows = CommonTestFlows(tester); + dashboardPageRobot = DashboardPageRobot(tester); + + // Start the app + await commonTestFlows.startAppFlow( + ValueKey('create_wallets_through_seeds_test_app_key'), + ); + + await commonTestFlows.welcomePageToCreateNewWalletFlow( + WalletType.solana, + CommonTestConstants.pin, + ); + + // Confirm it actually restores a solana wallet + await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); + + // Do the same for other available wallet types + for (var walletType in availableWalletTypes) { + if (walletType == WalletType.solana) { + continue; + } + + await commonTestFlows.switchToWalletMenuFromDashboardPage(); + + await commonTestFlows.createNewWalletFromWalletMenu(walletType); + + await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); + } + + // Goes to the wallet menu and provides a confirmation that all the wallets were correctly restored + await commonTestFlows.switchToWalletMenuFromDashboardPage(); + + commonTestFlows.confirmAllAvailableWalletTypeIconsDisplayCorrectly(); + + await Future.delayed(Duration(seconds: 5)); + }, + ); +} diff --git a/integration_test/test_suites/exchange_flow_test.dart b/integration_test/test_suites/exchange_flow_test.dart index 6c993634c..c36ef9396 100644 --- a/integration_test/test_suites/exchange_flow_test.dart +++ b/integration_test/test_suites/exchange_flow_test.dart @@ -9,6 +9,7 @@ import '../robots/dashboard_page_robot.dart'; import '../robots/exchange_confirm_page_robot.dart'; import '../robots/exchange_page_robot.dart'; import '../robots/exchange_trade_page_robot.dart'; +import 'package:cake_wallet/.secrets.g.dart' as secrets; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -20,40 +21,42 @@ void main() { ExchangeTradePageRobot exchangeTradePageRobot; ExchangeConfirmPageRobot exchangeConfirmPageRobot; - group('Exchange Flow Tests', () { - testWidgets('Exchange flow', (tester) async { - authPageRobot = AuthPageRobot(tester); - commonTestFlows = CommonTestFlows(tester); - exchangePageRobot = ExchangePageRobot(tester); - dashboardPageRobot = DashboardPageRobot(tester); - exchangeTradePageRobot = ExchangeTradePageRobot(tester); - exchangeConfirmPageRobot = ExchangeConfirmPageRobot(tester); + testWidgets('Exchange flow', (tester) async { + authPageRobot = AuthPageRobot(tester); + commonTestFlows = CommonTestFlows(tester); + exchangePageRobot = ExchangePageRobot(tester); + dashboardPageRobot = DashboardPageRobot(tester); + exchangeTradePageRobot = ExchangeTradePageRobot(tester); + exchangeConfirmPageRobot = ExchangeConfirmPageRobot(tester); - await commonTestFlows.startAppFlow(ValueKey('exchange_app_test_key')); - await commonTestFlows.restoreWalletThroughSeedsFlow(); - await dashboardPageRobot.navigateToExchangePage(); + await commonTestFlows.startAppFlow(ValueKey('exchange_app_test_key')); + await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( + CommonTestConstants.testWalletType, + secrets.solanaTestWalletSeeds, + CommonTestConstants.pin, + ); + await dashboardPageRobot.navigateToExchangePage(); - // ----------- Exchange Page ------------- - await exchangePageRobot.selectDepositCurrency(CommonTestConstants.testDepositCurrency); - await exchangePageRobot.selectReceiveCurrency(CommonTestConstants.testReceiveCurrency); + // ----------- Exchange Page ------------- + await exchangePageRobot.selectDepositCurrency(CommonTestConstants.testDepositCurrency); + await exchangePageRobot.selectReceiveCurrency(CommonTestConstants.testReceiveCurrency); - await exchangePageRobot.enterDepositAmount(CommonTestConstants.exchangeTestAmount); - await exchangePageRobot.enterDepositRefundAddress( - depositAddress: CommonTestConstants.testWalletAddress, - ); - await exchangePageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - - await exchangePageRobot.onExchangeButtonPressed(); + await exchangePageRobot.enterDepositAmount(CommonTestConstants.exchangeTestAmount); + await exchangePageRobot.enterDepositRefundAddress( + depositAddress: CommonTestConstants.testWalletAddress, + ); + await exchangePageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - await exchangePageRobot.handleErrors(CommonTestConstants.exchangeTestAmount); + await exchangePageRobot.onExchangeButtonPressed(); - final onAuthPage = authPageRobot.onAuthPage(); - if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin, false); - } + await exchangePageRobot.handleErrors(CommonTestConstants.exchangeTestAmount); - await exchangeConfirmPageRobot.onSavedTradeIdButtonPressed(); - await exchangeTradePageRobot.onGotItButtonPressed(); - }); + final onAuthPage = authPageRobot.onAuthPage(); + if (onAuthPage) { + await authPageRobot.enterPinCode(CommonTestConstants.pin); + } + + await exchangeConfirmPageRobot.onSavedTradeIdButtonPressed(); + await exchangeTradePageRobot.onGotItButtonPressed(); }); } diff --git a/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart b/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart new file mode 100644 index 000000000..a810aa722 --- /dev/null +++ b/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart @@ -0,0 +1,63 @@ +import 'package:cake_wallet/wallet_types.g.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../components/common_test_constants.dart'; +import '../components/common_test_flows.dart'; +import '../robots/dashboard_page_robot.dart'; +import 'package:cake_wallet/.secrets.g.dart' as secrets; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + CommonTestFlows commonTestFlows; + DashboardPageRobot dashboardPageRobot; + + testWidgets( + 'Restoring Wallets Through Seeds', + (tester) async { + commonTestFlows = CommonTestFlows(tester); + dashboardPageRobot = DashboardPageRobot(tester); + + // Start the app + await commonTestFlows.startAppFlow( + ValueKey('restore_wallets_through_seeds_test_app_key'), + ); + + // Restore the first wallet type: Solana + await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( + WalletType.solana, + secrets.solanaTestWalletSeeds, + CommonTestConstants.pin, + ); + + // Confirm it actually restores a solana wallet + await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); + + // Do the same for other available wallet types + for (var walletType in availableWalletTypes) { + if (walletType == WalletType.solana) { + continue; + } + + await commonTestFlows.switchToWalletMenuFromDashboardPage(); + + await commonTestFlows.restoreWalletFromWalletMenu( + walletType, + commonTestFlows.getWalletSeedsByWalletType(walletType), + ); + + await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); + } + + // Goes to the wallet menu and provides a visual confirmation that all the wallets were correctly restored + await commonTestFlows.switchToWalletMenuFromDashboardPage(); + + commonTestFlows.confirmAllAvailableWalletTypeIconsDisplayCorrectly(); + + await Future.delayed(Duration(seconds: 5)); + }, + ); +} diff --git a/integration_test/test_suites/send_flow_test.dart b/integration_test/test_suites/send_flow_test.dart index 38ac1574f..7a46435b8 100644 --- a/integration_test/test_suites/send_flow_test.dart +++ b/integration_test/test_suites/send_flow_test.dart @@ -6,6 +6,7 @@ import '../components/common_test_constants.dart'; import '../components/common_test_flows.dart'; import '../robots/dashboard_page_robot.dart'; import '../robots/send_page_robot.dart'; +import 'package:cake_wallet/.secrets.g.dart' as secrets; void main() { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -14,28 +15,30 @@ void main() { CommonTestFlows commonTestFlows; DashboardPageRobot dashboardPageRobot; - group('Send Flow Tests', () { - testWidgets('Send flow', (tester) async { - commonTestFlows = CommonTestFlows(tester); - sendPageRobot = SendPageRobot(tester: tester); - dashboardPageRobot = DashboardPageRobot(tester); + testWidgets('Send flow', (tester) async { + commonTestFlows = CommonTestFlows(tester); + sendPageRobot = SendPageRobot(tester: tester); + dashboardPageRobot = DashboardPageRobot(tester); - await commonTestFlows.startAppFlow(ValueKey('send_test_app_key')); - await commonTestFlows.restoreWalletThroughSeedsFlow(); - await dashboardPageRobot.navigateToSendPage(); + await commonTestFlows.startAppFlow(ValueKey('send_test_app_key')); + await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( + CommonTestConstants.testWalletType, + secrets.solanaTestWalletSeeds, + CommonTestConstants.pin, + ); + await dashboardPageRobot.navigateToSendPage(); - await sendPageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - await sendPageRobot.selectReceiveCurrency(CommonTestConstants.testReceiveCurrency); - await sendPageRobot.enterAmount(CommonTestConstants.sendTestAmount); - await sendPageRobot.selectTransactionPriority(); + await sendPageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); + await sendPageRobot.selectReceiveCurrency(CommonTestConstants.testReceiveCurrency); + await sendPageRobot.enterAmount(CommonTestConstants.sendTestAmount); + await sendPageRobot.selectTransactionPriority(); - await sendPageRobot.onSendButtonPressed(); + await sendPageRobot.onSendButtonPressed(); - await sendPageRobot.handleSendResult(); + await sendPageRobot.handleSendResult(); - await sendPageRobot.onSendButtonOnConfirmSendingDialogPressed(); + await sendPageRobot.onSendButtonOnConfirmSendingDialogPressed(); - await sendPageRobot.onSentDialogPopUp(); - }); + await sendPageRobot.onSentDialogPopUp(); }); } diff --git a/integration_test/test_suites/transaction_history_flow_test.dart b/integration_test/test_suites/transaction_history_flow_test.dart new file mode 100644 index 000000000..8af6d39fd --- /dev/null +++ b/integration_test/test_suites/transaction_history_flow_test.dart @@ -0,0 +1,70 @@ +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; + +import '../components/common_test_constants.dart'; +import '../components/common_test_flows.dart'; +import '../robots/dashboard_page_robot.dart'; +import '../robots/transactions_page_robot.dart'; +import 'package:cake_wallet/.secrets.g.dart' as secrets; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + CommonTestFlows commonTestFlows; + DashboardPageRobot dashboardPageRobot; + TransactionsPageRobot transactionsPageRobot; + + /// Two Test Scenarios + /// - Fully Synchronizes and display the transaction history either immediately or few seconds after fully synchronizing + /// - Displays the transaction history progressively as synchronizing happens + testWidgets('Transaction history flow', (tester) async { + commonTestFlows = CommonTestFlows(tester); + dashboardPageRobot = DashboardPageRobot(tester); + transactionsPageRobot = TransactionsPageRobot(tester); + + await commonTestFlows.startAppFlow( + ValueKey('confirm_creds_display_correctly_flow_app_key'), + ); + + /// Test Scenario 1 - Displays transaction history list after fully synchronizing. + /// + /// For Solana/Tron WalletTypes. + await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( + WalletType.solana, + secrets.solanaTestWalletSeeds, + CommonTestConstants.pin, + ); + + await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); + + await dashboardPageRobot.swipeDashboardTab(true); + + await transactionsPageRobot.isTransactionsPage(); + + await transactionsPageRobot.confirmTransactionsPageConstantsDisplayProperly(); + + await transactionsPageRobot.confirmTransactionHistoryListDisplaysCorrectly(false); + + /// Test Scenario 2 - Displays transaction history list while synchronizing. + /// + /// For bitcoin/Monero/Wownero WalletTypes. + await commonTestFlows.switchToWalletMenuFromDashboardPage(); + + await commonTestFlows.restoreWalletFromWalletMenu( + WalletType.bitcoin, + secrets.bitcoinTestWalletSeeds, + ); + + await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.bitcoin); + + await dashboardPageRobot.swipeDashboardTab(true); + + await transactionsPageRobot.isTransactionsPage(); + + await transactionsPageRobot.confirmTransactionsPageConstantsDisplayProperly(); + + await transactionsPageRobot.confirmTransactionHistoryListDisplaysCorrectly(true); + }); +} diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 322ef6f86..470bdf4e7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -109,15 +109,15 @@ PODS: - FlutterMacOS - permission_handler_apple (9.1.1): - Flutter - - Protobuf (3.27.2) + - Protobuf (3.28.2) - ReachabilitySwift (5.2.3) - reactive_ble_mobile (0.0.1): - Flutter - Protobuf (~> 3.5) - SwiftProtobuf (~> 1.0) - - SDWebImage (5.19.4): - - SDWebImage/Core (= 5.19.4) - - SDWebImage/Core (5.19.4) + - SDWebImage (5.19.7): + - SDWebImage/Core (= 5.19.7) + - SDWebImage/Core (5.19.7) - sensitive_clipboard (0.0.1): - Flutter - share_plus (0.0.1): @@ -271,10 +271,10 @@ SPEC CHECKSUMS: package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 - Protobuf: fb2c13674723f76ff6eede14f78847a776455fa2 + Protobuf: 28c89b24435762f60244e691544ed80f50d82701 ReachabilitySwift: 7f151ff156cea1481a8411701195ac6a984f4979 reactive_ble_mobile: 9ce6723d37ccf701dbffd202d487f23f5de03b4c - SDWebImage: 066c47b573f408f18caa467d71deace7c0f8280d + SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 sensitive_clipboard: d4866e5d176581536c27bb1618642ee83adca986 share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 diff --git a/lib/src/screens/InfoPage.dart b/lib/src/screens/InfoPage.dart index 5398df22c..84b9e8632 100644 --- a/lib/src/screens/InfoPage.dart +++ b/lib/src/screens/InfoPage.dart @@ -21,6 +21,7 @@ abstract class InfoPage extends BasePage { String get pageTitle; String get pageDescription; String get buttonText; + Key? get buttonKey; void Function(BuildContext) get onPressed; @override @@ -39,15 +40,14 @@ abstract class InfoPage extends BasePage { alignment: Alignment.center, padding: EdgeInsets.all(24), child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint), + constraints: + BoxConstraints(maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint), child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Expanded( child: ConstrainedBox( - constraints: BoxConstraints( - maxHeight: MediaQuery.of(context).size.height * 0.3), + constraints: BoxConstraints(maxHeight: MediaQuery.of(context).size.height * 0.3), child: AspectRatio(aspectRatio: 1, child: image), ), ), @@ -61,14 +61,13 @@ abstract class InfoPage extends BasePage { height: 1.7, fontSize: 14, fontWeight: FontWeight.normal, - color: Theme.of(context) - .extension()! - .secondaryTextColor, + color: Theme.of(context).extension()!.secondaryTextColor, ), ), ), ), PrimaryButton( + key: buttonKey, onPressed: () => onPressed(context), text: buttonText, color: Theme.of(context).primaryColor, diff --git a/lib/src/screens/dashboard/dashboard_page.dart b/lib/src/screens/dashboard/dashboard_page.dart index 953463269..9c012d518 100644 --- a/lib/src/screens/dashboard/dashboard_page.dart +++ b/lib/src/screens/dashboard/dashboard_page.dart @@ -140,7 +140,7 @@ class _DashboardPageView extends BasePage { bool get resizeToAvoidBottomInset => false; @override - Widget get endDrawer => MenuWidget(dashboardViewModel); + Widget get endDrawer => MenuWidget(dashboardViewModel, ValueKey('dashboard_page_drawer_menu_widget_key')); @override Widget leading(BuildContext context) { diff --git a/lib/src/screens/dashboard/pages/nft_details_page.dart b/lib/src/screens/dashboard/pages/nft_details_page.dart index 15d2a2b5c..b8352a672 100644 --- a/lib/src/screens/dashboard/pages/nft_details_page.dart +++ b/lib/src/screens/dashboard/pages/nft_details_page.dart @@ -28,7 +28,10 @@ class NFTDetailsPage extends BasePage { bool get resizeToAvoidBottomInset => false; @override - Widget get endDrawer => MenuWidget(dashboardViewModel); + Widget get endDrawer => MenuWidget( + dashboardViewModel, + ValueKey('nft_details_page_menu_widget_key'), + ); @override Widget trailing(BuildContext context) { diff --git a/lib/src/screens/dashboard/pages/transactions_page.dart b/lib/src/screens/dashboard/pages/transactions_page.dart index b6d1c286b..0db9ac35b 100644 --- a/lib/src/screens/dashboard/pages/transactions_page.dart +++ b/lib/src/screens/dashboard/pages/transactions_page.dart @@ -1,11 +1,13 @@ import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/anonpay_transaction_row.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/order_row.dart'; +import 'package:cake_wallet/src/screens/dashboard/widgets/trade_row.dart'; import 'package:cake_wallet/themes/extensions/placeholder_theme.dart'; import 'package:cake_wallet/src/widgets/dashboard_card_widget.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/order_list_item.dart'; +import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/sync_status.dart'; import 'package:cw_core/wallet_type.dart'; @@ -14,9 +16,7 @@ import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/header_row.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/date_section_raw.dart'; -import 'package:cake_wallet/src/screens/dashboard/widgets/trade_row.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/transaction_raw.dart'; -import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/transaction_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; import 'package:intl/intl.dart'; @@ -49,6 +49,7 @@ class TransactionsPage extends StatelessWidget { return Padding( padding: const EdgeInsets.fromLTRB(24, 0, 24, 8), child: DashBoardRoundedCardWidget( + key: ValueKey('transactions_page_syncing_alert_card_key'), onTap: () { try { final uri = Uri.parse( @@ -64,82 +65,93 @@ class TransactionsPage extends StatelessWidget { return Container(); } }), - HeaderRow(dashboardViewModel: dashboardViewModel), - Expanded(child: Observer(builder: (_) { - final items = dashboardViewModel.items; + HeaderRow( + dashboardViewModel: dashboardViewModel, + key: ValueKey('transactions_page_header_row_key'), + ), + Expanded( + child: Observer( + builder: (_) { + final items = dashboardViewModel.items; - return items.isNotEmpty - ? ListView.builder( - itemCount: items.length, - itemBuilder: (context, index) { - final item = items[index]; + return items.isNotEmpty + ? ListView.builder( + key: ValueKey('transactions_page_list_view_builder_key'), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; - if (item is DateSectionItem) { - return DateSectionRaw(date: item.date); - } - - if (item is TransactionListItem) { - if (item.hasTokens && item.assetOfTransaction == null) { - return Container(); - } - - final transaction = item.transaction; - final transactionType = dashboardViewModel.getTransactionType(transaction); - - List tags = []; - if (dashboardViewModel.type == WalletType.bitcoin) { - if (bitcoin!.txIsReceivedSilentPayment(transaction)) { - tags.add(S.of(context).silent_payment); + if (item is DateSectionItem) { + return DateSectionRaw(date: item.date, key: item.key); } - } - if (dashboardViewModel.type == WalletType.litecoin) { - if (bitcoin!.txIsMweb(transaction)) { - tags.add("MWEB"); + + if (item is TransactionListItem) { + if (item.hasTokens && item.assetOfTransaction == null) { + return Container(); + } + + final transaction = item.transaction; + final transactionType = + dashboardViewModel.getTransactionType(transaction); + + List tags = []; + if (dashboardViewModel.type == WalletType.bitcoin) { + if (bitcoin!.txIsReceivedSilentPayment(transaction)) { + tags.add(S.of(context).silent_payment); + } + } + if (dashboardViewModel.type == WalletType.litecoin) { + if (bitcoin!.txIsMweb(transaction)) { + tags.add("MWEB"); + } + } + + return Observer( + builder: (_) => TransactionRow( + key: item.key, + onTap: () => Navigator.of(context) + .pushNamed(Routes.transactionDetails, arguments: transaction), + direction: transaction.direction, + formattedDate: DateFormat('HH:mm').format(transaction.date), + formattedAmount: item.formattedCryptoAmount, + formattedFiatAmount: + dashboardViewModel.balanceViewModel.isFiatDisabled + ? '' + : item.formattedFiatAmount, + isPending: transaction.isPending, + title: + item.formattedTitle + item.formattedStatus + transactionType, + tags: tags, + ), + ); } - } - return Observer( - builder: (_) => TransactionRow( - onTap: () => Navigator.of(context) - .pushNamed(Routes.transactionDetails, arguments: transaction), - direction: transaction.direction, - formattedDate: DateFormat('HH:mm').format(transaction.date), - formattedAmount: item.formattedCryptoAmount, - formattedFiatAmount: - dashboardViewModel.balanceViewModel.isFiatDisabled - ? '' - : item.formattedFiatAmount, - isPending: transaction.isPending, - title: - item.formattedTitle + item.formattedStatus + transactionType, - tags: tags, - ), - ); - } + if (item is AnonpayTransactionListItem) { + final transactionInfo = item.transaction; - if (item is AnonpayTransactionListItem) { - final transactionInfo = item.transaction; + return AnonpayTransactionRow( + key: item.key, + onTap: () => Navigator.of(context).pushNamed( + Routes.anonPayDetailsPage, + arguments: transactionInfo), + currency: transactionInfo.fiatAmount != null + ? transactionInfo.fiatEquiv ?? '' + : CryptoCurrency.fromFullName(transactionInfo.coinTo) + .name + .toUpperCase(), + provider: transactionInfo.provider, + amount: transactionInfo.fiatAmount?.toString() ?? + (transactionInfo.amountTo?.toString() ?? ''), + createdAt: DateFormat('HH:mm').format(transactionInfo.createdAt), + ); + } - return AnonpayTransactionRow( - onTap: () => Navigator.of(context) - .pushNamed(Routes.anonPayDetailsPage, arguments: transactionInfo), - currency: transactionInfo.fiatAmount != null - ? transactionInfo.fiatEquiv ?? '' - : CryptoCurrency.fromFullName(transactionInfo.coinTo) - .name - .toUpperCase(), - provider: transactionInfo.provider, - amount: transactionInfo.fiatAmount?.toString() ?? - (transactionInfo.amountTo?.toString() ?? ''), - createdAt: DateFormat('HH:mm').format(transactionInfo.createdAt), - ); - } + if (item is TradeListItem) { + final trade = item.trade; - if (item is TradeListItem) { - final trade = item.trade; - - return Observer( - builder: (_) => TradeRow( + return Observer( + builder: (_) => TradeRow( + key: item.key, onTap: () => Navigator.of(context) .pushNamed(Routes.tradeDetails, arguments: trade), provider: trade.provider, @@ -148,36 +160,44 @@ class TransactionsPage extends StatelessWidget { createdAtFormattedDate: trade.createdAt != null ? DateFormat('HH:mm').format(trade.createdAt!) : null, - formattedAmount: item.tradeFormattedAmount)); - } + formattedAmount: item.tradeFormattedAmount, + ), + ); + } - if (item is OrderListItem) { - final order = item.order; + if (item is OrderListItem) { + final order = item.order; - return Observer( - builder: (_) => OrderRow( - onTap: () => Navigator.of(context) - .pushNamed(Routes.orderDetails, arguments: order), - provider: order.provider, - from: order.from!, - to: order.to!, - createdAtFormattedDate: - DateFormat('HH:mm').format(order.createdAt), - formattedAmount: item.orderFormattedAmount, - )); - } + return Observer( + builder: (_) => OrderRow( + key: item.key, + onTap: () => Navigator.of(context) + .pushNamed(Routes.orderDetails, arguments: order), + provider: order.provider, + from: order.from!, + to: order.to!, + createdAtFormattedDate: + DateFormat('HH:mm').format(order.createdAt), + formattedAmount: item.orderFormattedAmount, + ), + ); + } - return Container(color: Colors.transparent, height: 1); - }) - : Center( - child: Text( - S.of(context).placeholder_transactions, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).extension()!.color), - ), - ); - })) + return Container(color: Colors.transparent, height: 1); + }) + : Center( + child: Text( + key: ValueKey('transactions_page_placeholder_transactions_text_key'), + S.of(context).placeholder_transactions, + style: TextStyle( + fontSize: 14, + color: Theme.of(context).extension()!.color, + ), + ), + ); + }, + ), + ) ], ), ), diff --git a/lib/src/screens/dashboard/widgets/anonpay_transaction_row.dart b/lib/src/screens/dashboard/widgets/anonpay_transaction_row.dart index cb8bef0b7..64d4bfe85 100644 --- a/lib/src/screens/dashboard/widgets/anonpay_transaction_row.dart +++ b/lib/src/screens/dashboard/widgets/anonpay_transaction_row.dart @@ -9,6 +9,7 @@ class AnonpayTransactionRow extends StatelessWidget { required this.currency, required this.onTap, required this.amount, + super.key, }); final VoidCallback? onTap; diff --git a/lib/src/screens/dashboard/widgets/date_section_raw.dart b/lib/src/screens/dashboard/widgets/date_section_raw.dart index 73f9f03a1..02fcef7f4 100644 --- a/lib/src/screens/dashboard/widgets/date_section_raw.dart +++ b/lib/src/screens/dashboard/widgets/date_section_raw.dart @@ -1,42 +1,27 @@ import 'package:flutter/material.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; -import 'package:intl/intl.dart'; -import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/utils/date_formatter.dart'; class DateSectionRaw extends StatelessWidget { - DateSectionRaw({required this.date}); + DateSectionRaw({required this.date, super.key}); final DateTime date; @override Widget build(BuildContext context) { - final nowDate = DateTime.now(); - final diffDays = date.difference(nowDate).inDays; - final isToday = nowDate.day == date.day && - nowDate.month == date.month && - nowDate.year == date.year; - final dateSectionDateFormat = DateFormatter.withCurrentLocal(hasTime: false); - var title = ""; - - if (isToday) { - title = S.of(context).today; - } else if (diffDays == 0) { - title = S.of(context).yesterday; - } else if (diffDays > -7 && diffDays < 0) { - final dateFormat = DateFormat.EEEE(); - title = dateFormat.format(date); - } else { - title = dateSectionDateFormat.format(date); - } + final title = DateFormatter.convertDateTimeToReadableString(date); return Container( - height: 35, - alignment: Alignment.center, - color: Colors.transparent, - child: Text(title, - style: TextStyle( - fontSize: 12, - color: Theme.of(context).extension()!.dateSectionRowColor))); + height: 35, + alignment: Alignment.center, + color: Colors.transparent, + child: Text( + title, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).extension()!.dateSectionRowColor, + ), + ), + ); } } diff --git a/lib/src/screens/dashboard/widgets/header_row.dart b/lib/src/screens/dashboard/widgets/header_row.dart index cb4f67fc2..f1f3f0889 100644 --- a/lib/src/screens/dashboard/widgets/header_row.dart +++ b/lib/src/screens/dashboard/widgets/header_row.dart @@ -7,7 +7,7 @@ import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/themes/extensions/dashboard_page_theme.dart'; class HeaderRow extends StatelessWidget { - HeaderRow({required this.dashboardViewModel}); + HeaderRow({required this.dashboardViewModel, super.key}); final DashboardViewModel dashboardViewModel; @@ -34,6 +34,7 @@ class HeaderRow extends StatelessWidget { Semantics( container: true, child: GestureDetector( + key: ValueKey('transactions_page_header_row_transaction_filter_button_key'), onTap: () { showPopUp( context: context, diff --git a/lib/src/screens/dashboard/widgets/menu_widget.dart b/lib/src/screens/dashboard/widgets/menu_widget.dart index 6d8379b29..f0e330f04 100644 --- a/lib/src/screens/dashboard/widgets/menu_widget.dart +++ b/lib/src/screens/dashboard/widgets/menu_widget.dart @@ -9,7 +9,7 @@ import 'package:cw_core/wallet_type.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; class MenuWidget extends StatefulWidget { - MenuWidget(this.dashboardViewModel); + MenuWidget(this.dashboardViewModel, Key? key); final DashboardViewModel dashboardViewModel; @@ -193,6 +193,7 @@ class MenuWidgetState extends State { final isLastTile = index == itemCount - 1; return SettingActionButton( + key: item.key, isLastTile: isLastTile, tileHeight: tileHeight, selectionActive: false, diff --git a/lib/src/screens/dashboard/widgets/order_row.dart b/lib/src/screens/dashboard/widgets/order_row.dart index 8adc6e0d5..221ea5689 100644 --- a/lib/src/screens/dashboard/widgets/order_row.dart +++ b/lib/src/screens/dashboard/widgets/order_row.dart @@ -12,7 +12,10 @@ class OrderRow extends StatelessWidget { required this.to, required this.createdAtFormattedDate, this.onTap, - this.formattedAmount}); + this.formattedAmount, + super.key, + }); + final VoidCallback? onTap; final BuyProviderDescription provider; final String from; @@ -22,8 +25,7 @@ class OrderRow extends StatelessWidget { @override Widget build(BuildContext context) { - final iconColor = - Theme.of(context).extension()!.iconColor; + final iconColor = Theme.of(context).extension()!.iconColor; final providerIcon = getBuyProviderIcon(provider, iconColor: iconColor); @@ -36,46 +38,42 @@ class OrderRow extends StatelessWidget { mainAxisSize: MainAxisSize.max, crossAxisAlignment: CrossAxisAlignment.center, children: [ - if (providerIcon != null) Padding( - padding: EdgeInsets.only(right: 12), - child: providerIcon, - ), + if (providerIcon != null) + Padding( + padding: EdgeInsets.only(right: 12), + child: providerIcon, + ), Expanded( child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('$from → $to', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).extension()!.textColor - )), - formattedAmount != null - ? Text(formattedAmount! + ' ' + to, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).extension()!.textColor - )) - : Container() - ]), - SizedBox(height: 5), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(createdAtFormattedDate, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).extension()!.dateSectionRowColor)) - ]) - ], - ) - ) + mainAxisSize: MainAxisSize.min, + children: [ + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Text('$from → $to', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).extension()!.textColor)), + formattedAmount != null + ? Text(formattedAmount! + ' ' + to, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: + Theme.of(context).extension()!.textColor)) + : Container() + ]), + SizedBox(height: 5), + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ + Text(createdAtFormattedDate, + style: TextStyle( + fontSize: 14, + color: + Theme.of(context).extension()!.dateSectionRowColor)) + ]) + ], + )) ], ), )); } -} \ No newline at end of file +} diff --git a/lib/src/screens/dashboard/widgets/trade_row.dart b/lib/src/screens/dashboard/widgets/trade_row.dart index 7c809aa9d..84a5d2beb 100644 --- a/lib/src/screens/dashboard/widgets/trade_row.dart +++ b/lib/src/screens/dashboard/widgets/trade_row.dart @@ -13,6 +13,7 @@ class TradeRow extends StatelessWidget { required this.createdAtFormattedDate, this.onTap, this.formattedAmount, + super.key, }); final VoidCallback? onTap; diff --git a/lib/src/screens/dashboard/widgets/transaction_raw.dart b/lib/src/screens/dashboard/widgets/transaction_raw.dart index b18131f3d..2d7cbb809 100644 --- a/lib/src/screens/dashboard/widgets/transaction_raw.dart +++ b/lib/src/screens/dashboard/widgets/transaction_raw.dart @@ -14,6 +14,7 @@ class TransactionRow extends StatelessWidget { required this.tags, required this.title, required this.onTap, + super.key, }); final VoidCallback onTap; @@ -28,33 +29,36 @@ class TransactionRow extends StatelessWidget { @override Widget build(BuildContext context) { return InkWell( - onTap: onTap, - child: Container( - padding: EdgeInsets.fromLTRB(24, 8, 24, 8), - color: Colors.transparent, - child: Row( - mainAxisSize: MainAxisSize.max, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Container( - height: 36, - width: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: Theme.of(context).extension()!.rowsColor), - child: Image.asset(direction == TransactionDirection.incoming - ? 'assets/images/down_arrow.png' - : 'assets/images/up_arrow.png'), - ), - SizedBox(width: 12), - Expanded( - child: Column( + onTap: onTap, + child: Container( + padding: EdgeInsets.fromLTRB(24, 8, 24, 8), + color: Colors.transparent, + child: Row( + mainAxisSize: MainAxisSize.max, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + height: 36, + width: 36, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).extension()!.rowsColor), + child: Image.asset(direction == TransactionDirection.incoming + ? 'assets/images/down_arrow.png' + : 'assets/images/up_arrow.png'), + ), + SizedBox(width: 12), + Expanded( + child: Column( mainAxisSize: MainAxisSize.min, children: [ - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Row( - children: [ - Text(title, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Text( + title, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, @@ -65,28 +69,39 @@ class TransactionRow extends StatelessWidget { ), Text(formattedAmount, style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).extension()!.textColor)) - ]), + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).extension()!.textColor, + ), + ) + ], + ), SizedBox(height: 5), - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Text(formattedDate, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(formattedDate, + style: TextStyle( + fontSize: 14, + color: Theme.of(context) + .extension()! + .dateSectionRowColor)), + Text( + formattedFiatAmount, style: TextStyle( - fontSize: 14, - color: - Theme.of(context).extension()!.dateSectionRowColor)), - Text(formattedFiatAmount, - style: TextStyle( - fontSize: 14, - color: - Theme.of(context).extension()!.dateSectionRowColor)) - ]) + fontSize: 14, + color: Theme.of(context).extension()!.dateSectionRowColor, + ), + ) + ], + ), ], - )) - ], - ), - )); + ), + ) + ], + ), + ), + ); } } diff --git a/lib/src/screens/new_wallet/new_wallet_page.dart b/lib/src/screens/new_wallet/new_wallet_page.dart index 929e3027a..387904df0 100644 --- a/lib/src/screens/new_wallet/new_wallet_page.dart +++ b/lib/src/screens/new_wallet/new_wallet_page.dart @@ -112,10 +112,13 @@ class _WalletNameFormState extends State { context: context, builder: (_) { return AlertWithOneAction( - alertTitle: S.current.new_wallet, - alertContent: state.error, - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop()); + key: ValueKey('new_wallet_page_failure_dialog_key'), + buttonKey: ValueKey('new_wallet_page_failure_dialog_button_key'), + alertTitle: S.current.new_wallet, + alertContent: state.error, + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop(), + ); }); } }); @@ -152,6 +155,7 @@ class _WalletNameFormState extends State { child: Column( children: [ TextFormField( + key: ValueKey('new_wallet_page_wallet_name_textformfield_key'), onChanged: (value) => _walletNewVM.name = value, controller: _nameController, textAlign: TextAlign.center, @@ -182,6 +186,8 @@ class _WalletNameFormState extends State { suffixIcon: Semantics( label: S.of(context).generate_name, child: IconButton( + key: ValueKey( + 'new_wallet_page_wallet_name_textformfield_generate_name_button_key'), onPressed: () async { final rName = await generateName(); FocusManager.instance.primaryFocus?.unfocus(); @@ -297,6 +303,7 @@ class _WalletNameFormState extends State { builder: (BuildContext build) => Padding( padding: EdgeInsets.only(top: 24), child: SelectButton( + key: ValueKey('new_wallet_page_monero_seed_type_button_key'), text: widget._seedSettingsViewModel.moneroSeedType.title, onTap: () async { await showPopUp( @@ -318,6 +325,7 @@ class _WalletNameFormState extends State { padding: EdgeInsets.only(top: 10), child: SeedLanguageSelector( key: _languageSelectorKey, + buttonKey: ValueKey('new_wallet_page_seed_language_selector_button_key'), initialSelected: defaultSeedLanguage, seedType: _walletNewVM.hasSeedType ? widget._seedSettingsViewModel.moneroSeedType @@ -336,6 +344,7 @@ class _WalletNameFormState extends State { Observer( builder: (context) { return LoadingPrimaryButton( + key: ValueKey('new_wallet_page_confirm_button_key'), onPressed: _confirmForm, text: S.of(context).seed_language_next, color: Colors.green, @@ -347,6 +356,7 @@ class _WalletNameFormState extends State { ), const SizedBox(height: 25), GestureDetector( + key: ValueKey('new_wallet_page_advanced_settings_button_key'), onTap: () { Navigator.of(context).pushNamed(Routes.advancedPrivacySettings, arguments: { "type": _walletNewVM.type, diff --git a/lib/src/screens/new_wallet/wallet_group_description_page.dart b/lib/src/screens/new_wallet/wallet_group_description_page.dart index d0936b640..e892e0d49 100644 --- a/lib/src/screens/new_wallet/wallet_group_description_page.dart +++ b/lib/src/screens/new_wallet/wallet_group_description_page.dart @@ -66,6 +66,7 @@ class WalletGroupDescriptionPage extends BasePage { ), ), PrimaryButton( + key: ValueKey('wallet_group_description_page_create_new_seed_button_key'), onPressed: () => Navigator.of(context).pushNamed( Routes.newWallet, arguments: NewWalletArguments(type: selectedWalletType), @@ -76,6 +77,7 @@ class WalletGroupDescriptionPage extends BasePage { ), SizedBox(height: 12), PrimaryButton( + key: ValueKey('wallet_group_description_page_choose_wallet_group_button_key'), onPressed: () => Navigator.of(context).pushNamed( Routes.walletGroupsDisplayPage, arguments: selectedWalletType, diff --git a/lib/src/screens/restore/restore_options_page.dart b/lib/src/screens/restore/restore_options_page.dart index 57f5ec727..299846a72 100644 --- a/lib/src/screens/restore/restore_options_page.dart +++ b/lib/src/screens/restore/restore_options_page.dart @@ -56,7 +56,8 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> { } if (isMoneroOnly) { - return DeviceConnectionType.supportedConnectionTypes(WalletType.monero, Platform.isIOS).isNotEmpty; + return DeviceConnectionType.supportedConnectionTypes(WalletType.monero, Platform.isIOS) + .isNotEmpty; } return true; @@ -80,13 +81,12 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> { child: Column( children: [ OptionTile( - key: ValueKey('restore_options_from_seeds_button_key'), - onPressed: () => - Navigator.pushNamed( - context, - Routes.restoreWalletFromSeedKeys, - arguments: widget.isNewInstall, - ), + key: ValueKey('restore_options_from_seeds_or_keys_button_key'), + onPressed: () => Navigator.pushNamed( + context, + Routes.restoreWalletFromSeedKeys, + arguments: widget.isNewInstall, + ), image: imageSeedKeys, title: S.of(context).restore_title_from_seed_keys, description: S.of(context).restore_description_from_seed_keys, @@ -107,7 +107,8 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> { padding: EdgeInsets.only(top: 24), child: OptionTile( key: ValueKey('restore_options_from_hardware_wallet_button_key'), - onPressed: () => Navigator.pushNamed(context, Routes.restoreWalletFromHardwareWallet, + onPressed: () => Navigator.pushNamed( + context, Routes.restoreWalletFromHardwareWallet, arguments: widget.isNewInstall), image: imageLedger, title: S.of(context).restore_title_from_hardware_wallet, @@ -120,9 +121,9 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> { key: ValueKey('restore_options_from_qr_button_key'), onPressed: () => _onScanQRCode(context), icon: Icon( - Icons.qr_code_rounded, - color: imageColor, - size: 50, + Icons.qr_code_rounded, + color: imageColor, + size: 50, ), title: S.of(context).scan_qr_code, description: S.of(context).cold_or_recover_wallet), @@ -149,20 +150,20 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> { buttonAction: () => Navigator.of(context).pop()); }); }); - } Future _onScanQRCode(BuildContext context) async { - final isCameraPermissionGranted = await PermissionHandler.checkPermission(Permission.camera, context); + final isCameraPermissionGranted = + await PermissionHandler.checkPermission(Permission.camera, context); if (!isCameraPermissionGranted) return; bool isPinSet = false; if (widget.isNewInstall) { await Navigator.pushNamed(context, Routes.setupPin, arguments: (PinCodeState setupPinContext, String _) { - setupPinContext.close(); - isPinSet = true; - }); + setupPinContext.close(); + isPinSet = true; + }); } if (!widget.isNewInstall || isPinSet) { try { @@ -174,7 +175,8 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> { }); final restoreWallet = await WalletRestoreFromQRCode.scanQRCodeForRestoring(context); - final restoreFromQRViewModel = getIt.get(param1: restoreWallet.type); + final restoreFromQRViewModel = + getIt.get(param1: restoreWallet.type); await restoreFromQRViewModel.create(restoreWallet: restoreWallet); if (restoreFromQRViewModel.state is FailureState) { diff --git a/lib/src/screens/restore/wallet_restore_from_seed_form.dart b/lib/src/screens/restore/wallet_restore_from_seed_form.dart index c8a75cf40..1684f6f92 100644 --- a/lib/src/screens/restore/wallet_restore_from_seed_form.dart +++ b/lib/src/screens/restore/wallet_restore_from_seed_form.dart @@ -191,6 +191,7 @@ class WalletRestoreFromSeedFormState extends State { ), if (widget.type == WalletType.monero || widget.type == WalletType.wownero) GestureDetector( + key: ValueKey('wallet_restore_from_seed_seedtype_picker_button_key'), onTap: () async { await showPopUp( context: context, @@ -264,6 +265,7 @@ class WalletRestoreFromSeedFormState extends State { BlockchainHeightWidget( focusNode: widget.blockHeightFocusNode, key: blockchainHeightKey, + blockHeightTextFieldKey: ValueKey('wallet_restore_from_seed_blockheight_textfield_key'), onHeightOrDateEntered: widget.onHeightOrDateEntered, hasDatePicker: widget.type == WalletType.monero || widget.type == WalletType.wownero, walletType: widget.type, diff --git a/lib/src/screens/seed/pre_seed_page.dart b/lib/src/screens/seed/pre_seed_page.dart index 730dfa5f8..23de4564f 100644 --- a/lib/src/screens/seed/pre_seed_page.dart +++ b/lib/src/screens/seed/pre_seed_page.dart @@ -15,13 +15,15 @@ class PreSeedPage extends InfoPage { String get pageTitle => S.current.pre_seed_title; @override - String get pageDescription => - S.current.pre_seed_description(seedPhraseLength.toString()); + String get pageDescription => S.current.pre_seed_description(seedPhraseLength.toString()); @override String get buttonText => S.current.pre_seed_button_text; @override - void Function(BuildContext) get onPressed => (BuildContext context) => - Navigator.of(context).popAndPushNamed(Routes.seed, arguments: true); + Key? get buttonKey => ValueKey('pre_seed_page_button_key'); + + @override + void Function(BuildContext) get onPressed => + (BuildContext context) => Navigator.of(context).popAndPushNamed(Routes.seed, arguments: true); } diff --git a/lib/src/screens/seed/wallet_seed_page.dart b/lib/src/screens/seed/wallet_seed_page.dart index 200b87b7d..10160839c 100644 --- a/lib/src/screens/seed/wallet_seed_page.dart +++ b/lib/src/screens/seed/wallet_seed_page.dart @@ -33,16 +33,22 @@ class WalletSeedPage extends BasePage { void onClose(BuildContext context) async { if (isNewWalletCreated) { final confirmed = await showPopUp( - context: context, - builder: (BuildContext context) { - return AlertWithTwoActions( - alertTitle: S.of(context).seed_alert_title, - alertContent: S.of(context).seed_alert_content, - leftButtonText: S.of(context).seed_alert_back, - rightButtonText: S.of(context).seed_alert_yes, - actionLeftButton: () => Navigator.of(context).pop(false), - actionRightButton: () => Navigator.of(context).pop(true)); - }) ?? + context: context, + builder: (BuildContext context) { + return AlertWithTwoActions( + alertDialogKey: ValueKey('wallet_seed_page_seed_alert_dialog_key'), + alertRightActionButtonKey: + ValueKey('wallet_seed_page_seed_alert_confirm_button_key'), + alertLeftActionButtonKey: ValueKey('wallet_seed_page_seed_alert_back_button_key'), + alertTitle: S.of(context).seed_alert_title, + alertContent: S.of(context).seed_alert_content, + leftButtonText: S.of(context).seed_alert_back, + rightButtonText: S.of(context).seed_alert_yes, + actionLeftButton: () => Navigator.of(context).pop(false), + actionRightButton: () => Navigator.of(context).pop(true), + ); + }, + ) ?? false; if (confirmed) { @@ -62,6 +68,7 @@ class WalletSeedPage extends BasePage { Widget trailing(BuildContext context) { return isNewWalletCreated ? GestureDetector( + key: ValueKey('wallet_seed_page_next_button_key'), onTap: () => onClose(context), child: Container( width: 100, @@ -74,9 +81,9 @@ class WalletSeedPage extends BasePage { child: Text( S.of(context).seed_language_next, style: TextStyle( - fontSize: 14, fontWeight: FontWeight.w600, color: Theme.of(context) - .extension()! - .buttonTextColor), + fontSize: 14, + fontWeight: FontWeight.w600, + color: Theme.of(context).extension()!.buttonTextColor), ), ), ) @@ -93,7 +100,8 @@ class WalletSeedPage extends BasePage { padding: EdgeInsets.all(24), alignment: Alignment.center, child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint), + constraints: + BoxConstraints(maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint), child: Column( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ @@ -106,6 +114,7 @@ class WalletSeedPage extends BasePage { crossAxisAlignment: CrossAxisAlignment.center, children: [ Text( + key: ValueKey('wallet_seed_page_wallet_name_text_key'), walletSeedViewModel.name, style: TextStyle( fontSize: 20, @@ -115,12 +124,14 @@ class WalletSeedPage extends BasePage { Padding( padding: EdgeInsets.only(top: 20, left: 16, right: 16), child: Text( + key: ValueKey('wallet_seed_page_wallet_seed_text_key'), walletSeedViewModel.seed, textAlign: TextAlign.center, style: TextStyle( fontSize: 14, fontWeight: FontWeight.normal, - color: Theme.of(context).extension()!.secondaryTextColor), + color: + Theme.of(context).extension()!.secondaryTextColor), ), ) ], @@ -132,12 +143,18 @@ class WalletSeedPage extends BasePage { ? Padding( padding: EdgeInsets.only(bottom: 43, left: 43, right: 43), child: Text( + key: ValueKey( + 'wallet_seed_page_wallet_seed_reminder_text_key', + ), S.of(context).seed_reminder, textAlign: TextAlign.center, style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.normal, - color: Theme.of(context).extension()!.detailsTitlesColor), + fontSize: 12, + fontWeight: FontWeight.normal, + color: Theme.of(context) + .extension()! + .detailsTitlesColor, + ), ), ) : Offstage(), @@ -145,9 +162,10 @@ class WalletSeedPage extends BasePage { mainAxisSize: MainAxisSize.max, children: [ Flexible( - child: Container( - padding: EdgeInsets.only(right: 8.0), - child: PrimaryButton( + child: Container( + padding: EdgeInsets.only(right: 8.0), + child: PrimaryButton( + key: ValueKey('wallet_seed_page_save_seeds_button_key'), onPressed: () { ShareUtil.share( text: walletSeedViewModel.seed, @@ -156,22 +174,29 @@ class WalletSeedPage extends BasePage { }, text: S.of(context).save, color: Colors.green, - textColor: Colors.white), - )), + textColor: Colors.white, + ), + ), + ), Flexible( - child: Container( - padding: EdgeInsets.only(left: 8.0), - child: Builder( + child: Container( + padding: EdgeInsets.only(left: 8.0), + child: Builder( builder: (context) => PrimaryButton( - onPressed: () { - ClipboardUtil.setSensitiveDataToClipboard( - ClipboardData(text: walletSeedViewModel.seed)); - showBar(context, S.of(context).copied_to_clipboard); - }, - text: S.of(context).copy, - color: Theme.of(context).extension()!.indicatorsColor, - textColor: Colors.white)), - )) + key: ValueKey('wallet_seed_page_copy_seeds_button_key'), + onPressed: () { + ClipboardUtil.setSensitiveDataToClipboard( + ClipboardData(text: walletSeedViewModel.seed), + ); + showBar(context, S.of(context).copied_to_clipboard); + }, + text: S.of(context).copy, + color: Theme.of(context).extension()!.indicatorsColor, + textColor: Colors.white, + ), + ), + ), + ) ], ) ], diff --git a/lib/src/screens/settings/security_backup_page.dart b/lib/src/screens/settings/security_backup_page.dart index 04ae53d77..bb1c00ff5 100644 --- a/lib/src/screens/settings/security_backup_page.dart +++ b/lib/src/screens/settings/security_backup_page.dart @@ -16,7 +16,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; class SecurityBackupPage extends BasePage { - SecurityBackupPage(this._securitySettingsViewModel, this._authService, [this._isHardwareWallet = false]); + SecurityBackupPage(this._securitySettingsViewModel, this._authService, + [this._isHardwareWallet = false]); final AuthService _authService; @@ -30,10 +31,13 @@ class SecurityBackupPage extends BasePage { @override Widget body(BuildContext context) { return Container( - padding: EdgeInsets.only(top: 10), - child: Column(mainAxisSize: MainAxisSize.min, children: [ + padding: EdgeInsets.only(top: 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ if (!_isHardwareWallet) SettingsCellWithArrow( + key: ValueKey('security_backup_page_show_keys_button_key'), title: S.current.show_keys, handler: (_) => _authService.authenticateAction( context, @@ -44,15 +48,17 @@ class SecurityBackupPage extends BasePage { ), if (!SettingsStoreBase.walletPasswordDirectInput) SettingsCellWithArrow( + key: ValueKey('security_backup_page_create_backup_button_key'), title: S.current.create_backup, handler: (_) => _authService.authenticateAction( context, route: Routes.backup, - conditionToDetermineIfToUse2FA: _securitySettingsViewModel - .shouldRequireTOTP2FAForAllSecurityAndBackupSettings, + conditionToDetermineIfToUse2FA: + _securitySettingsViewModel.shouldRequireTOTP2FAForAllSecurityAndBackupSettings, ), ), SettingsCellWithArrow( + key: ValueKey('security_backup_page_change_pin_button_key'), title: S.current.settings_change_pin, handler: (_) => _authService.authenticateAction( context, @@ -60,28 +66,30 @@ class SecurityBackupPage extends BasePage { arguments: (PinCodeState setupPinContext, String _) { setupPinContext.close(); }, - conditionToDetermineIfToUse2FA: _securitySettingsViewModel - .shouldRequireTOTP2FAForAllSecurityAndBackupSettings, + conditionToDetermineIfToUse2FA: + _securitySettingsViewModel.shouldRequireTOTP2FAForAllSecurityAndBackupSettings, ), ), if (DeviceInfo.instance.isMobile || Platform.isMacOS || Platform.isLinux) Observer(builder: (_) { return SettingsSwitcherCell( + key: ValueKey('security_backup_page_allow_biometrics_button_key'), title: S.current.settings_allow_biometrical_authentication, value: _securitySettingsViewModel.allowBiometricalAuthentication, onValueChange: (BuildContext context, bool value) { if (value) { - _authService.authenticateAction(context, - onAuthSuccess: (isAuthenticatedSuccessfully) async { - if (isAuthenticatedSuccessfully) { - if (await _securitySettingsViewModel.biometricAuthenticated()) { + _authService.authenticateAction( + context, + onAuthSuccess: (isAuthenticatedSuccessfully) async { + if (isAuthenticatedSuccessfully) { + if (await _securitySettingsViewModel.biometricAuthenticated()) { + _securitySettingsViewModel + .setAllowBiometricalAuthentication(isAuthenticatedSuccessfully); + } + } else { _securitySettingsViewModel .setAllowBiometricalAuthentication(isAuthenticatedSuccessfully); } - } else { - _securitySettingsViewModel - .setAllowBiometricalAuthentication(isAuthenticatedSuccessfully); - } }, conditionToDetermineIfToUse2FA: _securitySettingsViewModel .shouldRequireTOTP2FAForAllSecurityAndBackupSettings, @@ -93,6 +101,7 @@ class SecurityBackupPage extends BasePage { }), Observer(builder: (_) { return SettingsPickerCell( + key: ValueKey('security_backup_page_require_pin_after_button_key'), title: S.current.require_pin_after, items: PinCodeRequiredDuration.values, selectedItem: _securitySettingsViewModel.pinCodeRequiredDuration, @@ -104,14 +113,15 @@ class SecurityBackupPage extends BasePage { Observer( builder: (context) { return SettingsCellWithArrow( + key: ValueKey('security_backup_page_totp_2fa_button_key'), title: _securitySettingsViewModel.useTotp2FA ? S.current.modify_2fa : S.current.setup_2fa, - handler: (_) => _authService.authenticateAction( - context, - route: _securitySettingsViewModel.useTotp2FA - ? Routes.modify2FAPage - : Routes.setup2faInfoPage, + handler: (_) => _authService.authenticateAction( + context, + route: _securitySettingsViewModel.useTotp2FA + ? Routes.modify2FAPage + : Routes.setup2faInfoPage, conditionToDetermineIfToUse2FA: _securitySettingsViewModel .shouldRequireTOTP2FAForAllSecurityAndBackupSettings, ), diff --git a/lib/src/screens/settings/widgets/settings_cell_with_arrow.dart b/lib/src/screens/settings/widgets/settings_cell_with_arrow.dart index f0e19a715..cb4f9dc78 100644 --- a/lib/src/screens/settings/widgets/settings_cell_with_arrow.dart +++ b/lib/src/screens/settings/widgets/settings_cell_with_arrow.dart @@ -3,8 +3,11 @@ import 'package:cake_wallet/src/widgets/standard_list.dart'; import 'package:cake_wallet/themes/extensions/transaction_trade_theme.dart'; class SettingsCellWithArrow extends StandardListRow { - SettingsCellWithArrow({required String title, required Function(BuildContext context)? handler}) - : super(title: title, isSelected: false, onTap: handler); + SettingsCellWithArrow({ + required String title, + required Function(BuildContext context)? handler, + Key? key, + }) : super(title: title, isSelected: false, onTap: handler, key: key); @override Widget buildTrailing(BuildContext context) => Image.asset('assets/images/select_arrow.png', diff --git a/lib/src/screens/settings/widgets/settings_picker_cell.dart b/lib/src/screens/settings/widgets/settings_picker_cell.dart index 8e0492330..765ac2991 100644 --- a/lib/src/screens/settings/widgets/settings_picker_cell.dart +++ b/lib/src/screens/settings/widgets/settings_picker_cell.dart @@ -5,19 +5,21 @@ import 'package:cake_wallet/src/widgets/picker.dart'; import 'package:cake_wallet/src/widgets/standard_list.dart'; class SettingsPickerCell extends StandardListRow { - SettingsPickerCell( - {required String title, - required this.selectedItem, - required this.items, - this.displayItem, - this.images, - this.searchHintText, - this.isGridView = false, - this.matchingCriteria, - this.onItemSelected}) - : super( + SettingsPickerCell({ + required String title, + required this.selectedItem, + required this.items, + this.displayItem, + this.images, + this.searchHintText, + this.isGridView = false, + this.matchingCriteria, + this.onItemSelected, + Key? key, + }) : super( title: title, isSelected: false, + key: key, onTap: (BuildContext context) async { final selectedAtIndex = items.indexOf(selectedItem); diff --git a/lib/src/screens/settings/widgets/settings_switcher_cell.dart b/lib/src/screens/settings/widgets/settings_switcher_cell.dart index 0e5c04524..6a3c8b4a0 100644 --- a/lib/src/screens/settings/widgets/settings_switcher_cell.dart +++ b/lib/src/screens/settings/widgets/settings_switcher_cell.dart @@ -10,7 +10,8 @@ class SettingsSwitcherCell extends StandardListRow { Decoration? decoration, this.leading, void Function(BuildContext context)? onTap, - }) : super(title: title, isSelected: false, decoration: decoration, onTap: onTap); + Key? key, + }) : super(title: title, isSelected: false, decoration: decoration, onTap: onTap, key: key); final bool value; final void Function(BuildContext context, bool value)? onValueChange; diff --git a/lib/src/screens/setup_2fa/setup_2fa_info_page.dart b/lib/src/screens/setup_2fa/setup_2fa_info_page.dart index ff6187665..8aa0ac3c9 100644 --- a/lib/src/screens/setup_2fa/setup_2fa_info_page.dart +++ b/lib/src/screens/setup_2fa/setup_2fa_info_page.dart @@ -4,7 +4,6 @@ import 'package:cake_wallet/src/screens/InfoPage.dart'; import 'package:flutter/cupertino.dart'; class Setup2FAInfoPage extends InfoPage { - @override String get pageTitle => S.current.pre_seed_title; @@ -15,6 +14,9 @@ class Setup2FAInfoPage extends InfoPage { String get buttonText => S.current.understand; @override - void Function(BuildContext) get onPressed => (BuildContext context) => - Navigator.of(context).popAndPushNamed(Routes.setup_2faPage); + Key? get buttonKey => ValueKey('setup_2fa_info_page_button_key'); + + @override + void Function(BuildContext) get onPressed => + (BuildContext context) => Navigator.of(context).popAndPushNamed(Routes.setup_2faPage); } diff --git a/lib/src/screens/transaction_details/blockexplorer_list_item.dart b/lib/src/screens/transaction_details/blockexplorer_list_item.dart index d5a70daa7..0126a147a 100644 --- a/lib/src/screens/transaction_details/blockexplorer_list_item.dart +++ b/lib/src/screens/transaction_details/blockexplorer_list_item.dart @@ -1,7 +1,12 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart'; +import 'package:flutter/foundation.dart'; class BlockExplorerListItem extends TransactionDetailsListItem { - BlockExplorerListItem({required String title, required String value, required this.onTap}) - : super(title: title, value: value); + BlockExplorerListItem({ + required String title, + required String value, + required this.onTap, + Key? key, + }) : super(title: title, value: value, key: key); final Function() onTap; } diff --git a/lib/src/screens/transaction_details/rbf_details_list_fee_picker_item.dart b/lib/src/screens/transaction_details/rbf_details_list_fee_picker_item.dart index db3d94500..96ddc94cd 100644 --- a/lib/src/screens/transaction_details/rbf_details_list_fee_picker_item.dart +++ b/lib/src/screens/transaction_details/rbf_details_list_fee_picker_item.dart @@ -1,18 +1,20 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart'; +import 'package:flutter/widgets.dart'; class StandardPickerListItem extends TransactionDetailsListItem { - StandardPickerListItem( - {required String title, - required String value, - required this.items, - required this.displayItem, - required this.onSliderChanged, - required this.onItemSelected, - required this.selectedIdx, - required this.customItemIndex, - this.maxValue, - required this.customValue}) - : super(title: title, value: value); + StandardPickerListItem({ + required String title, + required String value, + required this.items, + required this.displayItem, + required this.onSliderChanged, + required this.onItemSelected, + required this.selectedIdx, + required this.customItemIndex, + this.maxValue, + required this.customValue, + Key? key, + }) : super(title: title, value: value, key: key); final List items; final String Function(T item, double sliderValue) displayItem; diff --git a/lib/src/screens/transaction_details/standart_list_item.dart b/lib/src/screens/transaction_details/standart_list_item.dart index 705daf970..673eabe42 100644 --- a/lib/src/screens/transaction_details/standart_list_item.dart +++ b/lib/src/screens/transaction_details/standart_list_item.dart @@ -1,6 +1,9 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart'; class StandartListItem extends TransactionDetailsListItem { - StandartListItem({required String title, required String value}) - : super(title: title, value: value); + StandartListItem({ + required String super.title, + required String super.value, + super.key, + }); } diff --git a/lib/src/screens/transaction_details/textfield_list_item.dart b/lib/src/screens/transaction_details/textfield_list_item.dart index 49ef2705e..846f9acd5 100644 --- a/lib/src/screens/transaction_details/textfield_list_item.dart +++ b/lib/src/screens/transaction_details/textfield_list_item.dart @@ -1,11 +1,17 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart'; +import 'package:flutter/foundation.dart'; class TextFieldListItem extends TransactionDetailsListItem { TextFieldListItem({ required String title, required String value, - required this.onSubmitted}) - : super(title: title, value: value); + required this.onSubmitted, + Key? key, + }) : super( + title: title, + value: value, + key: key, + ); final Function(String value) onSubmitted; } \ No newline at end of file diff --git a/lib/src/screens/transaction_details/transaction_details_list_item.dart b/lib/src/screens/transaction_details/transaction_details_list_item.dart index 8a8449350..446383d72 100644 --- a/lib/src/screens/transaction_details/transaction_details_list_item.dart +++ b/lib/src/screens/transaction_details/transaction_details_list_item.dart @@ -1,6 +1,9 @@ +import 'package:flutter/foundation.dart'; + abstract class TransactionDetailsListItem { - TransactionDetailsListItem({required this.title, required this.value}); + TransactionDetailsListItem({required this.title, required this.value, this.key}); final String title; final String value; -} \ No newline at end of file + final Key? key; +} diff --git a/lib/src/screens/transaction_details/transaction_details_page.dart b/lib/src/screens/transaction_details/transaction_details_page.dart index 1b088fc31..9484bf4da 100644 --- a/lib/src/screens/transaction_details/transaction_details_page.dart +++ b/lib/src/screens/transaction_details/transaction_details_page.dart @@ -33,38 +33,42 @@ class TransactionDetailsPage extends BasePage { children: [ Expanded( child: SectionStandardList( - sectionCount: 1, - itemCounter: (int _) => transactionDetailsViewModel.items.length, - itemBuilder: (__, index) { - final item = transactionDetailsViewModel.items[index]; + sectionCount: 1, + itemCounter: (int _) => transactionDetailsViewModel.items.length, + itemBuilder: (__, index) { + final item = transactionDetailsViewModel.items[index]; - if (item is StandartListItem) { - return GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData(text: item.value)); - showBar(context, S.of(context).transaction_details_copied(item.title)); - }, - child: ListRow(title: '${item.title}:', value: item.value), - ); - } + if (item is StandartListItem) { + return GestureDetector( + key: item.key, + onTap: () { + Clipboard.setData(ClipboardData(text: item.value)); + showBar(context, S.of(context).transaction_details_copied(item.title)); + }, + child: ListRow(title: '${item.title}:', value: item.value), + ); + } - if (item is BlockExplorerListItem) { - return GestureDetector( - onTap: item.onTap, - child: ListRow(title: '${item.title}:', value: item.value), - ); - } + if (item is BlockExplorerListItem) { + return GestureDetector( + key: item.key, + onTap: item.onTap, + child: ListRow(title: '${item.title}:', value: item.value), + ); + } - if (item is TextFieldListItem) { - return TextFieldListRow( - title: item.title, - value: item.value, - onSubmitted: item.onSubmitted, - ); - } + if (item is TextFieldListItem) { + return TextFieldListRow( + key: item.key, + title: item.title, + value: item.value, + onSubmitted: item.onSubmitted, + ); + } - return Container(); - }), + return Container(); + }, + ), ), Observer( builder: (_) { diff --git a/lib/src/screens/transaction_details/transaction_expandable_list_item.dart b/lib/src/screens/transaction_details/transaction_expandable_list_item.dart index e87405de3..db6cf22ae 100644 --- a/lib/src/screens/transaction_details/transaction_expandable_list_item.dart +++ b/lib/src/screens/transaction_details/transaction_expandable_list_item.dart @@ -1,7 +1,12 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_details_list_item.dart'; +import 'package:flutter/foundation.dart'; class StandardExpandableListItem extends TransactionDetailsListItem { - StandardExpandableListItem({required String title, required this.expandableItems}) - : super(title: title, value: ''); + StandardExpandableListItem({ + required String title, + required this.expandableItems, + Key? key, + }) : super(title: title, value: '', key: key); + final List expandableItems; } diff --git a/lib/src/screens/transaction_details/widgets/textfield_list_row.dart b/lib/src/screens/transaction_details/widgets/textfield_list_row.dart index a86645ecb..24016a293 100644 --- a/lib/src/screens/transaction_details/widgets/textfield_list_row.dart +++ b/lib/src/screens/transaction_details/widgets/textfield_list_row.dart @@ -4,12 +4,14 @@ import 'package:cake_wallet/themes/extensions/transaction_trade_theme.dart'; import 'package:flutter/material.dart'; class TextFieldListRow extends StatefulWidget { - TextFieldListRow( - {required this.title, - required this.value, - this.titleFontSize = 14, - this.valueFontSize = 16, - this.onSubmitted}); + TextFieldListRow({ + required this.title, + required this.value, + this.titleFontSize = 14, + this.valueFontSize = 16, + this.onSubmitted, + super.key, + }); final String title; final String value; diff --git a/lib/src/screens/wallet_keys/wallet_keys_page.dart b/lib/src/screens/wallet_keys/wallet_keys_page.dart index 5117f152f..fac760516 100644 --- a/lib/src/screens/wallet_keys/wallet_keys_page.dart +++ b/lib/src/screens/wallet_keys/wallet_keys_page.dart @@ -25,23 +25,25 @@ class WalletKeysPage extends BasePage { @override Widget trailing(BuildContext context) => IconButton( - onPressed: () async { - final url = await walletKeysViewModel.url; + key: ValueKey('wallet_keys_page_fullscreen_qr_button_key'), + onPressed: () async { + final url = await walletKeysViewModel.url; - BrightnessUtil.changeBrightnessForFunction(() async { - await Navigator.pushNamed( - context, - Routes.fullscreenQR, - arguments: QrViewData(data: url.toString(), version: QrVersions.auto), - ); - }); - }, - splashColor: Colors.transparent, - highlightColor: Colors.transparent, - hoverColor: Colors.transparent, - icon: Image.asset( - 'assets/images/qr_code_icon.png', - )); + BrightnessUtil.changeBrightnessForFunction(() async { + await Navigator.pushNamed( + context, + Routes.fullscreenQR, + arguments: QrViewData(data: url.toString(), version: QrVersions.auto), + ); + }); + }, + splashColor: Colors.transparent, + highlightColor: Colors.transparent, + hoverColor: Colors.transparent, + icon: Image.asset( + 'assets/images/qr_code_icon.png', + ), + ); @override Widget body(BuildContext context) { @@ -60,6 +62,7 @@ class WalletKeysPage extends BasePage { child: Padding( padding: const EdgeInsets.all(8.0), child: AutoSizeText( + key: ValueKey('wallet_keys_page_share_warning_text_key'), S.of(context).do_not_share_warning_text.toUpperCase(), textAlign: TextAlign.center, maxLines: 4, @@ -92,6 +95,7 @@ class WalletKeysPage extends BasePage { final item = walletKeysViewModel.items[index]; return GestureDetector( + key: item.key, onTap: () { ClipboardUtil.setSensitiveDataToClipboard(ClipboardData(text: item.value)); showBar(context, S.of(context).copied_key_to_clipboard(item.title)); diff --git a/lib/src/screens/wallet_list/wallet_list_page.dart b/lib/src/screens/wallet_list/wallet_list_page.dart index afb86cf0a..a9a9d9413 100644 --- a/lib/src/screens/wallet_list/wallet_list_page.dart +++ b/lib/src/screens/wallet_list/wallet_list_page.dart @@ -318,6 +318,7 @@ class WalletListBodyState extends State { child: Column( children: [ PrimaryImageButton( + key: ValueKey('wallet_list_page_create_new_wallet_button_key'), onPressed: () { //TODO(David): Find a way to optimize this if (isSingleCoin) { @@ -359,6 +360,7 @@ class WalletListBodyState extends State { ), SizedBox(height: 10.0), PrimaryImageButton( + key: ValueKey('wallet_list_page_restore_wallet_button_key'), onPressed: () { if (widget.walletListViewModel.shouldRequireTOTP2FAForCreatingNewWallets) { widget.authService.authenticateAction( diff --git a/lib/src/widgets/blockchain_height_widget.dart b/lib/src/widgets/blockchain_height_widget.dart index 9d66c1789..1c6b6da02 100644 --- a/lib/src/widgets/blockchain_height_widget.dart +++ b/lib/src/widgets/blockchain_height_widget.dart @@ -23,6 +23,7 @@ class BlockchainHeightWidget extends StatefulWidget { this.doSingleScan = false, this.bitcoinMempoolAPIEnabled, required this.walletType, + this.blockHeightTextFieldKey, }) : super(key: key); final Function(int)? onHeightChange; @@ -35,6 +36,7 @@ class BlockchainHeightWidget extends StatefulWidget { final Future? bitcoinMempoolAPIEnabled; final Function()? toggleSingleScan; final WalletType walletType; + final Key? blockHeightTextFieldKey; @override State createState() => BlockchainHeightState(); @@ -81,6 +83,7 @@ class BlockchainHeightState extends State { child: Container( padding: EdgeInsets.only(top: 20.0, bottom: 10.0), child: BaseTextFormField( + key: widget.blockHeightTextFieldKey, focusNode: widget.focusNode, controller: restoreHeightController, keyboardType: diff --git a/lib/src/widgets/dashboard_card_widget.dart b/lib/src/widgets/dashboard_card_widget.dart index d9b545040..dc223eb02 100644 --- a/lib/src/widgets/dashboard_card_widget.dart +++ b/lib/src/widgets/dashboard_card_widget.dart @@ -15,6 +15,7 @@ class DashBoardRoundedCardWidget extends StatelessWidget { this.icon, this.onClose, this.customBorder, + super.key, }); final VoidCallback onTap; diff --git a/lib/src/widgets/option_tile.dart b/lib/src/widgets/option_tile.dart index 31f958f54..c2d8b9506 100644 --- a/lib/src/widgets/option_tile.dart +++ b/lib/src/widgets/option_tile.dart @@ -2,14 +2,14 @@ import 'package:cake_wallet/themes/extensions/option_tile_theme.dart'; import 'package:flutter/material.dart'; class OptionTile extends StatelessWidget { - const OptionTile( - {required this.onPressed, - this.image, - this.icon, - required this.title, - required this.description, - super.key}) - : assert(image!=null || icon!=null); + const OptionTile({ + required this.onPressed, + this.image, + this.icon, + required this.title, + required this.description, + super.key, + }) : assert(image != null || icon != null); final VoidCallback onPressed; final Image? image; @@ -34,7 +34,7 @@ class OptionTile extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.start, children: [ - icon ?? image!, + icon ?? image!, Expanded( child: Padding( padding: EdgeInsets.only(left: 16), diff --git a/lib/src/widgets/picker.dart b/lib/src/widgets/picker.dart index 801a79595..ed5c05be6 100644 --- a/lib/src/widgets/picker.dart +++ b/lib/src/widgets/picker.dart @@ -2,6 +2,7 @@ import 'dart:math'; +import 'package:cake_wallet/entities/seed_type.dart'; import 'package:cake_wallet/src/widgets/search_bar_widget.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cw_core/transaction_priority.dart'; @@ -310,6 +311,8 @@ class _PickerState extends State> { itemName = item.name; } else if (item is TransactionPriority) { itemName = item.title; + } else if (item is MoneroSeedType) { + itemName = item.title; } else { itemName = ''; } diff --git a/lib/src/widgets/seed_language_selector.dart b/lib/src/widgets/seed_language_selector.dart index 87f3aa573..711bff390 100644 --- a/lib/src/widgets/seed_language_selector.dart +++ b/lib/src/widgets/seed_language_selector.dart @@ -6,12 +6,16 @@ import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:flutter/material.dart'; class SeedLanguageSelector extends StatefulWidget { - SeedLanguageSelector( - {Key? key, required this.initialSelected, this.seedType = MoneroSeedType.defaultSeedType}) - : super(key: key); + SeedLanguageSelector({ + required this.initialSelected, + this.seedType = MoneroSeedType.defaultSeedType, + this.buttonKey, + Key? key, + }) : super(key: key); final String initialSelected; final MoneroSeedType seedType; + final Key? buttonKey; @override SeedLanguageSelectorState createState() => SeedLanguageSelectorState(selected: initialSelected); @@ -25,6 +29,7 @@ class SeedLanguageSelectorState extends State { @override Widget build(BuildContext context) { return SelectButton( + key: widget.buttonKey, image: null, text: "${seedLanguages.firstWhere((e) => e.name == selected).nameLocalized} (${S.of(context).seed_language})", diff --git a/lib/src/widgets/setting_actions.dart b/lib/src/widgets/setting_actions.dart index b9af97f32..048d006cc 100644 --- a/lib/src/widgets/setting_actions.dart +++ b/lib/src/widgets/setting_actions.dart @@ -5,9 +5,11 @@ import 'package:flutter/material.dart'; class SettingActions { final String Function(BuildContext) name; final String image; + final Key key; final void Function(BuildContext) onTap; SettingActions._({ + required this.key, required this.name, required this.image, required this.onTap, @@ -39,6 +41,7 @@ class SettingActions { ]; static SettingActions silentPaymentsSettingAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_silent_payment_settings_button_key'), name: (context) => S.of(context).silent_payments_settings, image: 'assets/images/bitcoin_menu.png', onTap: (BuildContext context) { @@ -48,6 +51,7 @@ class SettingActions { ); static SettingActions litecoinMwebSettingAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_litecoin_mweb_settings_button_key'), name: (context) => S.of(context).litecoin_mweb_settings, image: 'assets/images/litecoin_menu.png', onTap: (BuildContext context) { @@ -57,6 +61,7 @@ class SettingActions { ); static SettingActions connectionSettingAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_connection_and_sync_settings_button_key'), name: (context) => S.of(context).connection_sync, image: 'assets/images/nodes_menu.png', onTap: (BuildContext context) { @@ -66,6 +71,7 @@ class SettingActions { ); static SettingActions walletSettingAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_wallet_menu_button_key'), name: (context) => S.of(context).wallets, image: 'assets/images/wallet_menu.png', onTap: (BuildContext context) { @@ -75,6 +81,7 @@ class SettingActions { ); static SettingActions addressBookSettingAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_address_book_button_key'), name: (context) => S.of(context).address_book_menu, image: 'assets/images/open_book_menu.png', onTap: (BuildContext context) { @@ -84,6 +91,7 @@ class SettingActions { ); static SettingActions securityBackupSettingAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_security_and_backup_button_key'), name: (context) => S.of(context).security_and_backup, image: 'assets/images/key_menu.png', onTap: (BuildContext context) { @@ -93,6 +101,7 @@ class SettingActions { ); static SettingActions privacySettingAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_privacy_settings_button_key'), name: (context) => S.of(context).privacy, image: 'assets/images/privacy_menu.png', onTap: (BuildContext context) { @@ -102,6 +111,7 @@ class SettingActions { ); static SettingActions displaySettingAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_display_settings_button_key'), name: (context) => S.of(context).display_settings, image: 'assets/images/eye_menu.png', onTap: (BuildContext context) { @@ -111,6 +121,7 @@ class SettingActions { ); static SettingActions otherSettingAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_other_settings_button_key'), name: (context) => S.of(context).other_settings, image: 'assets/images/settings_menu.png', onTap: (BuildContext context) { @@ -120,6 +131,7 @@ class SettingActions { ); static SettingActions supportSettingAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_support_settings_button_key'), name: (context) => S.of(context).settings_support, image: 'assets/images/question_mark.png', onTap: (BuildContext context) { diff --git a/lib/src/widgets/standard_list.dart b/lib/src/widgets/standard_list.dart index c1fcae052..0780d64cd 100644 --- a/lib/src/widgets/standard_list.dart +++ b/lib/src/widgets/standard_list.dart @@ -4,7 +4,13 @@ import 'package:cake_wallet/src/widgets/standard_list_status_row.dart'; import 'package:flutter/material.dart'; class StandardListRow extends StatelessWidget { - StandardListRow({required this.title, required this.isSelected, this.onTap, this.decoration}); + StandardListRow({ + required this.title, + required this.isSelected, + this.onTap, + this.decoration, + super.key, + }); final String title; final bool isSelected; diff --git a/lib/store/anonpay/anonpay_transactions_store.dart b/lib/store/anonpay/anonpay_transactions_store.dart index c6f05b993..d0384ca5a 100644 --- a/lib/store/anonpay/anonpay_transactions_store.dart +++ b/lib/store/anonpay/anonpay_transactions_store.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart'; import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; +import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; @@ -27,7 +28,10 @@ abstract class AnonpayTransactionsStoreBase with Store { Future updateTransactionList() async { transactions = anonpayInvoiceInfoSource.values .map( - (transaction) => AnonpayTransactionListItem(transaction: transaction), + (transaction) => AnonpayTransactionListItem( + transaction: transaction, + key: ValueKey('anonpay_invoice_transaction_list_item_${transaction.invoiceId}_key'), + ), ) .toList(); } diff --git a/lib/store/dashboard/orders_store.dart b/lib/store/dashboard/orders_store.dart index b5ec658f7..d91ad3512 100644 --- a/lib/store/dashboard/orders_store.dart +++ b/lib/store/dashboard/orders_store.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cake_wallet/buy/order.dart'; import 'package:cake_wallet/view_model/dashboard/order_list_item.dart'; +import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:cake_wallet/store/settings_store.dart'; @@ -10,12 +11,10 @@ part 'orders_store.g.dart'; class OrdersStore = OrdersStoreBase with _$OrdersStore; abstract class OrdersStoreBase with Store { - OrdersStoreBase({required this.ordersSource, - required this.settingsStore}) - : orders = [], - orderId = '' { - _onOrdersChanged = - ordersSource.watch().listen((_) async => await updateOrderList()); + OrdersStoreBase({required this.ordersSource, required this.settingsStore}) + : orders = [], + orderId = '' { + _onOrdersChanged = ordersSource.watch().listen((_) async => await updateOrderList()); updateOrderList(); } @@ -38,8 +37,11 @@ abstract class OrdersStoreBase with Store { void setOrder(Order order) => this.order = order; @action - Future updateOrderList() async => orders = - ordersSource.values.map((order) => OrderListItem( - order: order, - settingsStore: settingsStore)).toList(); -} \ No newline at end of file + Future updateOrderList() async => orders = ordersSource.values + .map((order) => OrderListItem( + order: order, + settingsStore: settingsStore, + key: ValueKey('order_list_item_${order.id}_key'), + )) + .toList(); +} diff --git a/lib/store/dashboard/trades_store.dart b/lib/store/dashboard/trades_store.dart index 72442b46f..d0a4592e3 100644 --- a/lib/store/dashboard/trades_store.dart +++ b/lib/store/dashboard/trades_store.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:cake_wallet/exchange/trade.dart'; import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart'; +import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:cake_wallet/store/settings_store.dart'; @@ -11,9 +12,8 @@ class TradesStore = TradesStoreBase with _$TradesStore; abstract class TradesStoreBase with Store { TradesStoreBase({required this.tradesSource, required this.settingsStore}) - : trades = [] { - _onTradesChanged = - tradesSource.watch().listen((_) async => await updateTradeList()); + : trades = [] { + _onTradesChanged = tradesSource.watch().listen((_) async => await updateTradeList()); updateTradeList(); } @@ -31,8 +31,11 @@ abstract class TradesStoreBase with Store { void setTrade(Trade trade) => this.trade = trade; @action - Future updateTradeList() async => trades = - tradesSource.values.map((trade) => TradeListItem( - trade: trade, - settingsStore: settingsStore)).toList(); -} \ No newline at end of file + Future updateTradeList() async => trades = tradesSource.values + .map((trade) => TradeListItem( + trade: trade, + settingsStore: settingsStore, + key: ValueKey('trade_list_item_${trade.id}_key'), + )) + .toList(); +} diff --git a/lib/utils/date_formatter.dart b/lib/utils/date_formatter.dart index 9cff68614..58e59966a 100644 --- a/lib/utils/date_formatter.dart +++ b/lib/utils/date_formatter.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/generated/i18n.dart'; import 'package:intl/intl.dart'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/store/settings_store.dart'; @@ -5,8 +6,7 @@ import 'package:cake_wallet/store/settings_store.dart'; class DateFormatter { static String currentLocalFormat({bool hasTime = true, bool reverse = false}) { final isUSA = getIt.get().languageCode.toLowerCase() == 'en'; - final format = - isUSA ? usaStyleFormat(hasTime, reverse) : regularStyleFormat(hasTime, reverse); + final format = isUSA ? usaStyleFormat(hasTime, reverse) : regularStyleFormat(hasTime, reverse); return format; } @@ -20,4 +20,26 @@ class DateFormatter { static String regularStyleFormat(bool hasTime, bool reverse) => hasTime ? (reverse ? 'HH:mm dd.MM.yyyy' : 'dd.MM.yyyy, HH:mm') : 'dd.MM.yyyy'; + + static String convertDateTimeToReadableString(DateTime date) { + final nowDate = DateTime.now(); + final diffDays = date.difference(nowDate).inDays; + final isToday = + nowDate.day == date.day && nowDate.month == date.month && nowDate.year == date.year; + final dateSectionDateFormat = withCurrentLocal(hasTime: false); + var title = ""; + + if (isToday) { + title = S.current.today; + } else if (diffDays == 0) { + title = S.current.yesterday; + } else if (diffDays > -7 && diffDays < 0) { + final dateFormat = DateFormat.EEEE(); + title = dateFormat.format(date); + } else { + title = dateSectionDateFormat.format(date); + } + + return title; + } } diff --git a/lib/utils/image_utill.dart b/lib/utils/image_utill.dart index ef3775e4c..746fde453 100644 --- a/lib/utils/image_utill.dart +++ b/lib/utils/image_utill.dart @@ -11,6 +11,7 @@ class ImageUtil { if (isNetworkImage) { return isSvg ? SvgPicture.network( + key: ValueKey(imagePath), imagePath, height: _height, width: _width, @@ -23,6 +24,7 @@ class ImageUtil { ), ) : Image.network( + key: ValueKey(imagePath), imagePath, height: _height, width: _width, @@ -58,12 +60,14 @@ class ImageUtil { height: _height, width: _width, placeholderBuilder: (_) => Icon(Icons.error), + key: ValueKey(imagePath), ) : Image.asset( imagePath, height: _height, width: _width, errorBuilder: (_, __, ___) => Icon(Icons.error), + key: ValueKey(imagePath), ); } } diff --git a/lib/view_model/dashboard/action_list_item.dart b/lib/view_model/dashboard/action_list_item.dart index b03bd1bdc..1ee4e6a3c 100644 --- a/lib/view_model/dashboard/action_list_item.dart +++ b/lib/view_model/dashboard/action_list_item.dart @@ -1,3 +1,8 @@ +import 'package:flutter/foundation.dart'; + abstract class ActionListItem { + ActionListItem({required this.key}); + DateTime get date; + Key key; } \ No newline at end of file diff --git a/lib/view_model/dashboard/anonpay_transaction_list_item.dart b/lib/view_model/dashboard/anonpay_transaction_list_item.dart index 261e49070..a54a4b334 100644 --- a/lib/view_model/dashboard/anonpay_transaction_list_item.dart +++ b/lib/view_model/dashboard/anonpay_transaction_list_item.dart @@ -2,7 +2,7 @@ import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart'; import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; class AnonpayTransactionListItem extends ActionListItem { - AnonpayTransactionListItem({required this.transaction}); + AnonpayTransactionListItem({required this.transaction, required super.key}); final AnonpayInvoiceInfo transaction; diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 423babefc..6cf7c49d0 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -48,6 +48,7 @@ import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:eth_sig_util/util/utils.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:http/http.dart' as http; import 'package:mobx/mobx.dart'; @@ -182,10 +183,16 @@ abstract class DashboardViewModelBase with Store { final sortedTransactions = [..._accountTransactions]; sortedTransactions.sort((a, b) => a.date.compareTo(b.date)); - transactions = ObservableList.of(sortedTransactions.map((transaction) => TransactionListItem( - transaction: transaction, - balanceViewModel: balanceViewModel, - settingsStore: appStore.settingsStore))); + transactions = ObservableList.of( + sortedTransactions.map( + (transaction) => TransactionListItem( + transaction: transaction, + balanceViewModel: balanceViewModel, + settingsStore: appStore.settingsStore, + key: ValueKey('monero_transaction_history_item_${transaction.id}_key'), + ), + ), + ); } else if (_wallet.type == WalletType.wownero) { subname = wow.wownero!.getCurrentAccount(_wallet).label; @@ -206,18 +213,30 @@ abstract class DashboardViewModelBase with Store { final sortedTransactions = [..._accountTransactions]; sortedTransactions.sort((a, b) => a.date.compareTo(b.date)); - transactions = ObservableList.of(sortedTransactions.map((transaction) => TransactionListItem( - transaction: transaction, - balanceViewModel: balanceViewModel, - settingsStore: appStore.settingsStore))); + transactions = ObservableList.of( + sortedTransactions.map( + (transaction) => TransactionListItem( + transaction: transaction, + balanceViewModel: balanceViewModel, + settingsStore: appStore.settingsStore, + key: ValueKey('wownero_transaction_history_item_${transaction.id}_key'), + ), + ), + ); } else { final sortedTransactions = [...wallet.transactionHistory.transactions.values]; sortedTransactions.sort((a, b) => a.date.compareTo(b.date)); - transactions = ObservableList.of(sortedTransactions.map((transaction) => TransactionListItem( - transaction: transaction, - balanceViewModel: balanceViewModel, - settingsStore: appStore.settingsStore))); + transactions = ObservableList.of( + sortedTransactions.map( + (transaction) => TransactionListItem( + transaction: transaction, + balanceViewModel: balanceViewModel, + settingsStore: appStore.settingsStore, + key: ValueKey('${_wallet.type.name}_transaction_history_item_${transaction.id}_key'), + ), + ), + ); } // TODO: nano sub-account generation is disabled: @@ -234,9 +253,13 @@ abstract class DashboardViewModelBase with Store { appStore.wallet!.transactionHistory.transactions, transactions, (TransactionInfo? transaction) => TransactionListItem( - transaction: transaction!, - balanceViewModel: balanceViewModel, - settingsStore: appStore.settingsStore), filter: (TransactionInfo? transaction) { + transaction: transaction!, + balanceViewModel: balanceViewModel, + settingsStore: appStore.settingsStore, + key: ValueKey( + '${_wallet.type.name}_transaction_history_item_${transaction.id}_key', + ), + ), filter: (TransactionInfo? transaction) { if (transaction == null) { return false; } @@ -650,20 +673,29 @@ abstract class DashboardViewModelBase with Store { transactions.clear(); - transactions.addAll(wallet.transactionHistory.transactions.values.map((transaction) => - TransactionListItem( - transaction: transaction, - balanceViewModel: balanceViewModel, - settingsStore: appStore.settingsStore))); + transactions.addAll( + wallet.transactionHistory.transactions.values.map( + (transaction) => TransactionListItem( + transaction: transaction, + balanceViewModel: balanceViewModel, + settingsStore: appStore.settingsStore, + key: ValueKey('${wallet.type.name}_transaction_history_item_${transaction.id}_key'), + ), + ), + ); } connectMapToListWithTransform( appStore.wallet!.transactionHistory.transactions, transactions, (TransactionInfo? transaction) => TransactionListItem( - transaction: transaction!, - balanceViewModel: balanceViewModel, - settingsStore: appStore.settingsStore), filter: (TransactionInfo? tx) { + transaction: transaction!, + balanceViewModel: balanceViewModel, + settingsStore: appStore.settingsStore, + key: ValueKey( + '${wallet.type.name}_transaction_history_item_${transaction.id}_key', + ), + ), filter: (TransactionInfo? tx) { if (tx == null) { return false; } @@ -703,10 +735,16 @@ abstract class DashboardViewModelBase with Store { monero!.getTransactionInfoAccountId(tx) == monero!.getCurrentAccount(wallet).id) .toList(); - transactions.addAll(_accountTransactions.map((transaction) => TransactionListItem( - transaction: transaction, - balanceViewModel: balanceViewModel, - settingsStore: appStore.settingsStore))); + transactions.addAll( + _accountTransactions.map( + (transaction) => TransactionListItem( + transaction: transaction, + balanceViewModel: balanceViewModel, + settingsStore: appStore.settingsStore, + key: ValueKey('monero_transaction_history_item_${transaction.id}_key'), + ), + ), + ); } else if (wallet.type == WalletType.wownero) { final _accountTransactions = wow.wownero! .getTransactionHistory(wallet) @@ -717,10 +755,16 @@ abstract class DashboardViewModelBase with Store { wow.wownero!.getCurrentAccount(wallet).id) .toList(); - transactions.addAll(_accountTransactions.map((transaction) => TransactionListItem( - transaction: transaction, - balanceViewModel: balanceViewModel, - settingsStore: appStore.settingsStore))); + transactions.addAll( + _accountTransactions.map( + (transaction) => TransactionListItem( + transaction: transaction, + balanceViewModel: balanceViewModel, + settingsStore: appStore.settingsStore, + key: ValueKey('wownero_transaction_history_item_${transaction.id}_key'), + ), + ), + ); } } diff --git a/lib/view_model/dashboard/date_section_item.dart b/lib/view_model/dashboard/date_section_item.dart index 0b361ecce..75250a7ea 100644 --- a/lib/view_model/dashboard/date_section_item.dart +++ b/lib/view_model/dashboard/date_section_item.dart @@ -1,7 +1,7 @@ import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; class DateSectionItem extends ActionListItem { - DateSectionItem(this.date); + DateSectionItem(this.date, {required super.key}); @override final DateTime date; diff --git a/lib/view_model/dashboard/formatted_item_list.dart b/lib/view_model/dashboard/formatted_item_list.dart index a1cbbbf7d..04464ca89 100644 --- a/lib/view_model/dashboard/formatted_item_list.dart +++ b/lib/view_model/dashboard/formatted_item_list.dart @@ -1,5 +1,6 @@ import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; +import 'package:flutter/foundation.dart'; List formattedItemsList(List items) { final formattedList = []; @@ -11,7 +12,12 @@ List formattedItemsList(List items) { if (lastDate == null) { lastDate = transaction.date; - formattedList.add(DateSectionItem(transaction.date)); + formattedList.add( + DateSectionItem( + transaction.date, + key: ValueKey('date_section_item_${transaction.date.microsecondsSinceEpoch}_key'), + ), + ); formattedList.add(transaction); continue; } @@ -26,7 +32,12 @@ List formattedItemsList(List items) { } lastDate = transaction.date; - formattedList.add(DateSectionItem(transaction.date)); + formattedList.add( + DateSectionItem( + transaction.date, + key: ValueKey('date_section_item_${transaction.date.microsecondsSinceEpoch}_key'), + ), + ); formattedList.add(transaction); } diff --git a/lib/view_model/dashboard/order_list_item.dart b/lib/view_model/dashboard/order_list_item.dart index 9120cc1a6..52adf53a6 100644 --- a/lib/view_model/dashboard/order_list_item.dart +++ b/lib/view_model/dashboard/order_list_item.dart @@ -6,7 +6,9 @@ import 'package:cake_wallet/entities/balance_display_mode.dart'; class OrderListItem extends ActionListItem { OrderListItem({ required this.order, - required this.settingsStore}); + required this.settingsStore, + required super.key, + }); final Order order; final SettingsStore settingsStore; diff --git a/lib/view_model/dashboard/trade_list_item.dart b/lib/view_model/dashboard/trade_list_item.dart index 964ba4ffa..55ae4e99f 100644 --- a/lib/view_model/dashboard/trade_list_item.dart +++ b/lib/view_model/dashboard/trade_list_item.dart @@ -4,7 +4,11 @@ import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/view_model/dashboard/action_list_item.dart'; class TradeListItem extends ActionListItem { - TradeListItem({required this.trade, required this.settingsStore}); + TradeListItem({ + required this.trade, + required this.settingsStore, + required super.key, + }); final Trade trade; final SettingsStore settingsStore; diff --git a/lib/view_model/dashboard/transaction_list_item.dart b/lib/view_model/dashboard/transaction_list_item.dart index b218c8e9f..d9b361fd2 100644 --- a/lib/view_model/dashboard/transaction_list_item.dart +++ b/lib/view_model/dashboard/transaction_list_item.dart @@ -22,8 +22,12 @@ import 'package:cw_core/keyable.dart'; import 'package:cw_core/wallet_type.dart'; class TransactionListItem extends ActionListItem with Keyable { - TransactionListItem( - {required this.transaction, required this.balanceViewModel, required this.settingsStore}); + TransactionListItem({ + required this.transaction, + required this.balanceViewModel, + required this.settingsStore, + required super.key, + }); final TransactionInfo transaction; final BalanceViewModel balanceViewModel; diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index da6699ae6..a189ebe6c 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -20,6 +20,7 @@ import 'package:cake_wallet/view_model/send/send_view_model.dart'; import 'package:collection/collection.dart'; import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/transaction_priority.dart'; +import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; import 'package:intl/src/intl/date_format.dart'; import 'package:mobx/mobx.dart'; @@ -52,7 +53,7 @@ abstract class TransactionDetailsViewModelBase with Store { break; case WalletType.bitcoin: _addElectrumListItems(tx, dateFormat); - if(!canReplaceByFee)_checkForRBF(tx); + if (!canReplaceByFee) _checkForRBF(tx); break; case WalletType.litecoin: case WalletType.bitcoinCash: @@ -83,8 +84,7 @@ abstract class TransactionDetailsViewModelBase with Store { break; } - final descriptionKey = - '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; + final descriptionKey = '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; final description = transactionDescriptionBox.values.firstWhere( (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, orElse: () => TransactionDescription(id: descriptionKey)); @@ -93,15 +93,20 @@ abstract class TransactionDetailsViewModelBase with Store { final recipientAddress = description.recipientAddress; if (recipientAddress?.isNotEmpty ?? false) { - items.add(StandartListItem( - title: S.current.transaction_details_recipient_address, - value: recipientAddress!)); + items.add( + StandartListItem( + title: S.current.transaction_details_recipient_address, + value: recipientAddress!, + key: ValueKey('standard_list_item_${recipientAddress}_key'), + ), + ); } } final type = wallet.type; - items.add(BlockExplorerListItem( + items.add( + BlockExplorerListItem( title: S.current.view_in_block_explorer, value: _explorerDescription(type), onTap: () async { @@ -109,9 +114,13 @@ abstract class TransactionDetailsViewModelBase with Store { final uri = Uri.parse(_explorerUrl(type, tx.txHash)); if (await canLaunchUrl(uri)) await launchUrl(uri, mode: LaunchMode.externalApplication); } catch (e) {} - })); + }, + key: ValueKey('block_explorer_list_item_${type.name}_wallet_type_key'), + ), + ); - items.add(TextFieldListItem( + items.add( + TextFieldListItem( title: S.current.note_tap_to_change, value: description.note, onSubmitted: (value) { @@ -122,7 +131,10 @@ abstract class TransactionDetailsViewModelBase with Store { } else { transactionDescriptionBox.add(description); } - })); + }, + key: ValueKey('textfield_list_item_note_entry_key'), + ), + ); } final TransactionInfo transactionInfo; @@ -209,14 +221,38 @@ abstract class TransactionDetailsViewModelBase with Store { final addressIndex = tx.additionalInfo['addressIndex'] as int; final feeFormatted = tx.feeFormatted(); final _items = [ - StandartListItem(title: S.current.transaction_details_transaction_id, value: tx.txHash), StandartListItem( - title: S.current.transaction_details_date, value: dateFormat.format(tx.date)), - StandartListItem(title: S.current.transaction_details_height, value: '${tx.height}'), - StandartListItem(title: S.current.transaction_details_amount, value: tx.amountFormatted()), + title: S.current.transaction_details_transaction_id, + value: tx.txHash, + key: ValueKey('standard_list_item_transaction_details_id_key'), + ), + StandartListItem( + title: S.current.transaction_details_date, + value: dateFormat.format(tx.date), + key: ValueKey('standard_list_item_transaction_details_date_key'), + ), + StandartListItem( + title: S.current.transaction_details_height, + value: '${tx.height}', + key: ValueKey('standard_list_item_transaction_details_height_key'), + ), + StandartListItem( + title: S.current.transaction_details_amount, + value: tx.amountFormatted(), + key: ValueKey('standard_list_item_transaction_details_amount_key'), + ), if (feeFormatted != null) - StandartListItem(title: S.current.transaction_details_fee, value: feeFormatted), - if (key?.isNotEmpty ?? false) StandartListItem(title: S.current.transaction_key, value: key!), + StandartListItem( + title: S.current.transaction_details_fee, + value: feeFormatted, + key: ValueKey('standard_list_item_transaction_details_fee_key'), + ), + if (key?.isNotEmpty ?? false) + StandartListItem( + title: S.current.transaction_key, + value: key!, + key: ValueKey('standard_list_item_transaction_key'), + ), ]; if (tx.direction == TransactionDirection.incoming) { @@ -226,14 +262,21 @@ abstract class TransactionDetailsViewModelBase with Store { if (address.isNotEmpty) { isRecipientAddressShown = true; - _items.add(StandartListItem( - title: S.current.transaction_details_recipient_address, - value: address, - )); + _items.add( + StandartListItem( + title: S.current.transaction_details_recipient_address, + value: address, + key: ValueKey('standard_list_item_transaction_details_recipient_address_key'), + ), + ); } if (label.isNotEmpty) { - _items.add(StandartListItem(title: S.current.address_label, value: label)); + _items.add(StandartListItem( + title: S.current.address_label, + value: label, + key: ValueKey('standard_list_item_address_label_key'), + )); } } catch (e) { print(e.toString()); @@ -245,14 +288,37 @@ abstract class TransactionDetailsViewModelBase with Store { void _addElectrumListItems(TransactionInfo tx, DateFormat dateFormat) { final _items = [ - StandartListItem(title: S.current.transaction_details_transaction_id, value: tx.txHash), StandartListItem( - title: S.current.transaction_details_date, value: dateFormat.format(tx.date)), - StandartListItem(title: S.current.confirmations, value: tx.confirmations.toString()), - StandartListItem(title: S.current.transaction_details_height, value: '${tx.height}'), - StandartListItem(title: S.current.transaction_details_amount, value: tx.amountFormatted()), + title: S.current.transaction_details_transaction_id, + value: tx.txHash, + key: ValueKey('standard_list_item_transaction_details_id_key'), + ), + StandartListItem( + title: S.current.transaction_details_date, + value: dateFormat.format(tx.date), + key: ValueKey('standard_list_item_transaction_details_date_key'), + ), + StandartListItem( + title: S.current.confirmations, + value: tx.confirmations.toString(), + key: ValueKey('standard_list_item_transaction_confirmations_key'), + ), + StandartListItem( + title: S.current.transaction_details_height, + value: '${tx.height}', + key: ValueKey('standard_list_item_transaction_details_height_key'), + ), + StandartListItem( + title: S.current.transaction_details_amount, + value: tx.amountFormatted(), + key: ValueKey('standard_list_item_transaction_details_amount_key'), + ), if (tx.feeFormatted()?.isNotEmpty ?? false) - StandartListItem(title: S.current.transaction_details_fee, value: tx.feeFormatted()!), + StandartListItem( + title: S.current.transaction_details_fee, + value: tx.feeFormatted()!, + key: ValueKey('standard_list_item_transaction_details_fee_key'), + ), ]; items.addAll(_items); @@ -260,30 +326,80 @@ abstract class TransactionDetailsViewModelBase with Store { void _addHavenListItems(TransactionInfo tx, DateFormat dateFormat) { items.addAll([ - StandartListItem(title: S.current.transaction_details_transaction_id, value: tx.txHash), StandartListItem( - title: S.current.transaction_details_date, value: dateFormat.format(tx.date)), - StandartListItem(title: S.current.transaction_details_height, value: '${tx.height}'), - StandartListItem(title: S.current.transaction_details_amount, value: tx.amountFormatted()), + title: S.current.transaction_details_transaction_id, + value: tx.txHash, + key: ValueKey('standard_list_item_transaction_details_id_key'), + ), + StandartListItem( + title: S.current.transaction_details_date, + value: dateFormat.format(tx.date), + key: ValueKey('standard_list_item_transaction_details_date_key'), + ), + StandartListItem( + title: S.current.transaction_details_height, + value: '${tx.height}', + key: ValueKey('standard_list_item_transaction_details_height_key'), + ), + StandartListItem( + title: S.current.transaction_details_amount, + value: tx.amountFormatted(), + key: ValueKey('standard_list_item_transaction_details_amount_key'), + ), if (tx.feeFormatted()?.isNotEmpty ?? false) - StandartListItem(title: S.current.transaction_details_fee, value: tx.feeFormatted()!), + StandartListItem( + title: S.current.transaction_details_fee, + value: tx.feeFormatted()!, + key: ValueKey('standard_list_item_transaction_details_fee_key'), + ), ]); } void _addEthereumListItems(TransactionInfo tx, DateFormat dateFormat) { final _items = [ - StandartListItem(title: S.current.transaction_details_transaction_id, value: tx.txHash), StandartListItem( - title: S.current.transaction_details_date, value: dateFormat.format(tx.date)), - StandartListItem(title: S.current.confirmations, value: tx.confirmations.toString()), - StandartListItem(title: S.current.transaction_details_height, value: '${tx.height}'), - StandartListItem(title: S.current.transaction_details_amount, value: tx.amountFormatted()), + title: S.current.transaction_details_transaction_id, + value: tx.txHash, + key: ValueKey('standard_list_item_transaction_details_id_key'), + ), + StandartListItem( + title: S.current.transaction_details_date, + value: dateFormat.format(tx.date), + key: ValueKey('standard_list_item_transaction_details_date_key'), + ), + StandartListItem( + title: S.current.confirmations, + value: tx.confirmations.toString(), + key: ValueKey('standard_list_item_transaction_confirmations_key'), + ), + StandartListItem( + title: S.current.transaction_details_height, + value: '${tx.height}', + key: ValueKey('standard_list_item_transaction_details_height_key'), + ), + StandartListItem( + title: S.current.transaction_details_amount, + value: tx.amountFormatted(), + key: ValueKey('standard_list_item_transaction_details_amount_key'), + ), if (tx.feeFormatted()?.isNotEmpty ?? false) - StandartListItem(title: S.current.transaction_details_fee, value: tx.feeFormatted()!), + StandartListItem( + title: S.current.transaction_details_fee, + value: tx.feeFormatted()!, + key: ValueKey('standard_list_item_transaction_details_fee_key'), + ), if (showRecipientAddress && tx.to != null) - StandartListItem(title: S.current.transaction_details_recipient_address, value: tx.to!), + StandartListItem( + title: S.current.transaction_details_recipient_address, + value: tx.to!, + key: ValueKey('standard_list_item_transaction_details_recipient_address_key'), + ), if (tx.direction == TransactionDirection.incoming && tx.from != null) - StandartListItem(title: S.current.transaction_details_source_address, value: tx.from!), + StandartListItem( + title: S.current.transaction_details_source_address, + value: tx.from!, + key: ValueKey('standard_list_item_transaction_details_source_address_key'), + ), ]; items.addAll(_items); @@ -291,16 +407,43 @@ abstract class TransactionDetailsViewModelBase with Store { void _addNanoListItems(TransactionInfo tx, DateFormat dateFormat) { final _items = [ - StandartListItem(title: S.current.transaction_details_transaction_id, value: tx.txHash), - if (showRecipientAddress && tx.to != null) - StandartListItem(title: S.current.transaction_details_recipient_address, value: tx.to!), - if (showRecipientAddress && tx.from != null) - StandartListItem(title: S.current.transaction_details_source_address, value: tx.from!), - StandartListItem(title: S.current.transaction_details_amount, value: tx.amountFormatted()), StandartListItem( - title: S.current.transaction_details_date, value: dateFormat.format(tx.date)), - StandartListItem(title: S.current.confirmed_tx, value: (tx.confirmations > 0).toString()), - StandartListItem(title: S.current.transaction_details_height, value: '${tx.height}'), + title: S.current.transaction_details_transaction_id, + value: tx.txHash, + key: ValueKey('standard_list_item_transaction_details_id_key'), + ), + if (showRecipientAddress && tx.to != null) + StandartListItem( + title: S.current.transaction_details_recipient_address, + value: tx.to!, + key: ValueKey('standard_list_item_transaction_details_recipient_address_key'), + ), + if (showRecipientAddress && tx.from != null) + StandartListItem( + title: S.current.transaction_details_source_address, + value: tx.from!, + key: ValueKey('standard_list_item_transaction_details_source_address_key'), + ), + StandartListItem( + title: S.current.transaction_details_amount, + value: tx.amountFormatted(), + key: ValueKey('standard_list_item_transaction_details_amount_key'), + ), + StandartListItem( + title: S.current.transaction_details_date, + value: dateFormat.format(tx.date), + key: ValueKey('standard_list_item_transaction_details_date_key'), + ), + StandartListItem( + title: S.current.confirmed_tx, + value: (tx.confirmations > 0).toString(), + key: ValueKey('standard_list_item_transaction_confirmed_key'), + ), + StandartListItem( + title: S.current.transaction_details_height, + value: '${tx.height}', + key: ValueKey('standard_list_item_transaction_details_height_key'), + ), ]; items.addAll(_items); @@ -308,18 +451,49 @@ abstract class TransactionDetailsViewModelBase with Store { void _addPolygonListItems(TransactionInfo tx, DateFormat dateFormat) { final _items = [ - StandartListItem(title: S.current.transaction_details_transaction_id, value: tx.txHash), StandartListItem( - title: S.current.transaction_details_date, value: dateFormat.format(tx.date)), - StandartListItem(title: S.current.confirmations, value: tx.confirmations.toString()), - StandartListItem(title: S.current.transaction_details_height, value: '${tx.height}'), - StandartListItem(title: S.current.transaction_details_amount, value: tx.amountFormatted()), + title: S.current.transaction_details_transaction_id, + value: tx.txHash, + key: ValueKey('standard_list_item_transaction_details_id_key'), + ), + StandartListItem( + title: S.current.transaction_details_date, + value: dateFormat.format(tx.date), + key: ValueKey('standard_list_item_transaction_details_date_key'), + ), + StandartListItem( + title: S.current.confirmations, + value: tx.confirmations.toString(), + key: ValueKey('standard_list_item_transaction_confirmations_key'), + ), + StandartListItem( + title: S.current.transaction_details_height, + value: '${tx.height}', + key: ValueKey('standard_list_item_transaction_details_height_key'), + ), + StandartListItem( + title: S.current.transaction_details_amount, + value: tx.amountFormatted(), + key: ValueKey('standard_list_item_transaction_details_amount_key'), + ), if (tx.feeFormatted()?.isNotEmpty ?? false) - StandartListItem(title: S.current.transaction_details_fee, value: tx.feeFormatted()!), + StandartListItem( + title: S.current.transaction_details_fee, + value: tx.feeFormatted()!, + key: ValueKey('standard_list_item_transaction_details_fee_key'), + ), if (showRecipientAddress && tx.to != null && tx.direction == TransactionDirection.outgoing) - StandartListItem(title: S.current.transaction_details_recipient_address, value: tx.to!), + StandartListItem( + title: S.current.transaction_details_recipient_address, + value: tx.to!, + key: ValueKey('standard_list_item_transaction_details_recipient_address_key'), + ), if (tx.direction == TransactionDirection.incoming && tx.from != null) - StandartListItem(title: S.current.transaction_details_source_address, value: tx.from!), + StandartListItem( + title: S.current.transaction_details_source_address, + value: tx.from!, + key: ValueKey('standard_list_item_transaction_details_source_address_key'), + ), ]; items.addAll(_items); @@ -327,16 +501,39 @@ abstract class TransactionDetailsViewModelBase with Store { void _addSolanaListItems(TransactionInfo tx, DateFormat dateFormat) { final _items = [ - StandartListItem(title: S.current.transaction_details_transaction_id, value: tx.txHash), StandartListItem( - title: S.current.transaction_details_date, value: dateFormat.format(tx.date)), - StandartListItem(title: S.current.transaction_details_amount, value: tx.amountFormatted()), + title: S.current.transaction_details_transaction_id, + value: tx.txHash, + key: ValueKey('standard_list_item_transaction_details_id_key'), + ), + StandartListItem( + title: S.current.transaction_details_date, + value: dateFormat.format(tx.date), + key: ValueKey('standard_list_item_transaction_details_date_key'), + ), + StandartListItem( + title: S.current.transaction_details_amount, + value: tx.amountFormatted(), + key: ValueKey('standard_list_item_transaction_details_amount_key'), + ), if (tx.feeFormatted()?.isNotEmpty ?? false) - StandartListItem(title: S.current.transaction_details_fee, value: tx.feeFormatted()!), + StandartListItem( + title: S.current.transaction_details_fee, + value: tx.feeFormatted()!, + key: ValueKey('standard_list_item_transaction_details_fee_key'), + ), if (showRecipientAddress && tx.to != null) - StandartListItem(title: S.current.transaction_details_recipient_address, value: tx.to!), + StandartListItem( + title: S.current.transaction_details_recipient_address, + value: tx.to!, + key: ValueKey('standard_list_item_transaction_details_recipient_address_key'), + ), if (tx.from != null) - StandartListItem(title: S.current.transaction_details_source_address, value: tx.from!), + StandartListItem( + title: S.current.transaction_details_source_address, + value: tx.from!, + key: ValueKey('standard_list_item_transaction_details_source_address_key'), + ), ]; items.addAll(_items); @@ -354,7 +551,13 @@ abstract class TransactionDetailsViewModelBase with Store { newFee = bitcoin!.getFeeAmountForPriority( wallet, bitcoin!.getBitcoinTransactionPriorityMedium(), inputsCount, outputsCount); - RBFListItems.add(StandartListItem(title: S.current.old_fee, value: tx.feeFormatted() ?? '0.0')); + RBFListItems.add( + StandartListItem( + title: S.current.old_fee, + value: tx.feeFormatted() ?? '0.0', + key: ValueKey('standard_list_item_rbf_old_fee_key'), + ), + ); if (transactionInfo.fee != null && rawTransaction.isNotEmpty) { final size = bitcoin!.getTransactionVSize(wallet, rawTransaction); @@ -371,7 +574,9 @@ abstract class TransactionDetailsViewModelBase with Store { final customItemIndex = customItem != null ? priorities.indexOf(customItem) : null; final maxCustomFeeRate = sendViewModel.maxCustomFeeRate?.toDouble(); - RBFListItems.add(StandardPickerListItem( + RBFListItems.add( + StandardPickerListItem( + key: ValueKey('standard_picker_list_item_transaction_priorities_key'), title: S.current.estimated_new_fee, value: bitcoin!.formatterBitcoinAmountToString(amount: newFee) + ' ${walletTypeToCryptoCurrency(wallet.type)}', @@ -387,42 +592,73 @@ abstract class TransactionDetailsViewModelBase with Store { onItemSelected: (dynamic item, double sliderValue) { transactionPriority = item as TransactionPriority; return setNewFee(value: sliderValue, priority: transactionPriority!); - })); + }, + ), + ); if (transactionInfo.inputAddresses != null && transactionInfo.inputAddresses!.isNotEmpty) { - RBFListItems.add(StandardExpandableListItem( - title: S.current.inputs, expandableItems: transactionInfo.inputAddresses!)); + RBFListItems.add( + StandardExpandableListItem( + key: ValueKey('standard_expandable_list_item_transaction_input_addresses_key'), + title: S.current.inputs, + expandableItems: transactionInfo.inputAddresses!, + ), + ); } if (transactionInfo.outputAddresses != null && transactionInfo.outputAddresses!.isNotEmpty) { final outputAddresses = transactionInfo.outputAddresses!.map((element) { if (element.contains('OP_RETURN:') && element.length > 40) { - return element.substring(0, 40) + '...'; + return element.substring(0, 40) + '...'; } return element; }).toList(); RBFListItems.add( - StandardExpandableListItem(title: S.current.outputs, expandableItems: outputAddresses)); + StandardExpandableListItem( + title: S.current.outputs, + expandableItems: outputAddresses, + key: ValueKey('standard_expandable_list_item_transaction_output_addresses_key'), + ), + ); } } void _addTronListItems(TransactionInfo tx, DateFormat dateFormat) { final _items = [ - StandartListItem(title: S.current.transaction_details_transaction_id, value: tx.txHash), StandartListItem( - title: S.current.transaction_details_date, value: dateFormat.format(tx.date)), - StandartListItem(title: S.current.transaction_details_amount, value: tx.amountFormatted()), + title: S.current.transaction_details_transaction_id, + value: tx.txHash, + key: ValueKey('standard_list_item_transaction_details_id_key'), + ), + StandartListItem( + title: S.current.transaction_details_date, + value: dateFormat.format(tx.date), + key: ValueKey('standard_list_item_transaction_details_date_key'), + ), + StandartListItem( + title: S.current.transaction_details_amount, + value: tx.amountFormatted(), + key: ValueKey('standard_list_item_transaction_details_amount_key'), + ), if (tx.feeFormatted()?.isNotEmpty ?? false) - StandartListItem(title: S.current.transaction_details_fee, value: tx.feeFormatted()!), + StandartListItem( + title: S.current.transaction_details_fee, + value: tx.feeFormatted()!, + key: ValueKey('standard_list_item_transaction_details_fee_key'), + ), if (showRecipientAddress && tx.to != null) StandartListItem( - title: S.current.transaction_details_recipient_address, - value: tron!.getTronBase58Address(tx.to!, wallet)), + title: S.current.transaction_details_recipient_address, + value: tron!.getTronBase58Address(tx.to!, wallet), + key: ValueKey('standard_list_item_transaction_details_recipient_address_key'), + ), if (tx.from != null) StandartListItem( - title: S.current.transaction_details_source_address, - value: tron!.getTronBase58Address(tx.from!, wallet)), + title: S.current.transaction_details_source_address, + value: tron!.getTronBase58Address(tx.from!, wallet), + key: ValueKey('standard_list_item_transaction_details_source_address_key'), + ), ]; items.addAll(_items); @@ -455,7 +691,7 @@ abstract class TransactionDetailsViewModelBase with Store { return bitcoin!.formatterBitcoinAmountToString(amount: newFee); } - void replaceByFee(String newFee) => sendViewModel.replaceByFee(transactionInfo, newFee,); + void replaceByFee(String newFee) => sendViewModel.replaceByFee(transactionInfo, newFee); @computed String get pendingTransactionFiatAmountValueFormatted => sendViewModel.isFiatDisabled @@ -473,14 +709,38 @@ abstract class TransactionDetailsViewModelBase with Store { final addressIndex = tx.additionalInfo['addressIndex'] as int; final feeFormatted = tx.feeFormatted(); final _items = [ - StandartListItem(title: S.current.transaction_details_transaction_id, value: tx.txHash), StandartListItem( - title: S.current.transaction_details_date, value: dateFormat.format(tx.date)), - StandartListItem(title: S.current.transaction_details_height, value: '${tx.height}'), - StandartListItem(title: S.current.transaction_details_amount, value: tx.amountFormatted()), + title: S.current.transaction_details_transaction_id, + value: tx.txHash, + key: ValueKey('standard_list_item_transaction_details_id_key'), + ), + StandartListItem( + title: S.current.transaction_details_date, + value: dateFormat.format(tx.date), + key: ValueKey('standard_list_item_transaction_details_date_key'), + ), + StandartListItem( + title: S.current.transaction_details_height, + value: '${tx.height}', + key: ValueKey('standard_list_item_transaction_details_height_key'), + ), + StandartListItem( + title: S.current.transaction_details_amount, + value: tx.amountFormatted(), + key: ValueKey('standard_list_item_transaction_details_amount_key'), + ), if (feeFormatted != null) - StandartListItem(title: S.current.transaction_details_fee, value: feeFormatted), - if (key?.isNotEmpty ?? false) StandartListItem(title: S.current.transaction_key, value: key!), + StandartListItem( + title: S.current.transaction_details_fee, + value: feeFormatted, + key: ValueKey('standard_list_item_transaction_details_fee_key'), + ), + if (key?.isNotEmpty ?? false) + StandartListItem( + title: S.current.transaction_key, + value: key!, + key: ValueKey('standard_list_item_transaction_key'), + ), ]; if (tx.direction == TransactionDirection.incoming) { @@ -490,14 +750,23 @@ abstract class TransactionDetailsViewModelBase with Store { if (address.isNotEmpty) { isRecipientAddressShown = true; - _items.add(StandartListItem( - title: S.current.transaction_details_recipient_address, - value: address, - )); + _items.add( + StandartListItem( + title: S.current.transaction_details_recipient_address, + value: address, + key: ValueKey('standard_list_item_transaction_details_recipient_address_key'), + ), + ); } if (label.isNotEmpty) { - _items.add(StandartListItem(title: S.current.address_label, value: label)); + _items.add( + StandartListItem( + title: S.current.address_label, + value: label, + key: ValueKey('standard_list_item_address_label_key'), + ), + ); } } catch (e) { print(e.toString()); diff --git a/lib/view_model/wallet_keys_view_model.dart b/lib/view_model/wallet_keys_view_model.dart index 9921ae30a..6455fc0e3 100644 --- a/lib/view_model/wallet_keys_view_model.dart +++ b/lib/view_model/wallet_keys_view_model.dart @@ -10,6 +10,7 @@ import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cw_monero/monero_wallet.dart'; +import 'package:flutter/foundation.dart'; import 'package:mobx/mobx.dart'; import 'package:polyseed/polyseed.dart'; @@ -24,6 +25,7 @@ abstract class WalletKeysViewModelBase with Store { _appStore.wallet!.type == WalletType.bitcoinCash ? S.current.wallet_seed : S.current.wallet_keys, + _walletName = _appStore.wallet!.type.name, _restoreHeight = _appStore.wallet!.walletInfo.restoreHeight, _restoreHeightByTransactions = 0, items = ObservableList() { @@ -38,12 +40,10 @@ abstract class WalletKeysViewModelBase with Store { _appStore.wallet!.type == WalletType.wownero) { final accountTransactions = _getWalletTransactions(_appStore.wallet!); if (accountTransactions.isNotEmpty) { - final incomingAccountTransactions = accountTransactions - .where((tx) => tx.direction == TransactionDirection.incoming); + final incomingAccountTransactions = + accountTransactions.where((tx) => tx.direction == TransactionDirection.incoming); if (incomingAccountTransactions.isNotEmpty) { - incomingAccountTransactions - .toList() - .sort((a, b) => a.date.compareTo(b.date)); + incomingAccountTransactions.toList().sort((a, b) => a.date.compareTo(b.date)); _restoreHeightByTransactions = _getRestoreHeightByTransactions( _appStore.wallet!.type, incomingAccountTransactions.first.date); } @@ -55,6 +55,10 @@ abstract class WalletKeysViewModelBase with Store { final String title; + final String _walletName; + + AppStore get appStore => _appStore; + final AppStore _appStore; final int _restoreHeight; @@ -70,37 +74,56 @@ abstract class WalletKeysViewModelBase with Store { items.addAll([ if (keys['publicSpendKey'] != null) StandartListItem( - title: S.current.spend_key_public, - value: keys['publicSpendKey']!), + key: ValueKey('${_walletName}_wallet_public_spend_key_item_key'), + title: S.current.spend_key_public, + value: keys['publicSpendKey']!, + ), if (keys['privateSpendKey'] != null) StandartListItem( - title: S.current.spend_key_private, - value: keys['privateSpendKey']!), + key: ValueKey('${_walletName}_wallet_private_spend_key_item_key'), + title: S.current.spend_key_private, + value: keys['privateSpendKey']!, + ), if (keys['publicViewKey'] != null) StandartListItem( - title: S.current.view_key_public, value: keys['publicViewKey']!), + key: ValueKey('${_walletName}_wallet_public_view_key_item_key'), + title: S.current.view_key_public, + value: keys['publicViewKey']!, + ), if (keys['privateViewKey'] != null) StandartListItem( - title: S.current.view_key_private, - value: keys['privateViewKey']!), + key: ValueKey('${_walletName}_wallet_private_view_key_item_key'), + title: S.current.view_key_private, + value: keys['privateViewKey']!, + ), if (_appStore.wallet!.seed!.isNotEmpty) - StandartListItem(title: S.current.wallet_seed, value: _appStore.wallet!.seed!), + StandartListItem( + key: ValueKey('${_walletName}_wallet_seed_item_key'), + title: S.current.wallet_seed, + value: _appStore.wallet!.seed!, + ), ]); - if (_appStore.wallet?.seed != null && - Polyseed.isValidSeed(_appStore.wallet!.seed!)) { + if (_appStore.wallet?.seed != null && Polyseed.isValidSeed(_appStore.wallet!.seed!)) { final lang = PolyseedLang.getByPhrase(_appStore.wallet!.seed!); - items.add(StandartListItem( + items.add( + StandartListItem( + key: ValueKey('${_walletName}_wallet_seed_legacy_item_key'), title: S.current.wallet_seed_legacy, - value: (_appStore.wallet as MoneroWalletBase) - .seedLegacy(lang.nameEnglish))); + value: (_appStore.wallet as MoneroWalletBase).seedLegacy(lang.nameEnglish), + ), + ); } final restoreHeight = monero!.getRestoreHeight(_appStore.wallet!); if (restoreHeight != null) { - items.add(StandartListItem( + items.add( + StandartListItem( + key: ValueKey('${_walletName}_wallet_restore_height_item_key'), title: S.current.wallet_recovery_height, - value: restoreHeight.toString())); + value: restoreHeight.toString(), + ), + ); } } @@ -110,21 +133,34 @@ abstract class WalletKeysViewModelBase with Store { items.addAll([ if (keys['publicSpendKey'] != null) StandartListItem( - title: S.current.spend_key_public, - value: keys['publicSpendKey']!), + key: ValueKey('${_walletName}_wallet_public_spend_key_item_key'), + title: S.current.spend_key_public, + value: keys['publicSpendKey']!, + ), if (keys['privateSpendKey'] != null) StandartListItem( - title: S.current.spend_key_private, - value: keys['privateSpendKey']!), + key: ValueKey('${_walletName}_wallet_private_spend_key_item_key'), + title: S.current.spend_key_private, + value: keys['privateSpendKey']!, + ), if (keys['publicViewKey'] != null) StandartListItem( - title: S.current.view_key_public, value: keys['publicViewKey']!), + key: ValueKey('${_walletName}_wallet_public_view_key_item_key'), + title: S.current.view_key_public, + value: keys['publicViewKey']!, + ), if (keys['privateViewKey'] != null) StandartListItem( - title: S.current.view_key_private, - value: keys['privateViewKey']!), + key: ValueKey('${_walletName}_wallet_private_view_key_item_key'), + title: S.current.view_key_private, + value: keys['privateViewKey']!, + ), if (_appStore.wallet!.seed!.isNotEmpty) - StandartListItem(title: S.current.wallet_seed, value: _appStore.wallet!.seed!), + StandartListItem( + key: ValueKey('${_walletName}_wallet_seed_item_key'), + title: S.current.wallet_seed, + value: _appStore.wallet!.seed!, + ), ]); } @@ -134,29 +170,45 @@ abstract class WalletKeysViewModelBase with Store { items.addAll([ if (keys['publicSpendKey'] != null) StandartListItem( - title: S.current.spend_key_public, - value: keys['publicSpendKey']!), + key: ValueKey('${_walletName}_wallet_public_spend_key_item_key'), + title: S.current.spend_key_public, + value: keys['publicSpendKey']!, + ), if (keys['privateSpendKey'] != null) StandartListItem( - title: S.current.spend_key_private, - value: keys['privateSpendKey']!), + key: ValueKey('${_walletName}_wallet_private_spend_key_item_key'), + title: S.current.spend_key_private, + value: keys['privateSpendKey']!, + ), if (keys['publicViewKey'] != null) StandartListItem( - title: S.current.view_key_public, value: keys['publicViewKey']!), + key: ValueKey('${_walletName}_wallet_public_view_key_item_key'), + title: S.current.view_key_public, + value: keys['publicViewKey']!, + ), if (keys['privateViewKey'] != null) StandartListItem( - title: S.current.view_key_private, - value: keys['privateViewKey']!), + key: ValueKey('${_walletName}_wallet_private_view_key_item_key'), + title: S.current.view_key_private, + value: keys['privateViewKey']!, + ), if (_appStore.wallet!.seed!.isNotEmpty) - StandartListItem(title: S.current.wallet_seed, value: _appStore.wallet!.seed!), + StandartListItem( + key: ValueKey('${_walletName}_wallet_seed_item_key'), + title: S.current.wallet_seed, + value: _appStore.wallet!.seed!, + ), ]); - if (_appStore.wallet?.seed != null && - Polyseed.isValidSeed(_appStore.wallet!.seed!)) { + if (_appStore.wallet?.seed != null && Polyseed.isValidSeed(_appStore.wallet!.seed!)) { final lang = PolyseedLang.getByPhrase(_appStore.wallet!.seed!); - items.add(StandartListItem( + items.add( + StandartListItem( + key: ValueKey('${_walletName}_wallet_seed_legacy_item_key'), title: S.current.wallet_seed_legacy, - value: wownero!.getLegacySeed(_appStore.wallet!, lang.nameEnglish))); + value: wownero!.getLegacySeed(_appStore.wallet!, lang.nameEnglish), + ), + ); } } @@ -173,7 +225,10 @@ abstract class WalletKeysViewModelBase with Store { // if (keys['publicKey'] != null) // StandartListItem(title: S.current.public_key, value: keys['publicKey']!), StandartListItem( - title: S.current.wallet_seed, value: _appStore.wallet!.seed!), + key: ValueKey('${_walletName}_wallet_seed_item_key'), + title: S.current.wallet_seed, + value: _appStore.wallet!.seed!, + ), ]); } @@ -183,31 +238,43 @@ abstract class WalletKeysViewModelBase with Store { items.addAll([ if (_appStore.wallet!.privateKey != null) StandartListItem( - title: S.current.private_key, - value: _appStore.wallet!.privateKey!), + key: ValueKey('${_walletName}_wallet_private_key_item_key'), + title: S.current.private_key, + value: _appStore.wallet!.privateKey!, + ), if (_appStore.wallet!.seed != null) StandartListItem( - title: S.current.wallet_seed, value: _appStore.wallet!.seed!), + key: ValueKey('${_walletName}_wallet_seed_item_key'), + title: S.current.wallet_seed, + value: _appStore.wallet!.seed!, + ), ]); } - bool nanoBased = _appStore.wallet!.type == WalletType.nano || - _appStore.wallet!.type == WalletType.banano; + bool nanoBased = + _appStore.wallet!.type == WalletType.nano || _appStore.wallet!.type == WalletType.banano; if (nanoBased) { // we always have the hex version of the seed and private key: items.addAll([ if (_appStore.wallet!.seed != null) StandartListItem( - title: S.current.wallet_seed, value: _appStore.wallet!.seed!), + key: ValueKey('${_walletName}_wallet_seed_item_key'), + title: S.current.wallet_seed, + value: _appStore.wallet!.seed!, + ), if (_appStore.wallet!.hexSeed != null) StandartListItem( - title: S.current.seed_hex_form, - value: _appStore.wallet!.hexSeed!), + key: ValueKey('${_walletName}_wallet_hex_seed_key'), + title: S.current.seed_hex_form, + value: _appStore.wallet!.hexSeed!, + ), if (_appStore.wallet!.privateKey != null) StandartListItem( - title: S.current.private_key, - value: _appStore.wallet!.privateKey!), + key: ValueKey('${_walletName}_wallet_private_key_item_key'), + title: S.current.private_key, + value: _appStore.wallet!.privateKey!, + ), ]); } } @@ -273,8 +340,7 @@ abstract class WalletKeysViewModelBase with Store { if (_appStore.wallet!.seed != null) 'seed': _appStore.wallet!.seed!, if (_appStore.wallet!.seed == null && _appStore.wallet!.hexSeed != null) 'hexSeed': _appStore.wallet!.hexSeed!, - if (_appStore.wallet!.seed == null && - _appStore.wallet!.privateKey != null) + if (_appStore.wallet!.seed == null && _appStore.wallet!.privateKey != null) 'private_key': _appStore.wallet!.privateKey!, if (restoreHeightResult != null) ...{'height': restoreHeightResult}, if (_appStore.wallet!.passphrase != null) 'passphrase': _appStore.wallet!.passphrase! @@ -292,11 +358,7 @@ abstract class WalletKeysViewModelBase with Store { } else if (wallet.type == WalletType.haven) { return haven!.getTransactionHistory(wallet).transactions.values.toList(); } else if (wallet.type == WalletType.wownero) { - return wownero! - .getTransactionHistory(wallet) - .transactions - .values - .toList(); + return wownero!.getTransactionHistory(wallet).transactions.values.toList(); } return []; } @@ -312,6 +374,5 @@ abstract class WalletKeysViewModelBase with Store { return 0; } - String getRoundedRestoreHeight(int height) => - ((height / 1000).floor() * 1000).toString(); + String getRoundedRestoreHeight(int height) => ((height / 1000).floor() * 1000).toString(); } diff --git a/tool/utils/secret_key.dart b/tool/utils/secret_key.dart index d3b652935..88cbbfce3 100644 --- a/tool/utils/secret_key.dart +++ b/tool/utils/secret_key.dart @@ -44,12 +44,35 @@ class SecretKey { SecretKey('cakePayApiKey', () => ''), SecretKey('CSRFToken', () => ''), SecretKey('authorization', () => ''), + SecretKey('moneroTestWalletSeeds', () => ''), + SecretKey('moneroLegacyTestWalletSeeds ', () => ''), + SecretKey('bitcoinTestWalletSeeds', () => ''), + SecretKey('ethereumTestWalletSeeds', () => ''), + SecretKey('litecoinTestWalletSeeds', () => ''), + SecretKey('bitcoinCashTestWalletSeeds', () => ''), + SecretKey('polygonTestWalletSeeds', () => ''), + SecretKey('solanaTestWalletSeeds', () => ''), + SecretKey('polygonTestWalletSeeds', () => ''), + SecretKey('tronTestWalletSeeds', () => ''), + SecretKey('nanoTestWalletSeeds', () => ''), + SecretKey('wowneroTestWalletSeeds', () => ''), + SecretKey('moneroTestWalletReceiveAddress', () => ''), + SecretKey('bitcoinTestWalletReceiveAddress', () => ''), + SecretKey('ethereumTestWalletReceiveAddress', () => ''), + SecretKey('litecoinTestWalletReceiveAddress', () => ''), + SecretKey('bitco inCashTestWalletReceiveAddress', () => ''), + SecretKey('polygonTestWalletReceiveAddress', () => ''), + SecretKey('solanaTestWalletReceiveAddress', () => ''), + SecretKey('tronTestWalletReceiveAddress', () => ''), + SecretKey('nanoTestWalletReceiveAddress', () => ''), + SecretKey('wowneroTestWalletReceiveAddress', () => ''), SecretKey('etherScanApiKey', () => ''), SecretKey('polygonScanApiKey', () => ''), SecretKey('letsExchangeBearerToken', () => ''), SecretKey('letsExchangeAffiliateId', () => ''), SecretKey('stealthExBearerToken', () => ''), SecretKey('stealthExAdditionalFeePercent', () => ''), + SecretKey('moneroTestWalletBlockHeight', () => ''), ]; static final evmChainsSecrets = [ From 389c334f1076779a134cd037b2af5b1b1c11df2b Mon Sep 17 00:00:00 2001 From: Omar Hatem Date: Fri, 8 Nov 2024 12:50:21 +0200 Subject: [PATCH 7/8] Electrum enhancements (#1794) * Enhance the code for sending/sending-ALL for Electrum * remove prints [skip ci] --- cw_bitcoin/lib/electrum_wallet.dart | 159 +++++++++++----------------- cw_bitcoin/pubspec.lock | 4 +- 2 files changed, 63 insertions(+), 100 deletions(-) diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 7988a4479..6245aa787 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -11,7 +11,6 @@ import 'package:blockchain_utils/blockchain_utils.dart'; import 'package:collection/collection.dart'; import 'package:cw_bitcoin/address_from_output.dart'; import 'package:cw_bitcoin/bitcoin_address_record.dart'; -import 'package:cw_bitcoin/bitcoin_amount_format.dart'; import 'package:cw_bitcoin/bitcoin_transaction_credentials.dart'; import 'package:cw_bitcoin/bitcoin_transaction_priority.dart'; import 'package:cw_bitcoin/bitcoin_unspent.dart'; @@ -597,8 +596,8 @@ abstract class ElectrumWalletBase UtxoDetails _createUTXOS({ required bool sendAll, - required int credentialsAmount, required bool paysToSilentPayment, + int credentialsAmount = 0, int? inputsCount, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) { @@ -732,13 +731,11 @@ abstract class ElectrumWalletBase List outputs, int feeRate, { String? memo, - int credentialsAmount = 0, bool hasSilentPayment = false, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { final utxoDetails = _createUTXOS( sendAll: true, - credentialsAmount: credentialsAmount, paysToSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); @@ -764,23 +761,11 @@ abstract class ElectrumWalletBase throw BitcoinTransactionWrongBalanceException(amount: utxoDetails.allInputsAmount + fee); } - if (amount <= 0) { - throw BitcoinTransactionWrongBalanceException(); - } - // Attempting to send less than the dust limit if (_isBelowDust(amount)) { throw BitcoinTransactionNoDustException(); } - if (credentialsAmount > 0) { - final amountLeftForFee = amount - credentialsAmount; - if (amountLeftForFee > 0 && _isBelowDust(amountLeftForFee)) { - amount -= amountLeftForFee; - fee += amountLeftForFee; - } - } - if (outputs.length == 1) { outputs[0] = BitcoinOutput(address: outputs.last.address, value: BigInt.from(amount)); } @@ -810,6 +795,11 @@ abstract class ElectrumWalletBase bool hasSilentPayment = false, UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any, }) async { + // Attempting to send less than the dust limit + if (_isBelowDust(credentialsAmount)) { + throw BitcoinTransactionNoDustException(); + } + final utxoDetails = _createUTXOS( sendAll: false, credentialsAmount: credentialsAmount, @@ -894,7 +884,43 @@ abstract class ElectrumWalletBase final lastOutput = updatedOutputs.last; final amountLeftForChange = amountLeftForChangeAndFee - fee; - if (!_isBelowDust(amountLeftForChange)) { + if (_isBelowDust(amountLeftForChange)) { + // If has change that is lower than dust, will end up with tx rejected by network rules + // so remove the change amount + updatedOutputs.removeLast(); + outputs.removeLast(); + + if (amountLeftForChange < 0) { + if (!spendingAllCoins) { + return estimateTxForAmount( + credentialsAmount, + outputs, + updatedOutputs, + feeRate, + inputsCount: utxoDetails.utxos.length + 1, + memo: memo, + useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, + hasSilentPayment: hasSilentPayment, + coinTypeToSpendFrom: coinTypeToSpendFrom, + ); + } else { + throw BitcoinTransactionWrongBalanceException(); + } + } + + return EstimatedTxResult( + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, + fee: fee, + amount: amount, + hasChange: false, + isSendAll: spendingAllCoins, + memo: memo, + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, + ); + } else { // Here, lastOutput already is change, return the amount left without the fee to the user's address. updatedOutputs[updatedOutputs.length - 1] = BitcoinOutput( address: lastOutput.address, @@ -908,88 +934,20 @@ abstract class ElectrumWalletBase isSilentPayment: lastOutput.isSilentPayment, isChange: true, ); - } else { - // If has change that is lower than dust, will end up with tx rejected by network rules, so estimate again without the added change - updatedOutputs.removeLast(); - outputs.removeLast(); - // Still has inputs to spend before failing - if (!spendingAllCoins) { - return estimateTxForAmount( - credentialsAmount, - outputs, - updatedOutputs, - feeRate, - inputsCount: utxoDetails.utxos.length + 1, - memo: memo, - hasSilentPayment: hasSilentPayment, - useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, - coinTypeToSpendFrom: coinTypeToSpendFrom, - ); - } - - final estimatedSendAll = await estimateSendAllTx( - updatedOutputs, - feeRate, + return EstimatedTxResult( + utxos: utxoDetails.utxos, + inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, + publicKeys: utxoDetails.publicKeys, + fee: fee, + amount: amount, + hasChange: true, + isSendAll: spendingAllCoins, memo: memo, - coinTypeToSpendFrom: coinTypeToSpendFrom, - ); - - if (estimatedSendAll.amount == credentialsAmount) { - return estimatedSendAll; - } - - // Estimate to user how much is needed to send to cover the fee - final maxAmountWithReturningChange = utxoDetails.allInputsAmount - _dustAmount - fee - 1; - throw BitcoinTransactionNoDustOnChangeException( - bitcoinAmountToString(amount: maxAmountWithReturningChange), - bitcoinAmountToString(amount: estimatedSendAll.amount), + spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, + spendsSilentPayment: utxoDetails.spendsSilentPayment, ); } - - // Attempting to send less than the dust limit - if (_isBelowDust(amount)) { - throw BitcoinTransactionNoDustException(); - } - - final totalAmount = amount + fee; - - if (totalAmount > (balance[currency]!.confirmed + balance[currency]!.secondConfirmed)) { - throw BitcoinTransactionWrongBalanceException(); - } - - if (totalAmount > utxoDetails.allInputsAmount) { - if (spendingAllCoins) { - throw BitcoinTransactionWrongBalanceException(); - } else { - updatedOutputs.removeLast(); - outputs.removeLast(); - return estimateTxForAmount( - credentialsAmount, - outputs, - updatedOutputs, - feeRate, - inputsCount: utxoDetails.utxos.length + 1, - memo: memo, - useUnconfirmed: useUnconfirmed ?? spendingAllConfirmedCoins, - hasSilentPayment: hasSilentPayment, - coinTypeToSpendFrom: coinTypeToSpendFrom, - ); - } - } - - return EstimatedTxResult( - utxos: utxoDetails.utxos, - inputPrivKeyInfos: utxoDetails.inputPrivKeyInfos, - publicKeys: utxoDetails.publicKeys, - fee: fee, - amount: amount, - hasChange: true, - isSendAll: false, - memo: memo, - spendsUnconfirmedTX: utxoDetails.spendsUnconfirmedTX, - spendsSilentPayment: utxoDetails.spendsSilentPayment, - ); } Future calcFee({ @@ -1080,15 +1038,20 @@ abstract class ElectrumWalletBase : feeRate(transactionCredentials.priority!); EstimatedTxResult estimatedTx; - final updatedOutputs = - outputs.map((e) => BitcoinOutput(address: e.address, value: e.value)).toList(); + final updatedOutputs = outputs + .map((e) => BitcoinOutput( + address: e.address, + value: e.value, + isSilentPayment: e.isSilentPayment, + isChange: e.isChange, + )) + .toList(); if (sendAll) { estimatedTx = await estimateSendAllTx( updatedOutputs, feeRateInt, memo: memo, - credentialsAmount: credentialsAmount, hasSilentPayment: hasSilentPayment, coinTypeToSpendFrom: coinTypeToSpendFrom, ); diff --git a/cw_bitcoin/pubspec.lock b/cw_bitcoin/pubspec.lock index 5cba9b734..0e4921a3a 100644 --- a/cw_bitcoin/pubspec.lock +++ b/cw_bitcoin/pubspec.lock @@ -568,10 +568,10 @@ packages: dependency: "direct main" description: name: ledger_flutter_plus - sha256: ea3ed586e1697776dacf42ac979095f1ca3bd143bf007cbe5c78e09cb6943f42 + sha256: c7b04008553193dbca7e17b430768eecc372a72b0ff3625b5e7fc5e5c8d3231b url: "https://pub.dev" source: hosted - version: "1.2.5" + version: "1.4.1" ledger_litecoin: dependency: "direct main" description: From 8e12fb1ad9c4475abb846937946fc7c9ede6daf8 Mon Sep 17 00:00:00 2001 From: Rafael Date: Sat, 9 Nov 2024 02:20:06 -0300 Subject: [PATCH 8/8] fix: paymentUrls is null (#1786) * fix: paymentUrls is null * feat: potential secure storage error --- lib/cake_pay/cake_pay_order.dart | 1 - lib/cake_pay/cake_pay_service.dart | 6 ++++-- .../cake_pay/cards/cake_pay_confirm_purchase_card_page.dart | 6 +++++- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/lib/cake_pay/cake_pay_order.dart b/lib/cake_pay/cake_pay_order.dart index 8cb6e784a..acdf86ea0 100644 --- a/lib/cake_pay/cake_pay_order.dart +++ b/lib/cake_pay/cake_pay_order.dart @@ -1,4 +1,3 @@ - class CakePayOrder { final String orderId; final List cards; diff --git a/lib/cake_pay/cake_pay_service.dart b/lib/cake_pay/cake_pay_service.dart index 39f2ca77a..cf2ec254c 100644 --- a/lib/cake_pay/cake_pay_service.dart +++ b/lib/cake_pay/cake_pay_service.dart @@ -82,10 +82,12 @@ class CakePayService { } /// Logout - Future logout(String email) async { + Future logout([String? email]) async { await secureStorage.delete(key: cakePayUsernameStorageKey); await secureStorage.delete(key: cakePayUserTokenKey); - await cakePayApi.logoutUser(email: email, apiKey: cakePayApiKey); + if (email != null) { + await cakePayApi.logoutUser(email: email, apiKey: cakePayApiKey); + } } /// Purchase Gift Card diff --git a/lib/src/screens/cake_pay/cards/cake_pay_confirm_purchase_card_page.dart b/lib/src/screens/cake_pay/cards/cake_pay_confirm_purchase_card_page.dart index 81f6a354f..309c435a2 100644 --- a/lib/src/screens/cake_pay/cards/cake_pay_confirm_purchase_card_page.dart +++ b/lib/src/screens/cake_pay/cards/cake_pay_confirm_purchase_card_page.dart @@ -258,7 +258,11 @@ class CakePayBuyCardDetailPage extends BasePage { if (!isLogged) { Navigator.of(context).pushNamed(Routes.cakePayWelcomePage); } else { - await cakePayPurchaseViewModel.createOrder(); + try { + await cakePayPurchaseViewModel.createOrder(); + } catch (_) { + await cakePayPurchaseViewModel.cakePayService.logout(); + } } }