CW-1513/CW-1610: Add account archival and UI improvements (#3600)

* add ability to hide accounts

* remove import

* merge

* merge

* fix loading accounts when styleSettings are not saved yet

* merge

* auto-reformat

* small fixes

* proper reload on card unhide

* block hiding last card

* fixup

* revert formatting-only changes

* liner fixes

* fix overflows

* card layout as separate class to prevent index errors

* fix mini card on addresses page

* block opening customizers if wallet is syncing

* switch to BlocProvider.value to prevent auto dispose

* imports

* fix fiat balance

* Improve homescreen layout and account access

* Add homescreen layout regression tests

* improve account management w/ education + archival flows

* reorganize wallet-specific settings

* preserve account archival during dismissal

* fix: align account visuals with design feedback

* minor account & archival visual corrections

* simplify wallet settings + account UI state

* generalise education carousel & dismissal tracking

* refactor account UI flows + minor improvements

* minor refactors and improvement

* pass account balances as Money and format at display

* use fresh full balance for account archival check

* refactor BalanceCard w/ MoneyText + cleanup

* minor card_customizer cleanup

* rework card customizer flow

---------

Co-authored-by: Robert Malikowski <malikowskirobert@gmail.com>
Co-authored-by: Omar Hatem <omarh.ismail1@gmail.com>
This commit is contained in:
Patrick
2026-10-10 02:07:41 +02:00
committed by GitHub
co-authored by Robert Malikowski Omar Hatem
parent 9f1f82fdd4
commit 20f1ceb651
108 changed files with 6632 additions and 1392 deletions
@@ -11,6 +11,7 @@ import 'package:cake_wallet/evm/evm.dart';
import 'package:cake_wallet/solana/solana.dart';
import 'package:cake_wallet/tron/tron.dart';
import 'package:cake_wallet/zano/zano.dart';
import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart';
import 'package:cw_core/amount/money.dart';
import 'package:cw_core/crypto_amount_format.dart';
import 'package:cw_core/transaction_history.dart';
@@ -546,6 +547,21 @@ abstract class BalanceViewModelBase with Store {
}
}
String? accountFiatBalance(AccountListItem account) {
if (isFiatDisabled) {
return null;
}
final fiat = settingsStore.fiatCurrency.title;
final value = savedDisplayMode == BalanceDisplayMode.hiddenBalance
? "●●●●●"
: calculateFiatAmount(
price: price,
cryptoAmount: account.balance.toString(),
).withLocalSeperator(settingsStore.languageCode);
return "$value $fiat";
}
String _getFiatBalance({required double price, Money? cryptoAmount}) {
if (cryptoAmount == null) {
return '0.00';
@@ -9,6 +9,7 @@ import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_v
import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart';
import 'package:cw_core/account.dart';
import 'package:cake_wallet/view_model/dashboard/date_section_item.dart';
import "package:cw_core/balance_card_layout.dart";
import "package:cw_core/balance_card_style_settings.dart";
import 'package:cake_wallet/core/trade_monitor.dart';
import 'package:cake_wallet/entities/auto_generate_subaddress_status.dart';
@@ -110,7 +111,8 @@ abstract class DashboardViewModelBase with Store {
showDecredInfoCard = wallet.type == WalletType.decred &&
(sharedPreferences.getBool(PreferencesKey.showDecredInfoCard) ?? true);
showSeedBackupReminder = wallet.walletInfo.showSeedBackupReminder;
isMultiAccountsEnabled = wallet.walletInfo.isMultiAccountsEnabled == true;
isMultiAccountsEnabled =
wallet.hasNativeAccounts || wallet.walletInfo.isMultiAccountsEnabled == true;
name = wallet.name;
type = wallet.type;
isShowFirstYatIntroduction = false;
@@ -426,72 +428,86 @@ abstract class DashboardViewModelBase with Store {
}
}
List<int> get cardAccountIndices {
if (wallet.type == WalletType.monero) {
return monero!.getAccountList(wallet).accounts.map((account) => account.id).toList();
}
if (wallet.type == WalletType.wownero) {
return wow.wownero!.getAccountList(wallet).accounts.map((account) => account.id).toList();
}
if (wallet.type == WalletType.bitcoin) {
final accounts = accountListViewModel?.accounts;
return [
if (accounts == null || accounts.isEmpty) 0 else ...accounts.map((account) => account.id),
-2,
];
}
return const [-1];
}
@action
Future<void> loadCardDesigns() async {
final walletCardStyleSettings =
await BalanceCardStyleSettings.getAll(wallet.walletInfo.internalId);
final btcAccounts =
wallet.type == WalletType.bitcoin ? await wallet.walletInfo.getAccounts() : null;
final lightningCardIndex = btcAccounts?.length;
if (wallet.type == WalletType.bitcoin) {
await accountListViewModel?.reload();
}
final accountIndices = cardAccountIndices;
final settings = await BalanceCardStyleSettings.getAll(wallet.walletInfo.internalId);
if (wallet.type != WalletType.bitcoin && accountIndices.contains(0) &&
!settings.any((setting) => setting.accountIndex == 0)) {
final primarySettings = settings.where((setting) => setting.accountIndex == -1)
.firstOrNull?.copyWith(accountIndex: 0);
if (primarySettings != null) {
await primarySettings.insert();
settings.add(primarySettings);
}
}
final layout = BalanceCardLayout.resolve(
accountIndices: accountIndices.where((index) => index != -2).toList(),
settings: settings,
);
int numAccounts = 1;
cardDesigns
..clear()
..addAll(accountIndices.map((accountIndex) => CardDesign.fromStyleSettings(
settings.where((setting) => setting.accountIndex == accountIndex).firstOrNull,
accountIndex == -2 ? CryptoCurrency.btcln : wallet.currency,
)));
cardOrder = {
for (int position = 0; position < layout.visible.length; position++)
position: layout.visible[position],
}.asObservable();
if (layout.needsRepair) {
await BalanceCardStyleSettings.setVisibleOrder(wallet.walletInfo.internalId, layout.orders);
}
if (isMultiAccountsEnabled && !lightningMode && layout.visible.isNotEmpty &&
!layout.visible.contains(accountListViewModel?.selectedAccount?.id)) {
final visibleAccount = accountListViewModel?.accounts
.where((account) => account.id == layout.visible.last).firstOrNull;
if (visibleAccount != null) {
await accountListViewModel!.select(visibleAccount);
}
}
}
int get currentCardAccountIndex {
if (wallet.type == WalletType.monero) {
numAccounts = monero!.getAccountList(wallet).accounts.length;
} else if (wallet.type == WalletType.wownero) {
numAccounts = wow.wownero!.getAccountList(wallet).accounts.length;
} else if (wallet.type == WalletType.bitcoin) {
numAccounts = btcAccounts!.length + 1; // adding 1 for the lightning account
return monero!.getCurrentAccount(wallet).id;
}
final usesAccountIndices = wallet.type == WalletType.bitcoin || balanceViewModel.hasAccounts;
cardDesigns.clear();
final newOrder = <int, int>{};
final orderableCount = (wallet.type == WalletType.bitcoin) ? numAccounts - 1 : numAccounts;
for (int i = 0; i < numAccounts; i++) {
late final int index;
final isLightning = wallet.type == WalletType.bitcoin && i == lightningCardIndex;
if (isLightning) {
index = -2;
} else if (usesAccountIndices) {
index = i;
} else {
index = -1;
}
final setting = walletCardStyleSettings.where((e) => e.accountIndex == index).firstOrNull ??
(index == 0 && wallet.type != WalletType.bitcoin
? walletCardStyleSettings.where((e) => e.accountIndex == -1).firstOrNull
: null);
final curr = isLightning ? CryptoCurrency.btcln : wallet.currency;
cardDesigns.add(CardDesign.fromStyleSettings(setting, curr));
if (isLightning) {
continue;
}
if (setting?.cardOrder != null &&
setting!.cardOrder >= 0 &&
setting.cardOrder < orderableCount) {
newOrder[setting.cardOrder] = i;
}
if (wallet.type == WalletType.wownero) {
return wow.wownero!.getCurrentAccount(wallet).id;
}
// making sure ALL accounts have numbers, even the ones that existed before this feature was a thing
for (int i = 0; i < orderableCount; i++) {
if (!newOrder.containsValue(i)) {
int free = 0;
while (newOrder.containsKey(free)) {
free++;
}
newOrder[free] = i;
}
if (wallet.type == WalletType.bitcoin) {
return lightningMode ? -2 : wallet.walletInfo.currentAccountIndex;
}
cardOrder = newOrder.asObservable();
return -1;
}
CardDesign get currentCardDesign {
final designIndex = cardAccountIndices.indexOf(currentCardAccountIndex);
return cardDesigns.isEmpty || designIndex >= cardDesigns.length
? CardDesign.fromStyleSettings(null, wallet.currency)
: cardDesigns[designIndex == -1 ? 0 : designIndex];
}
void _transactionDisposerCallback(int _) async {
@@ -925,7 +941,9 @@ abstract class DashboardViewModelBase with Store {
List<AccountListItem> get visibleAccounts {
final all = accountListViewModel?.accounts;
if (all == null || all.isEmpty) return const <AccountListItem>[];
if (isMultiAccountsEnabled) return all.toList(growable: false);
if (isMultiAccountsEnabled) {
return all.where((account) => cardOrder.containsValue(account.id)).toList(growable: false);
}
final primary = all.where((a) => a.id == 0).firstOrNull ?? all.first;
return <AccountListItem>[primary];
}
@@ -1353,7 +1371,13 @@ abstract class DashboardViewModelBase with Store {
// Lightning belongs to the primary account only
if (lightningMode) {
unawaited(_selectPrimaryAccount());
unawaited(_selectPrimaryAccount().then((_) async {
if (!lightningMode && isMultiAccountsEnabled) {
await loadCardDesigns();
}
}));
} else {
unawaited(loadCardDesigns());
}
}
@@ -1382,7 +1406,8 @@ abstract class DashboardViewModelBase with Store {
this.wallet = wallet;
type = wallet.type;
name = wallet.name;
isMultiAccountsEnabled = wallet.walletInfo.isMultiAccountsEnabled == true;
isMultiAccountsEnabled =
wallet.hasNativeAccounts || wallet.walletInfo.isMultiAccountsEnabled == true;
_onAccountChangeReaction?.reaction.dispose();
_onAccountChangeReaction = null;
+1 -10
View File
@@ -444,16 +444,7 @@ abstract class SendViewModelBase extends WalletChangeListenerViewModel with Stor
@computed
bool get hasCoinControl =>
[
WalletType.bitcoin,
WalletType.litecoin,
WalletType.monero,
WalletType.wownero,
WalletType.decred,
WalletType.bitcoinCash,
WalletType.dogecoin
].contains(wallet.type) &&
coinTypeToSpendFrom != UnspentCoinType.lightning;
wallet.hasCoinControl && coinTypeToSpendFrom != UnspentCoinType.lightning;
@computed
bool get hasFees => feesViewModel.hasFees && coinTypeToSpendFrom != UnspentCoinType.lightning;
@@ -54,15 +54,7 @@ abstract class PrivacySettingsViewModelBase with Store {
].contains(_wallet.type);
@computed
bool get hasCoinControl => [
WalletType.bitcoin,
WalletType.litecoin,
WalletType.monero,
WalletType.wownero,
WalletType.decred,
WalletType.bitcoinCash,
WalletType.dogecoin
].contains(_wallet.type);
bool get hasCoinControl => _wallet.hasCoinControl;
bool get isMoneroWallet => _wallet.type == WalletType.monero;
@@ -1,8 +1,17 @@
import "package:cw_core/amount/money.dart";
class AccountListItem {
AccountListItem({required this.label, required this.id, this.balance, this.isSelected = false});
AccountListItem({
required this.label,
required this.id,
required this.balance,
this.isSelected = false,
});
final String label;
final int id;
final bool isSelected;
final String? balance;
final Money balance;
bool get isFunded => balance.sign > 0;
}
@@ -1,24 +1,22 @@
import 'dart:async';
import "dart:async";
import 'package:cake_wallet/bitcoin/bitcoin.dart';
import 'package:cake_wallet/core/amount_parsing_proxy.dart';
import 'package:cake_wallet/entities/balance_display_mode.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart';
import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart';
import 'package:collection/collection.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cw_core/wallet_info.dart';
import 'package:mobx/mobx.dart';
import "package:cake_wallet/bitcoin/bitcoin.dart";
import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart";
import "package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart";
import "package:collection/collection.dart";
import "package:cw_core/amount/money.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/wallet_base.dart";
import "package:cw_core/wallet_info.dart";
import "package:mobx/mobx.dart";
part 'bitcoin_account_list_view_model.g.dart';
part "bitcoin_account_list_view_model.g.dart";
class BitcoinAccountListViewModel = BitcoinAccountListViewModelBase
with _$BitcoinAccountListViewModel;
abstract class BitcoinAccountListViewModelBase with Store implements WalletAccountListViewModel {
BitcoinAccountListViewModelBase(this._wallet, this.settingsStore) {
BitcoinAccountListViewModelBase(this._wallet) {
unawaited(_loadAccounts());
reaction(
@@ -26,14 +24,8 @@ abstract class BitcoinAccountListViewModelBase with Store implements WalletAccou
(snapshot) => unawaited(_refreshBalances(snapshot)),
equals: const MapEquality<int, Object>().equals,
);
reaction(
(_) => settingsStore.balanceDisplayMode,
(_) => unawaited(_recomputeBalanceStrings()),
);
}
final SettingsStore settingsStore;
final WalletBase _wallet;
List<WalletInfoAccount>? _cachedStoredAccounts;
@@ -71,14 +63,6 @@ abstract class BitcoinAccountListViewModelBase with Store implements WalletAccou
return _runLoad(() async => cached);
}
@action
Future<void> _recomputeBalanceStrings() {
final cached = _cachedStoredAccounts;
if (cached == null) return _loadAccounts();
return _runLoad(() async => cached);
}
Future<void> _runLoad(Future<List<WalletInfoAccount>> Function() fetch) {
final inProgress = _loadAccountsFuture;
if (inProgress != null) {
@@ -99,7 +83,7 @@ abstract class BitcoinAccountListViewModelBase with Store implements WalletAccou
.map((account) => AccountListItem(
id: account.accountIndex,
label: account.label,
balance: _balanceForAccount(account.accountIndex),
balance: _fullBalance(account.accountIndex),
isSelected: account.accountIndex == currentAccountIndex,
))
.toList();
@@ -115,16 +99,12 @@ abstract class BitcoinAccountListViewModelBase with Store implements WalletAccou
});
}
Money _fullBalance(int accountIndex) {
final balance = bitcoin!.balanceForAccount(_wallet, accountIndex);
return balance.confirmed + balance.unconfirmed;
}
@override
@action
Future<void> reload() => _loadAccounts();
String _balanceForAccount(int accountIndex) {
if (settingsStore.balanceDisplayMode == BalanceDisplayMode.hiddenBalance) {
return '●●●●●●';
}
final balance = bitcoin!.balanceForAccount(_wallet, accountIndex);
return AmountParsingProxy(settingsStore.displayAmountsInSatoshi).asDisplayString(balance.available);
}
}
@@ -2,6 +2,7 @@ import 'dart:math';
import 'package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart';
import 'package:cake_wallet/wownero/wownero.dart';
import 'package:cw_core/balance_card_layout.dart';
import 'package:cw_core/balance_card_style_settings.dart';
import 'package:cw_core/card_design.dart';
import 'package:cw_core/wallet_base.dart';
@@ -36,7 +37,7 @@ abstract class MoneroAccountEditOrCreateViewModelBase
@observable
String label;
final MoneroAccountList _moneroAccountList;
final MoneroAccountList? _moneroAccountList;
final WowneroAccountList? _wowneroAccountList;
final AccountListItem? _accountListItem;
final WalletBase _wallet;
@@ -51,13 +52,22 @@ abstract class MoneroAccountEditOrCreateViewModelBase
return ret.isNotEmpty ? ret : CardDesign.allGradients;
}
Future<void> _saveRandomCardDesign() async {
Future<void> _saveRandomCardDesign(int accountIndex) async {
final gradients = await _getUsableCardGradients();
final accountIndices = _wallet.type == WalletType.monero
? _moneroAccountList!.accounts.map((account) => account.id).toList()
: _wowneroAccountList!.accounts.map((account) => account.id).toList();
final layout = BalanceCardLayout.resolve(
accountIndices: accountIndices,
settings: await BalanceCardStyleSettings.getAll(_wallet.walletInfo.internalId),
);
await BalanceCardStyleSettings.fromCardDesign(
walletInfoId: _wallet.walletInfo.internalId,
accountIndex: _moneroAccountList.accounts.length,
cardOrder: _moneroAccountList.accounts.length,
accountIndex: accountIndex,
cardOrder: layout.visible.length,
design: CardDesign.specialDesignsForCurrencies[_wallet.currency]!
.withGradient(gradients[Random().nextInt(gradients.length)]))
.insert();
@@ -67,7 +77,11 @@ abstract class MoneroAccountEditOrCreateViewModelBase
try {
state = IsExecutingState();
if (!isEdit) await _saveRandomCardDesign();
if (!isEdit) {
await _saveRandomCardDesign(_wallet.type == WalletType.monero
? _moneroAccountList!.accounts.length
: _wowneroAccountList!.accounts.length);
}
if (_wallet.type == WalletType.monero) {
await saveMonero();
@@ -84,13 +98,13 @@ abstract class MoneroAccountEditOrCreateViewModelBase
Future<void> saveMonero() async {
if (_accountListItem != null) {
await _moneroAccountList.setLabelAccount(
await _moneroAccountList!.setLabelAccount(
_wallet,
accountIndex: _accountListItem.id,
label: label,
);
} else {
await _moneroAccountList.addAccount(
await _moneroAccountList!.addAccount(
_wallet,
label: label,
);
@@ -1,22 +1,18 @@
import 'package:cake_wallet/entities/balance_display_mode.dart';
import 'package:cake_wallet/store/settings_store.dart';
import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart';
import 'package:cake_wallet/wownero/wownero.dart';
import 'package:cw_core/crypto_currency.dart';
import 'package:cw_core/wallet_type.dart';
import 'package:mobx/mobx.dart';
import 'package:cw_core/wallet_base.dart';
import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart';
import 'package:cake_wallet/monero/monero.dart';
import "package:cake_wallet/monero/monero.dart";
import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart";
import "package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart";
import "package:cake_wallet/wownero/wownero.dart";
import "package:cw_core/crypto_currency.dart";
import "package:cw_core/wallet_base.dart";
import "package:cw_core/wallet_type.dart";
import "package:mobx/mobx.dart";
part 'monero_account_list_view_model.g.dart';
part "monero_account_list_view_model.g.dart";
class MoneroAccountListViewModel = MoneroAccountListViewModelBase with _$MoneroAccountListViewModel;
abstract class MoneroAccountListViewModelBase with Store implements WalletAccountListViewModel {
MoneroAccountListViewModelBase(this._wallet, this.settingsStore);
final SettingsStore settingsStore;
MoneroAccountListViewModelBase(this._wallet);
@override
AccountListItem? get selectedAccount => selected;
@@ -25,7 +21,6 @@ abstract class MoneroAccountListViewModelBase with Store implements WalletAccoun
@computed
List<AccountListItem> get accounts {
final hideBalance = settingsStore.balanceDisplayMode == BalanceDisplayMode.hiddenBalance;
if (_wallet.type == WalletType.monero) {
return monero!
.getAccountList(_wallet)
@@ -33,7 +28,7 @@ abstract class MoneroAccountListViewModelBase with Store implements WalletAccoun
.map((acc) => AccountListItem(
label: acc.label,
id: acc.id,
balance: hideBalance ? '●●●●●●' : acc.balance,
balance: monero!.getAccountFullBalance(acc.id),
isSelected: acc.id == monero!.getCurrentAccount(_wallet).id))
.toList();
}
@@ -45,19 +40,16 @@ abstract class MoneroAccountListViewModelBase with Store implements WalletAccoun
.map((acc) => AccountListItem(
label: acc.label,
id: acc.id,
balance: hideBalance ? '●●●●●●' : acc.balance,
balance: wownero!.getAccountFullBalance(acc.id),
isSelected: acc.id == wownero!.getCurrentAccount(_wallet).id))
.toList();
}
throw Exception('Unexpected wallet type: ${_wallet.type} for monero');
throw Exception("Unexpected wallet type: ${_wallet.type} for monero");
}
@computed
AccountListItem get selected {
final currentId = monero!.getCurrentAccount(_wallet).id;
return accounts.firstWhere((item) => item.id == currentId);
}
AccountListItem get selected => accounts.firstWhere((item) => item.isSelected);
final WalletBase _wallet;
@@ -71,7 +63,7 @@ abstract class MoneroAccountListViewModelBase with Store implements WalletAccoun
_wallet,
item.id,
item.label,
item.balance,
item.balance.toString(),
);
}
@@ -80,7 +72,7 @@ abstract class MoneroAccountListViewModelBase with Store implements WalletAccoun
_wallet,
item.id,
item.label,
item.balance,
item.balance.toString(),
);
}
}
@@ -1,5 +1,5 @@
import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart';
import 'package:cw_core/crypto_currency.dart';
import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart";
import "package:cw_core/crypto_currency.dart";
abstract class WalletAccountListViewModel {
List<AccountListItem> get accounts;