From 109d9b458eac30534dd93492173145a3fc48b948 Mon Sep 17 00:00:00 2001 From: Serhii <borodenko.sv@gmail.com> Date: Thu, 7 Nov 2024 03:26:14 +0200 Subject: [PATCH 01/15] 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 <omarh.ismail1@gmail.com> --- 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<Contact> - with Store - implements ContactBase { +abstract class ContactRecordBase extends Record<Contact> with Store implements ContactBase { ContactRecordBase(Box<Contact> 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<Contact> @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<ContactPageBody> createState() => _ContactPageBodyState(); +} + +class _ContactPageBodyState extends State<ContactPageBody> 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: <Widget>[ 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<CakeTextTheme>()!.titleColor, ), ), - )) + ), ], ), ), ); } + Future<bool> showNameAndAddressDialog(BuildContext context, String name, String address) async { + return await showPopUp<bool>( + 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<ContactListBody> createState() => _ContactListBodyState(); +} + +class _ContactListBodyState extends State<ContactListBody> { + 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<void>(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: <Widget>[ + 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<CakeTextTheme>()!.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<void>( + 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<ExchangePageTheme>()!.buttonBackgroundColor, + ), + child: filterIcon, + ), + ), + ), + ), + ), + ), + ); + } + Future<bool> showAlertDialog(BuildContext context) async { return await showPopUp<bool>( 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<FilterListWidget> { late bool ascending; - late WalletListOrderType? type; + late FilterListOrderType? type; @override void initState() { @@ -37,7 +37,7 @@ class FilterListWidgetState extends State<FilterListWidget> { type = widget.initalType; } - void setSelectedOrderType(WalletListOrderType? orderType) { + void setSelectedOrderType(FilterListOrderType? orderType) { setState(() { type = orderType; }); @@ -72,7 +72,7 @@ class FilterListWidgetState extends State<FilterListWidget> { ), ), ), - if (type != WalletListOrderType.Custom) ...[ + if (type != FilterListOrderType.Custom) ...[ sectionDivider, SettingsChoicesCell( ChoicesListItem<ListOrderMode>( @@ -89,10 +89,10 @@ class FilterListWidgetState extends State<FilterListWidget> { ], 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<CakeTextTheme>()!.titleColor, fontSize: 16, @@ -104,10 +104,10 @@ class FilterListWidgetState extends State<FilterListWidget> { 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<CakeTextTheme>()!.titleColor, fontSize: 16, @@ -119,10 +119,10 @@ class FilterListWidgetState extends State<FilterListWidget> { 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<CakeTextTheme>()!.titleColor, fontSize: 16, @@ -134,10 +134,10 @@ class FilterListWidgetState extends State<FilterListWidget> { 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<CakeTextTheme>()!.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<dynamic> 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<FilteredList> { @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<Widget> items, int sectionIndex) { - if (sectionTitleBuilder == null) { - throw Exception('Cannot to build title. sectionTitleBuilder is null'); - } - return sectionTitleBuilder!.call(sectionIndex); - } - - @override - List<Widget> buildSection(int itemCount, List<Widget> items, int sectionIndex) { - final List<Widget> 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<Contact> contactsSourceCopy = contacts.map((e) => e.original).toList(); + await reorderContacts(contactsSourceCopy); + } + + void reorderAccordingToContactList() => + settingsStore.contactListOrder = FilterListOrderType.Custom; + + Future<void> reorderContacts(List<Contact> contactCopy) async { + await contactSource.deleteAll(contactCopy.map((e) => e.key).toList()); + await contactSource.addAll(contactCopy); + } + + Future<void> sortGroupByType() async { + List<Contact> 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<void> sortAlphabetically() async { + List<Contact> contactsSourceCopy = contactSource.values.toList(); + + contactsSourceCopy + .sort((a, b) => ascending ? a.name.compareTo(b.name) : b.name.compareTo(a.name)); + + await reorderContacts(contactsSourceCopy); + } + + Future<void> sortByCreationDate() async { + List<Contact> 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<void> 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<void> 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<WalletInfo> walletInfoSourceCopy = _walletInfoSource.values.toList(); @@ -186,22 +186,22 @@ abstract class WalletListViewModelBase with Store { _appStore.settingsStore.walletListAscending = ascending; } - Future<void> setOrderType(WalletListOrderType? type) async { + Future<void> 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 <matt@fosse.co> Date: Wed, 6 Nov 2024 18:57:36 -0800 Subject: [PATCH 02/15] 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 <omarh.ismail1@gmail.com> * Update cw_mweb.dart Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com> * [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 <tuxsudo@tux.pizza> Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com> --- 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<String, dynamic>? 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<String, Object> obj, WalletType type, @@ -212,6 +214,7 @@ class ElectrumTransactionInfo extends TransactionInfo { BitcoinSilentPaymentsUnspent.fromJSON(null, unspent as Map<String, dynamic>)) .toList(), isReceivedSilentPayment: data['isReceivedSilentPayment'] as bool? ?? false, + additionalInfo: data['additionalInfo'] as Map<String, dynamic>?, ); } @@ -246,7 +249,8 @@ class ElectrumTransactionInfo extends TransactionInfo { isReplaced: isReplaced ?? false, inputAddresses: inputAddresses, outputAddresses: outputAddresses, - confirmations: info.confirmations); + confirmations: info.confirmations, + additionalInfo: additionalInfo); } Map<String, dynamic> 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<ElectrumBalance, ElectrumTransactionHistory, ElectrumTransactionInfo> + 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 = <int>[], @@ -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<int> 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<int> 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<String, ElectrumTransactionInfo>) { 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<BtcTransaction> buildHardwareWalletTransaction({ required List<BitcoinBaseOutput> 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<void> _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<void> 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<void> 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<void> 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<void> 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>(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<TxOutput> 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 = <String>{}; 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<String> mwebAddrs = []; bool generating = false; - List<int> get scanSecret => - mwebHd!.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw; + List<int> get scanSecret => mwebHd!.childKey(Bip32KeyIndex(0x80000000)).privateKey.privKey.raw; List<int> 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<void> _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<String, dynamic> additionalInfo; + Map<String, dynamic> 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<void> 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<BroadcastResponse> 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<void> setup({ getIt.registerFactory(() => MwebSettingsPage(getIt.get<MwebSettingsViewModel>())); + getIt.registerFactory(() => MwebLogsPage(getIt.get<MwebSettingsViewModel>())); + + getIt.registerFactory(() => MwebNodePage(getIt.get<MwebSettingsViewModel>())); + getIt.registerFactory(() => OtherSettingsPage(getIt.get<OtherSettingsViewModel>())); 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<dynamic> createRoute(RouteSettings settings) { return CupertinoPageRoute<void>( fullscreenDialog: true, builder: (_) => getIt.get<MwebSettingsPage>()); + case Routes.mwebLogs: + return CupertinoPageRoute<void>( + fullscreenDialog: true, builder: (_) => getIt.get<MwebLogsPage>()); + + case Routes.mwebNode: + return CupertinoPageRoute<void>( + fullscreenDialog: true, builder: (_) => getIt.get<MwebNodePage>()); + case Routes.connectionSync: return CupertinoPageRoute<void>( fullscreenDialog: true, builder: (_) => getIt.get<ConnectionSyncPage>()); 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<BalancePageTheme>()! - .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<BalancePageTheme>()! + .labelTextColor, + height: 1, + ), + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: Icon(Icons.help_outline, + size: 16, + color: Theme.of(context) + .extension<BalancePageTheme>()! + .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<String>( + 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<void>( + 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<void> 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<void> _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: <Widget>[ + 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<bool> 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<String> 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<void> 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 <borodenko.sv@gmail.com> Date: Thu, 7 Nov 2024 07:50:48 +0200 Subject: [PATCH 03/15] 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 <tuxsudo@tux.pizza> Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com> --- 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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0003yP)t-sM{rE< zC@NQhyH|j_SAe=zf4We8x>$j``PLRzf4Wb7x>|$0RDQWte!7{p*<p#mqPyE>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<PlCWye!BBCHBWuJ@);WP8yn($ zgl3GvyO^&~gu(ADF84%4hnK)zk;+kp!l$jvU6ae2p~GU8%;|D>_fAgsl$B42#r8-@ z@F^?$ARf(;q3|6Z?<y?id4Q3w)bSi1>~3@MEG<!dx%60A|3W>(l&J43E%0P!`5PIB zRyK|R0004WQchC<K<3zH0002eNkl<ZSi@sr7zLwX6hHwx1pw6mP6`bWm0+RB02O^D zNs0_`wlmXWrNjWgRC{wS3JgdJ3XZfj;v~}mPBzZ~fA9DNYf~K&7BUQwbNBIx4QGr= zOtz2}=Vv0>08VAA(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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 OBXmEe@8Drx!T$m5@IgcX literal 0 HcmV?d00001 diff --git a/assets/images/flags/ago.png b/assets/images/flags/ago.png new file mode 100644 index 0000000000000000000000000000000000000000..b04d7dfa6652e1e029f4dbeefc4db104382764af GIT binary patch literal 1159 zcmV;21bF+2P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0007!P)t-sM{rF4 z>=?}qCd>#Y4*>(e5F^eICIA2c&JiZk5hu+HCd~~d$^a<M3MLK!1OMw7%?>77O$hAF z760fMzYil00R#Q&6wC%E&Jrff3Mc*O744xLy$2-w;uTm-2>ja@KsN@Lf(>X{35$FU zLO2E>90Sj`69fYQ6A%Kpo)CU)3tLVI0RaF}Mh8|(2nq-R2?znMmJb2~06sPbUr`9# zycEZ-5*8Bzxt|fptP-1q4%<v2zN8UzVhW~@5B=vA*TEF-p&HBwC;#mj5Do#rr4hZP z5!_HA+)p9wnj2(M3IhcId}|Bvsu}0U6}|@~>ysN|R0;On7XIoK@T(c`s2bN-AI=XZ z(GVw=felee2mRX?`r8)%>lFOj7X9fI&Iu>rRUzq%9LohKy`mA&1}OaI71Aao-(Vo{ ztQzBR9=-@9rH&8w%ok);3GSmBLNEvJq8iIPARHJ1<$E4^XbW&)3Xy#c2nhhYq7lIt zBhVTp4h#XOln;@74G0GTcxMaY!W7%R6CMu)qKgh^SqaS#CjaUf8WsZGRU!Q375LE> z>zEtfSRh?c2-GYj{N)wvmmAa>CcqOTi+BuDMhE=a7S%5#2nqo4(-q)jANJH1eQXPS zYzuZ}3h2odGAjiC^cnx@7oLa?!5AYW9|N0(4p~hI{@WJ$*cSHK7Vy#)|JxT$LI;0t z3$~vS=Y|}+p%J5y4(G`gI4}h^F9kI&1*47+*t!#?jSsn)5+oi2|J@eR93{{kCD=eB z$^a+lf*sKwCD=bA=!zWFD<tQG9q*|c_RALw00Qok91;No<#`?4KqJ#AB;#=&(G(`n z6eim{B>LPH=6@aTr5gI%752#&#U~-|ksQ7iBmEcE+5i9m0d!JMQvg8b*k%9#0i;Pp zK~z}7V_+BsqhJ(30S5(O)PNCW02@UHu!&1DF;iebcy(`Qp*t5F*#_`VpCBcc$<4(? zt^or3W=-tw%J;NpBF_NH!}|}2_xDHzJ2J76VSt1H&#@ys++55h_P%Vqq#Mw-_lorO z6Ve9{aXAH~g?KQN?gH*}S7l|7ALZuYP0tkz6<{OT05+cUm#>}Sk>KD+PKwVU(gmDI z;NpdwXD^+a6v4@vni!jv02U)?04p4bOx|$gmW<4jDiK!J?4p>sFn^FJ76aH(01F(5 zHqYF3`Xmb<ABbHL9pnXKu%N2OVgM4TXk4^u29zDe=;Mt=34R0E%lT*5bwHSr{DJJG z8{kqYJb&i`5X;<;(Z+~$1K8{5u3ouuDv)6%Y^Fzs3xHtv_EkIP)s#6~>a&w<fJyCA z5VdXn=4GW07CIywpl--7w0QZJt!vj!X-cs*W+&YR2Cj_ljB8dfE^H~bGu6@{-2ir7 zx4edFd*-y}u(NAxs;Q6{h=P3(T2)y{kSqfbKuKPKG6UEZ<tQ|Ooqg1RL2SS%7zLvM Z2ml<!oxHgb?CAgi002ovPDHLkV1ik@*$)5! literal 0 HcmV?d00001 diff --git a/assets/images/flags/aia.png b/assets/images/flags/aia.png new file mode 100644 index 0000000000000000000000000000000000000000..193f0ff418ac5ff731ed35c4a02f26222f1194e4 GIT binary patch literal 1357 zcmV-T1+w~yP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0007uP)t-sM{rC4 za=a%}sRK2d0CT<14-fn5>HssD|NsBY0013IrOVIO&(hiAhl<i29qy>819ZO9=JWjh z{@hVf`TG5PmCE37asK}Q@2;+9g~9-O$TnWD*Eu=AtHL2orr6-`&=nQRr??3^oypYc z)F&r|oz8xc!vHds0CmCLS62XZzW@IH-BeWm`1Jnz_w>G;^SG1s#isxM{^Vm304s9< zDs}*KzXN*5(&qF7biLB&^haBQ{`~sS4i5kQ{N-g6?wV!gW)=G4()!@f{O#lImtg+( z@%{4bcazB3MMWDqbOA?%0Y-!XcEZsZ7~S6B9659VO^N_ais9nq056OHak&68mH=_O z0W)#T(ANPki^9gv0D8#*GI0PglmIGn0Xug9Fp*nqjubU_0X1>~Hgf<fanRG+0Cd0r zFpvXPlL0+=0DH;9$IqUowg7a$(dP6PH+KV9l>mFnVR4abd6dP;(rkK_%+J@Pth-2D zf=FD0%Ffo$)7k@dzWw#^(&_m5-pyupk^nK2XLgbUb-w^Hk^^?Y>x@tM*~aXTQ`7GM z?U`f$@8I&YiwrS(tFgZQ^X<~*^7Y234KaJIv%mS<$k5*B_t3cRoM-8KHT$}e=yxyt z@8|ya^6i#i{PF2*dzkR4eE9kK`TF{5d6wvQF7?Eu=y);ZXBYOGVEW_L_R6mEwT}Pi z*5zm!|NZ^>+{*H|k^AP^^u3wypKS50fc^CE=XNdr`Ss^<Ci&dS^0A2Lawp_v6z6g% z^S6=ipKR`%X6%w#^uC+#q;%|)TJyP;`r*;;ooV#Hp6!`r@1Sn;yO#93m+Od0?2uOQ zrg!wbnCN&g>xoP2icJ3a_2gs{@vMQ5Ne83=0004WQchC<K<3zH0007TNkl<ZSi|kr zRdd@w6b0baZhWuPKpk`(W+#q0%-p1qGBYzXGcz+YGcz;e&*_RSt92#6bw=kwqaAy{ zd)69{0s_>iQNt!%7a64mHZg&E4B{5CA)#U5Gc*vLY{WE;6i7^nkI;q705mD-LJ0w& z#$!^P3;?v5LJ1CF#%H908~}in$Yn~ffUNNzfKY-4WRCX)_!1P*D093Rph`3w5ed*F zb36y2C<G8i22if|751BLyHVE_+YTS!xRko<(@6k)?k{J7u6?J^EnT+#7~;`QOG@X= z9N+P8N6-g~mN6_al3lmqKIkp^5mPh5brDT8U~G=g)QS%q%xr)6_4}_n`nHuf5SN5u zp&^YF5EZG*O;6ECfghjT-`;=u{Bnm?0?^~*6B2_c05oZ6Ocs|Qz~)2FpC230)o^|5 z;tDT-86OM40HBqWAi%D(uiYQe;;298eFSbi@Bm0k5a5Wj%KZlM)veQ2HW9#&_XOw? z*+Pk6JaGQfwL5Ps(02J$rCkK@<3#{hf(Q0H@b;5>*R$uYlhvy%o&Z1I3t&p{fbHN_ z#F|GB9^=h@Ys#dL@gJFw=s0oX-qY%bchS0MHR3FVz&ymV-IW*bzo<pDud^V|PzcN> zPtICXTZdQd&J7F6VuC_o8v7TxaAxm%`eukiU~);3fy&M;YZsLm3`LWMDg;JPDJ*VH zvU2$XaxE?#Ggu)2Bg_RY_!Nv92ucAMJ~gK$n~CFwfpTEL*nVbazu7b>%MSpudi6As z5;>;s{d<5v06>1{4knYiOa7nV3$jY&wQHZ31^xp*-%Kj-e*<dNs3G|c5vi-dQ6`o% P00000NkvXXu0mjfwOYRS literal 0 HcmV?d00001 diff --git a/assets/images/flags/and.png b/assets/images/flags/and.png new file mode 100644 index 0000000000000000000000000000000000000000..fc5d9a89be3fe0c181438caa47b8717186a2589f GIT binary patch literal 1966 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 A^8f$< literal 0 HcmV?d00001 diff --git a/assets/images/flags/asm.png b/assets/images/flags/asm.png new file mode 100644 index 0000000000000000000000000000000000000000..fd3818eda60ec1e2727de66410c7cf1411ddfe19 GIT binary patch literal 503 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0XBj({-ZRBb+Kpx~JR zpAc8~0-yatI^3F74BGV&w1rO*h|USBK@f*#^%+4mpcIhHrd1<s-BCSpFPCwPap(+5 z`))S<reo(HvFJD2#Lo52UKW_Q+%a)}Qq6{v?wzvEeTr`VVzyoPpS(|R-2Cv_hf6nK zFzYt3>Nj#4HG5?)*>>obm|Yi_NlW|eLo9lY51zhf)T!UF@4BQzH=99|Pu5a-*S_+; z-OILKlCbZVcI;`JeQ5c%%V(}W{r~^JLGW~b^EMH)=B~L%L`<8RbQ?AwxH)0z-b$dq zcuRu(f*I=T%j@4~<iD>kfB*ja-w-yS#12mv$B>A_Z?7E{I%FWw@GxoV2{X|<UAuR` z{=HwgmC4y+iemM{4fhp`eyYEG65y%SGC_U*{od=sMZ(*d86Pb9p}S(;l=BX{@s}RY zU`Sald4+QUuWR*|BTQ#E8ec!%xy2*ll6>Es&~-rqoF!3rk84f4;=<J1SU7*V?HP9# zi}UF_7&4@^HXPo2q;;p^^cf8s@5OyvF@1%6`2IM>L-%BFbl%?lzHvoY0k?+80tVK9 kDhlj^DZgx$TK2>+R;6a|2@RTk2j~X|Pgg&ebxsLQ0C#!O3jhEB 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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00004XF*Lt006O% z3;baP00001b5ch_0Itp)=>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*<<G@VUmwa(S*&D81} zI-oF2sRJgKL0hor>h(HRt!#k8=IQj8rqoVmwXeF}9Xz45zTZ7pt{OU^2r8IIV6rw* ztMT*s3@w`NYIW_<(^6@(%h2H%IG@qj?7_<AaD>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<<MCYIpj@&zcDiki+cPO4OF zxEVR0@A3GOq0$~bp;~deeU8aUVY6?8!&Pj!5HXvwz26NknxU@PJ65gs_xp&L&HerU zQ){;xIiQuJ)AIBA4lkOEo6q|C{b+o^L|(C}wcDPn*4^Uqn5NX#+wBrEoa5&6XMDfY z+3fA__m-v8bce<1?Dp8*?uM4k@bdV;$>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<Ykl~`UM#w0;C#XX2z%j(l4uOMy>&7%yJA2qC#fm8(_w# z$-rYqi2-KZ3`~?6z{J2ynF0I^W|SFVHfq4A0mIJ#1B3y(NCvbLZ9odbfFvXX+z1$8 z0|%)H1Cnjw0AWC`H4>NuH^7__fE(cH<K*PD$kK8JkXm4m#S3_WH<Wg?v~)%hBF=yu z*L0UQm;qbk<CmG4^_Kd&_z`VDR<>ocAItzt%k7b7X0D-@8EM3Op~SZvNG5^}*s=34 zkYU#2lS#Z6>|N~x&CEPDc|yV<dY`?SdoW0d_&@}Kb(ZVF2E^{RJYq&_EC4~cgM-6b zkO6xv9rnhMZotYdtJgSeSY>H>X#cJQmJy^Iu-tOv<|xYqOCUPvwas!VsRk@uVu=Dm z=aOi^?65dHJG;d|tqveYNZ2fr0}%w~2U%Lq!<C&00y`iswruExGD$a}42a#!>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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<STu@9@MMrQ<*F8=x0|+Yv2hI^DuMQyAJWb9KCIA2c&JiX60001T z#?BEY|NsB|(H4Wc<AS4W>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=<om7FS3I8WjQw2LMVv2Kdbtntu%@9|PdJ69fYQX;%p<BLm5& z5%kIx6%he-VG05P0HcQvrHKyJu@WL20}2TM`Og*j&K10z5b?+r+O-pJTnTPl2?RS} zE?r|@g=hpiU@cr@U4&>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$-b<s)6zrl=E83 z)j(x)?t$jYL;#uuADNyO95%skz}mq)4vni1uUCDHhnOGeu%-gg&SnVAA~Z9D&}@JW z&<^PUuo+_O2+hqQv>syvKqf#j4Bx7cwfF*>pGOPvHTA{uFa@9uPz;Z+U&w=3f=f%m z73@L2zzq$c4Nwddk+64h8GmE^iI*3>?~w@_fCx|wQ&IQFDo$?o%^jU$0FVI<gKNm+ zLg)pj*Msr1`tIS7iv-XGGz_PgqYqD${=e(kKRI?cdUl`#&;<yFk#OSbARhet8U7Ip zKp!9&eE06uw<qTw^U(lQAj1&!TwI@E&Caim_V@Gvbimr*sBU{_cc7lJfvwGr#CgA! z8vti#jdYz(;0M5Fm)*D9KokInEZ_ifpsQPazq?BkkT4Jc?0}Gg1mFZj3=S|7;4|m| zcme3`t7ERO7mNkK)KJS<g9(fVKw$+GFdi^l==&CP76E8&)8B7vg=_*80}DVH&@(8I zZGdE80*C_y0~<gR&@gZSWPuEW0=Wg!3_Jj7AjQB1kOz_sd;kN11cL(k1k4rzKq^pH lEm}pf@VKn-N4>EBvLAb1mlYUv-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+<OB(~22&AM79MTJb)gyw2R8^wTr+G}H0WVqIQ)@= UU2^j289>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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0004HP)t-sM{rE) z0yXLbHt7H{>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?myxO5y<o3^Pf6JbC)55w^p|3{ZX?{|x{#iBsq3c~vu zAE<VFoqb)N5CxPSI69U2Q1bWyQ2I1UMS*QN*vtVh&m34!i2{Lf*qBlIU|PYqo4uqY zyu^_-1P5%sgZa$;%0jj95=~_g`i6THNo|o$RwsKnq>g@o&gO=iiY9M|#E3Q2)fTU< zEQ@$MWyYeG5v$h?o&5_G<q-l1Am9Qp4C8$OOpah-4>aVl00000NkvXXu0mjfDHme4 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}<Gi z6XNP#;G^i)y-r30LjM2%AHsO}?EROAkJrg+LZpD=UmiVq{QSd|RxwkcUha}0zhH*> 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->%)f<s19$>Wy3ealf9wJTIpG_s5SP_aEH}PrKzGf5pJ*l7{uu#-4LRYL6>hFKgPI z6H@>8>-Y2SsTY;Z?#1W+`}6lsXv!5mhYNB>=Y%zWK7RJFu=ZK!<bS_@eZP74<K9E> zH|_lM<?EXzYySQDbHgp{!>$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^-VkQ47<xj0~LYq&l<&#^qpi1Xm(h~Q>rL(#%M$D zJ|C&x;)|0%G#9FsYku`-{J@<c_|a^U+od%%O>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 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 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 zcmV<K1rhp*P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00093P)t-sM{rCh zQmGwEr2{pZ&kqm#>gw$5?92cF05h5Y|Nn+lQ~CS-{r&#_{{PY)9pZ<I?x?8TQBix9 z%HVKu(G?Z%uC8W<!Zu#7*Eu;MO{TxA!r0*N2|At1r?|<~>C`7Dgq_ZQki*?qSHu7S zFO?&&mX^E#0LRJ8F_|Wv1Ov$c0NqqnHBS@y`T4j20EPhps09Gb&d~h-|Iq*dxT2!2 znVGbppz7`IEngRWPED@@0f$so&khbLnI^NDnLBnTER!KHj~|^xL?)gjTcuK8yxd|j z3wNcl(b3cD)!1UV**SL@ZUO*VKPGW!Tj%TRc19$u0R^3$i*!>wjvfq+wuxt^rgxLb zZI^*^XEfPGMWvpev!0$KejaWh2d<l&C~X>n6a~Qn0Kb}=hz<kV)zwEp4qqt-hY$ph z4gx5WBsEqOu1ZS4006^`j3SvHQ(7@9q$wJ6Cn1w0n+gG89|x(7j8riU%$b?71qCaU zAV51HE=DKx_4P+ZAbtb{DT*bdRaN-$@MJ?)E1D*ahJk{8ZZ=RcEMqMkX)I5kN95|} zXq%|3V`h*cDM(pBHE<#9@$s>DcUD?gv{g&mq@}a~03>P_)0LH~9vxDMjc<8;UaZM? z9}?yO0MgXfKan;+IyIxGq|yKYy}-J3Iwwp~5B2BfTP_c1IT^LRw@gkufp%|~j)`D? zN|O)(m!6ucsGYvMx_p0Vjv5_=h;d&?FpPO@)XK_JI4gi{VUm`VMq3tXw$pz#7hyIV zV5+pK4Gn`>9~3M&Za5RSwz**{9o^;IX1?K-aCz_6)h%0Xi9QgO5fPF{6LUx{RaQjK zy1I6>rEPUeQJA)qQXE}S4t8{Acz9WQYdF-_%xRBRl8=O_sjQYuPFkIzxL83fOkaMq zv&O`~x<p2ben?+HFTqq%FH1|Qs%TjY8Lh(0l$T+yrAnA97rCxYzA-e`y1HXoFG)ly zT}>U5n321wR*|o_iFY}mvU+}iPSv<fc!qGoZf-(fTl?$)lf*4X00001bW%=J06^y0 zW&i*JoJmAMR9M4fU>F6XU=%=s5-Yb5GY1s#vaoY9!8l_4d;;uv0oVXx5m8P#Moa@F zr6dGdd5AE8QC1$|2!sJ3>p7*xi7<eJSrOp~m;n&$6=XPwF@S>u=m>3yBcKLAtp`dG zX8^(xU<2UR6K??A5g-G?;ntI40MrqjKm(%S))Qv{Ckg->fC7jxz=oZC05$-x3aIIp zY+zuXmy>J`W<}%l0y_%|mlvYY8p5lnRMSiavrI4pQAUA>RWJ%{fT)PDA~Q-La)EW# zI_cN@*nyd57zT(-a|-bBhl30d;%3#Bmu18>V8=l<eKjXt5HkkN0I>BEQj$y{12`Dv z5RO1L;Nn?hW2;6Z5HlXt0EqQs92^h>IG7QRfE%#<l#!=?pq^$gXOkx{rzx@lFzY$s z25=x80XN{}tLG1{o;!1B-;};)ZWIIH)*~ALc7zhp5pV-u{|k<|A8{${fZyaET~1Xb z1K`%97yxqw%mAM6VLx9!dU)l8m#eEgssV88(F}k%0%m~GpI^Vfym@hY?;NjL{r)Hh z5R+uNI6r=P_x{t5%ZC@vcAq<Q5;y4v{51}F8~N0CzgzICka^Q4+L3I)6Q9rDB5wu+ ztX#2X^?I!tW+WP5&3W(H*T>iIoIkRC?WPUewoEl4(E!Wi{<nhE?;85;Qdc+J7__9j zj6?%`T|z@|-?(s8eZI%m&FdC)xfGCSK&i8X_ceoKyFCmSEj4iSuF6j*(Ew!~=bd_i zdnUBDO<%Uypt{f^i9`d~bIUv1J6as=?HwEHYKqeI*hw{jU8_XLRz<}@MMa}nCsT`^ zbOV6i$gr^0(8$V8Q6{BOMFDDYu?dOFq?O8OK+}rUVx8i8VblOR888Y)!6*O%07V4R UT)P=S+yDRo07*qoM6N<$f`F%)+yDRo literal 0 HcmV?d00001 diff --git a/assets/images/flags/bol.png b/assets/images/flags/bol.png new file mode 100644 index 0000000000000000000000000000000000000000..4996ddbcc30eda9b529fea925dc6f10456e3efec GIT binary patch literal 668 zcmV;N0%QG&P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0004TP)t-sM{rEg z!WsDM760oL_~R7$<P-Sg6WAUF_u>=)dOi8<75L&5`s)?!-4*}p6!P5^`Rf(_>J-|e z5&G&C_~aDs-WB=d6YAF+`05q><`eef6!z&CxvdxR-WT-X694NHw=@aIvJ}r$3I6F6 z-K-Jo*&F`n6Y|~{)WaJ6=MlxWD*ELVkAxTE)gzvt8PHM*!7T#9tPqwj2eD@k!&?lt zH3`SD6!_{E)M5(EzZ~M&B(=F2$hj)2ZWSab8}8W^_vRzilL*uw081tn;hh5i>=eDI z5y3J8)L99ZFbCBd0Dg%vst^I|z7|ARBQY)!sd*gAk}b4u55!>%Z;fQxp9xMW5@LW- z;-LYjqc7Au11u~lWMC71cqE~R7|3D;oS;Rj9Rf~WRA7HgLyd5X!AYfd9g56}jM0#! z!C)|yes_~%Ig@ixo_n&pC})gCL!*Kvm48Q$c-b}qMxT6putP?dWk;iatDG2WkVuZX zcyyBGL;wH)0d!JMQvg8b*k%9#0NF`IK~z}7V_+BsqhJ(30Xqd?)PPX~Mh!qRfP(@+ z$N)(-4sCfp7P1YH(y(^5*OTFAA=3aBO-sKRe>)vHR`LvBQ@8SsO^Wt*(3NE)#{e-^ zn}GPVqLhR{TP<-`a$H~@lvq)oo>36zV=PUU7r26pOAAvovnstzn8-4K$s;PSq$)or z(n()}JQrBFM`Y)gg}4|f36N!gu!^Z?SaP_tk%ABpSq89(GMc%CIvOf6^0JVbkVKT3 zxw)AISy;%<3&dq6P71)N0iy<t8h~WLC>RB!00;o`?qpIZDJ@R`0000<MNUMnLSTZd CQy`oG literal 0 HcmV?d00001 diff --git a/assets/images/flags/brn.png b/assets/images/flags/brn.png new file mode 100644 index 0000000000000000000000000000000000000000..bd1d1cc9a2443c1d49897f676f7640a5daf1f7cd GIT binary patch literal 1957 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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%<RVDNPHb6Mw<&;$U|_AFok literal 0 HcmV?d00001 diff --git a/assets/images/flags/bwa.png b/assets/images/flags/bwa.png new file mode 100644 index 0000000000000000000000000000000000000000..5b7eff92a31f28695c645d39181c4aecdc408f3c GIT binary patch literal 134 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSJOMr-uI>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`<<MSw?3AZ3D<4SGKQ-GF%!&bPv=2Koi~uolXh(9DHL70imjD8!jYq_!ip z8O~)0x{9Q0_(_J|agYRfCCA_ED7_77Kd9MI%>r477pw5E$K448UV%XYs=kA1A#RW1 zk2O4AMpO%A^LV)iBQe}6aOno#DnQRDI)@P<MYt5n*PxpZoqTBLVPg@TI`BKtbQeF% zP}_^|XOMFf1C#huj_4LN_an3k^?f*$hl9BYXu$e?aO<Iw4RRLVDaPU)3cIl|3->CB zzQx`TI9U#-QVdPv^LAKBKoR1DN=#0}@hjXM!S)(PrV!HtmkRKj@JS<rucD|MtV;OQ zVs{;H<{<uSY^~zy3Z5+Ep$v7sh;2n*Cc09fE5eUGc&{AQKIk6BjvVbnsPV(nJhmU; z`6?_W_-h@5lh~7^H3^QT7?{8}!+1K2y$yIvpeKS`6#6I7H;%P?u)2t2g}6S5NGY^( zp_zlqJ5U#5c>%5!5O%`41Z2OQRK-ci7yW$vvA@3`mAaph@taBTt|36!XR}<nfg@o& zI;oMTC03&I9yOptiMkoyh(sejo^y(Tv8!Uup7}I9yvy#qX~PIHmtb_C|3HV@^3hhp zL`&`R*y`zn)r644VzFf`U3;#Fw`DsK#?M3JWr73Io30agT|3C?faBrNt<f-kZ&7*m zF$W^UhIu@wxh0{$D*A=DjsFQo9>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^#dnWg<hARDe)M-z5xN}LX9_Y&nv<Rw$(>sdW>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*|<g@C*_m;7XMc?zQ)l|7B1T<$q)3>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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<TYEVp6MMrQ<03?0v;_1>OH~=4Q&lD@q6e|E9Z2%;F03>}DH;B+2 zG7v0)@YvqdWq2n>k>6%@zQNZMG=>~Ji_zET>YAkg|NrX2%KrWR)ig-iNLtO*<?ZkG z4lso8w7ghvsdIz003?0S6f6J#|Jg@b@U^`D`}_Os?hP-4&(-D(E`wKZsRJi}8$65D zFhmhEh6X8sI#!!{jk|)BzZN%$2`qvyO_lP)$p|Zf0wsO~D1RYAjv6|OElZRsNRoMn zw%L%M+l-s%>hsepK<w`I@bdTDPhi|uX7tO@c!##yPG9rH%Gi*g$P_L9{{G0&;}9~1 z+1~4fmB0=#gbFNz@w~%4Sep_whD2YWafP;PfV3GoiGGm1RBfos(&QgMjU+>lCPa`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<ue#R6&Ebfc!t?d{ z5i^ByhPTnz=Vf}ZVRx?7Ph$P{_i=)>_Vo8ZTAlOe>TQ9vPG_Zyo5YEl!*7GNPiUoh zi@MI$=G)-xb&0u?qR1FHiK4I19zKj_e6e$fxLtItvc1<>aH<F@fuF6;KwF(?eX`~0 z@|&s5Ol6}qQJ3A~?WwiX_4fL_#@)BU+I5JyY=E@E%HQGT@FPNwt+>@vYNpTD=Iq4G z@3XquMpzv^jDnB5_xSqy>Fd}-RpaOJ?WnQt-sJb&-qnnq>6oJZ`T5N&Lgj&t-(_^* zYI*Ibu;1hE$j#uZ;Pt2g001F$QchC<cYl9qfA4Sae}8{}e}8{}e}8{}cYp7H@9*#L z@9*#L0fFMR0007)Nkl<ZSi@sr7zLwX6pR8G5S0)U5$1#dE+%GHZeD&UPe@RJm5+xL zA7BI;AgL1S=W5)l2m)GaN=i!Vx?r}3r;nbOxw0Z&zyUNsAu9uHgg%M^Q0?hPgbm;@ z?SUHsGXSn#16@ZS76TL&;YRER8(@cByP={*Vnkq}wFQucY=FEr5OkhBee!6xatqji zLnls|D=W8mfF!gL1}v$Wsa39AW?|e|fntE1DiYWUG~ghzfGSL>)x0X@SjC(mW5o{T zh6OrE2CymN1rUWR6QdNDwZ&QmD+XJ{#w=2tA74tE0g-j};npz6=*%@)Sl0<RKuQG( z>;h`pk1U`9Ru@v<u)+{2JeQU&uQz}hAgu`k$;!&ghtu|f15sJ|SXx^58L+q}ScA1< z#43~+DNItdMv6v7jZ7D1Z#O-MJzxWxos>P?ogGv4VaoH1f$l~z0LUvw7y#35uK`U+ zrYR^!<OV9Y+o2^Wpjc%I!~j{icBG_?YQ$v!X&8yP+20820vWh=l+0w>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#hQQ4L0000<MNUMnLSTa3lC0?f literal 0 HcmV?d00001 diff --git a/assets/images/flags/cpv.png b/assets/images/flags/cpv.png new file mode 100644 index 0000000000000000000000000000000000000000..0683d931f40253e3cf325b609554383517f8224b GIT binary patch literal 297 zcmV+^0oMMBP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0000yP)t-sM{rCk zP<#LP)&KCcEmC<)WN6j282|tO&JiX6IFtYO(*M*dz^5d`sUr+Oj0ruC|NZm-*+2jN z_W#&C`WJ&Q00001bW%=J06^y0W&i*Hn@L1LR9M69l-mk`AP_`LQ@daP|6dD?5?xW$ zUSiIZEX$dNVyX%NaMNV~0R#|)1Wl7C5Ys491v*>Gs5=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<I?h_h(9Dxr?85@5KGhQnG*0)LJ-2Ubt$8NAoJuIn}5lEQ7 z<oMnE#HM@AjqFU>(hN~^&fgV%{@6hMg6vP9EhV4&4elC==`<LBh?&oNLh;t&n`*C^ z9d;*2+Ad%6y`iCqQI@fUQNvfkl_8Dy#2;=4H-<=tC}xkvt9={-8U9>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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<TI#5hhMMrQ<8;L>>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`<i9#<o<34mhm5B^Z=gPTqCWh@IU$Qd@xnRq!Z}c9#{?y=2Pd!A-0})3 zu#=(OBtf_ZC9b2bJUmsvm8v~Nlt6u>J~NO(FONaZxH?prKcukXil;qKX2!|4JHxg+ zQfbHg`~4+Bxg0sRc8AhVmq4w!<OC$HFG{?WsXcUt(lk%L5;3y1zvmJ#vJo({yv6C$ zx;kN;Kk4lFaD&nG_4|XRJ#~lD{{H@{wBuZH$_y*83@fo`ojw&av%16S8aTCEnm^pU zI#`)MUYkGf@%p*K=wh5dUz<N!nm;*_Ky05rMwLM5w?k`w&t#oH&bc}dF0s7C>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<F{jzK7mK_ZJmnX5g|WJI<A000bhQchC<e|PWiXm@Xa?`ZFU&cE{j00IU{L_t(o z!((6=1*2dTi~<;7V&otnumKHV7UUP;Aq8kkAlNKG12}nkNi(8BwiVp~(u|OpHK`lJ z0FsO_;hP|hX#fdE%(mj|7sX-#@kYoA@fl$?fUps@vQs9@@bFCM>%(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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0006;P)t-sM{rF4 z|NsC0|NsC0|NsC0|NsC0|NsC0|NsC0|Ns53G5-Dg|Nj2{{r$1JrvCo^jhcP^(Te@g zh5p%|{{8y>wLt#r$^EuN{`TqpvN`_u>;1Jt{{H>`;<o<Vr2V~B{^GU$yi?QI&Ai03 zS86}H!m)LQVz|Jr=<DVEu{e{UgzN3-uD7H9+M@l&ZT-%G{`TnoyHNi7_Wje3{^r2_ z&V&8MYyR-r{@9%Tx=sDhg#E~J{`c(u?9TnTNd3!s{j)s&{Q3Ugs{Zuk{@S7au{ZwN zpZ&d7{_N2H`S1STto^`U{k~ZJxl8@dg8j5V{`v6zx=;PUUj55^{{H;^wL$*%=l<rt z{kKN`<huUp$Nu>3{^Pj)$8Y_@WB%Qz{^!BV(Zl}k)&10x{`l_w#AyEe_5I0p{l#hi z*P8viQU2(~{QUfnoPT$QWY5*fo2QMx#<pQ|OMi}SsIr`Bc~hjWm;KR+{@ti*d{(~2 zwC3sK{j)p$!C?K(fByUQ{QUc`xTJK1VT_r4S!_Yn+0L1zi_g`_^7HU~i)!uf>VlAQ z`TF>Tl5$#YL&VFxgpzZLn0n*p-+GB>hLm;B)W~;-WXaINuD7CvlXUR%?zX<GQf4}o zp@ngQUEAN*ae`iuoq)2tsJX$f`uq8(v77Gj>{e+$`}_KNiD&!!`}X(sn52r8p@w&d zW&O!>{neEI<hk77*WKaR-s0M}zO2sF$XRScJyRjR#kFU7QvUSf(bmhBqKMw%*<El( zg_CuXpM&IFf7Sp1019+cPE!Ec+W*gihS%Q&<L)gs0005UNkl<ZSi@sr7zLwX6bu)@ z$;!e`Heg}pWME^a2(U3QlBgpg8->fnz(I=cv}DE1XcQ3+QVd9n&yr3-6C=R@?g}oh zSSe<54X9U8<dT==W+vVMS!V=0GCft3i>sU)i!$5>xNupD`dAC<3JV2AZ~;M0Ee|tE z26%CyfD9Sz%5WQCg=#<)Gw}v^*c<Ab+lrzZpg^PnHayIZc3fCMXc%z@Son#VabX4S z21FSUAc;-0gDaP$ydH635Qs&;Ff+H4C$p3YQ3eEKF+dDY3dJyhI}TmDv=BEb1~AK^ zY8Q8tz?(-f4dB_hUK66%-&D}p#E3v9!<rIzZ>Wn27x$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*O<cXbXGo~q%mXN?eNlB%+y1B8xWkM@2sVNZ-1ga`E2&P1Q um1_=j1EHkMNO|>6aRXx%jDk@xTmS%+KWr^)MU)Ky0000<MNUMnLSTaO`;GPh literal 0 HcmV?d00001 diff --git a/assets/images/flags/dji.png b/assets/images/flags/dji.png new file mode 100644 index 0000000000000000000000000000000000000000..185c5322b4f9c75c872460a3ec00e6d3949a304e GIT binary patch literal 387 zcmV-}0et?6P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0000{P)t-sM{rEE z<-B9bbD7HTcDU*D`TZueJZiG%|Ns9Itt;#J?#|}-{{R60;o<*RSO3h+%;oq0_V)ia zHvj7C|MT<zAtC=rNdJU{|0*i~($fFc)fsTZ7XSbN0d!JMQvg8b*k%9#0N_bPK~z}7 z?a;{*f-n#SP*l{QS(IJ>|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<u~quvEr^$x(Rw*a?( h1=#f~P*PH|<OBZoOMkfqd>;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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0007`P)t-sM{rEr z886iwFx40?|NsBg8!*!xFaQ7m0Bb+|(H7SmFxeU~*c&j`8!*xtF4Y<@-x)B_6E8qV zGwBsC(-|(%8!pomDB2k>%^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`-U<FEUFs+7~GuQZ%9`FBVcUnqt&*H8ARcjQ6%0>vtov9W1UL zCcqRf%^EMj5+qnYG5FKh>2D;`9WWeYJ;)j@)(|Gw882fwFeYI@dNMF~Gcd#xFZZw- z)e|v6Q#&M5HHs}S+dwN)O*z6GEwvvlSv)b`88H3#_%mBRUP(FZi<X5hFUAoh`r6*u z7AqlPJiZ<-sv<3xDKFnpVEfJ&C|*4F$jwbpIn)>}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=*O<IXF#3qDhsOCn$nd zv6M!ZmsPdQ89F&kG<RaPk6E_WASqs1p1fy6*C8#yBt)iAsvTNUh+VqEDO931b}?I9 zs8=G>9y&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;<qI z0004WQchC<K<3zH0005gNkl<ZSi@sr7zLwX6hHw7F2Kpj$qoc8EG#&cj2bX%038g- zukDy-YO2%USWTV*1?rtr7V~EFi}f_rkYhkmahE7BpS7Mjm+?eqEz%8$6z<lXr9XG= zBDTailJ!+3q#EF-%vd*dhWN(42W7adgtTX>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%TFJ<bL)Zq;h9VxaRJ^W8K9I`%EirR zc<S=)yZ5ehOK>w~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`{TZ0000<MNUMnLSTZh#P1vc literal 0 HcmV?d00001 diff --git a/assets/images/flags/dza.png b/assets/images/flags/dza.png new file mode 100644 index 0000000000000000000000000000000000000000..34228422334bbf6807ad5b7e2b68288aa1f81ec2 GIT binary patch literal 518 zcmV+h0{Q)kP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0002SP)t-sM{rCG zbUe%wCbk_Zc|0!%c03AoJpcdy0D3*n5hj%`EVLdeT}w0Rm7es=&;0T7;&Xrf^!5Gs z_vVC;_0QCzCo82UD=lL<TuU<~XE_~fIu>s`{{8*@@bUin`Sr}v{rLFfd4&4u>+Pwr z<9CD9EkNF1ZPO|}AZj`E%FaquHTBKW&lf9lKrr~*-YI1`&lD--dx!0%t<fJd_SM<< z*xUa6{Qmp=cReubm!ac!g6f*1`sV27hmq%rl=jlr`Qzp1jFteE1i1hJ00DGTPE!Ct z=GbNc008buL_t(o!|j#F5`r)ghDCu0HU#Xwiw%3P@BdQ9was{N++@bnekTsuZ<73* zB@iG8;z9T;>?<T7!A}CscWrS&cNj7_t;tA8psj<(gYnkJ3koTyHDmEaA|7kju>vi9 zE009=`@&?SiWI0&-+QM}!3pwk{^%>lK4T@605)LiPr<InSQ#aN{IbwUhs&uyu#hPz zkU$0m=NbQO^TkVqAO`~F-pW(xCTCHU5_Ft-QSb=+=Mh$dm*^Z_4}FQBF>cT?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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0008bP)t-sM{rC6 zO`-u!qWjta?b8bYN2&hZ00T~<|J?xX93<`24EWao1Wux{uP*>dsQudj`q=^f+yL&= z3k*`922Y{!&;SuspdwtIo`x0xN~aH0qaItN07a}raC-tyqv*^J|K<P>P@?tK1Fw)N z_t^q58yxJ>3aYC!07S0;?EuT3708|y@z(~~rx2`H2<E>ASU($Qd}6MzFw4Rlb(ELC z!6q3|kif$vRV#4mG%jE)ZNXwi<i!R@GAFg3Bks=!^U?r0BPNxBFaSuZkts<4N2GKv zS=GNO-M#{dcuF%IAkDH6F=d%=h+5LXH`>E5x0?tkVu^w-R9J#wZaZ8zYJN_AWsiF< zx0xzbKP56{m~e?$T0<J%%@j&EGKQB)^VA1^Z&;IpN<1YV*~2RK)c`CN7x2^*oRDqS zvjMEI4nJ^rcRX3v#~s<pC5V_v_u~R-h+O~Z0DxFf$iWFFia5%x1=7DX&%py_Q&zyL zUoaUM>&pR@T_Uc95t(Hkes@3T%@<mPWPEx=!nie6frO}l6tZy*sFH-6ct$rKAi<>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<qP@T-#}+U<mVQZ;d_gpORFqOrFmq<ZLg&u}yS^*8rU{{e2ac*6Pn(%v zlWmNeorJ1fsd!9y%j<opVX>!L-t+&6$=Z92lV_qbYL=akj4rOc4RL^E!?hERsTogV zgkP~xGloHvZfItxiBqG4X@iF%TV_sumR_j5Lz=nk_xt1kqK;>r8vp<R0d!JMQvg8b z*k%9#0k=s+K~z}7V_+BsqhJ(30S5(O)PPX~Xl?+vcX(n(Ivc;gr<0ux8|em!N<=1C zcXd`J#i@EZ+DZtJYyca-U`@9{e@A6nOn`@jC>!ZsNRS75V~?sqSt<vIygkWYVCLXf z<~}WOZlg8_n+mtG00%#b1_-1mtv&zn!Tk#xCJ2;dX)C!4km`l<DQ9orz5eFf<(V^j zN;4z;*hn=%Q0v~4M|WO7dp_G_T5pB6AnCE7Yy9%n&9^tM?%K9xy0I?4SP){t1YBHd z`e#l)eR1p3p*2fZZ!*<a<Kn`iObBcMJ2oJvVPtA`<?+Q6%Xh3;YGrPuA%|5NZUfj^ zc@JAypE|mI@$S9W7N)$c?4%p8^0?)(gL9XyT5q|37C8o}>zEzcw`lU5bvqB3>8O)# zfK0yT)XnoI85%Cwy0A$zUWRlR$SO3NOw?=d)0^L_q99AM3z)>DqjOst>T2uTn)70P zrNx*?G=Nc<hc!yEupp<nC|l8ok4Ko1R4*_xic3j`h6RNL2l7dYGcl4J2JAc{Oze`b zE^f|}>`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{}<QL4~@a#q!kfY-1;uvCaIyphYt-(};m4!!}@!x-q2ZuKZJ&0;$6648lOk`p3 XzTelNGWn%GP(6dEtDnm{r-UW|?oT7% literal 0 HcmV?d00001 diff --git a/assets/images/flags/eth.png b/assets/images/flags/eth.png new file mode 100644 index 0000000000000000000000000000000000000000..5b4970a6779c099b5ded6d86689b13eb00674a09 GIT binary patch literal 1584 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 literal 0 HcmV?d00001 diff --git a/assets/images/flags/fin.png b/assets/images/flags/fin.png new file mode 100644 index 0000000000000000000000000000000000000000..b6bc2f9a9f572470302bd09888ffbe96060b5474 GIT binary patch literal 131 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSoB=)|uI>ds3?`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 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 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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00093P)t-sM{rCW zNu?)Jss8`}&kqm#>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;_!5<KqBe zl#8ju`1JAsVWPIVy~wbnmxg%(N+WBq;R7+2uWw1tc0dR=nMhlmMv=`bi_jlJoC<fx z&khc!hk8z#(-eZuTb0CjpudBIot11=2{M)7+SLGPz)Mq<S#q&4gTLzM<wB9sg^;z~ z)zKwNp`etFbc?QxgotxF8}IM$e}}Zs%*rH*(A?(p0A;ktoP@~D)0~>1z`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)<!8~BBT711x zbGNZ`Suc#wNS)Zs*x|U2e8!f3rMAL9VXSDJ!R6)UlvX({MwQjby#X?qNMfd-i+^u` zuS{{Zes6!8JPvaz2S%ILWt_)una+Q9kAqJ(oM}%HH<q=ouo!50$l&vbz2-uh)UsVP zp03p+b)p$wLz$kr$HKp=(d_W}{IQdRp0UCaS3sL`VCmGzWL}O(hO0oC*9254qM*d1 z$KbTL+W+6(T3VQne|N>!$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~<z+cHhdp)2@?Ie|mYBcN|jx4(sL_00001bW%=J06^y0 zW&i*J<w-<AR9M4fU>F6XU=%=s0IQHN8#@#*vv6`S!Z_l*JbdhU0oVW$Q85k$CQJil zq@@H|`H3)qNlp>r2!sJ3>p5g4i7<ejO&Q?`m;n&$mE_rpF@T*N;RvV!L|Kny0KySq z1BkR9*#NjBKn64rX+5d|P)Be84QR$<y=kcS{LpZ2DA2YDLNb5@1po~|0Z;>U4b>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<s6Y|GK+hO?+fzl(dzdR09lRKKy8!()X+G z^^`upi~H7-YykU>-U&87J-`2W_`KKGuL&dF0EurorTz9^xpv-f9IhT<C*1(kJ1Sj) zKfix@*BW^L`r!nU4cJ+)e^+Jl*MNY4z{y2r`rAk~V0A{NR_kqx&X@PzmixJSS30aD z(SYDpnY;FDU1F~B;3&Ru+S_uYIf(|$T&$5@SCw4g#L;u*%<=7;9OjYg0xe6&Y#+-i zM@REhE_rz?%p*xOz(04!qPX(4ky<V;JK{IQmBuHN8Vg}L)7d3VQwlQHt;u3zn>N*- p<a9u|W*RkMXd5sJM!_fm0s#L)^8AUl&r$#Y002ovPDHLkV1jYh7()O6 literal 0 HcmV?d00001 diff --git a/assets/images/flags/fro.png b/assets/images/flags/fro.png new file mode 100644 index 0000000000000000000000000000000000000000..2c3ed5f6bc5ee4851653d4286c3d2da300a1ecbe GIT binary patch literal 147 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSJOMr-uI>ds|NsAIC_46DTX*}y z6|q2Z#*!evU<QY0H`0I{Q%@Ji5R22v2@-A%rY0sOU;l?M3=lBb!@47EZ4|3W1S>0d 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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0000yP)t-sM{rD; z#^$Nd>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>`1mF6359A<i zH%I^=hf+fZ0BbZRu7B9dhh?9c0DDmQ#c~1Cr@Twunv<7YfFMaBey_zqjRXgvwA;F; z?zfh0)oH~Als%ww0q+pnuMi|Jge#H=ml1BHJJ~w?oO7rJG8EX-e7EHWm?~=P_+UXt m+a<#l9`6tNBMl7=b-e%*FGCSDjgT||0000<MNUMnLSTZlubhbh literal 0 HcmV?d00001 diff --git a/assets/images/flags/gab.png b/assets/images/flags/gab.png new file mode 100644 index 0000000000000000000000000000000000000000..c8e1cbd1fd83875e7a74f46db2c29f231c50d0dc GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSJOMr-uI>dsR;5S&TohxNmyp}@ zSpX=`SQ6wH%;50sMjDW#;_2cTVsSb-LBg%!zdhFvgEcHW!WK5L9`<T56=7xJ$!|<# ZVR-zmvo%T2z5}S9!PC{xWt~$(6980fB`p8| literal 0 HcmV?d00001 diff --git a/assets/images/flags/geo.png b/assets/images/flags/geo.png new file mode 100644 index 0000000000000000000000000000000000000000..46c83a5894551fe8074afe0d906b6c7bdd42c8e6 GIT binary patch literal 187 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|ep#Yx{SN8&+|3JXNa86L|%Zal$ zoI}3u*%S?wWh@Eu3ubV5b|VeQ3HNky4DmRgoFKv4%+8_2tg7H3xL`5U!~pjYRs+Tz zoX1|MNi3eB!1TmgNxOmDL9|0kTACfi6j<G`K$Jz+v&Ed<OmE7EriqIaOyYZ<ai_2@ cPT0V}V9{3c&`auB2G9xyPgg&ebxsLQ0F|^j-~a#s literal 0 HcmV?d00001 diff --git a/assets/images/flags/ggi.png b/assets/images/flags/ggi.png new file mode 100644 index 0000000000000000000000000000000000000000..fbc403f1616681ad5cc69721774a075166817c11 GIT binary patch literal 892 zcmV-?1B3jDP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<SfKW_SMMrQ<{{H^rIYa;d|Jncyz5ojU|Np)K3jhEA|NsBm01V<e zLI3~&!3`I_01BM|2;bS-&unqO01B`Y8pJ3Hp#TWgl$h863&}!B*#HZ=01BP}2&n)G zxc~}#00y)G3B^AO=hfE3ZF1nn$FB$xog*o*Y;e`6s>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<sbZ-4JS$S|A$00E3iL_t(o!|m2bQ^HUXz;O^PtjkLP4PB&&ic*vgqF6u_!4BAa z@4f3+H#(yygh?_Em;4WTli4@F!=9c*A|@s#_ESTXfErjpArp||zk!iVE|(eM2iUuP zgQIz~!C=mh4*I(7oPkd7(Af0c{KCTg-1OLxx05sAP7pFnn~FsfJxfT!&3y+t0ES+q zs70Cq(D7KJZo<US`cg?xGo}eXKr%JS&;`T-%}l0}oPn<15RPYO%!uZh>^O#cyEp<J zE)p3|Pic)t?NoX=LR=jj0jq<MAl3RE6(q!A<+=kU0I1=+uR|%T0ie~Z;UOBefLgER z1N8L=3=l_*gCWr0#{+0XG{OMl02mRk_yH8WBYpsd*v1)f_IRjp%<}s5_F{>JDNm2{ zPe59BVpFM9evL-+c+Y<P?2oPNH7N_!HCO8Hx#GBv1?2E|mp8Z97pE02$g2RyXXhtJ z`vL%f!-I<B>Ogx-ZGnx=Jz>Ds_OdWweRowDu(P(ZR28Ui6i~hRJQ5QV`|T6pBV05k SBomYX0000<MNUMnLSTa6dui<e literal 0 HcmV?d00001 diff --git a/assets/images/flags/ggy.png b/assets/images/flags/ggy.png new file mode 100644 index 0000000000000000000000000000000000000000..a882b4a5951429aabdee7bd4c90bcc2fa96fce7f GIT binary patch literal 192 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|e{s5m4SN8&+|NsBL5Y+v7SL{Qs za)H`x9-tIsNswPKgTu2MX+TcAr;B5V$MNI@3D#zI4kboKRfPZ-G07gTIlaBUAG87t z&B_ktw{F~ci6eAD3sVDE0E1H_*D5A;9@bNSC%8g-3`8XsH#7!1oS4Y6x6^uoTd;^_ kWPidl*)^^gG$eF@j#4iR=bZ8{31}6Ar>mdKI;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 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 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}0CO<N{@ZYX1NKUs$#K z*}Er&)w_N?d_3doMKz#ld?i7C!3<NUz6XN&^Fic$29NKvfiel6E{-7)hu>b?F4|xq z;v6W=UZAx3|Ed*9jDdd_n;%#ka>8<HkBP2}aKjE^S4O5c49$#e4gUm68Gb69Vz}jb zg<(mr2ImH*mf0GA44+mBhBBn8YB8~J{9v#bu9PiJT`C@SY<hZ|z?IcNyBIuO{an^L HB{Ts5qQGjI literal 0 HcmV?d00001 diff --git a/assets/images/flags/grc.png b/assets/images/flags/grc.png new file mode 100644 index 0000000000000000000000000000000000000000..d7b37b0c70a4966b898520cd6351b21c3772442d GIT binary patch literal 436 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0XBj({-ZRBb+KpkQNw zPl&5~fzRK+|0Zm`_4nUD-njMu|Noar-2`Mn*!N$4*>U0d`TOq=U3<kJzg{SDgHF-T zniZG(x7^x({`vh^U-)1ufhLHjY<lqOD^UH#2Oo~#dZP_gx%@Iv%dg*m&)s|X@b%Ys zUw&SH^6}%hU(eos0~#WbutC3MS9sJ>psN_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<z^aIq2d)L?p8cSTmu&QRq3!Djg@u|O{{ Nc)I$ztaD0e0ssKr*8Tth literal 0 HcmV?d00001 diff --git a/assets/images/flags/grd.png b/assets/images/flags/grd.png new file mode 100644 index 0000000000000000000000000000000000000000..7138a28d7cb74b2396fa5dd130eaeb3ad4e8f2ec GIT binary patch literal 892 zcmV-?1B3jDP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0004HP)t-sM{rEp zJ|f*vA?9=*(<LO<Gb7_^AN<i4&JiX6dS2yl9tM0|7=l;%#uxg?7XW!*HH=S3lS*`= zJ?+dIaiKog#US?27}dlfN|Z_D$sO>`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|b7t6n<F%e_sH2UjTSu0Cr(`cs)=;Ngjh&0(f9FkWZ|=EfIiO0Ci#odS1f5CgaE* zI*m^J#23rMBGSSlCx}zAw=15WF$#QK|JV`#*Af2D75dW_dwV<o*%Hn$CZ{Pd{LU8h zrx{+FL;v3o*Tx_NdR`5GTJ_Hu+sqyF(;3`AAq{?8#1<)idOOxJBWyb6(f|Me0d!JM zQvg8b*k%9#0mVr~K~z}7?Uq+l0x=YYL6GDMO793r#|DZbh`smTd+)vdeiuP@2eL_g z@@3EL!=1^w-)!b2ccM}$QlzLXT&?<gc3l=3(9poHDS)~rgc|GUHT@6N2&q{$6`<2- zbgY^R(Efb%ngDRw(H~@Y)c`<m@$j~LEPAK~02sRY`nwE*nFIhZHM85BO+QvL0HDoH zZ+EwqjdB3!Xm`lBJK8&d1psJG$@QmNMHdACK#tVzl6t5BQl!P`Sb&UCTrWi$Y|(04 zf|cm#fYGsu#|;TWh%|ulmoNhYztPbF#>xG?yG_5u^BKZ;a1i6r1pp7=Y5VkKYk6Mu z^o66*LLnLr_i_RJ@#nLf`@<E{ljt8PZhxRZ!3Q`v-+jA%U6%kbjvy4l_=9f?#}AiR z*K1RfEnu7<8p>nLw*|=Tyc{jfOpBhyP&ksyMZ%$RJ^(Ddug=a@95EjX^b}Wm0>~HR z0)SBr(cmo|q-?fykh}&SfOwQ`*6O2dwdwf*W+&+~InA7acv9{<C*vG|bSG_~bf#H= z5!Of>rUHcMnDWUF)lzh9dC6m)5S^Tgk=6jn(aFharadr}7s@}pP!uUrB<LGsHfu0^ SMrlp}0000<MNUMnLSTY$`ig)6 literal 0 HcmV?d00001 diff --git a/assets/images/flags/grl.png b/assets/images/flags/grl.png new file mode 100644 index 0000000000000000000000000000000000000000..53e45988b667fb6ea5727482080e46c2262b069e GIT binary patch literal 407 zcmV;I0cie-P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00012P)t-sM{rF0 z?C;bcL)S7>-B4`pq_X(g-_Q&*|NsB;yT;;YfaG(D(-}VV!_D7VbMUjj>zJwBNM-!; z^xjr+(iJ-KwZZ-N_|_{;>5ic0eUka$<@)C8)g?&D<t4}f0004WQchC<K<3zH0002) zNkl<ZSi|kpM-qcD5JXXepe=IF_dlmuxEw%3HQ3(iubEerG;$o9&GzW_MHcuUP_?7# zo2FM|TU`L$qQtYZaL)od!$+Au2LwJg`N0_=Sg>mectHCw<ZBjiGZ@QU5-1>4a6nZ` 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#;s1x7<z-NJ90XCa$?G7PANeNLSMSB1M002ovPDHLkV1mCV Bxkms1 literal 0 HcmV?d00001 diff --git a/assets/images/flags/guf.png b/assets/images/flags/guf.png new file mode 100644 index 0000000000000000000000000000000000000000..07a2d5070e8125cdcfefab15d566901832640fb3 GIT binary patch literal 581 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0XBj({-ZRBb+Kpy0*; zpAc8~0-xxmN-wVQ{JG1?-f18+L1*njQ3xAIM)s;a*~<qMUuZ19YKz$KJDl_Oh_iR< z15xohIUpCJ@|KX4VwEnCn;NXPQdj<lkd#b~Zgzm`x82<DC-UDClYHzWdRJBA=V>m% zW<8-My{91}-w$*Dy3F~03jg<m+`lez_Sh=jmX&-KEBy03*8?-LH%$UJg`^&Pi3Y`K zY*v?j+bnQSLRzIl=V_=&zwYu*pyNeLg8YIRe*c~N{_pSk^WXpZJN4i1`IF|qpY;BT zAb%YL1EZv;i(^Q{;kVZ!^P3zbS`%}(cIU?4s^j6%n0<9y*7vXf|8M9MD!Adb@38F5 z;=@;MDivq8___P9SKU2x&coeP4!rfe-*A2M0ZZ9ZX(pEf7p4P(nhkCm3}OqJ0$jux zl!_Sa7cmqlcQF_*;z@9w!ZfFY%c0Z2m&3xvXSu=zmm6kWM+DbB-l4G}E|%xaOabHI zcYF-*=DTd#ewL|{wd%&P=lkt9<U4%%vB~=X&*TLRd*3%`GqcUB6>K{s|1I!<u%ig; zq1F3jO%Hs!s<dfe-Rk_~{~JvE=NGU3)OD@)$Sv(tj9$tS4v{Rv8g2!RPAu{#Tp1k9 zn3%3`@B}CYDD*Zosw`p@a5rK6CU}-XvQW42ad6GzjsF6#XP^GZ(3NoZT2$z!S-?<a N@O1TaS?83{1OTO7?fC!z literal 0 HcmV?d00001 diff --git a/assets/images/flags/gum.png b/assets/images/flags/gum.png new file mode 100644 index 0000000000000000000000000000000000000000..828c5f3d927602f145bc41d23c01bd52882b8a34 GIT binary patch literal 1259 zcmV<H1Qh#;P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0008?P)t-sM{rEU zF-gH6JH$0jv-JP9=>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$<T^rnth|xWU=JfSGhIZW2qoH$R`iX@NaJnxP|7z-)xXGfT^4OP(Q2s^RXvBtWzE z|G{8*v-khD<odbS@6BvXz+-!;BTcsJ|E%oxq^zN{?EbIF(XQn1yW{<TDrtVsxLG)c zzU%)aN3OZr@WM@H!NAt(l}SE4no>E7h}O!$gPy+6=Dg77r{L(oNM63f+Al+;X1|uh zAURBlIsfTXgVDc*&%AG{eUR4ByeK%!Cpf>{@~xqoV={f0B~$<FRI%**yv*dwB{$k& zLhG1F@Tf}bo=Lgp|Ivz`zS!@vv$lrO!ld5ghttEzrn^O;RA<1He#^6;>+8fXNGC_D zhbd#mAU8j(VZ|akE|fL6_y50qlUlihytT`Q&$_V2*I2fHfiG#i)a$zH|De<J``|^x zTu}eeP4ntr#<R|4zLT8l>b%<Wrr+tP<M6PPjI8hZx69zWywDz-HI387!utQgh@qn0 z<_?h?#J0;Ppg=^fXG*beYr&bZ^Z$9tu8`r?VXAZg<Vr<ysKFyXy_{<3XgYazi%hLx zxsZ_m<4L&Q^`74SHgvuJ>{6$&e|JiK0+#Tx#DQwLuey&fz`|_2*y{(7?3UU5((wP2 zyONH&ldZ9^ZL+EblIa(Y-YkW*?ezb<=K686s#9-+0EpvYevciK-$kOoYOJ77lb@bb z1i8w6L8<P2u$Axo|6ZzSbb5cm=KKtm=Tc^33Xk4myM6EZ|DeanE1J+Xh^&3Jo;_i7 zE_S3TYLt+ab6sZ=Du%maZgjA<xA*)1`$x_^00001bW%=J06^y0W&i*IrAb6VR9M4f zU>F6XU=%<BI|YDR25^uGMh&2k0h~&uGL#q~(Z6;QQ#knsD5|bpv~>!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<GeiZm}40WOja;CFC~YSmh> z?10vW`eYAVev%E4@?m40dFbrrUpLhk*Ri=<k!*l;wC*hB3%c*W>FMh)El-su*#OS2 z*@w^Hc=z`Ea|46@I|?{SHb748$hjxae!u+j`qi7OyVT@JPl+>*-?@GF=Zi02KVLgF zSCNdQ+`aqQgNN5YKK=CQ^t{SQa<X&J-lLCiUHNeD%$%xFvdRMyrhO;wU%Gg5ezk%% zc_mVu!XBN2J9TQ9Y{)GW;|pucvSVF&$S;*SeZ8IR%|`3Sp<%#4x6($zC>RAm003O^ 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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0002kP)t-sM{rCk zu4mQn;n?uw|Ns90001YgW&F_=0G>(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<<iqorzaSVND3xv?8*0B=U zvlBco1w=Im7y<-HOGrdVLkj@{biI~#zL!=|QBX@uR!&8&o?R3T1W!jg=fyIomr)Z8 z1LwmiJ@KYR00001bW%=J06^y0W&i*IQAtEWR9M69*4JXfAQ%N;Z9$q&Yxi!n)$YBQ z)vot{qY_Av06zh|<-b4QIms2ZTD*Ai@_T-3Oobi;@WJ84bYmO<<Bt@D84Orj#dHKh zAqE4I<rO;OGerRg2a+TkF&xYyZ=(SCh@J0wUqb+V#P$|x`WX_Ck7yAuLk7eM;$f75 z(FkWl0pt;Gh6a>II2byhjHoh9fI7lz7y*3*Yyf~^1YkgbafHc$0Y2jKo`A;fsU4sr zUNLT869O7%mq%8h^F)Cg1F*O-$AI&b3k$HePCwz{4opv89a}%4vw`v6E|{LF+kYaf z-)~i<VPI3QH>Dv!uLsfqpw`1_BcRk<(<Y!)UzOGYx!xhI0i$}iv;v5F=X4p!>wVHP z!0Z3~(h`u>|Ni)<1;FaRQtxyDWcA%%Kw5g@^wW^E0J^=fbOF4)*T;M2#fuk@-ai;= VSDSEy8L$8V002ovPDHLkV1oHp5<UO` literal 0 HcmV?d00001 diff --git a/assets/images/flags/hmd.png b/assets/images/flags/hmd.png new file mode 100644 index 0000000000000000000000000000000000000000..8c2931c4e5e7e9d568dfe49857235dcbabe402e1 GIT binary patch literal 780 zcmV+n1M~ceP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0002hP)t-sM{rC7 zHJZ;45Buus9!sVC{r(R>p8zwN|NsBY000$4p)*{qCsL^$N~H-poyO7Vftt;4jK#gm z<#mzB*x>Kthl<i29qy>8+)+{a`u%&A%4vtg;BayN{{Qc;u4aY8HeRpSIXS<p!XZtj z&=nQRr?|<~>C`7Dgq_ZQki)dY;fkTqDpjh$&F1#_``uSp-BeWG<?_$j>@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-_LuxRj<b)a`T zdcgrVev#WlO;c7Zp|P2HJrXQqpyouCd+!9$|2UjV+`zb+DZq(^_I7uc7;vyJUyjes z^6;dZ6hP7sPKR3<0Oknw&v+(iHDf@S{um$xfW$YER5Jv`>45^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><?+D)@p~ct;RmTI`Yv4n`(8-@@q}y} zJqaBuHo$QKTu10ou>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 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 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?<If$ literal 0 HcmV?d00001 diff --git a/assets/images/flags/jam.png b/assets/images/flags/jam.png new file mode 100644 index 0000000000000000000000000000000000000000..97bce2de3f98a2d497b6298f66117028816b7945 GIT binary patch literal 526 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I3?%1nZ+ru!I14-?iy0XBj({-ZRBb+KpkRA| zPl&5~fe*uM%X`}x{#|5%kU$1QyAi`|E1;6U7Z^NNX*#de`f!fv?OEn?o0-ifs~JvG zzqo~IW*x(-9){Ju472JPfQmu77#RLsVEA#K@y8j4Kj#_b7aFbDCoC_?puNOEdx`$c z-QqKVsFq<x7lXDk15o?QZU$`?hLu1yQVg6i6P^O~i<boX1vAuVl;_vK2cq}&K#&gv z?}4B{^PVmP10$EGi(^Q{;kVb0i!~Vtv?hwW9=zAgrS<L8zw_yj7A`r%bJDJS?*C@i z=PhBU&!0cPKIXz>Y5v>H%eT4<Uu2cxn0e#f<b2sOyLbO0Pc+Uj3}19cvfFZl&4x)9 z&+;s-b1Ver=9ymnBUbe9j~kPRP_@(9$x9e=IBlmhNFO>Qx}dY6h<5>pg*ijdfg*+m z4jXpHxeJO|0ys78XQ~T2C|+1Txxr1rlGEm0ChwB<+m#nQVQFLt+IN343&)9{s;Ae9 zFfwH6*{O6HFc>fA<ZUSK3Sh7~&dIr;xMxMfGZ{6m1A6Tp_6hT6w=jh1?etr-diO=E z@XifPGDR~59G0KnKI5yb`TVYRkL&yY{Q0x$FN1=_PUrF&ckhEj-qY33Wt~$(6993H B(7ylx literal 0 HcmV?d00001 diff --git a/assets/images/flags/jey.png b/assets/images/flags/jey.png new file mode 100644 index 0000000000000000000000000000000000000000..e144d060e6bf143d7e77859926628ee66e1e9f99 GIT binary patch literal 914 zcmV;D18w|?P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<SYfwy7MMrQ<`rP2(7c%gYpZ@;--w`d}5iS4!|NsC0-w`eEjhpe9 zq~8=T-xV?Nlc3)dFW(d}`rYB*7&H3Y;NKTB|NsBr5iR}p_y7L>=vQm#R%+lFGy2}* z?uM1$7&H3b;o~q!?uL~0w!h;qNA<P7@85Fv@y_wxPxbTR{QUa#>7DQAtM1-x@#dH9 z+EMZ4jq~0{=yM?ESR?Z7%<=8a_TS?2-Q4oQ$M)dk)j&7oQ)J~HIP}}z@7?0<s<86I z$?lw|>wAjggMR0bj@~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$?~jj<kB^U!kMHmA?_>YS 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<RL$*;H%PbeIXhC?2r zdZ|lW5+Do0efSq*@px>~PgE}@{{mD{Meh90_2cu_;t)~2B=--1h(5NT?ryFwHphtS zks3hsar<)i_WE%?iog^lfZ*fc{_ElNdv64RBGrJjl=a=qllT3#VFV5n2Eav5tR8<J zEsYN#aH7%xSVwa!hs#razz_ofRr28U#!PP)Fv$==B{wnK-wl-+3{d*$=>!X%22l6_ z)zbm8587g<0%RYQ?dD$rIa<hd5EYdpWHF6c<z!k&gf25GWnwb>Vglu|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 zcmb7Gc{r3^8-Hd{V`L{`Fj*pDY}wvnG<LFt?E5mvKE&8UA(Ac2*o~!VL1o{!FjA5& z$z&;Oi@hPrH+uVezwf{AJI{5V`*+{Z@BZEAKIgig>m(Q%XwgHtpa1~S>u9SRQ>??$ zgdC&1=d*(qC<YW@tc3=uhF~icC&tx62cxeKh*B^FpaHP}R7Vub1Aw>ynqL?IbV1yI zurWyFHwO#=H$4FA-yAE7JsR4SNy+|gsR*Ed6baxzYzG3>KlmukidI^mVrc!eEdv06 zj`e7QfZY636g5i^Q;R?g{fkP@zTOg!F1}8#5;$+aqbvZ4Q=&j`*FZ-&&fCi;KnbVJ z|BInS!AD^PKl~RZ&{LV;Lf;6k=8JWO%SlK|Nb;*d;cz$->w-}-R@eNUPVtoa-2(&t zln{vE;9!Yh83|vk8$wD^Q4t|2jgXcWr!d3=LVN-papFD!0)LDATSwhBz!~e|7wF;Z z13%Jrbn?9#sLaoQROrw6d!2zEn13qy1pID`(jemK3qndl67ffyl8QWvDj8xuTq%{0 z^i`ygznK3O`&|c#I4b@hi}}0LUr|b{Do`Zi&$g*RIql|-Q#SsSj`{^t9Oz5FwHM5s zv;R;g%znoAlU{s)_ZPWLE@%lF;-0Xts-bI|0p(?iNi@B1PJ0aM&zyn2EI4+LO^~-( zyHz%dx^V%-rHUK|(4tKW`s}%JW67jv*1qAEVThi?zhs=NKTH;olGv+%M2vjS$x#j% z8d{5NU$en@sp`{;fP@It8Br`oU|rS!$@!F~s_pX}Bt{20j6KWfO)@Kp_7e^wn-Ct7 zPJu_(qnB9LrnYl(=vo@{-gT!`LOxUz^VYhNLjf&u3D?aNmx{JVJ#8=<<m&WrC#+3q zQpZ~PnRL9?H@#{y`3G{m%=}Go<<kS?om+c5`%4QpEA6nvy**b{_ZPti`30aq*Ee)` zP5-a_8)=vUm->_UNRRWggT<CCM-Rx?RP@hbHYMLaxj)LERp3b<>rZHxkvDQ@Xp@zR zE)Vdy_8|&P!1Jg0qXr|=T1<6$A7p9~IzP{Y6q_z8*;p~xB=^W9*9pOT+_;S==#s1} zt2J{gEu~SfwoIQ-y=c!}*Pu$UJ(C$h0?WWf)6I>Y`=}~zEQroou%Rcd+Suj;=nGB} z<v+M*@k-(Q1mCHsvCfM*=kK%iXNwT*=DKi8>V641V}6Kc;nte0J<P;eeywUr-b+Pb zW$<wU@zhS)`MIoNwxuVXWWDpT%UTX#JBU^8%o*e8Rl3u>{)8@$lDD<j;?9gFNXotp zi-JDMSkQILV(;v5zX18hybUprH#jjOz@TA$Dl6)cxg+LGaxT*j!sUTeMyCaa%tNzY zoqqc+Z*lQ}u!${~1<;X8lj64zq1RW*c|P)#X8oZh6Iw0(RoR_dC(yxjS=l{7QN4#J zJFA1b1SrnM>t*lN#k40JLewErIhKQks4G)7E@-zL*JN4@R9@C^wZGUax+X9<VmRZ8 z9qOZ9VlV$0VPR4oVZ8CuOq~nwX&GqbSgr6(49Xu9kuBp{n)+g1nzKg1xJb7+b>S9k zuk|Z*JW!k(!d4Rs-Yt>9#hi=Th;L>)#`1Fa!!wb>l?tlR=CrT{ly*we9^N~9yZI(z zfhZ2?8Wz-py;fk5_374Pm6GKNW#i#H06trs*jw>X2s9l07UAB+;<?sJThmJJ>(eY7 z_1GqptJ{y;Bn>#+rNab-Ol~lR_SHP*L)|9czz^>wM8toc4%rd<w5ibRch1aKhHhgs z_1#LA-oQP`kPWCtihI-z;jsJq#xD7r_o>j2pS1Z<(IwkdvMZ0EP5C;m9cB&S<EIww zTnKX{eA&WHIEZ!0)iH?QeA1CcaCL7IlgubVTbn;*Vl#5*?qro*47dE?1BH8q-$6rI z&+ik?%LgB?$$o9lTp|TIq0`5Nt&{S;kW4~&;p?T23LJ-YZeA>AddiO`jwx_RY-RJf z-|l83Wm%u(gD%~6oo!9iY&>j(klT*4&P1MY^LZC5GqWovEo_)Gf3ZV#*C6^q2(Pi3 zvZrK#AE@lL)JI6g^7}U8s{*xtR(rLrF2s8A4@e35OSFvA*HEn{KXn<8aoSs9D)dV% z{rwH3gyCco5xJ*q7{3M&xI%>23T`JbozSWC>7sEMte;xlTi?_de1E7ijm1Llr;rlA z8Y)M%n<ooAt-~jdl*SNgR&<-<{iNdLCKmF`v&}Kg?JEo)*mH#W`X8Rqs+N*<wP?uR zh{yKYIp3n;P_;U1YY%h$zC2^p;LObrGw7X~Vp?12-mcMd5Ne<@2{#(Bu&dKqDs*!X zO=Lu`u+J3)!=HXKa4FoCP@XqC5wW31%%9EUnO8h7hV}!oF!%jDfo^Lk-Oo!QWZUDc ziOGG#TP;P#oS{*jDa|#l4>VH2WrhjsZci@?_b7r52U3!~-am7Egk-Aprc;65xnxie zJbXw~J@doSi#ME^hp#Cy*w^u+c}L&m5+nS@$<_Rpk3oHzSmq_>Wpr+d^?kwo&oFHH zPmj(9<t^vHuPuxN;%z?kBj0=Ju8du$gR8Czh_vt2H;sSJsln~YcW5-0_SCBHO@-t! zirn2XXJHAC8)a*bU9+u~mp-@%5B_d{EPQ{??RChZieQ+cO=fc$gHN4`GlN9bbX^R7 z&?Nq`GYqxOE@#}(P&XXC{*BbRYwB8gX?DpnOLnn<N0T_cfwx^ZJS5WCkId7wqXaXH zE)SX&0@!j#hC@y1$Duyay^7H*nQn_fP!{C)n(@gC9}c>7$GdV|D-``-(7ZBAcdEF* z)rqgi<#bkZoU;cK&X1fmVE$g)YAY=4zR-<_bUWoM(C}QN-%HB}&3x}EZ#)x-T5a$? zKlPlgkm_1JuE80<5-vW^p}X0l*tr-e>XIddv*<F8u@RC(jft_|EH_W1%1&iPyWox$ zllahEcvRBZ`!L7gpvHx;qOZ++S7DdMMSt}1?CLQugsHN3ysgDexYt|~mcVMTnJADq zc-$k`PsjK~%2z4K5Ie3}<t8vsMx1|i3;Jg2ZWSG&_&zg!6ed1mW)IMwmzKO`_1JB% zen+H;bClDN{+ZO|o7+oMyASPt(ys60r%~#uZ=Uob4gDD>-j+`b2sBQv(i*W`@d{#p zl6D;w8EVLK<A=^cYpmW$mm=azEb~<caq-|M+_V8uqZOsNFg4Gg={8<fA*b{DHDI<p zAYC>6gOZu~^j$T37L?~zd%Mp=e=Q<>tJd2-tCuCu#gj~4w@P;`a+)1bwP|{6&}^5G zZDzlR0CXK@6`Wk!^Rewp^|+t<VC!&>E<F)g8e^`Y<;{X_O)2IsT+^qOak|<2qNTZ+ zX#KFR3;7{8b!;A-TwiSPVp|`acDoFwraezNb6oB-1nt#VNYX(MeO?v<7`V)JMNVvi z-3QqS*rksf)qr0@+o6I{ak)_9u~wH5kn9+>x`l1M5STbqtXuoq$b@H-V9pI%<Vb7k zG@E_?_@!eaDw?ZVIDy004jFytQcLoc>-=^Z)h@8*LNb!D%*KCu(vnR8PiIzGx?LQt zK0y5(jYp=Id)~%*Zs_S+nWpfH3Bmm}ZdaEm!n8K&`4l&)A6I^?xWPF{TfkdUb9|oz zQ}qSb-=7nlJ)2l2F}tAKp>S4R90Iz->JB!_+HC9R(gZ@dD8G4dN2}u8LCLtSbZQ+J z>y_*ovHZ|y?BPt4=N8l4q_;5CyIMs%2Wl<c$&Mznk!0c{p+n92SK;3FSr->NbSrGN zz^9LGdKFHW`eqxSjZ=u7N;*CJt%ARsYM=a0%jZSPWOkiOq<U!d9fw*esb$-9|M#US bv-1!XC1>BWYBYyEx<Tt`7^qjF9j^Zih?`i# literal 0 HcmV?d00001 diff --git a/assets/images/flags/kaz.png b/assets/images/flags/kaz.png new file mode 100644 index 0000000000000000000000000000000000000000..db52cf0788cef4aea4b92172780e36b8589f7a21 GIT binary patch literal 1740 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 literal 0 HcmV?d00001 diff --git a/assets/images/flags/ken.png b/assets/images/flags/ken.png new file mode 100644 index 0000000000000000000000000000000000000000..2570c185d3f427cf78fc4cc033bcb7ff7a085d99 GIT binary patch literal 2074 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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{t<T!kE<zMi^wdNlLWH8rio=iuYo;yy2;rLg)_PP`4zNxn+wo*Ofxn z7E7)e%Z%~+&6q)v2_f59W;7MTJn!##=ljk%f5184`Oa;BUpKglwh90Ma1VDE(vCa+ zkGo(y`nWND5ddI-zjvUkTbwr+VZ6!50oF9&Fb%Ly2drpZl(9@E0~~HCIAsF1zbl{2 z((uWF;0B7$x4FtDfK3LdK;WW{7goOUcAN0jOr#u)Q(So;Oba-gvB^{6!i|H{Y8NHn zXWCF7CKlZjo_i%${PCiXUeyOG>D+zZi}boD@0f}8zQ4dk69OI-)N$vQq(kE(!0zVY z*n-JFA8`>zat^n6ALk0|xko9C?QPk}<dSfD`P7wXkTG#Txp?E}7FX3I_I|TxQl(!? zRc;OFdbvEm77_+U!QHYa=OqUto(Mk8|J>YaWD6lJBM7xuHh=@Q3|BcANZojmjb83X zLrNwf)(EuFTk1mbh4UL<=J<M6N%?I)NtN@<(w_^W;+Oqf<L4$5(h(ka?^STNwzhMs zI9mc7gxxD)VL>My=!EVDPXkmn{=vg?42bis80Z#a@G6e<TQ%rTSOAezt;l{I%kd}J z$p#eOk1ixd)N~9lSHFJI{(-N1C^7f%C$ITgPgy@!!OXvUMyHk~-_~O8H64yBF$*nv z(<A&M`r6d{iIUwYeusrrjQhj2z#y&fAa;O`UhG7J=QY{USittiV5$bc7y)?^IAmO} z5pn7ra;^#8e_nf~6SFpe9cx4gD4I(w^!s16Cpy*VI^b+G-Szj_;fq?E{b()?wOrM( zP*6JR8#ddjP}eykh4>q@^OA|e6;NL$Taayv#9!C8rju5`tzV?SZQ+cuJha&^gkD!d zPaFo^W5~AMGf;>GFKIvyx+A5iu|n)X7G@wGFUUiQYBl;#=#4cYz<tE8{g@AQEX#V& z`wSeUYsfvS_lc&-I!Sn6hJ=!_vmC^93ko6{LIK)+F*q?Fy&yn*{1Y`?jQ(7XndwFf zY>0z?x<g@vK5ISJA)U1$EE|KH&nsCdD%%`1oa=;d4j6pn4ajy<tSFIf%x<sLaMbZB z1@A=d#B~W*+Q*wKf4nWOTd8aOL^l*m*P44j?UcV-<x(yH$N>o?-_yWO>Hm)bnB)@b zcXmPS;o=l{uJc#2rs7{@tfQRmWg4nT-xt>9vJ}ZUuDRsm>Z+&^NxgCV2rVFarL7Lu zmP#ZZT1@>jJh;&6na<cdw*q=H-ZF&uOTp!6fpl-9bJj|}@NK<#^F*xHDN?Jx;V&0b zukl|$MNsS>y<6>_@;c_&n1fYn_ZOFh#J!C0?af%-`?aRrYI0XFwO;>1>UU1o+aq~F zCkhS@<?FzPqZY4N9B7HRJz(OmNfDwwl}m~8@>jF7v!}J%*%nhGp6OJH#r08zR;98Z z>w5FNK6ABi2r1D-O`+isLU-kH+N&hyL+4mq0UXILvXl)M3mJ^tPgkGi@!nvJA>dR= zVfcd#yh601a;)Ms*Dj@WJpUEgrFre3p@U<nS7>NkpqD*a(rhkJL*felK5j~G$P2Q; zoc<nJEv7YpIKa+{a3o6xRv9#_$+Q@6@0c_UUdcS~zyWn5;*pPbRp(F^C7)ReN@;Sd z55iukbr%utuB~O~#0yr3F2OwG2lSOJqw<x^_a$50ECb4QBY=|1vVijs>19Ygmps2Y zui=vYpG8m4I!SB)2z5y5Yh>ofr7$*38Q0F(XUaK8i9)wB@BAkFXzh81<nE4U%SK)$ zj{bmlUn=XEIM19Zzxa<L`=FupFK0xR@Bfp1XSC@}rMJd~_DAmOro&QO{*X1QLne)N zb7tP->5(_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|<yA$+ z>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<<JB+**YuOyCN5 zAIxNB>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 zcmV;x13&zUP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<S)KE-RMMrQ<@?&ZL|NreAB<&m|{{H^*VrcCgB>(^a?HnZXVrT%S zQ98PK-16rGr&Id<`~Lp^F}HI4{{9B2RTHdU<n`_S{{8s;`WCHVht;hlvT7-`Z92Mn zQ^Sgc)T`R^<ubQ(=JxLU{rnuSWO2=&4y#)Nrcx}mZ|C;zSj3GzyL(Z>hzO}xaLt~n z;lz>Hw9W0^(N|IKVP)rEV1Lr5>G$wo$CBms?E$7z@M34%^5>`E!?@<oH@SDq?c2QP z(W>FaGq-dIsaK!fy+*!(_51kdUSQ7d-t_zU&+gvY@#QtRbuG1Tx#rKX<H@Mt#CFf2 zIk|VT<jM}JTJZYyIk|XY$dlsr>w?p%p5482&7KFSRkh{KO230AvukS0nZfDQli9Vy z>ee5yX20mu@A>o;tX|gf<LmhGTgHzGsaGenY&^Ppqu#*M@8I3@=!Mj*Nxy<OxpsEX zqAj&>l-afmsaQk3eqP6sS;daq^5&e}yQtv9lG(LGy?+?4VJ^0COu&RHv~3ivUm&q( zPr-+z-@s$Yl=l4jP{N4a^XISQ$Wp?I?D+D<>et5W*bl2)AF*cP^y<6k(T>-$J-d2+ z(WbTK%qz8S5v*KFz=IyJWxl5il>h($3UpFVQvmP(|CR6WpYQKfeRC5400DeSL_t(o z!((6=1*2e?02X!%01MCnPG$-K=coar20#s9<iZEs@XB&A0u5kg!UH^H<nc)}u>uX? zz$331$Q>6Gf=2-d0R#NGfj~IQm0SZPdAPaxxVf_;$uxjJLOD4%r@W*nGn^~~gu=L0 zvO#{R(8wUifCL38K?UwY9-e|S=_)b|5GbxKjg<h}ucj@`ty--_h8Oxl&QAkktxheu z8j=m@oi>4+TQJ%updMt7a=Sc<258I>nbIc$)G8qiBm~>KI!H8Nx}M_1ncNW2)2gGR zB1Ebe^k#E&t2YJnSa5SUwsdPYRHl+-fRHZt)LGokJl?i^U`J>wrHGPZfIz(L<Vo6U zs$k77p+G|2mozVkCDw`P=JU&d4B!>u%}e)lG!rMufS@FhdRdSG=8%k`&r5o4^aKZh z4cX;^mz12ngA=#3DSiWR6=|Z*lI}pt&f3bv0B@1ThNHYRwuG^a3~?5!Y@;>Pr~%Y6 ifZ~ScC>RC90ssKTcvU~5#16Co0000<MNUMnLSTaLarMmr literal 0 HcmV?d00001 diff --git a/assets/images/flags/lie.png b/assets/images/flags/lie.png new file mode 100644 index 0000000000000000000000000000000000000000..0b8c77442d3925650f728e78dbf6020c7081c0a6 GIT binary patch literal 844 zcmV-S1GD^zP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0005$P)t-sM{rC4 zD1-ngg8(gs04jk1E{6aoga9jl&JiX6D}Vqkh5#vo04ak2DS-eefZ@M3v6d><wlkiF zCIBpf0V#k0Erb9pe*i3k3o&^BAb0>Gga9Uk049MQH)H@RfJID4PD?6KT2=rYWdtgF z6*Or86-NLaZ~zlSHb`3mC42xSgD*s002D+jJXio2R-B4A>BB7{IAH)6P$?q{02*EZ z7D@mof{%D011x(3D|izyZviTQB|BXVFmZ`=AwfD75exth3IL;!IXX{d03?0Ywl4eE zJG-4J8x;W$3IOxbI}i&1orEIj#5b0JBNh<=mVYBJK~w-Ifv%G%hkQRG90iVhAg+=n z8a8F$y*1&$HuBFjPEkQ>VJ}QiJ|8(@3@vt<fh4}6EwYv?<G(h5ZXlY2C6<CEw3sTY zktwv8EC1|6x0@_uQy03PEyATQE<aKTDSG3;HuusyRaHL!@I=h3E0ljEpNA%KWGb$c zDX5MpMoK%jnkynYU!#a3KuuH5t}%~!AOI+ZUtBk%h##7QC9IMuxSTA`uQ98TC0a}q zabzi>j3=p(D1UP^vX?4vT^O>KDzKF*W>y!#qb_=BB7STma$p;ZcOVoqYXBvAcxD}f zZXbYc9}6yaYG5seaUh?EC1*?p7ytkO0d!JMQvg8b*k%9#0TD?=K~z}7V_+BsqhJ(3 z0XqeN1_rQjQf2^;tpz6~2CyW=1zK~GYXA!)WApr)0$xTo9!_!$;I@pfn>D+?p)kTm zg^!bT1GueXn`Ta1FukWTHOx+4gp*_g%wk$6O<2@Dcgn;nfnal4CQ=RH<ZJ2bm^@Wz z;k??MtPCGrHj-TsR8rq2FlR<zMQ&PVqMsq@E^rAcEAMRYt<Fo&PYQ6=kS5s$TF&A8 zje?~@g4xMl?)u6sBpV>7=@eaD6vZEz;_GIhC`Cp(VAHk_^>=je^!6}Tkl-c505KUg z6C+i19aB9eNfz?*f;cl5D=RA(v#9VumIni300#wN)PPX~MhzG>V7M7D3P!;w00IDs WWObVo)C4a80000<MNUMnLSTYZL?;LU literal 0 HcmV?d00001 diff --git a/assets/images/flags/lka.png b/assets/images/flags/lka.png new file mode 100644 index 0000000000000000000000000000000000000000..2b6492daa9b79a194b0a84a3eb02bd51903d9eef GIT binary patch literal 1757 zcmV<31|s>1P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00093P)t-sM{rF4 zx&V$KD*w9x09HYcASsR>Dvcm1|F;1DTL6n1D~1~=jUFohzX1Qf0ROlEjT|fgzyS29 z1c4eSf)y(N!2pOBETupoiytV78!CVxBZnC(t6>g|8!LnsD~~xE|HS}(7$)(r0OX|r z)0zN_7%a$=0Oywp{<Z*!7%PMqDuWm)h8Qa6r2xWx2>P@D=&1nyxB!9|Dz<_E)0hC2 zQ4g6)72u-)&65DWi2$y41BfIe#*qMmBqG+E0ERIe>ZbtitpMw*0C*87s(Ar@AR)wo z2bwV?tVtfxdl0Kf9*Hm=olp~nX%>PWC50a*oM{A+LKeGv2>7x9gd!vV!vKRPAiReF z)tCU;odD&e0LhR5`L_W1ya3;y0F*-)fhit<9VNew0OF(or(h44R1SqZ6^|V&b`>S_ zumG%U3GlK2csCNZgaEmP0C*lDu6+Q2GZ?{&0NtGd!eth`UKrSe4vrxy`?&yzRR@M4 zCFrIBy>|+nO%#_$7Jv{ei5V-`lmmqwCeM)r;h_M-j{xDK0FqA+vS|&MYyi)d0Q$KA z(w6|{sQ|ce3;)Le{=ERkjsUTJ0>6F;w|WTLpa8#n2+fNH^|Ao%ssM{X6^%9=kvtlY zE+Mgj0Ozg%kwz5Lo&bLnE6Rlj*PZ}~M-R1a41_Tn*qQ;NbOE(}19~SMl}izzQxv6F z5~*|p^0NS#O%lX~1=ElP?XduaK@shr27VMNq;Ld#Eg6nH8LMavlU)b7bqUIn0I_cj zmP-@xtN`}20QI#1lsg=nVhEyH5y6W9{ks6ji~^209Ij#y$A|@*MHs$#3W!V%{k8y{ zT?>8+FNi83rDqBM$^dgD8>(;zsc#3OTMxE(2Z>z-t#Am8I2wae2Z0bR?X&=WDjbIq zFM2)?;GF`NK^luv3&Ds1oo)iNfdGPB0lk0)br30e8zY!y1$9&aoL3HjT>z$c0(eUY zxO57893<eV0BtZ6enAiNxd3lH3Un?O)RY6_oCCUk1^*xbtJO=E00001bW%=J06^y0 zW&i*Ki%CR5R9M4fU>F6XU=%<B2L)j87{CGroKOH1VkZm;7*HbQjz_b&h6f=7ggDgk z7@*FfLC65{ZKXhkOgIcE^%Q3(7zQQmtddd^DEe4o3@^fA00KHbrqW;r6B8Ryx2&9z zktPo-k%0&VvfjN%xPZF3Boc+C1O#+uFVma6HcXC<osEq+14R0&{RA4)6$A6LOmErR zuPJtOTC(L^inW-36)Q0Yuu17I7MZ)zwQyCl#tfyYtL*-5&H1%H>*u63XZC6lV*uA| z-j4h650qmH>}>M#lDGbi-Sj&{(Yf%u>6EjQ#2BDitFTUtdx1r!MO&U_L6M7ta`uL4 z>lPQU*kr26LyQ4(G74s@u4#{7m_)f4tNmHJ(KThW<CeI-r)Eym{dkBmV3|U^nEgUK z%V-Ogf|W)2(&=K_iC;6E7hO8tzl)0q1B}jz&QY~C*<j$5Y^-dMv|3e5*mjqU+n>n^ z9o^otgbiTT))Sl~>zecIcAAxCQIEyvA77?NRfzKP2_A}_ww;v-1Dy3l<yF}w7hOAl zyw$ifTrJm5l$jq?3h*;m%M<njPt}!)g6-C9I`IZJo#Ej<Jrn1d^TP^~P(5wJ2KZ;? z+RQ6_bT^y#V3%d|#2%$!W_U>?TBAkS3jz<-)IPpm?j{O!vIaB%1%9~w9Q+l5M8pDH zLT=J~ee+`G3l})}IT+)DK{_?`LB{Yk3bPSmK)%b%SHb#`e7X)mP5LH=K$jR;>}z4< z;1HCNBO)boE%;=$UP*9;qk}FZ2Xk8#KQmvX-38{zUOxU9P$UpEfG6yF+&h!x2`1*c zjQmPHe4@$i&4L_!n`b%kRfn(<VSu!cOT@}F`)tRYlYE9t4kzypt3MFS&->xcawW$E zZ6XY4sGX#CW3HHdUA>R!%%!tzMFdzyZgv~0q`s}@Gr!19*no1)odFp<<y@+=r%tp? zN!4LuXHB<t6s>U%nK|LkWnyw8tArHLt2|AK@e^W>S@W<naZA}}@}{m`{Up>(jJQ%6 zsGrS0s5Lrtf|(|;j7~{17M!6n|GD8+qM{K31iW`t9u10=(P3u`38>r?WEF7FYV!U% zqKY&xun7{v+}t95+^i6mk)k4Zldzm55fzKOXBnh2Vq#@wWdqkHAQlr75V5nDdAd_t zxe5_7z(YtJU-d5J!A{Kv#!zo$je=1y3V;9r1=SoG%-wcz00000NkvXXu0mjfDO~9; 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<Zq<Toa= ZFg$+O*_xzh-vLz5;OXk;vd$@?2>|$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?<md literal 0 HcmV?d00001 diff --git a/assets/images/flags/lva.png b/assets/images/flags/lva.png new file mode 100644 index 0000000000000000000000000000000000000000..b3312700d81424ed2071c632ccdaf19aa462c9cf GIT binary patch literal 124 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSoB=)|uI>ds|NsAAY+!L}3tt&f zkg+7lFPOpM*^M+HN72*8F~s6@a)N|gL$)Yagux}&2<JeD0}h-$$GBG=FkoVsx;Smc Uy|Yp0fQlJBUHx3vIVCg!0GMVZM*si- literal 0 HcmV?d00001 diff --git a/assets/images/flags/mco.png b/assets/images/flags/mco.png new file mode 100644 index 0000000000000000000000000000000000000000..6c12bd624c047058d63686549bd829330e7a6cfc GIT binary patch literal 204 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0J3?w7mbKU|e&H|6fVg?4jBOuH;Rhv&5D5x0V z6XNP#;Pe0g|8s(B$mGAjf6oc4Lj>pk5pe;TP!i-9%&>p|`}dnSzkh#hs)jF6(8kln zF~s9|a)N|pgD<DPM?%8PPH~GKEs0PA9?^ma;TH}Z*vF->k&tj~{egs#gDaX6S2TnQ f=yOOYe$`^g`ZJ%o=$rI6pg9blu6{1-oD!M<taM42 literal 0 HcmV?d00001 diff --git a/assets/images/flags/mlt.png b/assets/images/flags/mlt.png new file mode 100644 index 0000000000000000000000000000000000000000..12809815f7b20a0774facd4765cc04a83335fb4d GIT binary patch literal 485 zcmV<B0UG{^P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0002<P)t-sM{rEZ z!ou#t$lb5B_SV|wv%1E_#Q*>Q&lD@l$;tE0((b{>-K?_m&C=biv-8Z+-K(+YwY$Z{ z#PZA0->|mdvbX8Ezr?+~-><aiyu#nEwCA+D<hQ-VzrXY1<LS4)=eoe+vbp;0?!~>m z#J#=4wzmBB_UXUG>Au9nx47iEzU{!q?!U(2v$^=}?a0H#%%7srqNS&#p~%F<%ATOs zqo=sIx5&lCvaztvo}tQ~p|h^4jDUHrtg6Ju#<a4rt*E54u&mdns>jI4!@<GF$H=Xu zpToexrJb13pQOCJyUEGPv9PYm%F3*%r;&z!mW+jMwhu@E0004WQchC<K<3zH0001> zNkl<ZSi|kpH5S595JuqylEA~=-QC^YZU3u*f^+Cn%rq4LUBFk2ND@Nmn@KmbZa+X# z-Ch=u%;!QZpel<B2PpK1_jZE{D5>J+@VKp7rLdg@sO7G<e7**|X52*rqS@>Ia+=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 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 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<Z0RsMsP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0001iP)t-sM{rCY zZY}D(1kA7s`N9Flt_#4d4FF;@|HJ@4dL{;BGVQ$tRDvQocqrJl2+y(#EpsY|mKY~- zEB3(xW{DretqekYCWMt4fs`3Db}2A)DHm%l+qMT5YA=408e@nbBycR!vkASc4I6AO zHg_m{ks1SIGTyfbkeL>-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-E0<Izf5)F{&Yn6XJ zYu`;s0O!z}_X==t-Gf5{*hOm!+-?x9Ti7H3KWWR#aw+pnmq4C44G?K7sH>j_+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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0008tP)t-sM{rF0 z>goeEnguqR0HVDBp1aQv4*)Zn|NsBY001XasU1qCRj9oOH<bl9n$jH|;)javsHpt? z{@hVf`TG5PmCE37aRfG+{{H_6IG69Pu2rbM3OSZ$g~B#ouUDwP2{@D2IXS<p!XZtj z*x>Kb6&1>-xCuI)racGA)aleGCxo5OevrdfslEm_l>s!F-B(utqP_sA#0E5%0yLRa zsJ#j~m<TzUraA-wrN;oN$pE9l-BeTnf*}Ytm<l?U2{e@jHk1Z6nFlqORj0jGrn~@} zxe7X$mWh~=mauz}yqZ7}oGk#RDFCoH0HZkw0G_(f4h{f$9sqh80HM7AdLaORA^@ku z0H?)OrnmsF&Ucf@*+oUs7#M-D#ZIWa2sD&{uEa4~tWT%ASa`Kve7hb*oj6~uPpZOF ztHP#+d!}bbYk{_@d1QW+oTzP4eUzOSMWLEA0iG@ZT6(uKS*THSw3|K;qm!AUGXQ3R zxlm_aqmq|5Ua6&PO{RQmrH6l<FaS+yV5DnMqACEPE&!fA4xT*^ra1?sJ`PfFW3Dy; zP;qAftI^JSS$JC?t~v&gNfWDN4OVbwt2+mnKL-(M767EebxaM2Mhvzz0C$vLt4{~E zSO;2jU9U?6k3s_geIXKoBmk(&pGp7~bsCFR1g(c4KyOf^R0aT@xB!J@Az~E(nwkKc zvH+T-SEPUds>nTR63fr00Dwx5Dgd0jq(Kk?D4Lo}TuA_+yIh%50HD7Bbcl40kxg+@ zkGik`d=UVo$UItF7=(;fPfQS~#0EMHX;L0yeGLFM2p^uKK{+L#iafQflTj4_O=t*Z ziCQ{ND;|<qMV(9lHbwxSyZ~u?BeBK^LQMcG004cGDuRMInME2<Q71Gt06$GsR8T|; z2{~$S069Y#K1u*eR{$Fz1w~LPIXMC}3jlJ409I!lO;#g3Kf!QdOh`)5&Ao!o)Vb2l z0G<zGXpE=;0004WQchC<K<3zH00085Nkl<ZSi@sr7zLwX6hMIiqlhRII}}K;adL3O zII;r#g6wz!*Z?tADGp68Oaqja6onauh%kUlO$*@&gaIJyIaK6`Fo2y&2jK{q0TAo8 z)!B(LfSn!a2pfnapau|SJ(2+kM}Q3=(t2bA;En(p5RKb<ecT3s91$zU!2vWN2AB2X zeCCqo%=+jCaG(I70Vn`THJ_P`f`W{hIJyCWocI8;0YLpg6PVErz^gwHrZmx&2LvJ` z!IWzpPA_n>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*`Tb<bb2 z_53E=&;_&nd_71sK;JpX+d{*C&dTj;Y=dS_vGA@U(E!PWv@9PD4ga~zmM#pQF~O&y zkR$_~oh0?lQnHIRG&&|Nnl`(?rLN4$nG^$z9n5)o%~ErVDlIxEPVH-}FL8IUBgFs{ z8GA`RUcMyH{PM=u_GZsgcYA2TMr;_E@QI5{I_f#6dsWnU)k^Y-^D&bgjX*<q^&EM5 zc_q!6u-6Mj#H9%zAD_4hu8M^$FlxZ?F<=yof>8hj0CKX<%~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 zcmV<E1sD2>P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<TUQkR_MMrQ<4J(KZD~KF8kN^Mw|NsC0|Njjuhz%=<4J(NK{r)UQ zm;V0#^!E8GM3(mV`YcA5_xStx`THqDl`cq_|Ns9DD~KaLlN&dWDn*w5{{ApZnOSbF z6*7z%HH{H2iV-i0!^+`zh`bLiiIkzr`uqK@x7W76+xhzZ5H5+!(&ZO4jpgX_wZ7U= zW~uG(_SxO+4lIa1R-iLXni(~YsI$~gWT>C3&_i3I`~3YAFpFt>wPty<=<4$wIglnn zlp#HmQfR6-P@TQS-u(Uj^7Q!X?DV9s((?28)7k0${r=wK@1Cm9Hcy;yfw<x1@GVD| zv%J~C$>7%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<hPi3e&Ql785*!ueV_V@bp^!Z$HujA+O z+u!Zz>+{^;?fw1z!N}l1SfS6==k@pcfsw(Do5r}o+|}CZkDbU&Vy9trvC!7&yTjer z-0R2A<H^wERcfnPZLJ$PkNy7sa)i1LD~MKWtf;coeT~0tezy4e`!Y+KKv$ukprCt< zzM7hvRB5ZAtkCA^^Yr!kdyBqrfVbM-?eFpT$<O4ov$IiWss8@|#mwTbudn+1{F0x^ z&eY}{IFEphv^Y_nQD&+8{QO>Wuy%y9$j;=5mc+is-y=VhevZGLsm|i&@sXa%bcVap zI(#+&000$qQchC<hL!K{kDu?KkMHmAkB^_9kDs59dw|v}0009INkl<ZSi|ksS6353 z7zSXPqJZ6ZfenEqL_!IK5`oZ*5PFj)RYU=4B3%I~f+#g~#V+>Vv7lH{{*{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+<q$Hf48eKKor>~F$~lp zw47lo6?EV#Vk;RY(3Ju>mGBuRcpgWofKM{1m~e%NG6(8-qm*kBN!*cim=0WvLzodj zien5ND2J<n8;C-J9S6{Y42Byh#e0SWNW?26z$9|GfKYfO*GW|U87V##;3Tq=^G3Z> zUp#R#1gMa~V({Bo51J{!3}#qNwr9(5LI<eQKE42`{K<6$Dt*9DTTuv1dy(r;^y&c7 z0X(;?9P&P<iyBhK+91&fTwKoZa~HppkE!C~<=-+0b%3CD8F@6H?p5R-M{Zm_!}S4Y zQMLL(;dq`fmIV$;?DghAaOXjwTyxj-BRt5^DmX863^;0Q!y=qkR>9hy8ec8oORumd zfDP6V8v<CY1+Z8jfYn+6D*~9?QNz<#@#q!iI-BWCrtgRPSpmm(uKJWOSnzT)yq%pt zvYPU2I`HK)K#E2T1HmuPPZy7;#dK1Evv0c!h8^-{qZA<FcpAf`u=KtV!p27t=0G9k zL{bl}8SHiX5Y(G(nMDXkUwDQFBggt6@;J^!=NLp70b)7(f)~1-xPd?@JU9Rscp5p@ zA8L;>dS4h&w^<K55RVQnpa8ZLF254+JU~tXypbE(a<H3yR3<kBxI>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<MNUMnLSTa6?Gjx8 literal 0 HcmV?d00001 diff --git a/assets/images/flags/mwi.png b/assets/images/flags/mwi.png new file mode 100644 index 0000000000000000000000000000000000000000..7ddfbb17be5387d81a23b5a02c7dc7aa739685a1 GIT binary patch literal 754 zcmV<O0uB9%P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0004uP)t-sM{rD? z4IK{w1Je;F*b*q54IPdQ8UO$Q&JiXvo;5NA35E+91ONeq3mA<I8XyA(3jqR!3mMN5 zCJX@r(h(;R0Rtri2h|cMVh9oq0RoT=8z%z?4gmv}3>=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&<P$151q*@;7|9VO9|H!B3mViBCk_Du z2mu0g3Keh(6;KBcoDCe93><k17JmvCehL>|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)CUYINIAK<ja#K>Eq)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 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 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<Ru{p#V<eElaGYESbGKdfm7TJ$(W?dScA zzn<QFoTB(RUG?3<@(8`?Dxl*<OM?7@8Q$LyzW@HdfBbt_ugLrF-vin2?{l6he+5)< z$kW9!B;xSf>)ZL74FnnzFID%?Wof^6sk;6@`{@QD<<rwX`0{*T&p!W1A{!6$;jWPF zD<fSQvO+dnF>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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00093P)t-sM{rC6 zdOHPtJ^*z!{{Q|1dp!_>LI8R_|NsB>`u7TdKmd3*|Ns3BfI$FyI}?OOK$>0#eLnd6 z_yKx6-R<G+_V4KO=pBhjTc~hYrfnI8MMRuqSf_Inf<p*;IskSzFp*UYd^}#NcW1AD zD2+`okyB-@c>ezV=kn;w<I(i_^yKj7@%Ql9>)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)<e!7lKp=V{Sdpnm` z2zx$AonHWWI%BMMDvwM-nO#YrX#j9BGn7_)xrYdSJ~x$AB8x~FghT>(IUtBgJC{== zi%4y;fq}e<8-_qZnqXb2Yb%XTERau7qG|d3`CzDdZL)>{a4h5S;{5&j<?-hLc{mz} zOeT(1P@{5HrD`CGQ9GAnyxYOi=hd0WrwD#N1bjcX*uOiNTeaA}Tc~m>kXEzTya01E zYOsHe!kpFV+Eb%!c(sWsjYwv%f^oBiW375^vVw@fn8Dt~Sfp^U)wJB~-dU)21$#M` z$f1hCo}<pLL7QV&qil-7mXO4sh`ySt(X?W$bD_<xYp-{CxR5=XVm_HvM4Mu0u6$st zbpUxfJeXZzsdY4zQ9P7cB8yMZ=F<*+KrNA0KAB#e%BO6ve*ktkABjwbzKu|$Z+W<e zQ=@Kzyp|e;M@*hqMV(?nmRtjOFjJ##snW8j(Xeo`ffj^EB8*I2s&ls2ym_~Ys?x7D zlv=piy`apfE|OS#xQvX!mH=!gb+m^^ooZF3a8;pcMVePZnOSzWhEAbr&E?ZKlvr!9 zeTBS})achGhe<JxQiQ&eVXAM1zKnjlh6{Z@7=%7$t#o9pZz_#WO`>d4qhxxygBgWL zK9y2xu6t0TVqvLwE00iQt#>DiKLB$sM4VbBi%LP7S0IQ&Xb8?z00001bW%=J06^y0 zW&i*JJV``BR9M4fU>F6XU=%<BI|kt7<iG+rIWbk@FaSxHojMYS90QEYC^LYi!JIM! z%H4a^DKdaZT81JQY`vVTG)t5M11`LHlYDq4*#@ZdynI&>b7wybxdv!tMgGYwcze%* zTmwWCzx>O|NN?6B(*X4kzyAxQt4Mw{CewfiU%waTseb<U>p7VQJc-SZQ~R6yGxg~S zvJ5C+{4BpvA?=4+*4hKJ^~f+l=d7Gc_9rR+gd};zN#wX-{?X{X>{st!^D|kwO=u_G z0PUq-+z}5Y0%c<Zxt;o*g-A9)GnkuCSVbUJYSSEcR#vA*(!CILj5#GiLDoBlQFz;` z>0NrH8XyvKSNhg**F$XL^6PhaDooQN)qs<?!(<d>j(Df<UFWjJW8-pCy`XOYc*>)s zcxJ{urvA*Tp=)<KkZ6F|Dd*_>;i0~)Hmk#Zx!AUEo=c(uM#_%K!SbiAW&M_gtx4P9 zwPKMFX$D-ncHUjdU+xGS6BDzKQ=-Sh#iSWve?vJaC&DB^kexSBm^H=IaKQ`<QVg&> 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{aO<YvP~)`WZlCGj9}( cf>8hj03A)(@>(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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<TB2Y|JMMrQ<&lD@q6f6AE762${04QkOwiDd86a3K@{LvQr%^=}! zauz#v-)40uNq@e=((u^b?BeIp95Ng~c>B;66F76yBRJ96<NyEv>YAkf{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<n)h^v8)uGKY3?C<l{l%jf$u-sH% z{r30px4hCMDdTD(`_L9~gs9+UR`$}@(<Lb)M0NDa$rCqh`|Ica{rCR)^y}^H6*z6& zl9|;pKY@>_>6oGsHEU&kq<M{~(AeYOWpw@d`S;u2&lD=wH7DR|dF`mM(jzIz&fMKl zBw>1?+)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%<Wk_moMadK_vlcS3pDw}`E~J(+p_DTpBwmdqJ=i)Xf`+E67d6u- zDCvV9lZrT!ia4ehHrhrd0VihwCuj5Z`1#Kk?%w0k94h(97MG1Sk&molVZ4w4001O( zQchC<fA4pH?`UuDe}8{}e}8{}e}8}Pe}8{}cYp8i@9*#L@9*#Cdvn_W00EmxL_t(o z!((6=1*2dTi~<;tl#&n^W`O`6K`}-l0X`^Cl%JQ8n~M`4U<Mi>qhW7tVQ!!b0!Dgj z&1$-aV78g9mA<8krV3ub0W?6_&j)UVkzU)17O(+O?VfIg4PZ45gBxL_w`B7gpaF30 z#;QaZpsETtqDwDw({kNTgmzUT3{cbof|8<wtkgiw^?J(|uh!kzly9P`nVAlf&>?Jq zk~#<kfl2kPYgesXs*Wr|*Z?LqAP5c#22pdSt=zC>)_f4NFeEY;L=$O1X=pKsnly8P z%Dl;QLCmVqb~WM*kktT!^6)Ycr7>~g?5Q*QL9CkaR<H<R0~EADph8nqGbf^_clx3! zeVUrtl@Wm*U~z4tqEXe%-&fPgQQxjn59ny!T4zmH7Y7?}aOxz+0GM_UV_;IA08GlJ zUI-%y8z2wY4ob=m-SwcPj4*<*0djEdAOouF@*tTBW&~jaqyv(ZQWCYmAST}3-90u6 z!b!_Wh>H#&Y=DRg1ps6K$EX3L28<doNDN?OrvR{xHjhTZC>RBB003-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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0004WP)t-sM{rEq z5f<7H6xj?B+Y}kv5*OPS9RL6S+ZG!DfB@PP7}^jO+Z7ty7aQ6V7~n88*$EEX5f|JW zAlV2F>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`o6vSzP<U&%j#@y{MFOw zUt#4}TKwDF<xo@UW@*;|1^m|4>ThxQy1D$y$okLE<3vaDoSo}-dG@li^|G_`q^0`3 zyz6*-I-G?z00001bW%=J06^y0W&i*I0ZBwbR9M4fU>F6XU=%<B2Nu9iBtRIzBgnzQ z&CSBb%0iw2tSY(=f)Qcvru_V@WEsGsXCEY779A>Nrpia27aRkV^0EpN!aW4Y^8!~e zOIl88MM;#X8y7hSu!zNrWfm4^r=}-+8t{-|fWEVTWOZeJM!86Ur4BP02C$mA`o!j@ zRK<we8ENv7VE~u5xr<+7QJl9evpN$mS<%QS<m46NYa=2|W<pXB&@#5NwlGvvVqzpK z7GxEbHJI7hn0V!7q`1g1fLmIQgPRQ$noN@7j2y%RL>Ndw{LaC{%*aNL0px;F14a!P t<OXmO2}TVVHDJ^LY8o&KM!_fm0sxo=Wg}O5c*Xz#002ovPDHLkV1j_hPXGV_ literal 0 HcmV?d00001 diff --git a/assets/images/flags/per.png b/assets/images/flags/per.png new file mode 100644 index 0000000000000000000000000000000000000000..490a26441ee935007fff2056dc8c2d7042ee60a9 GIT binary patch literal 123 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSoB=)|uI>ds|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 zcmV-m0-ybfP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<R^H5AwMMrQ<|K0#ft<_7d)Hi{kK$p8pt=3Ad);EEmK$p9l!*%}M z0B*LctjA%?&^*G;M^>=Mi@u0Vt<#*tbgjr=g}smQ+7P?TP+YRWg}ji>&^utWyRpby z`P>HV*cOeyhRx7AO|H?*&^+eX99yx$qs44tv$}e^oci1aQm@R<(KqYZ7pKN&{oMlm z-2%DGQjoxc#?C^6ypo>7a=pw=ugG1?&po2VZQRr-_uB~W*%M;3xZu?z*V8U%w6o>b z9&5I)p~P-buFh+<u41#f#Lh(f+yuAERMOEjS+T@evBbN}PhGOVce$VT+X_#v&uX== zTfOks0000AbW%=J0RR90N?HH!PhaKSQicEk0R2fsK~z}7?bcUQ!cY)~VKxZ_LrWnb zN>QZQP*f~{GzFyl|9_2)afS)(#cp!LjOV@oo|~MUBoGjZL{7mY9|bpn8uF1UfWroK z`{pj4yl;C1J`!X2E%N>#@qzQ$Eo?h0ae>P#aCP0~0p<zplwYubGVJvwGl0|FJ^%&- z$Rc~_NMyiFVE{zQKos4>BN-^*z!Mp;aG*#AG#rSN0Rso-WT1}&8X1sVDDX``NHJ{x zN<T=U<2MAza9$?lIXp`oKpySxFJ`<8W!-S^dll}LCJ$=cc_g8GxKuwUD`#GhFFjuC u!>W~RzSvXoZTu&I684cIev?I_zkUF37e7s?&h>Es0000<MNUMnLSTZNQzPgA literal 0 HcmV?d00001 diff --git a/assets/images/flags/pri.png b/assets/images/flags/pri.png new file mode 100644 index 0000000000000000000000000000000000000000..cb0c54cd634d4bc546c3dd02bf5fbf084019aaf6 GIT binary patch literal 1880 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 SCijv60000<MNUMnLSTYByLe^* literal 0 HcmV?d00001 diff --git a/assets/images/flags/pyf.png b/assets/images/flags/pyf.png new file mode 100644 index 0000000000000000000000000000000000000000..66a5da6b82ffc63d15dbe66b821fd4f36a021983 GIT binary patch literal 1256 zcmV<E1Q+{>P)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<T&`?ZNMMrQ<-CJq@|NqVrCe9Hi{{H^mTWZb`CjbBc&JiZvTWSBC z5C}b-J!i51r6)FFuK&k)|G;R=YITaE(Es=B|McbW^7u4gt<&7?Pj$GyEIbK5od400 zU4Xu9jKra}+5f?5|IUhKhQa^Lg#XHc%@8I3qay#zh5wupydpEX4<Y^i{_*tr^7Q%s z{{GI`>-hTo%+~7v_2=j8^&L&5qqo|px!b?Z<t|&R|Ns8~_UW*}-^|zQowC=`-0i$6 zIi<MUMsBtL{{GjYr@F}FVuQf!+0Osx$GQ?EzA8HZwN12dcD^e+$`B?0+NJ-;djICc z{QLX=q96ahWB=W*|I32cs;>XrsQ<%nYl_3qf{JX5#GbR*#}Fh>bhw(Y*8lR~P<6Qf zvqzn4hX2Zd|JtXILP^FECI85L|LDm7;<~;qJ^!pY|D6>7tvSnqiL_~Y|JkIYw%Ve$ z+W(vqk*U+5X>LAev7fZqsIbkDPgQxA$)dK|zAio?QKnUSx*<@e(TkHxakguT!DEEL z<Id1~m&*RAF1k5Mu!4sF`0oGNrIxGH$&i+}jgZBorqQ>#PIR~jJ)HfsOb$YxbCJgX zxmSgj!vDBc&a$-s&WSr^u+zG|yUF6cmzu*7CI727$*QcpM^*o&D79~aqoluqnaEpy zy~WYz{;4zn!fXGrL#I1N|FlZ~)0M1AQvc74|G8HG*P#BsWAdm<|DhbjOj+^7kEA3q z|DYPIR%*x)B>%;8|EVzlyIU$$q=Ydzs46$aqNVD(iOY?a(6X<*Au-#hk=vz<&a$=K zteChWGTXzyP*{@x$$s0CW89i>>Zo129xwmRhu6Ww#BqB6zGVN*gYdn9@ufwz5F)pZ zk-HBf%B!x*q?^X1r}U;n>cE$?hl|Rrul3BD>#}p%!N32!U!iJm^2Lw46eirr$+!<8 z)498!XKcrLfv)Tg>;M1&3UpFVQvmP(|CR6WpYQKfeRC5400F2;L_t(o!((6=1*2e? z02X!%01MCnPG$-K=coar20#s9<Dvkt0S#bfq5!Z04dB2j>lC2qZ13$B;ckgj2?ss{ zL^FfDq&xz{x%2!)$uS^-FGnTV(>F}HL?)NdigW`+x;OF(WcV<r=g(NJCg3kZvH^nJ zhu61fNij1Et=h6?GPeS$26V04d-33A!EB%bTW{=GIW5(QL<2VL6g+Wy-=<oi0oR`1 z+P;=shC~DQsA-%(rl8W=xb5omhj(sjEDj~n0L634$2HF!?Vow&@x5nSFRu%vkZ8aH zZte@3Cl_|kdwcuQ>o*U$xsymVpm5gZQ<vuSl}$MG?*3h^C+g}UBpTpSH2duQ-m0{o zr3YTTf3<&kjWvk|7)#2}?NE&^m^gLOZq;2Y+FW%>O-RDB{FPD3%}PqsmmFEfA1zFB zO4Kot=8fZ#uIHUJMYfK|LYwr=q-W~L8(&)9Brn$>X=gxAcGfp@h?Fa?XsM2|H6*J% zU}7>C=Sk$}5w~H&TcF@A(!?Yr#0V8>jFc3rjH5Nvr~%Y6fZ~ScC>RC90ssI#){;<h S1YsZm0000<MNUMnLSTZI;k5q% literal 0 HcmV?d00001 diff --git a/assets/images/flags/qat.png b/assets/images/flags/qat.png new file mode 100644 index 0000000000000000000000000000000000000000..1e8461e918b676d6b06ef7dc202530a61b7d9fcf GIT binary patch literal 2021 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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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0000*P)t-sM{rE% z#w5<T9Eg)8|Ns9aTr;MnP9a+}08zU9(H2m8hV;!BLTxV<T&x>o!F8e9JbKE0r`%ME z(JgSsgsa~0_51Vp{C?QMe*gdg0d!JMQvg8b*k%9#0Q^ZrK~z}7?UTt4!ypVqyU<d) zWo`feQ(HA9m|zCuGou_VIeIS|#k*ZgOUpkOU9NlQoF4EvI~M__DJ<!X^v)duU>L%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`YoVZzX8<iBY;Lv x0V;g}DD^8qtM>r4-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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP00009a7bBm000ie z000ie0hKEb8vp<SK2S_lMMrQ<(cJIQ+wTuYumAu54L+F;LZl5onGQmv4@a;6|Nqk5 z@Y3Ax|Ni~|`Q-ol=>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<P`|-qyqk;eT*rT<W|M}(p z_~ifZuvU6ea*k}Ev9n}`Vyn8l-Qmi*#ICixvsidsrLn^P`Qv4SU}%JQOms(Bd0J_T zX#e!c;@`7mfMoyp-T(2r_w(GXxu9r(gJ5){SbJRk?5D7|vQBPb=jrWIkA(L2^pv8z zu(!*tx6A9vw70aMT6Sfjq@3^5$XJnrLx6eE)8DhW!`r_j=-|OcgnPEU(AvN{?86S~ z&aJ1hWbyLz>&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~PQ<KsI%#?V^GC<Wa zR461kJWR+*O^hr9R7`y`ViFUQJuDRX$TC1i)7mp4GS1J=P)>lnFp$-;4~h@8G*%QL zGZ6WtwQSvO4CF=m$uxjVSW4Z*NLgGEe>z~omyqEE4!|^89*i0=d<<Y^rvR{yHW5d` cC>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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0002AP)t-sM{rF0 z#>nzjTZO05?HnWuPNDXWlI|QO|Ns9BPNMEAGWe>k?<zBer_g7S#Ti+r^l^6ZM@{*v ztw(yf8d<1Ug}#_CQ;ITM(9qz*(&+Vngu>J3Id8PlA~gNu<k8;k{_*nq#>w@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<r{7-E6`76^WlVT1*^J@hL?Xa{4QQ~=Ov7u+3UH}T_UeY^uex{Vi5H$nyO z<p(ImxxE7(eb@Z}mc3CQAPkjaf&Gb?ClD9kLGnW&fv^4^@Pq_DLwD?T)#$jv+#><q zHG|FDU@oQXwC~y?s$zla`-e=MJvOm`?yWOG<<tT>!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 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 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 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 ST*{sR0000<MNUMnLSTZ78?H$J literal 0 HcmV?d00001 diff --git a/assets/images/flags/ton.png b/assets/images/flags/ton.png new file mode 100644 index 0000000000000000000000000000000000000000..84cf20ef5c2f712f2c66e49bf7bbe8c0a37be123 GIT binary patch literal 139 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0L3?#3!&-4XSoB=)|uI>ds|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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0006yP)t-sM{rDa zs@%^H4|A#B%m4s%s@!y{-F2$l05h5Y|Nr~y>T#*z4nd&<HJTktr6*FUa;e~Rs@`y^ z;?f-*;)jadQBnE&{d<+lf33}Ss@ig?-|w!j?x?8z{r+Z!!r*Xm{{H{hIXS<p!XZtj z*x>Kb6%~Z8#eJ;McB<KLsN<Bgum98t2|At1r?|<~>C`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)*(h<?dPU*}s&cs;LcYVRYY^}P9mb0t)&>H>H66wq^!@+3n%`CdUcI3=5_Rk;M z$U(QgdgseDqPLlIs^9$47j3BL+Q&%x&>C;4<Hf;c$HHUS$4c+bD4DgUpSGQ|ynt}1 z;?>4ZFkG+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-gsQsZ<XSSgG0D63A zShyDj;NG!3StL_}4TyA$U+Wu|mRG8m%d@NjW_&P?GXU;sDZv7SijmLi=~ex))sGFy zR$DNS$1MPEDZvI>Qf@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{HB5kTebIeBdYW<fN}RyGzJ`erseI0Kll`eNB>6QJ6jE7{J_ zC~{I;G0qLrFLhq;!ssU;T`H@qdp9aTvoREM1g3PuC66<ETBP)<p<?WVs)~J(INZeC z;y!UHk_|+d2*TlvngzX+0C*%Pj{tG49i0%9k0eTx%^g6<odb=sDoF?Qty_08OR3Ka zfZPE<0!h6u^F}J4r∈xLGc=`Db!;xwK04DNTTtzEZy~%&omTc=!A%E#?FOGScfa z(=(Jq-Nqv?#K{HnD+RgGnFiAG6{B%Zr5tT5ny)orP@(|PDF7Cv80%&m5rJqjUyo65 zF?13j-o8m0qP|~Qi2c8JM}S1T6P2%|o+kV^;BWa~09Rabh0GtgpTCXfl!Nd90000< KMNUMnLSTZW74L8W literal 0 HcmV?d00001 diff --git a/assets/images/flags/ury.png b/assets/images/flags/ury.png new file mode 100644 index 0000000000000000000000000000000000000000..c41e2780a85f64e8a507342a73468d8149819e59 GIT binary patch literal 1017 zcmV<V0|xwwP)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0006~P)t-sM{rEt z-QE2B{J6Qh{{H^h*x38~`~Uy{063`q{r&j(_lkpMiGg1I{r|_J0JxnXx|$HhxrM); z5rl3G$E6YAx)jo`62hSn^~)9f{QdIt_4)exyu82O-rnu*@8aU(;^N-#@ACTk`sC&1 z{{R2@`1$$y`uh6&+St_H-QJ#?kKy6t?d|RU{{N4NYvkkP?(g#I>+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&$y<LGQzZSyO{{7jvT0t9Kf@9#G??soDayU9q{n*%cv5%nh(mR48x-l<>J@k z+`7cMhTYe{y{uxup%<l*JD`q0zOj1L%%{AZ5x=l+*3h}OnH<f+kE4eTz^`Pal0u<{ z4Zffrs+dWhi!k)`^w!Oz<KND^nhn{~u)UxZ%c&OG)xzA@zoC#u;oj1<mJ#vt^u(hN z*s~MYvlHp*==aYSwU-gKm>$KuklE6)%BK;hk~PSv6Q+$GqlXO1rxWSI6u6lY>%$er zx{k)VgXH1Q%D;==*S)!!59Q#@!J{3gjULCR6QhO<r-}{3p8>(Nd%?1Bx|<T2e+=fn z6vwC`xt9mIm<`aa63VC%$fyy%uWZn)62!QF$fgmyoDYg}48)%R%BKy$pAp2P5}tz$ zyq_b!of4XV4CTHQx|$Nms2kkBe&GNB00DGTPE!Ct=GbNc00C7=L_t(o!((6=1*2dT zKmj`ifa(S?QDT6a4LhePivuesxdxbrvvYay20Oy!IEVoV16Zwj^UIQ>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_hlQvEc<d!GL07e7$ zwn`uWy22nUMMiQAU}tBswbRw(V%Jh2H#>7GtFXwhuuDk}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)<h;3K|Lk000e1NJLTq002M$002M;0{{R3owtGP0005_P)t-sM{rE7 zt*zL#SJJdt`19@G&YA!H_W%F?|KI@q{`~&;`1<<#_2S$3?dbjY`uOe5_~WVl{r&Fj z=l%Nd;p5`Pr-<j}=GCgI`tQ;F`0e!L;`Z$4`tZ`*wYBo*!sO=V(7%uP?c(vjIppNz z?d|RN?9J+`0o$dbz^$;;*4F;{`t;q|@z~h)<F51R(de@z&$Vdt>eBe^-_gE@_~o(q z`T6ta!`P-#^y99?U|_(&!0oy*w0mvnwzSUB(DK^a&8kc8#ZlP2YvRq3%yV<-)3wgF zXt=el-=~-M=eF_Hf#<MD&Z$e~<>l<E0K==G`02d<`~U3U#LBHv-mx+M`uyk8v%0&# z^xxjp%(K_LZN<jM%db_^t3>YJy6w0lxw4(*%#GN+ZvOuM-MypTo}Tpd_3zGY?#5Ql zcqHVqx8KW~*S&GnwtM5sfVjH5(yK${t*yVq#=EGd@bdKKv9$B><m10=$CfSd^7YoZ zdD6<U>FVpp$jRThlK%et;;Kmd^yL5i{_V|f*T0SY{r|_7Ec5W_>&%$@@zcDxtKg<M z>%zwG!aUJ?d(G0(-kg-<l^@7lT+C~5^z!V{&%?5clg!Gj=g3&_(rdqQOXjyT%Er_E z{`<_*(Co~Y{Qdj((OdZQ<NWj3)rX15c|+Ebm+i%HjTg%;00001bW%=J06^y0W&i*I z2}wjjR9M4fU>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<eLRC^JUqSGUaXD9?*`R1+Y_3%-oC?NjG0Z!XL5(<Lt&Yg*aoE}gZ2oh^X8Xbf#y zylCOXY4fI325OOIKtx0D>=_f5P46o5*CxvV&-D7vnUg2=mn7TClVyN~7kgv(lD^_J zc1;no43IMS&aCSxO|j7s<sr)eK4}w=tin`VbunHpva_?ji=_@_1&XZrXrVf4z=$zm d6pVrq3;>9FVF(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 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 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+Y<uPSNWSA+FvPh(mOhQtT8q<TxY4gaVBebHI zlN@S2N?}{H^pIDlBoymWvS-gk(Uw-*UHixP`~Bzh=jVJge0|olh{Xf|uoPZ0|Alq` zKMZW4FVvsV0-#~O8w2F)4{R*^3BGngNitA9K)n<&ad^gExqv}|=ch+ds0L6wo{WVr zkKtVl+$)Cc1mKWRl?}7^0n%Lg902FVbQ+KZxX?I9l~B#HMge)>3ibKmPZu_`Z6BAy z<XuPz0i`ccV?t^|2xmPY#~rqd!B7`s?KFg0JI4Xobnr40qBOv<IEIl3BhKks5(v6{ zDa34nSGCZ01D=$_*d3ty1sDnj0$64S5;1^?Z*ByzT;S~rMvGvr1T0Ko^$OUg06iYq zEdwuS2#|pe2HI90H7a!c9tLZ;C<=@CC{BYV1`rd7r^YY|2C~M17UE40_M+QZfV~Lw zlY%}U<*|_7YL3_(lR43bZgHsr!QpU5*(uUu2^zHpH_=DW9oOx+#yK5Nr0-{PNW^0m zgnGkV2Y>zv-(IW}xE}xTh(FQHO9*FBLjvlBz<Mc0y%C1_;39MEM=4F)#;eICsYzk& z8CF>)IdYrpE2SsxWT<vB<W4jefsh2EQZoB*a!OKkV}lrZYC>d&ZIRP0Ofc%LFjzrv zC}8o~2u=rA2c08ICKE^YZD;J?#-IiT4q~0hSJ`<g;&V4YIf`j(g;Bly^T&0qOtBL& z%-XZ8@h0BL9qtNC%*GNkHAFYM(ewq#P#xDzLbEhM3y<i|_V8OTvL{-&o7`w4zj1kN zq$-DGlZbQ`jT#qTODFdP;u0|y6@c&E%IGR*NBH8Q-gw(3wtd(=(*==`M1P;n;Qn@C z_kwnw3Yj!uYumemi6Eh$!NUyIi^*q}sUlHrY(2kosa2-*<?!$@F<>(g$~dg5rwZ<N z4Gmw=D>|B1J$tNZ_2k8Q7fFlDv_vWmys`D^vu01D-Wc?<vb^UPGz;i|Pxle<fJFZJ zw33r<MJ%C{2GNVo7#pthtIg0biz{U$+j2v9T~SR1qS3A_{d#D|u?a4>E8c`y$M$kS zI4LOW&c9WGnTaI@qQt9Nn_MCNbarV?yHk9z+r6pK?LXM4c6g^|lpf5J#U;4EZgPp* zqb&~VIy91-ZQfyG8@(BO-V(nix<UQ={P79nC%svn-)%6p*|nLL|26)Yng|GMjNDQB zYkP-Trme810&Nzr9iftD-hcXXz2nrE<-Zr^xtotE$+$|*NKW<K+?8?Xz4M)gA20>U zxpFdpSDmrm`_6C7{X70>N<{i{`kEj3eUKRj(@@RirIw)P<dX-}a<#*a9Vv}#E|8_@ zinT{SkJ}&kxGPjX7?Glhx-Wx<xxc;*_TGNvV>~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%<NF)NE5Cc*D%kPZ+dQ$GQBVKXVPpG)ApBJD`&tBT{ra&AN7p>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<List<String>> getCountries( + Future<List<Country>> 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<String>((country) => country['name'] as String).toList(); + return bodyJson + .map<String>((country) => country['name'] as String) + .map((name) => Country.fromCakePayName(name)) + .whereType<Country>() + .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<List<String>> getCountries() async => + Future<List<Country>> 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<void> setup({ () => CakePayService(getIt.get<SecureStorage>(), getIt.get<CakePayApi>())); getIt.registerFactory( - () => CakePayCardsListViewModel(cakePayService: getIt.get<CakePayService>())); + () => CakePayCardsListViewModel(cakePayService: getIt.get<CakePayService>(), + settingsStore: getIt.get<SettingsStore>())); getIt.registerFactory(() => CakePayAuthViewModel(cakePayService: getIt.get<CakePayService>())); 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<String> with Serializable<String> { + const Country({required String code, required this.fullName, required this.countryCode}) + : super(title: fullName, raw: code); + + final String fullName; + final String countryCode; + + static List<Country> 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<String, String> _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<String, Country> 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<SyncIndicatorTheme>()!.syncedBackgroundColor, - border: Border.all( - color: Colors.white.withOpacity(0.2), + width: 32, + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Theme.of(context).extension<SyncIndicatorTheme>()!.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<FilterTheme>()!.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<SyncIndicatorTheme>()!.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<DashboardPageTheme>()!.textColor, + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ], ), - child: Image.asset( - 'assets/images/filter.png', - color: Theme.of(context).extension<FilterTheme>()!.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<void> showFilterWidget(BuildContext context) async { @@ -148,6 +204,32 @@ class CakePayCardsPage extends BasePage { } } + +Future<void> showCountryPicker(BuildContext context, CakePayCardsListViewModel cardsListViewModel) async { + await showPopUp<void>( + 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<WalletType, Node>.of(nodes), powNodes = ObservableMap<WalletType, Node>.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<BackgroundTasks>(); 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<CakePayVendor> 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<CakePayVendor> cakePayVendors; @observable - List<String> availableCountries; + List<Country> 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<void> 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<void> 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 04/15] 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 <omarh.ismail1@gmail.com> --- 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<T>(); } - Future<void> tapItemByKey(String key, {bool shouldPumpAndSettle = true}) async { + Future<void> 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<void> 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<void> scrollUntilVisible(String childKey, String parentScrollableKey, - {double delta = 300}) async { - final scrollableWidget = find.descendant( - of: find.byKey(Key(parentScrollableKey)), + Future<void> 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<ScrollableState>(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<Element> of, + required FinderBase<Element> matching, + }) { + final textWidget = find.descendant(of: of, matching: matching); + + expect(textWidget, findsOneWidget); + } + Future<void> 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<void> 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<void> restoreWalletThroughSeedsFlow() async { - await _welcomeToRestoreFromSeedsPath(); - await _restoreFromSeeds(); + //* ========== Handles flow from welcome to creating a new wallet =============== + Future<void> welcomePageToCreateNewWalletFlow( + WalletType walletTypeToCreate, + List<int> walletPin, + ) async { + await _welcomeToCreateWalletPath(walletTypeToCreate, walletPin); + + await _generateNewWalletDetails(); + + await _confirmPreSeedInfo(); + + await _confirmWalletDetails(); } - Future<void> restoreWalletThroughKeysFlow() async { - await _welcomeToRestoreFromSeedsPath(); + //* ========== Handles flow from welcome to restoring wallet from seeds =============== + Future<void> welcomePageToRestoreWalletThroughSeedsFlow( + WalletType walletTypeToRestore, + String walletSeed, + List<int> walletPin, + ) async { + await _welcomeToRestoreFromSeedsOrKeysPath(walletTypeToRestore, walletPin); + await _restoreFromSeeds(walletTypeToRestore, walletSeed); + } + + //* ========== Handles flow from welcome to restoring wallet from keys =============== + Future<void> welcomePageToRestoreWalletThroughKeysFlow( + WalletType walletTypeToRestore, + List<int> walletPin, + ) async { + await _welcomeToRestoreFromSeedsOrKeysPath(walletTypeToRestore, walletPin); await _restoreFromKeys(); } - Future<void> _welcomeToRestoreFromSeedsPath() async { - // --------- Welcome Page --------------- - await _welcomePageRobot.navigateToRestoreWalletPage(); + //* ========== Handles switching to wallet list or menu from dashboard =============== + Future<void> 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<void> 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<void> _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<void> 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<void> setupPinCodeForWallet(List<int> 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<void> _welcomeToCreateWalletPath( + WalletType walletTypeToCreate, + List<int> pin, + ) async { + await _welcomePageRobot.navigateToCreateNewWalletPage(); + + await setupPinCodeForWallet(pin); + + await _selectWalletTypeForWallet(walletTypeToCreate); + } + + Future<void> _welcomeToRestoreFromSeedsOrKeysPath( + WalletType walletTypeToRestore, + List<int> pin, + ) async { + await _welcomePageRobot.navigateToRestoreWalletPage(); + + await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); + + await setupPinCodeForWallet(pin); + + await _selectWalletTypeForWallet(walletTypeToRestore); + } + + //* ============ Handles New Wallet Type Page ================== + Future<void> _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<void> _restoreFromSeeds() async { + //* ============ Handles New Wallet Page ================== + Future<void> _generateNewWalletDetails() async { + await _newWalletPageRobot.isNewWalletPage(); + + await _newWalletPageRobot.generateWalletName(); + + await _newWalletPageRobot.onNextButtonPressed(); + } + + //* ============ Handles Pre Seed Page ===================== + Future<void> _confirmPreSeedInfo() async { + await _preSeedPageRobot.isPreSeedPage(); + + await _preSeedPageRobot.onConfirmButtonPressed(); + } + + //* ============ Handles Wallet Seed Page ================== + Future<void> _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<void> _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<void> _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<void> hasMenuWidget() async { + commonTestCases.hasType<MenuWidget>(); + } + + void displaysTheCorrectWalletNameAndSubName() { + final menuWidgetState = tester.state<MenuWidgetState>(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<void> navigateToWalletMenu() async { + await commonTestCases.tapItemByKey('dashboard_page_menu_widget_wallet_menu_button_key'); + await commonTestCases.defaultSleepTime(); + } + + Future<void> 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<void> isDashboardPage() async { await commonTestCases.isSpecificPage<DashboardPage>(); } + Future<void> confirmWalletTypeIsDisplayedCorrectly( + WalletType type, { + bool isHaven = false, + }) async { + final cryptoBalanceWidget = + tester.widget<CryptoBalanceWidget>(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<void> confirmRightCryptoAssetTitleDisplaysPerPageView(WalletType type, - {bool isHaven = false}) async { + Future<void> 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<void> swipeDashboardTab(bool swipeRight) async { + await commonTestCases.swipeByPageKey( + key: 'dashboard_page_view_key', + swipeRight: swipeRight, + ); + await commonTestCases.defaultSleepTime(); + } + + Future<void> openDrawerMenu() async { + await commonTestCases.tapItemByKey('dashboard_page_wallet_menu_button_key'); + await commonTestCases.defaultSleepTime(); } Future<void> 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<void> isNewWalletPage() async { + await commonTestCases.isSpecificPage<NewWalletPage>(); + } + + Future<void> enterWalletName(String walletName) async { + await commonTestCases.enterText( + walletName, + 'new_wallet_page_wallet_name_textformfield_key', + ); + await commonTestCases.defaultSleepTime(); + } + + Future<void> generateWalletName() async { + await commonTestCases.tapItemByKey( + 'new_wallet_page_wallet_name_textformfield_generate_name_button_key', + ); + await commonTestCases.defaultSleepTime(); + } + + Future<void> 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<void> pushPinButton(int index) async { - await commonTestCases.tapItemByKey('pin_code_button_${index}_key'); - } - - Future<void> enterPinCode(List<int> pinCode, bool isFirstEntry) async { + Future<void> enterPinCode(List<int> 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<void> isPreSeedPage() async { + await commonTestCases.isSpecificPage<PreSeedPage>(); + } + + Future<void> 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<void> enterBlockHeightForWalletRestore(String blockHeight) async { + await commonTestCases.enterText( + blockHeight, + 'wallet_restore_from_seed_blockheight_textfield_key', + ); + await tester.pumpAndSettle(); + } + + Future<void> 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<void> 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<void> navigateToRestoreFromSeedsPage() async { - await commonTestCases.tapItemByKey('restore_options_from_seeds_button_key'); + Future<void> 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<void> isSecurityAndBackupPage() async { + await commonTestCases.isSpecificPage<SecurityBackupPage>(); + } + + void hasTitle() { + commonTestCases.hasText(S.current.security_and_backup); + } + + Future<void> 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<void> 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<void> isTransactionsPage() async { + await commonTestCases.isSpecificPage<TransactionsPage>(); + } + + Future<void> confirmTransactionsPageConstantsDisplayProperly() async { + await commonTestCases.defaultSleepTime(); + + final transactionsPage = tester.widget<TransactionsPage>(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<void> confirmTransactionHistoryListDisplaysCorrectly(bool hasTxHistoryWhileSyncing) async { + // Retrieve the TransactionsPage widget and its DashboardViewModel + final transactionsPage = tester.widget<TransactionsPage>(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<void> _performItemChecks(DashboardViewModel dashboardViewModel) async { + List<ActionListItem> items = dashboardViewModel.items; + for (var item in items) { + final keyId = (item.key as ValueKey<String>).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<void> _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<void> _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<void> _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<void> _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<void> _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<void> isWalletGroupDescriptionPage() async { + await commonTestCases.isSpecificPage<WalletGroupDescriptionPage>(); + } + + void hasTitle() { + commonTestCases.hasText(S.current.wallet_group); + } + + Future<void> navigateToCreateNewSeedPage() async { + await commonTestCases.tapItemByKey( + 'wallet_group_description_page_create_new_seed_button_key', + ); + } + + Future<void> 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<void> isWalletKeysAndSeedPage() async { + await commonTestCases.isSpecificPage<WalletKeysPage>(); + } + + void hasTitle() { + final walletKeysPage = tester.widget<WalletKeysPage>(find.byType(WalletKeysPage)); + final walletKeysViewModel = walletKeysPage.walletKeysViewModel; + commonTestCases.hasText(walletKeysViewModel.title); + } + + void hasShareWarning() { + commonTestCases.hasText(S.current.do_not_share_warning_text.toUpperCase()); + } + + Future<void> confirmWalletCredentials(WalletType walletType) async { + final walletKeysPage = tester.widget<WalletKeysPage>(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<void> 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<void> isWalletListPage() async { + await commonTestCases.isSpecificPage<WalletListPageRobot>(); + } + + void displaysCorrectTitle() { + commonTestCases.hasText(S.current.wallets); + } + + Future<void> navigateToCreateNewWalletPage() async { + commonTestCases.tapItemByKey('wallet_list_page_create_new_wallet_button_key'); + } + + Future<void> 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<void> isWalletSeedPage() async { + await commonTestCases.isSpecificPage<WalletSeedPage>(); + } + + Future<void> onNextButtonPressed() async { + await commonTestCases.tapItemByKey('wallet_seed_page_next_button_key'); + await commonTestCases.defaultSleepTime(); + } + + Future<void> onConfirmButtonOnSeedAlertDialogPressed() async { + await commonTestCases.tapItemByKey('wallet_seed_page_seed_alert_confirm_button_key'); + await commonTestCases.defaultSleepTime(); + } + + Future<void> onBackButtonOnSeedAlertDialogPressed() async { + await commonTestCases.tapItemByKey('wallet_seed_page_seed_alert_back_button_key'); + await commonTestCases.defaultSleepTime(); + } + + void confirmWalletDetailsDisplayCorrectly() { + final walletSeedPage = tester.widget<WalletSeedPage>(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<void> onSaveSeedsButtonPressed() async { + await commonTestCases.tapItemByKey('wallet_seed_page_save_seeds_button_key'); + await commonTestCases.defaultSleepTime(); + } + + Future<void> 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<void> _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: <Widget>[ 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<CakeTextTheme>()! - .secondaryTextColor, + color: Theme.of(context).extension<CakeTextTheme>()!.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<String> 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<String> 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<PlaceholderTheme>()!.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<PlaceholderTheme>()!.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<CakeTextTheme>()!.dateSectionRowColor))); + height: 35, + alignment: Alignment.center, + color: Colors.transparent, + child: Text( + title, + style: TextStyle( + fontSize: 12, + color: Theme.of(context).extension<CakeTextTheme>()!.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<void>( 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<MenuWidget> { 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<OrderTheme>()!.iconColor; + final iconColor = Theme.of(context).extension<OrderTheme>()!.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: <Widget>[ - Text('$from → $to', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).extension<DashboardPageTheme>()!.textColor - )), - formattedAmount != null - ? Text(formattedAmount! + ' ' + to, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).extension<DashboardPageTheme>()!.textColor - )) - : Container() - ]), - SizedBox(height: 5), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: <Widget>[ - Text(createdAtFormattedDate, - style: TextStyle( - fontSize: 14, - color: Theme.of(context).extension<CakeTextTheme>()!.dateSectionRowColor)) - ]) - ], - ) - ) + mainAxisSize: MainAxisSize.min, + children: [ + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[ + Text('$from → $to', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).extension<DashboardPageTheme>()!.textColor)), + formattedAmount != null + ? Text(formattedAmount! + ' ' + to, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: + Theme.of(context).extension<DashboardPageTheme>()!.textColor)) + : Container() + ]), + SizedBox(height: 5), + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[ + Text(createdAtFormattedDate, + style: TextStyle( + fontSize: 14, + color: + Theme.of(context).extension<CakeTextTheme>()!.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<TransactionTradeTheme>()!.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<TransactionTradeTheme>()!.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: <Widget>[ - Row( - children: [ - Text(title, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: <Widget>[ + 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<DashboardPageTheme>()!.textColor)) - ]), + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).extension<DashboardPageTheme>()!.textColor, + ), + ) + ], + ), SizedBox(height: 5), - Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: <Widget>[ - Text(formattedDate, + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: <Widget>[ + Text(formattedDate, + style: TextStyle( + fontSize: 14, + color: Theme.of(context) + .extension<CakeTextTheme>()! + .dateSectionRowColor)), + Text( + formattedFiatAmount, style: TextStyle( - fontSize: 14, - color: - Theme.of(context).extension<CakeTextTheme>()!.dateSectionRowColor)), - Text(formattedFiatAmount, - style: TextStyle( - fontSize: 14, - color: - Theme.of(context).extension<CakeTextTheme>()!.dateSectionRowColor)) - ]) + fontSize: 14, + color: Theme.of(context).extension<CakeTextTheme>()!.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<WalletNameForm> { 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<WalletNameForm> { 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<WalletNameForm> { 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<WalletNameForm> { 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<void>( @@ -318,6 +325,7 @@ class _WalletNameFormState extends State<WalletNameForm> { 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<WalletNameForm> { 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<WalletNameForm> { ), 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: <Widget>[ 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<void> _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<PinCodeWidget> 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<WalletRestorationFromQRVM>(param1: restoreWallet.type); + final restoreFromQRViewModel = + getIt.get<WalletRestorationFromQRVM>(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<WalletRestoreFromSeedForm> { ), if (widget.type == WalletType.monero || widget.type == WalletType.wownero) GestureDetector( + key: ValueKey('wallet_restore_from_seed_seedtype_picker_button_key'), onTap: () async { await showPopUp<void>( context: context, @@ -264,6 +265,7 @@ class WalletRestoreFromSeedFormState extends State<WalletRestoreFromSeedForm> { 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<bool>( - 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<CakeTextTheme>()! - .buttonTextColor), + fontSize: 14, + fontWeight: FontWeight.w600, + color: Theme.of(context).extension<CakeTextTheme>()!.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: <Widget>[ @@ -106,6 +114,7 @@ class WalletSeedPage extends BasePage { crossAxisAlignment: CrossAxisAlignment.center, children: <Widget>[ 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<CakeTextTheme>()!.secondaryTextColor), + color: + Theme.of(context).extension<CakeTextTheme>()!.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<TransactionTradeTheme>()!.detailsTitlesColor), + fontSize: 12, + fontWeight: FontWeight.normal, + color: Theme.of(context) + .extension<TransactionTradeTheme>()! + .detailsTitlesColor, + ), ), ) : Offstage(), @@ -145,9 +162,10 @@ class WalletSeedPage extends BasePage { mainAxisSize: MainAxisSize.max, children: <Widget>[ 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<void>(context, S.of(context).copied_to_clipboard); - }, - text: S.of(context).copy, - color: Theme.of(context).extension<PinCodeTheme>()!.indicatorsColor, - textColor: Colors.white)), - )) + key: ValueKey('wallet_seed_page_copy_seeds_button_key'), + onPressed: () { + ClipboardUtil.setSensitiveDataToClipboard( + ClipboardData(text: walletSeedViewModel.seed), + ); + showBar<void>(context, S.of(context).copied_to_clipboard); + }, + text: S.of(context).copy, + color: Theme.of(context).extension<PinCodeTheme>()!.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<PinCodeWidget> 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<PinCodeRequiredDuration>( + 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<ItemType> 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<T> 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<T> 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<void>(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<void>(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<T> 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<T> 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<void>(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<WalletListBody> { child: Column( children: <Widget>[ 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<WalletListBody> { ), 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<bool>? bitcoinMempoolAPIEnabled; final Function()? toggleSingleScan; final WalletType walletType; + final Key? blockHeightTextFieldKey; @override State<StatefulWidget> createState() => BlockchainHeightState(); @@ -81,6 +83,7 @@ class BlockchainHeightState extends State<BlockchainHeightWidget> { 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: <Widget>[ - 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<Item> extends State<Picker<Item>> { 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<SeedLanguageSelector> { @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<void> 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 = <OrderListItem>[], - orderId = '' { - _onOrdersChanged = - ordersSource.watch().listen((_) async => await updateOrderList()); + OrdersStoreBase({required this.ordersSource, required this.settingsStore}) + : orders = <OrderListItem>[], + 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 = <TradeListItem>[] { - _onTradesChanged = - tradesSource.watch().listen((_) async => await updateTradeList()); + : trades = <TradeListItem>[] { + _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<void> updateTradeList() async => trades = - tradesSource.values.map((trade) => TradeListItem( - trade: trade, - settingsStore: settingsStore)).toList(); -} \ No newline at end of file + Future<void> 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<SettingsStore>().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<ActionListItem> formattedItemsList(List<ActionListItem> items) { final formattedList = <ActionListItem>[]; @@ -11,7 +12,12 @@ List<ActionListItem> formattedItemsList(List<ActionListItem> 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<ActionListItem> formattedItemsList(List<ActionListItem> 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<StandartListItem>() { @@ -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 <omarh.ismail1@gmail.com> Date: Fri, 8 Nov 2024 12:50:21 +0200 Subject: [PATCH 05/15] 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<BitcoinOutput> 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<int> 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 <github@rafael.saes.dev> Date: Sat, 9 Nov 2024 02:20:06 -0300 Subject: [PATCH 06/15] 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<OrderCard> 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<void> logout(String email) async { + Future<void> 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 <cyjan@mrcyjanek.net> Date: Sat, 9 Nov 2024 19:59:47 +0100 Subject: [PATCH 07/15] 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 <omarh.ismail1@gmail.com> --- .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<String?> 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<String?> 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<void> commit(); + Future<String?> 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<String?> 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<String?> 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<PendingTransactionDescription> _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<void> 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<MoneroBalance, @override MoneroWalletKeys get keys => 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<MoneroBalance, } } + Future<bool> 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<PendingTransaction> 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<void> 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<String?> 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<String?> 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<String?> 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<String?> 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<String?> 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<void> setup({ getIt.registerFactory(() => WalletKeysViewModel(getIt.get<AppStore>())); getIt.registerFactory(() => WalletKeysPage(getIt.get<WalletKeysViewModel>())); + + getIt.registerFactory(() => AnimatedURModel(getIt.get<AppStore>())); + + getIt.registerFactoryParam<AnimatedURPage, String, void>((String urQr, _) => + AnimatedURPage(getIt.get<AnimatedURModel>(), urQr: urQr)); getIt.registerFactoryParam<ContactViewModel, ContactRecord?, void>( (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<void> defaultSettingsMigration( case 42: updateBtcElectrumNodeToUseSSL(nodes, sharedPreferences); break; + case 43: + _updateCakeXmrNode(nodes); + break; + default: break; } @@ -273,6 +277,15 @@ Future<void> defaultSettingsMigration( await sharedPreferences.setInt(PreferencesKey.currentDefaultSettingsMigrationVersion, version); } +void _updateCakeXmrNode(Box<Node> nodes) { + final node = nodes.values.firstWhereOrNull((element) => element.uriRaw == newCakeWalletMoneroUri); + + if (node != null && !node.trusted) { + node.trusted = true; + node.save(); + } +} + void updateBtcElectrumNodeToUseSSL(Box<Node> nodes, SharedPreferences sharedPreferences) { final btcElectrumNode = nodes.values.firstWhereOrNull((element) => element.uriRaw == newCakeWalletBitcoinUri); @@ -843,7 +856,7 @@ Future<void> 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<String> presentQRScanner() async { +Future<String> presentQRScanner(BuildContext context) async { isQrScannerShown = true; try { - final result = await BarcodeScanner.scan(); + final result = await Navigator.of(context).push<String>( + 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<BarcodeScannerSimple> createState() => _BarcodeScannerSimpleState(); +} + +class _BarcodeScannerSimpleState extends State<BarcodeScannerSimple> { + Barcode? _barcode; + bool popped = false; + + List<String> urCodes = []; + late var ur = URQRToURQRData(urCodes); + + void _handleBarcode(BarcodeCapture barcodes) { + try { + _handleBarcodeInternal(barcodes); + } catch (e) { + showPopUp<void>( + 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<int> _urParts() { + List<int> 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<String> inputs; + Map<String, dynamic> toJson() { + return { + "tag": tag, + "str": str, + "progress": progress, + "count": count, + "error": error, + "inputs": inputs, + }; + } +} + +URQRData URQRToURQRData(List<String> 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<int> 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<void> 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 <String, String>{ + 'primaryAddress': keys.primaryAddress, 'privateSpendKey': keys.privateSpendKey, 'privateViewKey': keys.privateViewKey, 'publicSpendKey': keys.publicSpendKey, @@ -357,9 +358,32 @@ class CWMonero extends Monero { Future<int> 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<bool> 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<dynamic> createRoute(RouteSettings settings) { case Routes.setup2faInfoPage: return MaterialPageRoute<void>(builder: (_) => getIt.get<Setup2FAInfoPage>()); + case Routes.urqrAnimatedPage: + return MaterialPageRoute<void>(builder: (_) => getIt.get<AnimatedURPage>(param1: settings.arguments)); + case Routes.homeSettings: return CupertinoPageRoute<void>( builder: (_) => getIt.get<HomeSettingsPage>(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<MenuWidget> { 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<MenuWidget> { 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<ExchangeTradeForm> { 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<void>( 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<DesktopSettingsPage> { 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<void> _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<void>( + 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<String> frames; + + @override + // ignore: library_private_types_in_public_api + _URQRState createState() => _URQRState(); +} + +const urFrameTime = 1000 ~/ 5; + +class _URQRState extends State<URQR> { + 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<String, dynamic> 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<RestoredWallet> 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<String, dynamic> credentials = {'type': walletType, ...queryParameters}; + Map<String, dynamic> 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<void> commitTransaction() async { + Future<void> 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<int> getCurrentHeight(); + Future<bool> 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 <borodenko.sv@gmail.com> Date: Sat, 9 Nov 2024 21:00:56 +0200 Subject: [PATCH 08/15] 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 <omarh.ismail1@gmail.com> --- .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 zcmeFacTiMK*Du-x5fzXTBxjJIB*{q_C8~&k837SN$$}t3(ujy835t@l4;T<oB&SiL zA|j$7Ns=TH21IhYz4sn`-tRl-RNbmu_trh9>^}td?q0ptZ-rjHx@QbAG|;8r&aoW; zKz~?I%NYLP0bpA@Ee(7UcSj`;{$O|0KI?Yk>SZ^Ni`T5dL945l)_jK@FJ7@Ww!UcP z<?_~A4S+7U!&(PVdUpMMO+f*N5Te%erf3nWf7@RK{vz-ffxig+Mc^+2e-ZeLz+VLZ zBJdZ1zX<$A;4cFIM<YP!yhVi+1Hi%cc20dm^w<BR1^z4CUj+Ul@E3u<2>eChF9LrN z_=~__1pXrM7lFSB{6*k@YXp+0El3Ra+v;C}aCQCpfgG(H`S-+yoOZ<dx>*bIeQkVn z=1p2uiovzs5N@h=Z<XvL4?e$NjhbqT6nou}%nmpH(w4nD){7^{-{~Ot3IHJL{m!=E zc(dVq)dBtyM;i&R44i*EJ}y1hYJ(xwpS{d1n5Dk$0erKMi3ie%{p>sI!5K_`U39u{ zwet8IAprUx1nNQ}q_L`{^1IsDYiTFn<tvwrQ>hQbH?rFh@)yRd)@^U$u=k&bN%V(( zcL_qW#~Bgg8b9FM`{cL!M^ir}MlTC#Q37ypnic@1tNS@5)5&kApQ|TpKKdXEfJFi_ zv-Gjval1BUCD<8hix1R0gQx+xFQ`Td2;*b-zCr>>D+xcfi=P7E=a4~$#Xe;`_y4?# zP)}CmzpeurnZrSL@V?iUMK%eRJWyXN*tQLT#{`6zAJO+GbufxVB{~Y@D|C>W!T@A0 zChuqwCnSNiq2CKMkG?@3)70Jw0w8)OmKx5AG(7wA@8f}xLFPlA@ZaI_d;MevMNgzs z>p^04D6!0=0dOGF(inVq@lgQc7=ok4YMIa`XFooRQ0xLA{2{_u{j)y<EI5{h@KE^H zc!ZsB19h{FwPg{E7?HU`!;JUS($+8DLr^O&`7_8^!R*<fOb2Fims*K?0LuHVCmBjb z6m`qCkJTb`4{n+LV&4luMQJJWF7aLVL`&P;Yf)?@sG3zz);OBHryFwGe+)6J{z<oS zF78T2@?8{y|AT6C|K!}3ddOKZs-o_uw-3pZ5@J159W)^4iHV5S_MG%6<GD*lw7+;R z+x{X1+u0XEv?SR~<*F)K=lyd*i;oqdX_us+Y<(GSo9k<vL-0qN<cOxq2JpTL1$5?C zfC?sFzn(qO%0_;jn{|A25(-GXjUe2A!+pGB5~6}M!UX4F<~P`}|JqbI{995|q?K%k z#OF&%Ebz(udW4;vqz_UBWL7;neO0bPF@X7DM8&c9?~jqmS<o#DsYChBl*_``CBc#* z5$H5f1p*?e1C)UHtobf{V(#d9Zte9Ze>%gyDqHn7QIk?c#(F;BS9w=Y9xQDa*7k*y z3aTX>F$KTW&H2)}1|f5v{D?)Z@@#UB_o{``PE#T{dMf{UT8ci^N1w9)dAfu?r9+>p z|9N@<eYyjE`Y&By^eK}E;vdyQDrD_EZp%b$rMdtyH9yI0SI^qU=N+7y81oh<i@F_u zc=IZ<q#Yk^MzpXq5aEt=Z5Ma%<&R#`51XtPTe^!5A8TV{#D<(w<zld{)=*pOTbpUP zMbWF1ip<N1nwzb1NQ9)~d)_x0ckV<6kvXUyXwW+x=Vs))ix=wN1Ev2e6Ianwd^5U) ztQsf#f~A&?4N1LaM@`Sq2$)HTHSlpLa9dwmES$SBJA6Evtorb_zWw(Y$F)&$A0Uud ziKr}xR4a09L$n85n*O0jS7z6aKtcS&DL}t!@AwV5AX3QYYlAyA{F4h?ai7|j-M;xC z;Yt_@R|QEQx+C-~p<a*{$ASBFA2k?@L4;>#ce#L=_K9P5-NN$6E|IN6+9<Kv|C4+9 zF<(+KXPdIx59lz2C%X_M_i8gr`2~Xd8{NqS`_I>~JZfk#ARtC)@dlx(Z)Pw$ZF6I1 z3af3Y>2|U;5>j{Z`G!QWtnY#0RU1)qmU#9y9bHZ5S0>k=*m7);aV1<y^d!7eF-!K} z0x8{vKoG<@zXgqo9hkmR`C@>KtmlJmTlH$%Grv_snX<H?OAt>&_jB31bKhnplXRqw z>MBb9B)Blng{a7?2l0QVm^Q79%p_LZ>b|f|&cf^ygt;Rdq*9L>Xc>(1mQn-4TVw^1 z*1jJc5hS5o-aFsa<cGos2=?siv0Q$PE&>#vroCL<e?TVd-b1e9;v`S;@X$-mo_S{A z48ccQPmkpbec$S5Yx`FQjFJ@qIKc-sVqq5{uhEk~JT@?pc>)F)q<woo;H>A^-M$|8 z%x!((C|np4P|4Z-PVehjc3xTgwi(YX<b?Cr8a<>bz~gU7ES1J7pW@&iu3+RQM~cjX zZpUh$lyfgWh*1GU)xraCTBG5j%z-^?(=XvgK%`mZFx=6!>n>_<;C%P=8`5tdCp65s zfFv!bKeS!VuuXW?N3!>=&=sKr_+|%$^X(CZ2R=?4`*|ww#CjQqhAQ%Q-k=8fLZm~Q zyP4z8KTdi?&gX-d8qbLjVO>1U?NH-St%o4Th0pA|qc@|;<JTBOKceqFhf|+yL?WME z`<LI+8Ds-XV=3<~Dr(kM&E5|1cM<#goRe^U{?BWL-SxW3C$@kz1z9*E&DiXzklL<I zFys@PpDypsBw14e!`+C8j~{H#oCaz}Gj_i<U<QN)#LXWYovG3M{x6PSIMrF7i>lvn z%YZZ19yt!#auZ*p2pY2~?pA?02+}NMFdTz)&n4x43xSi|LKX|dc%L$L<~6X%qeZ|J ziPYw^E;;F5{)u;ZZG8E=7S!L(yNEECJ*kq9`VrRrQWLUV?qPI^LJ}-ZpBL&Hbc>)J z8g4;it^C_@E}q>--N?2T^~vG%Y6s_;>}RfUP|VZ^XoHXSB$r2K5B=%#L8I$ziEcJ6 zz|)Vw_=(pJ?RA&ag7)0LjqGhnJ$-%IORHvGlkR&o>hp`9cqBk#`p}>75@jc+>obq1 zs{4AmFp$|g*R249<fE2Ujo4MT)FdGQ_MofI-qwdVh9>4<2pOU#o1tI6$@M*beg<1M z2*Lo+q1Hj8oUF*R+qS${<se3oAoB10|ERyYkp0X95ytK^6!K)6s#)ZoZ;O7_#hWW< zKkMa!jOwYea>yj}1S&B;y4C2zj?FcMC`+gD<81y<$b={oLkXXTjj#F?{zxL*J^Eg0 zwd7LzOD1ZNb`l9gM`DjwjL+S;)D1C48gU2n>QF?a!A?LhZieeK;SG0K#rRO`Te4sk zA5n|l_pAN*84@zlT||vB;<27>HAQ5b1?Eo0-4Aa}bdE#qWuPXJzw-$(wxD~meUr{= z7K(dl_l=n29q*8i3Gs;S<X^H?RC7?h;kh{W9pea?UsTxlYa$XZ$g0qrk6gO~;c-^o zv@SwNlb6wVM^$C`3dw%bPMV<<$SUriJN+!Tw%aM03xI!+Ow{n{pm}4F<Ira^b;&f_ zgk8lv)*@R0-WKt%n7ypm3%q8A>lDd7VvWYyGe1Da4R;}yxaZwJz0T=U6=Xnknj&Fh zLYzaEHc=zV$)L(<a#Sm+n7eVJOF~0M9<nBYu%_jBSn+&+m=i{rAn@ur?@btP?UA*` zFvIjy^Nm(|?42|Bv9?D^tpD`7k3twkZzC&Wu7l_1YvOhB*rS?IJNr<P5^^6Un&l}P zz+4V#{h>TO>JQzUo}ZusY0QY0OEQWIH_eDZvVu>r=SNO_meP<%mKkhdSNp_WerN={ zk&qyIOmbMK^6REFcZ(#Zboo`YX#jp3f)lynTNASOLyeSBqTq}lDeHuJ!XH+2u9>qz zRhY(Y&H;yse!EsGKJLz&3>Vxz$WlYxo%1Jq0VU~0j&u-QNz@MyuMRSiXF6`jflOiA zg)BItjW6Oj5AY<zd=Y8HyJ*NLH+J&4UZw(hB*t|jnIgHJyjW_=fv^#UxP|d|OD8!g zB-JE0Lp6l(KaKzNJihReY&f2rj@``H%b_5I7wB${-1n4CLdKiSpS1w6siN$hy;z+F z7i)5xeuUb4u$h?R@@T5RiUjtYpeU1CeYD(cKkxp$Bq;W!%g9<lZp7|J7@vr7hzV`B zZ#oK)k$MCJ!1~JkDuM{o`soWDFG2a8`BCqUBBwuqFlvLL6N*T<3sLcAE0I9>BsXHW zryN4q0SPk9X-98YI2_=KG}wW??!Vi7S=;x(Cl6%`z$SsL801EHifM?j1KnJ1J_g_> zI^8SC|7LSO|6e(S&-;rIgK_4cn5!ozoWS#m17si^P4&nlA0O#f*wgFeZuvhvZ<7mx zeOQt7;XfK`aR2^|W~=}HNx=Vy{^4V4gsb}8eZxB4MJ^_P`r+j<){Y^uW3&C>|BpZ5 z!~dMgFd4dKkKM#8XaM{E16af4;cMsKJ-?j21rX2vrzWA7{?Az1oC_wz{}V|6eG6#+ z!`a|t{Qq+l{w4lDom$eO@*Co_<Yu@Vk@8Xp2iXGQW8x$tAX_=2!p&AJ^1sFm_W8e~ zolR2z|AXXT&i+5w#^0&`U27l~|L>dsU%>wY{@2V<FZj=O4?g%C!yyR&n=R)58%MK6 zUhjo~B!wag3pQhR)<YbzTNHkk5G^fBD|_yA*ACt#RQ^MHcI5g8Uw0Onyq_1yDJ)R= zZ~4-l|JZpzG{*j0w#AL)h5xoR&HtJ<x*<EEe;a|}KT<yU_~U=e6#q_6#tQ!zb}|xw z4Q|tY{ui#M>rmW%xXp)Sb2A}*q#E7T5?+MnKXxqctG<xeaX&Awxpkwn%uiODOwHmX z=I7yWBxO`s*)}&P(^8m&-V#E#xo9hiL?Zy<wM{|`$zPJC@FMvI=s{1D0KQ#7;pG={ z4Fsh5V-ewHL9dgDV|iic=8OKD*o;<!MVy<*4gjQJ0R#WZvb^=$lKRqe7aO@^0*Fl5 zK2Yz^fh6cJ#ApcpW2-I_rtP@79R^a!X$9Q)Yzx>7M}${c=<$3q>8>gk`i5_l>s_-= zC`+EVX(Qgf{8Q`&Lhq33G301(K<4l--(Pcu`^g0%KnTP*@l42CN)l%&Jh(~A3fr0z zYw8|mW{A>lLdGXpjhxK^f^JCvWAS~P1xA1mLMoYXL(iWu;`;LJXPQmeqr|Xc!Ppn4 zrq-y8%#1iM3-UWc>kH(V3J|_&xt(OL>t8o5i2oCk*?7t0v>lW=EH_KHK>9RtrU*+0 zvFT3An@q8(Vtq?G*)lShDt_asU=O5a3SmqMTc5jY*=BU=6psV;0$9XgaGOeGWaj1F z{UbKr*Q+R0{l?vs?#$=OM<D>9XSaZ`<F35Q(+S0$>SQv~M=+E4@N#_97%Y4+%1Lq~ zV>aqiX5^lLXoDfG^i=$r(N&eFPv4Ob@HbDD0pXT_$}+T)O>-yeVI#&Ex!0cM_L*+# zY?IRB(KesZKOADK*K{hhiF4w1w-y`hzWmv%OWTnhhq2Yjh?$1M%339vgYsNrOVi;~ zr*t<~wD%{zzbtzT!?S-6CO~im!{Z4>P0&A3Ri)5Zojc>YFSSD)6Ng6|T@0nKWe9@C zCQBpUwU!mm7>A=|3g##(gy{2<ee?r&QWJ#$A8Uhx=%jgftsX|e6mt$~OY3voYDsGD zE~1<u==<oyNBLpVKbD&bST7|#Y#QT?%pr#WU_s};|I<|JAtCIo-?B9d)e!VYZC%}; z3}#fsuUZ7=>a4bLkF*}S)IJhB){{*NGY}nN21Zuxi;TL=w;go><IP`wkGL*6Rp@kW z$FwIcNoE|!ojih<2X1x3kGOI0+#fcgC+2lJkHq(BD)BQIQ-SP|D2#snP%K(V3)gJ( zF@R{%xE$h2q5+buIAezsNe0<{Bv|Q18XF55gov~-vQ-x`ImbED1LtzjJRF6w!`bi! zaykC{aZGH=rr~n_q!%Xgu*h{Fm_DqC5Az(lTd12@^bZoRa)FUoF!)h}v3LnGwq96O zlI|?m^j)gzB!$#iIDY4=?f~Sb0@518u<^u)O&*#h#D+V{6hv8CvMj%lf;8mxF|5O@ z_cYnQn6yzyWTysd(a1#w*!Xs0AW?);RUm<i_3_hpjG$i|xln;LTk`ZDuPr&zvO;gs z1O!$lGV719z$z{6a#oRnbnP2zq(W92WX}508zTTvUFvQfc2K1x$Y~Sgk_|+T&DxUB zV5KmZW{XlvvhQxSM*94gV43qEFmsX*w>;G`j0v>^rjp3<#rI6n(8K3WlS9(96~_L} zU`JikdU!I?c--N7_@wT)S&V%(Y*Db_b5Jcyf>si>;GOc-?^Ul8Jr-5^Ut{h;)&F55 zwgA@7f3bE)hGtCjU?%kEwFOy%i<MhLC&?RUwICFDKG&(={-s5Z^b~+bxQO>ycH3L` za(T?X2`QM<%Uprdjm%z?No<C}m*%PH_HJ^%c>`Mz8si*wV^<bE6{!JR$yYz-ndr?~ z*04}u^}Z;x>8m?67{bjMIcOl~@kr~zj4Mj4J3_L!GY9gZBRnWbip8X#!m6E(F_8GV z=NwRVxatdgo8IwZubnXT8$UvR19!Y19e$MC2iep4IblD`&Fi&+g)Zj<s0Q#s|JoPW zsMpmt3MPw?OD3bw6?eC$AgRbi{(h&!Wkx{UU?OvAs*AcnRD99(v-3$y6-l4x5@C?$ z9z>%vAT*ItDmeQ&$hp3up`0H8U2UI}4<Eo@NK3-<@5bQr+0^1c^bcRSz&V4}yov0X z9fZGnK0OxmX9iXrnS&%U%#D^toNu-zHg_=s!m~{QA}|}b%CJ?Z9|%M>SrW2Hwg71? z(daz?JmIZ_f-EiQzr4w|BnFHyOIQ~pLmUjhX4TNOoE0~D>ceb{{i{=PkTYIO7f#Cu z*T>Ci*U8$!cW;kmSVDE5=CMiJpA1Ppgk)N?yNC>_rk2lVxm2?8ha<=5aK-%@903Sd zf)~BZ3%#)L#)hRw_`x3AjjI`gpg%q|gOLnH<{m5s;Y6PAuEmUbvzEQ97|th+gU**% zXj;XIMHp$7H>jfSiNKpF=93flvSOPyL@+{5rT`Giu~paApn@4h)OND?(hB5Jd*CQz zrkPe~Wf-Ow%os32hhZl02s*>&i0$YILLdwQq8mMsKfdco&_pZ%fLmDQnU8(eOAJzc zIXzbxA7gEerB=i=3@^Py2+Sssu9e_m=A^ql`LzZ1de;}Z)Xb%VDM*^o!>r8@Z*w4x zW}+B1qv<1#-CftHKNircJbNk=oFc(e$nOjzpJWD>>(OBGj(D^-eK9i9qZLP_g2_(D zRhrfUSJ}JI6&+U=MmS%5Qu6V2tSUaWT#Ch*ARW~DiEY|$R{H?ZWZFO3lIE_V)w%P2 z`^20_&KqYftJBP~M%bz%EsBAlMSorMP<Ii-cS`9aD;GccMKLE{3t$uThk%rDpj@-X zT+l&&A<EW1%+&B-hgQ&kCpc>G4E4Sl6ZYsSyDvV~?)PG88{GM^m6J%W?0A+asZCC8 z0O$B%x1G23zVjEIb}Bx93ndC@4r1Ka{4C<C8?Xia3gwJq<|l??9rebM3;@wKTu`Vn zx7dm6W_M7gB&<&s@plHs<ihOhIoYE4uoZUf6c-`Fk?ut@1@KH-JEn)@?+l1}2$N8Z zP-6;a!{)UYN0k=2wx&@KMnh@Eiumg6Ur6YU+aw&<#Q1uNd2qiZj;rqBZKa=dpyHd; zj(cEy4sk!U4<QHBa>-jiXXf)P0H)Sap9~*9ARAC8a{daF&E2ozDxyvJZF1p4A`+@G zqvSh^;tnKt-FdJDE)-G}pC-g(NpP`6niW~|MlX6r8AKZRhm~@~l_&|Tla!A<lSh>u z4q%3KZLS-owkT!^b7Od?$%1GZcCl7k+lZ~6skS{c4jx~_hBqMR*XL_>pv5^+1HoN+ z3q`yK?vTO5M_?V;o+w$Eo_f%%0|=A4$1NwYU>C)SB|y7&a|SOnr?;-3&Q$tsSAYZg zmsVPbkc%ov`(BUk69_jucRqB0H{Wu1yIg|_4Ma<thlpHMfsIduY;1K38H-!Mbm#&< zP8#M^YzMj>5A3>w*{|{<mYKnuaLgC=VJKRvkPA<#Ba<L-VS0v_O+^PYn=ebK|LAlg zGm8I#IXVpBLh3(KH-%&H!)|+CV(F&oDk^tLgUilVFs}sz{+ER!o~bKX8jNSaM$^4Z zkFt63IB&h$^@snJLc8_l7A(cB*=jLetGi~GaQuZVDo&IfYL(N5E^)t@U(*zK?F@$= zc48+!bw~VsjnJJC3O0@zqiT{^#Tm3eFvg53cV1Sw%sTPC^2$JvQpnoGRA+Rn#Js=o zynl6hF1IudpT}UPU*C}DRczFwBdCjT74otj5Rg<E{<={OSDcfl=GMHo$~CyG9a!n| zuD@QnGCFmGrGp|$aK=bO;OTr7b`Y(HorNwIq1(=5eu?_4<&*1c?lVCWGBss1cbGZM zTo2qa3;u3G!^aAz{kF4qg%2l#$tQry7k*MQy>*YDQ*;R9Tbg*a+S8f*rnh`MZ|mh+ zgKNKLX4YFK9(4Tju3fp7w}6hrhoVSI3*fv0)33&KZp?P_-5<Fws!+CW!op7x_0-eR zu6sVU<H;N8DbMBwzuo94y=c@mX`FsWH>}gJ<L*sJUl9<3n5mata1fg7VnBO68bPyV z;&g3#-OC)|w(;Mpry0Fh?OFNiOfA*R=?&F%-gjJIWZU1O^mS5&hHnq9S!=A>vCG{a z^E>=Cj5kIcr#68xT9<m8KxCVrj^2Ka<x++^oxIYBq0zqUbi`+l3ig^Mt?TYsJRqDz z&5qBt{J9K{Q#r>oF<8;kZ}4t+@V8I-)mz@m(-HR-I@;5ER1|N;qzOT#kty79z~RAo zhhN1&|LBsv;qQkjW_qI>H2tS87j#+`+R+WtOcdTV!eBE&S&NEa>kn=S@f5twRZw(r zL}R7<uGZBh(;7-4kn$Xw?m&G9W*CGRj3+9bx7MnF(v7>lH1Yela+QB=bk9_qq!gmy z1}&e9p7ZxCR$*04zTK$%>6udN;=D^%yg2G#Z8<!xpf+P_d5aaaz-&=?*2@+PZh#%l zQt%n>oR_I}Z8?6iJX)L7$*mqRFGm=;Sa6Q>%>(K_jPWBF<8fxc2Z$Hd=ZpD<IruxB z<kZW7t<3-z7)O%9l7UKWS-@|{dIu|+iY_;v59BJR6|;=^yfeJMI(1U#XYRSJNfe$j za9a|%ukd+?H97+yJ<7S~i{7kwS`D^{VCBL6z1n(WA^xeo@*-}RL~`$xNWwjYTEKJN zj_Oz}BIzNw2;d!EQA)<d%RyI*jf68I&S>kEf3+z-)g<4*?_yT#YHas{3oI4Pk%RJa zY?_<B4+TABcw>WApk+k%ND3+po_49MEu;dzFXzUV7p|d9fxLM%{Pvd)g7p62V8Xm+ zTJOQ0jJRV^x6K`=oqbz7<T+YRx{DMq+z0tmMLb@qSlo_cMIB4+utU)~$v(&Afi%rK z&QhJ*^Ud?jPN<zUVump<$w9%s2J}14tn2u=Ev?i97h;oQca_0$izZyLb8}Q^aQC~H z2|hj*-G0<Wv(1H}u}KPvxP*q#nq`JFl%-*dqD8!Q^9rD(Vb6K)r87N5j2QNr05Hwx zqU5iV?8n=_l`S+lk}H6wzp7(bH+fTY4b6rLTZ1*1T$Fe%f-S?&JGfF2&cDB0@Ycti zqxN=QDMl?1&CCfYbr*fMQr}Dub95RM1Wu<;nPWZ#uA!F<2q|#?O43|(VdI5+@);ww z4GN+>+_p33E+%1%^z>d-4PytnXv9O|&AV^|HB#Mds%9%NwO5y-AE0!ckjJ8%CpQXY zk1Z=VMR@!oEy4cODQirZAOtmFB6q^b2{-C#4aiEaQODIF=bLpI3YdZPTceifocS_` zi;{Zrpbs@seq{C&*#UN;!J5FS<@Ouayl4qjwYtFc>C?TMC4`PssKEWFu<?6fX66Oy zOJekk<`S@5^cC2SEtgM)vLuO|-~;fBY&9|&f`kcepWmY`ythFA<1bI+krG-Gv;oIE z8H%_d-mZ+fpQ$yAof@@;XzYu^_W!1MH)`iT*Rjh53bddkx<$Zr=*rm`yRRNU`zAj6 z`)U~P4ehj2Nc2iW!AwE!@d9uKW@}SSE$NWUh>%HP1)kS)^HiDX8+6Ibvy-}qi}s+M ze`DJ_5!iVdNmfgGC1p(VI@B}Q^Lh77`e~%b-hcYE%3n$cHRF;N%*blAbirEa=w~Il z9YAC9s`x;D){<Oc^nq_P_Ryv__J$6sKAmBo`?Zj~KX0)x@uE+CWc&>cm88_@-b)i5 zGZ)NN^`cXo+mKtweA;M*j!5AmU2{{CuwIwmS>z~E(D_~(fO+*>L2Hu-e(zHsBKd#l zbJX4&8o!Xm9(wGwpE;k#U`t!=BBVfGEH!E`5L7EDr<HKLYfG~YeBi6Mqw`qmjPH~~ z_oP*&dHHx<7}t(y0UQ3EGjU5x$`Vz5*N)x4=Q&y9Z3(IT!iu5&8ZL|b7;34e&sv{j zrvw`<MY#H3Ua|_DaMw+0NybqJ3xC2zYO4v)EY{|E<~WS#oe#7$o$sM~;#?p?3W1n5 z-bZ85x-K+9HJ5x@23JZD>td+b*AlOBN$}TFd{~2iV2os<kL0BqI<ZROYdz%3+gmhy zByONN;k1@ZT-$3w;Eq@)8}*A;>AvF7s88S1)~!3F9^t7yOpK7dI>48q200!1GN-Ee zDwsA^Xr{<Naq0noR3WYLV8K6MZN9qWjMdr|JhP^~-W*&z8a%A``bMQfAk$^pmMaK) zM-giwj@2FHhc-P?n|=%sxauyhdrC1SIt%E{bY4>%d=b&0#<NmF6Ud!602y#d;D0cB zjG>?s-Z3P6Ml)izLM68E2A@IR?mLlOpQ(xM+poQ>?rl71H^`B{W&AqVSB;h|c4N=p z?<HvQ34VA8bv296gBFY+Pd+tv>Ldf9BVdQptHk<C7Uj5xpH~n5nq5|V^Q9^GnCG6E zZkL&4#K|8SpxoNjB?nV@K8J>qc$Z2K;JfB;36|7K;zLP14hufw13!jaE=}(pEFOCL z&I7rh`3SXgqG;X49sUFK(<&~yeQ-OYBt#2@_E|;;>rm_V2v&IqJ2p{1YA}H-mr_kV zySesncN4hZMhyQg8d#)NTs}6W?<aJ;7^Ve!YqFQL1aM&`7q|H0_zpb0wGwM~3vV)* zg_Jy_sL&vlS%B<ti92d<CIm;oO-w%<=RVKEukZMj2BYVxgqAldUN4O*c`7IpRC1qI zAn`3~D>iV~R&OpoD$gn<YPRzc(An^zgVO76D%X_~vO`G1g9%IlzZ)q0^djl6W_Py3 zts`C%HG^=SRqf(2+WsZWCL=fsi1`^XbIlxanXHo^b6#KL>aYHJ<JPDjZ-40|TcSE* ztZ2?cXcY~8!w6DDi@4wpvI%an6N1$&1#??M9tq^ul?ZRqTkxkDSe@ln3;R0xdA5oK zKRf{%(StdH5xRl$%ut$UMLGSLI@d+>x=Ni_=lexL$tNN9GP%e4F7`|f4fQg9$iEqM z1y`CpvVs&5uA!Afi|IkB(q1#BelK^pxBn=W{(dc$KD+Hn%N-%9+}4uQ++Ecvw^n1# zE=<!26c3$#hF<(@L@p-+!fSN9*nhaIXg91UycKKu;eDU(yHIXtiF;`T)!JxCmnTgk z$MFNzVM2(kXc?1m&_3a~$Cj<0_ktsOE^-0Aw@yvbEw{h#&Fu}23>1iUP20HF;*=|r zGP_!Lu}T$=<5NV}CK|^VkLfA;w;|fFb5OPv9h~+vZE|tl{b9EByINoDj7>^jM8N~Z zC%7=55chvgKh2dP2$qfEDn#tHl-1YLRgjRD!-drrHhVG5*VsSru-BaSV})ZV&@zX# zp3BGgz2_--;HoF$zrKppNe=t6gg3)`{6TwjC&%5EeV@A~-C7$hy|^>dFE?Xfl`dp) zbH+!*zj_$~k0?WvgTj;r9e5<D@LJ1_`VwPDne+G6D=ZNO(LuU1J#MC_I!$2GicdkU zhR&d8uER{8f(M2y@MxYL&Xr2Vp7`}QSBLr?<$nK!QGFim&+bW%4p3{~(X9!wA4Z>P zB`6zAx4G*^ShtR`KP#E9(?$BHG|Q>|@CuPU{}nM4^o)jZ@Wmu;zYD}pZD>u+)U^`f zinZ^Se)fr$bY1lt=cHyiPpJj9qI@V#lq~85w+2756Nli!<oOC#^&T|U2C=4Y^k1Ek z9r(D>$MsvIx!HT#e9Ft=BHTpsUBPse2@7l#{Rg{><f-8i7>-+6eCM5z$n}x54X<3* z<yT1ck4O%;=P;jUTesxw-!Gt&(0mydy(*H%Xg@IVt~KA>a7|q>-S@j@_QR7=>%J#Y zk|;%tEsJg(@e*W<k9*cj`G-dJ7_Fp;dROZ07TN5Q=$*c}7u=)X$o}RUT28WH<hlpO zkdJ+RGCuXiX5(juU`fj2*ah{rq*0ahomHs^-nYxc-a+UA8h)ND0*qa7IO&Yv3G@bL zQ^shWMAVmKZ*63Eo}`NO;hFD65{)I4_ozx#-8Yz9ThYvbFQmmAQjc70e7)i7@N~c& zmceOtOP(NG0ZlX!<Fj{huIHB{Ae9`#$**qIYwX@W%v(2x3K$wX!|M*T+xH1_S#~4U zA5Am`<g+j0ruaQER5QC5<PCk?3vbJh^?e=6eEQUsS_D=?gS)+&)pOt$1u?>dXdyXD zq8sKCUy3Fdl8F@krz)ypZqe^j??c5(L9`vb-~<a~LCypTdpHLj7JVE_nfBrR2l3$$ zBv1KPoZ9JCpSqULIE@A8)h@#M;C6-rYs9ViQ5T7Nj%0HLp4x%(=K6#D@FY}Acbe-` zL}Vby?li4=-XiAK`4WaiVidYpYP@%g5v*aw&qiX@&U{#i+8R3$GB_&XH>DQ&vSoxL zcUZNn#^DS!BMTeUA*|jWq$T*ha#W2+jDpytT%ux*(_n@tLDN<<u8Q@GHidR-i@LP< zbl#$8P;zNG)cRfy8GHq6!K%~nl#OtPiTh@1_2_!MFO7;e?R$B&gqv@M$dQi8kzCZv zL5bg~z}Y<n2pxNma#9dm23$**D1ToH=WLV2DS9EGk~?#Ie4;M7&|MS+@W$=-2Y-Im z`e~6UQDMHy6(PZQ_BqGSRa}+Fkg*fP^{-fU8%Qf*Obr?&fQ7JfQdyHCc!EnyQh25L zYo_zVC5Ca)P|K5jrOdZmG)Gf12e_iUE>>NIQ(6RLAXu0BC{hztOIIYpv>i?v*s`~I zNWLMoIlLU;a_PEQ^aPO{{p<&LIT-O3EQDxEGjvi!!J}chKpZUKGV&dqNp6ZuiQGcj z&}mhpJou`{0e+GISUkoisJW;`Sow1(;5k03q8LMP({`45cuZKGGx>5ReRd9vk4nV5 z=FnK!_M-0BInCMy7BGEQbhLbCWN2*QcRw%3=i4;wsjgVt%&zoahtmLu^XNoss>CQX z7B>b}f21if<9h5mY6dDgMjzBO%i^b6-d^f-hsn~`RoZmXhObBb&l)X$fLHJd1Ivy` z3jLjU!G@}*0Ur7o%Rj7{<QaWV6lcQaEU;e+X8m=Nd86T5<?yQrEhBLIbmYFOnG)LX zE)CYwOqtj5PI`lQu3wKEOpK4Nd5L8^Fn!4F?WwG++`ZwdlbF;Zs-OAf+G^)`K8*a} zh%l1V;8|7=a`wB?SOC3qeLHx#XT@900@?H?WaR_HGSUa`kwS4l7xx~rxv)_AQMYW6 zF|K#w%nv`t@SGJ{qTGCw`lKx+6=Q&|i{gv76o}&~skap=^kOw>{2s1IKcngHUgnTF z!jEU<&(1rTJ<#`CK1wJ&Ca8o#KF}b8yU5mu<Tl1G;Td>kx#cJFNr@vOC|kcQ;U23E zUfK@>_)lCihU+BPLqf$~TPxh|x0d?S3(-^c)DX&BK2CoA=$XmgF3aJU8F(4V4;J5Q zJcK_8pXo(-sHBrmcu?rYYtp>)PtiCaVMg2+oIXm+2@$E>U`>tR^@e%Iwbp+|cFuQW zU6y0ID%a%D1qKHt{Th1tZ%kQ@mGMZz5W$aega9QMGz#>RAw8DYGLG!A{(WKi53{ak zwD%ob4WB7s;1|lx4C+5D7q~xnLM4p2(Q<uWc^c6y2Q6Bn#i!F%gD90dbTP-^xtZ3` zaq6o_>=Y90X!+@n>6t|JJZqVWw@d<JR5xd4t{|nc&v+EsM_-(uN)q?5`B4yLzIXk| zm>iQKd1#hLN7ZJZ4NKqhR&{##^YmIc%X1QGoKL#yYN3e<Vn|Q<P|iP<<rdHa5$RP* z{0|7Ff}`%JptD;NKY>|7k5X^+jAnnD?aw?-xn4dW_r*@=DL|ePIZENPHE5)XlcbTL z$C&04X*lQ!2YY8TDXHA0dDJ9&K$>Ia{gul}S)73_^R|cK&@|xym;eHyro#y!+qS!1 z07R{pS6RGsxki^{qq=<HX6EeAWPU=e*JnocnexvIElxlE)&Iwl=fru$DZ#h79Z{EN zgA6-KTHbHEyAz46>~$vf33gdZeLM^0F;^6wN6*2?hCfA(p4g@F(d?yYym?|a@;)5( z`gfaFqj-W>AdjR-`{|Wd(wC1m4=;_i6(k<*a#%MB<gW6Dkt9ul%rBmb=yq(V)(m_) zedByk-_R<L;;BHclvLH`ZD*uk@osyVlpCZ|z@?e$eAukx+<zSn41?Ly<np)Uf7sbH zDaH15U6m8iXQfxesSK7`Hs6l?IcGdonR_7iv}V8a_P$DpkD(ykWWnu-RTPDViij&! zRGE#cOofT0qi#c3`Y<gwyJ=N2EpMGX(VVxy{Ho~~F5&8rA=NHljAtE`XJAN+j8uVR znjFUt9J1yTIi!obfm11~ETMU$JHHy2^+Kb8c4vp~SBF#*63ZS(_alY&!@qPL3AUDm z-N5*Y#gb<!edR)Bf9ng915QPN8yT41vSmoF-_zsSnle%kkl%$`!{PPE^wP(=i&!WK zkJwM92p6X>G&I}}o2N?8e6^4`9etpBU-3w(y~|m{1FzVpW_ywKzYrY?2$bS1JCSkQ zvbdy*+kVpv^LiY+9q~!n$BIOH_uOmBgp1|mPRF1Xp9N@l_~)oT^&;+nq{w>fsKgFP zi$2(}jn!OXx0pM$ytbt;7OMStR&$3-&1KVP)<%&7h|fn5Zvj9iw%b_-To2KqyY;N? zd;80QWga*x)uuq&)QPlWwu5x_P+j@yXa8u!zG)_y0c20mSySjoqXWHen3x2HWLOKb z(%j$4r1I{Jsp5d(A?ntzBF-l2jzM14snBwWrP~mnj!KG+y%GkbZw{~m<K5}Zqy-qY z5_8ro;n245{^c(g1xx2wTSNoh7uk=O1~&cj(|~S5aAiU;1%z<+04hG~u86Fr=Wjn+ z{3@v;-L8>k?Py6`YO%9<?$q6Wo2Rm68_$CykHt3@ki!5aMbvUYV;sK{oKS)dj%7Oi z_Cnw((*_xca=^OA;{kQvRI2%HDG!f6e!qg$LVsc!6x*LfOPon6+X44=k9?@!whg!E z4lWe?;*4>|qD^*%uV`u0d}k}N)#T;kAFTSpbqvI!Aq4)HBd$Lrf!GZM7$#cg(c^mm zykBrkzfbzYc<5DegKfRo{rV#$WMiLyl&k}5u#3Q42g+`KY5UG1+HO}U!GQ~W)AV~a zXRT(f^BYeld-nSUCH1-m$$1Sp4h@h|Y*afygFPBHVmdm`T#RATwz*v)0gdN1&vRpm z1>e-0z5(^aGrd0*`pV%b;#SO$EG?iae$D-U^@>)+E}_DpEi#Hdb-AjT_BNBaVDZZD zLYqdhFw*7T71+%p9Q6WV^$yShvG|trHBUvBXRr7vCJxJg{I(FFt9(JjAaiCUpW{vN z{E`yA@-U-7w|^B97kRNjgM6n5+sWN3DrV3d_%A*C@=4(2p`qrBg@L%+E%RRMLu<$_ ziFepaCS#|>4cFTvK9T-Dre}jMG`^_&D8G?5`B>8Bxx7Wmg@WTJ4A|AWCq3X0B`q}E zTYbK!B1u$MvgIT&YmGg&<=8P0Z!Y1eJJ(8p3rm@u-|2yZvONE{B)6}laFlZ}CJ+E3 zI2Dx!4!fqe@kN=-Hbl0CU%yP7TlV^#UYL`5K$ZT}74}|?*zA43d6Y)Rf1g2H%l?v# z-2=CZo|Y|0&h1j4br5YG8hRsu5s&)QU29CQ{55I#Mncu;z@0(&Ce42A@fqnf+?D>q zF`y1o&OeTE?`C!d2O_LK?k1easr*vY9V31#^l13ZY<~9nL&-&kyUI(uIJ(>AWW(SX zpo;l=&;WEsy~K~LOG?Kyu5w&uRPR1>!t<g6lRjIfvdD3UTQ94j>iR{n$OnMWKAMo* z5g(3T3Z2mX7TChb*P9KyeIxxu68y!GO`f-EBk*ZHxe~KtI59Mo@na%;oQC#T=I**# ze*eVg3t<))pk9fQn|fuF4~$Tf(tPgK=BD}aMxDcmxxqTJdd?$zrByw$X%EHtf?R$B zBCAT-d)FRA`)||@7N&P8f_gZAO3tn0)#XgOT3~-?I<l*VpO~j8d9_qg>5W*x4hRDa zziYrHIxgD$=k?$g-=I3Z$`a$Q?yR>+xMjdX1C+LRVq&6qZLPA6B&F4D#NNS3!$8e9 zH9obYyF&B3rr!HmZrD9yE^iO~!MDZWEBq{OclCXH<+2>(t_b+G5QQCv<=gW2QQEvw zSRpZYUHUZYT3#6maU|4i3cQy^LihObqcz#Ar;WR#FJ4>Z3$AxQ&fL?>a!e&<_jvWj zKra#oBIY8INnm??LT;DbF+n%CE&sgYrbs=qjp>&+PICDELg??g%KqpzB<eS8O7K`e z>6%7rF$YJkRpmEYQNWzzcs@+}SMI^G3aT!o3@d$`tSS7SAY$~x%n1`@;U}#4oRa(T zXiYsBC3gZ#p5TYdVJzL}>_*k<PEA<xb%b4xioUPf0_AzKDbIG6o0%&s-c&@zL1tfq zid^%1fuA39?hBIw=Oy5L`WRU7H_@YX%k{SNG)J#KBYB4b!;nsW!o>~cMLV;6WH*uq zIMAE|fbF=O@U~{9eS*%|SpE8HJm=-x+mXDK&{9M3P5TqmvAC~!h$Ig-B^j{{{=L4w ze!%(nuU`%acjcQ%NKoRf6Lk9B&ID#0*6p~?^4bsK`RyjpGIFmtwfprlVHuCw*HIVD zg?RhReVchg+7f9jkXBWB0nykHIfJ}R7fbbd-y5B-yPsI7M#JiE-3E_O!Z0+jgI;w! z*h5cUOAmfj94Mu1vh&7L&&@_5G(Q^Hb)jqC<GAT@1^#h2+MAu(u&47=e+f7NY<QKR zwy`bYznHQ|TQ1A)pfD`8sb(ta^ee{wE;*3;wq*^uS*I_E<dm>E3+CnJdH8NDuWtnC zc!lww*8BqC9i!W7Rl!-@EsfnA0h2yIkHd}@9GDxxd#NiN(%TQ|44;DzKtb@py`0I} z5DL_`TR{^4I33KpK!0q#LA0W?Y2@4uSH;9;acZy;CaiWdanfVY&B*Q<^W{=01d#D{ zCPJmRnH|@;c3|iJ-j)BOYavCqT5P!I{tq8UJc|<t2E!yzy(<(@v0SHrB=O|P_cLM` z>$^X_+TxQvp`y+9ID#W+>9Ab>4PmNjBmFn_922x7toIeYI*OhPK$eJG$SSh9C?GB< zP1P1$e+?`)3RP6s)p8%~_4*Cdrkf-_*wb`G1KE5aZFFMA{E{-b(B<S2>RkIh*-L9| zM+cJZnQD>+1RvZyJfz&HiNy)^u7h!JRoj^6uOSyLh>Ylbq5&GKlt~RCbwE0mmKzo{ z4uNS2ic>3JOSgHKA~}ZzEgA-yU-SCGuXZtUP&gh6h=+^1lLff;-@IaCCL%hi0+mFV zKr<9LH8Etl%EOrwh@8*!7khJ|#prdWSY^e}-2>GL^<6N7hS3az=0VqF%5DlE%$z;E zgPX!Rx4hh2d(nUAPJ8XRU=JGqvq%soUf(p_{;jQYPIg;BtWEc6T%2jg_0aqqJ0p;V zvIB>uH%Dv*C<(S&V>Z<bJzM%^0-pKHhFsy0lJeGeUPDOnVWiTG6M}(-&mmnvH>Wm` ztSMJ7(lcBJ2@&}Jh`_3lD54FBvsQS9R*Suk-FuPzi4NNzSt{M!)$e1lpN25=+qb%` zvhoxy$Q|*rlv4fY&YePm)s7o$N0H3e0${+^Y>R>BE*2O8*t*=yPXumsTu&AV|2{W& z3U`{J6cXN?7hfl5PeGhUI%>9g7I9TT&@R5Z9^P-{+=vw0=yWli?#(*40|f1XIz6j) zVg0g9ixLk{d+79qi8(24*o|<L?7hPXIcbDkH#+YDk4fu0BWuKR1Zboq1tu>Z?a0`2 zB3%&FcgUs4Zxx;me~~id$A>B#_M|E^f_!dYI9;=oJ^N&CN41ZMbgj-@{psEP8QN{# zi2@dr@DuClg8d@<wZ9hxt>;dhhOlpWDW%-B<~!j%#O~n#-JDwAs@OYM?B@?127r%V zM<~Jwe?jh$T{|UV*48tKD`VidDy5eG6UQx)>uPr<t=Ug=F>i&P=*Vdv3%-B_nEqO2 zRtYd!W#)Q1#_WCeIb`NaWNz={twVhx1vVV^s&D{X0v3?O@kt+ZAy@_?Zq#id<4L%f z4JQfv<sF?py}c{nWEZ_DPPZeekTfM4H9|ASDf?xnl7X{Ix}Z{|t)_HEk>li0#MFL< zPt)cWwB3^*SfL>S9c-&>BxeRp4IoQCQVc3z9Z~`#^oFx-H4i$|X0duJc6?K1^10*1 z3v01*?ViXaH0UJYEN!z1m@dSe&9E{rK#P*jFg5POV3+)pD~}f_+HWkp8Akf=BKHrr zX%E#oc~=2UE2azD{c^FpZAd8|J1*|ux5D+vrFVt!v#V$hRcJJ-c|&G;R?rdB217dN zkBk#ekB<MubzCA;Y&S<ezle9ShW>QrRQ#KF3kbG4LWncsjpR&$sfa;shXWNL!Cbb% z;xwfW@wfn={mb}}10Byjs3R@Q_8?sGkhzMpasFaOkH6gNQ`V{G#Ur!C@$=)VgceA8 z_nV~QUVde(ttn9!UNq`%$1p?Z0bs}M;^M0=lIm^1_YW<%7lX`go2Z?_>=YfrQ4VV5 zJ-Q`Yf#&D7<skmrxSokb|Cr0=y<(Ka-5D2;&Ef$2oUfEyt&M;U^M;d?lix<zHl~^5 zXF<tBTM3f>RH@bEB@*vz_|+%C!W4BskdIG@1dT!*27+$PCj>1-ao;1-TGxD6&U~b` zu*oW1^(76vr5t_s<?`%zG)EL9Z<e-v@}b7F?B6e+#kGxlf8bD>QJb7F2gUvUg~Kpr z7>GQY`@n&msn=tfw@X4jZx}@o<J|&~fAWr)ZkCpGZNj+c)95su^qnHd-4}-$@4C|3 zR&M<WMI+3ho@dCFeUg$+vxWs6wSI@|*rh;uL{M+#&SCZJ@iIwYdM=ACkbWCl9sz_c zvU9YAJ^P1)?K`yJ=-|g&l=XipKNyXp%0YzPi^ZD5nOlg``Ufa2#PpR1ckP!Kq|}9d z>5*u5HvYTo_B-JfdC28Mf-Pn#k056h7JMA{z~gu^UDLAlkbEd+z*qQ95B7cALe}+_ z?C1KfR7@a@WC>tldq;LB3&BLYzcEJ8V9uoshrg@u;x!8HlB{=L(U$IAMnK6z7jArX z(%Eg&;>XT|&q4{gec!&x#az2b%hlUgoW)5I)}Q=UmC5%fsk*J`xfBu%^wE@;AYdy^ z&30ODK<9)ci22E=WCE8F8VwZ&F6-1)Ab!WglGM|&6j3OEj35Vwd!9Z%#?&AbE_KyE zR?L~iL~u%!4<!Ri;(pFP_}R8**^6w|vEBqr+m`^+A4#}DjX%tAGjpv<cHk!OHZF>D zw1ZU>Wd=BPFP6)2QO0=E$71fT5FIlRmq^5oGAdENRNq(N+Oknx#j3XJJ+g3&W9rPw zY9-K02lY;!!-2TlSYH|0FT*Vb1PvIX$2;ypE{MOeb@!o;7Qsa;^WKOA$a>W~b^7Yn ztCYG);sKv1b@7+8$|__BMt80(ez7a?`i1&7J2|Q!ii|{7;4y}qg3WD7dlQ~sb#nT2 z+zrrXmB&rH>eDtIJOyWwW<}jJl;v(Hpp(klS_@AH*-YdPFZfD1*LF^ky8BMRLnkEl z$kAU`9xIcaBasg5hXdb}7nysZ_cP_4DWqd++#o`(90#r_>nn#^$N_)a=_=XrQM36` ztG2Tjpx%h4m_P{{qEz7%Gc$*ApyX`eU46wtjw~*U)zzG4NrvXe54D32ERfjzevB81 zBWYIaX>`)LO8b>L{4S|%0ZD^XQ+{8Ho~Pl?gox=<>H-Z{(VgiN*GQdPreSvrb73SI zF|4q0ptexMsfUhS0E<q?c<592cZ$AwdPvt)FiU}!n<A+`UClp*yK`YOep^2hL@F@m z%#*Shh$R_IXJDoG5>9THf~C!V+nIrj&l$lL_4@@)YBM6<V|yv5DbYxs=MR@`*k~cv z)tpTfjbaHZg1>o1-q}xC+2EWNhH3j{vMRr)EZ$dk+s$<Z@s3$p<bzv;LFIGY796dO zyHC)9M*aYu{Rh)YE7e&XFoejZ!<#X+1<EPioz}^#$jAy#xZNQvk+Y5sCi-LN5=+5D z$wN8;I{FgryASDt4%xXqqu*0$NtcD4k#KFjsEZ`Vj}OY}sjKDernI1Rsdh{_e|HQ# z$V=Usw5!F`@}A|q7(LJo8TFd?xWt|+=9{YODTEEiLJ3&kBg|B*MK>Twc)5+uXfNbz zpze3<&xQ6l5M!H~aKPcRFS4_;M!TnNH@LnfE@c`=tjH)`91<fXNIkjv5l2+AG>ckW z=U^hd{Ana;^}tsGDMQ2@#N1`kI56_tw{OgpduPvC+%_f^6jP>%hr&^wT<>SqDMXJl zsvbHW--&Eo@ye)%9@o2<!w6?V(~7U?^Vm+YJ4E=veHVgqI~_z}=$GG5-(7}5{qFNE zooK1(-3)3)@T(7*=xbZ`=)m-DD28qAtJ<G0k5j(mqjdid(m^LADMke0_Z<3YkI`DF zunVuP3e^au^-d|zT@klHVMSBOw7ni43!y-oX#ig87lic2<7o;^Dyt;De;9RKf1+`L z0x@9GFf_S1WqY2U1YCE)3q=eW<L4pa8(FV&PWx8>>^B-2eP)4dmt#<8B0ROp0dc9* zIKY^mnPtmNX+e|qTHIUUqtimta>3j>azOHKHWDfS8#3M94Xl*{BVoi|xD51k#)9`O zJH647d+lF1a+g4?sWG95A689VDlh&Xf<MY|D*25nvSk*HM0xsBT3&v^&6NT#F=*Z+ zw{og)f#Vk7i*9HT(N0sE$xU;tPI*^qzF{oHvZt!8Q}Zfr_55IA@>1of2frbtVxb<O z3xAvx;Rt~`d52@#85jxp?!<Z8Xw;vTOje;jXRVPWB1|PA;mLgQ>H9z}=wNC3*qDZp zTriANrz8w}`}*!y89blG@cPwD-Va0ZuPUw}(brHIjYz<P5qhsq`dZZqI&gNZAjNt` zOv&m8V@%zE8i^_3A+z24RWHAVRY)3)>_Kyr2XM3TL>FucWy&Uz&O_atjq|KJk=^n} zi&85;$G+!#s&(+&fcW@OM3cj4^)c;8tI!sFe8%|X$jId{3zb2q!C8V$VEbqDDy6g` z{H((F7oBR)6NE_zR2YO!-W|vz&C)~);1L2b=$b;<YiqSt^g^558(okebwy=ZDaAs_ zg8J*#%B@zhhIih#^=cT5Ae)bOzp!w2x*=Pc$}r33WL0$so*ESX;!3?heb;h>^X61> zaq$<wzBLAzM^J3-?2DGTXlxjmEi<c+EFCZ7Q5g#p>LGygo?e}KFs;$opd`H0VscY! zh&K<UwQRAUq)rl8QGFT5eA3(5dfUJg`xGxYH@;IJ+4-cIaO8s@yV@dQ)^!^Yj;N`t zi@gbq$mY<dsyra&ymujNS%l`gS?r?OO_CHMA$EfHw70i+A0=LUQ0T|XO3`wbG&rl$ z7Lmp8KuzbR@iu2Z<Y0ViX^Jl#V5p9&?rA%+Q#~on@f6|jWxTswiV|M)D=nKz&vBVB zO-Z{4URRS2NFyK?VxhyXGjT7lyS$78F>haGx^PkIuK0>LP=~vxrmLHE{Ik}ZSU-!T z3wBsSJtubxe4dbA6QzXro!IqNIZo@l29N7d>Jq=cJWi8${hHxOe%>gu@P9_*4B?0M z)u-TWfX%D2($XhIMs(oen^*9ZSC}jQ^A7zzD{EQxZx6;V5OMF32>oMo5*G^bhMJn? zh2NPrH)ufCm9Ew~xR^3U5K5;Gp4;}Jy-w8(c7J>ab*ZqZ(kGM_wi)MgY&j@|;CFZ0 z>UM;U(?gz;5S|<Z47sYJqdkzjqK4R-%fjz{VheHCHa-gCD}B{6Q#r%x%F6g};JxIE z?_;E(+WvmUlynX89ZA%8!~;eo3B$Yal!Wn=(j=1fFm2kSm3vptEiJ2)*4xWX5oWNM z6NVGfr^+{l@JOd1S%&1tg~}1zG^Jy%iOUOl*42yC`36f%o_CPwJ%@%gi~Z<MR<EBC zPYHtV7cMOsx5{y+XLfg=uLGg?*S?*s5lVl5$M%)uga8DI@D9x~^4U15z%S=RGJvi@ z==E#||BCOGo!glm%X^{D>}lKD86!Bt4&Ov%=0#0|WsmELEqGxAJsQG*m6M-(2frJ& zVOiE&?g;S%CljxKO7kd2I*X7yLxAusSZ7B+_1KR%!~1dX9(oz)7&^vRlp}nopo?O@ zXzNJ|;!A6%6e>dc19e=+qq#e<bK?8^mPZ-BOu4t%YOs==5F`ySBtO87E;V6GQ;*tK zpec(RluK;BF95#m7c0^?i<NEsk&M*kK4TtU2Wj`K?*vK0_Or9f%T<c$6djCT-l^>` z`uz5gl+;R0s&1R-L+D@RBcij9-XGfyrbvMJ<fVc}>fS6_TJ8akjLY#N^$*$T#O3Ma zx3rvq<R8akh7$t~hq%TKl(a`iMo(z<cRtcibB`xo+CxKCU~o8$4{=39DGZUru55%A zRX~(SwFL{dW0|j_9NWcg=YJu+uwxh&<eal<*NLt0@Lmlr{sW0?-zO)Z9|B<?<-AB& z_8eoo;p<x%i}>GDbhkycw0462A%Ki{689d6A0FYeKFk!ftbS$svEzAGe%SLQ77Xe_ zv$l)aQHoDgF#tNjiO;uB#HEBy&@L2NcTT2+Eh@h>;9wp>6tj#e#>U_?^;sHvB9P9> zIl*d>evqRBchpK|vEn7oZtm@fyEUQiM$E8fqdH4->>4042PWVA=bhTYqQJwN=Wc~E z+=kE+upGl;MlOQlvxI@(haRD=;F4(iwsNgEABgv?{d5%;YSVLC%<de5S|-Y1*6GR7 z0Dg=FTETCbee)sdC?T@Tis9yaWfz;Nk8MdUNcwBRjwTgnD{Q!C?>G@|D8mnL#RcTc zMjdSTuyUo@GD>%#*|xyz+G~XBA(U!VfQB2@XZVeyVwCVZNQ=Es2Og(Yb&bej(tmtm zBx=~S!?EMK*o0#!A0#i1#RjoOO+|ciLj4*iApEjQwm7sVb4Pr%%;|#p{J7mDBT@u9 z%YrKS)AZJ>gfdul*bzYq=EIo9Ca9Sn7Ia+eH4vPLId~@+_9VW<*w7haGNZ7ApKyS2 z&hSw<lm6L==8?D8RiktGwf{@U&=WZnKlr7$gCS4Pv-<+BZ$0Db>wAj&=|g2^nK|l= zaDHtPQp~mRLZQ(6$2O^Gn<3R*SUQY(3QGg|qffuYFa>8ZC_i|!at2`)+u0k3Fqx62 zrgl@NIcLEW0>iy0YR(4a3`B*)zRV;u+2fxPz5Rn7@+CKqz;u7RFyD4?)*FQHb5*i> zdRU8HGt{xWtOU}uxQR{P@EOT9gDJs@PeRSgS=+^l%thU1@A}Fo%33#gVQp8;-u?!% z{Sd#8I**vG-B!^P)S9xX74&eKlQEFKSo$(Z%iMfEb3<cU{lawPL8RK7kL~dvJ$k|o z^N@5X*<J)#3H!5FKPQwg>9tZHL<Ez>bbu5|fg#&#nCcvP;1aN{DOO%wW@Ii&{!!fZ z02bKM3R@ve*4Y>gS#BcT9-`gm*1pDdxYkEmw!b|GyG3E=2v2OUZ56$P7X{%`6U;Ov z?M@3hwBo1RKQ^9ism-1E^yM0~FZhlBfEa+fOjLrf2SJ85fRZRDV{nncALm|suh4NK zUMVd$*0;<AX3J$!khsNb46)+{s>D_5gUGkj+UkCdlxSnS`6hf}2kA7T(xrd<RLF_J z??aVoi^!K6Itka*mE^?wSAvMtDyY<FZF51~eIN=vrw%>QR}~1$8Hi5Ln?dH}`1Wl( zvZ*3|LK!3;ndOFiISmRzsdbXU>v3{t{!|L~TcPv65y5Yv%30O$7u_0{(k}v}i^JQ* zl<wN<THT-N{TkIm2tU}KbN?mm%EyV$zUZION0GH>dK<t$F~4x(n5sZ>4*$LMLqVMZ zIWX6@6|si%<FRWI&RR@rL^o=63WBj#BXz_9n8i?zYXu9v5$dGd|3q}i^$`qNNc~s{ zs~_viy{6LF{Ky1E55vnuGrH8YFONyyycK@&9)IpacngkX1Znf_#Q4C=su36EL@B@? zcFV3UO|5cw1uXI&zAt17gB$ZFKOVTqpay_mQv$p;L0j2k2_nFdqs>vNr26Xao4;j( zj^xIlb#t_NqQfq73E}!Oy5vlURFPl_MTjy5NK2jAt{(fsCCT6a|7!2b<EebY{VPNz z*`j0#SxYF&mZVU&Xrn@>M3%BswmM0KEJX-$N~BGypR(qZgk%><aZp)OC|kDfGw*vW zzx%oO{&nv^_hbGjXO?GXzVpoTEHf`lK=tYq>0jc5wHpIsdcQ+<x>pWW($UFDse|D% z6$U9TS(-Vy{DX3(FsG`0yp9Oz+U9+(cGysS^`5Z&jcB#?#Rr<U1mDKk-rX$)9G@tK z|9)Pg+lSH2-Z%$iJ$C=jnkK}+Ru04|?y*LB6vvT~A3a~KA8|OZvC95(73%?+Bg1H* zk_T`s)y9Rj7i~<t$!&4qX&+r#pWrL1xZ0iRZ;X<;t0R{6J$K)b3k4nDzYzMnR(X)o zR`|89T>xK@STX9rsi`m|6*2li?z;1WgU8vf4^Dbjkx#xXPE)|2(A|J96J+7uksG3z zxyXXVgS*QYVm($pChSC)X{%Olm*_N*4+*ww`p^sqVohQvclN(5nZJO|`l%P_btwF4 zLbA!#oQV=vrC$;k>2wA}$56*jP!|XVkWnTGHB96ss1w$;>JwY`M#SiMv8v0Rb?sS` z;-;miAL+6H4RAlc+hIoaBL$*ZVi|%-2WUqDZMlx4>URt_s46A?!@~|{3Ao!#fWKp) zWN1`O4W71$daNYDpn2UuRykE?fsDSWJ5y}=u*SOPH7R@lIdvbQ6d8xlki?f?*vWO) z>MK`a_96nl@#8*W4f+6UW7!JTKX$ijii$o?p@%JY5>}c@jtVb@myG1TA-_kg@QILP zJ;l2#I-?D{STiKufAx*)Nk~wUtbsF}vqS|i)*70&W-kv$@nzqyY-o^G+Hm{@kHTZU zF4n;2PV2mpbE$o9TevqE<-<~qO%Y7ZCtt=$bHEu13*~BC-j<TPqf1KS0K>3X>{!o> zzZOz<!2++4!WG|#sLaQ%>OPCIWH924;x<rQKvuO~qY+W2;&F*WuI6Nu>(J<XTBg&a zmELj=RYJ)9>;GwmCAR<kLK}=!CR(kP<C)jl%AjylY=y3X;+3)!QDGWO#AFXPd;bJ{ z>D{?@613H=-Q$*=7&XbJMzor%E#)M)oIOW!`3VsQlCoixQ%_CFxL@N})k~#quqk4i zxNt#U`&zga->7!V{k#Y=)?x;1on=78v3ueDCM(<BvU742zOqV0x>Sw0yfR7i2?H-3 zFeKtjJZAdwWD*a9vz)se6Itf^ac7mblt0DMDWd}9?&C{R1a`wyH%U=jItA9FzUAW@ zVe;ByyGKSC^M+6#ik&W;-1eHw@!gC)8P+9!DL%oK$?fyVub-2-8Tv!xA3M=#vEaic zPgWK;e<~Dd@@d>a4<qMh0i&2+vRk3xRWg_|pdALzaq8!p;tm9uJSkvQV=~xyQBd#+ z8U+EO*7LXxCVPYdd(SEQ!cs9M;jCg3tAKX)T6?y4a)(JuufjRK->vzLF2!TCR-9!? zt6kdf*J++NXiwrB(ZDEYC9>Iz22+j-vV?Ci<Ue1#=)wBr0u9LTOlUV3@`Um650ly2 zE$=je3{}Ka)FzN7tW63mXBF5c=%W==_7WK!sk%gu<d^!8eFQP%RZ&X=`OaTYNJw}O zd*2GlkVA_g`^N*>u6G4CAmDt7HG?rUaEs)H1^T=r$`xf;=DQQ>MLuWDmaO&@OIxLV z>Xwel+Vf)R7o6X2xPBZR9DNDftPV#7-<0vPbta#h72_DF8<fIG199vOx8u6Fk0$hn z-+`j!G1dp`BX%7?Z975qd=1XvKTlzrO2=;9WaN3n$L%M2(k;$ES~|d;b=7r8rt;O9 zx$5lvhygVS^RifQS^+HL3(PJnSExc!s_UYayVt(jtj{h|+{ayLI&oH?eU&z;>I>h7 zCtJ;13Xr0UwvoU>R5FlJpL1k`q%fmq_!hR`%1~w>h5y3NAWF^AdnOYa13t1AOv4YZ zmFaaks(gI59}O*S?_b!7;E^MT!oIVc%^B+74KiY-V)3OL1GM#c_nr@zWYlJzsOD%< ze6G#(T9o7-`DdIfIgBcFy?9HoH3tRJctL36xx2=vMs`(USuI71dlzU5)tp&(W?xLi zw%ZpvG;0SsHwSc(OuBM^)+LLmG59@EGZ^<gm!43Dj>uFn>A-)a1@ZFaLn<!?G1m=4 z>FmkOSA8ApEF~M>W{wGx2P^M8N|>Uiw*JBOQ{aX>Zp4X=tj8#ew;NV4G%m{Kl}hFi zhaCNa_)Z&V+zKkR>=O*w{!4c2$ypl`Mbo~vdo6<;^s}9Tl+m)G5?{)~8pUs~t*}yA z2{YR@EDqMn+Re{GjPItoAfGk(qzP_q;I7MOsV5co@i1U!r;zD6W67vVe%~!4f25(M z?k4)MFg}`m6<<85PBoW%X-uPHX)7FRgQ~nN>%Dd(a&#}9cdO&06^9wcYj-Zfe813` z&hXt_yw`&I``E5eb&!ipcz;k05(U_!b;QK6{`W1#JDAKKqF&R2I+;BR5HU=dI03$s zijSGNA}n>W?k)rQ(GKONmW%HUWCQD1?=_^HI;@Ry8WF>^v94=b^lE<T8%9g94|&u= z?XJp0D~;+DGp-$qJA?Qsk?<z8;jPd&GFtfU_7yHn*`x2+RpynAZxtS{P@X)vO>krr zWF+s!4{MqXx1@`NC(f(b-e$roIqNukElOYCmQzlSwEr{BWyf(AHNIp!(MV-6Fof=q zCb8tu?$>G0seeRohg6{*%Zwc#HmOSsHAs5=(hOw%*JC0LoTP_rzQt{-UpHJ?44IS( zk(Or%qp<un+XZ!ip83<$HT%M|HuPTu6Vgp%!;UGWhfE}_2a8kAFI#c=lNG=w0#C%e z_I}mE>|jmXh%D4n(Imt!&cvZyDiZ27Aa<pO<^7;oW;<tS67$0oK1BiJ+k}rV#W?;3 zSz*wx&)o_m$v@7xG{jtQ1bdxi;-RFp0kn`|%E|b~%VPj7ysg}12G=ZB#P@S%ajW_d zBrO{?fGl*;Rr94{Kg0H5o3}$_v0CsR+7AoSFKkK~Q5VBW>PbsAkm`hTmOw7smKLfb zMb@`VW5UunL{9kd**|SkmtJr=-Ok{HbhipRj5;sE@k`faS_pJwYYi5m0d8&g85<rR z9?x7xQqOqo`V(pEuvp?Sq-RTGTwgO%pd*J-TbRS+SQpnJ$wdAsvc*BRY~l>D?{z&F zO+ZT*>?;8gQrD_E4IbI`>UTavCmqN|J4Uqe3M<EsNbpswF_wm#P49M%UgByj)zXzO z_>kZ&^v<Ub@oRP<4$eNEQP$DmKt1$516?yQ#+$&S#y*fv*+bA0X%nNZ<(FX~*S`LE z90Xyer@@m?l{UO{D40rRwMSEUAHk+F%iF{%e5kOo!zUza27CLNzHzkXeb~xq-Fwi) z4N)XC$;OXuTk1410Wi#9;u~|WNf%ol>|%Xe3&O3zN&ILB9kC}W&tc_f6=sM(!$9U2 z0KZ`Q%iZ#e_CaPYmM-~8H>4w{5#F<z*el)0gs4vp<oMUQ#X^A!jc+8*Yvmx1lyoJ? zX1o#~5i`~>pGY{^VaC6OboMH451!1-Qiy(JXP#n@#;u;%Wq5atvbxroEyC&@3#NvA z3>h0QVTXHz9){iY&DfiTSh|PUB$j~vKn3+fpVJ}!`SZ==D=IzXTzMEaQH0Vy-$kOU zaV%jqijjP!OFwn5SmL&;qZE|(ZM-_bk!C#_--d=f-YbS3bFOXKNn`D)R~&w7kLG_p z?u?1?>W!vrl-#^!%dt|%?AO7KEsi4-g@1qMR*u}}a=A5b^Lq^p^16njV^<2;8eV7i z`uLfb|NPzYS@N?OunygkgzB%NkZ&FoSz|8gF8udg#NTm_om@IHyA|1sm6l>8x$WQl zqNQ~+*-ot`soI=EEEhP4E3wJeq1;B}FHA38*S=ux%kJXsGZAW&!?o%g`&Ls$?>AK| zx4KRAa(W-t(1QDc4Fv`dc11{DxO#f4#|K@8a+>#n!$686gb!yVxxZ#mRLa#hTurgG zxYM}fbf@yAH35oMr*{DmrRy3}9w$F$ugKvIud?LcvTlT5*AR8!7qPf;1@&K97%(6Z z6ecS5<n_1gh`LDM<TuNec;U&SGp$vxb4&RZt6F+8c{2tu7MddX=lI8?1IKdWWVnO9 z!kd36FiPa)VBJXBn%gcfOlxfKmT_UU-%9CN?UTlr3v?qd*xA^lwT)0EV_YE;8LT1B zIS-DeJQ9!)(T<|@HF(@=+LI!VQAd~}W1cVMRrb$s+-i@^Z_$Rh<a9NJvQyF1C3pKq zbEfcsFbzIKf0I>vBiFmxK4ZdYgF|<)J(E7`&Wn{gAYRWx({?ejtp#ganKJEA?DFe7 zlY7M`yY9Nbdv5l^4z+LN%$W0^79H$v#+Dc=r*rnXlo%+bNvwn9ze-&LLESn<f0UVn z3$ME}Ve-8nH#Qt-NU~NH=;0V=T>6VTP{0890>LhU+fJ|vo3R=-NvDYK9Gb3N1=6}0 z){x}YP{8hiy{J<hF1AOjY&3BS94$XOX7cQfx}?+Y@6KF)zKu_Ay!EloSA>heq>ovh zM;Bk^s8w`mDH1Bb$xf9Xv8zX#dnsaP=$8w6lb_0>m6&hw=JXF|uF2X2%?L(aet$6H za60T3xjoR3S8`O$XB|zTiCx?TnbGK%H>k1Uj3WB&Uw*h2I_W3+uYnI@84r)`W$n(* z%E;uDuitXLTruo4nz7Vud@~>m%fe-nt$E!#9?D7nJjn+iE~NzZB~m#tO+lZe?X2A# z0em~gyqv_rj#1_}@+2O76ChmvfwMSfw0x=cfYWmyOAm|Z-d;_7lfQMgn<tLlV@R<a zXwu$jb*aw+)M<R<zjcasH$RJ)JKFBE<?pw8H}dxeN|3xSsMsuqCLNahP!y808nPS2 z<)3}neImcg^6*BegbWWG0UKZRx!)FaIc-4&pQcXG!u&n{tAnb8GEI{DEEiQIztVxb zjRIN&`b)$F{WlKpqnbN9{_{9dEUk*6|M$m&FGUYDG#2|EgPl1YK9z$sJu(Wq9A7v_ zS$`J2I5#l4#Og2S!Hp8|(aMLr?wG~0KTgZaVAk7n&}y`|z2L)9Jw+_k>qS>5NX<Km zQ>J`??Y_`+L0EP5w#be*$|7Pf#-{ux&ibC5QXiFBeOkaq?VB14MpbdBt!ju2lHMh} z^fQxJ{%KW?^$YAdd_0Usn#R$lNLJWc3Sa+H<&MC3zujM5sxYz-#iG_{CU*SzVaSOY zk=i-I^|0!-7^IN)2=8V<y6Dx<AEmt-$cg)a);alWM5EwO40G!SZ;Q%Iw56gXV%^45 zY<~ZGVj8e9P6GKT8|)&C%J3)oV6SIYh;!id`QSFr5_l2H0z<2hP(3Cui?9E9q|HEo z=7E5Xp9n=4R)_DvcnZlCw2pHqeVL^cXAb(L8!2`m^+MJ(V#cCE)Gx@<&iPhe-uU|; z5Ai)!e#>lS5<_G5A8V!$`6OO^A;c>)KnmB&$MmiHUnxD{gTN}8o0n&bW-W<Oh2e#a z)9D9-xG=mbJTeN@@ypxT=&pX4&9s9oH12qA#@+yK+^7dx<=4(>7>*`4etw3ecl4Vp z@F#f}fhU-Q@Z+itv1*{IsB3U~r=F`3DRW?01A171fv)3;BaU9E43+|NGc@n{bG+}M zz(gVj9#&X@$ENcw`oL*&&Pn-PUT(>0^bZ{!3gK^x7rJbZ^zbu%GN${uqcoi%G_u@C zoQ7YT3UlaHeE)u5H1Q}pwEX8JLo~AXxQ-t-Y@S(&bbjOHlqzsvTuhsADMb~Lc=A3! z6;12{_Y@DGwYX3cO(@P#D%m8?(><3yP*O9u9pIroFoW+8*!ZBa5JVSPC;ycM-bw-7 z)5wO<J>wu3m2&K34WJ=nRIm6v1kvZ2!~5H=LsfHlZ`z`c@-4LbSmT8okKjkhoKYkh zsHsMPz5+0s@c{=1$pAau&L*S}hrmPZX6R4yHbuh}))Qvna)o%`SaBAijAzYT*1iI( zEJ`2@wx|R~-4BXgQ}EiosJy-I6YeN$tUjmMuhG%ltS7R;G2t_P7JkJw8+c*bvAoZ} zasA{8aG=DIO81}Tc0MmyZnuD`tKA?Cj$YGtWumAka)feDo8~B`Sq@Ncp7hIu#eMDT z>kZ$J;C<XT;}1>y`ysFS)3Ymzi&NYqJ!-BtE-!o1<#A=*sdsgY-_%|I^kINS{AsBh zS%;B<pVT^*peu*<xvJjSp({+omlxwz_#Ium4Njin;h@i5I>3-YG!MV^j3@O=+)9aX z!<C@v#1mjBnq0^w=ojvQ4+s8%LOd^)ujf9fLMK7IuZQCMMQdt0bI=PYaM=uJJQPb6 z4i49-%0f#l#Rk9rjAAkWE?a(rf)ajl9*^ul_pDj1riRI!7_`~14$<!XzLw(Vt{8qn zL4w|`ygt}dh(P%+lrVYr#HHYDyz+JDLs5hAuy)w@>rQt?*~W1ANfvj;U#mtbq>Gg= zYbGaet}J=Ef}7~K+2R?bs3EN3>xo8_r16{41hv)5x#4?4(5g)?CZ1?T@$2eUHpzUg z?;TOIGJk3F^c4Qo0a$xh%ygH`+?`vJ{Fir?MQc&fOI%dEHT{Afd?K^WSKD^I>8T_+ z{u_(JUc}&dgGGL3F=}ZW9^N$7D{uoWas|Iy{}B$^b9QNH(vV;4<qn#Njm-*g1TPj! zye*3U9KG$vx@O^_M;(vR=WnshovlcuB!aNp3(u_PNqIC-nLB6Nh>cQ$f-Vq!n;ldt zOX4Fz^rjkQCOw{oqMxCt8h_BwjCg(zC!N1nx)|?V`)u23f}0Xffy<-_<RBN%jx|+{ zZMze`hb*5%RVS4P@5+7R?N>yVy!pe)XK=ML5dAo?eTK0xCwaC4yt(^l*_e7Hde3E) z@j>{^CX89SsC&*3(48e92ZSpeM~cwFkSa5$$;{JzoAY(GiEZ@0-J6qfbBR^sKXh>3 z&*?^Jg-vs>=T`It1NMM;>=HE!oBA#wxOBS{!icOh+mOFvASl3~#m=^Q%0y2A#Zl#E z$v$XNQ3d~G)Lg7^mi;pAZz0UcTW7(|JVZpr&bHMI-p4}pzR#f-4zdk~(fZ~@g%DAv z=0pV&Q9tHH`4drovr+5dSqwyLa!yn{5oI+esure4Qc}_me1ZxSZwNy%jO0>TQzJi! zkMSt|nNbuU>Z<NZbY+q6W`rgl^iA~a=#z}fWUh{ILV@C)Q1Q&m+nabc-+*{|93^PJ z4=>4B8Q6j&$2|*^aVjh;d-R^dWLOB<sLXTf$Wl3?!X<#JG{?Ufj_6=y`@}~A(*wA6 z)MmP7?AuQPfopq+Zkc{T$4VkR^;GwqW--i^cz6<}kYbo{2#(-SJ>hw&4_MJ6g?-yL z!x+?c^r){1NQ}e7s0?&cI#qG^?%f11*Ii~7HS&G6Bb##zP9;1$6^2?azxNeBn^3`w zcHhr%D5$i?c|~t_^~qlG7&TY3V0M@cdd~EoojQzv0?wFtVg-@Fl7IQW?;3uF7C3yG zYt2$Q6WbZ8qlMG9o&~sH^a{>&-m`qz(x&pdd;G=9lA|VncUO7l2@By5+s-b%KI*zE zg(WKR-jMXy!S-{{Tl+G`Za|;GS1@$z=^T9SJfB^l3}^zs^LYoeMbTQKdFsV=yrt9s zjNX`?QYemf77+B4{#-5k69w{r`u?8(eBpV(=9y%EVxYxgeleI|J?D3f`CSsN1@nu+ z|G`lzF0Tfi;pm|eN&lpudP{|KTKn>m{}&N5rT*U?;mE~JeW$a0doDCWk27YE;i$u! zvvMBZe<U1GKZRxa+BR<SmL(v8e@P0c*t^fRGZG2haGyPg&lH_003XesIdbEX9REYT zp|u_nI&cbDG<)6$kB7>aFkJ8Om+d8<wD#d*H$i_LdUBb}uO2sB9gTPtcP8Rr{R*ue zL6SUa!JMHbnY?k4hu#=yB5{bjCs%h`l<7Z^0@6?%@ggNbklH9E0p%-EAL?nOxgg9+ z3Ls6^5d8>7{UB+LPgWvnf=)v8U!8k{ko<;!re0Quo1UkCvtMiDU``4j<8)oO>VMs| z80pHQXLwPn%#MJ<E1<{0fB%vzc-!1n-_wcw#2eghTpsjuMC}nXQKFw&@2-rxj3paJ z&iSu~kk485#x&T@)<h@dMbKy}t#tTPy=P`1^Lr2y-_ZNKnua0M669!9UD>i7V5_M| z8J!vCpkTT$1W7z;NKU0lAe8Yi8ug;jVeAu&+(JxqDc<7a54x@Le%6H2C35pJdbg37 zN7IrSSE6$!*?#|MQDh3d<49SE#@2psMUGct8oi^`B?wt#{9#`6>TY-32@7hZUi>kl z2Dz!sXh_m4%<4*Fh)-HUV)pID=Qw`7gf||Im!Ljpt#lYOE4)W1>3r}k2N#B|?|cJH zv#;xJT7+yW*jv4kp&bHwNp{ZNpO3U06W*S6=f=oGt{^*;`S*Kx!lNX#Vj>f^p5}h< z*T<T!uTc3#1jzc60xZ+qXRdj1E0YQ$Xph=N(Fyvfk^Nn$0GDE+&97x9L`vJ=Ls9%C z%?wgu>q<IIB&};x*cY5^Sr~wuUf-5{?x({TZI2!($YL3Z>f@*thS$z9*S0o;O-yDz zcsx_DeQFh+9&vdFq|2nK;^WX`+{_ZuQJrWcDaS&T?%xG0I@(lR&Okl)A+PnpCdc*c z)JElT@^O)*m2ztaKkH%_4zivad0A>1oX2IkBv!(3*QgciPVXc)_p1@L1-`YTca}uI zDK!Fb@=cDWM`{Dd&t7uLZ%`q~lTW-|rr~K}+TJi}1NgPaA>Jwr@mcz|o(>RsGVzA2 z-Y3smD4~Z;1)62N;*Po1`p$F;n`4E&-!0SNwM_+Ij%aj;np~UOzUgES(iA|TKINIH zwyK3Y@kH_N5Z~pq)bB{2`Dha34qtNOG6a_@^5Rd?+1>B$mp^SX7$M4ILPs}94@*XL zHfL3D10u(fHb!ZuxZ}r}CYBsrFQ?yfwiYET?T0xc)9;cuR=cU7>EtohNe+t>Gi`}@ z(D+?hW6Hc|cyBMd(W`kyLJt;YaYwz+qxM-LO$O5s%F{H8<RhZr?0KHNU3ZBEjNCpJ zJma3btG22g?n2(P5U5*X%FC~A*KN{*M+T&t_8}&CeWZElaPX?ulbaN7oV}C*mr9Ae zv`X)S@Q#iuabb~DjeV-%l3s?_t?j7SGV!mvSFZ~6>aGh9{BRMb3&jktSi4fcWumD` zi-pd6&!FPk?YakRcR`;|AV{*Ly?abrmnnod=F)3abSSWdCwNkY#kMBqR2xFZ%{Vww zMMHjf6Ho(_t2+AojU@@1WC=k*!S~LWx54l9m*-*H8DHNw#_+4$;t#VN(Fbw^TTy<X zRpR$GP+uIfH81B+q21Lz4VMPpp#TcDI_!d}iVe;@g%toBKSJ3!MaDAX-WTy{DlD?G zu~`A(R~;9?jnli{F!lBq*QdM?J|^(UgT6MWHvn20-|bZ@IbOlCw(m7E>f+fZZpD^C z3<{bLWtIr0a)7dxvcVtX>SeUW`EpTyJT(kbtvhoUgBl4LXVMQU#m*)g%CHHh%7cM~ zycjus7(eA%Zqd6~m=3Q}y1aYzbud3@Uc-x8?2pZxp<5=ilwMR1=h=mqo8LFOQ;7B> zH*W}1I;l5P-Z?1%lrh23pnoX2SB6hVMf@sOCo)idVhf!P@T;me!fii$3F~E;=GuD+ z-tH%|4i%r=RCkofMYDK&Gi4(CSn32jOwW7hvZm(Rz(dPojxcZhLm%cdPQOmSd%Iot z!g{cetL=hWc<9N8jHic|HI8h*+>r&bcyL-D6#h#rWVt@NzM#p7u5MDqLqAwd9gr^M ziaD}m<K#3w{XcouUvH-Ln%`l-Y+oX#wta_~`e9J>$xSXE9?R>xgSv9BJJJak8k}7- z(ACv745eJVy7Wq*)Oln?d)Rcc@p#@?{4$krs|N?Zj}CTQ2?z=Gs1ohzzXvP~bMez# z7ZyPljIx|t9Jt+N^Fx8b_G2q0C6BnHx1AuIaq)T!_n&12Rf58=Rx)DMA&hv=ihNBE zP!30xeHnfwOk|~rf9Z=Tl6R7IkJbWZHTezlC9l566K28Mx#X|!fE*dN5fpl@a`N*h zr{Zcqe5_&l47DpE{`qI!k;Qjn*c0WZm?MdN<j&IbDP_mG?%a`-6*_`QN+52j&i&21 zOm>=>`hkw~DeKOe8co3zu&u!X`(%?R(foe1*Jy=AMn=Z5tV1Ptb_>H~t3E<+f%7=I zP59izO25`(<#5?p(Nm3oC$!-s)%Svd2=$@w-j=o7Ul$i|Ffw`}MK7W##S&gw$$O<X zeTCX~bk{%=f7*<8f0g*?y}qEhs!pw)M6-=^1U4Od*x=&reQU*E3Gms{{EgU{c3;CY zDq{47{7k;(M^jjCI5ldmgofWhV>WSE_?7Nfx$0Jt+ZhSKh<kAzJMi;sS1+&eN0~Jj z_VglKD4k%TYk#_FU<v0A;)q5b>2$nhB4InY2E8|g(S#AI&Rg43wwRl*`}^l_Ss5QQ zeX%ih-&`iez2W0wc0qA#Som@G-BE?M6h6<*g58JxVO>L^->ub1?k#+hOf1#psAUrP z(_y~2ljqq75f-DDj#!NPEYNH1$vp^POGP*OAW)=QSuXvwI#!dw2`dBH2k`*4YDmPl zK0h=xRTR&I?Rz`8`dQ_3hns9r#}U-b{!EPkzz7fa(Z@C;+i5m_(N5h6FrlX??SiYN z;QWG{xFZ^a;8r~zG?(r{-g(puIL>xs9B?lm4^jsg--M;>3~uZ{lRI079?qhe5N`cW zy0feea-l%JdK;~1WJ%-$K<@m$eR@lxFcF~Qc68EKKX6whO5~p)5)PueN$2Jy=n@G{ z?8sYrIOdf${oF+LaT9v+70!&7X|Stda>t%p-2b6Eh8~GUq2JS$)};qLLE4HYC7Ext zqv<063J*@AFby{Vk51uT^_XeK#35-<)K%I=9P#hnR(zUt5NICwDoy4)O#$%w?f3Mq ze-^cePB1@S1ZS506GyP&pFc|NR<lxU8G|*~u-)Vc{%WxL>Zud$#w)J<V{O^8p;s^d zO6A?2tG!iIh4y@?yMi5GQ;s43EEJsHmaJyLNZW)Tz>fn1ThjUq&6x2NjC$Yt#%_-c zbbqZJ*%ztaPbyO@OT36wZ1i-;wUnm-5A_#1iDhOuWS^x^yZ)_DDjWa6D);~wU7Aas zr&mgvNyo($1=ZX`P{#w@5ZRm)_<fpH<jUgwQ=CwR-+fL%j_QCtojmZFCp?f(967ca z+D{HcXJU$MYGo9CY%rD>1#O@~ebaIn$|{OmjcHPwWaz~%08uey`BQvV3|QNm-gS#l zruV^T(;FQ-fTEfgAw#iP`TUVrss}w$%{{zZtjBISBn#om!s6!B!!vx8EqtK^H0BvP z@Kd&mPk%U8GnolmQq74D=7aYG=%1U#Y>zbL1khl9<RS|-dFooWHhtm29+t+f7f9U? zdvh|5qullzRc(u5Nkbv?M&}2qhrknKNFpKo$;_rCJ#Y)|whD%B8{xv--#}+(fE)$0 zH)lTEW`aTQgCuH)Kskpf9YmVPn-5Z*0FoyX?64B0cU4hPH6G+jRbx58qD^f>^vEXH zOO)uPNbX6=7a$we1@)F(C@nI*dWmtOdl~0&5j{f6Wl5w_1N=t8qFgohk6$J`aJLX@ zL_dGg?mmAyL0kPdvhr^cCkf#e!5HIqPZ7H{dIa;6trO3UZvhuytp5OlgnA%Q?QbZW zkPwt}f8Y4K(Oed?9>A@q-uv1cL(!asP<?@|jlTR65%d4fBQTG^JOc9w%p)+5z℞ z2+SiekH9<v^9al%@V|z@1Zf#N2-6@LPaIjbZU20jM_?X-c?9MWm`7k9fq4Yx5tv6{ z9)WoT<`I}jU><@0R|qiKE@Q`S?o5;Cez2iOSLVZiivU%ma4YxeBg{L`p)9+!jdo;c 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="256" height="256" viewBox="0 0 256 256" xml:space="preserve"> + +<defs> +</defs> + <g style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;" transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)" > + <path d="M 24.144 34.985 c -0.77 0.912 -2.003 1.631 -3.236 1.528 c -0.154 -1.233 0.449 -2.542 1.156 -3.351 c 0.77 -0.937 2.119 -1.605 3.21 -1.656 C 25.402 32.789 24.902 34.047 24.144 34.985 M 25.261 36.757 c -1.785 -0.103 -3.313 1.014 -4.16 1.014 c -0.86 0 -2.157 -0.963 -3.57 -0.937 c -1.836 0.026 -3.544 1.066 -4.481 2.722 c -1.926 3.313 -0.501 8.218 1.361 10.914 c 0.912 1.335 2.003 2.799 3.441 2.748 c 1.361 -0.051 1.9 -0.886 3.544 -0.886 c 1.656 0 2.131 0.886 3.57 0.86 c 1.489 -0.026 2.427 -1.335 3.338 -2.671 c 1.04 -1.515 1.464 -2.992 1.489 -3.069 c -0.026 -0.026 -2.876 -1.117 -2.902 -4.404 c -0.026 -2.748 2.247 -4.057 2.35 -4.134 C 27.958 37.014 25.954 36.808 25.261 36.757 M 35.572 33.033 v 20.018 h 3.107 v -6.844 h 4.301 c 3.929 0 6.69 -2.696 6.69 -6.6 s -2.709 -6.574 -6.587 -6.574 H 35.572 L 35.572 33.033 z M 38.679 35.652 h 3.582 c 2.696 0 4.237 1.438 4.237 3.968 c 0 2.529 -1.541 3.98 -4.25 3.98 h -3.57 V 35.652 z M 55.345 53.205 c 1.952 0 3.762 -0.989 4.584 -2.555 h 0.064 v 2.401 h 2.876 v -9.964 c 0 -2.889 -2.311 -4.751 -5.868 -4.751 c -3.3 0 -5.739 1.887 -5.829 4.481 h 2.799 c 0.231 -1.233 1.374 -2.042 2.94 -2.042 c 1.9 0 2.966 0.886 2.966 2.517 v 1.104 L 56 44.628 c -3.608 0.218 -5.56 1.695 -5.56 4.263 C 50.44 51.484 52.456 53.205 55.345 53.205 z M 56.18 50.829 c -1.656 0 -2.709 -0.796 -2.709 -2.016 c 0 -1.258 1.014 -1.99 2.953 -2.106 l 3.454 -0.218 v 1.13 C 59.878 49.494 58.286 50.829 56.18 50.829 z M 66.709 58.495 c 3.03 0 4.455 -1.156 5.701 -4.661 l 5.457 -15.305 h -3.159 l -3.659 11.826 h -0.064 l -3.659 -11.826 h -3.249 l 5.264 14.573 l -0.282 0.886 c -0.475 1.502 -1.245 2.08 -2.619 2.08 c -0.244 0 -0.719 -0.026 -0.912 -0.051 v 2.401 C 65.707 58.469 66.478 58.495 66.709 58.495 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: white; fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" /> + <path d="M 45 90 C 20.187 90 0 69.813 0 45 C 0 20.187 20.187 0 45 0 c 24.813 0 45 20.187 45 45 C 90 69.813 69.813 90 45 90 z M 45 3 C 21.841 3 3 21.841 3 45 c 0 23.159 18.841 42 42 42 c 23.159 0 42 -18.841 42 -42 C 87 21.841 68.159 3 45 3 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: white; fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" /> +</g> +</svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="256" height="256" viewBox="0 0 256 256" xml:space="preserve"> + +<defs> +</defs> +<g style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;" transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)" > + <path d="M 24.144 34.985 c -0.77 0.912 -2.003 1.631 -3.236 1.528 c -0.154 -1.233 0.449 -2.542 1.156 -3.351 c 0.77 -0.937 2.119 -1.605 3.21 -1.656 C 25.402 32.789 24.902 34.047 24.144 34.985 M 25.261 36.757 c -1.785 -0.103 -3.313 1.014 -4.16 1.014 c -0.86 0 -2.157 -0.963 -3.57 -0.937 c -1.836 0.026 -3.544 1.066 -4.481 2.722 c -1.926 3.313 -0.501 8.218 1.361 10.914 c 0.912 1.335 2.003 2.799 3.441 2.748 c 1.361 -0.051 1.9 -0.886 3.544 -0.886 c 1.656 0 2.131 0.886 3.57 0.86 c 1.489 -0.026 2.427 -1.335 3.338 -2.671 c 1.04 -1.515 1.464 -2.992 1.489 -3.069 c -0.026 -0.026 -2.876 -1.117 -2.902 -4.404 c -0.026 -2.748 2.247 -4.057 2.35 -4.134 C 27.958 37.014 25.954 36.808 25.261 36.757 M 35.572 33.033 v 20.018 h 3.107 v -6.844 h 4.301 c 3.929 0 6.69 -2.696 6.69 -6.6 s -2.709 -6.574 -6.587 -6.574 H 35.572 L 35.572 33.033 z M 38.679 35.652 h 3.582 c 2.696 0 4.237 1.438 4.237 3.968 c 0 2.529 -1.541 3.98 -4.25 3.98 h -3.57 V 35.652 z M 55.345 53.205 c 1.952 0 3.762 -0.989 4.584 -2.555 h 0.064 v 2.401 h 2.876 v -9.964 c 0 -2.889 -2.311 -4.751 -5.868 -4.751 c -3.3 0 -5.739 1.887 -5.829 4.481 h 2.799 c 0.231 -1.233 1.374 -2.042 2.94 -2.042 c 1.9 0 2.966 0.886 2.966 2.517 v 1.104 L 56 44.628 c -3.608 0.218 -5.56 1.695 -5.56 4.263 C 50.44 51.484 52.456 53.205 55.345 53.205 z M 56.18 50.829 c -1.656 0 -2.709 -0.796 -2.709 -2.016 c 0 -1.258 1.014 -1.99 2.953 -2.106 l 3.454 -0.218 v 1.13 C 59.878 49.494 58.286 50.829 56.18 50.829 z M 66.709 58.495 c 3.03 0 4.455 -1.156 5.701 -4.661 l 5.457 -15.305 h -3.159 l -3.659 11.826 h -0.064 l -3.659 -11.826 h -3.249 l 5.264 14.573 l -0.282 0.886 c -0.475 1.502 -1.245 2.08 -2.619 2.08 c -0.244 0 -0.719 -0.026 -0.912 -0.051 v 2.401 C 65.707 58.469 66.478 58.495 66.709 58.495 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" /> + <path d="M 45 90 C 20.187 90 0 69.813 0 45 C 0 20.187 20.187 0 45 0 c 24.813 0 45 20.187 45 45 C 90 69.813 69.813 90 45 90 z M 45 3 C 21.841 3 3 21.841 3 45 c 0 23.159 18.841 42 42 42 c 23.159 0 42 -18.841 42 -42 C 87 21.841 68.159 3 45 3 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(0,0,0); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" /> +</g> +</svg> \ 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~O6N<aJ@aD(wyEyD_Vv*F<UdcEKg7K}8pl<$ z-tLyv!>7Nv-%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%<a#pv!maIz3KX`lps>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-g<U#te0w!mQZ zhlVB2lg>SP&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#;_7pYcTIP1<Mxh)$K@<y1<Z3< zuOHocXyg2E7bR?0<;zN2-YEL(89hUHbKzs{FDndR?YN@Wlm1yRZ1<-d8I{lP-_iI~ z2MWWOtj&j9kIx3Gczwg;){Gy!<!1dmRxmlaO6F(TNAXW_|Guoa_&OrRzfN5L)))Tl zf6anlGEVgEdHVaB%(48Z*;iwy@m_pv@aEj^(B-_{_2<fYwtqgq{FD2Xt!sl`3PKao zf|(`h?^>3ewyisT{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 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="256" height="256" viewBox="0 0 256 256" xml:space="preserve"> + +<defs> +</defs> + <g style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: white; fill-rule: nonzero; opacity: 1;" transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)" > + <path d="M 84.668 38.004 v -6.27 H 90 V 20 L 45 3.034 L 0 20 v 11.734 h 5.332 v 6.27 h 4.818 v 30.892 H 5.332 v 6.271 H 0 v 11.8 h 90 v -11.8 h -5.332 v -6.271 H 79.85 V 38.004 H 84.668 z M 81.668 35.004 H 66.332 v -3.27 h 15.336 V 35.004 z M 63.332 68.896 v 6.271 h -7.664 v -6.271 H 50.85 V 38.004 h 4.818 v -6.27 h 7.664 v 6.27 h 4.818 v 30.892 H 63.332 z M 26.668 38.004 v -6.27 h 7.664 v 6.27 h 4.818 v 30.892 h -4.818 v 6.271 h -7.664 v -6.271 H 21.85 V 38.004 H 26.668 z M 42.15 68.896 V 38.004 h 5.7 v 30.892 H 42.15 z M 37.332 35.004 v -3.27 h 15.336 v 3.27 H 37.332 z M 37.332 71.896 h 15.336 v 3.271 H 37.332 V 71.896 z M 3 22.075 L 45 6.24 l 42 15.835 v 6.659 H 3 V 22.075 z M 8.332 31.734 h 15.336 v 3.27 H 8.332 V 31.734 z M 13.15 38.004 h 5.7 v 30.892 h -5.7 V 38.004 z M 8.332 71.896 h 15.336 v 3.271 H 8.332 V 71.896 z M 87 83.966 H 3 v -5.8 h 84 V 83.966 z M 81.668 75.166 H 66.332 v -3.271 h 15.336 V 75.166 z M 76.85 68.896 H 71.15 V 38.004 h 5.699 V 68.896 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: white; fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" /> +</g> +</svg> \ 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 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="256" height="256" viewBox="0 0 256 256" xml:space="preserve"> + +<defs> +</defs> + <g style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: black; fill-rule: nonzero; opacity: 1;" transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)" > + <path d="M 84.668 38.004 v -6.27 H 90 V 20 L 45 3.034 L 0 20 v 11.734 h 5.332 v 6.27 h 4.818 v 30.892 H 5.332 v 6.271 H 0 v 11.8 h 90 v -11.8 h -5.332 v -6.271 H 79.85 V 38.004 H 84.668 z M 81.668 35.004 H 66.332 v -3.27 h 15.336 V 35.004 z M 63.332 68.896 v 6.271 h -7.664 v -6.271 H 50.85 V 38.004 h 4.818 v -6.27 h 7.664 v 6.27 h 4.818 v 30.892 H 63.332 z M 26.668 38.004 v -6.27 h 7.664 v 6.27 h 4.818 v 30.892 h -4.818 v 6.271 h -7.664 v -6.271 H 21.85 V 38.004 H 26.668 z M 42.15 68.896 V 38.004 h 5.7 v 30.892 H 42.15 z M 37.332 35.004 v -3.27 h 15.336 v 3.27 H 37.332 z M 37.332 71.896 h 15.336 v 3.271 H 37.332 V 71.896 z M 3 22.075 L 45 6.24 l 42 15.835 v 6.659 H 3 V 22.075 z M 8.332 31.734 h 15.336 v 3.27 H 8.332 V 31.734 z M 13.15 38.004 h 5.7 v 30.892 h -5.7 V 38.004 z M 8.332 71.896 h 15.336 v 3.271 H 8.332 V 71.896 z M 87 83.966 H 3 v -5.8 h 84 V 83.966 z M 81.668 75.166 H 66.332 v -3.271 h 15.336 V 75.166 z M 76.85 68.896 H 71.15 V 38.004 h 5.699 V 68.896 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: black; fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" /> +</g> +</svg> \ 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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 literal 0 HcmV?d00001 diff --git a/assets/images/card.svg b/assets/images/card.svg new file mode 100644 index 000000000..95530cdc9 --- /dev/null +++ b/assets/images/card.svg @@ -0,0 +1 @@ +<?xml version="1.0" encoding="iso-8859-1"?><!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --><svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 50 50" width="100px" height="100px"><path style="fill:none;stroke:#000000;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;" d="M43,40H7c-2.209,0-4-1.791-4-4V14c0-2.209,1.791-4,4-4h36c2.209,0,4,1.791,4,4v22C47,38.209,45.209,40,43,40z"/><rect x="3" y="16" width="44" height="5"/><line style="fill:none;stroke:#000000;stroke-width:2;stroke-miterlimit:10;" x1="9" y1="25" x2="25" y2="25"/></svg> \ 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 @@ +<?xml version="1.0" encoding="iso-8859-1"?> +<!-- Generator: Adobe Illustrator 19.2.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) --> +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 50 50" width="100px" height="100px"> + <path style="fill:none;stroke:#ffffff;stroke-width:2;stroke-linecap:round;stroke-miterlimit:10;" d="M43,40H7c-2.209,0-4-1.791-4-4V14c0-2.209,1.791-4,4-4h36c2.209,0,4,1.791,4,4v22C47,38.209,45.209,40,43,40z"/> + <rect x="3" y="16" width="44" height="5" style="fill:white;stroke:#ffffff;stroke-width:2;"/> + <line style="fill:none;stroke:#ffffff;stroke-width:2;stroke-miterlimit:10;" x1="9" y1="25" x2="25" y2="25"/> +</svg> \ 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 @@ +<?xml version="1.0" ?> + <!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools --> +<svg fill="#000000" width="800px" height="800px" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg"> + <g id="Outline"> + <g data-name="Outline" id="Outline-2"> + <path d="M39,36.852a6.8,6.8,0,0,0-6.793-6.793h-.319A3.716,3.716,0,1,1,35.6,26.344a1,1,0,0,0,2,0,5.725,5.725,0,0,0-4.561-5.6V18.09a1,1,0,0,0-2,0V20.7a5.712,5.712,0,0,0,.846,11.361h.319a4.793,4.793,0,1,1-4.793,4.793,1,1,0,0,0-2,0A6.8,6.8,0,0,0,31.451,43.6v2.947a1,1,0,0,0,2,0v-3c0-.008,0-.014,0-.021A6.8,6.8,0,0,0,39,36.852Z"/> + <path d="M32,2A30,30,0,1,0,62,32,30.034,30.034,0,0,0,32,2Zm0,58A28,28,0,1,1,60,32,28.032,28.032,0,0,1,32,60Z"/> + <path d="M49.655,16.793a3.172,3.172,0,1,0-3.172,3.172,3.137,3.137,0,0,0,1.263-.266A19.994,19.994,0,0,1,22.692,49.707a1,1,0,0,0-.933,1.769,21.986,21.986,0,0,0,27.47-33.124A3.141,3.141,0,0,0,49.655,16.793Zm-4.344,0a1.172,1.172,0,1,1,1.172,1.172A1.172,1.172,0,0,1,45.311,16.793Z"/> + <path d="M16.793,44.035a3.164,3.164,0,0,0-.692.081A19.779,19.779,0,0,1,12,32,20.023,20.023,0,0,1,32,12a19.811,19.811,0,0,1,8.463,1.874,1,1,0,0,0,.848-1.812A21.989,21.989,0,0,0,14.39,45.16a3.141,3.141,0,0,0-.769,2.047,3.172,3.172,0,1,0,3.172-3.172Zm0,4.344a1.172,1.172,0,1,1,1.172-1.172A1.172,1.172,0,0,1,16.793,48.379Z"/> + </g> + </g> + </svg> \ 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<c literal 0 HcmV?d00001 diff --git a/assets/images/meld_logo.svg b/assets/images/meld_logo.svg new file mode 100644 index 000000000..1d9211d64 --- /dev/null +++ b/assets/images/meld_logo.svg @@ -0,0 +1,30 @@ +<svg width="40" height="42" viewBox="0 0 40 42" fill="none" xmlns="http://www.w3.org/2000/svg"> +<path opacity="0.7" d="M19.9526 41.9076L7.3877 34.655V26.1226L19.9526 33.3751V41.9076Z" fill="url(#paint0_linear_2113_32117)"/> +<path opacity="0.7" d="M19.9521 41.9076L32.5171 34.655V26.1226L19.9521 33.3751V41.9076Z" fill="url(#paint1_linear_2113_32117)"/> +<path opacity="0.7" d="M39.9095 7.34521V21.8562L32.5166 26.1225V11.6114L39.9095 7.34521Z" fill="url(#paint2_linear_2113_32117)"/> +<path d="M39.9099 7.34536L27.345 0.0927734L19.9521 4.359L32.5171 11.6116L39.9099 7.34536Z" fill="url(#paint3_linear_2113_32117)"/> +<path d="M0 7.34536L12.5649 0.0927734L19.9519 4.359L7.387 11.6116L0 7.34536Z" fill="#F969D3"/> +<path opacity="0.7" d="M0 7.34521V21.8562L7.387 26.1225V11.6114L0 7.34521Z" fill="url(#paint4_linear_2113_32117)"/> +<defs> +<linearGradient id="paint0_linear_2113_32117" x1="18.6099" y1="41.8335" x2="7.73529" y2="8.31842" gradientUnits="userSpaceOnUse"> +<stop stop-color="#E98ADA"/> +<stop offset="1" stop-color="#7E4DBD"/> +</linearGradient> +<linearGradient id="paint1_linear_2113_32117" x1="26.2346" y1="26.1226" x2="26.2346" y2="41.9076" gradientUnits="userSpaceOnUse"> +<stop stop-color="#719DED"/> +<stop offset="1" stop-color="#2545BE"/> +</linearGradient> +<linearGradient id="paint2_linear_2113_32117" x1="36.213" y1="7.34521" x2="36.213" y2="26.1225" gradientUnits="userSpaceOnUse"> +<stop stop-color="#93EBFF"/> +<stop offset="1" stop-color="#197DDB"/> +</linearGradient> +<linearGradient id="paint3_linear_2113_32117" x1="29.931" y1="0.0927734" x2="38.2156" y2="14.8448" gradientUnits="userSpaceOnUse"> +<stop stop-color="#F969D3"/> +<stop offset="1" stop-color="#4F51C0"/> +</linearGradient> +<linearGradient id="paint4_linear_2113_32117" x1="18.1251" y1="44.2539" x2="-7.06792" y2="15.2763" gradientUnits="userSpaceOnUse"> +<stop stop-color="#E98ADA"/> +<stop offset="1" stop-color="#7E4DBD"/> +</linearGradient> +</defs> +</svg> 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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 Hk754@^tnqa literal 0 HcmV?d00001 diff --git a/assets/images/skrill.svg b/assets/images/skrill.svg new file mode 100644 index 000000000..b264b57eb --- /dev/null +++ b/assets/images/skrill.svg @@ -0,0 +1,15 @@ +<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" version="1.1" width="256" height="256" viewBox="0 0 256 256" xml:space="preserve"> + +<defs> +</defs> +<g style="stroke: none; stroke-width: 0; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: none; fill-rule: nonzero; opacity: 1;" transform="translate(1.4065934065934016 1.4065934065934016) scale(2.81 2.81)" > + <circle cx="45" cy="45" r="45" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(127,33,99); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) "/> + <polygon points="69.59,36.9 69.59,54.86 74.5,54.86 74.5,36.02 " style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(249,249,249); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) "/> + <polygon points="62.42,36.9 67.33,36.02 67.33,54.87 62.42,54.87 " style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(249,249,249); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) "/> + <rect x="55.43" y="41.08" rx="0" ry="0" width="4.91" height="13.78" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(249,249,249); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) "/> + <path d="M 57.879 39.76 c 1.332 0 2.425 -1.082 2.425 -2.415 s -1.082 -2.425 -2.425 -2.425 c -1.332 0 -2.415 1.082 -2.415 2.425 C 55.465 38.677 56.547 39.76 57.879 39.76 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(249,249,249); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" /> + <path d="M 52.665 40.884 c -4.538 0.146 -6.838 2.186 -6.838 6.234 v 7.754 h 4.954 v -6.328 c 0 -2.425 0.312 -3.466 3.195 -3.559 v -4.038 C 53.477 40.853 52.665 40.884 52.665 40.884 L 52.665 40.884 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(249,249,249); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" /> + <path d="M 39.344 41.071 c -0.104 0.271 -0.895 2.498 -2.8 4.798 v -9.845 l -5.068 0.999 v 17.838 h 5.068 v -5.516 c 1.467 2.206 2.196 5.516 2.196 5.516 h 6.068 c -0.604 -2.498 -3.226 -7.098 -3.226 -7.098 c 2.352 -2.987 3.393 -6.172 3.559 -6.702 h -5.797 L 39.344 41.071 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(249,249,249); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" /> + <path d="M 22.973 43.028 c -0.624 -0.042 -2.061 -0.135 -2.061 -1.426 c 0 -1.561 2.071 -1.561 2.841 -1.561 c 1.363 0 3.133 0.406 4.392 0.781 c 0 0 0.708 0.25 1.301 0.5 l 0.052 0.01 v -4.267 l -0.073 -0.021 c -1.488 -0.52 -3.216 -1.02 -6.432 -1.02 c -5.537 0 -7.493 3.226 -7.493 5.984 c 0 1.592 0.687 5.339 7.025 5.776 c 0.541 0.031 1.967 0.114 1.967 1.457 c 0 1.103 -1.166 1.759 -3.133 1.759 c -2.154 0 -4.236 -0.552 -5.506 -1.072 v 4.402 c 1.894 0.5 4.038 0.749 6.546 0.749 c 5.412 0 7.837 -3.049 7.837 -6.078 C 30.237 45.567 27.531 43.34 22.973 43.028 z" style="stroke: none; stroke-width: 1; stroke-dasharray: none; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 10; fill: rgb(249,249,249); fill-rule: nonzero; opacity: 1;" transform=" matrix(1 0 0 1 0 0) " stroke-linecap="round" /> +</g> +</svg> \ 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24" width="512" height="512"> + <path d="M12,0C5.383,0,0,5.383,0,12s5.383,12,12,12,12-5.383,12-12S18.617,0,12,0Zm0,23c-6.065,0-11-4.935-11-11S5.935,1,12,1s11,4.935,11,11-4.935,11-11,11Zm4-8.626c0,1.448-1.178,2.626-2.626,2.626h-.874v1.5c0,.276-.224,.5-.5,.5s-.5-.224-.5-.5v-1.5h-.926c-.979,0-1.893-.526-2.382-1.375-.139-.239-.057-.545,.183-.683,.238-.14,.544-.057,.683,.183,.312,.54,.894,.875,1.517,.875h2.8c.896,0,1.626-.729,1.626-1.626,0-.803-.575-1.478-1.368-1.605l-3.422-.55c-1.28-.206-2.21-1.296-2.21-2.593,0-1.448,1.178-2.626,2.626-2.626h.874v-1.5c0-.276,.224-.5,.5-.5s.5,.224,.5,.5v1.5h.926c.979,0,1.892,.527,2.382,1.375,.139,.239,.057,.545-.183,.683-.236,.136-.545,.057-.683-.183-.312-.54-.894-.875-1.517-.875h-2.8c-.896,0-1.626,.729-1.626,1.626,0,.803,.575,1.478,1.368,1.605l3.422,.55c1.28,.206,2.21,1.297,2.21,2.593Z" fill="white"/> +</svg> \ 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 @@ +<?xml version="1.0" encoding="UTF-8"?> +<svg xmlns="http://www.w3.org/2000/svg" id="Layer_1" data-name="Layer 1" viewBox="0 0 24 24" width="512" height="512"><path d="M12,0C5.383,0,0,5.383,0,12s5.383,12,12,12,12-5.383,12-12S18.617,0,12,0Zm0,23c-6.065,0-11-4.935-11-11S5.935,1,12,1s11,4.935,11,11-4.935,11-11,11Zm4-8.626c0,1.448-1.178,2.626-2.626,2.626h-.874v1.5c0,.276-.224,.5-.5,.5s-.5-.224-.5-.5v-1.5h-.926c-.979,0-1.893-.526-2.382-1.375-.139-.239-.057-.545,.183-.683,.238-.14,.544-.057,.683,.183,.312,.54,.894,.875,1.517,.875h2.8c.896,0,1.626-.729,1.626-1.626,0-.803-.575-1.478-1.368-1.605l-3.422-.55c-1.28-.206-2.21-1.296-2.21-2.593,0-1.448,1.178-2.626,2.626-2.626h.874v-1.5c0-.276,.224-.5,.5-.5s.5,.224,.5,.5v1.5h.926c.979,0,1.892,.527,2.382,1.375,.139,.239,.057,.545-.183,.683-.236,.136-.545,.057-.683-.183-.312-.54-.894-.875-1.517-.875h-2.8c-.896,0-1.626,.729-1.626,1.626,0,.803,.575,1.478,1.368,1.605l3.422,.55c1.28,.206,2.21,1.297,2.21,2.593Z"/></svg> 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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 literal 0 HcmV?d00001 diff --git a/cw_core/lib/currency_for_wallet_type.dart b/cw_core/lib/currency_for_wallet_type.dart index 630078949..af6037a3b 100644 --- a/cw_core/lib/currency_for_wallet_type.dart +++ b/cw_core/lib/currency_for_wallet_type.dart @@ -35,3 +35,34 @@ CryptoCurrency currencyForWalletType(WalletType type, {bool? isTestnet}) { 'Unexpected wallet type: ${type.toString()} for CryptoCurrency currencyForWalletType'); } } + +WalletType? walletTypeForCurrency(CryptoCurrency currency) { + switch (currency) { + case CryptoCurrency.btc: + return WalletType.bitcoin; + case CryptoCurrency.xmr: + return WalletType.monero; + case CryptoCurrency.ltc: + return WalletType.litecoin; + case CryptoCurrency.xhv: + return WalletType.haven; + case CryptoCurrency.eth: + return WalletType.ethereum; + case CryptoCurrency.bch: + return WalletType.bitcoinCash; + case CryptoCurrency.nano: + return WalletType.nano; + case CryptoCurrency.banano: + return WalletType.banano; + case CryptoCurrency.maticpoly: + return WalletType.polygon; + case CryptoCurrency.sol: + return WalletType.solana; + case CryptoCurrency.trx: + return WalletType.tron; + case CryptoCurrency.wow: + return WalletType.wownero; + default: + return null; + } +} diff --git a/lib/buy/buy_provider.dart b/lib/buy/buy_provider.dart index 1a37e09b3..8e79e16b8 100644 --- a/lib/buy/buy_provider.dart +++ b/lib/buy/buy_provider.dart @@ -1,6 +1,10 @@ import 'package:cake_wallet/buy/buy_amount.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/view_model/hardware_wallet/ledger_view_model.dart'; +import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/wallet_base.dart'; import 'package:flutter/material.dart'; @@ -23,14 +27,38 @@ abstract class BuyProvider { String get darkIcon; + bool get isAggregator; + @override String toString() => title; - Future<void> launchProvider(BuildContext context, bool? isBuyAction); + Future<void>? launchProvider( + {required BuildContext context, + required Quote quote, + required double amount, + required bool isBuyAction, + required String cryptoCurrencyAddress, + String? countryCode}) => + null; Future<String> requestUrl(String amount, String sourceCurrency) => throw UnimplementedError(); Future<Order> findOrderById(String id) => throw UnimplementedError(); - Future<BuyAmount> calculateAmount(String amount, String sourceCurrency) => throw UnimplementedError(); + Future<BuyAmount> calculateAmount(String amount, String sourceCurrency) => + throw UnimplementedError(); + + Future<List<PaymentMethod>> getAvailablePaymentTypes( + String fiatCurrency, String cryptoCurrency, bool isBuyAction) async => + []; + + Future<List<Quote>?> 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<ProviderRecommendation> 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<String> 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<String, dynamic> json, bool isBuyAction, + Map<String, dynamic> 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<String>.from(json['recommendations'] as List<dynamic>) + : <String>[]; + + final enumRecommendations = recommendations + .map((e) => getRecommendationFromString(e)) + .whereType<ProviderRecommendation>() + .toList(); + + final availablePaymentMethods = json['availablePaymentMethods'] as List<dynamic>? ?? []; + double minLimit = 0.0; + double maxLimit = double.infinity; + + for (var paymentMethod in availablePaymentMethods) { + if (paymentMethod is Map<String, dynamic>) { + final details = paymentMethod['details'] as Map<String, dynamic>?; + + if (details != null) { + final limits = details['limits'] as Map<String, dynamic>?; + + if (limits != null && limits.isNotEmpty) { + final firstLimitEntry = limits.values.first as Map<String, dynamic>?; + 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<String, dynamic> 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<String, dynamic>?; + + 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<String, dynamic> json, + bool isBuyAction, + PaymentType paymentType, + ) { + final rate = _toDouble(json['exchangeRate']) ?? 0.0; + final fees = json['fees'] as Map<String, dynamic>; + + 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<String, dynamic> json, bool isBuyAction, PaymentType paymentType) { + final networkFee = json['networkFee'] as Map<String, dynamic>; + final processingFee = json['processingFee'] as Map<String, dynamic>; + 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<String, dynamic> json, bool isBuyAction, PaymentType paymentType) { + final quotes = json['quotes'][0] as Map<String, dynamic>; + 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<String> getSignMessage() async => + Future<String> 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<String> auth() async { - final signMessage = await getSignature(await getSignMessage()); + Future<String> 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<String> getSignature(String message) async { + Future<String> getSignature(String message, String walletAddress) async { switch (wallet.type) { case WalletType.ethereum: case WalletType.polygon: @@ -135,8 +118,178 @@ class DFXBuyProvider extends BuyProvider { } } + Future<Map<String, dynamic>> 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<dynamic>; + for (final item in data) { + if (item['name'] == fiatCurrency) return item as Map<String, dynamic>; + } + 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<Map<String, dynamic>> 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<String, dynamic>; + } else if (responseData is Map<String, dynamic>) { + 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<List<PaymentMethod>> getAvailablePaymentTypes( + String fiatCurrency, String cryptoCurrency, bool isBuyAction) async { + final List<PaymentMethod> paymentMethods = []; + + if (isBuyAction) { + final fiatBuyCredentials = await fetchFiatCredentials(fiatCurrency); + if (fiatBuyCredentials.isNotEmpty) { + fiatBuyCredentials.forEach((key, value) { + if (key == 'limits') { + final limits = value as Map<String, dynamic>; + 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<void> launchProvider(BuildContext context, bool? isBuyAction) async { + Future<List<Quote>?> 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<String, dynamic>) { + 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<String, dynamic> && 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<void>? 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<List<PaymentMethod>> 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<dynamic>; + final paymentMethods = + data.map((e) => PaymentMethod.fromMeldJson(e as Map<String, dynamic>)).toList(); + return paymentMethods; + } else { + print('Meld: Failed to fetch payment types'); + return List<PaymentMethod>.empty(); + } + } catch (e) { + print('Meld: Failed to fetch payment types: $e'); + return List<PaymentMethod>.empty(); + } + } + + @override + Future<List<Quote>?> 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<String, dynamic>; + 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<void>? 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<void>( + 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<String> 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<String, dynamic>)['signature'] as String; @@ -94,85 +99,195 @@ class MoonPayProvider extends BuyProvider { } } - Future<Uri> 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<Map<String, dynamic>> 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<String, dynamic>; + } 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<String, dynamic>.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<List<PaymentMethod>> getAvailablePaymentTypes( + String fiatCurrency, String cryptoCurrency, bool isBuyAction) async { + final List<PaymentMethod> 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<List<Quote>?> 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<Uri> 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<String, dynamic>; + + // 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<String, dynamic>?; + 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<void>? launchProvider( + {required BuildContext context, + required Quote quote, + required double amount, + required bool isBuyAction, + required String cryptoCurrencyAddress, + String? countryCode}) async { + + final Map<String, String> 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<void>( + 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<Uri> requestMoonPayUrl({ + required String walletAddress, + required SettingsStore settingsStore, + required bool isBuyAction, + required Map<String, String> 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<String, dynamic>.from(originalUri.queryParameters); @@ -181,33 +296,6 @@ class MoonPayProvider extends BuyProvider { return signedUri; } - Future<BuyAmount> 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<String, dynamic>; - 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<Order> 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<bool> 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<String, dynamic>; - isBuyEnable = responseJSON['isBuyAllowed'] as bool; - } catch (e) { - isBuyEnable = false; - print(e.toString()); - } - - return isBuyEnable; - } - - @override - Future<void> 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<void>( - 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<List<PaymentMethod>> 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<String, dynamic> data = jsonDecode(response.body) as Map<String, dynamic>; + final List<dynamic> message = data['message'] as List<dynamic>; + return message + .map((item) => PaymentMethod.fromOnramperJson(item as Map<String, dynamic>)) + .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<Map<String, dynamic>> 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<String, dynamic> data = jsonDecode(response.body) as Map<String, dynamic>; + + final List<dynamic> onramps = data['message'] as List<dynamic>; + + final Map<String, dynamic> 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<List<Quote>?> 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<CakeTextTheme>()!.titleColor); - secondaryTextColor = getColorStr( - Theme.of(context).extension<CakeTextTheme>()!.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<dynamic>; + if (data.isEmpty) return null; + + List<Quote> validQuotes = []; + + final onrampMetadata = await getOnrampMetadata(); + + for (var item in data) { + + if (item['errors'] != null) continue; + + final paymentMethod = (item as Map<String, dynamic>)['paymentMethod'] as String; + + final rampId = item['ramp'] as String?; + final rampMetaData = onrampMetadata[rampId] as Map<String, dynamic>?; + + 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<void>? 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<CakeTextTheme>()!.titleColor); + final secondaryTextColor = + getColorStr(Theme.of(context).extension<CakeTextTheme>()!.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, '', <String, dynamic>{ + 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<void> 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<CryptoCurrency> 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<String, dynamic> 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<String, dynamic> json, PaymentType paymentType) { + return PaymentMethod( + paymentMethodType: paymentType, + customTitle: json['paymentMethod'] as String, + customIconPath: 'assets/images/card.png'); + } + + factory PaymentMethod.fromMeldJson(Map<String, dynamic> json) { + final type = PaymentMethod.getPaymentTypeId(json['paymentMethod'] as String?); + final logos = json['logos'] as Map<String, dynamic>; + 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<void> launchProvider(BuildContext context, bool? isBuyAction) async { + Future<void>? 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<List<Quote>?> 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<String, dynamic>; + + 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<void> 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<String> 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<void> setup({ wallet: getIt.get<AppStore>().wallet!, )); + getIt.registerFactory<MeldBuyProvider>(() => MeldBuyProvider( + wallet: getIt.get<AppStore>().wallet!, + )); + getIt.registerFactoryParam<WebViewPage, String, Uri>((title, uri) => WebViewPage(title, uri)); getIt.registerFactory<PayfuraBuyProvider>(() => PayfuraBuyProvider( @@ -1193,8 +1202,25 @@ Future<void> setup({ getIt.registerFactory(() => BuyAmountViewModel()); - getIt.registerFactoryParam<BuySellOptionsPage, bool, void>( - (isBuyOption, _) => BuySellOptionsPage(getIt.get<DashboardViewModel>(), isBuyOption)); + getIt.registerFactory(() => BuySellViewModel(getIt.get<AppStore>())); + + getIt.registerFactory(() => BuySellPage(getIt.get<BuySellViewModel>())); + + getIt.registerFactoryParam<BuyOptionsPage, List<dynamic>, void>((List<dynamic> args, _) { + final items = args.first as List<SelectableItem>; + 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<PaymentMethodOptionsPage, List<dynamic>, void>((List<dynamic> args, _) { + final items = args.first as List<SelectableOption>; + final pickAnOption = args[1] as void Function(SelectableOption option)?; + + return PaymentMethodOptionsPage( + items: items, pickAnOption: pickAnOption); + }); getIt.registerFactory(() { final wallet = getIt.get<AppStore>().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<MainActions> 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<void> _showErrorDialog( - BuildContext context, String title, String errorMessage) async { - await showPopUp<void>( - 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<OnRamperBuyProvider>(); case ProviderType.moonpay: return getIt.get<MoonPayProvider>(); - case ProviderType.askEachTime: + case ProviderType.meld: + return getIt.get<MeldBuyProvider>(); + 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<dynamic> createRoute(RouteSettings settings) { case Routes.buySellPage: final args = settings.arguments as bool; - return MaterialPageRoute<void>(builder: (_) => getIt.get<BuySellOptionsPage>(param1: args)); + return MaterialPageRoute<void>(builder: (_) => getIt.get<BuySellPage>(param1: args)); + + case Routes.buyOptionsPage: + final args = settings.arguments as List; + return MaterialPageRoute<void>(builder: (_) => getIt.get<BuyOptionsPage>(param1: args)); + + case Routes.paymentMethodOptionsPage: + final args = settings.arguments as List; + return MaterialPageRoute<void>(builder: (_) => getIt.get<PaymentMethodOptionsPage>(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<OptionTileTheme>()?.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<TransactionTradeTheme>()!.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<SelectableItem> 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<ExchangeCardState>(); + final fiatCurrencyKey = GlobalKey<ExchangeCardState>(); + final _formKey = GlobalKey<FormState>(); + 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<KeyboardTheme>()!.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<CakeTextTheme>()!.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<ExchangeCardState> 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<ExchangeCardState> 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<SendPageTheme>()!.textFieldButtonColor, + borderColor: + Theme.of(context).extension<ExchangePageTheme>()!.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<SendPageTheme>()!.textFieldButtonColor, + borderColor: + Theme.of(context).extension<ExchangePageTheme>()!.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<String> fetchParsedAddress( + BuildContext context, String domain, CryptoCurrency currency) async { + final parsedAddress = await getIt.get<AddressResolver>().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<SelectableItem> 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<T extends Currency> 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<CryptoCurrency> currencies; - final Function(CryptoCurrency) onCurrencySelected; + final List<T> 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<T> createState() => ExchangeCardState<T>(); } -class ExchangeCardState extends State<ExchangeCard> { +class ExchangeCardState<T extends Currency> extends State<ExchangeCard<T>> { ExchangeCardState() : _title = '', _min = '', @@ -89,7 +97,6 @@ class ExchangeCardState extends State<ExchangeCard> { _isAmountEditable = false, _isAddressEditable = false, _walletName = '', - _selectedCurrency = CryptoCurrency.btc, _isAmountEstimated = false, _isMoneroWallet = false, _cardInstanceName = ''; @@ -101,7 +108,7 @@ class ExchangeCardState extends State<ExchangeCard> { 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<ExchangeCard> { _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<ExchangeCard> { }); } - void changeSelectedCurrency(CryptoCurrency currency) { + void changeSelectedCurrency(T currency) { setState(() => _selectedCurrency = currency); } @@ -157,7 +165,7 @@ class ExchangeCardState extends State<ExchangeCard> { } 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<ExchangeCard> { Divider(height: 1, color: Theme.of(context).extension<SendPageTheme>()!.textFieldHintColor), Padding( padding: EdgeInsets.only(top: 5), - child: Container( + child: widget.showLimitsField ? Container( height: 15, child: Row(mainAxisAlignment: MainAxisAlignment.start, children: <Widget>[ _min != null @@ -247,7 +255,7 @@ class ExchangeCardState extends State<ExchangeCard> { ), ) : Offstage(), - ])), + ])) : Offstage(), ), !_isAddressEditable && widget.hasRefundAddress ? Padding( @@ -261,10 +269,11 @@ class ExchangeCardState extends State<ExchangeCard> { )) : 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<ExchangeCard> { 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<ExchangePageTheme>()!.hintTextColor), + color: + Theme.of(context).extension<ExchangePageTheme>()!.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<ExchangeCard> { 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<ExchangeCard> { 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<BuySellOptionButtons> { + 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<SendPageTheme>()!.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<SendPageTheme>()!.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<SelectableItem> 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<TransactionTradeTheme>()!.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<SelectableItem> 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<BodySelectOptionsPage> { + late List<SelectableItem> _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<OptionTileTheme>()?.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<T extends Currency> 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<String>? 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<ReceivePageTheme>()!.currentTileBackgroundColor + : Theme.of(context).extension<OptionTileTheme>()!.titleColor + : Theme.of(context).cardColor; + + final textColor = isSelected + ? isLightMode + ? Colors.white + : Theme.of(context).cardColor + : Theme.of(context).extension<OptionTileTheme>()!.titleColor; + + final badgeColor = isSelected + ? Theme.of(context).cardColor + : Theme.of(context).extension<OptionTileTheme>()!.titleColor; + + final badgeTextColor = isSelected + ? Theme.of(context).extension<OptionTileTheme>()!.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<OptionTilePlaceholder> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation<double> _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<OptionTileTheme>()!.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<WalletType, TransactionPriority>(), - defaultBuyProviders = ObservableMap<WalletType, ProviderType>(), - defaultSellProviders = ObservableMap<WalletType, ProviderType>() { + priority = ObservableMap<WalletType, TransactionPriority>() { //this.nodes = ObservableMap<WalletType, Node>.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<String, bool> trocadorProviderStates = ObservableMap<String, bool>(); - @observable - ObservableMap<WalletType, ProviderType> defaultBuyProviders; - - @observable - ObservableMap<WalletType, ProviderType> 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 = <CryptoCurrency>[], + fiatCurrencies = <FiatCurrency>[], + paymentMethodState = InitialPaymentMethod(), + buySellQuotState = InitialBuySellQuotState(), + cryptoCurrency = appStore.wallet!.currency, + fiatCurrency = appStore.settingsStore.fiatCurrency, + providerList = [], + sortedRecommendedQuotes = ObservableList<Quote>(), + sortedQuotes = ObservableList<Quote>(), + paymentMethods = ObservableList<PaymentMethod>(), + 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<BuyProvider> get availableBuyProviders { + final providerTypes = ProvidersHelper.getAvailableBuyProviderTypes( + walletTypeForCurrency(cryptoCurrency) ?? wallet.type); + return providerTypes + .map((type) => ProvidersHelper.getProviderByType(type)) + .where((provider) => provider != null) + .cast<BuyProvider>() + .toList(); + } + + List<BuyProvider> get availableSellProviders { + final providerTypes = ProvidersHelper.getAvailableSellProviderTypes( + walletTypeForCurrency(cryptoCurrency) ?? wallet.type); + return providerTypes + .map((type) => ProvidersHelper.getProviderByType(type)) + .where((provider) => provider != null) + .cast<BuyProvider>() + .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<CryptoCurrency> cryptoCurrencies; + + @observable + List<FiatCurrency> fiatCurrencies; + + @observable + bool isBuyAction = true; + + @observable + List<BuyProvider> providerList; + + @observable + ObservableList<Quote> sortedRecommendedQuotes; + + @observable + ObservableList<Quote> sortedQuotes; + + @observable + ObservableList<PaymentMethod> 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<void> 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<void> 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<Quote>.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<SelectableItem>.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<void> _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<void> _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<PaymentType, PaymentMethod> uniquePaymentMethods = {}; + for (var methods in result) { + for (var method in methods) { + uniquePaymentMethods[method.paymentMethodType] = method; + } + } + + paymentMethods = ObservableList<PaymentMethod>.of(uniquePaymentMethods.values); + if (paymentMethods.isNotEmpty) { + paymentMethods.insert(0, PaymentMethod.all()); + selectedPaymentMethod = paymentMethods.first; + selectedPaymentMethod!.isSelected = true; + paymentMethodState = PaymentMethodLoaded(); + } else { + paymentMethodState = PaymentMethodFailed(); + } + } + + @action + Future<void> calculateBestRate() async { + buySellQuotState = BuySellQuotLoading(); + + final result = await Future.wait<List<Quote>?>(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<String> addedProviders = {}; + final List<Quote> 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<void> 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<String, List<FilterItem>> 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<BuyProvider> get availableBuyProviders { - final providerTypes = ProvidersHelper.getAvailableBuyProviderTypes(wallet.type); - return providerTypes - .map((type) => ProvidersHelper.getProviderByType(type)) - .where((provider) => provider != null) - .cast<BuyProvider>() - .toList(); - } - - bool get hasBuyProviders => ProvidersHelper.getAvailableBuyProviderTypes(wallet.type).isNotEmpty; - - List<BuyProvider> get availableSellProviders { - final providerTypes = ProvidersHelper.getAvailableSellProviderTypes(wallet.type); - return providerTypes - .map((type) => ProvidersHelper.getProviderByType(type)) - .where((provider) => provider != null) - .cast<BuyProvider>() - .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<ProviderType> get availableBuyProvidersTypes { - return ProvidersHelper.getAvailableBuyProviderTypes(walletType); - } - - List<ProviderType> 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 <omarh.ismail1@gmail.com> Date: Sat, 9 Nov 2024 21:07:17 +0200 Subject: [PATCH 09/15] 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 <borodenko.sv@gmail.com> Date: Sat, 9 Nov 2024 23:44:08 +0200 Subject: [PATCH 10/15] 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 <omarh.ismail1@gmail.com> Date: Sun, 10 Nov 2024 00:14:38 +0200 Subject: [PATCH 11/15] 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 <omarh.ismail1@gmail.com> Date: Sun, 10 Nov 2024 01:16:21 +0200 Subject: [PATCH 12/15] 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 <borodenko.sv@gmail.com> Date: Mon, 11 Nov 2024 19:39:45 +0200 Subject: [PATCH 13/15] 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 <konstantinullrich12@gmail.com> Date: Tue, 12 Nov 2024 04:26:09 +0100 Subject: [PATCH 14/15] 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 <omarh.ismail1@gmail.com> Co-authored-by: Czarek Nakamoto <cyjan@mrcyjanek.net> Co-authored-by: m <m@cakewallet.com> Co-authored-by: Rafael Saes <git@rafael.saes.dev> Co-authored-by: Matthew Fosse <matt@fosse.co> --- 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<DeviceConnectionType> 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<N extends WalletCredentials, RFS extends WalletCred return ''; } } + + /// Check if the Wallet requires a hardware wallet to be connected during + /// the opening flow. (Currently only the case for Monero) + bool requireHardwareWalletConnection(String name) => 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<bool> 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<void> 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<Void>.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<String, monero.wallet> openedWalletsByPath = {}; -void loadWallet( - {required String path, required String password, int nettype = 0}) { +Future<void> 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<Void>.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<void> _openWallet(Map<String, String> args) async => loadWallet( bool _isWalletExist(String path) => isWalletExistSync(path: path); -void openWallet( +Future<void> 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<Uint8>() + .asTypedList(ledgerRequestLength); + if (ledgerRequestLength > 0) { + _ledgerKeepAlive?.cancel(); + + final Pointer<Uint8> emptyPointer = malloc<Uint8>(0); + monero.Wallet_setDeviceSendData( + ptr, emptyPointer.cast<UnsignedChar>(), 0); + malloc.free(emptyPointer); + + // print("> ${ledgerRequest.toHexString()}"); + final response = await exchange(connection, ledgerRequest); + // print("< ${response.toHexString()}"); + + final Pointer<Uint8> result = malloc<Uint8>(response.length); + for (var i = 0; i < response.length; i++) { + result.asTypedList(response.length)[i] = response[i]; + } + + monero.Wallet_setDeviceReceivedData( + ptr, result.cast<UnsignedChar>(), 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<Uint8List> exchange(LedgerConnection connection, Uint8List data) async => + connection.sendOperation<Uint8List>(ExchangeOperation(data)); + +class ExchangeOperation extends LedgerRawOperation<Uint8List> { + final Uint8List inputData; + + ExchangeOperation(this.inputData); + + @override + Future<Uint8List> read(ByteDataReader reader) async => + reader.read(reader.remainingLength); + + @override + Future<List<Uint8List>> 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<MoneroBalance, return monero_wallet.verifyMessage(message, address, signature); } + void setLedgerConnection(LedgerConnection connection) { + final dummyWPtr = wptr ?? + monero.WalletManager_openWallet(wmPtr, path: '', password: ''); + enableLedgerExchange(dummyWPtr, connection); + } } diff --git a/cw_monero/lib/monero_wallet_service.dart b/cw_monero/lib/monero_wallet_service.dart index 63ad17583..b2089dfc7 100644 --- a/cw_monero/lib/monero_wallet_service.dart +++ b/cw_monero/lib/monero_wallet_service.dart @@ -9,10 +9,13 @@ import 'package:cw_core/wallet_info.dart'; import 'package:cw_core/wallet_service.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:cw_core/get_height_by_date.dart'; +import 'package:cw_monero/api/account_list.dart'; import 'package:cw_monero/api/wallet_manager.dart' as monero_wallet_manager; import 'package:cw_monero/api/wallet_manager.dart'; +import 'package:cw_monero/ledger.dart'; import 'package:cw_monero/monero_wallet.dart'; import 'package:hive/hive.dart'; +import 'package:ledger_flutter_plus/ledger_flutter_plus.dart'; import 'package:polyseed/polyseed.dart'; import 'package:monero/monero.dart' as monero; @@ -25,6 +28,15 @@ class MoneroNewWalletCredentials extends WalletCredentials { final bool isPolyseed; } +class MoneroRestoreWalletFromHardwareCredentials extends WalletCredentials { + MoneroRestoreWalletFromHardwareCredentials({required String name, + required this.ledgerConnection, + int height = 0, + String? password}) + : super(name: name, password: password, height: height); + LedgerConnection ledgerConnection; +} + class MoneroRestoreWalletFromSeedCredentials extends WalletCredentials { MoneroRestoreWalletFromSeedCredentials( {required String name, required this.mnemonic, int height = 0, String? password}) @@ -39,14 +51,13 @@ class MoneroWalletLoadingException implements Exception { } class MoneroRestoreWalletFromKeysCredentials extends WalletCredentials { - MoneroRestoreWalletFromKeysCredentials( - {required String name, - required String password, - required this.language, - required this.address, - required this.viewKey, - required this.spendKey, - int height = 0}) + MoneroRestoreWalletFromKeysCredentials({required String name, + required String password, + required this.language, + required this.address, + required this.viewKey, + required this.spendKey, + int height = 0}) : super(name: name, password: password, height: height); final String language; @@ -59,7 +70,7 @@ class MoneroWalletService extends WalletService< MoneroNewWalletCredentials, MoneroRestoreWalletFromSeedCredentials, MoneroRestoreWalletFromKeysCredentials, - MoneroNewWalletCredentials> { + MoneroRestoreWalletFromHardwareCredentials> { MoneroWalletService(this.walletInfoSource, this.unspentCoinsInfoSource); final Box<WalletInfo> 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<void> rename( - String currentName, String password, String newName) async { + Future<void> 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<MoneroWallet> restoreFromHardwareWallet(MoneroNewWalletCredentials credentials) { - throw UnimplementedError( - "Restoring a Monero wallet from a hardware wallet is not yet supported!"); + Future<MoneroWallet> 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<MoneroWallet> _restoreFromPolyseed( - String path, String password, Polyseed polyseed, WalletInfo walletInfo, PolyseedLang lang, + Future<MoneroWallet> _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<Utf8> 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<Utf8> 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<Utf8> address; + + external Pointer<Utf8> addressLabel; + + external Pointer<Utf8> keyImage; + + @Uint64() + external int unlockTime; + + @Int8() + external int unlocked; + + external Pointer<Utf8> pubKey; + + @Int8() + external int coinbase; + + external Pointer<Utf8> 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<Utf8> address; + + external Pointer<Utf8> 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<Utf8> hash; + + external Pointer<Utf8> 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<Utf8> 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<String?> 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<String?> getPlatformVersion() async { + final version = await methodChannel.invokeMethod<String>('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<String?> 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<String> tructWords(List<String> 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<void> 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<void> setup({ ); } - getIt.registerFactory(() => WalletListPage( - walletListViewModel: getIt.get<WalletListViewModel>(), - authService: getIt.get<AuthService>(), - )); + getIt.registerFactoryParam<WalletListPage, Function(BuildContext)?, void>( + (Function(BuildContext)? onWalletLoaded, _) => WalletListPage( + walletListViewModel: getIt.get<WalletListViewModel>(), + authService: getIt.get<AuthService>(), + onWalletLoaded: onWalletLoaded, + )); getIt.registerFactoryParam<WalletEditViewModel, WalletListViewModel, void>( (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<SharedPreferences>() + .getString(PreferencesKey.currentWalletName); + final typeRaw = + getIt.get<SharedPreferences>().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<WalletLoadingService>(); + 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<dynamic> authenticatedErrorStreamController = BehaviorSubject<dynamic>(); +StreamController<dynamic> authenticatedErrorStreamController = + BehaviorSubject<dynamic>(); 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<void>( + 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<BottomSheetService>().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<dynamic> createRoute(RouteSettings settings) { final type = arguments[0] as WalletType; final walletVM = getIt.get<WalletHardwareRestoreViewModel>(param1: type); + if (type == WalletType.monero) + return CupertinoPageRoute<void>(builder: (_) => MoneroHardwareWalletOptionsPage(walletVM)); + return CupertinoPageRoute<void>(builder: (_) => SelectHardwareWalletAccountPage(walletVM)); case Routes.setupPin: @@ -403,8 +407,11 @@ Route<dynamic> createRoute(RouteSettings settings) { return CupertinoPageRoute<void>(builder: (_) => getIt.get<NanoChangeRepPage>()); case Routes.walletList: + final onWalletLoaded = settings.arguments as Function(BuildContext)?; return MaterialPageRoute<void>( - fullscreenDialog: true, builder: (_) => getIt.get<WalletListPage>()); + fullscreenDialog: true, + builder: (_) => getIt.get<WalletListPage>(param1: onWalletLoaded), + ); case Routes.walletEdit: return MaterialPageRoute<void>( 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<ConnectDevicePageBody> { Future<void> _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<ConnectDevicePageBody> { style: TextStyle( fontSize: 14, fontWeight: FontWeight.w400, - color: Theme.of(context) - .extension<CakeTextTheme>()! - .titleColor, + color: Theme.of(context).extension<CakeTextTheme>()!.titleColor, ), ), ), @@ -247,11 +261,27 @@ class ConnectDevicePageBodyState extends State<ConnectDevicePageBody> { ), ) .toList(), - ] + ], + if (widget.allowChangeWallet) ...[ + PrimaryButton( + text: S.of(context).wallets, + color: Theme.of(context).extension<WalletListTheme>()!.createNewWalletButtonBackgroundColor, + textColor: Theme.of(context).extension<WalletListTheme>()!.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<FormState>(), + _blockchainHeightKey = GlobalKey<BlockchainHeightState>(), + _blockHeightFocusNode = FocusNode(), + _controller = TextEditingController(); + + final GlobalKey<FormState> _formKey; + final GlobalKey<BlockchainHeightState> _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<CakeTextTheme>()! + .titleColor, + ), + decoration: InputDecoration( + hintStyle: TextStyle( + fontSize: 18.0, + fontWeight: FontWeight.w500, + color: Theme.of(context) + .extension<NewWalletTheme>()! + .hintTextColor, + ), + hintText: S.of(context).wallet_name, + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context) + .extension<NewWalletTheme>()! + .underlineColor, + width: 1.0, + ), + ), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide( + color: Theme.of(context) + .extension<NewWalletTheme>()! + .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<SendPageTheme>()! + .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<void> _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<void> _confirmForm() async { + showPopUp<void>( + 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<void>( + 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<WalletListBody> { @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<WalletListBody> { 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<WalletListBody> { }, ), 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<WalletListBody> { 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<WalletListBody> { 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<WalletListBody> { 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<WalletListBody> { ); } - 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<void> _loadWallet(WalletListItem wallet) async { if (SettingsStoreBase.walletPasswordDirectInput) { Navigator.of(context).pushNamed(Routes.walletUnlockLoadable, @@ -438,12 +417,36 @@ class WalletListBodyState extends State<WalletListBody> { 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<void>( + 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<WalletListBody> { 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<HardwareAccountData> 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<void> 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<WalletInfo> 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<String, String> getKeys(Object wallet); int? getRestoreHeight(Object wallet); @@ -411,6 +414,8 @@ abstract class Monero { int getTransactionInfoAccountId(TransactionInfo tx); WalletService createMoneroWalletService(Box<WalletInfo> walletInfoSource, Box<UnspentCoinsInfo> unspentCoinSource); Map<String, String> 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 <borodenko.sv@gmail.com> Date: Tue, 12 Nov 2024 05:29:32 +0200 Subject: [PATCH 15/15] 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 <omarh.ismail1@gmail.com> --- .../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<ContactPageBody> with SingleTickerProv Widget _buildWalletContacts(BuildContext context) { final walletContacts = widget.contactListViewModel.walletContactsToShow; + final groupedContacts = <String, List<ContactBase>>{}; + 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<CakeTextTheme>()!.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<ContactPageBody> 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<bool> showNameAndAddressDialog(BuildContext context, String name, String address) async { return await showPopUp<bool>( context: context, @@ -263,12 +306,13 @@ class _ContactListBodyState extends State<ContactListBody> { @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<void> delete(ContactRecord contact) async => contact.original.delete(); ObservableList<ContactRecord> get contactsToShow => - ObservableList.of(contacts.where((element) => _isValidForCurrency(element))); + ObservableList.of(contacts.where((element) => _isValidForCurrency(element, false))); @computed List<WalletContact> 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) ||