From 858e5cac523583ee37255b7eb033d13946686593 Mon Sep 17 00:00:00 2001 From: rottenwheel <92872541+rottenwheel@users.noreply.github.com> Date: Fri, 25 Oct 2024 21:56:02 +0000 Subject: [PATCH 01/34] spanish: miscellaneous rewording (#1777) --- res/values/strings_es.arb | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index a8ad7549a..520c8ea78 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -78,7 +78,7 @@ "billing_address_info": "Si se le solicita una dirección de facturación, proporcione su dirección de envío", "biometric_auth_reason": "Escanee su huella digital para autenticar", "bitcoin_dark_theme": "Tema oscuro de Bitcoin", - "bitcoin_light_theme": "Tema de la luz de Bitcoin", + "bitcoin_light_theme": "Tema claro de Bitcoin", "bitcoin_payments_require_1_confirmation": "Los pagos de Bitcoin requieren 1 confirmación, que puede demorar 20 minutos o más. ¡Gracias por su paciencia! Se le enviará un correo electrónico cuando se confirme el pago.", "block_remaining": "1 bloqueo restante", "Blocks_remaining": "${status} Bloques restantes", @@ -93,7 +93,7 @@ "buy_with": "Compra con", "by_cake_pay": "por Cake Pay", "cake_2fa_preset": "Pastel 2FA preestablecido", - "cake_dark_theme": "Tema oscuro del pastel", + "cake_dark_theme": "Tema oscuro", "cake_pay_account_note": "Regístrese con solo una dirección de correo electrónico para ver y comprar tarjetas. ¡Algunas incluso están disponibles con descuento!", "cake_pay_learn_more": "¡Compre y canjee tarjetas de regalo al instante en la aplicación!\nDeslice el dedo de izquierda a derecha para obtener más información.", "cake_pay_save_order": "La tarjeta debe enviarse a su correo electrónico dentro de 1 día hábil \n Guardar su ID de pedido:", @@ -144,9 +144,9 @@ "confirm_delete_wallet": "Esta acción eliminará esta billetera. ¿Desea continuar?", "confirm_fee_deduction": "Confirmar la deducción de la tarifa", "confirm_fee_deduction_content": "¿Acepta deducir la tarifa de la producción?", - "confirm_passphrase": "Confirmar la frase de pases", + "confirm_passphrase": "Confirmar la contraseña", "confirm_sending": "Confirmar envío", - "confirm_silent_payments_switch_node": "Su nodo actual no admite pagos silenciosos \\ ncake billet cambiará a un nodo compatible, solo para escanear", + "confirm_silent_payments_switch_node": "Su nodo actual no admite pagos silenciosos \\ nCake cambiará a un nodo compatible, solo para escanear", "confirmations": "Confirmaciones", "confirmed": "Saldo confirmado", "confirmed_tx": "Confirmado", @@ -281,7 +281,7 @@ "etherscan_history": "historia de etherscan", "event": "Evento", "events": "Eventos", - "exchange": "Intercambio", + "exchange": "Intercambiar", "exchange_incorrect_current_wallet_for_xmr": "Si desea intercambiar XMR desde su billetera de pastel Monero Balance, primero cambie a su billetera Monero.", "exchange_new_template": "Nueva plantilla", "exchange_provider_unsupported": "¡${providerName} ya no es compatible!", @@ -826,7 +826,7 @@ "transaction_priority_slow": "Lento", "transaction_sent": "Transacción enviada!", "transaction_sent_notice": "Si la pantalla no continúa después de 1 minuto, revisa un explorador de bloques y tu correo electrónico.", - "transactions": "Actas", + "transactions": "Transacciones", "transactions_by_date": "Transacciones por fecha", "trongrid_history": "Historia trongrid", "trusted": "de confianza", @@ -939,4 +939,4 @@ "you_will_get": "Convertir a", "you_will_send": "Convertir de", "yy": "YY" -} \ No newline at end of file +} From 54c41d41cd1cd3dec59fafad9fd56eeb0bda671b Mon Sep 17 00:00:00 2001 From: rottenwheel <92872541+rottenwheel@users.noreply.github.com> Date: Fri, 25 Oct 2024 21:56:21 +0000 Subject: [PATCH 02/34] spanish (#1776) --- res/values/strings_es.arb | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 520c8ea78..449557671 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -372,8 +372,8 @@ "litecoin_mweb_dismiss": "Despedir", "litecoin_mweb_display_card": "Mostrar tarjeta MWEB", "litecoin_mweb_enable_later": "Puede elegir habilitar MWEB nuevamente en la configuración de visualización.", - "litecoin_mweb_pegin": "Meter", - "litecoin_mweb_pegout": "Estirar la pata", + "litecoin_mweb_pegin": "Convertir", + "litecoin_mweb_pegout": "Recuperar", "litecoin_mweb_scanning": "Escaneo mweb", "litecoin_mweb_settings": "Configuración de MWEB", "litecoin_mweb_warning": "El uso de MWEB inicialmente descargará ~ 600 MB de datos, y puede tomar hasta 30 minutos según la velocidad de la red. Estos datos iniciales solo se descargarán una vez y estarán disponibles para todas las billeteras de Litecoin", From 5c206a8b7cf2a9279910a7c97bf2689099a26f0a Mon Sep 17 00:00:00 2001 From: Serhii Date: Mon, 28 Oct 2024 02:37:38 +0200 Subject: [PATCH 03/34] fix MobX Exception: computed values issue (#1763) --- cw_bitcoin/lib/electrum_wallet_addresses.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart index 2686d12cc..c29579436 100644 --- a/cw_bitcoin/lib/electrum_wallet_addresses.dart +++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart @@ -331,7 +331,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { ); silentAddresses.add(address); - updateAddressesByMatch(); + Future.delayed(Duration.zero, () => updateAddressesByMatch()); return address; } @@ -348,7 +348,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { network: network, ); _addresses.add(address); - updateAddressesByMatch(); + Future.delayed(Duration.zero, () => updateAddressesByMatch()); return address; } From 198510533e854f437037573fe0575d73a9b21971 Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Wed, 30 Oct 2024 17:57:26 +0100 Subject: [PATCH 04/34] Fix disappearing recipientAddress for XMR Transactions (#1784) --- .../transaction_details_view_model.dart | 29 ++++++++----------- 1 file changed, 12 insertions(+), 17 deletions(-) diff --git a/lib/view_model/transaction_details_view_model.dart b/lib/view_model/transaction_details_view_model.dart index 1833a0f51..da6699ae6 100644 --- a/lib/view_model/transaction_details_view_model.dart +++ b/lib/view_model/transaction_details_view_model.dart @@ -83,18 +83,19 @@ abstract class TransactionDetailsViewModelBase with Store { break; } - if (showRecipientAddress && !isRecipientAddressShown) { - try { - final recipientAddress = transactionDescriptionBox.values - .firstWhere((val) => val.id == transactionInfo.txHash) - .recipientAddress; + final descriptionKey = + '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; + final description = transactionDescriptionBox.values.firstWhere( + (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, + orElse: () => TransactionDescription(id: descriptionKey)); - if (recipientAddress?.isNotEmpty ?? false) { - items.add(StandartListItem( - title: S.current.transaction_details_recipient_address, value: recipientAddress!)); - } - } catch (_) { - // FIX-ME: Unhandled exception + if (showRecipientAddress && !isRecipientAddressShown) { + final recipientAddress = description.recipientAddress; + + if (recipientAddress?.isNotEmpty ?? false) { + items.add(StandartListItem( + title: S.current.transaction_details_recipient_address, + value: recipientAddress!)); } } @@ -110,12 +111,6 @@ abstract class TransactionDetailsViewModelBase with Store { } catch (e) {} })); - final descriptionKey = '${transactionInfo.txHash}_${wallet.walletAddresses.primaryAddress}'; - - final description = transactionDescriptionBox.values.firstWhere( - (val) => val.id == descriptionKey || val.id == transactionInfo.txHash, - orElse: () => TransactionDescription(id: descriptionKey)); - items.add(TextFieldListItem( title: S.current.note_tap_to_change, value: description.note, From e501a4131444ea79e6db79348081e5945ee21490 Mon Sep 17 00:00:00 2001 From: tuxsudo Date: Wed, 30 Oct 2024 14:44:49 -0400 Subject: [PATCH 05/34] Fix the manifest once and for all (#1773) --- android/app/src/main/AndroidManifestBase.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/AndroidManifestBase.xml b/android/app/src/main/AndroidManifestBase.xml index 98bbb1236..5b080e3ec 100644 --- a/android/app/src/main/AndroidManifestBase.xml +++ b/android/app/src/main/AndroidManifestBase.xml @@ -14,7 +14,8 @@ - + + From 5fc649023cda6a802802466559642c5707d16f73 Mon Sep 17 00:00:00 2001 From: oksanaphmn Date: Wed, 30 Oct 2024 20:50:04 +0200 Subject: [PATCH 06/34] Update README.md (#1770) --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 1c28f92a2..ca3061e61 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ # Cake Wallet -Cake Wallet 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) @@ -26,7 +26,7 @@ Cake Wallet includes support for several cryptocurrencies, including: * Ethereum (ETH) * Litecoin (LTC) * Bitcoin Cash (BCH) -* Polygon (MATIC) +* Polygon (Pol) * Solana (SOL) * Nano (XNO) * Haven (XHV) From 752b6bbebf7e975840f11ff569f13bff7600d1ea Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Thu, 31 Oct 2024 02:10:40 +0100 Subject: [PATCH 07/34] fix: Generic fixes of bugs from Support (#1774) --- cw_bitcoin/lib/electrum_wallet.dart | 2 ++ cw_haven/lib/haven_wallet_addresses.dart | 2 ++ lib/core/wallet_connect/web3wallet_service.dart | 8 ++++++++ lib/entities/fiat_currency.dart | 2 +- .../desktop_wallet_selection_dropdown.dart | 8 ++++++-- .../widgets/modals/bottom_sheet_listener.dart | 4 +++- lib/src/widgets/cake_image_widget.dart | 2 ++ lib/src/widgets/services_updates_widget.dart | 2 ++ lib/utils/exception_handler.dart | 8 ++++++++ lib/utils/image_utill.dart | 14 ++++++++++++-- 10 files changed, 46 insertions(+), 6 deletions(-) diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 4b80cb201..c05095cf1 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -2270,6 +2270,8 @@ 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_haven/lib/haven_wallet_addresses.dart b/cw_haven/lib/haven_wallet_addresses.dart index 06de44dff..c3d1ef46c 100644 --- a/cw_haven/lib/haven_wallet_addresses.dart +++ b/cw_haven/lib/haven_wallet_addresses.dart @@ -23,6 +23,8 @@ abstract class HavenWalletAddressesBase extends WalletAddressesWithAccount address; + // @override @observable Account? account; diff --git a/lib/core/wallet_connect/web3wallet_service.dart b/lib/core/wallet_connect/web3wallet_service.dart index adb516817..ba4785643 100644 --- a/lib/core/wallet_connect/web3wallet_service.dart +++ b/lib/core/wallet_connect/web3wallet_service.dart @@ -267,6 +267,8 @@ abstract class Web3WalletServiceBase with Store { final keyForWallet = getKeyForStoringTopicsForWallet(); + if (keyForWallet.isEmpty) return; + final currentTopicsForWallet = getPairingTopicsForWallet(keyForWallet); final filteredPairings = @@ -360,6 +362,10 @@ abstract class Web3WalletServiceBase with Store { String getKeyForStoringTopicsForWallet() { List chainKeys = walletKeyService.getKeysForChain(appStore.wallet!); + if (chainKeys.isEmpty) { + return ''; + } + final keyForPairingTopic = PreferencesKey.walletConnectPairingTopicsListForWallet(chainKeys.first.publicKey); @@ -386,6 +392,8 @@ abstract class Web3WalletServiceBase with Store { // Get key specific to the current wallet final key = getKeyForStoringTopicsForWallet(); + if (key.isEmpty) return; + // Get all pairing topics attached to this key final pairingTopicsForWallet = getPairingTopicsForWallet(key); diff --git a/lib/entities/fiat_currency.dart b/lib/entities/fiat_currency.dart index a8e2829d8..1a031ee82 100644 --- a/lib/entities/fiat_currency.dart +++ b/lib/entities/fiat_currency.dart @@ -114,7 +114,7 @@ class FiatCurrency extends EnumerableItem with Serializable impl FiatCurrency.tur.raw: FiatCurrency.tur, }; - static FiatCurrency deserialize({required String raw}) => _all[raw]!; + static FiatCurrency deserialize({required String raw}) => _all[raw] ?? FiatCurrency.usd; @override bool operator ==(Object other) => other is FiatCurrency && other.raw == raw; diff --git a/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart b/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart index f8cbe9120..0fb629685 100644 --- a/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart +++ b/lib/src/screens/dashboard/desktop_widgets/desktop_wallet_selection_dropdown.dart @@ -10,7 +10,6 @@ import 'package:cake_wallet/src/screens/dashboard/desktop_widgets/dropdown_item_ import 'package:cake_wallet/src/screens/wallet_unlock/wallet_unlock_arguments.dart'; import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; import 'package:cake_wallet/themes/extensions/menu_theme.dart'; import 'package:cake_wallet/utils/show_bar.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; @@ -100,6 +99,11 @@ class _DesktopWalletSelectionDropDownState extends State element.isSelected, + orElse: () => dropDownItems.first, + ); + return DropdownButton( items: dropDownItems .map( @@ -115,7 +119,7 @@ class _DesktopWalletSelectionDropDownState extends State()!.backgroundColor, style: TextStyle(color: themeData.extension()!.titleColor), selectedItemBuilder: (context) => dropDownItems.map((item) => item.child).toList(), - value: dropDownItems.firstWhere((element) => element.isSelected), + value: selectedItem, underline: const SizedBox(), focusColor: Colors.transparent, borderRadius: BorderRadius.circular(15.0), diff --git a/lib/src/screens/wallet_connect/widgets/modals/bottom_sheet_listener.dart b/lib/src/screens/wallet_connect/widgets/modals/bottom_sheet_listener.dart index 30b6af7e0..42bef5135 100644 --- a/lib/src/screens/wallet_connect/widgets/modals/bottom_sheet_listener.dart +++ b/lib/src/screens/wallet_connect/widgets/modals/bottom_sheet_listener.dart @@ -52,7 +52,9 @@ class BottomSheetListenerState extends State { ); }, ); - item.completer.complete(value); + if (!item.completer.isCompleted) { + item.completer.complete(value); + } widget.bottomSheetService.resetCurrentSheet(); } } diff --git a/lib/src/widgets/cake_image_widget.dart b/lib/src/widgets/cake_image_widget.dart index ad02c48dd..2038030c0 100644 --- a/lib/src/widgets/cake_image_widget.dart +++ b/lib/src/widgets/cake_image_widget.dart @@ -25,6 +25,7 @@ class CakeImageWidget extends StatelessWidget { imageUrl!, height: height, width: width, + errorBuilder: (_, __, ___) => Icon(Icons.error), ); } @@ -33,6 +34,7 @@ class CakeImageWidget extends StatelessWidget { imageUrl!, height: height, width: width, + placeholderBuilder: (_) => Icon(Icons.error), ); } diff --git a/lib/src/widgets/services_updates_widget.dart b/lib/src/widgets/services_updates_widget.dart index 1babd23d0..1787b7118 100644 --- a/lib/src/widgets/services_updates_widget.dart +++ b/lib/src/widgets/services_updates_widget.dart @@ -46,6 +46,7 @@ class _ServicesUpdatesWidgetState extends State { "assets/images/notification_icon.svg", color: Theme.of(context).extension()!.pageTitleTextColor, width: 30, + placeholderBuilder: (_) => Icon(Icons.error), ), ); } @@ -136,6 +137,7 @@ class _ServicesUpdatesWidgetState extends State { "assets/images/notification_icon.svg", color: Theme.of(context).extension()!.pageTitleTextColor, width: 30, + placeholderBuilder: (_) => Icon(Icons.error), ), if (state.hasData && state.data!.hasUpdates && !wasOpened) Container( diff --git a/lib/utils/exception_handler.dart b/lib/utils/exception_handler.dart index 91797c45e..41ae91d41 100644 --- a/lib/utils/exception_handler.dart +++ b/lib/utils/exception_handler.dart @@ -62,6 +62,14 @@ class ExceptionHandler { await _addDeviceInfo(_file!); + // Check if a mail client is available + final bool canSend = await FlutterMailer.canSendMail(); + + if (Platform.isIOS && !canSend) { + debugPrint('Mail app is not available'); + return; + } + final MailOptions mailOptions = MailOptions( subject: 'Mobile App Issue', recipients: ['support@cakewallet.com'], diff --git a/lib/utils/image_utill.dart b/lib/utils/image_utill.dart index a138df23a..ef3775e4c 100644 --- a/lib/utils/image_utill.dart +++ b/lib/utils/image_utill.dart @@ -53,8 +53,18 @@ class ImageUtil { ); } else { return isSvg - ? SvgPicture.asset(imagePath, height: _height, width: _width) - : Image.asset(imagePath, height: _height, width: _width); + ? SvgPicture.asset( + imagePath, + height: _height, + width: _width, + placeholderBuilder: (_) => Icon(Icons.error), + ) + : Image.asset( + imagePath, + height: _height, + width: _width, + errorBuilder: (_, __, ___) => Icon(Icons.error), + ); } } } From 740f466e77464090f1e4c9457f3c4abeb7c2181e Mon Sep 17 00:00:00 2001 From: Rafael Date: Fri, 1 Nov 2024 14:34:17 -0300 Subject: [PATCH 08/34] fix: sp filter option (#1780) * fix: sp filter option * fix: nullable --- .../dashboard/transaction_filter_store.dart | 5 +++-- .../dashboard/dashboard_view_model.dart | 16 ++++++++++------ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/lib/store/dashboard/transaction_filter_store.dart b/lib/store/dashboard/transaction_filter_store.dart index fb9ee14cd..f28f7e915 100644 --- a/lib/store/dashboard/transaction_filter_store.dart +++ b/lib/store/dashboard/transaction_filter_store.dart @@ -91,8 +91,9 @@ abstract class TransactionFilterStoreBase with Store { (displayOutgoing && item.transaction.direction == TransactionDirection.outgoing) || (displayIncoming && item.transaction.direction == TransactionDirection.incoming && - !bitcoin!.txIsReceivedSilentPayment(item.transaction)) || - (displaySilentPayments && bitcoin!.txIsReceivedSilentPayment(item.transaction)); + !(bitcoin?.txIsReceivedSilentPayment(item.transaction) ?? false)) || + (displaySilentPayments && + (bitcoin?.txIsReceivedSilentPayment(item.transaction) ?? false)); } else if (item is AnonpayTransactionListItem) { allowed = displayIncoming; } diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 53a8277ab..423babefc 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -90,11 +90,12 @@ abstract class DashboardViewModelBase with Store { value: () => transactionFilterStore.displayOutgoing, caption: S.current.outgoing, onChanged: transactionFilterStore.toggleOutgoing), - FilterItem( - value: () => transactionFilterStore.displaySilentPayments, - caption: S.current.silent_payments, - onChanged: transactionFilterStore.toggleSilentPayments, - ), + if (appStore.wallet!.type == WalletType.bitcoin) + FilterItem( + value: () => transactionFilterStore.displaySilentPayments, + caption: S.current.silent_payments, + onChanged: transactionFilterStore.toggleSilentPayments, + ), // FilterItem( // value: () => false, // caption: S.current.transactions_by_date, @@ -435,7 +436,10 @@ abstract class DashboardViewModelBase with Store { } @computed - bool get hasMweb => wallet.type == WalletType.litecoin && (Platform.isIOS || Platform.isAndroid) && !wallet.isHardwareWallet; + bool get hasMweb => + wallet.type == WalletType.litecoin && + (Platform.isIOS || Platform.isAndroid) && + !wallet.isHardwareWallet; @computed bool get showMwebCard => hasMweb && settingsStore.mwebCardDisplay && !mwebEnabled; From 4916d1f4576e72542a57fcc8052e6a5038d25da2 Mon Sep 17 00:00:00 2001 From: Serhii Date: Fri, 1 Nov 2024 19:54:17 +0200 Subject: [PATCH 09/34] fix the issue with updating sending outputs (#1785) --- lib/view_model/send/send_view_model.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 24e83f364..1403eb109 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -400,7 +400,8 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor final updatedOutputs = bitcoin!.updateOutputs(pendingTransaction!, outputs); if (outputs.length == updatedOutputs.length) { - outputs = ObservableList.of(updatedOutputs); + outputs.clear(); + outputs.addAll(updatedOutputs); } } From 72241d49114afeb61a8551b53b9853fd695884af Mon Sep 17 00:00:00 2001 From: rottenwheel <92872541+rottenwheel@users.noreply.github.com> Date: Fri, 1 Nov 2024 21:57:17 +0000 Subject: [PATCH 10/34] spanish: tidy up and reword a lot of ES translations; catch missed unstralated strings (#1788) --- res/values/strings_es.arb | 312 +++++++++++++++++++------------------- 1 file changed, 156 insertions(+), 156 deletions(-) diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 449557671..25c9f95c1 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -1,5 +1,5 @@ { - "about_cake_pay": "Cake Pay le permite comprar fácilmente tarjetas de regalo con activos virtuales, gastables instantáneamente en más de 150 000 comerciantes en los Estados Unidos.", + "about_cake_pay": "Cake Pay te permite comprar fácilmente tarjetas de regalo con activos virtuales, gastables instantáneamente en más de 150,000 comerciantes en los Estados Unidos.", "account": "Cuenta", "accounts": "Cuentas", "accounts_subaddresses": "Cuentas y subdirecciones", @@ -21,13 +21,13 @@ "add_token_disclaimer_check": "He confirmado la dirección del contrato del token y la información utilizando una fuente confiable. Agregar información maliciosa o incorrecta puede resultar en una pérdida de fondos.", "add_token_warning": "No edite ni agregue tokens según las instrucciones de los estafadores.\n¡Confirme siempre las direcciones de los tokens con fuentes acreditadas!", "add_value": "Añadir valor", - "address": "DIRECCIÓN", + "address": "Dirección", "address_book": "Libreta de direcciones", "address_book_menu": "Libreta de direcciones", "address_detected": "Dirección detectada", "address_from_domain": "Esta dirección es de ${domain} en Unstoppable Domains", "address_from_yat": "Esta dirección es de ${emoji} en Yat", - "address_label": "Address label", + "address_label": "Etiqueta de dirección", "address_remove_contact": "Remover contacto", "address_remove_content": "¿Estás seguro de que quieres eliminar el contacto seleccionado?", "addresses": "Direcciones", @@ -37,12 +37,12 @@ "agree_and_continue": "Aceptar y continuar", "agree_to": "Al crear una cuenta, aceptas ", "alert_notice": "Aviso", - "all": "TODOS", + "all": "Todos", "all_trades": "Todos los oficios", "all_transactions": "Todas las transacciones", "alphabetical": "Alfabético", "already_have_account": "¿Ya tienes una cuenta?", - "always": "siempre", + "always": "Siempre", "amount": "Cantidad: ", "amount_is_below_minimum_limit": "Su saldo después de las tarifas sería menor que la cantidad mínima necesaria para el intercambio (${min})", "amount_is_estimate": "El monto recibido es un estimado", @@ -54,17 +54,17 @@ "arrive_in_this_address": "${currency} ${tag}llegará a esta dirección", "ascending": "Ascendente", "ask_each_time": "Pregunta cada vez", - "auth_store_ban_timeout": "prohibición de tiempo de espera", + "auth_store_ban_timeout": "Prohibición de tiempo de espera", "auth_store_banned_for": "Prohibido para ", "auth_store_banned_minutes": " minutos", "auth_store_incorrect_password": "Contraseña PIN", "authenticated": "Autenticados", "authentication": "Autenticación", - "auto_generate_addresses": "Auto Generar direcciones", + "auto_generate_addresses": "Auto-generar nuevas direcciones", "auto_generate_subaddresses": "Generar subdirecciones automáticamente", "automatic": "Automático", "available_balance": "Balance disponible", - "available_balance_description": "Su saldo disponible es la cantidad de fondos que puede gastar. Los fondos que se muestran aquí se pueden gastar inmediatamente.", + "available_balance_description": "Tu saldo disponible es la cantidad de fondos que puedes gastar. Los fondos que se muestran aquí, se pueden gastar inmediatamente.", "avg_savings": "Ahorro promedio", "awaitDAppProcessing": "Espere a que la dApp termine de procesarse.", "awaiting_payment_confirmation": "Esperando confirmación de pago", @@ -79,7 +79,7 @@ "biometric_auth_reason": "Escanee su huella digital para autenticar", "bitcoin_dark_theme": "Tema oscuro de Bitcoin", "bitcoin_light_theme": "Tema claro de Bitcoin", - "bitcoin_payments_require_1_confirmation": "Los pagos de Bitcoin requieren 1 confirmación, que puede demorar 20 minutos o más. ¡Gracias por su paciencia! Se le enviará un correo electrónico cuando se confirme el pago.", + "bitcoin_payments_require_1_confirmation": "Los pagos de Bitcoin requieren 1 confirmación, que puede demorar 20 minutos o más. ¡Gracias por tu paciencia! Se te enviará un correo electrónico cuando se confirme el pago.", "block_remaining": "1 bloqueo restante", "Blocks_remaining": "${status} Bloques restantes", "bluetooth": "Bluetooth", @@ -94,25 +94,25 @@ "by_cake_pay": "por Cake Pay", "cake_2fa_preset": "Pastel 2FA preestablecido", "cake_dark_theme": "Tema oscuro", - "cake_pay_account_note": "Regístrese con solo una dirección de correo electrónico para ver y comprar tarjetas. ¡Algunas incluso están disponibles con descuento!", - "cake_pay_learn_more": "¡Compre y canjee tarjetas de regalo al instante en la aplicación!\nDeslice el dedo de izquierda a derecha para obtener más información.", - "cake_pay_save_order": "La tarjeta debe enviarse a su correo electrónico dentro de 1 día hábil \n Guardar su ID de pedido:", - "cake_pay_subtitle": "Compre tarjetas prepagas y tarjetas de regalo en todo el mundo", - "cake_pay_web_cards_subtitle": "Compre tarjetas de prepago y tarjetas de regalo en todo el mundo", + "cake_pay_account_note": "Regístrate con solo una dirección de correo electrónico para ver y comprar tarjetas. ¡Algunas incluso están disponibles con descuento!", + "cake_pay_learn_more": "¡Compra y canjea tarjetas de regalo al instante en la aplicación!\nDesliza el dedo de izquierda a derecha para obtener más información.", + "cake_pay_save_order": "La tarjeta debe enviarse a tu correo electrónico dentro de 1 día hábil \n Guardar su ID de pedido:", + "cake_pay_subtitle": "Compra tarjetas prepagadas y tarjetas de regalo en todo el mundo", + "cake_pay_web_cards_subtitle": "Compra tarjetas de prepago y tarjetas de regalo en todo el mundo", "cake_pay_web_cards_title": "Tarjetas Web Cake Pay", "cake_wallet": "Cake Wallet", "cakepay_prepaid_card": "Tarjeta de Débito Prepago CakePay", - "camera_consent": "Su cámara será utilizada para capturar una imagen con fines de identificación por ${provider}. Consulte su Política de privacidad para obtener más detalles.", - "camera_permission_is_required": "Se requiere permiso de la cámara.\nHabilítelo desde la configuración de la aplicación.", + "camera_consent": "Su cámara será utilizada para capturar una imagen con fines de identificación por ${provider}. Consulta tu Política de privacidad para obtener más detalles.", + "camera_permission_is_required": "Se requiere permiso de la cámara.\nHabilítalo desde la configuración de la aplicación.", "cancel": "Cancelar", "card_address": "Dirección:", "cardholder_agreement": "Acuerdo del titular de la tarjeta", "cards": "Cartas", "chains": "Cadenas", "change": "Cambio", - "change_backup_password_alert": "Sus archivos de respaldo anteriores no estarán disponibles para importar con la nueva contraseña de respaldo. La nueva contraseña de respaldo se utilizará solo para los nuevos archivos de respaldo. ¿Está seguro de que desea cambiar la contraseña de respaldo?", + "change_backup_password_alert": "Tus archivos de respaldo anteriores no estarán disponibles para importar con la nueva contraseña de respaldo. La nueva contraseña de respaldo se utilizará solo para los nuevos archivos de respaldo. ¿Está seguro de que desea cambiar la contraseña de respaldo?", "change_currency": "Cambiar moneda", - "change_current_node": "¿Está seguro de cambiar el nodo actual a ${node}?", + "change_current_node": "¿Estás seguro de cambiar el nodo actual a ${node}?", "change_current_node_title": "Cambiar el nodo actual", "change_exchange_provider": "Cambiar proveedor de intercambio", "change_language": "Cambiar idioma", @@ -125,38 +125,38 @@ "change_wallet_alert_title": "Cambiar billetera actual", "choose_account": "Elegir cuenta", "choose_address": "\n\nPor favor elija la dirección:", - "choose_card_value": "Elija un valor de tarjeta", - "choose_derivation": "Elija la derivación de la billetera", - "choose_from_available_options": "Elija entre las opciones disponibles:", + "choose_card_value": "Elige un valor de tarjeta", + "choose_derivation": "Elige la derivación de la billetera", + "choose_from_available_options": "Elige entre las opciones disponibles:", "choose_one": "Elige uno", - "choose_relay": "Por favor elija un relé para usar", - "choose_wallet_currency": "Por favor, elija la moneda de la billetera:", - "choose_wallet_group": "Elija el grupo de billetera", + "choose_relay": "Por favor elige un relay para usar", + "choose_wallet_currency": "Por favor, elige la moneda de la billetera:", + "choose_wallet_group": "Elige el grupo de billetera", "clear": "Claro", "clearnet_link": "enlace Clearnet", "close": "Cerca", "coin_control": "Control de monedas (opcional)", - "cold_or_recover_wallet": "Agregue una billetera fría o recupere una billetera de papel", + "cold_or_recover_wallet": "Agrega una billetera fría o recupera una billetera de papel", "color_theme": "Tema de color", "commit_transaction_amount_fee": "Confirmar transacción\nCantidad: ${amount}\nCuota: ${fee}", "confirm": "Confirmar", - "confirm_delete_template": "Esta acción eliminará esta plantilla. ¿Desea continuar?", - "confirm_delete_wallet": "Esta acción eliminará esta billetera. ¿Desea continuar?", + "confirm_delete_template": "Esta acción eliminará esta plantilla. ¿Deseas continuar?", + "confirm_delete_wallet": "Esta acción eliminará esta billetera. ¿Deseas continuar?", "confirm_fee_deduction": "Confirmar la deducción de la tarifa", - "confirm_fee_deduction_content": "¿Acepta deducir la tarifa de la producción?", + "confirm_fee_deduction_content": "¿Aceptas deducir la tarifa de la producción?", "confirm_passphrase": "Confirmar la contraseña", "confirm_sending": "Confirmar envío", - "confirm_silent_payments_switch_node": "Su nodo actual no admite pagos silenciosos \\ nCake cambiará a un nodo compatible, solo para escanear", + "confirm_silent_payments_switch_node": "Tu nodo actual no admite pagos silenciosos \\ nCake cambiará a un nodo compatible, solo para escanear", "confirmations": "Confirmaciones", "confirmed": "Saldo confirmado", "confirmed_tx": "Confirmado", "congratulations": "Felicidades!", "connect_an_existing_yat": "Conectar un Yat existente", "connect_yats": "Conectar Yats", - "connect_your_hardware_wallet": "Conecte su billetera de hardware con Bluetooth o USB", - "connect_your_hardware_wallet_ios": "Conecte su billetera de hardware con Bluetooth", + "connect_your_hardware_wallet": "Conecta tu billetera de hardware con Bluetooth o USB", + "connect_your_hardware_wallet_ios": "Conecta tu billetera de hardware con Bluetooth", "connection_sync": "Conexión y sincronización", - "connectWalletPrompt": "Conecte su billetera con WalletConnect para realizar transacciones", + "connectWalletPrompt": "Conecte tu billetera con WalletConnect para realizar transacciones", "contact": "Contacto", "contact_list_contacts": "Contactos", "contact_list_wallets": "Mis billeteras", @@ -187,7 +187,7 @@ "custom_drag": "Custom (mantenía y arrastre)", "custom_redeem_amount": "Cantidad de canje personalizada", "custom_value": "Valor personalizado", - "dark_theme": "Oscura", + "dark_theme": "Oscuro", "debit_card": "Tarjeta de Débito", "debit_card_terms": "El almacenamiento y el uso de su número de tarjeta de pago (y las credenciales correspondientes a su número de tarjeta de pago) en esta billetera digital están sujetos a los Términos y condiciones del acuerdo del titular de la tarjeta aplicable con el emisor de la tarjeta de pago, en vigor desde tiempo al tiempo.", "decimal_places_error": "Demasiados lugares decimales", @@ -197,21 +197,21 @@ "delete": "Borrar", "delete_account": "Eliminar cuenta", "delete_wallet": "Eliminar billetera", - "delete_wallet_confirm_message": "¿Está seguro de que desea eliminar la billetera ${wallet_name}?", - "deleteConnectionConfirmationPrompt": "¿Está seguro de que desea eliminar la conexión a", + "delete_wallet_confirm_message": "¿Estás seguro de que deseas eliminar la billetera ${wallet_name}?", + "deleteConnectionConfirmationPrompt": "¿Estás seguro de que deseas eliminar la conexión a", "denominations": "Denominaciones", "derivationpath": "Ruta de derivación", "descending": "Descendente", "description": "Descripción", "destination_tag": "Etiqueta de destino:", - "dfx_option_description": "Compre criptografía con EUR y CHF. Para clientes minoristas y corporativos en Europa", + "dfx_option_description": "Compre cripto con EUR y CHF. Para clientes minoristas y corporativos en Europa", "didnt_get_code": "¿No recibiste el código?", "digit_pin": "-dígito PIN", "digital_and_physical_card": " tarjeta de débito prepago digital y física", "disable": "Desactivar", "disable_bulletin": "Desactivar el boletín de estado del servicio", "disable_buy": "Desactivar acción de compra", - "disable_cake_2fa": "Desactivar pastel 2FA", + "disable_cake_2fa": "Desactivar 2FA", "disable_exchange": "Deshabilitar intercambio", "disable_fee_api_warning": "Al apagar esto, las tasas de tarifas pueden ser inexactas en algunos casos, por lo que puede terminar pagando en exceso o pagando menos las tarifas por sus transacciones", "disable_fiat": "Deshabilitar fiat", @@ -224,7 +224,7 @@ "displayable": "Visualizable", "do_not_have_enough_gas_asset": "No tienes suficiente ${currency} para realizar una transacción con las condiciones actuales de la red blockchain. Necesita más ${currency} para pagar las tarifas de la red blockchain, incluso si envía un activo diferente.", "do_not_send": "no enviar", - "do_not_share_warning_text": "No comparta estos con nadie más, incluido el soporte.\n\n¡Sus fondos pueden ser y serán robados!", + "do_not_share_warning_text": "No compartas estos con nadie más, incluido el soporte.\n\n¡Tus fondos pueden ser y serán robados!", "do_not_show_me": "no me muestres esto otra vez", "domain_looks_up": "Búsquedas de dominio", "donation_link_details": "Detalles del enlace de donación", @@ -238,21 +238,21 @@ "enable": "Permitir", "enable_mempool_api": "API de Mempool para tarifas y fechas precisas", "enable_replace_by_fee": "Habilitar reemplazar por tarea", - "enable_silent_payments_scanning": "Comience a escanear pagos silenciosos, hasta que se alcance la punta", + "enable_silent_payments_scanning": "Comienza a escanear pagos silenciosos, hasta que se alcance la altura actual", "enabled": "Activado", - "enter_amount": "Ingrese la cantidad", - "enter_backup_password": "Ingrese la contraseña de respaldo aquí", + "enter_amount": "Ingresa la cantidad", + "enter_backup_password": "Ingresa la contraseña de respaldo aquí", "enter_code": "Ingresar código", - "enter_seed_phrase": "Ingrese su frase de semillas", - "enter_totp_code": "Ingrese el código TOTP.", - "enter_wallet_password": "Ingrese la contraseña de la billetera", + "enter_seed_phrase": "Ingresa su frase de semillas", + "enter_totp_code": "Ingresa el código TOTP.", + "enter_wallet_password": "Ingresa la contraseña de la billetera", "enter_your_note": "Ingresa tu nota…", "enter_your_pin": "Introduce tu PIN", - "enter_your_pin_again": "Ingrese su PIN nuevamente", - "enterTokenID": "Ingrese el ID del token", - "enterWalletConnectURI": "Ingrese el URI de WalletConnect", + "enter_your_pin_again": "Ingresa su PIN nuevamente", + "enterTokenID": "Ingresa el ID del token", + "enterWalletConnectURI": "Ingresa el URI de WalletConnect", "error": "Error", - "error_dialog_content": "Vaya, tenemos un error.\n\nEnvíe el informe de bloqueo a nuestro equipo de soporte para mejorar la aplicación.", + "error_dialog_content": "Vaya, tenemos un error.\n\nEnvía el informe de error a nuestro equipo de soporte para mejorar la aplicación.", "error_text_account_name": "El nombre de la cuenta solo puede contener letras, números \ny debe tener entre 1 y 15 caracteres de longitud", "error_text_address": "La dirección de la billetera debe corresponder al tipo \nde criptomoneda", "error_text_amount": "La cantidad solo puede contener números", @@ -304,14 +304,14 @@ "fee_rate": "Tarifa", "fetching": "Cargando", "fiat_api": "Fiat API", - "fiat_balance": "Equilibrio Fiat", + "fiat_balance": "Balance fiat", "field_required": "Este campo es obligatorio", - "fill_code": "Por favor complete el código de verificación proporcionado a su correo electrónico", + "fill_code": "Por favor completa el código de verificación proporcionado en tu correo electrónico", "filter_by": "Filtrado por", "first_wallet_text": "Impresionante billetera para Monero, Bitcoin, Ethereum, Litecoin, y Haven", "fixed_pair_not_supported": "Este par fijo no es compatible con los servicios de intercambio seleccionados", "fixed_rate": "Tipo de interés fijo", - "fixed_rate_alert": "Podrá ingresar la cantidad recibida cuando el modo de tarifa fija esté marcado. ¿Quieres cambiar al modo de tarifa fija?", + "fixed_rate_alert": "Podrás ingresar la cantidad recibida cuando el modo de tarifa fija esté marcado. ¿Quieres cambiar al modo de tarifa fija?", "forgot_password": "Olvidé mi contraseña", "freeze": "Congelar", "frequently_asked_questions": "Preguntas frecuentes", @@ -333,7 +333,7 @@ "gross_balance": "Saldo bruto", "group_by_type": "Grupo por tipo", "haven_app": "Haven by Cake Wallet", - "haven_app_wallet_text": "Awesome wallet for Haven", + "haven_app_wallet_text": "Increíble billetera para Haven", "help": "ayuda", "hidden_balance": "Balance oculto", "hide_details": "Ocultar detalles", @@ -349,9 +349,9 @@ "incoming": "Entrante", "incorrect_seed": "El texto ingresado no es válido.", "inputs": "Entradas", - "insufficient_lamport_for_tx": "No tiene suficiente SOL para cubrir la transacción y su tarifa de transacción. Por favor, agregue más SOL a su billetera o reduzca la cantidad de sol que está enviando.", - "insufficient_lamports": "No tiene suficiente SOL para cubrir la transacción y su tarifa de transacción. Necesita al menos ${solValueNeeded} sol. Por favor, agregue más sol a su billetera o reduzca la cantidad de sol que está enviando", - "insufficientFundsForRentError": "No tiene suficiente SOL para cubrir la tarifa de transacción y alquilar para la cuenta. Por favor, agregue más sol a su billetera o reduzca la cantidad de sol que está enviando", + "insufficient_lamport_for_tx": "No tienes suficiente SOL para cubrir la transacción y su tarifa de transacción. Por favor, agrega más SOL a su billetera o reduce la cantidad de sol que está enviando.", + "insufficient_lamports": "No tienes suficiente SOL para cubrir la transacción y su tarifa de transacción. Necesita al menos ${solValueNeeded} sol. Por favor, agrega más sol a su billetera o reduzca la cantidad de sol que está enviando", + "insufficientFundsForRentError": "No tienes suficiente SOL para cubrir la tarifa de transacción y alquilar para la cuenta. Por favor, agrega más sol a su billetera o reduce la cantidad de sol que está enviando", "introducing_cake_pay": "¡Presentamos Cake Pay!", "invalid_input": "Entrada inválida", "invalid_password": "Contraseña invalida", @@ -359,12 +359,12 @@ "is_percentage": "es", "last_30_days": "Últimos 30 días", "learn_more": "Aprende más", - "ledger_connection_error": "No se pudo conectar con su libro mayor. Inténtalo de nuevo.", - "ledger_error_device_locked": "El libro mayor está bloqueado", + "ledger_connection_error": "No se pudo conectar con ledger. Inténtalo de nuevo.", + "ledger_error_device_locked": "Ledger está bloqueado", "ledger_error_tx_rejected_by_user": "Transacción rechazada en el dispositivo", - "ledger_error_wrong_app": "Por favor, asegúrese de abrir la aplicación correcta en su libro mayor.", - "ledger_please_enable_bluetooth": "Habilite Bluetooth para detectar su libro mayor", - "light_theme": "Ligera", + "ledger_error_wrong_app": "Por favor, asegúrate de abrir la aplicación correcta en su libro mayor.", + "ledger_please_enable_bluetooth": "Habilita tu Bluetooth para detectar tu ledger", + "light_theme": "Ligero", "litecoin_enable_mweb_sync": "Habilitar el escaneo mweb", "litecoin_mweb": "Mweb", "litecoin_mweb_always_scan": "Establecer mweb siempre escaneo", @@ -401,9 +401,9 @@ "min_value": "Min: ${value} ${currency}", "minutes_to_pin_code": "${minute} minutos", "mm": "mm", - "modify_2fa": "Modificar torta 2FA", - "monero_com": "Monero.com by Cake Wallet", - "monero_com_wallet_text": "Awesome wallet for Monero", + "modify_2fa": "Modificar 2FA", + "monero_com": "Monero.com por Cake Wallet", + "monero_com_wallet_text": "Increíble billetera para Monero", "monero_dark_theme": "Tema oscuro de Monero", "monero_light_theme": "Tema ligero de Monero", "moonpay_alert_text": "El valor de la cantidad debe ser mayor o igual a ${minAmount} ${fiatCurrency}", @@ -412,12 +412,12 @@ "mweb_unconfirmed": "Mweb no confirmado", "name": "Nombre", "nano_current_rep": "Representante actual", - "nano_gpt_thanks_message": "¡Gracias por usar nanogpt! ¡Recuerde regresar al navegador después de que se complete su transacción!", + "nano_gpt_thanks_message": "¡Gracias por usar nanogpt! ¡Recuerda regresar al navegador después de que se complete su transacción!", "nano_pick_new_rep": "Elija un nuevo representante", "nanogpt_subtitle": "Todos los modelos más nuevos (GPT-4, Claude). \\nSin suscripción, pague con cripto.", "narrow": "Angosto", - "new_first_wallet_text": "Mantenga fácilmente su criptomoneda segura", - "new_node_testing": "Prueba de nuevos nodos", + "new_first_wallet_text": "Mantén fácilmente tu criptomoneda segura", + "new_node_testing": "Prueba nuevos nodos", "new_subaddress_create": "Crear", "new_subaddress_label_name": "Nombre de etiqueta", "new_subaddress_title": "Nueva direccion", @@ -426,10 +426,10 @@ "newConnection": "Nueva conexión", "no_cards_found": "No se encuentran cartas", "no_id_needed": "¡No se necesita identificación!", - "no_id_required": "No se requiere identificación. Recargue y gaste en cualquier lugar", - "no_relay_on_domain": "No hay una retransmisión para el dominio del usuario o la retransmisión no está disponible. Elija un relé para usar.", - "no_relays": "Sin relevos", - "no_relays_message": "Encontramos un registro Nostr NIP-05 para este usuario, pero no contiene ningún relé. Indique al destinatario que agregue retransmisiones a su registro Nostr.", + "no_id_required": "No se requiere identificación. Recarga y gaste en cualquier lugar", + "no_relay_on_domain": "No hay una retransmisión para el dominio del usuario o la retransmisión no está disponible. Elige un relay para usar.", + "no_relays": "Sin relays", + "no_relays_message": "Encontramos un registro Nostr NIP-05 para este usuario, pero no contiene ningún relay. Indica al destinatario que agregue retransmisiones a su registro Nostr.", "node_address": "Dirección de nodo", "node_connection_failed": "La conexión falló", "node_connection_successful": "La conexión fue exitosa", @@ -449,15 +449,15 @@ "offline": "fuera de línea", "ok": "OK", "old_fee": "Tarifa antigua", - "onion_link": "Enlace de cebolla", + "onion_link": "Enlace de cebolla (Tor)", "online": "En línea", - "onramper_option_description": "Compre rápidamente cripto con muchos métodos de pago. Disponible en la mayoría de los países. Los diferenciales y las tarifas varían.", + "onramper_option_description": "Compra rápidamente cripto con muchos métodos de pago. Disponible en la mayoría de los países. Los diferenciales y las tarifas varían.", "open_gift_card": "Abrir tarjeta de regalo", "optional_description": "Descripción opcional", "optional_email_hint": "Correo electrónico de notificación del beneficiario opcional", "optional_name": "Nombre del destinatario opcional", "optionally_order_card": "Opcionalmente pide una tarjeta física.", - "orbot_running_alert": "Asegúrese de que Orbot se esté ejecutando antes de conectarse a este nodo.", + "orbot_running_alert": "Asegúrate de que Orbot se esté ejecutando antes de conectarte a este nodo.", "order_by": "Ordenar", "order_id": "Identificación del pedido", "order_physical_card": "Pedir tarjeta física", @@ -466,7 +466,7 @@ "outdated_electrum_wallet_receive_warning": "Si esta billetera tiene una semilla de 12 palabras y se creó en Cake, NO deposite Bitcoin en esta billetera. Cualquier BTC transferido a esta billetera se puede perder. Cree una nueva billetera de 24 palabras (toque el menú en la parte superior derecha, seleccione Monederos, elija Crear nueva billetera, luego seleccione Bitcoin) e INMEDIATAMENTE mueva su BTC allí. Las nuevas carteras BTC (24 palabras) de Cake son seguras", "outgoing": "Saliente", "outputs": "Salidas", - "overwrite_amount": "Overwrite amount", + "overwrite_amount": "Sobreescribir monto", "pairingInvalidEvent": "Evento de emparejamiento no válido", "passphrase": "Passfrase (opcional)", "passphrases_doesnt_match": "Las frases de contrato no coinciden, intente nuevamente", @@ -482,18 +482,18 @@ "pin_is_incorrect": "PIN es incorrecto", "pin_number": "Número PIN", "placeholder_contacts": "Tus contactos se mostrarán aquí", - "placeholder_transactions": "Sus transacciones se mostrarán aquí", - "please_fill_totp": "Complete el código de 8 dígitos presente en su otro dispositivo", - "please_make_selection": "Seleccione a continuación para crear o recuperar su billetera.", - "please_reference_document": "Consulte los documentos a continuación para obtener más información.", - "please_select": "Por favor seleccione:", - "please_select_backup_file": "Seleccione el archivo de respaldo e ingrese la contraseña de respaldo.", + "placeholder_transactions": "Tus transacciones se mostrarán aquí", + "please_fill_totp": "Completa el código de 8 dígitos presente en su otro dispositivo", + "please_make_selection": "Selecciona a continuación para crear o recuperar su billetera.", + "please_reference_document": "Consulta los documentos a continuación para obtener más información.", + "please_select": "Por favor selecciona:", + "please_select_backup_file": "Selecciona el archivo de respaldo e ingrese la contraseña de respaldo.", "please_try_to_connect_to_another_node": "Intenta conectarte a otro nodo", - "please_wait": "Espere por favor", + "please_wait": "Espera por favor", "polygonscan_history": "Historial de PolygonScan", - "powered_by": "Energizado por ${title}", + "powered_by": "Posible gracias a ${title}", "pre_seed_button_text": "Entiendo. Muéstrame mi semilla", - "pre_seed_description": "En la página siguiente verá una serie de ${words} palabras. Esta es su semilla única y privada y es la ÚNICA forma de recuperar su billetera en caso de pérdida o mal funcionamiento. Es SU responsabilidad escribirlo y guardarlo en un lugar seguro fuera de la aplicación Cake Wallet.", + "pre_seed_description": "En la página siguiente verás una serie de ${words} palabras. Esta es su semilla única y privada y es la ÚNICA forma de recuperar tu billetera en caso de pérdida o mal funcionamiento. Es TU responsabilidad escribirla y guardarla en un lugar seguro fuera de la aplicación Cake Wallet.", "pre_seed_title": "IMPORTANTE", "prepaid_cards": "Tajetas prepagadas", "prevent_screenshots": "Evitar capturas de pantalla y grabación de pantalla", @@ -502,17 +502,17 @@ "privacy_settings": "Configuración de privacidad", "private_key": "Clave privada", "proceed_after_one_minute": "Si la pantalla no continúa después de 1 minuto, revisa tu correo electrónico.", - "proceed_on_device": "Continúe con su dispositivo", - "proceed_on_device_description": "Siga las instrucciones solicitadas en su billetera de hardware", + "proceed_on_device": "Continúa con tu dispositivo", + "proceed_on_device_description": "Sigue las instrucciones solicitadas en su billetera de hardware", "profile": "Perfil", "provider_error": "${provider} error", "public_key": "Clave pública", "purchase_gift_card": "Comprar tarjeta de regalo", "purple_dark_theme": "Tema morado oscuro", "qr_fullscreen": "Toque para abrir el código QR en pantalla completa", - "qr_payment_amount": "This QR code contains a payment amount. Do you want to overwrite the current value?", + "qr_payment_amount": "Este código QR contiene un monto de pago. ¿Quieres sobreescribirlo?", "quantity": "Cantidad", - "question_to_disable_2fa": "¿Está seguro de que desea deshabilitar Cake 2FA? Ya no se necesitará un código 2FA para acceder a la billetera y a ciertas funciones.", + "question_to_disable_2fa": "¿Estás seguro de que desea deshabilitar Cake 2FA? Ya no se necesitará un código 2FA para acceder a la billetera y a ciertas funciones.", "receivable_balance": "Saldo de cuentas por cobrar", "receive": "Recibir", "receive_amount": "Cantidad", @@ -529,12 +529,12 @@ "remaining": "restante", "remove": "Retirar", "remove_node": "Eliminar nodo", - "remove_node_message": "¿Está seguro de que desea eliminar el nodo seleccionado?", - "rename": "Rebautizar", + "remove_node_message": "¿Estás seguro de que desea eliminar el nodo seleccionado?", + "rename": "Renombrar", "rep_warning": "Advertencia representativa", - "rep_warning_sub": "Su representante no parece estar en buena posición. Toque aquí para seleccionar uno nuevo", - "repeat_wallet_password": "Repita la contraseña de billetera", - "repeated_password_is_incorrect": "La contraseña repetida es incorrecta. Repita la contraseña de la billetera nuevamente.", + "rep_warning_sub": "Tu representante no parece estar en buena posición. Toca aquí para seleccionar uno nuevo", + "repeat_wallet_password": "Repite la contraseña de billetera", + "repeated_password_is_incorrect": "La contraseña repetida es incorrecta. Repite la contraseña de la billetera nuevamente.", "require_for_adding_contacts": "Requerido para agregar contactos", "require_for_all_security_and_backup_settings": "Requerido para todas las configuraciones de seguridad y copia de seguridad", "require_for_assessing_wallet": "Requerido para acceder a la billetera", @@ -566,17 +566,17 @@ "restore_recover": "Recuperar", "restore_restore_wallet": "Recuperar Cartera", "restore_seed_keys_restore": "Restauración de semillas / llaves", - "restore_spend_key_private": "Spend clave (privado)", + "restore_spend_key_private": "Llave de gasto (privada)", "restore_title_from_backup": "Restaurar desde un archivo de respaldo", - "restore_title_from_hardware_wallet": "Restaurar desde la billetera de hardware", - "restore_title_from_keys": "De las claves", - "restore_title_from_seed": "De la semilla", - "restore_title_from_seed_keys": "Restaurar desde semilla/claves", - "restore_view_key_private": "View clave (privado)", + "restore_title_from_hardware_wallet": "Restaurar desde una cartera fría", + "restore_title_from_keys": "Usando las claves", + "restore_title_from_seed": "Usando la semilla", + "restore_title_from_seed_keys": "Restaurar usando semilla/claves", + "restore_view_key_private": "Llave de vista (privado)", "restore_wallet": "Restaurar billetera", "restore_wallet_name": "Nombre de la billetera", "restore_wallet_restore_description": "Restaurar billetera", - "robinhood_option_description": "Compre y transfiera instantáneamente utilizando su tarjeta de débito, cuenta bancaria o saldo de Robinhood. Solo EE. UU.", + "robinhood_option_description": "Compra y transfiere instantáneamente utilizando su tarjeta de débito, cuenta bancaria o saldo de Robinhood. Solo EE. UU.", "router_no_route": "No hay ruta definida para ${name}", "save": "Salvar", "save_backup_password": "Asegúrese de haber guardado su contraseña de respaldo. No podrá importar sus archivos de respaldo sin él.", @@ -585,7 +585,7 @@ "saved_the_trade_id": "He salvado comercial ID", "scan_one_block": "Escanear un bloque", "scan_qr_code": "Escanear código QR", - "scan_qr_code_to_get_address": "Escanee el código QR para obtener la dirección", + "scan_qr_code_to_get_address": "Escanea el código QR para obtener la dirección", "scan_qr_on_device": "Escanea este código QR en otro dispositivo", "search": "Búsqueda", "search_add_token": "Buscar/Agregar token", @@ -612,25 +612,25 @@ "seed_language_german": "Alemán", "seed_language_italian": "Italiana/Italiano", "seed_language_japanese": "Japonés", - "seed_language_korean": "coreano", + "seed_language_korean": "Coreano", "seed_language_next": "Próximo", "seed_language_portuguese": "Portugués", "seed_language_russian": "Ruso", "seed_language_spanish": "Español", "seed_phrase_length": "Longitud de la frase inicial", - "seed_reminder": "Anótelos en caso de que pierda o borre su teléfono", + "seed_reminder": "Anótalos en caso de que pierdas o borres tu aplicación", "seed_share": "Compartir semillas", "seed_title": "Semilla", - "seedtype": "Type de semillas", - "seedtype_alert_content": "Compartir semillas con otras billeteras solo es posible con Bip39 Seed Type.", - "seedtype_alert_title": "Alerta de type de semillas", - "seedtype_legacy": "Legado (25 palabras)", - "seedtype_polyseed": "Polieta (16 palabras)", + "seedtype": "Tipos de semillas", + "seedtype_alert_content": "Compartir semillas con otras billeteras solo es posible con semillas bip39 - un tipo específico de semilla.", + "seedtype_alert_title": "Alerta de tipo de semillas", + "seedtype_legacy": "Semilla clásica-legacy (25 palabras)", + "seedtype_polyseed": "Poli-semilla (16 palabras)", "seedtype_wownero": "Wownero (14 palabras)", "select_backup_file": "Seleccionar archivo de respaldo", - "select_buy_provider_notice": "Seleccione 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": "Seleccione el destino del archivo de copia de seguridad.", - "select_sell_provider_notice": "Seleccione un proveedor de venta arriba. Puede omitir esta pantalla configurando su proveedor de venta predeterminado en la configuración de la aplicación.", + "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.", "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", @@ -676,16 +676,16 @@ "settings_only_transactions": "Solo transacciones", "settings_personal": "Personal", "settings_save_recipient_address": "Guardar dirección del destinatario", - "settings_support": "Apoyo", + "settings_support": "Ayuda", "settings_terms_and_conditions": "Términos y Condiciones", "settings_title": "Configuraciones", "settings_trades": "Comercia", "settings_transactions": "Transacciones", "settings_wallets": "Carteras", - "setup_2fa": "Configurar pastel 2FA", - "setup_2fa_text": "Cake 2FA funciona utilizando TOTP como segundo factor de autenticación.\n\nEl TOTP de Cake 2FA requiere SHA-512 y soporte de 8 dígitos; esto proporciona una mayor seguridad. Puede encontrar más información y aplicaciones compatibles en la guía.", + "setup_2fa": "Configurar 2FA", + "setup_2fa_text": "Cake 2FA funciona utilizando TOTP como segundo factor de autenticación.\n\nEl TOTP de Cake 2FA requiere SHA-512 y soporte de 8 dígitos; esto proporciona una mayor seguridad. Puedes encontrar más información y aplicaciones compatibles en la guía.", "setup_pin": "PIN de configuración", - "setup_successful": "Su PIN se ha configurado correctamente!", + "setup_successful": "Tu PIN se ha configurado correctamente!", "setup_totp_recommended": "Configurar TOTP", "setup_warning_2fa_text": "Deberá restaurar su billetera a partir de la semilla mnemotécnica.\n\nEl soporte de Cake no podrá ayudarlo si pierde el acceso a su 2FA o a sus semillas mnemotécnicas.\nCake 2FA es una segunda autenticación para ciertas acciones en la billetera. Antes de usar Cake 2FA, recomendamos leer la guía.NO es tan seguro como el almacenamiento en frío.\n\nSi pierde el acceso a su aplicación 2FA o a sus claves TOTP, perderá el acceso a esta billetera. ", "setup_your_debit_card": "Configura tu tarjeta de débito", @@ -704,23 +704,23 @@ "signature": "Firma", "signature_invalid_error": "La firma no es válida para el mensaje dado", "signTransaction": "Firmar transacción", - "signup_for_card_accept_terms": "Regístrese para obtener la tarjeta y acepte los términos.", + "signup_for_card_accept_terms": "Regístrate para obtener la tarjeta y acepte los términos.", "silent_payment": "Pago silencioso", "silent_payments": "Pagos silenciosos", "silent_payments_always_scan": "Establecer pagos silenciosos siempre escaneando", "silent_payments_disclaimer": "Las nuevas direcciones no son nuevas identidades. Es una reutilización de una identidad existente con una etiqueta diferente.", "silent_payments_display_card": "Mostrar tarjeta de pagos silenciosos", "silent_payments_scan_from_date": "Escanear desde la fecha", - "silent_payments_scan_from_date_or_blockheight": "Ingrese la altura del bloque que desea comenzar a escanear para pagos silenciosos entrantes, o use la fecha en su lugar. Puede elegir si la billetera continúa escaneando cada bloque, o verifica solo la altura especificada.", - "silent_payments_scan_from_height": "Escanear desde la altura del bloque", - "silent_payments_scanned_tip": "Escaneado hasta la punta! (${tip})", + "silent_payments_scan_from_date_or_blockheight": "Ingresa la altura de bloque que desea comenzar a escanear para pagos silenciosos entrantes, o usa la fecha en su lugar. Puedes elegir si la billetera continúa escaneando cada bloque, o verifica solo la altura especificada.", + "silent_payments_scan_from_height": "Escanear desde la altura de bloque específico", + "silent_payments_scanned_tip": "Escaneado hasta la altura actual! (${tip})", "silent_payments_scanning": "Escaneo de pagos silenciosos", "silent_payments_settings": "Configuración de pagos silenciosos", "single_seed_wallets_group": "Billeteras de semillas individuales", "slidable": "deslizable", "sort_by": "Ordenar por", - "spend_key_private": "Spend clave (privado)", - "spend_key_public": "Spend clave (público)", + "spend_key_private": "Llave de gasto (privada)", + "spend_key_public": "Llave de gasto (pública)", "status": "Estado: ", "string_default": "Por defecto", "subaddress_title": "Lista de subdirecciones", @@ -729,14 +729,14 @@ "successful": "Exitoso", "support_description_guides": "Documentación y apoyo para problemas comunes", "support_description_live_chat": "¡GRATIS y RÁPIDO! Los representantes de apoyo capacitado están disponibles para ayudar", - "support_description_other_links": "Únase a nuestras comunidades o comuníquese con nosotros nuestros socios a través de otros métodos", + "support_description_other_links": "Únete a nuestras comunidades o comunícate con nosotros nuestros socios a través de otros métodos", "support_title_guides": "Guías de billetera para pastel", - "support_title_live_chat": "Soporte vital", + "support_title_live_chat": "Soporte en tiempo real", "support_title_other_links": "Otros enlaces de soporte", - "sweeping_wallet": "Billetera de barrido", + "sweeping_wallet": "Barrer billetera (gastar todos los fondos disponibles)", "sweeping_wallet_alert": "Esto no debería llevar mucho tiempo. NO DEJES ESTA PANTALLA O SE PUEDEN PERDER LOS FONDOS BARRIDOS", - "switchToETHWallet": "Cambie a una billetera Ethereum e inténtelo nuevamente.", - "switchToEVMCompatibleWallet": "Cambie a una billetera compatible con EVM e inténtelo nuevamente (Ethereum, Polygon)", + "switchToETHWallet": "Cambia a una billetera Ethereum e inténtelo nuevamente.", + "switchToEVMCompatibleWallet": "Cambia a una billetera compatible con EVM e inténtelo nuevamente (Ethereum, Polygon)", "symbol": "Símbolo", "sync_all_wallets": "Sincronizar todas las billeteras", "sync_status_attempting_scan": "Intento de escaneo", @@ -759,7 +759,7 @@ "third_intro_content": "Los Yats también viven fuera de Cake Wallet. Cualquier dirección de billetera en la tierra se puede reemplazar con un Yat!", "third_intro_title": "Yat juega muy bien con otras", "thorchain_contract_address_not_supported": "Thorchain no admite enviar a una dirección de contrato", - "thorchain_taproot_address_not_supported": "El proveedor de Thorchain no admite las direcciones de Taproot. Cambie la dirección o seleccione un proveedor diferente.", + "thorchain_taproot_address_not_supported": "El proveedor de Thorchain no admite las direcciones de Taproot. Cambia la dirección o selecciona un proveedor diferente.", "time": "${minutes}m ${seconds}s", "tip": "Consejo:", "today": "Hoy", @@ -772,8 +772,8 @@ "tor_only": "solo Tor", "total": "Total", "total_saving": "Ahorro Total", - "totp_2fa_failure": "Código incorrecto. Intente con un código diferente o genere una nueva clave secreta. Use una aplicación 2FA compatible que admita códigos de 8 dígitos y SHA512.", - "totp_2fa_success": "¡Éxito! Cake 2FA habilitado para esta billetera. Recuerde guardar su semilla mnemotécnica en caso de que pierda el acceso a la billetera.", + "totp_2fa_failure": "Código incorrecto. Intente con un código diferente o genere una nueva clave secreta. Usa una aplicación 2FA compatible que admita códigos de 8 dígitos y SHA512.", + "totp_2fa_success": "¡Éxito! Cake 2FA habilitado para esta billetera. Recuerda guardar tu semilla mnemotécnica en caso de que pierdas el acceso a la billetera.", "totp_auth_url": "URL de autenticación TOTP", "totp_code": "Código TOTP", "totp_secret_code": "Código secreto TOTP", @@ -831,16 +831,16 @@ "trongrid_history": "Historia trongrid", "trusted": "de confianza", "tx_commit_exception_no_dust_on_change": "La transacción se rechaza con esta cantidad. Con estas monedas puede enviar ${min} sin cambios o ${max} que devuelve el cambio.", - "tx_commit_failed": "La confirmación de transacción falló. Póngase en contacto con el soporte.", - "tx_commit_failed_no_peers": "La transacción no se transmitió, intente nuevamente en un segundo más o menos", + "tx_commit_failed": "La confirmación de transacción falló. Ponte en contacto con el soporte.", + "tx_commit_failed_no_peers": "La transacción no se transmitió, intenta nuevamente en un segundo más o menos", "tx_invalid_input": "Está utilizando el tipo de entrada incorrecto para este tipo de pago", "tx_no_dust_exception": "La transacción se rechaza enviando una cantidad demasiado pequeña. Intente aumentar la cantidad.", - "tx_not_enough_inputs_exception": "No hay suficientes entradas disponibles. Seleccione más bajo control de monedas", + "tx_not_enough_inputs_exception": "No hay suficientes entradas disponibles. Selecciona más bajo control de monedas", "tx_rejected_bip68_final": "La transacción tiene entradas no confirmadas y no ha podido reemplazar por tarifa.", - "tx_rejected_dust_change": "Transacción rechazada por reglas de red, bajo cambio de cambio (polvo). Intente enviar todo o reducir la cantidad.", - "tx_rejected_dust_output": "Transacción rechazada por reglas de red, baja cantidad de salida (polvo). Aumente la cantidad.", + "tx_rejected_dust_change": "Transacción rechazada por reglas de red, bajo cambio de cambio (polvo). Intenta enviar todo o reducir la cantidad.", + "tx_rejected_dust_output": "Transacción rechazada por reglas de red, baja cantidad de salida (polvo). Aumenta la cantidad.", "tx_rejected_dust_output_send_all": "Transacción rechazada por reglas de red, baja cantidad de salida (polvo). Verifique el saldo de monedas seleccionadas bajo control de monedas.", - "tx_rejected_vout_negative": "No es suficiente saldo para pagar las tarifas de esta transacción. Verifique el saldo de monedas bajo control de monedas.", + "tx_rejected_vout_negative": "No es suficiente saldo para pagar las tarifas de esta transacción. Verifica el saldo de monedas bajo control de monedas.", "tx_wrong_balance_exception": "No tiene suficiente ${currency} para enviar esta cantidad.", "tx_wrong_balance_with_amount_exception": "No tiene suficiente ${currency} para enviar la cantidad total de ${amount}", "tx_zero_fee_exception": "No se puede enviar transacciones con 0 tarifa. Intente aumentar la tasa o verificar su conexión para las últimas estimaciones.", @@ -849,7 +849,7 @@ "unconfirmed": "Saldo no confirmado", "understand": "Entiendo", "unlock": "desbloquear", - "unmatched_currencies": "La moneda de su billetera actual no coincide con la del QR escaneado", + "unmatched_currencies": "La moneda de tu billetera actual no coincide con la del QR escaneado", "unspent_change": "Cambiar", "unspent_coins_details_title": "Detalles de monedas no gastadas", "unspent_coins_title": "Monedas no gastadas", @@ -858,11 +858,11 @@ "upto": "hasta ${value}", "usb": "USB", "use": "Utilizar a ", - "use_card_info_three": "Utilice la tarjeta digital en línea o con métodos de pago sin contacto.", + "use_card_info_three": "Utiliza la tarjeta digital en línea o con métodos de pago sin contacto.", "use_card_info_two": "Los fondos se convierten a USD cuando se mantienen en la cuenta prepaga, no en monedas digitales.", - "use_ssl": "Utilice SSL", + "use_ssl": "Utiliza SSL", "use_suggested": "Usar sugerido", - "use_testnet": "Use TestNet", + "use_testnet": "Usar TestNet", "value": "Valor", "value_type": "Tipo de valor", "variable_pair_not_supported": "Este par de variables no es compatible con los intercambios seleccionados", @@ -871,17 +871,17 @@ "verify_with_2fa": "Verificar con Cake 2FA", "version": "Versión ${currentVersion}", "view_all": "Ver todo", - "view_in_block_explorer": "View in Block Explorer", - "view_key_private": "View clave (privado)", - "view_key_public": "View clave (público)", - "view_transaction_on": "View Transaction on ", + "view_in_block_explorer": "Ver en explorador de bloques", + "view_key_private": "Llave de vista (privada)", + "view_key_public": "Llave de vista (pública)", + "view_transaction_on": "Ver transacción en ", "voting_weight": "Peso de votación", - "waitFewSecondForTxUpdate": "Espere unos segundos para que la transacción se refleje en el historial de transacciones.", + "waitFewSecondForTxUpdate": "Espera unos segundos para que la transacción se refleje en el historial de transacciones.", "wallet_group": "Grupo de billetera", - "wallet_group_description_four": "para crear una billetera con una semilla completamente nueva.", + "wallet_group_description_four": "Para crear una billetera con una semilla completamente nueva.", "wallet_group_description_one": "En la billetera de pastel, puedes crear un", "wallet_group_description_three": "Para ver las billeteras disponibles y/o la pantalla de grupos de billeteras. O elegir", - "wallet_group_description_two": "seleccionando una billetera existente para compartir una semilla con. Cada grupo de billetera puede contener una sola billetera de cada tipo de moneda. \n\n puede seleccionar", + "wallet_group_description_two": "Seleccionando una billetera existente para compartir una semilla con. Cada grupo de billetera puede contener una sola billetera de cada tipo de moneda. \n\n puedes seleccionar", "wallet_group_empty_state_text_one": "Parece que no tienes ningún grupo de billetera compatible !\n\n toque", "wallet_group_empty_state_text_two": "a continuación para hacer uno nuevo.", "wallet_keys": "Billetera semilla/claves", @@ -889,7 +889,7 @@ "wallet_list_edit_group_name": "Editar nombre de grupo", "wallet_list_edit_wallet": "Editar billetera", "wallet_list_failed_to_load": "No se pudo cargar ${wallet_name} la billetera. ${error}", - "wallet_list_failed_to_remove": "Error al elimina ${wallet_name} billetera. ${error}", + "wallet_list_failed_to_remove": "Error al eliminar ${wallet_name} billetera. ${error}", "wallet_list_load_wallet": "Billetera de carga", "wallet_list_loading_wallet": "Billetera ${wallet_name} de carga", "wallet_list_removing_wallet": "Retirar ${wallet_name} billetera", @@ -898,8 +898,8 @@ "wallet_list_wallet_name": "Nombre de la billetera", "wallet_menu": "Menú de billetera", "wallet_name": "Nombre de la billetera", - "wallet_name_exists": "Wallet con ese nombre ya ha existido", - "wallet_password_is_empty": "La contraseña de billetera está vacía. La contraseña de la billetera no debe estar vacía", + "wallet_name_exists": "Cartera con ese nombre ya existe, escoge otro", + "wallet_password_is_empty": "La contraseña de billetera está vacía. La contraseña de la billetera no puede estar vacía", "wallet_recovery_height": "Altura de recuperación", "wallet_restoration_store_incorrect_seed_length": "Longitud de semilla incorrecta", "wallet_seed": "Semilla de billetera", @@ -913,28 +913,28 @@ "what_is_silent_payments": "¿Qué son los pagos silenciosos?", "widgets_address": "Dirección", "widgets_or": "o", - "widgets_restore_from_blockheight": "Restaurar desde blockheight", + "widgets_restore_from_blockheight": "Restaurar desde altura de bloque", "widgets_restore_from_date": "Restaurar desde fecha", "widgets_seed": "Semilla", "wouoldLikeToConnect": "quisiera conectar", - "write_down_backup_password": "Escriba su contraseña de respaldo, que se utiliza para la importación de sus archivos de respaldo.", - "xlm_extra_info": "No olvide especificar el ID de nota al enviar la transacción XLM para el intercambio", + "write_down_backup_password": "Escribe su contraseña de respaldo, que se utiliza para la importación de sus archivos de respaldo.", + "xlm_extra_info": "No olvides especificar el ID de nota al enviar la transacción XLM para el intercambio", "xmr_available_balance": "Saldo disponible", "xmr_full_balance": "Balance total", "xmr_hidden": "Oculto", "xmr_to_error": "Error de XMR.TO", "xmr_to_error_description": "Monto invalido. Límite máximo de 8 dígitos después del punto decimal", - "xrp_extra_info": "No olvide especificar la etiqueta de destino al enviar la transacción XRP para el intercambio", + "xrp_extra_info": "No olvides especificar la etiqueta de destino al enviar la transacción XRP para el intercambio", "yat": "Yat", "yat_address": "Dirección de Yat", "yat_alert_content": "Los usuarios de Cake Wallet ahora pueden enviar y recibir todas sus monedas favoritas con un nombre de usuario único basado en emoji.", - "yat_alert_title": "Envíe y reciba criptomonedas más fácilmente con Yat", + "yat_alert_title": "Envía y recibe criptomonedas más fácilmente con Yat", "yat_error": "Error de Yat", "yat_error_content": "No hay direcciones vinculadas con este Yat. Prueba con otro Yat", - "yat_popup_content": "Ahora puede enviar y recibir criptografía en Cake Wallet con su Yat, un nombre de usuario corto basado en emoji. Administre Yats en cualquier momento en la pantalla de configuración", - "yat_popup_title": "La dirección de su billetera se puede emojificar.", + "yat_popup_content": "Ahora puede enviar y recibir criptografía en Cake Wallet con su Yat, un nombre de usuario corto basado en emoji. Administra Yats en cualquier momento en la pantalla de configuración", + "yat_popup_title": "La dirección de tu billetera se puede emojificar.", "yesterday": "Ayer", - "you_now_have_debit_card": "Ahora tiene una tarjeta de débito", + "you_now_have_debit_card": "Ahora tienes una tarjeta de débito", "you_pay": "Tú pagas", "you_will_get": "Convertir a", "you_will_send": "Convertir de", 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 11/34] 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 12/34] 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 13/34] 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 14/34] 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 15/34] 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 16/34] 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 17/34] 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 18/34] 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(); + } } } From cea3084bb320dde27fbdbb78fba579311b58b704 Mon Sep 17 00:00:00 2001 From: cyan Date: Sat, 9 Nov 2024 19:59:47 +0100 Subject: [PATCH 19/34] WIP: CW-665 Implement AirGapped Monero Transactions (#1535) * replace qr scanner with fast_scanner * bump java version * fix qr code scanning * add flashlight and camera switch * airgap work * commitTransactionUR * bump fast_scanner, fix configure script * add option to restore wallet from NERO qr format * fixes to the flow and logic use gsed or otherwise it fails? * remove Expanded() to fix URQR on release builds * cache key to allow app updates * rename cache key * [skip ci] cache key.jks in cache_dependencies * update fast_scanner to work on ios, with light mlkit dependency * ui fixes * error handling fix * update fast_scanner to drop iOS dependency * changes from review * Update lib/entities/qr_scanner.dart * changes from review * remove useless commit * don't set state multiple times remove return Container() for non monero wallets * return on fail don't handle empty qr codes * set node as trusted display primary address in seed screen * fix wow and haven * migrate node to trusted * - update trusted node for existing users - update locales - fix conflicts - move menu item --------- Co-authored-by: Omar Hatem --- .github/workflows/cache_dependencies.yml | 3 +- .github/workflows/pr_test_build_android.yml | 4 +- android/app/build.gradle | 5 + android/gradle.properties | 2 +- assets/node_list.yml | 1 + .../lib/pending_bitcoin_transaction.dart | 5 + .../src/pending_bitcoin_cash_transaction.dart | 4 + cw_core/lib/monero_wallet_keys.dart | 4 +- cw_core/lib/pending_transaction.dart | 3 + cw_evm/lib/pending_evm_chain_transaction.dart | 5 + .../wallet_restore_from_keys_exception.dart | 5 + cw_haven/lib/haven_wallet.dart | 1 + cw_haven/lib/pending_haven_transaction.dart | 5 + cw_monero/lib/api/account_list.dart | 1 + cw_monero/lib/api/transaction_history.dart | 24 +- cw_monero/lib/api/wallet_manager.dart | 2 + cw_monero/lib/monero_wallet.dart | 32 ++ cw_monero/lib/pending_monero_transaction.dart | 24 +- cw_nano/lib/pending_nano_transaction.dart | 5 + cw_shared_external/pubspec.lock | 189 +++++++++ cw_solana/lib/pending_solana_transaction.dart | 5 + cw_tron/lib/pending_tron_transaction.dart | 5 + .../wallet_restore_from_keys_exception.dart | 1 + .../lib/pending_wownero_transaction.dart | 5 + cw_wownero/lib/wownero_wallet.dart | 1 + lib/di.dart | 7 + lib/entities/default_settings_migration.dart | 15 +- lib/entities/qr_scanner.dart | 369 +++++++++++++++++- lib/main.dart | 2 +- lib/monero/cw_monero.dart | 24 ++ lib/router.dart | 4 + lib/routes.dart | 1 + .../cake_pay_confirm_purchase_card_page.dart | 4 +- .../dashboard/widgets/menu_widget.dart | 4 +- .../exchange_trade/exchange_trade_page.dart | 2 +- lib/src/screens/send/send_page.dart | 2 +- .../desktop_settings_page.dart | 7 +- .../transaction_details/rbf_details_page.dart | 2 +- lib/src/screens/ur/animated_ur_page.dart | 184 +++++++++ .../wc_connections_listing_view.dart | 2 +- lib/src/widgets/address_text_field.dart | 2 +- lib/src/widgets/setting_actions.dart | 11 + lib/view_model/animated_ur_model.dart | 10 + .../dashboard/dashboard_view_model.dart | 6 + .../node_create_or_edit_view_model.dart | 2 +- lib/view_model/restore/restore_wallet.dart | 14 +- .../restore/wallet_restore_from_qr_code.dart | 28 +- lib/view_model/send/send_view_model.dart | 18 +- lib/view_model/wallet_keys_view_model.dart | 12 + linux/flutter/generated_plugins.cmake | 1 - macos/Flutter/GeneratedPluginRegistrant.swift | 4 +- macos/Podfile.lock | 6 + pubspec_base.yaml | 6 +- res/values/strings_ar.arb | 13 +- res/values/strings_bg.arb | 13 +- res/values/strings_cs.arb | 13 +- res/values/strings_de.arb | 8 +- res/values/strings_en.arb | 10 +- res/values/strings_es.arb | 13 +- res/values/strings_fr.arb | 13 +- res/values/strings_ha.arb | 13 +- res/values/strings_hi.arb | 13 +- res/values/strings_hr.arb | 13 +- res/values/strings_hy.arb | 16 +- res/values/strings_id.arb | 13 +- res/values/strings_it.arb | 13 +- res/values/strings_ja.arb | 13 +- res/values/strings_ko.arb | 13 +- res/values/strings_my.arb | 13 +- res/values/strings_nl.arb | 13 +- res/values/strings_pl.arb | 13 +- res/values/strings_pt.arb | 10 +- res/values/strings_ru.arb | 13 +- res/values/strings_th.arb | 13 +- res/values/strings_tl.arb | 10 +- res/values/strings_tr.arb | 13 +- res/values/strings_uk.arb | 13 +- res/values/strings_ur.arb | 13 +- res/values/strings_vi.arb | 16 +- res/values/strings_yo.arb | 13 +- res/values/strings_zh.arb | 13 +- scripts/macos/gen_common.sh | 4 +- tool/configure.dart | 8 + windows/flutter/generated_plugins.cmake | 1 - 84 files changed, 1318 insertions(+), 141 deletions(-) create mode 100644 cw_shared_external/pubspec.lock create mode 100644 lib/src/screens/ur/animated_ur_page.dart create mode 100644 lib/view_model/animated_ur_model.dart diff --git a/.github/workflows/cache_dependencies.yml b/.github/workflows/cache_dependencies.yml index c0042bf5c..0fcd5c45b 100644 --- a/.github/workflows/cache_dependencies.yml +++ b/.github/workflows/cache_dependencies.yml @@ -62,10 +62,9 @@ jobs: /opt/android/cake_wallet/cw_haven/android/.cxx /opt/android/cake_wallet/scripts/monero_c/release key: ${{ hashFiles('**/prepare_moneroc.sh' ,'**/build_monero_all.sh' ,'**/cache_dependencies.yml') }} - - if: ${{ steps.cache-externals.outputs.cache-hit != 'true' }} name: Generate Externals run: | cd /opt/android/cake_wallet/scripts/android/ source ./app_env.sh cakewallet - ./build_monero_all.sh + ./build_monero_all.sh \ No newline at end of file diff --git a/.github/workflows/pr_test_build_android.yml b/.github/workflows/pr_test_build_android.yml index c9021fac0..6d0e541ce 100644 --- a/.github/workflows/pr_test_build_android.yml +++ b/.github/workflows/pr_test_build_android.yml @@ -82,7 +82,8 @@ jobs: path: | /opt/android/cake_wallet/cw_haven/android/.cxx /opt/android/cake_wallet/scripts/monero_c/release - key: ${{ hashFiles('**/prepare_moneroc.sh' ,'**/build_monero_all.sh' ,'**/cache_dependencies.yml') }} + /opt/android/cake_wallet/android/app/key.jks + key: android_${{ hashFiles('**/prepare_moneroc.sh' ,'**/build_monero_all.sh' ,'**/cache_dependencies.yml') }} - if: ${{ steps.cache-externals.outputs.cache-hit != 'true' }} name: Generate Externals @@ -116,6 +117,7 @@ jobs: ./build_mwebd.sh --dont-install - name: Generate KeyStore + if: ${{ steps.cache-externals.outputs.cache-hit != 'true' }} run: | cd /opt/android/cake_wallet/android/app keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS diff --git a/android/app/build.gradle b/android/app/build.gradle index 2f5427531..5005a8bab 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -92,3 +92,8 @@ dependencies { androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' } +configurations { + implementation.exclude module:'proto-google-common-protos' + implementation.exclude module:'protolite-well-known-types' + implementation.exclude module:'protobuf-javalite' +} \ No newline at end of file diff --git a/android/gradle.properties b/android/gradle.properties index 38c8d4544..66dd09454 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -1,4 +1,4 @@ -org.gradle.jvmargs=-Xmx1536M +org.gradle.jvmargs=-Xmx3072M android.enableR8=true android.useAndroidX=true android.enableJetifier=true diff --git a/assets/node_list.yml b/assets/node_list.yml index d04a9a2e8..6191129b3 100644 --- a/assets/node_list.yml +++ b/assets/node_list.yml @@ -1,6 +1,7 @@ - uri: xmr-node.cakewallet.com:18081 is_default: true + trusted: true - uri: cakexmrl7bonq7ovjka5kuwuyd3f7qnkz6z6s6dmsy3uckwra7bvggyd.onion:18081 is_default: false diff --git a/cw_bitcoin/lib/pending_bitcoin_transaction.dart b/cw_bitcoin/lib/pending_bitcoin_transaction.dart index 140965655..411c7de16 100644 --- a/cw_bitcoin/lib/pending_bitcoin_transaction.dart +++ b/cw_bitcoin/lib/pending_bitcoin_transaction.dart @@ -153,4 +153,9 @@ class PendingBitcoinTransaction with PendingTransaction { inputAddresses: _tx.inputs.map((input) => input.txId).toList(), outputAddresses: outputAddresses, fee: fee); + + @override + Future commitUR() { + throw UnimplementedError(); + } } diff --git a/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart b/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart index e1fa9d6e0..05483ce54 100644 --- a/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart +++ b/cw_bitcoin_cash/lib/src/pending_bitcoin_cash_transaction.dart @@ -85,4 +85,8 @@ class PendingBitcoinCashTransaction with PendingTransaction { fee: fee, isReplaced: false, ); + @override + Future commitUR() { + throw UnimplementedError(); + } } diff --git a/cw_core/lib/monero_wallet_keys.dart b/cw_core/lib/monero_wallet_keys.dart index 1435002a8..fe3784986 100644 --- a/cw_core/lib/monero_wallet_keys.dart +++ b/cw_core/lib/monero_wallet_keys.dart @@ -1,10 +1,12 @@ class MoneroWalletKeys { const MoneroWalletKeys( - {required this.privateSpendKey, + {required this.primaryAddress, + required this.privateSpendKey, required this.privateViewKey, required this.publicSpendKey, required this.publicViewKey}); + final String primaryAddress; final String publicViewKey; final String privateViewKey; final String publicSpendKey; diff --git a/cw_core/lib/pending_transaction.dart b/cw_core/lib/pending_transaction.dart index 0a6103a5f..78eba68a3 100644 --- a/cw_core/lib/pending_transaction.dart +++ b/cw_core/lib/pending_transaction.dart @@ -14,5 +14,8 @@ mixin PendingTransaction { int? get outputCount => null; PendingChange? change; + bool shouldCommitUR() => false; + Future commit(); + Future commitUR(); } diff --git a/cw_evm/lib/pending_evm_chain_transaction.dart b/cw_evm/lib/pending_evm_chain_transaction.dart index 0b367da68..6861b41f8 100644 --- a/cw_evm/lib/pending_evm_chain_transaction.dart +++ b/cw_evm/lib/pending_evm_chain_transaction.dart @@ -47,4 +47,9 @@ class PendingEVMChainTransaction with PendingTransaction { return '0x${Hex.HEX.encode(txid)}'; } + + @override + Future commitUR() { + throw UnimplementedError(); + } } diff --git a/cw_haven/lib/api/exceptions/wallet_restore_from_keys_exception.dart b/cw_haven/lib/api/exceptions/wallet_restore_from_keys_exception.dart index c6b6c6ef7..3ff5f2438 100644 --- a/cw_haven/lib/api/exceptions/wallet_restore_from_keys_exception.dart +++ b/cw_haven/lib/api/exceptions/wallet_restore_from_keys_exception.dart @@ -2,4 +2,9 @@ class WalletRestoreFromKeysException implements Exception { WalletRestoreFromKeysException({required this.message}); final String message; + + @override + String toString() { + return message; + } } \ No newline at end of file diff --git a/cw_haven/lib/haven_wallet.dart b/cw_haven/lib/haven_wallet.dart index 06a838100..e2e598bbe 100644 --- a/cw_haven/lib/haven_wallet.dart +++ b/cw_haven/lib/haven_wallet.dart @@ -73,6 +73,7 @@ abstract class HavenWalletBase @override MoneroWalletKeys get keys => MoneroWalletKeys( + primaryAddress: haven_wallet.getAddress(accountIndex: 0, addressIndex: 0), privateSpendKey: haven_wallet.getSecretSpendKey(), privateViewKey: haven_wallet.getSecretViewKey(), publicSpendKey: haven_wallet.getPublicSpendKey(), diff --git a/cw_haven/lib/pending_haven_transaction.dart b/cw_haven/lib/pending_haven_transaction.dart index 1d95de08c..dbf075044 100644 --- a/cw_haven/lib/pending_haven_transaction.dart +++ b/cw_haven/lib/pending_haven_transaction.dart @@ -48,4 +48,9 @@ class PendingHavenTransaction with PendingTransaction { rethrow; } } + + @override + Future commitUR() { + throw UnimplementedError(); + } } diff --git a/cw_monero/lib/api/account_list.dart b/cw_monero/lib/api/account_list.dart index 7cb95a507..e3bb25c97 100644 --- a/cw_monero/lib/api/account_list.dart +++ b/cw_monero/lib/api/account_list.dart @@ -2,6 +2,7 @@ import 'package:cw_monero/api/wallet.dart'; import 'package:monero/monero.dart' as monero; monero.wallet? wptr = null; +bool get isViewOnly => int.tryParse(monero.Wallet_secretSpendKey(wptr!)) == 0; int _wlptrForW = 0; monero.WalletListener? _wlptr = null; diff --git a/cw_monero/lib/api/transaction_history.dart b/cw_monero/lib/api/transaction_history.dart index a308b682e..7748a16af 100644 --- a/cw_monero/lib/api/transaction_history.dart +++ b/cw_monero/lib/api/transaction_history.dart @@ -13,7 +13,13 @@ import 'package:mutex/mutex.dart'; String getTxKey(String txId) { - return monero.Wallet_getTxKey(wptr!, txid: txId); + final txKey = monero.Wallet_getTxKey(wptr!, txid: txId); + final status = monero.Wallet_status(wptr!); + if (status != 0) { + final error = monero.Wallet_errorString(wptr!); + return txId+"_"+error; + } + return txKey; } final txHistoryMutex = Mutex(); monero.TransactionHistory? txhistory; @@ -178,12 +184,13 @@ PendingTransactionDescription createTransactionMultDestSync( ); } -void commitTransactionFromPointerAddress({required int address}) => - commitTransaction(transactionPointer: monero.PendingTransaction.fromAddress(address)); +String? commitTransactionFromPointerAddress({required int address, required bool useUR}) => + commitTransaction(transactionPointer: monero.PendingTransaction.fromAddress(address), useUR: useUR); -void commitTransaction({required monero.PendingTransaction transactionPointer}) { - - final txCommit = monero.PendingTransaction_commit(transactionPointer, filename: '', overwrite: false); +String? commitTransaction({required monero.PendingTransaction transactionPointer, required bool useUR}) { + final txCommit = useUR + ? monero.PendingTransaction_commitUR(transactionPointer, 120) + : monero.PendingTransaction_commit(transactionPointer, filename: '', overwrite: false); final String? error = (() { final status = monero.PendingTransaction_status(transactionPointer.cast()); @@ -196,6 +203,11 @@ void commitTransaction({required monero.PendingTransaction transactionPointer}) if (error != null) { throw CreationTransactionException(message: error); } + if (useUR) { + return txCommit as String?; + } else { + return null; + } } Future _createTransactionSync(Map args) async { diff --git a/cw_monero/lib/api/wallet_manager.dart b/cw_monero/lib/api/wallet_manager.dart index dca7586fb..61fd6edf0 100644 --- a/cw_monero/lib/api/wallet_manager.dart +++ b/cw_monero/lib/api/wallet_manager.dart @@ -425,3 +425,5 @@ Future restoreFromSpendKey( }); bool isWalletExist({required String path}) => _isWalletExist(path); + +bool isViewOnlyBySpendKey() => int.tryParse(monero.Wallet_secretSpendKey(wptr!)) == 0; \ No newline at end of file diff --git a/cw_monero/lib/monero_wallet.dart b/cw_monero/lib/monero_wallet.dart index 0ae2202ba..bc8c3c94d 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -19,6 +19,7 @@ import 'package:cw_core/transaction_direction.dart'; import 'package:cw_core/unspent_coins_info.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_info.dart'; +import 'package:cw_monero/api/account_list.dart'; import 'package:cw_monero/api/coins_info.dart'; import 'package:cw_monero/api/monero_output.dart'; import 'package:cw_monero/api/structs/pending_transaction.dart'; @@ -121,6 +122,7 @@ abstract class MoneroWalletBase extends WalletBase MoneroWalletKeys( + primaryAddress: monero_wallet.getAddress(accountIndex: 0, addressIndex: 0), privateSpendKey: monero_wallet.getSecretSpendKey(), privateViewKey: monero_wallet.getSecretViewKey(), publicSpendKey: monero_wallet.getPublicSpendKey(), @@ -230,6 +232,36 @@ abstract class MoneroWalletBase extends WalletBase submitTransactionUR(String ur) async { + final retStatus = monero.Wallet_submitTransactionUR(wptr!, ur); + final status = monero.Wallet_status(wptr!); + if (status != 0) { + final err = monero.Wallet_errorString(wptr!); + throw MoneroTransactionCreationException("unable to broadcast signed transaction: $err"); + } + return retStatus; + } + + bool importKeyImagesUR(String ur) { + final retStatus = monero.Wallet_importKeyImagesUR(wptr!, ur); + final status = monero.Wallet_status(wptr!); + if (status != 0) { + final err = monero.Wallet_errorString(wptr!); + throw Exception("unable to import key images: $err"); + } + return retStatus; + } + + String exportOutputsUR(bool all) { + final str = monero.Wallet_exportOutputsUR(wptr!, all: all); + final status = monero.Wallet_status(wptr!); + if (status != 0) { + final err = monero.Wallet_errorString(wptr!); + throw MoneroTransactionCreationException("unable to export UR: $err"); + } + return str; + } + @override Future createTransaction(Object credentials) async { final _credentials = credentials as MoneroTransactionCreationCredentials; diff --git a/cw_monero/lib/pending_monero_transaction.dart b/cw_monero/lib/pending_monero_transaction.dart index 2a75fc26b..eb714eeb3 100644 --- a/cw_monero/lib/pending_monero_transaction.dart +++ b/cw_monero/lib/pending_monero_transaction.dart @@ -1,3 +1,4 @@ +import 'package:cw_monero/api/account_list.dart'; import 'package:cw_monero/api/structs/pending_transaction.dart'; import 'package:cw_monero/api/transaction_history.dart' as monero_transaction_history; @@ -35,11 +36,32 @@ class PendingMoneroTransaction with PendingTransaction { String get feeFormatted => AmountConverter.amountIntToString( CryptoCurrency.xmr, pendingTransactionDescription.fee); + bool shouldCommitUR() => isViewOnly; + @override Future commit() async { try { monero_transaction_history.commitTransactionFromPointerAddress( - address: pendingTransactionDescription.pointerAddress); + address: pendingTransactionDescription.pointerAddress, + useUR: false); + } catch (e) { + final message = e.toString(); + + if (message.contains('Reason: double spend')) { + throw DoubleSpendException(); + } + + rethrow; + } + } + + @override + Future commitUR() async { + try { + final ret = monero_transaction_history.commitTransactionFromPointerAddress( + address: pendingTransactionDescription.pointerAddress, + useUR: true); + return ret; } catch (e) { final message = e.toString(); diff --git a/cw_nano/lib/pending_nano_transaction.dart b/cw_nano/lib/pending_nano_transaction.dart index a027100fd..51a4ef6c1 100644 --- a/cw_nano/lib/pending_nano_transaction.dart +++ b/cw_nano/lib/pending_nano_transaction.dart @@ -37,4 +37,9 @@ class PendingNanoTransaction with PendingTransaction { await nanoClient.processBlock(block, "send"); } } + + @override + Future commitUR() { + throw UnimplementedError(); + } } diff --git a/cw_shared_external/pubspec.lock b/cw_shared_external/pubspec.lock new file mode 100644 index 000000000..203ecd5c3 --- /dev/null +++ b/cw_shared_external/pubspec.lock @@ -0,0 +1,189 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.dev" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.dev" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.dev" + source: hosted + version: "1.18.0" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.dev" + source: hosted + version: "1.3.1" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "7f0df31977cb2c0b88585095d168e689669a2cc9b97c309665e3386f3e9d341a" + url: "https://pub.dev" + source: hosted + version: "10.0.4" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "06e98f569d004c1315b991ded39924b21af84cf14cc94791b8aea337d25b57f8" + url: "https://pub.dev" + source: hosted + version: "3.0.3" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3" + url: "https://pub.dev" + source: hosted + version: "3.0.1" + matcher: + dependency: transitive + description: + name: matcher + sha256: d2323aa2060500f906aa31a895b4030b6da3ebdcc5619d14ce1aada65cd161cb + url: "https://pub.dev" + source: hosted + version: "0.12.16+1" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "0e0a020085b65b6083975e499759762399b4475f766c21668c4ecca34ea74e5a" + url: "https://pub.dev" + source: hosted + version: "0.8.0" + meta: + dependency: transitive + description: + name: meta + sha256: "7687075e408b093f36e6bbf6c91878cc0d4cd10f409506f7bc996f68220b9136" + url: "https://pub.dev" + source: hosted + version: "1.12.0" + path: + dependency: transitive + description: + name: path + sha256: "087ce49c3f0dc39180befefc60fdb4acd8f8620e5682fe2476afd0b3688bb4af" + url: "https://pub.dev" + source: hosted + version: "1.9.0" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.dev" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.dev" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.dev" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "9955ae474176f7ac8ee4e989dadfb411a58c30415bcfb648fa04b2b8a03afa7f" + url: "https://pub.dev" + source: hosted + version: "0.7.0" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.dev" + source: hosted + version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: "3923c89304b715fb1eb6423f017651664a03bf5f4b29983627c4da791f74a4ec" + url: "https://pub.dev" + source: hosted + version: "14.2.1" +sdks: + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/cw_solana/lib/pending_solana_transaction.dart b/cw_solana/lib/pending_solana_transaction.dart index 38347ed13..e01446000 100644 --- a/cw_solana/lib/pending_solana_transaction.dart +++ b/cw_solana/lib/pending_solana_transaction.dart @@ -40,4 +40,9 @@ class PendingSolanaTransaction with PendingTransaction { @override String get id => ''; + + @override + Future commitUR() { + throw UnimplementedError(); + } } diff --git a/cw_tron/lib/pending_tron_transaction.dart b/cw_tron/lib/pending_tron_transaction.dart index b6d064b31..2420f083b 100644 --- a/cw_tron/lib/pending_tron_transaction.dart +++ b/cw_tron/lib/pending_tron_transaction.dart @@ -30,4 +30,9 @@ class PendingTronTransaction with PendingTransaction { @override String get id => ''; + + @override + Future commitUR() { + throw UnimplementedError(); + } } diff --git a/cw_wownero/lib/api/exceptions/wallet_restore_from_keys_exception.dart b/cw_wownero/lib/api/exceptions/wallet_restore_from_keys_exception.dart index ad576faa2..6c461ee4c 100644 --- a/cw_wownero/lib/api/exceptions/wallet_restore_from_keys_exception.dart +++ b/cw_wownero/lib/api/exceptions/wallet_restore_from_keys_exception.dart @@ -3,5 +3,6 @@ class WalletRestoreFromKeysException implements Exception { final String message; + @override String toString() => message; } \ No newline at end of file diff --git a/cw_wownero/lib/pending_wownero_transaction.dart b/cw_wownero/lib/pending_wownero_transaction.dart index 1fc1805eb..967f63756 100644 --- a/cw_wownero/lib/pending_wownero_transaction.dart +++ b/cw_wownero/lib/pending_wownero_transaction.dart @@ -50,4 +50,9 @@ class PendingWowneroTransaction with PendingTransaction { rethrow; } } + + @override + Future commitUR() { + throw UnimplementedError(); + } } diff --git a/cw_wownero/lib/wownero_wallet.dart b/cw_wownero/lib/wownero_wallet.dart index 331957d67..5927d6434 100644 --- a/cw_wownero/lib/wownero_wallet.dart +++ b/cw_wownero/lib/wownero_wallet.dart @@ -120,6 +120,7 @@ abstract class WowneroWalletBase @override MoneroWalletKeys get keys => MoneroWalletKeys( + primaryAddress: wownero_wallet.getAddress(accountIndex: 0, addressIndex: 0), privateSpendKey: wownero_wallet.getSecretSpendKey(), privateViewKey: wownero_wallet.getSecretViewKey(), publicSpendKey: wownero_wallet.getPublicSpendKey(), diff --git a/lib/di.dart b/lib/di.dart index 540a546fd..9cff29798 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -125,6 +125,7 @@ import 'package:cake_wallet/src/screens/subaddress/address_edit_or_create_page.d import 'package:cake_wallet/src/screens/support/support_page.dart'; import 'package:cake_wallet/src/screens/support_chat/support_chat_page.dart'; import 'package:cake_wallet/src/screens/support_other_links/support_other_links_page.dart'; +import 'package:cake_wallet/src/screens/ur/animated_ur_page.dart'; import 'package:cake_wallet/src/screens/wallet/wallet_edit_page.dart'; import 'package:cake_wallet/src/screens/wallet_connect/wc_connections_listing_view.dart'; import 'package:cake_wallet/src/screens/wallet_unlock/wallet_unlock_arguments.dart'; @@ -134,6 +135,7 @@ import 'package:cake_wallet/utils/device_info.dart'; import 'package:cake_wallet/store/anonpay/anonpay_transactions_store.dart'; import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; +import 'package:cake_wallet/view_model/animated_ur_model.dart'; import 'package:cake_wallet/view_model/dashboard/desktop_sidebar_view_model.dart'; import 'package:cake_wallet/view_model/anon_invoice_page_view_model.dart'; import 'package:cake_wallet/view_model/anonpay_details_view_model.dart'; @@ -903,6 +905,11 @@ Future setup({ getIt.registerFactory(() => WalletKeysViewModel(getIt.get())); getIt.registerFactory(() => WalletKeysPage(getIt.get())); + + getIt.registerFactory(() => AnimatedURModel(getIt.get())); + + getIt.registerFactoryParam((String urQr, _) => + AnimatedURPage(getIt.get(), urQr: urQr)); getIt.registerFactoryParam( (ContactRecord? contact, _) => ContactViewModel(_contactSource, contact: contact)); diff --git a/lib/entities/default_settings_migration.dart b/lib/entities/default_settings_migration.dart index af6f1ce78..d863b136e 100644 --- a/lib/entities/default_settings_migration.dart +++ b/lib/entities/default_settings_migration.dart @@ -259,6 +259,10 @@ Future defaultSettingsMigration( case 42: updateBtcElectrumNodeToUseSSL(nodes, sharedPreferences); break; + case 43: + _updateCakeXmrNode(nodes); + break; + default: break; } @@ -273,6 +277,15 @@ Future defaultSettingsMigration( await sharedPreferences.setInt(PreferencesKey.currentDefaultSettingsMigrationVersion, version); } +void _updateCakeXmrNode(Box nodes) { + final node = nodes.values.firstWhereOrNull((element) => element.uriRaw == newCakeWalletMoneroUri); + + if (node != null && !node.trusted) { + node.trusted = true; + node.save(); + } +} + void updateBtcElectrumNodeToUseSSL(Box nodes, SharedPreferences sharedPreferences) { final btcElectrumNode = nodes.values.firstWhereOrNull((element) => element.uriRaw == newCakeWalletBitcoinUri); @@ -843,7 +856,7 @@ Future changeDefaultMoneroNode( } }); - final newCakeWalletNode = Node(uri: newCakeWalletMoneroUri, type: WalletType.monero); + final newCakeWalletNode = Node(uri: newCakeWalletMoneroUri, type: WalletType.monero, trusted: true); await nodeSource.add(newCakeWalletNode); diff --git a/lib/entities/qr_scanner.dart b/lib/entities/qr_scanner.dart index c6873a5bc..2e2637566 100644 --- a/lib/entities/qr_scanner.dart +++ b/lib/entities/qr_scanner.dart @@ -1,15 +1,376 @@ -import 'package:barcode_scan2/barcode_scan2.dart'; +import 'dart:math'; + +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/main.dart'; +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:fast_scanner/fast_scanner.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; var isQrScannerShown = false; -Future presentQRScanner() async { +Future presentQRScanner(BuildContext context) async { isQrScannerShown = true; try { - final result = await BarcodeScanner.scan(); + final result = await Navigator.of(context).push( + MaterialPageRoute( + builder:(context) { + return BarcodeScannerSimple(); + }, + ), + ); isQrScannerShown = false; - return result.rawContent.trim(); + return result??''; } catch (e) { isQrScannerShown = false; rethrow; } } + +// https://github.com/MrCyjaneK/fast_scanner/blob/master/example/lib/barcode_scanner_simple.dart +class BarcodeScannerSimple extends StatefulWidget { + const BarcodeScannerSimple({super.key}); + + @override + State createState() => _BarcodeScannerSimpleState(); +} + +class _BarcodeScannerSimpleState extends State { + Barcode? _barcode; + bool popped = false; + + List urCodes = []; + late var ur = URQRToURQRData(urCodes); + + void _handleBarcode(BarcodeCapture barcodes) { + try { + _handleBarcodeInternal(barcodes); + } catch (e) { + showPopUp( + context: context, + builder: (context) { + return AlertWithOneAction( + alertTitle: S.of(context).error, + alertContent: S.of(context).error_dialog_content, + buttonText: 'ok', + buttonAction: () { + Navigator.of(context).pop(); + }, + ); + }, + ); + print(e); + } + } + + void _handleBarcodeInternal(BarcodeCapture barcodes) { + for (final barcode in barcodes.barcodes) { + // don't handle unknown QR codes + if (barcode.rawValue?.trim().isEmpty??false == false) continue; + if (barcode.rawValue!.startsWith("ur:")) { + if (urCodes.contains(barcode.rawValue)) continue; + setState(() { + urCodes.add(barcode.rawValue!); + ur = URQRToURQRData(urCodes); + }); + if (ur.progress == 1) { + setState(() { + popped = true; + }); + SchedulerBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pop(ur.inputs.join("\n")); + }); + }; + } + } + if (urCodes.isNotEmpty) return; + if (mounted) { + setState(() { + _barcode = barcodes.barcodes.firstOrNull; + }); + if (_barcode != null && popped != true) { + setState(() { + popped = true; + }); + SchedulerBinding.instance.addPostFrameCallback((_) { + Navigator.of(context).pop(_barcode?.rawValue ?? ""); + }); + } + } + } + + final MobileScannerController ctrl = MobileScannerController(); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Scan'), + actions: [ + SwitchCameraButton(controller: ctrl), + ToggleFlashlightButton(controller: ctrl), + ], + ), + backgroundColor: Colors.black, + body: Stack( + children: [ + MobileScanner( + onDetect: _handleBarcode, + controller: ctrl, + ), + if (ur.inputs.length != 0) + Center(child: + Text( + "${ur.inputs.length}/${ur.count}", + style: Theme.of(context).textTheme.displayLarge?.copyWith(color: Colors.white) + ), + ), + SizedBox( + child: Center( + child: SizedBox( + width: 250, + height: 250, + child: CustomPaint( + painter: ProgressPainter( + urQrProgress: URQrProgress( + expectedPartCount: ur.count - 1, + processedPartsCount: ur.inputs.length, + receivedPartIndexes: _urParts(), + percentage: ur.progress, + ), + ), + ), + ), + ), + ), + ], + ), + ); + } + + List _urParts() { + List l = []; + for (var inp in ur.inputs) { + try { + l.add(int.parse(inp.split("/")[1].split("-")[0])); + } catch (e) {} + } + return l; + } +} + + +class ToggleFlashlightButton extends StatelessWidget { + const ToggleFlashlightButton({required this.controller, super.key}); + + final MobileScannerController controller; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, state, child) { + if (!state.isInitialized || !state.isRunning) { + return const SizedBox.shrink(); + } + + switch (state.torchState) { + case TorchState.auto: + return IconButton( + iconSize: 32.0, + icon: const Icon(Icons.flash_auto), + onPressed: () async { + await controller.toggleTorch(); + }, + ); + case TorchState.off: + return IconButton( + iconSize: 32.0, + icon: const Icon(Icons.flash_off), + onPressed: () async { + await controller.toggleTorch(); + }, + ); + case TorchState.on: + return IconButton( + iconSize: 32.0, + icon: const Icon(Icons.flash_on), + onPressed: () async { + await controller.toggleTorch(); + }, + ); + case TorchState.unavailable: + return const Icon( + Icons.no_flash, + color: Colors.grey, + ); + } + }, + ); + } +} + +class SwitchCameraButton extends StatelessWidget { + const SwitchCameraButton({required this.controller, super.key}); + + final MobileScannerController controller; + + @override + Widget build(BuildContext context) { + return ValueListenableBuilder( + valueListenable: controller, + builder: (context, state, child) { + if (!state.isInitialized || !state.isRunning) { + return const SizedBox.shrink(); + } + + final int? availableCameras = state.availableCameras; + + if (availableCameras != null && availableCameras < 2) { + return const SizedBox.shrink(); + } + + final Widget icon; + + switch (state.cameraDirection) { + case CameraFacing.front: + icon = const Icon(Icons.camera_front); + case CameraFacing.back: + icon = const Icon(Icons.camera_rear); + } + + return IconButton( + iconSize: 32.0, + icon: icon, + onPressed: () async { + await controller.switchCamera(); + }, + ); + }, + ); + } +} + +class URQRData { + URQRData( + {required this.tag, + required this.str, + required this.progress, + required this.count, + required this.error, + required this.inputs}); + final String tag; + final String str; + final double progress; + final int count; + final String error; + final List inputs; + Map toJson() { + return { + "tag": tag, + "str": str, + "progress": progress, + "count": count, + "error": error, + "inputs": inputs, + }; + } +} + +URQRData URQRToURQRData(List urqr_) { + final urqr = urqr_.toSet().toList(); + urqr.sort((s1, s2) { + final s1s = s1.split("/"); + final s1frameStr = s1s[1].split("-"); + final s1curFrame = int.parse(s1frameStr[0]); + final s2s = s2.split("/"); + final s2frameStr = s2s[1].split("-"); + final s2curFrame = int.parse(s2frameStr[0]); + return s1curFrame - s2curFrame; + }); + + String tag = ''; + int count = 0; + String bw = ''; + for (var elm in urqr) { + final s = elm.substring(elm.indexOf(":") + 1); // strip down ur: prefix + final s2 = s.split("/"); + tag = s2[0]; + final frameStr = s2[1].split("-"); + // final curFrame = int.parse(frameStr[0]); + count = int.parse(frameStr[1]); + final byteWords = s2[2]; + bw += byteWords; + } + String? error; + + return URQRData( + tag: tag, + str: bw, + progress: count == 0 ? 0 : (urqr.length / count), + count: count, + error: error ?? "", + inputs: urqr, + ); +} + +class ProgressPainter extends CustomPainter { + final URQrProgress urQrProgress; + + ProgressPainter({required this.urQrProgress}); + + @override + void paint(Canvas canvas, Size size) { + final c = Offset(size.width / 2.0, size.height / 2.0); + final radius = size.width * 0.9; + final rect = Rect.fromCenter(center: c, width: radius, height: radius); + const fullAngle = 360.0; + var startAngle = 0.0; + for (int i = 0; i < urQrProgress.expectedPartCount.toInt(); i++) { + var sweepAngle = + (1 / urQrProgress.expectedPartCount) * fullAngle * pi / 180.0; + drawSector(canvas, urQrProgress.receivedPartIndexes.contains(i), rect, + startAngle, sweepAngle); + startAngle += sweepAngle; + } + } + + void drawSector(Canvas canvas, bool isActive, Rect rect, double startAngle, + double sweepAngle) { + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = 8 + ..strokeCap = StrokeCap.round + ..strokeJoin = StrokeJoin.round + ..color = isActive ? const Color(0xffff6600) : Colors.white70; + canvas.drawArc(rect, startAngle, sweepAngle, false, paint); + } + + @override + bool shouldRepaint(covariant ProgressPainter oldDelegate) { + return urQrProgress != oldDelegate.urQrProgress; + } +} + +class URQrProgress { + int expectedPartCount; + int processedPartsCount; + List receivedPartIndexes; + double percentage; + + URQrProgress({ + required this.expectedPartCount, + required this.processedPartsCount, + required this.receivedPartIndexes, + required this.percentage, + }); + + bool equals(URQrProgress? progress) { + if (progress == null) { + return false; + } + return processedPartsCount == progress.processedPartsCount; + } +} diff --git a/lib/main.dart b/lib/main.dart index 29b216b22..51fab4dd1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -204,7 +204,7 @@ Future initializeAppConfigs() async { transactionDescriptions: transactionDescriptions, secureStorage: secureStorage, anonpayInvoiceInfo: anonpayInvoiceInfo, - initialMigrationVersion: 42, + initialMigrationVersion: 43, ); } diff --git a/lib/monero/cw_monero.dart b/lib/monero/cw_monero.dart index b1cf49482..dfda4d600 100644 --- a/lib/monero/cw_monero.dart +++ b/lib/monero/cw_monero.dart @@ -248,6 +248,7 @@ class CWMonero extends Monero { final moneroWallet = wallet as MoneroWallet; final keys = moneroWallet.keys; return { + 'primaryAddress': keys.primaryAddress, 'privateSpendKey': keys.privateSpendKey, 'privateViewKey': keys.privateViewKey, 'publicSpendKey': keys.publicSpendKey, @@ -357,9 +358,32 @@ class CWMonero extends Monero { Future getCurrentHeight() async { return monero_wallet_api.getCurrentHeight(); } + + @override + bool importKeyImagesUR(Object wallet, String ur) { + final moneroWallet = wallet as MoneroWallet; + return moneroWallet.importKeyImagesUR(ur); + } + + + @override + Future commitTransactionUR(Object wallet, String ur) { + final moneroWallet = wallet as MoneroWallet; + return moneroWallet.submitTransactionUR(ur); + } + + @override + String exportOutputsUR(Object wallet, bool all) { + final moneroWallet = wallet as MoneroWallet; + return moneroWallet.exportOutputsUR(all); + } @override void monerocCheck() { checkIfMoneroCIsFine(); } + + bool isViewOnly() { + return isViewOnlyBySpendKey(); + } } diff --git a/lib/router.dart b/lib/router.dart index 6c234ff80..4b99aabc0 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -96,6 +96,7 @@ import 'package:cake_wallet/src/screens/transaction_details/rbf_details_page.dar import 'package:cake_wallet/src/screens/transaction_details/transaction_details_page.dart'; import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_details_page.dart'; import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_list_page.dart'; +import 'package:cake_wallet/src/screens/ur/animated_ur_page.dart'; import 'package:cake_wallet/src/screens/wallet/wallet_edit_page.dart'; import 'package:cake_wallet/src/screens/wallet_connect/wc_connections_listing_view.dart'; import 'package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart'; @@ -732,6 +733,9 @@ Route createRoute(RouteSettings settings) { case Routes.setup2faInfoPage: return MaterialPageRoute(builder: (_) => getIt.get()); + case Routes.urqrAnimatedPage: + return MaterialPageRoute(builder: (_) => getIt.get(param1: settings.arguments)); + case Routes.homeSettings: return CupertinoPageRoute( builder: (_) => getIt.get(param1: settings.arguments), diff --git a/lib/routes.dart b/lib/routes.dart index 5f11b11a3..63c41bde5 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -108,6 +108,7 @@ class Routes { static const signPage = '/sign_page'; static const connectDevices = '/device/connect'; + static const urqrAnimatedPage = '/urqr/animated_page'; static const walletGroupsDisplayPage = '/wallet_groups_display_page'; static const walletGroupDescription = '/wallet_group_description'; } 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 309c435a2..9357df2c3 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 @@ -347,8 +347,8 @@ class CakePayBuyCardDetailPage extends BasePage { rightButtonText: S.of(popupContext).send, leftButtonText: S.of(popupContext).cancel, actionRightButton: () async { - Navigator.of(popupContext).pop(); - await cakePayPurchaseViewModel.sendViewModel.commitTransaction(); + Navigator.of(context).pop(); + await cakePayPurchaseViewModel.sendViewModel.commitTransaction(context); }, actionLeftButton: () => Navigator.of(popupContext).pop())); }, diff --git a/lib/src/screens/dashboard/widgets/menu_widget.dart b/lib/src/screens/dashboard/widgets/menu_widget.dart index f0e330f04..b49a08584 100644 --- a/lib/src/screens/dashboard/widgets/menu_widget.dart +++ b/lib/src/screens/dashboard/widgets/menu_widget.dart @@ -101,6 +101,9 @@ class MenuWidgetState extends State { if (!widget.dashboardViewModel.hasSilentPayments) { items.removeWhere((element) => element.name(context) == S.of(context).silent_payments_settings); } + if (!widget.dashboardViewModel.isMoneroViewOnly) { + items.removeWhere((element) => element.name(context) == S.of(context).export_outputs); + } if (!widget.dashboardViewModel.hasMweb) { items.removeWhere((element) => element.name(context) == S.of(context).litecoin_mweb_settings); } @@ -189,7 +192,6 @@ class MenuWidgetState extends State { index--; final item = items[index]; - final isLastTile = index == itemCount - 1; return SettingActionButton( diff --git a/lib/src/screens/exchange_trade/exchange_trade_page.dart b/lib/src/screens/exchange_trade/exchange_trade_page.dart index 0f3cc7bd9..130ffa6b0 100644 --- a/lib/src/screens/exchange_trade/exchange_trade_page.dart +++ b/lib/src/screens/exchange_trade/exchange_trade_page.dart @@ -277,7 +277,7 @@ class ExchangeTradeState extends State { actionRightButton: () async { Navigator.of(popupContext).pop(); await widget.exchangeTradeViewModel.sendViewModel - .commitTransaction(); + .commitTransaction(context); transactionStatePopup(); }, actionLeftButton: () => Navigator.of(popupContext).pop(), diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart index bce82312d..7003ceafb 100644 --- a/lib/src/screens/send/send_page.dart +++ b/lib/src/screens/send/send_page.dart @@ -498,7 +498,7 @@ class SendPage extends BasePage { ValueKey('send_page_confirm_sending_dialog_cancel_button_key'), actionRightButton: () async { Navigator.of(_dialogContext).pop(); - sendViewModel.commitTransaction(); + sendViewModel.commitTransaction(context); await showPopUp( context: context, builder: (BuildContext _dialogContext) { diff --git a/lib/src/screens/settings/desktop_settings/desktop_settings_page.dart b/lib/src/screens/settings/desktop_settings/desktop_settings_page.dart index 79f74065a..6f7afe2ff 100644 --- a/lib/src/screens/settings/desktop_settings/desktop_settings_page.dart +++ b/lib/src/screens/settings/desktop_settings/desktop_settings_page.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/src/widgets/setting_action_button.dart'; import 'package:cake_wallet/src/widgets/setting_actions.dart'; import 'package:cake_wallet/typography.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; import 'package:cake_wallet/router.dart' as Router; import 'package:cake_wallet/themes/extensions/menu_theme.dart'; @@ -60,8 +61,10 @@ class _DesktopSettingsPageState extends State { return Container(); } - if (!widget.dashboardViewModel.hasMweb && - item.name(context) == S.of(context).litecoin_mweb_settings) { + if ((!widget.dashboardViewModel.isMoneroViewOnly && + item.name(context) == S.of(context).export_outputs) || + (!widget.dashboardViewModel.hasMweb && + item.name(context) == S.of(context).litecoin_mweb_settings)) { return Container(); } diff --git a/lib/src/screens/transaction_details/rbf_details_page.dart b/lib/src/screens/transaction_details/rbf_details_page.dart index b117a0b68..2c5edd8b4 100644 --- a/lib/src/screens/transaction_details/rbf_details_page.dart +++ b/lib/src/screens/transaction_details/rbf_details_page.dart @@ -168,7 +168,7 @@ class RBFDetailsPage extends BasePage { leftButtonText: S.of(popupContext).cancel, actionRightButton: () async { Navigator.of(popupContext).pop(); - await transactionDetailsViewModel.sendViewModel.commitTransaction(); + await transactionDetailsViewModel.sendViewModel.commitTransaction(context); try { Navigator.of(popupContext).pop(); } catch (_) {} diff --git a/lib/src/screens/ur/animated_ur_page.dart b/lib/src/screens/ur/animated_ur_page.dart new file mode 100644 index 000000000..dc40e6a12 --- /dev/null +++ b/lib/src/screens/ur/animated_ur_page.dart @@ -0,0 +1,184 @@ +import 'dart:async'; + +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/entities/qr_scanner.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/monero/monero.dart'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/screens/receive/widgets/qr_image.dart'; +import 'package:cake_wallet/src/screens/send/widgets/confirm_sending_alert.dart'; +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/view_model/animated_ur_model.dart'; +import 'package:cake_wallet/view_model/dashboard/wallet_balance.dart'; +import 'package:cw_core/balance.dart'; +import 'package:cw_core/transaction_history.dart'; +import 'package:cw_core/transaction_info.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +// ur:xmr-txunsigned - unsigned transaction +// should show a scanner afterwards. + +class AnimatedURPage extends BasePage { + final bool isAll; + AnimatedURPage(this.animatedURmodel, {required String urQr, this.isAll = false}) { + if (urQr == "export-outputs") { + this.urQr = monero!.exportOutputsUR(animatedURmodel.wallet, false); + } else if (urQr == "export-outputs-all") { + this.urQr = monero!.exportOutputsUR(animatedURmodel.wallet, true); + } else { + this.urQr = urQr; + } + } + + late String urQr; + + final AnimatedURModel animatedURmodel; + + String get urQrType { + final first = urQr.trim().split("\n")[0]; + return first.split('/')[0]; + } + + @override + Widget body(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.only(top: 64.0), + child: URQR( + frames: urQr.trim().split("\n"), + ), + ), + SizedBox(height: 32), + if (urQrType == "ur:xmr-txunsigned" || urQrType == "ur:xmr-output") + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: double.maxFinite, + child: PrimaryButton( + onPressed: () => _continue(context), + text: "Continue", + color: Theme.of(context).primaryColor, + textColor: Colors.white, + ), + ), + ), + SizedBox(height: 32), + if (urQrType == "ur:xmr-output" && !isAll) Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: SizedBox( + width: double.maxFinite, + child: PrimaryButton( + onPressed: () => _exportAll(context), + text: "Export all", + color: Theme.of(context).colorScheme.secondary, + textColor: Colors.white, + ), + ), + ), + ], + ); + } + + void _exportAll(BuildContext context) { + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (context) { + return AnimatedURPage(animatedURmodel, urQr: "export-outputs-all", isAll: true); + }, + ), + ); + } + + Future _continue(BuildContext context) async { + try { + switch (urQrType) { + case "ur:xmr-txunsigned": // ur:xmr-txsigned + final ur = await presentQRScanner(context); + final result = await monero!.commitTransactionUR(animatedURmodel.wallet, ur); + if (result) { + Navigator.of(context).pop(true); + } + break; + case "ur:xmr-output": // xmr-keyimage + final ur = await presentQRScanner(context); + final result = await monero!.importKeyImagesUR(animatedURmodel.wallet, ur); + if (result) { + Navigator.of(context).pop(true); + } + break; + default: + throw UnimplementedError("unable to handle UR: ${urQrType}"); + } + } catch (e) { + await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + alertTitle: S.of(context).error, + alertContent: e.toString(), + buttonText: S.of(context).ok, + buttonAction: () => Navigator.pop(context, true)); + }); + } + } +} + +class URQR extends StatefulWidget { + URQR({super.key, required this.frames}); + + List frames; + + @override + // ignore: library_private_types_in_public_api + _URQRState createState() => _URQRState(); +} + +const urFrameTime = 1000 ~/ 5; + +class _URQRState extends State { + Timer? t; + int frame = 0; + @override + void initState() { + super.initState(); + setState(() { + t = Timer.periodic(const Duration(milliseconds: urFrameTime), (timer) { + _nextFrame(); + }); + }); + } + + void _nextFrame() { + setState(() { + frame++; + }); + } + + @override + void dispose() { + t?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Center( + child: QrImage( + data: widget.frames[frame % widget.frames.length], version: -1, + size: 400, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/src/screens/wallet_connect/wc_connections_listing_view.dart b/lib/src/screens/wallet_connect/wc_connections_listing_view.dart index eda2a748f..3a78f6af6 100644 --- a/lib/src/screens/wallet_connect/wc_connections_listing_view.dart +++ b/lib/src/screens/wallet_connect/wc_connections_listing_view.dart @@ -65,7 +65,7 @@ class WCPairingsWidget extends BasePage { bool isCameraPermissionGranted = await PermissionHandler.checkPermission(Permission.camera, context); if (!isCameraPermissionGranted) return; - uri = await presentQRScanner(); + uri = await presentQRScanner(context); } else { uri = await _showEnterWalletConnectURIPopUp(context); } diff --git a/lib/src/widgets/address_text_field.dart b/lib/src/widgets/address_text_field.dart index 0b1ef4796..d904768f0 100644 --- a/lib/src/widgets/address_text_field.dart +++ b/lib/src/widgets/address_text_field.dart @@ -231,7 +231,7 @@ class AddressTextField extends StatelessWidget { bool isCameraPermissionGranted = await PermissionHandler.checkPermission(Permission.camera, context); if (!isCameraPermissionGranted) return; - final code = await presentQRScanner(); + final code = await presentQRScanner(context); if (code.isEmpty) { return; } diff --git a/lib/src/widgets/setting_actions.dart b/lib/src/widgets/setting_actions.dart index 048d006cc..da9f26bc6 100644 --- a/lib/src/widgets/setting_actions.dart +++ b/lib/src/widgets/setting_actions.dart @@ -21,6 +21,7 @@ class SettingActions { addressBookSettingAction, silentPaymentsSettingAction, litecoinMwebSettingAction, + exportOutputsAction, securityBackupSettingAction, privacySettingAction, displaySettingAction, @@ -50,6 +51,16 @@ class SettingActions { }, ); + static SettingActions exportOutputsAction = SettingActions._( + key: ValueKey('dashboard_page_menu_widget_export_outputs_settings_button_key'), + name: (context) => S.of(context).export_outputs, + image: 'assets/images/monero_menu.png', + onTap: (BuildContext context) { + Navigator.pop(context); + Navigator.of(context).pushNamed(Routes.urqrAnimatedPage, arguments: 'export-outputs'); + }, + ); + static SettingActions litecoinMwebSettingAction = SettingActions._( key: ValueKey('dashboard_page_menu_widget_litecoin_mweb_settings_button_key'), name: (context) => S.of(context).litecoin_mweb_settings, diff --git a/lib/view_model/animated_ur_model.dart b/lib/view_model/animated_ur_model.dart new file mode 100644 index 000000000..f613d26be --- /dev/null +++ b/lib/view_model/animated_ur_model.dart @@ -0,0 +1,10 @@ +import 'package:cake_wallet/store/app_store.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:mobx/mobx.dart'; + +class AnimatedURModel with Store { + AnimatedURModel(this.appStore) + : wallet = appStore.wallet!; + final AppStore appStore; + final WalletBase wallet; +} \ No newline at end of file diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 6cf7c49d0..934cbdee7 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -393,6 +393,12 @@ abstract class DashboardViewModelBase with Store { wallet.type == WalletType.wownero || wallet.type == WalletType.haven; + @computed + bool get isMoneroViewOnly { + if (wallet.type != WalletType.monero) return false; + return monero!.isViewOnly(); + } + @computed String? get getMoneroError { if (wallet.type != WalletType.monero) return null; diff --git a/lib/view_model/node_list/node_create_or_edit_view_model.dart b/lib/view_model/node_list/node_create_or_edit_view_model.dart index 86ceb654c..8b3c70c5e 100644 --- a/lib/view_model/node_list/node_create_or_edit_view_model.dart +++ b/lib/view_model/node_list/node_create_or_edit_view_model.dart @@ -213,7 +213,7 @@ abstract class NodeCreateOrEditViewModelBase with Store { bool isCameraPermissionGranted = await PermissionHandler.checkPermission(Permission.camera, context); if (!isCameraPermissionGranted) return; - String code = await presentQRScanner(); + String code = await presentQRScanner(context); if (code.isEmpty) { throw Exception('Unexpected scan QR code value: value is empty'); diff --git a/lib/view_model/restore/restore_wallet.dart b/lib/view_model/restore/restore_wallet.dart index 2c2a25005..cc3ad4123 100644 --- a/lib/view_model/restore/restore_wallet.dart +++ b/lib/view_model/restore/restore_wallet.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:cake_wallet/view_model/restore/restore_mode.dart'; import 'package:cw_core/wallet_type.dart'; @@ -32,6 +34,16 @@ class RestoredWallet { final String? privateKey; factory RestoredWallet.fromKey(Map json) { + try { + final codeParsed = jsonDecode(json['raw_qr'].toString()); + if (codeParsed["version"] == 0) { + json['address'] = codeParsed["primaryAddress"]; + json['view_key'] = codeParsed["privateViewKey"]; + json['height'] = codeParsed["restoreHeight"].toString(); + } + } catch (e) { + // fine, we don't care, it is only for monero anyway + } final height = json['height'] as String?; return RestoredWallet( restoreMode: json['mode'] as WalletRestoreMode, @@ -39,7 +51,7 @@ class RestoredWallet { address: json['address'] as String?, spendKey: json['spend_key'] as String?, viewKey: json['view_key'] as String?, - height: height != null ? int.parse(height) : 0, + height: height != null ? int.tryParse(height)??0 : 0, privateKey: json['private_key'] as String?, ); } diff --git a/lib/view_model/restore/wallet_restore_from_qr_code.dart b/lib/view_model/restore/wallet_restore_from_qr_code.dart index 23850befa..c1a19ea57 100644 --- a/lib/view_model/restore/wallet_restore_from_qr_code.dart +++ b/lib/view_model/restore/wallet_restore_from_qr_code.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import 'package:cake_wallet/core/seed_validator.dart'; import 'package:cake_wallet/entities/parse_address_from_domain.dart'; import 'package:cake_wallet/entities/qr_scanner.dart'; @@ -48,6 +50,17 @@ class WalletRestoreFromQRCode { final extracted = sortedKeys.firstWhereOrNull((key) => code.toLowerCase().contains(key)); + if (extracted == null) { + // Special case for view-only monero wallet + final codeParsed = json.decode(code); + if (codeParsed["version"] == 0 && + codeParsed["primaryAddress"] != null && + codeParsed["privateViewKey"] != null && + codeParsed["restoreHeight"] != null) { + return WalletType.monero; + } + } + return _walletTypeMap[extracted]; } @@ -75,7 +88,7 @@ class WalletRestoreFromQRCode { } static Future scanQRCodeForRestoring(BuildContext context) async { - String code = await presentQRScanner(); + String code = await presentQRScanner(context); if (code.isEmpty) throw Exception('Unexpected scan QR code value: value is empty'); WalletType? walletType; @@ -109,7 +122,7 @@ class WalletRestoreFromQRCode { queryParameters['address'] = _extractAddressFromUrl(code, walletType!); } - Map credentials = {'type': walletType, ...queryParameters}; + Map credentials = {'type': walletType, ...queryParameters, 'raw_qr': code}; credentials['mode'] = _determineWalletRestoreMode(credentials); @@ -205,6 +218,17 @@ class WalletRestoreFromQRCode { return WalletRestoreMode.keys; } + if (type == WalletType.monero) { + final codeParsed = json.decode(credentials['raw_qr'].toString()); + if (codeParsed["version"] != 0) throw UnimplementedError("Found view-only restore with unsupported version"); + if (codeParsed["primaryAddress"] == null || + codeParsed["privateViewKey"] == null || + codeParsed["restoreHeight"] == null) { + throw UnimplementedError("Missing one or more attributes in the JSON"); + } + return WalletRestoreMode.keys; + } + throw Exception('Unexpected restore mode: restore params are invalid'); } } diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 1403eb109..4621e8d16 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -1,3 +1,4 @@ +import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/entities/contact.dart'; import 'package:cake_wallet/entities/priority_for_wallet_type.dart'; import 'package:cake_wallet/entities/transaction_description.dart'; @@ -10,7 +11,9 @@ import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/wallet_contact.dart'; import 'package:cake_wallet/polygon/polygon.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; +import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/solana/solana.dart'; +import 'package:cake_wallet/src/screens/ur/animated_ur_page.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/tron/tron.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; @@ -23,6 +26,7 @@ import 'package:cw_core/transaction_priority.dart'; import 'package:cw_core/unspent_coin_type.dart'; import 'package:cake_wallet/view_model/send/output.dart'; import 'package:cake_wallet/view_model/send/send_template_view_model.dart'; +import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:cake_wallet/entities/template.dart'; @@ -457,7 +461,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor } @action - Future commitTransaction() async { + Future commitTransaction(BuildContext context) async { if (pendingTransaction == null) { throw Exception("Pending transaction doesn't exist. It should not be happened."); } @@ -476,7 +480,17 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor try { state = TransactionCommitting(); - await pendingTransaction!.commit(); + + if (pendingTransaction!.shouldCommitUR()) { + final urstr = await pendingTransaction!.commitUR(); + final result = await Navigator.of(context).pushNamed(Routes.urqrAnimatedPage, arguments: urstr); + if (result == null) { + state = FailureState("Canceled by user"); + return; + } + } else { + await pendingTransaction!.commit(); + } if (walletType == WalletType.nano) { nano!.updateTransactions(wallet); diff --git a/lib/view_model/wallet_keys_view_model.dart b/lib/view_model/wallet_keys_view_model.dart index 6455fc0e3..81eda7cc8 100644 --- a/lib/view_model/wallet_keys_view_model.dart +++ b/lib/view_model/wallet_keys_view_model.dart @@ -72,6 +72,10 @@ abstract class WalletKeysViewModelBase with Store { final keys = monero!.getKeys(_appStore.wallet!); items.addAll([ + if (keys['primaryAddress'] != null) + StandartListItem( + title: S.current.primary_address, + value: keys['primaryAddress']!), if (keys['publicSpendKey'] != null) StandartListItem( key: ValueKey('${_walletName}_wallet_public_spend_key_item_key'), @@ -131,6 +135,10 @@ abstract class WalletKeysViewModelBase with Store { final keys = haven!.getKeys(_appStore.wallet!); items.addAll([ + if (keys['primaryAddress'] != null) + StandartListItem( + title: S.current.primary_address, + value: keys['primaryAddress']!), if (keys['publicSpendKey'] != null) StandartListItem( key: ValueKey('${_walletName}_wallet_public_spend_key_item_key'), @@ -168,6 +176,10 @@ abstract class WalletKeysViewModelBase with Store { final keys = wownero!.getKeys(_appStore.wallet!); items.addAll([ + if (keys['primaryAddress'] != null) + StandartListItem( + title: S.current.primary_address, + value: keys['primaryAddress']!), if (keys['publicSpendKey'] != null) StandartListItem( key: ValueKey('${_walletName}_wallet_public_spend_key_item_key'), diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index f52be7481..4b9eb3b2d 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,7 +10,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - sp_scanner ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 9895e6305..52b44e53e 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,9 +6,9 @@ import FlutterMacOS import Foundation import connectivity_plus -import cw_mweb import device_info_plus import devicelocale +import fast_scanner import flutter_inappwebview_macos import flutter_local_authentication import flutter_secure_storage_macos @@ -23,9 +23,9 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) - CwMwebPlugin.register(with: registry.registrar(forPlugin: "CwMwebPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DevicelocalePlugin.register(with: registry.registrar(forPlugin: "DevicelocalePlugin")) + MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) InAppWebViewFlutterPlugin.register(with: registry.registrar(forPlugin: "InAppWebViewFlutterPlugin")) FlutterLocalAuthenticationPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalAuthenticationPlugin")) FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) diff --git a/macos/Podfile.lock b/macos/Podfile.lock index d6199d028..001d75696 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -8,6 +8,8 @@ PODS: - FlutterMacOS - devicelocale (0.0.1): - FlutterMacOS + - fast_scanner (5.1.1): + - FlutterMacOS - flutter_inappwebview_macos (0.0.1): - FlutterMacOS - OrderedSet (~> 5.0) @@ -42,6 +44,7 @@ DEPENDENCIES: - cw_mweb (from `Flutter/ephemeral/.symlinks/plugins/cw_mweb/macos`) - device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`) - devicelocale (from `Flutter/ephemeral/.symlinks/plugins/devicelocale/macos`) + - fast_scanner (from `Flutter/ephemeral/.symlinks/plugins/fast_scanner/macos`) - flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`) - flutter_local_authentication (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_authentication/macos`) - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) @@ -69,6 +72,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos devicelocale: :path: Flutter/ephemeral/.symlinks/plugins/devicelocale/macos + fast_scanner: + :path: Flutter/ephemeral/.symlinks/plugins/fast_scanner/macos flutter_inappwebview_macos: :path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos flutter_local_authentication: @@ -99,6 +104,7 @@ SPEC CHECKSUMS: cw_mweb: 7440b12ead811dda972a9918442ea2a458e8742c device_info_plus: 5401765fde0b8d062a2f8eb65510fb17e77cf07f devicelocale: 9f0f36ac651cabae2c33f32dcff4f32b61c38225 + fast_scanner: d31bae07e2653403a69dac99fb710c1722b16a97 flutter_inappwebview_macos: 9600c9df9fdb346aaa8933812009f8d94304203d flutter_local_authentication: 85674893931e1c9cfa7c9e4f5973cb8c56b018b0 flutter_secure_storage_macos: d56e2d218c1130b262bef8b4a7d64f88d7f9c9ea diff --git a/pubspec_base.yaml b/pubspec_base.yaml index 2abd8401a..fa70b5837 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -14,8 +14,10 @@ dependencies: # provider: ^6.0.3 rxdart: ^0.28.0 yaml: ^3.1.1 - #barcode_scan: any - barcode_scan2: ^4.2.1 + fast_scanner: + git: + url: https://github.com/MrCyjaneK/fast_scanner + ref: c8311b46cc67dd02250970a54d2a4526168187f3 http: ^1.1.0 path_provider: ^2.0.11 mobx: ^2.1.4 diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index b8c933a2e..fc0ea8ea9 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -136,7 +136,7 @@ "clearnet_link": "رابط Clearnet", "close": "يغلق", "coin_control": "التحكم في العملة (اختياري)", - "cold_or_recover_wallet": "أضف محفظة باردة أو استعد محفظة ورقية", + "cold_or_recover_wallet": "أضف محفظة للقراءة فقط من Cupcake أو محفظة باردة أو استعاد محفظة ورقية", "color_theme": "سمة اللون", "commit_transaction_amount_fee": "تنفيذ الصفقة\nالمبلغ: ${amount}\nالرسوم: ${fee}", "confirm": "تأكيد", @@ -161,6 +161,7 @@ "contact_list_contacts": "جهات الاتصال", "contact_list_wallets": "محافظ", "contact_name": "اسم جهة الاتصال", + "contact_name_exists": " .ﻒﻠﺘﺨﻣ ﻢﺳﺍ ﺭﺎﻴﺘﺧﺍ ءﺎﺟﺮﻟﺍ .ﻞﻌﻔﻟﺎﺑ ﺓﺩﻮﺟﻮﻣ ﻢﺳﻻﺍ ﺍﺬﻬﺑ ﻝﺎﺼﺗﺍ ﺔﻬﺟ", "contact_support": "اتصل بالدعم", "continue_text": "التالي", "contract_warning": "تم وضع علامة على عنوان العقد هذا على أنه احتيالي محتمل. يرجى المعالجة بحذر.", @@ -296,6 +297,7 @@ "expiry_and_validity": "انتهاء الصلاحية والصلاحية", "export_backup": "تصدير نسخة احتياطية", "export_logs": "سجلات التصدير", + "export_outputs": "مخرجات التصدير", "extra_id": "معرف إضافي:", "extracted_address_content": "سوف ترسل الأموال إلى\n${recipient_name}", "failed_authentication": "${state_error} فشل المصادقة.", @@ -336,7 +338,9 @@ "haven_app": "Haven بواسطة Cake Wallet", "haven_app_wallet_text": "محفظة رائعة ل Haven", "help": "مساعده", + "hidden_addresses": "العناوين المخفية", "hidden_balance": "الميزان الخفي", + "hide": "يخفي", "hide_details": "أخف التفاصيل", "high_contrast_theme": "موضوع عالي التباين", "home_screen_settings": "إعدادات الشاشة الرئيسية", @@ -500,6 +504,7 @@ "pre_seed_title": "مهم", "prepaid_cards": "البطاقات المدفوعة مسبقا", "prevent_screenshots": "منع لقطات الشاشة وتسجيل الشاشة", + "primary_address": "العنوان الأساسي", "privacy": "خصوصية", "privacy_policy": "سياسة الخصوصية", "privacy_settings": "إعدادات الخصوصية", @@ -696,6 +701,7 @@ "share": "يشارك", "share_address": "شارك العنوان", "shared_seed_wallet_groups": "مجموعات محفظة البذور المشتركة", + "show": "يعرض", "show_details": "اظهر التفاصيل", "show_keys": "اظهار السييد / المفاتيح", "show_market_place": "إظهار السوق", @@ -942,6 +948,5 @@ "you_pay": "انت تدفع", "you_will_get": "حول الى", "you_will_send": "تحويل من", - "yy": "YY", - "contact_name_exists": " .ﻒﻠﺘﺨﻣ ﻢﺳﺍ ﺭﺎﻴﺘﺧﺍ ءﺎﺟﺮﻟﺍ .ﻞﻌﻔﻟﺎﺑ ﺓﺩﻮﺟﻮﻣ ﻢﺳﻻﺍ ﺍﺬﻬﺑ ﻝﺎﺼﺗﺍ ﺔﻬﺟ" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index 2fd644d01..e66a40b36 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -136,7 +136,7 @@ "clearnet_link": "Clearnet връзка", "close": "затвори", "coin_control": "Управление на монетите (не е задължително)", - "cold_or_recover_wallet": "Добавете студен портфейл или възстановете хартиен портфейл", + "cold_or_recover_wallet": "Добавете портфейл само за четене от Cupcake или студен портфейл или възстановете хартиен портфейл", "color_theme": "Цвят", "commit_transaction_amount_fee": "Изпълняване на транзакция\nСума: ${amount}\nТакса: ${fee}", "confirm": "Потвърждаване", @@ -161,6 +161,7 @@ "contact_list_contacts": "Контакти", "contact_list_wallets": "Моите портфейли", "contact_name": "Име на контакт", + "contact_name_exists": "Вече съществува контакт с това име. Моля, изберете друго име.", "contact_support": "Свържи се с отдел поддръжка", "continue_text": "Напред", "contract_warning": "Този адрес на договора е маркиран като потенциално измамник. Моля, обработете с повишено внимание.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Изтичане и валидност", "export_backup": "Експортиране на резервно копие", "export_logs": "Експортни дневници", + "export_outputs": "Експортни резултати", "extra_id": "Допълнително ID:", "extracted_address_content": "Ще изпратите средства на \n${recipient_name}", "failed_authentication": "Неуспешно удостоверяване. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven от Cake Wallet", "haven_app_wallet_text": "Невероятен портфейл за Haven", "help": "Помощ", + "hidden_addresses": "Скрити адреси", "hidden_balance": "Скрит баланс", + "hide": "Скрий", "hide_details": "Скриване на подробностите", "high_contrast_theme": "Тема с висок контраст", "home_screen_settings": "Настройки на началния екран", @@ -500,6 +504,7 @@ "pre_seed_title": "ВАЖНО", "prepaid_cards": "Предплатени карти", "prevent_screenshots": "Предотвратете екранни снимки и запис на екрана", + "primary_address": "Първичен адрес", "privacy": "Поверителност", "privacy_policy": "Политика за поверителността", "privacy_settings": "Настройки за поверителност", @@ -696,6 +701,7 @@ "share": "Дял", "share_address": "Сподели адрес", "shared_seed_wallet_groups": "Споделени групи за портфейли за семена", + "show": "Показване", "show_details": "Показване на подробностите", "show_keys": "Покажи seed/keys", "show_market_place": "Покажи пазар", @@ -942,6 +948,5 @@ "you_pay": "Вие плащате", "you_will_get": "Обръщане в", "you_will_send": "Обръщане от", - "yy": "гг", - "contact_name_exists": "Вече съществува контакт с това име. Моля, изберете друго име." -} + "yy": "гг" +} \ No newline at end of file diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index 8f3b5bf28..4911030b2 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -136,7 +136,7 @@ "clearnet_link": "Odkaz na Clearnet", "close": "zavřít", "coin_control": "Volba mincí (nepovinné)", - "cold_or_recover_wallet": "Přidejte studenou peněženku nebo obnovte papírovou peněženku", + "cold_or_recover_wallet": "Přidejte peněženku pouze pro čtení z Cupcake nebo studené peněženky nebo obnovte papírovou peněženku", "color_theme": "Barevný motiv", "commit_transaction_amount_fee": "Odeslat transakci\nČástka: ${amount}\nPoplatek: ${fee}", "confirm": "Potvrdit", @@ -161,6 +161,7 @@ "contact_list_contacts": "Kontakty", "contact_list_wallets": "Moje peněženky", "contact_name": "Jméno kontaktu", + "contact_name_exists": "Kontakt s tímto jménem již existuje. Vyberte prosím jiný název.", "contact_support": "Kontaktovat podporu", "continue_text": "Pokračovat", "contract_warning": "Tato adresa smlouvy byla označena jako potenciálně podvodná. Zpracovejte prosím opatrně.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Vypršení a platnost", "export_backup": "Exportovat zálohu", "export_logs": "Vývozní protokoly", + "export_outputs": "Vývozní výstupy", "extra_id": "Extra ID:", "extracted_address_content": "Prostředky budete posílat na\n${recipient_name}", "failed_authentication": "Ověřování selhalo. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven od Cake Wallet", "haven_app_wallet_text": "Úžasná peněženka pro Haven", "help": "pomoc", + "hidden_addresses": "Skryté adresy", "hidden_balance": "Skrytý zůstatek", + "hide": "Skrýt", "hide_details": "Skrýt detaily", "high_contrast_theme": "Téma s vysokým kontrastem", "home_screen_settings": "Nastavení domovské obrazovky", @@ -500,6 +504,7 @@ "pre_seed_title": "DŮLEŽITÉ", "prepaid_cards": "Předplacené karty", "prevent_screenshots": "Zabránit vytváření snímků obrazovky a nahrávání obrazovky", + "primary_address": "Primární adresa", "privacy": "Soukromí", "privacy_policy": "Zásady ochrany soukromí", "privacy_settings": "Nastavení soukromí", @@ -696,6 +701,7 @@ "share": "Podíl", "share_address": "Sdílet adresu", "shared_seed_wallet_groups": "Skupiny sdílených semen", + "show": "Show", "show_details": "Zobrazit detaily", "show_keys": "Zobrazit seed/klíče", "show_market_place": "Zobrazit trh", @@ -942,6 +948,5 @@ "you_pay": "Zaplatíte", "you_will_get": "Směnit na", "you_will_send": "Směnit z", - "yy": "YY", - "contact_name_exists": "Kontakt s tímto jménem již existuje. Vyberte prosím jiný název." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 77a531d1b..a69174dd6 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -136,7 +136,7 @@ "clearnet_link": "Clearnet-Link", "close": "Schließen", "coin_control": "Coin Control (optional)", - "cold_or_recover_wallet": "Fügen Sie eine Cold Wallet hinzu oder stellen Sie eine Paper Wallet wieder her", + "cold_or_recover_wallet": "Fügen Sie eine schreibgeschützte Brieftasche von Cupcake oder eine kalte Brieftasche hinzu oder erholen Sie sich eine Brieftasche", "color_theme": "Farbthema", "commit_transaction_amount_fee": "Transaktion absenden\nBetrag: ${amount}\nGebühr: ${fee}", "confirm": "Bestätigen", @@ -161,6 +161,7 @@ "contact_list_contacts": "Kontakte", "contact_list_wallets": "Meine Wallets", "contact_name": "Name des Kontakts", + "contact_name_exists": "Ein Kontakt mit diesem Namen besteht bereits. Bitte wählen Sie einen anderen Namen.", "contact_support": "Support kontaktieren", "continue_text": "Weiter", "contract_warning": "Diese Vertragsadresse wurde als potenziell betrügerisch gekennzeichnet. Bitte verarbeiten Sie mit Vorsicht.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Ablauf und Gültigkeit", "export_backup": "Sicherung exportieren", "export_logs": "Exportprotokolle", + "export_outputs": "Exportausgaben", "extra_id": "Extra ID:", "extracted_address_content": "Sie senden Geld an\n${recipient_name}", "failed_authentication": "Authentifizierung fehlgeschlagen. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven von Cake Wallet", "haven_app_wallet_text": "Eine großartige Wallet für Haven", "help": "hilfe", + "hidden_addresses": "Versteckte Adressen", "hidden_balance": "Verstecktes Guthaben", + "hide": "Verstecken", "hide_details": "Details ausblenden", "high_contrast_theme": "Kontrastreiches Thema", "home_screen_settings": "Einstellungen für den Startbildschirm", @@ -501,6 +505,7 @@ "pre_seed_title": "WICHTIG", "prepaid_cards": "Karten mit Guthaben", "prevent_screenshots": "Verhindern Sie Screenshots und Bildschirmaufzeichnungen", + "primary_address": "Primäradresse", "privacy": "Datenschutz", "privacy_policy": "Datenschutzrichtlinie", "privacy_settings": "Datenschutzeinstellungen", @@ -697,6 +702,7 @@ "share": "Teilen", "share_address": "Adresse teilen ", "shared_seed_wallet_groups": "Gemeinsame Walletsseed Gruppen", + "show": "Zeigen", "show_details": "Details anzeigen", "show_keys": "Seed/Schlüssel anzeigen", "show_market_place": "Marktplatz anzeigen", diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 4fe375ff9..322e38b85 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -136,7 +136,7 @@ "clearnet_link": "Clearnet link", "close": "Close", "coin_control": "Coin control (optional)", - "cold_or_recover_wallet": "Add a cold wallet or recover a paper wallet", + "cold_or_recover_wallet": "Add a read-only wallet from Cupcake or a cold wallet or recover a paper wallet", "color_theme": "Color theme", "commit_transaction_amount_fee": "Commit transaction\nAmount: ${amount}\nFee: ${fee}", "confirm": "Confirm", @@ -161,6 +161,7 @@ "contact_list_contacts": "Contacts", "contact_list_wallets": "My Wallets", "contact_name": "Contact Name", + "contact_name_exists": "A contact with that name already exists. Please choose a different name.", "contact_support": "Contact Support", "continue_text": "Continue", "contract_warning": "This contract address has been flagged as potentially fraudulent. Please process with caution.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Expiry and Validity", "export_backup": "Export backup", "export_logs": "Export logs", + "export_outputs": "Export outputs", "extra_id": "Extra ID:", "extracted_address_content": "You will be sending funds to\n${recipient_name}", "failed_authentication": "Failed authentication. ${state_error}", @@ -502,6 +504,7 @@ "pre_seed_title": "IMPORTANT", "prepaid_cards": "Prepaid Cards", "prevent_screenshots": "Prevent screenshots and screen recording", + "primary_address": "Primary Address", "privacy": "Privacy", "privacy_policy": "Privacy Policy", "privacy_settings": "Privacy settings", @@ -945,6 +948,5 @@ "you_pay": "You Pay", "you_will_get": "Convert to", "you_will_send": "Convert from", - "yy": "YY", - "contact_name_exists": "A contact with that name already exists. Please choose a different name." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 31498df2d..5a8ff0582 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -136,7 +136,7 @@ "clearnet_link": "enlace Clearnet", "close": "Cerca", "coin_control": "Control de monedas (opcional)", - "cold_or_recover_wallet": "Agrega una billetera fría o recupera una billetera de papel", + "cold_or_recover_wallet": "Agregue una billetera de solo lectura de Cupcake o una billetera en frío o recupere una billetera de papel", "color_theme": "Tema de color", "commit_transaction_amount_fee": "Confirmar transacción\nCantidad: ${amount}\nCuota: ${fee}", "confirm": "Confirmar", @@ -161,6 +161,7 @@ "contact_list_contacts": "Contactos", "contact_list_wallets": "Mis billeteras", "contact_name": "Nombre de contacto", + "contact_name_exists": "Ya existe un contacto con ese nombre. Elija un nombre diferente.", "contact_support": "Contactar con Soporte", "continue_text": "Continuar", "contract_warning": "Esta dirección de contrato ha sido marcada como potencialmente fraudulenta. Por favor, procese con precaución.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Vencimiento y validez", "export_backup": "Exportar copia de seguridad", "export_logs": "Registros de exportación", + "export_outputs": "Exportaciones de exportación", "extra_id": "ID adicional:", "extracted_address_content": "Enviará fondos a\n${recipient_name}", "failed_authentication": "Autenticación fallida. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Increíble billetera para Haven", "help": "ayuda", + "hidden_addresses": "Direcciones ocultas", "hidden_balance": "Balance oculto", + "hide": "Esconder", "hide_details": "Ocultar detalles", "high_contrast_theme": "Tema de alto contraste", "home_screen_settings": "Configuración de la pantalla de inicio", @@ -501,6 +505,7 @@ "pre_seed_title": "IMPORTANTE", "prepaid_cards": "Tajetas prepagadas", "prevent_screenshots": "Evitar capturas de pantalla y grabación de pantalla", + "primary_address": "Dirección principal", "privacy": "Privacidad", "privacy_policy": "Política de privacidad", "privacy_settings": "Configuración de privacidad", @@ -697,6 +702,7 @@ "share": "Compartir", "share_address": "Compartir dirección", "shared_seed_wallet_groups": "Grupos de billetera de semillas compartidas", + "show": "Espectáculo", "show_details": "Mostrar detalles", "show_keys": "Mostrar semilla/claves", "show_market_place": "Mostrar mercado", @@ -943,6 +949,5 @@ "you_pay": "Tú pagas", "you_will_get": "Convertir a", "you_will_send": "Convertir de", - "yy": "YY", - "contact_name_exists": "Ya existe un contacto con ese nombre. Elija un nombre diferente." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index f96e9e304..6abe72681 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -136,7 +136,7 @@ "clearnet_link": "Lien Clearnet", "close": "Fermer", "coin_control": "Contrôle optionnel des pièces (coins)", - "cold_or_recover_wallet": "Ajoutez un portefeuille froid (cold wallet) ou récupérez un portefeuille papier (paper wallet)", + "cold_or_recover_wallet": "Ajoutez un portefeuille en lecture seule de Cupcake ou d'un portefeuille froid ou récupérez un portefeuille en papier", "color_theme": "Thème", "commit_transaction_amount_fee": "Valider la transaction\nMontant : ${amount}\nFrais : ${fee}", "confirm": "Confirmer", @@ -161,6 +161,7 @@ "contact_list_contacts": "Contacts", "contact_list_wallets": "Mes portefeuilles (wallets)", "contact_name": "Nom de Contact", + "contact_name_exists": "Un contact portant ce nom existe déjà. Veuillez choisir un autre nom.", "contact_support": "Contacter l'assistance", "continue_text": "Continuer", "contract_warning": "Cette adresse contractuelle a été signalée comme potentiellement frauduleuse. Veuillez traiter avec prudence.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Expiration et validité", "export_backup": "Exporter la sauvegarde", "export_logs": "Journaux d'exportation", + "export_outputs": "Exportation des sorties", "extra_id": "ID supplémentaire :", "extracted_address_content": "Vous allez envoyer des fonds à\n${recipient_name}", "failed_authentication": "Échec d'authentification. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven par Cake Wallet", "haven_app_wallet_text": "Super portefeuille (wallet) pour Haven", "help": "aide", + "hidden_addresses": "Adresses cachées", "hidden_balance": "Solde Caché", + "hide": "Cacher", "hide_details": "Masquer les détails", "high_contrast_theme": "Thème à contraste élevé", "home_screen_settings": "Paramètres de l'écran d'accueil", @@ -500,6 +504,7 @@ "pre_seed_title": "IMPORTANT", "prepaid_cards": "Cartes prépayées", "prevent_screenshots": "Empêcher les captures d'écran et l'enregistrement d'écran", + "primary_address": "Adresse primaire", "privacy": "Confidentialité", "privacy_policy": "Politique de confidentialité", "privacy_settings": "Paramètres de confidentialité", @@ -696,6 +701,7 @@ "share": "Partager", "share_address": "Partager l'adresse", "shared_seed_wallet_groups": "Groupes de portefeuilles partagés", + "show": "Montrer", "show_details": "Afficher les détails", "show_keys": "Visualiser la phrase secrète (seed) et les clefs", "show_market_place": "Afficher la place de marché", @@ -942,6 +948,5 @@ "you_pay": "Vous payez", "you_will_get": "Convertir vers", "you_will_send": "Convertir depuis", - "yy": "AA", - "contact_name_exists": "Un contact portant ce nom existe déjà. Veuillez choisir un autre nom." -} + "yy": "AA" +} \ No newline at end of file diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index 5fc64e0b3..5a53d6795 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -136,7 +136,7 @@ "clearnet_link": "Lambar makomar kwayoyi", "close": "Rufa", "coin_control": "Sarrafa tsabar kuɗi (na zaɓi)", - "cold_or_recover_wallet": "Samun kashi na baya ko samun kashi na kasa", + "cold_or_recover_wallet": "Aara wani walat mai karanta-kawai Cupcake ko walat ɗin mai sanyi ko murmurewa takarda takarda", "color_theme": "Jigon launi", "commit_transaction_amount_fee": "Aikata ciniki\nAdadi: ${amount}\nKuda: ${fee}", "confirm": "Tabbatar", @@ -161,6 +161,7 @@ "contact_list_contacts": "Lambobin sadarwa", "contact_list_wallets": "Wallets dina", "contact_name": "Sunan Tuntuɓi", + "contact_name_exists": "An riga an sami lamba tare da wannan sunan. Da fatan za a zaɓi suna daban.", "contact_support": "Tuntuɓi Support", "continue_text": "Ci gaba", "contract_warning": "An kafa wannan adireshin kwantaragin kwangilar yayin da yuwuwar zamba. Da fatan za a aiwatar da taka tsantsan.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Karewa da inganci", "export_backup": "Ajiyayyen fitarwa", "export_logs": "Injin fitarwa", + "export_outputs": "Fitarwar fitarwa", "extra_id": "Karin ID:", "extracted_address_content": "Za ku aika da kudade zuwa\n${recipient_name}", "failed_authentication": "Binne wajen shiga. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven da Cake Wallet", "haven_app_wallet_text": "Aikace-aikacen e-wallet ga Haven", "help": "taimako", + "hidden_addresses": "Adireshin ɓoye", "hidden_balance": "BOYE KUDI", + "hide": "Ɓoye", "hide_details": "Ɓoye cikakkun bayanai", "high_contrast_theme": "Babban Jigon Kwatance", "home_screen_settings": "Saitunan allo na gida", @@ -502,6 +506,7 @@ "pre_seed_title": "MUHIMMANCI", "prepaid_cards": "Katunan shirye-shirye", "prevent_screenshots": "Fada lambobi da jarrabobi na kayan lambobi", + "primary_address": "Adireshin farko", "privacy": "Keɓantawa", "privacy_policy": "takardar kebantawa", "privacy_settings": "Saitunan sirri", @@ -698,6 +703,7 @@ "share": "Raba", "share_address": "Raba adireshin", "shared_seed_wallet_groups": "Raba ƙungiya walat", + "show": "Nuna", "show_details": "Nuna Cikakkun bayanai", "show_keys": "Nuna iri/maɓallai", "show_market_place": "Nuna dan kasuwa", @@ -944,6 +950,5 @@ "you_pay": "Ka Bayar", "you_will_get": "Maida zuwa", "you_will_send": "Maida daga", - "yy": "YY", - "contact_name_exists": "An riga an sami lamba tare da wannan sunan. Da fatan za a zaɓi suna daban." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index fb3b78900..d130c2b5f 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -136,7 +136,7 @@ "clearnet_link": "क्लियरनेट लिंक", "close": "बंद करना", "coin_control": "सिक्का नियंत्रण (वैकल्पिक)", - "cold_or_recover_wallet": "कोल्ड वॉलेट जोड़ें या पेपर वॉलेट पुनर्प्राप्त करें", + "cold_or_recover_wallet": "Cupcake या एक कोल्ड वॉलेट से एक रीड-ओनली वॉलेट जोड़ें या एक पेपर वॉलेट को पुनर्प्राप्त करें", "color_theme": "रंग विषय", "commit_transaction_amount_fee": "लेन-देन करें\nरकम: ${amount}\nशुल्क: ${fee}", "confirm": "की पुष्टि करें", @@ -161,6 +161,7 @@ "contact_list_contacts": "संपर्क", "contact_list_wallets": "मेरा बटुआ", "contact_name": "संपर्क नाम", + "contact_name_exists": "उस नाम का एक संपर्क पहले से मौजूद है. कृपया कोई भिन्न नाम चुनें.", "contact_support": "सहायता से संपर्क करें", "continue_text": "जारी रहना", "contract_warning": "इस अनुबंध के पते को संभावित रूप से धोखाधड़ी के रूप में चिह्नित किया गया है। कृपया सावधानी के साथ प्रक्रिया करें।", @@ -296,6 +297,7 @@ "expiry_and_validity": "समाप्ति और वैधता", "export_backup": "निर्यात बैकअप", "export_logs": "निर्यात लॉग", + "export_outputs": "निर्यात आउटपुट", "extra_id": "अतिरिक्त आईडी:", "extracted_address_content": "आपको धनराशि भेजी जाएगी\n${recipient_name}", "failed_authentication": "प्रमाणीकरण विफल. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "मदद करना", + "hidden_addresses": "छिपे हुए पते", "hidden_balance": "छिपा हुआ संतुलन", + "hide": "छिपाना", "hide_details": "विवरण छुपाएं", "high_contrast_theme": "उच्च कंट्रास्ट थीम", "home_screen_settings": "होम स्क्रीन सेटिंग्स", @@ -501,6 +505,7 @@ "pre_seed_title": "महत्वपूर्ण", "prepaid_cards": "पूर्वदत्त कार्ड", "prevent_screenshots": "स्क्रीनशॉट और स्क्रीन रिकॉर्डिंग रोकें", + "primary_address": "प्राथमिक पता", "privacy": "गोपनीयता", "privacy_policy": "गोपनीयता नीति", "privacy_settings": "गोपनीयता सेटिंग्स", @@ -698,6 +703,7 @@ "share": "शेयर करना", "share_address": "पता साझा करें", "shared_seed_wallet_groups": "साझा बीज बटुए समूह", + "show": "दिखाओ", "show_details": "विवरण दिखाएं", "show_keys": "बीज / कुंजियाँ दिखाएँ", "show_market_place": "बाज़ार दिखाएँ", @@ -944,6 +950,5 @@ "you_pay": "आप भुगतान करते हैं", "you_will_get": "में बदलें", "you_will_send": "से रूपांतरित करें", - "yy": "वाईवाई", - "contact_name_exists": "उस नाम का एक संपर्क पहले से मौजूद है. कृपया कोई भिन्न नाम चुनें." -} + "yy": "वाईवाई" +} \ No newline at end of file diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index 230aa955b..037e6e9f2 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -136,7 +136,7 @@ "clearnet_link": "Clearnet veza", "close": "Zatvoriti", "coin_control": "Kontrola novca (nije obavezno)", - "cold_or_recover_wallet": "Dodajte hladni novčanik ili povratite papirnati novčanik", + "cold_or_recover_wallet": "Dodajte novčanik samo za čitanje od Cupcake ili hladnog novčanika ili oporavite papirni novčanik", "color_theme": "Shema boja", "commit_transaction_amount_fee": "Izvrši transakciju \nAmount: ${amount}\nFee: ${fee}", "confirm": "Potvrdi", @@ -161,6 +161,7 @@ "contact_list_contacts": "Kontakti", "contact_list_wallets": "Moji novčanici", "contact_name": "Ime kontakta", + "contact_name_exists": "Kontakt s tim imenom već postoji. Odaberite drugo ime.", "contact_support": "Kontaktirajte podršku", "continue_text": "Nastavak", "contract_warning": "Ova adresa ugovora označena je kao potencijalno lažna. Molimo obradite s oprezom.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Istek i valjanost", "export_backup": "Izvezi sigurnosnu kopiju", "export_logs": "Izvozni trupci", + "export_outputs": "Izvoz izlaza", "extra_id": "Dodatni ID:", "extracted_address_content": "Poslat ćete sredstva primatelju\n${recipient_name}", "failed_authentication": "Autentifikacija neuspješna. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "pomozite", + "hidden_addresses": "Skrivene adrese", "hidden_balance": "Skriven iznos", + "hide": "Sakriti", "hide_details": "Sakrij pojedinosti", "high_contrast_theme": "Tema visokog kontrasta", "home_screen_settings": "Postavke početnog zaslona", @@ -500,6 +504,7 @@ "pre_seed_title": "VAŽNO", "prepaid_cards": "Unaprijed plaćene kartice", "prevent_screenshots": "Spriječite snimke zaslona i snimanje zaslona", + "primary_address": "Primarna adresa", "privacy": "Privatnost", "privacy_policy": "Pravila privatnosti", "privacy_settings": "Postavke privatnosti", @@ -696,6 +701,7 @@ "share": "Udio", "share_address": "Podijeli adresu", "shared_seed_wallet_groups": "Zajedničke grupe za sjeme novčanika", + "show": "Pokazati", "show_details": "Prikaži pojedinosti", "show_keys": "Prikaži pristupni izraz/ključ", "show_market_place": "Prikaži tržište", @@ -942,6 +948,5 @@ "you_pay": "Vi plaćate", "you_will_get": "Razmijeni u", "you_will_send": "Razmijeni iz", - "yy": "GG", - "contact_name_exists": "Kontakt s tim imenom već postoji. Odaberite drugo ime." -} + "yy": "GG" +} \ No newline at end of file diff --git a/res/values/strings_hy.arb b/res/values/strings_hy.arb index 63a986103..04a7cddf5 100644 --- a/res/values/strings_hy.arb +++ b/res/values/strings_hy.arb @@ -136,7 +136,7 @@ "clearnet_link": "Բաց ցանցի հղում", "close": "Փակել", "coin_control": "Մետաղադրամի վերահսկում (ըստ ցանկության)", - "cold_or_recover_wallet": "Ավելացնել սառը դրամապանակ կամ վերականգնել թղթային դրամապանակ", + "cold_or_recover_wallet": "Cupcake կամ ցուրտ դրամապանակից ավելացնել միայն ընթերցված դրամապանակ կամ վերականգնել թղթի դրամապանակը", "color_theme": "Գույների տեսք", "commit_transaction_amount_fee": "Հաստատել գործարքը\nՍկզբնական գումար. ${amount}\nՄիջնորդավճար. ${fee}", "confirm": "Հաստատել", @@ -161,6 +161,7 @@ "contact_list_contacts": "Կոնտակտներ", "contact_list_wallets": "Իմ դրամապանակներ", "contact_name": "Կոնտակտի անուն", + "contact_name_exists": "Այդ անվանման հետ կապ կա արդեն: Խնդրում ենք ընտրել այլ անուն:", "contact_support": "Հետադարձ կապ", "continue_text": "Շարունակել", "contract_warning": "Պայմանագրի այս հասցեն դրոշմել է որպես հնարավոր կեղծ: Խնդրում ենք զգուշությամբ մշակել:", @@ -296,6 +297,7 @@ "expiry_and_validity": "Վավերականություն և լրացում", "export_backup": "Արտահանել կրկնօրինակը", "export_logs": "Արտահանման տեղեկամատյաններ", + "export_outputs": "Արտահանման արդյունքներ", "extra_id": "Լրացուցիչ ID", "extracted_address_content": "Դուք կուղարկեք գումար ${recipient_name}", "failed_authentication": "Վավերացումը ձախողվեց. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven ծրագիր", "haven_app_wallet_text": "Հիանալի հաշվեհամար Haven համար", "help": "Օգնություն", + "hidden_addresses": "Թաքնված հասցեներ", "hidden_balance": "Թաքնված մնացորդ", + "hide": "Թաքցնել", "hide_details": "Թաքցնել մանրամասները", "high_contrast_theme": "Բարձր հակադրության տեսք", "home_screen_settings": "Գլխավոր էկրանի կարգավորումներ", @@ -366,14 +370,22 @@ "ledger_error_wrong_app": "Խնդրում ենք համոզվել, որ դուք բացել եք ճիշտ ծրագիրը ձեր Ledger-ում", "ledger_please_enable_bluetooth": "Խնդրում ենք միացնել Bluetooth-ը ձեր Ledger-ը հայտնաբերելու համար", "light_theme": "Լուսավոր", + "litecoin_enable_mweb_sync": "Միացնել MWEB սկան", + "litecoin_mweb": "Մուեբ", + "litecoin_mweb_always_scan": "Սահմանեք Mweb Միշտ սկանավորում", "litecoin_mweb_description": "Mweb- ը նոր արձանագրություն է, որը բերում է ավելի արագ, ավելի էժան եւ ավելի մասնավոր գործարքներ դեպի LITECOIN", "litecoin_mweb_dismiss": "Հեռացնել", + "litecoin_mweb_display_card": "Show ույց տալ Mweb քարտը", "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": "Հափշտակել", + "litecoin_mweb_scanning": "Mweb սկանավորում", + "litecoin_mweb_settings": "Mweb- ի պարամետրերը", + "litecoin_mweb_warning": "Mweb- ի օգտագործումը սկզբում ներբեռնվի 600 ՄԲ տվյալներ եւ կարող է տեւել 30 րոպե, կախված ցանցի արագությունից: Այս նախնական տվյալները միայն մեկ անգամ ներբեռնելու են եւ հասանելի կլինեն բոլոր Litecoin դրամապանակների համար", + "litecoin_what_is_mweb": "Ինչ է Mweb- ը:", "live_fee_rates": "Ապակի վարձավճարներ API- ի միջոցով", "load_more": "Բեռնել ավելին", "loading_your_wallet": "Ձեր հաշվեհամարը բեռնում է", @@ -492,6 +504,7 @@ "pre_seed_title": "ԿԱՐԵՎՈՐ", "prepaid_cards": "Նախավճարային քարտեր", "prevent_screenshots": "Կանխել էկրանի պատկերները և տեսագրությունը", + "primary_address": "Առաջնային հասցե", "privacy": "Գաղտնիություն", "privacy_policy": "Գաղտնիության քաղաքականություն", "privacy_settings": "Գաղտնիության կարգավորումներ", @@ -688,6 +701,7 @@ "share": "Կիսվել", "share_address": "Կիսվել հասցեով", "shared_seed_wallet_groups": "Համօգտագործված սերմերի դրամապանակների խմբեր", + "show": "Ցուցահանդես", "show_details": "Ցուցադրել մանրամասներ", "show_keys": "Ցուցադրել բանալիներ", "show_market_place": "Ցուցադրել շուկան", diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index 3fa3a958f..231ac037d 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -136,7 +136,7 @@ "clearnet_link": "Tautan clearnet", "close": "Menutup", "coin_control": "Kontrol koin (opsional)", - "cold_or_recover_wallet": "Tambahkan dompet dingin atau pulihkan dompet kertas", + "cold_or_recover_wallet": "Tambahkan dompet hanya baca dari Cupcake atau dompet dingin atau memulihkan dompet kertas", "color_theme": "Tema warna", "commit_transaction_amount_fee": "Lakukan transaksi\nJumlah: ${amount}\nBiaya: ${fee}", "confirm": "Konfirmasi", @@ -161,6 +161,7 @@ "contact_list_contacts": "Kontak", "contact_list_wallets": "Dompet Saya", "contact_name": "Nama Kontak", + "contact_name_exists": "Kontak dengan nama tersebut sudah ada. Silakan pilih nama lain.", "contact_support": "Hubungi Dukungan", "continue_text": "Lanjutkan", "contract_warning": "Alamat kontrak ini telah ditandai sebagai berpotensi curang. Silakan memproses dengan hati -hati.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Kedaluwarsa dan validitas", "export_backup": "Ekspor cadangan", "export_logs": "Log ekspor", + "export_outputs": "Ekspor output", "extra_id": "ID tambahan:", "extracted_address_content": "Anda akan mengirim dana ke\n${recipient_name}", "failed_authentication": "Otentikasi gagal. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven Oleh Cake Wallet", "haven_app_wallet_text": "Dompet luar biasa untuk Haven", "help": "bantuan", + "hidden_addresses": "Alamat tersembunyi", "hidden_balance": "Saldo Tersembunyi", + "hide": "Bersembunyi", "hide_details": "Sembunyikan Rincian", "high_contrast_theme": "Tema Kontras Tinggi", "home_screen_settings": "Pengaturan layar awal", @@ -502,6 +506,7 @@ "pre_seed_title": "PENTING", "prepaid_cards": "Kartu prabayar", "prevent_screenshots": "Cegah tangkapan layar dan perekaman layar", + "primary_address": "Alamat utama", "privacy": "Privasi", "privacy_policy": "Kebijakan Privasi", "privacy_settings": "Pengaturan privasi", @@ -699,6 +704,7 @@ "share": "Membagikan", "share_address": "Bagikan alamat", "shared_seed_wallet_groups": "Kelompok dompet benih bersama", + "show": "Menunjukkan", "show_details": "Tampilkan Rincian", "show_keys": "Tampilkan seed/kunci", "show_market_place": "Tampilkan Pasar", @@ -945,6 +951,5 @@ "you_pay": "Anda Membayar", "you_will_get": "Konversi ke", "you_will_send": "Konversi dari", - "yy": "YY", - "contact_name_exists": "Kontak dengan nama tersebut sudah ada. Silakan pilih nama lain." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index aab18d434..b06a35537 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -136,7 +136,7 @@ "clearnet_link": "Collegamento Clearnet", "close": "Chiudere", "coin_control": "Controllo monete (opzionale)", - "cold_or_recover_wallet": "Aggiungi un cold wallet o recupera un paper wallet", + "cold_or_recover_wallet": "Aggiungi un portafoglio di sola lettura da Cupcake o un portafoglio freddo o recupera un portafoglio di carta", "color_theme": "Colore tema", "commit_transaction_amount_fee": "Invia transazione\nAmmontare: ${amount}\nCommissione: ${fee}", "confirm": "Conferma", @@ -162,6 +162,7 @@ "contact_list_contacts": "Contatti", "contact_list_wallets": "I miei portafogli", "contact_name": "Nome Contatto", + "contact_name_exists": "Esiste già un contatto con quel nome. Scegli un nome diverso.", "contact_support": "Contatta l'assistenza", "continue_text": "Continua", "contract_warning": "Questo indirizzo del contratto è stato contrassegnato come potenzialmente fraudolento. Si prega di elaborare con cautela.", @@ -297,6 +298,7 @@ "expiry_and_validity": "Scadenza e validità", "export_backup": "Esporta backup", "export_logs": "Registri di esportazione", + "export_outputs": "Output di esportazione", "extra_id": "Extra ID:", "extracted_address_content": "Invierai i tuoi fondi a\n${recipient_name}", "failed_authentication": "Autenticazione fallita. ${state_error}", @@ -337,7 +339,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Portafoglio fantastico per Haven", "help": "aiuto", + "hidden_addresses": "Indirizzi nascosti", "hidden_balance": "Saldo Nascosto", + "hide": "Nascondere", "hide_details": "Nascondi dettagli", "high_contrast_theme": "Tema ad alto contrasto", "home_screen_settings": "Impostazioni della schermata iniziale", @@ -502,6 +506,7 @@ "pre_seed_title": "IMPORTANTE", "prepaid_cards": "Carte prepagata", "prevent_screenshots": "Impedisci screenshot e registrazione dello schermo", + "primary_address": "Indirizzo primario", "privacy": "Privacy", "privacy_policy": "Informativa sulla privacy", "privacy_settings": "Impostazioni privacy", @@ -698,6 +703,7 @@ "share": "Condividere", "share_address": "Condividi indirizzo", "shared_seed_wallet_groups": "Gruppi di portafoglio di semi condivisi", + "show": "Spettacolo", "show_details": "Mostra dettagli", "show_keys": "Mostra seme/chiavi", "show_market_place": "Mostra mercato", @@ -945,6 +951,5 @@ "you_pay": "Tu paghi", "you_will_get": "Converti a", "you_will_send": "Conveti da", - "yy": "YY", - "contact_name_exists": "Esiste già un contatto con quel nome. Scegli un nome diverso." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index f1fd0acd3..d55c4d5a1 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -136,7 +136,7 @@ "clearnet_link": "クリアネット リンク", "close": "近い", "coin_control": "コインコントロール(オプション)", - "cold_or_recover_wallet": "コールド ウォレットを追加するか、ペーパー ウォレットを復元する", + "cold_or_recover_wallet": "Cupcakeまたはコールドウォレットから読み取り専用ウォレットを追加するか、紙の財布を回収する", "color_theme": "カラーテーマ", "commit_transaction_amount_fee": "トランザクションをコミット\n量: ${amount}\n費用: ${fee}", "confirm": "確認する", @@ -161,6 +161,7 @@ "contact_list_contacts": "連絡先", "contact_list_wallets": "マイウォレット", "contact_name": "連絡先", + "contact_name_exists": "その名前の連絡先はすでに存在します。別の名前を選択してください。", "contact_support": "サポートに連絡する", "continue_text": "持続する", "contract_warning": "この契約住所は、潜在的に不正としてフラグが立てられています。注意して処理してください。", @@ -296,6 +297,7 @@ "expiry_and_validity": "有効期限と有効性", "export_backup": "バックアップのエクスポート", "export_logs": "ログをエクスポートします", + "export_outputs": "エクスポート出力", "extra_id": "追加ID:", "extracted_address_content": "に送金します\n${recipient_name}", "failed_authentication": "認証失敗. ${state_error}", @@ -337,7 +339,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "ヘルプ", + "hidden_addresses": "隠されたアドレス", "hidden_balance": "隠れたバランス", + "hide": "隠れる", "hide_details": "詳細を非表示", "high_contrast_theme": "ハイコントラストテーマ", "home_screen_settings": "ホーム画面の設定", @@ -501,6 +505,7 @@ "pre_seed_title": "重要", "prepaid_cards": "プリペイドカード", "prevent_screenshots": "スクリーンショットと画面録画を防止する", + "primary_address": "主なアドレス", "privacy": "プライバシー", "privacy_policy": "プライバシーポリシー", "privacy_settings": "プライバシー設定", @@ -697,6 +702,7 @@ "share": "共有", "share_address": "住所を共有する", "shared_seed_wallet_groups": "共有シードウォレットグループ", + "show": "見せる", "show_details": "詳細を表示", "show_keys": "シード/キーを表示する", "show_market_place": "マーケットプレイスを表示", @@ -943,6 +949,5 @@ "you_pay": "あなたが支払う", "you_will_get": "に変換", "you_will_send": "から変換", - "yy": "YY", - "contact_name_exists": "その名前の連絡先はすでに存在します。別の名前を選択してください。" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index 7e3467cf2..303527fea 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -136,7 +136,7 @@ "clearnet_link": "클리어넷 링크", "close": "닫다", "coin_control": "코인 제어 (옵션)", - "cold_or_recover_wallet": "콜드 지갑 추가 또는 종이 지갑 복구", + "cold_or_recover_wallet": "Cupcake 또는 차가운 지갑에서 읽기 전용 지갑을 추가하거나 종이 지갑을 복구하십시오.", "color_theme": "색상 테마", "commit_transaction_amount_fee": "커밋 거래\n양: ${amount}\n보수: ${fee}", "confirm": "확인", @@ -161,6 +161,7 @@ "contact_list_contacts": "콘택트 렌즈", "contact_list_wallets": "내 지갑", "contact_name": "담당자 이름", + "contact_name_exists": "해당 이름을 가진 연락처가 이미 존재합니다. 다른 이름을 선택하세요.", "contact_support": "지원팀에 문의", "continue_text": "잇다", "contract_warning": "이 계약 주소는 잠재적으로 사기성으로 표시되었습니다. 주의해서 처리하십시오.", @@ -296,6 +297,7 @@ "expiry_and_validity": "만료와 타당성", "export_backup": "백업 내보내기", "export_logs": "내보내기 로그", + "export_outputs": "내보내기 출력", "extra_id": "추가 ID:", "extracted_address_content": "당신은에 자금을 보낼 것입니다\n${recipient_name}", "failed_authentication": "인증 실패. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "돕다", + "hidden_addresses": "숨겨진 주소", "hidden_balance": "숨겨진 균형", + "hide": "숨다", "hide_details": "세부 정보 숨기기", "high_contrast_theme": "고대비 테마", "home_screen_settings": "홈 화면 설정", @@ -501,6 +505,7 @@ "pre_seed_title": "중대한", "prepaid_cards": "선불 카드", "prevent_screenshots": "스크린샷 및 화면 녹화 방지", + "primary_address": "기본 주소", "privacy": "프라이버시", "privacy_policy": "개인 정보 보호 정책", "privacy_settings": "개인정보 설정", @@ -697,6 +702,7 @@ "share": "공유하다", "share_address": "주소 공유", "shared_seed_wallet_groups": "공유 종자 지갑 그룹", + "show": "보여주다", "show_details": "세부정보 표시", "show_keys": "시드 / 키 표시", "show_market_place": "마켓플레이스 표시", @@ -944,6 +950,5 @@ "you_will_get": "로 변환하다", "you_will_send": "다음에서 변환", "YY": "YY", - "yy": "YY", - "contact_name_exists": "해당 이름을 가진 연락처가 이미 존재합니다. 다른 이름을 선택하세요." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 10c13cfef..9b9b44657 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -136,7 +136,7 @@ "clearnet_link": "Clearnet လင့်ခ်", "close": "အနီးကပ်", "coin_control": "အကြွေစေ့ထိန်းချုပ်မှု (ချန်လှပ်ထားနိုင်သည်)", - "cold_or_recover_wallet": "အေးသောပိုက်ဆံအိတ်ထည့်ပါ သို့မဟုတ် စက္ကူပိုက်ဆံအိတ်ကို ပြန်ယူပါ။", + "cold_or_recover_wallet": "Cupcake သို့မဟုတ်အအေးပိုက်ဆံအိတ်မှဖတ်ရန်သာပိုက်ဆံအိတ်တစ်ခုထည့်ပါသို့မဟုတ်စက္ကူပိုက်ဆံအိတ်ကိုပြန်လည်ရယူပါ", "color_theme": "အရောင်အပြင်အဆင်", "commit_transaction_amount_fee": "ငွေလွှဲခြင်း\nပမာဏ- ${amount}\nအခကြေးငွေ- ${fee}", "confirm": "အတည်ပြုပါ။", @@ -161,6 +161,7 @@ "contact_list_contacts": "အဆက်အသွယ်များ", "contact_list_wallets": "ကျွန်ုပ်၏ ပိုက်ဆံအိတ်များ", "contact_name": "ဆက်သွယ်ရန်အမည်", + "contact_name_exists": "ထိုအမည်နှင့် အဆက်အသွယ်တစ်ခု ရှိနှင့်ပြီးဖြစ်သည်။ အခြားအမည်တစ်ခုကို ရွေးပါ။", "contact_support": "ပံ့ပိုးကူညီမှုထံ ဆက်သွယ်ပါ။", "continue_text": "ဆက်လက်", "contract_warning": "ဒီစာချုပ်လိပ်စာအလားအလာအလားအလာအလားအလာအလံများကိုအလံလွှင့်တင်ခဲ့သည်။ ကျေးဇူးပြုပြီးသတိဖြင့်လုပ်ငန်းစဉ်။", @@ -296,6 +297,7 @@ "expiry_and_validity": "သက်တမ်းကုန်ဆုံးခြင်းနှင့်တရားဝင်မှု", "export_backup": "အရန်ကူးထုတ်ရန်", "export_logs": "ပို့ကုန်မှတ်တမ်းများ", + "export_outputs": "ပို့ကုန်ထုတ်ကုန်များ", "extra_id": "အပို ID-", "extracted_address_content": "သင်သည် \n${recipient_name} သို့ ရန်ပုံငွေများ ပေးပို့ပါမည်", "failed_authentication": "အထောက်အထားစိစစ်ခြင်း မအောင်မြင်ပါ။. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "ဟေးဗင် ကိတ် ဝေါလက်", "haven_app_wallet_text": "ဟေဗင်အတွက် အံ့ဩစရာကောင်းတဲ့ ပိုက်ဆံအုံး", "help": "ကူညီပါ", + "hidden_addresses": "လျှို့ဝှက်လိပ်စာများ", "hidden_balance": "Hidden Balance", + "hide": "သားရေ", "hide_details": "အသေးစိတ်ကို ဝှက်ပါ။", "high_contrast_theme": "အလင်းအမှောင် မြင့်မားသော အပြင်အဆင်", "home_screen_settings": "ပင်မစခရင် ဆက်တင်များ", @@ -500,6 +504,7 @@ "pre_seed_title": "အရေးကြီးသည်။", "prepaid_cards": "ကြိုတင်ငွေဖြည့်ကဒ်များ", "prevent_screenshots": "ဖန်သားပြင်ဓာတ်ပုံများနှင့် မျက်နှာပြင်ရိုက်ကူးခြင်းကို တားဆီးပါ။", + "primary_address": "အဓိကလိပ်စာ", "privacy": "ကိုယ်ရေးကိုယ်တာ", "privacy_policy": "ကိုယ်ရေးအချက်အလက်မူဝါဒ", "privacy_settings": "Privacy settings တွေကို", @@ -696,6 +701,7 @@ "share": "မျှဝေပါ။", "share_address": "လိပ်စာမျှဝေပါ။", "shared_seed_wallet_groups": "shared မျိုးစေ့ပိုက်ဆံအိတ်အုပ်စုများ", + "show": "ပြသ", "show_details": "အသေးစိတ်ပြ", "show_keys": "မျိုးစေ့ /သော့များကို ပြပါ။", "show_market_place": "စျေးကွက်ကိုပြသပါ။", @@ -942,6 +948,5 @@ "you_pay": "သင်ပေးချေပါ။", "you_will_get": "သို့ပြောင်းပါ။", "you_will_send": "မှပြောင်းပါ။", - "yy": "YY", - "contact_name_exists": "ထိုအမည်နှင့် အဆက်အသွယ်တစ်ခု ရှိနှင့်ပြီးဖြစ်သည်။ အခြားအမည်တစ်ခုကို ရွေးပါ။" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index 9552f2439..657450f61 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -136,7 +136,7 @@ "clearnet_link": "Clearnet-link", "close": "Dichtbij", "coin_control": "Muntcontrole (optioneel)", - "cold_or_recover_wallet": "Voeg een cold wallet toe of herstel een paper wallet", + "cold_or_recover_wallet": "Voeg een alleen-lezen portemonnee toe van Cupcake of een koude portemonnee of herstel een papieren portemonnee", "color_theme": "Kleur thema", "commit_transaction_amount_fee": "Verricht transactie\nBedrag: ${amount}\nhonorarium: ${fee}", "confirm": "Bevestigen", @@ -161,6 +161,7 @@ "contact_list_contacts": "Contacten", "contact_list_wallets": "Mijn portefeuilles", "contact_name": "Contactnaam", + "contact_name_exists": "Er bestaat al een contact met die naam. Kies een andere naam.", "contact_support": "Contact opnemen met ondersteuning", "continue_text": "Doorgaan met", "contract_warning": "Dit contractadres is gemarkeerd als mogelijk frauduleus. Verwerk met voorzichtigheid.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Vervallen en geldigheid", "export_backup": "Back-up exporteren", "export_logs": "Exporteer logboeken", + "export_outputs": "Exportuitgangen exporteren", "extra_id": "Extra ID:", "extracted_address_content": "U stuurt geld naar\n${recipient_name}", "failed_authentication": "Mislukte authenticatie. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "helpen", + "hidden_addresses": "Verborgen adressen", "hidden_balance": "Verborgen balans", + "hide": "Verbergen", "hide_details": "Details verbergen", "high_contrast_theme": "Thema met hoog contrast", "home_screen_settings": "Instellingen voor het startscherm", @@ -500,6 +504,7 @@ "pre_seed_title": "BELANGRIJK", "prepaid_cards": "Prepaid-kaarten", "prevent_screenshots": "Voorkom screenshots en schermopname", + "primary_address": "Primair adres", "privacy": "Privacy", "privacy_policy": "Privacybeleid", "privacy_settings": "Privacy-instellingen", @@ -696,6 +701,7 @@ "share": "Deel", "share_address": "Deel adres", "shared_seed_wallet_groups": "Gedeelde zaadportelgroepen", + "show": "Show", "show_details": "Toon details", "show_keys": "Toon zaad/sleutels", "show_market_place": "Toon Marktplaats", @@ -943,6 +949,5 @@ "you_pay": "U betaalt", "you_will_get": "Converteren naar", "you_will_send": "Converteren van", - "yy": "JJ", - "contact_name_exists": "Er bestaat al een contact met die naam. Kies een andere naam." -} + "yy": "JJ" +} \ No newline at end of file diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index f3c3e4810..e47c93dcf 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -136,7 +136,7 @@ "clearnet_link": "łącze Clearnet", "close": "Zamknąć", "coin_control": "Kontrola monet (opcjonalnie)", - "cold_or_recover_wallet": "Dodaj zimny portfel lub odzyskaj portfel papierowy", + "cold_or_recover_wallet": "Dodaj portfel tylko do odczytu od Cupcake lub zimnego portfela lub odzyskaj papierowy portfel", "color_theme": "Motyw kolorystyczny", "commit_transaction_amount_fee": "Zatwierdź transakcję\nIlość: ${amount}\nOpłata: ${fee}", "confirm": "Potwierdzać", @@ -161,6 +161,7 @@ "contact_list_contacts": "Łączność", "contact_list_wallets": "Moje portfele", "contact_name": "Nazwa Kontaktu", + "contact_name_exists": "Kontakt o tej nazwie już istnieje. Proszę wybrać inną nazwę.", "contact_support": "Skontaktuj się z pomocą techniczną", "continue_text": "Dalej", "contract_warning": "Ten adres umowy został oznaczony jako potencjalnie nieuczciwy. Prosimy o ostrożność.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Wygaśnięcie i ważność", "export_backup": "Eksportuj kopię zapasową", "export_logs": "Dzienniki eksportu", + "export_outputs": "Wyjścia eksportowe", "extra_id": "Dodatkowy ID:", "extracted_address_content": "Wysyłasz środki na\n${recipient_name}", "failed_authentication": "Nieudane uwierzytelnienie. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "pomoc", + "hidden_addresses": "Ukryte adresy", "hidden_balance": "Ukryte saldo", + "hide": "Ukrywać", "hide_details": "Ukryj szczegóły", "high_contrast_theme": "Motyw o wysokim kontraście", "home_screen_settings": "Ustawienia ekranu głównego", @@ -500,6 +504,7 @@ "pre_seed_title": "WAŻNY", "prepaid_cards": "Karty przedpłacone", "prevent_screenshots": "Zapobiegaj zrzutom ekranu i nagrywaniu ekranu", + "primary_address": "Adres podstawowy", "privacy": "Prywatność", "privacy_policy": "Polityka prywatności", "privacy_settings": "Ustawienia prywatności", @@ -696,6 +701,7 @@ "share": "Udział", "share_address": "Udostępnij adres", "shared_seed_wallet_groups": "Wspólne grupy portfeli nasion", + "show": "Pokazywać", "show_details": "Pokaż szczegóły", "show_keys": "Pokaż seed/klucze", "show_market_place": "Pokaż rynek", @@ -942,6 +948,5 @@ "you_pay": "Płacisz", "you_will_get": "Konwertuj na", "you_will_send": "Konwertuj z", - "yy": "RR", - "contact_name_exists": "Kontakt o tej nazwie już istnieje. Proszę wybrać inną nazwę." -} + "yy": "RR" +} \ No newline at end of file diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index 410e8cb1c..d5aa95c16 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -136,7 +136,7 @@ "clearnet_link": "link clear net", "close": "Fechar", "coin_control": "Controle de moedas (opcional)", - "cold_or_recover_wallet": "Adicione uma cold wallet ou recupere uma paper wallet", + "cold_or_recover_wallet": "Adicione uma carteira somente leitura de Cupcake ou uma carteira fria ou recupere uma carteira de papel", "color_theme": "Tema de cor", "commit_transaction_amount_fee": "Confirmar transação\nQuantia: ${amount}\nTaxa: ${fee}", "confirm": "Confirmar", @@ -161,6 +161,7 @@ "contact_list_contacts": "Contatos", "contact_list_wallets": "minhas carteiras", "contact_name": "Nome do contato", + "contact_name_exists": "Um contato com esse nome já existe. Escolha um nome diferente.", "contact_support": "Contatar Suporte", "continue_text": "Continuar", "contract_warning": "Este endereço do contrato foi sinalizado como potencialmente fraudulento. Por favor, processe com cautela.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Expiração e validade", "export_backup": "Backup de exportação", "export_logs": "Exportar logs", + "export_outputs": "Saídas de exportação", "extra_id": "ID extra:", "extracted_address_content": "Você enviará fundos para\n${recipient_name}", "failed_authentication": "Falha na autenticação. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "ajuda", + "hidden_addresses": "Endereços ocultos", "hidden_balance": "Saldo escondido", + "hide": "Esconder", "hide_details": "Ocultar detalhes", "high_contrast_theme": "Tema de alto contraste", "home_screen_settings": "Configurações da tela inicial", @@ -502,6 +506,7 @@ "pre_seed_title": "IMPORTANTE", "prepaid_cards": "Cartões pré-pagos", "prevent_screenshots": "Evite capturas de tela e gravação de tela", + "primary_address": "Endereço primário", "privacy": "Privacidade", "privacy_policy": "Política de privacidade", "privacy_settings": "Configurações de privacidade", @@ -698,6 +703,7 @@ "share": "Compartilhar", "share_address": "Compartilhar endereço", "shared_seed_wallet_groups": "Grupos de carteira de sementes compartilhados", + "show": "Mostrar", "show_details": "Mostrar detalhes", "show_keys": "Mostrar semente/chaves", "show_market_place": "Mostrar mercado", @@ -946,4 +952,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 a8ee5a309..f999b4aaa 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -136,7 +136,7 @@ "clearnet_link": "Клирнет ссылка", "close": "Закрывать", "coin_control": "Контроль монет (необязательно)", - "cold_or_recover_wallet": "Добавьте холодный кошелек или восстановите бумажный кошелек", + "cold_or_recover_wallet": "Добавить кошелек только для чтения из Cupcake или холодный кошелек или восстановить бумажный кошелек", "color_theme": "Цветовая тема", "commit_transaction_amount_fee": "Подтвердить транзакцию \nСумма: ${amount}\nКомиссия: ${fee}", "confirm": "Подтвердить", @@ -161,6 +161,7 @@ "contact_list_contacts": "Контакты", "contact_list_wallets": "Мои кошельки", "contact_name": "Имя контакта", + "contact_name_exists": "Контакт с таким именем уже существует. Пожалуйста, выберите другое имя.", "contact_support": "Связаться со службой поддержки", "continue_text": "Продолжить", "contract_warning": "Этот адрес контракта был отмечен как потенциально мошеннический. Пожалуйста, обработайтесь с осторожностью.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Истечение и достоверность", "export_backup": "Экспорт резервной копии", "export_logs": "Экспортные журналы", + "export_outputs": "Экспортные выходы", "extra_id": "Дополнительный ID:", "extracted_address_content": "Вы будете отправлять средства\n${recipient_name}", "failed_authentication": "Ошибка аутентификации. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "помощь", + "hidden_addresses": "Скрытые адреса", "hidden_balance": "Скрытый баланс", + "hide": "Скрывать", "hide_details": "Скрыть детали", "high_contrast_theme": "Высококонтрастная тема", "home_screen_settings": "Настройки главного экрана", @@ -501,6 +505,7 @@ "pre_seed_title": "ВАЖНО", "prepaid_cards": "Предоплаченные карты", "prevent_screenshots": "Предотвратить скриншоты и запись экрана", + "primary_address": "Первичный адрес", "privacy": "Конфиденциальность", "privacy_policy": "Политика конфиденциальности", "privacy_settings": "Настройки конфиденциальности", @@ -697,6 +702,7 @@ "share": "Делиться", "share_address": "Поделиться адресом", "shared_seed_wallet_groups": "Общие группы кошелька семян", + "show": "Показывать", "show_details": "Показать детали", "show_keys": "Показать мнемоническую фразу/ключи", "show_market_place": "Показать торговую площадку", @@ -943,6 +949,5 @@ "you_pay": "Вы платите", "you_will_get": "Конвертировать в", "you_will_send": "Конвертировать из", - "yy": "ГГ", - "contact_name_exists": "Контакт с таким именем уже существует. Пожалуйста, выберите другое имя." -} + "yy": "ГГ" +} \ No newline at end of file diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index 5102e150c..a35e16a97 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -136,7 +136,7 @@ "clearnet_link": "ลิงค์เคลียร์เน็ต", "close": "ปิด", "coin_control": "การควบคุมเหรียญ (ตัวเลือก)", - "cold_or_recover_wallet": "เพิ่มกระเป๋าเงินเย็นหรือกู้คืนกระเป๋าเงินกระดาษ", + "cold_or_recover_wallet": "เพิ่มกระเป๋าเงินแบบอ่านอย่างเดียวจาก Cupcake หรือกระเป๋าเงินเย็นหรือกู้คืนกระเป๋ากระดาษ", "color_theme": "ธีมสี", "commit_transaction_amount_fee": "ยืนยันธุรกรรม\nจำนวน: ${amount}\nค่าธรรมเนียม: ${fee}", "confirm": "ยืนยัน", @@ -161,6 +161,7 @@ "contact_list_contacts": "ติดต่อ", "contact_list_wallets": "กระเป๋าเงินของฉัน", "contact_name": "ชื่อผู้ติดต่อ", + "contact_name_exists": "มีผู้ติดต่อชื่อนั้นอยู่แล้ว โปรดเลือกชื่ออื่น", "contact_support": "ติดต่อฝ่ายสนับสนุน", "continue_text": "ดำเนินการต่อ", "contract_warning": "ที่อยู่สัญญานี้ได้รับการตั้งค่าสถานะว่าเป็นการฉ้อโกง กรุณาดำเนินการด้วยความระมัดระวัง", @@ -296,6 +297,7 @@ "expiry_and_validity": "หมดอายุและถูกต้อง", "export_backup": "ส่งออกข้อมูลสำรอง", "export_logs": "บันทึกการส่งออก", + "export_outputs": "เอาต์พุตส่งออก", "extra_id": "ไอดีเพิ่มเติม:", "extracted_address_content": "คุณกำลังจะส่งเงินไปยัง\n${recipient_name}", "failed_authentication": "การยืนยันสิทธิ์ล้มเหลว ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven ของ Cake Wallet", "haven_app_wallet_text": "กระเป๋าสตางค์ที่สวยงามสำหรับ Haven", "help": "ช่วยเหลือ", + "hidden_addresses": "ที่อยู่ที่ซ่อนอยู่", "hidden_balance": "ยอดคงเหลือซ่อนอยู่", + "hide": "ซ่อน", "hide_details": "ซ่อนรายละเอียด", "high_contrast_theme": "ธีมความคมชัดสูง", "home_screen_settings": "การตั้งค่าหน้าจอหลัก", @@ -500,6 +504,7 @@ "pre_seed_title": "สำคัญ", "prepaid_cards": "บัตรเติมเงิน", "prevent_screenshots": "ป้องกันภาพหน้าจอและการบันทึกหน้าจอ", + "primary_address": "ที่อยู่ปฐมภูมิ", "privacy": "ความเป็นส่วนตัว", "privacy_policy": "นโยบายความเป็นส่วนตัว", "privacy_settings": "การตั้งค่าความเป็นส่วนตัว", @@ -696,6 +701,7 @@ "share": "แบ่งปัน", "share_address": "แชร์ที่อยู่", "shared_seed_wallet_groups": "กลุ่มกระเป๋าเงินที่ใช้ร่วมกัน", + "show": "แสดง", "show_details": "แสดงรายละเอียด", "show_keys": "แสดงซีด/คีย์", "show_market_place": "แสดงตลาดกลาง", @@ -942,6 +948,5 @@ "you_pay": "คุณจ่าย", "you_will_get": "แปลงเป็น", "you_will_send": "แปลงจาก", - "yy": "ปี", - "contact_name_exists": "มีผู้ติดต่อชื่อนั้นอยู่แล้ว โปรดเลือกชื่ออื่น" -} + "yy": "ปี" +} \ No newline at end of file diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index eb78c8d3c..090bdb8a7 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -136,7 +136,7 @@ "clearnet_link": "Link ng Clearnet", "close": "Isara", "coin_control": "Coin control (opsyonal)", - "cold_or_recover_wallet": "Magdagdag ng isang cold wallet o mabawi ang isang paper wallet", + "cold_or_recover_wallet": "Magdagdag ng isang basahin lamang na pitaka mula sa Cupcake o isang malamig na pitaka o mabawi ang isang wallet ng papel", "color_theme": "Color theme", "commit_transaction_amount_fee": "Gumawa ng transaksyon\nHalaga: ${amount}\nFee: ${fee}", "confirm": "Kumpirmahin", @@ -161,6 +161,7 @@ "contact_list_contacts": "Mga Contact", "contact_list_wallets": "Mga Wallet Ko", "contact_name": "Pangalan ng Contact", + "contact_name_exists": "Ang isang pakikipag -ugnay sa pangalang iyon ay mayroon na. Mangyaring pumili ng ibang pangalan.", "contact_support": "Makipag-ugnay sa Suporta", "continue_text": "Magpatuloy", "contract_warning": "Ang address ng kontrata na ito ay na -flag bilang potensyal na mapanlinlang. Mangyaring iproseso nang may pag -iingat.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Pag-expire at Bisa", "export_backup": "I-export ang backup", "export_logs": "Mga log ng pag -export", + "export_outputs": "Mga output 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}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Kahanga-hangang wallet para sa Haven", "help": "Tulong", + "hidden_addresses": "Nakatagong mga address", "hidden_balance": "Nakatagong Balanse", + "hide": "Itago", "hide_details": "Itago ang mga detalye", "high_contrast_theme": "High Contrast Theme", "home_screen_settings": "Mga setting ng home screen", @@ -500,6 +504,7 @@ "pre_seed_title": "Mahalaga", "prepaid_cards": "Mga Prepaid Card", "prevent_screenshots": "Maiwasan ang mga screenshot at pag -record ng screen", + "primary_address": "Pangunahing address", "privacy": "Privacy", "privacy_policy": "Patakaran sa Pagkapribado", "privacy_settings": "Settings para sa pagsasa-pribado", @@ -696,6 +701,7 @@ "share": "Ibahagi", "share_address": "Ibahagi ang address", "shared_seed_wallet_groups": "Ibinahaging mga pangkat ng pitaka ng binhi", + "show": "Ipakita", "show_details": "Ipakita ang mga detalye", "show_keys": "Ipakita ang mga seed/key", "show_market_place": "Ipakita ang Marketplace", @@ -943,4 +949,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 0b4700397..3a426de9e 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -136,7 +136,7 @@ "clearnet_link": "Net bağlantı", "close": "Kapalı", "coin_control": "Koin kontrolü (isteğe bağlı)", - "cold_or_recover_wallet": "Soğuk bir cüzdan ekleyin veya bir kağıt cüzdanı kurtarın", + "cold_or_recover_wallet": "Cupcake veya soğuk bir cüzdandan salt okunur bir cüzdan ekleyin veya bir kağıt cüzdanı kurtar", "color_theme": "Renk teması", "commit_transaction_amount_fee": "Transferi gerçekleştir\nMiktar: ${amount}\nKomisyon: ${fee}", "confirm": "Onayla", @@ -161,6 +161,7 @@ "contact_list_contacts": "Rehberim", "contact_list_wallets": "Cüzdanlarım", "contact_name": "Kişi ismi", + "contact_name_exists": "Bu isimde bir kişi zaten mevcut. Lütfen farklı bir ad seçin.", "contact_support": "Destek ile İletişime Geç", "continue_text": "Devam et", "contract_warning": "Bu sözleşme adresi potansiyel olarak hileli olarak işaretlenmiştir. Lütfen dikkatle işleyin.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Sona erme ve geçerlilik", "export_backup": "Yedeği dışa aktar", "export_logs": "Dışa aktarma günlükleri", + "export_outputs": "İhracat çıktıları", "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}", @@ -336,7 +338,9 @@ "haven_app": "Cake Wallet tarafından Haven", "haven_app_wallet_text": "Haven için harika cüzdan ", "help": "yardım", + "hidden_addresses": "Gizli adresler", "hidden_balance": "Gizli Bakiye", + "hide": "Saklamak", "hide_details": "Detayları Gizle", "high_contrast_theme": "Yüksek Kontrastlı Tema", "home_screen_settings": "Ana ekran ayarları", @@ -500,6 +504,7 @@ "pre_seed_title": "UYARI", "prepaid_cards": "Ön ödemeli kartlar", "prevent_screenshots": "Ekran görüntülerini ve ekran kaydını önleyin", + "primary_address": "Birincil adres", "privacy": "Gizlilik", "privacy_policy": "Gizlilik Politikası", "privacy_settings": "Gizlilik ayarları", @@ -696,6 +701,7 @@ "share": "Paylaşmak", "share_address": "Adresi paylaş", "shared_seed_wallet_groups": "Paylaşılan tohum cüzdan grupları", + "show": "Göstermek", "show_details": "Detayları Göster", "show_keys": "Tohumları/anahtarları göster", "show_market_place": "Pazar Yerini Göster", @@ -942,6 +948,5 @@ "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", - "contact_name_exists": "Bu isimde bir kişi zaten mevcut. Lütfen farklı bir ad seçin." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index c9afde7be..63665875f 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -136,7 +136,7 @@ "clearnet_link": "Посилання Clearnet", "close": "Закрити", "coin_control": "Контроль монет (необов’язково)", - "cold_or_recover_wallet": "Додайте холодний гаманець або відновіть паперовий гаманець", + "cold_or_recover_wallet": "Додайте гаманець лише для читання від Cupcake або холодного гаманця або відновіть паперовий гаманець", "color_theme": "Кольорова тема", "commit_transaction_amount_fee": "Підтвердити транзакцію \nСума: ${amount}\nКомісія: ${fee}", "confirm": "Підтвердити", @@ -161,6 +161,7 @@ "contact_list_contacts": "Контакти", "contact_list_wallets": "Мої гаманці", "contact_name": "Ім'я контакту", + "contact_name_exists": "Контакт із такою назвою вже існує. Виберіть інше ім'я.", "contact_support": "Звернутися до служби підтримки", "continue_text": "Продовжити", "contract_warning": "Ця адреса контракту була позначена як потенційно шахрайська. Будь ласка, обробляйте обережно.", @@ -296,6 +297,7 @@ "expiry_and_validity": "Закінчення та обгрунтованість", "export_backup": "Експортувати резервну копію", "export_logs": "Експортні журнали", + "export_outputs": "Експортні результати", "extra_id": "Додатковий ID:", "extracted_address_content": "Ви будете відправляти кошти\n${recipient_name}", "failed_authentication": "Помилка аутентифікації. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "допомога", + "hidden_addresses": "Приховані адреси", "hidden_balance": "Прихований баланс", + "hide": "Сховати", "hide_details": "Приховати деталі", "high_contrast_theme": "Тема високої контрастності", "home_screen_settings": "Налаштування головного екрана", @@ -500,6 +504,7 @@ "pre_seed_title": "ВАЖЛИВО", "prepaid_cards": "Передплачені картки", "prevent_screenshots": "Запобігати знімкам екрана та запису екрана", + "primary_address": "Первинна адреса", "privacy": "Конфіденційність", "privacy_policy": "Політика конфіденційності", "privacy_settings": "Налаштування конфіденційності", @@ -697,6 +702,7 @@ "share": "Поділіться", "share_address": "Поділитися адресою", "shared_seed_wallet_groups": "Спільні групи насіннєвих гаманців", + "show": "Показувати", "show_details": "Показати деталі", "show_keys": "Показати мнемонічну фразу/ключі", "show_market_place": "Відображати маркетплейс", @@ -943,6 +949,5 @@ "you_pay": "Ви платите", "you_will_get": "Конвертувати в", "you_will_send": "Конвертувати з", - "yy": "YY", - "contact_name_exists": "Контакт із такою назвою вже існує. Виберіть інше ім'я." -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 50c6f1889..2a40fbe13 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -136,7 +136,7 @@ "clearnet_link": "کلیرنیٹ لنک", "close": "بند کریں", "coin_control": "سکے کنٹرول (اختیاری)", - "cold_or_recover_wallet": "ٹھنڈا پرس ڈالیں یا کاغذ کا پرس بازیافت کریں", + "cold_or_recover_wallet": "Cupcake یا سرد بٹوے سے صرف ایک پڑھنے والا پرس شامل کریں یا کاغذ کا پرس بازیافت کریں", "color_theme": "رنگین تھیم", "commit_transaction_amount_fee": "لین دین کا ارتکاب کریں\\nرقم: ${amount}\\nفیس: ${fee}", "confirm": "تصدیق کریں۔", @@ -161,6 +161,7 @@ "contact_list_contacts": "رابطے", "contact_list_wallets": "میرے بٹوے", "contact_name": "رابطے کا نام", + "contact_name_exists": " ۔ﮟﯾﺮﮐ ﺐﺨﺘﻨﻣ ﻡﺎﻧ ﻒﻠﺘﺨﻣ ﮏﯾﺍ ﻡﺮﮐ ﮦﺍﺮﺑ ۔ﮯﮨ ﺩﻮﺟﻮﻣ ﮯﺳ ﮯﻠﮩﭘ ﮧﻄﺑﺍﺭ ﮏﯾﺍ ﮫﺗﺎﺳ ﮯﮐ ﻡﺎﻧ ﺱﺍ", "contact_support": "سپورٹ سے رابطہ کریں۔", "continue_text": "جاری رہے", "contract_warning": "اس معاہدے کے پتے کو ممکنہ طور پر جعلی قرار دیا گیا ہے۔ براہ کرم احتیاط کے ساتھ کارروائی کریں۔", @@ -296,6 +297,7 @@ "expiry_and_validity": "میعاد ختم اور صداقت", "export_backup": "بیک اپ برآمد کریں۔", "export_logs": "نوشتہ جات برآمد کریں", + "export_outputs": "برآمد کے نتائج", "extra_id": "اضافی ID:", "extracted_address_content": "آپ فنڈز بھیج رہے ہوں گے\n${recipient_name}", "failed_authentication": "ناکام تصدیق۔ ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven از Cake والیٹ", "haven_app_wallet_text": "Havek کے لیے زبردست پرس", "help": "مدد", + "hidden_addresses": "پوشیدہ پتے", "hidden_balance": "پوشیدہ بیلنس", + "hide": "چھپائیں", "hide_details": "تفصیلات چھپائیں۔", "high_contrast_theme": "ہائی کنٹراسٹ تھیم", "home_screen_settings": "ہوم اسکرین کی ترتیبات", @@ -502,6 +506,7 @@ "pre_seed_title": "اہم", "prepaid_cards": "پری پیڈ کارڈز", "prevent_screenshots": "اسکرین شاٹس اور اسکرین ریکارڈنگ کو روکیں۔", + "primary_address": "بنیادی پتہ", "privacy": "رازداری", "privacy_policy": "رازداری کی پالیسی", "privacy_settings": "رازداری کی ترتیبات", @@ -698,6 +703,7 @@ "share": "بانٹیں", "share_address": "پتہ شیئر کریں۔", "shared_seed_wallet_groups": "مشترکہ بیج پرس گروپ", + "show": "دکھائیں", "show_details": "تفصیلات دکھائیں", "show_keys": "بیج / چابیاں دکھائیں۔", "show_market_place": "بازار دکھائیں۔", @@ -944,6 +950,5 @@ "you_pay": "تم ادا کرو", "you_will_get": "میں تبدیل کریں۔", "you_will_send": "سے تبدیل کریں۔", - "yy": "YY", - "contact_name_exists": " ۔ﮟﯾﺮﮐ ﺐﺨﺘﻨﻣ ﻡﺎﻧ ﻒﻠﺘﺨﻣ ﮏﯾﺍ ﻡﺮﮐ ﮦﺍﺮﺑ ۔ﮯﮨ ﺩﻮﺟﻮﻣ ﮯﺳ ﮯﻠﮩﭘ ﮧﻄﺑﺍﺭ ﮏﯾﺍ ﮫﺗﺎﺳ ﮯﮐ ﻡﺎﻧ ﺱﺍ" -} + "yy": "YY" +} \ No newline at end of file diff --git a/res/values/strings_vi.arb b/res/values/strings_vi.arb index e3ffbef3e..9fee9f4fc 100644 --- a/res/values/strings_vi.arb +++ b/res/values/strings_vi.arb @@ -136,7 +136,7 @@ "clearnet_link": "Liên kết Clearnet", "close": "Đóng", "coin_control": "Kiểm soát đồng xu (tùy chọn)", - "cold_or_recover_wallet": "Thêm ví lạnh hoặc khôi phục ví giấy", + "cold_or_recover_wallet": "Thêm ví chỉ đọc từ Cupcake hoặc ví lạnh hoặc thu hồi ví giấy", "color_theme": "Chủ đề màu sắc", "commit_transaction_amount_fee": "Cam kết giao dịch\nSố tiền: ${amount}\nPhí: ${fee}", "confirm": "Xác nhận", @@ -162,6 +162,7 @@ "contact_list_contacts": "Danh bạ", "contact_list_wallets": "Ví của tôi", "contact_name": "Tên liên hệ", + "contact_name_exists": "Một liên hệ với cái tên đó đã tồn tại. Vui lòng chọn một tên khác.", "contact_support": "Liên hệ Hỗ trợ", "continue_text": "Tiếp tục", "contract_warning": "Địa chỉ hợp đồng này đã được gắn cờ là có khả năng lừa đảo. Vui lòng xử lý một cách thận trọng.", @@ -297,6 +298,7 @@ "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", + "export_outputs": "Đầu ra 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}", @@ -337,7 +339,9 @@ "haven_app": "Haven bởi Cake Wallet", "haven_app_wallet_text": "Ví tuyệt vời cho Haven", "help": "Trợ giúp", + "hidden_addresses": "Địa chỉ ẩn", "hidden_balance": "Số dư ẩn", + "hide": "Trốn", "hide_details": "Ẩn chi tiết", "high_contrast_theme": "Chủ đề độ tương phản cao", "home_screen_settings": "Cài đặt màn hình chính", @@ -367,14 +371,22 @@ "ledger_error_wrong_app": "Vui lòng đảm bảo bạn đã mở đúng ứng dụng trên Ledger của mình", "ledger_please_enable_bluetooth": "Vui lòng bật Bluetooth để phát hiện Ledger của bạn", "light_theme": "Chủ đề sáng", + "litecoin_enable_mweb_sync": "Bật quét MWEB", + "litecoin_mweb": "Mweb", + "litecoin_mweb_always_scan": "Đặt MWEB luôn quét", "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_display_card": "Hiển thị thẻ MWEB", "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", + "litecoin_mweb_scanning": "Quét MWEB", + "litecoin_mweb_settings": "Cài đặt MWEB", + "litecoin_mweb_warning": "Sử dụng MWEB ban đầu sẽ tải xuống ~ 600MB dữ liệu và có thể mất tới 30 phút tùy thuộc vào tốc độ mạng. Dữ liệu ban đầu này sẽ chỉ tải xuống một lần và có sẵn cho tất cả các ví Litecoin", + "litecoin_what_is_mweb": "MWEB là gì?", "live_fee_rates": "Tỷ lệ phí hiện tại qua API", "load_more": "Tải thêm", "loading_your_wallet": "Đang tải ví của bạn", @@ -493,6 +505,7 @@ "pre_seed_title": "QUAN TRỌNG", "prepaid_cards": "Thẻ trả trước", "prevent_screenshots": "Ngăn chặn ảnh chụp màn hình và ghi hình màn hình", + "primary_address": "Địa chỉ chính", "privacy": "Quyền riêng tư", "privacy_policy": "Chính sách quyền riêng tư", "privacy_settings": "Cài đặt quyền riêng tư", @@ -689,6 +702,7 @@ "share": "Chia sẻ", "share_address": "Chia sẻ địa chỉ", "shared_seed_wallet_groups": "Nhóm ví hạt được chia sẻ", + "show": "Trình diễn", "show_details": "Hiển thị chi tiết", "show_keys": "Hiển thị hạt giống/khóa", "show_market_place": "Hiển thị Thị trường", diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index 19dc9f110..1dd6eafda 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -136,7 +136,7 @@ "clearnet_link": "Kọja ilọ oke", "close": "sunmo", "coin_control": "Ìdarí owó ẹyọ (ìyàn nìyí)", - "cold_or_recover_wallet": "Fi owo aisan tabi yiyewo owo iwe iwe", + "cold_or_recover_wallet": "Ṣafikun apamọwọ kika-nikan lati Cupcake tabi apamọwọ tutu tabi gba owo apamọwọ iwe kan", "color_theme": "Àwọn ààtò àwọ̀", "commit_transaction_amount_fee": "Jẹ́rìí sí àránṣẹ́\nOwó: ${amount}\nIye àfikún: ${fee}", "confirm": "Jẹ́rìísí", @@ -161,6 +161,7 @@ "contact_list_contacts": "Àwọn olùbásọ̀rọ̀", "contact_list_wallets": "Àwọn àpamọ́wọ́ mi", "contact_name": "Orúkọ olùbásọ̀rọ̀", + "contact_name_exists": "Olubasọrọ pẹlu orukọ yẹn ti wa tẹlẹ. Jọwọ yan orukọ ti o yatọ.", "contact_support": "Bá ìranlọ́wọ́ sọ̀rọ̀", "continue_text": "Tẹ̀síwájú", "contract_warning": "Adirẹsi adehun adehun yii ti samisi bi arekereke. Jọwọ ṣe ilana pẹlu iṣọra.", @@ -297,6 +298,7 @@ "expiry_and_validity": "Ipari ati idaniloju", "export_backup": "Sún ẹ̀dà nípamọ́ síta", "export_logs": "Wọle si okeere", + "export_outputs": "Agbohunlu 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}", @@ -337,7 +339,9 @@ "haven_app": "Haven latí ọwọ́ Cake Wallet", "haven_app_wallet_text": "Àpamọ́wọ́ Haven wà pa", "help": "ìranlọ́wọ́", + "hidden_addresses": "Awọn adirẹsi ti o farapamọ", "hidden_balance": "Ìyókù owó dídé", + "hide": "Pamọ", "hide_details": "Dé ìsọfúnni kékeré", "high_contrast_theme": "Akori Iyatọ giga", "home_screen_settings": "Awọn eto iboju ile", @@ -501,6 +505,7 @@ "pre_seed_title": "Ó TI ṢE PÀTÀKÌ", "prepaid_cards": "Awọn kaadi ti a ti sanwo", "prevent_screenshots": "Pese asapọ ti awọn ẹrọ eto aṣa", + "primary_address": "Adirẹsi akọkọ", "privacy": "Ìdáwà", "privacy_policy": "Òfin Aládàáni", "privacy_settings": "Ààtò àdáni", @@ -697,6 +702,7 @@ "share": "Pinpin", "share_address": "Pín àdírẹ́sì", "shared_seed_wallet_groups": "Awọn ẹgbẹ ti a pin irugbin", + "show": "Fihan", "show_details": "Fi ìsọfúnni kékeré hàn", "show_keys": "Wo hóró / àwọn kọ́kọ́rọ́", "show_market_place": "Wa Sopọ Pataki", @@ -943,6 +949,5 @@ "you_pay": "Ẹ sàn", "you_will_get": "Ṣe pàṣípààrọ̀ sí", "you_will_send": "Ṣe pàṣípààrọ̀ láti", - "yy": "Ọd", - "contact_name_exists": "Olubasọrọ pẹlu orukọ yẹn ti wa tẹlẹ. Jọwọ yan orukọ ti o yatọ." -} + "yy": "Ọd" +} \ No newline at end of file diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index 009a31d86..92a540d19 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -136,7 +136,7 @@ "clearnet_link": "明网链接", "close": "关闭", "coin_control": "硬幣控制(可選)", - "cold_or_recover_wallet": "添加冷钱包或恢复纸钱包", + "cold_or_recover_wallet": "从Cupcake或冷钱包中添加只读的钱包或恢复纸钱包", "color_theme": "主题", "commit_transaction_amount_fee": "提交交易\n金额: ${amount}\n手续费: ${fee}", "confirm": "确认", @@ -161,6 +161,7 @@ "contact_list_contacts": "联系人", "contact_list_wallets": "我的钱包", "contact_name": "联系人姓名", + "contact_name_exists": "已存在具有该名称的联系人。请选择不同的名称。", "contact_support": "联系支持", "continue_text": "继续", "contract_warning": "该合同地址已被标记为潜在的欺诈性。请谨慎处理。", @@ -296,6 +297,7 @@ "expiry_and_validity": "到期和有效性", "export_backup": "导出备份", "export_logs": "导出日志", + "export_outputs": "导出输出", "extra_id": "额外ID:", "extracted_address_content": "您将汇款至\n${recipient_name}", "failed_authentication": "身份验证失败. ${state_error}", @@ -336,7 +338,9 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "帮助", + "hidden_addresses": "隐藏的地址", "hidden_balance": "隐藏余额", + "hide": "隐藏", "hide_details": "隐藏细节", "high_contrast_theme": "高对比度主题", "home_screen_settings": "主屏幕设置", @@ -500,6 +504,7 @@ "pre_seed_title": "重要", "prepaid_cards": "预付费卡", "prevent_screenshots": "防止截屏和录屏", + "primary_address": "主要地址", "privacy": "隐私", "privacy_policy": "隐私政策", "privacy_settings": "隐私设置", @@ -696,6 +701,7 @@ "share": "分享", "share_address": "分享地址", "shared_seed_wallet_groups": "共享种子钱包组", + "show": "展示", "show_details": "显示详细信息", "show_keys": "显示种子/密钥", "show_market_place": "显示市场", @@ -942,6 +948,5 @@ "you_pay": "你付钱", "you_will_get": "转换到", "you_will_send": "转换自", - "yy": "YY", - "contact_name_exists": "已存在具有该名称的联系人。请选择不同的名称。" -} + "yy": "YY" +} \ No newline at end of file diff --git a/scripts/macos/gen_common.sh b/scripts/macos/gen_common.sh index 87fd54b43..d75ac919e 100755 --- a/scripts/macos/gen_common.sh +++ b/scripts/macos/gen_common.sh @@ -9,7 +9,7 @@ gen_podspec() { DEFAULT_FILE_PATH="${CW_PLUGIN_DIR}/${DEFAULT_FILENAME}" rm -f $DEFAULT_FILE_PATH cp $BASE_FILE_PATH $DEFAULT_FILE_PATH - sed -i '' "s/#___VALID_ARCHS___#/${ARCH}/g" $DEFAULT_FILE_PATH + gsed -i "s/#___VALID_ARCHS___#/${ARCH}/g" $DEFAULT_FILE_PATH } gen_project() { @@ -17,7 +17,7 @@ gen_project() { CW_DIR="`pwd`/../../macos/Runner.xcodeproj" DEFAULT_FILENAME="project.pbxproj" DEFAULT_FILE_PATH="${CW_DIR}/${DEFAULT_FILENAME}" - sed -i '' "s/ARCHS =.*/ARCHS = \"${ARCH}\";/g" $DEFAULT_FILE_PATH + gsed -i "s/ARCHS =.*/ARCHS = \"${ARCH}\";/g" $DEFAULT_FILE_PATH } gen() { diff --git a/tool/configure.dart b/tool/configure.dart index 97541c2fa..c5d4a6863 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -276,6 +276,7 @@ import 'package:cw_core/get_height_by_date.dart'; import 'package:cw_core/monero_amount_format.dart'; import 'package:cw_core/monero_transaction_priority.dart'; import 'package:cw_monero/monero_unspent.dart'; +import 'package:cw_monero/api/account_list.dart'; import 'package:cw_monero/monero_wallet_service.dart'; import 'package:cw_monero/api/wallet_manager.dart'; import 'package:cw_monero/monero_wallet.dart'; @@ -379,6 +380,12 @@ abstract class Monero { Future getCurrentHeight(); + Future commitTransactionUR(Object wallet, String ur); + + String exportOutputsUR(Object wallet, bool all); + + bool importKeyImagesUR(Object wallet, String ur); + WalletCredentials createMoneroRestoreWalletFromKeysCredentials({ required String name, required String spendKey, @@ -398,6 +405,7 @@ abstract class Monero { int formatterMoneroParseAmount({required String amount}); Account getCurrentAccount(Object wallet); void monerocCheck(); + bool isViewOnly(); void setCurrentAccount(Object wallet, int id, String label, String? balance); void onStartup(); int getTransactionInfoAccountId(TransactionInfo tx); diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 92431a6fb..26d481f85 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -13,7 +13,6 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - sp_scanner ) set(PLUGIN_BUNDLED_LIBRARIES) From 02f53055b1a72b5e7d33cc5bda2565412857b187 Mon Sep 17 00:00:00 2001 From: Serhii Date: Sat, 9 Nov 2024 21:00:56 +0200 Subject: [PATCH 20/34] Cw 657 new buy sell flow (#1553) * init commit * onramper * moonPay * dfx provider * meld * dfx payment methods * fiat buy credentials * moonpay payment method * payment loading state * dfx sell quote * onramper launch trade * meld launch trade * country picker * update option tile * buy/sell action * meld refactor * update pr_test_build.yml * ui fixes * revert country picker commit * update the ui * recommended providers * payment method [skip ci] * provider option tile * remove buy action * minor fixes * update the best rate when the amount is changed * fixes for currency title * fix icons * code refactoring * null issue * code review fixes * Update pr_test_build_linux.yml * Update meld_buy_provider.dart * Update meld_buy_provider.dart * add show wallets action * remove default sell / buy provider setting * localisation * providerTypes * icons * remove duplicate file [skip ci] * minor changes [skip ci] * fixes from review * disable dfx for non eur/chf currencies fix providers to be fetched with the selected currency * fix breaking from loop if one element failed * fix minor naming issue from merging conflicts * add fiat check for moonpay * fix address validation * merge conflict * fix destination and source currency * minor fix * minor fix * update the flow * fix bch address format * fix wallet addresses * fix initial fetching amount. * Update address_validator.dart * review comments * revert switch case to return null * minor fix --------- Co-authored-by: OmarHatem --- .github/workflows/pr_test_build_android.yml | 2 + .github/workflows/pr_test_build_linux.yml | 2 + assets/images/apple_pay_logo.png | Bin 0 -> 56018 bytes assets/images/apple_pay_round_dark.svg | 9 + assets/images/apple_pay_round_light.svg | 9 + assets/images/bank.png | Bin 0 -> 1323 bytes assets/images/bank_dark.svg | 8 + assets/images/bank_light.svg | 8 + assets/images/buy_sell.png | Bin 0 -> 9931 bytes assets/images/card.svg | 1 + assets/images/card_dark.svg | 7 + assets/images/dollar_coin.svg | 12 + assets/images/google_pay_icon.png | Bin 0 -> 12336 bytes assets/images/meld_logo.svg | 30 + assets/images/revolut.png | Bin 0 -> 11588 bytes assets/images/skrill.svg | 15 + assets/images/usd_round_dark.svg | 4 + assets/images/usd_round_light.svg | 2 + assets/images/wallet_new.png | Bin 0 -> 5619 bytes cw_core/lib/currency_for_wallet_type.dart | 31 ++ lib/buy/buy_provider.dart | 32 +- lib/buy/buy_quote.dart | 302 ++++++++++ lib/buy/dfx/dfx_buy_provider.dart | 273 +++++++-- lib/buy/meld/meld_buy_provider.dart | 266 +++++++++ lib/buy/moonpay/moonpay_provider.dart | 451 +++++++++------ lib/buy/onramper/onramper_buy_provider.dart | 359 ++++++++++-- lib/buy/payment_method.dart | 287 ++++++++++ lib/buy/robinhood/robinhood_buy_provider.dart | 102 +++- lib/buy/sell_buy_states.dart | 20 + lib/buy/wyre/wyre_buy_provider.dart | 9 +- lib/core/backup_service.dart | 19 +- lib/core/selectable_option.dart | 47 ++ lib/di.dart | 32 +- lib/entities/main_actions.dart | 65 +-- lib/entities/preferences_key.dart | 4 +- lib/entities/provider_types.dart | 42 +- lib/router.dart | 16 +- lib/routes.dart | 2 + .../screens/{InfoPage.dart => Info_page.dart} | 0 lib/src/screens/buy/buy_options_page.dart | 74 --- .../screens/buy/buy_sell_options_page.dart | 48 ++ lib/src/screens/buy/buy_sell_page.dart | 469 ++++++++++++++++ .../buy/payment_method_options_page.dart | 47 ++ .../desktop_dashboard_actions.dart | 27 +- .../exchange/widgets/exchange_card.dart | 58 +- .../mobile_exchange_cards_section.dart | 91 ++- lib/src/screens/seed/pre_seed_page.dart | 2 +- lib/src/screens/select_options_page.dart | 199 +++++++ .../screens/settings/other_settings_page.dart | 16 - lib/src/screens/settings/privacy_page.dart | 12 +- .../setup_2fa/setup_2fa_info_page.dart | 2 +- lib/src/widgets/address_text_field.dart | 7 +- lib/src/widgets/provider_optoin_tile.dart | 527 ++++++++++++++++++ lib/store/settings_store.dart | 79 +-- lib/typography.dart | 2 + lib/view_model/buy/buy_sell_view_model.dart | 446 +++++++++++++++ .../dashboard/dashboard_view_model.dart | 45 +- .../settings/other_settings_view_model.dart | 45 -- .../settings/privacy_settings_view_model.dart | 10 +- res/values/strings_ar.arb | 3 + res/values/strings_bg.arb | 3 + res/values/strings_cs.arb | 3 + res/values/strings_de.arb | 3 + res/values/strings_en.arb | 3 + res/values/strings_es.arb | 3 + res/values/strings_fr.arb | 3 + res/values/strings_ha.arb | 3 + res/values/strings_hi.arb | 3 + res/values/strings_hr.arb | 3 + res/values/strings_hy.arb | 3 + res/values/strings_id.arb | 3 + res/values/strings_it.arb | 3 + res/values/strings_ja.arb | 3 + res/values/strings_ko.arb | 3 + res/values/strings_my.arb | 3 + res/values/strings_nl.arb | 3 + res/values/strings_pl.arb | 3 + res/values/strings_pt.arb | 3 + res/values/strings_ru.arb | 3 + res/values/strings_th.arb | 3 + res/values/strings_tl.arb | 3 + res/values/strings_tr.arb | 3 + res/values/strings_uk.arb | 13 +- res/values/strings_ur.arb | 3 + res/values/strings_vi.arb | 1 + res/values/strings_yo.arb | 3 + res/values/strings_zh.arb | 3 + tool/utils/secret_key.dart | 2 + 88 files changed, 4082 insertions(+), 686 deletions(-) create mode 100644 assets/images/apple_pay_logo.png create mode 100644 assets/images/apple_pay_round_dark.svg create mode 100644 assets/images/apple_pay_round_light.svg create mode 100644 assets/images/bank.png create mode 100644 assets/images/bank_dark.svg create mode 100644 assets/images/bank_light.svg create mode 100644 assets/images/buy_sell.png create mode 100644 assets/images/card.svg create mode 100644 assets/images/card_dark.svg create mode 100644 assets/images/dollar_coin.svg create mode 100644 assets/images/google_pay_icon.png create mode 100644 assets/images/meld_logo.svg create mode 100644 assets/images/revolut.png create mode 100644 assets/images/skrill.svg create mode 100644 assets/images/usd_round_dark.svg create mode 100644 assets/images/usd_round_light.svg create mode 100644 assets/images/wallet_new.png create mode 100644 lib/buy/buy_quote.dart create mode 100644 lib/buy/meld/meld_buy_provider.dart create mode 100644 lib/buy/payment_method.dart create mode 100644 lib/buy/sell_buy_states.dart create mode 100644 lib/core/selectable_option.dart rename lib/src/screens/{InfoPage.dart => Info_page.dart} (100%) delete mode 100644 lib/src/screens/buy/buy_options_page.dart create mode 100644 lib/src/screens/buy/buy_sell_options_page.dart create mode 100644 lib/src/screens/buy/buy_sell_page.dart create mode 100644 lib/src/screens/buy/payment_method_options_page.dart create mode 100644 lib/src/screens/select_options_page.dart create mode 100644 lib/src/widgets/provider_optoin_tile.dart create mode 100644 lib/view_model/buy/buy_sell_view_model.dart diff --git a/.github/workflows/pr_test_build_android.yml b/.github/workflows/pr_test_build_android.yml index 6d0e541ce..a12fd422f 100644 --- a/.github/workflows/pr_test_build_android.yml +++ b/.github/workflows/pr_test_build_android.yml @@ -194,6 +194,8 @@ jobs: echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const meldTestApiKey = '${{ secrets.MELD_TEST_API_KEY }}';" >> lib/.secrets.g.dart + echo "const meldTestPublicKey = '${{ secrets.MELD_TEST_PUBLIC_KEY}}';" >> lib/.secrets.g.dar echo "const letsExchangeBearerToken = '${{ secrets.LETS_EXCHANGE_TOKEN }}';" >> lib/.secrets.g.dart echo "const letsExchangeAffiliateId = '${{ secrets.LETS_EXCHANGE_AFFILIATE_ID }}';" >> lib/.secrets.g.dart echo "const stealthExBearerToken = '${{ secrets.STEALTH_EX_BEARER_TOKEN }}';" >> lib/.secrets.g.dart diff --git a/.github/workflows/pr_test_build_linux.yml b/.github/workflows/pr_test_build_linux.yml index 5ea0cb377..f3ce1045d 100644 --- a/.github/workflows/pr_test_build_linux.yml +++ b/.github/workflows/pr_test_build_linux.yml @@ -175,6 +175,8 @@ jobs: echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const meldTestApiKey = '${{ secrets.MELD_TEST_API_KEY }}';" >> lib/.secrets.g.dart + echo "const meldTestPublicKey = '${{ secrets.MELD_TEST_PUBLIC_KEY}}';" >> lib/.secrets.g.dar echo "const letsExchangeBearerToken = '${{ secrets.LETS_EXCHANGE_TOKEN }}';" >> lib/.secrets.g.dart echo "const letsExchangeAffiliateId = '${{ secrets.LETS_EXCHANGE_AFFILIATE_ID }}';" >> lib/.secrets.g.dart echo "const stealthExBearerToken = '${{ secrets.STEALTH_EX_BEARER_TOKEN }}';" >> lib/.secrets.g.dart diff --git a/assets/images/apple_pay_logo.png b/assets/images/apple_pay_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..346007e3b164651dab29b53c8368c6d48fe3310e GIT binary patch literal 56018 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 Hk^KJyr@mf2 literal 0 HcmV?d00001 diff --git a/assets/images/apple_pay_round_dark.svg b/assets/images/apple_pay_round_dark.svg new file mode 100644 index 000000000..82443bfb4 --- /dev/null +++ b/assets/images/apple_pay_round_dark.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/images/apple_pay_round_light.svg b/assets/images/apple_pay_round_light.svg new file mode 100644 index 000000000..2beb1248f --- /dev/null +++ b/assets/images/apple_pay_round_light.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/assets/images/bank.png b/assets/images/bank.png new file mode 100644 index 0000000000000000000000000000000000000000..9dc68147aad18488da6007bca88b92ba13697559 GIT binary patch literal 1323 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7xzrVEN?f;uumf=j~kE^`fC7ZTBNI zG^94Nh;~MO6uz<|Tsk>DadpBM^%V=m!fHy&yw<#7RXXCQ+be(3>q^iAr-jdw^jeip z8d~3+kT}P-*e-4EOw-quljh!;V4J@7_rCWtZ>FEyuwG=cwTi9I%T61)Q~%k7p9^{( zSBafsvbvXhQlHl?^ODII>|fsf*SKWAbkF>&$4WitdsOi#CrR2Xb6idoeVop6P`_3R z$k``WuKxLJb)0j)$1kgXX>OVC{Y&~Ibbi$yzaR6&Hm-7Rt@a+iZr9_-t}Ajl_O5pp zXp)L=S70&S$LGkARU_LZaOH;;i{grpr5sKHk6#P4EI?MMUL&wbYW)kx13wnv#T5me z%X2Q@=~sRG{{Q#(Gym3n{Hi)H>E3F~O6N7Nv-%Kgte0-SY;ie_KSF`Us_ek%~(udo=9(dmBx!=>u;LdwS?IMNh8&BTu ze04;-)l+}XEk>Sm-K_cMt0(`{_3_GP{&4o(w4;A?y{>c4V4M3-s3Fstp~?6$kHBn6 z#*VXVv>9%yYlK2^Kd>m?ZQiSMzEpIfr&>Fy^YOy^ka?*DAx z-0pj?E@JMVKTPrJMkbuRX0JMbCb9S$ZV}_%r&Zhh#ab=nmExbxZ!Hfl|J1T@cFGBt zW_uOen_DY9Uj7ggHrXn$%=^#c&+8SJKL2!i$@A!U!UiT$ypz)HpI-gSP&b{vG#5z0OmkXBMW%l;zcRN3K|K#}wFLzm&hF+S;_-{X>t<*$0k)LIq zx2M=YyX$@p$hRxJ^u>L~b6Iimn_HXYOqcZaF2C}=`o|%qxwd5&79U%ato;7lkF`^; zHZT8@v18J{TS*zeL_Ta;QvIpI?NYk#=j}XpvKJOBwnv?*S^ac}vhMM{Uj0&w`+DbO zRz5E;+*a^Lv+9aOY0i`DP9IBdy?=D`(8iZ(g0@*DR%Ss}HaXX2j^B&6Fx#sJ5!v-` z&FhM)nE8FZ0u2^cvqYZpJaBiPe2C>hf1g?_XM?<~FPAWbJy1f8p{~S4(}&^Di;SQd z3_mVjTw=iZVew+mM5aNY;l^(^!S823m*>1JG_lg#;_7pYcTIP13ewyisT{DOw5hn)QMl5|rBb+-!=&e>NVM3^#!8#r8Knqjk_b$MN@^z>DX z7IsZ-5>cz!++TMe5NY!5xp1X<$ISKrx9c(~YW!!c{2hJkt)%`Xknx_belF{r5}E)9 CLv&mK literal 0 HcmV?d00001 diff --git a/assets/images/bank_dark.svg b/assets/images/bank_dark.svg new file mode 100644 index 000000000..670120796 --- /dev/null +++ b/assets/images/bank_dark.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/images/bank_light.svg b/assets/images/bank_light.svg new file mode 100644 index 000000000..804716289 --- /dev/null +++ b/assets/images/bank_light.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/assets/images/buy_sell.png b/assets/images/buy_sell.png new file mode 100644 index 0000000000000000000000000000000000000000..0fbffe56fb0e74a762e0b48e79c5930d5c116498 GIT binary patch literal 9931 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 \ No newline at end of file diff --git a/assets/images/card_dark.svg b/assets/images/card_dark.svg new file mode 100644 index 000000000..2e5bcf986 --- /dev/null +++ b/assets/images/card_dark.svg @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/assets/images/dollar_coin.svg b/assets/images/dollar_coin.svg new file mode 100644 index 000000000..22218f332 --- /dev/null +++ b/assets/images/dollar_coin.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/google_pay_icon.png b/assets/images/google_pay_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a3ca38311cf31e5304b3fa2eb541d7e59dfe2863 GIT binary patch literal 12336 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 O_efn=ty;x4^8W!pu>c + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/images/revolut.png b/assets/images/revolut.png new file mode 100644 index 0000000000000000000000000000000000000000..bbe342592660209396ef45a6a119154437f93259 GIT binary patch literal 11588 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 + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/images/usd_round_dark.svg b/assets/images/usd_round_dark.svg new file mode 100644 index 000000000..f329dd617 --- /dev/null +++ b/assets/images/usd_round_dark.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/assets/images/usd_round_light.svg b/assets/images/usd_round_light.svg new file mode 100644 index 000000000..f5965c597 --- /dev/null +++ b/assets/images/usd_round_light.svg @@ -0,0 +1,2 @@ + + diff --git a/assets/images/wallet_new.png b/assets/images/wallet_new.png new file mode 100644 index 0000000000000000000000000000000000000000..47c43bfca99bc5cf9958e150081d4f885ed27802 GIT binary patch literal 5619 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 title; - Future launchProvider(BuildContext context, bool? isBuyAction); + Future? launchProvider( + {required BuildContext context, + required Quote quote, + required double amount, + required bool isBuyAction, + required String cryptoCurrencyAddress, + String? countryCode}) => + null; Future requestUrl(String amount, String sourceCurrency) => throw UnimplementedError(); Future findOrderById(String id) => throw UnimplementedError(); - Future calculateAmount(String amount, String sourceCurrency) => throw UnimplementedError(); + Future calculateAmount(String amount, String sourceCurrency) => + throw UnimplementedError(); + + Future> getAvailablePaymentTypes( + String fiatCurrency, String cryptoCurrency, bool isBuyAction) async => + []; + + Future?> fetchQuote( + {required CryptoCurrency cryptoCurrency, + required FiatCurrency fiatCurrency, + required double amount, + required bool isBuyAction, + required String walletAddress, + PaymentType? paymentType, + String? countryCode}) async => + null; } diff --git a/lib/buy/buy_quote.dart b/lib/buy/buy_quote.dart new file mode 100644 index 000000000..72ab7bd7d --- /dev/null +++ b/lib/buy/buy_quote.dart @@ -0,0 +1,302 @@ +import 'package:cake_wallet/buy/buy_provider.dart'; +import 'package:cake_wallet/buy/payment_method.dart'; +import 'package:cake_wallet/core/selectable_option.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; +import 'package:cake_wallet/entities/provider_types.dart'; +import 'package:cake_wallet/exchange/limits.dart'; +import 'package:cw_core/crypto_currency.dart'; + +enum ProviderRecommendation { bestRate, lowKyc, successRate } + +extension RecommendationTitle on ProviderRecommendation { + String get title { + switch (this) { + case ProviderRecommendation.bestRate: + return 'BEST RATE'; + case ProviderRecommendation.lowKyc: + return 'LOW KYC'; + case ProviderRecommendation.successRate: + return 'SUCCESS RATE'; + } + } +} + +ProviderRecommendation? getRecommendationFromString(String title) { + switch (title) { + case 'BEST RATE': + return ProviderRecommendation.bestRate; + case 'LowKyc': + return ProviderRecommendation.lowKyc; + case 'SuccessRate': + return ProviderRecommendation.successRate; + default: + return null; + } +} + +class Quote extends SelectableOption { + Quote({ + required this.rate, + required this.feeAmount, + required this.networkFee, + required this.transactionFee, + required this.payout, + required this.provider, + required this.paymentType, + required this.recommendations, + this.isBuyAction = true, + this.quoteId, + this.rampId, + this.rampName, + this.rampIconPath, + this.limits, + }) : super(title: provider.isAggregator ? rampName ?? '' : provider.title); + + final double rate; + final double feeAmount; + final double networkFee; + final double transactionFee; + final double payout; + final PaymentType paymentType; + final BuyProvider provider; + final String? quoteId; + final List recommendations; + String? rampId; + String? rampName; + String? rampIconPath; + bool _isSelected = false; + bool _isBestRate = false; + bool isBuyAction; + Limits? limits; + + late FiatCurrency _fiatCurrency; + late CryptoCurrency _cryptoCurrency; + + + bool get isSelected => _isSelected; + bool get isBestRate => _isBestRate; + FiatCurrency get fiatCurrency => _fiatCurrency; + CryptoCurrency get cryptoCurrency => _cryptoCurrency; + + @override + bool get isOptionSelected => this._isSelected; + + @override + String get lightIconPath => + provider.isAggregator ? rampIconPath ?? provider.lightIcon : provider.lightIcon; + + @override + String get darkIconPath => + provider.isAggregator ? rampIconPath ?? provider.darkIcon : provider.darkIcon; + + @override + List get badges => recommendations.map((e) => e.title).toList(); + + @override + String get topLeftSubTitle => + this.rate > 0 ? '1 $cryptoName = ${rate.toStringAsFixed(2)} $fiatName' : ''; + + @override + String get bottomLeftSubTitle { + if (limits != null) { + final min = limits!.min; + final max = limits!.max; + return 'min: ${min} ${fiatCurrency.toString()} | max: ${max == double.infinity ? '' : '${max} ${fiatCurrency.toString()}'}'; + } + return ''; + } + + String get fiatName => isBuyAction ? fiatCurrency.toString() : cryptoCurrency.toString(); + + String get cryptoName => isBuyAction ? cryptoCurrency.toString() : fiatCurrency.toString(); + + @override + String? get topRightSubTitle => ''; + + @override + String get topRightSubTitleLightIconPath => provider.isAggregator ? provider.lightIcon : ''; + + @override + String get topRightSubTitleDarkIconPath => provider.isAggregator ? provider.darkIcon : ''; + + String get quoteTitle => '${provider.title} - ${paymentType.name}'; + + String get formatedFee => '$feeAmount ${isBuyAction ? fiatCurrency : cryptoCurrency}'; + + set setIsSelected(bool isSelected) => _isSelected = isSelected; + set setIsBestRate(bool isBestRate) => _isBestRate = isBestRate; + set setFiatCurrency(FiatCurrency fiatCurrency) => _fiatCurrency = fiatCurrency; + set setCryptoCurrency(CryptoCurrency cryptoCurrency) => _cryptoCurrency = cryptoCurrency; + set setLimits(Limits limits) => this.limits = limits; + + factory Quote.fromOnramperJson(Map json, bool isBuyAction, + Map metaData, PaymentType paymentType) { + final rate = _toDouble(json['rate']) ?? 0.0; + final networkFee = _toDouble(json['networkFee']) ?? 0.0; + final transactionFee = _toDouble(json['transactionFee']) ?? 0.0; + final feeAmount = double.parse((networkFee + transactionFee).toStringAsFixed(2)); + + final rampId = json['ramp'] as String? ?? ''; + final rampData = metaData[rampId] ?? {}; + final rampName = rampData['displayName'] as String? ?? ''; + final rampIconPath = rampData['svg'] as String? ?? ''; + + final recommendations = json['recommendations'] != null + ? List.from(json['recommendations'] as List) + : []; + + final enumRecommendations = recommendations + .map((e) => getRecommendationFromString(e)) + .whereType() + .toList(); + + final availablePaymentMethods = json['availablePaymentMethods'] as List? ?? []; + double minLimit = 0.0; + double maxLimit = double.infinity; + + for (var paymentMethod in availablePaymentMethods) { + if (paymentMethod is Map) { + final details = paymentMethod['details'] as Map?; + + if (details != null) { + final limits = details['limits'] as Map?; + + if (limits != null && limits.isNotEmpty) { + final firstLimitEntry = limits.values.first as Map?; + if (firstLimitEntry != null) { + minLimit = _toDouble(firstLimitEntry['min'])?.roundToDouble() ?? 0.0; + maxLimit = _toDouble(firstLimitEntry['max'])?.roundToDouble() ?? double.infinity; + break; + } + } + } + } + } + + return Quote( + rate: rate, + feeAmount: feeAmount, + networkFee: networkFee, + transactionFee: transactionFee, + payout: json['payout'] as double? ?? 0.0, + rampId: rampId, + rampName: rampName, + rampIconPath: rampIconPath, + paymentType: paymentType, + quoteId: json['quoteId'] as String? ?? '', + recommendations: enumRecommendations, + provider: ProvidersHelper.getProviderByType(ProviderType.onramper)!, + isBuyAction: isBuyAction, + limits: Limits(min: minLimit, max: maxLimit), + ); + } + + factory Quote.fromMoonPayJson( + Map json, bool isBuyAction, PaymentType paymentType) { + final rate = isBuyAction + ? json['quoteCurrencyPrice'] as double? ?? 0.0 + : json['baseCurrencyPrice'] as double? ?? 0.0; + final fee = _toDouble(json['feeAmount']) ?? 0.0; + final networkFee = _toDouble(json['networkFeeAmount']) ?? 0.0; + final transactionFee = _toDouble(json['extraFeeAmount']) ?? 0.0; + final feeAmount = double.parse((fee + networkFee + transactionFee).toStringAsFixed(2)); + + final baseCurrency = json['baseCurrency'] as Map?; + + double minLimit = 0.0; + double maxLimit = double.infinity; + + if (baseCurrency != null) { + minLimit = _toDouble(baseCurrency['minAmount']) ?? minLimit; + maxLimit = _toDouble(baseCurrency['maxAmount']) ?? maxLimit; + } + + return Quote( + rate: rate, + feeAmount: feeAmount, + networkFee: networkFee, + transactionFee: transactionFee, + payout: _toDouble(json['quoteCurrencyAmount']) ?? 0.0, + paymentType: paymentType, + recommendations: [], + quoteId: json['signature'] as String? ?? '', + provider: ProvidersHelper.getProviderByType(ProviderType.moonpay)!, + isBuyAction: isBuyAction, + limits: Limits(min: minLimit, max: maxLimit), + ); + } + + factory Quote.fromDFXJson( + Map json, + bool isBuyAction, + PaymentType paymentType, + ) { + final rate = _toDouble(json['exchangeRate']) ?? 0.0; + final fees = json['fees'] as Map; + + final minVolume = _toDouble(json['minVolume']) ?? 0.0; + final maxVolume = _toDouble(json['maxVolume']) ?? double.infinity; + + return Quote( + rate: isBuyAction ? rate : 1 / rate, + feeAmount: _toDouble(json['feeAmount']) ?? 0.0, + networkFee: _toDouble(fees['network']) ?? 0.0, + transactionFee: _toDouble(fees['rate']) ?? 0.0, + payout: _toDouble(json['payout']) ?? 0.0, + paymentType: paymentType, + recommendations: [ProviderRecommendation.lowKyc], + provider: ProvidersHelper.getProviderByType(ProviderType.dfx)!, + isBuyAction: isBuyAction, + limits: Limits(min: minVolume, max: maxVolume), + ); + } + + factory Quote.fromRobinhoodJson( + Map json, bool isBuyAction, PaymentType paymentType) { + final networkFee = json['networkFee'] as Map; + final processingFee = json['processingFee'] as Map; + final networkFeeAmount = _toDouble(networkFee['fiatAmount']) ?? 0.0; + final transactionFeeAmount = _toDouble(processingFee['fiatAmount']) ?? 0.0; + final feeAmount = double.parse((networkFeeAmount + transactionFeeAmount).toStringAsFixed(2)); + + return Quote( + rate: _toDouble(json['price']) ?? 0.0, + feeAmount: feeAmount, + networkFee: _toDouble(networkFee['fiatAmount']) ?? 0.0, + transactionFee: _toDouble(processingFee['fiatAmount']) ?? 0.0, + payout: _toDouble(json['cryptoAmount']) ?? 0.0, + paymentType: paymentType, + recommendations: [], + provider: ProvidersHelper.getProviderByType(ProviderType.robinhood)!, + isBuyAction: isBuyAction, + limits: Limits(min: 0.0, max: double.infinity), + ); + } + + factory Quote.fromMeldJson(Map json, bool isBuyAction, PaymentType paymentType) { + final quotes = json['quotes'][0] as Map; + return Quote( + rate: quotes['exchangeRate'] as double? ?? 0.0, + feeAmount: quotes['totalFee'] as double? ?? 0.0, + networkFee: quotes['networkFee'] as double? ?? 0.0, + transactionFee: quotes['transactionFee'] as double? ?? 0.0, + payout: quotes['payout'] as double? ?? 0.0, + paymentType: paymentType, + recommendations: [], + provider: ProvidersHelper.getProviderByType(ProviderType.meld)!, + isBuyAction: isBuyAction, + limits: Limits(min: 0.0, max: double.infinity), + ); + } + + static double? _toDouble(dynamic value) { + if (value is int) { + return value.toDouble(); + } else if (value is double) { + return value; + } else if (value is String) { + return double.tryParse(value); + } + return null; + } +} diff --git a/lib/buy/dfx/dfx_buy_provider.dart b/lib/buy/dfx/dfx_buy_provider.dart index b3ed72498..c1ed762b1 100644 --- a/lib/buy/dfx/dfx_buy_provider.dart +++ b/lib/buy/dfx/dfx_buy_provider.dart @@ -1,13 +1,17 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:cake_wallet/buy/buy_provider.dart'; +import 'package:cake_wallet/buy/buy_quote.dart'; +import 'package:cake_wallet/buy/payment_method.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/connect_device/connect_device_page.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/utils/device_info.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; @@ -15,10 +19,12 @@ import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; class DFXBuyProvider extends BuyProvider { - DFXBuyProvider({required WalletBase wallet, bool isTestEnvironment = false, LedgerViewModel? ledgerVM}) + DFXBuyProvider( + {required WalletBase wallet, bool isTestEnvironment = false, LedgerViewModel? ledgerVM}) : super(wallet: wallet, isTestEnvironment: isTestEnvironment, ledgerVM: ledgerVM); static const _baseUrl = 'api.dfx.swiss'; + // static const _signMessagePath = '/v1/auth/signMessage'; static const _authPath = '/v1/auth'; static const walletName = 'CakeWallet'; @@ -35,24 +41,8 @@ class DFXBuyProvider extends BuyProvider { @override String get darkIcon => 'assets/images/dfx_dark.png'; - String get assetOut { - switch (wallet.type) { - case WalletType.bitcoin: - return 'BTC'; - case WalletType.bitcoinCash: - return 'BCH'; - case WalletType.litecoin: - return 'LTC'; - case WalletType.monero: - return 'XMR'; - case WalletType.ethereum: - return 'ETH'; - case WalletType.polygon: - return 'MATIC'; - default: - throw Exception("WalletType is not available for DFX ${wallet.type}"); - } - } + @override + bool get isAggregator => false; String get blockchain { switch (wallet.type) { @@ -60,21 +50,13 @@ class DFXBuyProvider extends BuyProvider { case WalletType.bitcoinCash: case WalletType.litecoin: return 'Bitcoin'; - case WalletType.monero: - return 'Monero'; - case WalletType.ethereum: - return 'Ethereum'; - case WalletType.polygon: - return 'Polygon'; default: - throw Exception("WalletType is not available for DFX ${wallet.type}"); + return walletTypeToString(wallet.type); } } - String get walletAddress => - wallet.walletAddresses.primaryAddress ?? wallet.walletAddresses.address; - Future getSignMessage() async => + Future getSignMessage(String walletAddress) async => "By_signing_this_message,_you_confirm_that_you_are_the_sole_owner_of_the_provided_Blockchain_address._Your_ID:_$walletAddress"; // // Lets keep this just in case, but we can avoid this API Call @@ -92,8 +74,9 @@ class DFXBuyProvider extends BuyProvider { // } // } - Future auth() async { - final signMessage = await getSignature(await getSignMessage()); + Future auth(String walletAddress) async { + final signMessage = await getSignature( + await getSignMessage(walletAddress), walletAddress); final requestBody = jsonEncode({ 'wallet': walletName, @@ -120,7 +103,7 @@ class DFXBuyProvider extends BuyProvider { } } - Future getSignature(String message) async { + Future getSignature(String message, String walletAddress) async { switch (wallet.type) { case WalletType.ethereum: case WalletType.polygon: @@ -135,8 +118,178 @@ class DFXBuyProvider extends BuyProvider { } } + Future> fetchFiatCredentials(String fiatCurrency) async { + final url = Uri.https(_baseUrl, '/v1/fiat'); + + try { + final response = await http.get(url, headers: {'accept': 'application/json'}); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as List; + for (final item in data) { + if (item['name'] == fiatCurrency) return item as Map; + } + log('DFX does not support fiat: $fiatCurrency'); + return {}; + } else { + log('DFX Failed to fetch fiat currencies: ${response.statusCode}'); + return {}; + } + } catch (e) { + print('DFX Error fetching fiat currencies: $e'); + return {}; + } + } + + Future> fetchAssetCredential(String assetsName) async { + final url = Uri.https(_baseUrl, '/v1/asset', {'blockchains': blockchain}); + + try { + final response = await http.get(url, headers: {'accept': 'application/json'}); + + if (response.statusCode == 200) { + final responseData = jsonDecode(response.body); + + if (responseData is List && responseData.isNotEmpty) { + return responseData.first as Map; + } else if (responseData is Map) { + return responseData; + } else { + log('DFX: Does not support this asset name : ${blockchain}'); + } + } else { + log('DFX: Failed to fetch assets: ${response.statusCode}'); + } + } catch (e) { + log('DFX: Error fetching assets: $e'); + } + return {}; + } + + Future> getAvailablePaymentTypes( + String fiatCurrency, String cryptoCurrency, bool isBuyAction) async { + final List paymentMethods = []; + + if (isBuyAction) { + final fiatBuyCredentials = await fetchFiatCredentials(fiatCurrency); + if (fiatBuyCredentials.isNotEmpty) { + fiatBuyCredentials.forEach((key, value) { + if (key == 'limits') { + final limits = value as Map; + limits.forEach((paymentMethodKey, paymentMethodValue) { + final min = _toDouble(paymentMethodValue['minVolume']); + final max = _toDouble(paymentMethodValue['maxVolume']); + if (min != null && max != null && min > 0 && max > 0) { + final paymentMethod = PaymentMethod.fromDFX( + paymentMethodKey, _getPaymentTypeByString(paymentMethodKey)); + paymentMethods.add(paymentMethod); + } + }); + } + }); + } + } else { + final assetCredentials = await fetchAssetCredential(cryptoCurrency); + if (assetCredentials.isNotEmpty) { + if (assetCredentials['sellable'] == true) { + final availablePaymentTypes = [ + PaymentType.bankTransfer, + PaymentType.creditCard, + PaymentType.sepa + ]; + availablePaymentTypes.forEach((element) { + final paymentMethod = PaymentMethod.fromDFX(normalizePaymentMethod(element)!, element); + paymentMethods.add(paymentMethod); + }); + } + } + } + + return paymentMethods; + } + @override - Future launchProvider(BuildContext context, bool? isBuyAction) async { + Future?> fetchQuote( + {required CryptoCurrency cryptoCurrency, + required FiatCurrency fiatCurrency, + required double amount, + required bool isBuyAction, + required String walletAddress, + PaymentType? paymentType, + String? countryCode}) async { + /// if buying with any currency other than eur or chf then DFX is not supported + + if (isBuyAction && (fiatCurrency != FiatCurrency.eur && fiatCurrency != FiatCurrency.chf)) { + return null; + } + + String? paymentMethod; + if (paymentType != null && paymentType != PaymentType.all) { + paymentMethod = normalizePaymentMethod(paymentType); + if (paymentMethod == null) paymentMethod = paymentType.name; + } else { + paymentMethod = 'Bank'; + } + + final action = isBuyAction ? 'buy' : 'sell'; + + if (isBuyAction && cryptoCurrency != wallet.currency) return null; + + final fiatCredentials = await fetchFiatCredentials(fiatCurrency.name.toString()); + if (fiatCredentials['id'] == null) return null; + + final assetCredentials = await fetchAssetCredential(cryptoCurrency.title.toString()); + if (assetCredentials['id'] == null) return null; + + log('DFX: Fetching $action quote: ${isBuyAction ? cryptoCurrency : fiatCurrency} -> ${isBuyAction ? fiatCurrency : cryptoCurrency}, amount: $amount, paymentMethod: $paymentMethod'); + + final url = Uri.https(_baseUrl, '/v1/$action/quote'); + final headers = {'accept': 'application/json', 'Content-Type': 'application/json'}; + final body = jsonEncode({ + 'currency': {'id': fiatCredentials['id'] as int}, + 'asset': {'id': assetCredentials['id']}, + 'amount': amount, + 'targetAmount': 0, + 'paymentMethod': paymentMethod, + 'discountCode': '' + }); + + try { + final response = await http.put(url, headers: headers, body: body); + final responseData = jsonDecode(response.body); + + if (response.statusCode == 200) { + if (responseData is Map) { + final paymentType = _getPaymentTypeByString(responseData['paymentMethod'] as String?); + final quote = Quote.fromDFXJson(responseData, isBuyAction, paymentType); + quote.setFiatCurrency = fiatCurrency; + quote.setCryptoCurrency = cryptoCurrency; + return [quote]; + } else { + print('DFX: Unexpected data type: ${responseData.runtimeType}'); + return null; + } + } else { + if (responseData is Map && responseData.containsKey('message')) { + print('DFX Error: ${responseData['message']}'); + } else { + print('DFX Failed to fetch buy quote: ${response.statusCode}'); + } + return null; + } + } catch (e) { + print('DFX Error fetching buy quote: $e'); + return null; + } + } + + Future? launchProvider( + {required BuildContext context, + required Quote quote, + required double amount, + required bool isBuyAction, + required String cryptoCurrencyAddress, + String? countryCode}) async { if (wallet.isHardwareWallet) { if (!ledgerVM!.isConnected) { await Navigator.of(context).pushNamed(Routes.connectDevices, @@ -152,26 +305,21 @@ class DFXBuyProvider extends BuyProvider { } try { - final assetOut = this.assetOut; - final blockchain = this.blockchain; - final actionType = isBuyAction == true ? '/buy' : '/sell'; + final actionType = isBuyAction ? '/buy' : '/sell'; - final accessToken = await auth(); + final accessToken = await auth(cryptoCurrencyAddress); final uri = Uri.https('services.dfx.swiss', actionType, { 'session': accessToken, 'lang': 'en', - 'asset-out': assetOut, + 'asset-out': isBuyAction ? quote.cryptoCurrency.toString() : quote.fiatCurrency.toString(), 'blockchain': blockchain, - 'asset-in': 'EUR', + 'asset-in': isBuyAction ? quote.fiatCurrency.toString() : quote.cryptoCurrency.toString(), + 'amount': amount.toString() //TODO: Amount does not work }); if (await canLaunchUrl(uri)) { - if (DeviceInfo.instance.isMobile) { - Navigator.of(context).pushNamed(Routes.webViewPage, arguments: [title, uri]); - } else { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } + await launchUrl(uri, mode: LaunchMode.externalApplication); } else { throw Exception('Could not launch URL'); } @@ -187,4 +335,39 @@ class DFXBuyProvider extends BuyProvider { }); } } + + String? normalizePaymentMethod(PaymentType paymentMethod) { + switch (paymentMethod) { + case PaymentType.bankTransfer: + return 'Bank'; + case PaymentType.creditCard: + return 'Card'; + case PaymentType.sepa: + return 'Instant'; + default: + return null; + } + } + + PaymentType _getPaymentTypeByString(String? paymentMethod) { + switch (paymentMethod) { + case 'Bank': + return PaymentType.bankTransfer; + case 'Card': + return PaymentType.creditCard; + case 'Instant': + return PaymentType.sepa; + default: + return PaymentType.all; + } + } + + double? _toDouble(dynamic value) { + if (value is int) { + return value.toDouble(); + } else if (value is double) { + return value; + } + return null; + } } diff --git a/lib/buy/meld/meld_buy_provider.dart b/lib/buy/meld/meld_buy_provider.dart new file mode 100644 index 000000000..696301f2e --- /dev/null +++ b/lib/buy/meld/meld_buy_provider.dart @@ -0,0 +1,266 @@ +import 'dart:convert'; + +import 'package:cake_wallet/.secrets.g.dart' as secrets; +import 'package:cake_wallet/buy/buy_provider.dart'; +import 'package:cake_wallet/buy/buy_quote.dart'; +import 'package:cake_wallet/buy/payment_method.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; +import 'package:cake_wallet/entities/provider_types.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/utils/device_info.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:flutter/material.dart'; +import 'dart:developer'; +import 'package:http/http.dart' as http; +import 'package:url_launcher/url_launcher.dart'; + +class MeldBuyProvider extends BuyProvider { + MeldBuyProvider({required WalletBase wallet, bool isTestEnvironment = false}) + : super(wallet: wallet, isTestEnvironment: isTestEnvironment, ledgerVM: null); + + static const _isProduction = false; + + static const _baseUrl = _isProduction ? 'api.meld.io' : 'api-sb.meld.io'; + static const _providersProperties = '/service-providers/properties'; + static const _paymentMethodsPath = '/payment-methods'; + static const _quotePath = '/payments/crypto/quote'; + + static const String sandboxUrl = 'sb.fluidmoney.xyz'; + static const String productionUrl = 'fluidmoney.xyz'; + + static const String _baseWidgetUrl = _isProduction ? productionUrl : sandboxUrl; + + static String get _testApiKey => secrets.meldTestApiKey; + + static String get _testPublicKey => '' ; //secrets.meldTestPublicKey; + + @override + String get title => 'Meld'; + + @override + String get providerDescription => 'Meld Buy Provider'; + + @override + String get lightIcon => 'assets/images/meld_logo.svg'; + + @override + String get darkIcon => 'assets/images/meld_logo.svg'; + + @override + bool get isAggregator => true; + + @override + Future> getAvailablePaymentTypes( + String fiatCurrency, String cryptoCurrency, bool isBuyAction) async { + final params = {'fiatCurrencies': fiatCurrency, 'statuses': 'LIVE,RECENTLY_ADDED,BUILDING'}; + + final path = '$_providersProperties$_paymentMethodsPath'; + final url = Uri.https(_baseUrl, path, params); + + try { + final response = await http.get( + url, + headers: { + 'Authorization': _isProduction ? '' : _testApiKey, + 'Meld-Version': '2023-12-19', + 'accept': 'application/json', + 'content-type': 'application/json', + }, + ); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as List; + final paymentMethods = + data.map((e) => PaymentMethod.fromMeldJson(e as Map)).toList(); + return paymentMethods; + } else { + print('Meld: Failed to fetch payment types'); + return List.empty(); + } + } catch (e) { + print('Meld: Failed to fetch payment types: $e'); + return List.empty(); + } + } + + @override + Future?> fetchQuote( + {required CryptoCurrency cryptoCurrency, + required FiatCurrency fiatCurrency, + required double amount, + required bool isBuyAction, + required String walletAddress, + PaymentType? paymentType, + String? countryCode}) async { + String? paymentMethod; + if (paymentType != null && paymentType != PaymentType.all) { + paymentMethod = normalizePaymentMethod(paymentType); + if (paymentMethod == null) paymentMethod = paymentType.name; + } + + log('Meld: Fetching buy quote: ${isBuyAction ? cryptoCurrency : fiatCurrency} -> ${isBuyAction ? fiatCurrency : cryptoCurrency}, amount: $amount'); + + final url = Uri.https(_baseUrl, _quotePath); + final headers = { + 'Authorization': _testApiKey, + 'Meld-Version': '2023-12-19', + 'accept': 'application/json', + 'content-type': 'application/json', + }; + final body = jsonEncode({ + 'countryCode': countryCode, + 'destinationCurrencyCode': isBuyAction ? fiatCurrency.name : cryptoCurrency.title, + 'sourceAmount': amount, + 'sourceCurrencyCode': isBuyAction ? cryptoCurrency.title : fiatCurrency.name, + if (paymentMethod != null) 'paymentMethod': paymentMethod, + }); + + try { + final response = await http.post(url, headers: headers, body: body); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + final paymentType = _getPaymentTypeByString(data['paymentMethodType'] as String?); + final quote = Quote.fromMeldJson(data, isBuyAction, paymentType); + + quote.setFiatCurrency = fiatCurrency; + quote.setCryptoCurrency = cryptoCurrency; + + return [quote]; + } else { + return null; + } + } catch (e) { + print('Error fetching buy quote: $e'); + return null; + } + } + + Future? launchProvider( + {required BuildContext context, + required Quote quote, + required double amount, + required bool isBuyAction, + required String cryptoCurrencyAddress, + String? countryCode}) async { + final actionType = isBuyAction ? 'BUY' : 'SELL'; + + final params = { + 'publicKey': _isProduction ? '' : _testPublicKey, + 'countryCode': countryCode, + //'paymentMethodType': normalizePaymentMethod(paymentMethod.paymentMethodType), + 'sourceAmount': amount.toString(), + 'sourceCurrencyCode': quote.fiatCurrency, + 'destinationCurrencyCode': quote.cryptoCurrency, + 'walletAddress': cryptoCurrencyAddress, + 'transactionType': actionType + }; + + final uri = Uri.https(_baseWidgetUrl, '', params); + + try { + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + throw Exception('Could not launch URL'); + } + } catch (e) { + await showPopUp( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + alertTitle: "Meld", + alertContent: S.of(context).buy_provider_unavailable + ': $e', + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop()); + }); + } + } + + String? normalizePaymentMethod(PaymentType paymentType) { + switch (paymentType) { + case PaymentType.creditCard: + return 'CREDIT_DEBIT_CARD'; + case PaymentType.applePay: + return 'APPLE_PAY'; + case PaymentType.googlePay: + return 'GOOGLE_PAY'; + case PaymentType.neteller: + return 'NETELLER'; + case PaymentType.skrill: + return 'SKRILL'; + case PaymentType.sepa: + return 'SEPA'; + case PaymentType.sepaInstant: + return 'SEPA_INSTANT'; + case PaymentType.ach: + return 'ACH'; + case PaymentType.achInstant: + return 'INSTANT_ACH'; + case PaymentType.Khipu: + return 'KHIPU'; + case PaymentType.ovo: + return 'OVO'; + case PaymentType.zaloPay: + return 'ZALOPAY'; + case PaymentType.zaloBankTransfer: + return 'ZA_BANK_TRANSFER'; + case PaymentType.gcash: + return 'GCASH'; + case PaymentType.imps: + return 'IMPS'; + case PaymentType.dana: + return 'DANA'; + case PaymentType.ideal: + return 'IDEAL'; + default: + return null; + } + } + + PaymentType _getPaymentTypeByString(String? paymentMethod) { + switch (paymentMethod?.toUpperCase()) { + case 'CREDIT_DEBIT_CARD': + return PaymentType.creditCard; + case 'APPLE_PAY': + return PaymentType.applePay; + case 'GOOGLE_PAY': + return PaymentType.googlePay; + case 'NETELLER': + return PaymentType.neteller; + case 'SKRILL': + return PaymentType.skrill; + case 'SEPA': + return PaymentType.sepa; + case 'SEPA_INSTANT': + return PaymentType.sepaInstant; + case 'ACH': + return PaymentType.ach; + case 'INSTANT_ACH': + return PaymentType.achInstant; + case 'KHIPU': + return PaymentType.Khipu; + case 'OVO': + return PaymentType.ovo; + case 'ZALOPAY': + return PaymentType.zaloPay; + case 'ZA_BANK_TRANSFER': + return PaymentType.zaloBankTransfer; + case 'GCASH': + return PaymentType.gcash; + case 'IMPS': + return PaymentType.imps; + case 'DANA': + return PaymentType.dana; + case 'IDEAL': + return PaymentType.ideal; + default: + return PaymentType.all; + } + } +} diff --git a/lib/buy/moonpay/moonpay_provider.dart b/lib/buy/moonpay/moonpay_provider.dart index 67ee75d7c..b93c0f02d 100644 --- a/lib/buy/moonpay/moonpay_provider.dart +++ b/lib/buy/moonpay/moonpay_provider.dart @@ -1,19 +1,20 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:cake_wallet/.secrets.g.dart' as secrets; -import 'package:cake_wallet/buy/buy_amount.dart'; import 'package:cake_wallet/buy/buy_exception.dart'; import 'package:cake_wallet/buy/buy_provider.dart'; import 'package:cake_wallet/buy/buy_provider_description.dart'; +import 'package:cake_wallet/buy/buy_quote.dart'; import 'package:cake_wallet/buy/order.dart'; +import 'package:cake_wallet/buy/payment_method.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/exchange/trade_state.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/palette.dart'; -import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/themes/theme_base.dart'; -import 'package:cake_wallet/utils/device_info.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_type.dart'; @@ -39,6 +40,15 @@ class MoonPayProvider extends BuyProvider { static const _baseBuyProductUrl = 'buy.moonpay.com'; static const _cIdBaseUrl = 'exchange-helper.cakewallet.com'; static const _apiUrl = 'https://api.moonpay.com'; + static const _baseUrl = 'api.moonpay.com'; + static const _currenciesPath = '/v3/currencies'; + static const _buyQuote = '/buy_quote'; + static const _sellQuote = '/sell_quote'; + + static const _transactionsSuffix = '/v1/transactions'; + + final String baseBuyUrl; + final String baseSellUrl; @override String get providerDescription => @@ -53,6 +63,17 @@ class MoonPayProvider extends BuyProvider { @override String get darkIcon => 'assets/images/moonpay_dark.png'; + @override + bool get isAggregator => false; + + static String get _apiKey => secrets.moonPayApiKey; + + String get currencyCode => walletTypeToCryptoCurrency(wallet.type).title.toLowerCase(); + + String get trackUrl => baseBuyUrl + '/transaction_receipt?transactionId='; + + static String get _exchangeHelperApiKey => secrets.exchangeHelperApiKey; + static String themeToMoonPayTheme(ThemeBase theme) { switch (theme.type) { case ThemeType.bright: @@ -63,28 +84,12 @@ class MoonPayProvider extends BuyProvider { } } - static String get _apiKey => secrets.moonPayApiKey; - - final String baseBuyUrl; - final String baseSellUrl; - - String get currencyCode => walletTypeToCryptoCurrency(wallet.type).title.toLowerCase(); - - String get trackUrl => baseBuyUrl + '/transaction_receipt?transactionId='; - - static String get _exchangeHelperApiKey => secrets.exchangeHelperApiKey; - Future getMoonpaySignature(String query) async { final uri = Uri.https(_cIdBaseUrl, "/api/moonpay"); - final response = await post( - uri, - headers: { - 'Content-Type': 'application/json', - 'x-api-key': _exchangeHelperApiKey, - }, - body: json.encode({'query': query}), - ); + final response = await post(uri, + headers: {'Content-Type': 'application/json', 'x-api-key': _exchangeHelperApiKey}, + body: json.encode({'query': query})); if (response.statusCode == 200) { return (jsonDecode(response.body) as Map)['signature'] as String; @@ -94,85 +99,195 @@ class MoonPayProvider extends BuyProvider { } } - Future requestSellMoonPayUrl({ - required CryptoCurrency currency, - required String refundWalletAddress, - required SettingsStore settingsStore, - }) async { - final params = { - 'theme': themeToMoonPayTheme(settingsStore.currentTheme), - 'language': settingsStore.languageCode, - 'colorCode': settingsStore.currentTheme.type == ThemeType.dark - ? '#${Palette.blueCraiola.value.toRadixString(16).substring(2, 8)}' - : '#${Palette.moderateSlateBlue.value.toRadixString(16).substring(2, 8)}', - 'defaultCurrencyCode': _normalizeCurrency(currency), - 'refundWalletAddress': refundWalletAddress, - }; + Future> fetchFiatCredentials( + String fiatCurrency, String cryptocurrency, String? paymentMethod) async { + final params = {'baseCurrencyCode': fiatCurrency.toLowerCase(), 'apiKey': _apiKey}; - if (_apiKey.isNotEmpty) { - params['apiKey'] = _apiKey; + if (paymentMethod != null) params['paymentMethod'] = paymentMethod; + + final path = '$_currenciesPath/${cryptocurrency.toLowerCase()}/limits'; + final url = Uri.https(_baseUrl, path, params); + + try { + final response = await get(url, headers: {'accept': 'application/json'}); + if (response.statusCode == 200) { + return jsonDecode(response.body) as Map; + } else { + print('MoonPay does not support fiat: $fiatCurrency'); + return {}; + } + } catch (e) { + print('MoonPay Error fetching fiat currencies: $e'); + return {}; } - - final originalUri = Uri.https( - baseSellUrl, - '', - params, - ); - - if (isTestEnvironment) { - return originalUri; - } - - final signature = await getMoonpaySignature('?${originalUri.query}'); - - final query = Map.from(originalUri.queryParameters); - query['signature'] = signature; - final signedUri = originalUri.replace(queryParameters: query); - return signedUri; } - // BUY: - static const _currenciesSuffix = '/v3/currencies'; - static const _quoteSuffix = '/buy_quote'; - static const _transactionsSuffix = '/v1/transactions'; - static const _ipAddressSuffix = '/v4/ip_address'; + Future> getAvailablePaymentTypes( + String fiatCurrency, String cryptoCurrency, bool isBuyAction) async { + final List paymentMethods = []; + + if (isBuyAction) { + final fiatBuyCredentials = await fetchFiatCredentials(fiatCurrency, cryptoCurrency, null); + if (fiatBuyCredentials.isNotEmpty) { + final paymentMethod = fiatBuyCredentials['paymentMethod'] as String?; + paymentMethods.add(PaymentMethod.fromMoonPayJson( + fiatBuyCredentials, _getPaymentTypeByString(paymentMethod))); + return paymentMethods; + } + } + + return paymentMethods; + } + + @override + Future?> fetchQuote( + {required CryptoCurrency cryptoCurrency, + required FiatCurrency fiatCurrency, + required double amount, + required bool isBuyAction, + required String walletAddress, + PaymentType? paymentType, + String? countryCode}) async { + String? paymentMethod; + + if (paymentType != null && paymentType != PaymentType.all) { + paymentMethod = normalizePaymentMethod(paymentType); + if (paymentMethod == null) paymentMethod = paymentType.name; + } else { + paymentMethod = 'credit_debit_card'; + } + + final action = isBuyAction ? 'buy' : 'sell'; + + final formattedCryptoCurrency = _normalizeCurrency(cryptoCurrency); + final baseCurrencyCode = + isBuyAction ? fiatCurrency.name.toLowerCase() : cryptoCurrency.title.toLowerCase(); - Future requestBuyMoonPayUrl({ - required CryptoCurrency currency, - required SettingsStore settingsStore, - required String walletAddress, - String? amount, - }) async { final params = { - 'theme': themeToMoonPayTheme(settingsStore.currentTheme), - 'language': settingsStore.languageCode, - 'colorCode': settingsStore.currentTheme.type == ThemeType.dark + 'baseCurrencyCode': baseCurrencyCode, + 'baseCurrencyAmount': amount.toString(), + 'amount': amount.toString(), + 'paymentMethod': paymentMethod, + 'areFeesIncluded': 'false', + 'apiKey': _apiKey + }; + + log('MoonPay: Fetching $action quote: ${isBuyAction ? formattedCryptoCurrency : fiatCurrency.name.toLowerCase()} -> ${isBuyAction ? baseCurrencyCode : formattedCryptoCurrency}, amount: $amount, paymentMethod: $paymentMethod'); + + final quotePath = isBuyAction ? _buyQuote : _sellQuote; + + final path = '$_currenciesPath/$formattedCryptoCurrency$quotePath'; + final url = Uri.https(_baseUrl, path, params); + try { + final response = await get(url); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as Map; + + // Check if the response is for the correct fiat currency + if (isBuyAction) { + final fiatCurrencyCode = data['baseCurrencyCode'] as String?; + if (fiatCurrencyCode == null || fiatCurrencyCode != fiatCurrency.name.toLowerCase()) + return null; + } else { + final quoteCurrency = data['quoteCurrency'] as Map?; + if (quoteCurrency == null || quoteCurrency['code'] != fiatCurrency.name.toLowerCase()) + return null; + } + + final paymentMethods = data['paymentMethod'] as String?; + final quote = + Quote.fromMoonPayJson(data, isBuyAction, _getPaymentTypeByString(paymentMethods)); + + quote.setFiatCurrency = fiatCurrency; + quote.setCryptoCurrency = cryptoCurrency; + + return [quote]; + } else { + print('Moon Pay: Error fetching buy quote: '); + return null; + } + } catch (e) { + print('Moon Pay: Error fetching buy quote: $e'); + return null; + } + } + + @override + Future? launchProvider( + {required BuildContext context, + required Quote quote, + required double amount, + required bool isBuyAction, + required String cryptoCurrencyAddress, + String? countryCode}) async { + + final Map params = { + 'theme': themeToMoonPayTheme(_settingsStore.currentTheme), + 'language': _settingsStore.languageCode, + 'colorCode': _settingsStore.currentTheme.type == ThemeType.dark ? '#${Palette.blueCraiola.value.toRadixString(16).substring(2, 8)}' : '#${Palette.moderateSlateBlue.value.toRadixString(16).substring(2, 8)}', - 'baseCurrencyCode': settingsStore.fiatCurrency.title, - 'baseCurrencyAmount': amount ?? '0', - 'currencyCode': _normalizeCurrency(currency), - 'walletAddress': walletAddress, + 'baseCurrencyCode': isBuyAction ? quote.fiatCurrency.name : quote.cryptoCurrency.name, + 'baseCurrencyAmount': amount.toString(), + 'walletAddress': cryptoCurrencyAddress, 'lockAmount': 'false', 'showAllCurrencies': 'false', 'showWalletAddressForm': 'false', - 'enabledPaymentMethods': - 'credit_debit_card,apple_pay,google_pay,samsung_pay,sepa_bank_transfer,gbp_bank_transfer,gbp_open_banking_payment', + if (isBuyAction) + 'enabledPaymentMethods': normalizePaymentMethod(quote.paymentType) ?? + 'credit_debit_card,apple_pay,google_pay,samsung_pay,sepa_bank_transfer,gbp_bank_transfer,gbp_open_banking_payment', + if (!isBuyAction) 'refundWalletAddress': cryptoCurrencyAddress }; - if (_apiKey.isNotEmpty) { - params['apiKey'] = _apiKey; - } + if (isBuyAction) params['currencyCode'] = quote.cryptoCurrency.name; + if (!isBuyAction) params['quoteCurrencyCode'] = quote.cryptoCurrency.name; - final originalUri = Uri.https( - baseBuyUrl, - '', - params, - ); + try { + { + final uri = await requestMoonPayUrl( + walletAddress: cryptoCurrencyAddress, + settingsStore: _settingsStore, + isBuyAction: isBuyAction, + amount: amount.toString(), + params: params); - if (isTestEnvironment) { - return originalUri; + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } else { + throw Exception('Could not launch URL'); + } + } + } catch (e) { + if (context.mounted) { + await showDialog( + context: context, + builder: (BuildContext context) { + return AlertWithOneAction( + alertTitle: 'MoonPay', + alertContent: 'The MoonPay service is currently unavailable: $e', + buttonText: S.of(context).ok, + buttonAction: () => Navigator.of(context).pop(), + ); + }, + ); + } } + } + + Future requestMoonPayUrl({ + required String walletAddress, + required SettingsStore settingsStore, + required bool isBuyAction, + required Map params, + String? amount, + }) async { + if (_apiKey.isNotEmpty) params['apiKey'] = _apiKey; + + final baseUrl = isBuyAction ? baseBuyUrl : baseSellUrl; + final originalUri = Uri.https(baseUrl, '', params); + + if (isTestEnvironment) return originalUri; final signature = await getMoonpaySignature('?${originalUri.query}'); final query = Map.from(originalUri.queryParameters); @@ -181,33 +296,6 @@ class MoonPayProvider extends BuyProvider { return signedUri; } - Future calculateAmount(String amount, String sourceCurrency) async { - final url = _apiUrl + - _currenciesSuffix + - '/$currencyCode' + - _quoteSuffix + - '/?apiKey=' + - _apiKey + - '&baseCurrencyAmount=' + - amount + - '&baseCurrencyCode=' + - sourceCurrency.toLowerCase(); - final uri = Uri.parse(url); - final response = await get(uri); - - if (response.statusCode != 200) { - throw BuyException(title: providerDescription, content: 'Quote is not found!'); - } - - final responseJSON = json.decode(response.body) as Map; - final sourceAmount = responseJSON['totalAmount'] as double; - final destAmount = responseJSON['quoteCurrencyAmount'] as double; - final minSourceAmount = responseJSON['baseCurrency']['minAmount'] as int; - - return BuyAmount( - sourceAmount: sourceAmount, destAmount: destAmount, minAmount: minSourceAmount); - } - Future findOrderById(String id) async { final url = _apiUrl + _transactionsSuffix + '/$id' + '?apiKey=' + _apiKey; final uri = Uri.parse(url); @@ -235,74 +323,83 @@ class MoonPayProvider extends BuyProvider { walletId: wallet.id); } - static Future onEnabled() async { - final url = _apiUrl + _ipAddressSuffix + '?apiKey=' + _apiKey; - var isBuyEnable = false; - final uri = Uri.parse(url); - final response = await get(uri); - - try { - final responseJSON = json.decode(response.body) as Map; - isBuyEnable = responseJSON['isBuyAllowed'] as bool; - } catch (e) { - isBuyEnable = false; - print(e.toString()); - } - - return isBuyEnable; - } - - @override - Future launchProvider(BuildContext context, bool? isBuyAction) async { - try { - late final Uri uri; - if (isBuyAction ?? true) { - uri = await requestBuyMoonPayUrl( - currency: wallet.currency, - walletAddress: wallet.walletAddresses.address, - settingsStore: _settingsStore, - ); - } else { - uri = await requestSellMoonPayUrl( - currency: wallet.currency, - refundWalletAddress: wallet.walletAddresses.address, - settingsStore: _settingsStore, - ); - } - - if (await canLaunchUrl(uri)) { - if (DeviceInfo.instance.isMobile) { - Navigator.of(context).pushNamed(Routes.webViewPage, arguments: ['MoonPay', uri]); - } else { - await launchUrl(uri, mode: LaunchMode.externalApplication); - } - } else { - throw Exception('Could not launch URL'); - } - } catch (e) { - if (context.mounted) { - await showDialog( - context: context, - builder: (BuildContext context) { - return AlertWithOneAction( - alertTitle: 'MoonPay', - alertContent: 'The MoonPay service is currently unavailable: $e', - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop(), - ); - }, - ); - } - } - } - String _normalizeCurrency(CryptoCurrency currency) { - if (currency == CryptoCurrency.maticpoly) { - return "POL_POLYGON"; - } else if (currency == CryptoCurrency.matic) { - return "POL"; + if (currency.tag == 'POLY') { + return '${currency.title.toLowerCase()}_polygon'; + } + + if (currency.tag == 'TRX') { + return '${currency.title.toLowerCase()}_trx'; } return currency.toString().toLowerCase(); } + + String? normalizePaymentMethod(PaymentType paymentMethod) { + switch (paymentMethod) { + case PaymentType.creditCard: + return 'credit_debit_card'; + case PaymentType.debitCard: + return 'credit_debit_card'; + case PaymentType.ach: + return 'ach_bank_transfer'; + case PaymentType.applePay: + return 'apple_pay'; + case PaymentType.googlePay: + return 'google_pay'; + case PaymentType.sepa: + return 'sepa_bank_transfer'; + case PaymentType.paypal: + return 'paypal'; + case PaymentType.sepaOpenBankingPayment: + return 'sepa_open_banking_payment'; + case PaymentType.gbpOpenBankingPayment: + return 'gbp_open_banking_payment'; + case PaymentType.lowCostAch: + return 'low_cost_ach'; + case PaymentType.mobileWallet: + return 'mobile_wallet'; + case PaymentType.pixInstantPayment: + return 'pix_instant_payment'; + case PaymentType.yellowCardBankTransfer: + return 'yellow_card_bank_transfer'; + case PaymentType.fiatBalance: + return 'fiat_balance'; + default: + return null; + } + } + + PaymentType _getPaymentTypeByString(String? paymentMethod) { + switch (paymentMethod) { + case 'ach_bank_transfer': + return PaymentType.ach; + case 'apple_pay': + return PaymentType.applePay; + case 'credit_debit_card': + return PaymentType.creditCard; + case 'fiat_balance': + return PaymentType.fiatBalance; + case 'gbp_open_banking_payment': + return PaymentType.gbpOpenBankingPayment; + case 'google_pay': + return PaymentType.googlePay; + case 'low_cost_ach': + return PaymentType.lowCostAch; + case 'mobile_wallet': + return PaymentType.mobileWallet; + case 'paypal': + return PaymentType.paypal; + case 'pix_instant_payment': + return PaymentType.pixInstantPayment; + case 'sepa_bank_transfer': + return PaymentType.sepa; + case 'sepa_open_banking_payment': + return PaymentType.sepaOpenBankingPayment; + case 'yellow_card_bank_transfer': + return PaymentType.yellowCardBankTransfer; + default: + return PaymentType.all; + } + } } diff --git a/lib/buy/onramper/onramper_buy_provider.dart b/lib/buy/onramper/onramper_buy_provider.dart index 1f1c86962..ee9c9bb74 100644 --- a/lib/buy/onramper/onramper_buy_provider.dart +++ b/lib/buy/onramper/onramper_buy_provider.dart @@ -1,13 +1,19 @@ +import 'dart:convert'; +import 'dart:developer'; + import 'package:cake_wallet/.secrets.g.dart' as secrets; import 'package:cake_wallet/buy/buy_provider.dart'; +import 'package:cake_wallet/buy/buy_quote.dart'; +import 'package:cake_wallet/buy/payment_method.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; -import 'package:cake_wallet/utils/device_info.dart'; import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; class OnRamperBuyProvider extends BuyProvider { @@ -16,9 +22,15 @@ class OnRamperBuyProvider extends BuyProvider { : super(wallet: wallet, isTestEnvironment: isTestEnvironment, ledgerVM: null); static const _baseUrl = 'buy.onramper.com'; + static const _baseApiUrl = 'api.onramper.com'; + static const quotes = '/quotes'; + static const paymentTypes = '/payment-types'; + static const supported = '/supported'; final SettingsStore _settingsStore; + String get _apiKey => secrets.onramperApiKey; + @override String get title => 'Onramper'; @@ -31,74 +43,327 @@ class OnRamperBuyProvider extends BuyProvider { @override String get darkIcon => 'assets/images/onramper_dark.png'; - String get _apiKey => secrets.onramperApiKey; + @override + bool get isAggregator => true; - String get _normalizeCryptoCurrency { - switch (wallet.currency) { - case CryptoCurrency.ltc: - return "LTC_LITECOIN"; - case CryptoCurrency.xmr: - return "XMR_MONERO"; - case CryptoCurrency.bch: - return "BCH_BITCOINCASH"; - case CryptoCurrency.nano: - return "XNO_NANO"; - default: - return wallet.currency.title; + Future> getAvailablePaymentTypes( + String fiatCurrency, String cryptoCurrency, bool isBuyAction) async { + final params = { + 'fiatCurrency': fiatCurrency, + 'type': isBuyAction ? 'buy' : 'sell', + 'isRecurringPayment': 'false' + }; + + final url = Uri.https(_baseApiUrl, '$supported$paymentTypes/$fiatCurrency', params); + + try { + final response = + await http.get(url, headers: {'Authorization': _apiKey, 'accept': 'application/json'}); + + if (response.statusCode == 200) { + final Map data = jsonDecode(response.body) as Map; + final List message = data['message'] as List; + return message + .map((item) => PaymentMethod.fromOnramperJson(item as Map)) + .toList(); + } else { + print('Failed to fetch available payment types'); + return []; + } + } catch (e) { + print('Failed to fetch available payment types: $e'); + return []; } } - String getColorStr(Color color) { - return color.value.toRadixString(16).replaceAll(RegExp(r'^ff'), ""); + Future> getOnrampMetadata() async { + final url = Uri.https(_baseApiUrl, '$supported/onramps/all'); + + try { + final response = + await http.get(url, headers: {'Authorization': _apiKey, 'accept': 'application/json'}); + + if (response.statusCode == 200) { + final Map data = jsonDecode(response.body) as Map; + + final List onramps = data['message'] as List; + + final Map result = { + for (var onramp in onramps) + (onramp['id'] as String): { + 'displayName': onramp['displayName'] as String, + 'svg': onramp['icons']['svg'] as String + } + }; + + return result; + } else { + print('Failed to fetch onramp metadata'); + return {}; + } + } catch (e) { + print('Error occurred: $e'); + return {}; + } } - Uri requestOnramperUrl(BuildContext context, bool? isBuyAction) { - String primaryColor, - secondaryColor, - primaryTextColor, - secondaryTextColor, - containerColor, - cardColor; + @override + Future?> fetchQuote( + {required CryptoCurrency cryptoCurrency, + required FiatCurrency fiatCurrency, + required double amount, + required bool isBuyAction, + required String walletAddress, + PaymentType? paymentType, + String? countryCode}) async { + String? paymentMethod; - primaryColor = getColorStr(Theme.of(context).primaryColor); - secondaryColor = getColorStr(Theme.of(context).colorScheme.background); - primaryTextColor = - getColorStr(Theme.of(context).extension()!.titleColor); - secondaryTextColor = getColorStr( - Theme.of(context).extension()!.secondaryTextColor); - containerColor = getColorStr(Theme.of(context).colorScheme.background); - cardColor = getColorStr(Theme.of(context).cardColor); + if (paymentType != null && paymentType != PaymentType.all) { + paymentMethod = normalizePaymentMethod(paymentType); + if (paymentMethod == null) paymentMethod = paymentType.name; + } + + final actionType = isBuyAction ? 'buy' : 'sell'; + + final normalizedCryptoCurrency = _getNormalizeCryptoCurrency(cryptoCurrency); + + final params = { + 'amount': amount.toString(), + if (paymentMethod != null) 'paymentMethod': paymentMethod, + 'clientName': 'CakeWallet', + 'type': actionType, + 'walletAddress': walletAddress, + 'isRecurringPayment': 'false', + 'input': 'source', + }; + + log('Onramper: Fetching $actionType quote: ${isBuyAction ? normalizedCryptoCurrency : fiatCurrency.name} -> ${isBuyAction ? fiatCurrency.name : normalizedCryptoCurrency}, amount: $amount, paymentMethod: $paymentMethod'); + + final sourceCurrency = isBuyAction ? fiatCurrency.name : normalizedCryptoCurrency; + final destinationCurrency = isBuyAction ? normalizedCryptoCurrency : fiatCurrency.name; + + final url = Uri.https(_baseApiUrl, '$quotes/${sourceCurrency}/${destinationCurrency}', params); + final headers = {'Authorization': _apiKey, 'accept': 'application/json'}; + + try { + final response = await http.get(url, headers: headers); + + if (response.statusCode == 200) { + final data = jsonDecode(response.body) as List; + if (data.isEmpty) return null; + + List validQuotes = []; + + final onrampMetadata = await getOnrampMetadata(); + + for (var item in data) { + + if (item['errors'] != null) continue; + + final paymentMethod = (item as Map)['paymentMethod'] as String; + + final rampId = item['ramp'] as String?; + final rampMetaData = onrampMetadata[rampId] as Map?; + + if (rampMetaData == null) continue; + + final quote = Quote.fromOnramperJson( + item, isBuyAction, onrampMetadata, _getPaymentTypeByString(paymentMethod)); + quote.setFiatCurrency = fiatCurrency; + quote.setCryptoCurrency = cryptoCurrency; + validQuotes.add(quote); + } + + if (validQuotes.isEmpty) return null; + + return validQuotes; + } else { + print('Onramper: Failed to fetch rate'); + return null; + } + } catch (e) { + print('Onramper: Failed to fetch rate $e'); + return null; + } + } + + Future? launchProvider( + {required BuildContext context, + required Quote quote, + required double amount, + required bool isBuyAction, + required String cryptoCurrencyAddress, + String? countryCode}) async { + final actionType = isBuyAction ? 'buy' : 'sell'; + final prefix = actionType == 'sell' ? actionType + '_' : ''; + + final primaryColor = getColorStr(Theme.of(context).primaryColor); + final secondaryColor = getColorStr(Theme.of(context).colorScheme.background); + final primaryTextColor = getColorStr(Theme.of(context).extension()!.titleColor); + final secondaryTextColor = + getColorStr(Theme.of(context).extension()!.secondaryTextColor); + final containerColor = getColorStr(Theme.of(context).colorScheme.background); + var cardColor = getColorStr(Theme.of(context).cardColor); if (_settingsStore.currentTheme.title == S.current.high_contrast_theme) { cardColor = getColorStr(Colors.white); } - final networkName = - wallet.currency.fullName?.toUpperCase().replaceAll(" ", ""); + final defaultCrypto = _getNormalizeCryptoCurrency(quote.cryptoCurrency); - return Uri.https(_baseUrl, '', { + final paymentMethod = normalizePaymentMethod(quote.paymentType); + + final uri = Uri.https(_baseUrl, '', { 'apiKey': _apiKey, - 'defaultCrypto': _normalizeCryptoCurrency, - 'sell_defaultCrypto': _normalizeCryptoCurrency, - 'networkWallets': '${networkName}:${wallet.walletAddresses.address}', + 'mode': actionType, + '${prefix}defaultFiat': quote.fiatCurrency.name, + '${prefix}defaultCrypto': quote.cryptoCurrency.name, + '${prefix}defaultAmount': amount.toString(), + if (paymentMethod != null) '${prefix}defaultPaymentMethod': paymentMethod, + 'onlyOnramps': quote.rampId, + 'networkWallets': '$defaultCrypto:$cryptoCurrencyAddress', + 'walletAddress': cryptoCurrencyAddress, 'supportSwap': "false", 'primaryColor': primaryColor, 'secondaryColor': secondaryColor, + 'containerColor': containerColor, 'primaryTextColor': primaryTextColor, 'secondaryTextColor': secondaryTextColor, - 'containerColor': containerColor, 'cardColor': cardColor, - 'mode': isBuyAction == true ? 'buy' : 'sell', }); - } - Future launchProvider(BuildContext context, bool? isBuyAction) async { - final uri = requestOnramperUrl(context, isBuyAction); - if (DeviceInfo.instance.isMobile) { - Navigator.of(context) - .pushNamed(Routes.webViewPage, arguments: [title, uri]); + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); } else { - await launchUrl(uri); + throw Exception('Could not launch URL'); } } + + List mainCurrency = [ + CryptoCurrency.btc, + CryptoCurrency.eth, + CryptoCurrency.sol, + ]; + + String _tagToNetwork(String tag) { + switch (tag) { + case 'OMNI': + return tag; + case 'POL': + return 'POLYGON'; + default: + return CryptoCurrency.fromString(tag).fullName ?? tag; + } + } + + String _getNormalizeCryptoCurrency(Currency currency) { + if (currency is CryptoCurrency) { + if (!mainCurrency.contains(currency)) { + final network = currency.tag == null ? currency.fullName : _tagToNetwork(currency.tag!); + return '${currency.title}_${network?.replaceAll(' ', '')}'.toUpperCase(); + } + return currency.title.toUpperCase(); + } + return currency.name.toUpperCase(); + } + + String? normalizePaymentMethod(PaymentType paymentType) { + switch (paymentType) { + case PaymentType.bankTransfer: + return 'banktransfer'; + case PaymentType.creditCard: + return 'creditcard'; + case PaymentType.debitCard: + return 'debitcard'; + case PaymentType.applePay: + return 'applepay'; + case PaymentType.googlePay: + return 'googlepay'; + case PaymentType.revolutPay: + return 'revolutpay'; + case PaymentType.neteller: + return 'neteller'; + case PaymentType.skrill: + return 'skrill'; + case PaymentType.sepa: + return 'sepabanktransfer'; + case PaymentType.sepaInstant: + return 'sepainstant'; + case PaymentType.ach: + return 'ach'; + case PaymentType.achInstant: + return 'iach'; + case PaymentType.Khipu: + return 'khipu'; + case PaymentType.palomaBanktTansfer: + return 'palomabanktransfer'; + case PaymentType.ovo: + return 'ovo'; + case PaymentType.zaloPay: + return 'zalopay'; + case PaymentType.zaloBankTransfer: + return 'zalobanktransfer'; + case PaymentType.gcash: + return 'gcash'; + case PaymentType.imps: + return 'imps'; + case PaymentType.dana: + return 'dana'; + case PaymentType.ideal: + return 'ideal'; + default: + return null; + } + } + + PaymentType _getPaymentTypeByString(String paymentMethod) { + switch (paymentMethod.toLowerCase()) { + case 'banktransfer': + return PaymentType.bankTransfer; + case 'creditcard': + return PaymentType.creditCard; + case 'debitcard': + return PaymentType.debitCard; + case 'applepay': + return PaymentType.applePay; + case 'googlepay': + return PaymentType.googlePay; + case 'revolutpay': + return PaymentType.revolutPay; + case 'neteller': + return PaymentType.neteller; + case 'skrill': + return PaymentType.skrill; + case 'sepabanktransfer': + return PaymentType.sepa; + case 'sepainstant': + return PaymentType.sepaInstant; + case 'ach': + return PaymentType.ach; + case 'iach': + return PaymentType.achInstant; + case 'khipu': + return PaymentType.Khipu; + case 'palomabanktransfer': + return PaymentType.palomaBanktTansfer; + case 'ovo': + return PaymentType.ovo; + case 'zalopay': + return PaymentType.zaloPay; + case 'zalobanktransfer': + return PaymentType.zaloBankTransfer; + case 'gcash': + return PaymentType.gcash; + case 'imps': + return PaymentType.imps; + case 'dana': + return PaymentType.dana; + case 'ideal': + return PaymentType.ideal; + default: + return PaymentType.all; + } + } + + String getColorStr(Color color) => color.value.toRadixString(16).replaceAll(RegExp(r'^ff'), ""); } diff --git a/lib/buy/payment_method.dart b/lib/buy/payment_method.dart new file mode 100644 index 000000000..cf85c441b --- /dev/null +++ b/lib/buy/payment_method.dart @@ -0,0 +1,287 @@ +import 'dart:ui'; + +import 'package:cake_wallet/core/selectable_option.dart'; + +enum PaymentType { + all, + bankTransfer, + creditCard, + debitCard, + applePay, + googlePay, + revolutPay, + neteller, + skrill, + sepa, + sepaInstant, + ach, + achInstant, + Khipu, + palomaBanktTansfer, + ovo, + zaloPay, + zaloBankTransfer, + gcash, + imps, + dana, + ideal, + paypal, + sepaOpenBankingPayment, + gbpOpenBankingPayment, + lowCostAch, + mobileWallet, + pixInstantPayment, + yellowCardBankTransfer, + fiatBalance, + bancontact, +} + +extension PaymentTypeTitle on PaymentType { + String? get title { + switch (this) { + case PaymentType.all: + return 'All Payment Methods'; + case PaymentType.bankTransfer: + return 'Bank Transfer'; + case PaymentType.creditCard: + return 'Credit Card'; + case PaymentType.debitCard: + return 'Debit Card'; + case PaymentType.applePay: + return 'Apple Pay'; + case PaymentType.googlePay: + return 'Google Pay'; + case PaymentType.revolutPay: + return 'Revolut Pay'; + case PaymentType.neteller: + return 'Neteller'; + case PaymentType.skrill: + return 'Skrill'; + case PaymentType.sepa: + return 'SEPA'; + case PaymentType.sepaInstant: + return 'SEPA Instant'; + case PaymentType.ach: + return 'ACH'; + case PaymentType.achInstant: + return 'ACH Instant'; + case PaymentType.Khipu: + return 'Khipu'; + case PaymentType.palomaBanktTansfer: + return 'Paloma Bank Transfer'; + case PaymentType.ovo: + return 'OVO'; + case PaymentType.zaloPay: + return 'Zalo Pay'; + case PaymentType.zaloBankTransfer: + return 'Zalo Bank Transfer'; + case PaymentType.gcash: + return 'GCash'; + case PaymentType.imps: + return 'IMPS'; + case PaymentType.dana: + return 'DANA'; + case PaymentType.ideal: + return 'iDEAL'; + case PaymentType.paypal: + return 'PayPal'; + case PaymentType.sepaOpenBankingPayment: + return 'SEPA Open Banking Payment'; + case PaymentType.gbpOpenBankingPayment: + return 'GBP Open Banking Payment'; + case PaymentType.lowCostAch: + return 'Low Cost ACH'; + case PaymentType.mobileWallet: + return 'Mobile Wallet'; + case PaymentType.pixInstantPayment: + return 'PIX Instant Payment'; + case PaymentType.yellowCardBankTransfer: + return 'Yellow Card Bank Transfer'; + case PaymentType.fiatBalance: + return 'Fiat Balance'; + case PaymentType.bancontact: + return 'Bancontact'; + default: + return null; + } + } + + String? get lightIconPath { + switch (this) { + case PaymentType.all: + return 'assets/images/usd_round_light.svg'; + case PaymentType.creditCard: + case PaymentType.debitCard: + case PaymentType.yellowCardBankTransfer: + return 'assets/images/card.svg'; + case PaymentType.bankTransfer: + return 'assets/images/bank_light.svg'; + case PaymentType.skrill: + return 'assets/images/skrill.svg'; + case PaymentType.applePay: + return 'assets/images/apple_pay_round_light.svg'; + default: + return null; + } + } + + String? get darkIconPath { + switch (this) { + case PaymentType.all: + return 'assets/images/usd_round_dark.svg'; + case PaymentType.creditCard: + case PaymentType.debitCard: + case PaymentType.yellowCardBankTransfer: + return 'assets/images/card_dark.svg'; + case PaymentType.bankTransfer: + return 'assets/images/bank_dark.svg'; + case PaymentType.skrill: + return 'assets/images/skrill.svg'; + case PaymentType.applePay: + return 'assets/images/apple_pay_round_dark.svg'; + default: + return null; + } + } + + String? get description { + switch (this) { + default: + return null; + } + } +} + +class PaymentMethod extends SelectableOption { + PaymentMethod({ + required this.paymentMethodType, + required this.customTitle, + required this.customIconPath, + this.customDescription, + }) : super(title: paymentMethodType.title ?? customTitle); + + final PaymentType paymentMethodType; + final String customTitle; + final String customIconPath; + final String? customDescription; + bool isSelected = false; + + @override + String? get description => paymentMethodType.description ?? customDescription; + + @override + String get lightIconPath => paymentMethodType.lightIconPath ?? customIconPath; + + @override + String get darkIconPath => paymentMethodType.darkIconPath ?? customIconPath; + + @override + bool get isOptionSelected => isSelected; + + factory PaymentMethod.all() { + return PaymentMethod( + paymentMethodType: PaymentType.all, + customTitle: 'All Payment Methods', + customIconPath: 'assets/images/dollar_coin.svg'); + } + + factory PaymentMethod.fromOnramperJson(Map json) { + final type = PaymentMethod.getPaymentTypeId(json['paymentTypeId'] as String?); + return PaymentMethod( + paymentMethodType: type, + customTitle: json['name'] as String? ?? 'Unknown', + customIconPath: json['icon'] as String? ?? 'assets/images/card.png', + customDescription: json['description'] as String?); + } + + factory PaymentMethod.fromDFX(String paymentMethod, PaymentType paymentType) { + return PaymentMethod( + paymentMethodType: paymentType, + customTitle: paymentMethod, + customIconPath: 'assets/images/card.png'); + } + + factory PaymentMethod.fromMoonPayJson(Map json, PaymentType paymentType) { + return PaymentMethod( + paymentMethodType: paymentType, + customTitle: json['paymentMethod'] as String, + customIconPath: 'assets/images/card.png'); + } + + factory PaymentMethod.fromMeldJson(Map json) { + final type = PaymentMethod.getPaymentTypeId(json['paymentMethod'] as String?); + final logos = json['logos'] as Map; + return PaymentMethod( + paymentMethodType: type, + customTitle: json['name'] as String? ?? 'Unknown', + customIconPath: logos['dark'] as String? ?? 'assets/images/card.png', + customDescription: json['description'] as String?); + } + + static PaymentType getPaymentTypeId(String? type) { + switch (type?.toLowerCase()) { + case 'banktransfer': + case 'bank': + case 'yellow_card_bank_transfer': + return PaymentType.bankTransfer; + case 'creditcard': + case 'card': + case 'credit_debit_card': + return PaymentType.creditCard; + case 'debitcard': + return PaymentType.debitCard; + case 'applepay': + case 'apple_pay': + return PaymentType.applePay; + case 'googlepay': + case 'google_pay': + return PaymentType.googlePay; + case 'revolutpay': + return PaymentType.revolutPay; + case 'neteller': + return PaymentType.neteller; + case 'skrill': + return PaymentType.skrill; + case 'sepabanktransfer': + case 'sepa': + case 'sepa_bank_transfer': + return PaymentType.sepa; + case 'sepainstant': + case 'sepa_instant': + return PaymentType.sepaInstant; + case 'ach': + case 'ach_bank_transfer': + return PaymentType.ach; + case 'iach': + case 'instant_ach': + return PaymentType.achInstant; + case 'khipu': + return PaymentType.Khipu; + case 'palomabanktransfer': + return PaymentType.palomaBanktTansfer; + case 'ovo': + return PaymentType.ovo; + case 'zalopay': + return PaymentType.zaloPay; + case 'zalobanktransfer': + case 'za_bank_transfer': + return PaymentType.zaloBankTransfer; + case 'gcash': + return PaymentType.gcash; + case 'imps': + return PaymentType.imps; + case 'dana': + return PaymentType.dana; + case 'ideal': + return PaymentType.ideal; + case 'paypal': + return PaymentType.paypal; + case 'sepa_open_banking_payment': + return PaymentType.sepaOpenBankingPayment; + case 'bancontact': + return PaymentType.bancontact; + default: + return PaymentType.all; + } + } +} diff --git a/lib/buy/robinhood/robinhood_buy_provider.dart b/lib/buy/robinhood/robinhood_buy_provider.dart index 2d809772e..e8de5a59c 100644 --- a/lib/buy/robinhood/robinhood_buy_provider.dart +++ b/lib/buy/robinhood/robinhood_buy_provider.dart @@ -1,13 +1,18 @@ import 'dart:convert'; +import 'dart:developer'; import 'package:cake_wallet/.secrets.g.dart' as secrets; import 'package:cake_wallet/buy/buy_provider.dart'; +import 'package:cake_wallet/buy/buy_quote.dart'; +import 'package:cake_wallet/buy/payment_method.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/connect_device/connect_device_page.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; @@ -15,7 +20,8 @@ import 'package:http/http.dart' as http; import 'package:url_launcher/url_launcher.dart'; class RobinhoodBuyProvider extends BuyProvider { - RobinhoodBuyProvider({required WalletBase wallet, bool isTestEnvironment = false, LedgerViewModel? ledgerVM}) + RobinhoodBuyProvider( + {required WalletBase wallet, bool isTestEnvironment = false, LedgerViewModel? ledgerVM}) : super(wallet: wallet, isTestEnvironment: isTestEnvironment, ledgerVM: ledgerVM); static const _baseUrl = 'applink.robinhood.com'; @@ -33,6 +39,9 @@ class RobinhoodBuyProvider extends BuyProvider { @override String get darkIcon => 'assets/images/robinhood_dark.png'; + @override + bool get isAggregator => false; + String get _applicationId => secrets.robinhoodApplicationId; String get _apiSecret => secrets.exchangeHelperApiKey; @@ -86,7 +95,13 @@ class RobinhoodBuyProvider extends BuyProvider { }); } - Future launchProvider(BuildContext context, bool? isBuyAction) async { + Future? launchProvider( + {required BuildContext context, + required Quote quote, + required double amount, + required bool isBuyAction, + required String cryptoCurrencyAddress, + String? countryCode}) async { if (wallet.isHardwareWallet) { if (!ledgerVM!.isConnected) { await Navigator.of(context).pushNamed(Routes.connectDevices, @@ -116,4 +131,87 @@ class RobinhoodBuyProvider extends BuyProvider { }); } } + + @override + Future?> fetchQuote( + {required CryptoCurrency cryptoCurrency, + required FiatCurrency fiatCurrency, + required double amount, + required bool isBuyAction, + required String walletAddress, + PaymentType? paymentType, + String? countryCode}) async { + String? paymentMethod; + + if (paymentType != null && paymentType != PaymentType.all) { + paymentMethod = normalizePaymentMethod(paymentType); + if (paymentMethod == null) paymentMethod = paymentType.name; + } + + final action = isBuyAction ? 'buy' : 'sell'; + log('Robinhood: Fetching $action quote: ${isBuyAction ? cryptoCurrency.title : fiatCurrency.name.toUpperCase()} -> ${isBuyAction ? fiatCurrency.name.toUpperCase() : cryptoCurrency.title}, amount: $amount paymentMethod: $paymentMethod'); + + final queryParams = { + 'applicationId': _applicationId, + 'fiatCode': fiatCurrency.name, + 'assetCode': cryptoCurrency.title, + 'fiatAmount': amount.toString(), + if (paymentMethod != null) 'paymentMethod': paymentMethod, + }; + + final uri = + Uri.https('api.robinhood.com', '/catpay/v1/${cryptoCurrency.title}/quote/', queryParams); + + try { + final response = await http.get(uri, headers: {'accept': 'application/json'}); + final responseData = jsonDecode(response.body) as Map; + + if (response.statusCode == 200) { + final paymentType = _getPaymentTypeByString(responseData['paymentMethod'] as String?); + final quote = Quote.fromRobinhoodJson(responseData, isBuyAction, paymentType); + quote.setFiatCurrency = fiatCurrency; + quote.setCryptoCurrency = cryptoCurrency; + return [quote]; + } else { + if (responseData.containsKey('message')) { + log('Robinhood Error: ${responseData['message']}'); + } else { + print('Robinhood Failed to fetch $action quote: ${response.statusCode}'); + } + return null; + } + } catch (e) { + log('Robinhood: Failed to fetch $action quote: $e'); + return null; + } + + // ● buying_power + // ● crypto_balance + // ● debit_card + // ● bank_transfer + } + + String? normalizePaymentMethod(PaymentType paymentMethod) { + switch (paymentMethod) { + case PaymentType.creditCard: + return 'debit_card'; + case PaymentType.debitCard: + return 'debit_card'; + case PaymentType.bankTransfer: + return 'bank_transfer'; + default: + return null; + } + } + + PaymentType _getPaymentTypeByString(String? paymentMethod) { + switch (paymentMethod) { + case 'debit_card': + return PaymentType.debitCard; + case 'bank_transfer': + return PaymentType.bankTransfer; + default: + return PaymentType.all; + } + } } diff --git a/lib/buy/sell_buy_states.dart b/lib/buy/sell_buy_states.dart new file mode 100644 index 000000000..26ea20205 --- /dev/null +++ b/lib/buy/sell_buy_states.dart @@ -0,0 +1,20 @@ +abstract class PaymentMethodLoadingState {} + +class InitialPaymentMethod extends PaymentMethodLoadingState {} + +class PaymentMethodLoading extends PaymentMethodLoadingState {} + +class PaymentMethodLoaded extends PaymentMethodLoadingState {} + +class PaymentMethodFailed extends PaymentMethodLoadingState {} + + +abstract class BuySellQuotLoadingState {} + +class InitialBuySellQuotState extends BuySellQuotLoadingState {} + +class BuySellQuotLoading extends BuySellQuotLoadingState {} + +class BuySellQuotLoaded extends BuySellQuotLoadingState {} + +class BuySellQuotFailed extends BuySellQuotLoadingState {} \ No newline at end of file diff --git a/lib/buy/wyre/wyre_buy_provider.dart b/lib/buy/wyre/wyre_buy_provider.dart index e09186ad5..78b109ac0 100644 --- a/lib/buy/wyre/wyre_buy_provider.dart +++ b/lib/buy/wyre/wyre_buy_provider.dart @@ -42,6 +42,9 @@ class WyreBuyProvider extends BuyProvider { @override String get darkIcon => 'assets/images/robinhood_dark.png'; + @override + bool get isAggregator => false; + String get trackUrl => isTestEnvironment ? _trackTestUrl : _trackProductUrl; String baseApiUrl; @@ -148,10 +151,4 @@ class WyreBuyProvider extends BuyProvider { receiveAddress: wallet.walletAddresses.address, walletId: wallet.id); } - - @override - Future launchProvider(BuildContext context, bool? isBuyAction) { - // TODO: implement launchProvider - throw UnimplementedError(); - } } diff --git a/lib/core/backup_service.dart b/lib/core/backup_service.dart index d65530eb5..992ed6288 100644 --- a/lib/core/backup_service.dart +++ b/lib/core/backup_service.dart @@ -268,9 +268,7 @@ class BackupService { final currentFiatCurrency = data[PreferencesKey.currentFiatCurrencyKey] as String?; final shouldSaveRecipientAddress = data[PreferencesKey.shouldSaveRecipientAddressKey] as bool?; final isAppSecure = data[PreferencesKey.isAppSecureKey] as bool?; - final disableBuy = data[PreferencesKey.disableBuyKey] as bool?; - final disableSell = data[PreferencesKey.disableSellKey] as bool?; - final defaultBuyProvider = data[PreferencesKey.defaultBuyProvider] as int?; + final disableTradeOption = data[PreferencesKey.disableTradeOption] as bool?; final currentTransactionPriorityKeyLegacy = data[PreferencesKey.currentTransactionPriorityKeyLegacy] as int?; final currentBitcoinElectrumSererId = @@ -323,14 +321,8 @@ class BackupService { if (isAppSecure != null) await _sharedPreferences.setBool(PreferencesKey.isAppSecureKey, isAppSecure); - if (disableBuy != null) - await _sharedPreferences.setBool(PreferencesKey.disableBuyKey, disableBuy); - - if (disableSell != null) - await _sharedPreferences.setBool(PreferencesKey.disableSellKey, disableSell); - - if (defaultBuyProvider != null) - await _sharedPreferences.setInt(PreferencesKey.defaultBuyProvider, defaultBuyProvider); + if (disableTradeOption != null) + await _sharedPreferences.setBool(PreferencesKey.disableTradeOption, disableTradeOption); if (currentTransactionPriorityKeyLegacy != null) await _sharedPreferences.setInt( @@ -516,10 +508,7 @@ class BackupService { _sharedPreferences.getString(PreferencesKey.currentFiatCurrencyKey), PreferencesKey.shouldSaveRecipientAddressKey: _sharedPreferences.getBool(PreferencesKey.shouldSaveRecipientAddressKey), - PreferencesKey.disableBuyKey: _sharedPreferences.getBool(PreferencesKey.disableBuyKey), - PreferencesKey.disableSellKey: _sharedPreferences.getBool(PreferencesKey.disableSellKey), - PreferencesKey.defaultBuyProvider: - _sharedPreferences.getInt(PreferencesKey.defaultBuyProvider), + PreferencesKey.disableTradeOption: _sharedPreferences.getBool(PreferencesKey.disableTradeOption), PreferencesKey.currentPinLength: _sharedPreferences.getInt(PreferencesKey.currentPinLength), PreferencesKey.currentTransactionPriorityKeyLegacy: _sharedPreferences.getInt(PreferencesKey.currentTransactionPriorityKeyLegacy), diff --git a/lib/core/selectable_option.dart b/lib/core/selectable_option.dart new file mode 100644 index 000000000..0d7511c95 --- /dev/null +++ b/lib/core/selectable_option.dart @@ -0,0 +1,47 @@ +abstract class SelectableItem { + SelectableItem({required this.title}); + final String title; +} + +class OptionTitle extends SelectableItem { + OptionTitle({required String title}) : super(title: title); + +} + +abstract class SelectableOption extends SelectableItem { + SelectableOption({required String title}) : super(title: title); + + String get lightIconPath; + + String get darkIconPath; + + String? get description => null; + + String? get topLeftSubTitle => null; + + String? get topLeftSubTitleIconPath => null; + + String? get topRightSubTitle => null; + + String? get topRightSubTitleLightIconPath => null; + + String? get topRightSubTitleDarkIconPath => null; + + String? get bottomLeftSubTitle => null; + + String? get bottomLeftSubTitleIconPath => null; + + String? get bottomRightSubTitle => null; + + String? get bottomRightSubTitleLightIconPath => null; + + String? get bottomRightSubTitleDarkIconPath => null; + + List get badges => []; + + bool get isOptionSelected => false; + + set isOptionSelected(bool isSelected) => false; +} + + diff --git a/lib/di.dart b/lib/di.dart index 9cff29798..8dd1029a9 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -19,6 +19,7 @@ import 'package:cake_wallet/core/backup_service.dart'; import 'package:cake_wallet/core/key_service.dart'; import 'package:cake_wallet/core/new_wallet_type_arguments.dart'; import 'package:cake_wallet/core/secure_storage.dart'; +import 'package:cake_wallet/core/selectable_option.dart'; import 'package:cake_wallet/core/totp_request_details.dart'; import 'package:cake_wallet/core/wallet_connect/wallet_connect_key_service.dart'; import 'package:cake_wallet/core/wallet_connect/wc_bottom_sheet_service.dart'; @@ -34,6 +35,8 @@ import 'package:cake_wallet/entities/exchange_api_mode.dart'; 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/buy/buy_sell_options_page.dart'; +import 'package:cake_wallet/src/screens/buy/payment_method_options_page.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'; @@ -61,7 +64,6 @@ import 'package:cake_wallet/src/screens/anonpay_details/anonpay_details_page.dar import 'package:cake_wallet/src/screens/auth/auth_page.dart'; import 'package:cake_wallet/src/screens/backup/backup_page.dart'; import 'package:cake_wallet/src/screens/backup/edit_backup_password_page.dart'; -import 'package:cake_wallet/src/screens/buy/buy_options_page.dart'; import 'package:cake_wallet/src/screens/buy/buy_webview_page.dart'; import 'package:cake_wallet/src/screens/buy/webview_page.dart'; import 'package:cake_wallet/src/screens/contact/contact_list_page.dart'; @@ -135,6 +137,7 @@ import 'package:cake_wallet/utils/device_info.dart'; import 'package:cake_wallet/store/anonpay/anonpay_transactions_store.dart'; import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; +import 'package:cake_wallet/view_model/buy/buy_sell_view_model.dart'; import 'package:cake_wallet/view_model/animated_ur_model.dart'; import 'package:cake_wallet/view_model/dashboard/desktop_sidebar_view_model.dart'; import 'package:cake_wallet/view_model/anon_invoice_page_view_model.dart'; @@ -248,6 +251,8 @@ import 'package:get_it/get_it.dart'; import 'package:hive/hive.dart'; import 'package:mobx/mobx.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import 'buy/meld/meld_buy_provider.dart'; +import 'src/screens/buy/buy_sell_page.dart'; import 'cake_pay/cake_pay_payment_credantials.dart'; final getIt = GetIt.instance; @@ -1004,6 +1009,10 @@ Future setup({ wallet: getIt.get().wallet!, )); + getIt.registerFactory(() => MeldBuyProvider( + wallet: getIt.get().wallet!, + )); + getIt.registerFactoryParam((title, uri) => WebViewPage(title, uri)); getIt.registerFactory(() => PayfuraBuyProvider( @@ -1193,8 +1202,25 @@ Future setup({ getIt.registerFactory(() => BuyAmountViewModel()); - getIt.registerFactoryParam( - (isBuyOption, _) => BuySellOptionsPage(getIt.get(), isBuyOption)); + getIt.registerFactory(() => BuySellViewModel(getIt.get())); + + getIt.registerFactory(() => BuySellPage(getIt.get())); + + getIt.registerFactoryParam, void>((List args, _) { + final items = args.first as List; + final pickAnOption = args[1] as void Function(SelectableOption option)?; + final confirmOption = args[2] as void Function(BuildContext contex)?; + return BuyOptionsPage( + items: items, pickAnOption: pickAnOption, confirmOption: confirmOption); + }); + + getIt.registerFactoryParam, void>((List args, _) { + final items = args.first as List; + final pickAnOption = args[1] as void Function(SelectableOption option)?; + + return PaymentMethodOptionsPage( + items: items, pickAnOption: pickAnOption); + }); getIt.registerFactory(() { final wallet = getIt.get().wallet; diff --git a/lib/entities/main_actions.dart b/lib/entities/main_actions.dart index c1dd71cc9..94be0d2b7 100644 --- a/lib/entities/main_actions.dart +++ b/lib/entities/main_actions.dart @@ -23,31 +23,18 @@ class MainActions { }); static List all = [ - buyAction, + showWalletsAction, receiveAction, exchangeAction, sendAction, - sellAction, + tradeAction, ]; - static MainActions buyAction = MainActions._( - name: (context) => S.of(context).buy, - image: 'assets/images/buy.png', - isEnabled: (viewModel) => viewModel.isEnabledBuyAction, - canShow: (viewModel) => viewModel.hasBuyAction, + static MainActions showWalletsAction = MainActions._( + name: (context) => S.of(context).wallets, + image: 'assets/images/wallet_new.png', onTap: (BuildContext context, DashboardViewModel viewModel) async { - if (!viewModel.isEnabledBuyAction) { - return; - } - - final defaultBuyProvider = viewModel.defaultBuyProvider; - try { - defaultBuyProvider != null - ? await defaultBuyProvider.launchProvider(context, true) - : await Navigator.of(context).pushNamed(Routes.buySellPage, arguments: true); - } catch (e) { - await _showErrorDialog(context, defaultBuyProvider.toString(), e.toString()); - } + Navigator.pushNamed(context, Routes.walletList); }, ); @@ -79,39 +66,15 @@ class MainActions { }, ); - static MainActions sellAction = MainActions._( - name: (context) => S.of(context).sell, - image: 'assets/images/sell.png', - isEnabled: (viewModel) => viewModel.isEnabledSellAction, - canShow: (viewModel) => viewModel.hasSellAction, - onTap: (BuildContext context, DashboardViewModel viewModel) async { - if (!viewModel.isEnabledSellAction) { - return; - } - final defaultSellProvider = viewModel.defaultSellProvider; - try { - defaultSellProvider != null - ? await defaultSellProvider.launchProvider(context, false) - : await Navigator.of(context).pushNamed(Routes.buySellPage, arguments: false); - } catch (e) { - await _showErrorDialog(context, defaultSellProvider.toString(), e.toString()); - } + static MainActions tradeAction = MainActions._( + name: (context) => '${S.of(context).buy} / ${S.of(context).sell}', + image: 'assets/images/buy_sell.png', + isEnabled: (viewModel) => viewModel.isEnabledTradeAction, + canShow: (viewModel) => viewModel.hasTradeAction, + onTap: (BuildContext context, DashboardViewModel viewModel) async { + if (!viewModel.isEnabledTradeAction) return; + await Navigator.of(context).pushNamed(Routes.buySellPage, arguments: false); }, ); - - static Future _showErrorDialog( - BuildContext context, String title, String errorMessage) async { - await showPopUp( - context: context, - builder: (BuildContext context) { - return AlertWithOneAction( - alertTitle: title, - alertContent: errorMessage, - buttonText: S.of(context).ok, - buttonAction: () => Navigator.of(context).pop(), - ); - }, - ); - } } \ No newline at end of file diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index 0bb526e5d..5ed7a7ed6 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -21,10 +21,8 @@ class PreferencesKey { static const currentBalanceDisplayModeKey = 'current_balance_display_mode'; static const shouldSaveRecipientAddressKey = 'save_recipient_address'; static const isAppSecureKey = 'is_app_secure'; - static const disableBuyKey = 'disable_buy'; - static const disableSellKey = 'disable_sell'; + static const disableTradeOption = 'disable_buy'; 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'; diff --git a/lib/entities/provider_types.dart b/lib/entities/provider_types.dart index b9dd4ef2a..42ec74c12 100644 --- a/lib/entities/provider_types.dart +++ b/lib/entities/provider_types.dart @@ -1,24 +1,18 @@ import 'package:cake_wallet/buy/buy_provider.dart'; import 'package:cake_wallet/buy/dfx/dfx_buy_provider.dart'; +import 'package:cake_wallet/buy/meld/meld_buy_provider.dart'; import 'package:cake_wallet/buy/moonpay/moonpay_provider.dart'; import 'package:cake_wallet/buy/onramper/onramper_buy_provider.dart'; import 'package:cake_wallet/buy/robinhood/robinhood_buy_provider.dart'; import 'package:cake_wallet/di.dart'; import 'package:cw_core/wallet_type.dart'; +import 'package:http/http.dart'; -enum ProviderType { - askEachTime, - robinhood, - dfx, - onramper, - moonpay, -} +enum ProviderType { robinhood, dfx, onramper, moonpay, meld } extension ProviderTypeName on ProviderType { String get title { switch (this) { - case ProviderType.askEachTime: - return 'Ask each time'; case ProviderType.robinhood: return 'Robinhood Connect'; case ProviderType.dfx: @@ -27,13 +21,13 @@ extension ProviderTypeName on ProviderType { return 'Onramper'; case ProviderType.moonpay: return 'MoonPay'; + case ProviderType.meld: + return 'Meld'; } } String get id { switch (this) { - case ProviderType.askEachTime: - return 'ask_each_time_provider'; case ProviderType.robinhood: return 'robinhood_connect_provider'; case ProviderType.dfx: @@ -42,6 +36,8 @@ extension ProviderTypeName on ProviderType { return 'onramper_provider'; case ProviderType.moonpay: return 'moonpay_provider'; + case ProviderType.meld: + return 'meld_provider'; } } } @@ -52,14 +48,13 @@ class ProvidersHelper { case WalletType.nano: case WalletType.banano: case WalletType.wownero: - return [ProviderType.askEachTime, ProviderType.onramper]; + return [ProviderType.onramper]; case WalletType.monero: - return [ProviderType.askEachTime, ProviderType.onramper, ProviderType.dfx]; + return [ProviderType.onramper, ProviderType.dfx]; case WalletType.bitcoin: case WalletType.polygon: case WalletType.ethereum: return [ - ProviderType.askEachTime, ProviderType.onramper, ProviderType.dfx, ProviderType.robinhood, @@ -68,10 +63,13 @@ class ProvidersHelper { case WalletType.litecoin: case WalletType.bitcoinCash: case WalletType.solana: - return [ProviderType.askEachTime, ProviderType.onramper, ProviderType.robinhood, ProviderType.moonpay]; + return [ + ProviderType.onramper, + ProviderType.robinhood, + ProviderType.moonpay + ]; case WalletType.tron: return [ - ProviderType.askEachTime, ProviderType.onramper, ProviderType.robinhood, ProviderType.moonpay, @@ -88,28 +86,24 @@ class ProvidersHelper { case WalletType.ethereum: case WalletType.polygon: return [ - ProviderType.askEachTime, ProviderType.onramper, ProviderType.moonpay, ProviderType.dfx, ]; case WalletType.litecoin: case WalletType.bitcoinCash: - return [ProviderType.askEachTime, ProviderType.moonpay]; + return [ProviderType.moonpay]; case WalletType.solana: return [ - ProviderType.askEachTime, ProviderType.onramper, - ProviderType.robinhood, ProviderType.moonpay, ]; case WalletType.tron: return [ - ProviderType.askEachTime, - ProviderType.robinhood, ProviderType.moonpay, ]; case WalletType.monero: + return [ProviderType.dfx]; case WalletType.nano: case WalletType.banano: case WalletType.none: @@ -129,7 +123,9 @@ class ProvidersHelper { return getIt.get(); case ProviderType.moonpay: return getIt.get(); - case ProviderType.askEachTime: + case ProviderType.meld: + return getIt.get(); + default: return null; } } diff --git a/lib/router.dart b/lib/router.dart index 4b99aabc0..781a6e057 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -17,8 +17,9 @@ import 'package:cake_wallet/src/screens/anonpay_details/anonpay_details_page.dar import 'package:cake_wallet/src/screens/auth/auth_page.dart'; import 'package:cake_wallet/src/screens/backup/backup_page.dart'; import 'package:cake_wallet/src/screens/backup/edit_backup_password_page.dart'; -import 'package:cake_wallet/src/screens/buy/buy_options_page.dart'; +import 'package:cake_wallet/src/screens/buy/buy_sell_options_page.dart'; import 'package:cake_wallet/src/screens/buy/buy_webview_page.dart'; +import 'package:cake_wallet/src/screens/buy/payment_method_options_page.dart'; import 'package:cake_wallet/src/screens/buy/webview_page.dart'; import 'package:cake_wallet/src/screens/cake_pay/auth/cake_pay_account_page.dart'; import 'package:cake_wallet/src/screens/cake_pay/cake_pay.dart'; @@ -129,7 +130,8 @@ import 'package:cw_core/wallet_type.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; - +import 'package:cake_wallet/src/screens/cake_pay/cake_pay.dart'; +import 'src/screens/buy/buy_sell_page.dart'; import 'src/screens/dashboard/pages/nft_import_page.dart'; late RouteSettings currentRouteSettings; @@ -571,7 +573,15 @@ Route createRoute(RouteSettings settings) { case Routes.buySellPage: final args = settings.arguments as bool; - return MaterialPageRoute(builder: (_) => getIt.get(param1: args)); + return MaterialPageRoute(builder: (_) => getIt.get(param1: args)); + + case Routes.buyOptionsPage: + final args = settings.arguments as List; + return MaterialPageRoute(builder: (_) => getIt.get(param1: args)); + + case Routes.paymentMethodOptionsPage: + final args = settings.arguments as List; + return MaterialPageRoute(builder: (_) => getIt.get(param1: args)); case Routes.buyWebView: final args = settings.arguments as List; diff --git a/lib/routes.dart b/lib/routes.dart index 63c41bde5..0b8beb0ea 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -59,6 +59,8 @@ class Routes { static const supportOtherLinks = '/support/other'; static const orderDetails = '/order_details'; static const buySellPage = '/buy_sell_page'; + static const buyOptionsPage = '/buy_sell_options'; + static const paymentMethodOptionsPage = '/payment_method_options'; static const buyWebView = '/buy_web_view'; static const unspentCoinsList = '/unspent_coins_list'; static const unspentCoinsDetails = '/unspent_coins_details'; diff --git a/lib/src/screens/InfoPage.dart b/lib/src/screens/Info_page.dart similarity index 100% rename from lib/src/screens/InfoPage.dart rename to lib/src/screens/Info_page.dart diff --git a/lib/src/screens/buy/buy_options_page.dart b/lib/src/screens/buy/buy_options_page.dart deleted file mode 100644 index 38f3ed968..000000000 --- a/lib/src/screens/buy/buy_options_page.dart +++ /dev/null @@ -1,74 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/src/widgets/option_tile.dart'; -import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; -import 'package:cake_wallet/themes/extensions/option_tile_theme.dart'; -import 'package:cake_wallet/themes/extensions/transaction_trade_theme.dart'; -import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:flutter/material.dart'; - -class BuySellOptionsPage extends BasePage { - BuySellOptionsPage(this.dashboardViewModel, this.isBuyAction); - - final DashboardViewModel dashboardViewModel; - final bool isBuyAction; - - @override - String get title => isBuyAction ? S.current.buy : S.current.sell; - - @override - AppBarStyle get appBarStyle => AppBarStyle.regular; - - @override - Widget body(BuildContext context) { - final isLightMode = Theme.of(context).extension()?.useDarkImage ?? false; - final availableProviders = isBuyAction - ? dashboardViewModel.availableBuyProviders - : dashboardViewModel.availableSellProviders; - - return ScrollableWithBottomSection( - content: Container( - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: 330), - child: Column( - children: [ - ...availableProviders.map((provider) { - final icon = Image.asset( - isLightMode ? provider.lightIcon : provider.darkIcon, - height: 40, - width: 40, - ); - - return Padding( - padding: EdgeInsets.only(top: 24), - child: OptionTile( - image: icon, - title: provider.toString(), - description: provider.providerDescription, - onPressed: () => provider.launchProvider(context, isBuyAction), - ), - ); - }).toList(), - ], - ), - ), - ), - ), - bottomSection: Padding( - padding: EdgeInsets.fromLTRB(24, 24, 24, 32), - child: Text( - isBuyAction - ? S.of(context).select_buy_provider_notice - : S.of(context).select_sell_provider_notice, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.normal, - color: Theme.of(context).extension()!.detailsTitlesColor, - ), - ), - ), - ); - } -} diff --git a/lib/src/screens/buy/buy_sell_options_page.dart b/lib/src/screens/buy/buy_sell_options_page.dart new file mode 100644 index 000000000..900810f68 --- /dev/null +++ b/lib/src/screens/buy/buy_sell_options_page.dart @@ -0,0 +1,48 @@ +import 'package:cake_wallet/core/selectable_option.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/select_options_page.dart'; +import 'package:flutter/cupertino.dart'; + +class BuyOptionsPage extends SelectOptionsPage { + BuyOptionsPage({required this.items, this.pickAnOption, this.confirmOption}); + + final List items; + final Function(SelectableOption option)? pickAnOption; + final Function(BuildContext context)? confirmOption; + + @override + String get pageTitle => S.current.choose_a_provider; + + @override + EdgeInsets? get contentPadding => null; + + @override + EdgeInsets? get tilePadding => EdgeInsets.only(top: 8); + + @override + EdgeInsets? get innerPadding => EdgeInsets.symmetric(horizontal: 24, vertical: 8); + + @override + double? get imageHeight => 40; + + @override + double? get imageWidth => 40; + + @override + Color? get selectedBackgroundColor => null; + + @override + double? get tileBorderRadius => 30; + + @override + String get bottomSectionText => ''; + + @override + void Function(SelectableOption option)? get onOptionTap => pickAnOption; + + @override + String get primaryButtonText => S.current.confirm; + + @override + void Function(BuildContext context)? get primaryButtonAction => confirmOption; +} diff --git a/lib/src/screens/buy/buy_sell_page.dart b/lib/src/screens/buy/buy_sell_page.dart new file mode 100644 index 000000000..d2f16fe3c --- /dev/null +++ b/lib/src/screens/buy/buy_sell_page.dart @@ -0,0 +1,469 @@ +import 'package:cake_wallet/buy/sell_buy_states.dart'; +import 'package:cake_wallet/core/address_validator.dart'; +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; +import 'package:cake_wallet/entities/parse_address_from_domain.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/exchange/widgets/desktop_exchange_cards_section.dart'; +import 'package:cake_wallet/src/screens/exchange/widgets/exchange_card.dart'; +import 'package:cake_wallet/src/screens/exchange/widgets/mobile_exchange_cards_section.dart'; +import 'package:cake_wallet/src/widgets/keyboard_done_button.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/src/widgets/provider_optoin_tile.dart'; +import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; +import 'package:cake_wallet/src/widgets/trail_button.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/themes/extensions/keyboard_theme.dart'; +import 'package:cake_wallet/themes/extensions/send_page_theme.dart'; +import 'package:cake_wallet/themes/theme_base.dart'; +import 'package:cake_wallet/typography.dart'; +import 'package:cake_wallet/src/screens/send/widgets/extract_address_from_parsed.dart'; +import 'package:cake_wallet/utils/responsive_layout_util.dart'; +import 'package:cake_wallet/view_model/buy/buy_sell_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:keyboard_actions/keyboard_actions.dart'; +import 'package:mobx/mobx.dart'; + +class BuySellPage extends BasePage { + BuySellPage(this.buySellViewModel); + + final BuySellViewModel buySellViewModel; + final cryptoCurrencyKey = GlobalKey(); + final fiatCurrencyKey = GlobalKey(); + final _formKey = GlobalKey(); + final _fiatAmountFocus = FocusNode(); + final _cryptoAmountFocus = FocusNode(); + final _cryptoAddressFocus = FocusNode(); + var _isReactionsSet = false; + + final arrowBottomPurple = Image.asset( + 'assets/images/arrow_bottom_purple_icon.png', + color: Colors.white, + height: 8, + ); + final arrowBottomCakeGreen = Image.asset( + 'assets/images/arrow_bottom_cake_green.png', + color: Colors.white, + height: 8, + ); + + late final String? depositWalletName; + late final String? receiveWalletName; + + @override + String get title => S.current.buy + '/' + S.current.sell; + + @override + bool get gradientBackground => true; + + @override + bool get gradientAll => true; + + @override + bool get resizeToAvoidBottomInset => false; + + @override + bool get extendBodyBehindAppBar => true; + + @override + AppBarStyle get appBarStyle => AppBarStyle.transparent; + + @override + Function(BuildContext)? get pushToNextWidget => (context) { + FocusScopeNode currentFocus = FocusScope.of(context); + if (!currentFocus.hasPrimaryFocus) { + currentFocus.focusedChild?.unfocus(); + } + }; + + @override + Widget trailing(BuildContext context) => TrailButton( + caption: S.of(context).clear, + onPressed: () { + _formKey.currentState?.reset(); + buySellViewModel.reset(); + }); + + @override + Widget? leading(BuildContext context) { + final _backButton = Icon( + Icons.arrow_back_ios, + color: titleColor(context), + size: 16, + ); + final _closeButton = + currentTheme.type == ThemeType.dark ? closeButtonImageDarkTheme : closeButtonImage; + + bool isMobileView = responsiveLayoutUtil.shouldRenderMobileUI; + + return MergeSemantics( + child: SizedBox( + height: isMobileView ? 37 : 45, + width: isMobileView ? 37 : 45, + child: ButtonTheme( + minWidth: double.minPositive, + child: Semantics( + label: !isMobileView ? S.of(context).close : S.of(context).seed_alert_back, + child: TextButton( + style: ButtonStyle( + overlayColor: MaterialStateColor.resolveWith((states) => Colors.transparent), + ), + onPressed: () => onClose(context), + child: !isMobileView ? _closeButton : _backButton, + ), + ), + ), + ), + ); + } + + @override + Widget body(BuildContext context) { + WidgetsBinding.instance.addPostFrameCallback((_) => _setReactions(context, buySellViewModel)); + + return KeyboardActions( + disableScroll: true, + config: KeyboardActionsConfig( + keyboardActionsPlatform: KeyboardActionsPlatform.IOS, + keyboardBarColor: Theme.of(context).extension()!.keyboardBarColor, + nextFocus: false, + actions: [ + KeyboardActionsItem( + focusNode: _fiatAmountFocus, toolbarButtons: [(_) => KeyboardDoneButton()]), + KeyboardActionsItem( + focusNode: _cryptoAmountFocus, toolbarButtons: [(_) => KeyboardDoneButton()]) + ]), + child: Container( + color: Theme.of(context).colorScheme.background, + child: Form( + key: _formKey, + child: ScrollableWithBottomSection( + contentPadding: EdgeInsets.only(bottom: 24), + content: Observer( + builder: (_) => Column(children: [ + _exchangeCardsSection(context), + Padding( + padding: EdgeInsets.symmetric(horizontal: 24), + child: Column( + children: [ + SizedBox(height: 12), + _buildPaymentMethodTile(context), + ], + ), + ), + ])), + bottomSectionPadding: EdgeInsets.only(left: 24, right: 24, bottom: 24), + bottomSection: Column(children: [ + Observer( + builder: (_) => LoadingPrimaryButton( + text: S.current.choose_a_provider, + onPressed: () async { + if(!_formKey.currentState!.validate()) return; + buySellViewModel.onTapChoseProvider(context); + }, + color: Theme.of(context).primaryColor, + textColor: Colors.white, + isDisabled: false, + isLoading: !buySellViewModel.isReadyToTrade)), + ]), + )), + )); + } + + Widget _buildPaymentMethodTile(BuildContext context) { + if (buySellViewModel.paymentMethodState is PaymentMethodLoading || + buySellViewModel.paymentMethodState is InitialPaymentMethod) { + return OptionTilePlaceholder( + withBadge: false, + withSubtitle: false, + borderRadius: 30, + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8), + leadingIcon: Icons.arrow_forward_ios, + isDarkTheme: buySellViewModel.isDarkTheme); + } + if (buySellViewModel.paymentMethodState is PaymentMethodFailed) { + return OptionTilePlaceholder(errorText: 'No payment methods available', borderRadius: 30); + } + if (buySellViewModel.paymentMethodState is PaymentMethodLoaded && + buySellViewModel.selectedPaymentMethod != null) { + return Observer(builder: (_) { + final selectedPaymentMethod = buySellViewModel.selectedPaymentMethod!; + return ProviderOptionTile( + lightImagePath: selectedPaymentMethod.lightIconPath, + darkImagePath: selectedPaymentMethod.darkIconPath, + title: selectedPaymentMethod.title, + onPressed: () => _pickPaymentMethod(context), + leadingIcon: Icons.arrow_forward_ios, + isLightMode: !buySellViewModel.isDarkTheme, + borderRadius: 30, + padding: EdgeInsets.symmetric(horizontal: 24, vertical: 8), + titleTextStyle: + textLargeBold(color: Theme.of(context).extension()!.titleColor), + ); + }); + } + return OptionTilePlaceholder(errorText: 'No payment methods available', borderRadius: 30); + } + + void _pickPaymentMethod(BuildContext context) async { + final currentOption = buySellViewModel.selectedPaymentMethod; + await Navigator.of(context).pushNamed( + Routes.paymentMethodOptionsPage, + arguments: [ + buySellViewModel.paymentMethods, + buySellViewModel.changeOption, + ], + ); + + buySellViewModel.selectedPaymentMethod; + if (currentOption != null && + currentOption.paymentMethodType != + buySellViewModel.selectedPaymentMethod?.paymentMethodType) { + await buySellViewModel.calculateBestRate(); + } + } + + void _setReactions(BuildContext context, BuySellViewModel buySellViewModel) { + if (_isReactionsSet) { + return; + } + + final fiatAmountController = fiatCurrencyKey.currentState!.amountController; + final cryptoAmountController = cryptoCurrencyKey.currentState!.amountController; + final cryptoAddressController = cryptoCurrencyKey.currentState!.addressController; + + _onCurrencyChange(buySellViewModel.cryptoCurrency, buySellViewModel, cryptoCurrencyKey); + _onCurrencyChange(buySellViewModel.fiatCurrency, buySellViewModel, fiatCurrencyKey); + + reaction( + (_) => buySellViewModel.wallet.name, + (String _) => + _onWalletNameChange(buySellViewModel, buySellViewModel.cryptoCurrency, cryptoCurrencyKey)); + + reaction( + (_) => buySellViewModel.cryptoCurrency, + (CryptoCurrency currency) => + _onCurrencyChange(currency, buySellViewModel, cryptoCurrencyKey)); + + reaction( + (_) => buySellViewModel.fiatCurrency, + (FiatCurrency currency) => + _onCurrencyChange(currency, buySellViewModel, fiatCurrencyKey)); + + reaction((_) => buySellViewModel.fiatAmount, (String amount) { + if (fiatCurrencyKey.currentState!.amountController.text != amount) { + fiatCurrencyKey.currentState!.amountController.text = amount; + } + }); + + reaction((_) => buySellViewModel.isCryptoCurrencyAddressEnabled, (bool isEnabled) { + cryptoCurrencyKey.currentState!.isAddressEditable(isEditable: isEnabled); + }); + + reaction((_) => buySellViewModel.cryptoAmount, (String amount) { + if (cryptoCurrencyKey.currentState!.amountController.text != amount) { + cryptoCurrencyKey.currentState!.amountController.text = amount; + } + }); + + reaction((_) => buySellViewModel.cryptoCurrencyAddress, (String address) { + if (cryptoAddressController != address) { + cryptoCurrencyKey.currentState!.addressController.text = address; + } + }); + + fiatAmountController.addListener(() { + if (fiatAmountController.text != buySellViewModel.fiatAmount) { + buySellViewModel.changeFiatAmount(amount: fiatAmountController.text); + } + }); + + cryptoAmountController.addListener(() { + if (cryptoAmountController.text != buySellViewModel.cryptoAmount) { + buySellViewModel.changeCryptoAmount(amount: cryptoAmountController.text); + } + }); + + cryptoAddressController.addListener(() { + buySellViewModel.changeCryptoCurrencyAddress(cryptoAddressController.text); + }); + + _cryptoAddressFocus.addListener(() async { + if (!_cryptoAddressFocus.hasFocus && cryptoAddressController.text.isNotEmpty) { + final domain = cryptoAddressController.text; + buySellViewModel.cryptoCurrencyAddress = + await fetchParsedAddress(context, domain, buySellViewModel.cryptoCurrency); + } + }); + + reaction((_) => buySellViewModel.wallet.walletAddresses.addressForExchange, (String address) { + if (buySellViewModel.cryptoCurrency == CryptoCurrency.xmr) { + cryptoCurrencyKey.currentState!.changeAddress(address: address); + } + }); + + reaction((_) => buySellViewModel.isReadyToTrade, (bool isReady) { + if (isReady) { + if (cryptoAmountController.text.isNotEmpty && + cryptoAmountController.text != S.current.fetching) { + buySellViewModel.changeCryptoAmount(amount: cryptoAmountController.text); + } else if (fiatAmountController.text.isNotEmpty && + fiatAmountController.text != S.current.fetching) { + buySellViewModel.changeFiatAmount(amount: fiatAmountController.text); + } + } + }); + + _isReactionsSet = true; + } + + void _onCurrencyChange(Currency currency, BuySellViewModel buySellViewModel, + GlobalKey key) { + final isCurrentTypeWallet = currency == buySellViewModel.wallet.currency; + + key.currentState!.changeSelectedCurrency(currency); + key.currentState!.changeWalletName(isCurrentTypeWallet ? buySellViewModel.wallet.name : ''); + + key.currentState!.changeAddress( + address: isCurrentTypeWallet ? buySellViewModel.wallet.walletAddresses.addressForExchange : ''); + + key.currentState!.changeAmount(amount: ''); + } + + void _onWalletNameChange(BuySellViewModel buySellViewModel, CryptoCurrency currency, + GlobalKey key) { + final isCurrentTypeWallet = currency == buySellViewModel.wallet.currency; + + if (isCurrentTypeWallet) { + key.currentState!.changeWalletName(buySellViewModel.wallet.name); + key.currentState!.addressController.text = buySellViewModel.wallet.walletAddresses.addressForExchange; + } else if (key.currentState!.addressController.text == + buySellViewModel.wallet.walletAddresses.addressForExchange) { + key.currentState!.changeWalletName(''); + key.currentState!.addressController.text = ''; + } + } + + void disposeBestRateSync() => {}; + + Widget _exchangeCardsSection(BuildContext context) { + final fiatExchangeCard = Observer( + builder: (_) => ExchangeCard( + cardInstanceName: 'fiat_currency_trade_card', + onDispose: disposeBestRateSync, + amountFocusNode: _fiatAmountFocus, + key: fiatCurrencyKey, + title: 'FIAT ${S.of(context).amount}', + initialCurrency: buySellViewModel.fiatCurrency, + initialWalletName: '', + initialAddress: '', + initialIsAmountEditable: true, + isAmountEstimated: false, + currencyRowPadding: EdgeInsets.zero, + addressRowPadding: EdgeInsets.zero, + isMoneroWallet: buySellViewModel.wallet == WalletType.monero, + showAddressField: false, + showLimitsField: false, + currencies: buySellViewModel.fiatCurrencies, + onCurrencySelected: (currency) => + buySellViewModel.changeFiatCurrency(currency: currency), + imageArrow: arrowBottomPurple, + currencyButtonColor: Colors.transparent, + addressButtonsColor: + Theme.of(context).extension()!.textFieldButtonColor, + borderColor: + Theme.of(context).extension()!.textFieldBorderTopPanelColor, + onPushPasteButton: (context) async {}, + onPushAddressBookButton: (context) async {}, + )); + + final cryptoExchangeCard = Observer( + builder: (_) => ExchangeCard( + cardInstanceName: 'crypto_currency_trade_card', + onDispose: disposeBestRateSync, + amountFocusNode: _cryptoAmountFocus, + addressFocusNode: _cryptoAddressFocus, + key: cryptoCurrencyKey, + title: 'Crypto ${S.of(context).amount}', + initialCurrency: buySellViewModel.cryptoCurrency, + initialWalletName: '', + initialAddress: buySellViewModel.cryptoCurrency == buySellViewModel.wallet.currency + ? buySellViewModel.wallet.walletAddresses.addressForExchange + : buySellViewModel.cryptoCurrencyAddress, + initialIsAmountEditable: true, + isAmountEstimated: true, + showLimitsField: false, + currencyRowPadding: EdgeInsets.zero, + addressRowPadding: EdgeInsets.zero, + isMoneroWallet: buySellViewModel.wallet == WalletType.monero, + currencies: buySellViewModel.cryptoCurrencies, + onCurrencySelected: (currency) => + buySellViewModel.changeCryptoCurrency(currency: currency), + imageArrow: arrowBottomCakeGreen, + currencyButtonColor: Colors.transparent, + addressButtonsColor: + Theme.of(context).extension()!.textFieldButtonColor, + borderColor: + Theme.of(context).extension()!.textFieldBorderBottomPanelColor, + addressTextFieldValidator: AddressValidator(type: buySellViewModel.cryptoCurrency), + onPushPasteButton: (context) async {}, + onPushAddressBookButton: (context) async {}, + )); + + if (responsiveLayoutUtil.shouldRenderMobileUI) { + return Observer( + builder: (_) { + if (buySellViewModel.isBuyAction) { + return MobileExchangeCardsSection( + firstExchangeCard: fiatExchangeCard, + secondExchangeCard: cryptoExchangeCard, + onBuyTap: () => null, + onSellTap: () => + buySellViewModel.isBuyAction ? buySellViewModel.changeBuySellAction() : null, + isBuySellOption: true, + ); + } else { + return MobileExchangeCardsSection( + firstExchangeCard: cryptoExchangeCard, + secondExchangeCard: fiatExchangeCard, + onBuyTap: () => + !buySellViewModel.isBuyAction ? buySellViewModel.changeBuySellAction() : null, + onSellTap: () => null, + isBuySellOption: true, + ); + } + }, + ); + } + + return Observer( + builder: (_) { + if (buySellViewModel.isBuyAction) { + return DesktopExchangeCardsSection( + firstExchangeCard: fiatExchangeCard, + secondExchangeCard: cryptoExchangeCard, + ); + } else { + return DesktopExchangeCardsSection( + firstExchangeCard: cryptoExchangeCard, + secondExchangeCard: fiatExchangeCard, + ); + } + }, + ); + } + + Future fetchParsedAddress( + BuildContext context, String domain, CryptoCurrency currency) async { + final parsedAddress = await getIt.get().resolve(context, domain, currency); + final address = await extractAddressFromParsed(context, parsedAddress); + return address; + } +} diff --git a/lib/src/screens/buy/payment_method_options_page.dart b/lib/src/screens/buy/payment_method_options_page.dart new file mode 100644 index 000000000..541f91ab4 --- /dev/null +++ b/lib/src/screens/buy/payment_method_options_page.dart @@ -0,0 +1,47 @@ +import 'package:cake_wallet/core/selectable_option.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/select_options_page.dart'; +import 'package:flutter/cupertino.dart'; + +class PaymentMethodOptionsPage extends SelectOptionsPage { + PaymentMethodOptionsPage({required this.items, this.pickAnOption}); + + final List items; + final Function(SelectableOption option)? pickAnOption; + + @override + String get pageTitle => S.current.choose_a_payment_method; + + @override + EdgeInsets? get contentPadding => null; + + @override + EdgeInsets? get tilePadding => EdgeInsets.only(top: 12); + + @override + EdgeInsets? get innerPadding => EdgeInsets.symmetric(horizontal: 24, vertical: 12); + + @override + double? get imageHeight => null; + + @override + double? get imageWidth => null; + + @override + Color? get selectedBackgroundColor => null; + + @override + double? get tileBorderRadius => 30; + + @override + String get bottomSectionText => ''; + + @override + void Function(SelectableOption option)? get onOptionTap => pickAnOption; + + @override + String get primaryButtonText => S.current.confirm; + + @override + void Function(BuildContext context)? get primaryButtonAction => null; +} diff --git a/lib/src/screens/dashboard/desktop_widgets/desktop_dashboard_actions.dart b/lib/src/screens/dashboard/desktop_widgets/desktop_dashboard_actions.dart index d36c06013..7bb5f77f8 100644 --- a/lib/src/screens/dashboard/desktop_widgets/desktop_dashboard_actions.dart +++ b/lib/src/screens/dashboard/desktop_widgets/desktop_dashboard_actions.dart @@ -21,6 +21,14 @@ class DesktopDashboardActions extends StatelessWidget { return Column( children: [ const SizedBox(height: 16), + DesktopActionButton( + title: MainActions.showWalletsAction.name(context), + image: MainActions.showWalletsAction.image, + canShow: MainActions.showWalletsAction.canShow?.call(dashboardViewModel), + isEnabled: MainActions.showWalletsAction.isEnabled?.call(dashboardViewModel), + onTap: () async => + await MainActions.showWalletsAction.onTap(context, dashboardViewModel), + ), DesktopActionButton( title: MainActions.exchangeAction.name(context), image: MainActions.exchangeAction.image, @@ -55,20 +63,11 @@ class DesktopDashboardActions extends StatelessWidget { children: [ Expanded( child: DesktopActionButton( - title: MainActions.buyAction.name(context), - image: MainActions.buyAction.image, - canShow: MainActions.buyAction.canShow?.call(dashboardViewModel), - isEnabled: MainActions.buyAction.isEnabled?.call(dashboardViewModel), - onTap: () async => await MainActions.buyAction.onTap(context, dashboardViewModel), - ), - ), - Expanded( - child: DesktopActionButton( - title: MainActions.sellAction.name(context), - image: MainActions.sellAction.image, - canShow: MainActions.sellAction.canShow?.call(dashboardViewModel), - isEnabled: MainActions.sellAction.isEnabled?.call(dashboardViewModel), - onTap: () async => await MainActions.sellAction.onTap(context, dashboardViewModel), + title: MainActions.tradeAction.name(context), + image: MainActions.tradeAction.image, + canShow: MainActions.tradeAction.canShow?.call(dashboardViewModel), + isEnabled: MainActions.tradeAction.isEnabled?.call(dashboardViewModel), + onTap: () async => await MainActions.tradeAction.onTap(context, dashboardViewModel), ), ), ], diff --git a/lib/src/screens/exchange/widgets/exchange_card.dart b/lib/src/screens/exchange/widgets/exchange_card.dart index 75a2eadd7..7c4cc948d 100644 --- a/lib/src/screens/exchange/widgets/exchange_card.dart +++ b/lib/src/screens/exchange/widgets/exchange_card.dart @@ -18,7 +18,7 @@ import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; import 'package:cake_wallet/src/screens/exchange/widgets/currency_picker.dart'; import 'package:cake_wallet/themes/extensions/send_page_theme.dart'; -class ExchangeCard extends StatefulWidget { +class ExchangeCard extends StatefulWidget { ExchangeCard({ Key? key, required this.initialCurrency, @@ -40,19 +40,23 @@ class ExchangeCard extends StatefulWidget { this.borderColor = Colors.transparent, this.hasAllAmount = false, this.isAllAmountEnabled = false, + this.showAddressField = true, + this.showLimitsField = true, this.amountFocusNode, this.addressFocusNode, this.allAmount, + this.currencyRowPadding, + this.addressRowPadding, this.onPushPasteButton, this.onPushAddressBookButton, this.onDispose, required this.cardInstanceName, }) : super(key: key); - final List currencies; - final Function(CryptoCurrency) onCurrencySelected; + final List currencies; + final Function(T) onCurrencySelected; final String title; - final CryptoCurrency initialCurrency; + final T initialCurrency; final String initialWalletName; final String initialAddress; final bool initialIsAmountEditable; @@ -70,18 +74,22 @@ class ExchangeCard extends StatefulWidget { final FocusNode? amountFocusNode; final FocusNode? addressFocusNode; final bool hasAllAmount; + final bool showAddressField; + final bool showLimitsField; final bool isAllAmountEnabled; final VoidCallback? allAmount; + final EdgeInsets? currencyRowPadding; + final EdgeInsets? addressRowPadding; final void Function(BuildContext context)? onPushPasteButton; final void Function(BuildContext context)? onPushAddressBookButton; final Function()? onDispose; final String cardInstanceName; @override - ExchangeCardState createState() => ExchangeCardState(); + ExchangeCardState createState() => ExchangeCardState(); } -class ExchangeCardState extends State { +class ExchangeCardState extends State> { ExchangeCardState() : _title = '', _min = '', @@ -89,7 +97,6 @@ class ExchangeCardState extends State { _isAmountEditable = false, _isAddressEditable = false, _walletName = '', - _selectedCurrency = CryptoCurrency.btc, _isAmountEstimated = false, _isMoneroWallet = false, _cardInstanceName = ''; @@ -101,7 +108,7 @@ class ExchangeCardState extends State { String _title; String? _min; String? _max; - CryptoCurrency _selectedCurrency; + late T _selectedCurrency; String _walletName; bool _isAmountEditable; bool _isAddressEditable; @@ -118,7 +125,8 @@ class ExchangeCardState extends State { _selectedCurrency = widget.initialCurrency; _isAmountEstimated = widget.isAmountEstimated; _isMoneroWallet = widget.isMoneroWallet; - addressController.text = widget.initialAddress; + addressController.text = _normalizeAddressFormat(widget.initialAddress); + super.initState(); } @@ -136,7 +144,7 @@ class ExchangeCardState extends State { }); } - void changeSelectedCurrency(CryptoCurrency currency) { + void changeSelectedCurrency(T currency) { setState(() => _selectedCurrency = currency); } @@ -157,7 +165,7 @@ class ExchangeCardState extends State { } void changeAddress({required String address}) { - setState(() => addressController.text = address); + setState(() => addressController.text = _normalizeAddressFormat(address)); } void changeAmount({required String amount}) { @@ -222,7 +230,7 @@ class ExchangeCardState extends State { Divider(height: 1, color: Theme.of(context).extension()!.textFieldHintColor), Padding( padding: EdgeInsets.only(top: 5), - child: Container( + child: widget.showLimitsField ? Container( height: 15, child: Row(mainAxisAlignment: MainAxisAlignment.start, children: [ _min != null @@ -247,7 +255,7 @@ class ExchangeCardState extends State { ), ) : Offstage(), - ])), + ])) : Offstage(), ), !_isAddressEditable && widget.hasRefundAddress ? Padding( @@ -261,10 +269,11 @@ class ExchangeCardState extends State { )) : Offstage(), _isAddressEditable + ? widget.showAddressField ? FocusTraversalOrder( order: NumericFocusOrder(2), child: Padding( - padding: EdgeInsets.only(top: 20), + padding: widget.addressRowPadding ?? EdgeInsets.only(top: 20), child: AddressTextField( addressKey: ValueKey('${_cardInstanceName}_editable_address_textfield_key'), focusNode: widget.addressFocusNode, @@ -280,26 +289,29 @@ class ExchangeCardState extends State { widget.amountFocusNode?.requestFocus(); amountController.text = paymentRequest.amount; }, - placeholder: widget.hasRefundAddress ? S.of(context).refund_address : null, + placeholder: + widget.hasRefundAddress ? S.of(context).refund_address : null, options: [ AddressTextFieldOption.paste, AddressTextFieldOption.qrCode, AddressTextFieldOption.addressBook, ], isBorderExist: false, - textStyle: - TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), + textStyle: TextStyle( + fontSize: 16, fontWeight: FontWeight.w600, color: Colors.white), hintStyle: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: Theme.of(context).extension()!.hintTextColor), + color: + Theme.of(context).extension()!.hintTextColor), buttonColor: widget.addressButtonsColor, validator: widget.addressTextFieldValidator, onPushPasteButton: widget.onPushPasteButton, onPushAddressBookButton: widget.onPushAddressBookButton, selectedCurrency: _selectedCurrency), ), - ) + ) + : Offstage() : Padding( padding: EdgeInsets.only(top: 10), child: Builder( @@ -402,7 +414,7 @@ class ExchangeCardState extends State { hintText: S.of(context).search_currency, isMoneroWallet: _isMoneroWallet, isConvertFrom: widget.hasRefundAddress, - onItemSelected: (Currency item) => widget.onCurrencySelected(item as CryptoCurrency), + onItemSelected: (Currency item) => widget.onCurrencySelected(item as T), ), ); } @@ -424,4 +436,10 @@ class ExchangeCardState extends State { actionLeftButton: () => Navigator.of(dialogContext).pop()); }); } + + String _normalizeAddressFormat(String address) { + if (address.startsWith('bitcoincash:')) address = address.substring(12); + return address; + } } + diff --git a/lib/src/screens/exchange/widgets/mobile_exchange_cards_section.dart b/lib/src/screens/exchange/widgets/mobile_exchange_cards_section.dart index 126bca835..d53f16339 100644 --- a/lib/src/screens/exchange/widgets/mobile_exchange_cards_section.dart +++ b/lib/src/screens/exchange/widgets/mobile_exchange_cards_section.dart @@ -1,20 +1,29 @@ +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/src/screens/new_wallet/widgets/select_button.dart'; import 'package:cake_wallet/themes/extensions/exchange_page_theme.dart'; +import 'package:cake_wallet/themes/extensions/send_page_theme.dart'; import 'package:flutter/material.dart'; class MobileExchangeCardsSection extends StatelessWidget { final Widget firstExchangeCard; final Widget secondExchangeCard; + final bool isBuySellOption; + final VoidCallback? onBuyTap; + final VoidCallback? onSellTap; const MobileExchangeCardsSection({ Key? key, required this.firstExchangeCard, required this.secondExchangeCard, + this.isBuySellOption = false, + this.onBuyTap, + this.onSellTap, }) : super(key: key); @override Widget build(BuildContext context) { return Container( - padding: EdgeInsets.only(bottom: 32), + padding: EdgeInsets.only(bottom: isBuySellOption ? 8 : 32), decoration: BoxDecoration( borderRadius: BorderRadius.only( bottomLeft: Radius.circular(24), @@ -45,8 +54,18 @@ class MobileExchangeCardsSection extends StatelessWidget { end: Alignment.bottomRight, ), ), - padding: EdgeInsets.fromLTRB(24, 100, 24, 32), - child: firstExchangeCard, + padding: EdgeInsets.fromLTRB(24, 90, 24, isBuySellOption ? 8 : 32), + child: Column( + children: [ + if (isBuySellOption) Column( + children: [ + const SizedBox(height: 16), + BuySellOptionButtons(onBuyTap: onBuyTap, onSellTap: onSellTap), + ], + ), + firstExchangeCard, + ], + ), ), Padding( padding: EdgeInsets.only(top: 29, left: 24, right: 24), @@ -57,3 +76,69 @@ class MobileExchangeCardsSection extends StatelessWidget { ); } } + +class BuySellOptionButtons extends StatefulWidget { + final VoidCallback? onBuyTap; + final VoidCallback? onSellTap; + + const BuySellOptionButtons({this.onBuyTap, this.onSellTap}); + + @override + _BuySellOptionButtonsState createState() => _BuySellOptionButtonsState(); +} + +class _BuySellOptionButtonsState extends State { + bool isBuySelected = true; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 24), + child: Row( + children: [ + Expanded(flex: 2, child: SizedBox()), + Expanded( + flex: 5, + child: SelectButton( + height: 44, + text: S.of(context).buy, + isSelected: isBuySelected, + showTrailingIcon: false, + textColor: Colors.white, + image: Image.asset('assets/images/buy.png', height: 25, width: 25), + padding: EdgeInsets.only(left: 10, right: 30), + color: isBuySelected + ? null + : Theme.of(context).extension()!.textFieldButtonColor, + onTap: () { + setState(() => isBuySelected = true); + if (widget.onBuyTap != null) widget.onBuyTap!(); + }, + ), + ), + Expanded(child: const SizedBox()), + Expanded( + flex: 5, + child: SelectButton( + height: 44, + text: S.of(context).sell, + isSelected: !isBuySelected, + showTrailingIcon: false, + textColor: Colors.white, + image: Image.asset('assets/images/sell.png', height: 25, width: 25), + padding: EdgeInsets.only(left: 10, right: 30), + color: !isBuySelected + ? null + : Theme.of(context).extension()!.textFieldButtonColor, + onTap: () { + setState(() => isBuySelected = false); + if (widget.onSellTap != null) widget.onSellTap!(); + }, + ), + ), + Expanded(flex: 2, child: SizedBox()), + ], + ), + ); + } +} diff --git a/lib/src/screens/seed/pre_seed_page.dart b/lib/src/screens/seed/pre_seed_page.dart index 23de4564f..475f45fca 100644 --- a/lib/src/screens/seed/pre_seed_page.dart +++ b/lib/src/screens/seed/pre_seed_page.dart @@ -1,6 +1,6 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/InfoPage.dart'; +import 'package:cake_wallet/src/screens/Info_page.dart'; import 'package:flutter/cupertino.dart'; class PreSeedPage extends InfoPage { diff --git a/lib/src/screens/select_options_page.dart b/lib/src/screens/select_options_page.dart new file mode 100644 index 000000000..70cb2abc1 --- /dev/null +++ b/lib/src/screens/select_options_page.dart @@ -0,0 +1,199 @@ +import 'package:cake_wallet/core/selectable_option.dart'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/src/widgets/provider_optoin_tile.dart'; +import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; +import 'package:cake_wallet/themes/extensions/option_tile_theme.dart'; +import 'package:cake_wallet/themes/extensions/transaction_trade_theme.dart'; +import 'package:flutter/material.dart'; + +abstract class SelectOptionsPage extends BasePage { + SelectOptionsPage(); + + String get pageTitle; + + EdgeInsets? get contentPadding; + + EdgeInsets? get tilePadding; + + EdgeInsets? get innerPadding; + + double? get imageHeight; + + double? get imageWidth; + + Color? get selectedBackgroundColor; + + double? get tileBorderRadius; + + String get bottomSectionText; + + bool get primaryButtonEnabled => true; + + String get primaryButtonText => ''; + + List get items; + + void Function(SelectableOption option)? get onOptionTap; + + void Function(BuildContext context)? get primaryButtonAction; + + @override + String get title => pageTitle; + + @override + Widget body(BuildContext context) { + return ScrollableWithBottomSection( + content: BodySelectOptionsPage( + items: items, + onOptionTap: onOptionTap, + tilePadding: tilePadding, + tileBorderRadius: tileBorderRadius, + imageHeight: imageHeight, + imageWidth: imageWidth, + innerPadding: innerPadding), + bottomSection: Padding( + padding: contentPadding ?? EdgeInsets.zero, + child: Column( + children: [ + Text( + bottomSectionText, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: Theme.of(context).extension()!.detailsTitlesColor, + ), + ), + if (primaryButtonEnabled) + LoadingPrimaryButton( + text: primaryButtonText, + onPressed: () { + primaryButtonAction != null + ? primaryButtonAction!(context) + : Navigator.pop(context); + }, + color: Theme.of(context).primaryColor, + textColor: Colors.white, + isDisabled: false, + isLoading: false) + ], + ), + ), + ); + } +} + +class BodySelectOptionsPage extends StatefulWidget { + const BodySelectOptionsPage({ + required this.items, + this.onOptionTap, + this.tilePadding, + this.tileBorderRadius, + this.imageHeight, + this.imageWidth, + this.innerPadding, + }); + + final List items; + final void Function(SelectableOption option)? onOptionTap; + final EdgeInsets? tilePadding; + final double? tileBorderRadius; + final double? imageHeight; + final double? imageWidth; + final EdgeInsets? innerPadding; + + @override + _BodySelectOptionsPageState createState() => _BodySelectOptionsPageState(); +} + +class _BodySelectOptionsPageState extends State { + late List _items; + + @override + void initState() { + super.initState(); + _items = widget.items; + } + + void _handleOptionTap(SelectableOption option) { + setState(() { + for (var item in _items) { + if (item is SelectableOption) { + item.isOptionSelected = false; + } + } + option.isOptionSelected = true; + }); + widget.onOptionTap?.call(option); + } + + @override + Widget build(BuildContext context) { + final isLightMode = Theme.of(context).extension()?.useDarkImage ?? false; + + Color titleColor = + isLightMode ? Theme.of(context).appBarTheme.titleTextStyle!.color! : Colors.white; + + return Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 350), + child: Column( + children: _items.map((item) { + if (item is OptionTitle) { + return Padding( + padding: const EdgeInsets.only(top: 18, bottom: 8), + child: Container( + width: double.infinity, + decoration: BoxDecoration( + border: Border( + bottom: BorderSide( + color: titleColor, + width: 1, + ), + ), + ), + child: Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + item.title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: titleColor, + ), + ), + ), + ), + ); + } else if (item is SelectableOption) { + return Padding( + padding: widget.tilePadding ?? const EdgeInsets.only(top: 24), + child: ProviderOptionTile( + title: item.title, + lightImagePath: item.lightIconPath, + darkImagePath: item.darkIconPath, + imageHeight: widget.imageHeight, + imageWidth: widget.imageWidth, + padding: widget.innerPadding, + description: item.description, + topLeftSubTitle: item.topLeftSubTitle, + topRightSubTitle: item.topRightSubTitle, + rightSubTitleLightIconPath: item.topRightSubTitleLightIconPath, + rightSubTitleDarkIconPath: item.topRightSubTitleDarkIconPath, + bottomLeftSubTitle: item.bottomLeftSubTitle, + badges: item.badges, + isSelected: item.isOptionSelected, + borderRadius: widget.tileBorderRadius, + isLightMode: isLightMode, + onPressed: () => _handleOptionTap(item), + ), + ); + } + return const SizedBox.shrink(); + }).toList(), + ), + ), + ); + } +} diff --git a/lib/src/screens/settings/other_settings_page.dart b/lib/src/screens/settings/other_settings_page.dart index 137f699f5..f6a6288f5 100644 --- a/lib/src/screens/settings/other_settings_page.dart +++ b/lib/src/screens/settings/other_settings_page.dart @@ -57,22 +57,6 @@ class OtherSettingsPage extends BasePage { handler: (BuildContext context) => Navigator.of(context).pushNamed(Routes.changeRep), ), - if(_otherSettingsViewModel.isEnabledBuyAction) - SettingsPickerCell( - title: S.current.default_buy_provider, - items: _otherSettingsViewModel.availableBuyProvidersTypes, - displayItem: _otherSettingsViewModel.getBuyProviderType, - selectedItem: _otherSettingsViewModel.buyProviderType, - onItemSelected: _otherSettingsViewModel.onBuyProviderTypeSelected - ), - if(_otherSettingsViewModel.isEnabledSellAction) - SettingsPickerCell( - title: S.current.default_sell_provider, - items: _otherSettingsViewModel.availableSellProvidersTypes, - displayItem: _otherSettingsViewModel.getSellProviderType, - selectedItem: _otherSettingsViewModel.sellProviderType, - onItemSelected: _otherSettingsViewModel.onSellProviderTypeSelected, - ), SettingsCellWithArrow( title: S.current.settings_terms_and_conditions, handler: (BuildContext context) => diff --git a/lib/src/screens/settings/privacy_page.dart b/lib/src/screens/settings/privacy_page.dart index 53e7686e8..8652c4af6 100644 --- a/lib/src/screens/settings/privacy_page.dart +++ b/lib/src/screens/settings/privacy_page.dart @@ -73,16 +73,10 @@ class PrivacyPage extends BasePage { _privacySettingsViewModel.setIsAppSecure(value); }), SettingsSwitcherCell( - title: S.current.disable_buy, - value: _privacySettingsViewModel.disableBuy, + title: S.current.disable_trade_option, + value: _privacySettingsViewModel.disableTradeOption, onValueChange: (BuildContext _, bool value) { - _privacySettingsViewModel.setDisableBuy(value); - }), - SettingsSwitcherCell( - title: S.current.disable_sell, - value: _privacySettingsViewModel.disableSell, - onValueChange: (BuildContext _, bool value) { - _privacySettingsViewModel.setDisableSell(value); + _privacySettingsViewModel.setDisableTradeOption(value); }), SettingsSwitcherCell( title: S.current.disable_bulletin, 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 8aa0ac3c9..834d01c26 100644 --- a/lib/src/screens/setup_2fa/setup_2fa_info_page.dart +++ b/lib/src/screens/setup_2fa/setup_2fa_info_page.dart @@ -1,6 +1,6 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/screens/InfoPage.dart'; +import 'package:cake_wallet/src/screens/Info_page.dart'; import 'package:flutter/cupertino.dart'; class Setup2FAInfoPage extends InfoPage { diff --git a/lib/src/widgets/address_text_field.dart b/lib/src/widgets/address_text_field.dart index d904768f0..9b407dedb 100644 --- a/lib/src/widgets/address_text_field.dart +++ b/lib/src/widgets/address_text_field.dart @@ -1,20 +1,21 @@ import 'package:cake_wallet/utils/device_info.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; +import 'package:cw_core/currency.dart'; import 'package:flutter/services.dart'; import 'package:flutter/material.dart'; import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/entities/qr_scanner.dart'; import 'package:cake_wallet/entities/contact_base.dart'; -import 'package:cw_core/crypto_currency.dart'; import 'package:cake_wallet/themes/extensions/send_page_theme.dart'; import 'package:cake_wallet/utils/permission_handler.dart'; import 'package:permission_handler/permission_handler.dart'; enum AddressTextFieldOption { paste, qrCode, addressBook, walletAddresses } -class AddressTextField extends StatelessWidget { + +class AddressTextField extends StatelessWidget{ AddressTextField({ required this.controller, this.isActive = true, @@ -58,7 +59,7 @@ class AddressTextField extends StatelessWidget { final Function(BuildContext context)? onPushAddressBookButton; final Function(BuildContext context)? onPushAddressPickerButton; final Function(ContactBase contact)? onSelectedContact; - final CryptoCurrency? selectedCurrency; + final T? selectedCurrency; final Key? addressKey; @override diff --git a/lib/src/widgets/provider_optoin_tile.dart b/lib/src/widgets/provider_optoin_tile.dart new file mode 100644 index 000000000..85396a97d --- /dev/null +++ b/lib/src/widgets/provider_optoin_tile.dart @@ -0,0 +1,527 @@ +import 'package:cake_wallet/themes/extensions/option_tile_theme.dart'; +import 'package:cake_wallet/themes/extensions/receive_page_theme.dart'; +import 'package:cake_wallet/typography.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_svg/svg.dart'; + +class ProviderOptionTile extends StatelessWidget { + const ProviderOptionTile({ + required this.onPressed, + required this.lightImagePath, + required this.darkImagePath, + required this.title, + this.topLeftSubTitle, + this.topRightSubTitle, + this.bottomLeftSubTitle, + this.bottomRightSubTitle, + this.leftSubTitleIconPath, + this.rightSubTitleLightIconPath, + this.rightSubTitleDarkIconPath, + this.description, + this.badges, + this.borderRadius, + this.imageHeight, + this.imageWidth, + this.padding, + this.titleTextStyle, + this.firstSubTitleTextStyle, + this.secondSubTitleTextStyle, + this.leadingIcon, + this.selectedBackgroundColor, + this.isSelected = false, + required this.isLightMode, + }); + + final VoidCallback onPressed; + final String lightImagePath; + final String darkImagePath; + final String title; + final String? topLeftSubTitle; + final String? topRightSubTitle; + final String? bottomLeftSubTitle; + final String? bottomRightSubTitle; + final String? leftSubTitleIconPath; + final String? rightSubTitleLightIconPath; + final String? rightSubTitleDarkIconPath; + final String? description; + final List? badges; + final double? borderRadius; + final double? imageHeight; + final double? imageWidth; + final EdgeInsets? padding; + final TextStyle? titleTextStyle; + final TextStyle? firstSubTitleTextStyle; + final TextStyle? secondSubTitleTextStyle; + final IconData? leadingIcon; + final Color? selectedBackgroundColor; + final bool isSelected; + final bool isLightMode; + + @override + Widget build(BuildContext context) { + final backgroundColor = isSelected + ? isLightMode + ? Theme.of(context).extension()!.currentTileBackgroundColor + : Theme.of(context).extension()!.titleColor + : Theme.of(context).cardColor; + + final textColor = isSelected + ? isLightMode + ? Colors.white + : Theme.of(context).cardColor + : Theme.of(context).extension()!.titleColor; + + final badgeColor = isSelected + ? Theme.of(context).cardColor + : Theme.of(context).extension()!.titleColor; + + final badgeTextColor = isSelected + ? Theme.of(context).extension()!.titleColor + : Theme.of(context).cardColor; + + final imagePath = isSelected + ? isLightMode + ? darkImagePath + : lightImagePath + : isLightMode + ? lightImagePath + : darkImagePath; + + final rightSubTitleIconPath = isSelected + ? isLightMode + ? rightSubTitleDarkIconPath + : rightSubTitleLightIconPath + : isLightMode + ? rightSubTitleLightIconPath + : rightSubTitleDarkIconPath; + + return GestureDetector( + onTap: onPressed, + child: Container( + width: double.infinity, + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(borderRadius ?? 12)), + border: isSelected && !isLightMode ? Border.all(color: textColor) : null, + color: backgroundColor, + ), + child: Padding( + padding: padding ?? const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + getImage(imagePath, height: imageHeight, width: imageWidth), + SizedBox(width: 8), + Expanded( + child: Container( + child: Row( + children: [ + Expanded( + child: Text(title, + style: titleTextStyle ?? textLargeBold(color: textColor))), + Row( + children: [ + if (leadingIcon != null) + Icon(leadingIcon, size: 16, color: textColor), + ], + ) + ], + ), + ), + ), + ], + ), + if (topLeftSubTitle != null || topRightSubTitle != null) + subTitleWidget( + leftSubTitle: topLeftSubTitle, + subTitleIconPath: leftSubTitleIconPath, + textColor: textColor, + rightSubTitle: topRightSubTitle, + rightSubTitleIconPath: rightSubTitleIconPath), + if (bottomLeftSubTitle != null || bottomRightSubTitle != null) + subTitleWidget( + leftSubTitle: bottomLeftSubTitle, + textColor: textColor, + subTitleFontSize: 12), + if (badges != null && badges!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Row(children: [ + ...badges! + .map((badge) => Badge( + title: badge, textColor: badgeTextColor, backgroundColor: badgeColor)) + .toList() + ]), + ) + ], + ), + ), + ), + ); + } +} + +class subTitleWidget extends StatelessWidget { + const subTitleWidget({ + super.key, + this.leftSubTitle, + this.subTitleIconPath, + required this.textColor, + this.rightSubTitle, + this.rightSubTitleIconPath, + this.subTitleFontSize = 16, + }); + + final String? leftSubTitle; + final String? subTitleIconPath; + final Color textColor; + final String? rightSubTitle; + final String? rightSubTitleIconPath; + final double subTitleFontSize; + + @override + Widget build(BuildContext context) { + return Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + leftSubTitle != null || subTitleIconPath != null + ? Row( + children: [ + if (subTitleIconPath != null && subTitleIconPath!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: 6), + child: getImage(subTitleIconPath!), + ), + Text( + leftSubTitle ?? '', + style: TextStyle( + fontSize: subTitleFontSize, + fontWeight: FontWeight.w700, + color: textColor), + ), + ], + ) + : Offstage(), + rightSubTitle != null || rightSubTitleIconPath != null + ? Row( + children: [ + if (rightSubTitleIconPath != null && rightSubTitleIconPath!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(right: 4), + child: getImage(rightSubTitleIconPath!, imageColor: textColor), + ), + Text( + rightSubTitle ?? '', + style: TextStyle( + fontSize: subTitleFontSize, fontWeight: FontWeight.w700, color: textColor), + ), + ], + ) + : Offstage(), + ], + ); + } +} + +class Badge extends StatelessWidget { + Badge({required this.textColor, required this.backgroundColor, required this.title}); + + final String title; + final Color textColor; + final Color backgroundColor; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: FittedBox( + fit: BoxFit.fitHeight, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(24)), color: backgroundColor), + alignment: Alignment.center, + child: Text( + title, + style: TextStyle( + color: textColor, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} + +Widget getImage(String imagePath, {double? height, double? width, Color? imageColor}) { + final bool isNetworkImage = imagePath.startsWith('http') || imagePath.startsWith('https'); + final bool isSvg = imagePath.endsWith('.svg'); + final double imageHeight = height ?? 35; + final double imageWidth = width ?? 35; + + if (isNetworkImage) { + return isSvg + ? SvgPicture.network( + imagePath, + height: imageHeight, + width: imageWidth, + colorFilter: imageColor != null ? ColorFilter.mode(imageColor, BlendMode.srcIn) : null, + placeholderBuilder: (BuildContext context) => Container( + height: imageHeight, + width: imageWidth, + child: Center( + child: CircularProgressIndicator(), + ), + ), + ) + : Image.network( + imagePath, + height: imageHeight, + width: imageWidth, + loadingBuilder: (BuildContext context, Widget child, ImageChunkEvent? loadingProgress) { + if (loadingProgress == null) { + return child; + } + return Container( + height: imageHeight, + width: imageWidth, + child: Center( + child: CircularProgressIndicator( + value: loadingProgress.expectedTotalBytes != null + ? loadingProgress.cumulativeBytesLoaded / + loadingProgress.expectedTotalBytes! + : null, + ), + ), + ); + }, + errorBuilder: (BuildContext context, Object exception, StackTrace? stackTrace) { + return Container( + height: imageHeight, + width: imageWidth, + ); + }, + ); + } else { + return isSvg + ? SvgPicture.asset( + imagePath, + height: imageHeight, + width: imageWidth, + colorFilter: imageColor != null ? ColorFilter.mode(imageColor, BlendMode.srcIn) : null, + ) + : Image.asset(imagePath, height: imageHeight, width: imageWidth); + } +} + +class OptionTilePlaceholder extends StatefulWidget { + OptionTilePlaceholder({ + this.borderRadius, + this.imageHeight, + this.imageWidth, + this.padding, + this.leadingIcon, + this.withBadge = true, + this.withSubtitle = true, + this.isDarkTheme = false, + this.errorText, + }); + + final double? borderRadius; + final double? imageHeight; + final double? imageWidth; + final EdgeInsets? padding; + final IconData? leadingIcon; + final bool withBadge; + final bool withSubtitle; + final bool isDarkTheme; + final String? errorText; + + @override + _OptionTilePlaceholderState createState() => _OptionTilePlaceholderState(); +} + +class _OptionTilePlaceholderState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + + _controller = AnimationController( + duration: const Duration(seconds: 2), + vsync: this, + )..repeat(); + + _animation = CurvedAnimation( + parent: _controller, + curve: Curves.linear, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final backgroundColor = Theme.of(context).cardColor; + final titleColor = Theme.of(context).extension()!.titleColor.withOpacity(0.4); + + return widget.errorText != null + ? Container( + width: double.infinity, + padding: widget.padding ?? EdgeInsets.all(16), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius ?? 12)), + color: backgroundColor, + ), + child: Column( + children: [ + Text( + widget.errorText!, + style: TextStyle( + color: titleColor, + fontSize: 16, + ), + ), + if (widget.withSubtitle) SizedBox(height: 8), + Text( + '', + style: TextStyle( + color: titleColor, + fontSize: 16, + ), + ), + ], + ), + ) + : AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Stack( + children: [ + Container( + width: double.infinity, + padding: widget.padding ?? EdgeInsets.all(16), + alignment: Alignment.center, + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius ?? 12)), + color: backgroundColor, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Row( + children: [ + Container( + height: widget.imageHeight ?? 35, + width: widget.imageWidth ?? 35, + decoration: BoxDecoration( + color: titleColor, + shape: BoxShape.circle, + ), + ), + SizedBox(width: 8), + Expanded( + child: Container( + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + height: 20, + width: 70, + decoration: BoxDecoration( + color: titleColor, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ), + if (widget.leadingIcon != null) + Icon(widget.leadingIcon, size: 16, color: titleColor), + ], + ), + ), + ), + ], + ), + if (widget.withSubtitle) + Padding( + padding: EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Container( + height: 20, + width: 170, + decoration: BoxDecoration( + color: titleColor, + borderRadius: BorderRadius.all(Radius.circular(8)), + ), + ), + ], + ), + ), + if (widget.withBadge) + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: Row( + children: [ + Container( + height: 30, + width: 70, + decoration: BoxDecoration( + color: titleColor, + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + ), + SizedBox(width: 8), + Container( + height: 30, + width: 70, + decoration: BoxDecoration( + color: titleColor, + borderRadius: BorderRadius.all(Radius.circular(20)), + ), + ), + ], + ), + ), + ], + ), + ), + Positioned.fill( + child: Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.all(Radius.circular(widget.borderRadius ?? 12)), + gradient: LinearGradient( + begin: Alignment(-2, -4), + end: Alignment(2, 4), + stops: [ + _animation.value - 0.2, + _animation.value, + _animation.value + 0.2, + ], + colors: [ + backgroundColor.withOpacity(widget.isDarkTheme ? 0.4 : 0.7), + backgroundColor.withOpacity(widget.isDarkTheme ? 0.7 : 0.4), + backgroundColor.withOpacity(widget.isDarkTheme ? 0.4 : 0.7), + ], + ), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index 50d51d2ed..1ecaf50cc 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -61,8 +61,7 @@ abstract class SettingsStoreBase with Store { required BitcoinSeedType initialBitcoinSeedType, required NanoSeedType initialNanoSeedType, required bool initialAppSecure, - required bool initialDisableBuy, - required bool initialDisableSell, + required bool initialDisableTrade, required FilterListOrderType initialWalletListOrder, required FilterListOrderType initialContactListOrder, required bool initialDisableBulletin, @@ -150,8 +149,7 @@ abstract class SettingsStoreBase with Store { useTOTP2FA = initialUseTOTP2FA, numberOfFailedTokenTrials = initialFailedTokenTrial, isAppSecure = initialAppSecure, - disableBuy = initialDisableBuy, - disableSell = initialDisableSell, + disableTradeOption = initialDisableTrade, disableBulletin = initialDisableBulletin, walletListOrder = initialWalletListOrder, contactListOrder = initialContactListOrder, @@ -178,9 +176,7 @@ abstract class SettingsStoreBase with Store { initialShouldRequireTOTP2FAForAllSecurityAndBackupSettings, currentSyncMode = initialSyncMode, currentSyncAll = initialSyncAll, - priority = ObservableMap(), - defaultBuyProviders = ObservableMap(), - defaultSellProviders = ObservableMap() { + priority = ObservableMap() { //this.nodes = ObservableMap.of(nodes); if (initialMoneroTransactionPriority != null) { @@ -221,30 +217,6 @@ abstract class SettingsStoreBase with Store { initializeTrocadorProviderStates(); - WalletType.values.forEach((walletType) { - final key = 'buyProvider_${walletType.toString()}'; - final providerId = sharedPreferences.getString(key); - if (providerId != null) { - defaultBuyProviders[walletType] = ProviderType.values.firstWhere( - (provider) => provider.id == providerId, - orElse: () => ProviderType.askEachTime); - } else { - defaultBuyProviders[walletType] = ProviderType.askEachTime; - } - }); - - WalletType.values.forEach((walletType) { - final key = 'sellProvider_${walletType.toString()}'; - final providerId = sharedPreferences.getString(key); - if (providerId != null) { - defaultSellProviders[walletType] = ProviderType.values.firstWhere( - (provider) => provider.id == providerId, - orElse: () => ProviderType.askEachTime); - } else { - defaultSellProviders[walletType] = ProviderType.askEachTime; - } - }); - reaction( (_) => fiatCurrency, (FiatCurrency fiatCurrency) => sharedPreferences.setString( @@ -267,20 +239,6 @@ abstract class SettingsStoreBase with Store { reaction((_) => shouldShowRepWarning, (bool val) => sharedPreferences.setBool(PreferencesKey.shouldShowRepWarning, val)); - defaultBuyProviders.observe((change) { - final String key = 'buyProvider_${change.key.toString()}'; - if (change.newValue != null) { - sharedPreferences.setString(key, change.newValue!.id); - } - }); - - defaultSellProviders.observe((change) { - final String key = 'sellProvider_${change.key.toString()}'; - if (change.newValue != null) { - sharedPreferences.setString(key, change.newValue!.id); - } - }); - priority.observe((change) { final String? key; switch (change.key) { @@ -329,14 +287,9 @@ abstract class SettingsStoreBase with Store { }); } - reaction((_) => disableBuy, - (bool disableBuy) => sharedPreferences.setBool(PreferencesKey.disableBuyKey, disableBuy)); - - reaction( - (_) => disableSell, - (bool disableSell) => - sharedPreferences.setBool(PreferencesKey.disableSellKey, disableSell)); - + reaction((_) => disableTradeOption, + (bool disableTradeOption) => sharedPreferences.setBool(PreferencesKey.disableTradeOption, disableTradeOption)); + reaction( (_) => disableBulletin, (bool disableBulletin) => @@ -680,10 +633,7 @@ abstract class SettingsStoreBase with Store { bool isAppSecure; @observable - bool disableBuy; - - @observable - bool disableSell; + bool disableTradeOption; @observable FilterListOrderType contactListOrder; @@ -769,12 +719,6 @@ abstract class SettingsStoreBase with Store { @observable ObservableMap trocadorProviderStates = ObservableMap(); - @observable - ObservableMap defaultBuyProviders; - - @observable - ObservableMap defaultSellProviders; - @observable SortBalanceBy sortBalanceBy; @@ -956,8 +900,7 @@ abstract class SettingsStoreBase with Store { final shouldSaveRecipientAddress = sharedPreferences.getBool(PreferencesKey.shouldSaveRecipientAddressKey) ?? false; final isAppSecure = sharedPreferences.getBool(PreferencesKey.isAppSecureKey) ?? false; - final disableBuy = sharedPreferences.getBool(PreferencesKey.disableBuyKey) ?? false; - final disableSell = sharedPreferences.getBool(PreferencesKey.disableSellKey) ?? false; + final disableTradeOption = sharedPreferences.getBool(PreferencesKey.disableTradeOption) ?? false; final disableBulletin = sharedPreferences.getBool(PreferencesKey.disableBulletinKey) ?? false; final walletListOrder = FilterListOrderType.values[sharedPreferences.getInt(PreferencesKey.walletListOrder) ?? 0]; @@ -1255,8 +1198,7 @@ abstract class SettingsStoreBase with Store { initialBitcoinSeedType: bitcoinSeedType, initialNanoSeedType: nanoSeedType, initialAppSecure: isAppSecure, - initialDisableBuy: disableBuy, - initialDisableSell: disableSell, + initialDisableTrade: disableTradeOption, initialDisableBulletin: disableBulletin, initialWalletListOrder: walletListOrder, initialWalletListAscending: walletListAscending, @@ -1406,8 +1348,7 @@ abstract class SettingsStoreBase with Store { numberOfFailedTokenTrials = sharedPreferences.getInt(PreferencesKey.failedTotpTokenTrials) ?? numberOfFailedTokenTrials; isAppSecure = sharedPreferences.getBool(PreferencesKey.isAppSecureKey) ?? isAppSecure; - disableBuy = sharedPreferences.getBool(PreferencesKey.disableBuyKey) ?? disableBuy; - disableSell = sharedPreferences.getBool(PreferencesKey.disableSellKey) ?? disableSell; + disableTradeOption = sharedPreferences.getBool(PreferencesKey.disableTradeOption) ?? disableTradeOption; disableBulletin = sharedPreferences.getBool(PreferencesKey.disableBulletinKey) ?? disableBulletin; walletListOrder = diff --git a/lib/typography.dart b/lib/typography.dart index 8ae2cb4e5..816f116b4 100644 --- a/lib/typography.dart +++ b/lib/typography.dart @@ -22,6 +22,8 @@ TextStyle textMediumSemiBold({Color? color}) => _cakeSemiBold(22, color); TextStyle textLarge({Color? color}) => _cakeRegular(18, color); +TextStyle textLargeBold({Color? color}) => _cakeBold(18, color); + TextStyle textLargeSemiBold({Color? color}) => _cakeSemiBold(24, color); TextStyle textXLarge({Color? color}) => _cakeRegular(32, color); diff --git a/lib/view_model/buy/buy_sell_view_model.dart b/lib/view_model/buy/buy_sell_view_model.dart new file mode 100644 index 000000000..e1c53ee56 --- /dev/null +++ b/lib/view_model/buy/buy_sell_view_model.dart @@ -0,0 +1,446 @@ +import 'dart:async'; + +import 'package:cake_wallet/buy/buy_provider.dart'; +import 'package:cake_wallet/buy/buy_quote.dart'; +import 'package:cake_wallet/buy/onramper/onramper_buy_provider.dart'; +import 'package:cake_wallet/buy/payment_method.dart'; +import 'package:cake_wallet/buy/sell_buy_states.dart'; +import 'package:cake_wallet/core/selectable_option.dart'; +import 'package:cake_wallet/core/wallet_change_listener_view_model.dart'; +import 'package:cake_wallet/entities/fiat_currency.dart'; +import 'package:cake_wallet/entities/provider_types.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/store/app_store.dart'; +import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cake_wallet/themes/theme_base.dart'; +import 'package:cw_core/crypto_currency.dart'; +import 'package:cw_core/currency_for_wallet_type.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:intl/intl.dart'; +import 'package:mobx/mobx.dart'; + +part 'buy_sell_view_model.g.dart'; + +class BuySellViewModel = BuySellViewModelBase with _$BuySellViewModel; + +abstract class BuySellViewModelBase extends WalletChangeListenerViewModel with Store { + BuySellViewModelBase( + AppStore appStore, + ) : _cryptoNumberFormat = NumberFormat(), + cryptoAmount = '', + fiatAmount = '', + cryptoCurrencyAddress = '', + isCryptoCurrencyAddressEnabled = false, + cryptoCurrencies = [], + fiatCurrencies = [], + paymentMethodState = InitialPaymentMethod(), + buySellQuotState = InitialBuySellQuotState(), + cryptoCurrency = appStore.wallet!.currency, + fiatCurrency = appStore.settingsStore.fiatCurrency, + providerList = [], + sortedRecommendedQuotes = ObservableList(), + sortedQuotes = ObservableList(), + paymentMethods = ObservableList(), + settingsStore = appStore.settingsStore, + super(appStore: appStore) { + const excludeFiatCurrencies = []; + const excludeCryptoCurrencies = []; + + fiatCurrencies = + FiatCurrency.all.where((currency) => !excludeFiatCurrencies.contains(currency)).toList(); + cryptoCurrencies = CryptoCurrency.all + .where((currency) => !excludeCryptoCurrencies.contains(currency)) + .toList(); + _initialize(); + + isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency); + } + + final NumberFormat _cryptoNumberFormat; + late Timer bestRateSync; + + List get availableBuyProviders { + final providerTypes = ProvidersHelper.getAvailableBuyProviderTypes( + walletTypeForCurrency(cryptoCurrency) ?? wallet.type); + return providerTypes + .map((type) => ProvidersHelper.getProviderByType(type)) + .where((provider) => provider != null) + .cast() + .toList(); + } + + List get availableSellProviders { + final providerTypes = ProvidersHelper.getAvailableSellProviderTypes( + walletTypeForCurrency(cryptoCurrency) ?? wallet.type); + return providerTypes + .map((type) => ProvidersHelper.getProviderByType(type)) + .where((provider) => provider != null) + .cast() + .toList(); + } + + @override + void onWalletChange(wallet) { + cryptoCurrency = wallet.currency; + } + + bool get isDarkTheme => settingsStore.currentTheme.type == ThemeType.dark; + + double get amount { + final formattedFiatAmount = double.tryParse(fiatAmount) ?? 200.0; + final formattedCryptoAmount = + double.tryParse(cryptoAmount) ?? (cryptoCurrency == CryptoCurrency.btc ? 0.001 : 1); + + return isBuyAction ? formattedFiatAmount : formattedCryptoAmount; + } + + SettingsStore settingsStore; + + Quote? bestRateQuote; + + Quote? selectedQuote; + + @observable + List cryptoCurrencies; + + @observable + List fiatCurrencies; + + @observable + bool isBuyAction = true; + + @observable + List providerList; + + @observable + ObservableList sortedRecommendedQuotes; + + @observable + ObservableList sortedQuotes; + + @observable + ObservableList paymentMethods; + + @observable + FiatCurrency fiatCurrency; + + @observable + CryptoCurrency cryptoCurrency; + + @observable + String cryptoAmount; + + @observable + String fiatAmount; + + @observable + String cryptoCurrencyAddress; + + @observable + bool isCryptoCurrencyAddressEnabled; + + @observable + PaymentMethod? selectedPaymentMethod; + + @observable + PaymentMethodLoadingState paymentMethodState; + + @observable + BuySellQuotLoadingState buySellQuotState; + + @computed + bool get isReadyToTrade { + final hasSelectedQuote = selectedQuote != null; + final hasSelectedPaymentMethod = selectedPaymentMethod != null; + final isPaymentMethodLoaded = paymentMethodState is PaymentMethodLoaded; + final isBuySellQuotLoaded = buySellQuotState is BuySellQuotLoaded; + + return hasSelectedQuote && + hasSelectedPaymentMethod && + isPaymentMethodLoaded && + isBuySellQuotLoaded; + } + + @action + void reset() { + cryptoCurrency = wallet.currency; + fiatCurrency = settingsStore.fiatCurrency; + isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency); + _initialize(); + } + + @action + void changeBuySellAction() { + isBuyAction = !isBuyAction; + _initialize(); + } + + @action + void changeFiatCurrency({required FiatCurrency currency}) { + fiatCurrency = currency; + _onPairChange(); + } + + @action + void changeCryptoCurrency({required CryptoCurrency currency}) { + cryptoCurrency = currency; + _onPairChange(); + isCryptoCurrencyAddressEnabled = !(cryptoCurrency == wallet.currency); + } + + @action + void changeCryptoCurrencyAddress(String address) => cryptoCurrencyAddress = address; + + @action + Future changeFiatAmount({required String amount}) async { + fiatAmount = amount; + + if (amount.isEmpty) { + fiatAmount = ''; + cryptoAmount = ''; + return; + } + + final enteredAmount = double.tryParse(amount.replaceAll(',', '.')) ?? 0; + + if (!isReadyToTrade) { + cryptoAmount = S.current.fetching; + return; + } + + if (bestRateQuote != null) { + _cryptoNumberFormat.maximumFractionDigits = cryptoCurrency.decimals; + cryptoAmount = _cryptoNumberFormat + .format(enteredAmount / bestRateQuote!.rate) + .toString() + .replaceAll(RegExp('\\,'), ''); + } else { + await calculateBestRate(); + } + } + + @action + Future changeCryptoAmount({required String amount}) async { + cryptoAmount = amount; + + if (amount.isEmpty) { + fiatAmount = ''; + cryptoAmount = ''; + return; + } + + final enteredAmount = double.tryParse(amount.replaceAll(',', '.')) ?? 0; + + if (!isReadyToTrade) { + fiatAmount = S.current.fetching; + } + + if (bestRateQuote != null) { + fiatAmount = _cryptoNumberFormat + .format(enteredAmount * bestRateQuote!.rate) + .toString() + .replaceAll(RegExp('\\,'), ''); + } else { + await calculateBestRate(); + } + } + + @action + void changeOption(SelectableOption option) { + if (option is Quote) { + sortedRecommendedQuotes.forEach((element) => element.setIsSelected = false); + sortedQuotes.forEach((element) => element.setIsSelected = false); + option.setIsSelected = true; + selectedQuote = option; + } else if (option is PaymentMethod) { + paymentMethods.forEach((element) => element.isSelected = false); + option.isSelected = true; + selectedPaymentMethod = option; + } else { + throw ArgumentError('Unknown option type'); + } + } + + void onTapChoseProvider(BuildContext context) async { + final initialQuotes = List.from(sortedRecommendedQuotes + sortedQuotes); + await calculateBestRate(); + final newQuotes = (sortedRecommendedQuotes + sortedQuotes); + + for (var quote in newQuotes) quote.limits = null; + + final newQuoteProviders = newQuotes + .map((quote) => quote.provider.isAggregator ? quote.rampName : quote.provider.title) + .toSet(); + + final outOfLimitQuotes = initialQuotes.where((initialQuote) { + return !newQuoteProviders.contains( + initialQuote.provider.isAggregator ? initialQuote.rampName : initialQuote.provider.title); + }).map((missingQuote) { + final quote = Quote( + rate: missingQuote.rate, + feeAmount: missingQuote.feeAmount, + networkFee: missingQuote.networkFee, + transactionFee: missingQuote.transactionFee, + payout: missingQuote.payout, + rampId: missingQuote.rampId, + rampName: missingQuote.rampName, + rampIconPath: missingQuote.rampIconPath, + paymentType: missingQuote.paymentType, + quoteId: missingQuote.quoteId, + recommendations: missingQuote.recommendations, + provider: missingQuote.provider, + isBuyAction: missingQuote.isBuyAction, + limits: missingQuote.limits, + ); + quote.setFiatCurrency = missingQuote.fiatCurrency; + quote.setCryptoCurrency = missingQuote.cryptoCurrency; + return quote; + }).toList(); + + final updatedQuoteOptions = List.from([ + OptionTitle(title: 'Recommended'), + ...sortedRecommendedQuotes, + if (sortedQuotes.isNotEmpty) OptionTitle(title: 'All Providers'), + ...sortedQuotes, + if (outOfLimitQuotes.isNotEmpty) OptionTitle(title: 'Out of Limits'), + ...outOfLimitQuotes, + ]); + + await Navigator.of(context).pushNamed( + Routes.buyOptionsPage, + arguments: [ + updatedQuoteOptions, + changeOption, + launchTrade, + ], + ).then((value) => calculateBestRate()); + } + + void _onPairChange() { + _initialize(); + } + + void _setProviders() => + providerList = isBuyAction ? availableBuyProviders : availableSellProviders; + + Future _initialize() async { + _setProviders(); + cryptoAmount = ''; + fiatAmount = ''; + cryptoCurrencyAddress = _getInitialCryptoCurrencyAddress(); + paymentMethodState = InitialPaymentMethod(); + buySellQuotState = InitialBuySellQuotState(); + await _getAvailablePaymentTypes(); + await calculateBestRate(); + } + + String _getInitialCryptoCurrencyAddress() { + return cryptoCurrency == wallet.currency ? wallet.walletAddresses.address : ''; + } + + @action + Future _getAvailablePaymentTypes() async { + paymentMethodState = PaymentMethodLoading(); + selectedPaymentMethod = null; + final result = await Future.wait(providerList.map((element) => element + .getAvailablePaymentTypes(fiatCurrency.title, cryptoCurrency.title, isBuyAction) + .timeout( + Duration(seconds: 10), + onTimeout: () => [], + ))); + + final Map uniquePaymentMethods = {}; + for (var methods in result) { + for (var method in methods) { + uniquePaymentMethods[method.paymentMethodType] = method; + } + } + + paymentMethods = ObservableList.of(uniquePaymentMethods.values); + if (paymentMethods.isNotEmpty) { + paymentMethods.insert(0, PaymentMethod.all()); + selectedPaymentMethod = paymentMethods.first; + selectedPaymentMethod!.isSelected = true; + paymentMethodState = PaymentMethodLoaded(); + } else { + paymentMethodState = PaymentMethodFailed(); + } + } + + @action + Future calculateBestRate() async { + buySellQuotState = BuySellQuotLoading(); + + final result = await Future.wait?>(providerList.map((element) => element + .fetchQuote( + cryptoCurrency: cryptoCurrency, + fiatCurrency: fiatCurrency, + amount: amount, + paymentType: selectedPaymentMethod?.paymentMethodType, + isBuyAction: isBuyAction, + walletAddress: wallet.walletAddresses.address, + ) + .timeout( + Duration(seconds: 10), + onTimeout: () => null, + ))); + + sortedRecommendedQuotes.clear(); + sortedQuotes.clear(); + + final validQuotes = result + .where((element) => element != null && element.isNotEmpty) + .expand((element) => element!) + .toList(); + + if (validQuotes.isEmpty) { + buySellQuotState = BuySellQuotFailed(); + return; + } + + validQuotes.sort((a, b) => a.rate.compareTo(b.rate)); + + final Set addedProviders = {}; + final List uniqueProviderQuotes = validQuotes.where((element) { + if (addedProviders.contains(element.provider.title)) return false; + addedProviders.add(element.provider.title); + return true; + }).toList(); + + sortedRecommendedQuotes.addAll(uniqueProviderQuotes); + + sortedQuotes = ObservableList.of( + validQuotes.where((element) => !uniqueProviderQuotes.contains(element)).toList()); + + if (sortedRecommendedQuotes.isNotEmpty) { + sortedRecommendedQuotes.first + ..setIsBestRate = true + ..recommendations.insert(0, ProviderRecommendation.bestRate); + bestRateQuote = sortedRecommendedQuotes.first; + + sortedRecommendedQuotes.sort((a, b) { + if (a.provider is OnRamperBuyProvider) return -1; + if (b.provider is OnRamperBuyProvider) return 1; + return 0; + }); + + selectedQuote = sortedRecommendedQuotes.first; + sortedRecommendedQuotes.first.setIsSelected = true; + } + + buySellQuotState = BuySellQuotLoaded(); + } + + @action + Future launchTrade(BuildContext context) async { + final provider = selectedQuote!.provider; + await provider.launchProvider( + context: context, + quote: selectedQuote!, + amount: amount, + isBuyAction: isBuyAction, + cryptoCurrencyAddress: cryptoCurrencyAddress, + ); + } +} diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 934cbdee7..a78012ae9 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -71,8 +71,7 @@ abstract class DashboardViewModelBase with Store { required this.anonpayTransactionsStore, required this.sharedPreferences, required this.keyService}) - : hasSellAction = false, - hasBuyAction = false, + : hasTradeAction = false, hasExchangeAction = false, isShowFirstYatIntroduction = false, isShowSecondYatIntroduction = false, @@ -521,37 +520,8 @@ abstract class DashboardViewModelBase with Store { Map> filterItems; - BuyProvider? get defaultBuyProvider => ProvidersHelper.getProviderByType( - settingsStore.defaultBuyProviders[wallet.type] ?? ProviderType.askEachTime); - - BuyProvider? get defaultSellProvider => ProvidersHelper.getProviderByType( - settingsStore.defaultSellProviders[wallet.type] ?? ProviderType.askEachTime); - bool get isBuyEnabled => settingsStore.isBitcoinBuyEnabled; - List get availableBuyProviders { - final providerTypes = ProvidersHelper.getAvailableBuyProviderTypes(wallet.type); - return providerTypes - .map((type) => ProvidersHelper.getProviderByType(type)) - .where((provider) => provider != null) - .cast() - .toList(); - } - - bool get hasBuyProviders => ProvidersHelper.getAvailableBuyProviderTypes(wallet.type).isNotEmpty; - - List get availableSellProviders { - final providerTypes = ProvidersHelper.getAvailableSellProviderTypes(wallet.type); - return providerTypes - .map((type) => ProvidersHelper.getProviderByType(type)) - .where((provider) => provider != null) - .cast() - .toList(); - } - - bool get hasSellProviders => - ProvidersHelper.getAvailableSellProviderTypes(wallet.type).isNotEmpty; - bool get shouldShowYatPopup => settingsStore.shouldShowYatPopup; @action @@ -564,16 +534,10 @@ abstract class DashboardViewModelBase with Store { bool hasExchangeAction; @computed - bool get isEnabledBuyAction => !settingsStore.disableBuy && hasBuyProviders; + bool get isEnabledTradeAction => !settingsStore.disableTradeOption; @observable - bool hasBuyAction; - - @computed - bool get isEnabledSellAction => !settingsStore.disableSell && hasSellProviders; - - @observable - bool hasSellAction; + bool hasTradeAction; @computed bool get isEnabledBulletinAction => !settingsStore.disableBulletin; @@ -776,8 +740,7 @@ abstract class DashboardViewModelBase with Store { void updateActions() { hasExchangeAction = !isHaven; - hasBuyAction = !isHaven; - hasSellAction = !isHaven; + hasTradeAction = !isHaven; } @computed diff --git a/lib/view_model/settings/other_settings_view_model.dart b/lib/view_model/settings/other_settings_view_model.dart index 9af8c67cf..3036e8ae9 100644 --- a/lib/view_model/settings/other_settings_view_model.dart +++ b/lib/view_model/settings/other_settings_view_model.dart @@ -65,29 +65,6 @@ abstract class OtherSettingsViewModelBase with Store { _wallet.type == WalletType.solana || _wallet.type == WalletType.tron); - @computed - bool get isEnabledBuyAction => - !_settingsStore.disableBuy && _wallet.type != WalletType.haven; - - @computed - bool get isEnabledSellAction => - !_settingsStore.disableSell && _wallet.type != WalletType.haven; - - List get availableBuyProvidersTypes { - return ProvidersHelper.getAvailableBuyProviderTypes(walletType); - } - - List get availableSellProvidersTypes => - ProvidersHelper.getAvailableSellProviderTypes(walletType); - - ProviderType get buyProviderType => - _settingsStore.defaultBuyProviders[walletType] ?? - ProviderType.askEachTime; - - ProviderType get sellProviderType => - _settingsStore.defaultSellProviders[walletType] ?? - ProviderType.askEachTime; - String getDisplayPriority(dynamic priority) { final _priority = priority as TransactionPriority; @@ -115,20 +92,6 @@ abstract class OtherSettingsViewModelBase with Store { return priority.toString(); } - String getBuyProviderType(dynamic buyProviderType) { - final _buyProviderType = buyProviderType as ProviderType; - return _buyProviderType == ProviderType.askEachTime - ? S.current.ask_each_time - : _buyProviderType.title; - } - - String getSellProviderType(dynamic sellProviderType) { - final _sellProviderType = sellProviderType as ProviderType; - return _sellProviderType == ProviderType.askEachTime - ? S.current.ask_each_time - : _sellProviderType.title; - } - void onDisplayPrioritySelected(TransactionPriority priority) => _settingsStore.priority[walletType] = priority; @@ -157,12 +120,4 @@ abstract class OtherSettingsViewModelBase with Store { } return null; } - - @action - ProviderType onBuyProviderTypeSelected(ProviderType buyProviderType) => - _settingsStore.defaultBuyProviders[walletType] = buyProviderType; - - @action - ProviderType onSellProviderTypeSelected(ProviderType sellProviderType) => - _settingsStore.defaultSellProviders[walletType] = sellProviderType; } diff --git a/lib/view_model/settings/privacy_settings_view_model.dart b/lib/view_model/settings/privacy_settings_view_model.dart index c1e0fb1ce..eaa9f9e84 100644 --- a/lib/view_model/settings/privacy_settings_view_model.dart +++ b/lib/view_model/settings/privacy_settings_view_model.dart @@ -59,10 +59,7 @@ abstract class PrivacySettingsViewModelBase with Store { bool get isAppSecure => _settingsStore.isAppSecure; @computed - bool get disableBuy => _settingsStore.disableBuy; - - @computed - bool get disableSell => _settingsStore.disableSell; + bool get disableTradeOption => _settingsStore.disableTradeOption; @computed bool get disableBulletin => _settingsStore.disableBulletin; @@ -119,10 +116,7 @@ abstract class PrivacySettingsViewModelBase with Store { void setIsAppSecure(bool value) => _settingsStore.isAppSecure = value; @action - void setDisableBuy(bool value) => _settingsStore.disableBuy = value; - - @action - void setDisableSell(bool value) => _settingsStore.disableSell = value; + void setDisableTradeOption(bool value) => _settingsStore.disableTradeOption = value; @action void setDisableBulletin(bool value) => _settingsStore.disableBulletin = value; diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index fc0ea8ea9..9ebab6b6f 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -123,6 +123,8 @@ "change_rep_successful": "تم تغيير ممثل بنجاح", "change_wallet_alert_content": "هل تريد تغيير المحفظة الحالية إلى ${wallet_name}؟", "change_wallet_alert_title": "تغيير المحفظة الحالية", + "choose_a_payment_method": "اختر طريقة الدفع", + "choose_a_provider": "اختر مزودًا", "choose_account": "اختر حساب", "choose_address": "\n\nالرجاء اختيار عنوان:", "choose_card_value": "اختر قيمة بطاقة", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "من خلال إيقاف تشغيل هذا ، قد تكون معدلات الرسوم غير دقيقة في بعض الحالات ، لذلك قد ينتهي بك الأمر إلى دفع مبالغ زائدة أو دفع رسوم المعاملات الخاصة بك", "disable_fiat": "تعطيل fiat", "disable_sell": "قم بتعطيل إجراء البيع", + "disable_trade_option": "تعطيل خيار التجارة", "disableBatteryOptimization": "تعطيل تحسين البطارية", "disableBatteryOptimizationDescription": "هل تريد تعطيل تحسين البطارية من أجل جعل الخلفية مزامنة تعمل بحرية وسلاسة؟", "disabled": "معطلة", diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index e66a40b36..91256938d 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Успешно промени представител", "change_wallet_alert_content": "Искате ли да смените сегашния портфейл на ${wallet_name}?", "change_wallet_alert_title": "Смяна на сегашния портфейл", + "choose_a_payment_method": "Изберете начин на плащане", + "choose_a_provider": "Изберете доставчик", "choose_account": "Избиране на профил", "choose_address": "\n\nМоля, изберете адреса:", "choose_card_value": "Изберете стойност на картата", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Като изключите това, таксите могат да бъдат неточни в някои случаи, така че може да се препланите или да не плащате таксите за вашите транзакции", "disable_fiat": "Деактивиране на fiat", "disable_sell": "Деактивирайте действието за продажба", + "disable_trade_option": "Деактивирайте опцията за търговия", "disableBatteryOptimization": "Деактивирайте оптимизацията на батерията", "disableBatteryOptimizationDescription": "Искате ли да деактивирате оптимизацията на батерията, за да направите синхронизирането на фона да работи по -свободно и гладко?", "disabled": "Деактивирано", diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index 4911030b2..0fe38166c 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Úspěšně změnil zástupce", "change_wallet_alert_content": "Opravdu chcete změnit aktivní peněženku na ${wallet_name}?", "change_wallet_alert_title": "Přepnout peněženku", + "choose_a_payment_method": "Vyberte metodu platby", + "choose_a_provider": "Vyberte poskytovatele", "choose_account": "Zvolte částku", "choose_address": "\n\nProsím vyberte adresu:", "choose_card_value": "Vyberte hodnotu karty", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Tímto vypnutím by sazby poplatků mohly být v některých případech nepřesné, takže byste mohli skončit přepláváním nebo nedoplatkem poplatků za vaše transakce", "disable_fiat": "Zakázat fiat", "disable_sell": "Zakázat akci prodeje", + "disable_trade_option": "Zakázat možnost TRADE", "disableBatteryOptimization": "Zakázat optimalizaci baterie", "disableBatteryOptimizationDescription": "Chcete deaktivovat optimalizaci baterie, aby se synchronizovala pozadí volně a hladce?", "disabled": "Zakázáno", diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index a69174dd6..212ce05f7 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Vertreter erfolgreich gerändert", "change_wallet_alert_content": "Möchten Sie die aktuelle Wallet zu ${wallet_name} ändern?", "change_wallet_alert_title": "Aktuelle Wallet ändern", + "choose_a_payment_method": "Wählen Sie eine Zahlungsmethode", + "choose_a_provider": "Wählen Sie einen Anbieter", "choose_account": "Konto auswählen", "choose_address": "\n\nBitte wählen Sie die Adresse:", "choose_card_value": "Wählen Sie einen Kartenwert", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Wenn dies ausgeschaltet wird, sind die Gebührenquoten in einigen Fällen möglicherweise ungenau, sodass Sie die Gebühren für Ihre Transaktionen möglicherweise überbezahlt oder unterzahlt", "disable_fiat": "Fiat deaktivieren", "disable_sell": "Verkaufsaktion deaktivieren", + "disable_trade_option": "Handelsoption deaktivieren", "disableBatteryOptimization": "Batterieoptimierung deaktivieren", "disableBatteryOptimizationDescription": "Möchten Sie die Batterieoptimierung deaktivieren, um die Hintergrundsynchronisierung reibungsloser zu gestalten?", "disabled": "Deaktiviert", diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 322e38b85..15e0c04b3 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Successfully changed representative", "change_wallet_alert_content": "Do you want to change current wallet to ${wallet_name}?", "change_wallet_alert_title": "Change current wallet", + "choose_a_payment_method": "Choose a payment method", + "choose_a_provider": "Choose a provider", "choose_account": "Choose account", "choose_address": "\n\nPlease choose the address:", "choose_card_value": "Choose a card value", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "By turning this off, the fee rates might be inaccurate in some cases, so you might end up overpaying or underpaying the fees for your transactions", "disable_fiat": "Disable fiat", "disable_sell": "Disable sell action", + "disable_trade_option": "Disable trade option", "disableBatteryOptimization": "Disable Battery Optimization", "disableBatteryOptimizationDescription": "Do you want to disable battery optimization in order to make background sync run more freely and smoothly?", "disabled": "Disabled", diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index 5a8ff0582..83c0a09f0 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Representante cambiado con éxito", "change_wallet_alert_content": "¿Quieres cambiar la billetera actual a ${wallet_name}?", "change_wallet_alert_title": "Cambiar billetera actual", + "choose_a_payment_method": "Elija un método de pago", + "choose_a_provider": "Elija un proveedor", "choose_account": "Elegir cuenta", "choose_address": "\n\nPor favor elija la dirección:", "choose_card_value": "Elige un valor de tarjeta", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Al apagar esto, las tasas de tarifas pueden ser inexactas en algunos casos, por lo que puede terminar pagando en exceso o pagando menos las tarifas por sus transacciones", "disable_fiat": "Deshabilitar fiat", "disable_sell": "Desactivar acción de venta", + "disable_trade_option": "Deshabilitar la opción de comercio", "disableBatteryOptimization": "Deshabilitar la optimización de la batería", "disableBatteryOptimizationDescription": "¿Desea deshabilitar la optimización de la batería para que la sincronización de fondo se ejecute más libremente y sin problemas?", "disabled": "Desactivado", diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index 6abe72681..549ec5275 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Représentant changé avec succès", "change_wallet_alert_content": "Souhaitez-vous changer le portefeuille (wallet) actuel vers ${wallet_name} ?", "change_wallet_alert_title": "Changer le portefeuille (wallet) actuel", + "choose_a_payment_method": "Choisissez un mode de paiement", + "choose_a_provider": "Choisissez un fournisseur", "choose_account": "Choisir le compte", "choose_address": "\n\nMerci de choisir l'adresse :", "choose_card_value": "Choisissez une valeur de carte", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "En désactivant cela, les taux de frais peuvent être inexacts dans certains cas, vous pourriez donc finir par payer trop ou sous-paiement les frais pour vos transactions", "disable_fiat": "Désactiver les montants en fiat", "disable_sell": "Désactiver l'action de vente", + "disable_trade_option": "Désactiver l'option de commerce", "disableBatteryOptimization": "Désactiver l'optimisation de la batterie", "disableBatteryOptimizationDescription": "Voulez-vous désactiver l'optimisation de la batterie afin de faire fonctionner la synchronisation d'arrière-plan plus librement et en douceur?", "disabled": "Désactivé", diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index 5a53d6795..222a9cf2f 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -123,6 +123,8 @@ "change_rep_successful": "An samu nasarar canzawa wakilin", "change_wallet_alert_content": "Kana so ka canja walat yanzu zuwa ${wallet_name}?", "change_wallet_alert_title": "Canja walat yanzu", + "choose_a_payment_method": "Zabi hanyar biyan kuɗi", + "choose_a_provider": "Zabi mai bada", "choose_account": "Zaɓi asusu", "choose_address": "\n\n Da fatan za a zaɓi adireshin:", "choose_card_value": "Zabi darajar katin", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Ta hanyar juya wannan kashe, kudaden da zai iya zama ba daidai ba a wasu halaye, saboda haka zaku iya ƙare da overpaying ko a ƙarƙashin kudaden don ma'amaloli", "disable_fiat": "Dakatar da fiat", "disable_sell": "Kashe karbuwa", + "disable_trade_option": "Musaki zaɓi na kasuwanci", "disableBatteryOptimization": "Kashe ingantawa baturi", "disableBatteryOptimizationDescription": "Shin kana son kashe ingantawa baturi don yin setnc bankwali gudu da yar kyauta da kyau?", "disabled": "tsaya", diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index d130c2b5f..1b2f3a1b3 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -123,6 +123,8 @@ "change_rep_successful": "सफलतापूर्वक बदलकर प्रतिनिधि", "change_wallet_alert_content": "क्या आप करंट वॉलेट को बदलना चाहते हैं ${wallet_name}?", "change_wallet_alert_title": "वर्तमान बटुआ बदलें", + "choose_a_payment_method": "एक भुगतान विधि का चयन करें", + "choose_a_provider": "एक प्रदाता चुनें", "choose_account": "खाता चुनें", "choose_address": "\n\nकृपया पता चुनें:", "choose_card_value": "एक कार्ड मूल्य चुनें", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "इसे बंद करने से, कुछ मामलों में शुल्क दरें गलत हो सकती हैं, इसलिए आप अपने लेनदेन के लिए फीस को कम कर सकते हैं या कम कर सकते हैं", "disable_fiat": "िएट को अक्षम करें", "disable_sell": "बेचने की कार्रवाई अक्षम करें", + "disable_trade_option": "व्यापार विकल्प अक्षम करें", "disableBatteryOptimization": "बैटरी अनुकूलन अक्षम करें", "disableBatteryOptimizationDescription": "क्या आप बैकग्राउंड सिंक को अधिक स्वतंत्र और सुचारू रूप से चलाने के लिए बैटरी ऑप्टिमाइज़ेशन को अक्षम करना चाहते हैं?", "disabled": "अक्षम", diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index 037e6e9f2..4e106fdf6 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Uspješno promijenjena reprezentativna", "change_wallet_alert_content": "Želite li promijeniti trenutni novčanik u ${wallet_name}?", "change_wallet_alert_title": "Izmijeni trenutni novčanik", + "choose_a_payment_method": "Odaberite način plaćanja", + "choose_a_provider": "Odaberite davatelja usluga", "choose_account": "Odaberi račun", "choose_address": "\n\nOdaberite adresu:", "choose_card_value": "Odaberite vrijednost kartice", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Isključivanjem ovoga, stope naknade u nekim bi slučajevima mogle biti netočne, tako da biste mogli preplatiti ili predati naknadu za vaše transakcije", "disable_fiat": "Isključi, fiat", "disable_sell": "Onemogući akciju prodaje", + "disable_trade_option": "Onemogući trgovinsku opciju", "disableBatteryOptimization": "Onemogući optimizaciju baterije", "disableBatteryOptimizationDescription": "Želite li onemogućiti optimizaciju baterije kako bi se pozadinska sinkronizacija radila slobodnije i glatko?", "disabled": "Onemogućeno", diff --git a/res/values/strings_hy.arb b/res/values/strings_hy.arb index 04a7cddf5..40142ca5b 100644 --- a/res/values/strings_hy.arb +++ b/res/values/strings_hy.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Ներկայացուցչի փոփոխությունը հաջողությամբ կատարվեց", "change_wallet_alert_content": "Ցանկանում եք փոխել ընթացիկ դրամապանակը ${wallet_name}?", "change_wallet_alert_title": "Փոխել ընթացիկ դրամապանակը", + "choose_a_payment_method": "Ընտրեք վճարման եղանակ", + "choose_a_provider": "Ընտրեք մատակարար", "choose_account": "Ընտրեք հաշիվը", "choose_address": "\n\nԽնդրում ենք ընտրեք հասցեն", "choose_card_value": "Ընտրեք քարտի արժեք", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Դրանից անջատելով, վճարների տեմպերը որոշ դեպքերում կարող են անճիշտ լինել, այնպես որ դուք կարող եք վերջ տալ ձեր գործարքների համար վճարների գերավճարների կամ գերավճարների վրա", "disable_fiat": "Անջատել ֆիատ", "disable_sell": "Անջատել վաճառք գործողությունը", + "disable_trade_option": "Անջատեք առեւտրի տարբերակը", "disableBatteryOptimization": "Անջատել մարտկոցի օպտիմիզացիան", "disableBatteryOptimizationDescription": "Դուք ցանկանում եք անջատել մարտկոցի օպտիմիզացիան ֆոնային համաժամացման ավելի ազատ և հարթ ընթացքի համար?", "disabled": "Անջատված", diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index 231ac037d..04336b76e 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Berhasil mengubah perwakilan", "change_wallet_alert_content": "Apakah Anda ingin mengganti dompet saat ini ke ${wallet_name}?", "change_wallet_alert_title": "Ganti dompet saat ini", + "choose_a_payment_method": "Pilih metode pembayaran", + "choose_a_provider": "Pilih penyedia", "choose_account": "Pilih akun", "choose_address": "\n\nSilakan pilih alamat:", "choose_card_value": "Pilih nilai kartu", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Dengan mematikan ini, tarif biaya mungkin tidak akurat dalam beberapa kasus, jadi Anda mungkin akan membayar lebih atau membayar biaya untuk transaksi Anda", "disable_fiat": "Nonaktifkan fiat", "disable_sell": "Nonaktifkan aksi jual", + "disable_trade_option": "Nonaktifkan opsi perdagangan", "disableBatteryOptimization": "Nonaktifkan optimasi baterai", "disableBatteryOptimizationDescription": "Apakah Anda ingin menonaktifkan optimasi baterai untuk membuat sinkronisasi latar belakang berjalan lebih bebas dan lancar?", "disabled": "Dinonaktifkan", diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index b06a35537..ad15ab3a9 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Rappresentante modificato con successo", "change_wallet_alert_content": "Sei sicuro di voler cambiare il portafoglio attuale con ${wallet_name}?", "change_wallet_alert_title": "Cambia portafoglio attuale", + "choose_a_payment_method": "Scegli un metodo di pagamento", + "choose_a_provider": "Scegli un fornitore", "choose_account": "Scegli account", "choose_address": "\n\nSi prega di scegliere l'indirizzo:", "choose_card_value": "Scegli un valore della carta", @@ -218,6 +220,7 @@ "disable_fee_api_warning": "Disattivando questo, i tassi delle commissioni potrebbero essere inaccurati in alcuni casi, quindi potresti finire in eccesso o sostenere le commissioni per le transazioni", "disable_fiat": "Disabilita fiat", "disable_sell": "Disabilita l'azione di vendita", + "disable_trade_option": "Disabilita l'opzione commerciale", "disableBatteryOptimization": "Disabilita l'ottimizzazione della batteria", "disableBatteryOptimizationDescription": "Vuoi disabilitare l'ottimizzazione della batteria per far funzionare la sincronizzazione in background più libera e senza intoppi?", "disabled": "Disabilitato", diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index d55c4d5a1..520a73ade 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -123,6 +123,8 @@ "change_rep_successful": "代表者の変更に成功しました", "change_wallet_alert_content": "現在のウォレットをに変更しますか ${wallet_name}?", "change_wallet_alert_title": "現在のウォレットを変更する", + "choose_a_payment_method": "支払い方法を選択します", + "choose_a_provider": "プロバイダーを選択します", "choose_account": "アカウントを選択", "choose_address": "\n\n住所を選択してください:", "choose_card_value": "カード値を選択します", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "これをオフにすることで、料金金利は場合によっては不正確になる可能性があるため、取引の費用が過払いまたは不足している可能性があります", "disable_fiat": "フィアットを無効にする", "disable_sell": "販売アクションを無効にする", + "disable_trade_option": "取引オプションを無効にします", "disableBatteryOptimization": "バッテリーの最適化を無効にします", "disableBatteryOptimizationDescription": "バックグラウンドシンクをより自由かつスムーズに実行するために、バッテリーの最適化を無効にしたいですか?", "disabled": "無効", diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index 303527fea..1d9748866 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -123,6 +123,8 @@ "change_rep_successful": "대리인이 성공적으로 변경되었습니다", "change_wallet_alert_content": "현재 지갑을 다음으로 변경 하시겠습니까 ${wallet_name}?", "change_wallet_alert_title": "현재 지갑 변경", + "choose_a_payment_method": "결제 방법을 선택하십시오", + "choose_a_provider": "제공자를 선택하십시오", "choose_account": "계정을 선택하십시오", "choose_address": "\n\n주소를 선택하십시오:", "choose_card_value": "카드 값을 선택하십시오", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "이것을 끄면 경우에 따라 수수료가 부정확 할 수 있으므로 거래 수수료를 초과 지불하거나 지불 할 수 있습니다.", "disable_fiat": "법정화폐 비활성화", "disable_sell": "판매 조치 비활성화", + "disable_trade_option": "거래 옵션 비활성화", "disableBatteryOptimization": "배터리 최적화를 비활성화합니다", "disableBatteryOptimizationDescription": "백그라운드 동기화를보다 자유롭고 매끄럽게 실행하기 위해 배터리 최적화를 비활성화하고 싶습니까?", "disabled": "장애가 있는", diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 9b9b44657..e6be67060 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -123,6 +123,8 @@ "change_rep_successful": "အောင်မြင်စွာကိုယ်စားလှယ်ပြောင်းလဲသွားတယ်", "change_wallet_alert_content": "လက်ရှိပိုက်ဆံအိတ်ကို ${wallet_name} သို့ ပြောင်းလိုပါသလား။", "change_wallet_alert_title": "လက်ရှိပိုက်ဆံအိတ်ကို ပြောင်းပါ။", + "choose_a_payment_method": "ငွေပေးချေမှုနည်းလမ်းကိုရွေးချယ်ပါ", + "choose_a_provider": "ပံ့ပိုးပေးရွေးချယ်ပါ", "choose_account": "အကောင့်ကို ရွေးပါ။", "choose_address": "\n\nလိပ်စာကို ရွေးပါ-", "choose_card_value": "ကဒ်တန်ဖိုးတစ်ခုရွေးပါ", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "ဤအရာကိုဖွင့်ခြင်းအားဖြင့်အချို့သောကိစ္စရပ်များတွင်အခကြေးငွေနှုန်းထားများသည်တိကျမှုရှိနိုင်သည်,", "disable_fiat": "Fiat ကိုပိတ်ပါ။", "disable_sell": "ရောင်းချခြင်းလုပ်ဆောင်ချက်ကို ပိတ်ပါ။", + "disable_trade_option": "ကုန်သွယ်ရေး option ကိုပိတ်ပါ", "disableBatteryOptimization": "ဘက်ထရီ optimization ကိုပိတ်ပါ", "disableBatteryOptimizationDescription": "နောက်ခံထပ်တူပြုခြင်းနှင့်ချောချောမွေ့မွေ့ပြုလုပ်နိုင်ရန်ဘက်ထရီ optimization ကိုသင်ပိတ်ထားလိုပါသလား။", "disabled": "မသန်စွမ်း", diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index 657450f61..82c3899d4 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Met succes veranderde vertegenwoordiger", "change_wallet_alert_content": "Wilt u de huidige portemonnee wijzigen in ${wallet_name}?", "change_wallet_alert_title": "Wijzig huidige portemonnee", + "choose_a_payment_method": "Kies een betaalmethode", + "choose_a_provider": "Kies een provider", "choose_account": "Kies account", "choose_address": "\n\nKies het adres:", "choose_card_value": "Kies een kaartwaarde", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Door dit uit te schakelen, kunnen de tarieven in sommige gevallen onnauwkeurig zijn, dus u kunt de vergoedingen voor uw transacties te veel betalen of te weinig betalen", "disable_fiat": "Schakel Fiat uit", "disable_sell": "Verkoopactie uitschakelen", + "disable_trade_option": "Schakel handelsoptie uit", "disableBatteryOptimization": "Schakel de batterijoptimalisatie uit", "disableBatteryOptimizationDescription": "Wilt u de optimalisatie van de batterij uitschakelen om achtergrondsynchronisatie te laten werken, vrijer en soepeler?", "disabled": "Gehandicapt", diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index e47c93dcf..ed54624bf 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Pomyślnie zmienił przedstawiciela", "change_wallet_alert_content": "Czy chcesz zmienić obecny portfel na ${wallet_name}?", "change_wallet_alert_title": "Zmień obecny portfel", + "choose_a_payment_method": "Wybierz metodę płatności", + "choose_a_provider": "Wybierz dostawcę", "choose_account": "Wybierz konto", "choose_address": "\n\nWybierz adres:", "choose_card_value": "Wybierz wartość karty", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Wyłączając to, stawki opłaty mogą być w niektórych przypadkach niedokładne, więc możesz skończyć się przepłaceniem lub wynagrodzeniem opłat za transakcje", "disable_fiat": "Wyłącz waluty FIAT", "disable_sell": "Wyłącz akcję sprzedaży", + "disable_trade_option": "Wyłącz opcję handlu", "disableBatteryOptimization": "Wyłącz optymalizację baterii", "disableBatteryOptimizationDescription": "Czy chcesz wyłączyć optymalizację baterii, aby synchronizacja tła działała swobodniej i płynnie?", "disabled": "Wyłączone", diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index d5aa95c16..7b43b5b12 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Mudou com sucesso o representante", "change_wallet_alert_content": "Quer mudar a carteira atual para ${wallet_name}?", "change_wallet_alert_title": "Alterar carteira atual", + "choose_a_payment_method": "Escolha um método de pagamento", + "choose_a_provider": "Escolha um provedor", "choose_account": "Escolha uma conta", "choose_address": "\n\nEscolha o endereço:", "choose_card_value": "Escolha um valor de cartão", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Ao desativar isso, as taxas de taxas podem ser imprecisas em alguns casos, para que você possa acabar pagando demais ou pagando as taxas por suas transações", "disable_fiat": "Desativar fiat", "disable_sell": "Desativar ação de venda", + "disable_trade_option": "Desativar a opção comercial", "disableBatteryOptimization": "Desative a otimização da bateria", "disableBatteryOptimizationDescription": "Deseja desativar a otimização da bateria para fazer a sincronização de fundo funcionar de forma mais livre e suave?", "disabled": "Desabilitado", diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index f999b4aaa..2795e1ffc 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Успешно изменил представитель", "change_wallet_alert_content": "Вы хотите изменить текущий кошелек на ${wallet_name}?", "change_wallet_alert_title": "Изменить текущий кошелек", + "choose_a_payment_method": "Выберите способ оплаты", + "choose_a_provider": "Выберите поставщика", "choose_account": "Выберите аккаунт", "choose_address": "\n\nПожалуйста, выберите адрес:", "choose_card_value": "Выберите значение карты", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Выключив это, в некоторых случаях ставки платы могут быть неточными, так что вы можете в конечном итоге переплачивать или недоплачивать сборы за ваши транзакции", "disable_fiat": "Отключить фиат", "disable_sell": "Отключить действие продажи", + "disable_trade_option": "Отключить возможность торговли", "disableBatteryOptimization": "Отключить оптимизацию батареи", "disableBatteryOptimizationDescription": "Вы хотите отключить оптимизацию батареи, чтобы сделать фона синхронизации более свободно и плавно?", "disabled": "Отключено", diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index a35e16a97..596861646 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -123,6 +123,8 @@ "change_rep_successful": "เปลี่ยนตัวแทนสำเร็จ", "change_wallet_alert_content": "คุณต้องการเปลี่ยนกระเป๋าปัจจุบันเป็น ${wallet_name} หรือไม่?", "change_wallet_alert_title": "เปลี่ยนกระเป๋าปัจจุบัน", + "choose_a_payment_method": "เลือกวิธีการชำระเงิน", + "choose_a_provider": "เลือกผู้ให้บริการ", "choose_account": "เลือกบัญชี", "choose_address": "\n\nโปรดเลือกที่อยู่:", "choose_card_value": "เลือกค่าบัตร", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "โดยการปิดสิ่งนี้อัตราค่าธรรมเนียมอาจไม่ถูกต้องในบางกรณีดังนั้นคุณอาจจบลงด้วยการจ่ายเงินมากเกินไปหรือจ่ายค่าธรรมเนียมสำหรับการทำธุรกรรมของคุณมากเกินไป", "disable_fiat": "ปิดใช้งานสกุลเงินตรา", "disable_sell": "ปิดการใช้งานการขาย", + "disable_trade_option": "ปิดใช้งานตัวเลือกการค้า", "disableBatteryOptimization": "ปิดใช้งานการเพิ่มประสิทธิภาพแบตเตอรี่", "disableBatteryOptimizationDescription": "คุณต้องการปิดใช้งานการเพิ่มประสิทธิภาพแบตเตอรี่เพื่อให้การซิงค์พื้นหลังทำงานได้อย่างอิสระและราบรื่นมากขึ้นหรือไม่?", "disabled": "ปิดใช้งาน", diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index 090bdb8a7..f4678e00d 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Matagumpay na nagbago ng representative", "change_wallet_alert_content": "Gusto mo bang palitan ang kasalukuyang wallet sa ${wallet_name}?", "change_wallet_alert_title": "Baguhin ang kasalukuyang wallet", + "choose_a_payment_method": "Pumili ng isang paraan ng pagbabayad", + "choose_a_provider": "Pumili ng isang provider", "choose_account": "Pumili ng account", "choose_address": "Mangyaring piliin ang address:", "choose_card_value": "Pumili ng isang halaga ng card", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Sa pamamagitan ng pag -off nito, ang mga rate ng bayad ay maaaring hindi tumpak sa ilang mga kaso, kaya maaari mong tapusin ang labis na bayad o pagsuporta sa mga bayarin para sa iyong mga transaksyon", "disable_fiat": "Huwag paganahin ang fiat", "disable_sell": "Huwag paganahin ang pagkilos ng pagbebenta", + "disable_trade_option": "Huwag paganahin ang pagpipilian sa kalakalan", "disableBatteryOptimization": "Huwag Paganahin ang Pag-optimize ng Baterya", "disableBatteryOptimizationDescription": "Nais mo bang huwag paganahin ang pag-optimize ng baterya upang gawing mas malaya at maayos ang background sync?", "disabled": "Hindi pinagana", diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index 3a426de9e..d8b3ae3cf 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Temsilciyi başarıyla değiştirdi", "change_wallet_alert_content": "Şimdiki cüzdanı ${wallet_name} cüzdanı ile değiştirmek istediğinden emin misin?", "change_wallet_alert_title": "Şimdiki cüzdanı değiştir", + "choose_a_payment_method": "Bir Ödeme Yöntemi Seçin", + "choose_a_provider": "Bir Sağlayıcı Seçin", "choose_account": "Hesabı seç", "choose_address": "\n\nLütfen adresi seçin:", "choose_card_value": "Bir kart değeri seçin", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Bunu kapatarak, ücret oranları bazı durumlarda yanlış olabilir, bu nedenle işlemleriniz için ücretleri fazla ödeyebilir veya az ödeyebilirsiniz.", "disable_fiat": "İtibari paraları devre dışı bırak", "disable_sell": "Satış işlemini devre dışı bırak", + "disable_trade_option": "Ticaret seçeneğini devre dışı bırakın", "disableBatteryOptimization": "Pil optimizasyonunu devre dışı bırakın", "disableBatteryOptimizationDescription": "Arka plan senkronizasyonunu daha özgür ve sorunsuz bir şekilde çalıştırmak için pil optimizasyonunu devre dışı bırakmak istiyor musunuz?", "disabled": "Devre dışı", diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index 63665875f..f27300c18 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Успішно змінив представник", "change_wallet_alert_content": "Ви хочете змінити поточний гаманець на ${wallet_name}?", "change_wallet_alert_title": "Змінити поточний гаманець", + "choose_a_payment_method": "Виберіть метод оплати", + "choose_a_provider": "Виберіть постачальника", "choose_account": "Оберіть акаунт", "choose_address": "\n\nБудь ласка, оберіть адресу:", "choose_card_value": "Виберіть значення картки", @@ -185,7 +187,7 @@ "creating_new_wallet_error": "Помилка: ${description}", "creation_date": "Дата створення", "custom": "на замовлення", - "custom_drag": "На замовлення (утримуйте та перетягується)", + "custom_drag": "На замовлення (утримуйте та перетягуйте)", "custom_redeem_amount": "Власна сума викупу", "custom_value": "Спеціальне значення", "dark_theme": "Темна", @@ -206,17 +208,18 @@ "description": "опис", "destination_tag": "Тег призначення:", "dfx_option_description": "Купуйте криптовалюту з EUR & CHF. Для роздрібних та корпоративних клієнтів у Європі", - "didnt_get_code": "Не отримуєте код?", + "didnt_get_code": "Не отримали код?", "digit_pin": "-значний PIN", "digital_and_physical_card": " цифрова та фізична передплачена дебетова картка", "disable": "Вимкнути", "disable_bulletin": "Вимкнути статус послуги", "disable_buy": "Вимкнути дію покупки", "disable_cake_2fa": "Вимкнути Cake 2FA", - "disable_exchange": "Вимкнути exchange", + "disable_exchange": "Вимкнути можливість обміну", "disable_fee_api_warning": "Вимкнувши це, ставки плати в деяких випадках можуть бути неточними, тому ви можете переплатити або недооплатити плату за свої транзакції", "disable_fiat": "Вимкнути фиат", "disable_sell": "Вимкнути дію продажу", + "disable_trade_option": "Вимкнути можливість торгівлі", "disableBatteryOptimization": "Вимкнути оптимізацію акумулятора", "disableBatteryOptimizationDescription": "Ви хочете відключити оптимізацію акумулятора, щоб зробити фонову синхронізацію більш вільно та плавно?", "disabled": "Вимкнено", @@ -226,14 +229,14 @@ "do_not_have_enough_gas_asset": "У вас недостатньо ${currency}, щоб здійснити трансакцію з поточними умовами мережі блокчейн. Вам потрібно більше ${currency}, щоб сплатити комісію мережі блокчейн, навіть якщо ви надсилаєте інший актив.", "do_not_send": "Не надсилайте", "do_not_share_warning_text": "Не діліться цим нікому, включно зі службою підтримки.\n\nВаші кошти можуть і будуть вкрадені!", - "do_not_show_me": "Не показуй мені це знову", + "do_not_show_me": "Не показувати це знову", "domain_looks_up": "Пошук доменів", "donation_link_details": "Деталі посилання для пожертв", "e_sign_consent": "Згода електронного підпису", "edit": "Редагувати", "edit_backup_password": "Змінити пароль резервної копії", "edit_node": "Редагувати вузол", - "edit_token": "Редагувати маркер", + "edit_token": "Редагувати токен", "electrum_address_disclaimer": "Ми створюємо нові адреси щоразу, коли ви використовуєте їх, але попередні адреси продовжують працювати", "email_address": "Адреса електронної пошти", "enable": "Ввімкнути", diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 2a40fbe13..00f336d72 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -123,6 +123,8 @@ "change_rep_successful": "نمائندہ کو کامیابی کے ساتھ تبدیل کیا", "change_wallet_alert_content": "کیا آپ موجودہ والیٹ کو ${wallet_name} میں تبدیل کرنا چاہتے ہیں؟", "change_wallet_alert_title": "موجودہ پرس تبدیل کریں۔", + "choose_a_payment_method": "ادائیگی کا طریقہ منتخب کریں", + "choose_a_provider": "فراہم کنندہ کا انتخاب کریں", "choose_account": "اکاؤنٹ کا انتخاب کریں۔", "choose_address": "\\n\\nبراہ کرم پتہ منتخب کریں:", "choose_card_value": "کارڈ کی قیمت کا انتخاب کریں", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "اس کو بند کرنے سے ، کچھ معاملات میں فیس کی شرح غلط ہوسکتی ہے ، لہذا آپ اپنے لین دین کے لئے فیسوں کو زیادہ ادائیگی یا ادائیگی ختم کرسکتے ہیں۔", "disable_fiat": "فیاٹ کو غیر فعال کریں۔", "disable_sell": "فروخت کی کارروائی کو غیر فعال کریں۔", + "disable_trade_option": "تجارت کے آپشن کو غیر فعال کریں", "disableBatteryOptimization": "بیٹری کی اصلاح کو غیر فعال کریں", "disableBatteryOptimizationDescription": "کیا آپ پس منظر کی مطابقت پذیری کو زیادہ آزادانہ اور آسانی سے چلانے کے لئے بیٹری کی اصلاح کو غیر فعال کرنا چاہتے ہیں؟", "disabled": "معذور", diff --git a/res/values/strings_vi.arb b/res/values/strings_vi.arb index 9fee9f4fc..1291b505e 100644 --- a/res/values/strings_vi.arb +++ b/res/values/strings_vi.arb @@ -218,6 +218,7 @@ "disable_fee_api_warning": "Khi tắt chức năng này, tỉ lệ phí có thể không chính xác trong một số trường hợp, dẫn đến bạn trả quá hoặc không đủ phí cho giao dịch của mình.", "disable_fiat": "Vô hiệu hóa tiền tệ fiat", "disable_sell": "Vô hiệu hóa chức năng bán", + "disable_trade_option": "Tắt tùy chọn thương mại", "disableBatteryOptimization": "Vô hiệu hóa Tối ưu hóa Pin", "disableBatteryOptimizationDescription": "Bạn có muốn vô hiệu hóa tối ưu hóa pin để đồng bộ hóa nền hoạt động mượt mà hơn không?", "disabled": "Đã vô hiệu hóa", diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index 1dd6eafda..c9275b018 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -123,6 +123,8 @@ "change_rep_successful": "Ni ifijišẹ yipada aṣoju", "change_wallet_alert_content": "Ṣe ẹ fẹ́ pààrọ̀ àpamọ́wọ́ yìí sí ${wallet_name}?", "change_wallet_alert_title": "Ẹ pààrọ̀ àpamọ́wọ́ yìí", + "choose_a_payment_method": "Yan ọna isanwo kan", + "choose_a_provider": "Yan olupese", "choose_account": "Yan àkáǹtì", "choose_address": "\n\nẸ jọ̀wọ́ yan àdírẹ́sì:", "choose_card_value": "Yan iye kaadi", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "Nipa yiyi eyi kuro, awọn oṣuwọn owo naa le jẹ aiṣe deede ni awọn ọrọ kan, nitorinaa o le pari apọju tabi awọn idiyele ti o ni agbara fun awọn iṣowo rẹ", "disable_fiat": "Pa owó tí ìjọba pàṣẹ wa lò", "disable_sell": "Ko iṣọrọ iṣọrọ", + "disable_trade_option": "Mu aṣayan iṣowo ṣiṣẹ", "disableBatteryOptimization": "Mu Ifasi batiri", "disableBatteryOptimizationDescription": "Ṣe o fẹ lati mu iṣapelo batiri si lati le ṣiṣe ayẹwo ẹhin ati laisiyonu?", "disabled": "Wọ́n tí a ti pa", diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index 92a540d19..e508d3c2c 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -123,6 +123,8 @@ "change_rep_successful": "成功改变了代表", "change_wallet_alert_content": "您是否想将当前钱包改为 ${wallet_name}?", "change_wallet_alert_title": "更换当前钱包", + "choose_a_payment_method": "选择付款方式", + "choose_a_provider": "选择一个提供商", "choose_account": "选择账户", "choose_address": "\n\n請選擇地址:", "choose_card_value": "选择卡值", @@ -217,6 +219,7 @@ "disable_fee_api_warning": "通过将其关闭,在某些情况下,收费率可能不准确,因此您最终可能会超额付款或支付交易费用", "disable_fiat": "禁用法令", "disable_sell": "禁用卖出操作", + "disable_trade_option": "禁用贸易选项", "disableBatteryOptimization": "禁用电池优化", "disableBatteryOptimizationDescription": "您是否要禁用电池优化以使背景同步更加自由,平稳地运行?", "disabled": "禁用", diff --git a/tool/utils/secret_key.dart b/tool/utils/secret_key.dart index 88cbbfce3..affe4017c 100644 --- a/tool/utils/secret_key.dart +++ b/tool/utils/secret_key.dart @@ -44,6 +44,8 @@ class SecretKey { SecretKey('cakePayApiKey', () => ''), SecretKey('CSRFToken', () => ''), SecretKey('authorization', () => ''), + SecretKey('meldTestApiKey', () => ''), + SecretKey('meldTestPublicKey', () => ''), SecretKey('moneroTestWalletSeeds', () => ''), SecretKey('moneroLegacyTestWalletSeeds ', () => ''), SecretKey('bitcoinTestWalletSeeds', () => ''), From 288164603723279711dd6a894d48d7645821fb36 Mon Sep 17 00:00:00 2001 From: Omar Hatem Date: Sat, 9 Nov 2024 21:07:17 +0200 Subject: [PATCH 21/34] Updatable builds (#1791) * save and increment build number for each PR * cd into repo path * trial * try generating the release build without a keystore * cache keystore to maintain same signature for PR builds * weird fix * potential fix * fix key path * meh * Update cache_dependencies.yml --- .github/workflows/cache_dependencies.yml | 15 ++++++- .github/workflows/pr_test_build_android.yml | 49 ++++++++++++++++++++- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/.github/workflows/cache_dependencies.yml b/.github/workflows/cache_dependencies.yml index 0fcd5c45b..ce098b7f1 100644 --- a/.github/workflows/cache_dependencies.yml +++ b/.github/workflows/cache_dependencies.yml @@ -67,4 +67,17 @@ jobs: run: | cd /opt/android/cake_wallet/scripts/android/ source ./app_env.sh cakewallet - ./build_monero_all.sh \ No newline at end of file + ./build_monero_all.sh + + - name: Cache Keystore + id: cache-keystore + uses: actions/cache@v3 + with: + path: /opt/android/cake_wallet/android/app/key.jks + key: $STORE_PASS + + - if: ${{ steps.cache-keystore.outputs.cache-hit != 'true' }} + name: Generate KeyStore + run: | + cd /opt/android/cake_wallet/android/app + keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS diff --git a/.github/workflows/pr_test_build_android.yml b/.github/workflows/pr_test_build_android.yml index a12fd422f..1a67ed0ec 100644 --- a/.github/workflows/pr_test_build_android.yml +++ b/.github/workflows/pr_test_build_android.yml @@ -116,8 +116,15 @@ jobs: cd /opt/android/cake_wallet/scripts/android/ ./build_mwebd.sh --dont-install - - name: Generate KeyStore - if: ${{ steps.cache-externals.outputs.cache-hit != 'true' }} + - name: Cache Keystore + id: cache-keystore + uses: actions/cache@v3 + with: + path: /opt/android/cake_wallet/android/app/key.jks + key: $STORE_PASS + + - if: ${{ steps.cache-keystore.outputs.cache-hit != 'true' }} + name: Generate KeyStore run: | cd /opt/android/cake_wallet/android/app keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS @@ -205,6 +212,36 @@ jobs: run: | echo -e "id=com.cakewallet.test_${{ env.PR_NUMBER }}\nname=${{ env.BRANCH_NAME }}" > /opt/android/cake_wallet/android/app.properties + # Step 3: Download previous build number + - name: Download previous build number + id: download-build-number + run: | + # Download the artifact if it exists + if [[ ! -f build_number.txt ]]; then + echo "1" > build_number.txt + fi + + # Step 4: Read and Increment Build Number + - name: Increment Build Number + id: increment-build-number + run: | + # Read current build number from file + BUILD_NUMBER=$(cat build_number.txt) + BUILD_NUMBER=$((BUILD_NUMBER + 1)) + echo "New build number: $BUILD_NUMBER" + + # Save the incremented build number + echo "$BUILD_NUMBER" > build_number.txt + + # Export the build number to use in later steps + echo "BUILD_NUMBER=$BUILD_NUMBER" >> $GITHUB_ENV + + # Step 5: Update pubspec.yaml with new build number + - name: Update build number + run: | + cd /opt/android/cake_wallet + sed -i "s/^version: .*/version: 1.0.$BUILD_NUMBER/" pubspec.yaml + - name: Build run: | cd /opt/android/cake_wallet @@ -235,6 +272,13 @@ jobs: with: path: /opt/android/cake_wallet/build/app/outputs/flutter-apk/test-apk/ + # Re-upload updated build number for the next run + - name: Upload updated build number + uses: actions/upload-artifact@v3 + with: + name: build_number + path: build_number.txt + - name: Send Test APK continue-on-error: true uses: adrey/slack-file-upload-action@1.0.5 @@ -245,3 +289,4 @@ jobs: title: "${{ env.BRANCH_NAME }}.apk" filename: ${{ env.BRANCH_NAME }}.apk initial_comment: ${{ github.event.head_commit.message }} + From 5301ee4584429e57667a4f5a582b3b1538eec659 Mon Sep 17 00:00:00 2001 From: Serhii Date: Sat, 9 Nov 2024 23:44:08 +0200 Subject: [PATCH 22/34] Update onramper_buy_provider.dart (#1796) --- lib/buy/onramper/onramper_buy_provider.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/buy/onramper/onramper_buy_provider.dart b/lib/buy/onramper/onramper_buy_provider.dart index ee9c9bb74..dade41d43 100644 --- a/lib/buy/onramper/onramper_buy_provider.dart +++ b/lib/buy/onramper/onramper_buy_provider.dart @@ -217,7 +217,7 @@ class OnRamperBuyProvider extends BuyProvider { 'apiKey': _apiKey, 'mode': actionType, '${prefix}defaultFiat': quote.fiatCurrency.name, - '${prefix}defaultCrypto': quote.cryptoCurrency.name, + '${prefix}defaultCrypto': defaultCrypto, '${prefix}defaultAmount': amount.toString(), if (paymentMethod != null) '${prefix}defaultPaymentMethod': paymentMethod, 'onlyOnramps': quote.rampId, From ed69dc803b70d13b83b6d5445171fe99b18e9dee Mon Sep 17 00:00:00 2001 From: OmarHatem Date: Sun, 10 Nov 2024 00:14:38 +0200 Subject: [PATCH 23/34] fix cache key --- .github/workflows/pr_test_build_android.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/.github/workflows/pr_test_build_android.yml b/.github/workflows/pr_test_build_android.yml index 1a67ed0ec..1f2a9636e 100644 --- a/.github/workflows/pr_test_build_android.yml +++ b/.github/workflows/pr_test_build_android.yml @@ -82,8 +82,7 @@ jobs: path: | /opt/android/cake_wallet/cw_haven/android/.cxx /opt/android/cake_wallet/scripts/monero_c/release - /opt/android/cake_wallet/android/app/key.jks - key: android_${{ hashFiles('**/prepare_moneroc.sh' ,'**/build_monero_all.sh' ,'**/cache_dependencies.yml') }} + key: ${{ hashFiles('**/prepare_moneroc.sh' ,'**/build_monero_all.sh' ,'**/cache_dependencies.yml') }} - if: ${{ steps.cache-externals.outputs.cache-hit != 'true' }} name: Generate Externals From 89525b189732973b7e8b32f5be5157a96d0cdab1 Mon Sep 17 00:00:00 2001 From: OmarHatem Date: Sun, 10 Nov 2024 01:16:21 +0200 Subject: [PATCH 24/34] temporarily disable key caching --- .github/workflows/pr_test_build_android.yml | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/.github/workflows/pr_test_build_android.yml b/.github/workflows/pr_test_build_android.yml index 1f2a9636e..774404e3b 100644 --- a/.github/workflows/pr_test_build_android.yml +++ b/.github/workflows/pr_test_build_android.yml @@ -115,15 +115,15 @@ jobs: cd /opt/android/cake_wallet/scripts/android/ ./build_mwebd.sh --dont-install - - name: Cache Keystore - id: cache-keystore - uses: actions/cache@v3 - with: - path: /opt/android/cake_wallet/android/app/key.jks - key: $STORE_PASS - - - if: ${{ steps.cache-keystore.outputs.cache-hit != 'true' }} - name: Generate KeyStore +# - name: Cache Keystore +# id: cache-keystore +# uses: actions/cache@v3 +# with: +# path: /opt/android/cake_wallet/android/app/key.jks +# key: $STORE_PASS +# +# - if: ${{ steps.cache-keystore.outputs.cache-hit != 'true' }} + - name: Generate KeyStore run: | cd /opt/android/cake_wallet/android/app keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS From e148f645086e0c6797aab2121d8e707fa8cc18c7 Mon Sep 17 00:00:00 2001 From: Serhii Date: Mon, 11 Nov 2024 19:39:45 +0200 Subject: [PATCH 25/34] fix the UI issue with sending outputs (#1798) --- lib/view_model/send/send_view_model.dart | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 4621e8d16..69011aa74 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -404,8 +404,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor final updatedOutputs = bitcoin!.updateOutputs(pendingTransaction!, outputs); if (outputs.length == updatedOutputs.length) { - outputs.clear(); - outputs.addAll(updatedOutputs); + outputs.replaceRange(0, outputs.length, updatedOutputs); } } From 2c37e427e9eb7e2c882fdd7577f7ab8cfa28c558 Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Tue, 12 Nov 2024 04:26:09 +0100 Subject: [PATCH 26/34] Cw 660 add ledger monero (#1747) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Fix conflicts with main * fix for multiple wallets * Add tron to windows application configuration. * Add macOS option for description message in configure_cake_wallet.sh * Include missed monero dll for windows. * fix conflicts with main * Disable haven configuration for iOS as default. Add ability to configure cakewallet for iOS with for configuration script. Remove cw_shared configuration for cw_monero. * fix: scan fixes, add date, allow sending while scanning * add missing nano secrets file [skip ci] * ios library * don't pull prebuilds android * Add auto generation of manifest file for android project even for iOS, macOS, Windows. * feat: sync fixes, sp settings * feat: fix resyncing * store crash fix * make init async so it won't lag disable print starts * fix monero_c build issues * libstdc++ * Fix MacOS saving wallet file issue Fix Secure Storage issue (somehow) * update pubspec.lock * fix build script * Use dylib as iOS framework. Use custom path for loading of iOS framework for monero.dart. Add script for generate iOS framework for monero wallet. * fix: date from height logic, status disconnected & chain tip get * fix: params * feat: electrum migration if using cake electrum * fix nodes update versions * re-enable tron * update sp_scanner to work on iOS [skip ci] * bump monero_c hash * bump monero_c commit * bump moneroc version * bump monero_c commit * Add ability to build monero wallet lib as universal lib. Update macOS build guide. Change default arch for macOS project to . * fix: wrong socket for old electrum nodes * Fix unchecked wallet type call * get App Dir correctly in default_settings_migration.dart * handle previous issue with fetching linux documents directory [skip ci] * backup fix * fix NTFS issues * Close the wallet when the wallet gets changed * fix: double balance * feat: node domain * fix: menu name * bump monero_c commit * fix: update tip on set scanning * fix: connection switching back and forth * feat: check if node is electrs, and supports sp * chore: fix build * minor enhancements * fixes and enhancements * solve conflicts with main * Only stop wallet on rename and delete * fix: status toggle * minor enhancement * Monero.com fixes * bump monero_c commit * update sp_scanner to include windows and linux * Update macOS build guide. Change brew dependencies for build unbound locally. * fix conflicts and update macos build guide * remove build cache when on gh actions * update secure storage * free up even more storage * free up more storage * Add initial wownero * fix conflicts * fix workflow issue * build wownero * ios and windows changes * macos * complete wownero flow (app side) * add keychain group entitlement and update script for RunnerBase on macos * update secure_storage version to 8.1.0 in configure.dart * add wownero framework * update ios builds * proper path for wownero and monero * finalizing wownero * finalizing wownero * free up even more storage * revert commenting of build gradle configs * revert commenting of secrets [skip ci] * free more storage * minor fixes * link android wownero libraries * bump monero_c commit * wownero fixes * rename target * build_single.sh using clean env * bump monero_c commit * minor fix * Add wownero polyseed * fix conflicts with main * fix: wallet seed display fix: wownero not refreshing * fix: wallet seed display fix: wownero not refreshing * bump monero_c commit * minor fixes * fix: incorrectly displaying XMR instead of WOW * fix: incorrect restore height in wownero * bump monero_c commit * Add Inno Setup Script for windows exe installer * drop libc++_shared.so * fixes from comments * Fix CMake for windows * Merge latest monero dart changes [skip ci] * bump monero_c commit * add wownero to build scripts for macos [skip ci] * add 14 word seed support to wownero * UI fixes for wownero seed restore * minor fixes * reformat code to pass lints * Add debug ledger code * Add Litecoin Hardware Wallet Creation * Add Litecoin Hardware Wallet Creation * Fix Bitcoin not sending on Ledger * Fixes to sending LTC using Ledger * CW-679 Fix merge conflicts * CW-679 Fix merge conflicts * CW-679 Minor fixes * CW-679 Add derivation Path of change address * Add create Monero Wallet from Ledger * bug fix to create Monero Wallet from Ledger * ledger flutter plus refactoring * ledger flutter plus refactoring * ledger flutter plus refactoring * Ups :| * Ups :| I forgot USB * Handle BT Off * Fix Issue with A14 and USB * Small Ledger Quality of life improvements * Small Ledger Quality of life improvements * Small Ledger Quality of life improvements * Small Ledger Quality of life improvements * Small Ledger Quality of life improvements * Small Ledger Quality of life improvements * Small Ledger Quality of life improvements * Pls work * Pls work * Pls work * Pls work * Fix overpopulation * Fix ble device detection and support for Stax and Flex * clean up pubspec * clean up * MWeb merge fix * MWeb merge fix * Migrate to Ledger Flutter Plus * Add connect device page before loading the wallet (Only monero) * Add connect device page before loading the wallet (Only monero) * Fix merge error * Fix merge error * Fix merge error && Allow for wallet switching * Please compile now * Move monero/ledger.dart from monero_c to cw_monero * Upgrade ledger_flutter_plus * Add more popups if action on the device is needed. * Update monero_c dependency hash * Yay ledger monero is even more efficient and avoids memory leaks 🥳 * [skip-ci] more code * Fix Minor Bug * Fix Minor Bug * Apply requested changes * [skip ci] Apply requested changes * Minor Cleanup * Welp I'm dumb :/ * Implement requested changes * Increase ledger refresh speed * Add Monero Ledger keep connection alive * Add Monero Ledger keep connection alive --------- Co-authored-by: OmarHatem Co-authored-by: Czarek Nakamoto Co-authored-by: m Co-authored-by: Rafael Saes Co-authored-by: Matthew Fosse --- build-guide-win.md | 38 + cw_bitcoin/pubspec.lock | 8 +- .../lib/hardware/device_connection_type.dart | 1 + cw_core/lib/wallet_service.dart | 4 + cw_monero/lib/api/wallet.dart | 4 +- cw_monero/lib/api/wallet_manager.dart | 147 +- cw_monero/lib/ledger.dart | 88 + cw_monero/lib/monero_wallet.dart | 7 + cw_monero/lib/monero_wallet_service.dart | 108 +- cw_monero/pubspec.lock | 130 +- cw_monero/pubspec.yaml | 4 +- .../connection_to_node_exception.dart | 5 + cw_wownero/lib/api/structs/account_row.dart | 12 + .../lib/api/structs/coins_info_row.dart | 73 + .../lib/api/structs/subaddress_row.dart | 15 + .../lib/api/structs/transaction_info_row.dart | 41 + cw_wownero/lib/api/structs/ut8_box.dart | 8 + cw_wownero/lib/cw_wownero.dart | 8 + cw_wownero/lib/cw_wownero_method_channel.dart | 17 + .../lib/cw_wownero_platform_interface.dart | 29 + cw_wownero/lib/mywownero.dart | 1689 +++++++++++++++++ cw_wownero/pubspec.lock | 92 +- cw_wownero/pubspec.yaml | 3 +- lib/core/wallet_loading_service.dart | 14 +- lib/di.dart | 15 +- .../require_hardware_wallet_connection.dart | 25 + lib/monero/cw_monero.dart | 25 + .../on_authentication_state_change.dart | 57 +- lib/router.dart | 9 +- .../connect_device/connect_device_page.dart | 66 +- .../monero_hardware_wallet_options_page.dart | 230 +++ .../screens/wallet_list/wallet_list_page.dart | 135 +- .../dashboard/dashboard_view_model.dart | 2 +- .../hardware_wallet/ledger_view_model.dart | 3 + .../wallet_hardware_restore_view_model.dart | 15 +- .../wallet_list/wallet_list_view_model.dart | 19 +- scripts/prepare_moneroc.sh | 4 +- tool/configure.dart | 11 +- 38 files changed, 2912 insertions(+), 249 deletions(-) create mode 100644 build-guide-win.md create mode 100644 cw_monero/lib/ledger.dart create mode 100644 cw_wownero/lib/api/exceptions/connection_to_node_exception.dart create mode 100644 cw_wownero/lib/api/structs/account_row.dart create mode 100644 cw_wownero/lib/api/structs/coins_info_row.dart create mode 100644 cw_wownero/lib/api/structs/subaddress_row.dart create mode 100644 cw_wownero/lib/api/structs/transaction_info_row.dart create mode 100644 cw_wownero/lib/api/structs/ut8_box.dart create mode 100644 cw_wownero/lib/cw_wownero.dart create mode 100644 cw_wownero/lib/cw_wownero_method_channel.dart create mode 100644 cw_wownero/lib/cw_wownero_platform_interface.dart create mode 100644 cw_wownero/lib/mywownero.dart create mode 100644 lib/entities/hardware_wallet/require_hardware_wallet_connection.dart create mode 100644 lib/src/screens/connect_device/monero_hardware_wallet_options_page.dart diff --git a/build-guide-win.md b/build-guide-win.md new file mode 100644 index 000000000..6ace961af --- /dev/null +++ b/build-guide-win.md @@ -0,0 +1,38 @@ +# Building CakeWallet for Windows + +## Requirements and Setup + +The following are the system requirements to build CakeWallet for your Windows PC. + +``` +Windows 10 or later (64-bit), x86-64 based +Flutter 3 or above +``` + +## Building CakeWallet on Windows + +These steps will help you configure and execute a build of CakeWallet from its source code. + +### 1. Installing Package Dependencies + +For build CakeWallet windows application from sources you will be needed to have: +> [Install Flutter]Follow installation guide (https://docs.flutter.dev/get-started/install/windows) and install do not miss to dev tools (install https://docs.flutter.dev/get-started/install/windows/desktop#development-tools) which are required for windows desktop development (need to install Git for Windows and Visual Studio 2022). Then install `Desktop development with C++` packages via GUI Visual Studio 2022, or Visual Studio Build Tools 2022 including: `C++ Build Tools core features`, `C++ 2022 Redistributable Update`, `C++ core desktop features`, `MVC v143 - VS 2022 C++ x64/x86 build tools`, `C++ CMake tools for Windwos`, `Testing tools core features - Build Tools`, `C++ AddressSanitizer`. +> [Install WSL] for building monero dependencies need to install Windows WSL (https://learn.microsoft.com/en-us/windows/wsl/install) and required packages for WSL (Ubuntu): +`$ sudo apt update ` +`$ sudo apt build-essential cmake gcc-mingw-w64 g++-mingw-w64 autoconf libtool pkg-config` + +### 2. Pull CakeWallet source code + +You can downlaod CakeWallet source code from our [GitHub repository](github.com/cake-tech/cake_wallet) via git by following next command: +`$ git clone https://github.com/cake-tech/cake_wallet.git --branch MrCyjaneK-cyjan-monerodart` +OR you can download it as [Zip archive](https://github.com/cake-tech/cake_wallet/archive/refs/heads/MrCyjaneK-cyjan-monerodart.zip) + +### 3. Build Monero, Monero_c and their dependencies + +For use monero in the application need to build Monero wrapper - Monero_C which will be used by monero.dart package. For that need to run shell (bash - typically same named utility should be available after WSL is enabled in your system) with previously installed WSL, then change current directory to the application project directory with your used shell and then change current directory to `scripts/windows`: `$ cd scripts/windows`. Run build script: `$ ./build_all.sh`. + +### 4. Configure and build CakeWallet application + +To configure the application open directory where you have downloaded or unarchived CakeWallet sources and run `cakewallet.bat`. +Or if you used WSL and have active shell session you can run `$ ./cakewallet.sh` script in `scripts/windows` which will run `cakewallet.bat` in WSL. +After execution of `cakewallet.bat` you should to get `Cake Wallet.zip` in project root directory which will contains `CakeWallet.exe` file and another needed files for run the application. Now you can extract files from `Cake Wallet.zip` archive and run the application. diff --git a/cw_bitcoin/pubspec.lock b/cw_bitcoin/pubspec.lock index 0e4921a3a..8eafb2a44 100644 --- a/cw_bitcoin/pubspec.lock +++ b/cw_bitcoin/pubspec.lock @@ -386,10 +386,10 @@ packages: dependency: transitive description: name: flutter_web_bluetooth - sha256: "52ce64f65d7321c4bf6abfe9dac02fb888731339a5e0ad6de59fb916c20c9f02" + sha256: fcd03e2e5f82edcedcbc940f1b6a0635a50757374183254f447640886c53208e url: "https://pub.dev" source: hosted - version: "0.2.3" + version: "0.2.4" flutter_web_plugins: dependency: transitive description: flutter @@ -560,7 +560,7 @@ packages: description: path: "packages/ledger-bitcoin" ref: HEAD - resolved-ref: dbb5c4956949dc734af3fc8febdbabed89da72aa + resolved-ref: "07cd61ef76a2a017b6d5ef233396740163265457" url: "https://github.com/cake-tech/ledger-flutter-plus-plugins" source: git version: "0.0.3" @@ -577,7 +577,7 @@ packages: description: path: "packages/ledger-litecoin" ref: HEAD - resolved-ref: dbb5c4956949dc734af3fc8febdbabed89da72aa + resolved-ref: "07cd61ef76a2a017b6d5ef233396740163265457" url: "https://github.com/cake-tech/ledger-flutter-plus-plugins" source: git version: "0.0.2" diff --git a/cw_core/lib/hardware/device_connection_type.dart b/cw_core/lib/hardware/device_connection_type.dart index 9a3069552..76a501af1 100644 --- a/cw_core/lib/hardware/device_connection_type.dart +++ b/cw_core/lib/hardware/device_connection_type.dart @@ -7,6 +7,7 @@ enum DeviceConnectionType { static List supportedConnectionTypes(WalletType walletType, [bool isIOS = false]) { switch (walletType) { + case WalletType.monero: case WalletType.bitcoin: case WalletType.litecoin: case WalletType.ethereum: diff --git a/cw_core/lib/wallet_service.dart b/cw_core/lib/wallet_service.dart index d90ae30bc..85126853e 100644 --- a/cw_core/lib/wallet_service.dart +++ b/cw_core/lib/wallet_service.dart @@ -61,4 +61,8 @@ abstract class WalletService false; } diff --git a/cw_monero/lib/api/wallet.dart b/cw_monero/lib/api/wallet.dart index 8e03cff3e..928fd7ef1 100644 --- a/cw_monero/lib/api/wallet.dart +++ b/cw_monero/lib/api/wallet.dart @@ -119,7 +119,7 @@ Future setupNodeSync( daemonUsername: login ?? '', daemonPassword: password ?? ''); }); - // monero.Wallet_init3(wptr!, argv0: '', defaultLogBaseName: 'moneroc', console: true); + // monero.Wallet_init3(wptr!, argv0: '', defaultLogBaseName: 'moneroc', console: true, logPath: ''); final status = monero.Wallet_status(wptr!); @@ -330,4 +330,4 @@ String signMessage(String message, {String address = ""}) { bool verifyMessage(String message, String address, String signature) { return monero.Wallet_verifySignedMessage(wptr!, message: message, address: address, signature: signature); -} \ No newline at end of file +} diff --git a/cw_monero/lib/api/wallet_manager.dart b/cw_monero/lib/api/wallet_manager.dart index 61fd6edf0..7f9dbd8fa 100644 --- a/cw_monero/lib/api/wallet_manager.dart +++ b/cw_monero/lib/api/wallet_manager.dart @@ -7,19 +7,18 @@ import 'package:cw_monero/api/exceptions/wallet_creation_exception.dart'; import 'package:cw_monero/api/exceptions/wallet_opening_exception.dart'; import 'package:cw_monero/api/exceptions/wallet_restore_from_keys_exception.dart'; import 'package:cw_monero/api/exceptions/wallet_restore_from_seed_exception.dart'; -import 'package:cw_monero/api/wallet.dart'; import 'package:cw_monero/api/transaction_history.dart'; +import 'package:cw_monero/api/wallet.dart'; +import 'package:cw_monero/ledger.dart'; import 'package:monero/monero.dart' as monero; class MoneroCException implements Exception { final String message; MoneroCException(this.message); - + @override - String toString() { - return message; - } + String toString() => message; } void checkIfMoneroCIsFine() { @@ -43,7 +42,6 @@ void checkIfMoneroCIsFine() { throw MoneroCException("monero_c and monero.dart wrapper export list mismatch.\nLogic errors can occur.\nRefusing to run in release mode.\ncpp: '$cppCsExp'\ndart: '$dartCsExp'"); } } - monero.WalletManager? _wmPtr; final monero.WalletManager wmPtr = Pointer.fromAddress((() { try { @@ -60,6 +58,13 @@ final monero.WalletManager wmPtr = Pointer.fromAddress((() { return _wmPtr!.address; })()); +void createWalletPointer() { + final newWptr = monero.WalletManager_createWallet(wmPtr, + path: "", password: "", language: "", networkType: 0); + + wptr = newWptr; +} + void createWalletSync( {required String path, required String password, @@ -124,24 +129,24 @@ void restoreWalletFromKeysSync( int restoreHeight = 0}) { txhistory = null; var newWptr = (spendKey != "") - ? monero.WalletManager_createDeterministicWalletFromSpendKey( - wmPtr, - path: path, - password: password, - language: language, - spendKeyString: spendKey, - newWallet: true, // TODO(mrcyjanek): safe to remove - restoreHeight: restoreHeight) - : monero.WalletManager_createWalletFromKeys( - wmPtr, - path: path, - password: password, - restoreHeight: restoreHeight, - addressString: address, - viewKeyString: viewKey, - spendKeyString: spendKey, - nettype: 0, - ); + ? monero.WalletManager_createDeterministicWalletFromSpendKey(wmPtr, + path: path, + password: password, + language: language, + spendKeyString: spendKey, + newWallet: true, + // TODO(mrcyjanek): safe to remove + restoreHeight: restoreHeight) + : monero.WalletManager_createWalletFromKeys( + wmPtr, + path: path, + password: password, + restoreHeight: restoreHeight, + addressString: address, + viewKeyString: viewKey, + spendKeyString: spendKey, + nettype: 0, + ); final status = monero.Wallet_status(newWptr); if (status != 0) { @@ -156,7 +161,7 @@ void restoreWalletFromKeysSync( if (viewKey != viewKeyRestored && viewKey != "") { monero.WalletManager_closeWallet(wmPtr, newWptr, false); File(path).deleteSync(); - File(path+".keys").deleteSync(); + File(path + ".keys").deleteSync(); newWptr = monero.WalletManager_createWalletFromKeys( wmPtr, path: path, @@ -199,7 +204,7 @@ void restoreWalletFromSpendKeySync( // viewKeyString: '', // nettype: 0, // ); - + txhistory = null; final newWptr = monero.WalletManager_createDeterministicWalletFromSpendKey( wmPtr, @@ -230,41 +235,39 @@ void restoreWalletFromSpendKeySync( String _lastOpenedWallet = ""; -// void restoreMoneroWalletFromDevice( -// {required String path, -// required String password, -// required String deviceName, -// int nettype = 0, -// int restoreHeight = 0}) { -// -// final pathPointer = path.toNativeUtf8(); -// final passwordPointer = password.toNativeUtf8(); -// final deviceNamePointer = deviceName.toNativeUtf8(); -// final errorMessagePointer = ''.toNativeUtf8(); -// -// final isWalletRestored = restoreWalletFromDeviceNative( -// pathPointer, -// passwordPointer, -// deviceNamePointer, -// nettype, -// restoreHeight, -// errorMessagePointer) != 0; -// -// calloc.free(pathPointer); -// calloc.free(passwordPointer); -// -// storeSync(); -// -// if (!isWalletRestored) { -// throw WalletRestoreFromKeysException( -// message: convertUTF8ToString(pointer: errorMessagePointer)); -// } -// } +Future restoreWalletFromHardwareWallet( + {required String path, + required String password, + required String deviceName, + int nettype = 0, + int restoreHeight = 0}) async { + txhistory = null; + + final newWptrAddr = await Isolate.run(() { + return monero.WalletManager_createWalletFromDevice(wmPtr, + path: path, + password: password, + restoreHeight: restoreHeight, + deviceName: deviceName) + .address; + }); + final newWptr = Pointer.fromAddress(newWptrAddr); + + final status = monero.Wallet_status(newWptr); + + if (status != 0) { + final error = monero.Wallet_errorString(newWptr); + throw WalletRestoreFromSeedException(message: error); + } + wptr = newWptr; + + openedWalletsByPath[path] = wptr!; +} Map openedWalletsByPath = {}; -void loadWallet( - {required String path, required String password, int nettype = 0}) { +Future loadWallet( + {required String path, required String password, int nettype = 0}) async { if (openedWalletsByPath[path] != null) { txhistory = null; wptr = openedWalletsByPath[path]!; @@ -278,8 +281,29 @@ void loadWallet( }); } txhistory = null; - final newWptr = monero.WalletManager_openWallet(wmPtr, - path: path, password: password); + + /// Get the device type + /// 0: Software Wallet + /// 1: Ledger + /// 2: Trezor + final deviceType = monero.WalletManager_queryWalletDevice(wmPtr, + keysFileName: "$path.keys", password: password, kdfRounds: 1); + + if (deviceType == 1) { + final dummyWPtr = wptr ?? + monero.WalletManager_openWallet(wmPtr, path: '', password: ''); + enableLedgerExchange(dummyWPtr, gLedger!); + } + + final addr = wmPtr.address; + final newWptrAddr = await Isolate.run(() { + return monero.WalletManager_openWallet(Pointer.fromAddress(addr), + path: path, password: password) + .address; + }); + + final newWptr = Pointer.fromAddress(newWptrAddr); + _lastOpenedWallet = path; final status = monero.Wallet_status(newWptr); if (status != 0) { @@ -287,6 +311,7 @@ void loadWallet( print(err); throw WalletOpeningException(message: err); } + wptr = newWptr; openedWalletsByPath[path] = wptr!; } @@ -351,7 +376,7 @@ Future _openWallet(Map args) async => loadWallet( bool _isWalletExist(String path) => isWalletExistSync(path: path); -void openWallet( +Future openWallet( {required String path, required String password, int nettype = 0}) async => diff --git a/cw_monero/lib/ledger.dart b/cw_monero/lib/ledger.dart new file mode 100644 index 000000000..074975df3 --- /dev/null +++ b/cw_monero/lib/ledger.dart @@ -0,0 +1,88 @@ +import 'dart:async'; +import 'dart:ffi'; +import 'dart:typed_data'; + +import 'package:ffi/ffi.dart'; +import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; +import 'package:ledger_flutter_plus/ledger_flutter_plus_dart.dart'; +import 'package:monero/monero.dart' as monero; +// import 'package:polyseed/polyseed.dart'; + +LedgerConnection? gLedger; + +Timer? _ledgerExchangeTimer; +Timer? _ledgerKeepAlive; + +void enableLedgerExchange(monero.wallet ptr, LedgerConnection connection) { + _ledgerExchangeTimer?.cancel(); + _ledgerExchangeTimer = Timer.periodic(Duration(milliseconds: 1), (_) async { + final ledgerRequestLength = monero.Wallet_getSendToDeviceLength(ptr); + final ledgerRequest = monero.Wallet_getSendToDevice(ptr) + .cast() + .asTypedList(ledgerRequestLength); + if (ledgerRequestLength > 0) { + _ledgerKeepAlive?.cancel(); + + final Pointer emptyPointer = malloc(0); + monero.Wallet_setDeviceSendData( + ptr, emptyPointer.cast(), 0); + malloc.free(emptyPointer); + + // print("> ${ledgerRequest.toHexString()}"); + final response = await exchange(connection, ledgerRequest); + // print("< ${response.toHexString()}"); + + final Pointer result = malloc(response.length); + for (var i = 0; i < response.length; i++) { + result.asTypedList(response.length)[i] = response[i]; + } + + monero.Wallet_setDeviceReceivedData( + ptr, result.cast(), response.length); + malloc.free(result); + keepAlive(connection); + } + }); +} + +void keepAlive(LedgerConnection connection) { + if (connection.connectionType == ConnectionType.ble) { + UniversalBle.onConnectionChange = (String deviceId, bool isConnected) { + print("[Monero] Ledger Disconnected"); + _ledgerKeepAlive?.cancel(); + }; + _ledgerKeepAlive = Timer.periodic(Duration(seconds: 10), (_) async { + try { + UniversalBle.setNotifiable( + connection.device.id, + connection.device.deviceInfo.serviceId, + connection.device.deviceInfo.notifyCharacteristicKey, + BleInputProperty.notification, + ); + } catch (_){} + }); + } +} + +void disableLedgerExchange() { + _ledgerExchangeTimer?.cancel(); + _ledgerKeepAlive?.cancel(); + gLedger?.disconnect(); + gLedger = null; +} + +Future exchange(LedgerConnection connection, Uint8List data) async => + connection.sendOperation(ExchangeOperation(data)); + +class ExchangeOperation extends LedgerRawOperation { + final Uint8List inputData; + + ExchangeOperation(this.inputData); + + @override + Future read(ByteDataReader reader) async => + reader.read(reader.remainingLength); + + @override + Future> write(ByteDataWriter writer) async => [inputData]; +} diff --git a/cw_monero/lib/monero_wallet.dart b/cw_monero/lib/monero_wallet.dart index bc8c3c94d..5f53b30ba 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -28,6 +28,7 @@ import 'package:cw_monero/api/wallet.dart' as monero_wallet; import 'package:cw_monero/api/wallet_manager.dart'; import 'package:cw_monero/exceptions/monero_transaction_creation_exception.dart'; import 'package:cw_monero/exceptions/monero_transaction_no_inputs_exception.dart'; +import 'package:cw_monero/ledger.dart'; import 'package:cw_monero/monero_transaction_creation_credentials.dart'; import 'package:cw_monero/monero_transaction_history.dart'; import 'package:cw_monero/monero_transaction_info.dart'; @@ -36,6 +37,7 @@ import 'package:cw_monero/monero_wallet_addresses.dart'; import 'package:cw_monero/pending_monero_transaction.dart'; import 'package:flutter/foundation.dart'; import 'package:hive/hive.dart'; +import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; import 'package:mobx/mobx.dart'; import 'package:monero/monero.dart' as monero; @@ -828,4 +830,9 @@ abstract class MoneroWalletBase extends WalletBase { + MoneroRestoreWalletFromHardwareCredentials> { MoneroWalletService(this.walletInfoSource, this.unspentCoinsInfoSource); final Box walletInfoSource; @@ -81,7 +92,7 @@ class MoneroWalletService extends WalletService< final lang = PolyseedLang.getByEnglishName(credentials.language); final heightOverride = - getMoneroHeigthByDate(date: DateTime.now().subtract(Duration(days: 2))); + getMoneroHeigthByDate(date: DateTime.now().subtract(Duration(days: 2))); return _restoreFromPolyseed( path, credentials.password!, polyseed, credentials.walletInfo!, lang, @@ -91,9 +102,9 @@ class MoneroWalletService extends WalletService< await monero_wallet_manager.createWallet( path: path, password: credentials.password!, language: credentials.language); final wallet = MoneroWallet( - walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource, - password: credentials.password!); + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + password: credentials.password!); await wallet.init(); return wallet; @@ -128,11 +139,11 @@ class MoneroWalletService extends WalletService< await monero_wallet_manager .openWalletAsync({'path': path, 'password': password}); final walletInfo = walletInfoSource.values.firstWhere( - (info) => info.id == WalletBase.idFor(name, getType())); + (info) => info.id == WalletBase.idFor(name, getType())); final wallet = MoneroWallet( - walletInfo: walletInfo, - unspentCoinsInfo: unspentCoinsInfoSource, - password: password); + walletInfo: walletInfo, + unspentCoinsInfo: unspentCoinsInfoSource, + password: password); final isValid = wallet.walletAddresses.validate(); if (!isValid) { @@ -185,10 +196,9 @@ class MoneroWalletService extends WalletService< } @override - Future rename( - String currentName, String password, String newName) async { + Future rename(String currentName, String password, String newName) async { final currentWalletInfo = walletInfoSource.values.firstWhere( - (info) => info.id == WalletBase.idFor(currentName, getType())); + (info) => info.id == WalletBase.idFor(currentName, getType())); final currentWallet = MoneroWallet( walletInfo: currentWalletInfo, unspentCoinsInfo: unspentCoinsInfoSource, @@ -218,9 +228,9 @@ class MoneroWalletService extends WalletService< viewKey: credentials.viewKey, spendKey: credentials.spendKey); final wallet = MoneroWallet( - walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource, - password: credentials.password!); + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + password: credentials.password!); await wallet.init(); return wallet; @@ -232,9 +242,34 @@ class MoneroWalletService extends WalletService< } @override - Future restoreFromHardwareWallet(MoneroNewWalletCredentials credentials) { - throw UnimplementedError( - "Restoring a Monero wallet from a hardware wallet is not yet supported!"); + Future restoreFromHardwareWallet( + MoneroRestoreWalletFromHardwareCredentials credentials) async { + try { + final path = await pathForWallet(name: credentials.name, type: getType()); + final password = credentials.password; + final height = credentials.height; + + if (wptr == null ) monero_wallet_manager.createWalletPointer(); + + enableLedgerExchange(wptr!, credentials.ledgerConnection); + await monero_wallet_manager.restoreWalletFromHardwareWallet( + path: path, + password: password!, + restoreHeight: height!, + deviceName: 'Ledger'); + + final wallet = MoneroWallet( + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + password: credentials.password!); + await wallet.init(); + + return wallet; + } catch (e) { + // TODO: Implement Exception for wallet list service. + print('MoneroWalletsManager Error: $e'); + rethrow; + } } @override @@ -253,9 +288,9 @@ class MoneroWalletService extends WalletService< seed: credentials.mnemonic, restoreHeight: credentials.height!); final wallet = MoneroWallet( - walletInfo: credentials.walletInfo!, - unspentCoinsInfo: unspentCoinsInfoSource, - password: credentials.password!); + walletInfo: credentials.walletInfo!, + unspentCoinsInfo: unspentCoinsInfoSource, + password: credentials.password!); await wallet.init(); return wallet; @@ -283,8 +318,8 @@ class MoneroWalletService extends WalletService< } } - Future _restoreFromPolyseed( - String path, String password, Polyseed polyseed, WalletInfo walletInfo, PolyseedLang lang, + Future _restoreFromPolyseed(String path, String password, Polyseed polyseed, + WalletInfo walletInfo, PolyseedLang lang, {PolyseedCoin coin = PolyseedCoin.POLYSEED_MONERO, int? overrideHeight}) async { final height = overrideHeight ?? getMoneroHeigthByDate(date: DateTime.fromMillisecondsSinceEpoch(polyseed.birthday * 1000)); @@ -329,7 +364,9 @@ class MoneroWalletService extends WalletService< dir.listSync().forEach((f) { final file = File(f.path); - final name = f.path.split('/').last; + final name = f.path + .split('/') + .last; final newPath = newWalletDirPath + '/$name'; final newFile = File(newPath); @@ -366,4 +403,11 @@ class MoneroWalletService extends WalletService< return ''; } } + + @override + bool requireHardwareWalletConnection(String name) { + final walletInfo = walletInfoSource.values + .firstWhere((info) => info.id == WalletBase.idFor(name, getType())); + return walletInfo.isHardwareWallet; + } } diff --git a/cw_monero/pubspec.lock b/cw_monero/pubspec.lock index ee1d48df1..a6a03dce5 100644 --- a/cw_monero/pubspec.lock +++ b/cw_monero/pubspec.lock @@ -29,10 +29,10 @@ packages: dependency: transitive description: name: asn1lib - sha256: "58082b3f0dca697204dbab0ef9ff208bfaea7767ea771076af9a343488428dda" + sha256: "6b151826fcc95ff246cd219a0bf4c753ea14f4081ad71c61939becf3aba27f70" url: "https://pub.dev" source: hosted - version: "1.5.3" + version: "1.5.5" async: dependency: transitive description: @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + bluez: + dependency: transitive + description: + name: bluez + sha256: "203a1924e818a9dd74af2b2c7a8f375ab8e5edf0e486bba8f90a0d8a17ed9fce" + url: "https://pub.dev" + source: hosted + version: "0.8.2" boolean_selector: dependency: transitive description: @@ -209,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.4" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" encrypt: dependency: "direct main" description: @@ -229,10 +245,10 @@ packages: dependency: "direct main" description: name: ffi - sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + sha256: "16ed7b077ef01ad6170a3d0c57caa4a112a38d7a2ed5602e0aca9ca6f3d98da6" url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.1.3" file: dependency: transitive description: @@ -267,6 +283,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_bluetooth: + dependency: transitive + description: + name: flutter_web_bluetooth + sha256: "52ce64f65d7321c4bf6abfe9dac02fb888731339a5e0ad6de59fb916c20c9f02" + url: "https://pub.dev" + source: hosted + version: "0.2.3" frontend_server_client: dependency: transitive description: @@ -295,18 +319,18 @@ packages: dependency: transitive description: name: hashlib - sha256: d41795742c10947930630118c6836608deeb9047cd05aee32d2baeb697afd66a + sha256: f572f2abce09fc7aee53f15927052b9732ea1053e540af8cae211111ee0b99b1 url: "https://pub.dev" source: hosted - version: "1.19.2" + version: "1.21.0" hashlib_codecs: dependency: transitive description: name: hashlib_codecs - sha256: "2b570061f5a4b378425be28a576c1e11783450355ad4345a19f606ff3d96db0f" + sha256: "8cea9ccafcfeaa7324d2ae52c61c69f7ff71f4237507a018caab31b9e416e3b1" url: "https://pub.dev" source: hosted - version: "2.5.0" + version: "2.6.0" hive: dependency: transitive description: @@ -327,10 +351,10 @@ packages: dependency: "direct main" description: name: http - sha256: "761a297c042deedc1ffbb156d6e2af13886bb305c2a343a4d972504cd67dd938" + sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" http_multi_server: dependency: transitive description: @@ -403,6 +427,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + ledger_flutter_plus: + dependency: "direct main" + description: + name: ledger_flutter_plus + sha256: c7b04008553193dbca7e17b430768eecc372a72b0ff3625b5e7fc5e5c8d3231b + url: "https://pub.dev" + source: hosted + version: "1.4.1" + ledger_usb_plus: + dependency: transitive + description: + name: ledger_usb_plus + sha256: "21cc5d976cf7edb3518bd2a0c4164139cbb0817d2e4f2054707fc4edfdf9ce87" + url: "https://pub.dev" + source: hosted + version: "1.0.4" logging: dependency: transitive description: @@ -439,10 +479,10 @@ packages: dependency: transitive description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" mobx: dependency: "direct main" description: @@ -463,8 +503,8 @@ packages: dependency: "direct main" description: path: "impls/monero.dart" - ref: "6eb571ea498ed7b854934785f00fabfd0dadf75b" - resolved-ref: "6eb571ea498ed7b854934785f00fabfd0dadf75b" + ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b + resolved-ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b url: "https://github.com/mrcyjanek/monero_c" source: git version: "0.0.0" @@ -504,10 +544,10 @@ packages: dependency: "direct main" description: name: path_provider - sha256: c9e7d3a4cd1410877472158bee69963a4579f78b68c65a2b7d40d1a7a88bb161 + sha256: fec0d61223fba3154d87759e3cc27fe2c8dc498f6386c6d6fc80d1afdd1bf378 url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.4" path_provider_android: dependency: transitive description: @@ -544,10 +584,18 @@ packages: dependency: transitive description: name: path_provider_windows - sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 url: "https://pub.dev" source: hosted - version: "2.2.1" + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" platform: dependency: transitive description: @@ -612,6 +660,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.0" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shelf: dependency: transitive description: @@ -737,6 +793,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.2" + universal_ble: + dependency: transitive + description: + name: universal_ble + sha256: "0dfbd6b64bff3ad61ed7a895c232530d9614e9b01ab261a74433a43267edb7f3" + url: "https://pub.dev" + source: hosted + version: "0.12.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" unorm_dart: dependency: transitive description: @@ -785,22 +857,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.5" - win32: - dependency: transitive - description: - name: win32 - sha256: "0eaf06e3446824099858367950a813472af675116bf63f008a4c2a75ae13e9cb" - url: "https://pub.dev" - source: hosted - version: "5.5.0" xdg_directories: dependency: transitive description: name: xdg_directories - sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" url: "https://pub.dev" source: hosted - version: "1.0.4" + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yaml: dependency: transitive description: @@ -811,4 +883,4 @@ packages: version: "3.1.2" sdks: dart: ">=3.3.0 <4.0.0" - flutter: ">=3.16.6" + flutter: ">=3.19.0" diff --git a/cw_monero/pubspec.yaml b/cw_monero/pubspec.yaml index cb1f5519f..6fa235ee4 100644 --- a/cw_monero/pubspec.yaml +++ b/cw_monero/pubspec.yaml @@ -25,9 +25,11 @@ dependencies: monero: git: url: https://github.com/mrcyjanek/monero_c - ref: 6eb571ea498ed7b854934785f00fabfd0dadf75b # monero_c hash + ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b +# ref: 6eb571ea498ed7b854934785f00fabfd0dadf75b # monero_c hash path: impls/monero.dart mutex: ^3.1.0 + ledger_flutter_plus: ^1.4.1 dev_dependencies: flutter_test: diff --git a/cw_wownero/lib/api/exceptions/connection_to_node_exception.dart b/cw_wownero/lib/api/exceptions/connection_to_node_exception.dart new file mode 100644 index 000000000..483b0a174 --- /dev/null +++ b/cw_wownero/lib/api/exceptions/connection_to_node_exception.dart @@ -0,0 +1,5 @@ +class ConnectionToNodeException implements Exception { + ConnectionToNodeException({required this.message}); + + final String message; +} \ No newline at end of file diff --git a/cw_wownero/lib/api/structs/account_row.dart b/cw_wownero/lib/api/structs/account_row.dart new file mode 100644 index 000000000..aa492ee0f --- /dev/null +++ b/cw_wownero/lib/api/structs/account_row.dart @@ -0,0 +1,12 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; + +class AccountRow extends Struct { + @Int64() + external int id; + + external Pointer label; + + String getLabel() => label.toDartString(); + int getId() => id; +} diff --git a/cw_wownero/lib/api/structs/coins_info_row.dart b/cw_wownero/lib/api/structs/coins_info_row.dart new file mode 100644 index 000000000..ff6f6ce73 --- /dev/null +++ b/cw_wownero/lib/api/structs/coins_info_row.dart @@ -0,0 +1,73 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; + +class CoinsInfoRow extends Struct { + @Int64() + external int blockHeight; + + external Pointer hash; + + @Uint64() + external int internalOutputIndex; + + @Uint64() + external int globalOutputIndex; + + @Int8() + external int spent; + + @Int8() + external int frozen; + + @Uint64() + external int spentHeight; + + @Uint64() + external int amount; + + @Int8() + external int rct; + + @Int8() + external int keyImageKnown; + + @Uint64() + external int pkIndex; + + @Uint32() + external int subaddrIndex; + + @Uint32() + external int subaddrAccount; + + external Pointer address; + + external Pointer addressLabel; + + external Pointer keyImage; + + @Uint64() + external int unlockTime; + + @Int8() + external int unlocked; + + external Pointer pubKey; + + @Int8() + external int coinbase; + + external Pointer description; + + String getHash() => hash.toDartString(); + + String getAddress() => address.toDartString(); + + String getAddressLabel() => addressLabel.toDartString(); + + String getKeyImage() => keyImage.toDartString(); + + String getPubKey() => pubKey.toDartString(); + + String getDescription() => description.toDartString(); +} diff --git a/cw_wownero/lib/api/structs/subaddress_row.dart b/cw_wownero/lib/api/structs/subaddress_row.dart new file mode 100644 index 000000000..d593a793d --- /dev/null +++ b/cw_wownero/lib/api/structs/subaddress_row.dart @@ -0,0 +1,15 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; + +class SubaddressRow extends Struct { + @Int64() + external int id; + + external Pointer address; + + external Pointer label; + + String getLabel() => label.toDartString(); + String getAddress() => address.toDartString(); + int getId() => id; +} \ No newline at end of file diff --git a/cw_wownero/lib/api/structs/transaction_info_row.dart b/cw_wownero/lib/api/structs/transaction_info_row.dart new file mode 100644 index 000000000..bdcc64d3f --- /dev/null +++ b/cw_wownero/lib/api/structs/transaction_info_row.dart @@ -0,0 +1,41 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; + +class TransactionInfoRow extends Struct { + @Uint64() + external int amount; + + @Uint64() + external int fee; + + @Uint64() + external int blockHeight; + + @Uint64() + external int confirmations; + + @Uint32() + external int subaddrAccount; + + @Int8() + external int direction; + + @Int8() + external int isPending; + + @Uint32() + external int subaddrIndex; + + external Pointer hash; + + external Pointer paymentId; + + @Int64() + external int datetime; + + int getDatetime() => datetime; + int getAmount() => amount >= 0 ? amount : amount * -1; + bool getIsPending() => isPending != 0; + String getHash() => hash.toDartString(); + String getPaymentId() => paymentId.toDartString(); +} diff --git a/cw_wownero/lib/api/structs/ut8_box.dart b/cw_wownero/lib/api/structs/ut8_box.dart new file mode 100644 index 000000000..53e678c88 --- /dev/null +++ b/cw_wownero/lib/api/structs/ut8_box.dart @@ -0,0 +1,8 @@ +import 'dart:ffi'; +import 'package:ffi/ffi.dart'; + +class Utf8Box extends Struct { + external Pointer value; + + String getValue() => value.toDartString(); +} diff --git a/cw_wownero/lib/cw_wownero.dart b/cw_wownero/lib/cw_wownero.dart new file mode 100644 index 000000000..33a55e305 --- /dev/null +++ b/cw_wownero/lib/cw_wownero.dart @@ -0,0 +1,8 @@ + +import 'cw_wownero_platform_interface.dart'; + +class CwWownero { + Future getPlatformVersion() { + return CwWowneroPlatform.instance.getPlatformVersion(); + } +} diff --git a/cw_wownero/lib/cw_wownero_method_channel.dart b/cw_wownero/lib/cw_wownero_method_channel.dart new file mode 100644 index 000000000..d797f5f81 --- /dev/null +++ b/cw_wownero/lib/cw_wownero_method_channel.dart @@ -0,0 +1,17 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +import 'cw_wownero_platform_interface.dart'; + +/// An implementation of [CwWowneroPlatform] that uses method channels. +class MethodChannelCwWownero extends CwWowneroPlatform { + /// The method channel used to interact with the native platform. + @visibleForTesting + final methodChannel = const MethodChannel('cw_wownero'); + + @override + Future getPlatformVersion() async { + final version = await methodChannel.invokeMethod('getPlatformVersion'); + return version; + } +} diff --git a/cw_wownero/lib/cw_wownero_platform_interface.dart b/cw_wownero/lib/cw_wownero_platform_interface.dart new file mode 100644 index 000000000..78b21592c --- /dev/null +++ b/cw_wownero/lib/cw_wownero_platform_interface.dart @@ -0,0 +1,29 @@ +import 'package:plugin_platform_interface/plugin_platform_interface.dart'; + +import 'cw_wownero_method_channel.dart'; + +abstract class CwWowneroPlatform extends PlatformInterface { + /// Constructs a CwWowneroPlatform. + CwWowneroPlatform() : super(token: _token); + + static final Object _token = Object(); + + static CwWowneroPlatform _instance = MethodChannelCwWownero(); + + /// The default instance of [CwWowneroPlatform] to use. + /// + /// Defaults to [MethodChannelCwWownero]. + static CwWowneroPlatform get instance => _instance; + + /// Platform-specific implementations should set this with their own + /// platform-specific class that extends [CwWowneroPlatform] when + /// they register themselves. + static set instance(CwWowneroPlatform instance) { + PlatformInterface.verifyToken(instance, _token); + _instance = instance; + } + + Future getPlatformVersion() { + throw UnimplementedError('platformVersion() has not been implemented.'); + } +} diff --git a/cw_wownero/lib/mywownero.dart b/cw_wownero/lib/mywownero.dart new file mode 100644 index 000000000..d50e48b64 --- /dev/null +++ b/cw_wownero/lib/mywownero.dart @@ -0,0 +1,1689 @@ +const prefixLength = 3; + +String swapEndianBytes(String original) { + if (original.length != 8) { + return ''; + } + + return original[6] + + original[7] + + original[4] + + original[5] + + original[2] + + original[3] + + original[0] + + original[1]; +} + +List tructWords(List wordSet) { + final start = 0; + final end = prefixLength; + + return wordSet.map((word) => word.substring(start, end)).toList(); +} + +String mnemonicDecode(String seed) { + final n = englistWordSet.length; + var out = ''; + var wlist = seed.split(' '); + wlist.removeLast(); + + for (var i = 0; i < wlist.length; i += 3) { + final w1 = + tructWords(englistWordSet).indexOf(wlist[i].substring(0, prefixLength)); + final w2 = tructWords(englistWordSet) + .indexOf(wlist[i + 1].substring(0, prefixLength)); + final w3 = tructWords(englistWordSet) + .indexOf(wlist[i + 2].substring(0, prefixLength)); + + if (w1 == -1 || w2 == -1 || w3 == -1) { + print("invalid word in mnemonic"); + return ''; + } + + final x = w1 + n * (((n - w1) + w2) % n) + n * n * (((n - w2) + w3) % n); + + if (x % n != w1) { + print("Something went wrong when decoding your private key, please try again"); + return ''; + } + + final _res = '0000000' + x.toRadixString(16); + final start = _res.length - 8; + final end = _res.length; + final res = _res.substring(start, end); + + out += swapEndianBytes(res); + } + + return out; +} + +final englistWordSet = [ + "abbey", + "abducts", + "ability", + "ablaze", + "abnormal", + "abort", + "abrasive", + "absorb", + "abyss", + "academy", + "aces", + "aching", + "acidic", + "acoustic", + "acquire", + "across", + "actress", + "acumen", + "adapt", + "addicted", + "adept", + "adhesive", + "adjust", + "adopt", + "adrenalin", + "adult", + "adventure", + "aerial", + "afar", + "affair", + "afield", + "afloat", + "afoot", + "afraid", + "after", + "against", + "agenda", + "aggravate", + "agile", + "aglow", + "agnostic", + "agony", + "agreed", + "ahead", + "aided", + "ailments", + "aimless", + "airport", + "aisle", + "ajar", + "akin", + "alarms", + "album", + "alchemy", + "alerts", + "algebra", + "alkaline", + "alley", + "almost", + "aloof", + "alpine", + "already", + "also", + "altitude", + "alumni", + "always", + "amaze", + "ambush", + "amended", + "amidst", + "ammo", + "amnesty", + "among", + "amply", + "amused", + "anchor", + "android", + "anecdote", + "angled", + "ankle", + "annoyed", + "answers", + "antics", + "anvil", + "anxiety", + "anybody", + "apart", + "apex", + "aphid", + "aplomb", + "apology", + "apply", + "apricot", + "aptitude", + "aquarium", + "arbitrary", + "archer", + "ardent", + "arena", + "argue", + "arises", + "army", + "around", + "arrow", + "arsenic", + "artistic", + "ascend", + "ashtray", + "aside", + "asked", + "asleep", + "aspire", + "assorted", + "asylum", + "athlete", + "atlas", + "atom", + "atrium", + "attire", + "auburn", + "auctions", + "audio", + "august", + "aunt", + "austere", + "autumn", + "avatar", + "avidly", + "avoid", + "awakened", + "awesome", + "awful", + "awkward", + "awning", + "awoken", + "axes", + "axis", + "axle", + "aztec", + "azure", + "baby", + "bacon", + "badge", + "baffles", + "bagpipe", + "bailed", + "bakery", + "balding", + "bamboo", + "banjo", + "baptism", + "basin", + "batch", + "bawled", + "bays", + "because", + "beer", + "befit", + "begun", + "behind", + "being", + "below", + "bemused", + "benches", + "berries", + "bested", + "betting", + "bevel", + "beware", + "beyond", + "bias", + "bicycle", + "bids", + "bifocals", + "biggest", + "bikini", + "bimonthly", + "binocular", + "biology", + "biplane", + "birth", + "biscuit", + "bite", + "biweekly", + "blender", + "blip", + "bluntly", + "boat", + "bobsled", + "bodies", + "bogeys", + "boil", + "boldly", + "bomb", + "border", + "boss", + "both", + "bounced", + "bovine", + "bowling", + "boxes", + "boyfriend", + "broken", + "brunt", + "bubble", + "buckets", + "budget", + "buffet", + "bugs", + "building", + "bulb", + "bumper", + "bunch", + "business", + "butter", + "buying", + "buzzer", + "bygones", + "byline", + "bypass", + "cabin", + "cactus", + "cadets", + "cafe", + "cage", + "cajun", + "cake", + "calamity", + "camp", + "candy", + "casket", + "catch", + "cause", + "cavernous", + "cease", + "cedar", + "ceiling", + "cell", + "cement", + "cent", + "certain", + "chlorine", + "chrome", + "cider", + "cigar", + "cinema", + "circle", + "cistern", + "citadel", + "civilian", + "claim", + "click", + "clue", + "coal", + "cobra", + "cocoa", + "code", + "coexist", + "coffee", + "cogs", + "cohesive", + "coils", + "colony", + "comb", + "cool", + "copy", + "corrode", + "costume", + "cottage", + "cousin", + "cowl", + "criminal", + "cube", + "cucumber", + "cuddled", + "cuffs", + "cuisine", + "cunning", + "cupcake", + "custom", + "cycling", + "cylinder", + "cynical", + "dabbing", + "dads", + "daft", + "dagger", + "daily", + "damp", + "dangerous", + "dapper", + "darted", + "dash", + "dating", + "dauntless", + "dawn", + "daytime", + "dazed", + "debut", + "decay", + "dedicated", + "deepest", + "deftly", + "degrees", + "dehydrate", + "deity", + "dejected", + "delayed", + "demonstrate", + "dented", + "deodorant", + "depth", + "desk", + "devoid", + "dewdrop", + "dexterity", + "dialect", + "dice", + "diet", + "different", + "digit", + "dilute", + "dime", + "dinner", + "diode", + "diplomat", + "directed", + "distance", + "ditch", + "divers", + "dizzy", + "doctor", + "dodge", + "does", + "dogs", + "doing", + "dolphin", + "domestic", + "donuts", + "doorway", + "dormant", + "dosage", + "dotted", + "double", + "dove", + "down", + "dozen", + "dreams", + "drinks", + "drowning", + "drunk", + "drying", + "dual", + "dubbed", + "duckling", + "dude", + "duets", + "duke", + "dullness", + "dummy", + "dunes", + "duplex", + "duration", + "dusted", + "duties", + "dwarf", + "dwelt", + "dwindling", + "dying", + "dynamite", + "dyslexic", + "each", + "eagle", + "earth", + "easy", + "eating", + "eavesdrop", + "eccentric", + "echo", + "eclipse", + "economics", + "ecstatic", + "eden", + "edgy", + "edited", + "educated", + "eels", + "efficient", + "eggs", + "egotistic", + "eight", + "either", + "eject", + "elapse", + "elbow", + "eldest", + "eleven", + "elite", + "elope", + "else", + "eluded", + "emails", + "ember", + "emerge", + "emit", + "emotion", + "empty", + "emulate", + "energy", + "enforce", + "enhanced", + "enigma", + "enjoy", + "enlist", + "enmity", + "enough", + "enraged", + "ensign", + "entrance", + "envy", + "epoxy", + "equip", + "erase", + "erected", + "erosion", + "error", + "eskimos", + "espionage", + "essential", + "estate", + "etched", + "eternal", + "ethics", + "etiquette", + "evaluate", + "evenings", + "evicted", + "evolved", + "examine", + "excess", + "exhale", + "exit", + "exotic", + "exquisite", + "extra", + "exult", + "fabrics", + "factual", + "fading", + "fainted", + "faked", + "fall", + "family", + "fancy", + "farming", + "fatal", + "faulty", + "fawns", + "faxed", + "fazed", + "feast", + "february", + "federal", + "feel", + "feline", + "females", + "fences", + "ferry", + "festival", + "fetches", + "fever", + "fewest", + "fiat", + "fibula", + "fictional", + "fidget", + "fierce", + "fifteen", + "fight", + "films", + "firm", + "fishing", + "fitting", + "five", + "fixate", + "fizzle", + "fleet", + "flippant", + "flying", + "foamy", + "focus", + "foes", + "foggy", + "foiled", + "folding", + "fonts", + "foolish", + "fossil", + "fountain", + "fowls", + "foxes", + "foyer", + "framed", + "friendly", + "frown", + "fruit", + "frying", + "fudge", + "fuel", + "fugitive", + "fully", + "fuming", + "fungal", + "furnished", + "fuselage", + "future", + "fuzzy", + "gables", + "gadget", + "gags", + "gained", + "galaxy", + "gambit", + "gang", + "gasp", + "gather", + "gauze", + "gave", + "gawk", + "gaze", + "gearbox", + "gecko", + "geek", + "gels", + "gemstone", + "general", + "geometry", + "germs", + "gesture", + "getting", + "geyser", + "ghetto", + "ghost", + "giant", + "giddy", + "gifts", + "gigantic", + "gills", + "gimmick", + "ginger", + "girth", + "giving", + "glass", + "gleeful", + "glide", + "gnaw", + "gnome", + "goat", + "goblet", + "godfather", + "goes", + "goggles", + "going", + "goldfish", + "gone", + "goodbye", + "gopher", + "gorilla", + "gossip", + "gotten", + "gourmet", + "governing", + "gown", + "greater", + "grunt", + "guarded", + "guest", + "guide", + "gulp", + "gumball", + "guru", + "gusts", + "gutter", + "guys", + "gymnast", + "gypsy", + "gyrate", + "habitat", + "hacksaw", + "haggled", + "hairy", + "hamburger", + "happens", + "hashing", + "hatchet", + "haunted", + "having", + "hawk", + "haystack", + "hazard", + "hectare", + "hedgehog", + "heels", + "hefty", + "height", + "hemlock", + "hence", + "heron", + "hesitate", + "hexagon", + "hickory", + "hiding", + "highway", + "hijack", + "hiker", + "hills", + "himself", + "hinder", + "hippo", + "hire", + "history", + "hitched", + "hive", + "hoax", + "hobby", + "hockey", + "hoisting", + "hold", + "honked", + "hookup", + "hope", + "hornet", + "hospital", + "hotel", + "hounded", + "hover", + "howls", + "hubcaps", + "huddle", + "huge", + "hull", + "humid", + "hunter", + "hurried", + "husband", + "huts", + "hybrid", + "hydrogen", + "hyper", + "iceberg", + "icing", + "icon", + "identity", + "idiom", + "idled", + "idols", + "igloo", + "ignore", + "iguana", + "illness", + "imagine", + "imbalance", + "imitate", + "impel", + "inactive", + "inbound", + "incur", + "industrial", + "inexact", + "inflamed", + "ingested", + "initiate", + "injury", + "inkling", + "inline", + "inmate", + "innocent", + "inorganic", + "input", + "inquest", + "inroads", + "insult", + "intended", + "inundate", + "invoke", + "inwardly", + "ionic", + "irate", + "iris", + "irony", + "irritate", + "island", + "isolated", + "issued", + "italics", + "itches", + "items", + "itinerary", + "itself", + "ivory", + "jabbed", + "jackets", + "jaded", + "jagged", + "jailed", + "jamming", + "january", + "jargon", + "jaunt", + "javelin", + "jaws", + "jazz", + "jeans", + "jeers", + "jellyfish", + "jeopardy", + "jerseys", + "jester", + "jetting", + "jewels", + "jigsaw", + "jingle", + "jittery", + "jive", + "jobs", + "jockey", + "jogger", + "joining", + "joking", + "jolted", + "jostle", + "journal", + "joyous", + "jubilee", + "judge", + "juggled", + "juicy", + "jukebox", + "july", + "jump", + "junk", + "jury", + "justice", + "juvenile", + "kangaroo", + "karate", + "keep", + "kennel", + "kept", + "kernels", + "kettle", + "keyboard", + "kickoff", + "kidneys", + "king", + "kiosk", + "kisses", + "kitchens", + "kiwi", + "knapsack", + "knee", + "knife", + "knowledge", + "knuckle", + "koala", + "laboratory", + "ladder", + "lagoon", + "lair", + "lakes", + "lamb", + "language", + "laptop", + "large", + "last", + "later", + "launching", + "lava", + "lawsuit", + "layout", + "lazy", + "lectures", + "ledge", + "leech", + "left", + "legion", + "leisure", + "lemon", + "lending", + "leopard", + "lesson", + "lettuce", + "lexicon", + "liar", + "library", + "licks", + "lids", + "lied", + "lifestyle", + "light", + "likewise", + "lilac", + "limits", + "linen", + "lion", + "lipstick", + "liquid", + "listen", + "lively", + "loaded", + "lobster", + "locker", + "lodge", + "lofty", + "logic", + "loincloth", + "long", + "looking", + "lopped", + "lordship", + "losing", + "lottery", + "loudly", + "love", + "lower", + "loyal", + "lucky", + "luggage", + "lukewarm", + "lullaby", + "lumber", + "lunar", + "lurk", + "lush", + "luxury", + "lymph", + "lynx", + "lyrics", + "macro", + "madness", + "magically", + "mailed", + "major", + "makeup", + "malady", + "mammal", + "maps", + "masterful", + "match", + "maul", + "maverick", + "maximum", + "mayor", + "maze", + "meant", + "mechanic", + "medicate", + "meeting", + "megabyte", + "melting", + "memoir", + "menu", + "merger", + "mesh", + "metro", + "mews", + "mice", + "midst", + "mighty", + "mime", + "mirror", + "misery", + "mittens", + "mixture", + "moat", + "mobile", + "mocked", + "mohawk", + "moisture", + "molten", + "moment", + "money", + "moon", + "mops", + "morsel", + "mostly", + "motherly", + "mouth", + "movement", + "mowing", + "much", + "muddy", + "muffin", + "mugged", + "mullet", + "mumble", + "mundane", + "muppet", + "mural", + "musical", + "muzzle", + "myriad", + "mystery", + "myth", + "nabbing", + "nagged", + "nail", + "names", + "nanny", + "napkin", + "narrate", + "nasty", + "natural", + "nautical", + "navy", + "nearby", + "necklace", + "needed", + "negative", + "neither", + "neon", + "nephew", + "nerves", + "nestle", + "network", + "neutral", + "never", + "newt", + "nexus", + "nibs", + "niche", + "niece", + "nifty", + "nightly", + "nimbly", + "nineteen", + "nirvana", + "nitrogen", + "nobody", + "nocturnal", + "nodes", + "noises", + "nomad", + "noodles", + "northern", + "nostril", + "noted", + "nouns", + "novelty", + "nowhere", + "nozzle", + "nuance", + "nucleus", + "nudged", + "nugget", + "nuisance", + "null", + "number", + "nuns", + "nurse", + "nutshell", + "nylon", + "oaks", + "oars", + "oasis", + "oatmeal", + "obedient", + "object", + "obliged", + "obnoxious", + "observant", + "obtains", + "obvious", + "occur", + "ocean", + "october", + "odds", + "odometer", + "offend", + "often", + "oilfield", + "ointment", + "okay", + "older", + "olive", + "olympics", + "omega", + "omission", + "omnibus", + "onboard", + "oncoming", + "oneself", + "ongoing", + "onion", + "online", + "onslaught", + "onto", + "onward", + "oozed", + "opacity", + "opened", + "opposite", + "optical", + "opus", + "orange", + "orbit", + "orchid", + "orders", + "organs", + "origin", + "ornament", + "orphans", + "oscar", + "ostrich", + "otherwise", + "otter", + "ouch", + "ought", + "ounce", + "ourselves", + "oust", + "outbreak", + "oval", + "oven", + "owed", + "owls", + "owner", + "oxidant", + "oxygen", + "oyster", + "ozone", + "pact", + "paddles", + "pager", + "pairing", + "palace", + "pamphlet", + "pancakes", + "paper", + "paradise", + "pastry", + "patio", + "pause", + "pavements", + "pawnshop", + "payment", + "peaches", + "pebbles", + "peculiar", + "pedantic", + "peeled", + "pegs", + "pelican", + "pencil", + "people", + "pepper", + "perfect", + "pests", + "petals", + "phase", + "pheasants", + "phone", + "phrases", + "physics", + "piano", + "picked", + "pierce", + "pigment", + "piloted", + "pimple", + "pinched", + "pioneer", + "pipeline", + "pirate", + "pistons", + "pitched", + "pivot", + "pixels", + "pizza", + "playful", + "pledge", + "pliers", + "plotting", + "plus", + "plywood", + "poaching", + "pockets", + "podcast", + "poetry", + "point", + "poker", + "polar", + "ponies", + "pool", + "popular", + "portents", + "possible", + "potato", + "pouch", + "poverty", + "powder", + "pram", + "present", + "pride", + "problems", + "pruned", + "prying", + "psychic", + "public", + "puck", + "puddle", + "puffin", + "pulp", + "pumpkins", + "punch", + "puppy", + "purged", + "push", + "putty", + "puzzled", + "pylons", + "pyramid", + "python", + "queen", + "quick", + "quote", + "rabbits", + "racetrack", + "radar", + "rafts", + "rage", + "railway", + "raking", + "rally", + "ramped", + "randomly", + "rapid", + "rarest", + "rash", + "rated", + "ravine", + "rays", + "razor", + "react", + "rebel", + "recipe", + "reduce", + "reef", + "refer", + "regular", + "reheat", + "reinvest", + "rejoices", + "rekindle", + "relic", + "remedy", + "renting", + "reorder", + "repent", + "request", + "reruns", + "rest", + "return", + "reunion", + "revamp", + "rewind", + "rhino", + "rhythm", + "ribbon", + "richly", + "ridges", + "rift", + "rigid", + "rims", + "ringing", + "riots", + "ripped", + "rising", + "ritual", + "river", + "roared", + "robot", + "rockets", + "rodent", + "rogue", + "roles", + "romance", + "roomy", + "roped", + "roster", + "rotate", + "rounded", + "rover", + "rowboat", + "royal", + "ruby", + "rudely", + "ruffled", + "rugged", + "ruined", + "ruling", + "rumble", + "runway", + "rural", + "rustled", + "ruthless", + "sabotage", + "sack", + "sadness", + "safety", + "saga", + "sailor", + "sake", + "salads", + "sample", + "sanity", + "sapling", + "sarcasm", + "sash", + "satin", + "saucepan", + "saved", + "sawmill", + "saxophone", + "sayings", + "scamper", + "scenic", + "school", + "science", + "scoop", + "scrub", + "scuba", + "seasons", + "second", + "sedan", + "seeded", + "segments", + "seismic", + "selfish", + "semifinal", + "sensible", + "september", + "sequence", + "serving", + "session", + "setup", + "seventh", + "sewage", + "shackles", + "shelter", + "shipped", + "shocking", + "shrugged", + "shuffled", + "shyness", + "siblings", + "sickness", + "sidekick", + "sieve", + "sifting", + "sighting", + "silk", + "simplest", + "sincerely", + "sipped", + "siren", + "situated", + "sixteen", + "sizes", + "skater", + "skew", + "skirting", + "skulls", + "skydive", + "slackens", + "sleepless", + "slid", + "slower", + "slug", + "smash", + "smelting", + "smidgen", + "smog", + "smuggled", + "snake", + "sneeze", + "sniff", + "snout", + "snug", + "soapy", + "sober", + "soccer", + "soda", + "software", + "soggy", + "soil", + "solved", + "somewhere", + "sonic", + "soothe", + "soprano", + "sorry", + "southern", + "sovereign", + "sowed", + "soya", + "space", + "speedy", + "sphere", + "spiders", + "splendid", + "spout", + "sprig", + "spud", + "spying", + "square", + "stacking", + "stellar", + "stick", + "stockpile", + "strained", + "stunning", + "stylishly", + "subtly", + "succeed", + "suddenly", + "suede", + "suffice", + "sugar", + "suitcase", + "sulking", + "summon", + "sunken", + "superior", + "surfer", + "sushi", + "suture", + "swagger", + "swept", + "swiftly", + "sword", + "swung", + "syllabus", + "symptoms", + "syndrome", + "syringe", + "system", + "taboo", + "tacit", + "tadpoles", + "tagged", + "tail", + "taken", + "talent", + "tamper", + "tanks", + "tapestry", + "tarnished", + "tasked", + "tattoo", + "taunts", + "tavern", + "tawny", + "taxi", + "teardrop", + "technical", + "tedious", + "teeming", + "tell", + "template", + "tender", + "tepid", + "tequila", + "terminal", + "testing", + "tether", + "textbook", + "thaw", + "theatrics", + "thirsty", + "thorn", + "threaten", + "thumbs", + "thwart", + "ticket", + "tidy", + "tiers", + "tiger", + "tilt", + "timber", + "tinted", + "tipsy", + "tirade", + "tissue", + "titans", + "toaster", + "tobacco", + "today", + "toenail", + "toffee", + "together", + "toilet", + "token", + "tolerant", + "tomorrow", + "tonic", + "toolbox", + "topic", + "torch", + "tossed", + "total", + "touchy", + "towel", + "toxic", + "toyed", + "trash", + "trendy", + "tribal", + "trolling", + "truth", + "trying", + "tsunami", + "tubes", + "tucks", + "tudor", + "tuesday", + "tufts", + "tugs", + "tuition", + "tulips", + "tumbling", + "tunnel", + "turnip", + "tusks", + "tutor", + "tuxedo", + "twang", + "tweezers", + "twice", + "twofold", + "tycoon", + "typist", + "tyrant", + "ugly", + "ulcers", + "ultimate", + "umbrella", + "umpire", + "unafraid", + "unbending", + "uncle", + "under", + "uneven", + "unfit", + "ungainly", + "unhappy", + "union", + "unjustly", + "unknown", + "unlikely", + "unmask", + "unnoticed", + "unopened", + "unplugs", + "unquoted", + "unrest", + "unsafe", + "until", + "unusual", + "unveil", + "unwind", + "unzip", + "upbeat", + "upcoming", + "update", + "upgrade", + "uphill", + "upkeep", + "upload", + "upon", + "upper", + "upright", + "upstairs", + "uptight", + "upwards", + "urban", + "urchins", + "urgent", + "usage", + "useful", + "usher", + "using", + "usual", + "utensils", + "utility", + "utmost", + "utopia", + "uttered", + "vacation", + "vague", + "vain", + "value", + "vampire", + "vane", + "vapidly", + "vary", + "vastness", + "vats", + "vaults", + "vector", + "veered", + "vegan", + "vehicle", + "vein", + "velvet", + "venomous", + "verification", + "vessel", + "veteran", + "vexed", + "vials", + "vibrate", + "victim", + "video", + "viewpoint", + "vigilant", + "viking", + "village", + "vinegar", + "violin", + "vipers", + "virtual", + "visited", + "vitals", + "vivid", + "vixen", + "vocal", + "vogue", + "voice", + "volcano", + "vortex", + "voted", + "voucher", + "vowels", + "voyage", + "vulture", + "wade", + "waffle", + "wagtail", + "waist", + "waking", + "wallets", + "wanted", + "warped", + "washing", + "water", + "waveform", + "waxing", + "wayside", + "weavers", + "website", + "wedge", + "weekday", + "weird", + "welders", + "went", + "wept", + "were", + "western", + "wetsuit", + "whale", + "when", + "whipped", + "whole", + "wickets", + "width", + "wield", + "wife", + "wiggle", + "wildly", + "winter", + "wipeout", + "wiring", + "wise", + "withdrawn", + "wives", + "wizard", + "wobbly", + "woes", + "woken", + "wolf", + "womanly", + "wonders", + "woozy", + "worry", + "wounded", + "woven", + "wrap", + "wrist", + "wrong", + "yacht", + "yahoo", + "yanks", + "yard", + "yawning", + "yearbook", + "yellow", + "yesterday", + "yeti", + "yields", + "yodel", + "yoga", + "younger", + "yoyo", + "zapped", + "zeal", + "zebra", + "zero", + "zesty", + "zigzags", + "zinger", + "zippers", + "zodiac", + "zombie", + "zones", + "zoom" +]; diff --git a/cw_wownero/pubspec.lock b/cw_wownero/pubspec.lock index c90340800..b0347c023 100644 --- a/cw_wownero/pubspec.lock +++ b/cw_wownero/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" + bluez: + dependency: transitive + description: + name: bluez + sha256: "203a1924e818a9dd74af2b2c7a8f375ab8e5edf0e486bba8f90a0d8a17ed9fce" + url: "https://pub.dev" + source: hosted + version: "0.8.2" boolean_selector: dependency: transitive description: @@ -209,6 +217,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.4" + dbus: + dependency: transitive + description: + name: dbus + sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" + url: "https://pub.dev" + source: hosted + version: "0.7.10" encrypt: dependency: "direct main" description: @@ -267,6 +283,14 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_bluetooth: + dependency: transitive + description: + name: flutter_web_bluetooth + sha256: "52ce64f65d7321c4bf6abfe9dac02fb888731339a5e0ad6de59fb916c20c9f02" + url: "https://pub.dev" + source: hosted + version: "0.2.3" frontend_server_client: dependency: transitive description: @@ -403,6 +427,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" + ledger_flutter_plus: + dependency: transitive + description: + name: ledger_flutter_plus + sha256: ea3ed586e1697776dacf42ac979095f1ca3bd143bf007cbe5c78e09cb6943f42 + url: "https://pub.dev" + source: hosted + version: "1.2.5" + ledger_usb_plus: + dependency: transitive + description: + name: ledger_usb_plus + sha256: "21cc5d976cf7edb3518bd2a0c4164139cbb0817d2e4f2054707fc4edfdf9ce87" + url: "https://pub.dev" + source: hosted + version: "1.0.4" logging: dependency: transitive description: @@ -463,8 +503,8 @@ packages: dependency: "direct main" description: path: "impls/monero.dart" - ref: "6eb571ea498ed7b854934785f00fabfd0dadf75b" - resolved-ref: "6eb571ea498ed7b854934785f00fabfd0dadf75b" + ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b + resolved-ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b url: "https://github.com/mrcyjanek/monero_c" source: git version: "0.0.0" @@ -540,6 +580,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 + url: "https://pub.dev" + source: hosted + version: "6.0.2" platform: dependency: transitive description: @@ -552,10 +600,10 @@ packages: dependency: transitive description: name: plugin_platform_interface - sha256: dbf0f707c78beedc9200146ad3cb0ab4d5da13c246336987be6940f026500d3a + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" url: "https://pub.dev" source: hosted - version: "2.1.3" + version: "2.1.8" pointycastle: dependency: transitive description: @@ -596,6 +644,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + rxdart: + dependency: transitive + description: + name: rxdart + sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" + url: "https://pub.dev" + source: hosted + version: "0.28.0" shelf: dependency: transitive description: @@ -721,6 +777,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + universal_ble: + dependency: transitive + description: + name: universal_ble + sha256: "0dfbd6b64bff3ad61ed7a895c232530d9614e9b01ab261a74433a43267edb7f3" + url: "https://pub.dev" + source: hosted + version: "0.12.0" + universal_platform: + dependency: transitive + description: + name: universal_platform + sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" + url: "https://pub.dev" + source: hosted + version: "1.1.0" unorm_dart: dependency: transitive description: @@ -777,6 +849,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + xml: + dependency: transitive + description: + name: xml + sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 + url: "https://pub.dev" + source: hosted + version: "6.5.0" yaml: dependency: transitive description: @@ -786,5 +866,5 @@ packages: source: hosted version: "3.1.1" sdks: - dart: ">=3.2.0-0 <4.0.0" - flutter: ">=3.7.0" + dart: ">=3.3.0 <4.0.0" + flutter: ">=3.19.0" diff --git a/cw_wownero/pubspec.yaml b/cw_wownero/pubspec.yaml index 6943e60c3..582c15447 100644 --- a/cw_wownero/pubspec.yaml +++ b/cw_wownero/pubspec.yaml @@ -25,7 +25,8 @@ dependencies: monero: git: url: https://github.com/mrcyjanek/monero_c - ref: 6eb571ea498ed7b854934785f00fabfd0dadf75b # monero_c hash + ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b +# ref: 6eb571ea498ed7b854934785f00fabfd0dadf75b # monero_c hash path: impls/monero.dart mutex: ^3.1.0 diff --git a/lib/core/wallet_loading_service.dart b/lib/core/wallet_loading_service.dart index e58e14652..10e438e0c 100644 --- a/lib/core/wallet_loading_service.dart +++ b/lib/core/wallet_loading_service.dart @@ -14,7 +14,11 @@ import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; class WalletLoadingService { - WalletLoadingService(this.sharedPreferences, this.keyService, this.walletServiceFactory); + WalletLoadingService( + this.sharedPreferences, + this.keyService, + this.walletServiceFactory, + ); final SharedPreferences sharedPreferences; final KeyService keyService; @@ -77,7 +81,8 @@ class WalletLoadingService { await updateMoneroWalletPassword(wallet); } - await sharedPreferences.setString(PreferencesKey.currentWalletName, wallet.name); + await sharedPreferences.setString( + PreferencesKey.currentWalletName, wallet.name); await sharedPreferences.setInt( PreferencesKey.currentWalletType, serializeToInt(wallet.type)); @@ -129,4 +134,9 @@ class WalletLoadingService { return "\n\n$type ($name): ${await walletService.getSeeds(name, password, type)}"; } + + bool requireHardwareWalletConnection(WalletType type, String name) { + final walletService = walletServiceFactory.call(type); + return walletService.requireHardwareWalletConnection(name); + } } diff --git a/lib/di.dart b/lib/di.dart index 8dd1029a9..facdec912 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -32,12 +32,14 @@ import 'package:cake_wallet/entities/biometric_auth.dart'; import 'package:cake_wallet/entities/contact.dart'; import 'package:cake_wallet/entities/contact_record.dart'; import 'package:cake_wallet/entities/exchange_api_mode.dart'; +import 'package:cake_wallet/entities/hardware_wallet/require_hardware_wallet_connection.dart'; 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/buy/buy_sell_options_page.dart'; import 'package:cake_wallet/src/screens/buy/payment_method_options_page.dart'; import 'package:cake_wallet/src/screens/receive/address_list_page.dart'; +import 'package:cake_wallet/src/screens/wallet_list/wallet_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'; @@ -184,7 +186,6 @@ import 'package:cake_wallet/src/screens/transaction_details/transaction_details_ import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_details_page.dart'; import 'package:cake_wallet/src/screens/unspent_coins/unspent_coins_list_page.dart'; import 'package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart'; -import 'package:cake_wallet/src/screens/wallet_list/wallet_list_page.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/store/authentication_store.dart'; import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; @@ -586,7 +587,7 @@ Future setup({ ); } else { // wallet is already loaded: - if (appStore.wallet != null) { + if (appStore.wallet != null || requireHardwareWalletConnection()) { // goes to the dashboard: authStore.allowed(); // trigger any deep links: @@ -780,10 +781,12 @@ Future setup({ ); } - getIt.registerFactory(() => WalletListPage( - walletListViewModel: getIt.get(), - authService: getIt.get(), - )); + getIt.registerFactoryParam( + (Function(BuildContext)? onWalletLoaded, _) => WalletListPage( + walletListViewModel: getIt.get(), + authService: getIt.get(), + onWalletLoaded: onWalletLoaded, + )); getIt.registerFactoryParam( (WalletListViewModel walletListViewModel, _) => WalletEditViewModel( diff --git a/lib/entities/hardware_wallet/require_hardware_wallet_connection.dart b/lib/entities/hardware_wallet/require_hardware_wallet_connection.dart new file mode 100644 index 000000000..a64fa5873 --- /dev/null +++ b/lib/entities/hardware_wallet/require_hardware_wallet_connection.dart @@ -0,0 +1,25 @@ +import 'package:cake_wallet/core/wallet_loading_service.dart'; +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/entities/preferences_key.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +bool requireHardwareWalletConnection() { + final name = getIt + .get() + .getString(PreferencesKey.currentWalletName); + final typeRaw = + getIt.get().getInt(PreferencesKey.currentWalletType); + + if (typeRaw == null) { + return false; + } + + if (name == null) { + throw Exception('Incorrect current wallet name: $name'); + } + + final type = deserializeFromInt(typeRaw); + final walletLoadingService = getIt.get(); + return walletLoadingService.requireHardwareWalletConnection(type, name); +} diff --git a/lib/monero/cw_monero.dart b/lib/monero/cw_monero.dart index dfda4d600..09d9889c5 100644 --- a/lib/monero/cw_monero.dart +++ b/lib/monero/cw_monero.dart @@ -225,6 +225,19 @@ class CWMonero extends Monero { language: language, height: height); + @override + WalletCredentials createMoneroRestoreWalletFromHardwareCredentials({ + required String name, + required String password, + required int height, + required ledger.LedgerConnection ledgerConnection, + }) => + MoneroRestoreWalletFromHardwareCredentials( + name: name, + password: password, + height: height, + ledgerConnection: ledgerConnection); + @override WalletCredentials createMoneroRestoreWalletFromSeedCredentials( {required String name, @@ -383,6 +396,18 @@ class CWMonero extends Monero { checkIfMoneroCIsFine(); } + @override + void setLedgerConnection(Object wallet, ledger.LedgerConnection connection) { + final moneroWallet = wallet as MoneroWallet; + moneroWallet.setLedgerConnection(connection); + } + + @override + void setGlobalLedgerConnection(ledger.LedgerConnection connection) { + gLedger = connection; + keepAlive(connection); + } + bool isViewOnly() { return isViewOnlyBySpendKey(); } diff --git a/lib/reactions/on_authentication_state_change.dart b/lib/reactions/on_authentication_state_change.dart index 1aa0a12c6..b411c5a15 100644 --- a/lib/reactions/on_authentication_state_change.dart +++ b/lib/reactions/on_authentication_state_change.dart @@ -1,18 +1,28 @@ import 'dart:async'; +import 'package:cake_wallet/core/wallet_connect/wc_bottom_sheet_service.dart'; +import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/entities/hardware_wallet/require_hardware_wallet_connection.dart'; +import 'package:cake_wallet/entities/load_current_wallet.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/utils/exception_handler.dart'; +import 'package:cake_wallet/src/screens/connect_device/connect_device_page.dart'; +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/store/authentication_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; +import 'package:cake_wallet/utils/exception_handler.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:flutter/widgets.dart'; import 'package:mobx/mobx.dart'; -import 'package:cake_wallet/entities/load_current_wallet.dart'; -import 'package:cake_wallet/store/authentication_store.dart'; import 'package:rxdart/subjects.dart'; ReactionDisposer? _onAuthenticationStateChange; dynamic loginError; -StreamController authenticatedErrorStreamController = BehaviorSubject(); +StreamController authenticatedErrorStreamController = + BehaviorSubject(); void startAuthenticationStateChange( AuthenticationStore authenticationStore, @@ -27,18 +37,49 @@ void startAuthenticationStateChange( _onAuthenticationStateChange ??= autorun((_) async { final state = authenticationStore.state; - if (state == AuthenticationState.installed && !SettingsStoreBase.walletPasswordDirectInput) { + if (state == AuthenticationState.installed && + !SettingsStoreBase.walletPasswordDirectInput) { try { - await loadCurrentWallet(); + if (!requireHardwareWalletConnection()) await loadCurrentWallet(); } catch (error, stack) { loginError = error; - ExceptionHandler.onError(FlutterErrorDetails(exception: error, stack: stack)); + ExceptionHandler.onError( + FlutterErrorDetails(exception: error, stack: stack)); } return; } if (state == AuthenticationState.allowed) { - await navigatorKey.currentState!.pushNamedAndRemoveUntil(Routes.dashboard, (route) => false); + if (requireHardwareWalletConnection()) { + await navigatorKey.currentState!.pushNamedAndRemoveUntil( + Routes.connectDevices, + (route) => false, + arguments: ConnectDevicePageParams( + walletType: WalletType.monero, + onConnectDevice: (context, ledgerVM) async { + monero!.setGlobalLedgerConnection(ledgerVM.connection); + showPopUp( + context: context, + builder: (BuildContext context) => AlertWithOneAction( + alertTitle: S.of(context).proceed_on_device, + alertContent: S.of(context).proceed_on_device_description, + buttonText: S.of(context).cancel, + buttonAction: () => Navigator.of(context).pop()), + ); + await loadCurrentWallet(); + getIt.get().resetCurrentSheet(); + await navigatorKey.currentState! + .pushNamedAndRemoveUntil(Routes.dashboard, (route) => false); + }, + allowChangeWallet: true, + ), + ); + + // await navigatorKey.currentState!.pushNamedAndRemoveUntil(Routes.connectDevices, (route) => false, arguments: ConnectDevicePageParams(walletType: walletType, onConnectDevice: onConnectDevice)); + } else { + await navigatorKey.currentState! + .pushNamedAndRemoveUntil(Routes.dashboard, (route) => false); + } if (!(await authenticatedErrorStreamController.stream.isEmpty)) { ExceptionHandler.showError( (await authenticatedErrorStreamController.stream.first).toString()); diff --git a/lib/router.dart b/lib/router.dart index 781a6e057..1382da28f 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -24,6 +24,7 @@ import 'package:cake_wallet/src/screens/buy/webview_page.dart'; import 'package:cake_wallet/src/screens/cake_pay/auth/cake_pay_account_page.dart'; import 'package:cake_wallet/src/screens/cake_pay/cake_pay.dart'; import 'package:cake_wallet/src/screens/connect_device/connect_device_page.dart'; +import 'package:cake_wallet/src/screens/connect_device/monero_hardware_wallet_options_page.dart'; import 'package:cake_wallet/src/screens/connect_device/select_hardware_wallet_account_page.dart'; import 'package:cake_wallet/src/screens/contact/contact_list_page.dart'; import 'package:cake_wallet/src/screens/contact/contact_page.dart'; @@ -212,6 +213,9 @@ Route createRoute(RouteSettings settings) { final type = arguments[0] as WalletType; final walletVM = getIt.get(param1: type); + if (type == WalletType.monero) + return CupertinoPageRoute(builder: (_) => MoneroHardwareWalletOptionsPage(walletVM)); + return CupertinoPageRoute(builder: (_) => SelectHardwareWalletAccountPage(walletVM)); case Routes.setupPin: @@ -403,8 +407,11 @@ Route createRoute(RouteSettings settings) { return CupertinoPageRoute(builder: (_) => getIt.get()); case Routes.walletList: + final onWalletLoaded = settings.arguments as Function(BuildContext)?; return MaterialPageRoute( - fullscreenDialog: true, builder: (_) => getIt.get()); + fullscreenDialog: true, + builder: (_) => getIt.get(param1: onWalletLoaded), + ); case Routes.walletEdit: return MaterialPageRoute( diff --git a/lib/src/screens/connect_device/connect_device_page.dart b/lib/src/screens/connect_device/connect_device_page.dart index 9e331e818..c2cc40229 100644 --- a/lib/src/screens/connect_device/connect_device_page.dart +++ b/lib/src/screens/connect_device/connect_device_page.dart @@ -2,9 +2,12 @@ import 'dart:async'; import 'dart:io'; 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/connect_device/widgets/device_tile.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; +import 'package:cake_wallet/themes/extensions/wallet_list_theme.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart'; import 'package:cw_core/wallet_type.dart'; @@ -17,35 +20,46 @@ typedef OnConnectDevice = void Function(BuildContext, LedgerViewModel); class ConnectDevicePageParams { final WalletType walletType; final OnConnectDevice onConnectDevice; + final bool allowChangeWallet; - ConnectDevicePageParams( - {required this.walletType, required this.onConnectDevice}); + ConnectDevicePageParams({ + required this.walletType, + required this.onConnectDevice, + this.allowChangeWallet = false, + }); } class ConnectDevicePage extends BasePage { final WalletType walletType; final OnConnectDevice onConnectDevice; + final bool allowChangeWallet; final LedgerViewModel ledgerVM; ConnectDevicePage(ConnectDevicePageParams params, this.ledgerVM) : walletType = params.walletType, - onConnectDevice = params.onConnectDevice; + onConnectDevice = params.onConnectDevice, + allowChangeWallet = params.allowChangeWallet; @override String get title => S.current.restore_title_from_hardware_wallet; @override - Widget body(BuildContext context) => - ConnectDevicePageBody(walletType, onConnectDevice, ledgerVM); + Widget body(BuildContext context) => ConnectDevicePageBody( + walletType, onConnectDevice, allowChangeWallet, ledgerVM); } class ConnectDevicePageBody extends StatefulWidget { final WalletType walletType; final OnConnectDevice onConnectDevice; + final bool allowChangeWallet; final LedgerViewModel ledgerVM; const ConnectDevicePageBody( - this.walletType, this.onConnectDevice, this.ledgerVM); + this.walletType, + this.onConnectDevice, + this.allowChangeWallet, + this.ledgerVM, + ); @override ConnectDevicePageBodyState createState() => ConnectDevicePageBodyState(); @@ -102,14 +116,16 @@ class ConnectDevicePageBodyState extends State { Future _refreshBleDevices() async { try { - _bleRefresh = widget.ledgerVM - .scanForBleDevices() - .listen((device) => setState(() => bleDevices.add(device))) - ..onError((e) { - throw e.toString(); - }); - _bleRefreshTimer?.cancel(); - _bleRefreshTimer = null; + if (widget.ledgerVM.bleIsEnabled) { + _bleRefresh = widget.ledgerVM + .scanForBleDevices() + .listen((device) => setState(() => bleDevices.add(device))) + ..onError((e) { + throw e.toString(); + }); + _bleRefreshTimer?.cancel(); + _bleRefreshTimer = null; + } } catch (e) { print(e); } @@ -227,9 +243,7 @@ class ConnectDevicePageBodyState extends State { style: TextStyle( fontSize: 14, fontWeight: FontWeight.w400, - color: Theme.of(context) - .extension()! - .titleColor, + color: Theme.of(context).extension()!.titleColor, ), ), ), @@ -247,11 +261,27 @@ class ConnectDevicePageBodyState extends State { ), ) .toList(), - ] + ], + if (widget.allowChangeWallet) ...[ + PrimaryButton( + text: S.of(context).wallets, + color: Theme.of(context).extension()!.createNewWalletButtonBackgroundColor, + textColor: Theme.of(context).extension()!.restoreWalletButtonTextColor, + onPressed: _onChangeWallet, + ) + ], ], ), ), ), ); } + + void _onChangeWallet() { + Navigator.of(context).pushNamed( + Routes.walletList, + arguments: (BuildContext context) => Navigator.of(context) + .pushNamedAndRemoveUntil(Routes.dashboard, (route) => false), + ); + } } diff --git a/lib/src/screens/connect_device/monero_hardware_wallet_options_page.dart b/lib/src/screens/connect_device/monero_hardware_wallet_options_page.dart new file mode 100644 index 000000000..f8ace97bc --- /dev/null +++ b/lib/src/screens/connect_device/monero_hardware_wallet_options_page.dart @@ -0,0 +1,230 @@ +import 'package:cake_wallet/core/wallet_name_validator.dart'; +import 'package:cake_wallet/entities/generate_name.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_one_action.dart'; +import 'package:cake_wallet/src/widgets/blockchain_height_widget.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; +import 'package:cake_wallet/src/widgets/scollable_with_bottom_section.dart'; +import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; +import 'package:cake_wallet/themes/extensions/new_wallet_theme.dart'; +import 'package:cake_wallet/themes/extensions/send_page_theme.dart'; +import 'package:cake_wallet/utils/responsive_layout_util.dart'; +import 'package:cake_wallet/utils/show_pop_up.dart'; +import 'package:cake_wallet/view_model/wallet_hardware_restore_view_model.dart'; +import 'package:cw_core/wallet_type.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_mobx/flutter_mobx.dart'; +import 'package:mobx/mobx.dart'; + +class MoneroHardwareWalletOptionsPage extends BasePage { + MoneroHardwareWalletOptionsPage(this._walletHardwareRestoreVM); + + final WalletHardwareRestoreViewModel _walletHardwareRestoreVM; + + @override + String get title => S.current.restore_title_from_hardware_wallet; + + @override + Widget body(BuildContext context) => + _MoneroHardwareWalletOptionsForm(_walletHardwareRestoreVM); +} + +class _MoneroHardwareWalletOptionsForm extends StatefulWidget { + const _MoneroHardwareWalletOptionsForm(this._walletHardwareRestoreVM); + + final WalletHardwareRestoreViewModel _walletHardwareRestoreVM; + + @override + _MoneroHardwareWalletOptionsFormState createState() => + _MoneroHardwareWalletOptionsFormState(_walletHardwareRestoreVM); +} + +class _MoneroHardwareWalletOptionsFormState + extends State<_MoneroHardwareWalletOptionsForm> { + _MoneroHardwareWalletOptionsFormState(this._walletHardwareRestoreVM) + : _formKey = GlobalKey(), + _blockchainHeightKey = GlobalKey(), + _blockHeightFocusNode = FocusNode(), + _controller = TextEditingController(); + + final GlobalKey _formKey; + final GlobalKey _blockchainHeightKey; + final FocusNode _blockHeightFocusNode; + final WalletHardwareRestoreViewModel _walletHardwareRestoreVM; + final TextEditingController _controller; + + @override + void initState() { + super.initState(); + _setEffects(context); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only(top: 24), + child: ScrollableWithBottomSection( + contentPadding: EdgeInsets.only(left: 24, right: 24, bottom: 24), + content: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: ResponsiveLayoutUtilBase.kDesktopMaxWidthConstraint), + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Padding( + padding: EdgeInsets.only(top: 0), + child: Form( + key: _formKey, + child: Stack( + alignment: Alignment.centerRight, + children: [ + TextFormField( + onChanged: (value) => + _walletHardwareRestoreVM.name = value, + controller: _controller, + style: TextStyle( + fontSize: 20.0, + fontWeight: FontWeight.w600, + color: Theme.of(context) + .extension()! + .titleColor, + ), + decoration: InputDecoration( + hintStyle: TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.w500, + color: Theme.of(context) + .extension()! + .hintTextColor, + ), + hintText: S.of(context).wallet_name, + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context) + .extension()! + .underlineColor, + width: 1.0, + ), + ), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context) + .extension()! + .underlineColor, + width: 1.0, + ), + ), + suffixIcon: Semantics( + label: S.of(context).generate_name, + child: IconButton( + onPressed: _onGenerateName, + icon: Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(6.0), + color: Theme.of(context).hintColor, + ), + width: 34, + height: 34, + child: Image.asset( + 'assets/images/refresh_icon.png', + color: Theme.of(context) + .extension()! + .textFieldButtonIconColor, + ), + ), + ), + ), + ), + validator: WalletNameValidator(), + ), + ], + ), + ), + ), + Padding( + padding: EdgeInsets.only(top: 20), + child: BlockchainHeightWidget( + focusNode: _blockHeightFocusNode, + key: _blockchainHeightKey, + hasDatePicker: true, + walletType: WalletType.monero, + ), + ), + ], + ), + ), + ), + bottomSectionPadding: EdgeInsets.all(24), + bottomSection: Observer( + builder: (context) => LoadingPrimaryButton( + onPressed: _confirmForm, + text: S.of(context).seed_language_next, + color: Colors.green, + textColor: Colors.white, + isDisabled: _walletHardwareRestoreVM.name.isEmpty, + ), + ), + ), + ); + } + + Future _onGenerateName() async { + final rName = await generateName(); + FocusManager.instance.primaryFocus?.unfocus(); + + setState(() { + _controller.text = rName; + _walletHardwareRestoreVM.name = rName; + _controller.selection = TextSelection.fromPosition( + TextPosition(offset: _controller.text.length)); + }); + } + + Future _confirmForm() async { + showPopUp( + context: context, + builder: (BuildContext context) => AlertWithOneAction( + alertTitle: S.of(context).proceed_on_device, + alertContent: S.of(context).proceed_on_device_description, + buttonText: S.of(context).cancel, + buttonAction: () => Navigator.of(context).pop(), + ), + ); + + final options = {'height': _blockchainHeightKey.currentState?.height ?? -1}; + await _walletHardwareRestoreVM.create(options: options); + } + + bool _effectsInstalled = false; + + void _setEffects(BuildContext context) { + if (_effectsInstalled) return; + + reaction((_) => _walletHardwareRestoreVM.error, (String? error) { + if (error != null) { + if (error == S.current.ledger_connection_error) + Navigator.of(context).pop(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + showPopUp( + context: context, + builder: (BuildContext context) => AlertWithOneAction( + alertTitle: S.of(context).error, + alertContent: error, + buttonText: S.of(context).ok, + buttonAction: () { + _walletHardwareRestoreVM.error = null; + Navigator.of(context).pop(); + }, + ), + ); + }); + } + }); + + _effectsInstalled = true; + } +} diff --git a/lib/src/screens/wallet_list/wallet_list_page.dart b/lib/src/screens/wallet_list/wallet_list_page.dart index a9a9d9413..46eaa6143 100644 --- a/lib/src/screens/wallet_list/wallet_list_page.dart +++ b/lib/src/screens/wallet_list/wallet_list_page.dart @@ -1,44 +1,56 @@ +import 'package:another_flushbar/flushbar.dart'; +import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/core/new_wallet_arguments.dart'; import 'package:cake_wallet/entities/wallet_edit_page_arguments.dart'; import 'package:cake_wallet/entities/wallet_list_order_types.dart'; +import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/monero/monero.dart'; +import 'package:cake_wallet/routes.dart'; +import 'package:cake_wallet/src/screens/auth/auth_page.dart'; +import 'package:cake_wallet/src/screens/base_page.dart'; +import 'package:cake_wallet/src/screens/connect_device/connect_device_page.dart'; import 'package:cake_wallet/src/screens/dashboard/widgets/filter_list_widget.dart'; import 'package:cake_wallet/src/screens/new_wallet/widgets/grouped_wallet_expansion_tile.dart'; import 'package:cake_wallet/src/screens/wallet_list/edit_wallet_button_widget.dart'; import 'package:cake_wallet/src/screens/wallet_list/filtered_list.dart'; import 'package:cake_wallet/src/screens/wallet_unlock/wallet_unlock_arguments.dart'; +import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; +import 'package:cake_wallet/src/widgets/primary_button.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/themes/extensions/cake_text_theme.dart'; -import 'package:cake_wallet/src/screens/auth/auth_page.dart'; -import 'package:cake_wallet/core/auth_service.dart'; import 'package:cake_wallet/themes/extensions/filter_theme.dart'; import 'package:cake_wallet/themes/extensions/wallet_list_theme.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/utils/show_bar.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/wallet_list/wallet_list_item.dart'; -import 'package:another_flushbar/flushbar.dart'; +import 'package:cake_wallet/view_model/wallet_list/wallet_list_view_model.dart'; +import 'package:cake_wallet/wallet_type_utils.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:cake_wallet/view_model/wallet_list/wallet_list_view_model.dart'; -import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:cake_wallet/src/screens/base_page.dart'; -import 'package:cake_wallet/wallet_type_utils.dart'; class WalletListPage extends BasePage { - WalletListPage({required this.walletListViewModel, required this.authService}); + WalletListPage({ + required this.walletListViewModel, + required this.authService, + this.onWalletLoaded, + }); final WalletListViewModel walletListViewModel; final AuthService authService; + final Function(BuildContext)? onWalletLoaded; @override String get title => S.current.wallets; @override - Widget body(BuildContext context) => - WalletListBody(walletListViewModel: walletListViewModel, authService: authService); + Widget body(BuildContext context) => WalletListBody( + walletListViewModel: walletListViewModel, + authService: authService, + onWalletLoaded: + onWalletLoaded ?? (context) => Navigator.of(context).pop(), + ); @override Widget trailing(BuildContext context) { @@ -89,10 +101,15 @@ class WalletListPage extends BasePage { } class WalletListBody extends StatefulWidget { - WalletListBody({required this.walletListViewModel, required this.authService}); + WalletListBody({ + required this.walletListViewModel, + required this.authService, + required this.onWalletLoaded, + }); final WalletListViewModel walletListViewModel; final AuthService authService; + final Function(BuildContext) onWalletLoaded; @override WalletListBodyState createState() => WalletListBodyState(); @@ -118,8 +135,8 @@ class WalletListBodyState extends State { @override Widget build(BuildContext context) { - final newWalletImage = - Image.asset('assets/images/new_wallet.png', height: 12, width: 12, color: Colors.white); + final newWalletImage = Image.asset('assets/images/new_wallet.png', + height: 12, width: 12, color: Colors.white); final restoreWalletImage = Image.asset('assets/images/restore_wallet.png', height: 12, width: 12, @@ -180,8 +197,7 @@ class WalletListBodyState extends State { trailingWidget: EditWalletButtonWidget( width: 74, isGroup: true, - isExpanded: - widget.walletListViewModel.expansionTileStateTrack[index]!, + isExpanded: widget.walletListViewModel.expansionTileStateTrack[index]!, onTap: () { final wallet = widget.walletListViewModel .convertWalletInfoToWalletListItem(group.wallets.first); @@ -198,8 +214,7 @@ class WalletListBodyState extends State { }, ), childWallets: group.wallets.map((walletInfo) { - return widget.walletListViewModel - .convertWalletInfoToWalletListItem(walletInfo); + return widget.walletListViewModel.convertWalletInfoToWalletListItem(walletInfo); }).toList(), isSelected: false, onChildItemTapped: (wallet) => @@ -329,8 +344,7 @@ class WalletListBodyState extends State { arguments: NewWalletArguments( type: widget.walletListViewModel.currentWalletType, ), - conditionToDetermineIfToUse2FA: - widget.walletListViewModel.shouldRequireTOTP2FAForCreatingNewWallets, + conditionToDetermineIfToUse2FA: widget.walletListViewModel.shouldRequireTOTP2FAForCreatingNewWallets, ); } else { Navigator.of(context).pushNamed( @@ -345,8 +359,7 @@ class WalletListBodyState extends State { widget.authService.authenticateAction( context, route: Routes.newWalletType, - conditionToDetermineIfToUse2FA: - widget.walletListViewModel.shouldRequireTOTP2FAForCreatingNewWallets, + conditionToDetermineIfToUse2FA: widget.walletListViewModel.shouldRequireTOTP2FAForCreatingNewWallets, ); } else { Navigator.of(context).pushNamed(Routes.newWalletType); @@ -367,8 +380,7 @@ class WalletListBodyState extends State { context, route: Routes.restoreOptions, arguments: false, - conditionToDetermineIfToUse2FA: - widget.walletListViewModel.shouldRequireTOTP2FAForCreatingNewWallets, + conditionToDetermineIfToUse2FA: widget.walletListViewModel.shouldRequireTOTP2FAForCreatingNewWallets, ); } else { Navigator.of(context).pushNamed(Routes.restoreOptions, arguments: false); @@ -387,39 +399,6 @@ class WalletListBodyState extends State { ); } - Image _imageFor({required WalletType type, bool? isTestnet}) { - switch (type) { - case WalletType.bitcoin: - if (isTestnet == true) { - return tBitcoinIcon; - } - return bitcoinIcon; - case WalletType.monero: - return moneroIcon; - case WalletType.litecoin: - return litecoinIcon; - case WalletType.haven: - return havenIcon; - case WalletType.ethereum: - return ethereumIcon; - case WalletType.bitcoinCash: - return bitcoinCashIcon; - case WalletType.nano: - case WalletType.banano: - return nanoIcon; - case WalletType.polygon: - return polygonIcon; - case WalletType.solana: - return solanaIcon; - case WalletType.tron: - return tronIcon; - case WalletType.wownero: - return wowneroIcon; - case WalletType.none: - return nonWalletTypeIcon; - } - } - Future _loadWallet(WalletListItem wallet) async { if (SettingsStoreBase.walletPasswordDirectInput) { Navigator.of(context).pushNamed(Routes.walletUnlockLoadable, @@ -438,12 +417,36 @@ class WalletListBodyState extends State { await widget.authService.authenticateAction( context, onAuthSuccess: (isAuthenticatedSuccessfully) async { - if (!isAuthenticatedSuccessfully) { - return; - } + if (!isAuthenticatedSuccessfully) return; try { - changeProcessText(S.of(context).wallet_list_loading_wallet(wallet.name)); + if (widget.walletListViewModel + .requireHardwareWalletConnection(wallet)) { + await Navigator.of(context).pushNamed( + Routes.connectDevices, + arguments: ConnectDevicePageParams( + walletType: WalletType.monero, + onConnectDevice: (context, ledgerVM) async { + monero!.setGlobalLedgerConnection(ledgerVM.connection); + Navigator.of(context).pop(); + }, + ), + ); + + showPopUp( + context: context, + builder: (BuildContext context) => AlertWithOneAction( + alertTitle: S.of(context).proceed_on_device, + alertContent: S.of(context).proceed_on_device_description, + buttonText: S.of(context).cancel, + buttonAction: () => Navigator.of(context).pop()), + ); + } + + + + changeProcessText( + S.of(context).wallet_list_loading_wallet(wallet.name)); await widget.walletListViewModel.loadWallet(wallet); await hideProgressText(); // only pop the wallets route in mobile as it will go back to dashboard page @@ -451,13 +454,15 @@ class WalletListBodyState extends State { if (responsiveLayoutUtil.shouldRenderMobileUI) { WidgetsBinding.instance.addPostFrameCallback((_) { if (this.mounted) { - Navigator.of(context).pop(); + widget.onWalletLoaded.call(context); } }); } } catch (e) { if (this.mounted) { - changeProcessText(S.of(context).wallet_list_failed_to_load(wallet.name, e.toString())); + changeProcessText(S + .of(context) + .wallet_list_failed_to_load(wallet.name, e.toString())); } } }, diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index a78012ae9..808657f66 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -428,7 +428,7 @@ abstract class DashboardViewModelBase with Store { // to not cause work duplication, this will do the job as well, it will be slightly less precise // about what happened - but still enough. // if (keys['privateSpendKey'] == List.generate(64, (index) => "0").join("")) "Private spend key is 0", - if (keys['privateViewKey'] == List.generate(64, (index) => "0").join("")) + if (keys['privateViewKey'] == List.generate(64, (index) => "0").join("") && !wallet.isHardwareWallet) "private view key is 0", // if (keys['publicSpendKey'] == List.generate(64, (index) => "0").join("")) "public spend key is 0", if (keys['publicViewKey'] == List.generate(64, (index) => "0").join("")) diff --git a/lib/view_model/hardware_wallet/ledger_view_model.dart b/lib/view_model/hardware_wallet/ledger_view_model.dart index 19b190fe3..96f5930c0 100644 --- a/lib/view_model/hardware_wallet/ledger_view_model.dart +++ b/lib/view_model/hardware_wallet/ledger_view_model.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/polygon/polygon.dart'; import 'package:cake_wallet/utils/device_info.dart'; import 'package:cake_wallet/wallet_type_utils.dart'; @@ -114,6 +115,8 @@ abstract class LedgerViewModelBase with Store { void setLedger(WalletBase wallet) { switch (wallet.type) { + case WalletType.monero: + return monero!.setLedgerConnection(wallet, connection); case WalletType.bitcoin: case WalletType.litecoin: return bitcoin!.setLedgerConnection(wallet, connection); diff --git a/lib/view_model/wallet_hardware_restore_view_model.dart b/lib/view_model/wallet_hardware_restore_view_model.dart index 91e0de685..0971622a5 100644 --- a/lib/view_model/wallet_hardware_restore_view_model.dart +++ b/lib/view_model/wallet_hardware_restore_view_model.dart @@ -1,7 +1,9 @@ import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/core/generate_wallet_password.dart'; import 'package:cake_wallet/core/wallet_creation_service.dart'; import 'package:cake_wallet/ethereum/ethereum.dart'; import 'package:cake_wallet/generated/i18n.dart'; +import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/polygon/polygon.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart'; @@ -56,8 +58,8 @@ abstract class WalletHardwareRestoreViewModelBase extends WalletCreationVM with List accounts; switch (type) { case WalletType.bitcoin: - accounts = await bitcoin! - .getHardwareWalletBitcoinAccounts(ledgerViewModel, index: _nextIndex, limit: limit); + accounts = await bitcoin! + .getHardwareWalletBitcoinAccounts(ledgerViewModel, index: _nextIndex, limit: limit); break; case WalletType.litecoin: accounts = await bitcoin! @@ -104,6 +106,15 @@ abstract class WalletHardwareRestoreViewModelBase extends WalletCreationVM with case WalletType.polygon: credentials = polygon!.createPolygonHardwareWalletCredentials(name: name, hwAccountData: selectedAccount!); break; + case WalletType.monero: + final password = walletPassword ?? generateWalletPassword(); + + credentials = monero!.createMoneroRestoreWalletFromHardwareCredentials( + name: name, + ledgerConnection: ledgerViewModel.connection, + password: password, + height: _options['height'] as int? ?? 0, + ); default: throw Exception('Unexpected type: ${type.toString()}'); } 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 725af843f..c903b535f 100644 --- a/lib/view_model/wallet_list/wallet_list_view_model.dart +++ b/lib/view_model/wallet_list/wallet_list_view_model.dart @@ -68,6 +68,10 @@ abstract class WalletListViewModelBase with Store { WalletType get currentWalletType => _appStore.wallet!.type; + bool requireHardwareWalletConnection(WalletListItem walletItem) => + _walletLoadingService.requireHardwareWalletConnection( + walletItem.type, walletItem.name); + @action Future loadWallet(WalletListItem walletItem) async { // bool switchingToSameWalletType = walletItem.type == _appStore.wallet?.type; @@ -87,7 +91,8 @@ abstract class WalletListViewModelBase with Store { singleWalletsList.clear(); wallets.addAll( - _walletInfoSource.values.map((info) => convertWalletInfoToWalletListItem(info)), + _walletInfoSource.values + .map((info) => convertWalletInfoToWalletListItem(info)), ); //========== Split into shared seed groups and single wallets list @@ -95,7 +100,8 @@ abstract class WalletListViewModelBase with Store { for (var group in _walletManager.walletGroups) { if (group.wallets.length == 1) { - singleWalletsList.add(convertWalletInfoToWalletListItem(group.wallets.first)); + singleWalletsList + .add(convertWalletInfoToWalletListItem(group.wallets.first)); } else { multiWalletGroups.add(group); } @@ -148,9 +154,11 @@ abstract class WalletListViewModelBase with Store { List walletInfoSourceCopy = _walletInfoSource.values.toList(); await _walletInfoSource.clear(); if (ascending) { - walletInfoSourceCopy.sort((a, b) => a.type.toString().compareTo(b.type.toString())); + walletInfoSourceCopy + .sort((a, b) => a.type.toString().compareTo(b.type.toString())); } else { - walletInfoSourceCopy.sort((a, b) => b.type.toString().compareTo(a.type.toString())); + walletInfoSourceCopy + .sort((a, b) => b.type.toString().compareTo(a.type.toString())); } await _walletInfoSource.addAll(walletInfoSourceCopy); updateList(); @@ -213,7 +221,8 @@ abstract class WalletListViewModelBase with Store { name: info.name, type: info.type, key: info.key, - isCurrent: info.name == _appStore.wallet?.name && info.type == _appStore.wallet?.type, + isCurrent: info.name == _appStore.wallet?.name && + info.type == _appStore.wallet?.type, isEnabled: availableWalletTypes.contains(info.type), isTestnet: info.network?.toLowerCase().contains('testnet') ?? false, ); diff --git a/scripts/prepare_moneroc.sh b/scripts/prepare_moneroc.sh index 24f4d201c..0d90e431a 100755 --- a/scripts/prepare_moneroc.sh +++ b/scripts/prepare_moneroc.sh @@ -6,9 +6,9 @@ cd "$(dirname "$0")" if [[ ! -d "monero_c" ]]; then - git clone https://github.com/mrcyjanek/monero_c --branch rewrite-wip + git clone https://github.com/mrcyjanek/monero_c --branch master cd monero_c - git checkout 6eb571ea498ed7b854934785f00fabfd0dadf75b + git checkout 1d8e0fb30b53c28756f23676d5a3e1a99a9b3051 git reset --hard git submodule update --init --force --recursive ./apply_patches.sh monero diff --git a/tool/configure.dart b/tool/configure.dart index c5d4a6863..fb8291385 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -270,20 +270,22 @@ import 'package:cw_core/output_info.dart'; import 'package:cake_wallet/view_model/send/output.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:hive/hive.dart'; +import 'package:ledger_flutter_plus/ledger_flutter_plus.dart' as ledger; import 'package:polyseed/polyseed.dart';"""; const moneroCWHeaders = """ +import 'package:cw_core/account.dart' as monero_account; import 'package:cw_core/get_height_by_date.dart'; import 'package:cw_core/monero_amount_format.dart'; import 'package:cw_core/monero_transaction_priority.dart'; +import 'package:cw_monero/api/wallet_manager.dart'; +import 'package:cw_monero/api/wallet.dart' as monero_wallet_api; +import 'package:cw_monero/ledger.dart'; import 'package:cw_monero/monero_unspent.dart'; import 'package:cw_monero/api/account_list.dart'; import 'package:cw_monero/monero_wallet_service.dart'; -import 'package:cw_monero/api/wallet_manager.dart'; import 'package:cw_monero/monero_wallet.dart'; import 'package:cw_monero/monero_transaction_info.dart'; import 'package:cw_monero/monero_transaction_creation_credentials.dart'; -import 'package:cw_core/account.dart' as monero_account; -import 'package:cw_monero/api/wallet.dart' as monero_wallet_api; import 'package:cw_monero/mnemonics/english.dart'; import 'package:cw_monero/mnemonics/chinese_simplified.dart'; import 'package:cw_monero/mnemonics/dutch.dart'; @@ -395,6 +397,7 @@ abstract class Monero { required String language, required int height}); WalletCredentials createMoneroRestoreWalletFromSeedCredentials({required String name, required String password, required int height, required String mnemonic}); + WalletCredentials createMoneroRestoreWalletFromHardwareCredentials({required String name, required String password, required int height, required ledger.LedgerConnection ledgerConnection}); WalletCredentials createMoneroNewWalletCredentials({required String name, required String language, required bool isPolyseed, String? password}); Map getKeys(Object wallet); int? getRestoreHeight(Object wallet); @@ -411,6 +414,8 @@ abstract class Monero { int getTransactionInfoAccountId(TransactionInfo tx); WalletService createMoneroWalletService(Box walletInfoSource, Box unspentCoinSource); Map pendingTransactionInfo(Object transaction); + void setLedgerConnection(Object wallet, ledger.LedgerConnection connection); + void setGlobalLedgerConnection(ledger.LedgerConnection connection); } abstract class MoneroSubaddressList { From 96db38c0aa3b2628118302e137e7b1969b5367d0 Mon Sep 17 00:00:00 2001 From: Serhii Date: Tue, 12 Nov 2024 05:29:32 +0200 Subject: [PATCH 27/34] Cw 744 improve address book (#1771) * 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 * fix the address book filter by the selected currency * add dropdown for wallets with multiple address types * minor fixes * add dropdown for wallets with multiple address types * Update lib/entities/contact.dart [skip ci] * Update lib/src/screens/contact/contact_list_page.dart [skip ci] * Update lib/src/screens/contact/contact_list_page.dart [skip ci] --------- Co-authored-by: Omar Hatem --- .../screens/contact/contact_list_page.dart | 62 ++++++++++++++++--- .../contact_list/contact_list_view_model.dart | 35 +++++++---- 2 files changed, 75 insertions(+), 22 deletions(-) diff --git a/lib/src/screens/contact/contact_list_page.dart b/lib/src/screens/contact/contact_list_page.dart index 130380b09..f71747953 100644 --- a/lib/src/screens/contact/contact_list_page.dart +++ b/lib/src/screens/contact/contact_list_page.dart @@ -11,6 +11,7 @@ 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/themes/extensions/filter_theme.dart'; import 'package:cake_wallet/utils/show_bar.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.dart'; @@ -160,25 +161,60 @@ class _ContactPageBodyState extends State with SingleTickerProv Widget _buildWalletContacts(BuildContext context) { final walletContacts = widget.contactListViewModel.walletContactsToShow; + final groupedContacts = >{}; + for (var contact in walletContacts) { + final baseName = _extractBaseName(contact.name); + groupedContacts.putIfAbsent(baseName, () => []).add(contact); + } + return ListView.builder( - shrinkWrap: true, - itemCount: walletContacts.length * 2, + itemCount: groupedContacts.length * 2, itemBuilder: (context, index) { if (index.isOdd) { return StandardListSeparator(); } else { - final walletInfo = walletContacts[index ~/ 2]; - return generateRaw(context, walletInfo); + final groupIndex = index ~/ 2; + final groupName = groupedContacts.keys.elementAt(groupIndex); + final groupContacts = groupedContacts[groupName]!; + + if (groupContacts.length == 1) { + final contact = groupContacts[0]; + return generateRaw(context, contact); + } else { + final activeContact = groupContacts.firstWhere( + (contact) => contact.name.contains('Active'), + orElse: () => groupContacts[0], + ); + + return ExpansionTile( + title: Text( + groupName, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + color: Theme.of(context).extension()!.titleColor, + ), + ), + leading: _buildCurrencyIcon(activeContact), + tilePadding: EdgeInsets.zero, + childrenPadding: const EdgeInsets.only(left: 16), + expandedCrossAxisAlignment: CrossAxisAlignment.start, + expandedAlignment: Alignment.topLeft, + children: groupContacts.map((contact) => generateRaw(context, contact)).toList(), + ); + } } }, ); } + String _extractBaseName(String name) { + final bracketIndex = name.indexOf('('); + return (bracketIndex != -1) ? name.substring(0, bracketIndex).trim() : name; + } + Widget generateRaw(BuildContext context, ContactBase contact) { - final image = contact.type.iconPath; - final currencyIcon = image != null - ? Image.asset(image, height: 24, width: 24) - : const SizedBox(height: 24, width: 24); + final currencyIcon = _buildCurrencyIcon(contact); return GestureDetector( onTap: () async { @@ -219,6 +255,13 @@ class _ContactPageBodyState extends State with SingleTickerProv ); } + Widget _buildCurrencyIcon(ContactBase contact) { + final image = contact.type.iconPath; + return image != null + ? Image.asset(image, height: 24, width: 24) + : const SizedBox(height: 24, width: 24); + } + Future showNameAndAddressDialog(BuildContext context, String name, String address) async { return await showPopUp( context: context, @@ -263,12 +306,13 @@ class _ContactListBodyState extends State { @override void dispose() { widget.tabController.removeListener(_handleTabChange); + widget.contactListViewModel.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final contacts = widget.contactListViewModel.contacts; + final contacts = widget.contactListViewModel.contactsToShow; return Scaffold( body: Container( child: FilteredList( 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 0015463a5..25f222891 100644 --- a/lib/view_model/contact_list/contact_list_view_model.dart +++ b/lib/view_model/contact_list/contact_list_view_model.dart @@ -28,7 +28,8 @@ abstract class ContactListViewModelBase with Store { isAutoGenerateEnabled = settingsStore.autoGenerateSubaddressStatus == AutoGenerateSubaddressStatus.enabled { walletInfoSource.values.forEach((info) { - if ([WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) && info.addressInfos != null) { + if ([WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) && + info.addressInfos != null) { for (var key in info.addressInfos!.keys) { final value = info.addressInfos![key]; final address = value?.first; @@ -60,15 +61,19 @@ abstract class ContactListViewModelBase with Store { address, name, walletTypeToCryptoCurrency(info.type, - isTestnet: - info.network == null ? false : info.network!.toLowerCase().contains("testnet")), + isTestnet: info.network == null + ? false + : info.network!.toLowerCase().contains("testnet")), )); }); } } else { walletContacts.add(WalletContact( info.address, - _createName(info.name, "", key: [WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) ? 0 : null), + _createName(info.name, "", + key: [WalletType.monero, WalletType.wownero, WalletType.haven].contains(info.type) + ? 0 + : null), walletTypeToCryptoCurrency(info.type), )); } @@ -82,8 +87,11 @@ abstract class ContactListViewModelBase with Store { } String _createName(String walletName, String label, {int? key = null}) { - final actualLabel = label.replaceAll(RegExp(r'active', caseSensitive: false), S.current.active).replaceAll(RegExp(r'silent payments', caseSensitive: false), S.current.silent_payments); - return '$walletName${key == null ? "" : " [#${key}]"} ${actualLabel.isNotEmpty ? "($actualLabel)" : ""}'.trim(); + final actualLabel = label + .replaceAll(RegExp(r'active', caseSensitive: false), S.current.active) + .replaceAll(RegExp(r'silent payments', caseSensitive: false), S.current.silent_payments); + return '$walletName${key == null ? "" : " [#${key}]"} ${actualLabel.isNotEmpty ? "($actualLabel)" : ""}' + .trim(); } final bool isAutoGenerateEnabled; @@ -108,18 +116,19 @@ abstract class ContactListViewModelBase with Store { Future delete(ContactRecord contact) async => contact.original.delete(); ObservableList get contactsToShow => - ObservableList.of(contacts.where((element) => _isValidForCurrency(element))); + ObservableList.of(contacts.where((element) => _isValidForCurrency(element, false))); @computed List get walletContactsToShow => - walletContacts.where((element) => _isValidForCurrency(element)).toList(); + walletContacts.where((element) => _isValidForCurrency(element, true)).toList(); - bool _isValidForCurrency(ContactBase element) { - if (element.name.contains('Silent Payments')) return false; - if (element.name.contains('MWEB')) return false; + bool _isValidForCurrency(ContactBase element, bool isWalletContact) { + if (_currency == null) return true; + if (!element.name.contains('Active') && + isWalletContact && + (element.type == CryptoCurrency.btc || element.type == CryptoCurrency.ltc)) return false; - return _currency == null || - element.type == _currency || + return element.type == _currency || (element.type.tag != null && _currency?.tag != null && element.type.tag == _currency?.tag) || From c4698576a3fc8c2d1da51ebdb82267e456b9d014 Mon Sep 17 00:00:00 2001 From: Omar Hatem Date: Wed, 13 Nov 2024 01:19:50 +0200 Subject: [PATCH 28/34] add xmr to unstoppable domain (#1800) --- lib/entities/parse_address_from_domain.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/entities/parse_address_from_domain.dart b/lib/entities/parse_address_from_domain.dart index 481db5620..11ba4724c 100644 --- a/lib/entities/parse_address_from_domain.dart +++ b/lib/entities/parse_address_from_domain.dart @@ -41,7 +41,8 @@ class AddressResolver { 'kresus', 'anime', 'manga', - 'binanceus' + 'binanceus', + 'xmr', ]; static String? extractAddressByType({required String raw, required CryptoCurrency type}) { From 0adb69d095a80526c02101877b2141ac20e133ab Mon Sep 17 00:00:00 2001 From: Konstantin Ullrich Date: Wed, 13 Nov 2024 10:44:43 +0100 Subject: [PATCH 29/34] Better Ledger Connection handling (#1802) --- cw_monero/lib/ledger.dart | 6 +----- cw_monero/lib/monero_wallet_service.dart | 5 +++++ lib/monero/cw_monero.dart | 4 ++++ lib/view_model/hardware_wallet/ledger_view_model.dart | 4 +++- tool/configure.dart | 1 + 5 files changed, 14 insertions(+), 6 deletions(-) diff --git a/cw_monero/lib/ledger.dart b/cw_monero/lib/ledger.dart index 074975df3..c947d0944 100644 --- a/cw_monero/lib/ledger.dart +++ b/cw_monero/lib/ledger.dart @@ -47,10 +47,6 @@ void enableLedgerExchange(monero.wallet ptr, LedgerConnection connection) { void keepAlive(LedgerConnection connection) { if (connection.connectionType == ConnectionType.ble) { - UniversalBle.onConnectionChange = (String deviceId, bool isConnected) { - print("[Monero] Ledger Disconnected"); - _ledgerKeepAlive?.cancel(); - }; _ledgerKeepAlive = Timer.periodic(Duration(seconds: 10), (_) async { try { UniversalBle.setNotifiable( @@ -59,7 +55,7 @@ void keepAlive(LedgerConnection connection) { connection.device.deviceInfo.notifyCharacteristicKey, BleInputProperty.notification, ); - } catch (_){} + } catch (_) {} }); } } diff --git a/cw_monero/lib/monero_wallet_service.dart b/cw_monero/lib/monero_wallet_service.dart index b2089dfc7..6f49640be 100644 --- a/cw_monero/lib/monero_wallet_service.dart +++ b/cw_monero/lib/monero_wallet_service.dart @@ -146,6 +146,11 @@ class MoneroWalletService extends WalletService< password: password); final isValid = wallet.walletAddresses.validate(); + if (wallet.isHardwareWallet) { + wallet.setLedgerConnection(gLedger!); + gLedger = null; + } + if (!isValid) { await restoreOrResetWalletFiles(name); wallet.close(shouldCleanup: false); diff --git a/lib/monero/cw_monero.dart b/lib/monero/cw_monero.dart index 09d9889c5..89b1579fc 100644 --- a/lib/monero/cw_monero.dart +++ b/lib/monero/cw_monero.dart @@ -402,6 +402,10 @@ class CWMonero extends Monero { moneroWallet.setLedgerConnection(connection); } + void resetLedgerConnection() { + disableLedgerExchange(); + } + @override void setGlobalLedgerConnection(ledger.LedgerConnection connection) { gLedger = connection; diff --git a/lib/view_model/hardware_wallet/ledger_view_model.dart b/lib/view_model/hardware_wallet/ledger_view_model.dart index 96f5930c0..3cd131efa 100644 --- a/lib/view_model/hardware_wallet/ledger_view_model.dart +++ b/lib/view_model/hardware_wallet/ledger_view_model.dart @@ -98,7 +98,9 @@ abstract class LedgerViewModelBase with Store { print('Ledger Device State Changed: $event'); if (event == sdk.BleConnectionState.disconnected) { _connection = null; - _connectionChangeListener?.cancel(); + if (type == WalletType.monero) { + monero!.resetLedgerConnection(); + } } }); } diff --git a/tool/configure.dart b/tool/configure.dart index fb8291385..f0f79cfb1 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -415,6 +415,7 @@ abstract class Monero { WalletService createMoneroWalletService(Box walletInfoSource, Box unspentCoinSource); Map pendingTransactionInfo(Object transaction); void setLedgerConnection(Object wallet, ledger.LedgerConnection connection); + void resetLedgerConnection(); void setGlobalLedgerConnection(ledger.LedgerConnection connection); } From ed5e701d801cd88d4905a06a6f9e2b3aa6dca510 Mon Sep 17 00:00:00 2001 From: Omar Hatem Date: Wed, 13 Nov 2024 11:55:46 +0200 Subject: [PATCH 30/34] Potential fix for the race condition of loading a wallet while the name in the shared prefs hasn't updated yet (#1801) --- lib/reactions/on_current_wallet_change.dart | 7 ------- lib/store/app_store.dart | 7 +++++++ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lib/reactions/on_current_wallet_change.dart b/lib/reactions/on_current_wallet_change.dart index 6630f1dfc..d7e6f90c0 100644 --- a/lib/reactions/on_current_wallet_change.dart +++ b/lib/reactions/on_current_wallet_change.dart @@ -10,9 +10,6 @@ import 'package:cw_core/transaction_history.dart'; import 'package:cw_core/balance.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:mobx/mobx.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:cake_wallet/di.dart'; -import 'package:cake_wallet/entities/preferences_key.dart'; import 'package:cake_wallet/reactions/check_connection.dart'; import 'package:cake_wallet/reactions/on_wallet_sync_status_change.dart'; import 'package:cake_wallet/store/dashboard/fiat_conversion_store.dart'; @@ -65,10 +62,6 @@ void startCurrentWalletChangeReaction( startWalletSyncStatusChangeReaction(wallet, fiatConversionStore); startCheckConnectionReaction(wallet, settingsStore); - await getIt.get().setString(PreferencesKey.currentWalletName, wallet.name); - await getIt - .get() - .setInt(PreferencesKey.currentWalletType, serializeToInt(wallet.type)); if (wallet.type == WalletType.monero || wallet.type == WalletType.wownero || diff --git a/lib/store/app_store.dart b/lib/store/app_store.dart index cd8881633..24d5dc6a4 100644 --- a/lib/store/app_store.dart +++ b/lib/store/app_store.dart @@ -1,8 +1,10 @@ import 'package:cake_wallet/core/wallet_connect/web3wallet_service.dart'; import 'package:cake_wallet/di.dart'; +import 'package:cake_wallet/entities/preferences_key.dart'; import 'package:cake_wallet/reactions/wallet_connect.dart'; import 'package:cake_wallet/utils/exception_handler.dart'; import 'package:cw_core/transaction_info.dart'; +import 'package:cw_core/wallet_type.dart'; import 'package:mobx/mobx.dart'; import 'package:cw_core/balance.dart'; import 'package:cw_core/wallet_base.dart'; @@ -11,6 +13,7 @@ import 'package:cake_wallet/store/wallet_list_store.dart'; import 'package:cake_wallet/store/authentication_store.dart'; import 'package:cake_wallet/store/settings_store.dart'; import 'package:cake_wallet/store/node_list_store.dart'; +import 'package:shared_preferences/shared_preferences.dart'; part 'app_store.g.dart'; @@ -47,5 +50,9 @@ abstract class AppStoreBase with Store { getIt.get().create(); await getIt.get().init(); } + await getIt.get().setString(PreferencesKey.currentWalletName, wallet.name); + await getIt + .get() + .setInt(PreferencesKey.currentWalletType, serializeToInt(wallet.type)); } } From cf7f019a925bec52086a63f6785e4c4dfa329298 Mon Sep 17 00:00:00 2001 From: Serhii Date: Thu, 14 Nov 2024 03:20:08 +0200 Subject: [PATCH 31/34] fix the UI issue with address book (#1803) --- .../screens/contact/contact_list_page.dart | 19 +++++++++++++------ .../contact_list/contact_list_view_model.dart | 7 ++++--- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/lib/src/screens/contact/contact_list_page.dart b/lib/src/screens/contact/contact_list_page.dart index f71747953..878a8bb54 100644 --- a/lib/src/screens/contact/contact_list_page.dart +++ b/lib/src/screens/contact/contact_list_page.dart @@ -1,4 +1,5 @@ import 'package:cake_wallet/core/auth_service.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_list_order_types.dart'; @@ -11,14 +12,12 @@ 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/themes/extensions/filter_theme.dart'; import 'package:cake_wallet/utils/show_bar.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/contact_list/contact_list_view_model.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 { @@ -92,16 +91,19 @@ class ContactPageBody extends StatefulWidget { class _ContactPageBodyState extends State with SingleTickerProviderStateMixin { late TabController _tabController; + late ContactListViewModel contactListViewModel; @override void initState() { super.initState(); _tabController = TabController(length: 2, vsync: this); + contactListViewModel = widget.contactListViewModel; } @override void dispose() { _tabController.dispose(); + contactListViewModel.dispose(); super.dispose(); } @@ -306,13 +308,17 @@ class _ContactListBodyState extends State { @override void dispose() { widget.tabController.removeListener(_handleTabChange); - widget.contactListViewModel.dispose(); + if (widget.contactListViewModel.settingsStore.contactListOrder == FilterListOrderType.Custom) { + widget.contactListViewModel.saveCustomOrder(); + } super.dispose(); } @override Widget build(BuildContext context) { - final contacts = widget.contactListViewModel.contactsToShow; + final contacts = widget.contactListViewModel.isEditable + ? widget.contactListViewModel.contacts + : widget.contactListViewModel.contactsToShow; return Scaffold( body: Container( child: FilteredList( @@ -351,8 +357,9 @@ class _ContactListBodyState extends State { }, ), ), - floatingActionButton: - _isContactsTabActive ? filterButtonWidget(context, widget.contactListViewModel) : null, + floatingActionButton: _isContactsTabActive && widget.contactListViewModel.isEditable + ? filterButtonWidget(context, widget.contactListViewModel) + : null, ); } 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 25f222891..eb3fb837e 100644 --- a/lib/view_model/contact_list/contact_list_view_model.dart +++ b/lib/view_model/contact_list/contact_list_view_model.dart @@ -136,10 +136,11 @@ abstract class ContactListViewModelBase with Store { _currency?.tag == element.type.toString(); } - void dispose() async { - _subscription?.cancel(); + void dispose() => _subscription?.cancel(); + + void saveCustomOrder() { final List contactsSourceCopy = contacts.map((e) => e.original).toList(); - await reorderContacts(contactsSourceCopy); + reorderContacts(contactsSourceCopy); } void reorderAccordingToContactList() => From 9be990a3c094b76a22e55e89c4144ecbfeee9ca6 Mon Sep 17 00:00:00 2001 From: Omar Hatem Date: Sat, 16 Nov 2024 11:06:49 -0600 Subject: [PATCH 32/34] Move the api key of trocador to the headers (#1808) --- .../provider/trocador_exchange_provider.dart | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/lib/exchange/provider/trocador_exchange_provider.dart b/lib/exchange/provider/trocador_exchange_provider.dart index 652543607..5529d824a 100644 --- a/lib/exchange/provider/trocador_exchange_provider.dart +++ b/lib/exchange/provider/trocador_exchange_provider.dart @@ -89,13 +89,12 @@ class TrocadorExchangeProvider extends ExchangeProvider { required CryptoCurrency to, required bool isFixedRateMode}) async { final params = { - 'api_key': apiKey, 'ticker': _normalizeCurrency(from), 'name': from.name, }; final uri = await _getUri(coinPath, params); - final response = await get(uri); + final response = await get(uri, headers: {'API-Key': apiKey}); if (response.statusCode != 200) throw Exception('Unexpected http status: ${response.statusCode}'); @@ -123,7 +122,6 @@ class TrocadorExchangeProvider extends ExchangeProvider { if (amount == 0) return 0.0; final params = { - 'api_key': apiKey, 'ticker_from': _normalizeCurrency(from), 'ticker_to': _normalizeCurrency(to), 'network_from': _networkFor(from), @@ -136,7 +134,8 @@ class TrocadorExchangeProvider extends ExchangeProvider { }; final uri = await _getUri(newRatePath, params); - final response = await get(uri); + final response = await get(uri, headers: {'API-Key': apiKey}); + final responseJSON = json.decode(response.body) as Map; final fromAmount = double.parse(responseJSON['amount_from'].toString()); final toAmount = double.parse(responseJSON['amount_to'].toString()); @@ -161,7 +160,6 @@ class TrocadorExchangeProvider extends ExchangeProvider { required bool isSendAll, }) async { final params = { - 'api_key': apiKey, 'ticker_from': _normalizeCurrency(request.fromCurrency), 'ticker_to': _normalizeCurrency(request.toCurrency), 'network_from': _networkFor(request.fromCurrency), @@ -202,7 +200,7 @@ class TrocadorExchangeProvider extends ExchangeProvider { params['provider'] = firstAvailableProvider; final uri = await _getUri(createTradePath, params); - final response = await get(uri); + final response = await get(uri, headers: {'API-Key': apiKey}); if (response.statusCode == 400) { final responseJSON = json.decode(response.body) as Map; @@ -248,8 +246,8 @@ class TrocadorExchangeProvider extends ExchangeProvider { @override Future findTradeById({required String id}) async { - final uri = await _getUri(tradePath, {'api_key': apiKey, 'id': id}); - return get(uri).then((response) { + final uri = await _getUri(tradePath, {'id': id}); + return get(uri, headers: {'API-Key': apiKey}).then((response) { if (response.statusCode != 200) throw Exception('Unexpected http status: ${response.statusCode}'); From ff5fbd794649619085568e6ff572227a83fff23e Mon Sep 17 00:00:00 2001 From: Omar Hatem Date: Sat, 16 Nov 2024 16:23:54 -0600 Subject: [PATCH 33/34] v4.21.0 Release Candidate (#1795) * new versions * minor fix of cache key * fix cache hit * - potential improvement for sync status - update with latest main * disable thorchain by default [skip ci] * update monero_c commit hash * minor fixes update versions * remove monero ledger * increase macos build number [skip ci] --- assets/text/Monerocom_Release_Notes.txt | 4 +- assets/text/Release_Notes.txt | 10 +-- cw_bitcoin/lib/electrum_wallet.dart | 9 +- cw_bitcoin/pubspec.lock | 4 +- .../lib/hardware/device_connection_type.dart | 2 +- cw_monero/pubspec.lock | 4 +- cw_monero/pubspec.yaml | 2 +- cw_wownero/pubspec.lock | 88 +------------------ cw_wownero/pubspec.yaml | 2 +- ios/Podfile.lock | 38 +++----- ios/Runner.xcodeproj/project.pbxproj | 18 ++++ lib/buy/meld/meld_buy_provider.dart | 4 - lib/entities/default_settings_migration.dart | 8 +- .../screens/contact/contact_list_page.dart | 18 ++-- .../screens/restore/restore_options_page.dart | 5 +- linux/flutter/generated_plugins.cmake | 1 + macos/Flutter/GeneratedPluginRegistrant.swift | 2 + scripts/android/app_env.sh | 8 +- scripts/ios/app_env.sh | 8 +- scripts/linux/app_env.sh | 4 +- scripts/macos/app_env.sh | 8 +- scripts/prepare_moneroc.sh | 2 +- scripts/windows/build_exe_installer.iss | 2 +- windows/flutter/generated_plugins.cmake | 1 + 24 files changed, 92 insertions(+), 160 deletions(-) diff --git a/assets/text/Monerocom_Release_Notes.txt b/assets/text/Monerocom_Release_Notes.txt index 613ea4281..46f21e172 100644 --- a/assets/text/Monerocom_Release_Notes.txt +++ b/assets/text/Monerocom_Release_Notes.txt @@ -1,3 +1,3 @@ -Monero enhancements -Introducing StealthEx and LetxExchange +Add airgapped Monero wallet support (best used with our new offline app Cupcake) +New Buy & Sell flow Bug fixes \ No newline at end of file diff --git a/assets/text/Release_Notes.txt b/assets/text/Release_Notes.txt index 61aafb6e4..6764826a7 100644 --- a/assets/text/Release_Notes.txt +++ b/assets/text/Release_Notes.txt @@ -1,7 +1,5 @@ -Added Litecoin MWEB -Added wallet groups -Silent Payment enhancements for speed & reliability -Monero enhancements -Introducing StealthEx and LetxExchange -Additional ERC20 tokens scam detection +Add Litecoin Ledger support +Add airgapped Monero wallet support (best used with our new offline app Cupcake) +MWEB fixes and enhancements +New Buy & Sell flow Bug fixes \ No newline at end of file diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index 6245aa787..e58fad00f 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -574,6 +574,8 @@ abstract class ElectrumWalletBase Future connectToNode({required Node node}) async { this.node = node; + if (syncStatus is ConnectingSyncStatus) return; + try { syncStatus = ConnectingSyncStatus(); @@ -2216,13 +2218,14 @@ abstract class ElectrumWalletBase if (syncStatus is NotConnectedSyncStatus || syncStatus is LostConnectionSyncStatus || syncStatus is ConnectingSyncStatus) { - syncStatus = AttemptingSyncStatus(); - startSync(); + syncStatus = ConnectedSyncStatus(); } break; case ConnectionStatus.disconnected: - if (syncStatus is! NotConnectedSyncStatus) { + if (syncStatus is! NotConnectedSyncStatus && + syncStatus is! ConnectingSyncStatus && + syncStatus is! SyncronizingSyncStatus) { syncStatus = NotConnectedSyncStatus(); } break; diff --git a/cw_bitcoin/pubspec.lock b/cw_bitcoin/pubspec.lock index 8eafb2a44..c8a83f90c 100644 --- a/cw_bitcoin/pubspec.lock +++ b/cw_bitcoin/pubspec.lock @@ -87,8 +87,8 @@ packages: dependency: "direct overridden" description: path: "." - ref: cake-update-v8 - resolved-ref: fc045a11db3d85d806ca67f75e8b916c706745a2 + ref: cake-update-v9 + resolved-ref: "86969a14e337383e14965f5fb45a72a63e5009bc" url: "https://github.com/cake-tech/bitcoin_base" source: git version: "4.7.0" diff --git a/cw_core/lib/hardware/device_connection_type.dart b/cw_core/lib/hardware/device_connection_type.dart index 76a501af1..466d58e2a 100644 --- a/cw_core/lib/hardware/device_connection_type.dart +++ b/cw_core/lib/hardware/device_connection_type.dart @@ -7,7 +7,7 @@ enum DeviceConnectionType { static List supportedConnectionTypes(WalletType walletType, [bool isIOS = false]) { switch (walletType) { - case WalletType.monero: + // case WalletType.monero: case WalletType.bitcoin: case WalletType.litecoin: case WalletType.ethereum: diff --git a/cw_monero/pubspec.lock b/cw_monero/pubspec.lock index a6a03dce5..31d44ac63 100644 --- a/cw_monero/pubspec.lock +++ b/cw_monero/pubspec.lock @@ -503,8 +503,8 @@ packages: dependency: "direct main" description: path: "impls/monero.dart" - ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b - resolved-ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b + ref: d72c15f4339791a7bbdf17e9d827b7b56ca144e4 + resolved-ref: d72c15f4339791a7bbdf17e9d827b7b56ca144e4 url: "https://github.com/mrcyjanek/monero_c" source: git version: "0.0.0" diff --git a/cw_monero/pubspec.yaml b/cw_monero/pubspec.yaml index 6fa235ee4..71cbfa243 100644 --- a/cw_monero/pubspec.yaml +++ b/cw_monero/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: monero: git: url: https://github.com/mrcyjanek/monero_c - ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b + ref: d72c15f4339791a7bbdf17e9d827b7b56ca144e4 # ref: 6eb571ea498ed7b854934785f00fabfd0dadf75b # monero_c hash path: impls/monero.dart mutex: ^3.1.0 diff --git a/cw_wownero/pubspec.lock b/cw_wownero/pubspec.lock index b0347c023..a861c6bac 100644 --- a/cw_wownero/pubspec.lock +++ b/cw_wownero/pubspec.lock @@ -41,14 +41,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.11.0" - bluez: - dependency: transitive - description: - name: bluez - sha256: "203a1924e818a9dd74af2b2c7a8f375ab8e5edf0e486bba8f90a0d8a17ed9fce" - url: "https://pub.dev" - source: hosted - version: "0.8.2" boolean_selector: dependency: transitive description: @@ -217,14 +209,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.4" - dbus: - dependency: transitive - description: - name: dbus - sha256: "365c771ac3b0e58845f39ec6deebc76e3276aa9922b0cc60840712094d9047ac" - url: "https://pub.dev" - source: hosted - version: "0.7.10" encrypt: dependency: "direct main" description: @@ -283,14 +267,6 @@ packages: description: flutter source: sdk version: "0.0.0" - flutter_web_bluetooth: - dependency: transitive - description: - name: flutter_web_bluetooth - sha256: "52ce64f65d7321c4bf6abfe9dac02fb888731339a5e0ad6de59fb916c20c9f02" - url: "https://pub.dev" - source: hosted - version: "0.2.3" frontend_server_client: dependency: transitive description: @@ -427,22 +403,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" - ledger_flutter_plus: - dependency: transitive - description: - name: ledger_flutter_plus - sha256: ea3ed586e1697776dacf42ac979095f1ca3bd143bf007cbe5c78e09cb6943f42 - url: "https://pub.dev" - source: hosted - version: "1.2.5" - ledger_usb_plus: - dependency: transitive - description: - name: ledger_usb_plus - sha256: "21cc5d976cf7edb3518bd2a0c4164139cbb0817d2e4f2054707fc4edfdf9ce87" - url: "https://pub.dev" - source: hosted - version: "1.0.4" logging: dependency: transitive description: @@ -503,8 +463,8 @@ packages: dependency: "direct main" description: path: "impls/monero.dart" - ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b - resolved-ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b + ref: d72c15f4339791a7bbdf17e9d827b7b56ca144e4 + resolved-ref: d72c15f4339791a7bbdf17e9d827b7b56ca144e4 url: "https://github.com/mrcyjanek/monero_c" source: git version: "0.0.0" @@ -580,14 +540,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.1" - petitparser: - dependency: transitive - description: - name: petitparser - sha256: c15605cd28af66339f8eb6fbe0e541bfe2d1b72d5825efc6598f3e0a31b9ad27 - url: "https://pub.dev" - source: hosted - version: "6.0.2" platform: dependency: transitive description: @@ -644,14 +596,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" - rxdart: - dependency: transitive - description: - name: rxdart - sha256: "5c3004a4a8dbb94bd4bf5412a4def4acdaa12e12f269737a5751369e12d1a962" - url: "https://pub.dev" - source: hosted - version: "0.28.0" shelf: dependency: transitive description: @@ -777,22 +721,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" - universal_ble: - dependency: transitive - description: - name: universal_ble - sha256: "0dfbd6b64bff3ad61ed7a895c232530d9614e9b01ab261a74433a43267edb7f3" - url: "https://pub.dev" - source: hosted - version: "0.12.0" - universal_platform: - dependency: transitive - description: - name: universal_platform - sha256: "64e16458a0ea9b99260ceb5467a214c1f298d647c659af1bff6d3bf82536b1ec" - url: "https://pub.dev" - source: hosted - version: "1.1.0" unorm_dart: dependency: transitive description: @@ -849,14 +777,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" - xml: - dependency: transitive - description: - name: xml - sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226 - url: "https://pub.dev" - source: hosted - version: "6.5.0" yaml: dependency: transitive description: @@ -866,5 +786,5 @@ packages: source: hosted version: "3.1.1" sdks: - dart: ">=3.3.0 <4.0.0" - flutter: ">=3.19.0" + dart: ">=3.2.0-0 <4.0.0" + flutter: ">=3.7.0" diff --git a/cw_wownero/pubspec.yaml b/cw_wownero/pubspec.yaml index 582c15447..28ece82ef 100644 --- a/cw_wownero/pubspec.yaml +++ b/cw_wownero/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: monero: git: url: https://github.com/mrcyjanek/monero_c - ref: caaf1e56b1d2a254b332fdf848926fb963af4a3b + ref: d72c15f4339791a7bbdf17e9d827b7b56ca144e4 # ref: 6eb571ea498ed7b854934785f00fabfd0dadf75b # monero_c hash path: impls/monero.dart mutex: ^3.1.0 diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 470bdf4e7..abcac01fb 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -1,8 +1,4 @@ PODS: - - barcode_scan2 (0.0.1): - - Flutter - - MTBBarcodeScanner - - SwiftProtobuf - connectivity_plus (0.0.1): - Flutter - ReachabilitySwift @@ -76,6 +72,8 @@ PODS: - DKPhotoGallery/Resource (0.0.19): - SDWebImage - SwiftyGif + - fast_scanner (5.1.1): + - Flutter - file_picker (0.0.1): - DKImagePickerController/PhotoGallery - Flutter @@ -100,7 +98,6 @@ PODS: - Flutter - integration_test (0.0.1): - Flutter - - MTBBarcodeScanner (5.0.11) - OrderedSet (5.0.0) - package_info_plus (0.4.5): - Flutter @@ -109,12 +106,7 @@ PODS: - FlutterMacOS - permission_handler_apple (9.1.1): - Flutter - - Protobuf (3.28.2) - ReachabilitySwift (5.2.3) - - reactive_ble_mobile (0.0.1): - - Flutter - - Protobuf (~> 3.5) - - SwiftProtobuf (~> 1.0) - SDWebImage (5.19.7): - SDWebImage/Core (= 5.19.7) - SDWebImage/Core (5.19.7) @@ -127,11 +119,13 @@ PODS: - FlutterMacOS - sp_scanner (0.0.1): - Flutter - - SwiftProtobuf (1.27.1) - SwiftyGif (5.4.5) - Toast (4.1.1) - uni_links (0.0.1): - Flutter + - universal_ble (0.0.1): + - Flutter + - FlutterMacOS - url_launcher_ios (0.0.1): - Flutter - wakelock_plus (0.0.1): @@ -140,7 +134,6 @@ PODS: - Flutter DEPENDENCIES: - - barcode_scan2 (from `.symlinks/plugins/barcode_scan2/ios`) - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`) - CryptoSwift - cw_haven (from `.symlinks/plugins/cw_haven/ios`) @@ -149,6 +142,7 @@ DEPENDENCIES: - device_display_brightness (from `.symlinks/plugins/device_display_brightness/ios`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - devicelocale (from `.symlinks/plugins/devicelocale/ios`) + - fast_scanner (from `.symlinks/plugins/fast_scanner/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) - Flutter (from `Flutter`) - flutter_inappwebview_ios (from `.symlinks/plugins/flutter_inappwebview_ios/ios`) @@ -161,12 +155,12 @@ DEPENDENCIES: - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - - reactive_ble_mobile (from `.symlinks/plugins/reactive_ble_mobile/ios`) - sensitive_clipboard (from `.symlinks/plugins/sensitive_clipboard/ios`) - share_plus (from `.symlinks/plugins/share_plus/ios`) - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sp_scanner (from `.symlinks/plugins/sp_scanner/ios`) - uni_links (from `.symlinks/plugins/uni_links/ios`) + - universal_ble (from `.symlinks/plugins/universal_ble/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`) - workmanager (from `.symlinks/plugins/workmanager/ios`) @@ -176,18 +170,13 @@ SPEC REPOS: - CryptoSwift - DKImagePickerController - DKPhotoGallery - - MTBBarcodeScanner - OrderedSet - - Protobuf - ReachabilitySwift - SDWebImage - - SwiftProtobuf - SwiftyGif - Toast EXTERNAL SOURCES: - barcode_scan2: - :path: ".symlinks/plugins/barcode_scan2/ios" connectivity_plus: :path: ".symlinks/plugins/connectivity_plus/ios" cw_haven: @@ -202,6 +191,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/device_info_plus/ios" devicelocale: :path: ".symlinks/plugins/devicelocale/ios" + fast_scanner: + :path: ".symlinks/plugins/fast_scanner/ios" file_picker: :path: ".symlinks/plugins/file_picker/ios" Flutter: @@ -226,8 +217,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/path_provider_foundation/darwin" permission_handler_apple: :path: ".symlinks/plugins/permission_handler_apple/ios" - reactive_ble_mobile: - :path: ".symlinks/plugins/reactive_ble_mobile/ios" sensitive_clipboard: :path: ".symlinks/plugins/sensitive_clipboard/ios" share_plus: @@ -238,6 +227,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sp_scanner/ios" uni_links: :path: ".symlinks/plugins/uni_links/ios" + universal_ble: + :path: ".symlinks/plugins/universal_ble/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" wakelock_plus: @@ -246,7 +237,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/workmanager/ios" SPEC CHECKSUMS: - barcode_scan2: 0af2bb63c81b4565aab6cd78278e4c0fa136dbb0 connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d CryptoSwift: c63a805d8bb5e5538e88af4e44bb537776af11ea cw_haven: b3e54e1fbe7b8e6fda57a93206bc38f8e89b898a @@ -257,6 +247,7 @@ SPEC CHECKSUMS: devicelocale: b22617f40038496deffba44747101255cee005b0 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60 + fast_scanner: 44c00940355a51258cd6c2085734193cd23d95bc file_picker: 15fd9539e4eb735dc54bae8c0534a7a9511a03de Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 flutter_inappwebview_ios: 97215cf7d4677db55df76782dbd2930c5e1c1ea0 @@ -266,23 +257,20 @@ SPEC CHECKSUMS: fluttertoast: 48c57db1b71b0ce9e6bba9f31c940ff4b001293c in_app_review: 318597b3a06c22bb46dc454d56828c85f444f99d integration_test: 13825b8a9334a850581300559b8839134b124670 - MTBBarcodeScanner: f453b33c4b7dfe545d8c6484ed744d55671788cb OrderedSet: aaeb196f7fef5a9edf55d89760da9176ad40b93c package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 permission_handler_apple: e76247795d700c14ea09e3a2d8855d41ee80a2e6 - Protobuf: 28c89b24435762f60244e691544ed80f50d82701 ReachabilitySwift: 7f151ff156cea1481a8411701195ac6a984f4979 - reactive_ble_mobile: 9ce6723d37ccf701dbffd202d487f23f5de03b4c SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 sensitive_clipboard: d4866e5d176581536c27bb1618642ee83adca986 share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sp_scanner: eaa617fa827396b967116b7f1f43549ca62e9a12 - SwiftProtobuf: b109bd17979d7993a84da14b1e1fdd8b0ded934a SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e uni_links: d97da20c7701486ba192624d99bffaaffcfc298a + universal_ble: cf52a7b3fd2e7c14d6d7262e9fdadb72ab6b88a6 url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1 workmanager: 0afdcf5628bbde6924c21af7836fed07b42e30e6 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 09c75feee..85d39167a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -230,6 +230,7 @@ 97C146EC1CF9000F007C117D /* Resources */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, F6F67323547956BC4F7B67F1 /* [CP] Embed Pods Frameworks */, + 777FE2B16F25A3E820834145 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -343,6 +344,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin\n"; }; + 777FE2B16F25A3E820834145 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; diff --git a/lib/buy/meld/meld_buy_provider.dart b/lib/buy/meld/meld_buy_provider.dart index 696301f2e..1ac3931d1 100644 --- a/lib/buy/meld/meld_buy_provider.dart +++ b/lib/buy/meld/meld_buy_provider.dart @@ -5,14 +5,10 @@ import 'package:cake_wallet/buy/buy_provider.dart'; import 'package:cake_wallet/buy/buy_quote.dart'; import 'package:cake_wallet/buy/payment_method.dart'; import 'package:cake_wallet/entities/fiat_currency.dart'; -import 'package:cake_wallet/entities/provider_types.dart'; import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/utils/device_info.dart'; import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/currency.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:flutter/material.dart'; import 'dart:developer'; diff --git a/lib/entities/default_settings_migration.dart b/lib/entities/default_settings_migration.dart index d863b136e..f873314c0 100644 --- a/lib/entities/default_settings_migration.dart +++ b/lib/entities/default_settings_migration.dart @@ -252,7 +252,7 @@ Future defaultSettingsMigration( await removeMoneroWorld(sharedPreferences: sharedPreferences, nodes: nodes); break; case 41: - _deselectQuantex(sharedPreferences); + _deselectExchangeProvider(sharedPreferences, "Quantex"); await _addSethNode(nodes, sharedPreferences); await updateTronNodesWithNowNodes(sharedPreferences: sharedPreferences, nodes: nodes); break; @@ -261,6 +261,8 @@ Future defaultSettingsMigration( break; case 43: _updateCakeXmrNode(nodes); + _deselectExchangeProvider(sharedPreferences, "THORChain"); + _deselectExchangeProvider(sharedPreferences, "SimpleSwap"); break; default: @@ -295,12 +297,12 @@ void updateBtcElectrumNodeToUseSSL(Box nodes, SharedPreferences sharedPref } } -void _deselectQuantex(SharedPreferences sharedPreferences) { +void _deselectExchangeProvider(SharedPreferences sharedPreferences, String providerName) { final Map exchangeProvidersSelection = json.decode(sharedPreferences.getString(PreferencesKey.exchangeProvidersSelection) ?? "{}") as Map; - exchangeProvidersSelection['Quantex'] = false; + exchangeProvidersSelection[providerName] = false; sharedPreferences.setString( PreferencesKey.exchangeProvidersSelection, diff --git a/lib/src/screens/contact/contact_list_page.dart b/lib/src/screens/contact/contact_list_page.dart index 878a8bb54..166288135 100644 --- a/lib/src/screens/contact/contact_list_page.dart +++ b/lib/src/screens/contact/contact_list_page.dart @@ -240,14 +240,16 @@ class _ContactPageBodyState extends State with SingleTickerProv mainAxisAlignment: MainAxisAlignment.start, children: [ currencyIcon, - Padding( - padding: EdgeInsets.only(left: 12), - child: Text( - contact.name, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.normal, - color: Theme.of(context).extension()!.titleColor, + 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, + ), ), ), ), diff --git a/lib/src/screens/restore/restore_options_page.dart b/lib/src/screens/restore/restore_options_page.dart index 299846a72..d1d419d60 100644 --- a/lib/src/screens/restore/restore_options_page.dart +++ b/lib/src/screens/restore/restore_options_page.dart @@ -56,8 +56,9 @@ class _RestoreOptionsBodyState extends State<_RestoreOptionsBody> { } if (isMoneroOnly) { - return DeviceConnectionType.supportedConnectionTypes(WalletType.monero, Platform.isIOS) - .isNotEmpty; + // return DeviceConnectionType.supportedConnectionTypes(WalletType.monero, Platform.isIOS) + // .isNotEmpty; + return false; } return true; diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 4b9eb3b2d..f52be7481 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -10,6 +10,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + sp_scanner ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 52b44e53e..42b9fa84c 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,6 +6,7 @@ import FlutterMacOS import Foundation import connectivity_plus +import cw_mweb import device_info_plus import devicelocale import fast_scanner @@ -23,6 +24,7 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { ConnectivityPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlugin")) + CwMwebPlugin.register(with: registry.registrar(forPlugin: "CwMwebPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) DevicelocalePlugin.register(with: registry.registrar(forPlugin: "DevicelocalePlugin")) MobileScannerPlugin.register(with: registry.registrar(forPlugin: "MobileScannerPlugin")) diff --git a/scripts/android/app_env.sh b/scripts/android/app_env.sh index 8beffbcc2..7eee6d6ae 100644 --- a/scripts/android/app_env.sh +++ b/scripts/android/app_env.sh @@ -15,15 +15,15 @@ TYPES=($MONERO_COM $CAKEWALLET $HAVEN) APP_ANDROID_TYPE=$1 MONERO_COM_NAME="Monero.com" -MONERO_COM_VERSION="1.17.0" -MONERO_COM_BUILD_NUMBER=103 +MONERO_COM_VERSION="1.18.0" +MONERO_COM_BUILD_NUMBER=105 MONERO_COM_BUNDLE_ID="com.monero.app" MONERO_COM_PACKAGE="com.monero.app" MONERO_COM_SCHEME="monero.com" CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="4.20.1" -CAKEWALLET_BUILD_NUMBER=233 +CAKEWALLET_VERSION="4.21.0" +CAKEWALLET_BUILD_NUMBER=236 CAKEWALLET_BUNDLE_ID="com.cakewallet.cake_wallet" CAKEWALLET_PACKAGE="com.cakewallet.cake_wallet" CAKEWALLET_SCHEME="cakewallet" diff --git a/scripts/ios/app_env.sh b/scripts/ios/app_env.sh index bc3b39747..fe18758c8 100644 --- a/scripts/ios/app_env.sh +++ b/scripts/ios/app_env.sh @@ -13,13 +13,13 @@ TYPES=($MONERO_COM $CAKEWALLET $HAVEN) APP_IOS_TYPE=$1 MONERO_COM_NAME="Monero.com" -MONERO_COM_VERSION="1.17.0" -MONERO_COM_BUILD_NUMBER=101 +MONERO_COM_VERSION="1.18.0" +MONERO_COM_BUILD_NUMBER=103 MONERO_COM_BUNDLE_ID="com.cakewallet.monero" CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="4.20.1" -CAKEWALLET_BUILD_NUMBER=277 +CAKEWALLET_VERSION="4.21.0" +CAKEWALLET_BUILD_NUMBER=281 CAKEWALLET_BUNDLE_ID="com.fotolockr.cakewallet" HAVEN_NAME="Haven" diff --git a/scripts/linux/app_env.sh b/scripts/linux/app_env.sh index 2dabf083b..1cdb43ced 100755 --- a/scripts/linux/app_env.sh +++ b/scripts/linux/app_env.sh @@ -14,8 +14,8 @@ if [ -n "$1" ]; then fi CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="1.10.1" -CAKEWALLET_BUILD_NUMBER=37 +CAKEWALLET_VERSION="1.11.0" +CAKEWALLET_BUILD_NUMBER=38 if ! [[ " ${TYPES[*]} " =~ " ${APP_LINUX_TYPE} " ]]; then echo "Wrong app type." diff --git a/scripts/macos/app_env.sh b/scripts/macos/app_env.sh index 026ea034b..8f3b68ab5 100755 --- a/scripts/macos/app_env.sh +++ b/scripts/macos/app_env.sh @@ -16,13 +16,13 @@ if [ -n "$1" ]; then fi MONERO_COM_NAME="Monero.com" -MONERO_COM_VERSION="1.7.0" -MONERO_COM_BUILD_NUMBER=34 +MONERO_COM_VERSION="1.8.0" +MONERO_COM_BUILD_NUMBER=36 MONERO_COM_BUNDLE_ID="com.cakewallet.monero" CAKEWALLET_NAME="Cake Wallet" -CAKEWALLET_VERSION="1.13.1" -CAKEWALLET_BUILD_NUMBER=93 +CAKEWALLET_VERSION="1.14.0" +CAKEWALLET_BUILD_NUMBER=95 CAKEWALLET_BUNDLE_ID="com.fotolockr.cakewallet" if ! [[ " ${TYPES[*]} " =~ " ${APP_MACOS_TYPE} " ]]; then diff --git a/scripts/prepare_moneroc.sh b/scripts/prepare_moneroc.sh index 0d90e431a..3596bd18b 100755 --- a/scripts/prepare_moneroc.sh +++ b/scripts/prepare_moneroc.sh @@ -8,7 +8,7 @@ if [[ ! -d "monero_c" ]]; then git clone https://github.com/mrcyjanek/monero_c --branch master cd monero_c - git checkout 1d8e0fb30b53c28756f23676d5a3e1a99a9b3051 + git checkout d72c15f4339791a7bbdf17e9d827b7b56ca144e4 git reset --hard git submodule update --init --force --recursive ./apply_patches.sh monero diff --git a/scripts/windows/build_exe_installer.iss b/scripts/windows/build_exe_installer.iss index f65900cca..25f94cb1f 100644 --- a/scripts/windows/build_exe_installer.iss +++ b/scripts/windows/build_exe_installer.iss @@ -1,5 +1,5 @@ #define MyAppName "Cake Wallet" -#define MyAppVersion "0.1.1" +#define MyAppVersion "0.2.0" #define MyAppPublisher "Cake Labs LLC" #define MyAppURL "https://cakewallet.com/" #define MyAppExeName "CakeWallet.exe" diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 26d481f85..92431a6fb 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -13,6 +13,7 @@ list(APPEND FLUTTER_PLUGIN_LIST ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + sp_scanner ) set(PLUGIN_BUNDLED_LIBRARIES) From 29b659e748e800921e7c1c2127c006d1cb11c649 Mon Sep 17 00:00:00 2001 From: XD22 Date: Thu, 21 Nov 2024 22:04:46 +0200 Subject: [PATCH 34/34] Fix Whitespace in secret_key.dart (#1812) --- tool/utils/secret_key.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tool/utils/secret_key.dart b/tool/utils/secret_key.dart index affe4017c..d67ab7605 100644 --- a/tool/utils/secret_key.dart +++ b/tool/utils/secret_key.dart @@ -62,7 +62,7 @@ class SecretKey { SecretKey('bitcoinTestWalletReceiveAddress', () => ''), SecretKey('ethereumTestWalletReceiveAddress', () => ''), SecretKey('litecoinTestWalletReceiveAddress', () => ''), - SecretKey('bitco inCashTestWalletReceiveAddress', () => ''), + SecretKey('bitcoinCashTestWalletReceiveAddress', () => ''), SecretKey('polygonTestWalletReceiveAddress', () => ''), SecretKey('solanaTestWalletReceiveAddress', () => ''), SecretKey('tronTestWalletReceiveAddress', () => ''),