diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index e0f3eaebd..b04e490e1 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -579,6 +579,9 @@ abstract class ElectrumWalletBase @override bool get hasSilentPaymentsScanning => type == WalletType.bitcoin && keys.privateKey.isNotEmpty; + @override + bool get hasCoinControl => true; + @override bool get hasAccountsSupport => type == WalletType.bitcoin && diff --git a/cw_core/lib/balance_card_layout.dart b/cw_core/lib/balance_card_layout.dart new file mode 100644 index 000000000..9ec521959 --- /dev/null +++ b/cw_core/lib/balance_card_layout.dart @@ -0,0 +1,125 @@ +import "package:cw_core/balance_card_style_settings.dart"; + +class BalanceCardLayout { + BalanceCardLayout._( + this._settings, { + required List visible, + required List hidden, + required this.needsRepair, + }) : visible = List.unmodifiable(visible), + hidden = List.unmodifiable(hidden); + + factory BalanceCardLayout.resolve({ + required List accountIndices, + required List settings, + }) { + final List accounts = []; + for (final accountIndex in accountIndices) { + if (!accounts.contains(accountIndex)) { + accounts.add(accountIndex); + } + } + + final settingsByAccount = {}; + for (final setting in settings) { + if (accounts.contains(setting.accountIndex)) { + settingsByAccount.putIfAbsent(setting.accountIndex, () => setting); + } + } + + final hidden = [ + for (final accountIndex in accounts) + if (settingsByAccount[accountIndex]?.hidden ?? false) accountIndex, + ]..sort(); + + final visible = [ + for (final accountIndex in accounts) + if (!hidden.contains(accountIndex)) accountIndex, + ]; + + visible.sort((a, b) => _compareStackPosition(a, b, settingsByAccount)); + + var needsRepair = false; + for (var position = 0; position < visible.length; position++) { + final stored = settingsByAccount[visible[position]]; + if (stored != null && stored.cardOrder != position) { + needsRepair = true; + } + } + + return BalanceCardLayout._( + settingsByAccount, + visible: visible, + hidden: hidden, + needsRepair: needsRepair, + ); + } + + final Map _settings; + + final List visible; + final List hidden; + + // flag when db order is broken + final bool needsRepair; + + List get designOrder => designOrderOf(visible); + + static List stackFromOrder(Map cardOrder, {bool forceSingleCard = false}) { + final positions = cardOrder.keys.toList()..sort(); + final accountIndices = [for (final position in positions) cardOrder[position]!]; + + if (accountIndices.isEmpty || !forceSingleCard) { + return accountIndices; + } + return [accountIndices.first]; + } + + static List designOrderOf(Iterable accountIndices) => + List.of(accountIndices)..sort(); + + Map get orders => + {for (var position = 0; position < visible.length; position++) visible[position]: position}; + + BalanceCardStyleSettings? settingFor(int accountIndex) => _settings[accountIndex]; + + BalanceCardLayout unhiding(int accountIndex) { + if (!hidden.contains(accountIndex)) { + return this; + } + + return BalanceCardLayout._( + _settings, + visible: [...visible, accountIndex], + hidden: [ + for (final hiddenAccount in hidden) + if (hiddenAccount != accountIndex) hiddenAccount, + ], + needsRepair: true, + ); + } + + static int _compareStackPosition( + int a, + int b, + Map settingsByAccount, + ) { + final orderA = _storedOrder(settingsByAccount[a]); + final orderB = _storedOrder(settingsByAccount[b]); + + if (orderA != orderB) { + if (orderA == null) { + return 1; + } + if (orderB == null) { + return -1; + } + return orderA.compareTo(orderB); + } + + return a.compareTo(b); + } + + static int? _storedOrder(BalanceCardStyleSettings? setting) => + setting == null || setting.cardOrder < 0 ? null : setting.cardOrder; +} diff --git a/cw_core/lib/balance_card_style_settings.dart b/cw_core/lib/balance_card_style_settings.dart index 6e376b33f..526d9dfee 100644 --- a/cw_core/lib/balance_card_style_settings.dart +++ b/cw_core/lib/balance_card_style_settings.dart @@ -7,6 +7,7 @@ class BalanceCardStyleSettings { final int accountIndex; final int gradientIndex; final bool useSpecialDesign; + final bool hidden; final String backgroundImagePath; final int iconStyleIndex; final bool isGradientOnly; @@ -17,6 +18,7 @@ class BalanceCardStyleSettings { required this.accountIndex, required this.gradientIndex, required this.useSpecialDesign, + required this.hidden, required this.backgroundImagePath, this.iconStyleIndex = 0, this.isGradientOnly = false, @@ -29,6 +31,7 @@ class BalanceCardStyleSettings { "walletInfoId": walletInfoId, "accountIndex": accountIndex, "gradientIndex": gradientIndex, + "hidden": hidden ? 1 : 0, "useSpecialDesign": useSpecialDesign ? 1 : 0, "backgroundImagePath": backgroundImagePath, "iconStyleIndex": iconStyleIndex, @@ -42,6 +45,7 @@ class BalanceCardStyleSettings { return BalanceCardStyleSettings( walletInfoId: json["walletInfoId"] as int, accountIndex: json["accountIndex"] as int, + hidden: json["hidden"] == 1, gradientIndex: json["gradientIndex"] as int, useSpecialDesign: json["useSpecialDesign"] == 1, backgroundImagePath: json["backgroundImagePath"] as String? ?? "", @@ -51,6 +55,30 @@ class BalanceCardStyleSettings { ); } + BalanceCardStyleSettings copyWith({ + int? walletInfoId, + int? accountIndex, + int? gradientIndex, + int? iconStyleIndex, + bool? isGradientOnly, + bool? useSpecialDesign, + bool? hidden, + String? backgroundImagePath, + int? cardOrder, + }) { + return BalanceCardStyleSettings( + walletInfoId: walletInfoId ?? this.walletInfoId, + accountIndex: accountIndex ?? this.accountIndex, + gradientIndex: gradientIndex ?? this.gradientIndex, + useSpecialDesign: useSpecialDesign ?? this.useSpecialDesign, + hidden: hidden ?? this.hidden, + backgroundImagePath: backgroundImagePath ?? this.backgroundImagePath, + cardOrder: cardOrder ?? this.cardOrder, + iconStyleIndex: iconStyleIndex ?? this.iconStyleIndex, + isGradientOnly: isGradientOnly ?? this.isGradientOnly + ); + } + static BalanceCardStyleSettings fromCardDesign({ required int walletInfoId, required int accountIndex, @@ -58,6 +86,7 @@ class BalanceCardStyleSettings { required CardDesign design, int iconStyleIndex = 0, int? gradientIndexOverride, + bool hidden = false }) { final int gradientIndex = gradientIndexOverride ?? CardDesign.allGradients.indexOf(design.gradient); @@ -66,6 +95,7 @@ class BalanceCardStyleSettings { accountIndex: accountIndex, gradientIndex: gradientIndex, useSpecialDesign: design.backgroundType == CardDesignBackgroundTypes.svgFull, + hidden: hidden, backgroundImagePath: design.backgroundType == CardDesignBackgroundTypes.image ? design.imagePath : "", iconStyleIndex: iconStyleIndex, @@ -100,4 +130,56 @@ class BalanceCardStyleSettings { Future insert() async { await db!.insert(tableName, toJson(), conflictAlgorithm: ConflictAlgorithm.replace); } + + static Future setVisibleOrder(int walletInfoId, Map orderByAccountIndex) => + _setOrder(walletInfoId, orderByAccountIndex, unhide: true); + + static Future setOrder(int walletInfoId, Map orderByAccountIndex) => + _setOrder(walletInfoId, orderByAccountIndex, unhide: false); + + static Future _setOrder( + int walletInfoId, + Map orderByAccountIndex, { + required bool unhide, + }) async { + if (orderByAccountIndex.isEmpty) { + return; + } + + final entries = orderByAccountIndex.entries.toList(); + final batch = db!.batch(); + + for (final entry in entries) { + batch.update( + tableName, + {"cardOrder": entry.value, if (unhide) "hidden": 0}, + where: "walletInfoId = ? AND accountIndex = ?", + whereArgs: [walletInfoId, entry.key], + ); + } + + final updatedRowCounts = await batch.commit(); + + for (int i = 0; i < entries.length; i++) { + if (updatedRowCounts[i] == 0) { + await db!.insert( + tableName, + positionOnly(walletInfoId, entries[i].key, entries[i].value).toJson(), + conflictAlgorithm: ConflictAlgorithm.ignore, + ); + } + } + } + + static BalanceCardStyleSettings positionOnly( + int walletInfoId, int accountIndex, int cardOrder) => + BalanceCardStyleSettings( + walletInfoId: walletInfoId, + accountIndex: accountIndex, + gradientIndex: -1, + useSpecialDesign: true, + hidden: false, + backgroundImagePath: "", + cardOrder: cardOrder, + ); } diff --git a/cw_core/lib/db/sqlite.dart b/cw_core/lib/db/sqlite.dart index 77c38b599..30b1be318 100644 --- a/cw_core/lib/db/sqlite.dart +++ b/cw_core/lib/db/sqlite.dart @@ -66,7 +66,7 @@ Future _initDb({String? pathOverride}) async { await db?.close(); db = await openDatabase( dbFile.path, - version: 14, + version: 15, onUpgrade: (db, oldVersion, newVersion) async { printV("migrating: $oldVersion, $newVersion"); if (oldVersion <= 1) { @@ -193,6 +193,14 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings ( await _migrateBitcoinCardStylesForAccounts(db); } + if(oldVersion <= 14) { + await _addColumnIfNotExists( + db, + table: "BalanceCardStyleSettings", + column: "hidden", + definition: "BOOLEAN DEFAULT FALSE", + ); + } }, onCreate: (Database db, int version) async { await db.execute(''' @@ -286,6 +294,7 @@ CREATE TABLE BalanceCardStyleSettings ( iconStyleIndex INTEGER DEFAULT 0, isGradientOnly BOOLEAN DEFAULT FALSE, cardOrder INTEGER DEFAULT 0, + hidden BOOLEAN DEFAULT FALSE, PRIMARY KEY (walletInfoId, accountIndex), FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId) ); @@ -311,7 +320,7 @@ CREATE TABLE PriceData ( timestamp INTEGER NOT NULL, price TEXT NOT NULL, PRIMARY KEY (fromCurrency, toCurrency, timestamp) -); +); """); await db.execute(""" CREATE TABLE ChartsAssets ( diff --git a/cw_core/lib/wallet_base.dart b/cw_core/lib/wallet_base.dart index 5e0fa7787..03c438a45 100644 --- a/cw_core/lib/wallet_base.dart +++ b/cw_core/lib/wallet_base.dart @@ -142,6 +142,7 @@ abstract class WalletBase false; bool get hasSilentPaymentsScanning => false; + bool get hasCoinControl => false; bool get hasAccountsSupport => false; bool get hasNativeAccounts => false; bool get canToggleMultiAccounts => hasAccountsSupport && !hasNativeAccounts; diff --git a/cw_decred/lib/wallet.dart b/cw_decred/lib/wallet.dart index 9bb76643e..b5398b7a2 100644 --- a/cw_decred/lib/wallet.dart +++ b/cw_decred/lib/wallet.dart @@ -543,6 +543,9 @@ abstract class DecredWalletBase @override bool get hasRescan => walletBirthdayBlockHeight() != -1; + @override + bool get hasCoinControl => true; + @override Future rescan({required int height}) async { // The required height is not used. A birthday time is recorded in the diff --git a/cw_monero/lib/monero_wallet.dart b/cw_monero/lib/monero_wallet.dart index 4da29bdfc..a76e7d628 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -120,6 +120,9 @@ abstract class MoneroWalletBase @override bool get hasRescan => true; + @override + bool get hasCoinControl => true; + @override bool get hasAccountsSupport => true; diff --git a/cw_wownero/lib/wownero_wallet.dart b/cw_wownero/lib/wownero_wallet.dart index b619c16bc..5eb89b5ee 100644 --- a/cw_wownero/lib/wownero_wallet.dart +++ b/cw_wownero/lib/wownero_wallet.dart @@ -137,6 +137,9 @@ abstract class WowneroWalletBase @override bool get hasRescan => true; + @override + bool get hasCoinControl => true; + @override bool get hasAccountsSupport => true; diff --git a/lib/di.dart b/lib/di.dart index e8933b0ea..8fbe5bf43 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -295,7 +295,6 @@ import 'dogecoin/dogecoin.dart'; import 'new-ui/viewmodels/card_customizer/card_customizer_bloc.dart'; import 'new-ui/widgets/addresses_page/address_info.dart'; import 'src/screens/buy/buy_sell_page.dart'; -import "src/screens/settings/widgets/account_creation_modal.dart"; final getIt = GetIt.instance; @@ -561,14 +560,15 @@ Future setup({ sharedPreferences: getIt.get(), keyService: getIt.get())); - getIt.registerFactoryParam( - (lightningMode, displayMode) { + getIt.registerFactoryParam( + (params, _) { final wallet = getIt.get().wallet!; return CardCustomizerBloc(wallet, - lightningMode: lightningMode, + canHide: params.canHide, + lightningMode: params.lightningMode, displaySats: wallet.type == WalletType.bitcoin && - (displayMode == BitcoinAmountDisplayMode.satoshi || - (displayMode == BitcoinAmountDisplayMode.satoshiForLightning && lightningMode))); + (params.amountDisplayMode == BitcoinAmountDisplayMode.satoshi || + (params.amountDisplayMode == BitcoinAmountDisplayMode.satoshiForLightning && params.lightningMode))); }); getIt.registerLazySingleton(() => PriceStore(settingsStore: getIt.get())); @@ -896,7 +896,7 @@ Future setup({ if (wallet.type == WalletType.monero || wallet.type == WalletType.wownero || wallet.type == WalletType.haven) { - return MoneroAccountListViewModel(wallet, getIt.get()); + return MoneroAccountListViewModel(wallet); } throw Exception( 'Unexpected wallet type: ${wallet.type} for generate Monero AccountListViewModel'); @@ -905,7 +905,7 @@ Future setup({ getIt.registerFactory(() { final wallet = getIt.get().wallet!; if (wallet.type == WalletType.bitcoin) { - return BitcoinAccountListViewModel(wallet, getIt.get()); + return BitcoinAccountListViewModel(wallet); } throw Exception( 'Unexpected wallet type: ${wallet.type} for generate Bitcoin AccountListViewModel'); @@ -927,11 +927,29 @@ Future setup({ }); getIt.registerFactoryParam( - (AccountListItem? account, _) => MoneroAccountEditOrCreateViewModel( - monero!.getAccountList(getIt.get().wallet!), - wownero?.getAccountList(getIt.get().wallet!), - wallet: getIt.get().wallet!, - accountListItem: account)); + (AccountListItem? account, _) { + final wallet = getIt.get().wallet!; + + if (wallet.type == WalletType.monero) { + return MoneroAccountEditOrCreateViewModel( + monero!.getAccountList(wallet), + null, + wallet: wallet, + accountListItem: account, + ); + } + + if (wallet.type == WalletType.wownero) { + return MoneroAccountEditOrCreateViewModel( + null, + wownero!.getAccountList(wallet), + wallet: wallet, + accountListItem: account, + ); + } + + throw StateError("Account creation is unavailable for ${wallet.type}"); + }); getIt.registerFactory( () => BitcoinAccountEditOrCreateViewModel( diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index 088cb45a0..90b9d4df9 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -3,6 +3,8 @@ import "package:cw_core/wallet_type.dart"; class PreferencesKey { static const currentWalletType = 'current_wallet_type'; static const currentWalletName = 'current_wallet_name'; + static const accountsHomePromoDismissed = 'accounts_home_promo_dismissed'; + static String educationDismissed(String educationId) => "education_dismissed_$educationId"; static const currentNodeIdKey = 'current_node_id'; static const currentBitcoinElectrumSererIdKey = 'current_node_id_btc'; static const currentLitecoinElectrumSererIdKey = 'current_node_id_ltc'; diff --git a/lib/monero/cw_monero.dart b/lib/monero/cw_monero.dart index 05450ca77..63134c43d 100644 --- a/lib/monero/cw_monero.dart +++ b/lib/monero/cw_monero.dart @@ -146,6 +146,10 @@ class CWMonero extends Monero { @override MoneroAccountList getAccountList(Object wallet) => CWMoneroAccountList(wallet); + @override + Money getAccountFullBalance(int accountIndex) => + monero_wallet_api.getFullBalance(accountIndex: accountIndex); + @override MoneroSubaddressList getSubaddressList(Object wallet) => CWMoneroSubaddressList(wallet); diff --git a/lib/new-ui/pages/account_education_page.dart b/lib/new-ui/pages/account_education_page.dart new file mode 100644 index 000000000..dc6f91693 --- /dev/null +++ b/lib/new-ui/pages/account_education_page.dart @@ -0,0 +1,128 @@ +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/pages/education_page.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:flutter/material.dart"; + +class AccountEducationPage extends EducationPage { + const AccountEducationPage({required super.settingsStore, super.key}); + + @override + String get educationId => "accounts"; + + @override + String get title => S.current.accounts; + + @override + String get iconPath => "assets/new-ui/settings_row_icons/accounts.svg"; + + @override + String get completionLabel => S.current.accounts_education_understand_continue; + + @override + String progressLabel(int current, int total) => + S.current.accounts_education_progress("$current", "$total"); + + @override + List get slides => [ + EducationSlide( + distributeChildren: true, + children: [ + EducationText(text: S.current.accounts_education_organize_title), + const CakeImageWidget( + imageUrl: "assets/new-ui/account_education/accounts_overview.svg", + key: ValueKey("accounts-education-overview-image"), + width: 133, + height: 324, + fit: BoxFit.contain, + ), + EducationText( + text: S.current.accounts_education_organize_description, + secondary: true, + ), + ], + ), + EducationSlide( + children: [ + const CakeImageWidget( + imageUrl: "assets/new-ui/account_education/recovery_shield.svg", + key: ValueKey("accounts-education-recovery-image"), + width: 213, + height: 213, + fit: BoxFit.contain, + ), + const SizedBox(height: 25.5), + EducationText( + text: S.current.accounts_education_recovery_title, + highlightedText: S.current.accounts_education_recovery_highlight, + ), + const SizedBox(height: 25.5), + EducationText( + text: S.current.accounts_education_recovery_description, + secondary: true, + ), + ], + ), + EducationSlide( + children: [ + const CakeImageWidget( + imageUrl: "assets/new-ui/account_education/account_order.svg", + key: ValueKey("accounts-education-order-image"), + width: 309, + height: 48, + fit: BoxFit.contain, + ), + const SizedBox(height: 51), + EducationText(text: S.current.accounts_education_order_title), + const SizedBox(height: 51), + const CakeImageWidget( + imageUrl: "assets/new-ui/account_education/ordered_account.svg", + key: ValueKey("accounts-education-ordered-account-image"), + width: 160, + height: 99, + fit: BoxFit.contain, + ), + const SizedBox(height: 51), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + EducationText( + text: S.current.accounts_education_order_description, + highlightedText: S.current.accounts_education_order_highlight, + ), + const SizedBox(height: 25.5), + EducationText( + text: S.current.accounts_education_order_restore_description, + secondary: true, + ), + ], + ), + ], + ), + EducationSlide( + children: [ + const CakeImageWidget( + imageUrl: "assets/new-ui/account_education/archive_warning.svg", + key: ValueKey("accounts-education-archive-image"), + width: 232, + height: 101, + fit: BoxFit.contain, + ), + const SizedBox(height: 51), + Column( + mainAxisSize: MainAxisSize.min, + children: [ + EducationText( + text: S.current.accounts_education_archive_title, + warning: true, + ), + const SizedBox(height: 13), + EducationText( + text: S.current.accounts_education_archive_description, + secondary: true, + ), + ], + ), + ], + ), + ]; +} diff --git a/lib/new-ui/pages/addresses_page.dart b/lib/new-ui/pages/addresses_page.dart index a068c5886..7d3833f6a 100644 --- a/lib/new-ui/pages/addresses_page.dart +++ b/lib/new-ui/pages/addresses_page.dart @@ -2,7 +2,6 @@ import 'dart:ui'; import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/new-ui/widgets/long_press_menu/long_press_popup.dart'; import 'package:cake_wallet/new-ui/widgets/addresses_page/address_info.dart'; import 'package:cake_wallet/new-ui/widgets/addresses_page/address_label_input.dart'; @@ -16,7 +15,6 @@ import 'package:cake_wallet/utils/show_pop_up.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart'; -import 'package:cake_wallet/wownero/wownero.dart'; import 'package:cw_core/card_design.dart'; import 'package:cw_core/wallet_type.dart'; import 'package:flutter/material.dart'; @@ -69,6 +67,7 @@ class _NewAddressesPageState extends State { late final TextEditingController _searchController; CardDesign? design; + ReactionDisposer? _cardDesignsDisposer; void updateItems() { setState(() { @@ -86,20 +85,32 @@ class _NewAddressesPageState extends State { setState(() {}); }); + loadCardDesign(); WidgetsBinding.instance.addPostFrameCallback((_) { - reaction((_) { - final index = widget.dashboardViewModel.accountListViewModel?.selectedAccount?.id ?? 0; - final designs = widget.dashboardViewModel.cardDesigns; - return (index >= 0 && index < designs.length) ? designs[index] : null; - }, (value) { + if (!mounted) return; + _cardDesignsDisposer = reaction((_) => widget.dashboardViewModel.currentCardDesign, (value) { if (!mounted) return; setState(() { - design = value; + design = widget.dashboardViewModel.currentCardDesign; }); }); }); } + Future loadCardDesign() async { + await widget.dashboardViewModel.loadCardDesigns(); + if (!mounted) return; + setState(() { + design = widget.dashboardViewModel.currentCardDesign; + }); + } + + @override + void dispose() { + _cardDesignsDisposer?.call(); + super.dispose(); + } + @override Widget build(BuildContext context) { final filteredItems = items.where((item) { diff --git a/lib/new-ui/pages/card_customizer.dart b/lib/new-ui/pages/card_customizer.dart index 7911f7ab1..0f39699d7 100644 --- a/lib/new-ui/pages/card_customizer.dart +++ b/lib/new-ui/pages/card_customizer.dart @@ -1,75 +1,96 @@ -import "dart:async"; import "dart:math"; import "package:cake_wallet/di.dart"; -import "package:cake_wallet/generated/i18n.dart"; import "package:cake_wallet/new-ui/modal_navigator.dart"; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; +import "package:cake_wallet/generated/i18n.dart"; import "package:cake_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart"; +import "package:cake_wallet/new-ui/widgets/account_confirmation_content.dart"; import "package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart"; import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/src/widgets/alert_with_two_actions.dart"; +import "package:cake_wallet/src/widgets/base_alert_dialog.dart"; import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cake_wallet/src/widgets/new_list_row/list_Item_style_wrapper.dart"; +import "package:cake_wallet/utils/show_pop_up.dart"; import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart"; import "package:cw_core/card_design.dart"; +import "package:flutter/cupertino.dart"; import "package:flutter/material.dart"; import "package:flutter_bloc/flutter_bloc.dart"; -import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; class CardCustomizer extends StatefulWidget { - const CardCustomizer({required this.cryptoTitle, required this.cryptoName, super.key}); + const CardCustomizer({ + required this.cryptoTitle, + required this.cryptoName, + this.account, + this.fiatBalance, + super.key, + }); final String cryptoTitle; final String cryptoName; + final AccountListItem? account; + final String? fiatBalance; - static Future show({ + static Future show({ required BuildContext context, required DashboardViewModel dashboardViewModel, - required bool lightningMode, - bool asModalSheet = true, - VoidCallback? onSaved, + required AccountListItem? account, + bool lightningMode = false, + bool canHide = false, + bool asPage = false, }) async { final bloc = getIt.get( - param1: lightningMode, - param2: dashboardViewModel.settingsStore.displayAmountsInSatoshi, + param1: CardCustomizerBlocParams( + lightningMode: lightningMode, + amountDisplayMode: dashboardViewModel.settingsStore.displayAmountsInSatoshi, + canHide: canHide, + ), ); - - final customizer = BlocProvider( - create: (_) => bloc, + if (bloc.state is CardCustomizerNotLoaded) { + await bloc.stream.firstWhere((state) => state is! CardCustomizerNotLoaded); + } + if (!context.mounted) { + await bloc.close(); + return false; + } + final balanceViewModel = dashboardViewModel.balanceViewModel; + final customizer = BlocProvider.value( + value: bloc, child: Material( child: CardCustomizer( cryptoTitle: dashboardViewModel.wallet.currency.fullName ?? dashboardViewModel.wallet.currency.name, cryptoName: dashboardViewModel.wallet.currency.name, + account: account, + fiatBalance: account == null ? null : balanceViewModel.accountFiatBalance(account), ), ), ); - if (asModalSheet) { - await CupertinoScaffold.showCupertinoModalBottomSheet( - barrierColor: Colors.black.withAlpha(60), - context: context, - builder: (context) => ModalNavigator( - parentContext: context, - heightMode: ModalHeightModes.fullScreen, - rootPage: customizer, - ), - ); - } else { - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => customizer), - ); - } + final archived = await (asPage + ? Navigator.of(context).push(CupertinoPageRoute(builder: (_) => customizer)) + : CupertinoScaffold.showCupertinoModalBottomSheet( + barrierColor: Colors.black.withAlpha(60), + context: context, + builder: (context) => ModalNavigator( + parentContext: context, + heightMode: ModalHeightModes.fullScreen, + rootPage: customizer, + ), + )); + // Save edits before AccountHidden writes the hidden state. bloc.add(DesignSaved()); - - await bloc.stream.firstWhere( - (state) => state is CardCustomizerSaved, - orElse: () => bloc.state, - ); - - await dashboardViewModel.loadCardDesigns(); - await dashboardViewModel.accountListViewModel?.reload(); - - onSaved?.call(); + await bloc.stream.firstWhere((state) => state is CardCustomizerSaved); + if (archived == true) { + bloc.add(AccountHidden()); + await bloc.stream.firstWhere((state) => state is CardCustomizerSaved); + } + await bloc.close(); + return archived == true; } @override @@ -78,9 +99,10 @@ class CardCustomizer extends StatefulWidget { class _CardCustomizerState extends State { final accountNameController = TextEditingController(); - bool editEnabled = false; late final CardCustomizerBloc bloc; + bool get _isAccount => widget.account != null; + @override void initState() { super.initState(); @@ -88,18 +110,7 @@ class _CardCustomizerState extends State { bloc = context.read(); if (bloc.state is! CardCustomizerNotLoaded) { - editEnabled = bloc.state.accountName.isNotEmpty; accountNameController.text = bloc.state.accountName; - } else { - late final StreamSubscription sub; - sub = bloc.stream.listen((state) { - if (state is! CardCustomizerNotLoaded) { - setState(() { - editEnabled = state.accountName.isNotEmpty; - }); - sub.cancel(); - } - }); } } @@ -118,6 +129,46 @@ class _CardCustomizerState extends State { return design; } + Future _requestArchive() async { + final account = AccountListItem( + id: widget.account!.id, + label: bloc.state.accountName, + balance: widget.account!.balance, + ); + final isFunded = account.isFunded; + final confirmed = await showPopUp( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertWithTwoActions( + alertTitle: isFunded + ? S.of(dialogContext).archive_account_confirmation_title + : S.of(dialogContext).archive_account_title, + alertContent: "", + alertContentTextWidget: ArchiveConfirmationContent( + account: account, + fiatBalance: widget.fiatBalance, + isFunded: isFunded, + ), + leftButtonText: S.of(dialogContext).cancel, + rightButtonText: S.of(dialogContext).continue_text, + leftAlertButtonStyle: AlertButtonStyle.primary(dialogContext), + rightAlertButtonStyle: AlertButtonStyle.secondary(dialogContext), + actionLeftButton: () => Navigator.of(dialogContext).pop(false), + actionRightButton: () => Navigator.of(dialogContext).pop(true), + ), + ); + if (confirmed != true || !mounted) { + return; + } + Navigator.of(context).pop(true); + } + + @override + void dispose() { + accountNameController.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) => BlocListener( listenWhen: (previous, current) => previous.accountName != current.accountName, @@ -128,288 +179,352 @@ class _CardCustomizerState extends State { }, child: BlocBuilder( builder: (context, state) { - if (state is CardCustomizerNotLoaded) return SizedBox.shrink(); + if (state is CardCustomizerNotLoaded) { + return const SizedBox.shrink(); + } return PopScope( child: SingleChildScrollView( child: SafeArea( child: Column( - spacing: 25.0, + spacing: 25, mainAxisSize: MainAxisSize.min, children: [ ModalTopBar( - title: editEnabled ? S.of(context).edit_account : S.of(context).edit_card, - leadingIcon: Icon(Icons.close), - leadingSemanticLabel: S.of(context).close, - // trailingIcon: editEnabled ? Icon(Icons.delete_forever) : null, + title: _isAccount ? S.of(context).edit_account : S.of(context).edit_card, + subtitle: _isAccount ? "#${widget.account!.id + 1}" : null, + leadingIcon: + Icon(_isAccount ? Icons.arrow_back_ios_new : Icons.close, size: 18), + leadingSemanticLabel: + _isAccount ? S.of(context).seed_alert_back : S.of(context).close, onLeadingPressed: () => Navigator.of(context).maybePop(), - // onTrailingPressed: () {}, ), - if (editEnabled) + BalanceCard( + width: min(MediaQuery.of(context).size.width * 0.87, 768), + selected: true, + designSwitchDuration: const Duration(milliseconds: 300), + accountIndex: _isAccount ? widget.account!.id + 1 : null, + accountName: !_isAccount + ? "" + : state.accountName.trim().isEmpty + ? S.of(context).unnamed_account + : state.accountName, + accountBalance: widget.account?.balance, + balance: _isAccount ? "" : "0.00", + fiatBalance: widget.fiatBalance ?? "", + assetName: state.displaySats ? "sats" : widget.cryptoName, + capitalizeAssetName: !state.displaySats, + design: state.selectedDesign, + ), + if (_isAccount) Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), + padding: const EdgeInsets.symmetric(horizontal: 18), child: Column( - spacing: 8.0, + spacing: 8, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(S.of(context).account_name), TextField( maxLength: 32, - decoration: InputDecoration(counterText: ""), + decoration: const InputDecoration(counterText: ""), onChanged: (value) { context.read().add(AccountNameChanged(value)); }, controller: accountNameController, - ) + ), ], ), ), - BalanceCard( - width: min(MediaQuery.of(context).size.width * 0.87, 768), - selected: true, - designSwitchDuration: Duration(milliseconds: 300), - accountName: editEnabled ? state.accountName : "", - balance: "0.00", - assetName: state.displaySats ? "sats" : widget.cryptoName, - capitalizeAssetName: !state.displaySats, - design: state.selectedDesign, - ), Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(16), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - spacing: 8.0, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).card_style), - SizedBox( - height: 63, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: state.availableDesigns.length, - separatorBuilder: (context, index) => - const SizedBox(width: 8), - itemBuilder: (context, index) => GestureDetector( - onTap: () { - context - .read() - .add(CardDesignSelected(index)); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), - decoration: ShapeDecoration( - shape: RoundedSuperellipseBorder( - side: BorderSide( - color: ((index == - state.selectedDesignIndex) + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(12), + child: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(S.of(context).card_style), + SizedBox( + height: 63, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: state.availableDesigns.length, + separatorBuilder: (context, index) => + const SizedBox(width: 8), + itemBuilder: (context, index) => GestureDetector( + onTap: () { + context + .read() + .add(CardDesignSelected(index)); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: ShapeDecoration( + shape: RoundedSuperellipseBorder( + side: BorderSide( + color: ((index == state.selectedDesignIndex) + ? Theme.of(context).colorScheme.onSurface + : Colors.transparent), + width: 1, + ), + borderRadius: BorderRadiusGeometry.circular(12), + ), + ), + child: AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: index == state.selectedDesignIndex ? 0.94 : 1, + child: BalanceCard( + width: 96, + borderRadius: 10, + selected: false, + designSwitchDuration: + const Duration(milliseconds: 300), + design: _cardStylePreviewDesign(state, index), + ), + ), + ), + ), + ), + ), + ], + ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SizeTransition( + sizeFactor: animation, + axisAlignment: -1, + child: child, + ), + ), + child: _showIconStylePanel(state) + ? Column( + key: const ValueKey("icon_style_panel"), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(S.of(context).icon_style), + SizedBox( + height: 48, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: state.availableIconPaths.length, + separatorBuilder: (context, _) => + const SizedBox(width: 8), + itemBuilder: (context, index) { + final icon = state.availableIconPaths[index]; + final isSelected = + index == state.selectedIconIndex; + return GestureDetector( + onTap: () { + context + .read() + .add(IconStyleSelected(index)); + }, + child: AnimatedContainer( + duration: + const Duration(milliseconds: 200), + width: 48, + height: 48, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(18), + color: Theme.of(context).brightness == + Brightness.light + ? Theme.of(context) + .colorScheme + .onSurfaceVariant + .withAlpha(64) + : Theme.of(context) + .colorScheme + .surfaceContainerHigh, + border: Border.all( + color: isSelected ? Theme.of(context) .colorScheme .onSurface - : Colors.transparent), - width: 1), - borderRadius: - BorderRadiusGeometry.circular(12), - ), - ), - child: AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: index == state.selectedDesignIndex - ? 0.94 - : 1, - child: BalanceCard( - width: 96, - borderRadius: 10, - selected: false, - designSwitchDuration: - const Duration(milliseconds: 300), - design: - _cardStylePreviewDesign(state, index), - ), - ), - ), - )), - ), - ], - )), - AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - transitionBuilder: (child, Animation animation) => - FadeTransition( - opacity: animation, - child: SizeTransition( - sizeFactor: animation, - axisAlignment: -1, - child: child, - ), - ), - child: _showIconStylePanel(state) - ? Column( - key: const ValueKey("icon_style_panel"), - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Column( - spacing: 8.0, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).icon_style), - SizedBox( - height: 48, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: state.availableIconPaths.length, - separatorBuilder: (context, _) => - const SizedBox(width: 8.0), - itemBuilder: (context, index) { - final icon = - state.availableIconPaths[index]; - final isSelected = - index == state.selectedIconIndex; - return GestureDetector( - onTap: () { - context - .read() - .add(IconStyleSelected(index)); - }, - child: AnimatedContainer( - duration: - const Duration(milliseconds: 200), - width: 48, - height: 48, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(18), - color: Theme.of(context).brightness == - Brightness.light - ? Theme.of(context) - .colorScheme - .onSurfaceVariant - .withAlpha(64) - : Theme.of(context) - .colorScheme - .surfaceContainerHigh, - border: Border.all( - color: isSelected - ? Theme.of(context) - .colorScheme - .onSurface - : Colors.transparent, - width: 2, - ), - ), - child: Padding( - padding: const EdgeInsets.all(10.0), - child: CakeImageWidget( - imageUrl: icon.path), + : Colors.transparent, + width: 2, ), ), - ); - }, - ), + child: Padding( + padding: const EdgeInsets.all(10), + child: CakeImageWidget( + imageUrl: icon.path, + ), + ), + ), + ); + }, ), - ], + ), + ], + ), + ), + ], + ) + : const SizedBox.shrink(key: ValueKey("icon_style_hidden")), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + spacing: 8, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(S.of(context).color), + SizedBox( + width: double.infinity, + child: Wrap( + direction: Axis.horizontal, + spacing: 4, + runSpacing: 8, + children: List.generate( + state.availableColors.length, + (index) => Material( + borderRadius: BorderRadius.circular(999999999), + child: InkWell( + borderRadius: BorderRadius.circular(999999999), + onTap: () { + context + .read() + .add(ColorSelected(index)); + }, + child: Stack( + children: [ + AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: + index == state.selectedColorIndex ? 1 : 0, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(99999999), + border: Border.all( + color: Theme.of(context) + .colorScheme + .onSurface, + ), + ), + ), + ), + AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: index == state.selectedColorIndex + ? 0.8 + : 1, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(99999999), + gradient: state.availableColors[index], + ), + ), + ), + ], + ), ), ), - ], - ) - : const SizedBox.shrink(key: ValueKey("icon_style_hidden")), + ), + ), + ), + ], + ), ), - const SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(12.0), + ), + ], + ), + ), + ), + if (bloc.canHide) + Padding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 24), + child: ListItemStyleWrapper( + isFirstInSection: true, + isLastInSection: true, + onTap: _requestArchive, + builder: (context, textStyle, labelStyle) => ConstrainedBox( + constraints: const BoxConstraints(minHeight: 40), + child: Row( + children: [ + Padding( + padding: const EdgeInsets.all(2.5), + child: CakeImageWidget( + imageUrl: "assets/new-ui/archived.svg", + width: 19, + height: 19, + color: Theme.of(context).colorScheme.primary, + ), + ), + const SizedBox(width: 12), + Expanded( child: Column( - spacing: 8.0, crossAxisAlignment: CrossAxisAlignment.start, + spacing: 4, children: [ - Text(S.of(context).color), - Container( - width: double.infinity, - child: Wrap( - direction: Axis.horizontal, - spacing: 4, // space between items in a row - runSpacing: 8, - children: List.generate( - state.availableColors.length, - (index) => Material( - borderRadius: - BorderRadius.circular(999999999), - child: InkWell( - borderRadius: - BorderRadius.circular(999999999), - onTap: () { - context - .read() - .add(ColorSelected(index)); - }, - child: Stack( - children: [ - AnimatedOpacity( - duration: const Duration( - milliseconds: 200), - opacity: index == - state.selectedColorIndex - ? 1 - : 0, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular( - 99999999), - border: Border.all( - color: Theme.of(context) - .colorScheme - .onSurface))), - ), - AnimatedScale( - duration: const Duration( - milliseconds: 200), - scale: index == - state.selectedColorIndex - ? 0.8 - : 1, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular( - 99999999, - ), - gradient: state - .availableColors[index]), - ), - ), - ], - ), - ), - )), - )), + Text( + S.of(context).archive_account, + style: textStyle.copyWith( + height: 18 / 14, + letterSpacing: -0.07, + ), + ), + Text( + S.of(context).archive_account_reversible, + style: labelStyle.copyWith( + fontSize: 12, + fontWeight: FontWeight.w400, + height: 15 / 12, + letterSpacing: -0.06, + ), + ), ], ), ), - ) - ], + const SizedBox(width: 8), + SizedBox.square( + dimension: 16, + child: Align( + alignment: Alignment.centerRight, + child: CakeImageWidget( + imageUrl: "assets/new-ui/arrow_right.svg", + width: 7, + height: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), ), - )), + ), + ), ], ), ), diff --git a/lib/new-ui/pages/education_page.dart b/lib/new-ui/pages/education_page.dart new file mode 100644 index 000000000..b58f29c04 --- /dev/null +++ b/lib/new-ui/pages/education_page.dart @@ -0,0 +1,246 @@ +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/widgets/modern_button.dart"; +import "package:cake_wallet/new-ui/widgets/new_primary_button.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:cake_wallet/themes/core/theme_extension.dart"; +import "package:flutter/material.dart"; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; + +abstract class EducationPage extends StatefulWidget { + const EducationPage({required this.settingsStore, super.key}); + + final SettingsStore settingsStore; + + String get educationId; + String get title; + String get iconPath; + String get completionLabel; + List get slides; + String progressLabel(int current, int total); + + bool get isDismissed => settingsStore.isEducationDismissed(educationId); + + Future show(BuildContext context) async { + await showCupertinoModalBottomSheet( + useRootNavigator: true, + context: context, + backgroundColor: Colors.transparent, + builder: (_) => this, + ); + await settingsStore.dismissEducation(educationId); + } + + @override + State createState() => _EducationPageState(); +} + +class _EducationPageState extends State { + final PageController _pageController = PageController(); + int _currentPage = 0; + bool _isCompleting = false; + + @override + void dispose() { + _pageController.dispose(); + super.dispose(); + } + + void _complete() { + if (_isCompleting) { + return; + } + + _isCompleting = true; + Navigator.of(context).pop(); + } + + Future _continue(int pageCount) async { + if (_currentPage == pageCount - 1) { + _complete(); + return; + } + + await _pageController.nextPage( + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ); + } + + @override + Widget build(BuildContext context) { + final pages = widget.slides; + + return Material( + color: Colors.transparent, + child: Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), + child: SafeArea( + top: false, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(18), + child: Row( + children: [ + CakeImageWidget( + imageUrl: widget.iconPath, + width: 24, + height: 24, + ), + const SizedBox(width: 8), + Text(widget.title, style: Theme.of(context).textTheme.headlineMedium), + const Spacer(), + ModernButton( + size: 36, + icon: const Icon(Icons.close), + semanticLabel: S.of(context).close, + onPressed: _complete, + ), + ], + ), + ), + Expanded( + child: PageView( + controller: _pageController, + onPageChanged: (page) => setState(() => _currentPage = page), + children: pages, + ), + ), + Semantics( + label: widget.progressLabel(_currentPage + 1, pages.length), + child: ExcludeSemantics( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate( + pages.length, + (index) => AnimatedContainer( + key: ValueKey("education-dot-$index"), + duration: const Duration(milliseconds: 200), + width: 8, + height: 8, + margin: const EdgeInsets.symmetric(horizontal: 4), + decoration: BoxDecoration( + color: index == _currentPage + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainerHighest, + shape: BoxShape.circle, + ), + ), + ), + ), + ), + ), + const SizedBox(height: 24), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: NewPrimaryButton( + onPressed: () => _continue(pages.length), + text: _currentPage == pages.length - 1 + ? widget.completionLabel + : S.of(context).continue_text, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + ), + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ); + } +} + +class EducationSlide extends StatelessWidget { + const EducationSlide({ + required this.children, + super.key, + this.distributeChildren = false, + }); + + final List children; + final bool distributeChildren; + + @override + Widget build(BuildContext context) => LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 28, vertical: 8), + child: ConstrainedBox( + constraints: BoxConstraints(minHeight: constraints.maxHeight - 16), + child: distributeChildren + ? IntrinsicHeight( + child: Column( + children: [ + for (var index = 0; index < children.length; index++) ...[ + children[index], + if (index < children.length - 1) ...[ + const SizedBox(height: 24), + const Spacer(), + ], + ], + ], + ), + ) + : Column( + mainAxisAlignment: MainAxisAlignment.center, + children: children, + ), + ), + ), + ); +} + +class EducationText extends StatelessWidget { + const EducationText({ + required this.text, + super.key, + this.highlightedText, + this.secondary = false, + this.warning = false, + }); + + final String text; + final String? highlightedText; + final bool secondary; + final bool warning; + + @override + Widget build(BuildContext context) { + final style = Theme.of(context).textTheme.bodyLarge?.copyWith( + fontSize: 14, + fontWeight: FontWeight.w400, + color: warning + ? context.customColors.warningOutlineColor + : secondary + ? Theme.of(context).colorScheme.onSurfaceVariant + : Theme.of(context).colorScheme.onSurface, + ); + final highlight = highlightedText; + final highlightStart = highlight == null ? -1 : text.indexOf(highlight); + + if (highlight == null || highlightStart < 0) { + return Text(text, textAlign: TextAlign.center, style: style); + } + + final highlightEnd = highlightStart + highlight.length; + return Text.rich( + TextSpan( + style: style, + children: [ + TextSpan(text: text.substring(0, highlightStart)), + TextSpan( + text: text.substring(highlightStart, highlightEnd), + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + TextSpan(text: text.substring(highlightEnd)), + ], + ), + textAlign: TextAlign.center, + ); + } +} diff --git a/lib/new-ui/pages/hidden_accounts.dart b/lib/new-ui/pages/hidden_accounts.dart new file mode 100644 index 000000000..22ded8342 --- /dev/null +++ b/lib/new-ui/pages/hidden_accounts.dart @@ -0,0 +1,480 @@ +import "dart:async"; + +import "package:cake_wallet/core/utilities.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/widgets/account_confirmation_content.dart"; +import "package:cake_wallet/new-ui/widgets/modal_page_wrapper.dart"; +import "package:cake_wallet/new-ui/widgets/money/money_text.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/src/widgets/alert_with_two_actions.dart"; +import "package:cake_wallet/src/widgets/base_alert_dialog.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cake_wallet/utils/show_pop_up.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart"; +import "package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart"; +import "package:cw_core/balance_card_layout.dart"; +import "package:cw_core/balance_card_style_settings.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_mobx/flutter_mobx.dart"; + +class HiddenAccountsPage extends StatefulWidget { + const HiddenAccountsPage({ + required this.accountListViewModel, + required this.dashboardViewModel, + super.key, + }); + + final WalletAccountListViewModel accountListViewModel; + final DashboardViewModel dashboardViewModel; + + @override + State createState() => _HiddenAccountsPageState(); +} + +class _HiddenAccountsPageState extends State { + final List _items = []; + final Set _accountsBeingRestored = {}; + bool _isLoading = true; + + int get _walletInfoId => widget.dashboardViewModel.wallet.walletInfo.internalId; + + @override + void initState() { + super.initState(); + unawaited(_loadAccounts()); + } + + Future _layout() async => BalanceCardLayout.resolve( + accountIndices: widget.accountListViewModel.accounts.map((account) => account.id).toList(), + settings: await BalanceCardStyleSettings.getAll(_walletInfoId), + ); + + Future _loadAccounts() async { + final accounts = widget.accountListViewModel.accounts; + final layout = await _layout(); + final hiddenAccounts = []; + + for (final accountIndex in layout.hidden) { + final account = accounts.firstWhereOrNull((account) => account.id == accountIndex); + if (account != null) { + hiddenAccounts.add(account); + } + } + + if (!mounted) { + return; + } + setState(() { + _items + ..clear() + ..addAll(hiddenAccounts); + _isLoading = false; + }); + } + + Future _confirmUnarchival(AccountListItem account) async { + final isFunded = account.isFunded; + + final result = await showPopUp( + context: context, + barrierDismissible: false, + builder: (dialogContext) => AlertWithTwoActions( + alertTitle: S.of(dialogContext).unarchive_account_title, + alertContent: "", + alertContentTextWidget: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AccountSummary(account: account), + const SizedBox(height: 20), + Text( + isFunded + ? S.of(dialogContext).unarchive_account_funds_description + : S.of(dialogContext).unarchive_account_description, + textAlign: TextAlign.center, + style: Theme.of(dialogContext).textTheme.bodyMedium, + ), + if (isFunded) ...[ + const SizedBox(height: 20), + AccountFundsSummary( + account: account, + fiatBalance: widget.dashboardViewModel.balanceViewModel.accountFiatBalance(account), + borderColor: Theme.of(dialogContext).colorScheme.primary, + ), + ], + ], + ), + leftButtonText: S.of(dialogContext).cancel, + rightButtonText: S.of(dialogContext).continue_text, + leftAlertButtonStyle: AlertButtonStyle.secondary(dialogContext), + rightAlertButtonStyle: AlertButtonStyle.primary(dialogContext), + actionLeftButton: () => Navigator.of(dialogContext).pop(false), + actionRightButton: () => Navigator.of(dialogContext).pop(true), + ), + ); + + return result ?? false; + } + + Future _unarchive(AccountListItem account) async { + final accountForConfirmation = + widget.accountListViewModel.accounts.firstWhereOrNull((item) => item.id == account.id) ?? + account; + final confirmed = await _confirmUnarchival(accountForConfirmation); + if (!confirmed || !mounted) { + return; + } + + setState(() => _accountsBeingRestored.add(account.id)); + try { + final restored = (await _layout()).unhiding(account.id); + await BalanceCardStyleSettings.setVisibleOrder(_walletInfoId, restored.orders); + final latestAccount = + widget.accountListViewModel.accounts.firstWhereOrNull((item) => item.id == account.id) ?? + account; + await widget.accountListViewModel.select(latestAccount); + await widget.dashboardViewModel.loadCardDesigns(); + await _loadAccounts(); + } finally { + if (mounted) { + setState(() => _accountsBeingRestored.remove(account.id)); + } + } + } + + @override + Widget build(BuildContext context) => ModalPageWrapper( + topBar: ModalTopBar( + title: S.of(context).archived_accounts, + leadingIcon: const Icon(Icons.arrow_back_ios_new, size: 18), + leadingSemanticLabel: S.of(context).seed_alert_back, + onLeadingPressed: Navigator.of(context).pop, + ), + content: _isLoading + ? const SizedBox( + height: 320, + child: Center(child: CupertinoActivityIndicator()), + ) + : _items.isEmpty + ? const _EmptyArchiveView() + : _PopulatedArchiveView( + accounts: _items, + accountsBeingRestored: _accountsBeingRestored, + dashboardViewModel: widget.dashboardViewModel, + onUnarchive: _unarchive, + ), + ); +} + +class _EmptyArchiveView extends StatelessWidget { + const _EmptyArchiveView(); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SizedBox( + height: MediaQuery.sizeOf(context).height - 180, + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + _ArchiveIcon(size: 50, color: theme.colorScheme.onSurfaceVariant), + const SizedBox(height: 12), + Text( + S.of(context).no_archived_accounts, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + ), + const SizedBox(height: 12), + Text( + S.of(context).no_archived_accounts_description, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ), + ); + } +} + +class _PopulatedArchiveView extends StatelessWidget { + const _PopulatedArchiveView({ + required this.accounts, + required this.accountsBeingRestored, + required this.dashboardViewModel, + required this.onUnarchive, + }); + + final List accounts; + final Set accountsBeingRestored; + final DashboardViewModel dashboardViewModel; + final Future Function(AccountListItem account) onUnarchive; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final fundedAccounts = accounts.where((account) => account.isFunded).toList(growable: false); + final emptyAccounts = + accounts.where((account) => !account.isFunded).toList(growable: false); + + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _ArchiveIcon(size: 50, color: theme.colorScheme.primary), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.fromLTRB(12, 0, 12, 36), + child: Text( + S.of(context).select_account_to_unarchive, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + if (fundedAccounts.isNotEmpty) ...[ + _ArchiveSectionHeader( + title: S.of(context).funded_accounts, + description: S.of(context).funded_accounts_description, + ), + const SizedBox(height: 12), + _accountRows(context, fundedAccounts), + ], + if (fundedAccounts.isNotEmpty && emptyAccounts.isNotEmpty) const SizedBox(height: 28), + if (emptyAccounts.isNotEmpty) ...[ + _ArchiveSectionHeader(title: S.of(context).empty_accounts), + const SizedBox(height: 12), + _accountRows(context, emptyAccounts), + ], + ], + ); + } + + Widget _accountRows(BuildContext context, List sectionAccounts) => Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(18), + ), + clipBehavior: Clip.antiAlias, + child: Column( + children: [ + for (var index = 0; index < sectionAccounts.length; index++) ...[ + _ArchivedAccountRow( + account: sectionAccounts[index], + dashboardViewModel: dashboardViewModel, + isBusy: accountsBeingRestored.contains(sectionAccounts[index].id), + onTap: accountsBeingRestored.contains(sectionAccounts[index].id) + ? null + : () => onUnarchive(sectionAccounts[index]), + ), + if (index < sectionAccounts.length - 1) + Divider( + height: 1, + indent: 48, + color: Theme.of(context).colorScheme.outlineVariant, + ), + ], + ], + ), + ); +} + +class _ArchivedAccountRow extends StatelessWidget { + const _ArchivedAccountRow({ + required this.account, + required this.dashboardViewModel, + required this.isBusy, + required this.onTap, + }); + + final AccountListItem account; + final DashboardViewModel dashboardViewModel; + final bool isBusy; + final VoidCallback? onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final label = account.label.trim(); + + return Material( + color: theme.colorScheme.surfaceContainer, + child: Semantics( + button: true, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Row( + children: [ + CakeImageWidget( + imageUrl: "assets/new-ui/account.svg", + width: 24, + height: 24, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text.rich( + TextSpan( + children: [ + TextSpan( + text: "${account.id + 1}. ", + style: TextStyle(color: theme.colorScheme.primary), + ), + TextSpan( + text: label.isEmpty ? S.of(context).unnamed_account : label, + ), + ], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ), + ], + ), + ), + const SizedBox(width: 8), + _AccountBalanceTrailing( + account: account, + dashboardViewModel: dashboardViewModel, + isBusy: isBusy, + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ArchiveSectionHeader extends StatelessWidget { + const _ArchiveSectionHeader({required this.title, this.description}); + + final String title; + final String? description; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600), + ), + if (description != null) ...[ + const SizedBox(height: 4), + Text( + description!, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ); + } +} + +class _AccountBalanceTrailing extends StatelessWidget { + const _AccountBalanceTrailing({ + required this.account, + required this.dashboardViewModel, + required this.isBusy, + }); + + final AccountListItem account; + final DashboardViewModel dashboardViewModel; + final bool isBusy; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + if (isBusy) { + return const CupertinoActivityIndicator(radius: 10); + } + + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (account.isFunded) ...[ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 142), + child: Observer( + builder: (_) { + final fiatBalance = dashboardViewModel.balanceViewModel.accountFiatBalance(account); + return Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + MoneyText( + account.balance, + fractionalDigits: account.balance.decimals, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + if (fiatBalance != null) ...[ + const SizedBox(height: 2), + Text( + fiatBalance, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); + }, + ), + ), + const SizedBox(width: 8), + ], + Icon( + Icons.arrow_forward_ios_rounded, + size: 14, + color: theme.colorScheme.onSurfaceVariant, + ), + ], + ); + } +} + +class _ArchiveIcon extends StatelessWidget { + const _ArchiveIcon({required this.size, required this.color}); + + final double size; + final Color color; + + @override + Widget build(BuildContext context) => SizedBox.square( + dimension: size, + child: Center( + child: CakeImageWidget( + imageUrl: "assets/new-ui/archived.svg", + width: size * 19 / 24, + height: size * 19 / 24, + color: color, + ), + ), + ); +} diff --git a/lib/new-ui/pages/home_page.dart b/lib/new-ui/pages/home_page.dart index 1335011a2..f332d0fb7 100644 --- a/lib/new-ui/pages/home_page.dart +++ b/lib/new-ui/pages/home_page.dart @@ -7,6 +7,10 @@ import "package:cake_wallet/generated/i18n.dart"; import "package:cake_wallet/main.dart"; import "package:cake_wallet/new-ui/modal_navigator.dart"; import "package:cake_wallet/new-ui/pages/card_customizer.dart"; +import "package:cake_wallet/new-ui/pages/account_education_page.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/accounts_promo.dart"; +import "package:cake_wallet/src/widgets/alert_with_one_action.dart"; +import "package:cw_core/sync_status.dart"; import "package:cake_wallet/new-ui/pages/seed/seed_backup_reminder_page.dart"; import "package:cake_wallet/new-ui/pages/send_page.dart"; import "package:cake_wallet/new-ui/pages/settings_page.dart"; @@ -214,6 +218,14 @@ class _NewHomePageState extends State with RouteAware { ), ], ), + if (AccountsPromo.supportsWallet(widget.dashboardViewModel.wallet.type) && + widget.dashboardViewModel.accountListViewModel != null && + !_lightningMode) + AccountsPromo( + settingsStore: widget.dashboardViewModel.settingsStore, + walletName: walletTypeToString(widget.dashboardViewModel.wallet.type), + onTap: _openAccountsFromPromo, + ), Column( children: [ CoinActionRow( @@ -264,11 +276,18 @@ class _NewHomePageState extends State with RouteAware { ); Future openCardCustomizer() async { + if (!_checkReadyToManage()) return; await CardCustomizer.show( context: context, dashboardViewModel: widget.dashboardViewModel, + account: !widget.dashboardViewModel.lightningMode && + widget.dashboardViewModel.isMultiAccountsEnabled + ? widget.dashboardViewModel.accountListViewModel?.selectedAccount + : null, lightningMode: widget.dashboardViewModel.lightningMode, ); + await widget.dashboardViewModel.accountListViewModel?.reload(); + await widget.dashboardViewModel.loadCardDesigns(); } void openSeedBackupReminder() { @@ -283,7 +302,9 @@ class _NewHomePageState extends State with RouteAware { } Future openAccountCustomizer() async { - if (widget.dashboardViewModel.accountListViewModel == null) return; + if (widget.dashboardViewModel.accountListViewModel == null || !_checkReadyToManage()) return; + + final page = getIt.get(param1: widget.dashboardViewModel); await CupertinoScaffold.showCupertinoModalBottomSheet( barrierColor: Colors.black.withAlpha(60), @@ -292,10 +313,36 @@ class _NewHomePageState extends State with RouteAware { parentContext: context, heightMode: ModalHeightModes.fullScreen, rootPage: Material( - child: getIt.get(param1: widget.dashboardViewModel), + child: page, ), ), ); await widget.dashboardViewModel.loadCardDesigns(); } + + Future _openAccountsFromPromo() async { + if (!AccountsPromo.supportsWallet(widget.dashboardViewModel.wallet.type) || + widget.dashboardViewModel.accountListViewModel == null || !_checkReadyToManage()) return; + + await AccountEducationPage( + settingsStore: widget.dashboardViewModel.settingsStore, + ).show(context); + if (mounted) await openAccountCustomizer(); + } + + bool _checkReadyToManage() { + if (widget.dashboardViewModel.status is! SyncedSyncStatus) { + showDialog( + context: context, + builder: (context) => AlertWithOneAction( + alertTitle: S.of(context).wallet_is_syncing, + alertContent: S.of(context).cannot_manage_accounts_during_sync, + buttonText: S.of(context).ok, + buttonAction: Navigator.of(context).pop, + ), + ); + return false; + } + return true; + } } diff --git a/lib/new-ui/pages/settings_page.dart b/lib/new-ui/pages/settings_page.dart index 756f00df5..ce206416d 100644 --- a/lib/new-ui/pages/settings_page.dart +++ b/lib/new-ui/pages/settings_page.dart @@ -1,30 +1,33 @@ -import 'package:cake_wallet/core/auth_service.dart'; -import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item.dart'; -import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/new-ui/modal_navigator.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; -import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; -import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart'; -import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:cw_core/wallet_info.dart'; +import "package:cake_wallet/core/auth_service.dart"; +import "package:cake_wallet/entities/new_ui_entities/list_item/list_item.dart"; +import "package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/modal_navigator.dart"; +import "package:cake_wallet/new-ui/pages/coin_control_page.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cake_wallet/src/widgets/new_list_row/new_list_section.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cw_core/wallet_info.dart"; import "package:cw_core/wallet_type.dart"; -import 'package:flutter/material.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import "package:flutter/material.dart"; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; -bool _trueFunc(DashboardViewModel _) => true; - -bool _falseFunc(DashboardViewModel _) => false; - -bool _isBtc(DashboardViewModel vm) => vm.wallet.type == WalletType.bitcoin; +bool _alwaysVisible(DashboardViewModel _) => true; bool _hasLightning(DashboardViewModel vm) => vm.hasLightning; +bool _hasSilentPayments(DashboardViewModel vm) => vm.hasSilentPayments; + bool _hasMweb(DashboardViewModel vm) => vm.hasMweb; bool _hasWalletConnect(DashboardViewModel vm) => vm.hasWalletConnect; +bool _hasAccounts(DashboardViewModel vm) => vm.wallet.hasAccountsSupport; + +bool _hasCoinControl(DashboardViewModel vm) => vm.wallet.hasCoinControl; + bool _requiresKeyImageSync(DashboardViewModel vm) => vm.wallet.type == WalletType.monero && [HardwareWalletType.cupcake, HardwareWalletType.trezor].contains(vm.wallet.hardwareWalletType); @@ -34,16 +37,20 @@ class SettingsListItem { this.iconPath, this.title, this.route, { + this.isCore = false, this.requireAuth = false, - this.use2fa = _falseFunc, - this.condition = _trueFunc, + this.use2fa = _neverUse2fa, + this.condition = _alwaysVisible, this.routeArgs, this.routeArgsBuilder, }); + static bool _neverUse2fa(DashboardViewModel _) => false; + final String iconPath; final String title; final String route; + final bool isCore; final Object? routeArgs; final Object? Function(DashboardViewModel)? routeArgsBuilder; final bool requireAuth; @@ -52,72 +59,165 @@ class SettingsListItem { } class SettingsSectionData { - const SettingsSectionData(this.title, this.titleIconPath, this.items); + const SettingsSectionData( + this.title, + this.titleIconPath, + this.groups, { + this.titleColor, + this.headerSpacing = 16, + }); final String title; final String titleIconPath; - final List items; + final Map> groups; + final Color? titleColor; + final double headerSpacing; +} - static SettingsSectionData walletSettings = - SettingsSectionData(S.current.wallet_settings, "assets/new-ui/wallet-setting.svg", [ - SettingsListItem( - "assets/new-ui/settings_row_icons/accounts.svg", - S.current.accounts, - Routes.walletAccountsPage, - condition: (vm) => vm.wallet.hasAccountsSupport, - routeArgsBuilder: (vm) => vm, - ), - SettingsListItem( - "assets/new-ui/settings_row_icons/nodes.svg", S.current.nodes, Routes.manageNodes), - SettingsListItem( - "assets/new-ui/settings_row_icons/privacy.svg", S.current.privacy, Routes.privacyPage), - SettingsListItem("assets/new-ui/settings_row_icons/seed.svg", S.current.recovery_and_keys, - Routes.showKeysDisclaimer), - SettingsListItem("assets/new-ui/settings_row_icons/lightning_username.svg", - "Lightning ${S.current.username}", Routes.lightningUsernamePage, - condition: _hasLightning), - SettingsListItem("assets/new-ui/settings_row_icons/wc.svg", S.current.walletConnect, +class WalletSettingsResolver { + const WalletSettingsResolver(); + + String titleFor(WalletType walletType, S strings) => + '${walletTypeToString(walletType)} ${strings.settings_title}'.trim(); + + String iconPathFor(WalletType walletType) => + walletType == WalletType.robinhood + ? "assets/new-ui/crypto_full_icons/robinhood.svg" + : "assets/new-ui/network_icons/${walletType.name.toLowerCase()}.svg"; + + List> resolveSections(DashboardViewModel viewModel) { + final visibleItems = [ + SettingsListItem( + "assets/new-ui/settings_row_icons/accounts.svg", + viewModel.wallet.type == WalletType.bitcoin ? S.current.accounts_onchain : S.current.accounts, + Routes.walletAccountsPage, + isCore: true, + condition: _hasAccounts, + routeArgsBuilder: (vm) => vm, + ), + SettingsListItem( + "assets/new-ui/settings_row_icons/nodes.svg", + S.current.nodes, + Routes.manageNodes, + isCore: true, + ), + SettingsListItem( + "assets/new-ui/settings_row_icons/coin-control.svg", + S.current.coin_control_settings, + Routes.unspentCoinsList, + isCore: true, + condition: _hasCoinControl, + routeArgs: const CoinControlPageArgs(canEdit: false), + ), + SettingsListItem( + "assets/new-ui/settings_row_icons/lightning_username.svg", + "Lightning ${S.current.username}", + Routes.lightningUsernamePage, + condition: _hasLightning, + ), + SettingsListItem( + "assets/new-ui/settings_row_icons/silent-payments.svg", + S.current.silent_payments, + Routes.silentPaymentsSettings, + condition: _hasSilentPayments, + ), + SettingsListItem( + "assets/new-ui/settings_row_icons/mweb.svg", + S.current.litecoin_mweb, + Routes.mwebSettings, + condition: _hasMweb, + ), + SettingsListItem( + "assets/new-ui/settings_row_icons/wc.svg", + S.current.walletConnect, Routes.walletConnectConnectionsListing, - condition: _hasWalletConnect), - //SettingsListItem("assets/new-ui/settings_row_icons/silent-payments.svg", S.current.silent_payments_settings, Routes.silentPaymentsSettings, condition: _isBtc), - //SettingsListItem("assets/new-ui/settings_row_icons/mweb.svg", S.current.litecoin_mweb_settings, Routes.mwebSettings, condition: _hasMweb), - SettingsListItem( - "assets/new-ui/settings_row_icons/sync-balance.svg", - S.current.resync_device, - Routes.syncKeyImagesDevices, - routeArgs: {'export-outputs': 'export-outputs'}, - condition: _requiresKeyImageSync, - ), - SettingsListItem( - "assets/new-ui/settings_row_icons/other.svg", S.current.other, Routes.otherSettingsPage), - ]); + condition: _hasWalletConnect, + ), + SettingsListItem( + "assets/new-ui/settings_row_icons/sync-balance.svg", + S.current.resync_device, + Routes.syncKeyImagesDevices, + routeArgs: const {"export-outputs": "export-outputs"}, + condition: _requiresKeyImageSync, + ), + ].where((item) => item.condition(viewModel)).toList(); - static SettingsSectionData appSettings = - SettingsSectionData(S.current.app_settings, "assets/new-ui/cake-setting.svg", [ - SettingsListItem("assets/new-ui/settings_row_icons/connections.svg", S.current.connections, - Routes.connectionSync), - // SettingsListItem("assets/new-ui/settings_row_icons/defaults.svg", "Defaults", ""), - SettingsListItem("assets/new-ui/settings_row_icons/display.svg", S.current.display, - Routes.displaySettingsPage), - SettingsListItem("assets/new-ui/settings_row_icons/security.svg", S.current.security, - Routes.securityBackupPage), - SettingsListItem("assets/new-ui/settings_row_icons/backup.svg", S.current.backup, Routes.backup, - requireAuth: true, - use2fa: (vm) => vm.settingsStore.shouldRequireTOTP2FAForAllSecurityAndBackupSettings), - ]); + final coreItems = visibleItems.where((item) => item.isCore).toList(); + final featureItems = visibleItems.where((item) => !item.isCore).toList(); - static SettingsSectionData otherSettings = SettingsSectionData("", "", [ - SettingsListItem( - "assets/new-ui/settings_row_icons/support.svg", S.current.settings_support, Routes.support), - SettingsListItem( - "assets/new-ui/settings_row_icons/info.svg", S.current.about, Routes.aboutPage), - ]); + return [ + if (coreItems.isNotEmpty) coreItems, + if (featureItems.isNotEmpty) featureItems, + ]; + } +} - static List all = [walletSettings, appSettings, otherSettings]; +class SettingsPageSectionsResolver { + const SettingsPageSectionsResolver(); + + List walletGeneral(S strings) => [ + SettingsListItem( + 'assets/new-ui/settings_row_icons/privacy.svg', + strings.privacy, + Routes.privacyPage, + ), + SettingsListItem( + 'assets/new-ui/settings_row_icons/seed.svg', + strings.recovery_and_keys, + Routes.showKeysDisclaimer, + ), + SettingsListItem( + 'assets/new-ui/settings_row_icons/other.svg', + strings.other, + Routes.otherSettingsPage, + ), + ]; + + List appSettings() => [ + SettingsListItem( + "assets/new-ui/settings_row_icons/connections.svg", + S.current.connections, + Routes.connectionSync, + ), + SettingsListItem( + "assets/new-ui/settings_row_icons/display.svg", + S.current.display, + Routes.displaySettingsPage, + ), + SettingsListItem( + "assets/new-ui/settings_row_icons/security.svg", + S.current.security, + Routes.securityBackupPage, + ), + SettingsListItem( + "assets/new-ui/settings_row_icons/backup.svg", + S.current.backup, + Routes.backup, + requireAuth: true, + use2fa: (vm) => vm.settingsStore.shouldRequireTOTP2FAForAllSecurityAndBackupSettings, + ), + ]; + + List supportAndAbout(S strings) => [ + SettingsListItem( + 'assets/new-ui/settings_row_icons/support.svg', + strings.settings_support, + Routes.support, + ), + SettingsListItem( + 'assets/new-ui/settings_row_icons/info.svg', + strings.about, + Routes.aboutPage, + ), + ]; } class NewSettingsPage extends StatelessWidget { - const NewSettingsPage({super.key, required this.dashboardViewModel, required this.authService}); + const NewSettingsPage({ + super.key, + required this.dashboardViewModel, + required this.authService, + }); final DashboardViewModel dashboardViewModel; final AuthService authService; @@ -125,89 +225,248 @@ class NewSettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { return ModalNavigator( - parentContext: context, - rootPage: SettingsMainPage( - dashboardViewModel: dashboardViewModel, - authService: authService, - )); + parentContext: context, + rootPage: SettingsMainPage( + dashboardViewModel: dashboardViewModel, + authService: authService, + ), + ); } } class SettingsMainPage extends StatelessWidget { - const SettingsMainPage({super.key, required this.dashboardViewModel, required this.authService}); + const SettingsMainPage({ + super.key, + required this.dashboardViewModel, + required this.authService, + }); + + static const _walletSettingsResolver = WalletSettingsResolver(); + static const _sectionsResolver = SettingsPageSectionsResolver(); + + final DashboardViewModel dashboardViewModel; + final AuthService authService; + + @override + Widget build(BuildContext context) => Container( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + ModalTopBar( + title: S.of(context).settings_title, + leadingIcon: const Icon(Icons.close), + leadingSemanticLabel: S.of(context).close, + onLeadingPressed: Navigator.of(context, rootNavigator: true).pop, + ), + Expanded( + child: ListView( + controller: ModalScrollController.of(context), + padding: const EdgeInsets.fromLTRB(18, 0, 18, 32), + children: [ + for (final (index, section) in _sections(context).indexed) ...[ + if (index > 0) ...[ + const SizedBox(height: 24), + Divider(color: Theme.of(context).colorScheme.surfaceContainerHighest), + const SizedBox(height: 24), + ], + _SettingsSectionHeader( + iconPath: section.titleIconPath, + title: section.title, + foregroundColor: section.titleColor, + ), + SizedBox(height: section.headerSpacing), + for (final (groupIndex, group) in section.groups.entries.indexed) ...[ + if (groupIndex > 0) const SizedBox(height: 24), + NewListSections(sections: {group.key: group.value}), + ], + ], + ], + ), + ), + ], + ), + ); + + List _sections(BuildContext context) => [ + SettingsSectionData( + dashboardViewModel.wallet.name, + "assets/new-ui/wallet-setting.svg", + { + "wallet_type_settings": [ + ListItemRegularRow( + keyValue: "wallet_type_settings", + label: _walletSettingsResolver.titleFor( + dashboardViewModel.wallet.type, + S.of(context), + ), + iconPath: _walletSettingsResolver.iconPathFor(dashboardViewModel.wallet.type), + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => WalletSettingsPage( + dashboardViewModel: dashboardViewModel, + authService: authService, + ), + ), + ), + ), + ], + "wallet_general": _buildRows(context, _sectionsResolver.walletGeneral(S.of(context))), + }, + titleColor: Theme.of(context).colorScheme.primary, + headerSpacing: 20, + ), + SettingsSectionData( + S.of(context).app_settings, + "assets/new-ui/cake-setting.svg", + { + "app_settings": _buildRows(context, _sectionsResolver.appSettings()), + "support_and_about": + _buildRows(context, _sectionsResolver.supportAndAbout(S.of(context))), + }, + ), + ]; + + List _buildRows(BuildContext context, List items) => items + .where((item) => item.condition(dashboardViewModel)) + .map((item) => _buildRow(context, item)) + .toList(); + + ListItemRegularRow _buildRow(BuildContext context, SettingsListItem item) => ListItemRegularRow( + keyValue: item.route, + label: item.title, + iconPath: item.iconPath, + onTap: () => _openItem(context, item), + ); + + Future _openItem(BuildContext context, SettingsListItem item) async { + final arguments = item.routeArgsBuilder?.call(dashboardViewModel) ?? item.routeArgs; + if (item.requireAuth) { + await authService.authenticateAction( + context, + conditionToDetermineIfToUse2FA: item.use2fa(dashboardViewModel), + route: item.route, + arguments: arguments, + ); + return; + } + + await Navigator.of(context).pushNamed(item.route, arguments: arguments); + } +} + +class WalletSettingsPage extends StatelessWidget { + const WalletSettingsPage({ + super.key, + required this.dashboardViewModel, + required this.authService, + }); + + static const _resolver = WalletSettingsResolver(); final DashboardViewModel dashboardViewModel; final AuthService authService; @override Widget build(BuildContext context) { - List buildItems(SettingsSectionData section) => section.items - .map((item) => item.condition(dashboardViewModel) - ? ListItemRegularRow( - keyValue: item.route, - label: item.title, - iconPath: item.iconPath, - onTap: () { - if (item.route.isNotEmpty) { - if (item.requireAuth) { - authService.authenticateAction(context, - conditionToDetermineIfToUse2FA: item.use2fa(dashboardViewModel), - route: item.route); - } else { - Navigator.of(context).pushNamed( - item.route, - arguments: - item.routeArgsBuilder?.call(dashboardViewModel) ?? item.routeArgs, - ); - } - } - }) - : null) - .whereType() - .toList(); + final strings = S.of(context); + final sections = _resolver.resolveSections(dashboardViewModel); return Container( color: Theme.of(context).colorScheme.surface, - child: Column(children: [ - ModalTopBar( - title: S.of(context).settings_title, - leadingIcon: Icon(Icons.close), - leadingSemanticLabel: S.of(context).close, - onLeadingPressed: Navigator.of(context, rootNavigator: true).pop, - onTrailingPressed: () {}, - ), - Expanded( - child: ListView(controller: ModalScrollController.of(context), children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20.0), - child: Column( - spacing: 16, - children: SettingsSectionData.all.expand((section) { - return [ - if (section.titleIconPath.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8.0), - child: Row( - spacing: 8, - children: [ - CakeImageWidget( - imageUrl: section.titleIconPath, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.onSurfaceVariant, - BlendMode.srcIn, - )), - Text(section.title, style: Theme.of(context).textTheme.titleMedium), - ], - ), - ), - NewListSections(sections: {section.title: buildItems(section)}), - ]; - }).toList(), - ), + child: Column( + children: [ + ModalTopBar( + title: _resolver.titleFor(dashboardViewModel.wallet.type, strings), + titleLeadingWidget: CakeImageWidget( + imageUrl: _resolver.iconPathFor(dashboardViewModel.wallet.type), + width: 24, + height: 24, ), - ]), - ), - ]), + leadingIcon: const Icon(Icons.arrow_back_ios_new), + leadingSemanticLabel: strings.seed_alert_back, + onLeadingPressed: Navigator.of(context).pop, + ), + Expanded( + child: ListView( + controller: ModalScrollController.of(context), + padding: const EdgeInsets.fromLTRB(18, 0, 18, 32), + children: [ + for (var index = 0; index < sections.length; index++) ...[ + if (index > 0) const SizedBox(height: 24), + NewListSections( + sections: { + 'wallet_settings_$index': sections[index] + .map( + (item) => ListItemRegularRow( + keyValue: item.route, + label: item.title, + iconPath: item.iconPath, + onTap: () => _openItem(context, item), + ), + ) + .toList(), + }, + ), + ], + ], + ), + ), + ], + ), + ); + } + + Future _openItem(BuildContext context, SettingsListItem item) async { + final arguments = item.routeArgsBuilder?.call(dashboardViewModel) ?? item.routeArgs; + if (item.requireAuth) { + await authService.authenticateAction( + context, + conditionToDetermineIfToUse2FA: item.use2fa(dashboardViewModel), + route: item.route, + arguments: arguments, + ); + return; + } + + await Navigator.of(context).pushNamed(item.route, arguments: arguments); + } +} + +class _SettingsSectionHeader extends StatelessWidget { + const _SettingsSectionHeader({ + required this.iconPath, + required this.title, + this.foregroundColor, + }); + + final String iconPath; + final String title; + final Color? foregroundColor; + + @override + Widget build(BuildContext context) { + final color = foregroundColor ?? Theme.of(context).colorScheme.onSurfaceVariant; + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + CakeImageWidget( + imageUrl: iconPath, + height: 20, + width: 20, + colorFilter: ColorFilter.mode(color, BlendMode.srcIn), + ), + const SizedBox(width: 8), + Expanded( + child: Text( + title, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith(color: color), + ), + ), + ], + ), ); } } diff --git a/lib/new-ui/pages/wallet_accounts_page.dart b/lib/new-ui/pages/wallet_accounts_page.dart index 47b9a7b78..80ef1f1f0 100644 --- a/lib/new-ui/pages/wallet_accounts_page.dart +++ b/lib/new-ui/pages/wallet_accounts_page.dart @@ -1,41 +1,45 @@ import "dart:async"; +import "dart:math"; import "dart:ui"; -import "package:cake_wallet/di.dart"; +import "package:cake_wallet/core/utilities.dart"; +import "package:cake_wallet/core/amount_parsing_proxy.dart"; import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart"; import "package:cake_wallet/entities/new_ui_entities/list_item/list_item_toggle.dart"; -import "package:cake_wallet/generated/i18n.dart"; -import "package:cake_wallet/new-ui/pages/card_customizer.dart"; -import "package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart"; -import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; -import "package:cake_wallet/src/screens/settings/widgets/account_creation_modal.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/src/widgets/new_list_row/new_list_section.dart"; -import "package:cake_wallet/utils/show_pop_up.dart"; +import "package:cake_wallet/src/screens/settings/widgets/account_creation_modal.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:mobx/mobx.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/pages/account_education_page.dart"; +import "package:cake_wallet/new-ui/pages/card_customizer.dart"; +import "package:cake_wallet/new-ui/pages/hidden_accounts.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart"; +import "package:cake_wallet/new-ui/widgets/modern_button.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/src/widgets/alert_with_one_action.dart"; import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; -import "package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart"; import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart"; import "package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart"; +import "package:cw_core/balance_card_layout.dart"; import "package:cw_core/balance_card_style_settings.dart"; import "package:cw_core/card_design.dart"; import "package:cw_core/sync_status.dart"; -import "package:cw_core/utils/print_verbose.dart"; -import "package:cw_core/wallet_type.dart"; +import "package:flutter/cupertino.dart"; import "package:flutter/material.dart"; -import "package:mobx/mobx.dart"; import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; class AccountCustomizerListItem { - AccountCustomizerListItem({ + const AccountCustomizerListItem({ required this.card, - required this.order, required this.accountListItem, + required this.settings, }); final BalanceCard card; - final int order; final AccountListItem accountListItem; + final BalanceCardStyleSettings? settings; } class WalletAccountsPage extends StatefulWidget { @@ -55,315 +59,229 @@ class WalletAccountsPage extends StatefulWidget { } class _WalletAccountsPageState extends State { - static const double _kStackVisibleFactor = 0.2; - late final double cardWidth = MediaQuery.of(context).size.width * 0.9; - final List _items = []; - + bool _hasArchivedAccounts = false; + bool _loading = true; ReactionDisposer? _accountsReaction; - WalletAccountListViewModel get accountListViewModel => - widget.dashboardViewModel.accountListViewModel ?? widget.accountListViewModel; - - bool get _isMultiAccountsEnabled => - widget.dashboardViewModel.isMultiAccountsEnabled; - - String get _assetName => widget.dashboardViewModel.appStore.amountParsingProxy - .getCryptoSymbol(accountListViewModel.currency); + bool get _isMultiAccountsEnabled => widget.dashboardViewModel.isMultiAccountsEnabled; + String get _assetName => AmountParsingProxy( + widget.dashboardViewModel.settingsStore.displayAmountsInSatoshi, + ).getCryptoSymbol(widget.accountListViewModel.currency); bool get _capitalizeAssetName => widget.dashboardViewModel.wallet.type != WalletType.bitcoin || - widget.dashboardViewModel.settingsStore.displayAmountsInSatoshi != - BitcoinAmountDisplayMode.satoshi; + widget.dashboardViewModel.settingsStore.displayAmountsInSatoshi != + BitcoinAmountDisplayMode.satoshi; + + double get cardWidth => min(MediaQuery.sizeOf(context).width * 0.9, 768); @override void initState() { super.initState(); - _accountsReaction = reaction( - (_) => accountListViewModel.accounts + (_) => widget.accountListViewModel.accounts .map((account) => "${account.id}:${account.label}:${account.balance}") .join(","), (_) { - if (!mounted) return; - loadCards(); + if (mounted) unawaited(loadCards()); }, ); + WidgetsBinding.instance.addPostFrameCallback((_) => unawaited(_initialize())); + } - WidgetsBinding.instance.addPostFrameCallback((_) async { - await widget.dashboardViewModel.loadCardDesigns(); - if (!mounted) return; - loadCards(); + Future _initialize() async { + await widget.dashboardViewModel.loadCardDesigns(); + if (!mounted) return; + await _bringActiveAccountToFront(); + if (!mounted || !_isMultiAccountsEnabled || _items.isEmpty) { + return; + } - if (!_isMultiAccountsEnabled) return; - - final activeId = accountListViewModel.selectedAccount?.id; - - for (int i = 0; i < _items.length - 1; i++) { - if (activeId != null && _items[i].accountListItem.id == activeId) { - final lastIndex = _items.length - 1; - final temp = _items[i]; - _items[i] = _items[lastIndex]; - _items[lastIndex] = temp; - - await saveCardOrder(); - await widget.dashboardViewModel.loadCardDesigns(); - loadCards(); - break; - } - } - }); + final educationPage = AccountEducationPage( + settingsStore: widget.dashboardViewModel.settingsStore, + ); + if (!educationPage.isDismissed) { + await educationPage.show(context); + } } @override void dispose() { _accountsReaction?.call(); - accountListViewModel.reload(); saveCardOrder().then((value) => widget.dashboardViewModel.loadCardDesigns()); super.dispose(); } - void loadCards() { - final allAccounts = accountListViewModel.accounts; - final visible = widget.dashboardViewModel.visibleAccounts; + int get _walletInfoId => widget.dashboardViewModel.wallet.walletInfo.internalId; - if (visible.isEmpty && _items.isNotEmpty) return; + Future _bringActiveAccountToFront() async { + await loadCards(); - _items.clear(); - - if (!_isMultiAccountsEnabled) { - if (visible.isNotEmpty) { - final account = visible.first; - final realIndex = allAccounts.indexWhere((a) => a.id == account.id); - final design = (realIndex >= 0 && - realIndex < widget.dashboardViewModel.cardDesigns.length) - ? widget.dashboardViewModel.cardDesigns[realIndex] - : CardDesign.genericDefault; - - _items.add(AccountCustomizerListItem( - card: BalanceCard( - accountName: account.label, - accountIndex: account.id, - balance: account.balance ?? "0.00", - accountBalance: account.balance ?? "0.00", - designSwitchDuration: Duration.zero, - assetName: _assetName, - capitalizeAssetName: _capitalizeAssetName, - selected: true, - width: cardWidth, - design: design, - ), - order: realIndex >= 0 ? realIndex : 0, - accountListItem: account, - )); - } - if (mounted) setState(() {}); + final activeAccount = + widget.accountListViewModel.accounts.firstWhereOrNull((account) => account.isSelected); + if (activeAccount == null || _items.isEmpty) { return; } - for (int i = 0; i < allAccounts.length; i++) { - final realIndex = widget.dashboardViewModel.cardOrder[i]; + final index = _items.indexWhere((item) => item.accountListItem.id == activeAccount.id); + if (index == -1 || index == _items.length - 1 || !mounted) { + return; + } - if (realIndex == null || realIndex < 0 || realIndex >= allAccounts.length) { - // db order broken. - reset(); - break; + reorder(index, _items.length); + await saveCardOrder(); + await widget.dashboardViewModel.loadCardDesigns(); + } + + Future loadCards() async { + if (!mounted) return; + final accounts = widget.accountListViewModel.accounts; + final unnamedAccount = S.of(context).unnamed_account; + final resolvedCardWidth = cardWidth; + final walletCurrency = widget.dashboardViewModel.wallet.currency; + final styleSettings = await BalanceCardStyleSettings.getAll(_walletInfoId); + final layout = BalanceCardLayout.resolve( + accountIndices: accounts.map((account) => account.id).toList(), + settings: styleSettings, + ); + + final List newItems = []; + for (int position = 0; position < layout.visible.length; position++) { + final accountIndex = layout.visible[position]; + final account = accounts.firstWhereOrNull((item) => item.id == accountIndex); + + if (account == null) { + continue; } - _items.add(AccountCustomizerListItem( + final setting = layout.settingFor(accountIndex); + final isFrontCard = position == layout.visible.length - 1; + final accountLabel = account.label.trim().isEmpty ? unnamedAccount : account.label; + + newItems.add( + AccountCustomizerListItem( card: BalanceCard( - accountName: allAccounts[realIndex].label, - accountIndex: allAccounts[realIndex].id, - balance: allAccounts[realIndex].balance ?? "0.00", - accountBalance: allAccounts[realIndex].balance ?? "0.00", + accountName: "${account.id + 1}. $accountLabel", + accountBalance: account.balance, + fiatBalance: + widget.dashboardViewModel.balanceViewModel.accountFiatBalance(account) ?? "", designSwitchDuration: Duration.zero, assetName: _assetName, capitalizeAssetName: _capitalizeAssetName, - selected: i == allAccounts.length - 1, - width: cardWidth, - design: widget.dashboardViewModel.cardDesigns[realIndex], + onCustomizeTapped: isFrontCard ? _openCardCustomizer : null, + selected: isFrontCard, + width: resolvedCardWidth, + design: CardDesign.fromStyleSettings(setting, walletCurrency), ), - order: realIndex, - accountListItem: allAccounts[realIndex])); + accountListItem: account, + settings: setting, + ), + ); } + if (mounted) { - setState(() {}); + setState(() { + _items + ..clear() + ..addAll(newItems); + _hasArchivedAccounts = layout.hidden.isNotEmpty; + _loading = false; + }); + } + + if (layout.needsRepair) { + await BalanceCardStyleSettings.setVisibleOrder(_walletInfoId, layout.orders); } } @override - Widget build(BuildContext context) { - final showResetButton = _isMultiAccountsEnabled && _items.length > 1; - final showAccountsToggle = widget.dashboardViewModel.canToggleMultiAccounts; - final isToggleEnabled = widget.dashboardViewModel.isMultiAccountsEnabled; - - if (_items.isEmpty && !showAccountsToggle) return const SizedBox.shrink(); - - return Container( - decoration: BoxDecoration( + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(24))), - child: Column( - children: [ - ModalTopBar( - title: S.of(context).wallet_accounts, - leadingIcon: const Icon(Icons.close), - leadingSemanticLabel: S.of(context).close, - onLeadingPressed: Navigator.of(context).maybePop, - trailingIcon: showResetButton ? const Icon(Icons.refresh) : null, - trailingSemanticLabel: S.of(context).reset, - onTrailingPressed: showResetDialog, - ), - const SizedBox(height: 24), - if (showAccountsToggle) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: NewListSections( - sections: { - "": [ - ListItemToggle( - keyValue: S.of(context).multiple_accounts, - label: S.of(context).multiple_accounts, - value: isToggleEnabled, - onChanged: (val) async { - await widget.dashboardViewModel.setMultiAccountsEnabled(val); - if (!mounted) return; - loadCards(); - }), - ], - }, - ), - ), - if (_isMultiAccountsEnabled && _items.isNotEmpty) - Expanded( - child: Column( + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + child: Column( + children: [ + ModalTopBar( + title: S.of(context).accounts, + leadingIcon: const Icon(Icons.close), + leadingSemanticLabel: S.of(context).close, + onLeadingPressed: Navigator.of(context).maybePop, + trailingWidget: Row( + mainAxisSize: MainAxisSize.min, + spacing: 8, children: [ - Padding( - padding: const EdgeInsets.symmetric(vertical: 24.0), - child: Text( - S.of(context).account_customizer_desc, - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), + ModernButton( + semanticLabel: S.of(context).accounts_help, + icon: const Icon(Icons.question_mark), + size: 36, + iconSize: 19, + onPressed: () => AccountEducationPage( + settingsStore: widget.dashboardViewModel.settingsStore, + ).show(context), ), - Expanded( - child: Stack( - children: [ - ReorderableListView.builder( - padding: const EdgeInsets.only(bottom: 196), - scrollController: ModalScrollController.of(context), - onReorder: reorder, - proxyDecorator: (child, index, animation) => AnimatedBuilder( - animation: animation, - builder: (context, _) { - final animValue = Curves.easeOutCubic.transform(animation.value); - final scale = lerpDouble(1, 1.05, animValue)!; - - return Opacity( - opacity: 1 - animValue.clamp(0.0, 0.1), - child: Center( - child: SizedBox( - width: cardWidth, - child: Transform.scale( - scale: scale, - child: child, - ), - ), - ), - ); - }, - child: _items[index].card, - ), - itemCount: _items.length, - itemBuilder: (context, int index) { - final card = _items[index].card; - final selectedItemIndex = _items.length - 1; - - return Container( - key: ValueKey(index), - child: Semantics( - button: true, - selected: selectedItemIndex == index, - label: _items[index].accountListItem.label, - onTap: () => reorder(index, _items.length), - child: GestureDetector( - excludeFromSemantics: true, - onTap: () => reorder(index, _items.length), - child: Align( - alignment: Alignment.topCenter, - heightFactor: _kStackVisibleFactor, - child: card, - ), - ), - ), - ); - }, - ), - SafeArea( - child: Padding( - padding: const EdgeInsets.only(bottom: 50), - child: Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Material( - color: Colors.transparent, - child: MergeSemantics( - child: Semantics( - button: true, - child: InkWell( - borderRadius: BorderRadius.circular(999999), - onTap: _showAddAccountModal, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(999999), - ), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 18), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 8, - children: [ - Icon( - Icons.add, - size: 28, - color: Theme.of(context).colorScheme.primary, - ), - Text( - S.of(context).add_account, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500, - ), - ) - ], - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ), - ], + if (_isMultiAccountsEnabled) + ModernButton.svg( + semanticLabel: S.of(context).archived_accounts, + svgPath: "assets/new-ui/archived.svg", + size: 36, + iconSize: 19, + backgroundColor: + _hasArchivedAccounts ? Theme.of(context).colorScheme.primary : null, + iconColor: + _hasArchivedAccounts ? Theme.of(context).colorScheme.onPrimary : null, + onPressed: _openArchivedAccounts, ), - ), ], ), ), - ], - ), - ); - } + if (widget.dashboardViewModel.canToggleMultiAccounts) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: NewListSections(sections: { + "": [ + ListItemToggle( + keyValue: S.of(context).multiple_accounts, + label: S.of(context).multiple_accounts, + value: _isMultiAccountsEnabled, + onChanged: (value) async { + await widget.dashboardViewModel.setMultiAccountsEnabled(value); + if (mounted) await _initialize(); + }, + ), + ], + }), + ), + if (_isMultiAccountsEnabled) + Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 18), + child: Text( + S.of(context).account_customizer_desc, + textAlign: TextAlign.center, + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + Expanded( + child: !_isMultiAccountsEnabled + ? const SizedBox.shrink() + : _loading + ? const Center(child: CupertinoActivityIndicator()) + : _items.isEmpty + ? const SizedBox.shrink() + : _AccountCards( + items: _items, + cardWidth: cardWidth, + onReorder: reorder, + onAddAccount: _showAddAccountModal, + ), + ), + ], + ), + ); bool _checkReadyToManage() { - if (widget.dashboardViewModel.wallet.type == WalletType.bitcoin) { - return true; - } if (widget.dashboardViewModel.status is! SyncedSyncStatus) { showDialog( context: context, @@ -380,33 +298,48 @@ class _WalletAccountsPageState extends State { } Future _showAddAccountModal() async { - if (!_checkReadyToManage()) return; - - final createViewModel = getIt.get(); - - final res = await showCupertinoModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (context) => Material( - child: AccountCreationModal( - viewModel: createViewModel, - ), - )); - - if (res != null && res is bool && res == true) { - await accountListViewModel.reload(); - await Future.delayed(Duration.zero); - - final accounts = accountListViewModel.accounts; - if (accounts.isNotEmpty) { - final newestAccount = accounts.reduce((a, b) => a.id > b.id ? a : b); - await accountListViewModel.select(newestAccount); - } - - await widget.dashboardViewModel.loadCardDesigns(); - loadCards(); - if (mounted) setState(() {}); + if (!_checkReadyToManage()) { + return; } + + final modal = AccountCreationModal(viewModel: widget.accountEditOrCreateViewModel); + final res = await showCupertinoModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + expand: true, + enableDrag: false, + builder: (_) => Material(child: modal), + ); + if (res == true && mounted) { + await widget.accountListViewModel.reload(); + final accounts = widget.accountListViewModel.accounts; + if (accounts.isNotEmpty) { + await widget.accountListViewModel.select( + accounts.reduce((a, b) => a.id > b.id ? a : b), + ); + } + await widget.dashboardViewModel.loadCardDesigns(); + await loadCards(); + await saveCardOrder(); + } + } + + Future _openArchivedAccounts() async { + await Navigator.of(context).push( + CupertinoPageRoute( + builder: (context) => Material( + child: HiddenAccountsPage( + accountListViewModel: widget.accountListViewModel, + dashboardViewModel: widget.dashboardViewModel, + ), + ), + ), + ); + if (!mounted) { + return; + } + await widget.dashboardViewModel.loadCardDesigns(); + await loadCards(); } Future _openCardCustomizer() async { @@ -414,49 +347,34 @@ class _WalletAccountsPageState extends State { return; } - await CardCustomizer.show( + final account = _items.last.accountListItem; + await widget.accountListViewModel.select(account); + if (!mounted) return; + + final archived = await CardCustomizer.show( context: context, dashboardViewModel: widget.dashboardViewModel, - lightningMode: widget.dashboardViewModel.lightningMode, - asModalSheet: false, - onSaved: _reloadAccountsAndCards, + account: account, + canHide: _items.length > 1, + asPage: true, ); - } - - Future _reloadAccountsAndCards() async { - await accountListViewModel.reload(); + if (archived && _items.length > 1) { + final nextAccount = _items[_items.length - 2].accountListItem; + await widget.accountListViewModel.select( + widget.accountListViewModel.accounts + .firstWhereOrNull((item) => item.id == nextAccount.id) ?? + nextAccount, + ); + } + await widget.accountListViewModel.reload(); await widget.dashboardViewModel.loadCardDesigns(); - if (!mounted) return; - loadCards(); - } - - void _onCardsReordered(int oldRealIndex, int newRealIndex) { - final accounts = accountListViewModel.accounts; - if (oldRealIndex < 0 || - newRealIndex < 0 || - oldRealIndex >= accounts.length || - newRealIndex >= accounts.length) { + if (!mounted) { return; } - - final oldAccountId = accounts[oldRealIndex].id; - final newAccountId = accounts[newRealIndex].id; - - final from = _items.indexWhere((item) => item.accountListItem.id == oldAccountId); - final to = _items.indexWhere((item) => item.accountListItem.id == newAccountId); - if (from < 0 || to < 0 || from == to) return; - - final insertIndex = from < to ? to + 1 : to; - reorder(from, insertIndex); - - saveCardOrder().then((_) { - if (!mounted) return; - widget.dashboardViewModel.loadCardDesigns(); - }); + await loadCards(); } void reorder(int oldIndex, int newIndex) { - if (!_isMultiAccountsEnabled) return; setState(() { if (oldIndex < newIndex) { newIndex -= 1; @@ -468,100 +386,159 @@ class _WalletAccountsPageState extends State { // necessary to copy all this to keep constant constructor for BalanceCard for (int i = 0; i < _items.length; i++) { _items[i] = AccountCustomizerListItem( - card: BalanceCard( - accountName: _items[i].card.accountName, - balance: _items[i].card.balance, - accountIndex: _items[i].card.accountIndex, - accountBalance: _items[i].card.accountBalance, - assetName: _items[i].card.assetName, - capitalizeAssetName: _items[i].card.capitalizeAssetName, - designSwitchDuration: _items[i].card.designSwitchDuration, - onCustomizeTapped: (i == _items.length - 1) ? _openCardCustomizer : null, - selected: i == _items.length - 1, - width: _items[i].card.width, - design: _items[i].card.design, - ), - order: i, - accountListItem: _items[i].accountListItem); + card: BalanceCard( + accountName: _items[i].card.accountName, + balance: _items[i].card.balance, + accountIndex: _items[i].card.accountIndex, + accountBalance: _items[i].card.accountBalance, + fiatBalance: _items[i].card.fiatBalance, + assetName: _items[i].card.assetName, + capitalizeAssetName: _items[i].card.capitalizeAssetName, + designSwitchDuration: _items[i].card.designSwitchDuration, + onCustomizeTapped: (i == _items.length - 1) ? _openCardCustomizer : null, + selected: i == _items.length - 1, + width: _items[i].card.width, + design: _items[i].card.design, + ), + accountListItem: _items[i].accountListItem, + settings: _items[i].settings, + ); } if (newIndex == _items.length - 1 || oldIndex == _items.length - 1) { - accountListViewModel.select(_items[_items.length - 1].accountListItem); + widget.accountListViewModel.select(_items[_items.length - 1].accountListItem); } } Future saveCardOrder() async { - if (!_isMultiAccountsEnabled) return; - - for (int orderIndex = 0; orderIndex < _items.length; orderIndex++) { - final item = _items[orderIndex]; - printV("${item.accountListItem.id}: $orderIndex"); - - final existing = await BalanceCardStyleSettings.get( - widget.dashboardViewModel.wallet.walletInfo.internalId, - item.accountListItem.id, - ); - - await BalanceCardStyleSettings.fromCardDesign( - walletInfoId: widget.dashboardViewModel.wallet.walletInfo.internalId, - accountIndex: item.accountListItem.id, - cardOrder: orderIndex, - design: item.card.design, - iconStyleIndex: existing?.iconStyleIndex ?? 0, - gradientIndexOverride: existing?.gradientIndex) - .insert(); - } - } - - Future showResetDialog() async { - final res = await showPopUp( - context: context, - builder: (context) => AlertWithTwoActions( - alertTitle: S.of(context).reset, - alertContent: S.of(context).card_order_reset_desc, - leftButtonText: S.of(context).yes, - rightButtonText: S.of(context).no, - actionLeftButton: () { - Navigator.of(context).pop(true); - }, - actionRightButton: Navigator.of(context).pop)); - if (res != null && res is bool && res) { - reset(); - } - } - - Future reset() async { - _items.clear(); - - final accounts = accountListViewModel.accounts; - for (int i = 0; i < accounts.length; i++) { - _items.add(AccountCustomizerListItem( - card: BalanceCard( - accountName: accounts[i].label, - accountIndex: accounts[i].id, - balance: accounts[i].balance ?? "0.00", - accountBalance: accounts[i].balance ?? "0.00", - assetName: _assetName, - capitalizeAssetName: _capitalizeAssetName, - selected: i == accounts.length - 1, - designSwitchDuration: const Duration(milliseconds: 200), - width: cardWidth, - design: i >= widget.dashboardViewModel.cardDesigns.length - ? CardDesign.genericDefault - : widget.dashboardViewModel.cardDesigns[i], - ), - order: i, - accountListItem: accounts[i])); - } - - await saveCardOrder(); - - if (accounts.isNotEmpty) { - await accountListViewModel.select(accounts.last); - } - - await widget.dashboardViewModel.loadCardDesigns(); - if (mounted) setState(() {}); + await BalanceCardStyleSettings.setOrder(_walletInfoId, { + for (int position = 0; position < _items.length; position++) + _items[position].accountListItem.id: position, + }); } } + +class _AccountCards extends StatelessWidget { + const _AccountCards({ + required this.items, + required this.cardWidth, + required this.onReorder, + required this.onAddAccount, + }); + + static const double _kStackVisibleFactor = 0.2; + + final List items; + final double cardWidth; + final ReorderCallback onReorder; + final VoidCallback onAddAccount; + + @override + Widget build(BuildContext context) => Stack( + children: [ + ReorderableListView.builder( + padding: const EdgeInsets.only(bottom: 196), + scrollController: ModalScrollController.of(context), + onReorder: onReorder, + proxyDecorator: (child, index, animation) => AnimatedBuilder( + animation: animation, + builder: (context, _) { + final animValue = Curves.easeOutCubic.transform(animation.value); + final scale = lerpDouble(1, 1.05, animValue)!; + + return Opacity( + opacity: 1 - animValue.clamp(0.0, 0.1), + child: Center( + child: SizedBox( + width: cardWidth, + child: Transform.scale( + scale: scale, + child: child, + ), + ), + ), + ); + }, + child: items[index].card, + ), + itemCount: items.length, + itemBuilder: (context, index) { + final selectedItemIndex = items.length - 1; + + return Container( + key: ValueKey(items[index].accountListItem.id), + child: Semantics( + button: true, + selected: selectedItemIndex == index, + label: "${items[index].accountListItem.id + 1}. " + "${items[index].accountListItem.label.trim().isEmpty ? S.of(context).unnamed_account : items[index].accountListItem.label}", + onTap: () => onReorder(index, items.length), + child: GestureDetector( + excludeFromSemantics: true, + onTap: () { + onReorder(index, items.length); + }, + child: Align( + alignment: Alignment.topCenter, + heightFactor: _kStackVisibleFactor, + child: items[index].card, + ), + ), + ), + ); + }, + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.only(bottom: 50), + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Material( + color: Colors.transparent, + child: MergeSemantics( + child: Semantics( + button: true, + child: InkWell( + borderRadius: BorderRadius.circular(999999), + onTap: onAddAccount, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(999999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + Icon( + Icons.add, + size: 28, + color: Theme.of(context).colorScheme.primary, + ), + Text( + S.of(context).add_account, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ], + ); +} diff --git a/lib/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart b/lib/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart index 936fdade4..ba756a97d 100644 --- a/lib/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart +++ b/lib/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart @@ -1,38 +1,50 @@ -import 'package:bloc/bloc.dart'; +import "package:bloc/bloc.dart"; +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart"; import "package:cake_wallet/bitcoin/bitcoin.dart"; -import 'package:cake_wallet/monero/monero.dart'; -import 'package:cake_wallet/wownero/wownero.dart'; +import "package:cake_wallet/monero/monero.dart"; +import "package:cake_wallet/wownero/wownero.dart" as wow; import "package:cw_core/balance_card_style_settings.dart"; -import 'package:cw_core/card_design.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/wallet_base.dart'; +import "package:cw_core/card_design.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/src/painting/gradient.dart'; -import 'package:meta/meta.dart'; +import "package:flutter/src/painting/gradient.dart"; +import "package:meta/meta.dart"; -part 'card_customizer_event.dart'; -part 'card_customizer_state.dart'; +part "card_customizer_event.dart"; +part "card_customizer_state.dart"; + +class CardCustomizerBlocParams { + CardCustomizerBlocParams({required this.lightningMode, required this.canHide, this.amountDisplayMode}); + + final bool lightningMode; + final BitcoinAmountDisplayMode? amountDisplayMode; + final bool canHide; +} class CardCustomizerBloc extends Bloc { - final WalletBase _wallet; - final bool lightningMode; - final bool displaySats; - CardCustomizerBloc(this._wallet, {this.lightningMode = false, this.displaySats = false}) + CardCustomizerBloc(this._wallet, {this.lightningMode = false, this.displaySats = false, this.canHide = false}) : super(CardCustomizerNotLoaded( - 0, 0, [CardDesign.genericDefault], [], "", -1, displaySats, 0)) { + 0, 0, [CardDesign.genericDefault], [], "", -1, displaySats, 0,),) { on<_Init>(_init); on(_onDesignSelected); on(_onColorSelected); on(_onIconStyleSelected); on(_onDesignSaved); + on(_onAccountHidden); on(_onAccountNameChanged); add(_Init()); } - bool get _accountNameEnabled => - !lightningMode && _wallet.walletInfo.isMultiAccountsEnabled == true; + final WalletBase _wallet; + final bool lightningMode; + final bool displaySats; + final bool canHide; + + bool get _accountNameEnabled => !lightningMode && + (_wallet.hasNativeAccounts || _wallet.walletInfo.isMultiAccountsEnabled == true); List _updateAvailableColors(CardDesign currentDesign) { final list = List.from(CardDesign.allGradients, growable: true); @@ -42,8 +54,21 @@ class CardCustomizerBloc extends Bloc return list; } - Future _loadCurrentDesignSettings(int accountIndex) async { - return (await BalanceCardStyleSettings.get(_wallet.walletInfo.internalId, accountIndex)); + Future _loadCurrentDesignSettings(int accountIndex) => + BalanceCardStyleSettings.get(_wallet.walletInfo.internalId, accountIndex); + + Future _cardOrderFor(BalanceCardStyleSettings? currentDesignSettings) async { + if (currentDesignSettings != null) { + return currentDesignSettings.cardOrder; + } + + var highest = -1; + for (final setting in await BalanceCardStyleSettings.getAll(_wallet.walletInfo.internalId)) { + if (setting.cardOrder > highest) { + highest = setting.cardOrder; + } + } + return highest + 1; } List _initAvailableDesigns({bool lightningMode = false}) { @@ -53,8 +78,9 @@ class CardCustomizerBloc extends Bloc ret.add(CardDesign.gradientOnlyDesign); ret.add(CardDesign.forCurrencyIcon(curr)); - if (CardDesign.specialDesignsForCurrencies[curr] != null) + if (CardDesign.specialDesignsForCurrencies[curr] != null) { ret.add(CardDesign.forCurrencySpecial(curr)); + } return ret; } @@ -76,8 +102,12 @@ class CardCustomizerBloc extends Bloc BalanceCardStyleSettings? settings, List availableIconPaths, ) { - if (settings == null || availableIconPaths.isEmpty) return 0; - if (settings.iconStyleIndex >= availableIconPaths.length) return 0; + if (settings == null || availableIconPaths.isEmpty) { + return 0; + } + if (settings.iconStyleIndex >= availableIconPaths.length) { + return 0; + } return settings.iconStyleIndex; } @@ -86,7 +116,7 @@ class CardCustomizerBloc extends Bloc return ret == -1 ? CardDesign.allGradients.length : ret; } - void _init(_Init event, Emitter emit) async { + Future _init(_Init event, Emitter emit) async { final accountInfo = await _getCurrentAccountInfo(); final accountName = accountInfo.accountName; final accountIndex = accountInfo.accountIndex; @@ -108,9 +138,9 @@ class CardCustomizerBloc extends Bloc accountName, accountIndex, displaySats, - currentDesignSettings?.cardOrder ?? 0, + await _cardOrderFor(currentDesignSettings), availableIconPaths: availableIconPaths, - selectedIconIndex: selectedIconIndex)); + selectedIconIndex: selectedIconIndex,),); } Future<({String accountName, int accountIndex})> _getCurrentAccountInfo() async { @@ -134,7 +164,7 @@ class CardCustomizerBloc extends Bloc } if (_wallet.type == WalletType.wownero) { - final account = wownero!.getCurrentAccount(_wallet); + final account = wow.wownero!.getCurrentAccount(_wallet); return (accountName: account.label, accountIndex: account.id); } @@ -160,7 +190,7 @@ class CardCustomizerBloc extends Bloc emit(state.copyWith( selectedDesignIndex: event.newDesignIndex, availableColors: newColors, - selectedColorIndex: newColorIndex)); + selectedColorIndex: newColorIndex,),); } void _onColorSelected(ColorSelected event, Emitter emit) { @@ -176,18 +206,20 @@ class CardCustomizerBloc extends Bloc } Future _onDesignSaved(DesignSaved event, Emitter emit) async { + final currentSettings = await _loadCurrentDesignSettings(state.accountIndex); await BalanceCardStyleSettings.fromCardDesign( - walletInfoId: _wallet.walletInfo.internalId, - accountIndex: state.accountIndex, - cardOrder: state.cardOrder, - design: state.selectedDesign, - iconStyleIndex: state.selectedIconIndex, - gradientIndexOverride: state.selectedColorIndex) - .insert(); - + walletInfoId: _wallet.walletInfo.internalId, + accountIndex: state.accountIndex, + cardOrder: state.cardOrder, + design: state.selectedDesign, + iconStyleIndex: state.selectedIconIndex, + gradientIndexOverride: state.selectedColorIndex, + hidden: currentSettings?.hidden ?? false, + ).insert(); await saveAccountName(); - emit(CardCustomizerSaved( + emit( + CardCustomizerSaved( state.selectedDesignIndex, state.selectedColorIndex, state.availableDesigns, @@ -197,7 +229,34 @@ class CardCustomizerBloc extends Bloc state.displaySats, state.cardOrder, availableIconPaths: state.availableIconPaths, - selectedIconIndex: state.selectedIconIndex)); + selectedIconIndex: state.selectedIconIndex, + ), + ); + } + + Future _onAccountHidden(AccountHidden event, Emitter emit) async { + await BalanceCardStyleSettings.fromCardDesign( + walletInfoId: _wallet.walletInfo.internalId, + accountIndex: state.accountIndex, + cardOrder: state.cardOrder, + design: state.selectedDesign, + iconStyleIndex: state.selectedIconIndex, + gradientIndexOverride: state.selectedColorIndex, + hidden: true, + ).insert(); + + emit( + CardCustomizerSaved( + state.selectedDesignIndex, + state.selectedColorIndex, + state.availableDesigns, + state.availableColors, + state.accountName, + state.accountIndex, + state.displaySats, + state.cardOrder, + ), + ); } Future saveAccountName() async { @@ -205,12 +264,9 @@ class CardCustomizerBloc extends Bloc if (_wallet.type == WalletType.monero) { await saveMoneroAccountName(); - } - - if (_wallet.type == WalletType.wownero) { + } else if (_wallet.type == WalletType.wownero) { await saveWowneroAccountName(); - } - if (_wallet.type == WalletType.bitcoin && !lightningMode) { + } else if (_wallet.type == WalletType.bitcoin) { await saveBitcoinAccountName(); } } @@ -218,19 +274,21 @@ class CardCustomizerBloc extends Bloc Future saveMoneroAccountName() async { final MoneroAccountList moneroAccountList = monero!.getAccountList(_wallet); await moneroAccountList.setLabelAccount(_wallet, - accountIndex: state.accountIndex, label: state.accountName); + accountIndex: state.accountIndex, label: state.accountName,); await _wallet.save(); } Future saveWowneroAccountName() async { - final WowneroAccountList wowneroAccountList = wownero!.getAccountList(_wallet); - await wowneroAccountList.setLabelAccount(_wallet, - accountIndex: state.accountIndex, label: state.accountName); + final accountList = wow.wownero!.getAccountList(_wallet); + await accountList.setLabelAccount( + _wallet, + accountIndex: state.accountIndex, + label: state.accountName, + ); await _wallet.save(); } - Future saveBitcoinAccountName() async { await _wallet.walletInfo.renameAccount( accountIndex: state.accountIndex, diff --git a/lib/new-ui/viewmodels/card_customizer/card_customizer_event.dart b/lib/new-ui/viewmodels/card_customizer/card_customizer_event.dart index e0a94b19e..500837043 100644 --- a/lib/new-ui/viewmodels/card_customizer/card_customizer_event.dart +++ b/lib/new-ui/viewmodels/card_customizer/card_customizer_event.dart @@ -1,4 +1,4 @@ -part of 'card_customizer_bloc.dart'; +part of "card_customizer_bloc.dart"; @immutable sealed class CardCustomizerEvent {} @@ -6,27 +6,29 @@ sealed class CardCustomizerEvent {} class _Init extends CardCustomizerEvent {} class CardDesignSelected extends CardCustomizerEvent { - final int newDesignIndex; CardDesignSelected(this.newDesignIndex); + final int newDesignIndex; } class ColorSelected extends CardCustomizerEvent { - final int newColorIndex; ColorSelected(this.newColorIndex); + final int newColorIndex; } class AccountNameChanged extends CardCustomizerEvent { - final String newAccountName; AccountNameChanged(this.newAccountName); + final String newAccountName; } class DesignSaved extends CardCustomizerEvent {} class IconStyleSelected extends CardCustomizerEvent { - final int iconIndex; IconStyleSelected(this.iconIndex); + final int iconIndex; } + +class AccountHidden extends CardCustomizerEvent {} diff --git a/lib/new-ui/widgets/account_confirmation_content.dart b/lib/new-ui/widgets/account_confirmation_content.dart new file mode 100644 index 000000000..a1fe91007 --- /dev/null +++ b/lib/new-ui/widgets/account_confirmation_content.dart @@ -0,0 +1,174 @@ +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/widgets/money/money_text.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cake_wallet/themes/core/theme_extension.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart"; +import "package:flutter/material.dart"; + +class ArchiveConfirmationContent extends StatelessWidget { + const ArchiveConfirmationContent({ + required this.account, + required this.fiatBalance, + required this.isFunded, + }); + + final AccountListItem account; + final String? fiatBalance; + final bool isFunded; + + @override + Widget build(BuildContext context) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (isFunded) ...[ + Text( + S.of(context).archive_account_funds_title, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.customColors.warningOutlineColor, + ), + ), + const SizedBox(height: 20), + AccountFundsSummary( + account: account, + fiatBalance: fiatBalance, + borderColor: context.customColors.warningOutlineColor, + ), + const SizedBox(height: 24), + Text( + S.of(context).archive_account_move_funds, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: context.customColors.warningOutlineColor, + ), + ), + const SizedBox(height: 20), + Text( + S.of(context).archive_account_funded_disclaimer, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ] else ...[ + AccountSummary(account: account), + const SizedBox(height: 24), + Text( + S.of(context).archive_account_empty_disclaimer, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 12), + Text( + S.of(context).archive_account_restore_hint, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ); +} + +class AccountSummary extends StatelessWidget { + const AccountSummary({required this.account}); + + final AccountListItem account; + + @override + Widget build(BuildContext context) { + final label = account.label.trim(); + + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(18), + ), + child: Row( + children: [ + CakeImageWidget( + imageUrl: "assets/new-ui/account.svg", + width: 24, + height: 24, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 12), + Expanded( + child: Text.rich( + TextSpan( + children: [ + TextSpan( + text: "${account.id + 1}. ", + style: TextStyle(color: Theme.of(context).colorScheme.primary), + ), + TextSpan( + text: label.isEmpty ? S.of(context).unnamed_account : label, + ), + ], + ), + style: Theme.of(context).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w500), + ), + ), + ], + ), + ); + } +} + +class AccountFundsSummary extends StatelessWidget { + const AccountFundsSummary({ + required this.account, + required this.fiatBalance, + required this.borderColor, + }); + + final AccountListItem account; + final String? fiatBalance; + final Color borderColor; + + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + children: [ + CakeImageWidget( + imageUrl: account.balance.currency.iconPath ?? + "assets/new-ui/crypto_full_icons/${account.balance.currency.name.toLowerCase()}.svg", + width: 24, + height: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MoneyText( + account.balance, + fractionalDigits: account.balance.decimals, + style: Theme.of(context).textTheme.bodyMedium, + ), + if (fiatBalance != null) ...[ + const SizedBox(height: 2), + Text( + fiatBalance!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + ], + ), + ); +} diff --git a/lib/new-ui/widgets/coins_page/accounts_promo.dart b/lib/new-ui/widgets/coins_page/accounts_promo.dart new file mode 100644 index 000000000..0199b4199 --- /dev/null +++ b/lib/new-ui/widgets/coins_page/accounts_promo.dart @@ -0,0 +1,109 @@ +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_mobx/flutter_mobx.dart"; + +class AccountsPromo extends StatelessWidget { + const AccountsPromo({ + required this.settingsStore, + required this.walletName, + required this.onTap, + super.key, + }); + + final SettingsStore settingsStore; + final String walletName; + final VoidCallback onTap; + + static bool supportsWallet(WalletType walletType) => walletType == WalletType.bitcoin; + + @override + Widget build(BuildContext context) => Observer( + builder: (_) { + if (settingsStore.accountsHomePromoDismissed) { + return const SizedBox.shrink(); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Column( + children: [ + Material( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(18), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: onTap, + child: Container( + constraints: const BoxConstraints(minHeight: 64), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: BoxDecoration( + border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), + borderRadius: BorderRadius.circular(18), + ), + child: Row( + children: [ + const CakeImageWidget( + imageUrl: "assets/new-ui/settings_row_icons/accounts.svg", + width: 24, + height: 24, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + S.of(context).accounts_home_promo_title(walletName), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + S.of(context).accounts_home_promo_description, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + Icon( + Icons.arrow_forward_ios, + size: 16, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ), + ), + const SizedBox(height: 12), + Semantics( + button: true, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => settingsStore.accountsHomePromoDismissed = true, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + child: Text( + S.of(context).do_not_show_anymore, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ), + ), + ), + ), + ], + ), + ); + }, + ); +} diff --git a/lib/new-ui/widgets/coins_page/cards/balance_card.dart b/lib/new-ui/widgets/coins_page/cards/balance_card.dart index 5ca0dcd33..ed97cf790 100644 --- a/lib/new-ui/widgets/coins_page/cards/balance_card.dart +++ b/lib/new-ui/widgets/coins_page/cards/balance_card.dart @@ -1,7 +1,9 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; -import 'package:cw_core/card_design.dart'; -import 'package:flutter/material.dart'; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/widgets/money/money_text.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/card_design.dart"; +import "package:flutter/material.dart"; class BalanceCardAction { final String label; @@ -22,7 +24,7 @@ class BalanceCard extends StatelessWidget { this.borderRadius = 20, this.selected = false, this.accountName = "", - this.accountBalance = "", + this.accountBalance, this.balance = "", this.fiatBalance = "", this.assetName = "", @@ -37,7 +39,7 @@ class BalanceCard extends StatelessWidget { final double width; final double borderRadius; final Gradient? gradient; - final String accountBalance; + final Money? accountBalance; final String accountName; final String balance; final String fiatBalance; @@ -62,7 +64,7 @@ class BalanceCard extends StatelessWidget { final leadText = fiatFirst ? S.of(context).wallet_balance : accountName; - final bool showText = accountBalance.isNotEmpty || + final bool showText = accountBalance != null || leadText.isNotEmpty || balance.isNotEmpty || fiatBalance.isNotEmpty || @@ -102,7 +104,7 @@ class BalanceCard extends StatelessWidget { ), ) : const SizedBox.shrink( - key: ValueKey('svgFullOff'), + key: ValueKey("svgFullOff"), ), ), Padding( @@ -117,7 +119,7 @@ class BalanceCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.max, children: [ - if (leadText.isNotEmpty || accountBalance.isNotEmpty) + if (leadText.isNotEmpty || accountBalance != null) Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -153,8 +155,10 @@ class BalanceCard extends StatelessWidget { // readers, so drop it while it is invisible. child: ExcludeSemantics( excluding: selected, - child: Text( + child: MoneyText.optional( accountBalance, + fractionalDigits: accountBalance?.decimals ?? 8, + showSymbol: false, style: TextStyle(color: design.colors.textColor, fontSize: 14), ), ), @@ -190,7 +194,13 @@ class BalanceCard extends StatelessWidget { fontSize: 28, fontWeight: FontWeight.w500, letterSpacing: -0.4), - child: Text(fiatFirst ? fiatBalance : balance), + child: !fiatFirst && balance.isEmpty + ? MoneyText.optional( + accountBalance, + fractionalDigits: accountBalance?.decimals ?? 8, + showSymbol: false, + ) + : Text(fiatFirst ? fiatBalance : balance), ), AnimatedDefaultTextStyle( duration: designSwitchDuration, @@ -270,7 +280,7 @@ class BalanceCard extends StatelessWidget { child: _CornerSvgIcon(design: design, iconWidth: iconWidth), ) : const SizedBox.shrink( - key: ValueKey('svgIconOff'), + key: ValueKey("svgIconOff"), ), ), ], diff --git a/lib/new-ui/widgets/coins_page/cards/cards_view.dart b/lib/new-ui/widgets/coins_page/cards/cards_view.dart index 0acc70f6c..c42d00277 100644 --- a/lib/new-ui/widgets/coins_page/cards/cards_view.dart +++ b/lib/new-ui/widgets/coins_page/cards/cards_view.dart @@ -1,4 +1,6 @@ import 'dart:math'; +import 'package:cake_wallet/core/utilities.dart'; +import 'package:cw_core/balance_card_layout.dart'; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/di.dart'; @@ -9,7 +11,6 @@ import 'package:cake_wallet/new-ui/modal_navigator.dart'; import 'package:cake_wallet/new-ui/pages/send_page.dart'; import 'package:cake_wallet/new-ui/widgets/buy_sell/buy_sell_selector_modal.dart'; import 'package:cake_wallet/routes.dart'; -import 'package:cake_wallet/utils/feature_flag.dart'; import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; @@ -60,9 +61,9 @@ class _CardsViewState extends State { _selectedIndex = _fallbackSelectedIndex; _cardOrderReaction = reaction( (_) => widget.dashboardViewModel.cardOrder.values.toList(), - (_) => setState(() { - _selectedIndex = _fallbackSelectedIndex; - }), + (_) { + if (mounted) setState(() => _selectedIndex = _fallbackSelectedIndex); + }, ); } @@ -96,20 +97,18 @@ class _CardsViewState extends State { final isSelected = _selectedIndex == visualIndex; final accounts = widget.accountListViewModel?.accounts; - final cardLabel = (!_hideAccountInfo && accounts != null && realIndex < accounts.length) - ? accounts[realIndex].label - : S.of(context).balance; + final account = accounts?.firstWhereOrNull((account) => account.id == realIndex); + final cardLabel = !_hideAccountInfo && account != null ? account.label : S.of(context).balance; - void onCardTap() { + Future onCardTap() async { // printV(visualIndex); if (compactMode && visualIndex != 0) { widget.onCompactModeBackgroundCardsTapped(); } else if (!compactMode) { - setState(() { - if (widget.accountListViewModel != null && !widget.lightningMode) - widget.accountListViewModel!.select(widget.accountListViewModel!.accounts[realIndex]); - _selectedIndex = visualIndex; - }); + if (account != null && !_hideAccountInfo) { + await widget.accountListViewModel!.select(account); + } + if (mounted) setState(() => _selectedIndex = visualIndex); } } @@ -149,10 +148,8 @@ class _CardsViewState extends State { onTap: onCardTap, onLongPress: onCardLongPress, child: Observer(builder: (_) { - if (realIndex >= (widget.accountListViewModel?.accounts.length ?? 1)) { - return Container(); - } - final account = widget.accountListViewModel?.accounts[realIndex]; + final account = widget.accountListViewModel?.accounts + .firstWhereOrNull((account) => account.id == realIndex); // The second balance should always be the lightning balance // printV(widget.dashboardViewModel.balanceViewModel.formattedBalances.first.availableBalance); @@ -180,25 +177,17 @@ class _CardsViewState extends State { // the card designs is empty if widget gets built before it loads. // should get populated before user sees anything - final CardDesign cardDesign; - if (widget.dashboardViewModel.cardDesigns.isEmpty || - realIndex >= widget.dashboardViewModel.cardDesigns.length) - cardDesign = CardDesign.genericDefault; - else if (widget.lightningMode) - // the lightning design is always the last one (after all bitcoin accounts) - cardDesign = widget.dashboardViewModel.cardDesigns.last; - else - cardDesign = widget.dashboardViewModel.cardDesigns[realIndex]; + final designIndex = widget.dashboardViewModel.cardAccountIndices.indexOf( + widget.lightningMode ? -2 : realIndex, + ); + final cardDesign = designIndex < 0 || + designIndex >= widget.dashboardViewModel.cardDesigns.length + ? CardDesign.genericDefault + : widget.dashboardViewModel.cardDesigns[designIndex]; - final String accountName; - final String accountBalance; - if (account == null || _hideAccountInfo) { - accountName = ""; - accountBalance = ""; - } else { - accountName = account.label; - accountBalance = account.balance ?? "0.00"; - } + final showAccount = account != null && !_hideAccountInfo; + final accountName = showAccount ? account.label : ""; + final accountBalance = showAccount ? account.balance : null; final assetName = widget.dashboardViewModel.balanceViewModel.showCombinedBalance ? "" @@ -288,28 +277,13 @@ class _CardsViewState extends State { } - int _visibleCardsCount() { - if (widget.dashboardViewModel.wallet.type != WalletType.bitcoin) { - return widget.dashboardViewModel.cardDesigns.length; - } - - if (widget.lightningMode || !widget.dashboardViewModel.isMultiAccountsEnabled) { - return 1; - } - - return widget.accountListViewModel?.accounts.length ?? 1; - } - int? _visualIndexForSelectedAccount(Map order) { final vm = widget.accountListViewModel; if (vm == null || _hideAccountInfo) return null; final selectedId = vm.selectedAccount?.id; - final realIndex = vm.accounts.indexWhere((a) => a.id == selectedId); - if (realIndex < 0) return null; - for (final entry in order.entries) { - if (entry.value == realIndex) return entry.key; + if (entry.value == selectedId) return entry.key; } return null; } @@ -319,27 +293,15 @@ class _CardsViewState extends State { final parentWidth = MediaQuery.of(context).size.width; final children = []; - int numCards = _visibleCardsCount(); - if (numCards == 0) numCards = 1; - - if (_selectedIndex >= numCards) { - _selectedIndex = 0; - } - - Map order = widget.dashboardViewModel.cardOrder.length != numCards - ? Map.fromEntries( - List.generate(numCards, (i) => MapEntry(i, i)), - ) - : widget.dashboardViewModel.cardOrder; - - for (int i = min(numCards - 1, maxCards); i >= 0; i--) { - int visualIndex = (_selectedIndex - i + numCards) % numCards; - if (order[visualIndex] == null) { - order = Map.fromEntries( - List.generate(numCards, (i) => MapEntry(i, i)), - ); - } + final stack = _hideAccountInfo + ? const [0] + : BalanceCardLayout.stackFromOrder(widget.dashboardViewModel.cardOrder); + if (stack.isEmpty) return const SizedBox.shrink(); + final numCards = stack.length; + if (_selectedIndex < 0 || _selectedIndex >= numCards) { + _selectedIndex = numCards - 1; } + final order = {for (int i = 0; i < stack.length; i++) i: stack[i]}; final followIndex = _visualIndexForSelectedAccount(order); if (followIndex != null) _selectedIndex = followIndex; @@ -461,4 +423,4 @@ class _CardsViewState extends State { ); } } -} \ No newline at end of file +} diff --git a/lib/new-ui/widgets/receive_page/receive_top_bar.dart b/lib/new-ui/widgets/receive_page/receive_top_bar.dart index 0f909e0b9..052e01dae 100644 --- a/lib/new-ui/widgets/receive_page/receive_top_bar.dart +++ b/lib/new-ui/widgets/receive_page/receive_top_bar.dart @@ -16,6 +16,7 @@ class ModalTopBar extends StatelessWidget { this.bottomText, this.leadingWidget, this.trailingWidget, + this.titleLeadingWidget, this.leadingSemanticLabel, this.trailingSemanticLabel}) { if (leadingIcon != null && leadingWidget != null) { @@ -36,6 +37,7 @@ class ModalTopBar extends StatelessWidget { final Widget? trailingIcon; final Widget? leadingWidget; final Widget? trailingWidget; + final Widget? titleLeadingWidget; /// Accessible name for the leading chrome button. Required (and must be /// non-empty) whenever a [leadingIcon] is supplied, because the icon alone @@ -53,6 +55,51 @@ class ModalTopBar extends StatelessWidget { @override Widget build(BuildContext context) { final hasBottomText = bottomText != null && bottomText!.isNotEmpty; + final titleContent = Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + child: Semantics( + key: ValueKey(title), + header: title.isNotEmpty, + // Android reads the heading from headingLevel since the + // Flutter 3.41 engine; header: alone only covers iOS. + headingLevel: title.isNotEmpty ? 1 : null, + child: titleLeadingWidget == null + ? Text( + title, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontSize: hasBottomText ? 16 : 18, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + spacing: 12, + children: [ + ExcludeSemantics(child: titleLeadingWidget), + Flexible( + child: Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontSize: hasBottomText ? 16 : 18, + ), + ), + ), + ], + ), + ), + ), + if (hasBottomText) + Text( + bottomText!, + style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ], + ); return Padding( padding: padding ?? EdgeInsets.all(18), child: Stack( @@ -60,36 +107,14 @@ class ModalTopBar extends StatelessWidget { children: [ Positioned( top: hasBottomText ? -4 : 6, + left: titleLeadingWidget == null ? null : buttonSize + 8, + right: titleLeadingWidget == null ? null : buttonSize + 8, child: Row( mainAxisSize: MainAxisSize.max, mainAxisAlignment: MainAxisAlignment.center, spacing: 4, children: [ - Column( - children: [ - AnimatedSwitcher( - duration: const Duration(milliseconds: 200), - child: Semantics( - key: ValueKey(title), - header: title.isNotEmpty, - // Android reads the heading from headingLevel since the - // Flutter 3.41 engine; header: alone only covers iOS. - headingLevel: title.isNotEmpty ? 1 : null, - child: Text( - title, - style: TextStyle( - fontSize: hasBottomText ? 16 : 18, fontWeight: FontWeight.w600), - ), - ), - ), - if (hasBottomText) - Text( - bottomText!, - style: TextStyle( - fontSize: 12, color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ], - ), + if (titleLeadingWidget == null) titleContent else Expanded(child: titleContent), if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: Theme.of(context) diff --git a/lib/router.dart b/lib/router.dart index a1a185ce7..eecfe76e5 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -129,7 +129,6 @@ import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart"; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart'; import 'package:cake_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart'; -import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; import 'package:cake_wallet/view_model/hardware_wallet/trezor_connect_view_model.dart'; import 'package:cake_wallet/view_model/node_list/node_create_or_edit_view_model.dart'; import 'package:cake_wallet/view_model/wallet_groups_display_view_model.dart'; @@ -138,7 +137,6 @@ import 'package:cake_wallet/view_model/wallet_hardware_restore_view_model.dart'; import 'package:cake_wallet/view_model/wallet_new_vm.dart'; import 'package:cake_wallet/wallet_type_utils.dart'; import 'package:cake_wallet/wallet_types.g.dart'; -import 'package:cw_core/nano_account.dart'; import 'package:cw_core/node.dart'; import 'package:cw_core/transaction_info.dart'; import 'package:cw_core/unspent_coin_type.dart'; @@ -848,9 +846,8 @@ Route createRoute(RouteSettings settings) { case Routes.walletAccountsPage: final dashboardViewModel = settings.arguments as DashboardViewModel; - return handleRouteWithPlatformAwareness( - (_) => getIt.get(param1: dashboardViewModel), - ); + final page = getIt.get(param1: dashboardViewModel); + return handleRouteWithPlatformAwareness((_) => page); case Routes.managePowNodes: return MaterialPageRoute(builder: (_) => getIt.get(param1: true)); diff --git a/lib/src/screens/settings/privacy_page.dart b/lib/src/screens/settings/privacy_page.dart index 241202ff8..18856d08f 100644 --- a/lib/src/screens/settings/privacy_page.dart +++ b/lib/src/screens/settings/privacy_page.dart @@ -1,11 +1,8 @@ -import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_regular_row.dart'; import 'package:cake_wallet/entities/new_ui_entities/list_item/list_item_toggle.dart'; import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/new-ui/pages/coin_control_page.dart'; import 'package:cake_wallet/new-ui/widgets/modal_header.dart'; import 'package:cake_wallet/new-ui/widgets/modal_page_wrapper.dart'; import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; -import 'package:cake_wallet/routes.dart'; import 'package:cake_wallet/src/screens/base_page.dart'; import 'package:cake_wallet/src/widgets/new_list_row/new_list_section.dart'; import 'package:cake_wallet/view_model/settings/privacy_settings_view_model.dart'; @@ -75,29 +72,6 @@ class PrivacyPage extends BasePage { _privacySettingsViewModel.setUseLightning(val); }), ], - "": [ - if (_privacySettingsViewModel.hasSilentPaymentsScanning) - ListItemRegularRow( - iconPath: "assets/new-ui/settings_row_icons/silent-payments.svg", - keyValue: "silent_payments", - label: S.of(context).silent_payments, - onTap: () => - Navigator.of(context).pushNamed(Routes.silentPaymentsSettings)), - if (_privacySettingsViewModel.hasMWEB) - ListItemRegularRow( - iconPath: "assets/new-ui/settings_row_icons/mweb.svg", - keyValue: "mweb", - label: "MWEB", - onTap: () => Navigator.of(context).pushNamed(Routes.mwebSettings)), - if (_privacySettingsViewModel.hasCoinControl) - ListItemRegularRow( - iconPath: "assets/new-ui/settings_row_icons/coin-control.svg", - keyValue: "coin_control", - label: "Coin Control", - onTap: () => Navigator.of(context).pushNamed(Routes.unspentCoinsList, - arguments: - CoinControlPageArgs(canEdit: false, coinTypeToSpendFrom: null))), - ], }), ], ); diff --git a/lib/src/screens/settings/widgets/account_creation_modal.dart b/lib/src/screens/settings/widgets/account_creation_modal.dart index af0163fee..dd79fec4c 100644 --- a/lib/src/screens/settings/widgets/account_creation_modal.dart +++ b/lib/src/screens/settings/widgets/account_creation_modal.dart @@ -1,20 +1,16 @@ import "package:cake_wallet/core/execution_state.dart"; import "package:cake_wallet/generated/i18n.dart"; -import "package:cake_wallet/new-ui/widgets/modal_grab_handle.dart"; import "package:cake_wallet/new-ui/widgets/new_primary_button.dart"; import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/src/widgets/alert_with_one_action.dart"; import "package:cake_wallet/src/widgets/cake_image_widget.dart"; import "package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart"; import "package:cw_core/generate_name.dart"; import "package:flutter/material.dart"; import "package:flutter/services.dart"; -import "package:flutter_mobx/flutter_mobx.dart"; class AccountCreationModal extends StatefulWidget { - const AccountCreationModal({ - required this.viewModel, - super.key, - }); + const AccountCreationModal({required this.viewModel, super.key}); final WalletAccountEditOrCreateViewModel viewModel; @@ -26,14 +22,59 @@ class _AccountCreationModalState extends State { static const int maxAccountNameLength = 25; final TextEditingController _controller = TextEditingController(); + bool _loading = false; - @override - void initState() { - super.initState(); - _controller.text = widget.viewModel.label; - _controller.addListener(() { - widget.viewModel.label = _controller.text; - }); + bool get _canContinue => + _controller.text.trim().isNotEmpty && _controller.text.length <= maxAccountNameLength; + + Future _generateAccountName() async { + final generatedName = await generateName(); + if (!mounted) { + return; + } + setState(() => _controller.text = generatedName.length > maxAccountNameLength + ? generatedName.substring(0, maxAccountNameLength) + : generatedName); + } + + Future _save() async { + if (_loading || !_canContinue) { + return; + } + + setState(() => _loading = true); + widget.viewModel.label = _controller.text; + + late final String errorMessage; + try { + await widget.viewModel.save(); + if (!mounted) { + return; + } + + final state = widget.viewModel.state; + if (state is ExecutedSuccessfullyState) { + Navigator.of(context).pop(true); + return; + } + errorMessage = state is FailureState ? state.error : S.of(context).error_while_processing; + } catch (error) { + errorMessage = error.toString(); + } + + if (!mounted) { + return; + } + setState(() => _loading = false); + await showDialog( + context: context, + builder: (dialogContext) => AlertWithOneAction( + alertTitle: S.of(dialogContext).error, + alertContent: errorMessage, + buttonText: S.of(dialogContext).ok, + buttonAction: Navigator.of(dialogContext).pop, + ), + ); } @override @@ -42,105 +83,117 @@ class _AccountCreationModalState extends State { super.dispose(); } - Future _onSubmit() async { - if (widget.viewModel.state is IsExecutingState) return; - if (_controller.text.trim().isEmpty || _controller.text.length > maxAccountNameLength) return; - - await widget.viewModel.save(); - - if (!mounted) return; - - if (widget.viewModel.state is ExecutedSuccessfullyState) { - Navigator.of(context).pop(true); - } - } - @override - Widget build(BuildContext context) => Container( + Widget build(BuildContext context) => PopScope( + canPop: !_loading, + child: _buildContent(context), + ); + + Widget _buildContent(BuildContext context) => Container( decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: const BorderRadius.vertical(top: Radius.circular(30))), + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(30)), + ), child: SafeArea( top: false, child: Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom), child: Column( - mainAxisSize: MainAxisSize.min, children: [ - const ModalGrabHandle(), ModalTopBar( - title: widget.viewModel.isEdit - ? S.of(context).edit_account - : S.of(context).create_account, + title: S.of(context).create_account, + trailingIcon: const Icon(Icons.close), + trailingSemanticLabel: S.of(context).close, + onTrailingPressed: Navigator.of(context).maybePop, ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 18), - child: Column( - spacing: 50, - children: [ - const SizedBox(), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(16), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.fromLTRB(18, 32, 18, 24), + child: Column( + spacing: 24, + children: [ + const CakeImageWidget( + imageUrl: "assets/new-ui/account_education/create_account.svg", + width: 125, + height: 125, ), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - maxLength: maxAccountNameLength, - inputFormatters: [ - LengthLimitingTextInputFormatter(maxAccountNameLength), - ], - decoration: InputDecoration( - hintText: S.of(context).account_name, - counterText: "", - ), - ), - ), - Padding( - padding: const EdgeInsets.all(12), - child: GestureDetector( - onTap: () async { - final generated = await generateName(); - _controller.text = generated.length > maxAccountNameLength - ? generated.substring(0, maxAccountNameLength) - : generated; - }, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(5)), - child: CakeImageWidget( - imageUrl: "assets/new-ui/randomize.svg", - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.primary, BlendMode.srcIn), + Text( + S.of(context).account_creation_description, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + fontSize: 14, + ), + ), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + maxLength: maxAccountNameLength, + inputFormatters: [ + LengthLimitingTextInputFormatter(maxAccountNameLength) + ], + textInputAction: TextInputAction.done, + onChanged: (_) => setState(() {}), + decoration: InputDecoration( + hintText: S.of(context).account_name, + counterText: "", ), ), ), - ) - ], - ), - ), - ValueListenableBuilder( - valueListenable: _controller, - builder: (context, value, _) => Observer( - builder: (_) => NewPrimaryButton( - onPressed: _onSubmit, - text: S.of(context).continue_text, - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - disabled: value.text.trim().isEmpty || - value.text.length > maxAccountNameLength, - isLoading: widget.viewModel.state is IsExecutingState, + Padding( + padding: const EdgeInsets.all(12), + child: Semantics( + button: true, + label: S.of(context).generate_name, + onTap: _generateAccountName, + child: ExcludeSemantics( + child: GestureDetector( + onTap: _generateAccountName, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(5), + ), + child: CakeImageWidget( + imageUrl: "assets/new-ui/randomize.svg", + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ), + ), + ), + ), + ), + ), + ], ), ), - ), - const SizedBox(), - ], + ], + ), ), - ) + ), + Padding( + padding: const EdgeInsets.fromLTRB(18, 0, 18, 12), + child: SizedBox( + width: double.infinity, + child: NewPrimaryButton( + onPressed: _save, + text: S.of(context).continue_text, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + isLoading: _loading, + disabled: !_canContinue, + ), + ), + ), ], ), ), diff --git a/lib/store/settings_store.dart b/lib/store/settings_store.dart index c0945ab1c..db616393f 100644 --- a/lib/store/settings_store.dart +++ b/lib/store/settings_store.dart @@ -153,6 +153,7 @@ abstract class SettingsStoreBase with Store { required this.hasEnabledMwebBefore, required this.mwebNodeUri, required this.mwebAdDismissed, + required this.accountsHomePromoDismissed, required this.balanceHideCounter, required this.zcashMigrationModalViewed, required bool initialEnableAutomaticNodeSwitching, @@ -313,6 +314,9 @@ abstract class SettingsStoreBase with Store { reaction((_) => mwebAdDismissed, (val) => sharedPreferences.setBool(PreferencesKey.mwebAdDismissed, val)); + reaction((_) => accountsHomePromoDismissed, + (val) => sharedPreferences.setBool(PreferencesKey.accountsHomePromoDismissed, val)); + priority.observe((change) { final String? key; switch (change.key) { @@ -1105,6 +1109,9 @@ abstract class SettingsStoreBase with Store { @observable bool mwebAdDismissed; + @observable + bool accountsHomePromoDismissed; + @observable bool zcashMigrationModalViewed; @@ -1191,6 +1198,12 @@ abstract class SettingsStoreBase with Store { Future setShouldShowReceiveWarning(bool value) async => _sharedPreferences.setBool(PreferencesKey.shouldShowReceiveWarning, value); + bool isEducationDismissed(String educationId) => + _sharedPreferences.getBool(PreferencesKey.educationDismissed(educationId)) ?? false; + + Future dismissEducation(String educationId) => + _sharedPreferences.setBool(PreferencesKey.educationDismissed(educationId), true); + static Future load( {required bool isBitcoinBuyEnabled, FiatCurrency initialFiatCurrency = FiatCurrency.usd, @@ -1692,6 +1705,9 @@ abstract class SettingsStoreBase with Store { final mwebAdDismissed = await sharedPreferences.getBool(PreferencesKey.mwebAdDismissed) ?? false; + final accountsHomePromoDismissed = + sharedPreferences.getBool(PreferencesKey.accountsHomePromoDismissed) ?? false; + final balanceHideCounter = await sharedPreferences.getInt(PreferencesKey.balanceHideCounter) ?? 0; @@ -1815,6 +1831,7 @@ abstract class SettingsStoreBase with Store { shouldShowRepWarning: shouldShowRepWarning, initialBuiltinTor: builtinTor, mwebAdDismissed: mwebAdDismissed, + accountsHomePromoDismissed: accountsHomePromoDismissed, balanceHideCounter: balanceHideCounter, zcashMigrationModalViewed: zcashMigrationModalViewed ); diff --git a/lib/view_model/dashboard/balance_view_model.dart b/lib/view_model/dashboard/balance_view_model.dart index b39580096..d6b34c43a 100644 --- a/lib/view_model/dashboard/balance_view_model.dart +++ b/lib/view_model/dashboard/balance_view_model.dart @@ -11,6 +11,7 @@ import 'package:cake_wallet/evm/evm.dart'; import 'package:cake_wallet/solana/solana.dart'; import 'package:cake_wallet/tron/tron.dart'; import 'package:cake_wallet/zano/zano.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_amount_format.dart'; import 'package:cw_core/transaction_history.dart'; @@ -546,6 +547,21 @@ abstract class BalanceViewModelBase with Store { } } + String? accountFiatBalance(AccountListItem account) { + if (isFiatDisabled) { + return null; + } + + final fiat = settingsStore.fiatCurrency.title; + final value = savedDisplayMode == BalanceDisplayMode.hiddenBalance + ? "●●●●●" + : calculateFiatAmount( + price: price, + cryptoAmount: account.balance.toString(), + ).withLocalSeperator(settingsStore.languageCode); + return "$value $fiat"; + } + String _getFiatBalance({required double price, Money? cryptoAmount}) { if (cryptoAmount == null) { return '0.00'; diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 061a0d612..d95cc2b9c 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -9,6 +9,7 @@ import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_v import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; import 'package:cw_core/account.dart'; import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; +import "package:cw_core/balance_card_layout.dart"; import "package:cw_core/balance_card_style_settings.dart"; import 'package:cake_wallet/core/trade_monitor.dart'; import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart'; @@ -110,7 +111,8 @@ abstract class DashboardViewModelBase with Store { showDecredInfoCard = wallet.type == WalletType.decred && (sharedPreferences.getBool(PreferencesKey.showDecredInfoCard) ?? true); showSeedBackupReminder = wallet.walletInfo.showSeedBackupReminder; - isMultiAccountsEnabled = wallet.walletInfo.isMultiAccountsEnabled == true; + isMultiAccountsEnabled = + wallet.hasNativeAccounts || wallet.walletInfo.isMultiAccountsEnabled == true; name = wallet.name; type = wallet.type; isShowFirstYatIntroduction = false; @@ -426,72 +428,86 @@ abstract class DashboardViewModelBase with Store { } } + List get cardAccountIndices { + if (wallet.type == WalletType.monero) { + return monero!.getAccountList(wallet).accounts.map((account) => account.id).toList(); + } + if (wallet.type == WalletType.wownero) { + return wow.wownero!.getAccountList(wallet).accounts.map((account) => account.id).toList(); + } + if (wallet.type == WalletType.bitcoin) { + final accounts = accountListViewModel?.accounts; + return [ + if (accounts == null || accounts.isEmpty) 0 else ...accounts.map((account) => account.id), + -2, + ]; + } + return const [-1]; + } + @action Future loadCardDesigns() async { - final walletCardStyleSettings = - await BalanceCardStyleSettings.getAll(wallet.walletInfo.internalId); - final btcAccounts = - wallet.type == WalletType.bitcoin ? await wallet.walletInfo.getAccounts() : null; - final lightningCardIndex = btcAccounts?.length; + if (wallet.type == WalletType.bitcoin) { + await accountListViewModel?.reload(); + } + final accountIndices = cardAccountIndices; + final settings = await BalanceCardStyleSettings.getAll(wallet.walletInfo.internalId); + if (wallet.type != WalletType.bitcoin && accountIndices.contains(0) && + !settings.any((setting) => setting.accountIndex == 0)) { + final primarySettings = settings.where((setting) => setting.accountIndex == -1) + .firstOrNull?.copyWith(accountIndex: 0); + if (primarySettings != null) { + await primarySettings.insert(); + settings.add(primarySettings); + } + } + final layout = BalanceCardLayout.resolve( + accountIndices: accountIndices.where((index) => index != -2).toList(), + settings: settings, + ); - int numAccounts = 1; + cardDesigns + ..clear() + ..addAll(accountIndices.map((accountIndex) => CardDesign.fromStyleSettings( + settings.where((setting) => setting.accountIndex == accountIndex).firstOrNull, + accountIndex == -2 ? CryptoCurrency.btcln : wallet.currency, + ))); + cardOrder = { + for (int position = 0; position < layout.visible.length; position++) + position: layout.visible[position], + }.asObservable(); + + if (layout.needsRepair) { + await BalanceCardStyleSettings.setVisibleOrder(wallet.walletInfo.internalId, layout.orders); + } + if (isMultiAccountsEnabled && !lightningMode && layout.visible.isNotEmpty && + !layout.visible.contains(accountListViewModel?.selectedAccount?.id)) { + final visibleAccount = accountListViewModel?.accounts + .where((account) => account.id == layout.visible.last).firstOrNull; + if (visibleAccount != null) { + await accountListViewModel!.select(visibleAccount); + } + } + } + + int get currentCardAccountIndex { if (wallet.type == WalletType.monero) { - numAccounts = monero!.getAccountList(wallet).accounts.length; - } else if (wallet.type == WalletType.wownero) { - numAccounts = wow.wownero!.getAccountList(wallet).accounts.length; - } else if (wallet.type == WalletType.bitcoin) { - numAccounts = btcAccounts!.length + 1; // adding 1 for the lightning account + return monero!.getCurrentAccount(wallet).id; } - - final usesAccountIndices = wallet.type == WalletType.bitcoin || balanceViewModel.hasAccounts; - - cardDesigns.clear(); - final newOrder = {}; - - final orderableCount = (wallet.type == WalletType.bitcoin) ? numAccounts - 1 : numAccounts; - - for (int i = 0; i < numAccounts; i++) { - late final int index; - final isLightning = wallet.type == WalletType.bitcoin && i == lightningCardIndex; - if (isLightning) { - index = -2; - } else if (usesAccountIndices) { - index = i; - } else { - index = -1; - } - - final setting = walletCardStyleSettings.where((e) => e.accountIndex == index).firstOrNull ?? - (index == 0 && wallet.type != WalletType.bitcoin - ? walletCardStyleSettings.where((e) => e.accountIndex == -1).firstOrNull - : null); - - final curr = isLightning ? CryptoCurrency.btcln : wallet.currency; - - cardDesigns.add(CardDesign.fromStyleSettings(setting, curr)); - - if (isLightning) { - continue; - } - - if (setting?.cardOrder != null && - setting!.cardOrder >= 0 && - setting.cardOrder < orderableCount) { - newOrder[setting.cardOrder] = i; - } + if (wallet.type == WalletType.wownero) { + return wow.wownero!.getCurrentAccount(wallet).id; } - - // making sure ALL accounts have numbers, even the ones that existed before this feature was a thing - for (int i = 0; i < orderableCount; i++) { - if (!newOrder.containsValue(i)) { - int free = 0; - while (newOrder.containsKey(free)) { - free++; - } - newOrder[free] = i; - } + if (wallet.type == WalletType.bitcoin) { + return lightningMode ? -2 : wallet.walletInfo.currentAccountIndex; } - cardOrder = newOrder.asObservable(); + return -1; + } + + CardDesign get currentCardDesign { + final designIndex = cardAccountIndices.indexOf(currentCardAccountIndex); + return cardDesigns.isEmpty || designIndex >= cardDesigns.length + ? CardDesign.fromStyleSettings(null, wallet.currency) + : cardDesigns[designIndex == -1 ? 0 : designIndex]; } void _transactionDisposerCallback(int _) async { @@ -925,7 +941,9 @@ abstract class DashboardViewModelBase with Store { List get visibleAccounts { final all = accountListViewModel?.accounts; if (all == null || all.isEmpty) return const []; - if (isMultiAccountsEnabled) return all.toList(growable: false); + if (isMultiAccountsEnabled) { + return all.where((account) => cardOrder.containsValue(account.id)).toList(growable: false); + } final primary = all.where((a) => a.id == 0).firstOrNull ?? all.first; return [primary]; } @@ -1353,7 +1371,13 @@ abstract class DashboardViewModelBase with Store { // Lightning belongs to the primary account only if (lightningMode) { - unawaited(_selectPrimaryAccount()); + unawaited(_selectPrimaryAccount().then((_) async { + if (!lightningMode && isMultiAccountsEnabled) { + await loadCardDesigns(); + } + })); + } else { + unawaited(loadCardDesigns()); } } @@ -1382,7 +1406,8 @@ abstract class DashboardViewModelBase with Store { this.wallet = wallet; type = wallet.type; name = wallet.name; - isMultiAccountsEnabled = wallet.walletInfo.isMultiAccountsEnabled == true; + isMultiAccountsEnabled = + wallet.hasNativeAccounts || wallet.walletInfo.isMultiAccountsEnabled == true; _onAccountChangeReaction?.reaction.dispose(); _onAccountChangeReaction = null; diff --git a/lib/view_model/send/send_view_model.dart b/lib/view_model/send/send_view_model.dart index 28c80f4e9..63547db52 100644 --- a/lib/view_model/send/send_view_model.dart +++ b/lib/view_model/send/send_view_model.dart @@ -444,16 +444,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor @computed bool get hasCoinControl => - [ - WalletType.bitcoin, - WalletType.litecoin, - WalletType.monero, - WalletType.wownero, - WalletType.decred, - WalletType.bitcoinCash, - WalletType.dogecoin - ].contains(wallet.type) && - coinTypeToSpendFrom != UnspentCoinType.lightning; + wallet.hasCoinControl && coinTypeToSpendFrom != UnspentCoinType.lightning; @computed bool get hasFees => feesViewModel.hasFees && coinTypeToSpendFrom != UnspentCoinType.lightning; diff --git a/lib/view_model/settings/privacy_settings_view_model.dart b/lib/view_model/settings/privacy_settings_view_model.dart index 6eeabc0a6..64c6d3612 100644 --- a/lib/view_model/settings/privacy_settings_view_model.dart +++ b/lib/view_model/settings/privacy_settings_view_model.dart @@ -54,15 +54,7 @@ abstract class PrivacySettingsViewModelBase with Store { ].contains(_wallet.type); @computed - bool get hasCoinControl => [ - WalletType.bitcoin, - WalletType.litecoin, - WalletType.monero, - WalletType.wownero, - WalletType.decred, - WalletType.bitcoinCash, - WalletType.dogecoin - ].contains(_wallet.type); + bool get hasCoinControl => _wallet.hasCoinControl; bool get isMoneroWallet => _wallet.type == WalletType.monero; diff --git a/lib/view_model/wallet_account_list/account_list_item.dart b/lib/view_model/wallet_account_list/account_list_item.dart index 504ec72f8..a7fe2faa1 100644 --- a/lib/view_model/wallet_account_list/account_list_item.dart +++ b/lib/view_model/wallet_account_list/account_list_item.dart @@ -1,8 +1,17 @@ +import "package:cw_core/amount/money.dart"; + class AccountListItem { - AccountListItem({required this.label, required this.id, this.balance, this.isSelected = false}); + AccountListItem({ + required this.label, + required this.id, + required this.balance, + this.isSelected = false, + }); final String label; final int id; final bool isSelected; - final String? balance; + final Money balance; + + bool get isFunded => balance.sign > 0; } diff --git a/lib/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_list_view_model.dart b/lib/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_list_view_model.dart index e97b844e2..44f10b35d 100644 --- a/lib/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_list_view_model.dart +++ b/lib/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_list_view_model.dart @@ -1,24 +1,22 @@ -import 'dart:async'; +import "dart:async"; -import 'package:cake_wallet/bitcoin/bitcoin.dart'; -import 'package:cake_wallet/core/amount_parsing_proxy.dart'; -import 'package:cake_wallet/entities/balance_display_mode.dart'; -import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; -import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart'; -import 'package:collection/collection.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:cw_core/wallet_info.dart'; -import 'package:mobx/mobx.dart'; +import "package:cake_wallet/bitcoin/bitcoin.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart"; +import "package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart"; +import "package:collection/collection.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:mobx/mobx.dart"; -part 'bitcoin_account_list_view_model.g.dart'; +part "bitcoin_account_list_view_model.g.dart"; class BitcoinAccountListViewModel = BitcoinAccountListViewModelBase with _$BitcoinAccountListViewModel; abstract class BitcoinAccountListViewModelBase with Store implements WalletAccountListViewModel { - BitcoinAccountListViewModelBase(this._wallet, this.settingsStore) { + BitcoinAccountListViewModelBase(this._wallet) { unawaited(_loadAccounts()); reaction( @@ -26,14 +24,8 @@ abstract class BitcoinAccountListViewModelBase with Store implements WalletAccou (snapshot) => unawaited(_refreshBalances(snapshot)), equals: const MapEquality().equals, ); - - reaction( - (_) => settingsStore.balanceDisplayMode, - (_) => unawaited(_recomputeBalanceStrings()), - ); } - final SettingsStore settingsStore; final WalletBase _wallet; List? _cachedStoredAccounts; @@ -71,14 +63,6 @@ abstract class BitcoinAccountListViewModelBase with Store implements WalletAccou return _runLoad(() async => cached); } - - @action - Future _recomputeBalanceStrings() { - final cached = _cachedStoredAccounts; - if (cached == null) return _loadAccounts(); - return _runLoad(() async => cached); - } - Future _runLoad(Future> Function() fetch) { final inProgress = _loadAccountsFuture; if (inProgress != null) { @@ -99,7 +83,7 @@ abstract class BitcoinAccountListViewModelBase with Store implements WalletAccou .map((account) => AccountListItem( id: account.accountIndex, label: account.label, - balance: _balanceForAccount(account.accountIndex), + balance: _fullBalance(account.accountIndex), isSelected: account.accountIndex == currentAccountIndex, )) .toList(); @@ -115,16 +99,12 @@ abstract class BitcoinAccountListViewModelBase with Store implements WalletAccou }); } + Money _fullBalance(int accountIndex) { + final balance = bitcoin!.balanceForAccount(_wallet, accountIndex); + return balance.confirmed + balance.unconfirmed; + } + @override @action Future reload() => _loadAccounts(); - - String _balanceForAccount(int accountIndex) { - if (settingsStore.balanceDisplayMode == BalanceDisplayMode.hiddenBalance) { - return '●●●●●●'; - } - final balance = bitcoin!.balanceForAccount(_wallet, accountIndex); - - return AmountParsingProxy(settingsStore.displayAmountsInSatoshi).asDisplayString(balance.available); - } } diff --git a/lib/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart b/lib/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart index da097e5e4..db5b23914 100644 --- a/lib/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart +++ b/lib/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart @@ -2,6 +2,7 @@ import 'dart:math'; import 'package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart'; import 'package:cake_wallet/wownero/wownero.dart'; +import 'package:cw_core/balance_card_layout.dart'; import 'package:cw_core/balance_card_style_settings.dart'; import 'package:cw_core/card_design.dart'; import 'package:cw_core/wallet_base.dart'; @@ -36,7 +37,7 @@ abstract class MoneroAccountEditOrCreateViewModelBase @observable String label; - final MoneroAccountList _moneroAccountList; + final MoneroAccountList? _moneroAccountList; final WowneroAccountList? _wowneroAccountList; final AccountListItem? _accountListItem; final WalletBase _wallet; @@ -51,13 +52,22 @@ abstract class MoneroAccountEditOrCreateViewModelBase return ret.isNotEmpty ? ret : CardDesign.allGradients; } - Future _saveRandomCardDesign() async { + Future _saveRandomCardDesign(int accountIndex) async { final gradients = await _getUsableCardGradients(); + final accountIndices = _wallet.type == WalletType.monero + ? _moneroAccountList!.accounts.map((account) => account.id).toList() + : _wowneroAccountList!.accounts.map((account) => account.id).toList(); + + final layout = BalanceCardLayout.resolve( + accountIndices: accountIndices, + settings: await BalanceCardStyleSettings.getAll(_wallet.walletInfo.internalId), + ); + await BalanceCardStyleSettings.fromCardDesign( walletInfoId: _wallet.walletInfo.internalId, - accountIndex: _moneroAccountList.accounts.length, - cardOrder: _moneroAccountList.accounts.length, + accountIndex: accountIndex, + cardOrder: layout.visible.length, design: CardDesign.specialDesignsForCurrencies[_wallet.currency]! .withGradient(gradients[Random().nextInt(gradients.length)])) .insert(); @@ -67,7 +77,11 @@ abstract class MoneroAccountEditOrCreateViewModelBase try { state = IsExecutingState(); - if (!isEdit) await _saveRandomCardDesign(); + if (!isEdit) { + await _saveRandomCardDesign(_wallet.type == WalletType.monero + ? _moneroAccountList!.accounts.length + : _wowneroAccountList!.accounts.length); + } if (_wallet.type == WalletType.monero) { await saveMonero(); @@ -84,13 +98,13 @@ abstract class MoneroAccountEditOrCreateViewModelBase Future saveMonero() async { if (_accountListItem != null) { - await _moneroAccountList.setLabelAccount( + await _moneroAccountList!.setLabelAccount( _wallet, accountIndex: _accountListItem.id, label: label, ); } else { - await _moneroAccountList.addAccount( + await _moneroAccountList!.addAccount( _wallet, label: label, ); diff --git a/lib/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart b/lib/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart index 2f04be7f8..a8665aae2 100644 --- a/lib/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart +++ b/lib/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart @@ -1,22 +1,18 @@ -import 'package:cake_wallet/entities/balance_display_mode.dart'; -import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart'; -import 'package:cake_wallet/wownero/wownero.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:mobx/mobx.dart'; -import 'package:cw_core/wallet_base.dart'; -import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; -import 'package:cake_wallet/monero/monero.dart'; +import "package:cake_wallet/monero/monero.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart"; +import "package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart"; +import "package:cake_wallet/wownero/wownero.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:mobx/mobx.dart"; -part 'monero_account_list_view_model.g.dart'; +part "monero_account_list_view_model.g.dart"; class MoneroAccountListViewModel = MoneroAccountListViewModelBase with _$MoneroAccountListViewModel; abstract class MoneroAccountListViewModelBase with Store implements WalletAccountListViewModel { - MoneroAccountListViewModelBase(this._wallet, this.settingsStore); - - final SettingsStore settingsStore; + MoneroAccountListViewModelBase(this._wallet); @override AccountListItem? get selectedAccount => selected; @@ -25,7 +21,6 @@ abstract class MoneroAccountListViewModelBase with Store implements WalletAccoun @computed List get accounts { - final hideBalance = settingsStore.balanceDisplayMode == BalanceDisplayMode.hiddenBalance; if (_wallet.type == WalletType.monero) { return monero! .getAccountList(_wallet) @@ -33,7 +28,7 @@ abstract class MoneroAccountListViewModelBase with Store implements WalletAccoun .map((acc) => AccountListItem( label: acc.label, id: acc.id, - balance: hideBalance ? '●●●●●●' : acc.balance, + balance: monero!.getAccountFullBalance(acc.id), isSelected: acc.id == monero!.getCurrentAccount(_wallet).id)) .toList(); } @@ -45,19 +40,16 @@ abstract class MoneroAccountListViewModelBase with Store implements WalletAccoun .map((acc) => AccountListItem( label: acc.label, id: acc.id, - balance: hideBalance ? '●●●●●●' : acc.balance, + balance: wownero!.getAccountFullBalance(acc.id), isSelected: acc.id == wownero!.getCurrentAccount(_wallet).id)) .toList(); } - throw Exception('Unexpected wallet type: ${_wallet.type} for monero'); + throw Exception("Unexpected wallet type: ${_wallet.type} for monero"); } @computed - AccountListItem get selected { - final currentId = monero!.getCurrentAccount(_wallet).id; - return accounts.firstWhere((item) => item.id == currentId); - } + AccountListItem get selected => accounts.firstWhere((item) => item.isSelected); final WalletBase _wallet; @@ -71,7 +63,7 @@ abstract class MoneroAccountListViewModelBase with Store implements WalletAccoun _wallet, item.id, item.label, - item.balance, + item.balance.toString(), ); } @@ -80,7 +72,7 @@ abstract class MoneroAccountListViewModelBase with Store implements WalletAccoun _wallet, item.id, item.label, - item.balance, + item.balance.toString(), ); } } diff --git a/lib/view_model/wallet_account_list/wallet_account_list_view_model.dart b/lib/view_model/wallet_account_list/wallet_account_list_view_model.dart index 8d8824b98..68ee5bf53 100644 --- a/lib/view_model/wallet_account_list/wallet_account_list_view_model.dart +++ b/lib/view_model/wallet_account_list/wallet_account_list_view_model.dart @@ -1,5 +1,5 @@ -import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; -import 'package:cw_core/crypto_currency.dart'; +import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart"; +import "package:cw_core/crypto_currency.dart"; abstract class WalletAccountListViewModel { List get accounts; diff --git a/lib/wownero/cw_wownero.dart b/lib/wownero/cw_wownero.dart index cacce940c..174c641b0 100644 --- a/lib/wownero/cw_wownero.dart +++ b/lib/wownero/cw_wownero.dart @@ -136,6 +136,12 @@ class CWWownero extends Wownero { @override WowneroAccountList getAccountList(Object wallet) => CWWowneroAccountList(wallet); + @override + Money getAccountFullBalance(int accountIndex) => Money.fromInt( + wownero_wallet_api.getFullBalance(accountIndex: accountIndex), + CryptoCurrency.wow, + ); + @override WowneroSubaddressList getSubaddressList(Object wallet) => CWWowneroSubaddressList(wallet); diff --git a/pubspec_base.yaml b/pubspec_base.yaml index 71bf305a5..2409e3877 100644 --- a/pubspec_base.yaml +++ b/pubspec_base.yaml @@ -292,8 +292,10 @@ flutter: - assets/faq/ - assets/animation/ - assets/new-ui/ + - assets/new-ui/account_education/ - assets/new-ui/icons/ - assets/new-ui/hero/ + - assets/new-ui/network_icons/ - assets/new-ui/balance_card_icons/ - assets/new-ui/balance_card_backgrounds/ - assets/new-ui/settings_row_icons/ diff --git a/res/pictures/account_education/account_order.svg b/res/pictures/account_education/account_order.svg new file mode 100644 index 000000000..e1911c21b --- /dev/null +++ b/res/pictures/account_education/account_order.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/res/pictures/account_education/accounts_overview.svg b/res/pictures/account_education/accounts_overview.svg new file mode 100644 index 000000000..b8320d5be --- /dev/null +++ b/res/pictures/account_education/accounts_overview.svg @@ -0,0 +1,197 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/account_education/archive_warning.svg b/res/pictures/account_education/archive_warning.svg new file mode 100644 index 000000000..7fc0cb51d --- /dev/null +++ b/res/pictures/account_education/archive_warning.svg @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/res/pictures/account_education/create_account.svg b/res/pictures/account_education/create_account.svg new file mode 100644 index 000000000..2a878d7fa --- /dev/null +++ b/res/pictures/account_education/create_account.svg @@ -0,0 +1,34 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/account_education/ordered_account.svg b/res/pictures/account_education/ordered_account.svg new file mode 100644 index 000000000..aa7382bf3 --- /dev/null +++ b/res/pictures/account_education/ordered_account.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/account_education/recovery_shield.svg b/res/pictures/account_education/recovery_shield.svg new file mode 100644 index 000000000..8bae22dd3 --- /dev/null +++ b/res/pictures/account_education/recovery_shield.svg @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/archived.svg b/res/pictures/archived.svg new file mode 100644 index 000000000..76579a25f --- /dev/null +++ b/res/pictures/archived.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/pictures/hide.svg b/res/pictures/hide.svg new file mode 100644 index 000000000..1e15e2f9b --- /dev/null +++ b/res/pictures/hide.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/pictures/network_icons/arbitrum.svg b/res/pictures/network_icons/arbitrum.svg new file mode 100644 index 000000000..162e7fe97 --- /dev/null +++ b/res/pictures/network_icons/arbitrum.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/base.svg b/res/pictures/network_icons/base.svg new file mode 100644 index 000000000..9e6d550bd --- /dev/null +++ b/res/pictures/network_icons/base.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/bitcoin.svg b/res/pictures/network_icons/bitcoin.svg new file mode 100644 index 000000000..79c74cf05 --- /dev/null +++ b/res/pictures/network_icons/bitcoin.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/bitcoincash.svg b/res/pictures/network_icons/bitcoincash.svg new file mode 100644 index 000000000..0c5dcd128 --- /dev/null +++ b/res/pictures/network_icons/bitcoincash.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/bsc.svg b/res/pictures/network_icons/bsc.svg new file mode 100644 index 000000000..46bfe4c52 --- /dev/null +++ b/res/pictures/network_icons/bsc.svg @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/decred.svg b/res/pictures/network_icons/decred.svg new file mode 100644 index 000000000..c34c9a4c2 --- /dev/null +++ b/res/pictures/network_icons/decred.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/dogecoin.svg b/res/pictures/network_icons/dogecoin.svg new file mode 100644 index 000000000..97c624aa9 --- /dev/null +++ b/res/pictures/network_icons/dogecoin.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/ethereum.svg b/res/pictures/network_icons/ethereum.svg new file mode 100644 index 000000000..c6ab86b7f --- /dev/null +++ b/res/pictures/network_icons/ethereum.svg @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/litecoin.svg b/res/pictures/network_icons/litecoin.svg new file mode 100644 index 000000000..8d12caafa --- /dev/null +++ b/res/pictures/network_icons/litecoin.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/monero.svg b/res/pictures/network_icons/monero.svg new file mode 100644 index 000000000..9fd6fecac --- /dev/null +++ b/res/pictures/network_icons/monero.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/nano.svg b/res/pictures/network_icons/nano.svg new file mode 100644 index 000000000..9a7c94606 --- /dev/null +++ b/res/pictures/network_icons/nano.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/polygon.svg b/res/pictures/network_icons/polygon.svg new file mode 100644 index 000000000..2a7b9d858 --- /dev/null +++ b/res/pictures/network_icons/polygon.svg @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/solana.svg b/res/pictures/network_icons/solana.svg new file mode 100644 index 000000000..c9854533c --- /dev/null +++ b/res/pictures/network_icons/solana.svg @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/tron.svg b/res/pictures/network_icons/tron.svg new file mode 100644 index 000000000..de8346730 --- /dev/null +++ b/res/pictures/network_icons/tron.svg @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/zano.svg b/res/pictures/network_icons/zano.svg new file mode 100644 index 000000000..f2ab591aa --- /dev/null +++ b/res/pictures/network_icons/zano.svg @@ -0,0 +1,61 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/network_icons/zcash.svg b/res/pictures/network_icons/zcash.svg new file mode 100644 index 000000000..ecc4167da --- /dev/null +++ b/res/pictures/network_icons/zcash.svg @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/res/pictures/restore.svg b/res/pictures/restore.svg new file mode 100644 index 000000000..5dfbfb8db --- /dev/null +++ b/res/pictures/restore.svg @@ -0,0 +1,3 @@ + + + diff --git a/res/pictures/wallet-setting.svg b/res/pictures/wallet-setting.svg index aa603df83..45f030f9a 100644 --- a/res/pictures/wallet-setting.svg +++ b/res/pictures/wallet-setting.svg @@ -1,3 +1,10 @@ - + + + + + + + + diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index e551b19d0..279badc28 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -560,6 +560,7 @@ "haven_app": "Haven من Cake Wallet", "haven_app_wallet_text": "محفظة رائعة لـ Haven", "help": "مساعدة", + "hidden_accounts": "الحسابات المخفية", "hidden_addresses": "العناوين المخفية", "hidden_balance": "الرصيد المخفي", "hide": "إخفاء", @@ -735,6 +736,8 @@ "no_buy_providers": "لم يتم العثور على موفري خدمة ${curr}.", "no_cards_found": "لم يتم العثور على أي بطاقات", "no_extra_detail": "لا تتوفر تفاصيل إضافية", + "no_hidden_accounts": "لا توجد حسابات مخفية", + "no_hidden_accounts_desc": "يمكنك إخفاء الحسابات من شاشة التخصيص.", "no_id_needed": "لا حاجة لإثبات الهوية!", "no_id_required": "لا حاجة لإثبات الهوية. اشحن وأنفق في أي مكان", "no_providers_available": "لا يوجد مزوّدون متاحون", diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index b29f2ac68..b9967b79e 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -560,6 +560,7 @@ "haven_app": "Haven от Cake Wallet", "haven_app_wallet_text": "Страхотен портфейл за Haven", "help": "помощ", + "hidden_accounts": "Скрити сметки", "hidden_addresses": "Скрити адреси", "hidden_balance": "Скрит баланс", "hide": "Скрий", @@ -735,6 +736,8 @@ "no_buy_providers": "Няма намерени доставчици за ${curr}.", "no_cards_found": "Няма намерени карти", "no_extra_detail": "Няма налични допълнителни подробности", + "no_hidden_accounts": "Няма скрити акаунти", + "no_hidden_accounts_desc": "Можете да скриете акаунти от екрана за персонализиране.", "no_id_needed": "Не е нужен документ за самоличност!", "no_id_required": "Не се изисква документ за самоличност. Зареждайте и харчете навсякъде", "no_providers_available": "Няма налични доставчици", diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index 845d1d43d..a8b5d7443 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -560,6 +560,7 @@ "haven_app": "Haven od Cake Walletu", "haven_app_wallet_text": "Skvělá peněženka pro Haven", "help": "pomoc", + "hidden_accounts": "Skryté účty", "hidden_addresses": "Skryté adresy", "hidden_balance": "Skrytý zůstatek", "hide": "Skrýt", @@ -735,6 +736,8 @@ "no_buy_providers": "Pro ${curr} nebyli nalezeni žádní poskytovatelé.", "no_cards_found": "Nebyly nalezeny žádné karty", "no_extra_detail": "Nejsou k dispozici žádné další podrobnosti", + "no_hidden_accounts": "Žádné skryté účty", + "no_hidden_accounts_desc": "Účty můžete skrýt na obrazovce přizpůsobení.", "no_id_needed": "Není potřeba žádné ID!", "no_id_required": "Není vyžadován žádný doklad totožnosti. Dobijte si a utrácejte kdekoli", "no_providers_available": "Nejsou k dispozici žádní poskytovatelé", diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 6a8dd526a..edc9ad0ab 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -562,6 +562,7 @@ "haven_app": "Haven von Cake Wallet", "haven_app_wallet_text": "Eine großartige Wallet für Haven", "help": "Hilfe", + "hidden_accounts": "Versteckte Konten", "hidden_addresses": "Versteckte Adressen", "hidden_balance": "Versteckter Kontostand", "hide": "Ausblenden", @@ -737,6 +738,8 @@ "no_buy_providers": "Für ${curr} wurden keine Anbieter gefunden.", "no_cards_found": "Keine Karten gefunden", "no_extra_detail": "Keine zusätzlichen Details verfügbar", + "no_hidden_accounts": "Keine versteckten Konten", + "no_hidden_accounts_desc": "Sie können Konten auf dem Anpassungsbildschirm ausblenden.", "no_id_needed": "Keine ID erforderlich!", "no_id_required": "Keine ID erforderlich. Aufladen und überall ausgeben", "no_providers_available": "Keine Anbieter verfügbar", diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 8daf7b34f..01e2569b7 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -4,9 +4,27 @@ "about_litecoin_privacy": "About Litecoin Privacy", "accent_color": "Accent color", "account": "Account", + "account_creation_description": "Accounts let you organize your funds into different compartments without needing to create a separate wallet", "account_customizer_desc": "Drag and drop cards to organize accounts.", "account_name": "Account name", "accounts": "Accounts", + "accounts_education_archive_description": "If you deposit funds to an account, they will still be there even if you archive it", + "accounts_education_archive_title": "Accounts cannot be deleted, only archived", + "accounts_education_order_description": "The order of creation is shown alongside the account name, in the Accounts menu", + "accounts_education_order_highlight": "Accounts", + "accounts_education_order_restore_description": "This is useful to know if you want to restore your accounts, both in Cake Wallet or elsewhere", + "accounts_education_order_title": "Accounts are created in order, and they are restored in the same order", + "accounts_education_organize_description": "You can create one for your regular spending, another for your savings... all accessible from the same homescreen", + "accounts_education_organize_title": "Accounts let you organize your funds by acting as separate pockets within one wallet", + "accounts_education_progress": "Page ${current} of ${total}", + "accounts_education_recovery_description": "This saves you from the hassle of creating and dealing with multiple separate wallets", + "accounts_education_recovery_highlight": "Recovery Phrase", + "accounts_education_recovery_title": "Accounts belong together in one wallet, they can all be restored from just one Recovery Phrase", + "accounts_education_understand_continue": "I understand. Continue", + "accounts_help": "Learn about accounts", + "accounts_home_promo_description": "Manage all your assets in a unified interface", + "accounts_home_promo_title": "Accounts for ${walletName} are here!", + "accounts_onchain": "Accounts (Onchain)", "accounts_subaddresses": "Accounts and subaddresses", "activate": "Activate", "active": "Active", @@ -80,6 +98,16 @@ "approve_request": "Approve Request", "approve_tokens": "Approve tokens", "apps": "Apps", + "archive_account": "Archive Account", + "archive_account_confirmation_title": "Are you sure you want to Archive this account?", + "archive_account_empty_disclaimer": "This action won’t delete the account or its past activity, but only hide it inside Cake Wallet", + "archive_account_funded_disclaimer": "Archiving an account does not delete any funds or activity. You can reverse this action", + "archive_account_funds_title": "This account has the following funds:", + "archive_account_move_funds": "Before proceeding, it is recommended you move them to an account you will use", + "archive_account_restore_hint": "You can reverse this action from Accounts settings", + "archive_account_reversible": "This change can be reverted", + "archive_account_title": "Archive Account", + "archived_accounts": "Archived Accounts", "arbiscan_history": "ArbiScan History", "are_you_sure_exit": "Are you sure you want to exit?", "arrive_in_this_address": "${currency} ${tag}will arrive in this address", @@ -236,6 +264,7 @@ "coin_control_desc": "Filter outputs for this transaction. The changes you make here will only reflect on the current transaction.", "coin_control_desc_no_edit": "Freeze outputs if needed. The changes will be persistent across all transactions. You can also filter outputs for one transaction on the Send or Swap screens.", "coin_control_load_failed": "Failed to load unspent coins", + "coin_control_settings": "Coin Control", "cold_or_recover_wallet": "Add an air-gapped cold wallet or recover a paper wallet.", "collection_address": "Collection Address", "collection_description": "Collection Description", @@ -419,6 +448,7 @@ "edit_current": "Edit Current", "edit_node": "Edit Node", "edit_token": "Edit token", + "empty_accounts": "Empty Accounts", "electrum_address_disclaimer": "We generate new addresses each time you use one, but previous addresses continue to work", "email_address": "Email Address", "enable": "Enable", @@ -542,6 +572,8 @@ "from": "From", "frozen": "Frozen", "frozen_balance": "Frozen Balance", + "funded_accounts": "Funded accounts", + "funded_accounts_description": "Unarchive an account to access its funds", "full_balance": "Full Balance", "gains": "Gains", "gallery": "Gallery", @@ -569,6 +601,7 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Awesome wallet for Haven", "help": "help", + "hidden_accounts": "Hidden Accounts", "hidden_addresses": "Hidden Addresses", "hidden_balance": "Hidden Balance", "hide": "Hide", @@ -744,9 +777,13 @@ "nft_some_not_loaded": "Some NFTs could not be loaded", "nfts": "NFTs", "no": "No", + "no_archived_accounts": "No Archived Accounts", + "no_archived_accounts_description": "When you Archive an Account, it will show up here", "no_buy_providers": "No providers found for ${curr}.", "no_cards_found": "No cards found", "no_extra_detail": "No extra details available", + "no_hidden_accounts": "No hidden accounts", + "no_hidden_accounts_desc": "You can hide accounts from the customization screen.", "no_id_needed": "No ID needed!", "no_id_required": "No ID required. Top up and spend anywhere", "no_providers_available": "No providers available", @@ -1063,6 +1100,7 @@ "seedtype_alert_content": "Sharing a recovery phrase with other wallets is only possible with the BIP39 type.", "seedtype_alert_title": "Recovery phrase type alert", "select_a_wallet": "Select a wallet", + "select_account_to_unarchive": "Select an Account to Unarchive it", "select_address_to_copy": "Select Address to Copy", "select_all": "Select all", "select_asset": "Select Asset", @@ -1419,6 +1457,9 @@ "tx_wrong_balance_with_amount_exception": "You do not have enough ${currency} to send the total amount of ${amount}", "tx_zero_fee_exception": "Cannot send transaction with 0 fee. Try increasing the rate or checking your connection for latest estimates.", "type": "Type", + "unarchive_account_description": "This account will show up again on your wallet", + "unarchive_account_funds_description": "This account will show up again on your wallet, letting you access your funds", + "unarchive_account_title": "Unarchive this account?", "unavailable_balance": "Unavailable balance", "unavailable_balance_description": "Unavailable Balance: This total includes funds that are locked in pending transactions and those you have actively frozen in your Coin Control settings. Locked balances will become available once their respective transactions are completed, while frozen balances remain inaccessible for transactions until you decide to unfreeze them.", "unconfirmed": "Unconfirmed Balance", @@ -1426,6 +1467,7 @@ "unhide_address": "Unhide address", "unified_view_key": "Unified View Key", "unknown_error": "Unknown error", + "unnamed_account": "Unnamed Account", "unlock": "Unlock", "unmask": "Unmask", "unmatched_currencies": "Your current wallet's currency does not match that of the scanned QR", diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index ea6decadd..cd29478dd 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -560,6 +560,7 @@ "haven_app": "Haven de Cake Wallet", "haven_app_wallet_text": "Excelente cartera para Haven", "help": "ayuda", + "hidden_accounts": "Cuentas ocultas", "hidden_addresses": "Direcciones ocultas", "hidden_balance": "Saldo oculto", "hide": "Ocultar", @@ -735,6 +736,8 @@ "no_buy_providers": "No se encontraron proveedores para ${curr}.", "no_cards_found": "No se encontraron tarjetas", "no_extra_detail": "No hay detalles adicionales disponibles", + "no_hidden_accounts": "Sin cuentas ocultas", + "no_hidden_accounts_desc": "Puede ocultar cuentas desde la pantalla de personalización.", "no_id_needed": "¡No se necesita identificación!", "no_id_required": "No se requiere identificación. Recarga y gasta en cualquier lugar", "no_providers_available": "No hay proveedores disponibles", diff --git a/res/values/strings_fa.arb b/res/values/strings_fa.arb index cac2a0319..ba028c7b9 100644 --- a/res/values/strings_fa.arb +++ b/res/values/strings_fa.arb @@ -560,6 +560,7 @@ "haven_app": "Haven توسط Cake Wallet", "haven_app_wallet_text": "کیف پول فوق‌العاده برای Haven", "help": "راهنما", + "hidden_accounts": "حساب های مخفی", "hidden_addresses": "آدرس‌های پنهان", "hidden_balance": "موجودی پنهان", "hide": "پنهان کردن", @@ -735,6 +736,8 @@ "no_buy_providers": "هیچ ارائه دهنده ای برای ${curr} یافت نشد.", "no_cards_found": "هیچ کارتی پیدا نشد", "no_extra_detail": "جزئیات بیشتری در دسترس نیست", + "no_hidden_accounts": "بدون حساب های مخفی", + "no_hidden_accounts_desc": "می توانید حساب ها را از صفحه سفارشی سازی مخفی کنید.", "no_id_needed": "بدون نیاز به احراز هویت!", "no_id_required": "بدون نیاز به مدرک شناسایی. شارژ کنید و همه‌جا خرج کنید", "no_providers_available": "هیچ ارائه‌دهنده‌ای در دسترس نیست", diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index 031c1df8f..555a3456e 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -560,6 +560,7 @@ "haven_app": "Haven par Cake Wallet", "haven_app_wallet_text": "Excellent portefeuille pour Haven", "help": "aide", + "hidden_accounts": "Comptes cachés", "hidden_addresses": "Adresses masquées", "hidden_balance": "Solde masqué", "hide": "Masquer", @@ -735,6 +736,8 @@ "no_buy_providers": "Aucun fournisseur trouvé pour ${curr}.", "no_cards_found": "Aucune carte trouvée", "no_extra_detail": "Aucun détail supplémentaire disponible", + "no_hidden_accounts": "Pas de comptes cachés", + "no_hidden_accounts_desc": "Vous pouvez masquer des comptes depuis l'écran de personnalisation.", "no_id_needed": "Aucune pièce d’identité requise !", "no_id_required": "Aucune pièce d’identité requise. Rechargez et dépensez partout", "no_providers_available": "Aucun fournisseur disponible", diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index ecd8851f4..504aca1a7 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -560,6 +560,7 @@ "haven_app": "Haven na Cake Wallet", "haven_app_wallet_text": "Kyakkyawan walat don Haven", "help": "Taimako", + "hidden_accounts": "Hidden Accounts", "hidden_addresses": "Adiresoshin da aka ɓoye", "hidden_balance": "Ma’aunin da aka ɓoye", "hide": "Boye", @@ -735,6 +736,8 @@ "no_buy_providers": "Ba a sami masu samar da ${curr} ba.", "no_cards_found": "Ba a sami katuna ba", "no_extra_detail": "Babu ƙarin bayani", + "no_hidden_accounts": "Babu boye asusu", + "no_hidden_accounts_desc": "Kuna iya ɓoye asusu daga allon keɓancewa.", "no_id_needed": "Ba a buƙatar ID!", "no_id_required": "Ba a buƙatar ID. Yi top up kuma ka kashe a ko’ina", "no_providers_available": "Babu masu ba da sabis", diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index 55370866c..9c5aa75d7 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -560,6 +560,7 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Haven के लिए शानदार वॉलेट", "help": "मदद", + "hidden_accounts": "छिपे हुए खाते", "hidden_addresses": "छिपे हुए पते", "hidden_balance": "छिपा हुआ बैलेंस", "hide": "छिपाएँ", @@ -735,6 +736,8 @@ "no_buy_providers": "${curr} के लिए कोई प्रदाता नहीं मिला।", "no_cards_found": "कोई कार्ड नहीं मिले", "no_extra_detail": "कोई अतिरिक्त विवरण उपलब्ध नहीं है", + "no_hidden_accounts": "कोई छिपा हुआ खाता नहीं", + "no_hidden_accounts_desc": "आप अनुकूलन स्क्रीन से खाते छिपा सकते हैं.", "no_id_needed": "कोई ID नहीं चाहिए!", "no_id_required": "कोई आईडी आवश्यक नहीं। टॉप अप करें और कहीं भी खर्च करें", "no_providers_available": "कोई प्रदाता उपलब्ध नहीं है", diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index d229d6f20..5afa85e49 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -560,6 +560,7 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Odličan novčanik za Haven", "help": "pomoć", + "hidden_accounts": "Skriveni računi", "hidden_addresses": "Skrivene adrese", "hidden_balance": "Skriveno stanje", "hide": "Sakrij", @@ -735,6 +736,8 @@ "no_buy_providers": "Nije pronađen nijedan pružatelj za ${curr}.", "no_cards_found": "Nisu pronađene kartice", "no_extra_detail": "Nema dostupnih dodatnih detalja", + "no_hidden_accounts": "Nema skrivenih računa", + "no_hidden_accounts_desc": "Možete sakriti račune sa zaslona za prilagodbu.", "no_id_needed": "ID nije potreban!", "no_id_required": "Nije potrebna osobna iskaznica. Nadopunite i trošite bilo gdje", "no_providers_available": "Nema dostupnih pružatelja", diff --git a/res/values/strings_hy.arb b/res/values/strings_hy.arb index 3d76ae42a..e2df499db 100644 --- a/res/values/strings_hy.arb +++ b/res/values/strings_hy.arb @@ -560,6 +560,7 @@ "haven_app": "Haven՝ Cake Wallet-ից", "haven_app_wallet_text": "Հիանալի դրամապանակ Haven-ի համար", "help": "Օգնություն", + "hidden_accounts": "Թաքնված հաշիվներ", "hidden_addresses": "Թաքնված հասցեներ", "hidden_balance": "Թաքցված մնացորդ", "hide": "Թաքցնել", @@ -735,6 +736,8 @@ "no_buy_providers": "${curr}-ի մատակարարներ չեն գտնվել:", "no_cards_found": "Քարտեր չեն գտնվել", "no_extra_detail": "Լրացուցիչ մանրամասներ հասանելի չեն", + "no_hidden_accounts": "Թաքնված հաշիվներ չկան", + "no_hidden_accounts_desc": "Դուք կարող եք թաքցնել հաշիվները հարմարեցման էկրանից:", "no_id_needed": "ID չի պահանջվում!", "no_id_required": "Անձը հաստատող փաստաթուղթ չի պահանջվում։ Լրացրեք հաշիվը և ծախսեք ամենուր", "no_providers_available": "Մատակարարներ չկան", diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index be9f4f2bf..fa4e741cb 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -560,6 +560,7 @@ "haven_app": "Haven oleh Cake Wallet", "haven_app_wallet_text": "Dompet yang keren untuk Haven", "help": "bantuan", + "hidden_accounts": "Akun Tersembunyi", "hidden_addresses": "Alamat Tersembunyi", "hidden_balance": "Saldo Tersembunyi", "hide": "Sembunyikan", @@ -735,6 +736,8 @@ "no_buy_providers": "Tidak ditemukan penyedia untuk ${curr}.", "no_cards_found": "Tidak ada kartu ditemukan", "no_extra_detail": "Tidak ada detail tambahan yang tersedia", + "no_hidden_accounts": "Tidak ada akun tersembunyi", + "no_hidden_accounts_desc": "Anda dapat menyembunyikan akun dari layar penyesuaian.", "no_id_needed": "Tidak perlu ID!", "no_id_required": "Tanpa perlu ID. Isi ulang dan belanja di mana saja", "no_providers_available": "Tidak ada penyedia yang tersedia", diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index a44cba1b6..ef1f04f40 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -560,6 +560,7 @@ "haven_app": "Haven di Cake Wallet", "haven_app_wallet_text": "Ottimo wallet per Haven", "help": "aiuto", + "hidden_accounts": "Conti nascosti", "hidden_addresses": "Indirizzi nascosti", "hidden_balance": "Saldo nascosto", "hide": "Nascondi", @@ -735,6 +736,8 @@ "no_buy_providers": "Nessun fornitore trovato per ${curr}.", "no_cards_found": "Nessuna carta trovata", "no_extra_detail": "Nessun dettaglio aggiuntivo disponibile", + "no_hidden_accounts": "Nessun account nascosto", + "no_hidden_accounts_desc": "Puoi nascondere gli account dalla schermata di personalizzazione.", "no_id_needed": "Nessun ID necessario!", "no_id_required": "Nessun documento d'identità richiesto. Ricarica e spendi ovunque", "no_providers_available": "Nessun fornitore disponibile", diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index 3aa12c694..e31764407 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -561,6 +561,7 @@ "haven_app": "Cake WalletのHaven", "haven_app_wallet_text": "Haven向けの素晴らしいウォレット", "help": "ヘルプ", + "hidden_accounts": "隠しアカウント", "hidden_addresses": "非表示のアドレス", "hidden_balance": "非表示残高", "hide": "非表示", @@ -736,6 +737,8 @@ "no_buy_providers": "${curr} のプロバイダーが見つかりません。", "no_cards_found": "カードが見つかりません", "no_extra_detail": "追加の詳細はありません", + "no_hidden_accounts": "隠しアカウントはありません", + "no_hidden_accounts_desc": "カスタマイズ画面からアカウントを非表示にすることができます。", "no_id_needed": "ID不要!", "no_id_required": "ID不要。どこでもチャージして使えます", "no_providers_available": "利用可能なプロバイダーがありません", diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index a02fb214f..ff955f992 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -560,6 +560,7 @@ "haven_app": "Cake Wallet의 Haven", "haven_app_wallet_text": "Haven용 멋진 지갑", "help": "도움말", + "hidden_accounts": "숨겨진 계정", "hidden_addresses": "숨겨진 주소", "hidden_balance": "숨겨진 잔액", "hide": "숨기기", @@ -735,6 +736,8 @@ "no_buy_providers": "${curr}에 대한 제공업체를 찾을 수 없습니다.", "no_cards_found": "카드를 찾을 수 없습니다", "no_extra_detail": "추가 세부 정보가 없습니다", + "no_hidden_accounts": "숨겨진 계정 없음", + "no_hidden_accounts_desc": "사용자 정의 화면에서 계정을 숨길 수 있습니다.", "no_id_needed": "신분증 필요 없음!", "no_id_required": "신분증 필요 없음. 어디서나 충전하고 사용하세요", "no_providers_available": "사용 가능한 제공업체가 없습니다", diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 57a35d5b1..a88bda56f 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -560,6 +560,7 @@ "haven_app": "Cake Wallet မှ Haven", "haven_app_wallet_text": "Haven အတွက် အံ့ဩဖွယ်ကောင်းတဲ့ ဝေါလက်", "help": "အကူအညီ", + "hidden_accounts": "ဝှက်ထားသောအကောင့်များ", "hidden_addresses": "ဖျောက်ထားသော လိပ်စာများ", "hidden_balance": "လျှို့ဝှက်ထားသော လက်ကျန်ငွေ", "hide": "ဖျောက်ရန်", @@ -735,6 +736,8 @@ "no_buy_providers": "${curr} အတွက် ဝန်ဆောင်မှုပေးသူများကို မတွေ့ပါ။", "no_cards_found": "ကဒ် မတွေ့ပါ", "no_extra_detail": "အပိုအသေးစိတ်အချက်အလက် မရှိပါ", + "no_hidden_accounts": "ဝှက်ထားသောအကောင့်များမရှိပါ။", + "no_hidden_accounts_desc": "စိတ်ကြိုက်ပြင်ဆင်ခြင်းစခရင်မှ အကောင့်များကို ဖျောက်ထားနိုင်သည်။", "no_id_needed": "ID မလိုပါ!", "no_id_required": "ID မလိုအပ်ပါ။ ငွေဖြည့်ပြီး ဘယ်နေရာမဆို သုံးနိုင်ပါသည်", "no_providers_available": "ပံ့ပိုးသူများ မရှိပါ", diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index b18b186a7..f78e74951 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -560,6 +560,7 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Geweldige wallet voor Haven", "help": "Help", + "hidden_accounts": "Verborgen rekeningen", "hidden_addresses": "Verborgen adressen", "hidden_balance": "Verborgen saldo", "hide": "Verbergen", @@ -735,6 +736,8 @@ "no_buy_providers": "Geen providers gevonden voor ${curr}.", "no_cards_found": "Geen kaarten gevonden", "no_extra_detail": "Geen extra details beschikbaar", + "no_hidden_accounts": "Geen verborgen accounts", + "no_hidden_accounts_desc": "U kunt accounts verbergen in het aanpassingsscherm.", "no_id_needed": "Geen ID nodig!", "no_id_required": "Geen ID vereist. Bijladen en overal uitgeven", "no_providers_available": "Geen providers beschikbaar", diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index a5a99774e..5f67f9c76 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -560,6 +560,7 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Świetny portfel dla Haven", "help": "pomoc", + "hidden_accounts": "Ukryte konta", "hidden_addresses": "Ukryte adresy", "hidden_balance": "Ukryte saldo", "hide": "Ukryj", @@ -735,6 +736,8 @@ "no_buy_providers": "Nie znaleziono dostawców dla ${curr}.", "no_cards_found": "Nie znaleziono żadnych kart", "no_extra_detail": "Brak dodatkowych szczegółów", + "no_hidden_accounts": "Żadnych ukrytych kont", + "no_hidden_accounts_desc": "Możesz ukryć konta na ekranie dostosowywania.", "no_id_needed": "Nie wymaga dokumentu tożsamości!", "no_id_required": "Nie jest wymagany dokument tożsamości. Doładuj i wydawaj wszędzie", "no_providers_available": "Brak dostępnych dostawców", diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index c314e85c2..677c94b34 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -560,6 +560,7 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Carteira incrível para Haven", "help": "ajuda", + "hidden_accounts": "Contas ocultas", "hidden_addresses": "Endereços ocultos", "hidden_balance": "Saldo oculto", "hide": "Ocultar", @@ -736,6 +737,8 @@ "no_buy_providers": "Nenhum provedor encontrado para ${curr}.", "no_cards_found": "Nenhum cartão encontrado", "no_extra_detail": "Nenhum detalhe extra disponível", + "no_hidden_accounts": "Sem contas ocultas", + "no_hidden_accounts_desc": "Você pode ocultar contas na tela de personalização.", "no_id_needed": "Não é necessário ID!", "no_id_required": "Não é necessário documento de identificação. Recarregue e gaste em qualquer lugar", "no_providers_available": "Nenhum provedor disponível", diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index 415cf3272..9b24f387b 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -560,6 +560,7 @@ "haven_app": "Haven от Cake Wallet", "haven_app_wallet_text": "Отличный кошелёк для Haven", "help": "помощь", + "hidden_accounts": "Скрытые аккаунты", "hidden_addresses": "Скрытые адреса", "hidden_balance": "Скрытый баланс", "hide": "Скрыть", @@ -735,6 +736,8 @@ "no_buy_providers": "Для ${curr} провайдеров не найдено.", "no_cards_found": "Карты не найдены", "no_extra_detail": "Нет дополнительных сведений", + "no_hidden_accounts": "Никаких скрытых аккаунтов", + "no_hidden_accounts_desc": "Вы можете скрыть учетные записи с экрана настройки.", "no_id_needed": "Документы не нужны!", "no_id_required": "Документы не требуются. Пополняйте и тратьте где угодно", "no_providers_available": "Нет доступных провайдеров", diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index 837ded235..fad2384e5 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -560,6 +560,7 @@ "haven_app": "Haven โดย Cake Wallet", "haven_app_wallet_text": "กระเป๋าเงินที่ยอดเยี่ยมสำหรับ Haven", "help": "ช่วยเหลือ", + "hidden_accounts": "บัญชีที่ซ่อนอยู่", "hidden_addresses": "ที่อยู่ที่ซ่อนอยู่", "hidden_balance": "ยอดคงเหลือที่ซ่อนอยู่", "hide": "ซ่อน", @@ -735,6 +736,8 @@ "no_buy_providers": "ไม่พบผู้ให้บริการสำหรับ ${curr}", "no_cards_found": "ไม่พบการ์ด", "no_extra_detail": "ไม่มีรายละเอียดเพิ่มเติม", + "no_hidden_accounts": "ไม่มีบัญชีที่ซ่อนอยู่", + "no_hidden_accounts_desc": "คุณสามารถซ่อนบัญชีได้จากหน้าจอการปรับแต่ง", "no_id_needed": "ไม่ต้องใช้บัตรประชาชน!", "no_id_required": "ไม่ต้องใช้บัตรประจำตัว เติมเงินและใช้จ่ายได้ทุกที่", "no_providers_available": "ไม่มีผู้ให้บริการที่ใช้งานได้", diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index e6a2a3208..7e033e99f 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -560,6 +560,7 @@ "haven_app": "Haven by Cake Wallet", "haven_app_wallet_text": "Napakagandang wallet para sa Haven", "help": "Tulong", + "hidden_accounts": "Mga Nakatagong Account", "hidden_addresses": "Nakatagong mga Address", "hidden_balance": "Nakatagong Balanse", "hide": "Itago", @@ -735,6 +736,8 @@ "no_buy_providers": "Walang nakitang provider para sa ${curr}.", "no_cards_found": "Walang nahanap na card", "no_extra_detail": "Walang available na karagdagang detalye", + "no_hidden_accounts": "Walang nakatagong account", + "no_hidden_accounts_desc": "Maaari mong itago ang mga account mula sa screen ng pag-customize.", "no_id_needed": "Hindi kailangan ng ID!", "no_id_required": "Hindi kailangan ng ID. Mag-top up at gumastos kahit saan", "no_providers_available": "Walang available na provider", diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index 543eb97a9..c38804fd9 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -560,6 +560,7 @@ "haven_app": "Cake Wallet'tan Haven", "haven_app_wallet_text": "Haven için harika cüzdan", "help": "yardım", + "hidden_accounts": "Gizli Hesaplar", "hidden_addresses": "Gizli Adresler", "hidden_balance": "Gizli Bakiye", "hide": "Gizle", @@ -735,6 +736,8 @@ "no_buy_providers": "${curr} için sağlayıcı bulunamadı.", "no_cards_found": "Kart bulunamadı", "no_extra_detail": "Ekstra ayrıntı mevcut değil", + "no_hidden_accounts": "Gizli hesap yok", + "no_hidden_accounts_desc": "Hesapları özelleştirme ekranından gizleyebilirsiniz.", "no_id_needed": "Kimlik gerekmiyor!", "no_id_required": "Kimlik gerekmez. Bakiye yükleyin ve her yerde harcayın", "no_providers_available": "Kullanılabilir sağlayıcı yok", diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index 2ea9c2994..8db7613a6 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -560,6 +560,7 @@ "haven_app": "Haven від Cake Wallet", "haven_app_wallet_text": "Чудовий гаманець для Haven", "help": "допомога", + "hidden_accounts": "Приховані облікові записи", "hidden_addresses": "Приховані адреси", "hidden_balance": "Прихований баланс", "hide": "Сховати", @@ -735,6 +736,8 @@ "no_buy_providers": "Не знайдено постачальників для ${curr}.", "no_cards_found": "Картки не знайдено", "no_extra_detail": "Немає додаткових відомостей", + "no_hidden_accounts": "Без прихованих облікових записів", + "no_hidden_accounts_desc": "Ви можете приховати облікові записи на екрані налаштування.", "no_id_needed": "Без посвідчення особи!", "no_id_required": "Документ не потрібен. Поповнюйте та витрачайте будь-де", "no_providers_available": "Немає доступних провайдерів", diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 1ac8a594d..c37e63edf 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -560,6 +560,7 @@ "haven_app": "Cake Wallet کی جانب سے Haven", "haven_app_wallet_text": "Haven کے لیے زبردست والیٹ", "help": "مدد", + "hidden_accounts": "پوشیدہ اکاؤنٹس", "hidden_addresses": "پوشیدہ پتے", "hidden_balance": "پوشیدہ بیلنس", "hide": "چھپائیں", @@ -735,6 +736,8 @@ "no_buy_providers": "${curr} کے لیے کوئی فراہم کنندہ نہیں ملا۔", "no_cards_found": "کوئی کارڈ نہیں ملا", "no_extra_detail": "کوئی اضافی تفصیلات دستیاب نہیں ہیں", + "no_hidden_accounts": "کوئی پوشیدہ اکاؤنٹس نہیں۔", + "no_hidden_accounts_desc": "آپ حسب ضرورت اسکرین سے اکاؤنٹس چھپا سکتے ہیں۔", "no_id_needed": "شناختی کارڈ کی ضرورت نہیں!", "no_id_required": "کوئی شناختی دستاویز درکار نہیں۔ ٹاپ اپ کریں اور کہیں بھی خرچ کریں", "no_providers_available": "کوئی فراہم کنندہ دستیاب نہیں ہے", diff --git a/res/values/strings_vi.arb b/res/values/strings_vi.arb index c219bfd1c..8021f33d0 100644 --- a/res/values/strings_vi.arb +++ b/res/values/strings_vi.arb @@ -561,6 +561,7 @@ "haven_app": "Haven của Cake Wallet", "haven_app_wallet_text": "Ví tuyệt vời cho Haven", "help": "Trợ giúp", + "hidden_accounts": "Tài khoản ẩn", "hidden_addresses": "Địa chỉ bị ẩn", "hidden_balance": "Số dư ẩn", "hide": "Ẩn", @@ -736,6 +737,8 @@ "no_buy_providers": "Không tìm thấy nhà cung cấp nào cho ${curr}.", "no_cards_found": "Không tìm thấy thẻ nào", "no_extra_detail": "Không có thông tin chi tiết bổ sung", + "no_hidden_accounts": "Không có tài khoản ẩn", + "no_hidden_accounts_desc": "Bạn có thể ẩn tài khoản khỏi màn hình tùy chỉnh.", "no_id_needed": "Không cần ID!", "no_id_required": "Không cần ID. Nạp tiền và chi tiêu ở bất kỳ đâu", "no_providers_available": "Không có nhà cung cấp nào khả dụng", diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index 46bd63c05..eba92d442 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -561,6 +561,7 @@ "haven_app": "Haven nipasẹ Cake Wallet", "haven_app_wallet_text": "Àpamọ́wọ́ tó dára jùlọ fún Haven", "help": "Ìrànlọ́wọ́", + "hidden_accounts": "Farasin Accounts", "hidden_addresses": "Awọn Adirẹsi Tí a Fipamọ́", "hidden_balance": "Ìwọ̀ntúnwọ̀nsì tí a fi pamọ́", "hide": "Fi pamọ́", @@ -736,6 +737,8 @@ "no_buy_providers": "Ko si olupese fun ${curr}.", "no_cards_found": "A kò rí kaadi kankan", "no_extra_detail": "Ko si alaye afikun ti o wa", + "no_hidden_accounts": "Ko si awọn iroyin pamọ", + "no_hidden_accounts_desc": "O le tọju awọn akọọlẹ lati iboju isọdi.", "no_id_needed": "Kò sí ID tí o nílò!", "no_id_required": "Kò sí ìdánimọ̀ tí a nílò. Ẹ lè fikún owó sí ìwọ̀n àti náà níbikíbi", "no_providers_available": "Ko si olupese kankan ti o wa", diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index 7df890309..229b2caf8 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -560,6 +560,7 @@ "haven_app": "Cake Wallet 的 Haven", "haven_app_wallet_text": "适用于 Haven 的超棒钱包", "help": "帮助", + "hidden_accounts": "隐藏账户", "hidden_addresses": "隐藏地址", "hidden_balance": "隐藏余额", "hide": "隐藏", @@ -735,6 +736,8 @@ "no_buy_providers": "未找到 ${curr} 的提供商。", "no_cards_found": "未找到卡片", "no_extra_detail": "没有可用的额外详细信息", + "no_hidden_accounts": "没有隐藏帐户", + "no_hidden_accounts_desc": "您可以从自定义屏幕隐藏帐户。", "no_id_needed": "无需 ID!", "no_id_required": "无需提供身份证明。随时充值,随处消费", "no_providers_available": "暂无可用的服务提供商", diff --git a/test/new-ui/pages/account_creation_modal_test.dart b/test/new-ui/pages/account_creation_modal_test.dart new file mode 100644 index 000000000..eb3600e6b --- /dev/null +++ b/test/new-ui/pages/account_creation_modal_test.dart @@ -0,0 +1,177 @@ +import "dart:async"; + +import "package:cake_wallet/core/execution_state.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/locales/locale.dart"; +import "package:cake_wallet/src/screens/settings/widgets/account_creation_modal.dart"; +import "package:cake_wallet/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mocktail/mocktail.dart"; + +class _MockAccountEditOrCreateViewModel extends Mock + implements MoneroAccountEditOrCreateViewModel {} + +void main() { + late _MockAccountEditOrCreateViewModel viewModel; + bool? routeResult; + + setUp(() { + viewModel = _MockAccountEditOrCreateViewModel(); + routeResult = null; + }); + + Widget creationLauncher() => MaterialApp( + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () async { + routeResult = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => Scaffold( + body: AccountCreationModal( + viewModel: viewModel, + ), + ), + ), + ); + }, + child: const Text("Open account creation"), + ), + ), + ), + ); + + Future openModal(WidgetTester tester) async { + await tester.pumpWidget(creationLauncher()); + await tester.tap(find.text("Open account creation")); + await tester.pumpAndSettle(); + } + + testWidgets("shows the Figma account-creation presentation", (tester) async { + await openModal(tester); + + expect(find.text("Create Account"), findsOneWidget); + expect( + find.text( + "Accounts let you organize your funds into different compartments without needing to create a separate wallet", + ), + findsOneWidget, + ); + expect(find.byType(TextField), findsOneWidget); + expect(find.text("Continue"), findsOneWidget); + expect(find.bySemanticsLabel("Close"), findsOneWidget); + }); + + testWidgets("passes the entered name to the existing account save operation", (tester) async { + when(() => viewModel.save()).thenAnswer((_) async {}); + when(() => viewModel.state).thenReturn(ExecutedSuccessfullyState()); + await openModal(tester); + + await tester.enterText(find.byType(TextField), "Savings"); + await tester.pump(); + await tester.tap(find.text("Continue")); + await tester.pumpAndSettle(); + + verify(() => viewModel.label = "Savings").called(1); + verify(() => viewModel.save()).called(1); + expect(routeResult, isTrue); + expect(find.byType(AccountCreationModal), findsNothing); + }); + + testWidgets("cannot be dismissed while account creation is in progress", (tester) async { + final saveCompleter = Completer(); + when(() => viewModel.save()).thenAnswer((_) => saveCompleter.future); + when(() => viewModel.state).thenReturn(ExecutedSuccessfullyState()); + await openModal(tester); + + await tester.enterText(find.byType(TextField), "Savings"); + await tester.pump(); + await tester.tap(find.text("Continue")); + await tester.pump(); + + await tester.tap(find.bySemanticsLabel("Close")); + await tester.pump(); + expect(find.byType(AccountCreationModal), findsOneWidget); + + await tester.binding.handlePopRoute(); + await tester.pump(); + expect(find.byType(AccountCreationModal), findsOneWidget); + + saveCompleter.complete(); + await tester.pumpAndSettle(); + expect(routeResult, isTrue); + expect(find.byType(AccountCreationModal), findsNothing); + }); + + testWidgets("shows a save failure and restores safe dismissal", (tester) async { + when(() => viewModel.save()).thenAnswer((_) async {}); + when(() => viewModel.state).thenReturn(FailureState("Could not create account")); + await openModal(tester); + + await tester.enterText(find.byType(TextField), "Savings"); + await tester.pump(); + await tester.tap(find.text("Continue")); + await tester.pumpAndSettle(); + + expect(find.text("Could not create account"), findsOneWidget); + expect(find.byType(AccountCreationModal), findsOneWidget); + expect(routeResult, isNull); + + await tester.tap(find.text("OK")); + await tester.pumpAndSettle(); + await tester.tap(find.bySemanticsLabel("Close")); + await tester.pumpAndSettle(); + + verify(() => viewModel.save()).called(1); + expect(find.byType(AccountCreationModal), findsNothing); + expect(routeResult, isNull); + }); + + testWidgets("shows a thrown save error and restores safe dismissal", (tester) async { + when(() => viewModel.save()).thenThrow(Exception("Card design failed")); + await openModal(tester); + + await tester.enterText(find.byType(TextField), "Savings"); + await tester.pump(); + await tester.tap(find.text("Continue")); + await tester.pumpAndSettle(); + + expect(find.text("Exception: Card design failed"), findsOneWidget); + expect(find.byType(AccountCreationModal), findsOneWidget); + expect(routeResult, isNull); + + await tester.tap(find.text("OK")); + await tester.pumpAndSettle(); + await tester.tap(find.bySemanticsLabel("Close")); + await tester.pumpAndSettle(); + + verify(() => viewModel.save()).called(1); + expect(find.byType(AccountCreationModal), findsNothing); + expect(routeResult, isNull); + }); + + testWidgets("requires a non-empty account name before continuing", (tester) async { + await openModal(tester); + + TextButton continueButton() => tester.widget( + find.ancestor( + of: find.text("Continue"), + matching: find.byType(TextButton), + ), + ); + + expect(continueButton().onPressed, isNull); + + await tester.enterText(find.byType(TextField), " "); + await tester.pump(); + expect(continueButton().onPressed, isNull); + verifyNever(() => viewModel.save()); + + await tester.enterText(find.byType(TextField), "Savings"); + await tester.pump(); + expect(continueButton().onPressed, isNotNull); + }); +} diff --git a/test/new-ui/pages/account_education_page_test.dart b/test/new-ui/pages/account_education_page_test.dart new file mode 100644 index 000000000..9c15e332f --- /dev/null +++ b/test/new-ui/pages/account_education_page_test.dart @@ -0,0 +1,206 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/locales/locale.dart"; +import "package:cake_wallet/new-ui/pages/account_education_page.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:cake_wallet/themes/core/theme_store.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mocktail/mocktail.dart"; + +class _MockSettingsStore extends Mock implements SettingsStore {} + +void main() { + late bool registeredThemeStore; + late _MockSettingsStore settingsStore; + + setUpAll(() { + registeredThemeStore = !getIt.isRegistered(); + if (registeredThemeStore) { + getIt.registerSingleton(ThemeStore()); + } + }); + + tearDownAll(() async { + if (registeredThemeStore) { + await getIt.unregister(); + } + }); + + setUp(() { + settingsStore = _MockSettingsStore(); + when(() => settingsStore.dismissEducation(any())).thenAnswer((_) async {}); + }); + + Future openEducation(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + home: Scaffold( + body: Builder( + builder: (context) => TextButton( + onPressed: () => AccountEducationPage(settingsStore: settingsStore).show(context), + child: const Text("Open education"), + ), + ), + ), + ), + ); + await tester.tap(find.text("Open education")); + await tester.pumpAndSettle(); + } + + Future continueToNextPage(WidgetTester tester) async { + await tester.tap(find.text("Continue")); + await tester.pump(); + await tester.pumpAndSettle(); + } + + testWidgets("first page explains account organization and exposes progress", (tester) async { + final semantics = tester.ensureSemantics(); + + await openEducation(tester); + + expect( + find.text( + "Accounts let you organize your funds by acting as separate pockets within one wallet", + ), + findsOneWidget, + ); + expect( + find.text( + "You can create one for your regular spending, another for your savings... all accessible from the same homescreen", + ), + findsOneWidget, + ); + expect(find.bySemanticsLabel(RegExp("Page 1 of 4")), findsOneWidget); + expect(find.text("Continue"), findsOneWidget); + verifyNever(() => settingsStore.dismissEducation(any())); + + final title = find.text( + "Accounts let you organize your funds by acting as separate pockets within one wallet", + ); + final image = find.byKey(const ValueKey("accounts-education-overview-image")); + final description = find.text( + "You can create one for your regular spending, another for your savings... all accessible from the same homescreen", + ); + expect(tester.getBottomLeft(title).dy, lessThan(tester.getTopLeft(image).dy)); + expect(tester.getBottomLeft(image).dy, lessThan(tester.getTopLeft(description).dy)); + expect( + tester.getTopLeft(image).dy - tester.getBottomLeft(title).dy, + greaterThanOrEqualTo(24), + ); + expect( + tester.getTopLeft(description).dy - tester.getBottomLeft(image).dy, + greaterThanOrEqualTo(24), + ); + semantics.dispose(); + }); + + testWidgets("Continue advances to the recovery education page", (tester) async { + final semantics = tester.ensureSemantics(); + + await openEducation(tester); + await continueToNextPage(tester); + + expect( + find.text( + "Accounts belong together in one wallet, they can all be restored from just one Recovery Phrase", + ), + findsOneWidget, + ); + expect(find.bySemanticsLabel(RegExp("Page 2 of 4")), findsOneWidget); + verifyNever(() => settingsStore.dismissEducation(any())); + semantics.dispose(); + }); + + testWidgets("account-order education follows the Figma content sequence", (tester) async { + await openEducation(tester); + await continueToNextPage(tester); + await continueToNextPage(tester); + + final orderImage = find.byKey(const ValueKey("accounts-education-order-image")); + final title = find.text( + "Accounts are created in order, and they are restored in the same order", + ); + final accountImage = find.byKey( + const ValueKey("accounts-education-ordered-account-image"), + ); + final description = find.byWidgetPredicate( + (widget) => + widget is RichText && + widget.text.toPlainText() == + "The order of creation is shown alongside the account name, in the Accounts menu", + ); + final restoreDescription = find.text( + "This is useful to know if you want to restore your accounts, both in Cake Wallet or elsewhere", + ); + + expect(tester.getBottomLeft(orderImage).dy, lessThan(tester.getTopLeft(title).dy)); + expect(tester.getBottomLeft(title).dy, lessThan(tester.getTopLeft(accountImage).dy)); + expect(tester.getBottomLeft(accountImage).dy, lessThan(tester.getTopLeft(description).dy)); + expect( + tester.getBottomLeft(description).dy, + lessThan(tester.getTopLeft(restoreDescription).dy), + ); + expect( + tester.getTopLeft(title).dy - tester.getBottomLeft(orderImage).dy, + moreOrLessEquals(51, epsilon: 0.1), + ); + expect( + tester.getTopLeft(accountImage).dy - tester.getBottomLeft(title).dy, + moreOrLessEquals(51, epsilon: 0.1), + ); + expect( + tester.getTopLeft(description).dy - tester.getBottomLeft(accountImage).dy, + moreOrLessEquals(51, epsilon: 0.1), + ); + }); + + testWidgets("final acknowledgement marks education as seen", (tester) async { + await openEducation(tester); + + await continueToNextPage(tester); + await continueToNextPage(tester); + await continueToNextPage(tester); + + expect( + find.text("Accounts cannot be deleted, only archived"), + findsOneWidget, + ); + expect( + find.text( + "If you deposit funds to an account, they will still be there even if you archive it", + ), + findsOneWidget, + ); + expect(find.text("I understand. Continue"), findsOneWidget); + + await tester.tap(find.text("I understand. Continue")); + await tester.pumpAndSettle(); + + verify(() => settingsStore.dismissEducation("accounts")).called(1); + }); + + testWidgets("closing education also marks it as seen", (tester) async { + final semantics = tester.ensureSemantics(); + + await openEducation(tester); + await tester.tap(find.bySemanticsLabel("Close")); + await tester.pumpAndSettle(); + + verify(() => settingsStore.dismissEducation("accounts")).called(1); + semantics.dispose(); + }); + + testWidgets("dismissing the modal without completing education marks it as seen", (tester) async { + await openEducation(tester); + + Navigator.of(tester.element(find.byType(AccountEducationPage))).pop(); + await tester.pumpAndSettle(); + + expect(find.byType(AccountEducationPage), findsNothing); + verify(() => settingsStore.dismissEducation("accounts")).called(1); + }); +} diff --git a/test/new-ui/pages/archived_accounts_page_test.dart b/test/new-ui/pages/archived_accounts_page_test.dart new file mode 100644 index 000000000..4a9fe2658 --- /dev/null +++ b/test/new-ui/pages/archived_accounts_page_test.dart @@ -0,0 +1,690 @@ +import "dart:async"; + +import "package:cake_wallet/core/amount_parsing_proxy.dart"; +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/entities/balance_display_mode.dart"; +import "package:cake_wallet/entities/fiat_api_mode.dart"; +import "package:cake_wallet/entities/fiat_currency.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart"; +import "package:cake_wallet/locales/locale.dart"; +import "package:cake_wallet/new-ui/pages/wallet_accounts_page.dart"; +import "package:cake_wallet/new-ui/pages/account_education_page.dart"; +import "package:cake_wallet/new-ui/pages/card_customizer.dart"; +import "package:cake_wallet/new-ui/pages/hidden_accounts.dart"; +import "package:cake_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart"; +import "package:cake_wallet/new-ui/widgets/money/money_settings_provider.dart"; +import "package:cake_wallet/new-ui/widgets/modern_button.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cake_wallet/store/dashboard/fiat_conversion_store.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:cake_wallet/themes/core/theme_store.dart"; +import "package:cake_wallet/view_model/dashboard/balance_view_model.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart"; +import "package:cake_wallet/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/balance.dart"; +import "package:cw_core/balance_card_layout.dart"; +import "package:cw_core/balance_card_style_settings.dart"; +import "package:cw_core/card_design.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/db/sqlite.dart" as sqlite; +import "package:cw_core/sync_status.dart"; +import "package:cw_core/transaction_history.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mobx/mobx.dart" show Observable, runInAction; +import "package:mocktail/mocktail.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +class _MockAccountEditOrCreateViewModel extends Mock implements WalletAccountEditOrCreateViewModel {} + +class _MockDashboardViewModel extends Mock implements DashboardViewModel {} + +class _MockBalanceViewModel extends Mock implements BalanceViewModel {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockAccountListViewModel extends Mock implements MoneroAccountListViewModel {} + +class _MockWallet extends Mock + implements WalletBase, TransactionInfo> {} + +class _MockWalletInfo extends Mock implements WalletInfo {} + +class _MockCardCustomizerBloc extends Mock implements CardCustomizerBloc {} + +class _MockSettingsStore extends Mock implements SettingsStore {} + +Future _waitForArchivePage(WidgetTester tester) async { + for (var attempt = 0; attempt < 50; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(); + if (find.byType(CupertinoActivityIndicator).evaluate().isEmpty) { + return; + } + } + + throw TestFailure("Archived accounts did not finish loading"); +} + +Future _waitForTextToDisappear(WidgetTester tester, String text) async { + for (var attempt = 0; attempt < 50; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 20)); + if (find.text(text).evaluate().isEmpty) { + return; + } + } + + throw TestFailure("$text did not disappear"); +} + +Future _pumpUntil(WidgetTester tester, bool Function() done) async { + for (var attempt = 0; attempt < 100; attempt++) { + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 10)), + ); + await tester.pump(const Duration(milliseconds: 10)); + if (done()) { + return; + } + } + + throw TestFailure("Timed out waiting for WalletAccountsPage"); +} + +void main() { + late bool registeredThemeStore; + late DatabaseFactory? originalDatabaseFactory; + late Database? originalDatabase; + late _MockDashboardViewModel dashboardViewModel; + late _MockBalanceViewModel balanceViewModel; + late _MockAccountListViewModel accountListViewModel; + late _MockWallet wallet; + late _MockWalletInfo walletInfo; + + final fundedAccount = + AccountListItem(id: 0, label: "Savings", balance: Money.parse("1.25", CryptoCurrency.xmr)); + final activeAccount = + AccountListItem(id: 1, label: "Primary", balance: Money.parse("0", CryptoCurrency.xmr)); + final emptyAccount = + AccountListItem(id: 2, label: "Travel", balance: Money.parse("0", CryptoCurrency.xmr)); + + setUpAll(() { + registerFallbackValue(DesignSaved()); + registerFallbackValue( + AccountListItem(id: -1, label: "fallback", balance: Money.zero(CryptoCurrency.xmr)), + ); + + registeredThemeStore = !getIt.isRegistered(); + if (registeredThemeStore) { + getIt.registerSingleton(ThemeStore()); + } + + originalDatabaseFactory = databaseFactoryOrNull; + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + tearDownAll(() async { + databaseFactoryOrNull = originalDatabaseFactory; + if (registeredThemeStore) { + await getIt.unregister(); + } + }); + + setUp(() async { + originalDatabase = sqlite.db; + sqlite.db = await databaseFactory.openDatabase(inMemoryDatabasePath); + await sqlite.db!.execute(""" + CREATE TABLE ${BalanceCardStyleSettings.tableName} ( + walletInfoId INTEGER, + accountIndex INTEGER DEFAULT -1, + gradientIndex INTEGER DEFAULT -1, + useSpecialDesign BOOLEAN DEFAULT FALSE, + hidden BOOLEAN DEFAULT FALSE, + backgroundImagePath TEXT DEFAULT "", + iconStyleIndex INTEGER DEFAULT 0, + isGradientOnly BOOLEAN DEFAULT FALSE, + cardOrder INTEGER DEFAULT 0, + PRIMARY KEY (walletInfoId, accountIndex) + ) + """); + + dashboardViewModel = _MockDashboardViewModel(); + balanceViewModel = _MockBalanceViewModel(); + accountListViewModel = _MockAccountListViewModel(); + wallet = _MockWallet(); + walletInfo = _MockWalletInfo(); + final settingsStore = _MockSettingsStore(); + when(() => settingsStore.balanceDisplayMode).thenReturn(BalanceDisplayMode.displayableBalance); + when(() => settingsStore.displayAmountsInSatoshi).thenReturn(BitcoinAmountDisplayMode.bitcoin); + when(() => dashboardViewModel.settingsStore).thenReturn(settingsStore); + when(() => accountListViewModel.select(any())).thenAnswer((_) async {}); + when(() => accountListViewModel.reload()).thenAnswer((_) async {}); + + when(() => dashboardViewModel.balanceViewModel).thenReturn(balanceViewModel); + when(() => balanceViewModel.accountFiatBalance(any())).thenReturn(null); + when(() => dashboardViewModel.isMultiAccountsEnabled).thenReturn(true); + when(() => dashboardViewModel.canToggleMultiAccounts).thenReturn(false); + when(() => dashboardViewModel.wallet).thenReturn(wallet); + when(() => dashboardViewModel.loadCardDesigns()).thenAnswer((_) async {}); + when(() => wallet.walletInfo).thenReturn(walletInfo); + when(() => wallet.type).thenReturn(WalletType.monero); + when(() => wallet.currency).thenReturn(CryptoCurrency.xmr); + when(() => walletInfo.internalId).thenReturn(42); + when(() => accountListViewModel.currency).thenReturn(CryptoCurrency.xmr); + when(() => accountListViewModel.accounts).thenReturn([activeAccount]); + }); + + tearDown(() async { + await sqlite.db?.close(); + sqlite.db = originalDatabase; + }); + + Future hideAccount(int accountId, int order) => BalanceCardStyleSettings.fromCardDesign( + walletInfoId: 42, + accountIndex: accountId, + cardOrder: order, + design: CardDesign.gradientOnlyDesign, + hidden: true, + ).insert(); + + Widget testApp(Widget home) => MaterialApp( + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + builder: (_, child) => MoneySettingsProvider( + settingsStore: dashboardViewModel.settingsStore, + child: child!, + ), + home: home, + ); + + testWidgets("empty archive explains where archived accounts will appear", (tester) async { + await tester.pumpWidget( + testApp( + HiddenAccountsPage( + accountListViewModel: accountListViewModel, + dashboardViewModel: dashboardViewModel, + ), + ), + ); + await _waitForArchivePage(tester); + + expect(find.text("Archived Accounts"), findsOneWidget); + expect(find.text("No Archived Accounts"), findsOneWidget); + expect(find.text("When you Archive an Account, it will show up here"), findsOneWidget); + }); + + testWidgets("archived accounts use the persisted hidden layout and Figma groups", (tester) async { + when(() => accountListViewModel.accounts) + .thenReturn([fundedAccount, activeAccount, emptyAccount]); + await tester.runAsync(() async { + await hideAccount(fundedAccount.id, 0); + await hideAccount(emptyAccount.id, 1); + }); + + await tester.pumpWidget( + testApp( + HiddenAccountsPage( + accountListViewModel: accountListViewModel, + dashboardViewModel: dashboardViewModel, + ), + ), + ); + await _waitForArchivePage(tester); + + expect(find.text("Funded accounts"), findsOneWidget); + expect(find.text("Unarchive an account to access its funds"), findsOneWidget); + expect(find.text("1. Savings"), findsOneWidget); + expect(find.text("1.25 XMR"), findsOneWidget); + expect(find.text("Empty Accounts"), findsOneWidget); + expect(find.text("3. Travel"), findsOneWidget); + expect(find.text("0 XMR"), findsNothing); + }); + + for (final currency in [CryptoCurrency.xmr, CryptoCurrency.btc]) { + testWidgets("archived ${currency.title} balances respect price, units, and privacy", + (tester) async { + final settingsStore = _MockSettingsStore(); + final displayMode = Observable(BalanceDisplayMode.displayableBalance); + final amountDisplayMode = Observable(BitcoinAmountDisplayMode.bitcoin); + when(() => settingsStore.balanceDisplayMode).thenAnswer((_) => displayMode.value); + when(() => settingsStore.displayAmountsInSatoshi).thenAnswer((_) => amountDisplayMode.value); + final fundedAccount = AccountListItem( + id: 0, + label: "Savings", + balance: Money.parse("1.25", currency), + ); + when(() => wallet.currency).thenReturn(currency); + when(() => wallet.type) + .thenReturn(currency == CryptoCurrency.btc ? WalletType.bitcoin : WalletType.monero); + when(() => accountListViewModel.currency).thenReturn(currency); + + when(() => accountListViewModel.accounts) + .thenReturn([fundedAccount, activeAccount, emptyAccount]); + when(() => dashboardViewModel.settingsStore).thenReturn(settingsStore); + when(() => settingsStore.fiatCurrency).thenReturn(FiatCurrency.usd); + when(() => settingsStore.languageCode).thenReturn("de_DE"); + when(() => settingsStore.fiatApiMode).thenReturn(FiatApiMode.enabled); + when(() => settingsStore.mwebAlwaysScan).thenReturn(false); + when(() => walletInfo.isShowIntroCakePayCard).thenReturn(false); + final appStore = _MockAppStore(); + when(() => appStore.wallet).thenReturn(wallet); + when(() => appStore.amountParsingProxy) + .thenAnswer((_) => AmountParsingProxy(amountDisplayMode.value)); + when(() => dashboardViewModel.balanceViewModel).thenReturn( + BalanceViewModel( + appStore: appStore, + settingsStore: settingsStore, + fiatConversionStore: FiatConversionStore()..prices[currency] = 20, + ), + ); + await tester.runAsync(() async { + await hideAccount(fundedAccount.id, 0); + await hideAccount(emptyAccount.id, 1); + }); + + await tester.pumpWidget( + testApp( + HiddenAccountsPage( + accountListViewModel: accountListViewModel, + dashboardViewModel: dashboardViewModel, + ), + ), + ); + await _waitForArchivePage(tester); + + expect(find.text("1.25 ${currency.title}"), findsOneWidget); + expect(find.text("25,00 USD"), findsOneWidget); + expect(find.text("3. Travel"), findsOneWidget); + expect(find.text("0,00 USD"), findsNothing); + + if (currency == CryptoCurrency.btc) { + runInAction(() => amountDisplayMode.value = BitcoinAmountDisplayMode.satoshi); + await tester.pumpAndSettle(); + expect(find.text("125,000,000 sats"), findsOneWidget); + expect(find.text("25,00 USD"), findsOneWidget); + } + + runInAction(() => displayMode.value = BalanceDisplayMode.hiddenBalance); + await tester.pumpAndSettle(); + expect(find.text("●●●●●●"), findsOneWidget); + expect(find.text("●●●●● USD"), findsOneWidget); + expect(find.text("25,00 USD"), findsNothing); + }); + } + + testWidgets("WalletAccountsPage shows education only until it has been seen", (tester) async { + var cardDesignLoads = 0; + var educationDismissed = false; + final settingsStore = _MockSettingsStore(); + when(() => settingsStore.balanceDisplayMode).thenReturn(BalanceDisplayMode.displayableBalance); + when(() => settingsStore.displayAmountsInSatoshi).thenReturn(BitcoinAmountDisplayMode.bitcoin); + + when(() => dashboardViewModel.settingsStore).thenReturn(settingsStore); + when(() => settingsStore.isEducationDismissed("accounts")) + .thenAnswer((_) => educationDismissed); + when(() => settingsStore.dismissEducation("accounts")).thenAnswer((_) async { + educationDismissed = true; + }); + + when(() => dashboardViewModel.loadCardDesigns()).thenAnswer((_) async { + cardDesignLoads++; + }); + when(() => wallet.type).thenReturn(WalletType.monero); + when(() => wallet.currency).thenReturn(CryptoCurrency.xmr); + + await tester.pumpWidget( + testApp( + WalletAccountsPage( + accountListViewModel: accountListViewModel, + accountEditOrCreateViewModel: _MockAccountEditOrCreateViewModel(), + dashboardViewModel: dashboardViewModel, + ), + ), + ); + await _pumpUntil(tester, () => find.byType(AccountEducationPage).evaluate().isNotEmpty); + + expect(find.byType(AccountEducationPage), findsOneWidget); + await tester.tap( + find.descendant( + of: find.byType(AccountEducationPage), + matching: find.bySemanticsLabel("Close"), + ), + ); + await tester.pumpAndSettle(); + expect(settingsStore.isEducationDismissed("accounts"), isTrue); + + final loadsBeforeFirstDispose = cardDesignLoads; + await tester.pumpWidget(testApp(const SizedBox.shrink())); + await _pumpUntil(tester, () => cardDesignLoads > loadsBeforeFirstDispose); + + await tester.pumpWidget( + testApp( + WalletAccountsPage( + accountListViewModel: accountListViewModel, + accountEditOrCreateViewModel: _MockAccountEditOrCreateViewModel(), + dashboardViewModel: dashboardViewModel, + ), + ), + ); + await _pumpUntil(tester, () => find.text("Add Account").evaluate().isNotEmpty); + await tester.pumpAndSettle(); + + expect(find.byType(AccountEducationPage), findsNothing); + + final loadsBeforeSecondDispose = cardDesignLoads; + await tester.pumpWidget(testApp(const SizedBox.shrink())); + await _pumpUntil(tester, () => cardDesignLoads > loadsBeforeSecondDispose); + }); + + testWidgets("unnamed archived accounts get the Figma display fallback", (tester) async { + final unnamedAccount = + AccountListItem(id: 4, label: "", balance: Money.parse("0", CryptoCurrency.xmr)); + when(() => accountListViewModel.accounts).thenReturn([activeAccount, unnamedAccount]); + await tester.runAsync(() => hideAccount(unnamedAccount.id, 0)); + + await tester.pumpWidget( + testApp( + HiddenAccountsPage( + accountListViewModel: accountListViewModel, + dashboardViewModel: dashboardViewModel, + ), + ), + ); + await _waitForArchivePage(tester); + + expect(find.text("5. Unnamed Account"), findsOneWidget); + }); + + testWidgets("unarchiving uses the latest account state for confirmation and selection", + (tester) async { + when(() => accountListViewModel.accounts).thenReturn([emptyAccount, activeAccount]); + await tester.runAsync(() => hideAccount(emptyAccount.id, 0)); + + await tester.pumpWidget( + testApp( + HiddenAccountsPage( + accountListViewModel: accountListViewModel, + dashboardViewModel: dashboardViewModel, + ), + ), + ); + await _waitForArchivePage(tester); + + final accountForConfirmation = AccountListItem( + id: emptyAccount.id, + label: emptyAccount.label, + balance: Money.parse("1.5", CryptoCurrency.xmr), + ); + when(() => accountListViewModel.accounts).thenReturn([accountForConfirmation, activeAccount]); + await tester.tap(find.text("3. Travel")); + await tester.pumpAndSettle(); + + expect(find.text("Unarchive this account?"), findsOneWidget); + expect( + find.text("This account will show up again on your wallet, letting you access your funds"), + findsOneWidget, + ); + expect(find.text("1.5 XMR"), findsOneWidget); + + final latestAccount = AccountListItem( + id: emptyAccount.id, + label: "Updated", + balance: Money.parse("2.5", CryptoCurrency.xmr), + ); + when(() => accountListViewModel.accounts).thenReturn([latestAccount, activeAccount]); + await tester.tap(find.text("Continue")); + await tester.pump(); + await _waitForTextToDisappear(tester, "3. Travel"); + + final setting = await tester.runAsync( + () => BalanceCardStyleSettings.get(42, emptyAccount.id), + ); + final settings = await tester.runAsync(() => BalanceCardStyleSettings.getAll(42)); + final layout = BalanceCardLayout.resolve( + accountIndices: [emptyAccount.id, activeAccount.id], + settings: settings!, + ); + + expect(setting?.hidden, isFalse); + expect(layout.hidden, isEmpty); + expect(layout.visible, contains(emptyAccount.id)); + verify(() => accountListViewModel.select(latestAccount)).called(1); + verify(() => dashboardViewModel.loadCardDesigns()).called(1); + expect(find.text("3. Travel"), findsNothing); + expect(find.text("No Archived Accounts"), findsOneWidget); + }); + + testWidgets("archive flow survives dismissal and protects the final visible account", + (tester) async { + final remainingAccount = + AccountListItem(id: 0, label: "Primary", balance: Money.parse("0", CryptoCurrency.xmr)); + final archivedAccount = AccountListItem( + id: 1, + label: "Savings", + balance: Money.parse("0", CryptoCurrency.xmr), + isSelected: true, + ); + final bloc = _MockCardCustomizerBloc(); + final settingsStore = _MockSettingsStore(); + when(() => settingsStore.balanceDisplayMode).thenReturn(BalanceDisplayMode.displayableBalance); + when(() => settingsStore.displayAmountsInSatoshi).thenReturn(BitcoinAmountDisplayMode.bitcoin); + final states = StreamController.broadcast(); + final events = []; + final selections = []; + final canHideValues = []; + final finishArchiveReload = Completer(); + var canHide = false; + var cardDesignLoads = 0; + var pauseCardDesignLoad = false; + + when(() => settingsStore.isEducationDismissed("accounts")).thenReturn(true); + var customizerState = CardCustomizerInitial( + 0, + 0, + const [CardDesign.gradientOnlyDesign], + const [CardDesign.gradientBlue], + "Savings", + archivedAccount.id, + false, + 1, + ); + final savedState = CardCustomizerSaved( + 0, + 0, + const [CardDesign.gradientOnlyDesign], + const [CardDesign.gradientBlue], + "Savings", + archivedAccount.id, + false, + 1, + ); + + final accounts = [remainingAccount, archivedAccount]; + when(() => accountListViewModel.accounts).thenAnswer((_) => accounts); + when(() => accountListViewModel.select(any())).thenAnswer((invocation) async { + selections.add((invocation.positionalArguments.single as AccountListItem).id); + }); + when(() => dashboardViewModel.status).thenReturn(SyncedSyncStatus()); + when(() => dashboardViewModel.settingsStore).thenReturn(settingsStore); + when(() => dashboardViewModel.loadCardDesigns()).thenAnswer((_) async { + cardDesignLoads++; + if (pauseCardDesignLoad) { + await finishArchiveReload.future; + } + }); + when(() => balanceViewModel.accountFiatBalance(any())).thenReturn("0,00 USD"); + when(() => wallet.type).thenReturn(WalletType.monero); + when(() => wallet.currency).thenReturn(CryptoCurrency.xmr); + when(() => bloc.state).thenAnswer((_) => customizerState); + when(() => bloc.close()).thenAnswer((_) async {}); + when(() => bloc.stream).thenAnswer((_) => states.stream); + when(() => bloc.canHide).thenAnswer((_) => canHide); + when(() => bloc.add(any())).thenAnswer((invocation) { + final event = invocation.positionalArguments.single as CardCustomizerEvent; + if (event is AccountNameChanged) { + customizerState = customizerState.copyWith(accountName: event.newAccountName); + return; + } + events.add(event.runtimeType); + + if (event is DesignSaved) { + scheduleMicrotask(() => states.add(savedState)); + } else if (event is AccountHidden) { + // Simulate AccountHidden's persisted output so WalletAccountsPage reloads visible cards. + scheduleMicrotask(() { + unawaited( + BalanceCardStyleSettings.fromCardDesign( + walletInfoId: 42, + accountIndex: archivedAccount.id, + cardOrder: 1, + design: CardDesign.gradientOnlyDesign, + hidden: true, + ).insert().then((_) => states.add(savedState)), + ); + }); + } + }); + + var scopeOpen = true; + getIt.pushNewScope(); + addTearDown(() async { + if (!finishArchiveReload.isCompleted) { + finishArchiveReload.complete(); + } + if (!states.isClosed) { + await states.close(); + } + if (scopeOpen) { + await getIt.popScope(); + } + }); + getIt.registerFactoryParam( + (params, _) { + canHideValues.add(canHide = params.canHide); + return bloc; + }, + ); + + await tester.pumpWidget( + testApp( + Scaffold( + body: WalletAccountsPage( + accountListViewModel: accountListViewModel, + accountEditOrCreateViewModel: _MockAccountEditOrCreateViewModel(), + dashboardViewModel: dashboardViewModel, + ), + ), + ), + ); + await _pumpUntil(tester, () => find.text("Add Account").evaluate().isNotEmpty); + + ModernButton archiveButton() => tester.widget( + find.byWidgetPredicate( + (widget) => widget is ModernButton && widget.semanticLabel == "Archived Accounts", + ), + ); + + expect(archiveButton().backgroundColor, isNull); + expect(archiveButton().iconColor, isNull); + + final frontCard = tester + .widgetList(find.byType(BalanceCard)) + .singleWhere((card) => card.onCustomizeTapped != null); + expect(frontCard.fiatBalance, "0,00 USD"); + frontCard.onCustomizeTapped!(); + await tester.pumpAndSettle(); + expect(find.byType(CardCustomizer), findsOneWidget); + expect(canHideValues, [true]); + expect(find.text("Archive Account"), findsOneWidget); + + await tester.enterText(find.byType(TextField), "Long-term Savings"); + await tester.ensureVisible(find.text("Archive Account")); + await tester.tap(find.text("Archive Account")); + await tester.pumpAndSettle(); + expect(find.text("2. Long-term Savings"), findsOneWidget); + + await tester.tap(find.text("Cancel")); + await tester.pumpAndSettle(); + await tester.tap(find.text("Archive Account")); + await tester.pumpAndSettle(); + + final loadsBeforeArchive = cardDesignLoads; + pauseCardDesignLoad = true; + await tester.tap(find.text("Continue")); + await tester.pump(); + await _pumpUntil( + tester, + () => events.length == 2 && selections.length == 2 && cardDesignLoads > loadsBeforeArchive, + ); + + expect(events, [DesignSaved, AccountHidden]); + expect(selections, [archivedAccount.id, remainingAccount.id]); + await tester.pumpWidget(testApp(const SizedBox.shrink())); + pauseCardDesignLoad = false; + finishArchiveReload.complete(); + await tester.pump(); + final archivedSetting = await tester.runAsync(() async { + // Queue the final read behind both disposal writes from the unfixed flow. + await BalanceCardStyleSettings.getAll(42); + await BalanceCardStyleSettings.getAll(42); + return BalanceCardStyleSettings.get(42, archivedAccount.id); + }); + expect(archivedSetting?.hidden, isTrue); + expect(selections, [archivedAccount.id, remainingAccount.id]); + + await tester.pumpWidget( + testApp( + Scaffold( + body: WalletAccountsPage( + accountListViewModel: accountListViewModel, + accountEditOrCreateViewModel: _MockAccountEditOrCreateViewModel(), + dashboardViewModel: dashboardViewModel, + ), + ), + ), + ); + await _pumpUntil(tester, () => find.text("Add Account").evaluate().isNotEmpty); + + final colorScheme = Theme.of(tester.element(find.byType(WalletAccountsPage))).colorScheme; + expect(archiveButton().backgroundColor, colorScheme.primary); + expect(archiveButton().iconColor, colorScheme.onPrimary); + + final remainingFrontCard = tester + .widgetList(find.byType(BalanceCard)) + .singleWhere((card) => card.onCustomizeTapped != null); + remainingFrontCard.onCustomizeTapped!(); + await tester.pumpAndSettle(); + + expect(canHideValues, [true, false]); + expect(find.text("Archive Account"), findsNothing); + + Navigator.of(tester.element(find.byType(CardCustomizer))).pop(false); + await tester.pump(); + await _pumpUntil(tester, () => events.length == 3 && selections.length == 3); + expect(events.last, DesignSaved); + expect(selections.last, remainingAccount.id); + + final loadsBeforeDispose = cardDesignLoads; + await tester.pumpWidget(testApp(const SizedBox.shrink())); + await _pumpUntil(tester, () => cardDesignLoads > loadsBeforeDispose); + await states.close(); + await getIt.popScope(); + scopeOpen = false; + }); +} diff --git a/test/new-ui/pages/card_customizer_test.dart b/test/new-ui/pages/card_customizer_test.dart new file mode 100644 index 000000000..c979cb6c2 --- /dev/null +++ b/test/new-ui/pages/card_customizer_test.dart @@ -0,0 +1,252 @@ +import "dart:async"; + +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/entities/balance_display_mode.dart"; +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/locales/locale.dart"; +import "package:cake_wallet/new-ui/pages/card_customizer.dart"; +import "package:cake_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart"; +import "package:cake_wallet/new-ui/widgets/money/money_settings_provider.dart"; +import "package:cake_wallet/themes/core/theme_store.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/card_design.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter/material.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mocktail/mocktail.dart"; + +class _MockCardCustomizerBloc extends Mock implements CardCustomizerBloc {} + +class _MockDashboardViewModel extends Mock implements DashboardViewModel {} + +class _MockSettingsStore extends Mock implements SettingsStore {} + +class _RouteTracker { + bool completed = false; + bool? result; +} + +CardCustomizerState _accountState(String accountName) => CardCustomizerInitial( + 0, + 0, + const [CardDesign.gradientOnlyDesign], + const [CardDesign.gradientBlue], + accountName, + 0, + false, + 0, + ); + +Future<_RouteTracker> _openCustomizer( + WidgetTester tester, + CardCustomizerBloc bloc, { + required DashboardViewModel dashboardViewModel, + required AccountListItem? account, + String fiatBalance = "", +}) async { + final tracker = _RouteTracker(); + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + builder: (_, child) => MoneySettingsProvider( + settingsStore: dashboardViewModel.settingsStore, + child: child!, + ), + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () { + Navigator.of(context) + .push( + MaterialPageRoute( + builder: (_) => BlocProvider.value( + value: bloc, + child: Material( + child: CardCustomizer( + cryptoTitle: "Monero", + cryptoName: "xmr", + account: account, + fiatBalance: fiatBalance, + ), + ), + ), + ), + ) + .then((result) { + tracker + ..completed = true + ..result = result; + }); + }, + child: const Text("Open card customizer"), + ), + ), + ), + ), + ); + await tester.tap(find.text("Open card customizer")); + await tester.pumpAndSettle(); + return tracker; +} + +void main() { + late _MockCardCustomizerBloc bloc; + late _MockDashboardViewModel dashboardViewModel; + late AccountListItem account; + late StreamController stateController; + + setUpAll(() { + getIt.registerSingleton(ThemeStore()); + }); + + tearDownAll(() async { + await getIt.unregister(); + }); + + setUp(() { + bloc = _MockCardCustomizerBloc(); + dashboardViewModel = _MockDashboardViewModel(); + account = + AccountListItem(id: 0, label: "Savings", balance: Money.parse("1.25", CryptoCurrency.xmr)); + final settingsStore = _MockSettingsStore(); + when(() => settingsStore.balanceDisplayMode).thenReturn(BalanceDisplayMode.displayableBalance); + when(() => dashboardViewModel.settingsStore).thenReturn(settingsStore); + when(() => settingsStore.displayAmountsInSatoshi).thenReturn(BitcoinAmountDisplayMode.bitcoin); + stateController = StreamController.broadcast(); + when(() => bloc.stream).thenAnswer((_) => stateController.stream); + when(() => bloc.canHide).thenReturn(true); + }); + + tearDown(() async { + await stateController.close(); + }); + + testWidgets("account mode renders edit controls and cancels funded archival", (tester) async { + when(() => bloc.state).thenReturn(_accountState("")); + final tracker = await _openCustomizer( + tester, + bloc, + dashboardViewModel: dashboardViewModel, + account: account, + fiatBalance: "2.50 USD", + ); + + expect(find.text("Edit Account"), findsOneWidget); + expect(find.text("#1"), findsOneWidget); + expect(find.text("Unnamed Account"), findsOneWidget); + expect(find.text("1.25"), findsWidgets); + expect(find.text("2.50 USD"), findsOneWidget); + expect(find.text("Account name"), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + expect(find.text("Archive Account"), findsOneWidget); + expect(find.text("This change can be reverted"), findsOneWidget); + expect( + tester.getTopLeft(find.byType(BalanceCard).first).dy, + lessThan(tester.getTopLeft(find.text("Account name")).dy), + ); + + await tester.ensureVisible(find.text("Archive Account")); + await tester.pumpAndSettle(); + await tester.tap(find.text("Archive Account")); + await tester.pumpAndSettle(); + + expect(find.text("Are you sure you want to Archive this account?"), findsOneWidget); + expect(find.text("This account has the following funds:"), findsOneWidget); + expect(find.text("1.25 XMR"), findsOneWidget); + expect( + find.text("Before proceeding, it is recommended you move them to an account you will use"), + findsOneWidget, + ); + expect( + find.text( + "Archiving an account does not delete any funds or activity. You can reverse this action", + ), + findsOneWidget, + ); + + await tester.tap(find.text("Cancel")); + await tester.pumpAndSettle(); + + expect(find.byType(CardCustomizer), findsOneWidget); + expect(tracker.completed, isFalse); + }); + + testWidgets("confirming hidden empty-account archival returns true to the parent", + (tester) async { + account = + AccountListItem(id: 0, label: "Savings", balance: Money.parse("0", CryptoCurrency.xmr)); + final settingsStore = dashboardViewModel.settingsStore; + when(() => settingsStore.balanceDisplayMode).thenReturn(BalanceDisplayMode.hiddenBalance); + when(() => bloc.state).thenReturn(_accountState("Savings")); + final tracker = await _openCustomizer( + tester, + bloc, + dashboardViewModel: dashboardViewModel, + account: account, + ); + + await tester.ensureVisible(find.text("Archive Account")); + await tester.pumpAndSettle(); + await tester.tap(find.text("Archive Account")); + await tester.pumpAndSettle(); + + expect(find.text("1. Savings"), findsOneWidget); + expect(find.text("This account has the following funds:"), findsNothing); + expect( + find.text( + "This action won’t delete the account or its past activity, but only hide it inside Cake Wallet", + ), + findsOneWidget, + ); + expect(find.text("You can reverse this action from Accounts settings"), findsOneWidget); + + await tester.tap(find.text("Continue")); + await tester.pumpAndSettle(); + + expect(find.byType(CardCustomizer), findsNothing); + expect(tracker.completed, isTrue); + expect(tracker.result, isTrue); + }); + + testWidgets("account editing remains available when archival is disabled", (tester) async { + when(() => bloc.state).thenReturn(_accountState("Savings")); + when(() => bloc.canHide).thenReturn(false); + + await _openCustomizer( + tester, + bloc, + dashboardViewModel: dashboardViewModel, + account: account, + ); + + expect(find.text("Edit Account"), findsOneWidget); + expect(find.text("Account name"), findsOneWidget); + expect(find.byType(TextField), findsOneWidget); + expect(find.text("Savings"), findsWidgets); + expect(find.text("Archive Account"), findsNothing); + }); + + testWidgets("wallet card editing does not require an account", (tester) async { + when(() => bloc.state).thenReturn(_accountState("")); + when(() => bloc.canHide).thenReturn(false); + + await _openCustomizer( + tester, + bloc, + dashboardViewModel: dashboardViewModel, + account: null, + ); + + expect(find.text("Edit Card"), findsOneWidget); + expect(find.byType(BalanceCard), findsWidgets); + expect(find.byType(TextField), findsNothing); + expect(find.text("Archive Account"), findsNothing); + }); +} diff --git a/test/new-ui/pages/settings_page_test.dart b/test/new-ui/pages/settings_page_test.dart index c06cc6793..03745e1ad 100644 --- a/test/new-ui/pages/settings_page_test.dart +++ b/test/new-ui/pages/settings_page_test.dart @@ -1,125 +1,329 @@ import "package:cake_wallet/core/auth_service.dart"; import "package:cake_wallet/di.dart"; import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart"; import "package:cake_wallet/locales/locale.dart"; -import "package:cake_wallet/new-ui/pages/account_customizer.dart"; +import "package:cake_wallet/new-ui/pages/wallet_accounts_page.dart"; import "package:cake_wallet/new-ui/pages/settings_page.dart"; +import "package:cake_wallet/new-ui/widgets/modern_button.dart"; import "package:cake_wallet/routes.dart"; import "package:cake_wallet/view_model/dashboard/balance_view_model.dart"; import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; -import "package:cake_wallet/view_model/monero_account_list/account_list_item.dart"; -import "package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart"; +import "package:cake_wallet/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart"; +import "package:cake_wallet/wallet_types.g.dart"; import "package:cw_core/balance.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/db/sqlite.dart" as sqlite; import "package:cw_core/transaction_history.dart"; import "package:cw_core/transaction_info.dart"; import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_info.dart"; import "package:cw_core/wallet_type.dart"; import "package:flutter/material.dart"; +import "package:flutter/services.dart"; import "package:flutter_test/flutter_test.dart"; import "package:mocktail/mocktail.dart"; +import "package:sqflite/sqflite.dart"; class MockBalanceViewModel extends Mock implements BalanceViewModel {} class MockDashboardViewModel extends Mock implements DashboardViewModel {} +class _MockAccountEditOrCreateViewModel extends Mock implements WalletAccountEditOrCreateViewModel {} + class _MockAccountListViewModel extends Mock implements MoneroAccountListViewModel {} class _MockAuthService extends Mock implements AuthService {} +class _MockDatabase extends Mock implements Database {} + class _MockWallet extends Mock implements WalletBase, TransactionInfo> {} +class _MockWalletInfo extends Mock implements WalletInfo {} + void main() { + late S english; + late S french; + late S portuguese; late MockBalanceViewModel balanceViewModel; late MockDashboardViewModel dashboardViewModel; + late _MockWallet wallet; + late _MockAuthService authService; - setUpAll(() => S.delegate.load(const Locale("en"))); + setUpAll(() async { + english = await S.delegate.load(const Locale("en")); + french = await S.delegate.load(const Locale("fr")); + portuguese = await S.delegate.load(const Locale("pt")); + await S.delegate.load(const Locale("en")); + }); setUp(() { balanceViewModel = MockBalanceViewModel(); dashboardViewModel = MockDashboardViewModel(); + wallet = _MockWallet(); + authService = _MockAuthService(); + when(() => dashboardViewModel.balanceViewModel).thenReturn(balanceViewModel); - }); - - SettingsListItem accountsItem() => SettingsSectionData.walletSettings.items.singleWhere( - (item) => item.route == Routes.accountCustomizer, - ); - - test("Accounts is the first Wallet Settings item and routes with the dashboard", () { - final item = accountsItem(); - - expect(SettingsSectionData.walletSettings.items.first, same(item)); - expect(item.iconPath, "assets/new-ui/settings_row_icons/accounts.svg"); - expect(item.title, S.current.accounts); - expect(item.route, Routes.accountCustomizer); - expect(item.routeArgs, isNull); - expect(item.routeArgsBuilder, isNotNull); - expect(item.routeArgsBuilder!(dashboardViewModel), same(dashboardViewModel)); - }); - - test("Accounts visibility follows account support", () { - final item = accountsItem(); - - when(() => balanceViewModel.hasAccounts).thenReturn(true); - expect(item.condition(dashboardViewModel), isTrue); - - when(() => balanceViewModel.hasAccounts).thenReturn(false); - expect(item.condition(dashboardViewModel), isFalse); - }); - - testWidgets("Accounts opens through the settings modal with the active dashboard", - (tester) async { - final accountListViewModel = _MockAccountListViewModel(); - final authService = _MockAuthService(); - final wallet = _MockWallet(); - DashboardViewModel? routedDashboardViewModel; - - when(() => balanceViewModel.hasAccounts).thenReturn(true); - when(() => dashboardViewModel.hasLightning).thenReturn(false); - when(() => dashboardViewModel.hasWalletConnect).thenReturn(false); + when(() => dashboardViewModel.isMultiAccountsEnabled).thenReturn(true); + when(() => dashboardViewModel.canToggleMultiAccounts).thenReturn(false); when(() => dashboardViewModel.wallet).thenReturn(wallet); - when(() => dashboardViewModel.loadCardDesigns()).thenAnswer((_) async {}); - when(() => wallet.type).thenReturn(WalletType.monero); + when(() => dashboardViewModel.hasLightning).thenReturn(false); + when(() => dashboardViewModel.hasSilentPayments).thenReturn(false); + when(() => dashboardViewModel.hasMweb).thenReturn(false); + when(() => dashboardViewModel.hasWalletConnect).thenReturn(false); + when(() => wallet.hasAccountsSupport).thenReturn(false); + when(() => wallet.hasCoinControl).thenReturn(false); + when(() => wallet.name).thenReturn("My Cake Wallet"); when(() => wallet.hardwareWalletType).thenReturn(null); - when(() => accountListViewModel.accounts).thenReturn(const []); - when(() => accountListViewModel.selected).thenReturn( - AccountListItem(label: "Primary", id: 0, isSelected: true), - ); + }); - getIt.pushNewScope(scopeName: "settings-accounts-route-test"); - getIt.registerFactoryParam( - (routedViewModel, _) { - routedDashboardViewModel = routedViewModel; - return AccountCustomizer( - accountListViewModel: accountListViewModel, - dashboardViewModel: routedViewModel, - ); - }, - ); - - try { - await tester.pumpWidget( - MaterialApp( - localizationsDelegates: localizationDelegates, - supportedLocales: S.delegate.supportedLocales, - home: NewSettingsPage( - dashboardViewModel: dashboardViewModel, - authService: authService, - ), + Widget settingsApp({Locale locale = const Locale("en")}) => MaterialApp( + locale: locale, + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + home: NewSettingsPage( + dashboardViewModel: dashboardViewModel, + authService: authService, ), ); + + test("wallet settings follow capabilities rather than wallet type", () { + const resolver = WalletSettingsResolver(); + when(() => wallet.type).thenReturn(WalletType.solana); + when(() => wallet.hasCoinControl).thenReturn(true); + when(() => dashboardViewModel.hasWalletConnect).thenReturn(true); + + expect( + resolver.resolveSections(dashboardViewModel).map( + (section) => section.map((item) => item.route), + ), + [ + [Routes.manageNodes, Routes.unspentCoinsList], + [Routes.walletConnectConnectionsListing], + ], + ); + + when(() => wallet.hasCoinControl).thenReturn(false); + when(() => dashboardViewModel.hasWalletConnect).thenReturn(false); + + expect( + resolver.resolveSections(dashboardViewModel).single.map((item) => item.route), + [Routes.manageNodes], + ); + }); + + test("Monero resync and Litecoin MWEB retain their feature section", () { + const resolver = WalletSettingsResolver(); + when(() => wallet.type).thenReturn(WalletType.monero); + when(() => wallet.hardwareWalletType).thenReturn(HardwareWalletType.trezor); + + expect( + resolver.resolveSections(dashboardViewModel).last.single.route, + Routes.syncKeyImagesDevices, + ); + + when(() => wallet.type).thenReturn(WalletType.litecoin); + when(() => wallet.hardwareWalletType).thenReturn(null); + when(() => dashboardViewModel.hasMweb).thenReturn(true); + + expect( + resolver.resolveSections(dashboardViewModel).last.single.route, + Routes.mwebSettings, + ); + }); + + test("settings icons are bundled for available wallet types", () async { + const resolver = WalletSettingsResolver(); + for (final type in availableWalletTypes) { + final path = resolver.iconPathFor(type); + final asset = await rootBundle.load("$path.vec"); + expect(asset.lengthInBytes, greaterThan(0), reason: "$type: $path"); + } + }); + + test("app settings expose only implemented destinations in the Figma order", () { + final items = const SettingsPageSectionsResolver().appSettings(); + + expect( + items.map((item) => item.route), + [ + Routes.connectionSync, + Routes.displaySettingsPage, + Routes.securityBackupPage, + Routes.backup, + ], + ); + }); + + test("runtime wallet capabilities filter supported Bitcoin settings", () { + when(() => wallet.type).thenReturn(WalletType.bitcoin); + when(() => wallet.hasCoinControl).thenReturn(true); + when(() => dashboardViewModel.hasLightning).thenReturn(true); + + final sections = const WalletSettingsResolver().resolveSections(dashboardViewModel); + final routes = sections.expand((section) => section).map((item) => item.route); + + expect(routes, contains(Routes.manageNodes)); + expect(routes, contains(Routes.unspentCoinsList)); + expect(routes, contains(Routes.lightningUsernamePage)); + expect(routes, isNot(contains(Routes.walletAccountsPage))); + expect(routes, isNot(contains(Routes.silentPaymentsSettings))); + }); + + testWidgets("main Settings shows the active wallet and wallet-general/app groups", + (tester) async { + when(() => wallet.type).thenReturn(WalletType.monero); + + await tester.pumpWidget(settingsApp()); + await tester.pumpAndSettle(); + + expect(find.text("My Cake Wallet"), findsOneWidget); + expect(find.text("Monero ${english.settings_title}"), findsOneWidget); + expect(find.text(english.privacy), findsOneWidget); + expect(find.text(english.recovery_and_keys), findsOneWidget); + expect(find.text(english.other), findsOneWidget); + expect(find.text(english.app_settings), findsOneWidget); + expect(find.text(english.connections), findsOneWidget); + expect(find.text(english.display), findsOneWidget); + expect(find.text(english.security), findsOneWidget); + expect(find.text(english.backup), findsOneWidget); + expect(find.text(english.accounts), findsNothing); + expect(find.text(english.nodes), findsNothing); + }); + + testWidgets("wallet settings title and rows follow the active Bitcoin capabilities", + (tester) async { + when(() => wallet.type).thenReturn(WalletType.bitcoin); + when(() => wallet.hasCoinControl).thenReturn(true); + when(() => dashboardViewModel.hasLightning).thenReturn(true); + when(() => dashboardViewModel.hasSilentPayments).thenReturn(true); + + await tester.pumpWidget(settingsApp()); + await tester.pumpAndSettle(); + await tester.tap(find.text("Bitcoin ${english.settings_title}")); + await tester.pumpAndSettle(); + + expect(find.text("Bitcoin ${english.settings_title}"), findsOneWidget); + expect(find.text(english.nodes), findsOneWidget); + expect(find.text(english.coin_control_settings), findsOneWidget); + expect(find.text("Lightning ${english.username}"), findsOneWidget); + expect(find.text(english.silent_payments), findsOneWidget); + expect(find.text(english.accounts), findsNothing); + expect(find.text(english.privacy), findsNothing); + }); + + testWidgets("long localized wallet settings title stays clear of the back control", + (tester) async { + await tester.binding.setSurfaceSize(const Size(320, 640)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + when(() => wallet.type).thenReturn(WalletType.bsc); + + await tester.pumpWidget(settingsApp(locale: const Locale("pt"))); + await tester.pumpAndSettle(); + + final title = "BNB Smart Chain ${portuguese.settings_title}"; + await tester.tap(find.text(title)); + await tester.pumpAndSettle(); + + final titleFinder = find.text(title); + final titleText = tester.widget(titleFinder); + final backButton = find.byType(ModernButton); + final titleGroup = find.byKey(ValueKey(title)); + + expect(titleText.maxLines, 1); + expect(titleText.overflow, TextOverflow.ellipsis); + expect(tester.getRect(titleGroup).left, greaterThanOrEqualTo(tester.getRect(backButton).right)); + expect(tester.getRect(titleGroup).center.dx, 160); + expect(tester.takeException(), isNull); + }); + + testWidgets("settings labels rebuild when the locale changes", (tester) async { + when(() => wallet.type).thenReturn(WalletType.monero); + + await tester.pumpWidget(settingsApp()); + await tester.pumpAndSettle(); + expect(find.text(english.privacy), findsOneWidget); + + await tester.pumpWidget(settingsApp(locale: const Locale("fr"))); + await tester.pumpAndSettle(); + + expect(find.text(french.privacy), findsOneWidget); + if (english.privacy != french.privacy) { + expect(find.text(english.privacy), findsNothing); + } + }); + + testWidgets("Accounts opens through nested wallet settings with the active dashboard", + (tester) async { + final accountListViewModel = _MockAccountListViewModel(); + final database = _MockDatabase(); + final walletInfo = _MockWalletInfo(); + final originalDatabase = sqlite.db; + var scopeOpen = false; + DashboardViewModel? routedDashboardViewModel; + + when(() => wallet.hasAccountsSupport).thenReturn(true); + when(() => dashboardViewModel.loadCardDesigns()).thenAnswer((_) async {}); + when(() => wallet.type).thenReturn(WalletType.monero); + when(() => wallet.currency).thenReturn(CryptoCurrency.xmr); + when(() => wallet.walletInfo).thenReturn(walletInfo); + when(() => walletInfo.internalId).thenReturn(42); + when(() => accountListViewModel.accounts).thenReturn(const []); + when( + () => database.query( + any(), + where: any(named: "where"), + whereArgs: any(named: "whereArgs"), + ), + ).thenAnswer((_) async => const []); + + try { + sqlite.db = database; + getIt.pushNewScope(scopeName: "settings-accounts-route-test"); + scopeOpen = true; + getIt.registerFactoryParam( + (routedViewModel, _) { + routedDashboardViewModel = routedViewModel; + return WalletAccountsPage( + accountListViewModel: accountListViewModel, + accountEditOrCreateViewModel: _MockAccountEditOrCreateViewModel(), + dashboardViewModel: routedViewModel, + ); + }, + ); + + await tester.pumpWidget(settingsApp()); await tester.pumpAndSettle(); - await tester.tap(find.text(S.current.accounts)); + await tester.tap(find.text("Monero ${english.settings_title}")); + await tester.pumpAndSettle(); + await tester.tap(find.text(english.accounts)); await tester.pumpAndSettle(); expect(routedDashboardViewModel, same(dashboardViewModel)); - expect(find.byType(AccountCustomizer), findsOneWidget); + expect(find.byType(WalletAccountsPage), findsOneWidget); expect(tester.takeException(), isNull); } finally { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); - await getIt.popScope(); + if (scopeOpen) { + await getIt.popScope(); + } + sqlite.db = originalDatabase; } }); + + test("Bitcoin account copy is explicitly onchain when the capability is available", () { + when(() => wallet.hasAccountsSupport).thenReturn(true); + when(() => wallet.type).thenReturn(WalletType.bitcoin); + + final accountItem = const WalletSettingsResolver() + .resolveSections(dashboardViewModel) + .expand((section) => section) + .singleWhere((item) => item.route == Routes.walletAccountsPage); + + expect(accountItem.title, english.accounts_onchain); + }); } diff --git a/test/new-ui/viewmodels/card_customizer/card_customizer_bloc_test.dart b/test/new-ui/viewmodels/card_customizer/card_customizer_bloc_test.dart new file mode 100644 index 000000000..e3e55d80b --- /dev/null +++ b/test/new-ui/viewmodels/card_customizer/card_customizer_bloc_test.dart @@ -0,0 +1,123 @@ +import "package:cake_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart"; +import "package:cake_wallet/wownero/wownero.dart" as wow; +import "package:cw_core/account.dart"; +import "package:cw_core/balance.dart"; +import "package:cw_core/balance_card_style_settings.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/db/sqlite.dart" as sqlite; +import "package:cw_core/transaction_history.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mocktail/mocktail.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +class _MockWallet extends Mock + implements WalletBase, TransactionInfo> {} + +class _MockWalletInfo extends Mock implements WalletInfo {} + +class _MockWownero extends Mock implements wow.Wownero {} + +class _MockWowneroAccountList extends Mock implements wow.WowneroAccountList {} + +void main() { + late DatabaseFactory? originalDatabaseFactory; + late Database? originalDatabase; + late wow.Wownero? originalWownero; + late _MockWallet wallet; + late _MockWownero wowneroAdapter; + late _MockWowneroAccountList accountList; + + setUpAll(() { + originalDatabaseFactory = databaseFactoryOrNull; + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + tearDownAll(() { + databaseFactoryOrNull = originalDatabaseFactory; + }); + + setUp(() async { + originalDatabase = sqlite.db; + originalWownero = wow.wownero; + sqlite.db = await databaseFactory.openDatabase(inMemoryDatabasePath); + await sqlite.db!.execute(""" + CREATE TABLE ${BalanceCardStyleSettings.tableName} ( + walletInfoId INTEGER, + accountIndex INTEGER DEFAULT -1, + gradientIndex INTEGER DEFAULT -1, + useSpecialDesign BOOLEAN DEFAULT FALSE, + hidden BOOLEAN DEFAULT FALSE, + backgroundImagePath TEXT DEFAULT "", + iconStyleIndex INTEGER DEFAULT 0, + isGradientOnly BOOLEAN DEFAULT FALSE, + cardOrder INTEGER DEFAULT 0, + PRIMARY KEY (walletInfoId, accountIndex) + ) + """); + + wallet = _MockWallet(); + final walletInfo = _MockWalletInfo(); + wowneroAdapter = _MockWownero(); + accountList = _MockWowneroAccountList(); + wow.wownero = wowneroAdapter; + + when(() => wallet.hasNativeAccounts).thenReturn(true); + when(() => wallet.type).thenReturn(WalletType.wownero); + when(() => wallet.currency).thenReturn(CryptoCurrency.wow); + when(() => wallet.walletInfo).thenReturn(walletInfo); + when(() => walletInfo.internalId).thenReturn(42); + when(() => wallet.save()).thenAnswer((_) async {}); + when(() => wowneroAdapter.getCurrentAccount(wallet)) + .thenReturn(Account(id: 3, label: "Savings", balance: "2.0")); + when(() => wowneroAdapter.getAccountList(wallet)).thenReturn(accountList); + when( + () => accountList.setLabelAccount( + wallet, + accountIndex: any(named: "accountIndex"), + label: any(named: "label"), + ), + ).thenAnswer((_) async {}); + }); + + tearDown(() async { + wow.wownero = originalWownero; + await sqlite.db?.close(); + sqlite.db = originalDatabase; + }); + + test("Wownero customization saves the selected account identity and name", () async { + final bloc = CardCustomizerBloc(wallet); + addTearDown(bloc.close); + + final initial = await bloc.stream.firstWhere((state) => state is CardCustomizerInitial); + expect(initial.accountIndex, 3); + expect(initial.accountName, "Savings"); + expect(bloc.canHide, isFalse); + + final renamed = bloc.stream.firstWhere((state) => state.accountName == "Long-term Savings"); + bloc.add(AccountNameChanged("Long-term Savings")); + await renamed; + + final saved = bloc.stream.firstWhere((state) => state is CardCustomizerSaved); + bloc.add(DesignSaved()); + await saved; + + verify( + () => accountList.setLabelAccount( + wallet, + accountIndex: 3, + label: "Long-term Savings", + ), + ).called(1); + verify(() => wallet.save()).called(1); + + final persisted = await BalanceCardStyleSettings.get(42, 3); + expect(persisted, isNotNull); + expect(await BalanceCardStyleSettings.get(42, -1), isNull); + }); +} diff --git a/test/new-ui/widgets/coins_page/accounts_promo_test.dart b/test/new-ui/widgets/coins_page/accounts_promo_test.dart new file mode 100644 index 000000000..8d27a8486 --- /dev/null +++ b/test/new-ui/widgets/coins_page/accounts_promo_test.dart @@ -0,0 +1,76 @@ +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/locales/locale.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/accounts_promo.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mobx/mobx.dart" show Observable, runInAction; +import "package:mocktail/mocktail.dart"; + +class _MockSettingsStore extends Mock implements SettingsStore { + final _dismissed = Observable(false); + + @override + bool get accountsHomePromoDismissed => _dismissed.value; + + @override + set accountsHomePromoDismissed(bool value) => runInAction(() => _dismissed.value = value); +} + +void main() { + late _MockSettingsStore settingsStore; + late int openCount; + + setUp(() { + settingsStore = _MockSettingsStore(); + openCount = 0; + }); + + Widget testApp() => MaterialApp( + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + home: Scaffold( + body: AccountsPromo( + settingsStore: settingsStore, + walletName: "Bitcoin", + onTap: () => openCount++, + ), + ), + ); + + test("accounts promo is only eligible for Bitcoin", () { + expect(AccountsPromo.supportsWallet(WalletType.bitcoin), isTrue); + expect(AccountsPromo.supportsWallet(WalletType.monero), isFalse); + expect(AccountsPromo.supportsWallet(WalletType.wownero), isFalse); + }); + + testWidgets("shows generic current-wallet copy and opens Accounts", (tester) async { + await tester.pumpWidget(testApp()); + + expect(find.text("Accounts for Bitcoin are here!"), findsOneWidget); + expect(find.text("Manage all your assets in a unified interface"), findsOneWidget); + expect(find.text("Don’t show this anymore"), findsOneWidget); + + await tester.tap(find.text("Accounts for Bitcoin are here!")); + expect(openCount, 1); + }); + + testWidgets("explicit dismissal updates the store and hides only the promo", (tester) async { + await tester.pumpWidget(testApp()); + + await tester.tap(find.text("Don’t show this anymore")); + await tester.pumpAndSettle(); + + expect(find.text("Accounts for Bitcoin are here!"), findsNothing); + expect(settingsStore.accountsHomePromoDismissed, isTrue); + expect(openCount, 0); + }); + + testWidgets("a previously dismissed promo stays hidden", (tester) async { + settingsStore.accountsHomePromoDismissed = true; + await tester.pumpWidget(testApp()); + + expect(find.text("Accounts for Bitcoin are here!"), findsNothing); + }); +} diff --git a/test/new-ui/widgets/coins_page/assets_history/assets_top_bar_test.dart b/test/new-ui/widgets/coins_page/assets_history/assets_top_bar_test.dart new file mode 100644 index 000000000..625f3158b --- /dev/null +++ b/test/new-ui/widgets/coins_page/assets_history/assets_top_bar_test.dart @@ -0,0 +1,53 @@ +import "package:cake_wallet/new-ui/widgets/coins_page/assets_history/assets_history_section.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mocktail/mocktail.dart"; + +class _MockDashboardViewModel extends Mock implements DashboardViewModel {} + +void main() { + testWidgets("uses the same assets gap for EVM and non-EVM wallets", (tester) async { + final dashboardViewModel = _MockDashboardViewModel(); + const tabs = [AssetsHistorySectionTab("Assets", SizedBox.shrink(), null)]; + + Future pumpTopBar({required bool isEVMWallet}) async { + when(() => dashboardViewModel.isEVMWallet).thenReturn(isEVMWallet); + + await tester.pumpWidget( + MaterialApp( + home: CustomScrollView( + slivers: [ + AssetsTopBar( + onTabChange: (_) {}, + onTransactionHistoryOpened: () {}, + selectedTab: 0, + tabs: tabs, + dashboardViewModel: dashboardViewModel, + ), + ], + ), + ), + ); + + final sliver = tester.widget( + find.descendant( + of: find.byType(AssetsTopBar), + matching: find.byType(SliverToBoxAdapter), + ), + ); + + return (sliver.child! as Padding).padding; + } + + expect( + await pumpTopBar(isEVMWallet: true), + const EdgeInsets.only(top: 20, left: 12, right: 18), + ); + expect( + await pumpTopBar(isEVMWallet: false), + const EdgeInsets.only(top: 20, left: 12, right: 18), + ); + }); +} diff --git a/test/view_model/dashboard/dashboard_view_model_account_cards_test.dart b/test/view_model/dashboard/dashboard_view_model_account_cards_test.dart new file mode 100644 index 000000000..5fbb4f7b1 --- /dev/null +++ b/test/view_model/dashboard/dashboard_view_model_account_cards_test.dart @@ -0,0 +1,215 @@ +import "package:cake_wallet/core/key_service.dart"; +import "package:cake_wallet/core/trade_monitor.dart"; +import "package:cake_wallet/store/anonpay/anonpay_transactions_store.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cake_wallet/store/dashboard/order_filter_store.dart"; +import "package:cake_wallet/store/dashboard/orders_store.dart"; +import "package:cake_wallet/store/dashboard/payjoin_transactions_store.dart"; +import "package:cake_wallet/store/dashboard/trade_filter_store.dart"; +import "package:cake_wallet/store/dashboard/trades_store.dart"; +import "package:cake_wallet/store/dashboard/transaction_filter_store.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:cake_wallet/view_model/dashboard/balance_view_model.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cake_wallet/wownero/wownero.dart" as wow; +import "package:cw_core/account.dart"; +import "package:cw_core/balance.dart"; +import "package:cw_core/balance_card_style_settings.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/db/sqlite.dart" as sqlite; +import "package:cw_core/transaction_history.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mobx/mobx.dart" show ObservableList, ObservableMap; +import "package:mocktail/mocktail.dart"; +import "package:shared_preferences/shared_preferences.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +class _MockBalanceViewModel extends Mock implements BalanceViewModel {} + +class _MockTradeMonitor extends Mock implements TradeMonitor {} + +class _MockAppStore extends Mock implements AppStore {} + +class _MockTradesStore extends Mock implements TradesStore {} + +class _MockTradeFilterStore extends Mock implements TradeFilterStore {} + +class _MockOrderFilterStore extends Mock implements OrderFilterStore {} + +class _MockTransactionFilterStore extends Mock implements TransactionFilterStore {} + +class _MockSettingsStore extends Mock implements SettingsStore {} + +class _MockOrdersStore extends Mock implements OrdersStore {} + +class _MockAnonpayTransactionsStore extends Mock implements AnonpayTransactionsStore {} + +class _MockPayjoinTransactionsStore extends Mock implements PayjoinTransactionsStore {} + +class _MockKeyService extends Mock implements KeyService {} + +class _MockWallet extends Mock + implements WalletBase, TransactionInfo> {} + +class _MockWalletInfo extends Mock implements WalletInfo {} + +class _MockTransactionHistory extends Mock implements TransactionHistoryBase {} + +class _MockWownero extends Mock implements wow.Wownero {} + +class _MockWowneroAccountList extends Mock implements wow.WowneroAccountList {} + +class _MockWowneroWalletDetails extends Mock implements wow.WowneroWalletDetails {} + +class _MockWowneroBalance extends Mock implements wow.WowneroBalance {} + +class _DashboardViewModelProbe extends DashboardViewModelBase { + _DashboardViewModelProbe({ + required super.balanceViewModel, + required super.accountListViewModelFactory, + required super.tradeMonitor, + required super.appStore, + required super.tradesStore, + required super.tradeFilterStore, + required super.orderFilterStore, + required super.transactionFilterStore, + required super.settingsStore, + required super.ordersStore, + required super.anonpayTransactionsStore, + required super.payjoinTransactionsStore, + required super.sharedPreferences, + required super.keyService, + }); + + bool _loadRealCards = false; + + void enableRealCardLoading() => _loadRealCards = true; + + @override + void loadFilterItems() {} + + @override + Future loadCardDesigns() => _loadRealCards ? super.loadCardDesigns() : Future.value(); + + @override + Future isBackgroundSyncEnabled() async => false; + + @override + Future isBatteryOptimizationEnabled() async => false; +} + +void main() { + late DatabaseFactory? originalDatabaseFactory; + late Database? originalDatabase; + late wow.Wownero? originalWownero; + + setUpAll(() { + originalDatabaseFactory = databaseFactoryOrNull; + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + tearDownAll(() { + databaseFactoryOrNull = originalDatabaseFactory; + }); + + setUp(() async { + originalDatabase = sqlite.db; + originalWownero = wow.wownero; + sqlite.db = await databaseFactory.openDatabase(inMemoryDatabasePath); + await sqlite.db!.execute(""" + CREATE TABLE ${BalanceCardStyleSettings.tableName} ( + walletInfoId INTEGER, + accountIndex INTEGER DEFAULT -1, + gradientIndex INTEGER DEFAULT -1, + useSpecialDesign BOOLEAN DEFAULT FALSE, + hidden BOOLEAN DEFAULT FALSE, + backgroundImagePath TEXT DEFAULT "", + iconStyleIndex INTEGER DEFAULT 0, + isGradientOnly BOOLEAN DEFAULT FALSE, + cardOrder INTEGER DEFAULT 0, + PRIMARY KEY (walletInfoId, accountIndex) + ) + """); + }); + + tearDown(() async { + wow.wownero = originalWownero; + await sqlite.db?.close(); + sqlite.db = originalDatabase; + }); + + test("Wownero keeps every account card and maps the selected account", () async { + SharedPreferences.setMockInitialValues({}); + final sharedPreferences = await SharedPreferences.getInstance(); + final wallet = _MockWallet(); + final walletInfo = _MockWalletInfo(); + final transactionHistory = _MockTransactionHistory(); + final appStore = _MockAppStore(); + final settingsStore = _MockSettingsStore(); + final tradesStore = _MockTradesStore(); + final tradeMonitor = _MockTradeMonitor(); + final wowneroAdapter = _MockWownero(); + final accountList = _MockWowneroAccountList(); + final walletDetails = _MockWowneroWalletDetails(); + final wowneroBalance = _MockWowneroBalance(); + final accounts = ObservableList.of([ + Account(id: 0, label: "Primary", balance: "1.0"), + Account(id: 1, label: "Savings", balance: "2.0"), + ]); + + wow.wownero = wowneroAdapter; + when(() => appStore.wallet).thenReturn(wallet); + when(() => wallet.hasNativeAccounts).thenReturn(true); + when(() => wallet.name).thenReturn("Wownero Wallet"); + when(() => wallet.id).thenReturn("wownero-wallet"); + when(() => wallet.type).thenReturn(WalletType.wownero); + when(() => wallet.currency).thenReturn(CryptoCurrency.wow); + when(() => wallet.walletInfo).thenReturn(walletInfo); + when(() => walletInfo.internalId).thenReturn(42); + when(() => walletInfo.showSeedBackupReminder).thenReturn(false); + when(() => wallet.transactionHistory).thenReturn(transactionHistory); + when(() => transactionHistory.transactions) + .thenReturn(ObservableMap()); + when(() => wowneroAdapter.getAccountList(wallet)).thenReturn(accountList); + when(() => accountList.accounts).thenReturn(accounts); + when(() => wowneroAdapter.getCurrentAccount(wallet)).thenReturn(accounts[1]); + when(() => wowneroAdapter.getWowneroWalletDetails(wallet)).thenReturn(walletDetails); + when(() => walletDetails.account).thenReturn(accounts[1]); + when(() => walletDetails.balance).thenReturn(wowneroBalance); + when(() => settingsStore.mwebAlwaysScan).thenReturn(false); + when(() => tradesStore.trades).thenReturn(const []); + when(() => tradeMonitor.monitorActiveTrades(any())).thenReturn(null); + + final viewModel = _DashboardViewModelProbe( + balanceViewModel: _MockBalanceViewModel(), + accountListViewModelFactory: () => null, + tradeMonitor: tradeMonitor, + appStore: appStore, + tradesStore: tradesStore, + tradeFilterStore: _MockTradeFilterStore(), + orderFilterStore: _MockOrderFilterStore(), + transactionFilterStore: _MockTransactionFilterStore(), + settingsStore: settingsStore, + ordersStore: _MockOrdersStore(), + anonpayTransactionsStore: _MockAnonpayTransactionsStore(), + payjoinTransactionsStore: _MockPayjoinTransactionsStore(), + sharedPreferences: sharedPreferences, + keyService: _MockKeyService(), + ); + + expect(viewModel.cardAccountIndices, [0, 1]); + expect(viewModel.currentCardAccountIndex, 1); + + viewModel.enableRealCardLoading(); + await viewModel.loadCardDesigns(); + + expect(viewModel.cardOrder, {0: 0, 1: 1}); + expect(viewModel.cardDesigns, hasLength(2)); + expect(viewModel.currentCardDesign, same(viewModel.cardDesigns[1])); + }); +} diff --git a/test/view_model/monero_account_list/monero_account_edit_or_create_view_model_test.dart b/test/view_model/monero_account_list/monero_account_edit_or_create_view_model_test.dart new file mode 100644 index 000000000..334caf5e1 --- /dev/null +++ b/test/view_model/monero_account_list/monero_account_edit_or_create_view_model_test.dart @@ -0,0 +1,136 @@ +import "package:cake_wallet/core/execution_state.dart"; +import "package:cake_wallet/monero/monero.dart" as xmr; +import "package:cake_wallet/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart"; +import "package:cake_wallet/wownero/wownero.dart" as wow; +import "package:cw_core/account.dart"; +import "package:cw_core/balance.dart"; +import "package:cw_core/balance_card_style_settings.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/db/sqlite.dart" as sqlite; +import "package:cw_core/transaction_history.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mobx/mobx.dart" show ObservableList; +import "package:mocktail/mocktail.dart"; +import "package:sqflite_common_ffi/sqflite_ffi.dart"; + +class _MockWallet extends Mock + implements WalletBase, TransactionInfo> {} + +class _MockWalletInfo extends Mock implements WalletInfo {} + +class _MockMoneroAccountList extends Mock implements xmr.MoneroAccountList {} + +class _MockWowneroAccountList extends Mock implements wow.WowneroAccountList {} + +void main() { + late DatabaseFactory? originalDatabaseFactory; + late Database? originalDatabase; + late _MockWallet wallet; + late _MockWalletInfo walletInfo; + + setUpAll(() { + originalDatabaseFactory = databaseFactoryOrNull; + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + tearDownAll(() { + databaseFactoryOrNull = originalDatabaseFactory; + }); + + setUp(() async { + originalDatabase = sqlite.db; + + sqlite.db = await databaseFactory.openDatabase(inMemoryDatabasePath); + await sqlite.db!.execute(""" + CREATE TABLE ${BalanceCardStyleSettings.tableName} ( + walletInfoId INTEGER, + accountIndex INTEGER DEFAULT -1, + gradientIndex INTEGER DEFAULT -1, + hidden BOOLEAN DEFAULT FALSE, + useSpecialDesign BOOLEAN DEFAULT FALSE, + backgroundImagePath TEXT DEFAULT "", + iconStyleIndex INTEGER DEFAULT 0, + isGradientOnly BOOLEAN DEFAULT FALSE, + cardOrder INTEGER DEFAULT 0, + PRIMARY KEY (walletInfoId, accountIndex) + ) + """); + + wallet = _MockWallet(); + walletInfo = _MockWalletInfo(); + when(() => wallet.walletInfo).thenReturn(walletInfo); + when(() => walletInfo.internalId).thenReturn(42); + when(() => wallet.save()).thenAnswer((_) async {}); + }); + + tearDown(() async { + await sqlite.db?.close(); + sqlite.db = originalDatabase; + }); + + test("creates a Wownero account and stores its card design at the next Wownero index", () async { + final accountList = _MockWowneroAccountList(); + final accounts = ObservableList.of([ + Account(id: 0, label: "Primary"), + Account(id: 1, label: "Savings"), + ]); + when(() => wallet.type).thenReturn(WalletType.wownero); + when(() => wallet.currency).thenReturn(CryptoCurrency.wow); + when(() => accountList.accounts).thenReturn(accounts); + when(() => accountList.addAccount(wallet, label: "Spending")).thenAnswer((_) async { + accounts.add(Account(id: 2, label: "Spending")); + }); + + final viewModel = MoneroAccountEditOrCreateViewModel( + null, + accountList, + wallet: wallet, + )..label = "Spending"; + + await viewModel.save(); + + expect(viewModel.state, isA()); + verify(() => accountList.addAccount(wallet, label: "Spending")).called(1); + verify(() => wallet.save()).called(1); + + final cardDesign = await BalanceCardStyleSettings.get(42, 2); + expect(cardDesign, isNotNull); + expect(cardDesign!.accountIndex, 2); + expect(cardDesign.cardOrder, 2); + }); + + test("creates a Monero account and stores its card design at the next Monero index", () async { + final accountList = _MockMoneroAccountList(); + final accounts = ObservableList.of([ + Account(id: 0, label: "Primary"), + ]); + when(() => wallet.type).thenReturn(WalletType.monero); + when(() => wallet.currency).thenReturn(CryptoCurrency.xmr); + when(() => accountList.accounts).thenReturn(accounts); + when(() => accountList.addAccount(wallet, label: "Savings")).thenAnswer((_) async { + accounts.add(Account(id: 1, label: "Savings")); + }); + + final viewModel = MoneroAccountEditOrCreateViewModel( + accountList, + null, + wallet: wallet, + )..label = "Savings"; + + await viewModel.save(); + + expect(viewModel.state, isA()); + verify(() => accountList.addAccount(wallet, label: "Savings")).called(1); + verify(() => wallet.save()).called(1); + + final cardDesign = await BalanceCardStyleSettings.get(42, 1); + expect(cardDesign, isNotNull); + expect(cardDesign!.accountIndex, 1); + expect(cardDesign.cardOrder, 1); + }); +} diff --git a/test/view_model/monero_account_list/monero_account_list_view_model_test.dart b/test/view_model/monero_account_list/monero_account_list_view_model_test.dart new file mode 100644 index 000000000..5229b5cb8 --- /dev/null +++ b/test/view_model/monero_account_list/monero_account_list_view_model_test.dart @@ -0,0 +1,73 @@ +import "package:cake_wallet/monero/monero.dart" as xmr; +import "package:cake_wallet/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart"; +import "package:cake_wallet/wownero/wownero.dart" as wow; +import "package:cw_core/account.dart"; +import "package:cw_core/amount/money.dart"; +import "package:cw_core/balance.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/transaction_history.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mobx/mobx.dart" show ObservableList; +import "package:mocktail/mocktail.dart"; + +class _MockWallet extends Mock + implements WalletBase, TransactionInfo> {} + +class _MockWownero extends Mock implements wow.Wownero {} + +class _MockWowneroAccountList extends Mock implements wow.WowneroAccountList {} + +void main() { + late xmr.Monero? originalMonero; + late wow.Wownero? originalWownero; + late _MockWallet wallet; + late _MockWownero wowneroAdapter; + late _MockWowneroAccountList accountList; + + setUp(() { + originalMonero = xmr.monero; + originalWownero = wow.wownero; + + wallet = _MockWallet(); + wowneroAdapter = _MockWownero(); + accountList = _MockWowneroAccountList(); + + xmr.monero = null; + wow.wownero = wowneroAdapter; + + when(() => wallet.type).thenReturn(WalletType.wownero); + when(() => wallet.currency).thenReturn(CryptoCurrency.wow); + when(() => wowneroAdapter.getAccountList(wallet)).thenReturn(accountList); + when(() => wowneroAdapter.getCurrentAccount(wallet)) + .thenReturn(Account(id: 1, label: "Selected", balance: "2.00000000001")); + when(() => accountList.accounts).thenReturn( + ObservableList.of([ + Account(id: 0, label: "Primary", balance: "0.0"), + Account(id: 1, label: "Selected", balance: "2.00000000001"), + ]), + ); + when(() => wowneroAdapter.getAccountFullBalance(0)).thenReturn(Money.zero(CryptoCurrency.wow)); + when(() => wowneroAdapter.getAccountFullBalance(1)) + .thenReturn(Money.fromInt(200000000001, CryptoCurrency.wow)); + }); + + tearDown(() { + xmr.monero = originalMonero; + wow.wownero = originalWownero; + }); + + test("Wownero accounts retain exact amounts and select through the Wownero adapter", () async { + final viewModel = MoneroAccountListViewModel(wallet); + + expect(viewModel.selected.id, 1); + expect(viewModel.selected.label, "Selected"); + expect(viewModel.accounts.first.isFunded, isFalse); + expect(viewModel.selected.balance.amount, BigInt.from(200000000001)); + await viewModel.select(viewModel.selected); + verify(() => wowneroAdapter.setCurrentAccount(wallet, 1, "Selected", "2.00000000001")) + .called(1); + }); +} diff --git a/tool/configure.dart b/tool/configure.dart index 05cef5b7c..6260f8282 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -424,6 +424,8 @@ abstract class MoneroWalletDetails { abstract class Monero { MoneroAccountList getAccountList(Object wallet); + Money getAccountFullBalance(int accountIndex); + MoneroSubaddressList getSubaddressList(Object wallet); TransactionHistoryBase getTransactionHistory(Object wallet); @@ -628,6 +630,8 @@ abstract class WowneroWalletDetails { abstract class Wownero { WowneroAccountList getAccountList(Object wallet); + Money getAccountFullBalance(int accountIndex); + WowneroSubaddressList getSubaddressList(Object wallet); TransactionHistoryBase getTransactionHistory(Object wallet);