diff --git a/mobile-app/lib/l10n/app_en.arb b/mobile-app/lib/l10n/app_en.arb index 8f2309a7..088fd955 100644 --- a/mobile-app/lib/l10n/app_en.arb +++ b/mobile-app/lib/l10n/app_en.arb @@ -325,21 +325,30 @@ } } }, - "accountsSheetSubheaderTransparent": "Transparent Accounts", - "@accountsSheetSubheaderTransparent": { - "description": "Sub-segment header for transparent accounts" + "accountsScreenActiveWallet": "Active Wallet", + "@accountsScreenActiveWallet": { + "description": "Badge on the wallet header containing the active account; rendered uppercase" }, - "accountsSheetSubheaderEncrypted": "Encrypted Account", - "@accountsSheetSubheaderEncrypted": { - "description": "Sub-segment header for the encrypted account" + "accountsScreenAccountCount": "{count, plural, =1{1 Account} other{{count} Accounts}}", + "@accountsScreenAccountCount": { + "description": "Account count on a collapsed wallet row", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "walletNameTitle": "Wallet Name", + "@walletNameTitle": { + "description": "App bar title of the wallet naming screen" }, - "accountsSheetSubheaderKeystone": "Keystone Accounts", - "@accountsSheetSubheaderKeystone": { - "description": "Sub-segment header for keystone accounts" + "walletNameSubtitle": "Naming your wallets makes it easier to tell their accounts apart. Each wallet has its own encrypted account.", + "@walletNameSubtitle": { + "description": "Explainer text on the wallet naming screen" }, - "accountsSheetSubheaderMultisig": "Multisig Accounts", - "@accountsSheetSubheaderMultisig": { - "description": "Sub-segment header for multisig accounts" + "walletNameHint": "Enter a name for your wallet", + "@walletNameHint": { + "description": "Placeholder of the wallet name field" }, "addAccountMenuTitle": "Add Account", @@ -358,7 +367,7 @@ "@addAccountMenuMoreTitle": { "description": "Title of the add account more options action sheet" }, - "addAccountMenuImportKeystoneTitle": "Add Keystone Account", + "addAccountMenuImportKeystoneTitle": "Add Keystone Wallet", "@addAccountMenuImportKeystoneTitle": { "description": "Add keystone hardware account menu row title" }, diff --git a/mobile-app/lib/l10n/app_id.arb b/mobile-app/lib/l10n/app_id.arb index b2c3cfa0..a340f5e8 100644 --- a/mobile-app/lib/l10n/app_id.arb +++ b/mobile-app/lib/l10n/app_id.arb @@ -76,16 +76,17 @@ "accountsSheetBalance": "{balance} {symbol}", "accountsSheetWallet": "Wallet {number}", "accountsSheetKeystoneWallet": "{number, plural, =1{Wallet Perangkat Keras Keystone} other{Wallet Perangkat Keras Keystone {number}}}", - "accountsSheetSubheaderTransparent": "Akun Transparan", - "accountsSheetSubheaderEncrypted": "Akun Terenkripsi", - "accountsSheetSubheaderKeystone": "Akun Keystone", - "accountsSheetSubheaderMultisig": "Akun Multisig", + "accountsScreenActiveWallet": "Wallet Aktif", + "accountsScreenAccountCount": "{count, plural, =1{1 Akun} other{{count} Akun}}", + "walletNameTitle": "Nama Wallet", + "walletNameSubtitle": "Memberi nama wallet memudahkan Anda membedakan akun-akunnya. Setiap wallet memiliki akun terenkripsi sendiri.", + "walletNameHint": "Masukkan nama untuk wallet Anda", "addAccountMenuTitle": "Tambah Akun", "addAccountMenuCreateTitle": "Tambah Akun Transparan", "addAccountMenuCreateSubtitle": "Tambahkan akun publik lainnya", "addAccountMenuMoreTitle": "Lanjutan", - "addAccountMenuImportKeystoneTitle": "Tambah Akun Keystone", + "addAccountMenuImportKeystoneTitle": "Tambah Wallet Keystone", "addAccountMenuImportKeystoneSubtitle": "Penandatanganan air-gap melalui kode QR", "addAccountMenuImportTitle": "Impor Akun", "addAccountMenuImportSubtitle": "Pulihkan dari recovery phrase", diff --git a/mobile-app/lib/l10n/app_localizations.dart b/mobile-app/lib/l10n/app_localizations.dart index 60a0be27..16aa9cf8 100644 --- a/mobile-app/lib/l10n/app_localizations.dart +++ b/mobile-app/lib/l10n/app_localizations.dart @@ -494,29 +494,35 @@ abstract class AppLocalizations { /// **'{number, plural, =1{Keystone Hardware Wallet} other{Keystone Hardware Wallet {number}}}'** String accountsSheetKeystoneWallet(int number); - /// Sub-segment header for transparent accounts + /// Badge on the wallet header containing the active account; rendered uppercase /// /// In en, this message translates to: - /// **'Transparent Accounts'** - String get accountsSheetSubheaderTransparent; + /// **'Active Wallet'** + String get accountsScreenActiveWallet; - /// Sub-segment header for the encrypted account + /// Account count on a collapsed wallet row /// /// In en, this message translates to: - /// **'Encrypted Account'** - String get accountsSheetSubheaderEncrypted; + /// **'{count, plural, =1{1 Account} other{{count} Accounts}}'** + String accountsScreenAccountCount(int count); - /// Sub-segment header for keystone accounts + /// App bar title of the wallet naming screen /// /// In en, this message translates to: - /// **'Keystone Accounts'** - String get accountsSheetSubheaderKeystone; + /// **'Wallet Name'** + String get walletNameTitle; - /// Sub-segment header for multisig accounts + /// Explainer text on the wallet naming screen /// /// In en, this message translates to: - /// **'Multisig Accounts'** - String get accountsSheetSubheaderMultisig; + /// **'Naming your wallets makes it easier to tell their accounts apart. Each wallet has its own encrypted account.'** + String get walletNameSubtitle; + + /// Placeholder of the wallet name field + /// + /// In en, this message translates to: + /// **'Enter a name for your wallet'** + String get walletNameHint; /// App bar title on add account menu /// @@ -545,7 +551,7 @@ abstract class AppLocalizations { /// Add keystone hardware account menu row title /// /// In en, this message translates to: - /// **'Add Keystone Account'** + /// **'Add Keystone Wallet'** String get addAccountMenuImportKeystoneTitle; /// Add keystone hardware account menu row subtitle diff --git a/mobile-app/lib/l10n/app_localizations_en.dart b/mobile-app/lib/l10n/app_localizations_en.dart index 11c0f7fa..57eaef55 100644 --- a/mobile-app/lib/l10n/app_localizations_en.dart +++ b/mobile-app/lib/l10n/app_localizations_en.dart @@ -255,16 +255,23 @@ class AppLocalizationsEn extends AppLocalizations { } @override - String get accountsSheetSubheaderTransparent => 'Transparent Accounts'; + String get accountsScreenActiveWallet => 'Active Wallet'; @override - String get accountsSheetSubheaderEncrypted => 'Encrypted Account'; + String accountsScreenAccountCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count Accounts', one: '1 Account'); + return '$_temp0'; + } + + @override + String get walletNameTitle => 'Wallet Name'; @override - String get accountsSheetSubheaderKeystone => 'Keystone Accounts'; + String get walletNameSubtitle => + 'Naming your wallets makes it easier to tell their accounts apart. Each wallet has its own encrypted account.'; @override - String get accountsSheetSubheaderMultisig => 'Multisig Accounts'; + String get walletNameHint => 'Enter a name for your wallet'; @override String get addAccountMenuTitle => 'Add Account'; @@ -279,7 +286,7 @@ class AppLocalizationsEn extends AppLocalizations { String get addAccountMenuMoreTitle => 'Advanced'; @override - String get addAccountMenuImportKeystoneTitle => 'Add Keystone Account'; + String get addAccountMenuImportKeystoneTitle => 'Add Keystone Wallet'; @override String get addAccountMenuImportKeystoneSubtitle => 'Air-gapped signing via QR code'; diff --git a/mobile-app/lib/l10n/app_localizations_id.dart b/mobile-app/lib/l10n/app_localizations_id.dart index fc3f9b7c..0d0579b2 100644 --- a/mobile-app/lib/l10n/app_localizations_id.dart +++ b/mobile-app/lib/l10n/app_localizations_id.dart @@ -250,16 +250,23 @@ class AppLocalizationsId extends AppLocalizations { } @override - String get accountsSheetSubheaderTransparent => 'Akun Transparan'; + String get accountsScreenActiveWallet => 'Wallet Aktif'; @override - String get accountsSheetSubheaderEncrypted => 'Akun Terenkripsi'; + String accountsScreenAccountCount(int count) { + String _temp0 = intl.Intl.pluralLogic(count, locale: localeName, other: '$count Akun', one: '1 Akun'); + return '$_temp0'; + } + + @override + String get walletNameTitle => 'Nama Wallet'; @override - String get accountsSheetSubheaderKeystone => 'Akun Keystone'; + String get walletNameSubtitle => + 'Memberi nama wallet memudahkan Anda membedakan akun-akunnya. Setiap wallet memiliki akun terenkripsi sendiri.'; @override - String get accountsSheetSubheaderMultisig => 'Akun Multisig'; + String get walletNameHint => 'Masukkan nama untuk wallet Anda'; @override String get addAccountMenuTitle => 'Tambah Akun'; @@ -274,7 +281,7 @@ class AppLocalizationsId extends AppLocalizations { String get addAccountMenuMoreTitle => 'Lanjutan'; @override - String get addAccountMenuImportKeystoneTitle => 'Tambah Akun Keystone'; + String get addAccountMenuImportKeystoneTitle => 'Tambah Wallet Keystone'; @override String get addAccountMenuImportKeystoneSubtitle => 'Penandatanganan air-gap melalui kode QR'; diff --git a/mobile-app/lib/providers/wallet_providers.dart b/mobile-app/lib/providers/wallet_providers.dart index 1a9f263b..a9a1e245 100644 --- a/mobile-app/lib/providers/wallet_providers.dart +++ b/mobile-app/lib/providers/wallet_providers.dart @@ -293,6 +293,12 @@ final walletOriginProvider = Provider.family((ref, walletInd return ref.watch(settingsServiceProvider).getWalletOrigin(walletIndex); }); +/// Optional user-set wallet name; null falls back to "Wallet {n}" display copy. +/// Invalidate after [SettingsService.setWalletName]. +final walletNameProvider = Provider.family((ref, walletIndex) { + return ref.watch(settingsServiceProvider).getWalletName(walletIndex); +}); + /// 0.0001 tokens in smallest units; dust below this doesn't warrant a backup nudge. final _backupNudgeBalanceThreshold = BigInt.from(10).pow(AppConstants.decimals - 4); diff --git a/mobile-app/lib/shared/utils/accounts_grouping.dart b/mobile-app/lib/shared/utils/accounts_grouping.dart index 69f65bc9..c463d225 100644 --- a/mobile-app/lib/shared/utils/accounts_grouping.dart +++ b/mobile-app/lib/shared/utils/accounts_grouping.dart @@ -2,48 +2,54 @@ import 'package:quantus_sdk/quantus_sdk.dart'; enum WalletKind { software, keystone } -enum AccountSegment { transparent, encrypted, keystone, multisig } - -sealed class AccountListItem { - const AccountListItem(); -} - -/// A wallet group header, e.g. "Wallet 1" or "Keystone Hardware Wallet 1". -/// [number] is the 1-based position within its [kind]. -class WalletHeaderItem extends AccountListItem { +/// One wallet as displayed on the Accounts screen: its transparent (or +/// keystone) accounts, the single encrypted account (software wallets only), +/// and any multisigs owned by one of its accounts. [number] is the 1-based +/// position within its [kind], used for "Wallet {n}" fallback naming. +class WalletGroup { + final int walletIndex; final WalletKind kind; final int number; - const WalletHeaderItem({required this.kind, required this.number}); -} - -class SegmentHeaderItem extends AccountListItem { - final AccountSegment segment; - const SegmentHeaderItem(this.segment); + final List accounts; + final Account? encryptedAccount; + final List multisigs; + + const WalletGroup({ + required this.walletIndex, + required this.kind, + required this.number, + required this.accounts, + required this.encryptedAccount, + required this.multisigs, + }); + + int get accountCount => accounts.length + (encryptedAccount == null ? 0 : 1) + multisigs.length; + + bool contains(String accountId) => + accounts.any((a) => a.accountId == accountId) || + encryptedAccount?.accountId == accountId || + multisigs.any((m) => m.accountId == accountId); } -class AccountRowItem extends AccountListItem { - final BaseAccount account; - const AccountRowItem(this.account); +/// Result of grouping accounts for the Accounts screen. The first wallet is +/// the active wallet (falling back to the lowest software walletIndex); +/// multisigs whose member account is unknown trail in [standaloneMultisigs]. +class WalletsGrouping { + final List wallets; + final List standaloneMultisigs; + const WalletsGrouping({required this.wallets, required this.standaloneMultisigs}); } -/// Result of grouping accounts for the Accounts popup. When [segmented] is -/// false, [items] is a flat list of [AccountRowItem] with no headers. -class AccountsGrouping { - final bool segmented; - final List items; - const AccountsGrouping({required this.segmented, required this.items}); -} - -/// Pure mapping of accounts + multisigs to an ordered, segmented list. -/// -/// Order: software wallets (by walletIndex) then keystone wallets (by -/// walletIndex). Within each wallet, sub-segments appear in order and are -/// skipped when empty: transparent, encrypted (software) / keystone (hardware), -/// then multisig. Multisigs are owned by the wallet of the account matching -/// [MultisigAccount.myMemberAccountId]; unresolved ones trail in a standalone -/// multisig segment. A single software wallet of only transparent accounts with -/// no multisigs renders flat (no headers). -AccountsGrouping groupAccounts({required List accounts, required List multisigs}) { +/// Pure mapping of accounts + multisigs to wallet groups, ordered software +/// wallets (by walletIndex) then keystone wallets (by walletIndex). The wallet +/// containing [activeAccountId] moves to the front; the rest keep their order +/// and their "Wallet {n}" numbering. Multisigs are owned by the wallet of the +/// account matching [MultisigAccount.myMemberAccountId]. +WalletsGrouping groupWallets({ + required List accounts, + required List multisigs, + String? activeAccountId, +}) { final byWallet = >{}; for (final a in accounts) { byWallet.putIfAbsent(a.walletIndex, () => []).add(a); @@ -72,58 +78,41 @@ AccountsGrouping groupAccounts({required List accounts, required List a.accountType == AccountType.encrypted); - final segmented = !(atMostOneSoftwareWallet && !hasEncrypted && multisigs.isEmpty); - - if (!segmented) { - final flat = [...accounts]..sort(_compareAccounts); - return AccountsGrouping(segmented: false, items: [for (final a in flat) AccountRowItem(a)]); - } - - final items = []; - - void addSegment(AccountSegment segment, List rows) { - if (rows.isEmpty) return; - items.add(SegmentHeaderItem(segment)); - items.addAll(rows.map(AccountRowItem.new)); - } - - void addWallet(WalletKind kind, int number, int walletIndex) { + WalletGroup buildGroup(WalletKind kind, int number, int walletIndex) { final group = byWallet[walletIndex] ?? []; + final regular = group.where((a) => a.accountType != AccountType.encrypted).toList()..sort(_compareAccounts); + final encrypted = group.where((a) => a.accountType == AccountType.encrypted).toList()..sort(_compareAccounts); final msigs = [...?multisigsByWallet[walletIndex]]..sort(_compareMultisigs); - items.add(WalletHeaderItem(kind: kind, number: number)); - - if (kind == WalletKind.software) { - final transparent = group.where((a) => a.accountType != AccountType.encrypted).toList()..sort(_compareAccounts); - final encrypted = group.where((a) => a.accountType == AccountType.encrypted).toList()..sort(_compareAccounts); - addSegment(AccountSegment.transparent, transparent); - addSegment(AccountSegment.encrypted, encrypted); - } else { - final keystone = [...group]..sort(_compareAccounts); - addSegment(AccountSegment.keystone, keystone); - } - addSegment(AccountSegment.multisig, msigs); + return WalletGroup( + walletIndex: walletIndex, + kind: kind, + number: number, + accounts: regular, + encryptedAccount: encrypted.isEmpty ? null : encrypted.first, + multisigs: msigs, + ); } - for (var i = 0; i < softwareIndices.length; i++) { - addWallet(WalletKind.software, i + 1, softwareIndices[i]); - } - for (var i = 0; i < keystoneIndices.length; i++) { - addWallet(WalletKind.keystone, i + 1, keystoneIndices[i]); - } + final wallets = [ + for (var i = 0; i < softwareIndices.length; i++) buildGroup(WalletKind.software, i + 1, softwareIndices[i]), + for (var i = 0; i < keystoneIndices.length; i++) buildGroup(WalletKind.keystone, i + 1, keystoneIndices[i]), + ]; - final standalone = [...standaloneMultisigs]..sort(_compareMultisigs); - addSegment(AccountSegment.multisig, standalone); + if (activeAccountId != null) { + final activePos = wallets.indexWhere((w) => w.contains(activeAccountId)); + if (activePos > 0) { + wallets.insert(0, wallets.removeAt(activePos)); + } + } - return AccountsGrouping(segmented: true, items: items); + return WalletsGrouping(wallets: wallets, standaloneMultisigs: [...standaloneMultisigs]..sort(_compareMultisigs)); } bool _isKeystoneWallet(List group) => group.isNotEmpty && group.every((a) => a.accountType == AccountType.keystone); /// 1-based display number of the software wallet at [walletIndex], matching the -/// numbering used by [groupAccounts]. Returns null when [walletIndex] is not a +/// numbering used by [groupWallets]. Returns null when [walletIndex] is not a /// software wallet. int? softwareWalletNumber(List accounts, int walletIndex) { final byWallet = >{}; diff --git a/mobile-app/lib/v2/components/name_field.dart b/mobile-app/lib/v2/components/name_field.dart index bcdeaae2..68878f5a 100644 --- a/mobile-app/lib/v2/components/name_field.dart +++ b/mobile-app/lib/v2/components/name_field.dart @@ -8,8 +8,9 @@ class NameField extends ConsumerWidget { final TextEditingController controller; final String? subtitle; final String? error; + final String? hint; - const NameField({super.key, required this.controller, this.subtitle, this.error}); + const NameField({super.key, required this.controller, this.subtitle, this.error, this.hint}); @override Widget build(BuildContext context, WidgetRef ref) { @@ -28,7 +29,7 @@ class NameField extends ConsumerWidget { controller: controller, style: textStyle, decoration: InputDecoration.collapsed( - hintText: l10n.componentNameFieldHint, + hintText: hint ?? l10n.componentNameFieldHint, hintStyle: textStyle.copyWith(color: context.colors.textSecondary), ), ), diff --git a/mobile-app/lib/v2/screens/accounts/account_menu_screen.dart b/mobile-app/lib/v2/screens/accounts/account_menu_screen.dart index 65aeab10..3e04446e 100644 --- a/mobile-app/lib/v2/screens/accounts/account_menu_screen.dart +++ b/mobile-app/lib/v2/screens/accounts/account_menu_screen.dart @@ -71,7 +71,7 @@ class AccountMenuScreen extends ConsumerWidget { return ScaffoldBaseBottomContent( child: QuantusButton.simple( label: l10n.accountMenuDone, - onTap: () => returnToAccountsSheet(context, ref, highlightAccountId: account.accountId), + onTap: () => returnToAccountsScreen(context, ref, highlightAccountId: account.accountId), ), ); } @@ -172,7 +172,7 @@ class AccountMenuScreen extends ConsumerWidget { try { await AccountsService().removeAccount(account); invalidateAccountProviders(ref); - if (context.mounted) returnToAccountsSheet(context, ref); + if (context.mounted) returnToAccountsScreen(context, ref); } catch (e, st) { quantusPrint('[AccountMenu] disconnect account error: $e\n$st'); if (context.mounted) context.showErrorToaster(message: l10n.accountMenuDisconnectError); @@ -184,7 +184,7 @@ class AccountMenuScreen extends ConsumerWidget { try { await AccountsService().removeWallet(walletIndex); invalidateAccountProviders(ref); - if (context.mounted) returnToAccountsSheet(context, ref); + if (context.mounted) returnToAccountsScreen(context, ref); } catch (e, st) { quantusPrint('[AccountMenu] disconnect wallet error: $e\n$st'); if (context.mounted) context.showErrorToaster(message: l10n.accountMenuDisconnectError); diff --git a/mobile-app/lib/v2/screens/accounts/accounts_navigation.dart b/mobile-app/lib/v2/screens/accounts/accounts_navigation.dart index 306efb07..496f5e75 100644 --- a/mobile-app/lib/v2/screens/accounts/accounts_navigation.dart +++ b/mobile-app/lib/v2/screens/accounts/accounts_navigation.dart @@ -2,18 +2,18 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; -/// Route name of the Accounts popup sheet, used to pop back to it. -const accountsSheetRouteName = 'accounts_sheet'; +/// Route name of the Accounts screen, used to pop back to it. +const accountsScreenRouteName = 'accounts_screen'; -/// Pops back to the already-open Accounts popup sheet. Every in-app -/// add/import/disconnect flow originates from that sheet, so it stays in the +/// Pops back to the already-open Accounts screen. Every in-app +/// add/import/disconnect flow originates from that screen, so it stays in the /// navigation stack and we simply pop back to it instead of rebuilding Home. /// /// When [highlightAccountId] is given, that account is highlighted and scrolled -/// into view once the sheet is revealed. -void returnToAccountsSheet(BuildContext context, WidgetRef ref, {String? highlightAccountId}) { +/// into view once the screen is revealed. +void returnToAccountsScreen(BuildContext context, WidgetRef ref, {String? highlightAccountId}) { if (highlightAccountId != null) { ref.read(openAccountsIntentProvider.notifier).state = OpenAccountsIntent(highlightAccountId: highlightAccountId); } - Navigator.of(context).popUntil((route) => route.settings.name == accountsSheetRouteName); + Navigator.of(context).popUntil((route) => route.settings.name == accountsScreenRouteName); } diff --git a/mobile-app/lib/v2/screens/accounts/accounts_screen.dart b/mobile-app/lib/v2/screens/accounts/accounts_screen.dart new file mode 100644 index 00000000..e24b2f47 --- /dev/null +++ b/mobile-app/lib/v2/screens/accounts/accounts_screen.dart @@ -0,0 +1,568 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/multisig_providers.dart'; +import 'package:resonance_network_wallet/shared/utils/accounts_grouping.dart'; +import 'package:resonance_network_wallet/v2/components/account_badge.dart'; +import 'package:resonance_network_wallet/v2/components/loader.dart'; +import 'package:resonance_network_wallet/v2/components/private_activity_notice.dart'; +import 'package:resonance_network_wallet/v2/components/multisig_tag.dart'; +import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; +import 'package:resonance_network_wallet/v2/components/quantus_icon_button.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; +import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/l10n/app_localizations.dart'; +import 'package:resonance_network_wallet/providers/account_providers.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/shared/utils/print.dart'; +import 'package:resonance_network_wallet/v2/screens/accounts/account_menu_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/accounts/accounts_navigation.dart'; +import 'package:resonance_network_wallet/v2/screens/accounts/multisig_account_menu_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/accounts/wallet_name_screen.dart'; +import 'package:resonance_network_wallet/v2/screens/settings/add_account_menu_screen.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +Future openAccountsScreen(BuildContext context) { + return Navigator.of(context).push( + MaterialPageRoute( + settings: const RouteSettings(name: accountsScreenRouteName), + builder: (_) => const AccountsScreen(), + ), + ); +} + +class AccountsScreen extends ConsumerStatefulWidget { + const AccountsScreen({super.key}); + + @override + ConsumerState createState() => _AccountsScreenState(); +} + +class _AccountsScreenState extends ConsumerState { + final GlobalKey _scrollTargetKey = GlobalKey(); + bool _scrolledToTarget = false; + String? _highlightAccountId; + final Set _expandedWallets = {}; + + @override + void initState() { + super.initState(); + _ensureEncryptedAccounts(); + } + + /// Backfills the per-wallet encrypted (wormhole) account for every software + /// wallet so it shows alongside transparent accounts. Gated by the feature + /// flag; persists once, then no-ops on subsequent opens. + Future _ensureEncryptedAccounts() async { + try { + final created = await ensureEncryptedAccounts(ref); + if (created && mounted) ref.invalidate(accountsProvider); + } catch (e, st) { + quantusPrint('[AccountsScreen] ensure encrypted accounts failed: $e\n$st'); + } + } + + /// Reacts to an add/import flow popping back to this already-open screen: + /// highlight the new account and re-run the scroll-into-view. + void _applyOpenAccountsIntent(OpenAccountsIntent? intent) { + if (intent == null) return; + ref.read(openAccountsIntentProvider.notifier).state = null; + setState(() { + _highlightAccountId = intent.highlightAccountId; + _scrolledToTarget = false; + }); + } + + void _maybeScrollToTarget() { + if (_scrolledToTarget) return; + WidgetsBinding.instance.addPostFrameCallback((_) { + final ctx = _scrollTargetKey.currentContext; + if (ctx == null) return; + _scrolledToTarget = true; + Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, alignment: 0.5); + }); + } + + void _openAddAccountMenu() { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => const AddAccountMenuScreen())); + } + + Future _switchAccount(DisplayAccount display) async { + await ref.read(activeAccountProvider.notifier).setActiveAccount(display); + if (mounted) { + Navigator.of(context).pop(); + } + } + + void _openAccountMenu(Account account) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => AccountMenuScreen(initialAccount: account))); + } + + void _openMultisigAccountMenu(MultisigAccount account) { + Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => MultisigAccountMenuScreen(initialAccount: account))); + } + + void _openWalletNameEditor(int walletIndex) { + Navigator.of(context).push(MaterialPageRoute(builder: (_) => WalletNameScreen(walletIndex: walletIndex))); + } + + @override + Widget build(BuildContext context) { + ref.listen(openAccountsIntentProvider, (_, next) => _applyOpenAccountsIntent(next)); + + final l10n = ref.watch(l10nProvider); + final accountsAsync = ref.watch(accountsProvider); + final multisigAsync = ref.watch(multisigAccountsProvider); + final activeDisplayAccountAsync = ref.watch(activeAccountProvider); + + final activeAccountId = activeDisplayAccountAsync.value?.account.accountId; + + return ScaffoldBase( + appBar: V2AppBar(title: l10n.accountsSheetTitle), + mainContent: _buildContent( + l10n: l10n, + accountsAsync: accountsAsync, + multisigAsync: multisigAsync, + activeDisplayAccountAsync: activeDisplayAccountAsync, + activeAccountId: activeAccountId, + ), + bottomContent: ScaffoldBaseBottomContent( + child: QuantusButton.simple( + label: l10n.accountsSheetAddAccount, + onTap: _openAddAccountMenu, + variant: ButtonVariant.primary, + icon: Icon(Icons.add, size: 16, color: context.colors.background), + iconPlacement: IconPlacement.leading, + ), + ), + ); + } + + Widget _buildContent({ + required AppLocalizations l10n, + required AsyncValue> accountsAsync, + required AsyncValue> multisigAsync, + required AsyncValue activeDisplayAccountAsync, + required String? activeAccountId, + }) { + if (accountsAsync.isLoading || activeDisplayAccountAsync.isLoading || multisigAsync.isLoading) { + return const Center(child: Loader()); + } + + if (accountsAsync.hasError || multisigAsync.hasError) { + return _centeredMessage(l10n.accountsSheetFailedLoadAccounts); + } + + if (activeDisplayAccountAsync.hasError) { + return _centeredMessage(l10n.accountsSheetFailedLoadActiveAccount); + } + + final grouping = groupWallets( + accounts: accountsAsync.value ?? [], + multisigs: multisigAsync.value ?? [], + activeAccountId: activeAccountId, + ); + + if (grouping.wallets.isEmpty && grouping.standaloneMultisigs.isEmpty) { + return _centeredMessage(l10n.accountsSheetNoAccountsFound); + } + + return _buildWalletsListView(l10n, grouping, activeAccountId); + } + + Widget _centeredMessage(String message) { + return Center( + child: Text(message, style: context.themeText.smallParagraph?.copyWith(color: context.colors.textSecondary)), + ); + } + + bool _containsAccount(WalletGroup group, String? accountId) => accountId != null && group.contains(accountId); + + Widget _buildWalletsListView(AppLocalizations l10n, WalletsGrouping grouping, String? activeAccountId) { + final scrollTargetId = _highlightAccountId ?? activeAccountId; + + // A pending highlight (new account from an add/import flow) must expand + // its wallet to be scrolled to. + if (!_scrolledToTarget && scrollTargetId != null) { + final targetGroup = grouping.wallets.skip(1).firstWhereOrNull((g) => _containsAccount(g, scrollTargetId)); + if (targetGroup != null) _expandedWallets.add(targetGroup.walletIndex); + } + _maybeScrollToTarget(); + + final mainWallet = grouping.wallets.first; + final otherWallets = grouping.wallets.skip(1).toList(); + + return ListView( + children: [ + const SizedBox(height: 8), + _buildWalletHeader(l10n, mainWallet, activeAccountId), + const SizedBox(height: 14), + ..._withGaps(_walletRows(l10n, mainWallet, activeAccountId, scrollTargetId), 14), + if (otherWallets.isNotEmpty || grouping.standaloneMultisigs.isNotEmpty) ...[ + const SizedBox(height: 16), + Divider(color: context.colors.separator, height: 1), + const SizedBox(height: 16), + ], + ..._withGaps([ + for (final wallet in otherWallets) _buildCollapsibleWallet(l10n, wallet, activeAccountId, scrollTargetId), + for (final multisig in grouping.standaloneMultisigs) + _buildMultisigRow(l10n, multisig, multisig.accountId == activeAccountId, scrollTargetId), + ], 14), + const SizedBox(height: 16), + ], + ); + } + + List _withGaps(List children, double gap) => [ + for (var i = 0; i < children.length; i++) ...[if (i > 0) SizedBox(height: gap), children[i]], + ]; + + String _walletDisplayName(AppLocalizations l10n, WalletGroup wallet) { + final custom = ref.watch(walletNameProvider(wallet.walletIndex)); + if (custom != null && custom.isNotEmpty) return custom; + return switch (wallet.kind) { + WalletKind.software => l10n.accountsSheetWallet(wallet.number), + WalletKind.keystone => l10n.accountsSheetKeystoneWallet(wallet.number), + }; + } + + List _walletRows(AppLocalizations l10n, WalletGroup wallet, String? activeAccountId, String? scrollTargetId) { + return [ + for (final account in wallet.accounts) _buildRegularRow(l10n, account, activeAccountId, scrollTargetId), + if (wallet.encryptedAccount != null) + _buildEncryptedRow(l10n, wallet.encryptedAccount!, activeAccountId, scrollTargetId), + for (final multisig in wallet.multisigs) + _buildMultisigRow(l10n, multisig, multisig.accountId == activeAccountId, scrollTargetId), + ]; + } + + Widget _buildWalletHeader(AppLocalizations l10n, WalletGroup wallet, String? activeAccountId) { + return Row( + children: [ + Expanded(child: _buildWalletNameAndEdit(l10n, wallet)), + if (_containsAccount(wallet, activeAccountId)) _ActiveWalletTag(label: l10n.accountsScreenActiveWallet), + ], + ); + } + + Widget _buildWalletNameAndEdit(AppLocalizations l10n, WalletGroup wallet) { + return Row( + children: [ + Flexible( + child: Text( + _walletDisplayName(l10n, wallet), + style: context.themeText.paragraph?.copyWith(height: 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 4), + QuantusIconButton.circular( + icon: Icons.edit_outlined, + onTap: () => _openWalletNameEditor(wallet.walletIndex), + size: IconButtonSize.small, + ), + ], + ); + } + + Widget _buildCollapsibleWallet( + AppLocalizations l10n, + WalletGroup wallet, + String? activeAccountId, + String? scrollTargetId, + ) { + final colors = context.colors; + final expanded = _expandedWallets.contains(wallet.walletIndex); + final displayName = _walletDisplayName(l10n, wallet); + + return Container( + decoration: BoxDecoration(color: colors.surfaceDeep, borderRadius: BorderRadius.circular(14)), + padding: const EdgeInsets.all(16), + child: Column( + children: [ + GestureDetector( + onTap: () => setState(() { + expanded ? _expandedWallets.remove(wallet.walletIndex) : _expandedWallets.add(wallet.walletIndex); + }), + behavior: HitTestBehavior.opaque, + child: expanded + ? _buildExpandedWalletHeader(l10n, wallet, activeAccountId) + : _buildCollapsedWalletHeader(l10n, wallet, displayName), + ), + AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + alignment: Alignment.topCenter, + child: expanded + ? Column( + children: [ + Padding( + padding: const EdgeInsets.only(top: 16), + child: Divider(color: colors.separator, height: 1), + ), + const SizedBox(height: 8), + ..._walletRows(l10n, wallet, activeAccountId, scrollTargetId), + ], + ) + : const SizedBox.shrink(), + ), + ], + ), + ); + } + + Widget _buildCollapsedWalletHeader(AppLocalizations l10n, WalletGroup wallet, String displayName) { + final colors = context.colors; + return Row( + children: [ + AccountBadge(name: displayName), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayName, + style: context.themeText.paragraph!.copyWith(fontSize: 18, height: 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 8), + Text( + _walletBalanceText(l10n, wallet), + style: context.themeText.smallParagraph!.copyWith(color: colors.textTertiary, height: 1), + ), + ], + ), + ), + const SizedBox(width: 8), + Text( + l10n.accountsScreenAccountCount(wallet.accountCount), + style: context.themeText.smallParagraph?.copyWith(color: colors.textSubtle, height: 1), + ), + const SizedBox(width: 4), + Icon(Icons.keyboard_arrow_down, size: 20, color: colors.textMuted), + ], + ); + } + + Widget _buildExpandedWalletHeader(AppLocalizations l10n, WalletGroup wallet, String? activeAccountId) { + return Row( + children: [ + Expanded(child: _buildWalletNameAndEdit(l10n, wallet)), + if (_containsAccount(wallet, activeAccountId)) ...[ + _ActiveWalletTag(label: l10n.accountsScreenActiveWallet), + const SizedBox(width: 8), + ], + Icon(Icons.keyboard_arrow_up, size: 20, color: context.colors.textMuted), + ], + ); + } + + String _walletBalanceText(AppLocalizations l10n, WalletGroup wallet) { + final balances = >[ + for (final account in wallet.accounts) ref.watch(balanceProviderFamily(account.accountId)), + if (wallet.encryptedAccount != null) ref.watch(encryptedBalanceProvider(wallet.walletIndex)), + for (final multisig in wallet.multisigs) ref.watch(balanceProviderFamily(multisig.accountId)), + ]; + if (balances.any((b) => b.isLoading)) return l10n.commonLoading; + if (balances.any((b) => b.hasError)) return l10n.accountsSheetBalanceUnavailable; + final total = balances.fold(BigInt.zero, (sum, b) => sum + (b.value ?? BigInt.zero)); + final formattingService = ref.watch(numberFormattingServiceProvider); + return l10n.accountsSheetBalance(formattingService.formatBalance(total), AppConstants.tokenSymbol); + } + + String _balanceText(AppLocalizations l10n, BaseAccount account) { + final balanceAsync = isEncryptedAccount(account) + ? ref.watch(encryptedBalanceProvider((account as Account).walletIndex)) + : ref.watch(balanceProviderFamily(account.accountId)); + final formattingService = ref.watch(numberFormattingServiceProvider); + return balanceAsync.when( + loading: () => l10n.commonLoading, + error: (_, _) => l10n.accountsSheetBalanceUnavailable, + data: (balance) => l10n.accountsSheetBalance(formattingService.formatBalance(balance), AppConstants.tokenSymbol), + ); + } + + Widget _buildRegularRow(AppLocalizations l10n, Account account, String? activeAccountId, String? scrollTargetId) { + final isActive = account.accountId == activeAccountId; + return _AccountRowShell( + key: account.accountId == scrollTargetId ? _scrollTargetKey : null, + isActive: isActive, + isHighlighted: account.accountId == _highlightAccountId, + onTap: () => _switchAccount(RegularAccount(account)), + leading: AccountBadge.account(account: account, isActive: isActive), + title: account.name, + subtitle: _balanceText(l10n, account), + trailing: _MenuCaret(onTap: () => _openAccountMenu(account)), + ); + } + + Widget _buildEncryptedRow(AppLocalizations l10n, Account account, String? activeAccountId, String? scrollTargetId) { + return _AccountRowShell( + key: account.accountId == scrollTargetId ? _scrollTargetKey : null, + isActive: account.accountId == activeAccountId, + isHighlighted: account.accountId == _highlightAccountId, + onTap: () => _switchAccount(RegularAccount(account)), + leading: const EncryptedLockBadge(), + title: account.name, + subtitle: _balanceText(l10n, account), + ); + } + + Widget _buildMultisigRow(AppLocalizations l10n, MultisigAccount account, bool isActive, String? scrollTargetId) { + return _AccountRowShell( + key: account.accountId == scrollTargetId ? _scrollTargetKey : null, + isActive: isActive, + isHighlighted: account.accountId == _highlightAccountId, + onTap: () => _switchAccount(MultisigDisplayAccount(account)), + leading: AccountBadge(name: account.name, isActive: isActive), + title: account.name, + subtitle: _balanceText(l10n, account), + tag: MultisigTag(label: l10n.multisigTag), + trailing: _MenuCaret(onTap: () => _openMultisigAccountMenu(account)), + ); + } +} + +/// Trailing chevron on an account row: opens the account menu, while tapping +/// the row itself switches to the account. +class _MenuCaret extends StatelessWidget { + final VoidCallback onTap; + + const _MenuCaret({required this.onTap}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.all(10), + child: Icon(Icons.chevron_right, size: 20, color: context.colors.textMuted), + ), + ); + } +} + +class _ActiveWalletTag extends StatelessWidget { + final String label; + + const _ActiveWalletTag({required this.label}); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final accent = colors.accentOrange; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + border: Border.all(color: accent.useOpacity(0.1)), + color: accent.useOpacity(0.1), + ), + child: Text( + label.toUpperCase(), + style: context.themeText.detail?.copyWith( + color: accent, + fontSize: 11, + letterSpacing: 0.88, + height: 1.2, + fontFamily: AppTextTheme.fontFamilySecondary, + ), + ), + ); + } +} + +class _AccountRowShell extends StatelessWidget { + final bool isActive; + final bool isHighlighted; + final VoidCallback onTap; + final Widget leading; + final String? title; + final String subtitle; + final Widget? trailing; + final Widget? tag; + + const _AccountRowShell({ + super.key, + required this.isActive, + required this.onTap, + required this.leading, + required this.subtitle, + this.isHighlighted = false, + this.title, + this.trailing, + this.tag, + }); + + @override + Widget build(BuildContext context) { + final colors = context.colors; + final border = isHighlighted + ? Border.all(color: colors.accentOrange, width: 2) + : isActive + ? Border.all(color: colors.borderButton) + : null; + final hasTitle = title != null && title!.isNotEmpty; + + return GestureDetector( + onTap: onTap, + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: colors.surfaceDeep, borderRadius: BorderRadius.circular(14), border: border), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + leading, + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasTitle) ...[ + Row( + children: [ + Flexible( + child: Text( + title!, + style: context.themeText.paragraph!.copyWith(fontSize: 18, height: 1), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + if (tag != null) ...[const SizedBox(width: 8), tag!], + ], + ), + const SizedBox(height: 8), + ], + Text( + subtitle, + style: context.themeText.smallParagraph!.copyWith(color: colors.textTertiary, height: 1), + ), + ], + ), + ), + ], + ), + ), + if (trailing != null) ...[const SizedBox(width: 8), trailing!], + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/accounts/accounts_sheet.dart b/mobile-app/lib/v2/screens/accounts/accounts_sheet.dart deleted file mode 100644 index 743c995b..00000000 --- a/mobile-app/lib/v2/screens/accounts/accounts_sheet.dart +++ /dev/null @@ -1,419 +0,0 @@ -import 'dart:math' as math; -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:quantus_sdk/quantus_sdk.dart'; -import 'package:resonance_network_wallet/providers/multisig_providers.dart'; -import 'package:resonance_network_wallet/shared/utils/accounts_grouping.dart'; -import 'package:resonance_network_wallet/v2/components/account_badge.dart'; -import 'package:resonance_network_wallet/v2/components/loader.dart'; -import 'package:resonance_network_wallet/v2/components/private_activity_notice.dart'; -import 'package:resonance_network_wallet/v2/components/multisig_tag.dart'; -import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; -import 'package:resonance_network_wallet/v2/components/quantus_icon_button.dart'; -import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; -import 'package:resonance_network_wallet/l10n/app_localizations.dart'; -import 'package:resonance_network_wallet/providers/account_providers.dart'; -import 'package:resonance_network_wallet/providers/l10n_provider.dart'; -import 'package:resonance_network_wallet/providers/route_intent_providers.dart'; -import 'package:resonance_network_wallet/providers/wallet_providers.dart'; -import 'package:resonance_network_wallet/shared/utils/print.dart'; -import 'package:resonance_network_wallet/v2/components/bottom_sheet_container.dart'; -import 'package:resonance_network_wallet/v2/screens/accounts/account_menu_screen.dart'; -import 'package:resonance_network_wallet/v2/screens/accounts/accounts_navigation.dart'; -import 'package:resonance_network_wallet/v2/screens/accounts/multisig_account_menu_screen.dart'; -import 'package:resonance_network_wallet/v2/screens/settings/add_account_menu_screen.dart'; -import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; - -Future showAccountsSheet(BuildContext context) async { - return BottomSheetContainer.show( - context, - routeSettings: const RouteSettings(name: accountsSheetRouteName), - builder: (_) => const AccountsSheet(), - ); -} - -class AccountsSheet extends ConsumerStatefulWidget { - const AccountsSheet({super.key}); - - @override - ConsumerState createState() => _AccountsScreenState(); -} - -class _AccountsScreenState extends ConsumerState { - final GlobalKey _scrollTargetKey = GlobalKey(); - bool _scrolledToTarget = false; - String? _highlightAccountId; - - @override - void initState() { - super.initState(); - _ensureEncryptedAccounts(); - } - - /// Backfills the per-wallet encrypted (wormhole) account for every software - /// wallet so it shows alongside transparent accounts. Gated by the feature - /// flag; persists once, then no-ops on subsequent opens. - Future _ensureEncryptedAccounts() async { - try { - final created = await ensureEncryptedAccounts(ref); - if (created && mounted) ref.invalidate(accountsProvider); - } catch (e, st) { - quantusPrint('[AccountsSheet] ensure encrypted accounts failed: $e\n$st'); - } - } - - /// Reacts to an add/import flow popping back to this already-open sheet: - /// highlight the new account and re-run the scroll-into-view. - void _applyOpenAccountsIntent(OpenAccountsIntent? intent) { - if (intent == null) return; - ref.read(openAccountsIntentProvider.notifier).state = null; - setState(() { - _highlightAccountId = intent.highlightAccountId; - _scrolledToTarget = false; - }); - } - - void _maybeScrollToTarget() { - if (_scrolledToTarget) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - final ctx = _scrollTargetKey.currentContext; - if (ctx == null) return; - _scrolledToTarget = true; - Scrollable.ensureVisible(ctx, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, alignment: 0.5); - }); - } - - void _openAddAccountMenu() { - Navigator.of( - context, - rootNavigator: true, - ).push(MaterialPageRoute(builder: (_) => const AddAccountMenuScreen())); - } - - Future _switchAccount(DisplayAccount display) async { - await ref.read(activeAccountProvider.notifier).setActiveAccount(display); - if (mounted) { - Navigator.of(context).pop(); - } - } - - void _openAccountMenu(Account account) { - Navigator.of(context).push(MaterialPageRoute(builder: (_) => AccountMenuScreen(initialAccount: account))); - } - - void _openMultisigAccountMenu(MultisigAccount account) { - Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => MultisigAccountMenuScreen(initialAccount: account))); - } - - @override - Widget build(BuildContext context) { - ref.listen(openAccountsIntentProvider, (_, next) => _applyOpenAccountsIntent(next)); - - final l10n = ref.watch(l10nProvider); - final accountsAsync = ref.watch(accountsProvider); - final multisigAsync = ref.watch(multisigAccountsProvider); - final activeDisplayAccountAsync = ref.watch(activeAccountProvider); - - final activeAccountId = activeDisplayAccountAsync.value?.account.accountId; - - final media = MediaQuery.of(context); - final maxHeight = media.size.height - media.padding.top - 20; - final sheetHeight = math.min(610.0, maxHeight); - - return BottomSheetContainer( - title: l10n.accountsSheetTitle, - height: sheetHeight, - child: _buildContent( - l10n: l10n, - accountsAsync: accountsAsync, - multisigAsync: multisigAsync, - activeDisplayAccountAsync: activeDisplayAccountAsync, - activeAccountId: activeAccountId, - ), - ); - } - - Widget _buildContent({ - required AppLocalizations l10n, - required AsyncValue> accountsAsync, - required AsyncValue> multisigAsync, - required AsyncValue activeDisplayAccountAsync, - required String? activeAccountId, - }) { - if (accountsAsync.isLoading || activeDisplayAccountAsync.isLoading || multisigAsync.isLoading) { - return const Center(child: Loader()); - } - - if (accountsAsync.hasError || multisigAsync.hasError) { - return Center( - child: Text( - l10n.accountsSheetFailedLoadAccounts, - style: context.themeText.smallParagraph?.copyWith(color: context.colors.textSecondary), - ), - ); - } - - if (activeDisplayAccountAsync.hasError) { - return Center( - child: Text( - l10n.accountsSheetFailedLoadActiveAccount, - style: context.themeText.smallParagraph?.copyWith(color: context.colors.textSecondary), - ), - ); - } - - final grouping = groupAccounts(accounts: accountsAsync.value ?? [], multisigs: multisigAsync.value ?? []); - - return _buildAccountsListView(l10n, grouping, activeAccountId); - } - - Widget _buildAccountsListView(AppLocalizations l10n, AccountsGrouping grouping, String? activeAccountId) { - final scrollTargetId = _highlightAccountId ?? activeAccountId; - _maybeScrollToTarget(); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: grouping.items.isEmpty - ? Center( - child: Text( - l10n.accountsSheetNoAccountsFound, - style: context.themeText.smallParagraph?.copyWith(color: context.colors.textSecondary), - ), - ) - : ListView(children: _buildItemWidgets(l10n, grouping.items, activeAccountId, scrollTargetId)), - ), - const SizedBox(height: 24), - QuantusButton.simple( - label: l10n.accountsSheetAddAccount, - onTap: _openAddAccountMenu, - variant: ButtonVariant.primary, - ), - ], - ); - } - - List _buildItemWidgets( - AppLocalizations l10n, - List items, - String? activeAccountId, - String? scrollTargetId, - ) { - final widgets = []; - AccountListItem? prev; - for (final item in items) { - final gap = _gapBefore(prev, item); - if (gap > 0) widgets.add(SizedBox(height: gap)); - widgets.add(switch (item) { - WalletHeaderItem() => _buildWalletHeader(l10n, item), - SegmentHeaderItem() => _buildSegmentHeader(l10n, item), - AccountRowItem() => _buildRow(l10n, item.account, activeAccountId, scrollTargetId), - }); - prev = item; - } - return widgets; - } - - double _gapBefore(AccountListItem? prev, AccountListItem item) { - if (prev == null) return 0; - if (item is WalletHeaderItem) return 24; - if (item is SegmentHeaderItem) return 18; - return prev is AccountRowItem ? 14 : 12; - } - - Widget _buildWalletHeader(AppLocalizations l10n, WalletHeaderItem item) { - final title = switch (item.kind) { - WalletKind.software => l10n.accountsSheetWallet(item.number), - WalletKind.keystone => l10n.accountsSheetKeystoneWallet(item.number), - }; - return Text( - title, - style: context.themeText.smallParagraph?.copyWith( - color: context.colors.textSecondary, - fontWeight: FontWeight.w600, - ), - ); - } - - Widget _buildSegmentHeader(AppLocalizations l10n, SegmentHeaderItem item) { - final title = switch (item.segment) { - AccountSegment.transparent => l10n.accountsSheetSubheaderTransparent, - AccountSegment.encrypted => l10n.accountsSheetSubheaderEncrypted, - AccountSegment.keystone => l10n.accountsSheetSubheaderKeystone, - AccountSegment.multisig => l10n.accountsSheetSubheaderMultisig, - }; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text(title, style: context.themeText.detail?.copyWith(color: context.colors.textTertiary)), - ); - } - - Widget _buildRow(AppLocalizations l10n, BaseAccount account, String? activeAccountId, String? scrollTargetId) { - final isActive = account.accountId == activeAccountId; - final isHighlighted = account.accountId == _highlightAccountId; - final key = account.accountId == scrollTargetId ? _scrollTargetKey : null; - - if (account is MultisigAccount) return _buildMultisigRow(l10n, account, isActive, isHighlighted, key); - if (account is Account && account.accountType == AccountType.encrypted) { - return _buildEncryptedRow(l10n, account, isActive, isHighlighted, key); - } - if (account is Account) return _buildRegularRow(l10n, account, isActive, isHighlighted, key); - return const SizedBox.shrink(); - } - - String _balanceText(AppLocalizations l10n, BaseAccount account) { - final balanceAsync = isEncryptedAccount(account) - ? ref.watch(encryptedBalanceProvider((account as Account).walletIndex)) - : ref.watch(balanceProviderFamily(account.accountId)); - final formattingService = ref.watch(numberFormattingServiceProvider); - return balanceAsync.when( - loading: () => l10n.commonLoading, - error: (_, _) => l10n.accountsSheetBalanceUnavailable, - data: (balance) => l10n.accountsSheetBalance(formattingService.formatBalance(balance), AppConstants.tokenSymbol), - ); - } - - Widget _buildRegularRow(AppLocalizations l10n, Account account, bool isActive, bool isHighlighted, Key? key) { - return _AccountRowShell( - key: key, - isActive: isActive, - isHighlighted: isHighlighted, - onTap: () => _switchAccount(RegularAccount(account)), - leading: AccountBadge.account(account: account, isActive: isActive), - title: account.name, - subtitle: _balanceText(l10n, account), - trailing: QuantusIconButton.circular( - icon: Icons.edit_outlined, - onTap: () => _openAccountMenu(account), - size: IconButtonSize.medium, - ), - ); - } - - Widget _buildEncryptedRow(AppLocalizations l10n, Account account, bool isActive, bool isHighlighted, Key? key) { - return _AccountRowShell( - key: key, - isActive: isActive, - isHighlighted: isHighlighted, - onTap: () => _switchAccount(RegularAccount(account)), - leading: const EncryptedLockBadge(), - title: account.name, - subtitle: _balanceText(l10n, account), - ); - } - - Widget _buildMultisigRow( - AppLocalizations l10n, - MultisigAccount account, - bool isActive, - bool isHighlighted, - Key? key, - ) { - return _AccountRowShell( - key: key, - isActive: isActive, - isHighlighted: isHighlighted, - onTap: () => _switchAccount(MultisigDisplayAccount(account)), - leading: AccountBadge(name: account.name, isActive: isActive), - title: account.name, - subtitle: _balanceText(l10n, account), - tag: MultisigTag(label: l10n.multisigTag), - trailing: QuantusIconButton.circular( - icon: Icons.edit_outlined, - onTap: () => _openMultisigAccountMenu(account), - size: IconButtonSize.medium, - ), - ); - } -} - -class _AccountRowShell extends StatelessWidget { - final bool isActive; - final bool isHighlighted; - final VoidCallback onTap; - final Widget leading; - final String? title; - final String subtitle; - final Widget? trailing; - final Widget? tag; - - const _AccountRowShell({ - super.key, - required this.isActive, - required this.onTap, - required this.leading, - required this.subtitle, - this.isHighlighted = false, - this.title, - this.trailing, - this.tag, - }); - - @override - Widget build(BuildContext context) { - final colors = context.colors; - final border = isHighlighted - ? Border.all(color: colors.accentOrange, width: 2) - : isActive - ? Border.all(color: colors.borderButton) - : null; - final hasTitle = title != null && title!.isNotEmpty; - - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: colors.surfaceDeep, borderRadius: BorderRadius.circular(14), border: border), - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Expanded( - child: Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - leading, - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (hasTitle) ...[ - Row( - children: [ - Flexible( - child: Text( - title!, - style: context.themeText.paragraph!.copyWith(fontSize: 18, height: 1), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - if (tag != null) ...[const SizedBox(width: 8), tag!], - ], - ), - const SizedBox(height: 8), - ], - Text( - subtitle, - style: context.themeText.smallParagraph!.copyWith( - fontSize: 14, - color: colors.textTertiary, - height: 1, - ), - ), - ], - ), - ), - ], - ), - ), - if (trailing != null) ...[const SizedBox(width: 8), trailing!], - ], - ), - ), - ); - } -} diff --git a/mobile-app/lib/v2/screens/accounts/create_account_screen.dart b/mobile-app/lib/v2/screens/accounts/create_account_screen.dart index 3a54cc4b..6bf2ff7d 100644 --- a/mobile-app/lib/v2/screens/accounts/create_account_screen.dart +++ b/mobile-app/lib/v2/screens/accounts/create_account_screen.dart @@ -54,7 +54,7 @@ class _CreateAccountScreenState extends ConsumerState { invalidateAccountProviders(ref); ref.read(firebaseMessagingServiceProvider).insertNewAddress(accountToSave.accountId); - if (mounted) returnToAccountsSheet(context, ref, highlightAccountId: accountToSave.accountId); + if (mounted) returnToAccountsScreen(context, ref, highlightAccountId: accountToSave.accountId); } catch (e, st) { quantusPrint('[CreateAccount] create account error: $e\n$st'); if (mounted) { diff --git a/mobile-app/lib/v2/screens/accounts/multisig_account_menu_screen.dart b/mobile-app/lib/v2/screens/accounts/multisig_account_menu_screen.dart index 8590ea7b..b76bda3c 100644 --- a/mobile-app/lib/v2/screens/accounts/multisig_account_menu_screen.dart +++ b/mobile-app/lib/v2/screens/accounts/multisig_account_menu_screen.dart @@ -83,7 +83,7 @@ class MultisigAccountMenuScreen extends ConsumerWidget { try { await ref.read(multisigAccountsProvider.notifier).remove(account.accountId); ref.invalidate(activeAccountProvider); - if (context.mounted) returnToAccountsSheet(context, ref); + if (context.mounted) returnToAccountsScreen(context, ref); } catch (e, st) { quantusPrint('[MultisigAccountMenu] disconnect error: $e\n$st'); if (context.mounted) context.showErrorToaster(message: l10n.accountMenuDisconnectError); diff --git a/mobile-app/lib/v2/screens/accounts/open_accounts_management_button.dart b/mobile-app/lib/v2/screens/accounts/open_accounts_management_button.dart index 96529165..00cf5820 100644 --- a/mobile-app/lib/v2/screens/accounts/open_accounts_management_button.dart +++ b/mobile-app/lib/v2/screens/accounts/open_accounts_management_button.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:resonance_network_wallet/v2/components/glass_button_base.dart'; -import 'package:resonance_network_wallet/v2/screens/accounts/accounts_sheet.dart'; +import 'package:resonance_network_wallet/v2/screens/accounts/accounts_screen.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; class OpenAccountsManagementButton extends StatelessWidget { @@ -14,7 +14,7 @@ class OpenAccountsManagementButton extends StatelessWidget { final double iconSize = 20; return GestureDetector( - onTap: () => showAccountsSheet(context), + onTap: () => openAccountsScreen(context), child: GlassButtonBase( buttonHeight: buttonHeight, borderRadius: borderRadius, diff --git a/mobile-app/lib/v2/screens/accounts/wallet_name_screen.dart b/mobile-app/lib/v2/screens/accounts/wallet_name_screen.dart new file mode 100644 index 00000000..f58b10e5 --- /dev/null +++ b/mobile-app/lib/v2/screens/accounts/wallet_name_screen.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:quantus_sdk/quantus_sdk.dart'; +import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; +import 'package:resonance_network_wallet/v2/components/name_field.dart'; +import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; +import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; +import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; +import 'package:resonance_network_wallet/v2/screens/accounts/accounts_navigation.dart'; +import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; +import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; + +/// Names (or renames) a wallet. The name is optional: saving an empty field +/// clears it and the wallet falls back to its "Wallet {n}" display name. +class WalletNameScreen extends ConsumerStatefulWidget { + const WalletNameScreen({super.key, required this.walletIndex, this.returnHighlightAccountId}); + + final int walletIndex; + + /// When set (post-import), Done returns to the Accounts screen highlighting + /// this account instead of popping back to the previous screen. + final String? returnHighlightAccountId; + + @override + ConsumerState createState() => _WalletNameScreenState(); +} + +class _WalletNameScreenState extends ConsumerState { + late final TextEditingController _controller; + bool _saving = false; + + @override + void initState() { + super.initState(); + _controller = TextEditingController(text: SettingsService().getWalletName(widget.walletIndex) ?? ''); + _controller.addListener(() => setState(() {})); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _save() async { + setState(() => _saving = true); + await SettingsService().setWalletName(widget.walletIndex, _controller.text.trim()); + ref.invalidate(walletNameProvider(widget.walletIndex)); + if (!mounted) return; + final highlightAccountId = widget.returnHighlightAccountId; + if (highlightAccountId != null) { + returnToAccountsScreen(context, ref, highlightAccountId: highlightAccountId); + } else { + Navigator.of(context).pop(); + } + } + + @override + Widget build(BuildContext context) { + final l10n = ref.watch(l10nProvider); + + return ScaffoldBase( + appBar: V2AppBar(title: l10n.walletNameTitle, showBackButton: widget.returnHighlightAccountId == null), + mainContent: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [NameField(controller: _controller, hint: l10n.walletNameHint)], + ), + bottomContent: ScaffoldBaseBottomContent( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.walletNameSubtitle, + style: context.themeText.smallParagraph?.copyWith(color: context.colors.textTertiary), + ), + const SizedBox(height: 24), + QuantusButton.simple(label: l10n.commonDone, onTap: _save, isLoading: _saving), + ], + ), + ), + ); + } +} diff --git a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart index f84b6773..213947e1 100644 --- a/mobile-app/lib/v2/screens/import/import_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/import/import_wallet_screen.dart @@ -15,7 +15,7 @@ import 'package:resonance_network_wallet/v2/components/quantus_button.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base_bottom_content.dart'; import 'package:resonance_network_wallet/v2/components/v2_app_bar.dart'; -import 'package:resonance_network_wallet/v2/screens/accounts/accounts_navigation.dart'; +import 'package:resonance_network_wallet/v2/screens/accounts/wallet_name_screen.dart'; import 'package:resonance_network_wallet/v2/screens/home/home_screen.dart'; import 'package:resonance_network_wallet/v2/theme/app_colors.dart'; import 'package:resonance_network_wallet/v2/theme/app_text_styles.dart'; @@ -25,8 +25,9 @@ class ImportWalletScreenV2 extends ConsumerStatefulWidget { final int walletIndex; - /// When true (in-app add), returns to the Accounts popup with the imported - /// account pre-selected. Onboarding leaves this false and goes to Home. + /// When true (in-app add), continues to the wallet naming step and then + /// returns to the Accounts screen with the imported account pre-selected. + /// Onboarding leaves this false and goes to Home. final bool openAccountsOnComplete; @override @@ -125,7 +126,12 @@ class _ImportWalletScreenV2State extends ConsumerState { if (!mounted) return; if (widget.openAccountsOnComplete) { - returnToAccountsSheet(context, ref, highlightAccountId: key.ss58Address); + Navigator.of(context).pushReplacement( + MaterialPageRoute( + builder: (_) => + WalletNameScreen(walletIndex: widget.walletIndex, returnHighlightAccountId: key.ss58Address), + ), + ); } else { Navigator.pushAndRemoveUntil(context, MaterialPageRoute(builder: (_) => const HomeScreen()), (route) => false); } diff --git a/mobile-app/lib/v2/screens/multisig/add_multisig_screen.dart b/mobile-app/lib/v2/screens/multisig/add_multisig_screen.dart index 6a75e97e..993657a7 100644 --- a/mobile-app/lib/v2/screens/multisig/add_multisig_screen.dart +++ b/mobile-app/lib/v2/screens/multisig/add_multisig_screen.dart @@ -255,7 +255,7 @@ class _AddMultisigScreenState extends ConsumerState { ); if (!mounted) return; - returnToAccountsSheet(context, ref, highlightAccountId: _predictedAddress!); + returnToAccountsScreen(context, ref, highlightAccountId: _predictedAddress!); } on MultisigAlreadyExistsException { if (mounted) { context.showErrorToaster(message: l10n.multisigCreateAlreadyExists); diff --git a/mobile-app/lib/v2/screens/settings/add_account_menu_screen.dart b/mobile-app/lib/v2/screens/settings/add_account_menu_screen.dart index 061a15a4..09d6a25d 100644 --- a/mobile-app/lib/v2/screens/settings/add_account_menu_screen.dart +++ b/mobile-app/lib/v2/screens/settings/add_account_menu_screen.dart @@ -93,26 +93,18 @@ class _AddAccountMenuScreenState extends ConsumerState { ), const SizedBox(height: 14), ], - _AccountOptionCard( - icon: Icons.save_alt, - title: l10n.addAccountMenuImportTitle, - subtitle: l10n.addAccountMenuImportSubtitle, - onTap: _onImportWallet, + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Divider(color: colors.separator, height: 1), + ), + _AdvancedSection( + title: l10n.addAccountMenuMoreTitle, + expanded: _advancedExpanded, + onToggle: () => setState(() => _advancedExpanded = !_advancedExpanded), colors: colors, text: text, - ), - if (enableMultisig) ...[ - Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Divider(color: colors.separator, height: 1), - ), - _AdvancedSection( - title: l10n.addAccountMenuMoreTitle, - expanded: _advancedExpanded, - onToggle: () => setState(() => _advancedExpanded = !_advancedExpanded), - colors: colors, - text: text, - children: [ + children: [ + if (enableMultisig) ...[ GestureDetector( onTap: _onDiscoverMultisig, behavior: HitTestBehavior.opaque, @@ -139,9 +131,24 @@ class _AddAccountMenuScreenState extends ConsumerState { text: text, ), ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Divider(color: colors.separator, height: 1), + ), ], - ), - ], + GestureDetector( + onTap: _onImportWallet, + behavior: HitTestBehavior.opaque, + child: _AccountOptionRowContent( + icon: Icons.save_alt, + title: l10n.addAccountMenuImportTitle, + subtitle: l10n.addAccountMenuImportSubtitle, + colors: colors, + text: text, + ), + ), + ], + ), ], ), ); diff --git a/mobile-app/lib/v2/screens/settings/select_wallet_screen.dart b/mobile-app/lib/v2/screens/settings/select_wallet_screen.dart index 2c001b8f..0fe20cd2 100644 --- a/mobile-app/lib/v2/screens/settings/select_wallet_screen.dart +++ b/mobile-app/lib/v2/screens/settings/select_wallet_screen.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:resonance_network_wallet/providers/account_providers.dart'; import 'package:resonance_network_wallet/providers/l10n_provider.dart'; +import 'package:resonance_network_wallet/providers/wallet_providers.dart'; import 'package:resonance_network_wallet/shared/utils/account_utils.dart'; import 'package:resonance_network_wallet/v2/components/loader.dart'; import 'package:resonance_network_wallet/v2/components/scaffold_base.dart'; @@ -41,7 +42,7 @@ class SelectWalletScreen extends ConsumerWidget { return ListView.separated( itemCount: indices.length, separatorBuilder: (_, _) => const SizedBox(height: 12), - itemBuilder: (_, i) => _walletItem(context, l10n, indices[i], colors, text), + itemBuilder: (_, i) => _walletItem(context, ref, l10n, indices[i], colors, text), ); }, ), @@ -50,11 +51,13 @@ class SelectWalletScreen extends ConsumerWidget { Widget _walletItem( BuildContext context, + WidgetRef ref, AppLocalizations l10n, int walletIndex, AppColorsV2 colors, AppTextTheme text, ) { + final walletName = ref.watch(walletNameProvider(walletIndex)) ?? l10n.settingsSelectWalletItem(walletIndex + 1); return GestureDetector( onTap: () => Navigator.push( context, @@ -67,7 +70,7 @@ class SelectWalletScreen extends ConsumerWidget { children: [ Expanded( child: Text( - l10n.settingsSelectWalletItem(walletIndex + 1), + walletName, style: text.paragraph?.copyWith(color: colors.textPrimary, fontWeight: FontWeight.w500), ), ), diff --git a/mobile-app/test/unit/accounts_grouping_test.dart b/mobile-app/test/unit/accounts_grouping_test.dart index dd15887e..a8ad9380 100644 --- a/mobile-app/test/unit/accounts_grouping_test.dart +++ b/mobile-app/test/unit/accounts_grouping_test.dart @@ -20,84 +20,103 @@ MultisigAccount _msig(String id, String myMember, {String name = 'Multisig'}) => ); void main() { - group('groupAccounts', () { - test('empty input is flat and empty', () { - final r = groupAccounts(accounts: [], multisigs: []); - expect(r.segmented, isFalse); - expect(r.items, isEmpty); + group('groupWallets', () { + test('empty input has no wallets', () { + final r = groupWallets(accounts: [], multisigs: []); + expect(r.wallets, isEmpty); + expect(r.standaloneMultisigs, isEmpty); }); - test('single software wallet of only transparent accounts renders flat', () { - final r = groupAccounts(accounts: [_acc(0, 0), _acc(0, 1)], multisigs: []); - expect(r.segmented, isFalse); - expect(r.items.length, 2); - expect(r.items.every((e) => e is AccountRowItem), isTrue); + test('single wallet groups transparent accounts sorted by index', () { + final r = groupWallets(accounts: [_acc(0, 1), _acc(0, 0)], multisigs: []); + expect(r.wallets.length, 1); + final w = r.wallets.single; + expect(w.walletIndex, 0); + expect(w.kind, WalletKind.software); + expect(w.number, 1); + expect(w.accounts.map((a) => a.index), [0, 1]); + expect(w.encryptedAccount, isNull); + expect(w.accountCount, 2); }); - test('encrypted account forces segmented layout with sub-segments', () { - final r = groupAccounts( + test('encrypted account is split out of the account list', () { + final r = groupWallets( accounts: [ _acc(0, 0), - _acc(0, 1, type: AccountType.encrypted, id: 'enc'), + _acc(0, 1024, type: AccountType.encrypted, id: 'enc'), ], multisigs: [], ); - expect(r.segmented, isTrue); - expect(r.items[0], isA()); - final header = r.items[0] as WalletHeaderItem; - expect(header.kind, WalletKind.software); - expect(header.number, 1); - expect((r.items[1] as SegmentHeaderItem).segment, AccountSegment.transparent); - expect(r.items[2], isA()); - expect((r.items[3] as SegmentHeaderItem).segment, AccountSegment.encrypted); - expect(((r.items[4] as AccountRowItem).account as Account).accountType, AccountType.encrypted); + final w = r.wallets.single; + expect(w.accounts.length, 1); + expect(w.encryptedAccount?.accountId, 'enc'); + expect(w.accountCount, 2); }); - test('multiple software wallets are numbered by display order', () { - final r = groupAccounts(accounts: [_acc(1, 0), _acc(0, 0)], multisigs: []); - final headers = r.items.whereType().toList(); - expect(headers.length, 2); - expect(headers.map((h) => h.number), [1, 2]); - expect(headers.every((h) => h.kind == WalletKind.software), isTrue); + test('multiple software wallets are ordered and numbered by walletIndex', () { + final r = groupWallets(accounts: [_acc(1, 0), _acc(0, 0)], multisigs: []); + expect(r.wallets.map((w) => w.walletIndex), [0, 1]); + expect(r.wallets.map((w) => w.number), [1, 2]); + expect(r.wallets.every((w) => w.kind == WalletKind.software), isTrue); + }); + + test('active wallet moves to front keeping its number, others stay in order', () { + final r = groupWallets( + accounts: [_acc(0, 0), _acc(1, 0), _acc(2, 0)], + multisigs: [], + activeAccountId: 'addr-1-0', + ); + expect(r.wallets.map((w) => w.walletIndex), [1, 0, 2]); + expect(r.wallets.map((w) => w.number), [2, 1, 3]); + }); + + test('unknown active account leaves wallet order unchanged', () { + final r = groupWallets(accounts: [_acc(0, 0), _acc(1, 0)], multisigs: [], activeAccountId: 'stranger'); + expect(r.wallets.map((w) => w.walletIndex), [0, 1]); }); test('keystone wallets come after software wallets with own numbering', () { - final r = groupAccounts( + final r = groupWallets( accounts: [ - _acc(0, 0), - _acc(1, 0, type: AccountType.keystone, id: 'k'), + _acc(1, 0), + _acc(0, 0, type: AccountType.keystone, id: 'k'), ], multisigs: [], ); - final headers = r.items.whereType().toList(); - expect(headers.length, 2); - expect(headers[0].kind, WalletKind.software); - expect(headers[0].number, 1); - expect(headers[1].kind, WalletKind.keystone); - expect(headers[1].number, 1); - expect(r.items.whereType().any((s) => s.segment == AccountSegment.keystone), isTrue); + expect(r.wallets.length, 2); + expect(r.wallets[0].kind, WalletKind.software); + expect(r.wallets[0].walletIndex, 1); + expect(r.wallets[0].number, 1); + expect(r.wallets[1].kind, WalletKind.keystone); + expect(r.wallets[1].walletIndex, 0); + expect(r.wallets[1].number, 1); }); - test('multisig is grouped under its owner wallet', () { + test('multisig is grouped under its owner wallet and counted', () { final accounts = [_acc(0, 0, id: 'mine0'), _acc(1, 0, id: 'mine1')]; - final r = groupAccounts(accounts: accounts, multisigs: [_msig('msigaddr', 'mine1')]); - final idx = r.items.indexWhere((e) => e is AccountRowItem && e.account.accountId == 'msigaddr'); - expect(idx, greaterThan(0)); - final header = r.items.sublist(0, idx).whereType().last; - expect(header.kind, WalletKind.software); - expect(header.number, 2); - expect((r.items[idx - 1] as SegmentHeaderItem).segment, AccountSegment.multisig); + final r = groupWallets(accounts: accounts, multisigs: [_msig('msigaddr', 'mine1')]); + expect(r.wallets[0].multisigs, isEmpty); + expect(r.wallets[1].multisigs.single.accountId, 'msigaddr'); + expect(r.wallets[1].accountCount, 2); + expect(r.standaloneMultisigs, isEmpty); }); - test('unresolved multisig trails as a standalone multisig segment', () { - final r = groupAccounts( + test('unresolved multisig trails as standalone', () { + final r = groupWallets( accounts: [_acc(0, 0, id: 'mine')], multisigs: [_msig('msigaddr', 'stranger')], ); - expect(r.segmented, isTrue); - final last = r.items.last as AccountRowItem; - expect(last.account.accountId, 'msigaddr'); - expect((r.items[r.items.length - 2] as SegmentHeaderItem).segment, AccountSegment.multisig); + expect(r.wallets.single.multisigs, isEmpty); + expect(r.standaloneMultisigs.single.accountId, 'msigaddr'); + }); + }); + + group('softwareWalletNumber', () { + test('numbers software wallets by display order, skipping keystone', () { + final accounts = [_acc(0, 0, type: AccountType.keystone, id: 'k'), _acc(1, 0), _acc(3, 0)]; + expect(softwareWalletNumber(accounts, 1), 1); + expect(softwareWalletNumber(accounts, 3), 2); + expect(softwareWalletNumber(accounts, 0), isNull); }); }); } diff --git a/quantus_sdk/lib/src/services/settings_service.dart b/quantus_sdk/lib/src/services/settings_service.dart index dfb1f7fd..6d014ca6 100644 --- a/quantus_sdk/lib/src/services/settings_service.dart +++ b/quantus_sdk/lib/src/services/settings_service.dart @@ -167,6 +167,7 @@ class SettingsService { await deleteMnemonic(walletIndex); await _prefs.remove(_walletOriginKey(walletIndex)); await _prefs.remove(_recoveryPhraseViewedKey(walletIndex)); + await _prefs.remove(_walletNameKey(walletIndex)); } Future setActiveAccount(DisplayAccount account) async { @@ -567,6 +568,21 @@ class SettingsService { _prefs.setString(_walletOriginKey(walletIndex), origin.name); } + // Note: the bare legacy key 'wallet_name' (no index suffix) is a pre-v5 + // account name consumed by the migration in [getAccounts]; index 0 must stay + // suffixed so the two never collide. + String _walletNameKey(int walletIndex) => 'wallet_name_$walletIndex'; + + String? getWalletName(int walletIndex) => _prefs.getString(_walletNameKey(walletIndex)); + + Future setWalletName(int walletIndex, String? name) async { + if (name == null || name.isEmpty) { + await _prefs.remove(_walletNameKey(walletIndex)); + } else { + await _prefs.setString(_walletNameKey(walletIndex), name); + } + } + bool existingUserSeenPromoVideo() { return _prefs.getBool(existingUserSeenPromoVideoKey) ?? false; }