diff --git a/lib/di.dart b/lib/di.dart index a4f691703f..2f7afc3d96 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -598,6 +598,13 @@ Future setup({ getIt.registerFactory(() => AccountCreationModal( accountEditOrCreateViewModel: getIt.get())); + getIt.registerFactoryParam( + (dashboardViewModel, _) => AccountCustomizer( + accountListViewModel: getIt.get(), + dashboardViewModel: dashboardViewModel, + ), + ); + getIt.registerFactory( () => LightningUsernameBloc(getIt.get().wallet!)); diff --git a/lib/new-ui/pages/account_customizer.dart b/lib/new-ui/pages/account_customizer.dart index ff9d6f59ca..8dd2ff26e4 100644 --- a/lib/new-ui/pages/account_customizer.dart +++ b/lib/new-ui/pages/account_customizer.dart @@ -1,7 +1,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/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'; @@ -24,7 +23,6 @@ import 'package:cw_core/utils/print_verbose.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; class AccountCustomizerListItem { @@ -38,13 +36,9 @@ class AccountCustomizerListItem { class AccountCustomizer extends StatefulWidget { const AccountCustomizer( - {super.key, - required this.accountListViewModel, - required this.accountEditOrCreateViewModel, - required this.dashboardViewModel}); + {super.key, required this.accountListViewModel, required this.dashboardViewModel}); final MoneroAccountListViewModel accountListViewModel; - final MoneroAccountEditOrCreateViewModel accountEditOrCreateViewModel; final DashboardViewModel dashboardViewModel; @override @@ -62,7 +56,7 @@ class _AccountCustomizerState extends State { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { loadCards(); - final activeId = monero!.getCurrentAccount(widget.dashboardViewModel.wallet).id; + final activeId = widget.accountListViewModel.selected.id; for (int i = 0; i < _items.length - 1; i++) { if (_items[i].accountListItem.id == activeId) { final lastIndex = _items.length - 1; diff --git a/lib/new-ui/pages/home_page.dart b/lib/new-ui/pages/home_page.dart index a7ba2694b7..9fd87e201b 100644 --- a/lib/new-ui/pages/home_page.dart +++ b/lib/new-ui/pages/home_page.dart @@ -14,11 +14,9 @@ import 'package:cake_wallet/new-ui/widgets/coins_page/cards/cards_view.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/mweb_ad.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/unconfirmed_balance_widget.dart'; -import 'package:cake_wallet/new-ui/widgets/coins_page/wallet_info.dart'; import "package:cake_wallet/new-ui/widgets/coins_page/zcash_migration_modal.dart"; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; -import 'package:cake_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart'; import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart'; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; @@ -115,6 +113,7 @@ class _NewHomePageState extends State { spacing: 24.0, children: [ TopBar( + key: ValueKey(widget.dashboardViewModel.wallet.id), dashboardViewModel: widget.dashboardViewModel, lightningMode: _lightningMode, onLightningSwitchPress: () { @@ -122,8 +121,8 @@ class _NewHomePageState extends State { _lightningMode = !_lightningMode; }); }, - onSettingsButtonPress: () { - CupertinoScaffold.showCupertinoModalBottomSheet( + onSettingsButtonPress: () async { + await CupertinoScaffold.showCupertinoModalBottomSheet( context: context, barrierColor: Colors.black.withAlpha(85), builder: (context) => FractionallySizedBox( @@ -133,70 +132,77 @@ class _NewHomePageState extends State { authService: getIt.get(), ))), ); + + if (!mounted) { + return; + } + _setAccountViewModel(); + await widget.dashboardViewModel.loadCardDesigns(); + if (mounted) { + setState(() {}); + } }, ), - Observer( - builder: (_) => WalletInfoBar( - lightningMode: _lightningMode, - hardwareWalletType: widget.dashboardViewModel.wallet.hardwareWalletType, - name: widget.dashboardViewModel.wallet.name, - hasCustomize: accountListViewModel != null, - onCustomizeButtonTap: openAccountCustomizer), - ), Column( + spacing: 20, children: [ - Observer( - builder: (_) => CardsView( - key: ValueKey(widget.dashboardViewModel.wallet.name), - onCustomizeTapped: openCardCustomizer, - dashboardViewModel: widget.dashboardViewModel, - accountListViewModel: accountListViewModel, - onCompactModeBackgroundCardsTapped: openAccountCustomizer, - lightningMode: _lightningMode, - ), - ), - Observer(builder: (_) { - return AnimatedSize( - duration: Duration(milliseconds: 150), - curve: Curves.easeInOutCubic, - child: (widget.dashboardViewModel.shouldShowBalanceHiddenMessage) - ? Column( - children: [ - SizedBox( - height: 12, - width: double.infinity, - ), - Text( - S.of(context).long_press_show_balance, - style: TextStyle( - color: - Theme.of(context).colorScheme.onSurfaceVariant), - ) - ], - ) - : SizedBox(width: double.infinity), - ); - }), - UnconfirmedBalanceWidget( - dashboardViewModel: widget.dashboardViewModel, - ), - ], - ), - Observer( - builder: (_) { - return Column( + Column( children: [ - CoinActionRow( - lightningMode: _lightningMode, - showSwap: widget.dashboardViewModel.isEnabledSwapAction, - walletType: widget.dashboardViewModel.wallet.type, + Observer( + builder: (_) => CardsView( + key: ValueKey(widget.dashboardViewModel.wallet.name), + onCustomizeTapped: openCardCustomizer, + dashboardViewModel: widget.dashboardViewModel, + accountListViewModel: accountListViewModel, + onCompactModeBackgroundCardsTapped: openAccountCustomizer, + lightningMode: _lightningMode, + ), ), - MwebAd( + Observer(builder: (_) { + return AnimatedSize( + duration: Duration(milliseconds: 150), + curve: Curves.easeInOutCubic, + child: (widget.dashboardViewModel.shouldShowBalanceHiddenMessage) + ? Column( + children: [ + SizedBox( + height: 12, + width: double.infinity, + ), + Text( + S.of(context).long_press_show_balance, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ) + ], + ) + : SizedBox(width: double.infinity), + ); + }), + UnconfirmedBalanceWidget( dashboardViewModel: widget.dashboardViewModel, ), ], - ); - }, + ), + Observer( + builder: (_) { + return Column( + children: [ + CoinActionRow( + lightningMode: _lightningMode, + showSwap: widget.dashboardViewModel.isEnabledSwapAction, + walletType: widget.dashboardViewModel.wallet.type, + ), + MwebAd( + dashboardViewModel: widget.dashboardViewModel, + ), + ], + ); + }, + ), + ], ), ], ), @@ -234,7 +240,12 @@ class _NewHomePageState extends State { ); } - void openAccountCustomizer() async { + Future openAccountCustomizer() async { + final accountList = accountListViewModel; + if (accountList == null) { + return; + } + await CupertinoScaffold.showCupertinoModalBottomSheet( barrierColor: Colors.black.withAlpha(60), context: context, @@ -243,15 +254,15 @@ class _NewHomePageState extends State { parentContext: context, heightMode: ModalHeightModes.fullScreen, rootPage: Material( - child: AccountCustomizer( - accountListViewModel: accountListViewModel!, - accountEditOrCreateViewModel: getIt.get(), - dashboardViewModel: widget.dashboardViewModel, - )), + child: AccountCustomizer( + accountListViewModel: accountList, + dashboardViewModel: widget.dashboardViewModel, + ), + ), ); }, ); - widget.dashboardViewModel.loadCardDesigns(); + await widget.dashboardViewModel.loadCardDesigns(); } void openCardCustomizer() async { diff --git a/lib/new-ui/pages/settings_page.dart b/lib/new-ui/pages/settings_page.dart index 8aaec520df..ea216c430d 100644 --- a/lib/new-ui/pages/settings_page.dart +++ b/lib/new-ui/pages/settings_page.dart @@ -25,6 +25,8 @@ bool _hasMweb(DashboardViewModel vm) => vm.hasMweb; bool _hasWalletConnect(DashboardViewModel vm) => vm.hasWalletConnect; +bool _hasAccounts(DashboardViewModel vm) => vm.balanceViewModel.hasAccounts; + bool _requiresKeyImageSync(DashboardViewModel vm) => vm.wallet.type == WalletType.monero && [HardwareWalletType.cupcake, HardwareWalletType.trezor].contains(vm.wallet.hardwareWalletType); @@ -38,12 +40,14 @@ class SettingsListItem { this.use2fa = _falseFunc, this.condition = _trueFunc, this.routeArgs, + this.routeArgsBuilder, }); final String iconPath; final String title; final String route; final Object? routeArgs; + final Object? Function(DashboardViewModel)? routeArgsBuilder; final bool requireAuth; final bool Function(DashboardViewModel) use2fa; final bool Function(DashboardViewModel) condition; @@ -58,6 +62,13 @@ class SettingsSectionData { 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.accountCustomizer, + condition: _hasAccounts, + routeArgsBuilder: (vm) => vm, + ), SettingsListItem( "assets/new-ui/settings_row_icons/nodes.svg", S.current.nodes, Routes.manageNodes), SettingsListItem( @@ -148,7 +159,11 @@ class SettingsMainPage extends StatelessWidget { conditionToDetermineIfToUse2FA: item.use2fa(dashboardViewModel), route: item.route); } else { - Navigator.of(context).pushNamed(item.route, arguments: item.routeArgs); + Navigator.of(context).pushNamed( + item.route, + arguments: + item.routeArgsBuilder?.call(dashboardViewModel) ?? item.routeArgs, + ); } } }) diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart index e04a179a7b..cceb6ac086 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart @@ -26,7 +26,12 @@ class AssetsTopBar extends StatelessWidget { return SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.only(top: 32.0, bottom: 0.0, left: 12.0, right: 18.0), + padding: const EdgeInsets.only( + top: 20, + bottom: 0, + left: 12, + right: 18, + ), child: Row( crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart index 85865109e0..0203d59ba7 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart @@ -20,7 +20,7 @@ class HistoryTopBar extends StatelessWidget { onTap: onTap, behavior: HitTestBehavior.opaque, child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16, top: 24), + padding: const EdgeInsets.only(left: 16, right: 16, top: 20), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.vertical( diff --git a/lib/new-ui/widgets/coins_page/top_bar_widget/lightning_switcher.dart b/lib/new-ui/widgets/coins_page/top_bar_widget/lightning_switcher.dart index 6ea000518d..35a29cdee4 100644 --- a/lib/new-ui/widgets/coins_page/top_bar_widget/lightning_switcher.dart +++ b/lib/new-ui/widgets/coins_page/top_bar_widget/lightning_switcher.dart @@ -30,64 +30,59 @@ class LightningSwitcher extends StatelessWidget { shape: RoundedSuperellipseBorder(borderRadius: BorderRadiusGeometry.circular(900.0)), color: Theme.of(context).colorScheme.surfaceContainer), - width: 70, + width: 63, height: 36, - padding: EdgeInsets.symmetric(vertical: 2), + padding: const EdgeInsets.all(4.5), child: Stack( children: [ AnimatedContainer( alignment: Alignment.centerRight, - margin: EdgeInsets.only(left: lightningMode ? 36 : 2), - duration: Duration(milliseconds: 250), + margin: EdgeInsets.only(left: lightningMode ? 27 : 0), + duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, - width: 32, - height: 32, - // height: double.infinity, + width: 27, + height: 27, decoration: BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(9999990.0)), color: Theme.of(context).colorScheme.primary), ), - Container( - child: Row( - spacing: 2.0, - children: [ - SizedBox(), - AnimatedSwitcher( - duration: Duration(milliseconds: 150), - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: CakeImageWidget( - imageUrl: 'assets/new-ui/switcher-bitcoin.svg', - key: ValueKey(lightningMode), - width: 32, - height: 32, - colorFilter: ColorFilter.mode( - lightningMode - ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.surfaceContainer, - BlendMode.srcIn, - ), + Row( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: CakeImageWidget( + imageUrl: "assets/new-ui/switcher-bitcoin.svg", + key: ValueKey(lightningMode), + width: 27, + height: 27, + colorFilter: ColorFilter.mode( + lightningMode + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.surfaceContainer, + BlendMode.srcIn, ), ), - AnimatedSwitcher( - duration: Duration(milliseconds: 150), - transitionBuilder: (child, animation) => - FadeTransition(opacity: animation, child: child), - child: CakeImageWidget( - imageUrl: 'assets/new-ui/switcher-lightning.svg', - key: ValueKey(lightningMode), - width: 32, - height: 32, - colorFilter: ColorFilter.mode( - lightningMode - ? Theme.of(context).colorScheme.surfaceContainer - : Theme.of(context).colorScheme.primary, - BlendMode.srcIn, - ), + ), + AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: CakeImageWidget( + imageUrl: "assets/new-ui/switcher-lightning.svg", + key: ValueKey(lightningMode), + width: 27, + height: 27, + colorFilter: ColorFilter.mode( + lightningMode + ? Theme.of(context).colorScheme.surfaceContainer + : Theme.of(context).colorScheme.primary, + BlendMode.srcIn, ), ), - ], - ), + ), + ], ), ], ), diff --git a/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart b/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart index f38f72b0d4..9bf3ec045a 100644 --- a/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart +++ b/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart @@ -3,6 +3,7 @@ import 'package:cake_wallet/di.dart'; import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/src/screens/settings/manage_nodes_page.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/dashboard/dashboard_view_model.dart'; import 'package:cw_core/sync_status.dart'; import "package:flutter/cupertino.dart"; @@ -11,11 +12,11 @@ import 'package:flutter_mobx/flutter_mobx.dart'; import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; class SyncBar extends StatelessWidget { - SyncBar({ - super.key, + const SyncBar({ required this.dashboardViewModel, required this.isSyncHeavy, required this.showSyncedMessage, + super.key, }); final DashboardViewModel dashboardViewModel; @@ -46,106 +47,126 @@ class SyncBar extends StatelessWidget { @override Widget build(BuildContext context) => Observer( - builder: (_) { - final status = dashboardViewModel.status; - final Widget? icon = _getIcon(context, status.runtimeType); - - return Expanded( - child: Stack( - alignment: Alignment.centerLeft, - children: [ - if (!_showFullBar()) _buildCompactBar(context), - if (_showFullBar()) - // A single node: the localized status text (plus any active - // Tor/MWEB/Silent Payments badge) is the label, and the hint says - // where tapping leads. Everything inside is redundant with it. - Semantics( - button: true, - label: _statusSemanticsLabel(context, status), - hint: S.of(context).manage_nodes, - onTap: () => _openNodeManagement(context), - child: ExcludeSemantics( - child: GestureDetector( - onTap: () => _openNodeManagement(context), - child: AnimatedSwitcher( - duration: Duration(milliseconds: 100), - child: Container( - key: ValueKey(status.runtimeType), - height: 36, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(9999), - border: _getBorder(context, status.runtimeType), - color: _getBackgroundColor(context, status.runtimeType), - ), - child: Row( - spacing: 10, - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.max, - children: [ - if (icon != null) icon, - // if (dashboardViewModel.silentPaymentsScanningActive && - // progressStatuses.contains(status.runtimeType)) ...[ - // Text( - // "${(status.progress() * 100).toInt()}%", - // style: TextStyle(fontSize: 12, color: Color(0xFFEFBA5E)), - // ), - // Text( - // "·", - // style: TextStyle(fontSize: 12), - // ) - // ], - Text( - syncStatusTitle(status, - dashboardViewModel.settingsStore.syncStatusDisplayMode), - style: _getTextStyle(context, status.runtimeType), + builder: (_) { + final status = dashboardViewModel.status; + if (!_showFullBar) { + return _buildCompactBar(context); + } + + final Widget? icon = _getIcon(context, status.runtimeType); + final statusTitle = _statusTitle(context, status); + final silentPaymentsProgress = _silentPaymentsProgress(status); + final barHeight = silentPaymentsProgress == null ? 36.0 : 40.0; + + // A single node: the localized status text (plus any active + // Tor/MWEB/Silent Payments badge) is the label, and the hint says + // where tapping leads. Everything inside is redundant with it. + return Semantics( + button: true, + label: _statusSemanticsLabel(context, status, statusTitle), + value: silentPaymentsProgress, + hint: S.of(context).manage_nodes, + onTap: () => _openNodeManagement(context), + child: ExcludeSemantics( + child: GestureDetector( + onTap: () => _openNodeManagement(context), + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + child: OverflowBox( + key: ValueKey(status.runtimeType), + alignment: Alignment.center, + minHeight: barHeight, + maxHeight: barHeight, + child: Container( + height: barHeight, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(9999), + border: _getBorder(context, status.runtimeType), + color: _getBackgroundColor(context, status.runtimeType), + ), + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + spacing: 8, + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.max, + children: [ + if (icon != null) icon, + if (silentPaymentsProgress != null) ...[ + Text( + silentPaymentsProgress, + style: TextStyle( + fontSize: 12, + color: context.customColors.warningOutlineColor, ), - ], + ), + Text( + "·", + style: TextStyle( + fontSize: 12, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + Flexible( + child: Text( + statusTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: _getTextStyle(context, status.runtimeType), + ), ), - ), + ], ), ), ), ), - ], - ), - ); - }, - ); + ), + ), + ); + }, + ); - void _openNodeManagement(BuildContext context) => - CupertinoScaffold.showCupertinoModalBottomSheet( - context: context, - barrierColor: Colors.black.withAlpha(85), - builder: (context) => FractionallySizedBox( - child: Material( - child: getIt.get(param1: false), - ))); + void _openNodeManagement(BuildContext context) { + CupertinoScaffold.showCupertinoModalBottomSheet( + context: context, + barrierColor: Colors.black.withAlpha(85), + builder: (context) => FractionallySizedBox( + child: Material( + child: getIt.get(param1: false), + ), + ), + ); + } /// Compact mode shows sync state with a pulsing dot (and a Tor glyph) only, so /// the whole row needs a text equivalent. Widget _buildCompactBar(BuildContext context) { final row = Row( mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.start, spacing: 6, children: [ if (dashboardViewModel.isTorEnabled) - CakeImageWidget( + const CakeImageWidget( imageUrl: "assets/new-ui/tor.svg", width: 20, height: 20, ), if (_showDot()) const CupertinoActivityIndicator(radius: 8), - if(_showLightSyncCheck()) const Icon(Icons.check, color: syncedColor, size: 18), - ], + if (_showLightSyncCheck()) const Icon(Icons.check, color: syncedColor, size: 18), + ], ); final label = _joinLabels([ if (dashboardViewModel.isTorEnabled) S.of(context).tor_connection, if (_showDot()) S.of(context).synchronizing, + if (_showLightSyncCheck()) S.of(context).sync_synced, ]); - if (label.isEmpty) return row; + if (label.isEmpty) { + return row; + } return Semantics(label: label, child: ExcludeSemantics(child: row)); } @@ -153,11 +174,17 @@ class SyncBar extends StatelessWidget { bool get _isShowingSyncedMessage => showSyncedMessage && dashboardViewModel.status.runtimeType == SyncedSyncStatus; - String _statusSemanticsLabel(BuildContext context, SyncStatus status) { + String _statusTitle(BuildContext context, SyncStatus status) { + final title = syncStatusTitle(status, dashboardViewModel.settingsStore.syncStatusDisplayMode); + + return title.isNotEmpty ? title : S.of(context).synchronizing; + } + + String _statusSemanticsLabel(BuildContext context, SyncStatus status, String statusTitle) { final isFailure = failStatuses.contains(status.runtimeType); return _joinLabels([ - syncStatusTitle(status, dashboardViewModel.settingsStore.syncStatusDisplayMode), + statusTitle, if (!isFailure && dashboardViewModel.isTorEnabled) S.of(context).tor_connection, if (!isFailure && dashboardViewModel.hasMweb) S.of(context).litecoin_mweb, if (!isFailure && dashboardViewModel.hasSilentPayments) S.of(context).silent_payments, @@ -166,6 +193,16 @@ class SyncBar extends StatelessWidget { String _joinLabels(List parts) => parts.where((part) => part.isNotEmpty).join(", "); + String? _silentPaymentsProgress(SyncStatus status) { + if (!dashboardViewModel.hasSilentPayments || + !dashboardViewModel.silentPaymentsScanningActive || + status is! SyncingSyncStatus) { + return null; + } + + return "${(status.progress() * 100).toInt()}%"; + } + Color? _getBackgroundColor(BuildContext context, Type status) { if (failStatuses.contains(status)) { return Theme.of(context).colorScheme.errorContainer.withAlpha(64); @@ -192,14 +229,12 @@ class SyncBar extends StatelessWidget { color = Theme.of(context).colorScheme.onSurfaceVariant; } - return TextStyle( - fontSize: 12, fontWeight: FontWeight.w400, color: color); + return TextStyle(fontSize: 12, fontWeight: FontWeight.w400, color: color); } Widget? _getIcon(BuildContext context, Type status) { - - if(status == SyncedSyncStatus) { - return Icon(Icons.check, color: syncedColor, size: 12); + if (status == SyncedSyncStatus) { + return const Icon(Icons.check, color: syncedColor, size: 12); } if (status == LostConnectionSyncStatus) { @@ -219,22 +254,35 @@ class SyncBar extends StatelessWidget { final List children = []; if (dashboardViewModel.isTorEnabled) { - children.add(CakeImageWidget( + children.add( + const CakeImageWidget( imageUrl: "assets/new-ui/tor_sync.svg", - colorFilter: ColorFilter.mode(Color(0xFF8A38F5), BlendMode.srcIn))); + colorFilter: ColorFilter.mode(Color(0xFF8A38F5), BlendMode.srcIn), + ), + ); } if (dashboardViewModel.hasMweb) { - children.add(CakeImageWidget( - imageUrl: "assets/new-ui/mweb_sync.svg", - colorFilter: - ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), - )); + children.add( + CakeImageWidget( + imageUrl: "assets/new-ui/mweb_sync.svg", + colorFilter: + ColorFilter.mode(Theme.of(context).colorScheme.onSurfaceVariant, BlendMode.srcIn), + ), + ); } if (dashboardViewModel.hasSilentPayments) { - children.add(CakeImageWidget( - imageUrl: "assets/new-ui/silent_sync.svg", - colorFilter: ColorFilter.mode(Color(0xFFEFBA5E), BlendMode.srcIn), - )); + children.add( + CakeImageWidget( + imageUrl: "assets/new-ui/silent_sync.svg", + width: 16, + height: 16, + colorFilter: ColorFilter.mode(context.customColors.warningOutlineColor, BlendMode.srcIn), + ), + ); + } + + if (children.isEmpty) { + return null; } return Row( @@ -243,15 +291,16 @@ class SyncBar extends StatelessWidget { ); } - bool _showFullBar() { - if (dashboardViewModel.status.runtimeType == SyncedSyncStatus) + bool get _showFullBar { + if (dashboardViewModel.status.runtimeType == SyncedSyncStatus) { return isSyncHeavy && _isShowingSyncedMessage; + } + return isSyncHeavy || failStatuses.contains(dashboardViewModel.status.runtimeType); } bool _showDot() => !isSyncHeavy && progressStatuses.contains(dashboardViewModel.status.runtimeType); - bool _showLightSyncCheck() => - !isSyncHeavy && _isShowingSyncedMessage; + bool _showLightSyncCheck() => !isSyncHeavy && _isShowingSyncedMessage; } diff --git a/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart b/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart index 21035c258a..026af46baf 100644 --- a/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart +++ b/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart @@ -5,6 +5,7 @@ import 'package:cake_wallet/generated/i18n.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/chain_icon.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/lightning_switcher.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart'; +import "package:cake_wallet/new-ui/widgets/coins_page/wallet_info.dart"; import 'package:cake_wallet/new-ui/widgets/modern_button.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; import 'package:cw_core/sync_status.dart'; @@ -15,11 +16,11 @@ import 'package:mobx/mobx.dart'; class TopBar extends StatefulWidget { const TopBar({ - super.key, required this.lightningMode, required this.onLightningSwitchPress, required this.dashboardViewModel, required this.onSettingsButtonPress, + super.key, }); final bool lightningMode; @@ -36,25 +37,59 @@ class _TopBarState extends State { bool showSyncedMessage = false; Timer? syncedMessageTimer; - late final ReactionDisposer? _statusReactionDisposer; + ReactionDisposer? _statusReactionDisposer; + + bool get replacesWalletName { + final status = widget.dashboardViewModel.status.runtimeType; + if (status == SyncedSyncStatus) { + return showSyncedMessage; + } + + return widget.dashboardViewModel.isSyncHeavy || + SyncBar.progressStatuses.contains(status) || + SyncBar.failStatuses.contains(status); + } @override void initState() { super.initState(); + _bindStatusReaction(); + } + @override + void didUpdateWidget(covariant TopBar oldWidget) { + super.didUpdateWidget(oldWidget); + + if (identical(oldWidget.dashboardViewModel, widget.dashboardViewModel)) { + return; + } + + syncedMessageTimer?.cancel(); + syncedMessageTimer = null; + showSyncedMessage = false; + _statusReactionDisposer?.call(); + _bindStatusReaction(); + } + + void _bindStatusReaction() { _statusReactionDisposer = reaction( (_) => widget.dashboardViewModel.status.runtimeType, (status) { syncedMessageTimer?.cancel(); + syncedMessageTimer = null; if (status == SyncedSyncStatus) { - if(mounted) { + if (mounted) { setState(() => showSyncedMessage = true); } - syncedMessageTimer = - Timer(syncedMessageDuration, () => setState(() => showSyncedMessage = false)); + syncedMessageTimer = Timer(syncedMessageDuration, () { + syncedMessageTimer = null; + if (mounted) { + setState(() => showSyncedMessage = false); + } + }); } else { - if(mounted) { + if (mounted) { setState(() => showSyncedMessage = false); } } @@ -65,51 +100,90 @@ class _TopBarState extends State { @override void dispose() { syncedMessageTimer?.cancel(); - _statusReactionDisposer?.reaction.dispose(); + syncedMessageTimer = null; + _statusReactionDisposer?.call(); super.dispose(); } @override Widget build(BuildContext context) => Padding( - padding: EdgeInsets.only( - bottom: 10, left: 18, right: 18, top: 10 + _additionalTopPadding(context)), - child: Observer( - builder: (_) => Row( - spacing: 12, - children: [ - (widget.dashboardViewModel.hasLightning) - ? LightningSwitcher( - lightningMode: widget.lightningMode, - onLightningSwitchPress: widget.onLightningSwitchPress, - ) - : ChainIcon( - iconPath: widget.dashboardViewModel.wallet.currency.flatIconPath ?? "", - dashboardViewModel: widget.dashboardViewModel, - isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, - showSyncedMessage: showSyncedMessage), - SyncBar( - dashboardViewModel: widget.dashboardViewModel, - isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, - showSyncedMessage: showSyncedMessage, - ), - ModernButton.svg( - iconColor: Theme.of(context).colorScheme.primary, - size: 36, - onPressed: () { - HapticFeedback.mediumImpact(); - widget.onSettingsButtonPress(); - }, - svgPath: "assets/new-ui/top-settings.svg", - semanticLabel: S.of(context).settings_title, - ), - ], + padding: EdgeInsets.only(left: 18, right: 18, top: 10 + _additionalTopPadding(context)), + child: Observer( + builder: (_) => Row( + spacing: 12, + children: [ + widget.dashboardViewModel.hasLightning + ? LightningSwitcher( + lightningMode: widget.lightningMode, + onLightningSwitchPress: widget.onLightningSwitchPress, + ) + : ChainIcon( + iconPath: widget.dashboardViewModel.wallet.currency.flatIconPath ?? "", + dashboardViewModel: widget.dashboardViewModel, + isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, + showSyncedMessage: showSyncedMessage, + ), + Expanded( + child: SizedBox( + height: 36, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + reverseDuration: Duration.zero, + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.centerLeft, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + child: replacesWalletName + ? SyncBar( + dashboardViewModel: widget.dashboardViewModel, + isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, + showSyncedMessage: showSyncedMessage, + ) + : Row( + children: [ + Expanded( + child: WalletInfoBar( + name: widget.dashboardViewModel.wallet.name, + hardwareWalletType: + widget.dashboardViewModel.wallet.hardwareWalletType, + ), + ), + if (widget.dashboardViewModel.isTorEnabled) ...[ + const SizedBox(width: 6), + SyncBar( + dashboardViewModel: widget.dashboardViewModel, + isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, + showSyncedMessage: showSyncedMessage, + ), + ], + ], + ), + ), + ), + ), + ModernButton.svg( + iconColor: Theme.of(context).colorScheme.primary, + size: 36, + onPressed: () { + HapticFeedback.mediumImpact(); + widget.onSettingsButtonPress(); + }, + svgPath: "assets/new-ui/top-settings.svg", + semanticLabel: S.of(context).settings_title, + ), + ], + ), ), - ), - ); + ); //FIXME remove after this gets fixed flutter-side double _additionalTopPadding(BuildContext context) { - if (Platform.isIOS && MediaQuery.of(context).viewPadding.top < 12) return 24; + if (Platform.isIOS && MediaQuery.of(context).viewPadding.top < 12) { + return 24; + } return 0; } diff --git a/lib/new-ui/widgets/coins_page/wallet_info.dart b/lib/new-ui/widgets/coins_page/wallet_info.dart index dcdbc23a51..b7478abce1 100644 --- a/lib/new-ui/widgets/coins_page/wallet_info.dart +++ b/lib/new-ui/widgets/coins_page/wallet_info.dart @@ -1,102 +1,67 @@ import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/new-ui/widgets/modern_button.dart'; import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; import 'package:cw_core/wallet_info.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_svg/flutter_svg.dart'; class WalletInfoBar extends StatelessWidget { - const WalletInfoBar( - {super.key, - required this.lightningMode, - required this.name, - required this.hardwareWalletType, - required this.onCustomizeButtonTap, - required this.hasCustomize}); + const WalletInfoBar({ + required this.name, + required this.hardwareWalletType, + super.key, + }); - final bool lightningMode; final String name; final HardwareWalletType? hardwareWalletType; - final bool hasCustomize; - final VoidCallback onCustomizeButtonTap; - - void _openAccountCustomizer() { - if (hasCustomize) { - onCustomizeButtonTap(); - HapticFeedback.mediumImpact(); - } - } @override Widget build(BuildContext context) { - // The row, the hardware-wallet glyph and the inner accounts button are one - // control for a screen reader: a single labeled node opening the customizer. final semanticsLabel = hardwareWalletType == null ? name : "$name, ${S.of(context).hardware_wallet}"; - final row = GestureDetector( - onTap: _openAccountCustomizer, - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AnimatedSwitcher( - duration: Duration(milliseconds: 150), - transitionBuilder: (child, animation) { - return SizeTransition( + return Semantics( + label: semanticsLabel, + child: ExcludeSemantics( + child: Row( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + transitionBuilder: (child, animation) => SizeTransition( axis: Axis.horizontal, sizeFactor: animation, child: FadeTransition(opacity: animation, child: child), - ); - }, - child: hardwareWalletIcon == null - ? const SizedBox.shrink(key: ValueKey("empty")) - : Padding( - padding: const EdgeInsets.only(right: 8), - child: CakeImageWidget( - imageUrl: hardwareWalletIcon!, - key: ValueKey("hardware_wallet_icon"), - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.onSurfaceVariant, - BlendMode.srcIn, + ), + child: hardwareWalletIcon == null + ? const SizedBox.shrink(key: ValueKey("empty")) + : Padding( + padding: const EdgeInsets.only(right: 4), + child: CakeImageWidget( + imageUrl: hardwareWalletIcon!, + key: const ValueKey("hardware_wallet_icon"), + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.onSurfaceVariant, + BlendMode.srcIn, + ), ), ), - ), - ), - Text( - name, - style: Theme.of(context) - .textTheme - .titleLarge - ?.copyWith(color: Theme.of(context).colorScheme.onSurface), - ), - if (hasCustomize) ...[ - SizedBox(width: 8), - ModernButton.svg( - size: 24, - onPressed: _openAccountCustomizer, - svgPath: "assets/new-ui/icon-accounts.svg", - semanticLabel: S.of(context).wallet_accounts, - ) - ] - ], + ), + Expanded( + child: Text( + name, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .titleMedium + ?.copyWith(color: Theme.of(context).colorScheme.onSurface), + ), + ), + ], + ), ), ); - - if (!hasCustomize) { - return Semantics(label: semanticsLabel, child: ExcludeSemantics(child: row)); - } - - return Semantics( - button: true, - label: semanticsLabel, - hint: S.of(context).wallet_accounts, - onTap: _openAccountCustomizer, - child: ExcludeSemantics(child: row), - ); } String? get hardwareWalletIcon { diff --git a/lib/router.dart b/lib/router.dart index a583e587c7..9ddcb028ae 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -4,6 +4,7 @@ import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart'; import "package:cake_wallet/core/auth_service.dart"; import 'package:cake_wallet/core/new_wallet_arguments.dart'; import 'package:cake_wallet/new-ui/new_dashboard.dart'; +import "package:cake_wallet/new-ui/pages/account_customizer.dart"; import 'package:cake_wallet/new-ui/pages/about_page.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart'; @@ -667,6 +668,13 @@ Route createRoute(RouteSettings settings) { builder: (_) => getIt.get( param1: settings.arguments as AccountListItem?)); + case Routes.accountCustomizer: + return handleRouteWithPlatformAwareness( + (_) => getIt.get( + param1: settings.arguments! as DashboardViewModel, + ), + ); + case Routes.nanoAccountCreation: return CupertinoPageRoute( builder: (_) => diff --git a/lib/routes.dart b/lib/routes.dart index 9417a3e59b..96283c9b05 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -32,6 +32,7 @@ class Routes { static const login = '/login'; static const splash = '/splash'; static const accountCreation = '/account_new'; + static const accountCustomizer = "/account_customizer"; static const nanoAccountCreation = '/nano_account_new'; static const addressBook = '/address_book'; static const pickerAddressBook = '/picker_address_book'; diff --git a/res/pictures/settings_row_icons/accounts.svg b/res/pictures/settings_row_icons/accounts.svg new file mode 100644 index 0000000000..6afc7088e1 --- /dev/null +++ b/res/pictures/settings_row_icons/accounts.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/test/new-ui/pages/settings_page_test.dart b/test/new-ui/pages/settings_page_test.dart new file mode 100644 index 0000000000..c06cc6793a --- /dev/null +++ b/test/new-ui/pages/settings_page_test.dart @@ -0,0 +1,125 @@ +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/locales/locale.dart"; +import "package:cake_wallet/new-ui/pages/account_customizer.dart"; +import "package:cake_wallet/new-ui/pages/settings_page.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:cw_core/balance.dart"; +import "package:cw_core/transaction_history.dart"; +import "package:cw_core/transaction_info.dart"; +import "package:cw_core/wallet_base.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mocktail/mocktail.dart"; + +class MockBalanceViewModel extends Mock implements BalanceViewModel {} + +class MockDashboardViewModel extends Mock implements DashboardViewModel {} + +class _MockAccountListViewModel extends Mock implements MoneroAccountListViewModel {} + +class _MockAuthService extends Mock implements AuthService {} + +class _MockWallet extends Mock + implements WalletBase, TransactionInfo> {} + +void main() { + late MockBalanceViewModel balanceViewModel; + late MockDashboardViewModel dashboardViewModel; + + setUpAll(() => S.delegate.load(const Locale("en"))); + + setUp(() { + balanceViewModel = MockBalanceViewModel(); + dashboardViewModel = MockDashboardViewModel(); + 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.wallet).thenReturn(wallet); + when(() => dashboardViewModel.loadCardDesigns()).thenAnswer((_) async {}); + when(() => wallet.type).thenReturn(WalletType.monero); + 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, + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(S.current.accounts)); + await tester.pumpAndSettle(); + + expect(routedDashboardViewModel, same(dashboardViewModel)); + expect(find.byType(AccountCustomizer), findsOneWidget); + expect(tester.takeException(), isNull); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + await getIt.popScope(); + } + }); +} diff --git a/test/new-ui/widgets/coins_page/top_bar_widget/sync_bar_test.dart b/test/new-ui/widgets/coins_page/top_bar_widget/sync_bar_test.dart new file mode 100644 index 0000000000..ac5fe1dba7 --- /dev/null +++ b/test/new-ui/widgets/coins_page/top_bar_widget/sync_bar_test.dart @@ -0,0 +1,198 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/entities/sync_status_display_mode.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/locales/locale.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/sync_bar.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_store.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cw_core/sync_status.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mobx/mobx.dart" show Observable; +import "package:mocktail/mocktail.dart"; + +class _ObservableDashboardViewModel extends Mock implements DashboardViewModel { + _ObservableDashboardViewModel(SyncStatus status) : _status = Observable(status); + + final Observable _status; + + @override + SyncStatus get status => _status.value; +} + +class _MockSettingsStore extends Mock implements SettingsStore {} + +class _UnknownSyncStatus extends SyncStatus { + const _UnknownSyncStatus(); + + @override + double progress() => 0; +} + +void main() { + late bool registeredThemeStore; + + setUpAll(() { + registeredThemeStore = !getIt.isRegistered(); + if (registeredThemeStore) { + getIt.registerSingleton(ThemeStore()); + } + }); + + tearDownAll(() async { + if (registeredThemeStore) { + await getIt.unregister(); + } + }); + + testWidgets("unknown sync-heavy statuses render a localized fallback", (tester) async { + const status = _UnknownSyncStatus(); + final dashboardViewModel = _ObservableDashboardViewModel(status); + final settingsStore = _MockSettingsStore(); + + when(() => dashboardViewModel.settingsStore).thenReturn(settingsStore); + when(() => dashboardViewModel.isTorEnabled).thenReturn(false); + when(() => dashboardViewModel.hasMweb).thenReturn(false); + when(() => dashboardViewModel.hasSilentPayments).thenReturn(false); + when(() => settingsStore.syncStatusDisplayMode) + .thenReturn(SyncStatusDisplayMode.blocksRemaining); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + home: Scaffold( + body: SyncBar( + dashboardViewModel: dashboardViewModel, + isSyncHeavy: true, + showSyncedMessage: false, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text(S.current.synchronizing), findsOneWidget); + expect(find.bySemanticsLabel(S.current.synchronizing), findsOneWidget); + }); + + testWidgets("active Silent Payments syncing shows integer progress and its icon in a 40px pill", + (tester) async { + final dashboardViewModel = _ObservableDashboardViewModel(SyncingSyncStatus(100, 0.24)); + final settingsStore = _MockSettingsStore(); + + when(() => dashboardViewModel.settingsStore).thenReturn(settingsStore); + when(() => dashboardViewModel.isTorEnabled).thenReturn(false); + when(() => dashboardViewModel.hasMweb).thenReturn(false); + when(() => dashboardViewModel.hasSilentPayments).thenReturn(true); + when(() => dashboardViewModel.silentPaymentsScanningActive).thenReturn(true); + when(() => settingsStore.syncStatusDisplayMode) + .thenReturn(SyncStatusDisplayMode.blocksRemaining); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + home: Scaffold( + body: Center( + child: SizedBox( + width: 210, + height: 36, + child: SyncBar( + dashboardViewModel: dashboardViewModel, + isSyncHeavy: true, + showSyncedMessage: false, + ), + ), + ), + ), + ), + ); + await tester.pump(); + + final progressText = tester.widget(find.text("24%")); + final pill = find.descendant( + of: find.byType(SyncBar), + matching: find.byWidgetPredicate( + (widget) => + widget is Container && + widget.constraints?.minHeight == 40 && + widget.constraints?.maxHeight == 40, + ), + ); + final silentPaymentsIcon = tester.widget( + find.byWidgetPredicate( + (widget) => widget is CakeImageWidget && widget.imageUrl == "assets/new-ui/silent_sync.svg", + ), + ); + + final warningOutlineColor = getIt().currentTheme.customColors.warningOutlineColor; + + expect(progressText.style?.color, warningOutlineColor); + expect(find.text("·"), findsOneWidget); + expect(find.text(S.current.Blocks_remaining("100")), findsOneWidget); + expect(tester.getSize(pill).height, 40); + expect(Size(silentPaymentsIcon.width!, silentPaymentsIcon.height!), const Size(16, 16)); + expect( + silentPaymentsIcon.colorFilter, + ColorFilter.mode(warningOutlineColor, BlendMode.srcIn), + ); + expect(tester.getSemantics(find.text("24%")).value, "24%"); + }); + + testWidgets("Silent Payments progress requires support, active scanning, and syncing", + (tester) async { + final testCases = <({bool hasSilentPayments, bool scanningActive, SyncStatus status})>[ + ( + hasSilentPayments: false, + scanningActive: true, + status: SyncingSyncStatus(100, 0.24), + ), + ( + hasSilentPayments: true, + scanningActive: false, + status: SyncingSyncStatus(100, 0.24), + ), + ( + hasSilentPayments: true, + scanningActive: true, + status: AttemptingScanSyncStatus(), + ), + ]; + + for (final testCase in testCases) { + final dashboardViewModel = _ObservableDashboardViewModel(testCase.status); + final settingsStore = _MockSettingsStore(); + + when(() => dashboardViewModel.settingsStore).thenReturn(settingsStore); + when(() => dashboardViewModel.isTorEnabled).thenReturn(false); + when(() => dashboardViewModel.hasMweb).thenReturn(false); + when(() => dashboardViewModel.hasSilentPayments).thenReturn(testCase.hasSilentPayments); + when(() => dashboardViewModel.silentPaymentsScanningActive) + .thenReturn(testCase.scanningActive); + when(() => settingsStore.syncStatusDisplayMode) + .thenReturn(SyncStatusDisplayMode.blocksRemaining); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + home: Scaffold( + body: SyncBar( + dashboardViewModel: dashboardViewModel, + isSyncHeavy: true, + showSyncedMessage: false, + ), + ), + ), + ); + await tester.pump(); + + expect(find.text("24%"), findsNothing, reason: "$testCase"); + expect(find.text("0%"), findsNothing, reason: "$testCase"); + expect(find.text("·"), findsNothing, reason: "$testCase"); + } + }); +} diff --git a/test/new-ui/widgets/coins_page/top_bar_widget/top_bar_test.dart b/test/new-ui/widgets/coins_page/top_bar_widget/top_bar_test.dart new file mode 100644 index 0000000000..367f061365 --- /dev/null +++ b/test/new-ui/widgets/coins_page/top_bar_widget/top_bar_test.dart @@ -0,0 +1,387 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/entities/sync_status_display_mode.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/locales/locale.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/lightning_switcher.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/wallet_info.dart"; +import "package:cake_wallet/new-ui/widgets/modern_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_store.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cw_core/balance.dart"; +import "package:cw_core/crypto_currency.dart"; +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:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:mobx/mobx.dart" show Observable, runInAction; +import "package:mocktail/mocktail.dart"; + +class _MockWallet extends Mock + implements WalletBase, TransactionInfo> {} + +class _ObservableDashboardViewModel extends Mock implements DashboardViewModel { + _ObservableDashboardViewModel(SyncStatus initialStatus) + : _status = Observable(initialStatus); + + final Observable _status; + + @override + SyncStatus get status => _status.value; + + void setStatus(SyncStatus status) => runInAction(() => _status.value = status); +} + +class _MockSettingsStore extends Mock implements SettingsStore {} + +class _UnknownSyncStatus extends SyncStatus { + const _UnknownSyncStatus(); + + @override + double progress() => 0; +} + +void _stubDashboardViewModel( + _ObservableDashboardViewModel dashboardViewModel, + _MockWallet wallet, + String walletName, { + bool hasLightning = true, + bool isSyncHeavy = false, + bool hasSilentPayments = false, + bool silentPaymentsScanningActive = false, +}) { + when(() => dashboardViewModel.hasLightning).thenReturn(hasLightning); + when(() => dashboardViewModel.isSyncHeavy).thenReturn(isSyncHeavy); + when(() => dashboardViewModel.isTorEnabled).thenReturn(false); + when(() => dashboardViewModel.hasMweb).thenReturn(false); + when(() => dashboardViewModel.hasSilentPayments).thenReturn(hasSilentPayments); + when(() => dashboardViewModel.silentPaymentsScanningActive) + .thenReturn(silentPaymentsScanningActive); + when(() => dashboardViewModel.wallet).thenReturn(wallet); + when(() => wallet.name).thenReturn(walletName); + when(() => wallet.hardwareWalletType).thenReturn(null); + final settingsStore = _MockSettingsStore(); + when(() => dashboardViewModel.settingsStore).thenReturn(settingsStore); + when(() => settingsStore.syncStatusDisplayMode).thenReturn(SyncStatusDisplayMode.blocksRemaining); +} + +Widget _buildTopBar(DashboardViewModel dashboardViewModel) => TopBar( + lightningMode: false, + onLightningSwitchPress: () {}, + dashboardViewModel: dashboardViewModel, + onSettingsButtonPress: () {}, + ); + +Widget _buildTestApp(Widget child) => MaterialApp( + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + home: Scaffold(body: child), + ); + +void main() { + late bool registeredThemeStore; + + setUpAll(() { + registeredThemeStore = !getIt.isRegistered(); + if (registeredThemeStore) { + getIt.registerSingleton(ThemeStore()); + } + }); + + tearDownAll(() async { + if (registeredThemeStore) { + await getIt.unregister(); + } + }); + + for (final isSyncHeavy in [false, true]) { + testWidgets( + "keeps the name hidden until Synced expires, then restores it (heavy: $isSyncHeavy)", + (tester) async { + const walletName = "My Bitcoin Wallet"; + final wallet = _MockWallet(); + final dashboardViewModel = _ObservableDashboardViewModel(SyncingSyncStatus(100, 0.5)); + + _stubDashboardViewModel(dashboardViewModel, wallet, walletName, isSyncHeavy: isSyncHeavy); + + await tester.pumpWidget(_buildTestApp(_buildTopBar(dashboardViewModel))); + await tester.pump(); + + expect(find.text(walletName), findsNothing); + expect(find.byType(SyncBar), findsOneWidget); + + dashboardViewModel.setStatus(SyncedSyncStatus()); + await tester.pump(); + + expect(find.text(walletName), findsNothing); + expect(find.byIcon(Icons.check), findsOneWidget); + expect(tester.widget(find.byType(SyncBar)).showSyncedMessage, isTrue); + + await tester.pump(const Duration(seconds: 3) - const Duration(milliseconds: 1)); + + expect(find.text(walletName), findsNothing); + expect(find.byIcon(Icons.check), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 1)); + + expect(find.text(walletName), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 150)); + expect(find.byType(SyncBar), findsNothing); + + dashboardViewModel.setStatus(SyncingSyncStatus(100, 0.5)); + await tester.pump(); + await tester.pump(); + expect(find.text(walletName), findsNothing); + }); + } + + testWidgets("replaces the wallet name for progress and failure statuses in all wallets", + (tester) async { + final statuses = [ + SyncingSyncStatus(100, 0.5), + const NotConnectedSyncStatus(), + SyncronizingSyncStatus(), + AttemptingSyncStatus(), + StartingScanSyncStatus(0), + AttemptingScanSyncStatus(), + SyncedTipSyncStatus(100), + ProcessingSyncStatus(), + ConnectingSyncStatus(), + ConnectedSyncStatus(), + FailedSyncStatus(), + LostConnectionSyncStatus(), + TimedOutSyncStatus(), + UnsupportedSyncStatus(), + const _UnknownSyncStatus(), + SyncedSyncStatus(), + ]; + + for (final isSyncHeavy in [false, true]) { + for (final status in statuses) { + final dashboardViewModel = _ObservableDashboardViewModel(status); + _stubDashboardViewModel( + dashboardViewModel, + _MockWallet(), + "Wallet", + isSyncHeavy: isSyncHeavy, + ); + + await tester.pumpWidget(_buildTestApp(_buildTopBar(dashboardViewModel))); + await tester.pump(const Duration(milliseconds: 150)); + + final showsName = status.runtimeType == SyncedSyncStatus || + (status is _UnknownSyncStatus && !isSyncHeavy); + expect( + find.text("Wallet"), + showsName ? findsOneWidget : findsNothing, + reason: "$status, sync-heavy: $isSyncHeavy", + ); + expect(find.byType(SyncBar), showsName ? findsNothing : findsOneWidget); + } + } + }); + + testWidgets("rebinds to a replacement view model and cancels its stale Synced timer", + (tester) async { + const firstWalletName = "First Wallet"; + const secondWalletName = "Second Wallet"; + final firstWallet = _MockWallet(); + final secondWallet = _MockWallet(); + final firstViewModel = _ObservableDashboardViewModel(SyncingSyncStatus(100, 0.5)); + final secondViewModel = _ObservableDashboardViewModel(SyncingSyncStatus(100, 0.5)); + DashboardViewModel activeViewModel = firstViewModel; + late StateSetter setHarnessState; + + _stubDashboardViewModel(firstViewModel, firstWallet, firstWalletName); + _stubDashboardViewModel(secondViewModel, secondWallet, secondWalletName); + + await tester.pumpWidget( + _buildTestApp( + StatefulBuilder( + builder: (context, setState) { + setHarnessState = setState; + return _buildTopBar(activeViewModel); + }, + ), + ), + ); + await tester.pump(); + + firstViewModel.setStatus(SyncedSyncStatus()); + await tester.pump(); + expect(tester.widget(find.byType(SyncBar)).showSyncedMessage, isTrue); + + await tester.pump(const Duration(seconds: 1)); + setHarnessState(() => activeViewModel = secondViewModel); + await tester.pump(); + expect(tester.widget(find.byType(SyncBar)).showSyncedMessage, isFalse); + + firstViewModel.setStatus(SyncingSyncStatus(50, 0.75)); + await tester.pump(); + firstViewModel.setStatus(SyncedSyncStatus()); + await tester.pump(); + expect(tester.widget(find.byType(SyncBar)).showSyncedMessage, isFalse); + + secondViewModel.setStatus(SyncedSyncStatus()); + await tester.pump(); + expect(tester.widget(find.byType(SyncBar)).showSyncedMessage, isTrue); + + await tester.pump(const Duration(seconds: 2)); + expect(tester.widget(find.byType(SyncBar)).showSyncedMessage, isTrue); + + await tester.pump(const Duration(seconds: 1)); + await tester.pump(const Duration(milliseconds: 150)); + expect(find.text(secondWalletName), findsOneWidget); + expect(find.byType(SyncBar), findsNothing); + }); + + testWidgets("cancels the Synced timer when disposed", (tester) async { + final wallet = _MockWallet(); + final dashboardViewModel = _ObservableDashboardViewModel(SyncingSyncStatus(100, 0.5)); + + _stubDashboardViewModel(dashboardViewModel, wallet, "Wallet"); + + await tester.pumpWidget(_buildTestApp(_buildTopBar(dashboardViewModel))); + await tester.pump(); + + dashboardViewModel.setStatus(SyncedSyncStatus()); + await tester.pump(); + expect(tester.widget(find.byType(SyncBar)).showSyncedMessage, isTrue); + + await tester.pumpWidget(const SizedBox.shrink()); + + // Flutter reports any timer that survives disposal as pending when this test ends. + expect(tester.takeException(), isNull); + }); + + testWidgets("uses natural control widths in the synced Bitcoin header", (tester) async { + await tester.binding.setSurfaceSize(const Size(376, 200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final wallet = _MockWallet(); + final dashboardViewModel = _ObservableDashboardViewModel(SyncedSyncStatus()); + + _stubDashboardViewModel(dashboardViewModel, wallet, "My Bitcoin Wallet"); + + await tester.pumpWidget( + _buildTestApp( + Align( + alignment: Alignment.topCenter, + child: _buildTopBar(dashboardViewModel), + ), + ), + ); + await tester.pump(); + + final switcher = find.byType(LightningSwitcher); + final walletInfo = find.byType(WalletInfoBar); + final settingsButton = find.byType(ModernButton); + final switcherIcons = find.descendant( + of: switcher, + matching: find.byWidgetPredicate( + (widget) => + widget is CakeImageWidget && + widget.imageUrl?.startsWith("assets/new-ui/switcher-") == true, + ), + ); + + expect(tester.getSize(switcher), const Size(63, 36)); + expect(switcherIcons, findsNWidgets(2)); + for (final icon in tester.widgetList(switcherIcons)) { + expect(Size(icon.width!, icon.height!), const Size(27, 27)); + } + expect(tester.getSize(walletInfo).width, 217); + expect(tester.getRect(settingsButton).right, 358); + }); + + testWidgets("fits the syncing pill between the controls at different screen widths", + (tester) async { + await tester.binding.setSurfaceSize(const Size(376, 200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final wallet = _MockWallet(); + final dashboardViewModel = _ObservableDashboardViewModel(SyncingSyncStatus(100, 0.24)); + + _stubDashboardViewModel( + dashboardViewModel, + wallet, + "My Bitcoin Wallet", + isSyncHeavy: true, + hasSilentPayments: true, + silentPaymentsScanningActive: true, + ); + + await tester.pumpWidget( + _buildTestApp( + Align( + alignment: Alignment.topCenter, + child: _buildTopBar(dashboardViewModel), + ), + ), + ); + await tester.pump(); + + final pill = find.descendant( + of: find.byType(SyncBar), + matching: find.byWidgetPredicate( + (widget) => + widget is Container && + widget.constraints?.minHeight == 40 && + widget.constraints?.maxHeight == 40, + ), + ); + + for (final width in [320.0, 376.0, 600.0]) { + await tester.binding.setSurfaceSize(Size(width, 200)); + await tester.pump(); + + final switcherRect = tester.getRect(find.byType(LightningSwitcher)); + final syncBarRect = tester.getRect(find.byType(SyncBar)); + final settingsRect = tester.getRect(find.byType(ModernButton)); + final pillRect = tester.getRect(pill); + + expect(switcherRect.left, 18); + expect(settingsRect.right, width - 18); + expect(syncBarRect.height, 36); + expect(pillRect.height, 40); + expect(pillRect.left - switcherRect.right, closeTo(12, 0.001)); + expect(settingsRect.left - pillRect.right, closeTo(12, 0.001)); + expect(pillRect.center.dy, closeTo(syncBarRect.center.dy, 0.001)); + expect(tester.takeException(), isNull); + } + }); + + testWidgets("keeps the non-Lightning header geometry unchanged", (tester) async { + await tester.binding.setSurfaceSize(const Size(376, 200)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + + final wallet = _MockWallet(); + final dashboardViewModel = _ObservableDashboardViewModel(SyncedSyncStatus()); + + _stubDashboardViewModel( + dashboardViewModel, + wallet, + "My Monero Wallet", + hasLightning: false, + ); + when(() => wallet.currency).thenReturn(CryptoCurrency.xmr); + + await tester.pumpWidget( + _buildTestApp( + Align( + alignment: Alignment.topCenter, + child: _buildTopBar(dashboardViewModel), + ), + ), + ); + await tester.pump(); + + expect(tester.getSize(find.byType(WalletInfoBar)).width, 244); + }); +} diff --git a/test/new-ui/widgets/coins_page/wallet_info_test.dart b/test/new-ui/widgets/coins_page/wallet_info_test.dart new file mode 100644 index 0000000000..c51a1c0209 --- /dev/null +++ b/test/new-ui/widgets/coins_page/wallet_info_test.dart @@ -0,0 +1,62 @@ +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/locales/locale.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/wallet_info.dart"; +import "package:cake_wallet/new-ui/widgets/modern_button.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +void main() { + testWidgets("keeps a hardware wallet name on one ellipsized line", (tester) async { + const walletName = + "My exceptionally long Ledger hardware wallet name that cannot fit in the header"; + final semanticsHandle = tester.ensureSemantics(); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: localizationDelegates, + supportedLocales: S.delegate.supportedLocales, + home: const Scaffold( + body: Center( + child: SizedBox( + width: 160, + child: WalletInfoBar( + name: walletName, + hardwareWalletType: HardwareWalletType.ledger, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final name = tester.widget(find.text(walletName)); + expect(name.maxLines, 1); + expect(name.softWrap, isFalse); + expect(name.overflow, TextOverflow.ellipsis); + expect(name.style?.fontSize, 16); + + final hardwareIcon = tester.widget( + find.byKey(const ValueKey("hardware_wallet_icon")), + ); + expect(hardwareIcon.imageUrl, "assets/new-ui/hardware_wallets/device_ledger_nano_x.svg"); + expect(hardwareIcon.width, 24); + expect(hardwareIcon.height, 24); + expect( + tester.getRect(find.text(walletName)).left - + tester.getRect(find.byKey(const ValueKey("hardware_wallet_icon"))).right, + 4, + ); + + expect( + find.bySemanticsLabel("$walletName, ${S.current.hardware_wallet}"), + findsOneWidget, + ); + expect(find.byType(ModernButton), findsNothing); + expect(find.bySemanticsLabel(S.current.wallet_accounts), findsNothing); + expect(tester.takeException(), isNull); + semanticsHandle.dispose(); + }); +}