diff --git a/cw_bitcoin/lib/bitcoin_address_record.dart b/cw_bitcoin/lib/bitcoin_address_record.dart index 65b728904..a08801234 100644 --- a/cw_bitcoin/lib/bitcoin_address_record.dart +++ b/cw_bitcoin/lib/bitcoin_address_record.dart @@ -9,6 +9,7 @@ abstract class BaseBitcoinAddressRecord { BaseBitcoinAddressRecord( this.address, { required this.index, + this.accountIndex = 0, this.isHidden = false, this.isLegacyDerivation = false, int txCount = 0, @@ -29,6 +30,7 @@ abstract class BaseBitcoinAddressRecord { bool isHidden; bool isLegacyDerivation; final int index; + final int accountIndex; int _txCount; int _balance; String _name; @@ -63,6 +65,7 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { BitcoinAddressRecord( super.address, { required super.index, + super.accountIndex = 0, super.isHidden = false, super.isLegacyDerivation = false, super.txCount = 0, @@ -105,6 +108,7 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { return BitcoinAddressRecord( decoded['address'] as String, index: decoded['index'] as int, + accountIndex: decoded['accountIndex'] as int? ?? 0, isHidden: decoded['isHidden'] as bool? ?? false, isLegacyDerivation: parsedIsLegacy, isUsed: decoded['isUsed'] as bool? ?? false, @@ -162,7 +166,8 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { final coinType = _coinTypeForNetwork(); final purpose = _purposeForType(type); - final accountPath = isLegacyDerivation ? electrum_path : "m/$purpose'/$coinType'/0'"; + final accountPath = + isLegacyDerivation ? electrum_path : "m/$purpose'/$coinType'/$accountIndex'"; final chain = isHidden ? 1 : 0; return "$accountPath/$chain/$index"; @@ -172,6 +177,7 @@ class BitcoinAddressRecord extends BaseBitcoinAddressRecord { String toJSON() => json.encode({ 'address': address, 'index': index, + 'accountIndex': accountIndex, 'isHidden': isHidden, 'isLegacyDerivation': isLegacyDerivation, 'isUsed': isUsed, @@ -187,6 +193,7 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { BitcoinSilentPaymentAddressRecord( super.address, { required super.index, + super.accountIndex = 0, super.isHidden = false, super.txCount = 0, super.balance = 0, @@ -205,6 +212,7 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { return BitcoinSilentPaymentAddressRecord( decoded['address'] as String, index: decoded['index'] as int, + accountIndex: decoded['accountIndex'] as int? ?? 0, isHidden: decoded['isHidden'] as bool? ?? false, isUsed: decoded['isUsed'] as bool? ?? false, txCount: decoded['txCount'] as int? ?? 0, @@ -233,6 +241,7 @@ class BitcoinSilentPaymentAddressRecord extends BaseBitcoinAddressRecord { String toJSON() => json.encode({ 'address': address, 'index': index, + 'accountIndex': accountIndex, 'isHidden': isHidden, 'isUsed': isUsed, 'txCount': txCount, diff --git a/cw_bitcoin/lib/bitcoin_wallet.dart b/cw_bitcoin/lib/bitcoin_wallet.dart index a583dcc01..1614cbe2e 100644 --- a/cw_bitcoin/lib/bitcoin_wallet.dart +++ b/cw_bitcoin/lib/bitcoin_wallet.dart @@ -14,6 +14,7 @@ import 'package:cw_bitcoin/electrum_balance.dart'; import 'package:cw_bitcoin/electrum_derivations.dart'; import 'package:cw_bitcoin/electrum_transaction_info.dart'; import 'package:cw_bitcoin/electrum_wallet.dart'; +import 'package:cw_bitcoin/electrum_wallet_addresses.dart'; import 'package:cw_bitcoin/electrum_wallet_snapshot.dart'; import 'package:cw_bitcoin/locktime.dart'; import 'package:cw_bitcoin/hardware/bitcoin_hardware_wallet_service.dart'; @@ -128,8 +129,10 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { initialChangeAddressIndex: initialChangeAddressIndex, initialSilentAddresses: initialSilentAddresses, initialSilentAddressIndex: initialSilentAddressIndex, - mainHdByType: mainHdByType, - sideHdByType: sideHdByType, + mainHdByTypeAndAccount: mainHdByTypeAndAccount, + sideHdByTypeAndAccount: sideHdByTypeAndAccount, + accountIndexes: [currentAccountIndex], + currentAccountIndex: currentAccountIndex, legacyMainHd: mainHd, legacySideHd: sideHd, network: networkParam ?? network, @@ -612,10 +615,12 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return tx; } - List getUtxoWithPrivateKeys({bool confirmedOnly = false}) => unspentCoins - .where((e) => e.isSending && !e.isFrozen && (!confirmedOnly || (e.confirmations ?? 0) > 0)) - .map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this)) - .toList(); + List getUtxoWithPrivateKeys({bool confirmedOnly = false}) => + unspentCoinsForCurrentAccount + .where( + (e) => e.isSending && !e.isFrozen && (!confirmedOnly || (e.confirmations ?? 0) > 0)) + .map((unspent) => UtxoWithPrivateKey.fromUnspent(unspent, this)) + .toList(); Future commitPsbt(String finalizedPsbt) { final psbt = PsbtV2()..deserializeV0(base64.decode(finalizedPsbt)); @@ -625,6 +630,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return PendingBitcoinTransaction( btcTx, type, + accountIndex: currentAccountIndex, electrumClient: electrumClient, amount: Money.zero(currency), fee: Money.zero(currency), @@ -683,6 +689,7 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { return PendingBitcoinTransaction( btcTx, type, + accountIndex: currentAccountIndex, electrumClient: electrumClient, amount: Money.zero(currency), fee: Money.zero(currency), @@ -716,12 +723,24 @@ abstract class BitcoinWalletBase extends ElectrumWallet with Store { @override bool receiveOptionAvailable(ReceivePageOption option) { - if(option == BitcoinReceivePageOption.lightning) { - return hasLightningSupport; + final isExtraAccount = currentAccountIndex > 0; + + if (option == BitcoinReceivePageOption.lightning) { + // Lightning is tied to the primary account only + return !isExtraAccount && hasLightningSupport; } - if(option == BitcoinReceivePageOption.silent_payments) { - return hasSilentPaymentsScanning; + if (option == BitcoinReceivePageOption.silent_payments) { + // Silent payments are tied to the primary account only + return !isExtraAccount && hasSilentPaymentsScanning; + } + + // Restrict extra Bitcoin accounts (accountIndex > 0) to the allowed address types + if (isExtraAccount && option is BitcoinReceivePageOption) { + final addressType = option.toType(); + if (!EXTRA_ACCOUNT_ADDRESS_TYPES.contains(addressType)) { + return false; + } } return true; diff --git a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart index c4c41f913..534abf8b4 100644 --- a/cw_bitcoin/lib/bitcoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/bitcoin_wallet_addresses.dart @@ -23,8 +23,10 @@ class BitcoinWalletAddresses = BitcoinWalletAddressesBase with _$BitcoinWalletAd abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with Store { BitcoinWalletAddressesBase( WalletInfo walletInfo, { - required super.mainHdByType, - required super.sideHdByType, + required super.mainHdByTypeAndAccount, + required super.sideHdByTypeAndAccount, + required super.accountIndexes, + required super.currentAccountIndex, required super.legacySideHd, required super.legacyMainHd, required super.network, @@ -145,7 +147,11 @@ abstract class BitcoinWalletAddressesBase extends ElectrumWalletAddresses with S try { return super .allAddresses - .firstWhere((e) => e.type == SegwitAddresType.p2wpkh && !e.isHidden && !e.isUsed) + .firstWhere((e) => + e.accountIndex == currentAccountIndex && + e.type == SegwitAddresType.p2wpkh && + !e.isHidden && + !e.isUsed) .address; } catch (_) { return super.addressForBuy; diff --git a/cw_bitcoin/lib/electrum_balance.dart b/cw_bitcoin/lib/electrum_balance.dart index 02569887c..78c2c7f94 100644 --- a/cw_bitcoin/lib/electrum_balance.dart +++ b/cw_bitcoin/lib/electrum_balance.dart @@ -2,9 +2,17 @@ import 'dart:convert'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/balance.dart'; +import "package:cw_core/crypto_currency.dart"; import 'package:cw_core/currency.dart'; class ElectrumBalance extends Balance { + + factory ElectrumBalance.zero(CryptoCurrency currency) => ElectrumBalance( + confirmed: Money.zero(currency), + unconfirmed: Money.zero(currency), + frozen: Money.zero(currency), + ); + ElectrumBalance({ required this.confirmed, required this.unconfirmed, @@ -49,4 +57,26 @@ class ElectrumBalance extends Balance { 'secondConfirmed': secondAvailable?.amount.toInt() ?? 0, 'secondUnconfirmed': secondUnavailable?.amount.toInt() ?? 0, }); + + // Value equality so instances can be compared/deduped (e.g. by MobX reactions via MapEquality) + @override + bool operator ==(Object other) => + identical(this, other) || + (other is ElectrumBalance && + other.confirmed == confirmed && + other.unconfirmed == unconfirmed && + other.frozen == frozen && + other.secondConfirmed == secondConfirmed && + other.secondUnconfirmed == secondUnconfirmed); + + @override + int get hashCode => Object.hash( + confirmed, + unconfirmed, + frozen, + secondConfirmed, + secondUnconfirmed, + ); } + + diff --git a/cw_bitcoin/lib/electrum_transaction_info.dart b/cw_bitcoin/lib/electrum_transaction_info.dart index 48c006ae2..17360e5e7 100644 --- a/cw_bitcoin/lib/electrum_transaction_info.dart +++ b/cw_bitcoin/lib/electrum_transaction_info.dart @@ -24,6 +24,7 @@ class ElectrumTransactionBundle { } class ElectrumTransactionInfo extends TransactionInfo { + int? accountIndex; List? unspents; bool isReceivedSilentPayment; bool isHogEx; @@ -41,6 +42,7 @@ class ElectrumTransactionInfo extends TransactionInfo { bool isReplaced = false, required DateTime date, required int confirmations, + this.accountIndex, String? to, this.unspents, this.isReceivedSilentPayment = false, @@ -231,6 +233,7 @@ class ElectrumTransactionInfo extends TransactionInfo { isPending: data['isPending'] as bool, isReplaced: data['isReplaced'] as bool? ?? false, confirmations: data['confirmations'] as int, + accountIndex: data['accountIndex'] as int?, inputAddresses: inputAddresses.isEmpty ? [] : inputAddresses.map((e) => e.toString()).toList(), outputAddresses: @@ -260,6 +263,7 @@ class ElectrumTransactionInfo extends TransactionInfo { inputAddresses: inputAddresses, outputAddresses: outputAddresses, confirmations: info.confirmations, + accountIndex: accountIndex, additionalInfo: additionalInfo); } @@ -273,6 +277,7 @@ class ElectrumTransactionInfo extends TransactionInfo { m['isPending'] = isPending; m['isReplaced'] = isReplaced; m['confirmations'] = confirmations; + m['accountIndex'] = accountIndex; m['fee'] = fee?.amount.toInt(); m['to'] = to; m['unspents'] = unspents?.map((e) => e.toJson()).toList() ?? []; @@ -284,6 +289,6 @@ class ElectrumTransactionInfo extends TransactionInfo { } String toString() { - return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses, additionalInfo: $additionalInfo)'; + return 'ElectrumTransactionInfo(id: $id, height: $height, amount: $amount, fee: $fee, direction: $direction, date: $date, isPending: $isPending, isReplaced: $isReplaced, confirmations: $confirmations, accountIndex: $accountIndex, to: $to, unspent: $unspents, inputAddresses: $inputAddresses, outputAddresses: $outputAddresses, additionalInfo: $additionalInfo)'; } } diff --git a/cw_bitcoin/lib/electrum_wallet.dart b/cw_bitcoin/lib/electrum_wallet.dart index c3c718906..ae36b55a0 100644 --- a/cw_bitcoin/lib/electrum_wallet.dart +++ b/cw_bitcoin/lib/electrum_wallet.dart @@ -4,6 +4,7 @@ import 'dart:isolate'; import 'dart:math' show Random; import 'package:bitcoin_base/bitcoin_base.dart'; +import 'package:cw_bitcoin/lightning/lightning_addres_type.dart'; import 'package:cw_bitcoin/lightning/lightning_wallet.dart'; import 'package:cw_bitcoin/locktime.dart'; import 'package:cw_core/hardware/hardware_wallet_service.dart'; @@ -92,24 +93,14 @@ abstract class ElectrumWalletBase _scripthashesUpdateSubject = {}, this.alwaysScan = alwaysScan, silentPaymentsScanningActive = alwaysScan ?? false, - balance = ObservableMap.of(currency != null - ? { - currency: initialBalance ?? - ElectrumBalance( - confirmed: Money.zero(currency), - unconfirmed: Money.zero(currency), - frozen: Money.zero(currency), - ) - } - : {}), + balance = ObservableMap.of( + currency != null ? {currency: initialBalance ?? ElectrumBalance.zero(currency)} : {}), this.unspentCoinsInfo = unspentCoinsInfo, this.isTestnet = !network.isMainnet, this._mnemonic = mnemonic, + this.electrumClient = electrumClient ?? electrum.ElectrumClient(), _useLightning = useLightning, super(walletInfo, derivationInfo) { - this.electrumClient = electrumClient ?? electrum.ElectrumClient(); - this.walletInfo = walletInfo; - this.derivationInfo = derivationInfo; transactionHistory = ElectrumTransactionHistory( walletInfo: walletInfo, password: password, @@ -117,41 +108,9 @@ abstract class ElectrumWalletBase ); reaction((_) => syncStatus, _syncStatusReaction); - sharedPrefs.complete(SharedPreferences.getInstance()); - - final supportedTypes = supportedAddressTypes(walletInfo.type); - mainHdByType = {}; - sideHdByType = {}; - - final isElectrumDerivation = derivationInfo.derivationType == DerivationType.electrum; - - final canDeriveFromSeed = _masterHD != null && currency != null; - - if (isElectrumDerivation) { - // Electrum derivation does not follow BIP44/49/84 etc. standards - for (final type in supportedTypes) { - mainHdByType[type] = mainHd; // accountHD.child(0) - sideHdByType[type] = sideHd; // accountHD.child(1) - } - } else if (canDeriveFromSeed) { - final coinType = _coinTypeFor(currency); - final accountIndex = _parseAccountIndex(derivationInfo.derivationPath); - - for (final type in supportedTypes) { - final purpose = _purposeForType(type); - final accountPath = "m/$purpose'/$coinType'/$accountIndex'"; - - mainHdByType[type] = _masterHD!.derivePath("$accountPath/0") as Bip32Slip10Secp256k1; - sideHdByType[type] = _masterHD!.derivePath("$accountPath/1") as Bip32Slip10Secp256k1; - } - } else { - // View-only wallet (xpub only) - for (final type in supportedTypes) { - mainHdByType[type] = mainHd; - sideHdByType[type] = sideHd; - } - } + // Probably not needed as init will be called right after creating the wallet. + _prepareHdForAccount(currentAccountIndex, currency); } int _purposeForType(BitcoinAddressType type) { @@ -200,8 +159,7 @@ abstract class ElectrumWalletBase final coinType = _coinTypeFor(currency); final purpose = _purposeForType(record.type); - final accountIndex = _parseAccountIndex(derivationInfo.derivationPath); - return "m/$purpose'/$coinType'/$accountIndex'"; + return "m/$purpose'/$coinType'/${record.accountIndex}'"; } List supportedAddressTypes(WalletType type) { @@ -317,16 +275,16 @@ abstract class ElectrumWalletBase static const int addressHistoryChunkSize = 150; static const int transactionChunkSize = 150; - static const int inputTransactionChunkSize = 150; static const int discoveryHistoryChunkSize = 20; - static const int transactionBatchTimeoutMs = 15000; - static const int batchTestTimeoutMs = 4000; static const int batchTestHashesCount = 2; - static const bool useBatchForHistory = true; + static const int maxProbAccounts = 3; + + final Map> _appliedCoinKeysByAddress = {}; + @observable bool? alwaysScan; @@ -338,12 +296,15 @@ abstract class ElectrumWalletBase @observable bool _useLightning; + @observable + ObservableMap accountBalances = ObservableMap(); + final Bip32Slip10Secp256k1? _masterHD; final Bip32Slip10Secp256k1 accountHD; final String? _mnemonic; - late final Map mainHdByType; - late final Map sideHdByType; + final mainHdByTypeAndAccount = >{}; + final sideHdByTypeAndAccount = >{}; Bip32Slip10Secp256k1 get mainHd => accountHD.childKey(Bip32KeyIndex(0)); @@ -372,6 +333,145 @@ abstract class ElectrumWalletBase @observable SyncStatus syncStatus; + bool _hasCompleteUnspentSet = false; + bool _isSyncing = false; + + Future loadCurrentAccount() async { + final accounts = await walletInfo.getAccounts(); + final current = accounts.firstWhere( + (account) => account.accountIndex == currentAccountIndex, + orElse: () => accounts.first, + ); + + return current; + } + + Future> loadAccountIndexes() async { + if (!hasAccountsSupport) return [0]; + + final accounts = await walletInfo.getAccounts(); + return accounts.map((account) => account.accountIndex).toList(); + } + + void _prepareHdForAccount(int accountIndex, CryptoCurrency? currency) { + // For the first account, we support all address types. For extra accounts, we only support SegwitAddresType.p2wpkh. + final supportedTypes = + accountIndex == 0 ? supportedAddressTypes(walletInfo.type) : EXTRA_ACCOUNT_ADDRESS_TYPES; + final isElectrumDerivation = derivationInfo.derivationType == DerivationType.electrum; + final canDeriveFromSeed = _masterHD != null && currency != null; + + final mainMap = mainHdByTypeAndAccount[accountIndex] ??= {}; + final sideMap = sideHdByTypeAndAccount[accountIndex] ??= {}; + + if (canDeriveFromSeed && !isElectrumDerivation) { + final coinType = _coinTypeFor(currency); + + for (final type in supportedTypes) { + final purpose = _purposeForType(type); + final accountPath = "m/$purpose'/$coinType'/$accountIndex'"; + + mainMap[type] = _masterHD!.derivePath("$accountPath/0") as Bip32Slip10Secp256k1; + sideMap[type] = _masterHD!.derivePath("$accountPath/1") as Bip32Slip10Secp256k1; + } + } else { + // case for Electrum derivation or when we don't have seed but only xpub (view only wallet) + for (final type in supportedTypes) { + mainMap[type] = mainHd; // accountHD.child(0) + sideMap[type] = sideHd; // accountHD.child(1) + } + } + } + + ElectrumBalance balanceForAccount(int accountIndex) => + accountBalances[accountIndex] ?? ElectrumBalance.zero(currency); + + void _updateAccountBalancesFromUnspents() { + if (!_hasCompleteUnspentSet) return; + + final newBalances = {}; + + for (final coin in unspentCoins) { + final accountIndex = coin.bitcoinAddressRecord.accountIndex; + final current = newBalances[accountIndex] ?? ElectrumBalance.zero(currency); + final amount = Money.fromInt(coin.value, currency); + + if (coin.isFrozen) { + current.frozen += amount; + } + + if (coin.confirmations != null && coin.confirmations! > 0) { + current.confirmed += amount; + } else { + current.unconfirmed += amount; + } + + newBalances[accountIndex] = current; + } + + for (final accountIndex in walletAddresses.accountIndexes) { + newBalances.putIfAbsent(accountIndex, () => ElectrumBalance.zero(currency)); + } + + accountBalances = ObservableMap.of(newBalances); + _updateCurrentAccountBalance(); + } + + void _updateCurrentAccountBalance({int? accountIndex}) { + if (type != WalletType.bitcoin) return; + + final targetAccountIndex = accountIndex ?? currentAccountIndex; + final newBalance = accountBalances[targetAccountIndex]; + + if (newBalance == null) { + // If the account balance is not found, set it to zero for the current currency. + balance[currency] = ElectrumBalance.zero(currency); + return; + } + + balance[currency] = newBalance; + } + + Map> get addressesSetByAccount { + final result = >{}; + for (final addr in walletAddresses.allAddresses) { + if (addr.type == SegwitAddresType.mweb) continue; + (result[addr.accountIndex] ??= {}).add(addr.address); + } + return result; + } + + int? _accountForTransaction( + ElectrumTransactionBundle bundle, + Map> addressesByAccount, + ) { + if (type != WalletType.bitcoin) return null; + + int? accountForAddress(String address) { + for (final entry in addressesByAccount.entries) { + if (entry.value.contains(address)) return entry.key; + } + return null; + } + + for (var i = 0; i < bundle.originalTransaction.inputs.length; i++) { + final inputTransaction = bundle.ins[i]; + if (inputTransaction == null) continue; + + final outTransaction = + inputTransaction.outputs[bundle.originalTransaction.inputs[i].txIndex]; + final account = + accountForAddress(addressFromOutputScript(outTransaction.scriptPubKey, network)); + if (account != null) return account; + } + + for (final out in bundle.originalTransaction.outputs) { + final account = accountForAddress(addressFromOutputScript(out.scriptPubKey, network)); + if (account != null) return account; + } + + return null; + } + Set get addressesSet => walletAddresses.allAddresses .where((element) => element.type != SegwitAddresType.mweb) .map((addr) => addr.address) @@ -390,8 +490,77 @@ abstract class ElectrumWalletBase String get xpub => accountHD.publicKey.toExtended; + int get currentAccountIndex => walletInfo.currentAccountIndex; + + @action + Future setCurrentAccount(int accountIndex) async { + + final isNewAccount = !walletAddresses.accountIndexes.contains(accountIndex); + if (isNewAccount) { + walletAddresses.accountIndexes.add(accountIndex); + + accountBalances[accountIndex] = ElectrumBalance.zero(currency); + } + + await walletInfo.setSelectedAccount(accountIndex); + + _prepareHdForAccount(accountIndex, currency); + + walletAddresses.currentAccountIndex = accountIndex; + + final currentPageType = walletAddresses.addressPageType; + await walletAddresses.setAddressType(currentPageType); + + // For extra accounts, we only prepare SegwitAddresType.p2wpkh type. + if (isNewAccount) { + await walletAddresses.prepareAccountAddresses( + accountIndex, + types: accountIndex == 0 ? BITCOIN_ADDRESS_TYPES : EXTRA_ACCOUNT_ADDRESS_TYPES, + ); + await walletAddresses.updateAddressesInBox(); + } + + walletAddresses.updateReceiveAddresses(); + walletAddresses.updateChangeAddresses(); + + _updateCurrentAccountBalance(accountIndex: accountIndex); + + unawaited(() async { + try { + if (isNewAccount && !_isSyncing) { + final histories = {}; + + final typesToFetch = accountIndex == 0 ? BITCOIN_ADDRESS_TYPES : EXTRA_ACCOUNT_ADDRESS_TYPES; + // Explicit for clarity — fetchTransactionsForAddressTypeBatch already skips types + // with no addresses for this account, so this doesn't change RPC calls, just intent. + + for (final addressType in typesToFetch) { + if (shouldUseBatchFetching) { + await fetchTransactionsForAddressTypeBatch(histories, addressType, + accountIndex: accountIndex); + } else { + await fetchTransactionsForAddressType(histories, addressType, + accountIndex: accountIndex); + } + } + + await resolveQueuedTransactionDetails(); + } + + await updateAllUnspents(); + } catch (e) { + printV('setCurrentAccount balance update failed: $e'); + } + }()); + } + bool get shouldUseBatchFetching => useBatchForHistory && _isBatchSupported == true; + bool get isInitialBitcoinAccountsSync => + hasAccountsSupport && + walletInfo.isMultiAccountsEnabled == true && + (walletInfo.accountDiscoveryLimit ?? 0) < maxProbAccounts; + @override String? get seed => _mnemonic; @@ -410,6 +579,13 @@ abstract class ElectrumWalletBase @override bool get hasSilentPaymentsScanning => type == WalletType.bitcoin && keys.privateKey.isNotEmpty; + @override + bool get hasAccountsSupport => + type == WalletType.bitcoin && + isSoftwareWallet && + _masterHD != null && // xpub / watch-only: can't derive other (hardened) accounts + derivationInfo.derivationType != DerivationType.electrum; + @observable bool nodeSupportsSilentPayments = true; @observable @@ -418,6 +594,9 @@ abstract class ElectrumWalletBase bool _isTryingToConnect = false; bool? _isBatchSupported; DateTime? _syncBenchmarkStartTime; + DateTime? _syncStartedAt; + + static const Duration maxSyncDuration = Duration(minutes: 2); Completer sharedPrefs = Completer(); @@ -449,7 +628,7 @@ abstract class ElectrumWalletBase _isolate?.then((value) => value.kill(priority: Isolate.immediate)); if (electrumClient.isConnected) { - syncStatus = SyncedSyncStatus(); + if (!_isSyncing) syncStatus = SyncedSyncStatus(); } else { syncStatus = NotConnectedSyncStatus(); } @@ -490,7 +669,9 @@ abstract class ElectrumWalletBase String? privateKey; String? publicKey; - final hd = mainHdByType[SegwitAddresType.p2wpkh] ?? mainHd; + // `keys` is wallet-level metadata + final hd = + mainHdByTypeAndAccount[0]?[SegwitAddresType.p2wpkh] ?? mainHd; try { wif = WifEncoder.encode(hd.privateKey.raw, netVer: network.wifNetVer); @@ -523,6 +704,12 @@ abstract class ElectrumWalletBase bool _isTransactionUpdating; Future? _isolate; + final Map> _missingHistoryQueue = {}; + + Future? _resolveMissingHistoryFuture; + final Map _unresolvedAccountRetryCount = {}; + static const int _maxUnresolvedAccountRetries = 3; + void Function(FlutterErrorDetails)? _onError; Timer? _autoSaveTimer; StreamSubscription? _receiveStream; @@ -530,9 +717,16 @@ abstract class ElectrumWalletBase static const int _autoSaveInterval = 1; Future init() async { - await walletAddresses.init(); + final accountIndexes = await loadAccountIndexes(); + + for (final accountIndex in accountIndexes) { + _prepareHdForAccount(accountIndex, currency); + } + + await walletAddresses.init(accountIndexes: accountIndexes); await transactionHistory.init(); await cleanUpDuplicateUnspentCoins(); + _seedAccountBalancesFromAddressRecords(); await save(); _autoSaveTimer = @@ -699,14 +893,32 @@ abstract class ElectrumWalletBase DateTime? _lastSilentPaymentsScan; static const Duration _silentPaymentsScanDelay = Duration(minutes: 1); + Iterable get unspentCoinsForCurrentAccount { + if (type != WalletType.bitcoin) { + return unspentCoins; + } + + return unspentCoins + .where((coin) => coin.bitcoinAddressRecord.accountIndex == currentAccountIndex); + } + @action @override Future startSync() async { - try { - if (syncStatus is SyncronizingSyncStatus) { + if (_isSyncing) { + final startedAt = _syncStartedAt; + if (startedAt != null && DateTime.now().difference(startedAt) > maxSyncDuration) { + printV('startSync: previous sync appears stuck, allowing a new one'); + _isSyncing = false; + } else { + printV('startSync: already syncing, skipping'); return; } + } + _isSyncing = true; + _syncStartedAt = DateTime.now(); + try { if (_syncBenchmarkStartTime == null) { _syncBenchmarkStartTime = DateTime.now(); printV('[ELECTRUM_WALLET SYNC] Starting: ${_syncBenchmarkStartTime!}'); @@ -749,18 +961,12 @@ abstract class ElectrumWalletBase await updateAllUnspents(); await updateBalance(); - await updateFeeRates(); - - _updateFeeRateTimer ??= - Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); if (alwaysScan == true) { setSilentPaymentsScanning(true); + } else if (syncStatus is LostConnectionSyncStatus) { + _syncBenchmarkStartTime = null; } else { - if (syncStatus is LostConnectionSyncStatus) { - return; - } - final syncEnd = DateTime.now(); final totalMs = _syncBenchmarkStartTime != null ? syncEnd.difference(_syncBenchmarkStartTime!).inMilliseconds @@ -771,6 +977,13 @@ abstract class ElectrumWalletBase _syncBenchmarkStartTime = null; syncStatus = SyncedSyncStatus(); } + + await updateFeeRates(); + + _updateFeeRateTimer ??= + Timer.periodic(const Duration(minutes: 1), (timer) async => await updateFeeRates()); + + unawaited(resolveQueuedTransactionDetails()); } catch (e, stacktrace) { final syncEnd = DateTime.now(); final totalMs = _syncBenchmarkStartTime != null @@ -783,6 +996,8 @@ abstract class ElectrumWalletBase _syncBenchmarkStartTime = null; syncStatus = FailedSyncStatus(); + } finally { + _isSyncing = false; } } @@ -939,7 +1154,7 @@ abstract class ElectrumWalletBase bool spendsUnconfirmedTX = false; int leftAmount = credentialsAmount; - var availableInputs = unspentCoins.where((utx) { + var availableInputs = unspentCoinsForCurrentAccount.where((utx) { if (!utx.isSending || utx.isFrozen) { return false; } @@ -1020,7 +1235,7 @@ abstract class ElectrumWalletBase final address = RegexUtils.addressTypeFromStr(utx.address, network); ECPrivate? privkey; bool? isSilentPayment = false; - final hd = _hdFor(record: utx.bitcoinAddressRecord); + final hd = _hdForAddressRecord(record: utx.bitcoinAddressRecord); if (utx.bitcoinAddressRecord is BitcoinSilentPaymentAddressRecord) { final unspentAddress = utx.bitcoinAddressRecord as BitcoinSilentPaymentAddressRecord; @@ -1287,7 +1502,7 @@ abstract class ElectrumWalletBase final changeDerivationPath = "${_hardenedDerivationPath(changeBaseDerivationPath)}" "/${changeAddress.isHidden ? "1" : "0"}" "/${changeAddress.index}"; - final changeHd = _hdFor(record: changeAddress).childKey(Bip32KeyIndex(changeAddress.index)); + final changeHd = _hdForAddressRecord(record: changeAddress).childKey(Bip32KeyIndex(changeAddress.index)); final changePubKeyHex = changeHd.publicKey.toHex(); utxoDetails.publicKeys[address.pubKeyHash()] = @@ -1617,6 +1832,7 @@ abstract class ElectrumWalletBase return PendingBitcoinTransaction( transaction, type, + accountIndex: currentAccountIndex, electrumClient: electrumClient, amount: estimatedTx.amount, fee: estimatedTx.fee, @@ -1708,6 +1924,7 @@ abstract class ElectrumWalletBase return PendingBitcoinTransaction( transaction, type, + accountIndex: currentAccountIndex, electrumClient: electrumClient, amount: estimatedTx.amount, fee: estimatedTx.fee, @@ -1819,7 +2036,7 @@ abstract class ElectrumWalletBase if (amount != null) { int totalValue = 0; - for (final input in unspentCoins) { + for (final input in unspentCoinsForCurrentAccount) { if (totalValue >= amount) { break; } @@ -1832,7 +2049,7 @@ abstract class ElectrumWalletBase if (totalValue < amount) return 0; } else { - for (final input in unspentCoins) { + for (final input in unspentCoinsForCurrentAccount) { if (input.isSending) { inputsCount += 1; } @@ -1875,6 +2092,8 @@ abstract class ElectrumWalletBase @override Future close({bool shouldCleanup = false}) async { + _missingHistoryQueue.clear(); + _unresolvedAccountRetryCount.clear(); try { await _receiveStream?.cancel(); await electrumClient.close(); @@ -1886,6 +2105,7 @@ abstract class ElectrumWalletBase @action Future updateAllUnspents() async { + List updatedUnspentCoins = []; final previousUnspentCoins = List.from(unspentCoins.where((utxo) => @@ -1910,6 +2130,7 @@ abstract class ElectrumWalletBase for (final addr in targetAddresses) { if (addr is! BitcoinSilentPaymentAddressRecord) { addr.balance = 0; + _appliedCoinKeysByAddress[addr.address]?.clear(); } } @@ -1924,6 +2145,7 @@ abstract class ElectrumWalletBase updatedUnspentCoins.addAll(result!); } unspentCoins = updatedUnspentCoins; + _hasCompleteUnspentSet = true; } else { if (updatedUnspentCoins.isEmpty) { unspentCoins = handleFailedUtxoFetch( @@ -1945,6 +2167,7 @@ abstract class ElectrumWalletBase } await updateCoins(unspentCoins); + _updateAccountBalancesFromUnspents(); await _refreshUnspentCoinsInfo(); } @@ -1965,11 +2188,13 @@ abstract class ElectrumWalletBase final scriptHashes = byScriptHash.keys.toList(); try { + final failedScriptHashes = {}; final unspentByScriptHash = await _processChunksToMap>>( items: scriptHashes, chunkSize: addressHistoryChunkSize, processChunk: _getListUnspentBatch, + onChunkError: (chunk, _) => failedScriptHashes.addAll(chunk), ); final txHashes = {}; @@ -2007,6 +2232,8 @@ abstract class ElectrumWalletBase return addresses.map((address) { final scriptHash = address.getScriptHash(network); + if (failedScriptHashes.contains(scriptHash)) return null; + if (!unspentByScriptHash.containsKey(scriptHash)) return null; return coinsByScriptHash[scriptHash] ?? []; }).toList(); } catch (e) { @@ -2066,8 +2293,15 @@ abstract class ElectrumWalletBase coin.isSending = coinInfo.isSending; coin.note = coinInfo.note; - if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) - coin.bitcoinAddressRecord.balance += coinInfo.value; + if (coin.bitcoinAddressRecord is! BitcoinSilentPaymentAddressRecord) { + final addr = coin.bitcoinAddressRecord.address; + final coinKey = '${coin.hash}:${coin.vout}'; + final appliedKeys = _appliedCoinKeysByAddress.putIfAbsent(addr, () => {}); + + if (appliedKeys.add(coinKey)) { + coin.bitcoinAddressRecord.balance += coinInfo.value; + } + } } else { addCoinInfo(coin); } @@ -2175,6 +2409,41 @@ abstract class ElectrumWalletBase if (duplicateKeys.isNotEmpty) await unspentCoinsInfo.deleteAll(duplicateKeys); } + void _seedAccountBalancesFromAddressRecords() { + if (type != WalletType.bitcoin) return; + if (accountBalances.isNotEmpty) return; + + final newBalances = {}; + + for (final addr in walletAddresses.allAddresses) { + if (addr.type == SegwitAddresType.mweb) continue; + final current = newBalances[addr.accountIndex] ?? ElectrumBalance.zero(currency); + current.confirmed += Money.fromInt(addr.balance, currency); + newBalances[addr.accountIndex] = current; + } + + for (final accountIndex in walletAddresses.accountIndexes) { + newBalances.putIfAbsent(accountIndex, () => ElectrumBalance.zero(currency)); + } + + // balance[currency] came from the wallet file and is authoritative for the + // selected account — prefer it if the address records look stale. + final persisted = balance[currency]; + final seeded = newBalances[currentAccountIndex]; + if (persisted != null && seeded != null) { + final seededIsZero = seeded.confirmed == Money.zero(currency) && + seeded.unconfirmed == Money.zero(currency); + final persistedIsNonZero = persisted.confirmed != Money.zero(currency) || + persisted.unconfirmed != Money.zero(currency); + if (seededIsZero && persistedIsNonZero) { + newBalances[currentAccountIndex] = persisted; + } + } + + accountBalances = ObservableMap.of(newBalances); + _updateCurrentAccountBalance(); + } + int transactionVSize(String transactionHex) => BtcTransaction.fromRaw(transactionHex).getVSize(); Future canReplaceByFee(ElectrumTransactionInfo tx) async { @@ -2203,7 +2472,8 @@ abstract class ElectrumWalletBase throw Exception("Receiver output not found."); } - final availableInputs = unspentCoins.where((utxo) => utxo.isSending && !utxo.isFrozen).toList(); + final availableInputs = + unspentCoinsForCurrentAccount.where((utxo) => utxo.isSending && !utxo.isFrozen).toList(); int totalBalance = availableInputs.fold( 0, (previousValue, element) => previousValue + element.value.toInt()); @@ -2254,7 +2524,7 @@ abstract class ElectrumWalletBase walletAddresses.allAddresses.firstWhere((element) => element.address == address); final btcAddress = RegexUtils.addressTypeFromStr(addressRecord.address, network); - final hd = _hdFor(record: addressRecord); + final hd = _hdForAddressRecord(record: addressRecord); final privkey = generateECPrivate(hd: hd, index: addressRecord.index, network: network); @@ -2331,7 +2601,7 @@ abstract class ElectrumWalletBase // If still not enough, add UTXOs until the fee is covered, drawing them at // random instead of in the predictable wallet scan order (address, then age). if (remainingFee > BigInt.zero) { - final unusedUtxos = unspentCoins + final unusedUtxos = unspentCoinsForCurrentAccount .where((utxo) => utxo.isSending && !utxo.isFrozen && utxo.confirmations! > 0) .toList() ..shuffle(Random.secure()); @@ -2339,7 +2609,7 @@ abstract class ElectrumWalletBase for (final utxo in unusedUtxos) { final address = RegexUtils.addressTypeFromStr(utxo.address, network); - final hd = _hdFor(record: utxo.bitcoinAddressRecord); + final hd = _hdForAddressRecord(record: utxo.bitcoinAddressRecord); final privkey = generateECPrivate( hd: hd, @@ -2449,6 +2719,7 @@ abstract class ElectrumWalletBase return PendingBitcoinTransaction( transaction, type, + accountIndex: transactionHistory.transactions[hash]?.accountIndex ?? currentAccountIndex, electrumClient: electrumClient, amount: Money.fromInt(sendingAmount, currency), fee: Money.fromInt(newFee, currency), @@ -2457,17 +2728,17 @@ abstract class ElectrumWalletBase feeRate: newFee.toString(), isViewOnly: keys.privateKey.isEmpty, )..addListener((transaction) async { - transactionHistory.transactions.values.forEach((tx) { - if (tx.id == hash) { - tx.isReplaced = true; - tx.isPending = false; - transactionHistory.addOne(tx); - } - }); - transactionHistory.addOne(transaction); - await updateBalance(); - await updateAllUnspents(); + transactionHistory.transactions.values.forEach((tx) { + if (tx.id == hash) { + tx.isReplaced = true; + tx.isPending = false; + transactionHistory.addOne(tx); + } }); + transactionHistory.addOne(transaction); + await updateBalance(); + await updateAllUnspents(); + }); } catch (e) { throw e; } @@ -2564,13 +2835,19 @@ abstract class ElectrumWalletBase Future fetchTransactionInfo( {required String hash, int? height, bool? retryOnFailure}) async { try { - return ElectrumTransactionInfo.fromElectrumBundle( - await getTransactionExpanded(hash: hash, height: height), + final bundle = await getTransactionExpanded(hash: hash, height: height); + final addressesByAccount = addressesSetByAccount; + final owner = _accountForTransaction(bundle, addressesByAccount); + + final info = ElectrumTransactionInfo.fromElectrumBundle( + bundle, walletInfo.type, network, - addresses: addressesSet, + addresses: owner != null ? addressesByAccount[owner]! : addressesSet, height: height, ); + info.accountIndex = owner; + return info; } catch (e) { if (e is FormatException && retryOnFailure == true) { await Future.delayed(const Duration(seconds: 2)); @@ -2589,14 +2866,17 @@ abstract class ElectrumWalletBase Future> fetchTransactions() async { try { final Map historiesWithDetails = {}; - ; - printV('[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchFetching'); + printV("[BATCH_TEST] Fetching transactions with batch: $shouldUseBatchFetching"); if (type == WalletType.bitcoin) { - await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching - ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) - : fetchTransactionsForAddressType(historiesWithDetails, type))); + if (isInitialBitcoinAccountsSync) { + await fetchBitcoinTransactionsForInitialRestore(historiesWithDetails); + } else { + await Future.wait(BITCOIN_ADDRESS_TYPES.map((type) => shouldUseBatchFetching + ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) + : fetchTransactionsForAddressType(historiesWithDetails, type))); + } } else if (type == WalletType.bitcoinCash) { await Future.wait(BITCOIN_CASH_ADDRESS_TYPES.map((type) => shouldUseBatchFetching ? fetchTransactionsForAddressTypeBatch(historiesWithDetails, type) @@ -2612,9 +2892,12 @@ abstract class ElectrumWalletBase : fetchTransactionsForAddressType(historiesWithDetails, type))); } + printV('[QUICK_SYNC] deferred ${_missingHistoryQueue.length} transactions to background'); + transactionHistory.transactions.values.forEach((tx) async { - final isPendingSilentPaymentUtxo = - (tx.isPending || tx.confirmations == 0) && historiesWithDetails[tx.id] == null; + final isPendingSilentPaymentUtxo = (tx.isPending || tx.confirmations == 0) && + historiesWithDetails[tx.id] == null && + !_missingHistoryQueue.containsKey(tx.id); if (isPendingSilentPaymentUtxo) { final info = @@ -2637,285 +2920,180 @@ abstract class ElectrumWalletBase } Future fetchTransactionsForAddressType( - Map historiesWithDetails, - BitcoinAddressType type, - ) async { - final addressesByType = - walletAddresses.allAddresses.where((addr) => addr.type == type).toList(); + Map historiesWithDetails, + BitcoinAddressType type, { + int? accountIndex, + }) async { + final addressesByType = walletAddresses.allAddresses + .where((addr) => addr.type == type) + .where((addr) => accountIndex == null || addr.accountIndex == accountIndex) + .toList(); - final receiveStandard = getAddressBranchByType(hidden: false, legacy: false, type: type); - final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type); - final receiveLegacy = getAddressBranchByType(hidden: false, legacy: true, type: type); - final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type); + + final branchCache = >{}; + List branchFor(BitcoinAddressRecord record) => + branchCache.putIfAbsent( + '${record.accountIndex}:${record.isHidden}:${record.isLegacyDerivation}', + () => getAddressBranchByType( + type: type, + hidden: record.isHidden, + legacy: record.isLegacyDerivation, + accountIndex: record.accountIndex, + ), + ); walletAddresses.hiddenAddresses - .addAll([...changeStandard, ...changeLegacy].map((e) => e.address)); + .addAll(addressesByType.where((addr) => addr.isHidden).map((e) => e.address)); await walletAddresses.saveAddressesInBox(); + await Future.wait(addressesByType.map((addressRecord) async { - final history = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); + final result = await _fetchAddressHistory(addressRecord, await getCurrentChainTip()); - if (history.isNotEmpty) { - addressRecord.txCount = history.length; - historiesWithDetails.addAll(history); + if (result.hasHistory) { + historiesWithDetails.addAll(result.transactions); - final matchedAddresses = addressRecord.isHidden - ? (addressRecord.isLegacyDerivation ? changeLegacy : changeStandard) - : (addressRecord.isLegacyDerivation ? receiveLegacy : receiveStandard); - final isUsedAddressAboveGap = matchedAddresses.toList().indexOf(addressRecord) >= + final matchedAddresses = branchFor(addressRecord); + final isUsedAddressAboveGap = matchedAddresses.indexOf(addressRecord) >= matchedAddresses.length - (addressRecord.isHidden ? ElectrumWalletAddressesBase.defaultChangeAddressesCount : ElectrumWalletAddressesBase.defaultReceiveAddressesCount); if (isUsedAddressAboveGap) { - final prevLength = walletAddresses.allAddresses.length; // Discover new addresses for the same address type until the gap limit is respected await walletAddresses.discoverAddresses( - matchedAddresses.toList(), + matchedAddresses, addressRecord.isHidden, - (address) async { + (address) async { await subscribeForUpdates(); return _fetchAddressHistory(address, await getCurrentChainTip()) - .then((history) => history.isNotEmpty ? address.address : null); + .then((result) => result.hasHistory ? address.address : null); }, type: type, isLegacyDerivation: addressRecord.isLegacyDerivation, + accountIndex: addressRecord.accountIndex, ); - final newLength = walletAddresses.allAddresses.length; - - if (newLength > prevLength) { - await fetchTransactionsForAddressType(historiesWithDetails, type); - } } } + }),); + } + + Future _fetchAddressHistory( + BitcoinAddressRecord addressRecord, int? currentHeight) => + _fetchAddressHistories([addressRecord], currentHeight, addressHistoryChunkSize); + + Future _fetchAddressHistories( + List addressRecords, + int? currentHeight, + int historyChunkSize) async { + final scan = await _scanAddressHistories(addressRecords, historyChunkSize); + + final split = await _markUsedAndSplitHistory(scan, addressRecords, currentHeight); + + _enqueueMissingHistory(split.missingHistoryItems); + + return split; + } + + void _enqueueMissingHistory(List> missingHistoryItems) { + for (final item in missingHistoryItems) { + final txid = item['tx_hash'] as String?; + if (txid == null || txid.isEmpty) continue; + _missingHistoryQueue.putIfAbsent(txid, () => item); + } + } + + Future resolveQueuedTransactionDetails() { + final inProgress = _resolveMissingHistoryFuture; + if (inProgress != null) return inProgress; + + if (_missingHistoryQueue.isEmpty) return Future.value(); + + final future = _drainMissingHistoryQueue(); + _resolveMissingHistoryFuture = future; + return future.whenComplete(() => _resolveMissingHistoryFuture = null); + } + + Future _drainMissingHistoryQueue() async { + final watch = Stopwatch()..start(); + var resolvedCount = 0; + try { + printV('[QUICK_SYNC] resolving ${_missingHistoryQueue.length} deferred transactions'); + + while (_missingHistoryQueue.isNotEmpty) { + final queued = _missingHistoryQueue.values.toList(growable: false); + _missingHistoryQueue.clear(); + final resolved = await _resolveTransactions(queued, addressHistoryChunkSize); + resolvedCount += resolved.length; + } + } finally { + printV( + '[QUICK_SYNC] resolved $resolvedCount deferred transactions in ${watch.elapsedMilliseconds} ms'); + } + } + + Future _scanAddressHistories( + List addressRecords, int historyChunkSize) async { + final historyByScriptHash = >>{}; + var hadFailures = false; + + if (shouldUseBatchFetching) { + final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList(); + + historyByScriptHash.addAll( + await _processChunksToMap>>( + items: scriptHashes, + chunkSize: historyChunkSize, + processChunk: _getHistoryBatch, + onChunkError: (chunk, error) { + hadFailures = true; + printV('address history scan failed for ${chunk.length} scripthashes: $error'); + }, + ), + ); + + return AddressHistoryScan( + historyByScriptHash: historyByScriptHash, + hadFailures: hadFailures, + ); + } + + await Future.wait(addressRecords.map((addressRecord) async { + final scriptHash = addressRecord.getScriptHash(network); + + try { + historyByScriptHash[scriptHash] = await electrumClient.getHistory(scriptHash); + } catch (e) { + hadFailures = true; + printV('address history scan failed for ${addressRecord.address}: $e'); + } })); - } - Future> _fetchAddressHistory( - BitcoinAddressRecord addressRecord, int? currentHeight) async { - String txid = ""; - - try { - final Map historiesWithDetails = {}; - - final history = await electrumClient.getHistory(addressRecord.getScriptHash(network)); - - if (history.isNotEmpty) { - addressRecord.setAsUsed(); - walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address); - - if (this is BitcoinWallet) { - //removes transactions no longer returned by the api, presumed replaced/invalid. - transactionHistory.transactions.removeWhere( - (hash, tx) => - tx.outputAddresses != null && - tx.outputAddresses!.contains(addressRecord.address) && - !history.any((newTransaction) => newTransaction['tx_hash'] == hash), - ); - } - - await Future.wait(history.map((transaction) async { - txid = transaction['tx_hash'] as String; - final height = transaction['height'] as int; - final storedTx = transactionHistory.transactions[txid]; - - if (storedTx != null) { - if (height > 0) { - storedTx.height = height; - // the tx's block itself is the first confirmation so add 1 - if ((currentHeight ?? 0) > 0) { - storedTx.confirmations = currentHeight! - height + 1; - } - storedTx.isPending = storedTx.confirmations == 0; - } - - historiesWithDetails[txid] = storedTx; - } else { - final tx = await fetchTransactionInfo(hash: txid, height: height, retryOnFailure: true); - - if (tx != null) { - historiesWithDetails[txid] = tx; - - // Got a new transaction fetched, add it to the transaction history - // instead of waiting all to finish, and next time it will be faster - - _applyLitecoinPegOutTag(tx); - transactionHistory.addOne(tx); - await transactionHistory.save(); - } - } - - return Future.value(null); - })); - } - - return historiesWithDetails; - } catch (e, stacktrace) { - _onError?.call(FlutterErrorDetails( - exception: "$txid - $e", - stack: stacktrace, - library: this.runtimeType.toString(), - )); - return {}; - } - } - - Future fetchTransactionsForAddressTypeBatch( - Map historiesWithDetails, BitcoinAddressType type) async { - final receiveStandard = getAddressBranchByType(hidden: false, legacy: false, type: type); - final changeStandard = getAddressBranchByType(hidden: true, legacy: false, type: type); - final receiveLegacy = getAddressBranchByType(hidden: false, legacy: true, type: type); - final changeLegacy = getAddressBranchByType(hidden: true, legacy: true, type: type); - - walletAddresses.hiddenAddresses - .addAll([...changeStandard, ...changeLegacy].map((e) => e.address)); - await walletAddresses.saveAddressesInBox(); - - await fetchTransactionsForAddressesBranchBatch( - historiesWithDetails, - type, - receiveStandard, - isHidden: false, - isLegacyDerivation: false, - ); - - await fetchTransactionsForAddressesBranchBatch( - historiesWithDetails, - type, - changeStandard, - isHidden: true, - isLegacyDerivation: false, - ); - - await fetchTransactionsForAddressesBranchBatch( - historiesWithDetails, - type, - receiveLegacy, - isHidden: false, - isLegacyDerivation: true, - ); - - await fetchTransactionsForAddressesBranchBatch( - historiesWithDetails, - type, - changeLegacy, - isHidden: true, - isLegacyDerivation: true, + return AddressHistoryScan( + historyByScriptHash: historyByScriptHash, + hadFailures: hadFailures, ); } - Future fetchTransactionsForAddressesBranchBatch( - Map historiesWithDetails, - BitcoinAddressType type, - List branchAddresses, { - required bool isHidden, - required bool isLegacyDerivation, - }) async { - if (branchAddresses.isEmpty) return; + Future _markUsedAndSplitHistory(AddressHistoryScan scan, + List addressRecords, int? currentHeight) async { - final tip = await getCurrentChainTip(); - final currentBranch = [...branchAddresses]; - - final initialHistory = - await _processChunksToMap( - items: currentBranch, - chunkSize: addressHistoryChunkSize, - processChunk: (chunk) => _fetchBatchAddressHistory( - chunk, - tip, - addressHistoryChunkSize, - ), - ); - - if (initialHistory.isNotEmpty) { - historiesWithDetails.addAll(initialHistory); - } - - final gapLimit = isHidden - ? ElectrumWalletAddressesBase.defaultChangeAddressesCount - : ElectrumWalletAddressesBase.defaultReceiveAddressesCount; - - final highestUsedIndex = _highestUsedIndex(currentBranch); - final shouldDiscover = - highestUsedIndex >= 0 && highestUsedIndex >= currentBranch.length - gapLimit; - - if (!shouldDiscover) return; - - final newAddresses = await walletAddresses.discoverAddressesBatch( - currentBranch, - isHidden, - (newAddresses) async { - final newHistory = await _fetchBatchAddressHistory( - newAddresses, - tip, - discoveryHistoryChunkSize, - ); - - if (newHistory.isNotEmpty) { - historiesWithDetails.addAll(newHistory); - } - - return newAddresses - .where((addressRecord) => addressRecord.isUsed) - .map((addressRecord) => addressRecord.address) - .toSet(); - }, - type: type, - isLegacyDerivation: isLegacyDerivation, - ); - - if (newAddresses.isNotEmpty) { - currentBranch.addAll(newAddresses); - - if (isHidden) { - walletAddresses.hiddenAddresses.addAll(newAddresses.map((e) => e.address)); - await walletAddresses.saveAddressesInBox(); - } - } - } - - List getAddressBranchByType( - {required bool hidden, required bool legacy, required BitcoinAddressType type}) => - walletAddresses.allAddresses - .where((addr) => - addr.type == type && addr.isHidden == hidden && addr.isLegacyDerivation == legacy) - .toList() - ..sort((a, b) => a.index.compareTo(b.index)); - - int _highestUsedIndex(List addresses) { - for (int i = addresses.length - 1; i >= 0; i--) { - if (addresses[i].isUsed) return i; - } - return -1; - } - - Future> _fetchBatchAddressHistory( - List addressRecords, int? currentHeight, int historyChunkSize) async { String lastTxId = ''; bool didUpdateHistory = false; try { - final Map historiesWithDetails = {}; + final Map transactions = {}; + final missingHistoryItems = >[]; + var hasHistory = false; - // List of script hashes for the given address records - final scriptHashes = addressRecords.map((a) => a.getScriptHash(network)).toList(); - - final historyByScriptHash = - await _processChunksToMap>>( - items: scriptHashes, chunkSize: historyChunkSize, processChunk: _getHistoryBatch); - - // Map scriptHash -> addressRecord final byScriptHash = {}; for (final a in addressRecords) { byScriptHash[a.getScriptHash(network)] = a; } - // Split into already-known txs vs missing txs - final missingHistoryItems = >[]; - - for (final entry in historyByScriptHash.entries) { + for (final entry in scan.historyByScriptHash.entries) { final sh = entry.key; final addressRecord = byScriptHash[sh]; if (addressRecord == null) continue; @@ -2923,7 +3101,9 @@ abstract class ElectrumWalletBase final history = entry.value; if (history.isEmpty) continue; + hasHistory = true; addressRecord.setAsUsed(); + addressRecord.txCount = history.length; walletAddresses.clearLockIfMatches(addressRecord.type, addressRecord.address); //removes transactions no longer returned by the api, presumed replaced/invalid. @@ -2939,7 +3119,6 @@ abstract class ElectrumWalletBase } } - // For each transaction in the history, check if we already have it in our transaction history. If we do, update its details if necessary. If we don't, add it to the list of missing history items to fetch later. for (final item in history) { final txid = item['tx_hash'] as String?; final height = item['height'] as int? ?? 0; @@ -2948,7 +3127,19 @@ abstract class ElectrumWalletBase lastTxId = txid; final storedTx = transactionHistory.transactions[txid]; - if (storedTx != null) { + // If we've already tried resolving this tx's account owner + // several times and it still can't be determined, stop treating it + // as "missing" every scan - otherwise we'd refetch its full raw tx + // (and all its inputs' raw txs) forever with no chance of ever + // converging. + final hasExhaustedAccountRetries = + (_unresolvedAccountRetryCount[txid] ?? 0) >= _maxUnresolvedAccountRetries; + final needsAccount = type == WalletType.bitcoin && + storedTx != null && + storedTx.accountIndex == null && + !hasExhaustedAccountRetries; + + if (storedTx != null && !needsAccount) { if (height > 0) { final oldHeight = storedTx.height; final oldConfs = storedTx.confirmations; @@ -2956,6 +3147,7 @@ abstract class ElectrumWalletBase storedTx.height = height; + // the tx's block itself is the first confirmation so add 1 if ((currentHeight ?? 0) > 0) { storedTx.confirmations = currentHeight! - height + 1; } @@ -2970,7 +3162,7 @@ abstract class ElectrumWalletBase } } - historiesWithDetails[txid] = storedTx; + transactions[txid] = storedTx; } else { missingHistoryItems.add({ 'tx_hash': txid, @@ -2982,12 +3174,86 @@ abstract class ElectrumWalletBase } } - // Batch fetch missing tx verbose details - if (missingHistoryItems.isEmpty) { - if (didUpdateHistory) await transactionHistory.save(); - return historiesWithDetails; + if (didUpdateHistory) { + await transactionHistory.save(); } + return AddressHistoryResult( + transactions: transactions, + missingHistoryItems: missingHistoryItems, + hasHistory: hasHistory, + hadFailures: scan.hadFailures, + ); + } catch (e, stacktrace) { + final prefix = lastTxId.isNotEmpty ? '$lastTxId - ' : ''; + _onError?.call(FlutterErrorDetails( + exception: '$prefix$e', + stack: stacktrace, + library: runtimeType.toString(), + )); + return AddressHistoryResult( + transactions: {}, + missingHistoryItems: const [], + hasHistory: false, + hadFailures: true, + ); + } + } + + // Tracks how many times we've resolved a tx and still couldn't determine + // its owning account. Used by `_markUsedAndSplitHistory` to stop + // requeueing a tx forever when its account can never be determined. + void _trackAccountResolution(ElectrumTransactionInfo tx) { + if (type != WalletType.bitcoin) return; + + if (tx.accountIndex == null) { + _unresolvedAccountRetryCount.update(tx.id, (count) => count + 1, ifAbsent: () => 1); + } else { + _unresolvedAccountRetryCount.remove(tx.id); + } + } + + Future> _resolveTransactions( + List> missingHistoryItems, int historyChunkSize) async { + final Map resolved = {}; + + if (missingHistoryItems.isEmpty) return resolved; + + if (!shouldUseBatchFetching) { + await Future.wait(missingHistoryItems.map((item) async { + final txid = (item['tx_hash'] as String).trim(); + if (txid.isEmpty) return; + + try { + final tx = await fetchTransactionInfo( + hash: txid, + height: item['height'] as int?, + retryOnFailure: true, + ); + + if (tx == null) return; + + resolved[tx.id] = tx; + + _trackAccountResolution(tx); + + _applyLitecoinPegOutTag(tx); + + transactionHistory.addOne(tx); + await transactionHistory.save(); + } catch (e, stacktrace) { + _onError?.call(FlutterErrorDetails( + exception: '$txid - $e', + stack: stacktrace, + library: runtimeType.toString(), + )); + } + })); + + return resolved; + } + + try { for (var i = 0; i < missingHistoryItems.length; i += historyChunkSize) { final end = (i + historyChunkSize < missingHistoryItems.length) ? i + historyChunkSize @@ -3010,35 +3276,233 @@ abstract class ElectrumWalletBase retryDelay: const Duration(seconds: 1), ); + bool chunkUpdatedHistory = false; + for (final txid in hashes) { final tx = infosByHash[txid]; if (tx == null) continue; - historiesWithDetails[tx.id] = tx; + resolved[tx.id] = tx; + + _trackAccountResolution(tx); _applyLitecoinPegOutTag(tx); transactionHistory.addOne(tx); - didUpdateHistory = true; + chunkUpdatedHistory = true; + } + + if (chunkUpdatedHistory) { + await transactionHistory.save(); } } - if (didUpdateHistory) { - await transactionHistory.save(); - } - - return historiesWithDetails; + return resolved; } catch (e, stacktrace) { - final prefix = lastTxId.isNotEmpty ? '$lastTxId - ' : ''; _onError?.call(FlutterErrorDetails( - exception: '$prefix$e', + exception: '$e', stack: stacktrace, library: runtimeType.toString(), )); - return {}; + return resolved; } } + Future fetchBitcoinTransactionsForInitialRestore( + Map historiesWithDetails, + ) async { + int accountIndex = 0; + + while (accountIndex < maxProbAccounts) { + final isAccountProbe = accountIndex > 0; + + if (!mainHdByTypeAndAccount.containsKey(accountIndex)) { + _prepareHdForAccount(accountIndex, currency); + } + + if (!walletAddresses.accountIndexes.contains(accountIndex)) { + walletAddresses.accountIndexes.add(accountIndex); + await walletAddresses.prepareAccountAddresses( + accountIndex, + types: isAccountProbe ? EXTRA_ACCOUNT_ADDRESS_TYPES : BITCOIN_ADDRESS_TYPES, + ); + } + + final probeTypes = isAccountProbe ? EXTRA_ACCOUNT_ADDRESS_TYPES : BITCOIN_ADDRESS_TYPES; + + for (final addressType in probeTypes) { + if (shouldUseBatchFetching) { + await fetchTransactionsForAddressTypeBatch( + historiesWithDetails, + addressType, + accountIndex: accountIndex, + ); + } else { + await fetchTransactionsForAddressType( + historiesWithDetails, + addressType, + accountIndex: accountIndex, + ); + } + } + + final accountHasHistory = walletAddresses.allAddresses + .where((addr) => addr.accountIndex == accountIndex) + .any((addr) => addr.isUsed); + + if (!accountHasHistory) { + final persistedAccounts = await walletInfo.getAccounts(); + final isUserAccount = + persistedAccounts.any((account) => account.accountIndex == accountIndex); + + if (isAccountProbe && !isUserAccount) { + walletAddresses.accountIndexes.remove(accountIndex); + walletAddresses.removeAddressesForAccount(accountIndex); + } + break; + } + + // If the account has history, add it to the wallet (if it's not the first account). + if (isAccountProbe) { + await walletInfo.addAccount( + accountIndex: accountIndex, + label: "Account $accountIndex", + ); + } + // Move on to the next account index + accountIndex++; + } + if (electrumClient.isConnected) { + await walletInfo.setAccountDiscoveryLimit(maxProbAccounts); + } + } + + Future fetchTransactionsForAddressTypeBatch( + Map historiesWithDetails, + BitcoinAddressType type, { + int? accountIndex, + }) async { + final addressesByType = walletAddresses.allAddresses + .where((addr) => addr.type == type) + .where((addr) => accountIndex == null || addr.accountIndex == accountIndex) + .toList(); + + walletAddresses.hiddenAddresses + .addAll(addressesByType.where((addr) => addr.isHidden).map((e) => e.address)); + await walletAddresses.saveAddressesInBox(); + + final accountIndexes = addressesByType.map((addr) => addr.accountIndex).toSet(); + + for (final account in accountIndexes) { + for (final isHidden in [false, true]) { + for (final isLegacyDerivation in [false, true]) { + await fetchTransactionsForAddressesBranchBatch( + historiesWithDetails, + type, + getAddressBranchByType( + type: type, + hidden: isHidden, + legacy: isLegacyDerivation, + accountIndex: account, + ), + isHidden: isHidden, + isLegacyDerivation: isLegacyDerivation, + accountIndex: account, + ); + } + } + } + } + + + Future fetchTransactionsForAddressesBranchBatch( + Map historiesWithDetails, + BitcoinAddressType type, + List branchAddresses, { + required bool isHidden, + required bool isLegacyDerivation, + required int accountIndex, + }) async { + if (branchAddresses.isEmpty) return; + + final tip = await getCurrentChainTip(); + final currentBranch = [...branchAddresses]; + + final initialHistory = + await _processChunksToMap( + items: currentBranch, + chunkSize: addressHistoryChunkSize, + processChunk: (chunk) async => + (await _fetchAddressHistories(chunk, tip, addressHistoryChunkSize)).transactions, + ); + + if (initialHistory.isNotEmpty) { + historiesWithDetails.addAll(initialHistory); + } + + final gapLimit = isHidden + ? ElectrumWalletAddressesBase.defaultChangeAddressesCount + : ElectrumWalletAddressesBase.defaultReceiveAddressesCount; + + final highestUsedIndex = _highestUsedIndex(currentBranch); + final shouldDiscover = + highestUsedIndex >= 0 && highestUsedIndex >= currentBranch.length - gapLimit; + + if (!shouldDiscover) return; + + final newAddresses = await walletAddresses.discoverAddressesBatch( + currentBranch, + isHidden, + (newAddresses) async { + final newHistory = + await _fetchAddressHistories(newAddresses, tip, discoveryHistoryChunkSize); + + if (newHistory.transactions.isNotEmpty) { + historiesWithDetails.addAll(newHistory.transactions); + } + + return newAddresses + .where((addressRecord) => addressRecord.isUsed) + .map((addressRecord) => addressRecord.address) + .toSet(); + }, + type: type, + isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex, + ); + + if (newAddresses.isNotEmpty) { + currentBranch.addAll(newAddresses); + + if (isHidden) { + walletAddresses.hiddenAddresses.addAll(newAddresses.map((e) => e.address)); + await walletAddresses.saveAddressesInBox(); + } + } + } + + List getAddressBranchByType({ + required BitcoinAddressType type, + required bool hidden, + required bool legacy, + int? accountIndex, + }) => + walletAddresses.allAddresses + .where((addr) => + addr.type == type && + (accountIndex == null || addr.accountIndex == accountIndex) && + addr.isHidden == hidden && + addr.isLegacyDerivation == legacy) + .toList() + ..sort((a, b) => a.index.compareTo(b.index)); + + int _highestUsedIndex(List addresses) { + for (int i = addresses.length - 1; i >= 0; i--) { + if (addresses[i].isUsed) return i; + } + return -1; + } + Future>> _getTransactionVerboseBatch(List hashes) { return electrumClient.getBatchTransactionVerbose( hashes, @@ -3113,9 +3577,11 @@ abstract class ElectrumWalletBase required Map result, required Map? heightsByHash, }) async { + final addressesByAccount = addressesSetByAccount; + for (var i = 0; i < txIds.length; i += transactionChunkSize) { final end = - (i + transactionChunkSize < txIds.length) ? i + transactionChunkSize : txIds.length; + (i + transactionChunkSize < txIds.length) ? i + transactionChunkSize : txIds.length; final chunk = txIds.sublist(i, end); final bundlesByHash = await getTransactionExpandedBatch( @@ -3131,14 +3597,17 @@ abstract class ElectrumWalletBase continue; } + final owner = _accountForTransaction(bundle, addressesByAccount); + final info = ElectrumTransactionInfo.fromElectrumBundle( bundle, walletInfo.type, network, - addresses: addressesSet, + addresses: owner != null ? addressesByAccount[owner]! : addressesSet, height: heightsByHash?[txId], ); info.id = txId; + info.accountIndex = owner; result[txId] = info; } catch (_) { result[txId] = null; @@ -3399,10 +3868,14 @@ abstract class ElectrumWalletBase Future updateTransactions() async { printV("updateTransactions() called!"); + + if (_isTransactionUpdating) { + printV("updateTransactions: already running, skipping"); + return; + } + + _isTransactionUpdating = true; try { - if (_isTransactionUpdating) { - return; - } currentChainTip = await getUpdatedChainTip(); bool updated = false; @@ -3425,13 +3898,12 @@ abstract class ElectrumWalletBase await transactionHistory.save(); } - _isTransactionUpdating = true; await fetchTransactions(); walletAddresses.updateReceiveAddresses(); - _isTransactionUpdating = false; } catch (e, stacktrace) { printV(stacktrace); printV(e); + } finally { _isTransactionUpdating = false; } } @@ -3464,6 +3936,7 @@ abstract class ElectrumWalletBase await updateBalance(); await _fetchAddressHistory(address, await getCurrentChainTip()); + await resolveQueuedTransactionDetails(); } catch (e, s) { printV("sub error: $e"); _onError?.call(FlutterErrorDetails( @@ -3530,13 +4003,36 @@ abstract class ElectrumWalletBase .where((address) => RegexUtils.addressTypeFromStr(address.address, network) is! MwebAddress) .toList(); - final balances = shouldUseBatchFetching - ? await fetchBalancesBatch(addresses) - : await fetchBalancesRegular(addresses); + List> balances; + try { + balances = shouldUseBatchFetching + ? await fetchBalancesBatch(addresses) + : await fetchBalancesRegular(addresses); + } catch (e) { + printV("fetchBalances network error, returning last known balance: $e"); + syncStatus = LostConnectionSyncStatus(); + return balance[currency] ?? + ElectrumBalance( + confirmed: Money.zero(currency), + unconfirmed: Money.zero(currency), + frozen: Money.zero(currency), + ); + } printV( 'Fetched balances for ${addresses.length} addresses. Batch fetching: $shouldUseBatchFetching'); + // If the balances list is empty (no addresses or all fetches silently returned nothing), + // preserve the last known balance rather than returning zero. + if (balances.isEmpty) { + return balance[currency] ?? + ElectrumBalance( + confirmed: Money.zero(currency), + unconfirmed: Money.zero(currency), + frozen: Money.zero(currency), + ); + } + var totalFrozen = 0; var totalConfirmed = 0; var totalUnconfirmed = 0; @@ -3571,7 +4067,9 @@ abstract class ElectrumWalletBase }); }); - if (balances.isNotEmpty && balances.first['confirmed'] == null) { + // Check if ALL returned balances have null confirmed — indicates server is unreachable. + final allNull = balances.every((b) => b['confirmed'] == null); + if (allNull) { // if we got null balance responses from the server, set our connection status to lost and return our last known balance: printV("got null balance responses from the server, setting connection status to lost"); syncStatus = LostConnectionSyncStatus(); @@ -3605,10 +4103,56 @@ abstract class ElectrumWalletBase ); } + List get currentAccountBitcoinTransactions { + final all = transactionHistory.transactions.values; + if (!hasAccountsSupport) return all.toList(); + + final accountIndex = currentAccountIndex; + final accountAddresses = walletAddresses.allAddresses + .where((a) => a.accountIndex == accountIndex) + .map((a) => a.address) + .toSet(); + + return all.where((tx) { + + if (tx.additionalInfo["isLightning"] == true) return accountIndex == 0; + if (tx.isReceivedSilentPayment) return accountIndex == 0; + + // Locally created transactions store the account index explicitly, + // so use it first instead of checking the addresses. + if (tx.accountIndex != null) return tx.accountIndex == accountIndex; + + return (tx.inputAddresses ?? const []).any(accountAddresses.contains) || + (tx.outputAddresses ?? const []).any(accountAddresses.contains); + }).toList(); + } + Future updateBalance() async { printV("updateBalance() called!"); - balance[currency] = await fetchBalances(); - await save(); + + if (!electrumClient.isConnected) { + printV("updateBalance: not connected, keeping existing balance"); + return; + } + + try { + final fetchedTotal = await fetchBalances(); + + if (type == WalletType.bitcoin && hasAccountsSupport) { + // If the wallet has accounts support, we only want to update the balance for the current account. + final accountBalance = accountBalances[currentAccountIndex]; + if (accountBalance == null) { + return; + } + balance[currency] = accountBalance; + } else { + balance[currency] = fetchedTotal; + } + + await save(); + } catch (e) { + printV("updateBalance failed: $e"); + } } @override @@ -3646,7 +4190,7 @@ abstract class ElectrumWalletBase } final hd = addressRecord != null - ? _hdFor(record: addressRecord).childKey(Bip32KeyIndex(addressRecord.index)) + ? _hdForAddressRecord(record: addressRecord).childKey(Bip32KeyIndex(addressRecord.index)) : mainHd; final priv = ECPrivate.fromHex(hd.privateKey.privKey.toHex()); @@ -4061,21 +4605,19 @@ abstract class ElectrumWalletBase } } - Bip32Slip10Secp256k1 _hdFor({required BaseBitcoinAddressRecord record}) { + Bip32Slip10Secp256k1 _hdForAddressRecord({required BaseBitcoinAddressRecord record}) { final addrType = record.type; if (record.isLegacyDerivation) { - if (record.isHidden) { - return walletAddresses.legacySideHd; - } else { - return walletAddresses.legacyMainHd; - } + return record.isHidden ? walletAddresses.legacySideHd : walletAddresses.legacyMainHd; } + final accountIndex = record.accountIndex; + if (record.isHidden) { - return sideHdByType[addrType] ?? sideHd; + return sideHdByTypeAndAccount[accountIndex]?[addrType] ?? sideHd; } else { - return mainHdByType[addrType] ?? mainHd; + return mainHdByTypeAndAccount[accountIndex]?[addrType] ?? mainHd; } } } @@ -4570,3 +5112,27 @@ class UtxoDetails { required this.spendsUnconfirmedTX, }); } + +class AddressHistoryScan { + AddressHistoryScan({ + required this.historyByScriptHash, + required this.hadFailures, + }); + + final Map>> historyByScriptHash; + final bool hadFailures; +} + +class AddressHistoryResult { + AddressHistoryResult({ + required this.transactions, + required this.missingHistoryItems, + required this.hasHistory, + required this.hadFailures, + }); + + final Map transactions; + final List> missingHistoryItems; + final bool hasHistory; + final bool hadFailures; +} \ No newline at end of file diff --git a/cw_bitcoin/lib/electrum_wallet_addresses.dart b/cw_bitcoin/lib/electrum_wallet_addresses.dart index 0beb02d25..305e1c28a 100644 --- a/cw_bitcoin/lib/electrum_wallet_addresses.dart +++ b/cw_bitcoin/lib/electrum_wallet_addresses.dart @@ -1,5 +1,6 @@ import 'dart:io' show Platform; import 'dart:math'; +import "package:collection/collection.dart"; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:blockchain_utils/blockchain_utils.dart'; @@ -20,6 +21,15 @@ import 'package:mobx/mobx.dart'; part 'electrum_wallet_addresses.g.dart'; +class UnsupportedAddressTypeForAccountException implements Exception { + UnsupportedAddressTypeForAccountException(this.message); + + final String message; + + @override + String toString() => message; +} + abstract class ElectrumWalletAddresses = ElectrumWalletAddressesBase with _$ElectrumWalletAddresses; const List BITCOIN_ADDRESS_TYPES = [ @@ -30,6 +40,11 @@ const List BITCOIN_ADDRESS_TYPES = [ P2shAddressType.p2wpkhInP2sh, ]; +const List LEGACY_DUPLICATE_ADDRESS_TYPES = [ + SegwitAddresType.p2wpkh, + SegwitAddresType.p2wsh, +]; + const List LITECOIN_ADDRESS_TYPES = [ SegwitAddresType.p2wpkh, SegwitAddresType.mweb, @@ -43,11 +58,15 @@ const List DOGECOIN_ADDRESS_TYPES = [ P2pkhAddressType.p2pkh, ]; +const List EXTRA_ACCOUNT_ADDRESS_TYPES = [SegwitAddresType.p2wpkh]; + abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { ElectrumWalletAddressesBase( WalletInfo walletInfo, { - required this.mainHdByType, - required this.sideHdByType, + required this.mainHdByTypeAndAccount, + required this.sideHdByTypeAndAccount, + required this.accountIndexes, + required this.currentAccountIndex, required this.legacyMainHd, required this.legacySideHd, required this.network, @@ -72,12 +91,12 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { .toSet()), currentReceiveAddressIndexByType = initialRegularAddressIndex ?? {}, currentChangeAddressIndexByType = initialChangeAddressIndex ?? {}, - _addressPageType = initialAddressPageType ?? - (walletInfo.addressPageType != null - ? walletInfo.addressPageType == LightningAddressType.p2l.value - ? LightningAddressType.p2l - : BitcoinAddressType.fromValue(walletInfo.addressPageType!) - : SegwitAddresType.p2wpkh), + _addressPageType = _resolveInitialAddressPageType( + initialAddressPageType: initialAddressPageType, + walletInfo: walletInfo, + mainHdByTypeAndAccount: mainHdByTypeAndAccount, + currentAccountIndex: currentAccountIndex, + ), silentAddresses = ObservableList.of( (initialSilentAddresses ?? []).toSet()), currentSilentAddressIndex = initialSilentAddressIndex, @@ -142,8 +161,12 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { // TODO: add this variable in `litecoin_wallet_addresses` and just add a cast in cw_bitcoin to use it final ObservableList mwebAddresses; final BasedUtxoNetwork network; - Map mainHdByType; - Map sideHdByType; + final Map> mainHdByTypeAndAccount; + final Map> sideHdByTypeAndAccount; + List accountIndexes; + + @observable + int currentAccountIndex; final Bip32Slip10Secp256k1 legacyMainHd; final Bip32Slip10Secp256k1 legacySideHd; final bool isHardwareWallet; @@ -199,8 +222,16 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { "Error: Unable to fetch your Lightning address, please check your network connection."; } - final typeMatchingAddressesAll = - _addresses.where((addr) => !addr.isHidden && _isAddressPageTypeMatch(addr)).toList(); + final accountIndexForCheck = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0; + if (!_isAddressTypeSupportedForAccount(addressPageType, accountIndexForCheck)) { + printV("addressPageType $addressPageType is not supported for account $accountIndexForCheck"); + return ""; + } + + final typeMatchingAddressesAll = _addresses + .where((addr) => + _isCurrentAccountAddress(addr) && !addr.isHidden && _isAddressPageTypeMatch(addr)) + .toList(); // Prefer standard derivation addresses for the current/active address, // but keep legacy addresses present in the overall address lists. @@ -238,6 +269,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { final prev = previousAddressRecord; if (prev != null && + _isCurrentAccountAddress(prev) && prev.type == addressPageType && !prev.isUsed && !prev.isLegacyDerivation && @@ -303,8 +335,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return silentAddress?.toString() ?? ''; } - final mainHd = mainHdByType[addressPageType] ?? mainHdByType.values.first; - return getAddress(index: 0, hd: mainHd, addressType: addressPageType); + return _firstAddressForType(addressPageType); } String get payjoinCompatibleAddress { @@ -313,7 +344,18 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { ? SegwitAddresType.p2wpkh : addressPageType; - final mainHd = mainHdByType[addrType] ?? mainHdByType.values.first; + return _firstAddressForType(addrType); + } + + String _firstAddressForType(BitcoinAddressType addrType) { + final mainMap = walletInfo.type == WalletType.bitcoin + ? mainHdByTypeAndAccount[currentAccountIndex] + : mainHdByTypeAndAccount[0]; + + final mainHd = mainMap?[addrType] ?? mainMap?.values.first; + + if (mainHd == null) return ''; + return getAddress(index: 0, hd: mainHd, addressType: addrType); } @@ -355,7 +397,11 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { }); @override - Future init() async { + Future init({List accountIndexes = const []}) async { + final effectiveAccountIndexes = + accountIndexes.isNotEmpty ? accountIndexes : this.accountIndexes; + + if (accountIndexes.isNotEmpty) this.accountIndexes = accountIndexes; if (walletInfo.type == WalletType.bitcoinCash) { await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); } else if (walletInfo.type == WalletType.litecoin) { @@ -366,21 +412,13 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { } else if (walletInfo.type == WalletType.dogecoin) { await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); } else if (walletInfo.type == WalletType.bitcoin) { - await _generateInitialAddresses(isLegacyDerivation: true); - await _generateInitialAddresses(); - if (!isHardwareWallet) { - await _generateInitialAddresses(type: P2pkhAddressType.p2pkh, isLegacyDerivation: true); - await _generateInitialAddresses(type: P2pkhAddressType.p2pkh); + for (final accountIndex in effectiveAccountIndexes) { - await _generateInitialAddresses( - type: P2shAddressType.p2wpkhInP2sh, isLegacyDerivation: true); - await _generateInitialAddresses(type: P2shAddressType.p2wpkhInP2sh); - - await _generateInitialAddresses(type: SegwitAddresType.p2tr, isLegacyDerivation: true); - await _generateInitialAddresses(type: SegwitAddresType.p2tr); - - await _generateInitialAddresses(type: SegwitAddresType.p2wsh, isLegacyDerivation: true); - await _generateInitialAddresses(type: SegwitAddresType.p2wsh); + await prepareAccountAddresses( + accountIndex, + types: accountIndex == 0 ? BITCOIN_ADDRESS_TYPES : EXTRA_ACCOUNT_ADDRESS_TYPES, + includeLegacy: accountIndex == 0, + ); } } @@ -407,9 +445,21 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { updateChangeAddresses(); if (changeAddresses.isEmpty) { - final newAddresses = await _createNewAddresses(gap, - startIndex: totalCountOfChangeAddresses > 0 ? totalCountOfChangeAddresses - 1 : 0, - isHidden: true); + final accountChangeCount = _addresses + .where((addressRecord) => + _isCurrentAccountAddress(addressRecord) && + addressRecord.isHidden && + addressRecord.type == addressPageType) + .length; + + final newAddresses = await _createNewAddresses( + gap, + startIndex: accountChangeCount, + isHidden: true, + type: addressPageType, + accountIndex: currentAccountIndex, + ); + addAddresses(newAddresses); } @@ -423,6 +473,11 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return address; } + bool _isCurrentAccountAddress(BitcoinAddressRecord addressRecord) { + return walletInfo.type != WalletType.bitcoin || + addressRecord.accountIndex == currentAccountIndex; + } + Map get labels { final G = ECPublic.fromBytes(BigintUtils.toBytes(Curves.generatorSecp256k1.x, length: 32)); final labels = {}; @@ -440,6 +495,29 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return labels; } + Future prepareAccountAddresses( + int accountIndex, { + List types = BITCOIN_ADDRESS_TYPES, + bool includeLegacy = false, + }) async { + for (final type in types) { + final shouldSkipHardwareWalletType = isHardwareWallet && type != SegwitAddresType.p2wpkh; + + if (shouldSkipHardwareWalletType) continue; + + await _generateInitialAddresses(accountIndex: accountIndex, type: type); + + // Legacy derivation for these types is identical to the standard one. + if (includeLegacy && !LEGACY_DUPLICATE_ADDRESS_TYPES.contains(type)) { + await _generateInitialAddresses( + accountIndex: accountIndex, + type: type, + isLegacyDerivation: true, + ); + } + } + } + @action BaseBitcoinAddressRecord generateNewAddress({String label = ''}) { if (addressPageType is LightningAddressType) { @@ -471,13 +549,26 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return address; } - final newAddressIndex = addressesByReceiveType.fold( - 0, (int acc, addressRecord) => addressRecord.isHidden == false ? acc + 1 : acc); + final accountIndex = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0; - final hd = _hdFor(isHidden: false, type: addressPageType, isLegacyDerivation: false); + final newAddressIndex = addressesByReceiveType.fold( + 0, + (int acc, addressRecord) => addressRecord is BitcoinAddressRecord && + addressRecord.accountIndex == accountIndex && + addressRecord.isHidden == false + ? acc + 1 + : acc); + + final hd = _hdForAddressGeneration( + isHidden: false, + type: addressPageType, + isLegacyDerivation: false, + accountIndex: accountIndex, + ); final address = BitcoinAddressRecord( getAddress(index: newAddressIndex, hd: hd, addressType: addressPageType), index: newAddressIndex, + accountIndex: accountIndex, isHidden: false, isLegacyDerivation: false, name: label, @@ -508,45 +599,54 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { void addBitcoinAddressTypes() { final lastP2wpkh = _addresses .where((addressRecord) => - _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh)) - .toList() - .last; - if (lastP2wpkh.address != address) { - addressesMap[lastP2wpkh.address] = 'P2WPKH'; - } else { - addressesMap[address] = 'Active - P2WPKH'; + _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wpkh)) + .lastOrNull; + if (lastP2wpkh != null) { + if (lastP2wpkh.address != address) { + addressesMap[lastP2wpkh.address] = 'P2WPKH'; + } else { + addressesMap[address] = 'Active - P2WPKH'; + } } - final lastP2pkh = _addresses.firstWhere( - (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh)); - if (lastP2pkh.address != address) { - addressesMap[lastP2pkh.address] = 'P2PKH'; - } else { - addressesMap[address] = 'Active - P2PKH'; + final lastP2pkh = _addresses.firstWhereOrNull( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2pkhAddressType.p2pkh)); + if (lastP2pkh != null) { + if (lastP2pkh.address != address) { + addressesMap[lastP2pkh.address] = 'P2PKH'; + } else { + addressesMap[address] = 'Active - P2PKH'; + } } - final lastP2sh = _addresses.firstWhere((addressRecord) => + final lastP2sh = _addresses.firstWhereOrNull((addressRecord) => _isUnusedReceiveAddressByType(addressRecord, P2shAddressType.p2wpkhInP2sh)); - if (lastP2sh.address != address) { - addressesMap[lastP2sh.address] = 'P2SH'; - } else { - addressesMap[address] = 'Active - P2SH'; + if (lastP2sh != null) { + if (lastP2sh.address != address) { + addressesMap[lastP2sh.address] = 'P2SH'; + } else { + addressesMap[address] = 'Active - P2SH'; + } } - final lastP2tr = _addresses.firstWhere( - (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2tr)); - if (lastP2tr.address != address) { - addressesMap[lastP2tr.address] = 'P2TR'; - } else { - addressesMap[address] = 'Active - P2TR'; + final lastP2tr = _addresses.firstWhereOrNull( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2tr)); + if (lastP2tr != null) { + if (lastP2tr.address != address) { + addressesMap[lastP2tr.address] = 'P2TR'; + } else { + addressesMap[address] = 'Active - P2TR'; + } } - final lastP2wsh = _addresses.firstWhere( - (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wsh)); - if (lastP2wsh.address != address) { - addressesMap[lastP2wsh.address] = 'P2WSH'; - } else { - addressesMap[address] = 'Active - P2WSH'; + final lastP2wsh = _addresses.firstWhereOrNull( + (addressRecord) => _isUnusedReceiveAddressByType(addressRecord, SegwitAddresType.p2wsh)); + if (lastP2wsh != null) { + if (lastP2wsh.address != address) { + addressesMap[lastP2wsh.address] = 'P2WSH'; + } else { + addressesMap[address] = 'Active - P2WSH'; + } } final firstSilentAddressRecord = silentAddresses.firstOrNull; @@ -678,15 +778,20 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { addressesByReceiveType.clear(); addressesByReceiveType.addAll( - _addresses.where(_isAddressPageTypeMatch).toList(), + _addresses + .where((addressRecord) => + _isCurrentAccountAddress(addressRecord) && _isAddressPageTypeMatch(addressRecord)) + .toList(), ); } @action void updateReceiveAddresses() { receiveAddresses.removeRange(0, receiveAddresses.length); - final newAddresses = - _addresses.where((addressRecord) => !addressRecord.isHidden && !addressRecord.isUsed); + final newAddresses = _addresses.where((addressRecord) => + _isCurrentAccountAddress(addressRecord) && + !addressRecord.isHidden && + !addressRecord.isUsed); receiveAddresses.addAll(newAddresses); } @@ -694,6 +799,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { void updateChangeAddresses() { changeAddresses.removeRange(0, changeAddresses.length); final newAddresses = _addresses.where((addressRecord) => + _isCurrentAccountAddress(addressRecord) && addressRecord.isHidden && !addressRecord.isUsed && // TODO: feature to change change address type. For now fixed to p2wpkh, the cheapest type @@ -708,6 +814,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { Future Function(BitcoinAddressRecord) getAddressHistory, { BitcoinAddressType type = SegwitAddresType.p2wpkh, required bool isLegacyDerivation, + int accountIndex = 0, }) async { final newAddresses = await _createNewAddresses( gap, @@ -715,6 +822,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { isHidden: isHidden, isLegacyDerivation: isLegacyDerivation, type: type, + accountIndex: accountIndex, ); addAddresses(newAddresses); @@ -730,6 +838,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { getAddressHistory, type: type, isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex, ); } } @@ -741,6 +850,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { Future> Function(List) getUsedAddresses, { BitcoinAddressType type = SegwitAddresType.p2wpkh, required bool isLegacyDerivation, + int accountIndex = 0, }) async { final newAddresses = await _createNewAddresses( gap, @@ -748,6 +858,7 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { isHidden: isHidden, type: type, isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex, ); addAddresses(newAddresses); @@ -768,24 +879,26 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { getUsedAddresses, type: type, isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex, ); return [...newAddresses, ...moreNewAddresses]; } - Future _generateInitialAddresses( - {BitcoinAddressType type = SegwitAddresType.p2wpkh, bool isLegacyDerivation = false}) async { - // Legacy derivation produces the same addresses as standard for these types. - // Don't generate a legacy set to avoid duplicates. - if (isLegacyDerivation && (type == SegwitAddresType.p2wpkh || type == SegwitAddresType.p2wsh)) { - return; - } + Future _generateInitialAddresses({ + BitcoinAddressType type = SegwitAddresType.p2wpkh, + bool isLegacyDerivation = false, + int accountIndex = 0, + }) async { + if (isLegacyDerivation && accountIndex != 0) return; var countOfReceiveAddresses = 0; var countOfHiddenAddresses = 0; _addresses.forEach((addr) { - if (addr.type == type && addr.isLegacyDerivation == isLegacyDerivation) { + if (addr.accountIndex == accountIndex && + addr.type == type && + addr.isLegacyDerivation == isLegacyDerivation) { if (addr.isHidden) { countOfHiddenAddresses += 1; } else { @@ -800,7 +913,9 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { startIndex: countOfReceiveAddresses, isHidden: false, type: type, - isLegacyDerivation: isLegacyDerivation); + isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex); + addAddresses(newAddresses); } @@ -810,31 +925,48 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { startIndex: countOfHiddenAddresses, isHidden: true, type: type, - isLegacyDerivation: isLegacyDerivation); + isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex); addAddresses(newAddresses); } } - Future> _createNewAddresses(int count, - {int startIndex = 0, - bool isHidden = false, - BitcoinAddressType? type, - bool isLegacyDerivation = false}) async { + Future> _createNewAddresses( + int count, { + int startIndex = 0, + bool isHidden = false, + BitcoinAddressType? type, + bool isLegacyDerivation = false, + int accountIndex = 0, + }) async { final list = []; for (var i = startIndex; i < count + startIndex; i++) { final addrType = type ?? addressPageType; - final hd = _hdFor(isHidden: isHidden, type: addrType, isLegacyDerivation: isLegacyDerivation); - final address = BitcoinAddressRecord( - await getAddressAsync(index: i, hd: hd, addressType: addrType), - index: i, - isHidden: isHidden, - isLegacyDerivation: isLegacyDerivation, - type: addrType, - network: network, - ); - list.add(address); + try { + final hd = _hdForAddressGeneration( + isHidden: isHidden, + type: addrType, + isLegacyDerivation: isLegacyDerivation, + accountIndex: accountIndex, + ); + + final address = BitcoinAddressRecord( + await getAddressAsync(index: i, hd: hd, addressType: addrType), + index: i, + isHidden: isHidden, + isLegacyDerivation: isLegacyDerivation, + type: addrType, + network: network, + accountIndex: accountIndex, + ); + list.add(address); + } on UnsupportedAddressTypeForAccountException catch (e) { + printV("_createNewAddresses: skipping index $i, type $addrType, " + "account $accountIndex: $e"); + return []; + } } return list; @@ -876,18 +1008,30 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { return; } - final mainHd = _hdFor( - isHidden: false, type: element.type, isLegacyDerivation: element.isLegacyDerivation); - final sideHd = _hdFor( - isHidden: true, type: element.type, isLegacyDerivation: element.isLegacyDerivation); - if (!element.isHidden && - element.address != - await getAddressAsync(index: element.index, hd: mainHd, addressType: element.type)) { - element.isHidden = true; - } else if (element.isHidden && - element.address != - await getAddressAsync(index: element.index, hd: sideHd, addressType: element.type)) { - element.isHidden = false; + try { + final mainHd = _hdForAddressGeneration( + isHidden: false, + type: element.type, + isLegacyDerivation: element.isLegacyDerivation, + accountIndex: element.accountIndex); + final sideHd = _hdForAddressGeneration( + isHidden: true, + type: element.type, + isLegacyDerivation: element.isLegacyDerivation, + accountIndex: element.accountIndex); + if (!element.isHidden && + element.address != + await getAddressAsync( + index: element.index, hd: mainHd, addressType: element.type)) { + element.isHidden = true; + } else if (element.isHidden && + element.address != + await getAddressAsync( + index: element.index, hd: sideHd, addressType: element.type)) { + element.isHidden = false; + } + } on UnsupportedAddressTypeForAccountException catch (e) { + printV("_validateAddresses: skipping ${element.address}: $e"); } }); } @@ -897,7 +1041,19 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { @action Future setAddressType(BitcoinAddressType type) async { - _addressPageType = type; + + final needsAccountScopedHd = + type is! LightningAddressType && type != SilentPaymentsAddresType.p2sp; + + BitcoinAddressType resolvedType = type; + if (needsAccountScopedHd) { + final accountIndex = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0; + if (!_isAddressTypeSupportedForAccount(type, accountIndex)) { + resolvedType = EXTRA_ACCOUNT_ADDRESS_TYPES.first; + } + } + + _addressPageType = resolvedType; updateAddressesByMatch(); walletInfo.addressPageType = addressPageType.toString(); await walletInfo.save(); @@ -909,8 +1065,15 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { bool _isAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) => addr.type == type; - bool _isUnusedReceiveAddressByType(BitcoinAddressRecord addr, BitcoinAddressType type) => - !addr.isHidden && !addr.isUsed && addr.type == type; + bool _isUnusedReceiveAddressByType( + BitcoinAddressRecord addressRecord, + BitcoinAddressType type, + ) { + return _isCurrentAccountAddress(addressRecord) && + !addressRecord.isHidden && + !addressRecord.isUsed && + addressRecord.type == type; + } @action void deleteSilentPaymentAddress(String address) { @@ -921,16 +1084,84 @@ abstract class ElectrumWalletAddressesBase extends WalletAddresses with Store { updateAddressesByMatch(); } - Bip32Slip10Secp256k1 _hdFor({ + // Remove all addresses associated with a specific account index. + @action + Future removeAddressesForAccount(int accountIndex) async { + _addresses.removeWhere((addr) => addr.accountIndex == accountIndex); + updateAddressesByMatch(); + updateReceiveAddresses(); + updateChangeAddresses(); + await updateAddressesInBox(); + } + + + bool _isAddressTypeSupportedForAccount(BitcoinAddressType type, int accountIndex) { + final effectiveAccountIndex = walletInfo.type == WalletType.bitcoin ? accountIndex : 0; + return mainHdByTypeAndAccount[effectiveAccountIndex]?.containsKey(type) ?? false; + } + + static BitcoinAddressType _resolveInitialAddressPageType({ + required BitcoinAddressType? initialAddressPageType, + required WalletInfo walletInfo, + required Map> mainHdByTypeAndAccount, + required int currentAccountIndex, + }) { + final resolved = initialAddressPageType ?? + (walletInfo.addressPageType != null + ? walletInfo.addressPageType == LightningAddressType.p2l.value + ? LightningAddressType.p2l + : BitcoinAddressType.fromValue(walletInfo.addressPageType!) + : SegwitAddresType.p2wpkh); + + // Non-account-scoped address types (Lightning, Silent Payments) don't need + // an HD map entry. + if (resolved is LightningAddressType || resolved == SilentPaymentsAddresType.p2sp) { + return resolved; + } + + final effectiveAccountIndex = walletInfo.type == WalletType.bitcoin ? currentAccountIndex : 0; + + final isValid = mainHdByTypeAndAccount[effectiveAccountIndex]?.containsKey(resolved) ?? false; + + if (!isValid) { + return EXTRA_ACCOUNT_ADDRESS_TYPES.first; + } + + return resolved; + } + + Bip32Slip10Secp256k1 _hdForAddressGeneration({ required bool isHidden, required BitcoinAddressType type, required bool isLegacyDerivation, + int accountIndex = 0, }) { - if (isLegacyDerivation) return isHidden ? legacySideHd : legacyMainHd; + if (isLegacyDerivation) { + if (accountIndex != 0) { + throw UnsupportedAddressTypeForAccountException( + "Legacy derivation is only supported for the first account"); + } + + return isHidden ? legacySideHd : legacyMainHd; + } + + final effectiveAccountIndex = walletInfo.type == WalletType.bitcoin ? accountIndex : 0; + + final map = isHidden + ? sideHdByTypeAndAccount[effectiveAccountIndex] + : mainHdByTypeAndAccount[effectiveAccountIndex]; + + if (map == null) { + throw UnsupportedAddressTypeForAccountException( + "HD map not found for account $effectiveAccountIndex"); + } - final map = isHidden ? sideHdByType : mainHdByType; final hd = map[type]; - if (hd == null) throw Exception("HD not found for type $type"); + if (hd == null) { + throw UnsupportedAddressTypeForAccountException( + "HD not found for account $accountIndex type $type"); + } + return hd; } diff --git a/cw_bitcoin/lib/litecoin_wallet.dart b/cw_bitcoin/lib/litecoin_wallet.dart index fbd946233..d07f4b8f6 100644 --- a/cw_bitcoin/lib/litecoin_wallet.dart +++ b/cw_bitcoin/lib/litecoin_wallet.dart @@ -115,8 +115,10 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, initialMwebAddresses: initialMwebAddresses, - mainHdByType: mainHdByType, - sideHdByType: sideHdByType, + mainHdByTypeAndAccount: mainHdByTypeAndAccount, + sideHdByTypeAndAccount: sideHdByTypeAndAccount, + accountIndexes: [currentAccountIndex], + currentAccountIndex: currentAccountIndex, legacyMainHd: mainHd, legacySideHd: sideHd, network: network, @@ -1365,6 +1367,7 @@ abstract class LitecoinWalletBase extends ElectrumWallet with Store { final tx = PendingBitcoinTransaction( btcTx, type, + accountIndex: 0, electrumClient: electrumClient, amount: Money.zero(currency), fee: Money.fromInt(resp.fee.toInt(), currency), diff --git a/cw_bitcoin/lib/litecoin_wallet_addresses.dart b/cw_bitcoin/lib/litecoin_wallet_addresses.dart index fe85cd782..3c66dd63d 100644 --- a/cw_bitcoin/lib/litecoin_wallet_addresses.dart +++ b/cw_bitcoin/lib/litecoin_wallet_addresses.dart @@ -25,8 +25,10 @@ class LitecoinWalletAddresses = LitecoinWalletAddressesBase with _$LitecoinWalle abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with Store { LitecoinWalletAddressesBase( WalletInfo walletInfo, { - required super.mainHdByType, - required super.sideHdByType, + required super.mainHdByTypeAndAccount, + required super.sideHdByTypeAndAccount, + required super.accountIndexes, + required super.currentAccountIndex, required super.legacyMainHd, required super.legacySideHd, required super.network, @@ -66,9 +68,9 @@ abstract class LitecoinWalletAddressesBase extends ElectrumWalletAddresses with List.filled(32, 0); @override - Future init() async { + Future init({List accountIndexes = const []}) async { if (!isHardwareWallet) await initMwebAddresses(); - await super.init(); + await super.init(accountIndexes: accountIndexes); } @computed diff --git a/cw_bitcoin/lib/pending_bitcoin_transaction.dart b/cw_bitcoin/lib/pending_bitcoin_transaction.dart index c92e80780..50c96311f 100644 --- a/cw_bitcoin/lib/pending_bitcoin_transaction.dart +++ b/cw_bitcoin/lib/pending_bitcoin_transaction.dart @@ -21,6 +21,7 @@ class PendingBitcoinTransaction with PendingTransaction { PendingBitcoinTransaction( this._tx, this.type, { + required this.accountIndex, required this.electrumClient, required this.amount, required this.fee, @@ -40,6 +41,7 @@ class PendingBitcoinTransaction with PendingTransaction { final WalletType type; final BtcTransaction _tx; + final int? accountIndex; final ElectrumClient electrumClient; final String feeRate; final BasedUtxoNetwork? network; @@ -182,6 +184,7 @@ class PendingBitcoinTransaction with PendingTransaction { isPending: true, isReplaced: false, confirmations: 0, + accountIndex: accountIndex, inputAddresses: _tx.inputs.map((input) => input.txId).toList(), outputAddresses: outputAddresses, fee: fee, diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart index 18b9ea1c9..f05aaa845 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet.dart @@ -56,8 +56,10 @@ abstract class BitcoinCashWalletBase extends ElectrumWallet with Store { initialAddresses: initialAddresses, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, - mainHdByType: mainHdByType, - sideHdByType: sideHdByType, + mainHdByTypeAndAccount: mainHdByTypeAndAccount, + sideHdByTypeAndAccount: sideHdByTypeAndAccount, + accountIndexes: [currentAccountIndex], + currentAccountIndex: currentAccountIndex, legacyMainHd: mainHd, legacySideHd: sideHd, network: network, diff --git a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart index d45f5293f..c6451c729 100644 --- a/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart +++ b/cw_bitcoin_cash/lib/src/bitcoin_cash_wallet_addresses.dart @@ -13,8 +13,10 @@ class BitcoinCashWalletAddresses = BitcoinCashWalletAddressesBase with _$Bitcoin abstract class BitcoinCashWalletAddressesBase extends ElectrumWalletAddresses with Store { BitcoinCashWalletAddressesBase( WalletInfo walletInfo, { - required super.mainHdByType, - required super.sideHdByType, + required super.mainHdByTypeAndAccount, + required super.sideHdByTypeAndAccount, + required super.accountIndexes, + required super.currentAccountIndex, required super.legacyMainHd, required super.legacySideHd, required super.network, diff --git a/cw_core/lib/account.dart b/cw_core/lib/account.dart index 3061cfe23..3f899ddcc 100644 --- a/cw_core/lib/account.dart +++ b/cw_core/lib/account.dart @@ -1,12 +1,32 @@ class Account { - Account({required this.id, required this.label, this.balance}); + Account({ + required this.id, + required this.label, + String? balance, + }) : balance = balance ?? "0.00"; - Account.fromMap(Map map) - : this.id = map['id'] == null ? 0 : int.parse(map['id'] as String), - this.label = (map['label'] ?? '') as String, - this.balance = (map['balance'] ?? '0.00') as String; + Account.fromMap(Map map) + : id = map["id"] is int ? map["id"] as int : int.tryParse(map["id"]?.toString() ?? "") ?? 0, + label = (map["label"] ?? "") as String, + balance = (map["balance"] ?? "0.00") as String; final int id; final String label; - final String? balance; + final String balance; + + Account copyWith({ + int? id, + String? label, + String? balance, + }) => Account( + id: id ?? this.id, + label: label ?? this.label, + balance: balance ?? this.balance, + ); + + Map toMap() => { + "id": id.toString(), + "label": label, + "balance": balance, + }; } diff --git a/cw_core/lib/account_list.dart b/cw_core/lib/account_list.dart deleted file mode 100644 index 49b931211..000000000 --- a/cw_core/lib/account_list.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:mobx/mobx.dart'; - -abstract class AccountList { - ObservableList get accounts; - - void update(); - - List getAll(); - - Future addAccount({required String label}); - - Future setLabelAccount({required int accountIndex, required String label}); - - void refresh(); -} diff --git a/cw_core/lib/balance_card_style_settings.dart b/cw_core/lib/balance_card_style_settings.dart index 2c874ca1d..6e376b33f 100644 --- a/cw_core/lib/balance_card_style_settings.dart +++ b/cw_core/lib/balance_card_style_settings.dart @@ -98,6 +98,6 @@ class BalanceCardStyleSettings { } Future insert() async { - db!.insert(tableName, toJson(), conflictAlgorithm: ConflictAlgorithm.replace); + await db!.insert(tableName, toJson(), conflictAlgorithm: ConflictAlgorithm.replace); } } diff --git a/cw_core/lib/db/sqlite.dart b/cw_core/lib/db/sqlite.dart index eb3784983..77c38b599 100644 --- a/cw_core/lib/db/sqlite.dart +++ b/cw_core/lib/db/sqlite.dart @@ -3,6 +3,7 @@ import "dart:io"; import "package:cw_core/db/sqlite_debug.dart"; import "package:cw_core/root_dir.dart"; import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/wallet_type.dart"; import "package:flutter/foundation.dart"; import "package:path/path.dart" as p; import "package:sqflite_common_ffi/sqflite_ffi.dart"; @@ -10,11 +11,11 @@ import "package:sqflite_common_ffi/sqflite_ffi.dart"; Database? db; Future _addColumnIfNotExists( - Database db, { - required String table, - required String column, - required String definition, -}) async { + Database db, { + required String table, + required String column, + required String definition, + }) async { final result = await db.rawQuery("PRAGMA table_info($table)"); final columnExists = result.any((row) => row["name"] == column); @@ -65,7 +66,7 @@ Future _initDb({String? pathOverride}) async { await db?.close(); db = await openDatabase( dbFile.path, - version: 13, + version: 14, onUpgrade: (db, oldVersion, newVersion) async { printV("migrating: $oldVersion, $newVersion"); if (oldVersion <= 1) { @@ -100,77 +101,101 @@ CREATE TABLE IF NOT EXISTS BalanceCardStyleSettings ( definition: "BOOLEAN DEFAULT FALSE", ); - await _addColumnIfNotExists( - db, - table: 'BalanceCardStyleSettings', - column: 'cardOrder', - definition: 'INTEGER DEFAULT 0', - ); - } - if (oldVersion <= 3) { - await _addColumnIfNotExists(db, - table: "WalletInfo", column: "showCombinedBalance", definition: "BOOLEAN DEFAULT TRUE"); - // null - primary token (eth, sol etc) - // not null - address of fav token - // if address doesn't correspond to a valid token, fallback to primary token - await _addColumnIfNotExists(db, - table: "WalletInfo", column: "favoriteTokenAddress", definition: "TEXT DEFAULT NULL"); - } + await _addColumnIfNotExists( + db, + table: 'BalanceCardStyleSettings', + column: 'cardOrder', + definition: 'INTEGER DEFAULT 0', + ); + } + if (oldVersion <= 3) { + await _addColumnIfNotExists(db, + table: "WalletInfo", column: "showCombinedBalance", definition: "BOOLEAN DEFAULT TRUE"); + // null - primary token (eth, sol etc) + // not null - address of fav token + // if address doesn't correspond to a valid token, fallback to primary token + await _addColumnIfNotExists(db, + table: "WalletInfo", column: "favoriteTokenAddress", definition: "TEXT DEFAULT NULL"); + } - if (oldVersion <= 4) { - await _createBridgeTransferTable(db); - } + if (oldVersion <= 4) { + await _createBridgeTransferTable(db); + } - if (oldVersion <= 5) { - await _createTradeTable(db); - } - if (oldVersion <= 6) { - await _addColumnIfNotExists( - db, - table: 'Trade', - column: 'toAddressExtraId', - definition: 'TEXT', - ); - } - if (oldVersion <= 7) { - await _createNodeTable(db); - } - if (oldVersion <= 8) { - await _addColumnIfNotExists( - db, - table: 'BalanceCardStyleSettings', - column: 'iconStyleIndex', - definition: 'INTEGER DEFAULT 0', - ); - await _addColumnIfNotExists( - db, - table: 'BalanceCardStyleSettings', - column: 'isGradientOnly', - definition: 'BOOLEAN DEFAULT FALSE', - ); - } - if (oldVersion <= 9) { - await _createErc20TokenTable(db); - await _createSplTokenTable(db); - await _createTronTokenTable(db); - } + if (oldVersion <= 5) { + await _createTradeTable(db); + } + if (oldVersion <= 6) { + await _addColumnIfNotExists( + db, + table: 'Trade', + column: 'toAddressExtraId', + definition: 'TEXT', + ); + } + if (oldVersion <= 7) { + await _createNodeTable(db); + } + if (oldVersion <= 8) { + await _addColumnIfNotExists( + db, + table: 'BalanceCardStyleSettings', + column: 'iconStyleIndex', + definition: 'INTEGER DEFAULT 0', + ); + await _addColumnIfNotExists( + db, + table: 'BalanceCardStyleSettings', + column: 'isGradientOnly', + definition: 'BOOLEAN DEFAULT FALSE', + ); + } + if (oldVersion <= 9) { + await _createErc20TokenTable(db); + await _createSplTokenTable(db); + await _createTronTokenTable(db); + } + if (oldVersion <= 10) { + await _createImportedNFTTable(db); + } + if (oldVersion <= 11) { + await _addColumnIfNotExists( + db, + table: "WalletInfo", + column: "showSeedBackupReminder", + definition: "BOOLEAN DEFAULT FALSE", + ); + } + if (oldVersion <= 12) { + await _createChartsTables(db); + } - if (oldVersion <= 10) { - await _createImportedNFTTable(db); - } - if (oldVersion <= 11) { - await _addColumnIfNotExists( - db, - table: "WalletInfo", - column: "showSeedBackupReminder", - definition: "BOOLEAN DEFAULT FALSE", - ); - } - if(oldVersion <= 12) { - await _createChartsTables(db); - } - }, onCreate: (Database db, int version) async { - await db.execute(''' + if (oldVersion <= 13) { + await _createWalletInfoAccountTable(db); + await _addColumnIfNotExists( + db, + table: "WalletInfo", + column: "accountDiscoveryLimit", + definition: "INTEGER DEFAULT NULL", + ); + await _addColumnIfNotExists( + db, + table: "WalletInfo", + column: "isMultiAccountsEnabled", + definition: "INTEGER DEFAULT NULL", + ); + await _addColumnIfNotExists( + db, + table: "WalletInfo", + column: "currentAccountIndex", + definition: "INTEGER NOT NULL DEFAULT 0", + ); + + await _migrateBitcoinCardStylesForAccounts(db); + } + }, + onCreate: (Database db, int version) async { + await db.execute(''' CREATE TABLE WalletInfo ( walletInfoId INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, id TEXT NOT NULL, @@ -193,9 +218,12 @@ CREATE TABLE WalletInfo ( hashedWalletIdentifier TEXT, isNonSeedWallet INTEGER DEFAULT (0) NOT NULL, sortOrder INTEGER DEFAULT (0) NOT NULL, + currentAccountIndex INTEGER NOT NULL DEFAULT 0, receiveInfoboxDismissed BOOLEAN DEFAULT FALSE, showCombinedBalance BOOLEAN DEFAULT TRUE, favoriteTokenAddress TEXT DEFAULT NULL, + accountDiscoveryLimit INTEGER DEFAULT NULL, + isMultiAccountsEnabled INTEGER DEFAULT NULL, showSeedBackupReminder BOOLEAN DEFAULT FALSE ); '''); @@ -269,8 +297,8 @@ CREATE TABLE BalanceCardStyleSettings ( await _createErc20TokenTable(db); await _createSplTokenTable(db); await _createTronTokenTable(db); - await _createImportedNFTTable(db); - + await _createImportedNFTTable(db); + await _createWalletInfoAccountTable(db); }, ); } @@ -356,6 +384,43 @@ ON Trade (id); """); } +Future _createWalletInfoAccountTable(Database db) async { + await db.execute(""" +CREATE TABLE IF NOT EXISTS WalletInfoAccount ( + walletInfoId INTEGER NOT NULL, + accountIndex INTEGER NOT NULL, + label TEXT NOT NULL, + PRIMARY KEY (walletInfoId, accountIndex), + CONSTRAINT WalletInfoAccount_WalletInfo_FK FOREIGN KEY (walletInfoId) REFERENCES WalletInfo(walletInfoId) +); +"""); + + await db.execute(""" +CREATE INDEX IF NOT EXISTS idx_walletinfoaccount_walletinfoid +ON WalletInfoAccount(walletInfoId); +"""); +} + + +Future _migrateBitcoinCardStylesForAccounts(Database db) async { + const bitcoinWallets = 'SELECT walletInfoId FROM WalletInfo WHERE "type" = ?'; + final btc = WalletType.bitcoin.index; + + // Lightning: 0 -> -2 (first, so accountIndex 0 is free for the Bitcoin card) + await db.rawUpdate( + "UPDATE OR REPLACE BalanceCardStyleSettings SET accountIndex = -2 " + "WHERE accountIndex = 0 AND walletInfoId IN ($bitcoinWallets)", + [btc], + ); + + // Bitcoin: -1 -> 0 (primary account, first in order) + await db.rawUpdate( + "UPDATE OR REPLACE BalanceCardStyleSettings SET accountIndex = 0, cardOrder = 0 " + "WHERE accountIndex = -1 AND walletInfoId IN ($bitcoinWallets)", + [btc], + ); +} + Future> dumpDb() async { try { return await _dumpDb(); diff --git a/cw_core/lib/wallet_base.dart b/cw_core/lib/wallet_base.dart index 024095b80..5e0fa7787 100644 --- a/cw_core/lib/wallet_base.dart +++ b/cw_core/lib/wallet_base.dart @@ -142,6 +142,10 @@ abstract class WalletBase false; bool get hasSilentPaymentsScanning => false; + bool get hasAccountsSupport => false; + bool get hasNativeAccounts => false; + bool get canToggleMultiAccounts => hasAccountsSupport && !hasNativeAccounts; + // hardware wallet - bitbox, ledger, trezor. // we also have airgap wallets but those can't sign messages bool get canSignMessages => walletInfo.hardwareWalletType == null || walletInfo.isHardwareWallet; diff --git a/cw_core/lib/wallet_info.dart b/cw_core/lib/wallet_info.dart index 59c87dd55..6cb883f77 100644 --- a/cw_core/lib/wallet_info.dart +++ b/cw_core/lib/wallet_info.dart @@ -244,6 +244,68 @@ class WalletInfoAddress { } } +class WalletInfoAccount { + WalletInfoAccount({ + required this.walletInfoId, + required this.accountIndex, + required this.label, + }); + + factory WalletInfoAccount.fromJson(Map json) => WalletInfoAccount( + walletInfoId: json["walletInfoId"] as int, + accountIndex: json["accountIndex"] as int, + label: json["label"] as String, + ); + + int walletInfoId; + int accountIndex; + String label; + + static String get tableName => "walletInfoAccount"; + + static Future> selectList(int walletInfoId) async { + final query = await db!.query( + tableName, + where: "walletInfoId = ?", + whereArgs: [walletInfoId], + orderBy: "accountIndex ASC", + ); + + return List.generate(query.length, (index) => WalletInfoAccount.fromJson(query[index])); + } + + static Future deleteByWalletInfoId(int walletInfoId) async => + await db!.delete(tableName, where: "walletInfoId = ?", whereArgs: [walletInfoId]); + + static Future insertOrUpdate({ + required int walletInfoId, + required int accountIndex, + required String label, + }) async => + await db!.transaction((txn) async { + final updated = await txn.update( + tableName, + {"label": label}, + where: "walletInfoId = ? AND accountIndex = ?", + whereArgs: [walletInfoId, accountIndex], + ); + + if (updated > 0) return updated; + + return await txn.insert(tableName, { + "walletInfoId": walletInfoId, + "accountIndex": accountIndex, + "label": label, + }); + }); + + Map toJson() => { + "walletInfoId": walletInfoId, + "accountIndex": accountIndex, + "label": label, + }; +} + class DerivationInfo { DerivationInfo({ this.id = 0, @@ -346,6 +408,7 @@ class WalletInfo { this.hashedWalletIdentifier, this.isNonSeedWallet, this.sortOrder, + this.currentAccountIndex, this.addressPageType, this.receiveInfoboxDismissed, this.showCombinedBalance, @@ -353,53 +416,65 @@ class WalletInfo { this.showSeedBackupReminder) : _yatLastUsedAddressController = StreamController.broadcast(); - factory WalletInfo.external( - {required String id, - required String name, - required WalletType type, - required bool isRecovery, - required int restoreHeight, - required DateTime date, - required String dirPath, - required String path, - required String address, - bool? showIntroCakePayCard, - String yatEid = '', - String yatLastUsedAddressRaw = '', - int? derivationInfoId, - HardwareWalletType? hardwareWalletType, - String? parentAddress, - String? hashedWalletIdentifier, - bool? isNonSeedWallet, - int? sortOrder, - bool? receiveInfoboxDismissed, - bool? showCombinedBalance, - String? favoriteTokenAddress}) { - return WalletInfo( - 0, - id, - name, - type, - isRecovery, - restoreHeight, - date.millisecondsSinceEpoch, - dirPath, - path, - address, - yatEid, - yatLastUsedAddressRaw, - showIntroCakePayCard, - derivationInfoId ?? -1, - hardwareWalletType, - parentAddress, - hashedWalletIdentifier, - isNonSeedWallet ?? false, - sortOrder ?? 0, - null, - receiveInfoboxDismissed ?? false, - showCombinedBalance ?? true, - favoriteTokenAddress, - false); + factory WalletInfo.external({ + required String id, + required String name, + required WalletType type, + required bool isRecovery, + required int restoreHeight, + required DateTime date, + required String dirPath, + required String path, + required String address, + bool? showIntroCakePayCard, + String yatEid = "", + String yatLastUsedAddressRaw = "", + int? derivationInfoId, + HardwareWalletType? hardwareWalletType, + String? parentAddress, + String? hashedWalletIdentifier, + bool? isNonSeedWallet, + int? sortOrder, + int currentAccountIndex = 0, + bool? receiveInfoboxDismissed, + bool? showCombinedBalance, + String? favoriteTokenAddress, + }) { + final wi = WalletInfo( + 0, + id, + name, + type, + isRecovery, + restoreHeight, + date.millisecondsSinceEpoch, + dirPath, + path, + address, + yatEid.isNotEmpty ? yatEid : null, + yatLastUsedAddressRaw.isNotEmpty ? yatLastUsedAddressRaw : null, + showIntroCakePayCard ?? false, + derivationInfoId ?? -1, + hardwareWalletType, + parentAddress, + hashedWalletIdentifier, + isNonSeedWallet ?? false, + sortOrder ?? 0, + currentAccountIndex, + null, + receiveInfoboxDismissed ?? false, + showCombinedBalance ?? true, + favoriteTokenAddress?.isNotEmpty == true ? favoriteTokenAddress : null, + false, + ); + + if (type == WalletType.bitcoin) { + wi.isMultiAccountsEnabled = false; + } else if (type == WalletType.monero || type == WalletType.wownero) { + wi.isMultiAccountsEnabled = true; + } + + return wi; } static String get tableName => 'walletInfo'; @@ -408,6 +483,8 @@ class WalletInfo { int internalId; + int currentAccountIndex; + String id; String name; WalletType type; @@ -506,8 +583,76 @@ class WalletInfo { await WalletInfoAddress.insert(internalId, type, address); } + Future> getAccounts() async { + var accounts = await WalletInfoAccount.selectList(internalId); + + if (accounts.isEmpty) { + await WalletInfoAccount.insertOrUpdate( + walletInfoId: internalId, + accountIndex: 0, + label: "Primary account", + ); + + accounts = await WalletInfoAccount.selectList(internalId); + } + + if (!accounts.any((a) => a.accountIndex == currentAccountIndex)) { + currentAccountIndex = accounts.first.accountIndex; + await save(); + } + + return accounts; + } + + Future setSelectedAccount(int accountIndex) async { + currentAccountIndex = accountIndex; + await save(); + } + + Future setAccounts(List accounts) async { + await WalletInfoAccount.deleteByWalletInfoId(internalId); + + for (final account in accounts) { + await WalletInfoAccount.insertOrUpdate( + walletInfoId: internalId, + accountIndex: account.accountIndex, + label: account.label, + ); + } + + if (accounts.isNotEmpty && !accounts.any((a) => a.accountIndex == currentAccountIndex)) { + currentAccountIndex = accounts.first.accountIndex; + await save(); + } + } + + Future addAccount({ + required int accountIndex, + required String label, + }) async { + await WalletInfoAccount.insertOrUpdate( + walletInfoId: internalId, + accountIndex: accountIndex, + label: label, + ); + } + + Future renameAccount({ + required int accountIndex, + required String label, + }) async { + await WalletInfoAccount.insertOrUpdate( + walletInfoId: internalId, + accountIndex: accountIndex, + label: label, + ); + } + String? addressPageType; String? network; + int? accountDiscoveryLimit; + bool? isMultiAccountsEnabled; + int derivationInfoId; DerivationInfo? _derivationInfo; @@ -586,12 +731,16 @@ class WalletInfo { "hashedWalletIdentifier": hashedWalletIdentifier, "isNonSeedWallet": isNonSeedWallet ? 1 : 0, "sortOrder": sortOrder, + "currentAccountIndex": currentAccountIndex, "addressPageType": addressPageType, "receiveInfoboxDismissed": receiveInfoboxDismissed ? 1 : 0, "showCombinedBalance": showCombinedBalance ? 1 : 0, "favoriteTokenAddress": favoriteTokenAddress, "showSeedBackupReminder": showSeedBackupReminder ? 1 : 0, "network": network, + "accountDiscoveryLimit": accountDiscoveryLimit, + "isMultiAccountsEnabled": + isMultiAccountsEnabled == null ? null : (isMultiAccountsEnabled! ? 1 : 0), }; factory WalletInfo.fromJson(Map json) { @@ -617,12 +766,17 @@ class WalletInfo { json['hashedWalletIdentifier'] as String?, (json['isNonSeedWallet'] as int) == 1, json['sortOrder'] as int? ?? 0, + json['currentAccountIndex'] as int? ?? 0, json['addressPageType'] as String? ?? null, json['receiveInfoboxDismissed'] != 0, json["showCombinedBalance"] != 0, json["favoriteTokenAddress"] as String? ?? null, json["showSeedBackupReminder"] == 1); info.network = json['network'] as String?; + info.accountDiscoveryLimit = json['accountDiscoveryLimit'] as int?; + final rawIsMultiAccountsEnabled = json['isMultiAccountsEnabled']; + info.isMultiAccountsEnabled = + rawIsMultiAccountsEnabled == null ? null : (rawIsMultiAccountsEnabled as int) == 1; return info; } @@ -670,6 +824,11 @@ class WalletInfo { await save(); } + Future setAccountDiscoveryLimit(int limit) async { + accountDiscoveryLimit = limit; + await save(); + } + Future clearSeedBackupReminder() async { final previous = showSeedBackupReminder; showSeedBackupReminder = false; diff --git a/cw_core/lib/wallet_info_legacy.dart b/cw_core/lib/wallet_info_legacy.dart index 6f9ff9c94..16d364003 100644 --- a/cw_core/lib/wallet_info_legacy.dart +++ b/cw_core/lib/wallet_info_legacy.dart @@ -271,6 +271,7 @@ class WalletInfo extends HiveObject { hashedWalletIdentifier, isNonSeedWallet, 0, + 0, addressPageType, false, true, diff --git a/cw_dogecoin/lib/src/dogecoin_wallet.dart b/cw_dogecoin/lib/src/dogecoin_wallet.dart index 5b952bd55..41dd4047a 100644 --- a/cw_dogecoin/lib/src/dogecoin_wallet.dart +++ b/cw_dogecoin/lib/src/dogecoin_wallet.dart @@ -54,8 +54,10 @@ abstract class DogeCoinWalletBase extends ElectrumWallet with Store { initialAddresses: initialAddresses, initialRegularAddressIndex: initialRegularAddressIndex, initialChangeAddressIndex: initialChangeAddressIndex, - mainHdByType: mainHdByType, - sideHdByType: sideHdByType, + mainHdByTypeAndAccount: mainHdByTypeAndAccount, + sideHdByTypeAndAccount: sideHdByTypeAndAccount, + accountIndexes: [currentAccountIndex], + currentAccountIndex: currentAccountIndex, legacyMainHd: mainHd, legacySideHd: sideHd, network: network, diff --git a/cw_dogecoin/lib/src/dogecoin_wallet_addresses.dart b/cw_dogecoin/lib/src/dogecoin_wallet_addresses.dart index 5fdd5ac17..55ceb6b22 100644 --- a/cw_dogecoin/lib/src/dogecoin_wallet_addresses.dart +++ b/cw_dogecoin/lib/src/dogecoin_wallet_addresses.dart @@ -13,8 +13,10 @@ class DogeCoinWalletAddresses = DogeCoinWalletAddressesBase with _$DogeCoinWalle abstract class DogeCoinWalletAddressesBase extends ElectrumWalletAddresses with Store { DogeCoinWalletAddressesBase( WalletInfo walletInfo, { - required super.mainHdByType, - required super.sideHdByType, + required super.mainHdByTypeAndAccount, + required super.sideHdByTypeAndAccount, + required super.accountIndexes, + required super.currentAccountIndex, required super.legacyMainHd, required super.legacySideHd, required super.network, diff --git a/cw_monero/lib/monero_wallet.dart b/cw_monero/lib/monero_wallet.dart index fd86e82b2..4da29bdfc 100644 --- a/cw_monero/lib/monero_wallet.dart +++ b/cw_monero/lib/monero_wallet.dart @@ -120,6 +120,12 @@ abstract class MoneroWalletBase @override bool get hasRescan => true; + @override + bool get hasAccountsSupport => true; + + @override + bool get hasNativeAccounts => true; + @override String get seed => monero_wallet.getSeed(); String seedLegacy(String? language) => monero_wallet.getSeedLegacy(language); diff --git a/cw_wownero/lib/wownero_wallet.dart b/cw_wownero/lib/wownero_wallet.dart index 8c5eab714..b619c16bc 100644 --- a/cw_wownero/lib/wownero_wallet.dart +++ b/cw_wownero/lib/wownero_wallet.dart @@ -137,6 +137,9 @@ abstract class WowneroWalletBase @override bool get hasRescan => true; + @override + bool get hasAccountsSupport => true; + @override MoneroWalletKeys get keys => MoneroWalletKeys( primaryAddress: wownero_wallet.getAddress(accountIndex: 0, addressIndex: 0), diff --git a/lib/bitcoin/cw_bitcoin.dart b/lib/bitcoin/cw_bitcoin.dart index 4e4789e57..a16770b37 100644 --- a/lib/bitcoin/cw_bitcoin.dart +++ b/lib/bitcoin/cw_bitcoin.dart @@ -256,7 +256,10 @@ class CWBitcoin extends Bitcoin { List getUnspents(Object wallet, {UnspentCoinType coinTypeToSpendFrom = UnspentCoinType.any}) { final bitcoinWallet = wallet as ElectrumWallet; - return bitcoinWallet.unspentCoins.where((element) { + + final coins = bitcoinWallet.unspentCoinsForCurrentAccount; + + return coins.where((element) { switch (coinTypeToSpendFrom) { case UnspentCoinType.mweb: return element.bitcoinAddressRecord.type == SegwitAddresType.mweb; @@ -919,4 +922,36 @@ class CWBitcoin extends Bitcoin { } return null; } + + @override + ElectrumBalance balanceForAccount(Object wallet, int accountIndex) { + final bitcoinWallet = wallet as ElectrumWallet; + return bitcoinWallet.balanceForAccount(accountIndex); + } + + @override + Map accountBalancesSnapshot(Object wallet) { + final bitcoinWallet = wallet as ElectrumWallet; + return Map.of(bitcoinWallet.accountBalances); + } + + @override + List getCurrentAccountBitcoinTransactions(Object wallet) => + (wallet as ElectrumWallet).currentAccountBitcoinTransactions; + + @override + Future setCurrentAccount(Object wallet, int accountIndex) async { + final bitcoinWallet = wallet as ElectrumWallet; + await bitcoinWallet.setCurrentAccount(accountIndex); + } + + @override + Future getCurrentAccount(Object wallet) async { + final electrumWallet = wallet as ElectrumWallet; + final acc = await electrumWallet.loadCurrentAccount(); + return Account( + id: acc.accountIndex, + label: acc.label, + balance: electrumWallet.balanceForAccount(acc.accountIndex).confirmed.toString()); + } } diff --git a/lib/di.dart b/lib/di.dart index 649b19a00..bd1e79b24 100644 --- a/lib/di.dart +++ b/lib/di.dart @@ -55,7 +55,6 @@ import 'package:cake_wallet/nano/nano.dart'; import 'package:cake_wallet/new-ui/model/charts/price_store.dart'; import 'package:cake_wallet/new-ui/new_dashboard.dart'; import 'package:cake_wallet/new-ui/pages/about_page.dart'; -import 'package:cake_wallet/new-ui/pages/account_customizer.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_amount_page.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_receiving_wallet_page.dart'; @@ -131,6 +130,7 @@ import 'package:cake_wallet/src/screens/settings/privacy_page.dart'; import 'package:cake_wallet/src/screens/settings/security_backup_page.dart'; import 'package:cake_wallet/src/screens/settings/silent_payments_logs_page.dart'; import 'package:cake_wallet/src/screens/settings/silent_payments_settings.dart'; +import 'package:cake_wallet/new-ui/pages/wallet_accounts_page.dart'; import 'package:cake_wallet/src/screens/setup_2fa/modify_2fa_page.dart'; import 'package:cake_wallet/src/screens/setup_2fa/setup_2fa.dart'; import 'package:cake_wallet/src/screens/setup_2fa/setup_2fa_enter_code_page.dart'; @@ -214,11 +214,14 @@ import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart'; import 'package:cake_wallet/view_model/hardware_wallet/trezor_connect_view_model.dart'; import 'package:cake_wallet/view_model/integrations/deuro_view_model.dart'; import 'package:cake_wallet/view_model/link_view_model.dart'; -import 'package:cake_wallet/view_model/monero_account_list/account_list_item.dart'; -import 'package:cake_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart'; -import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart'; -import 'package:cake_wallet/view_model/nano_account_list/nano_account_edit_or_create_view_model.dart'; -import 'package:cake_wallet/view_model/nano_account_list/nano_account_list_view_model.dart'; +import "package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart"; +import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_edit_or_create_view_model.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_list_view_model.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/nano_account_list/nano_account_edit_or_create_view_model.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/nano_account_list/nano_account_list_view_model.dart'; import 'package:cake_wallet/view_model/node_list/node_create_or_edit_view_model.dart'; import 'package:cake_wallet/view_model/node_list/node_list_view_model.dart'; import 'package:cake_wallet/view_model/order_details_view_model.dart'; @@ -248,6 +251,7 @@ import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_details_view_ import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_item.dart'; import 'package:cake_wallet/view_model/unspent_coins/unspent_coins_list_view_model.dart'; import 'package:cake_wallet/view_model/bridge/bridge_view_model.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_edit_or_create_view_model.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_item.dart'; import 'package:cake_wallet/view_model/wallet_address_list/wallet_address_list_view_model.dart'; @@ -290,6 +294,9 @@ import 'buy/meld/meld_buy_provider.dart'; import 'dogecoin/dogecoin.dart'; import 'new-ui/viewmodels/card_customizer/card_customizer_bloc.dart'; import 'new-ui/widgets/addresses_page/address_info.dart'; +import 'src/screens/buy/buy_sell_page.dart'; +import "src/screens/settings/widgets/account_creation_modal.dart"; + final getIt = GetIt.instance; @@ -535,6 +542,13 @@ Future setup({ getIt.registerFactory(() => DashboardViewModel( tradeMonitor: getIt.get(), balanceViewModel: getIt.get(), + accountListViewModelFactory: () { + final wallet = getIt.get().wallet; + if (wallet == null) return null; + final hasAccounts = wallet.hasAccountsSupport; + if (!hasAccounts) return null; + return getIt.get(); + }, appStore: getIt.get(), tradesStore: getIt.get(), ordersStore: getIt.get(), @@ -560,21 +574,11 @@ Future setup({ getIt.registerLazySingleton(() => PriceStore()); getIt.registerFactory( - () => ChartsBloc(appStore: getIt.get(), priceStore: getIt.get())); + () => ChartsBloc(appStore: getIt.get(), priceStore: getIt.get())); getIt.registerFactory(() => ChartsPage( - chartsBloc: getIt.get(), - )); - - getIt.registerFactory(() => AccountCreationModal( - accountEditOrCreateViewModel: getIt.get())); - - getIt.registerFactoryParam( - (dashboardViewModel, _) => AccountCustomizer( - accountListViewModel: getIt.get(), - dashboardViewModel: dashboardViewModel, - ), - ); + chartsBloc: getIt.get(), + )); getIt.registerFactory( () => LightningUsernameBloc(getIt.get().wallet!)); @@ -898,6 +902,30 @@ Future setup({ 'Unexpected wallet type: ${wallet.type} for generate Monero AccountListViewModel'); }); + getIt.registerFactory(() { + final wallet = getIt.get().wallet!; + if (wallet.type == WalletType.bitcoin) { + return BitcoinAccountListViewModel(wallet, getIt.get()); + } + throw Exception( + 'Unexpected wallet type: ${wallet.type} for generate Bitcoin AccountListViewModel'); + }); + + getIt.registerFactory(() { + final wallet = getIt.get().wallet!; + + switch (wallet.type) { + case WalletType.bitcoin: + return getIt.get(); + case WalletType.monero: + case WalletType.wownero: + case WalletType.haven: + return getIt.get(); + default: + throw Exception('Unsupported wallet type for WalletAccountListViewModel: ${wallet.type}'); + } + }); + getIt.registerFactoryParam( (AccountListItem? account, _) => MoneroAccountEditOrCreateViewModel( monero!.getAccountList(getIt.get().wallet!), @@ -905,6 +933,12 @@ Future setup({ wallet: getIt.get().wallet!, accountListItem: account)); + getIt.registerFactory( + () => BitcoinAccountEditOrCreateViewModel( + wallet: getIt.get().wallet!, + ), + ); + getIt.registerFactoryParam( (NanoAccount? account, _) => NanoAccountEditOrCreateViewModel(nano!.getAccountList(getIt.get().wallet!), @@ -1002,6 +1036,31 @@ Future setup({ getIt.registerFactory(() => SecurityBackupPage(getIt.get(), getIt.get(), getIt.get().wallet!.isHardwareWallet)); + + getIt.registerFactory(() { + final wallet = getIt.get().wallet!; + switch (wallet.type) { + case WalletType.bitcoin: + return getIt.get(); + case WalletType.monero: + case WalletType.wownero: + case WalletType.haven: + return getIt.get(); + default: + throw Exception( + 'Unsupported wallet type for WalletAccountEditOrCreateViewModel: ${wallet.type}'); + } + }); + + getIt.registerFactoryParam( + (dashboardViewModel, _) => WalletAccountsPage( + dashboardViewModel: dashboardViewModel, + accountListViewModel: + dashboardViewModel.accountListViewModel ?? getIt.get(), + accountEditOrCreateViewModel: getIt.get(), + ), + ); + getIt.registerFactory(() => PrivacyPage(getIt.get())); diff --git a/lib/entities/preferences_key.dart b/lib/entities/preferences_key.dart index 09a7bd24c..bacd03e0b 100644 --- a/lib/entities/preferences_key.dart +++ b/lib/entities/preferences_key.dart @@ -117,7 +117,6 @@ class PreferencesKey { static const showDecredInfoCard = 'show_decred_info_card'; static const forceDecentralizedExchanges = 'force_decentralized_exchanges'; static const decentralizedExchangesPromptDismissed = 'decentralized_exchanges_prompt_dismissed'; - static String moneroWalletUpdateV1Key(String name) => '${PreferencesKey.moneroWalletPasswordUpdateV1Base}_${name}'; diff --git a/lib/new-ui/pages/account_customizer.dart b/lib/new-ui/pages/account_customizer.dart deleted file mode 100644 index 8dd2ff26e..000000000 --- a/lib/new-ui/pages/account_customizer.dart +++ /dev/null @@ -1,505 +0,0 @@ -import 'dart:ui'; -import 'package:cake_wallet/di.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/new-ui/pages/card_customizer.dart'; -import 'package:cake_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart'; -import 'package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart'; -import 'package:cake_wallet/new-ui/widgets/modal_grab_handle.dart'; -import 'package:cake_wallet/new-ui/widgets/new_primary_button.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; -import 'package:cake_wallet/src/widgets/alert_with_one_action.dart'; -import 'package:cake_wallet/src/widgets/alert_with_two_actions.dart'; -import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; -import 'package:cake_wallet/utils/show_pop_up.dart'; -import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:cake_wallet/view_model/monero_account_list/account_list_item.dart'; -import 'package:cake_wallet/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart'; -import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart'; -import 'package:cw_core/balance_card_style_settings.dart'; -import 'package:cw_core/card_design.dart'; -import 'package:cw_core/generate_name.dart'; -import 'package:cw_core/sync_status.dart'; -import 'package:cw_core/utils/print_verbose.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; - -class AccountCustomizerListItem { - final BalanceCard card; - final int order; - final AccountListItem accountListItem; - - AccountCustomizerListItem( - {required this.card, required this.order, required this.accountListItem}); -} - -class AccountCustomizer extends StatefulWidget { - const AccountCustomizer( - {super.key, required this.accountListViewModel, required this.dashboardViewModel}); - - final MoneroAccountListViewModel accountListViewModel; - final DashboardViewModel dashboardViewModel; - - @override - State createState() => _AccountCustomizerState(); -} - -class _AccountCustomizerState extends State { - static const double _kStackVisibleFactor = 0.2; - late final double cardWidth = MediaQuery.of(context).size.width * 0.9; - - final List _items = []; - - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - loadCards(); - final activeId = widget.accountListViewModel.selected.id; - for (int i = 0; i < _items.length - 1; i++) { - if (_items[i].accountListItem.id == activeId) { - final lastIndex = _items.length - 1; - final temp = _items[i]; - _items[i] = _items[lastIndex]; - _items[lastIndex] = temp; - saveCardOrder(); - widget.dashboardViewModel.loadCardDesigns(); - break; - } - } - }); - } - - @override - void dispose() { - saveCardOrder().then((value) => widget.dashboardViewModel.loadCardDesigns()); - super.dispose(); - } - - void loadCards() { - _items.clear(); - - final accounts = widget.accountListViewModel.accounts; - for (int i = 0; i < accounts.length; i++) { - final index = widget.dashboardViewModel.cardOrder[i]; - - if (index == null || index >= accounts.length) { - // db order broken. - reset(); - break; - } - - _items.add(AccountCustomizerListItem( - card: BalanceCard( - accountName: accounts[index].label, - accountIndex: accounts[index].id, - balance: accounts[index].balance ?? "0.00", - accountBalance: accounts[index].balance ?? "0.00", - designSwitchDuration: Duration.zero, - assetName: widget.accountListViewModel.currency.title, - onCustomizeTapped: (i == accounts.length - 1) ? _openCardCustomizer : null, - selected: i == accounts.length - 1, - width: cardWidth, - design: widget.dashboardViewModel.cardDesigns[index], - ), - order: index, - accountListItem: accounts[index])); - } - setState(() {}); - } - - @override - Widget build(BuildContext context) { - if (_items.isEmpty) return SizedBox.shrink(); - - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(24))), - child: Column( - children: [ - ModalTopBar( - title: S.of(context).wallet_accounts, - leadingIcon: Icon(Icons.close), - leadingSemanticLabel: S.of(context).close, - onLeadingPressed: Navigator.of(context).maybePop, - trailingIcon: Icon(Icons.refresh), - trailingSemanticLabel: S.of(context).reset, - onTrailingPressed: showResetDialog, - ), - Padding( - padding: const EdgeInsets.symmetric(vertical: 24.0), - child: Text( - S.of(context).account_customizer_desc, - style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), - ), - ), - Expanded( - child: Stack( - children: [ - ReorderableListView.builder( - padding: EdgeInsets.only(bottom: 196), - scrollController: ModalScrollController.of(context), - onReorder: reorder, - proxyDecorator: (child, index, animation) { - return AnimatedBuilder( - animation: animation, - builder: (context, _) { - final animValue = Curves.easeOutCubic.transform(animation.value); - final scale = lerpDouble(1, 1.05, animValue)!; - - return Opacity( - opacity: 1 - animValue.clamp(0.0, 0.1), - child: Center( - child: SizedBox( - width: cardWidth, - child: Transform.scale( - scale: scale, - child: child, - ), - ), - ), - ); - }, - child: _items[index].card, - ); - }, - itemCount: _items.length, - itemBuilder: (BuildContext context, int index) { - final card = _items[index].card; - // The stack is ordered bottom to top, so the last item - // is the account currently in front — the selected one. - final selectedItemIndex = _items.length - 1; - - return Container( - key: ValueKey(index), - // One labeled, selectable node per account; the card's own - // texts stay reachable underneath it. - child: Semantics( - button: true, - selected: selectedItemIndex == index, - label: _items[index].accountListItem.label, - onTap: () => reorder(index, _items.length), - child: GestureDetector( - excludeFromSemantics: true, - onTap: () { - reorder(index, _items.length); - }, - child: Align( - alignment: Alignment.topCenter, - heightFactor: _kStackVisibleFactor, - child: card, - ), - ), - ), - ); - }, - ), - SafeArea( - child: Padding( - padding: EdgeInsets.only(bottom: 50), - child: Align( - alignment: Alignment.bottomCenter, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24.0), - child: Material( - color: Colors.transparent, - child: MergeSemantics( - child: Semantics( - button: true, - child: InkWell( - borderRadius: BorderRadius.circular(999999), - onTap: _showAddAccountModal, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(999999)), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 18.0), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - spacing: 8, - children: [ - Icon( - Icons.add, - size: 28, - color: Theme.of(context).colorScheme.primary, - ), - Text( - S.of(context).add_account, - style: TextStyle( - color: Theme.of(context).colorScheme.primary, - fontWeight: FontWeight.w500), - ) - ], - ), - ), - ), - ), - ), - ), - ), - )))), - ], - ), - ), - ], - ), - ); - } - - bool _checkReadyToManage() { - if (widget.dashboardViewModel.status is! SyncedSyncStatus) { - showDialog( - context: context, - builder: (context) => AlertWithOneAction( - alertTitle: S.of(context).wallet_is_syncing, - alertContent: S.of(context).cannot_manage_accounts_during_sync, - buttonText: S.of(context).ok, - buttonAction: Navigator.of(context).pop)); - return false; - } - return true; - } - - Future _showAddAccountModal() async { - if (!_checkReadyToManage()) { - return; - } - - final res = await showCupertinoModalBottomSheet( - context: context, - backgroundColor: Colors.transparent, - builder: (context) { - final modal = getIt.get(); - return Material(child: modal); - }); - if (res != null && res is bool && res == true) { - await widget.dashboardViewModel.loadCardDesigns(); - loadCards(); - await saveCardOrder(); - } - } - - void _openCardCustomizer() { - if (!_checkReadyToManage()) { - return; - } - - final bloc = getIt.get(param1: false); - - Navigator.of(context).push(CupertinoPageRoute( - builder: (context) { - return BlocProvider( - create: (context) => bloc, - child: Material( - child: CardCustomizer( - cryptoTitle: widget.dashboardViewModel.wallet.currency.fullName ?? - widget.dashboardViewModel.wallet.currency.name, - cryptoName: widget.dashboardViewModel.wallet.currency.name, - ), - ), - ); - }, - )).then((_) async { - bloc.add(DesignSaved()); - await bloc.stream.firstWhere((item) => item is CardCustomizerSaved); - await widget.dashboardViewModel.loadCardDesigns(); - loadCards(); - }); - } - - void reorder(int oldIndex, int newIndex) { - setState(() { - if (oldIndex < newIndex) { - newIndex -= 1; - } - final AccountCustomizerListItem item = _items.removeAt(oldIndex); - _items.insert(newIndex, item); - }); - - // necessary to copy all this to keep constant constructor for BalanceCard - for (int i = 0; i < _items.length; i++) { - _items[i] = AccountCustomizerListItem( - card: BalanceCard( - accountName: _items[i].card.accountName, - balance: _items[i].card.balance, - accountIndex: _items[i].card.accountIndex, - accountBalance: _items[i].card.accountBalance, - assetName: _items[i].card.assetName, - designSwitchDuration: _items[i].card.designSwitchDuration, - onCustomizeTapped: (i == _items.length - 1) ? _openCardCustomizer : null, - selected: i == _items.length - 1, - width: _items[i].card.width, - design: _items[i].card.design, - ), - order: i, - accountListItem: _items[i].accountListItem); - } - - if (newIndex == _items.length - 1 || oldIndex == _items.length - 1) { - widget.accountListViewModel.select(_items[_items.length - 1].accountListItem); - } - } - - Future saveCardOrder() async { - for (int i = 0; i < _items.length; i++) { - final idx = _items.indexWhere((element) => element.accountListItem.id == i); - printV("$i: $idx"); - - await BalanceCardStyleSettings.fromCardDesign( - walletInfoId: widget.dashboardViewModel.wallet.walletInfo.internalId, - accountIndex: i, - cardOrder: idx, - design: _items[idx].card.design) - .insert(); - } - } - - Future showResetDialog() async { - final res = await showPopUp( - context: context, - builder: (context) { - return AlertWithTwoActions( - alertTitle: S.of(context).reset, - alertContent: S.of(context).card_order_reset_desc, - leftButtonText: S.of(context).yes, - rightButtonText: S.of(context).no, - actionLeftButton: () { - Navigator.of(context).pop(true); - }, - actionRightButton: Navigator.of(context).pop); - }); - if (res != null && res is bool && res) { - reset(); - } - } - - Future reset() async { - _items.clear(); - - final accounts = widget.accountListViewModel.accounts; - for (int i = 0; i < widget.accountListViewModel.accounts.length; i++) { - _items.add(AccountCustomizerListItem( - card: BalanceCard( - accountName: accounts[i].label, - accountIndex: accounts[i].id, - balance: accounts[i].balance ?? "0.00", - accountBalance: accounts[i].balance ?? "0.00", - assetName: widget.accountListViewModel.currency.title, - selected: true, - designSwitchDuration: Duration(milliseconds: 200), - width: cardWidth, - design: i >= widget.dashboardViewModel.cardDesigns.length - ? CardDesign.genericDefault - : widget.dashboardViewModel.cardDesigns[i], - ), - order: i, - accountListItem: accounts[i])); - } - - saveCardOrder(); - setState(() {}); - } -} - -class AccountCreationModal extends StatefulWidget { - const AccountCreationModal({super.key, required this.accountEditOrCreateViewModel}); - - final MoneroAccountEditOrCreateViewModel accountEditOrCreateViewModel; - - @override - State createState() => _AccountCreationModalState(); -} - -class _AccountCreationModalState extends State { - final TextEditingController _controller = TextEditingController(); - bool _loading = false; - - Future _generateAccountName() async => _controller.text = await generateName(); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.vertical(top: Radius.circular(30))), - child: SafeArea( - top: false, - child: Padding( - padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ModalGrabHandle(), - ModalTopBar(title: S.of(context).create_account), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Column( - spacing: 50, - children: [ - SizedBox(), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(16)), - child: Row( - children: [ - Expanded( - child: TextField( - controller: _controller, - decoration: InputDecoration(hintText: S.of(context).account_name), - ), - ), - Padding( - padding: const EdgeInsets.all(12.0), - child: Semantics( - button: true, - label: S.of(context).generate_name, - onTap: _generateAccountName, - child: ExcludeSemantics( - child: GestureDetector( - onTap: _generateAccountName, - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(5)), - child: CakeImageWidget( - imageUrl: "assets/new-ui/randomize.svg", - colorFilter: ColorFilter.mode( - Theme.of(context).colorScheme.primary, BlendMode.srcIn), - ), - ), - ), - ), - ), - ) - ], - ), - ), - NewPrimaryButton( - onPressed: () async { - if (_loading) return; - setState(() { - _loading = true; - }); - widget.accountEditOrCreateViewModel.label = _controller.text; - await widget.accountEditOrCreateViewModel.save(); - Navigator.of(context).pop(true); - }, - text: S.of(context).continue_text, - color: Theme.of(context).colorScheme.primary, - textColor: Theme.of(context).colorScheme.onPrimary, - isLoading: _loading, - ), - SizedBox(), - ], - ), - ) - ], - ), - ), - )); - } -} diff --git a/lib/new-ui/pages/addresses_page.dart b/lib/new-ui/pages/addresses_page.dart index 568cc1c90..a068c5886 100644 --- a/lib/new-ui/pages/addresses_page.dart +++ b/lib/new-ui/pages/addresses_page.dart @@ -87,7 +87,12 @@ class _NewAddressesPageState extends State { }); WidgetsBinding.instance.addPostFrameCallback((_) { - reaction((context) => widget.dashboardViewModel.cardDesigns.first, (value) { + reaction((_) { + final index = widget.dashboardViewModel.accountListViewModel?.selectedAccount?.id ?? 0; + final designs = widget.dashboardViewModel.cardDesigns; + return (index >= 0 && index < designs.length) ? designs[index] : null; + }, (value) { + if (!mounted) return; setState(() { design = value; }); @@ -130,8 +135,7 @@ class _NewAddressesPageState extends State { child: Column( spacing: 16, children: [ - if (widget.dashboardViewModel.type == WalletType.monero || - widget.dashboardViewModel.type == WalletType.wownero) + if (widget.dashboardViewModel.isMultiAccountsEnabled) Observer( builder: (_) => AccountPreviewHeader( dashboardViewModel: widget.dashboardViewModel, @@ -271,8 +275,7 @@ class AccountPreviewHeader extends StatelessWidget { final CardDesign? design; @override - Widget build(BuildContext context) { - return Container( + Widget build(BuildContext context) => Container( height: 64, width: MediaQuery.of(context).size.width * 0.9, decoration: BoxDecoration( @@ -295,9 +298,7 @@ class AccountPreviewHeader extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ Text( - monero?.getCurrentAccount(dashboardViewModel.wallet).label ?? - wownero?.getCurrentAccount(dashboardViewModel.wallet).label ?? - "", + dashboardViewModel.accountListViewModel?.selectedAccount?.label ?? '', style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.primary), ), Text( @@ -325,7 +326,6 @@ class AccountPreviewHeader extends StatelessWidget { ), ), ); - } } class AddressRow extends StatelessWidget { diff --git a/lib/new-ui/pages/card_customizer.dart b/lib/new-ui/pages/card_customizer.dart index df5026fb4..7911f7ab1 100644 --- a/lib/new-ui/pages/card_customizer.dart +++ b/lib/new-ui/pages/card_customizer.dart @@ -1,21 +1,77 @@ -import 'dart:async'; -import 'dart:math'; +import "dart:async"; +import "dart:math"; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart'; -import 'package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart'; -import 'package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart'; -import 'package:cake_wallet/src/widgets/cake_image_widget.dart'; -import 'package:cw_core/card_design.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/modal_navigator.dart"; +import "package:cake_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cw_core/card_design.dart"; +import "package:flutter/material.dart"; +import "package:flutter_bloc/flutter_bloc.dart"; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; class CardCustomizer extends StatefulWidget { - const CardCustomizer({super.key, required this.cryptoTitle, required this.cryptoName}); + const CardCustomizer({required this.cryptoTitle, required this.cryptoName, super.key}); final String cryptoTitle; final String cryptoName; + static Future show({ + required BuildContext context, + required DashboardViewModel dashboardViewModel, + required bool lightningMode, + bool asModalSheet = true, + VoidCallback? onSaved, + }) async { + final bloc = getIt.get( + param1: lightningMode, + param2: dashboardViewModel.settingsStore.displayAmountsInSatoshi, + ); + + final customizer = BlocProvider( + create: (_) => bloc, + child: Material( + child: CardCustomizer( + cryptoTitle: dashboardViewModel.wallet.currency.fullName ?? + dashboardViewModel.wallet.currency.name, + cryptoName: dashboardViewModel.wallet.currency.name, + ), + ), + ); + + if (asModalSheet) { + await CupertinoScaffold.showCupertinoModalBottomSheet( + barrierColor: Colors.black.withAlpha(60), + context: context, + builder: (context) => ModalNavigator( + parentContext: context, + heightMode: ModalHeightModes.fullScreen, + rootPage: customizer, + ), + ); + } else { + await Navigator.of(context).push( + MaterialPageRoute(builder: (_) => customizer), + ); + } + + bloc.add(DesignSaved()); + + await bloc.stream.firstWhere( + (state) => state is CardCustomizerSaved, + orElse: () => bloc.state, + ); + + await dashboardViewModel.loadCardDesigns(); + await dashboardViewModel.accountListViewModel?.reload(); + + onSaved?.call(); + } + @override State createState() => _CardCustomizerState(); } @@ -63,298 +119,303 @@ class _CardCustomizerState extends State { } @override - Widget build(BuildContext context) { - return BlocListener( - listenWhen: (previous, current) => previous.accountName != current.accountName, - listener: (context, state) { - if (accountNameController.text != state.accountName) { - accountNameController.text = state.accountName; - } - }, - child: BlocBuilder( - builder: (context, state) { - if (state is CardCustomizerNotLoaded) return SizedBox.shrink(); - return PopScope( - child: SingleChildScrollView( - child: SafeArea( - child: Column( - spacing: 25.0, - mainAxisSize: MainAxisSize.min, - children: [ - ModalTopBar( - title: editEnabled ? S.of(context).edit_account : S.of(context).edit_card, - leadingIcon: Icon(Icons.close), - leadingSemanticLabel: S.of(context).close, - // trailingIcon: editEnabled ? Icon(Icons.delete_forever) : null, - onLeadingPressed: () => Navigator.of(context).maybePop(), - // onTrailingPressed: () {}, - ), - if (editEnabled) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Column( - spacing: 8.0, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).account_name), - TextField( - maxLength: 32, - decoration: InputDecoration(counterText: ""), - onChanged: (value) { - context.read().add(AccountNameChanged(value)); - }, - controller: accountNameController, - ) - ], - ), + Widget build(BuildContext context) => BlocListener( + listenWhen: (previous, current) => previous.accountName != current.accountName, + listener: (context, state) { + if (accountNameController.text != state.accountName) { + accountNameController.text = state.accountName; + } + }, + child: BlocBuilder( + builder: (context, state) { + if (state is CardCustomizerNotLoaded) return SizedBox.shrink(); + return PopScope( + child: SingleChildScrollView( + child: SafeArea( + child: Column( + spacing: 25.0, + mainAxisSize: MainAxisSize.min, + children: [ + ModalTopBar( + title: editEnabled ? S.of(context).edit_account : S.of(context).edit_card, + leadingIcon: Icon(Icons.close), + leadingSemanticLabel: S.of(context).close, + // trailingIcon: editEnabled ? Icon(Icons.delete_forever) : null, + onLeadingPressed: () => Navigator.of(context).maybePop(), + // onTrailingPressed: () {}, ), - BalanceCard( - width: min(MediaQuery.of(context).size.width * 0.87, 768), - selected: true, - designSwitchDuration: Duration(milliseconds: 300), - accountName: editEnabled ? state.accountName : "", - balance: "0.00", - assetName: state.displaySats ? "sats" : widget.cryptoName, - capitalizeAssetName: !state.displaySats, - design: state.selectedDesign, - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 18.0), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(16), - ), + if (editEnabled) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), child: Column( + spacing: 8.0, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - spacing: 8.0, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).card_style), - Container( - height: 63, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: state.availableDesigns.length, - separatorBuilder: (context, index) { - return SizedBox(width: 8.0); - }, - itemBuilder: (context, index) { - return GestureDetector( - onTap: () { - context - .read() - .add(CardDesignSelected(index)); - }, - child: AnimatedContainer( - duration: Duration(milliseconds: 300), - decoration: ShapeDecoration( - shape: RoundedSuperellipseBorder( - side: BorderSide( - color: - ((index == state.selectedDesignIndex) + Text(S.of(context).account_name), + TextField( + maxLength: 32, + decoration: InputDecoration(counterText: ""), + onChanged: (value) { + context.read().add(AccountNameChanged(value)); + }, + controller: accountNameController, + ) + ], + ), + ), + BalanceCard( + width: min(MediaQuery.of(context).size.width * 0.87, 768), + selected: true, + designSwitchDuration: Duration(milliseconds: 300), + accountName: editEnabled ? state.accountName : "", + balance: "0.00", + assetName: state.displaySats ? "sats" : widget.cryptoName, + capitalizeAssetName: !state.displaySats, + design: state.selectedDesign, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18.0), + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + spacing: 8.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(S.of(context).card_style), + SizedBox( + height: 63, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: state.availableDesigns.length, + separatorBuilder: (context, index) => + const SizedBox(width: 8), + itemBuilder: (context, index) => GestureDetector( + onTap: () { + context + .read() + .add(CardDesignSelected(index)); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 300), + decoration: ShapeDecoration( + shape: RoundedSuperellipseBorder( + side: BorderSide( + color: ((index == + state.selectedDesignIndex) ? Theme.of(context) .colorScheme .onSurface : Colors.transparent), - width: 1), - borderRadius: - BorderRadiusGeometry.circular(12), + width: 1), + borderRadius: + BorderRadiusGeometry.circular(12), + ), + ), + child: AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: index == state.selectedDesignIndex + ? 0.94 + : 1, + child: BalanceCard( + width: 96, + borderRadius: 10, + selected: false, + designSwitchDuration: + const Duration(milliseconds: 300), + design: + _cardStylePreviewDesign(state, index), + ), + ), ), - ), - child: AnimatedScale( - duration: Duration(milliseconds: 200), - scale: index == state.selectedDesignIndex - ? 0.94 - : 1, - child: BalanceCard( - width: 96, - borderRadius: 10, - selected: false, - designSwitchDuration: - Duration(milliseconds: 300), - design: _cardStylePreviewDesign(state, index), - ), - ), - ), - ); - }), - ), - ], - )), - AnimatedSwitcher( - duration: const Duration(milliseconds: 350), - switchInCurve: Curves.easeOut, - switchOutCurve: Curves.easeIn, - transitionBuilder: (Widget child, Animation animation) { - return FadeTransition( + )), + ), + ], + )), + AnimatedSwitcher( + duration: const Duration(milliseconds: 350), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, Animation animation) => + FadeTransition( opacity: animation, child: SizeTransition( sizeFactor: animation, axisAlignment: -1, child: child, ), - ); - }, - child: _showIconStylePanel(state) - ? Column( - key: const ValueKey('icon_style_panel'), - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12.0), - child: Column( - spacing: 8.0, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).icon_style), - SizedBox( - height: 48, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: state.availableIconPaths.length, - separatorBuilder: (context, _) => - const SizedBox(width: 8.0), - itemBuilder: (context, index) { - final icon = state.availableIconPaths[index]; - final isSelected = - index == state.selectedIconIndex; - return GestureDetector( - onTap: () { - context - .read() - .add(IconStyleSelected(index)); - }, - child: AnimatedContainer( - duration: - const Duration(milliseconds: 200), - width: 48, - height: 48, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(18), - color: Theme.of(context).brightness == - Brightness.light - ? Theme.of(context) - .colorScheme - .onSurfaceVariant - .withAlpha(64) - : Theme.of(context) - .colorScheme - .surfaceContainerHigh, - border: Border.all( - color: isSelected + ), + child: _showIconStylePanel(state) + ? Column( + key: const ValueKey("icon_style_panel"), + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12.0), + child: Column( + spacing: 8.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(S.of(context).icon_style), + SizedBox( + height: 48, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: state.availableIconPaths.length, + separatorBuilder: (context, _) => + const SizedBox(width: 8.0), + itemBuilder: (context, index) { + final icon = + state.availableIconPaths[index]; + final isSelected = + index == state.selectedIconIndex; + return GestureDetector( + onTap: () { + context + .read() + .add(IconStyleSelected(index)); + }, + child: AnimatedContainer( + duration: + const Duration(milliseconds: 200), + width: 48, + height: 48, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(18), + color: Theme.of(context).brightness == + Brightness.light ? Theme.of(context) .colorScheme - .onSurface - : Colors.transparent, - width: 2, + .onSurfaceVariant + .withAlpha(64) + : Theme.of(context) + .colorScheme + .surfaceContainerHigh, + border: Border.all( + color: isSelected + ? Theme.of(context) + .colorScheme + .onSurface + : Colors.transparent, + width: 2, + ), + ), + child: Padding( + padding: const EdgeInsets.all(10.0), + child: CakeImageWidget( + imageUrl: icon.path), ), ), - child: Padding( - padding: const EdgeInsets.all(10.0), - child: CakeImageWidget( - imageUrl: icon.path), + ); + }, + ), + ), + ], + ), + ), + ], + ) + : const SizedBox.shrink(key: ValueKey("icon_style_hidden")), + ), + const SizedBox(height: 8), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(16)), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + spacing: 8.0, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(S.of(context).color), + Container( + width: double.infinity, + child: Wrap( + direction: Axis.horizontal, + spacing: 4, // space between items in a row + runSpacing: 8, + children: List.generate( + state.availableColors.length, + (index) => Material( + borderRadius: + BorderRadius.circular(999999999), + child: InkWell( + borderRadius: + BorderRadius.circular(999999999), + onTap: () { + context + .read() + .add(ColorSelected(index)); + }, + child: Stack( + children: [ + AnimatedOpacity( + duration: const Duration( + milliseconds: 200), + opacity: index == + state.selectedColorIndex + ? 1 + : 0, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular( + 99999999), + border: Border.all( + color: Theme.of(context) + .colorScheme + .onSurface))), + ), + AnimatedScale( + duration: const Duration( + milliseconds: 200), + scale: index == + state.selectedColorIndex + ? 0.8 + : 1, + child: Container( + width: 32, + height: 32, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular( + 99999999, + ), + gradient: state + .availableColors[index]), + ), + ), + ], ), ), - ); - }, - ), - ), - ], - ), - ), - ], - ) - : const SizedBox.shrink(key: ValueKey('icon_style_hidden')), - ), - SizedBox(height: 8), - Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHigh, - borderRadius: BorderRadius.circular(16)), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - spacing: 8.0, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(S.of(context).color), - Container( - width: double.infinity, - child: Wrap( - direction: Axis.horizontal, - spacing: 4, // space between items in a row - runSpacing: 8, - children: List.generate(state.availableColors.length, - (index) { - return Material( - borderRadius: BorderRadius.circular(999999999), - child: InkWell( - borderRadius: BorderRadius.circular(999999999), - onTap: () { - context - .read() - .add(ColorSelected(index)); - }, - child: Stack( - children: [ - AnimatedOpacity( - duration: Duration(milliseconds: 200), - opacity: index == state.selectedColorIndex - ? 1 - : 0, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(99999999), - border: Border.all( - color: Theme.of(context) - .colorScheme - .onSurface))), - ), - AnimatedScale( - duration: Duration(milliseconds: 200), - scale: index == state.selectedColorIndex - ? 0.8 - : 1, - child: Container( - width: 32, - height: 32, - decoration: BoxDecoration( - borderRadius: - BorderRadius.circular(99999999), - gradient: - state.availableColors[index]), - ), - ), - ], - ), - ), - ); - }), - )), - ], + )), + )), + ], + ), ), - ), - ) - ], - ), - )), - ], + ) + ], + ), + )), + ], + ), ), ), - ), - ); - }, - ), - ); - } + ); + }, + ), + ); } diff --git a/lib/new-ui/pages/home_page.dart b/lib/new-ui/pages/home_page.dart index 97b6b1aa4..1335011a2 100644 --- a/lib/new-ui/pages/home_page.dart +++ b/lib/new-ui/pages/home_page.dart @@ -1,35 +1,40 @@ import "dart:async"; -import 'package:cake_wallet/core/auth_service.dart'; -import 'package:cake_wallet/di.dart'; -import 'package:cake_wallet/generated/i18n.dart'; +import "package:cake_wallet/bitcoin/bitcoin.dart"; +import "package:cake_wallet/core/auth_service.dart"; +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/generated/i18n.dart"; import "package:cake_wallet/main.dart"; -import 'package:cake_wallet/new-ui/modal_navigator.dart'; -import 'package:cake_wallet/new-ui/pages/account_customizer.dart'; -import 'package:cake_wallet/new-ui/pages/card_customizer.dart'; +import "package:cake_wallet/new-ui/modal_navigator.dart"; +import "package:cake_wallet/new-ui/pages/card_customizer.dart"; import "package:cake_wallet/new-ui/pages/seed/seed_backup_reminder_page.dart"; -import 'package:cake_wallet/new-ui/pages/settings_page.dart'; -import 'package:cake_wallet/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart'; -import 'package:cake_wallet/new-ui/widgets/coins_page/action_row/coin_action_row.dart'; -import 'package:cake_wallet/new-ui/widgets/coins_page/assets_history/assets_history_section.dart'; -import 'package:cake_wallet/new-ui/widgets/coins_page/cards/cards_view.dart'; -import 'package:cake_wallet/new-ui/widgets/coins_page/mweb_ad.dart'; +import "package:cake_wallet/new-ui/pages/send_page.dart"; +import "package:cake_wallet/new-ui/pages/settings_page.dart"; +import "package:cake_wallet/new-ui/pages/wallet_accounts_page.dart"; +import "package:cake_wallet/new-ui/widgets/buy_sell/buy_sell_selector_modal.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/action_row/coin_action_row.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/assets_history/assets_history_section.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/cards/cards_view.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/mweb_ad.dart"; import "package:cake_wallet/new-ui/widgets/coins_page/seed_backup_reminder_card.dart"; -import 'package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart'; -import 'package:cake_wallet/new-ui/widgets/coins_page/unconfirmed_balance_widget.dart'; +import "package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/unconfirmed_balance_widget.dart"; import "package:cake_wallet/new-ui/widgets/coins_page/zcash_migration_modal.dart"; -import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; -import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_mobx/flutter_mobx.dart'; -import 'package:mobx/mobx.dart'; -import 'package:modal_bottom_sheet/modal_bottom_sheet.dart'; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/utils/payment_request.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cake_wallet/view_model/dashboard/nft_view_model.dart"; +import "package:cw_core/unspent_coin_type.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/cupertino.dart"; +import "package:flutter/material.dart"; +import "package:flutter_mobx/flutter_mobx.dart"; +import "package:mobx/mobx.dart"; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; class NewHomePage extends StatefulWidget { - NewHomePage({super.key, required this.dashboardViewModel, required this.nftViewModel}); + const NewHomePage({required this.dashboardViewModel, required this.nftViewModel, super.key}); final DashboardViewModel dashboardViewModel; final NFTViewModel nftViewModel; @@ -39,20 +44,15 @@ class NewHomePage extends StatefulWidget { } class _NewHomePageState extends State with RouteAware { - MoneroAccountListViewModel? accountListViewModel; - bool _lightningMode = false; late final ReactionDisposer _walletReaction; late final ReactionDisposer _migrationReaction; @override void initState() { super.initState(); - _setAccountViewModel(); + _walletReaction = reaction((_) => widget.dashboardViewModel.wallet, (_) { - _setAccountViewModel(); - setState(() { - _lightningMode = false; - }); + setState(() {}); }); _migrationReaction = reaction((_) => widget.dashboardViewModel.isMigratingToIronwood, (val) { @@ -91,178 +91,183 @@ class _NewHomePageState extends State with RouteAware { @override void didPopNext() => widget.dashboardViewModel.loadSeedBackupReminder(); - void _setAccountViewModel() { - accountListViewModel = widget.dashboardViewModel.balanceViewModel.hasAccounts - ? getIt.get() - : null; - } - @override - Widget build(BuildContext context) { - return Container( - height: MediaQuery.of(context).size.height, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).colorScheme.surface, - Theme.of(context).colorScheme.surfaceDim, - ], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, + Widget build(BuildContext context) => Container( + height: MediaQuery.of(context).size.height, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Theme.of(context).colorScheme.surface, + Theme.of(context).colorScheme.surfaceDim, + ], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), ), - ), - child: Stack( - children: [ - CustomScrollView( - physics: BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), - slivers: [ - SliverPadding( - padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), - sliver: CupertinoSliverRefreshControl( - refreshTriggerPullDistance: 160, - refreshIndicatorExtent: 90, - onRefresh: () { - unawaited(widget.nftViewModel.getNFTAssetByWallet()); + child: Stack( + children: [ + CustomScrollView( + physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()), + slivers: [ + SliverPadding( + padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top), + sliver: CupertinoSliverRefreshControl( + refreshTriggerPullDistance: 160, + refreshIndicatorExtent: 90, + onRefresh: () { + unawaited(widget.nftViewModel.getNFTAssetByWallet()); - return widget.dashboardViewModel.refreshDashboard(); - }, + return widget.dashboardViewModel.refreshDashboard(); + }, + ), ), - ), - SliverToBoxAdapter( - child: Column( - mainAxisSize: MainAxisSize.max, - mainAxisAlignment: MainAxisAlignment.start, - spacing: 24.0, - children: [ - TopBar( - key: ValueKey(widget.dashboardViewModel.wallet.id), - dashboardViewModel: widget.dashboardViewModel, - lightningMode: _lightningMode, - onLightningSwitchPress: () { - setState(() { - _lightningMode = !_lightningMode; - }); - }, - onSettingsButtonPress: () async { - await CupertinoScaffold.showCupertinoModalBottomSheet( - context: context, - barrierColor: Colors.black.withAlpha(85), - builder: (context) => FractionallySizedBox( - child: Material( - child: NewSettingsPage( + SliverToBoxAdapter( + child: Observer( + builder: (_) { + final _lightningMode = widget.dashboardViewModel.lightningMode; + return Column( + mainAxisSize: MainAxisSize.max, + mainAxisAlignment: MainAxisAlignment.start, + spacing: 24.0, + children: [ + TopBar( + key: ValueKey(widget.dashboardViewModel.wallet.id), dashboardViewModel: widget.dashboardViewModel, - authService: getIt.get(), - ))), - ); - - if (!mounted) { - return; - } - _setAccountViewModel(); - await widget.dashboardViewModel.loadCardDesigns(); - if (mounted) { - setState(() {}); - } - }, - ), - Column( - spacing: 20, - children: [ - Column( - children: [ - Observer( - builder: (_) => CardsView( - key: ValueKey(widget.dashboardViewModel.wallet.name), - onCustomizeTapped: openCardCustomizer, - dashboardViewModel: widget.dashboardViewModel, - accountListViewModel: accountListViewModel, - onCompactModeBackgroundCardsTapped: openAccountCustomizer, - lightningMode: _lightningMode, + onLightningSwitchPress: () { + widget.dashboardViewModel.toggleLightningMode(); + }, + onSettingsButtonPress: () { + CupertinoScaffold.showCupertinoModalBottomSheet( + context: context, + barrierColor: Colors.black.withAlpha(85), + builder: (context) => FractionallySizedBox( + child: Material( + child: NewSettingsPage( + dashboardViewModel: widget.dashboardViewModel, + authService: getIt.get(), + ), + ), + ), + ).then((_) async { + widget.dashboardViewModel.accountListViewModel?.reload(); + await Future.delayed(Duration.zero); + await widget.dashboardViewModel.loadCardDesigns(); + if (mounted) setState(() {}); + }); + }, + ), + Column( + children: [ + AnimatedSwitcher( + duration: const Duration(milliseconds: 200), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeOut, + layoutBuilder: (currentChild, previousChildren) => Stack( + // the front (selected) card sits at the bottom of the stack box + alignment: Alignment.bottomCenter, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + child: KeyedSubtree( + key: ValueKey(_lightningMode), + child: CardsView( + key: ValueKey( + "${widget.dashboardViewModel.wallet.name}_${_lightningMode}_${widget.dashboardViewModel.accountListViewModel?.accounts.length ?? 0}_${widget.dashboardViewModel.cardDesigns.length}"), + onCustomizeTapped: openCardCustomizer, + dashboardViewModel: widget.dashboardViewModel, + accountListViewModel: + widget.dashboardViewModel.accountListViewModel, + onCompactModeBackgroundCardsTapped: openAccountCustomizer, + lightningMode: _lightningMode), + ), ), - ), - Observer(builder: (_) { - return AnimatedSize( - duration: Duration(milliseconds: 150), - curve: Curves.easeInOutCubic, - child: (widget.dashboardViewModel.shouldShowBalanceHiddenMessage) - ? Column( - children: [ - SizedBox( - height: 12, - width: double.infinity, - ), - Text( - S.of(context).long_press_show_balance, - style: TextStyle( - color: Theme.of(context) - .colorScheme - .onSurfaceVariant), - ) - ], - ) - : SizedBox(width: double.infinity), - ); - }), - UnconfirmedBalanceWidget( - dashboardViewModel: widget.dashboardViewModel, - ), - ], - ), - Observer( - builder: (_) { - return Column( - children: [ - CoinActionRow( - lightningMode: _lightningMode, - showSwap: widget.dashboardViewModel.isEnabledSwapAction, - walletType: widget.dashboardViewModel.wallet.type, - ), - MwebAd( - dashboardViewModel: widget.dashboardViewModel, - ), - SeedBackupReminderCard( - dashboardViewModel: widget.dashboardViewModel, - onTap: openSeedBackupReminder, - ), - ], - ); - }, - ), - ], - ), - ], + Observer( + builder: (_) => AnimatedSize( + duration: const Duration(milliseconds: 150), + curve: Curves.easeInOutCubic, + child: (widget.dashboardViewModel + .shouldShowBalanceHiddenMessage) + ? Column( + children: [ + const SizedBox( + height: 12, + width: double.infinity, + ), + Text( + S.of(context).long_press_show_balance, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ) + ], + ) + : const SizedBox(width: double.infinity), + )), + UnconfirmedBalanceWidget( + dashboardViewModel: widget.dashboardViewModel), + SeedBackupReminderCard( + dashboardViewModel: widget.dashboardViewModel, + onTap: openSeedBackupReminder, + ), + ], + ), + Column( + children: [ + CoinActionRow( + lightningMode: _lightningMode, + showSwap: widget.dashboardViewModel.isEnabledSwapAction, + walletType: widget.dashboardViewModel.wallet.type, + ), + MwebAd( + dashboardViewModel: widget.dashboardViewModel, + ), + ], + ) + ], + ); + }, + ), ), - ), - Observer( - builder: (_) => AssetsHistorySection( - nftViewModel: widget.nftViewModel, - dashboardViewModel: widget.dashboardViewModel, + Observer( + builder: (_) => AssetsHistorySection( + nftViewModel: widget.nftViewModel, + dashboardViewModel: widget.dashboardViewModel, + ), ), + const SliverToBoxAdapter( + child: SizedBox(height: 80.0), + ) + ]), + Container( + height: (MediaQuery.of(context).padding.top), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + colors: [ + Theme.of(context).colorScheme.surface.withAlpha(5), + Theme.of(context).colorScheme.surface.withAlpha(25), + Theme.of(context).colorScheme.surface.withAlpha(50), + Theme.of(context).colorScheme.surface.withAlpha(100), + Theme.of(context).colorScheme.surface.withAlpha(150), + Theme.of(context).colorScheme.surface.withAlpha(175), + Theme.of(context).colorScheme.surface.withAlpha(200), + ], ), - SliverToBoxAdapter( - child: SizedBox(height: 80.0), - ) - ]), - Container( - height: (MediaQuery.of(context).padding.top), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - colors: [ - Theme.of(context).colorScheme.surface.withAlpha(5), - Theme.of(context).colorScheme.surface.withAlpha(25), - Theme.of(context).colorScheme.surface.withAlpha(50), - Theme.of(context).colorScheme.surface.withAlpha(100), - Theme.of(context).colorScheme.surface.withAlpha(150), - Theme.of(context).colorScheme.surface.withAlpha(175), - Theme.of(context).colorScheme.surface.withAlpha(200), - ], ), ), - ), - ], - ), + ], + ), + ); + + Future openCardCustomizer() async { + await CardCustomizer.show( + context: context, + dashboardViewModel: widget.dashboardViewModel, + lightningMode: widget.dashboardViewModel.lightningMode, ); } @@ -278,54 +283,19 @@ class _NewHomePageState extends State with RouteAware { } Future openAccountCustomizer() async { - final accountList = accountListViewModel; - if (accountList == null) { - return; - } + if (widget.dashboardViewModel.accountListViewModel == null) return; await CupertinoScaffold.showCupertinoModalBottomSheet( barrierColor: Colors.black.withAlpha(60), context: context, - builder: (context) { - return ModalNavigator( - parentContext: context, - heightMode: ModalHeightModes.fullScreen, - rootPage: Material( - child: AccountCustomizer( - accountListViewModel: accountList, - dashboardViewModel: widget.dashboardViewModel, - ), - ), - ); - }, + builder: (context) => ModalNavigator( + parentContext: context, + heightMode: ModalHeightModes.fullScreen, + rootPage: Material( + child: getIt.get(param1: widget.dashboardViewModel), + ), + ), ); await widget.dashboardViewModel.loadCardDesigns(); } - - void openCardCustomizer() async { - final bloc = getIt.get( - param1: _lightningMode, - param2: widget.dashboardViewModel.settingsStore.displayAmountsInSatoshi); - await CupertinoScaffold.showCupertinoModalBottomSheet( - barrierColor: Colors.black.withAlpha(60), - context: context, - builder: (context) { - return ModalNavigator( - parentContext: context, - heightMode: ModalHeightModes.fullScreen, - rootPage: BlocProvider( - create: (context) => bloc, - child: Material( - child: CardCustomizer( - cryptoTitle: widget.dashboardViewModel.wallet.currency.fullName ?? - widget.dashboardViewModel.wallet.currency.name, - cryptoName: widget.dashboardViewModel.wallet.currency.name, - )), - )); - }, - ); - bloc.add(DesignSaved()); - await bloc.stream.firstWhere((s) => s is CardCustomizerSaved); - widget.dashboardViewModel.loadCardDesigns(); - } } diff --git a/lib/new-ui/pages/settings_page.dart b/lib/new-ui/pages/settings_page.dart index a2615cae7..756f00df5 100644 --- a/lib/new-ui/pages/settings_page.dart +++ b/lib/new-ui/pages/settings_page.dart @@ -25,8 +25,6 @@ bool _hasMweb(DashboardViewModel vm) => vm.hasMweb; bool _hasWalletConnect(DashboardViewModel vm) => vm.hasWalletConnect; -bool _hasAccounts(DashboardViewModel vm) => vm.balanceViewModel.hasAccounts; - bool _requiresKeyImageSync(DashboardViewModel vm) => vm.wallet.type == WalletType.monero && [HardwareWalletType.cupcake, HardwareWalletType.trezor].contains(vm.wallet.hardwareWalletType); @@ -65,8 +63,8 @@ class SettingsSectionData { SettingsListItem( "assets/new-ui/settings_row_icons/accounts.svg", S.current.accounts, - Routes.accountCustomizer, - condition: _hasAccounts, + Routes.walletAccountsPage, + condition: (vm) => vm.wallet.hasAccountsSupport, routeArgsBuilder: (vm) => vm, ), SettingsListItem( diff --git a/lib/new-ui/pages/wallet_accounts_page.dart b/lib/new-ui/pages/wallet_accounts_page.dart new file mode 100644 index 000000000..47b9a7b78 --- /dev/null +++ b/lib/new-ui/pages/wallet_accounts_page.dart @@ -0,0 +1,567 @@ +import "dart:async"; +import "dart:ui"; + +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/entities/bitcoin_amount_display_mode.dart"; +import "package:cake_wallet/entities/new_ui_entities/list_item/list_item_toggle.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/pages/card_customizer.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/cards/balance_card.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/src/screens/settings/widgets/account_creation_modal.dart"; +import "package:cake_wallet/src/widgets/alert_with_one_action.dart"; +import "package:cake_wallet/src/widgets/alert_with_two_actions.dart"; +import "package:cake_wallet/src/widgets/new_list_row/new_list_section.dart"; +import "package:cake_wallet/utils/show_pop_up.dart"; +import "package:cake_wallet/view_model/dashboard/dashboard_view_model.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_list_item.dart"; +import "package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart"; +import "package:cw_core/balance_card_style_settings.dart"; +import "package:cw_core/card_design.dart"; +import "package:cw_core/sync_status.dart"; +import "package:cw_core/utils/print_verbose.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:mobx/mobx.dart"; +import "package:modal_bottom_sheet/modal_bottom_sheet.dart"; + +class AccountCustomizerListItem { + AccountCustomizerListItem({ + required this.card, + required this.order, + required this.accountListItem, + }); + + final BalanceCard card; + final int order; + final AccountListItem accountListItem; +} + +class WalletAccountsPage extends StatefulWidget { + const WalletAccountsPage({ + required this.accountListViewModel, + required this.accountEditOrCreateViewModel, + required this.dashboardViewModel, + super.key, + }); + + final WalletAccountListViewModel accountListViewModel; + final WalletAccountEditOrCreateViewModel accountEditOrCreateViewModel; + final DashboardViewModel dashboardViewModel; + + @override + State createState() => _WalletAccountsPageState(); +} + +class _WalletAccountsPageState extends State { + static const double _kStackVisibleFactor = 0.2; + late final double cardWidth = MediaQuery.of(context).size.width * 0.9; + + final List _items = []; + + ReactionDisposer? _accountsReaction; + + WalletAccountListViewModel get accountListViewModel => + widget.dashboardViewModel.accountListViewModel ?? widget.accountListViewModel; + + bool get _isMultiAccountsEnabled => + widget.dashboardViewModel.isMultiAccountsEnabled; + + String get _assetName => widget.dashboardViewModel.appStore.amountParsingProxy + .getCryptoSymbol(accountListViewModel.currency); + + + bool get _capitalizeAssetName => + widget.dashboardViewModel.wallet.type != WalletType.bitcoin || + widget.dashboardViewModel.settingsStore.displayAmountsInSatoshi != + BitcoinAmountDisplayMode.satoshi; + + @override + void initState() { + super.initState(); + + _accountsReaction = reaction( + (_) => accountListViewModel.accounts + .map((account) => "${account.id}:${account.label}:${account.balance}") + .join(","), + (_) { + if (!mounted) return; + loadCards(); + }, + ); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + await widget.dashboardViewModel.loadCardDesigns(); + if (!mounted) return; + loadCards(); + + if (!_isMultiAccountsEnabled) return; + + final activeId = accountListViewModel.selectedAccount?.id; + + for (int i = 0; i < _items.length - 1; i++) { + if (activeId != null && _items[i].accountListItem.id == activeId) { + final lastIndex = _items.length - 1; + final temp = _items[i]; + _items[i] = _items[lastIndex]; + _items[lastIndex] = temp; + + await saveCardOrder(); + await widget.dashboardViewModel.loadCardDesigns(); + loadCards(); + break; + } + } + }); + } + + @override + void dispose() { + _accountsReaction?.call(); + accountListViewModel.reload(); + saveCardOrder().then((value) => widget.dashboardViewModel.loadCardDesigns()); + super.dispose(); + } + + void loadCards() { + final allAccounts = accountListViewModel.accounts; + final visible = widget.dashboardViewModel.visibleAccounts; + + if (visible.isEmpty && _items.isNotEmpty) return; + + _items.clear(); + + if (!_isMultiAccountsEnabled) { + if (visible.isNotEmpty) { + final account = visible.first; + final realIndex = allAccounts.indexWhere((a) => a.id == account.id); + final design = (realIndex >= 0 && + realIndex < widget.dashboardViewModel.cardDesigns.length) + ? widget.dashboardViewModel.cardDesigns[realIndex] + : CardDesign.genericDefault; + + _items.add(AccountCustomizerListItem( + card: BalanceCard( + accountName: account.label, + accountIndex: account.id, + balance: account.balance ?? "0.00", + accountBalance: account.balance ?? "0.00", + designSwitchDuration: Duration.zero, + assetName: _assetName, + capitalizeAssetName: _capitalizeAssetName, + selected: true, + width: cardWidth, + design: design, + ), + order: realIndex >= 0 ? realIndex : 0, + accountListItem: account, + )); + } + if (mounted) setState(() {}); + return; + } + + for (int i = 0; i < allAccounts.length; i++) { + final realIndex = widget.dashboardViewModel.cardOrder[i]; + + if (realIndex == null || realIndex < 0 || realIndex >= allAccounts.length) { + // db order broken. + reset(); + break; + } + + _items.add(AccountCustomizerListItem( + card: BalanceCard( + accountName: allAccounts[realIndex].label, + accountIndex: allAccounts[realIndex].id, + balance: allAccounts[realIndex].balance ?? "0.00", + accountBalance: allAccounts[realIndex].balance ?? "0.00", + designSwitchDuration: Duration.zero, + assetName: _assetName, + capitalizeAssetName: _capitalizeAssetName, + selected: i == allAccounts.length - 1, + width: cardWidth, + design: widget.dashboardViewModel.cardDesigns[realIndex], + ), + order: realIndex, + accountListItem: allAccounts[realIndex])); + } + if (mounted) { + setState(() {}); + } + } + + @override + Widget build(BuildContext context) { + final showResetButton = _isMultiAccountsEnabled && _items.length > 1; + final showAccountsToggle = widget.dashboardViewModel.canToggleMultiAccounts; + final isToggleEnabled = widget.dashboardViewModel.isMultiAccountsEnabled; + + if (_items.isEmpty && !showAccountsToggle) return const SizedBox.shrink(); + + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24))), + child: Column( + children: [ + ModalTopBar( + title: S.of(context).wallet_accounts, + leadingIcon: const Icon(Icons.close), + leadingSemanticLabel: S.of(context).close, + onLeadingPressed: Navigator.of(context).maybePop, + trailingIcon: showResetButton ? const Icon(Icons.refresh) : null, + trailingSemanticLabel: S.of(context).reset, + onTrailingPressed: showResetDialog, + ), + const SizedBox(height: 24), + if (showAccountsToggle) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: NewListSections( + sections: { + "": [ + ListItemToggle( + keyValue: S.of(context).multiple_accounts, + label: S.of(context).multiple_accounts, + value: isToggleEnabled, + onChanged: (val) async { + await widget.dashboardViewModel.setMultiAccountsEnabled(val); + if (!mounted) return; + loadCards(); + }), + ], + }, + ), + ), + if (_isMultiAccountsEnabled && _items.isNotEmpty) + Expanded( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 24.0), + child: Text( + S.of(context).account_customizer_desc, + style: TextStyle(color: Theme.of(context).colorScheme.onSurfaceVariant), + ), + ), + Expanded( + child: Stack( + children: [ + ReorderableListView.builder( + padding: const EdgeInsets.only(bottom: 196), + scrollController: ModalScrollController.of(context), + onReorder: reorder, + proxyDecorator: (child, index, animation) => AnimatedBuilder( + animation: animation, + builder: (context, _) { + final animValue = Curves.easeOutCubic.transform(animation.value); + final scale = lerpDouble(1, 1.05, animValue)!; + + return Opacity( + opacity: 1 - animValue.clamp(0.0, 0.1), + child: Center( + child: SizedBox( + width: cardWidth, + child: Transform.scale( + scale: scale, + child: child, + ), + ), + ), + ); + }, + child: _items[index].card, + ), + itemCount: _items.length, + itemBuilder: (context, int index) { + final card = _items[index].card; + final selectedItemIndex = _items.length - 1; + + return Container( + key: ValueKey(index), + child: Semantics( + button: true, + selected: selectedItemIndex == index, + label: _items[index].accountListItem.label, + onTap: () => reorder(index, _items.length), + child: GestureDetector( + excludeFromSemantics: true, + onTap: () => reorder(index, _items.length), + child: Align( + alignment: Alignment.topCenter, + heightFactor: _kStackVisibleFactor, + child: card, + ), + ), + ), + ); + }, + ), + SafeArea( + child: Padding( + padding: const EdgeInsets.only(bottom: 50), + child: Align( + alignment: Alignment.bottomCenter, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Material( + color: Colors.transparent, + child: MergeSemantics( + child: Semantics( + button: true, + child: InkWell( + borderRadius: BorderRadius.circular(999999), + onTap: _showAddAccountModal, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(999999), + ), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 18), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + spacing: 8, + children: [ + Icon( + Icons.add, + size: 28, + color: Theme.of(context).colorScheme.primary, + ), + Text( + S.of(context).add_account, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w500, + ), + ) + ], + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ), + ], + ), + ), + ], + ), + ), + ], + ), + ); + } + + bool _checkReadyToManage() { + if (widget.dashboardViewModel.wallet.type == WalletType.bitcoin) { + return true; + } + if (widget.dashboardViewModel.status is! SyncedSyncStatus) { + showDialog( + context: context, + builder: (context) => AlertWithOneAction( + alertTitle: S.of(context).wallet_is_syncing, + alertContent: S.of(context).cannot_manage_accounts_during_sync, + buttonText: S.of(context).ok, + buttonAction: Navigator.of(context).pop, + ), + ); + return false; + } + return true; + } + + Future _showAddAccountModal() async { + if (!_checkReadyToManage()) return; + + final createViewModel = getIt.get(); + + final res = await showCupertinoModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (context) => Material( + child: AccountCreationModal( + viewModel: createViewModel, + ), + )); + + if (res != null && res is bool && res == true) { + await accountListViewModel.reload(); + await Future.delayed(Duration.zero); + + final accounts = accountListViewModel.accounts; + if (accounts.isNotEmpty) { + final newestAccount = accounts.reduce((a, b) => a.id > b.id ? a : b); + await accountListViewModel.select(newestAccount); + } + + await widget.dashboardViewModel.loadCardDesigns(); + loadCards(); + if (mounted) setState(() {}); + } + } + + Future _openCardCustomizer() async { + if (!_checkReadyToManage()) { + return; + } + + await CardCustomizer.show( + context: context, + dashboardViewModel: widget.dashboardViewModel, + lightningMode: widget.dashboardViewModel.lightningMode, + asModalSheet: false, + onSaved: _reloadAccountsAndCards, + ); + } + + Future _reloadAccountsAndCards() async { + await accountListViewModel.reload(); + await widget.dashboardViewModel.loadCardDesigns(); + if (!mounted) return; + loadCards(); + } + + void _onCardsReordered(int oldRealIndex, int newRealIndex) { + final accounts = accountListViewModel.accounts; + if (oldRealIndex < 0 || + newRealIndex < 0 || + oldRealIndex >= accounts.length || + newRealIndex >= accounts.length) { + return; + } + + final oldAccountId = accounts[oldRealIndex].id; + final newAccountId = accounts[newRealIndex].id; + + final from = _items.indexWhere((item) => item.accountListItem.id == oldAccountId); + final to = _items.indexWhere((item) => item.accountListItem.id == newAccountId); + if (from < 0 || to < 0 || from == to) return; + + final insertIndex = from < to ? to + 1 : to; + reorder(from, insertIndex); + + saveCardOrder().then((_) { + if (!mounted) return; + widget.dashboardViewModel.loadCardDesigns(); + }); + } + + void reorder(int oldIndex, int newIndex) { + if (!_isMultiAccountsEnabled) return; + setState(() { + if (oldIndex < newIndex) { + newIndex -= 1; + } + final AccountCustomizerListItem item = _items.removeAt(oldIndex); + _items.insert(newIndex, item); + }); + + // necessary to copy all this to keep constant constructor for BalanceCard + for (int i = 0; i < _items.length; i++) { + _items[i] = AccountCustomizerListItem( + card: BalanceCard( + accountName: _items[i].card.accountName, + balance: _items[i].card.balance, + accountIndex: _items[i].card.accountIndex, + accountBalance: _items[i].card.accountBalance, + assetName: _items[i].card.assetName, + capitalizeAssetName: _items[i].card.capitalizeAssetName, + designSwitchDuration: _items[i].card.designSwitchDuration, + onCustomizeTapped: (i == _items.length - 1) ? _openCardCustomizer : null, + selected: i == _items.length - 1, + width: _items[i].card.width, + design: _items[i].card.design, + ), + order: i, + accountListItem: _items[i].accountListItem); + } + + if (newIndex == _items.length - 1 || oldIndex == _items.length - 1) { + accountListViewModel.select(_items[_items.length - 1].accountListItem); + } + } + + Future saveCardOrder() async { + + if (!_isMultiAccountsEnabled) return; + + for (int orderIndex = 0; orderIndex < _items.length; orderIndex++) { + final item = _items[orderIndex]; + printV("${item.accountListItem.id}: $orderIndex"); + + final existing = await BalanceCardStyleSettings.get( + widget.dashboardViewModel.wallet.walletInfo.internalId, + item.accountListItem.id, + ); + + await BalanceCardStyleSettings.fromCardDesign( + walletInfoId: widget.dashboardViewModel.wallet.walletInfo.internalId, + accountIndex: item.accountListItem.id, + cardOrder: orderIndex, + design: item.card.design, + iconStyleIndex: existing?.iconStyleIndex ?? 0, + gradientIndexOverride: existing?.gradientIndex) + .insert(); + } + } + + Future showResetDialog() async { + final res = await showPopUp( + context: context, + builder: (context) => AlertWithTwoActions( + alertTitle: S.of(context).reset, + alertContent: S.of(context).card_order_reset_desc, + leftButtonText: S.of(context).yes, + rightButtonText: S.of(context).no, + actionLeftButton: () { + Navigator.of(context).pop(true); + }, + actionRightButton: Navigator.of(context).pop)); + if (res != null && res is bool && res) { + reset(); + } + } + + Future reset() async { + _items.clear(); + + final accounts = accountListViewModel.accounts; + for (int i = 0; i < accounts.length; i++) { + _items.add(AccountCustomizerListItem( + card: BalanceCard( + accountName: accounts[i].label, + accountIndex: accounts[i].id, + balance: accounts[i].balance ?? "0.00", + accountBalance: accounts[i].balance ?? "0.00", + assetName: _assetName, + capitalizeAssetName: _capitalizeAssetName, + selected: i == accounts.length - 1, + designSwitchDuration: const Duration(milliseconds: 200), + width: cardWidth, + design: i >= widget.dashboardViewModel.cardDesigns.length + ? CardDesign.genericDefault + : widget.dashboardViewModel.cardDesigns[i], + ), + order: i, + accountListItem: accounts[i])); + } + + await saveCardOrder(); + + if (accounts.isNotEmpty) { + await accountListViewModel.select(accounts.last); + } + + await widget.dashboardViewModel.loadCardDesigns(); + if (mounted) setState(() {}); + } +} diff --git a/lib/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart b/lib/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart index b136cf9af..936fdade4 100644 --- a/lib/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart +++ b/lib/new-ui/viewmodels/card_customizer/card_customizer_bloc.dart @@ -1,4 +1,5 @@ import 'package:bloc/bloc.dart'; +import "package:cake_wallet/bitcoin/bitcoin.dart"; import 'package:cake_wallet/monero/monero.dart'; import 'package:cake_wallet/wownero/wownero.dart'; import "package:cw_core/balance_card_style_settings.dart"; @@ -30,6 +31,9 @@ class CardCustomizerBloc extends Bloc add(_Init()); } + bool get _accountNameEnabled => + !lightningMode && _wallet.walletInfo.isMultiAccountsEnabled == true; + List _updateAvailableColors(CardDesign currentDesign) { final list = List.from(CardDesign.allGradients, growable: true); if (CardDesign.specialDesignsForCurrencies[_wallet.currency] != null) { @@ -83,23 +87,9 @@ class CardCustomizerBloc extends Bloc } void _init(_Init event, Emitter emit) async { - late final account; - if (_wallet.type == WalletType.monero) { - account = monero!.getCurrentAccount(_wallet); - } else if (_wallet.type == WalletType.wownero) { - account = wownero!.getCurrentAccount(_wallet); - } else { - account = null; - } - final accountName = (account?.label ?? "") as String; - late final int accountIndex; - if (account != null) { - accountIndex = account.id as int; - } else if (lightningMode) { - accountIndex = 0; - } else { - accountIndex = -1; - } + final accountInfo = await _getCurrentAccountInfo(); + final accountName = accountInfo.accountName; + final accountIndex = accountInfo.accountIndex; final curr = lightningMode ? CryptoCurrency.btcln : _wallet.currency; final currentDesignSettings = await _loadCurrentDesignSettings(accountIndex); final currentDesign = CardDesign.fromStyleSettings(currentDesignSettings, curr); @@ -123,6 +113,39 @@ class CardCustomizerBloc extends Bloc selectedIconIndex: selectedIconIndex)); } + Future<({String accountName, int accountIndex})> _getCurrentAccountInfo() async { + if (lightningMode) { + return (accountName: "", accountIndex: -2); + } + + final account = await _resolveCurrentAccount(); + + if (!_accountNameEnabled) { + return (accountName: "", accountIndex: account.accountIndex); + } + + return account; + } + + Future<({String accountName, int accountIndex})> _resolveCurrentAccount() async { + if (_wallet.type == WalletType.monero) { + final account = monero!.getCurrentAccount(_wallet); + return (accountName: account.label, accountIndex: account.id); + } + + if (_wallet.type == WalletType.wownero) { + final account = wownero!.getCurrentAccount(_wallet); + return (accountName: account.label, accountIndex: account.id); + } + + if (_wallet.type == WalletType.bitcoin) { + final account = await bitcoin!.getCurrentAccount(_wallet); + return (accountName: account.label, accountIndex: account.id); + } + + return (accountName: "", accountIndex: -1); + } + void _onDesignSelected(CardDesignSelected event, Emitter emit) { final newColors = _updateAvailableColors(state.availableDesigns[event.newDesignIndex]); late final int newColorIndex; @@ -152,32 +175,34 @@ class CardCustomizerBloc extends Bloc emit(state.copyWith(accountName: event.newAccountName)); } - void _onDesignSaved(DesignSaved event, Emitter emit) { - BalanceCardStyleSettings.fromCardDesign( + Future _onDesignSaved(DesignSaved event, Emitter emit) async { + await BalanceCardStyleSettings.fromCardDesign( walletInfoId: _wallet.walletInfo.internalId, accountIndex: state.accountIndex, cardOrder: state.cardOrder, design: state.selectedDesign, iconStyleIndex: state.selectedIconIndex, gradientIndexOverride: state.selectedColorIndex) - .insert() - .then((value) { - emit(CardCustomizerSaved( - state.selectedDesignIndex, - state.selectedColorIndex, - state.availableDesigns, - state.availableColors, - state.accountName, - state.accountIndex, - state.displaySats, - state.cardOrder, - availableIconPaths: state.availableIconPaths, - selectedIconIndex: state.selectedIconIndex)); - }); - saveAccountName(); + .insert(); + + await saveAccountName(); + + emit(CardCustomizerSaved( + state.selectedDesignIndex, + state.selectedColorIndex, + state.availableDesigns, + state.availableColors, + state.accountName, + state.accountIndex, + state.displaySats, + state.cardOrder, + availableIconPaths: state.availableIconPaths, + selectedIconIndex: state.selectedIconIndex)); } Future saveAccountName() async { + if (!_accountNameEnabled) return; + if (_wallet.type == WalletType.monero) { await saveMoneroAccountName(); } @@ -185,6 +210,9 @@ class CardCustomizerBloc extends Bloc if (_wallet.type == WalletType.wownero) { await saveWowneroAccountName(); } + if (_wallet.type == WalletType.bitcoin && !lightningMode) { + await saveBitcoinAccountName(); + } } Future saveMoneroAccountName() async { @@ -202,4 +230,11 @@ class CardCustomizerBloc extends Bloc await _wallet.save(); } + + Future saveBitcoinAccountName() async { + await _wallet.walletInfo.renameAccount( + accountIndex: state.accountIndex, + label: state.accountName, + ); + } } diff --git a/lib/new-ui/widgets/coins_page/cards/balance_card.dart b/lib/new-ui/widgets/coins_page/cards/balance_card.dart index 20b16ed65..5ca0dcd33 100644 --- a/lib/new-ui/widgets/coins_page/cards/balance_card.dart +++ b/lib/new-ui/widgets/coins_page/cards/balance_card.dart @@ -215,10 +215,12 @@ class BalanceCard extends StatelessWidget { child: AnimatedSwitcher( duration: designSwitchDuration, layoutBuilder: (currentChild, previousChildren) { + final currentKey = currentChild?.key; return Stack( alignment: Alignment.centerLeft, children: [ - ...previousChildren, + for (final child in previousChildren) + if (child.key != currentKey) child, if (currentChild != null) currentChild, ], ); diff --git a/lib/new-ui/widgets/coins_page/cards/cards_view.dart b/lib/new-ui/widgets/coins_page/cards/cards_view.dart index 2dbbfac7d..0acc70f6c 100644 --- a/lib/new-ui/widgets/coins_page/cards/cards_view.dart +++ b/lib/new-ui/widgets/coins_page/cards/cards_view.dart @@ -13,7 +13,7 @@ import 'package:cake_wallet/utils/feature_flag.dart'; import 'package:cake_wallet/utils/payment_request.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:cake_wallet/view_model/monero_account_list/monero_account_list_view_model.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart'; import 'package:cw_core/card_design.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/unspent_coin_type.dart'; @@ -30,14 +30,14 @@ import 'balance_card.dart'; class CardsView extends StatefulWidget { const CardsView( {super.key, - required this.dashboardViewModel, - required this.accountListViewModel, - required this.lightningMode, - required this.onCompactModeBackgroundCardsTapped, - required this.onCustomizeTapped}); + required this.dashboardViewModel, + required this.accountListViewModel, + required this.lightningMode, + required this.onCompactModeBackgroundCardsTapped, + required this.onCustomizeTapped}); final DashboardViewModel dashboardViewModel; - final MoneroAccountListViewModel? accountListViewModel; + final WalletAccountListViewModel? accountListViewModel; final VoidCallback onCompactModeBackgroundCardsTapped; final VoidCallback onCustomizeTapped; final bool lightningMode; @@ -49,22 +49,39 @@ class CardsView extends StatefulWidget { class _CardsViewState extends State { late int _selectedIndex; bool isFirstBuild = true; + ReactionDisposer? _cardOrderReaction; + + int get _fallbackSelectedIndex => + max(0, widget.dashboardViewModel.cardOrder.length - 1); @override void initState() { super.initState(); - _selectedIndex = widget.dashboardViewModel.cardOrder.length - 1; - reaction( - (_) => widget.dashboardViewModel.cardOrder.values.toList(), - (_) => setState(() { - _selectedIndex = widget.dashboardViewModel.cardOrder.length - 1; - })); + _selectedIndex = _fallbackSelectedIndex; + _cardOrderReaction = reaction( + (_) => widget.dashboardViewModel.cardOrder.values.toList(), + (_) => setState(() { + _selectedIndex = _fallbackSelectedIndex; + }), + ); + } + + @override + void dispose() { + _cardOrderReaction?.call(); + super.dispose(); } static const Duration animDuration = Duration(milliseconds: 200); static const int compactModeTreshold = 4; static const int maxCards = 5; + /// No account name on the card: Lightning card, or Bitcoin with multi-accounts off. + bool get _hideAccountInfo => + widget.lightningMode || + (widget.dashboardViewModel.wallet.type == WalletType.bitcoin && + !widget.dashboardViewModel.isMultiAccountsEnabled); + Widget _buildCard(int visualIndex, int realIndex, int numCards, double parentWidth, Map order, bool compactMode, double overlapAmount) { final baseTop = overlapAmount * (numCards - 1); @@ -79,7 +96,7 @@ class _CardsViewState extends State { final isSelected = _selectedIndex == visualIndex; final accounts = widget.accountListViewModel?.accounts; - final cardLabel = (accounts != null && realIndex < accounts.length) + final cardLabel = (!_hideAccountInfo && accounts != null && realIndex < accounts.length) ? accounts[realIndex].label : S.of(context).balance; @@ -89,7 +106,7 @@ class _CardsViewState extends State { widget.onCompactModeBackgroundCardsTapped(); } else if (!compactMode) { setState(() { - if (widget.accountListViewModel != null) + if (widget.accountListViewModel != null && !widget.lightningMode) widget.accountListViewModel!.select(widget.accountListViewModel!.accounts[realIndex]); _selectedIndex = visualIndex; }); @@ -121,9 +138,9 @@ class _CardsViewState extends State { label: cardLabel, hint: isSelected ? (widget.dashboardViewModel.balanceViewModel.displayMode == - BalanceDisplayMode.hiddenBalance - ? S.of(context).long_press_show_balance - : S.of(context).long_press_hide_balance) + BalanceDisplayMode.hiddenBalance + ? S.of(context).long_press_show_balance + : S.of(context).long_press_hide_balance) : null, onTap: onCardTap, onLongPress: isSelected ? onCardLongPress : null, @@ -168,13 +185,14 @@ class _CardsViewState extends State { realIndex >= widget.dashboardViewModel.cardDesigns.length) cardDesign = CardDesign.genericDefault; else if (widget.lightningMode) - cardDesign = widget.dashboardViewModel.cardDesigns[realIndex + 1]; + // the lightning design is always the last one (after all bitcoin accounts) + cardDesign = widget.dashboardViewModel.cardDesigns.last; else cardDesign = widget.dashboardViewModel.cardDesigns[realIndex]; final String accountName; final String accountBalance; - if (account == null) { + if (account == null || _hideAccountInfo) { accountName = ""; accountBalance = ""; } else { @@ -188,30 +206,30 @@ class _CardsViewState extends State { final List actions = widget.lightningMode ? [ - BalanceCardAction( - label: S.current.bitcoin_lightning_deposit, - icon: Icons.arrow_downward, - onTap: depositToL2, - ), - BalanceCardAction( - label: S.current.bitcoin_lightning_withdraw, - icon: Icons.arrow_upward, - onTap: withdrawFromL2, - ) - ] + BalanceCardAction( + label: S.current.bitcoin_lightning_deposit, + icon: Icons.arrow_downward, + onTap: depositToL2, + ), + BalanceCardAction( + label: S.current.bitcoin_lightning_withdraw, + icon: Icons.arrow_upward, + onTap: withdrawFromL2, + ) + ] : widget.dashboardViewModel.isEnabledTradeAction - ? [ - BalanceCardAction( - label: S.current.buy, - icon: Icons.arrow_forward_ios_rounded, - iconSize: 12, - onTap: () { - showModalBottomSheet( - context: context, builder: (context) => BuySellSelectorModal()); - }, - ) - ] - : []; + ? [ + BalanceCardAction( + label: S.current.buy, + icon: Icons.arrow_forward_ios_rounded, + iconSize: 12, + onTap: () { + showModalBottomSheet( + context: context, builder: (context) => BuySellSelectorModal()); + }, + ) + ] + : []; return BalanceCard( width: effectiveCardWidth, @@ -263,31 +281,55 @@ class _CardsViewState extends State { double _getBoxHeight(int numCards, double overlapAmount) { return - /* height of initial card */ - (2 / 3.2) * (effectiveCardWidth) + - /* height of bg card * amount of bg cards */ - overlapAmount * ((numCards) - 1); + /* height of initial card */ + (2 / 3.2) * (effectiveCardWidth) + + /* height of bg card * amount of bg cards */ + overlapAmount * ((numCards) - 1); + } + + + int _visibleCardsCount() { + if (widget.dashboardViewModel.wallet.type != WalletType.bitcoin) { + return widget.dashboardViewModel.cardDesigns.length; + } + + if (widget.lightningMode || !widget.dashboardViewModel.isMultiAccountsEnabled) { + return 1; + } + + return widget.accountListViewModel?.accounts.length ?? 1; + } + + int? _visualIndexForSelectedAccount(Map order) { + final vm = widget.accountListViewModel; + if (vm == null || _hideAccountInfo) return null; + + final selectedId = vm.selectedAccount?.id; + final realIndex = vm.accounts.indexWhere((a) => a.id == selectedId); + if (realIndex < 0) return null; + + for (final entry in order.entries) { + if (entry.value == realIndex) return entry.key; + } + return null; } @override - Widget build(BuildContext context) { - return Observer(builder: (context) { + Widget build(BuildContext context) => Observer(builder: (context) { final parentWidth = MediaQuery.of(context).size.width; final children = []; - int numCards = widget.dashboardViewModel.wallet.type == WalletType.bitcoin - ? 1 - : widget.dashboardViewModel.cardDesigns.length; + int numCards = _visibleCardsCount(); if (numCards == 0) numCards = 1; - if (_selectedIndex >= (numCards)) { + if (_selectedIndex >= numCards) { _selectedIndex = 0; } Map order = widget.dashboardViewModel.cardOrder.length != numCards ? Map.fromEntries( - List.generate(numCards, (i) => MapEntry(i, i)), - ) + List.generate(numCards, (i) => MapEntry(i, i)), + ) : widget.dashboardViewModel.cardOrder; for (int i = min(numCards - 1, maxCards); i >= 0; i--) { @@ -299,6 +341,9 @@ class _CardsViewState extends State { } } + final followIndex = _visualIndexForSelectedAccount(order); + if (followIndex != null) _selectedIndex = followIndex; + final bool compactMode = numCards >= compactModeTreshold; final double overlapAmount = compactMode ? 5.0 : 46.0; for (int i = min(numCards - 1, maxCards); i >= 0; i--) { @@ -306,14 +351,6 @@ class _CardsViewState extends State { int realIndex = order[visualIndex]!; - if (visualIndex == _selectedIndex && - widget.accountListViewModel != null && - realIndex < widget.accountListViewModel!.accounts.length && - widget.accountListViewModel?.selected.label != - widget.accountListViewModel?.accounts[realIndex].label) { - widget.accountListViewModel!.select(widget.accountListViewModel!.accounts[realIndex]); - } - children.add(_buildCard( visualIndex, realIndex, numCards, parentWidth, order, compactMode, overlapAmount)); } @@ -331,7 +368,6 @@ class _CardsViewState extends State { ), ); }); - } Future depositToL2() async { PaymentRequest? paymentRequest = null; @@ -343,7 +379,7 @@ class _CardsViewState extends State { } } else if (widget.dashboardViewModel.type == WalletType.bitcoin) { final depositAddress = - await bitcoin!.getUnusedSpakDepositAddress(widget.dashboardViewModel.wallet); + await bitcoin!.getUnusedSpakDepositAddress(widget.dashboardViewModel.wallet); if ((depositAddress?.isNotEmpty ?? false)) { paymentRequest = PaymentRequest.fromUri(Uri.parse("bitcoin:$depositAddress")); } @@ -352,10 +388,10 @@ class _CardsViewState extends State { if (widget.dashboardViewModel.type == WalletType.bitcoin) { final page = getIt.get( param1: SendPageParams( - initialPaymentRequest: paymentRequest, - unspentCoinType: UnspentCoinType.nonMweb, - mode: SendPageModes.lightningDeposit, - )); + initialPaymentRequest: paymentRequest, + unspentCoinType: UnspentCoinType.nonMweb, + mode: SendPageModes.lightningDeposit, + )); showCupertinoModalBottomSheet( context: context, barrierColor: Colors.black.withAlpha(128), @@ -399,10 +435,10 @@ class _CardsViewState extends State { if (widget.dashboardViewModel.type == WalletType.bitcoin) { final page = getIt.get( param1: SendPageParams( - initialPaymentRequest: paymentRequest, - unspentCoinType: unspentCoinType, - mode: SendPageModes.lightningWithdrawal, - )); + initialPaymentRequest: paymentRequest, + unspentCoinType: unspentCoinType, + mode: SendPageModes.lightningWithdrawal, + )); showCupertinoModalBottomSheet( context: context, barrierColor: Colors.black.withAlpha(128), @@ -425,4 +461,4 @@ class _CardsViewState extends State { ); } } -} +} \ No newline at end of file diff --git a/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart b/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart index 91e28fc02..86ded76a9 100644 --- a/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart +++ b/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart @@ -16,14 +16,12 @@ import 'package:mobx/mobx.dart'; class TopBar extends StatefulWidget { const TopBar({ - required this.lightningMode, required this.onLightningSwitchPress, required this.dashboardViewModel, required this.onSettingsButtonPress, super.key, }); - final bool lightningMode; final VoidCallback onLightningSwitchPress; final VoidCallback onSettingsButtonPress; final DashboardViewModel dashboardViewModel; @@ -73,8 +71,8 @@ class _TopBarState extends State { void _bindStatusReaction() { _statusReactionDisposer = reaction( - (_) => widget.dashboardViewModel.status.runtimeType, - (status) { + (_) => widget.dashboardViewModel.status.runtimeType, + (status) { syncedMessageTimer?.cancel(); syncedMessageTimer = null; @@ -107,78 +105,78 @@ class _TopBarState extends State { @override Widget build(BuildContext context) => Padding( - padding: EdgeInsets.only(left: 18, right: 18, top: 10 + _additionalTopPadding(context)), - child: Observer( - builder: (_) => Row( - spacing: 12, - children: [ - widget.dashboardViewModel.hasLightning - ? LightningSwitcher( - lightningMode: widget.lightningMode, - onLightningSwitchPress: widget.onLightningSwitchPress, - ) - : ChainIcon( - iconPath: widget.dashboardViewModel.wallet.currency.flatIconPath ?? "", - dashboardViewModel: widget.dashboardViewModel, - isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, - showSyncedMessage: showSyncedMessage, + padding: EdgeInsets.only( + bottom: 10, left: 18, right: 18, top: 10 + _additionalTopPadding(context)), + child: Observer( + builder: (_) => Row( + spacing: 12, + children: [ + widget.dashboardViewModel.hasLightning + ? LightningSwitcher( + lightningMode: widget.dashboardViewModel.lightningMode, + onLightningSwitchPress: widget.onLightningSwitchPress, + ) + : ChainIcon( + iconPath: widget.dashboardViewModel.wallet.currency.flatIconPath ?? "", + dashboardViewModel: widget.dashboardViewModel, + isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, + showSyncedMessage: showSyncedMessage, + ), + Expanded( + child: SizedBox( + height: 36, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 150), + reverseDuration: Duration.zero, + layoutBuilder: (currentChild, previousChildren) => Stack( + alignment: Alignment.centerLeft, + children: [ + ...previousChildren, + if (currentChild != null) currentChild, + ], + ), + child: replacesWalletName + ? SyncBar( + dashboardViewModel: widget.dashboardViewModel, + isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, + showSyncedMessage: showSyncedMessage, + ) + : Row( + children: [ + Expanded( + child: WalletInfoBar( + name: widget.dashboardViewModel.wallet.name, + hardwareWalletType: widget.dashboardViewModel.wallet.hardwareWalletType, + ), ), - Expanded( - child: SizedBox( - height: 36, - child: AnimatedSwitcher( - duration: const Duration(milliseconds: 150), - reverseDuration: Duration.zero, - layoutBuilder: (currentChild, previousChildren) => Stack( - alignment: Alignment.centerLeft, - children: [ - ...previousChildren, - if (currentChild != null) currentChild, - ], - ), - child: replacesWalletName - ? SyncBar( - dashboardViewModel: widget.dashboardViewModel, - isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, - showSyncedMessage: showSyncedMessage, - ) - : Row( - children: [ - Expanded( - child: WalletInfoBar( - name: widget.dashboardViewModel.wallet.name, - hardwareWalletType: - widget.dashboardViewModel.wallet.hardwareWalletType, - ), - ), - if (widget.dashboardViewModel.isTorEnabled) ...[ - const SizedBox(width: 6), - SyncBar( - dashboardViewModel: widget.dashboardViewModel, - isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, - showSyncedMessage: showSyncedMessage, - ), - ], - ], - ), - ), + if (widget.dashboardViewModel.isTorEnabled) ...[ + const SizedBox(width: 6), + SyncBar( + dashboardViewModel: widget.dashboardViewModel, + isSyncHeavy: widget.dashboardViewModel.isSyncHeavy, + showSyncedMessage: showSyncedMessage, + ), + ], + ], ), ), - ModernButton.svg( - key: const ValueKey("home_page_settings_button_key"), - iconColor: Theme.of(context).colorScheme.primary, - size: 36, - onPressed: () { - HapticFeedback.mediumImpact(); - widget.onSettingsButtonPress(); - }, - svgPath: "assets/new-ui/top-settings.svg", - semanticLabel: S.of(context).settings_title, - ), - ], + ), ), - ), - ); + ModernButton.svg( + key: const ValueKey("home_page_settings_button_key"), + iconColor: Theme.of(context).colorScheme.primary, + size: 36, + onPressed: () { + HapticFeedback.mediumImpact(); + widget.onSettingsButtonPress(); + }, + svgPath: "assets/new-ui/top-settings.svg", + semanticLabel: S.of(context).settings_title, + ), + ], + ), + ), + ); //FIXME remove after this gets fixed flutter-side double _additionalTopPadding(BuildContext context) { @@ -188,4 +186,4 @@ class _TopBarState extends State { return 0; } -} +} \ No newline at end of file diff --git a/lib/new-ui/widgets/coins_page/wallet_info.dart b/lib/new-ui/widgets/coins_page/wallet_info.dart index 469ff630c..df4d665b7 100644 --- a/lib/new-ui/widgets/coins_page/wallet_info.dart +++ b/lib/new-ui/widgets/coins_page/wallet_info.dart @@ -4,11 +4,7 @@ import 'package:cw_core/wallet_info.dart'; import 'package:flutter/material.dart'; class WalletInfoBar extends StatelessWidget { - const WalletInfoBar({ - required this.name, - required this.hardwareWalletType, - super.key, - }); + const WalletInfoBar({required this.name, required this.hardwareWalletType, super.key}); final String name; final HardwareWalletType? hardwareWalletType; @@ -22,6 +18,8 @@ class WalletInfoBar extends StatelessWidget { label: semanticsLabel, child: ExcludeSemantics( child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 150), @@ -33,7 +31,7 @@ class WalletInfoBar extends StatelessWidget { child: hardwareWalletIcon == null ? const SizedBox.shrink(key: ValueKey("empty")) : Padding( - padding: const EdgeInsets.only(right: 4), + padding: const EdgeInsets.only(right: 8), child: CakeImageWidget( imageUrl: hardwareWalletIcon!, key: const ValueKey("hardware_wallet_icon"), diff --git a/lib/router.dart b/lib/router.dart index e35406b64..a1a185ce7 100644 --- a/lib/router.dart +++ b/lib/router.dart @@ -4,7 +4,6 @@ import 'package:cake_wallet/anonpay/anonpay_invoice_info.dart'; import "package:cake_wallet/core/auth_service.dart"; import 'package:cake_wallet/core/new_wallet_arguments.dart'; import 'package:cake_wallet/new-ui/new_dashboard.dart'; -import "package:cake_wallet/new-ui/pages/account_customizer.dart"; import 'package:cake_wallet/new-ui/pages/about_page.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_history_page.dart'; import 'package:cake_wallet/new-ui/pages/bridge/bridge_network_page.dart'; @@ -51,6 +50,7 @@ import 'package:cake_wallet/src/screens/dev/monero_background_sync.dart'; import 'package:cake_wallet/src/screens/dev/moneroc_cache_debug.dart'; import 'package:cake_wallet/src/screens/dev/moneroc_call_profiler.dart'; import 'package:cake_wallet/src/screens/dev/network_requests.dart'; +import 'package:cake_wallet/new-ui/pages/wallet_accounts_page.dart'; import 'package:cake_wallet/store/app_store.dart'; import 'package:cake_wallet/utils/feature_flag.dart'; import 'package:cake_wallet/src/screens/dev/qr_tools_page.dart'; @@ -129,8 +129,8 @@ import "package:cake_wallet/view_model/dashboard/nft_send_view_model.dart"; import 'package:cake_wallet/view_model/dashboard/nft_view_model.dart'; import 'package:cake_wallet/view_model/dashboard/sign_view_model.dart'; import 'package:cake_wallet/view_model/hardware_wallet/hardware_wallet_view_model.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; import 'package:cake_wallet/view_model/hardware_wallet/trezor_connect_view_model.dart'; -import 'package:cake_wallet/view_model/monero_account_list/account_list_item.dart'; import 'package:cake_wallet/view_model/node_list/node_create_or_edit_view_model.dart'; import 'package:cake_wallet/view_model/wallet_groups_display_view_model.dart'; import 'package:cake_wallet/view_model/seed_settings_view_model.dart'; @@ -617,13 +617,6 @@ Route createRoute(RouteSettings settings) { param1: args?['editingNode'] as Node?, param2: args?['isSelected'] as bool?); return CupertinoPageRoute(builder: (_) => page); - case Routes.accountCustomizer: - return handleRouteWithPlatformAwareness( - (_) => getIt.get( - param1: settings.arguments! as DashboardViewModel, - ), - ); - case Routes.addressBook: return handleRouteWithPlatformAwareness( (context) => getIt.get(), @@ -853,6 +846,12 @@ Route createRoute(RouteSettings settings) { case Routes.manageNodes: return MaterialPageRoute(builder: (_) => getIt.get(param1: false)); + case Routes.walletAccountsPage: + final dashboardViewModel = settings.arguments as DashboardViewModel; + return handleRouteWithPlatformAwareness( + (_) => getIt.get(param1: dashboardViewModel), + ); + case Routes.managePowNodes: return MaterialPageRoute(builder: (_) => getIt.get(param1: true)); diff --git a/lib/routes.dart b/lib/routes.dart index 536d80913..03cc872c7 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -31,8 +31,6 @@ class Routes { static const newPowNode = '/new_pow_node_list'; static const login = '/login'; static const splash = '/splash'; - static const accountCreation = '/account_new'; - static const accountCustomizer = "/account_customizer"; static const nanoAccountCreation = '/nano_account_new'; static const addressBook = '/address_book'; static const pickerAddressBook = '/picker_address_book'; @@ -112,6 +110,7 @@ class Routes { static const setup2faInfoPage = '/setup_2fa_info_page'; static const homeSettings = '/home_settings'; static const editToken = '/edit_token'; + static const walletAccountsPage = '/wallet_accounts_page'; static const manageNodes = '/manage_nodes'; static const managePowNodes = '/manage_pow_nodes'; static const walletConnectConnectionsListing = '/wallet-connect-connections-listing'; diff --git a/lib/src/screens/settings/widgets/account_creation_modal.dart b/lib/src/screens/settings/widgets/account_creation_modal.dart new file mode 100644 index 000000000..af0163fee --- /dev/null +++ b/lib/src/screens/settings/widgets/account_creation_modal.dart @@ -0,0 +1,149 @@ +import "package:cake_wallet/core/execution_state.dart"; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/new-ui/widgets/modal_grab_handle.dart"; +import "package:cake_wallet/new-ui/widgets/new_primary_button.dart"; +import "package:cake_wallet/new-ui/widgets/receive_page/receive_top_bar.dart"; +import "package:cake_wallet/src/widgets/cake_image_widget.dart"; +import "package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart"; +import "package:cw_core/generate_name.dart"; +import "package:flutter/material.dart"; +import "package:flutter/services.dart"; +import "package:flutter_mobx/flutter_mobx.dart"; + +class AccountCreationModal extends StatefulWidget { + const AccountCreationModal({ + required this.viewModel, + super.key, + }); + + final WalletAccountEditOrCreateViewModel viewModel; + + @override + State createState() => _AccountCreationModalState(); +} + +class _AccountCreationModalState extends State { + static const int maxAccountNameLength = 25; + + final TextEditingController _controller = TextEditingController(); + + @override + void initState() { + super.initState(); + _controller.text = widget.viewModel.label; + _controller.addListener(() { + widget.viewModel.label = _controller.text; + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _onSubmit() async { + if (widget.viewModel.state is IsExecutingState) return; + if (_controller.text.trim().isEmpty || _controller.text.length > maxAccountNameLength) return; + + await widget.viewModel.save(); + + if (!mounted) return; + + if (widget.viewModel.state is ExecutedSuccessfullyState) { + Navigator.of(context).pop(true); + } + } + + @override + Widget build(BuildContext context) => Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: const BorderRadius.vertical(top: Radius.circular(30))), + child: SafeArea( + top: false, + child: Padding( + padding: EdgeInsets.only(bottom: MediaQuery.of(context).viewInsets.bottom), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const ModalGrabHandle(), + ModalTopBar( + title: widget.viewModel.isEdit + ? S.of(context).edit_account + : S.of(context).create_account, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 18), + child: Column( + spacing: 50, + children: [ + const SizedBox(), + Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Expanded( + child: TextField( + controller: _controller, + maxLength: maxAccountNameLength, + inputFormatters: [ + LengthLimitingTextInputFormatter(maxAccountNameLength), + ], + decoration: InputDecoration( + hintText: S.of(context).account_name, + counterText: "", + ), + ), + ), + Padding( + padding: const EdgeInsets.all(12), + child: GestureDetector( + onTap: () async { + final generated = await generateName(); + _controller.text = generated.length > maxAccountNameLength + ? generated.substring(0, maxAccountNameLength) + : generated; + }, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHigh, + borderRadius: BorderRadius.circular(5)), + child: CakeImageWidget( + imageUrl: "assets/new-ui/randomize.svg", + colorFilter: ColorFilter.mode( + Theme.of(context).colorScheme.primary, BlendMode.srcIn), + ), + ), + ), + ) + ], + ), + ), + ValueListenableBuilder( + valueListenable: _controller, + builder: (context, value, _) => Observer( + builder: (_) => NewPrimaryButton( + onPressed: _onSubmit, + text: S.of(context).continue_text, + color: Theme.of(context).colorScheme.primary, + textColor: Theme.of(context).colorScheme.onPrimary, + disabled: value.text.trim().isEmpty || + value.text.length > maxAccountNameLength, + isLoading: widget.viewModel.state is IsExecutingState, + ), + ), + ), + const SizedBox(), + ], + ), + ) + ], + ), + ), + ), + ); +} diff --git a/lib/view_model/dashboard/balance_view_model.dart b/lib/view_model/dashboard/balance_view_model.dart index 3128f74d8..f464076b6 100644 --- a/lib/view_model/dashboard/balance_view_model.dart +++ b/lib/view_model/dashboard/balance_view_model.dart @@ -159,7 +159,7 @@ abstract class BalanceViewModelBase with Store { bool get isEVMCompatible => isEVMCompatibleChain(wallet.type); @computed - bool get hasAccounts => [WalletType.monero, WalletType.wownero].contains(wallet.type); + bool get hasAccounts => wallet.hasAccountsSupport; @computed SortBalanceBy get sortBalanceBy => settingsStore.sortBalanceBy; diff --git a/lib/view_model/dashboard/dashboard_view_model.dart b/lib/view_model/dashboard/dashboard_view_model.dart index 023ed1944..96c1adf42 100644 --- a/lib/view_model/dashboard/dashboard_view_model.dart +++ b/lib/view_model/dashboard/dashboard_view_model.dart @@ -5,6 +5,9 @@ import 'dart:io' show Platform; import 'package:cake_wallet/.secrets.g.dart' as secrets; import 'package:cake_wallet/bitcoin/bitcoin.dart'; import 'package:cake_wallet/core/key_service.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/wallet_account_list_view_model.dart'; +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_style_settings.dart"; import 'package:cake_wallet/core/trade_monitor.dart'; @@ -77,6 +80,7 @@ class DashboardViewModel = DashboardViewModelBase with _$DashboardViewModel; abstract class DashboardViewModelBase with Store { DashboardViewModelBase( {required this.balanceViewModel, + required this.accountListViewModelFactory, required this.tradeMonitor, required this.appStore, required this.tradesStore, @@ -106,7 +110,7 @@ abstract class DashboardViewModelBase with Store { showDecredInfoCard = wallet.type == WalletType.decred && (sharedPreferences.getBool(PreferencesKey.showDecredInfoCard) ?? true); showSeedBackupReminder = wallet.walletInfo.showSeedBackupReminder; - + isMultiAccountsEnabled = wallet.walletInfo.isMultiAccountsEnabled == true; name = wallet.name; type = wallet.type; isShowFirstYatIntroduction = false; @@ -115,6 +119,7 @@ abstract class DashboardViewModelBase with Store { unawaited(isBackgroundSyncEnabled()); unawaited(isBatteryOptimizationEnabled()); unawaited(_loadConstraints()); + accountListViewModel = accountListViewModelFactory(); final _wallet = wallet; loadFilterItems(); @@ -122,8 +127,7 @@ abstract class DashboardViewModelBase with Store { if (_wallet.type == WalletType.monero) { subname = monero!.getCurrentAccount(_wallet).label; - _onMoneroAccountChangeReaction = reaction( - (_) => monero!.getMoneroWalletDetails(wallet).account, + _onAccountChangeReaction = reaction((_) => monero!.getMoneroWalletDetails(wallet).account, (Account account) => _onMoneroAccountChange(_wallet)); _onMoneroBalanceChangeReaction = reaction( @@ -151,9 +155,9 @@ abstract class DashboardViewModelBase with Store { } else if (_wallet.type == WalletType.wownero) { subname = wow.wownero!.getCurrentAccount(_wallet).label; - _onMoneroAccountChangeReaction = reaction( + _onAccountChangeReaction = reaction( (_) => wow.wownero!.getWowneroWalletDetails(wallet).account, - (wow.Account account) => _onMoneroAccountChange(_wallet)); + (Account account) => _onMoneroAccountChange(_wallet)); _onMoneroBalanceChangeReaction = reaction( (_) => wow.wownero!.getWowneroWalletDetails(wallet).balance, @@ -179,21 +183,12 @@ abstract class DashboardViewModelBase with Store { ), ); } else { - final sortedTransactions = [...wallet.transactionHistory.transactions.values]; - sortedTransactions.sort((a, b) => a.date.compareTo(b.date)); - - transactions = ObservableList.of( - sortedTransactions.map( - (transaction) => TransactionListItem( - transaction: transaction, - balanceViewModel: balanceViewModel, - appStore: appStore, - key: ValueKey('${_wallet.type.name}_transaction_history_item_${transaction.id}_key'), - ), - ), - ); + subname = ""; + _reloadTransactions(); } + _setupBitcoinAccountChangeReaction(_wallet); + // TODO: nano sub-account generation is disabled: // if (_wallet.type == WalletType.nano || _wallet.type == WalletType.banano) { // subname = nano!.getCurrentAccount(_wallet).label; @@ -201,7 +196,9 @@ abstract class DashboardViewModelBase with Store { _walletChangeDisposer?.reaction.dispose(); _walletChangeDisposer = reaction((_) => appStore.wallet, (wallet) { + accountListViewModel = accountListViewModelFactory(); _onWalletChange(wallet); + resetLightningMode(); _checkMweb(); loadCardDesigns(); showDecredInfoCard = wallet?.type == WalletType.decred && @@ -362,8 +359,14 @@ abstract class DashboardViewModelBase with Store { transactions.clear(); + final filteredTransactions = wallet.type == WalletType.bitcoin + ? bitcoin!.getCurrentAccountBitcoinTransactions(wallet) + : wallet.transactionHistory.transactions.values.toList(); + filteredTransactions.sort((a, b) => a.date.compareTo(b.date)); + + transactions.addAll( - wallet.transactionHistory.transactions.values.map( + filteredTransactions.map( (transaction) => TransactionListItem( transaction: transaction, balanceViewModel: balanceViewModel, @@ -425,60 +428,67 @@ abstract class DashboardViewModelBase with Store { @action Future loadCardDesigns() async { - final accountStyleSettings = + final walletCardStyleSettings = await BalanceCardStyleSettings.getAll(wallet.walletInfo.internalId); + final btcAccounts = + wallet.type == WalletType.bitcoin ? await wallet.walletInfo.getAccounts() : null; + final lightningCardIndex = btcAccounts?.length; - late final int numAccounts; + int numAccounts = 1; 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) { - // bitcoin and lightning - numAccounts = 2; - } else { - numAccounts = 1; + numAccounts = btcAccounts!.length + 1; // adding 1 for the lightning account } + + final usesAccountIndices = wallet.type == WalletType.bitcoin || balanceViewModel.hasAccounts; + cardDesigns.clear(); - Map newOrder = {}; + final newOrder = {}; + + final orderableCount = (wallet.type == WalletType.bitcoin) ? numAccounts - 1 : numAccounts; for (int i = 0; i < numAccounts; i++) { late final int index; - if (balanceViewModel.hasAccounts) { + final isLightning = wallet.type == WalletType.bitcoin && i == lightningCardIndex; + if (isLightning) { + index = -2; + } else if (usesAccountIndices) { index = i; - } else if (wallet.type == WalletType.bitcoin && i == 1) { - index = 0; } else { index = -1; } - final setting = accountStyleSettings.where((e) => e.accountIndex == index).firstOrNull; + final setting = walletCardStyleSettings.where((e) => e.accountIndex == index).firstOrNull ?? + (index == 0 && wallet.type != WalletType.bitcoin + ? walletCardStyleSettings.where((e) => e.accountIndex == -1).firstOrNull + : null); - late final CryptoCurrency curr; - if (wallet.type == WalletType.bitcoin && i == 1) { - curr = CryptoCurrency.btcln; - } else { - curr = wallet.currency; - } + final curr = isLightning ? CryptoCurrency.btcln : wallet.currency; cardDesigns.add(CardDesign.fromStyleSettings(setting, curr)); - if (setting?.cardOrder != null) { - newOrder[setting!.cardOrder] = i; + + if (isLightning) { + continue; + } + + if (setting?.cardOrder != null && + setting!.cardOrder >= 0 && + setting.cardOrder < orderableCount) { + newOrder[setting.cardOrder] = i; } } // making sure ALL accounts have numbers, even the ones that existed before this feature was a thing - for (int i = 0; i < numAccounts; i++) { - if (!newOrder.containsKey(i) && !(wallet.type != WalletType.bitcoin && i == 1)) { + for (int i = 0; i < orderableCount; i++) { + if (!newOrder.containsValue(i)) { int free = 0; - while (newOrder.containsValue(free)) { + while (newOrder.containsKey(free)) { free++; } - if (wallet.type == WalletType.bitcoin) { - newOrder[free] = 0; - } else { - newOrder[free] = i; - } + newOrder[free] = i; } } cardOrder = newOrder.asObservable(); @@ -496,19 +506,22 @@ abstract class DashboardViewModelBase with Store { : wallet.type == WalletType.wownero ? wow.wownero!.getCurrentAccount(wallet).id : null; - final List relevantTxs = []; - for (final tx in appStore.wallet!.transactionHistory.transactions.values) { - bool isRelevant = true; - if (wallet.type == WalletType.monero) { - isRelevant = monero!.getTransactionInfoAccountId(tx) == currentAccountId; - } else if (wallet.type == WalletType.wownero) { - isRelevant = wow.wownero!.getTransactionInfoAccountId(tx) == currentAccountId; - } + final allTxs = appStore.wallet!.transactionHistory.transactions.values; + final List relevantTxs; - if (isRelevant) { - relevantTxs.add(tx); - } + if (wallet.type == WalletType.monero) { + relevantTxs = allTxs + .where((tx) => monero!.getTransactionInfoAccountId(tx) == currentAccountId) + .toList(); + } else if (wallet.type == WalletType.wownero) { + relevantTxs = allTxs + .where((tx) => wow.wownero!.getTransactionInfoAccountId(tx) == currentAccountId) + .toList(); + } else if (wallet.type == WalletType.bitcoin) { + relevantTxs = bitcoin!.getCurrentAccountBitcoinTransactions(wallet); + } else { + relevantTxs = allTxs.toList(); } // printV("Transaction disposer callback (relevantTxs: ${relevantTxs.length} current: ${transactions.length})"); @@ -600,6 +613,12 @@ abstract class DashboardViewModelBase with Store { @observable ObservableMap cardOrder; + @observable + bool lightningMode = false; + + @observable + WalletAccountListViewModel? accountListViewModel; + @computed bool get isDarkTheme => appStore.themeStore.currentTheme.isDark; @@ -896,6 +915,42 @@ abstract class DashboardViewModelBase with Store { @observable late bool showSeedBackupReminder; + @computed + bool get canToggleMultiAccounts => wallet.canToggleMultiAccounts; + + @observable + late bool isMultiAccountsEnabled; + + @computed + List get visibleAccounts { + final all = accountListViewModel?.accounts; + if (all == null || all.isEmpty) return const []; + if (isMultiAccountsEnabled) return all.toList(growable: false); + final primary = all.where((a) => a.id == 0).firstOrNull ?? all.first; + return [primary]; + } + + @action + Future setMultiAccountsEnabled(bool value) async { + if (!canToggleMultiAccounts) { + return; + } + + if (!value) { + await _selectPrimaryAccount(); + } + + wallet.walletInfo.isMultiAccountsEnabled = value; + await wallet.walletInfo.save(); + isMultiAccountsEnabled = value; + await accountListViewModel?.reload(); + await loadCardDesigns(); + + if (value) { + unawaited(wallet.startSync()); + } + } + @computed bool get hasBalance => wallet.balance.values.any( (balance) => @@ -1161,6 +1216,8 @@ abstract class DashboardViewModelBase with Store { bitcoin!.updatePayjoinState(wallet, true); } + final WalletAccountListViewModel? Function() accountListViewModelFactory; + BalanceViewModel balanceViewModel; TradeMonitor tradeMonitor; @@ -1215,7 +1272,7 @@ abstract class DashboardViewModelBase with Store { @computed bool get isEnabledBulletinAction => !settingsStore.disableBulletin; - ReactionDisposer? _onMoneroAccountChangeReaction; + ReactionDisposer? _onAccountChangeReaction; ReactionDisposer? _onMoneroBalanceChangeReaction; @@ -1289,6 +1346,31 @@ abstract class DashboardViewModelBase with Store { } } + @action + void toggleLightningMode() { + lightningMode = !lightningMode; + + // Lightning belongs to the primary account only + if (lightningMode) { + unawaited(_selectPrimaryAccount()); + } + } + + Future _selectPrimaryAccount() async { + if (wallet.type != WalletType.bitcoin) return; + + final vm = accountListViewModel; + if (vm == null || vm.selectedAccount?.id == 0) return; + + final primary = vm.accounts.where((a) => a.id == 0).firstOrNull; + if (primary != null) { + await vm.select(primary); + } + } + + @action + void resetLightningMode() => lightningMode = false; + @action void _onWalletChange( WalletBase, TransactionInfo>? wallet) { @@ -1299,16 +1381,19 @@ abstract class DashboardViewModelBase with Store { this.wallet = wallet; type = wallet.type; name = wallet.name; + isMultiAccountsEnabled = wallet.walletInfo.isMultiAccountsEnabled == true; + + _onAccountChangeReaction?.reaction.dispose(); + _onAccountChangeReaction = null; loadFilterItems(); if (wallet.type == WalletType.monero) { subname = monero!.getCurrentAccount(wallet).label; - _onMoneroAccountChangeReaction?.reaction.dispose(); + _onAccountChangeReaction?.reaction.dispose(); _onMoneroBalanceChangeReaction?.reaction.dispose(); - _onMoneroAccountChangeReaction = reaction( - (_) => monero!.getMoneroWalletDetails(wallet).account, + _onAccountChangeReaction = reaction((_) => monero!.getMoneroWalletDetails(wallet).account, (Account account) => _onMoneroAccountChange(wallet)); _onMoneroBalanceChangeReaction = reaction( @@ -1319,12 +1404,12 @@ abstract class DashboardViewModelBase with Store { } else if (wallet.type == WalletType.wownero) { subname = wow.wownero!.getCurrentAccount(wallet).label; - _onMoneroAccountChangeReaction?.reaction.dispose(); + _onAccountChangeReaction?.reaction.dispose(); _onMoneroBalanceChangeReaction?.reaction.dispose(); - _onMoneroAccountChangeReaction = reaction( + _onAccountChangeReaction = reaction( (_) => wow.wownero!.getWowneroWalletDetails(wallet).account, - (wow.Account account) => _onMoneroAccountChange(wallet)); + (Account account) => _onMoneroAccountChange(wallet)); _onMoneroBalanceChangeReaction = reaction( (_) => wow.wownero!.getWowneroWalletDetails(wallet).balance, @@ -1335,7 +1420,7 @@ abstract class DashboardViewModelBase with Store { // FIX-ME: Check for side effects // subname = null; subname = ''; - + _setupBitcoinAccountChangeReaction(wallet); _reloadTransactions(); } @@ -1378,6 +1463,21 @@ abstract class DashboardViewModelBase with Store { }, _transactionDisposerCallback, delay: 300); } + void _setupBitcoinAccountChangeReaction(WalletBase wallet) { + if (wallet.type != WalletType.bitcoin) { + return; + } + + _onAccountChangeReaction?.reaction.dispose(); + _onAccountChangeReaction = reaction( + (_) => accountListViewModel?.selectedAccount?.id, + (_) { + _reloadTransactions(); + }, + fireImmediately: true, + ); + } + @action void _onMoneroAccountChange(WalletBase wallet) { if (wallet.type == WalletType.monero) { diff --git a/lib/view_model/dashboard/transaction_list_item.dart b/lib/view_model/dashboard/transaction_list_item.dart index 620dcd1e1..f72ab6da4 100644 --- a/lib/view_model/dashboard/transaction_list_item.dart +++ b/lib/view_model/dashboard/transaction_list_item.dart @@ -55,10 +55,6 @@ class TransactionListItem extends ActionListItem with Keyable { } String get formattedTitle { - if (balanceViewModel.wallet.type == WalletType.bitcoin && - transaction.additionalInfo['hasMissingInputTx'] == true) { - return 'Transaction has missing data'; - } if (transaction.additionalInfo['isIronwoodMigration'] == true) { return 'Migration'; diff --git a/lib/view_model/wallet_account_list/account_edit_or_create_view_model.dart b/lib/view_model/wallet_account_list/account_edit_or_create_view_model.dart new file mode 100644 index 000000000..0fb8dfd27 --- /dev/null +++ b/lib/view_model/wallet_account_list/account_edit_or_create_view_model.dart @@ -0,0 +1,9 @@ +import 'package:cake_wallet/core/execution_state.dart'; + +abstract class WalletAccountEditOrCreateViewModel { + bool get isEdit; + ExecutionState get state; + String get label; + set label(String value); + Future save(); +} diff --git a/lib/view_model/monero_account_list/account_list_item.dart b/lib/view_model/wallet_account_list/account_list_item.dart similarity index 100% rename from lib/view_model/monero_account_list/account_list_item.dart rename to lib/view_model/wallet_account_list/account_list_item.dart diff --git a/lib/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_edit_or_create_view_model.dart b/lib/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_edit_or_create_view_model.dart new file mode 100644 index 000000000..8e39c0640 --- /dev/null +++ b/lib/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_edit_or_create_view_model.dart @@ -0,0 +1,92 @@ +import 'dart:math'; + +import 'package:cake_wallet/bitcoin/bitcoin.dart'; +import 'package:cake_wallet/core/execution_state.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/account_edit_or_create_view_model.dart'; +import 'package:cw_core/balance_card_style_settings.dart'; +import 'package:cw_core/card_design.dart'; +import 'package:cw_core/wallet_base.dart'; +import 'package:flutter/material.dart'; +import 'package:mobx/mobx.dart'; + +part 'bitcoin_account_edit_or_create_view_model.g.dart'; + +class BitcoinAccountEditOrCreateViewModel = BitcoinAccountEditOrCreateViewModelBase + with _$BitcoinAccountEditOrCreateViewModel; + +abstract class BitcoinAccountEditOrCreateViewModelBase + with Store + implements WalletAccountEditOrCreateViewModel { + BitcoinAccountEditOrCreateViewModelBase({required WalletBase wallet}) + : state = InitialExecutionState(), + label = "", + _wallet = wallet; + + @override + bool get isEdit => false; + + @override + @observable + ExecutionState state; + + @override + @observable + String label; + + final WalletBase _wallet; + + Future> _getUsableCardGradients() async { + final List ret = List.from(CardDesign.allGradients); + final designs = (await BalanceCardStyleSettings.getAll(_wallet.walletInfo.internalId)) + .map((item) => CardDesign.fromStyleSettings(item, _wallet.currency)); + for (final design in designs) { + ret.remove(design.gradient); + } + return ret.isNotEmpty ? ret : CardDesign.allGradients; + } + + Future _saveRandomCardDesign(int accountIndex) async { + final gradients = await _getUsableCardGradients(); + final accounts = await _wallet.walletInfo.getAccounts(); + + await BalanceCardStyleSettings.fromCardDesign( + walletInfoId: _wallet.walletInfo.internalId, + accountIndex: accountIndex, + cardOrder: accounts.length - 1, + design: CardDesign.specialDesignsForCurrencies[_wallet.currency]! + .withGradient(gradients[Random().nextInt(gradients.length)])) + .insert(); + } + + @override + Future save() async { + try { + state = IsExecutingState(); + + final accountIndex = await _createAccount(label); + await _saveRandomCardDesign(accountIndex); + + await _wallet.save(); + state = ExecutedSuccessfullyState(); + } catch (e) { + state = FailureState(e.toString()); + } + } + + Future _createAccount(String label) async { + final accounts = await _wallet.walletInfo.getAccounts(); + + final nextIndex = accounts.isEmpty + ? 0 + : accounts.map((e) => e.accountIndex).reduce((a, b) => a > b ? a : b) + 1; + + await _wallet.walletInfo.addAccount( + accountIndex: nextIndex, + label: label.isEmpty ? "Account $nextIndex" : label, + ); + + await bitcoin!.setCurrentAccount(_wallet, nextIndex); + + return nextIndex; + } +} diff --git a/lib/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_list_view_model.dart b/lib/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_list_view_model.dart new file mode 100644 index 000000000..e97b844e2 --- /dev/null +++ b/lib/view_model/wallet_account_list/bitcoin_account_list/bitcoin_account_list_view_model.dart @@ -0,0 +1,130 @@ +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'; + +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) { + unawaited(_loadAccounts()); + + reaction( + (_) => bitcoin!.accountBalancesSnapshot(_wallet), + (snapshot) => unawaited(_refreshBalances(snapshot)), + equals: const MapEquality().equals, + ); + + reaction( + (_) => settingsStore.balanceDisplayMode, + (_) => unawaited(_recomputeBalanceStrings()), + ); + } + + final SettingsStore settingsStore; + final WalletBase _wallet; + + List? _cachedStoredAccounts; + Future? _loadAccountsFuture; + + CryptoCurrency get currency => _wallet.currency; + + @override + @observable + ObservableList accounts = ObservableList(); + + @override + @observable + AccountListItem? selectedAccount; + + @override + @action + Future select(AccountListItem account) async { + await bitcoin!.setCurrentAccount(_wallet, account.id); + runInAction(() => selectedAccount = account); + await reload(); + } + + @action + Future _loadAccounts() => _runLoad(() => _wallet.walletInfo.getAccounts()); + + @action + Future _refreshBalances(Map balancesSnapshot) { + final cached = _cachedStoredAccounts; + final hasUnknownAccount = cached == null || + balancesSnapshot.keys + .any((accountIndex) => !cached.any((a) => a.accountIndex == accountIndex)); + + if (hasUnknownAccount) return _loadAccounts(); + return _runLoad(() async => cached); + } + + + @action + Future _recomputeBalanceStrings() { + final cached = _cachedStoredAccounts; + if (cached == null) return _loadAccounts(); + return _runLoad(() async => cached); + } + + Future _runLoad(Future> Function() fetch) { + final inProgress = _loadAccountsFuture; + if (inProgress != null) { + return inProgress; + } + + final future = _applyAccounts(fetch); + _loadAccountsFuture = future; + return future.whenComplete(() => _loadAccountsFuture = null); + } + + Future _applyAccounts(Future> Function() fetch) async { + final storedAccounts = await fetch(); + _cachedStoredAccounts = storedAccounts; + + final currentAccountIndex = _wallet.walletInfo.currentAccountIndex; + final items = storedAccounts + .map((account) => AccountListItem( + id: account.accountIndex, + label: account.label, + balance: _balanceForAccount(account.accountIndex), + isSelected: account.accountIndex == currentAccountIndex, + )) + .toList(); + + runInAction(() { + accounts = ObservableList.of(items); + selectedAccount = items.isEmpty + ? null + : items.firstWhere( + (account) => account.isSelected, + orElse: () => items.first, + ); + }); + } + + @override + @action + Future reload() => _loadAccounts(); + + String _balanceForAccount(int accountIndex) { + if (settingsStore.balanceDisplayMode == BalanceDisplayMode.hiddenBalance) { + return '●●●●●●'; + } + final balance = bitcoin!.balanceForAccount(_wallet, accountIndex); + + return AmountParsingProxy(settingsStore.displayAmountsInSatoshi).asDisplayString(balance.available); + } +} diff --git a/lib/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart b/lib/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart similarity index 70% rename from lib/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart rename to lib/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart index 14438007d..da097e5e4 100644 --- a/lib/view_model/monero_account_list/monero_account_edit_or_create_view_model.dart +++ b/lib/view_model/wallet_account_list/monero_account_list/monero_account_edit_or_create_view_model.dart @@ -1,5 +1,6 @@ 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_style_settings.dart'; import 'package:cw_core/card_design.dart'; @@ -9,14 +10,16 @@ import 'package:flutter/material.dart'; import 'package:mobx/mobx.dart'; import 'package:cake_wallet/core/execution_state.dart'; import 'package:cake_wallet/monero/monero.dart'; -import 'package:cake_wallet/view_model/monero_account_list/account_list_item.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; part 'monero_account_edit_or_create_view_model.g.dart'; class MoneroAccountEditOrCreateViewModel = MoneroAccountEditOrCreateViewModelBase with _$MoneroAccountEditOrCreateViewModel; -abstract class MoneroAccountEditOrCreateViewModelBase with Store { +abstract class MoneroAccountEditOrCreateViewModelBase + with Store + implements WalletAccountEditOrCreateViewModel { MoneroAccountEditOrCreateViewModelBase(this._moneroAccountList, this._wowneroAccountList, {required WalletBase wallet, AccountListItem? accountListItem}) : state = InitialExecutionState(), @@ -61,49 +64,51 @@ abstract class MoneroAccountEditOrCreateViewModelBase with Store { } Future save() async { - await _saveRandomCardDesign(); - if (_wallet.type == WalletType.monero) { - await saveMonero(); - } + try { + state = IsExecutingState(); - if (_wallet.type == WalletType.wownero) { - await saveWownero(); + if (!isEdit) await _saveRandomCardDesign(); + + if (_wallet.type == WalletType.monero) { + await saveMonero(); + } else if (_wallet.type == WalletType.wownero) { + await saveWownero(); + } + + await _wallet.save(); + state = ExecutedSuccessfullyState(); + } catch (e) { + state = FailureState(e.toString()); } } Future saveMonero() async { - try { - state = IsExecutingState(); - - if (_accountListItem != null) { - await _moneroAccountList.setLabelAccount(_wallet, - accountIndex: _accountListItem.id, label: label); - } else { - await _moneroAccountList.addAccount(_wallet, label: label); - } - - await _wallet.save(); - state = ExecutedSuccessfullyState(); - } catch (e) { - state = FailureState(e.toString()); + if (_accountListItem != null) { + await _moneroAccountList.setLabelAccount( + _wallet, + accountIndex: _accountListItem.id, + label: label, + ); + } else { + await _moneroAccountList.addAccount( + _wallet, + label: label, + ); } } Future saveWownero() async { - try { - state = IsExecutingState(); - - if (_accountListItem != null) { - await _wowneroAccountList?.setLabelAccount(_wallet, - accountIndex: _accountListItem.id, label: label); - } else { - await _wowneroAccountList?.addAccount(_wallet, label: label); - } - - await _wallet.save(); - state = ExecutedSuccessfullyState(); - } catch (e) { - state = FailureState(e.toString()); + if (_accountListItem != null) { + await _wowneroAccountList?.setLabelAccount( + _wallet, + accountIndex: _accountListItem.id, + label: label, + ); + } else { + await _wowneroAccountList?.addAccount( + _wallet, + label: label, + ); } } } diff --git a/lib/view_model/monero_account_list/monero_account_list_view_model.dart b/lib/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart similarity index 84% rename from lib/view_model/monero_account_list/monero_account_list_view_model.dart rename to lib/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart index 7f37a81ca..2f04be7f8 100644 --- a/lib/view_model/monero_account_list/monero_account_list_view_model.dart +++ b/lib/view_model/wallet_account_list/monero_account_list/monero_account_list_view_model.dart @@ -1,29 +1,25 @@ 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/monero_account_list/account_list_item.dart'; +import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; import 'package:cake_wallet/monero/monero.dart'; part 'monero_account_list_view_model.g.dart'; class MoneroAccountListViewModel = MoneroAccountListViewModelBase with _$MoneroAccountListViewModel; -abstract class MoneroAccountListViewModelBase with Store { - MoneroAccountListViewModelBase(this._wallet, this.settingsStore) : scrollOffsetFromTop = 0; +abstract class MoneroAccountListViewModelBase with Store implements WalletAccountListViewModel { + MoneroAccountListViewModelBase(this._wallet, this.settingsStore); final SettingsStore settingsStore; - @observable - double scrollOffsetFromTop; - - @action - void setScrollOffsetFromTop(double scrollOffsetFromTop) { - this.scrollOffsetFromTop = scrollOffsetFromTop; - } + @override + AccountListItem? get selectedAccount => selected; CryptoCurrency get currency => _wallet.currency; @@ -65,7 +61,11 @@ abstract class MoneroAccountListViewModelBase with Store { final WalletBase _wallet; - void select(AccountListItem item) { + @override + Future reload() async {} + + @override + Future select(AccountListItem item) async { if (_wallet.type == WalletType.monero) { monero!.setCurrentAccount( _wallet, diff --git a/lib/view_model/nano_account_list/nano_account_edit_or_create_view_model.dart b/lib/view_model/wallet_account_list/nano_account_list/nano_account_edit_or_create_view_model.dart similarity index 100% rename from lib/view_model/nano_account_list/nano_account_edit_or_create_view_model.dart rename to lib/view_model/wallet_account_list/nano_account_list/nano_account_edit_or_create_view_model.dart diff --git a/lib/view_model/nano_account_list/nano_account_list_view_model.dart b/lib/view_model/wallet_account_list/nano_account_list/nano_account_list_view_model.dart similarity index 100% rename from lib/view_model/nano_account_list/nano_account_list_view_model.dart rename to lib/view_model/wallet_account_list/nano_account_list/nano_account_list_view_model.dart diff --git a/lib/view_model/wallet_account_list/wallet_account_list_view_model.dart b/lib/view_model/wallet_account_list/wallet_account_list_view_model.dart new file mode 100644 index 000000000..8d8824b98 --- /dev/null +++ b/lib/view_model/wallet_account_list/wallet_account_list_view_model.dart @@ -0,0 +1,14 @@ +import 'package:cake_wallet/view_model/wallet_account_list/account_list_item.dart'; +import 'package:cw_core/crypto_currency.dart'; + +abstract class WalletAccountListViewModel { + List get accounts; + + AccountListItem? get selectedAccount; + + CryptoCurrency get currency; + + Future select(AccountListItem account); + + Future reload(); +} diff --git a/res/pictures/settings_row_icons/accounts.svg b/res/pictures/settings_row_icons/accounts.svg index 6afc7088e..60a839a6a 100644 --- a/res/pictures/settings_row_icons/accounts.svg +++ b/res/pictures/settings_row_icons/accounts.svg @@ -1,27 +1,28 @@ - - - - - - - - + + + + + + - + - + - + - + + + + diff --git a/res/values/strings_ar.arb b/res/values/strings_ar.arb index c79d6bedd..7f80e1f0b 100644 --- a/res/values/strings_ar.arb +++ b/res/values/strings_ar.arb @@ -238,8 +238,6 @@ "color_theme": "سمة الألوان", "coming_soon_tag": "قريبًا", "commit_transaction_amount_fee": "تأكيد المعاملة\nالمبلغ: ${amount}\nالرسوم: ${fee}", - "compatible_services": "الخدمات المتوافقة", - "compatible_services_desc": "استخدم أداة المسح لقراءة الأنواع التالية من رموز QR المدعومة في Cake Wallet:", "compatible_networks": "الشبكات المتوافقة", "compatible_services": "الخدمات المتوافقة", "compatible_services_desc": "استخدم أداة المسح لقراءة الأنواع التالية من رموز QR المدعومة في Cake Wallet:", @@ -699,6 +697,7 @@ "moralis_nft_error": "حدث خطأ أثناء جلب رموز NFT. يُرجى التحقق من اتصالك بالإنترنت والمحاولة مرة أخرى.", "more_details": "مزيد من التفاصيل", "more_options": "مزيد من الخيارات", + "multiple_accounts": "حسابات متعددة", "multiple_addresses_detected": "تم اكتشاف عدة عناوين", "mweb_ad": "فعّل MWEB، طبقة الخصوصية لـ Litecoin التي تضيف مزيدًا من إخفاء الهوية لعملاتك", "mweb_confirmed": "MWEB مؤكَّد", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "جميع أحدث النماذج (GPT-5.3، Claude 4.6).\nبدون اشتراك، ادفع بالعملات المشفرة.", "narrow": "ضيق", "network": "الشبكة", - "networks": "الشبكات", "network_address_detected": "تم اكتشاف عنوان ${network}.", "network_prefix_on": "على", "network_switch_failed": "فشل التبديل إلى الشبكة المطلوبة. لم يتم تطبيق الدفع، يرجى المحاولة مرة أخرى.", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "خضعت Zcash لترقية شبكة تسمى Ironwood والتي تتطلب ترحيل الأموال قبل أن يتم إنفاقها. هذا الترحيل تلقائي، ويحافظ على الخصوصية، ويبدأ بمجرد فتح محفظة Zcash الخاصة بك.", "zcash_network_upgrade_desc_2": "عند انتهاء عملية الترحيل، سيعود رصيدك المتاح إلى وضعه الطبيعي، ويمكنك الاستمرار في إرسال واستقبال Zcash كالمعتاد.", "zcash_network_upgrade_desc_3": "يرجى السماح لمحفظة Zcash الخاصة بك بإنهاء المزامنة، ثم اترك التطبيق مفتوحًا حتى انتهاء الترحيل، الأمر الذي قد يستغرق بضع دقائق حسب الرصيد." -} +} \ No newline at end of file diff --git a/res/values/strings_bg.arb b/res/values/strings_bg.arb index eb9c596fa..daec5e533 100644 --- a/res/values/strings_bg.arb +++ b/res/values/strings_bg.arb @@ -238,8 +238,6 @@ "color_theme": "Цветова тема", "coming_soon_tag": "Очаквайте скоро", "commit_transaction_amount_fee": "Потвърдете транзакцията\nСума: ${amount}\nТакса: ${fee}", - "compatible_services": "Съвместими услуги", - "compatible_services_desc": "Използвайте инструмента за сканиране, за да четете следните типове QR кодове, поддържани в Cake Wallet:", "compatible_networks": "Съвместими мрежи", "compatible_services": "Съвместими услуги", "compatible_services_desc": "Използвайте инструмента за сканиране, за да четете следните типове QR кодове, поддържани в Cake Wallet:", @@ -699,6 +697,7 @@ "moralis_nft_error": "Възникна грешка при извличането на NFT. Моля, проверете интернет връзката си и опитайте отново.", "more_details": "Повече подробности", "more_options": "Още опции", + "multiple_accounts": "Множество сметки", "multiple_addresses_detected": "Открити са множество адреси", "mweb_ad": "Включете MWEB – слоя за поверителност на Litecoin, който добавя допълнителна анонимност към вашите монети", "mweb_confirmed": "Потвърдено MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Всички най-нови модели (GPT-5.3, Claude 4.6).\\nБез абонамент, плащайте с крипто.", "narrow": "Тесен", "network": "Мрежа", - "networks": "Мрежи", "network_address_detected": "Открит адрес на ${network}", "network_prefix_on": "на", "network_switch_failed": "Неуспешно превключване към заявената мрежа. Плащането не е приложено, моля опитайте отново.", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash претърпя надграждане на мрежата, наречено Ironwood, което изисква миграция на средства, преди да могат да бъдат изразходвани. Тази миграция е автоматична, запазвайки поверителността и започва веднага щом отворите Zcash портфейла си.", "zcash_network_upgrade_desc_2": "Когато миграцията приключи, вашият наличен баланс ще се върне към нормалното и можете да продължите да изпращате и получавате Zcash както обикновено.", "zcash_network_upgrade_desc_3": "Моля, оставете вашия Zcash портфейл да завърши синхронизирането и след това оставете приложението отворено, докато миграцията приключи, което може да отнеме няколко минути в зависимост от баланса." -} +} \ No newline at end of file diff --git a/res/values/strings_cs.arb b/res/values/strings_cs.arb index 922c90f18..c82c45a36 100644 --- a/res/values/strings_cs.arb +++ b/res/values/strings_cs.arb @@ -238,8 +238,6 @@ "color_theme": "Barevné téma", "coming_soon_tag": "Již brzy", "commit_transaction_amount_fee": "Potvrdit transakci\nČástka: ${amount}\nPoplatek: ${fee}", - "compatible_services": "Kompatibilní služby", - "compatible_services_desc": "Pomocí nástroje pro skenování načtěte následující typy QR kódů podporované v Cake Wallet:", "compatible_networks": "Kompatibilní sítě", "compatible_services": "Kompatibilní služby", "compatible_services_desc": "Pomocí nástroje pro skenování načtěte následující typy QR kódů podporované v Cake Wallet:", @@ -699,6 +697,7 @@ "moralis_nft_error": "Při načítání NFT došlo k chybě. Zkontrolujte prosím připojení k internetu a zkuste to znovu.", "more_details": "Více podrobností", "more_options": "Více možností", + "multiple_accounts": "Více účtů", "multiple_addresses_detected": "Detekováno více adres", "mweb_ad": "Povolte MWEB, vrstvu soukromí pro Litecoin, která vašim mincím přidává další anonymitu", "mweb_confirmed": "Potvrzené MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Všechny nejnovější modely (GPT-5.3, Claude 4.6).\nBez předplatného, plaťte kryptem.", "narrow": "Úzké", "network": "Síť", - "networks": "Sítě", "network_address_detected": "Zjištěna adresa ${network}", "network_prefix_on": "na", "network_switch_failed": "Přepnutí na požadovanou síť se nezdařilo. Platba nebyla připsána, zkuste to prosím znovu.", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash prošel upgradem sítě nazvaným Ironwood, který vyžaduje migraci finančních prostředků, než je lze utratit. Tato migrace je automatická, zachovává soukromí a začíná, jakmile otevřete peněženku Zcash.", "zcash_network_upgrade_desc_2": "Po dokončení migrace se váš disponibilní zůstatek vrátí do normálu a můžete nadále odesílat a přijímat Zcash jako obvykle.", "zcash_network_upgrade_desc_3": "Nechte prosím dokončit synchronizaci vaší peněženky Zcash a poté nechte aplikaci otevřenou, dokud nebude migrace dokončena, což může trvat několik minut v závislosti na zůstatku." -} +} \ No newline at end of file diff --git a/res/values/strings_de.arb b/res/values/strings_de.arb index 8c74c404c..fcd55f654 100644 --- a/res/values/strings_de.arb +++ b/res/values/strings_de.arb @@ -239,8 +239,6 @@ "color_theme": "Farbschema", "coming_soon_tag": "Demnächst verfügbar", "commit_transaction_amount_fee": "Transaktion bestätigen\nBetrag: ${amount}\nGebühr: ${fee}", - "compatible_services": "Kompatible Dienste", - "compatible_services_desc": "Verwenden Sie das Scan-Tool, um die folgenden von Cake Wallet unterstützten QR-Code-Typen zu lesen:", "compatible_networks": "Kompatible Netzwerke", "compatible_services": "Kompatible Dienste", "compatible_services_desc": "Verwenden Sie das Scan-Tool, um die folgenden von Cake Wallet unterstützten QR-Code-Typen zu lesen:", @@ -701,6 +699,7 @@ "moralis_nft_error": "Beim Abrufen von NFTs ist ein Fehler aufgetreten. Bitte überprüfen Sie Ihre Internetverbindung und versuchen Sie es erneut.", "more_details": "Weitere Details", "more_options": "Weitere Optionen", + "multiple_accounts": "Mehrere Konten", "multiple_addresses_detected": "Mehrere Adressen erkannt", "mweb_ad": "Aktivieren Sie MWEB, die Datenschutzebene für Litecoin, die Ihren Coins zusätzliche Anonymität verleiht", "mweb_confirmed": "Bestätigtes MWEB", @@ -715,7 +714,6 @@ "nanogpt_subtitle": "Alle neuesten Modelle (GPT-5.3, Claude 4.6).\nKein Abonnement – bezahlen Sie mit Krypto.", "narrow": "Schmal", "network": "Netzwerk", - "networks": "Netzwerke", "network_address_detected": "${network}-Adresse erkannt", "network_prefix_on": "auf", "network_switch_failed": "Der Wechsel zum angeforderten Netzwerk ist fehlgeschlagen. Die Zahlung wurde nicht ausgeführt. Bitte versuchen Sie es erneut.", @@ -803,7 +801,6 @@ "pairing_error": "Kopplungsfehler", "pairingInvalidEvent": "Ungültiges Pairing-Ereignis", "passphrase": "Passphrase (Optional)", - "passphrase_view_keys": "Passphrase", "passphrase_entry": "Passphrase-Eingabe", "passphrase_raw": "Passphrase", "passphrase_view_keys": "Passphrase", @@ -1580,9 +1577,9 @@ "zcash_card_enable_later": "Sie können diese Karte jederzeit später in den Einstellungen aktivieren", "zcash_card_missing_funds": "Fehlendes Guthaben?", "zcash_card_scan": "Scannen", + "zcash_card_warning": "Schließen Sie die App nicht, bis der Vorgang abgeschlossen ist. Andernfalls muss der Vorgang von Grund auf neu gestartet werden.", "zcash_network_upgrade": "Zcash-Netzwerk-Upgrade", "zcash_network_upgrade_desc_1": "Das Zcash-Netzwerk wurde auf Ironwood aktualisiert. Damit Ihr Guthaben wieder ausgegeben werden kann, muss es migriert werden. Die Migration erfolgt automatisch und unter Wahrung Ihrer Privatsphäre. Sie beginnt, sobald Sie Ihre Zcash-Wallet öffnen.", "zcash_network_upgrade_desc_2": "Nach Abschluss der Migration wird Ihr verfügbares Guthaben wieder wie gewohnt angezeigt und Sie können Zcash wieder normal senden und empfangen.", - "zcash_network_upgrade_desc_3": "Bitte lassen Sie Ihre Zcash-Wallet vollständig synchronisieren und anschließend die App geöffnet, bis die Migration abgeschlossen ist. Dies kann je nach Guthaben einige Minuten dauern.", - "zcash_card_warning": "Schließen Sie die App nicht, bis der Vorgang abgeschlossen ist. Andernfalls muss der Vorgang von Grund auf neu gestartet werden." -} + "zcash_network_upgrade_desc_3": "Bitte lassen Sie Ihre Zcash-Wallet vollständig synchronisieren und anschließend die App geöffnet, bis die Migration abgeschlossen ist. Dies kann je nach Guthaben einige Minuten dauern." +} \ No newline at end of file diff --git a/res/values/strings_en.arb b/res/values/strings_en.arb index 06a380dbe..50e1ec7ba 100644 --- a/res/values/strings_en.arb +++ b/res/values/strings_en.arb @@ -270,8 +270,8 @@ "connect_your_hardware_wallet_ble": "Connect your hardware wallet using Bluetooth", "connect_your_hardware_wallet_usb": "Connect your hardware wallet using USB", "connected": "Connected", - "connection_sync": "Connection and sync", "connecting_you_to": "Connecting you to", + "connection_sync": "Connection and sync", "connections": "Connections", "connections_desc": "Manage connections to services and third party APIs.", "connectWalletPrompt": "Connect your wallet with WalletConnect to make transactions", @@ -706,6 +706,7 @@ "moralis_nft_error": "An error occurred while fetching NFTs. Kindly check your internet connection and try again.", "more_details": "More Details", "more_options": "More Options", + "multiple_accounts": "Multiple Accounts", "multiple_addresses_detected": "Multiple addresses detected", "mweb_ad": "Enable MWEB, the privacy layer for Litecoin that adds extra anonymity to your coins", "mweb_confirmed": "Confirmed MWEB", @@ -879,8 +880,8 @@ "privacy_settings": "Privacy settings", "private_key": "Private key", "private_memo_optional": "Private Memo (optional)", - "proceed_after_one_minute": "If the screen doesn’t proceed after 1 minute, check your email.", "proceed": "Proceed", + "proceed_after_one_minute": "If the screen doesn’t proceed after 1 minute, check your email.", "proceed_on_device": "Proceed on your device", "proceed_on_device_description": "Please follow the instructions prompted on your hardware wallet", "processing": "Processing", @@ -1431,9 +1432,9 @@ "unsupported_asset": "We don't support this action for this asset. Please create or switch to a wallet of a supported asset type.", "unsupported_network_requested": "This payment request is for a network we don't support (chain ID ${chainId}).", "unsupported_token_requested": "This payment request is for a token we couldn't identify on the selected network.", + "up_to": "Up to", "uptime": "Uptime", "upto": "up to ${value}", - "up_to": "Up to", "usb": "USB", "use": "Switch to ", "use_blink_protection": "Use Blink Protection", @@ -1587,4 +1588,4 @@ "zcash_network_upgrade_desc_1": "Zcash has undergone a network upgrade called Ironwood that requires migration of funds before they can be spent. This migration is automatic, privacy-preserving, and starts as soon as you open your Zcash wallet.", "zcash_network_upgrade_desc_2": "When the migration finishes, your Available Balance will return to normal, and you can continue to send and receive Zcash as usual.", "zcash_network_upgrade_desc_3": "Please allow your Zcash wallet to finishing syncing, and then leave the app open until migration finishes which can take a few minutes depending on the balance." -} +} \ No newline at end of file diff --git a/res/values/strings_es.arb b/res/values/strings_es.arb index d1d5ae934..62f5a930a 100644 --- a/res/values/strings_es.arb +++ b/res/values/strings_es.arb @@ -238,8 +238,6 @@ "color_theme": "Tema de color", "coming_soon_tag": "Próximamente", "commit_transaction_amount_fee": "Confirmar transacción\nCantidad: ${amount}\nComisión: ${fee}", - "compatible_services": "Servicios compatibles", - "compatible_services_desc": "Usa la herramienta Escanear para leer los siguientes tipos de códigos QR compatibles con Cake Wallet:", "compatible_networks": "Redes compatibles", "compatible_services": "Servicios compatibles", "compatible_services_desc": "Usa la herramienta Escanear para leer los siguientes tipos de códigos QR compatibles con Cake Wallet:", @@ -699,6 +697,7 @@ "moralis_nft_error": "Se produjo un error al obtener los NFT. Por favor, verifica tu conexión a Internet y vuelve a intentarlo.", "more_details": "Más detalles", "more_options": "Más opciones", + "multiple_accounts": "Varias cuentas", "multiple_addresses_detected": "Se detectaron varias direcciones", "mweb_ad": "Habilita MWEB, la capa de privacidad de Litecoin que añade anonimato adicional a tus monedas", "mweb_confirmed": "MWEB confirmado", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Todos los modelos más recientes (GPT-5.3, Claude 4.6).\nSin suscripción, paga con cripto.", "narrow": "Estrecho", "network": "Red", - "networks": "Redes", "network_address_detected": "${network} dirección detectada", "network_prefix_on": "en", "network_switch_failed": "No se pudo cambiar a la red solicitada. El pago no se aplicó, inténtelo nuevamente.", @@ -1583,4 +1581,4 @@ "zcash_network_upgrade_desc_1": "Zcash ha experimentado una actualización de red llamada Ironwood que requiere la migración de fondos antes de que puedan gastarse. Esta migración es automática, preserva la privacidad y comienza tan pronto como abre su billetera Zcash.", "zcash_network_upgrade_desc_2": "Cuando finalice la migración, su Saldo disponible volverá a la normalidad y podrá continuar enviando y recibiendo Zcash como de costumbre.", "zcash_network_upgrade_desc_3": "Permita que su billetera Zcash termine de sincronizarse y luego deje la aplicación abierta hasta que finalice la migración, lo que puede demorar unos minutos dependiendo del saldo." -} +} \ No newline at end of file diff --git a/res/values/strings_fa.arb b/res/values/strings_fa.arb index 845556d2a..9e0ec0245 100644 --- a/res/values/strings_fa.arb +++ b/res/values/strings_fa.arb @@ -238,8 +238,6 @@ "color_theme": "تم رنگ", "coming_soon_tag": "به‌زودی", "commit_transaction_amount_fee": "ثبت تراکنش\nمقدار: ${amount}\nکارمزد: ${fee}", - "compatible_services": "سرویس‌های سازگار", - "compatible_services_desc": "از ابزار اسکن برای خواندن انواع کدهای QR زیر که در Cake Wallet پشتیبانی می‌شوند استفاده کنید:", "compatible_networks": "شبکه های سازگار", "compatible_services": "سرویس‌های سازگار", "compatible_services_desc": "از ابزار اسکن برای خواندن انواع کدهای QR زیر که در Cake Wallet پشتیبانی می‌شوند استفاده کنید:", @@ -699,6 +697,7 @@ "moralis_nft_error": "هنگام دریافت NFTها خطایی رخ داد.\nلطفاً اتصال اینترنت خود را بررسی کنید و دوباره تلاش کنید.", "more_details": "جزئیات بیشتر", "more_options": "گزینه‌های بیشتر", + "multiple_accounts": "حساب های چندگانه", "multiple_addresses_detected": "چندین آدرس شناسایی شد", "mweb_ad": "MWEB را فعال کنید؛ لایهٔ حریم خصوصی لایت‌کوین که ناشناس‌بودن بیشتری به کوین‌های شما اضافه می‌کند", "mweb_confirmed": "MWEB تأییدشده", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "همه جدیدترین مدل‌ها (GPT-5.3, Claude 4.6).\nبدون اشتراک، پرداخت با کریپتو.", "narrow": "باریک", "network": "شبکه", - "networks": "شبکه‌ها", "network_address_detected": "آدرس ${network} شناسایی شد", "network_prefix_on": "در", "network_switch_failed": "تغییر به شبکه درخواستی انجام نشد. پرداخت اعمال نشد، لطفاً دوباره امتحان کنید.", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash تحت یک ارتقاء شبکه به نام Ironwood قرار گرفته است که قبل از خرج کردن، نیاز به انتقال سرمایه دارد. این انتقال خودکار است و حریم خصوصی را حفظ می کند و به محض باز کردن کیف پول Zcash خود شروع می شود.", "zcash_network_upgrade_desc_2": "هنگامی که انتقال به پایان رسید، موجودی موجود شما به حالت عادی باز می گردد و می توانید طبق معمول به ارسال و دریافت Zcash ادامه دهید.", "zcash_network_upgrade_desc_3": "لطفاً به کیف پول Zcash خود اجازه دهید تا همگام‌سازی تمام شود، و سپس برنامه را باز بگذارید تا انتقال به پایان برسد که بسته به موجودی می‌تواند چند دقیقه طول بکشد." -} +} \ No newline at end of file diff --git a/res/values/strings_fr.arb b/res/values/strings_fr.arb index 660e1213f..2e44f9036 100644 --- a/res/values/strings_fr.arb +++ b/res/values/strings_fr.arb @@ -238,8 +238,6 @@ "color_theme": "Thème de couleur", "coming_soon_tag": "Bientôt disponible", "commit_transaction_amount_fee": "Confirmer la transaction\nMontant : ${amount}\nFrais : ${fee}", - "compatible_services": "Services compatibles", - "compatible_services_desc": "Utilisez l'outil Scan pour lire les types de codes QR suivants pris en charge par Cake Wallet :", "compatible_networks": "Réseaux compatibles", "compatible_services": "Services compatibles", "compatible_services_desc": "Utilisez l'outil Scan pour lire les types de codes QR suivants pris en charge par Cake Wallet :", @@ -699,6 +697,7 @@ "moralis_nft_error": "Une erreur s'est produite lors de la récupération des NFT. Veuillez vérifier votre connexion Internet et réessayer.", "more_details": "Plus de détails", "more_options": "Plus d’options", + "multiple_accounts": "Plusieurs comptes", "multiple_addresses_detected": "Plusieurs adresses détectées", "mweb_ad": "Activez MWEB, la couche de confidentialité de Litecoin qui ajoute une anonymisation supplémentaire à vos coins", "mweb_confirmed": "MWEB confirmé", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Tous les modèles les plus récents (GPT-5.3, Claude 4.6).\nPas d'abonnement, payez en crypto.", "narrow": "Étroit", "network": "Réseau", - "networks": "Réseaux", "network_address_detected": "Adresse ${network} détectée", "network_prefix_on": "sur", "network_switch_failed": "Échec du basculement vers le réseau demandé. Le paiement n'a pas été appliqué, veuillez réessayer.", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash a fait l'objet d'une mise à niveau du réseau appelée Ironwood qui nécessite la migration des fonds avant de pouvoir les dépenser. Cette migration est automatique, préserve la confidentialité et démarre dès que vous ouvrez votre portefeuille Zcash.", "zcash_network_upgrade_desc_2": "Une fois la migration terminée, votre solde disponible reviendra à la normale et vous pourrez continuer à envoyer et recevoir du Zcash comme d'habitude.", "zcash_network_upgrade_desc_3": "Veuillez laisser votre portefeuille Zcash terminer la synchronisation, puis laisser l'application ouverte jusqu'à la fin de la migration, ce qui peut prendre quelques minutes en fonction du solde." -} +} \ No newline at end of file diff --git a/res/values/strings_gn.arb b/res/values/strings_gn.arb index f82152a74..0c7ff9f11 100644 --- a/res/values/strings_gn.arb +++ b/res/values/strings_gn.arb @@ -238,8 +238,6 @@ "color_theme": "Téma sa'y", "coming_soon_tag": "Og̃uahẽ pototáma", "commit_transaction_amount_fee": "Emoneĩ ñembohasa\nMboheta: ${amount}\nHepyme’ẽ: ${fee}", - "compatible_services": "Servicio-kuéra Ojokupytýva", - "compatible_services_desc": "Eipuru pe Scan tembiporu emoñe'ẽ hag̃ua ko'ã QR código Cake Wallet oipytyvõva:", "compatible_networks": "Redes Ojogueraháva", "compatible_services": "Servicio-kuéra Ojokupytýva", "compatible_services_desc": "Eipuru pe Scan tembiporu emoñe'ẽ hag̃ua ko'ã QR código Cake Wallet oipytyvõva:", @@ -282,7 +280,6 @@ "contact_support": "Eñe’ẽ Support ndive", "contacts": "Contactos", "continue_text": "Eku'e", - "contract_address": "Contrato Kundaharape", "continue_to_swap": "Esegíkena eintercambia", "contract_address": "Contrato Kundaharape", "contract_warning": "Ko contrato dirección ojehechakuaa hag̃ua ikatuha haʼe peteĩ ñembotavy térã mba’e vai. Eñangareko porãke.", @@ -478,7 +475,6 @@ "etherscan_history": "Etherscan rembiasakue", "event": "Tembiaporã", "events": "Tembiaporã", - "evm_ecosystem_description": "Tekotevẽramo, emoambue pe red ha token reipuruséva ko jehepyme'ẽme g̃uarã.", "evm_address_detected": "EVM Dirección Ojejuhúva", "evm_ecosystem_description": "Tekotevẽramo, emoambue pe red ha token reipuruséva ko jehepyme'ẽme g̃uarã.", "exchange": "Mbojopyru", @@ -703,6 +699,7 @@ "moralis_nft_error": "Oiko peteĩ jejavy oñembyaty aja umi NFT. Ehecha porãke ne ñanduti joaju ha eha'ã jey.", "more_details": "Detallesve", "more_options": "Hetave jeporavorã", + "multiple_accounts": "Heta Cuenta rehegua", "multiple_addresses_detected": "Heta kundaharape ojejuhu", "mweb_ad": "Emboguata MWEB, pe capa ñemigua Litecoin-pe g̃uarã omoĩva anonimato extra nde viru'ikuérape", "mweb_confirmed": "MWEB Oñemoañetéva", @@ -717,7 +714,6 @@ "nanogpt_subtitle": "Opaite umi modelo ipyahuvéva (GPT-5.3, Claude 4.6).\nNdaipóri suscripción, ehepyme’ẽ cripto rupive.", "narrow": "Poʼi", "network": "Red", - "networks": "Red-kuéra", "network_address_detected": "${network} Dirección Ojejuhúva", "network_prefix_on": "pe", "network_switch_failed": "Ndojehechái oñembohasa hag̃ua red ojejerurévape. Pe pago ndojejapóikuri, eñeha’ã jey.", @@ -1241,8 +1237,6 @@ "support_title_other_links": "Ambue juaju pytyvõ rehegua", "supported": "Ojepytyvõva", "swap": "Swap", - "supported": "Ojepytyvõva", - "swap": "Swap", "swap_from_network": "Embohasa ${network} guive.", "swap_from_network_description": "Emondo hag̃ua viru ${targetNetwork} red-pe, tekotevẽta rembohasa ne ${currentNetwork} viru. ¿Reprocedese piko?", "swap_id": "ID ñemoambue rehegua", @@ -1257,7 +1251,6 @@ "swipe_to_send": "Emoinge hag̃ua emondo hag̃ua.", "switch_camera": "Ombohasa cámara", "switch_input_currency": "Oñemoambue cripto ha fiat jeike apytépe", - "switch_wallet": "Emoambue Billetera", "switch_to_x_wallet": "Embohasa ${network} Wallet-pe", "switch_wallet": "Emoambue Billetera", "switchToETHWallet": "Ikatúpa emoambue peteĩ billetera Ethereum-pe ha eha’ãjey.", @@ -1594,4 +1587,4 @@ "zcash_network_upgrade_desc_1": "Zcash ohasa peteî actualización red hérava Ironwood oikotevêva migración de fondos ikatu mboyve ojegasta. Ko migración ha’e automática, oñongatúva privacidad ha oñepyrũ eipe’a riremínte nde billetera Zcash.", "zcash_network_upgrade_desc_2": "Opa vove pe migración, nde Saldo Disponible ohojeýta normal-pe, ha ikatu esegi emondo ha eguerahauka Zcash jepiveguáicha.", "zcash_network_upgrade_desc_3": "Eheja ne billetera Zcash omohu’ã sincronización, ha upéi eheja pe app ojepe’a opa peve migración ikatúva ogueraha mbovymi minuto odependéva saldo rehe." -} +} \ No newline at end of file diff --git a/res/values/strings_ha.arb b/res/values/strings_ha.arb index cba6fd6e7..981818a46 100644 --- a/res/values/strings_ha.arb +++ b/res/values/strings_ha.arb @@ -238,8 +238,6 @@ "color_theme": "Jigon launi", "coming_soon_tag": "Zai zo nan ba da jimawa ba", "commit_transaction_amount_fee": "Tabbatar da ma'amala\nAdadi: ${amount}\nKuɗi: ${fee}", - "compatible_services": "Sabis masu jituwa", - "compatible_services_desc": "Yi amfani da kayan aikin Scan don karanta nau'ikan QR code masu zuwa da Cake Wallet ke goyon baya:", "compatible_networks": "Hanyoyin sadarwa masu jituwa", "compatible_services": "Sabis masu jituwa", "compatible_services_desc": "Yi amfani da kayan aikin Scan don karanta nau'ikan QR code masu zuwa da Cake Wallet ke goyon baya:", @@ -699,6 +697,7 @@ "moralis_nft_error": "Kuskure ya faru yayin da ake kawo NFTs. Da fatan za a duba haɗin intanet ɗinka kuma a sake gwadawa.", "more_details": "Ƙarin Bayani", "more_options": "Ƙarin Zaɓuɓɓuka", + "multiple_accounts": "Asusu da yawa", "multiple_addresses_detected": "An gano adiresoshi da yawa", "mweb_ad": "Kunna MWEB, layin sirri na Litecoin wanda ke ƙara ƙarin ɓoyewa ga tsabar kuɗin ku", "mweb_confirmed": "MWEB da aka tabbatar", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Duk sabbin samfura (GPT-5.3, Claude 4.6).\nBabu biyan kuɗin rajista, biya da crypto.", "narrow": "Kunkuntar", "network": "Hanyar sadarwa", - "networks": "Hanyoyin sadarwa", "network_address_detected": "An gano adireshin ${network}", "network_prefix_on": "kan", "network_switch_failed": "An kasa canzawa zuwa cibiyar sadarwar da ake nema. Ba a yi amfani da kuɗin ba, da fatan za a sake gwadawa.", @@ -1584,4 +1582,4 @@ "zcash_network_upgrade_desc_1": "Zcash ya sami haɓaka hanyar sadarwa mai suna Ironwood wanda ke buƙatar ƙaura na kuɗi kafin a kashe su. Wannan ƙaura ta atomatik ce, tana kiyaye sirri, kuma tana farawa da zaran ka buɗe jakar kuɗin Zcash.", "zcash_network_upgrade_desc_2": "Lokacin da ƙaura ta ƙare, Ma'aunin Ma'aunin ku na samuwa zai dawo daidai, kuma za ku iya ci gaba da aikawa da karɓar Zcash kamar yadda kuka saba.", "zcash_network_upgrade_desc_3": "Da fatan za a ba da izinin walat ɗin ku na Zcash don gama daidaitawa, sannan ku bar app ɗin a buɗe har sai ƙaura ta ƙare wanda zai iya ɗaukar ƴan mintuna ya danganta da ma'auni." -} +} \ No newline at end of file diff --git a/res/values/strings_hi.arb b/res/values/strings_hi.arb index 0af5fb545..86624c9b0 100644 --- a/res/values/strings_hi.arb +++ b/res/values/strings_hi.arb @@ -238,8 +238,6 @@ "color_theme": "रंग थीम", "coming_soon_tag": "जल्द आ रहा है", "commit_transaction_amount_fee": "लेन-देन कमिट करें\nराशि: ${amount}\nशुल्क: ${fee}", - "compatible_services": "संगत सेवाएँ", - "compatible_services_desc": "Cake Wallet पर समर्थित निम्न प्रकार के QR कोड पढ़ने के लिए स्कैन टूल का उपयोग करें:", "compatible_networks": "संगत नेटवर्क", "compatible_services": "संगत सेवाएँ", "compatible_services_desc": "Cake Wallet पर समर्थित निम्न प्रकार के QR कोड पढ़ने के लिए स्कैन टूल का उपयोग करें:", @@ -699,6 +697,7 @@ "moralis_nft_error": "NFTs प्राप्त करते समय एक त्रुटि हुई। कृपया अपना इंटरनेट कनेक्शन जाँचें और फिर से प्रयास करें।", "more_details": "अधिक विवरण", "more_options": "अधिक विकल्प", + "multiple_accounts": "एकाधिक खाते", "multiple_addresses_detected": "कई पते पाए गए", "mweb_ad": "MWEB सक्षम करें, Litecoin के लिए गोपनीयता लेयर जो आपके कॉइन्स में अतिरिक्त गुमनामी जोड़ती है", "mweb_confirmed": "पुष्ट MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "सभी नवीनतम मॉडल (GPT-5.3, Claude 4.6)।\nकोई सब्सक्रिप्शन नहीं, क्रिप्टो से भुगतान करें।", "narrow": "संकीर्ण", "network": "नेटवर्क", - "networks": "नेटवर्क", "network_address_detected": "${network} पता मिला", "network_prefix_on": "पर", "network_switch_failed": "अनुरोधित नेटवर्क पर स्विच करने में विफल. भुगतान लागू नहीं हुआ, कृपया पुनः प्रयास करें।", @@ -1583,4 +1581,4 @@ "zcash_network_upgrade_desc_1": "ज़कैश ने आयरनवुड नामक एक नेटवर्क अपग्रेड किया है जिसके लिए धन को खर्च करने से पहले स्थानांतरण की आवश्यकता होती है। यह माइग्रेशन स्वचालित, गोपनीयता-संरक्षित है, और जैसे ही आप अपना Zcash वॉलेट खोलते हैं, शुरू हो जाता है।", "zcash_network_upgrade_desc_2": "जब माइग्रेशन समाप्त हो जाएगा, तो आपकी उपलब्ध शेष राशि सामान्य हो जाएगी, और आप हमेशा की तरह Zcash भेजना और प्राप्त करना जारी रख सकते हैं।", "zcash_network_upgrade_desc_3": "कृपया अपने Zcash वॉलेट को सिंकिंग समाप्त करने की अनुमति दें, और फिर माइग्रेशन समाप्त होने तक ऐप को खुला छोड़ दें, जिसमें शेष राशि के आधार पर कुछ मिनट लग सकते हैं।" -} +} \ No newline at end of file diff --git a/res/values/strings_hr.arb b/res/values/strings_hr.arb index 9b42201a4..76d627115 100644 --- a/res/values/strings_hr.arb +++ b/res/values/strings_hr.arb @@ -238,8 +238,6 @@ "color_theme": "Tema boja", "coming_soon_tag": "Uskoro", "commit_transaction_amount_fee": "Potvrdi transakciju\nIznos: ${amount}\nNaknada: ${fee}", - "compatible_services": "Kompatibilne usluge", - "compatible_services_desc": "Upotrijebite alat Skeniraj za čitanje sljedećih vrsta QR kodova podržanih u Cake Walletu:", "compatible_networks": "Kompatibilne mreže", "compatible_services": "Kompatibilne usluge", "compatible_services_desc": "Upotrijebite alat Skeniraj za čitanje sljedećih vrsta QR kodova podržanih u Cake Walletu:", @@ -699,6 +697,7 @@ "moralis_nft_error": "Došlo je do pogreške prilikom dohvaćanja NFT-ova. Provjerite internetsku vezu i pokušajte ponovno.", "more_details": "Više detalja", "more_options": "Više opcija", + "multiple_accounts": "Višestruki računi", "multiple_addresses_detected": "Otkriveno je više adresa", "mweb_ad": "Omogućite MWEB, sloj privatnosti za Litecoin koji vašim kovanicama dodaje dodatnu anonimnost", "mweb_confirmed": "Potvrđeni MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Svi najnoviji modeli (GPT-5.3, Claude 4.6).\nBez pretplate, plaćajte kriptom.", "narrow": "Usko", "network": "Mreža", - "networks": "Mreže", "network_address_detected": "Otkrivena ${network} adresa", "network_prefix_on": "na", "network_switch_failed": "Nije uspjelo prebacivanje na traženu mrežu. Plaćanje nije primijenjeno, pokušajte ponovno.", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash je prošao mrežnu nadogradnju pod nazivom Ironwood koja zahtijeva migraciju sredstava prije nego što se mogu potrošiti. Ova migracija je automatska, čuva privatnost i počinje čim otvorite svoj Zcash novčanik.", "zcash_network_upgrade_desc_2": "Kada migracija završi, vaš će se raspoloživi saldo vratiti u normalu i možete nastaviti slati i primati Zcash kao i obično.", "zcash_network_upgrade_desc_3": "Dopustite svom Zcash novčaniku da dovrši sinkronizaciju, a zatim ostavite aplikaciju otvorenom dok migracija ne završi što može potrajati nekoliko minuta ovisno o stanju." -} +} \ No newline at end of file diff --git a/res/values/strings_hy.arb b/res/values/strings_hy.arb index 19795bfc6..21dde734e 100644 --- a/res/values/strings_hy.arb +++ b/res/values/strings_hy.arb @@ -238,8 +238,6 @@ "color_theme": "Գունային թեմա", "coming_soon_tag": "Շուտով", "commit_transaction_amount_fee": "Հաստատել գործարքը\nԳումար՝ ${amount}\nՄիջնորդավճար՝ ${fee}", - "compatible_services": "Համատեղելի ծառայություններ", - "compatible_services_desc": "Օգտագործեք Սկանավորման գործիքը՝ Cake Wallet-ի կողմից աջակցվող QR կոդերի հետևյալ տեսակները կարդալու համար՝", "compatible_networks": "Համատեղելի ցանցեր", "compatible_services": "Համատեղելի ծառայություններ", "compatible_services_desc": "Օգտագործեք Սկանավորման գործիքը՝ Cake Wallet-ի կողմից աջակցվող QR կոդերի հետևյալ տեսակները կարդալու համար՝", @@ -699,6 +697,7 @@ "moralis_nft_error": "NFT-ները բեռնելիս սխալ է տեղի ունեցել։ Խնդրում ենք ստուգել ձեր ինտերնետային կապը և կրկին փորձել։", "more_details": "Ավելի մանրամասներ", "more_options": "Լրացուցիչ տարբերակներ", + "multiple_accounts": "Բազմաթիվ հաշիվներ", "multiple_addresses_detected": "Հայտնաբերվել են բազմաթիվ հասցեներ", "mweb_ad": "Միացնել MWEB-ը՝ Litecoin-ի գաղտնիության շերտը, որը լրացուցիչ անանունություն է ապահովում ձեր մետաղադրամների համար", "mweb_confirmed": "Հաստատված MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Բոլոր ամենանոր մոդելները (GPT-5.3, Claude 4.6).\nԱռանց բաժանորդագրության՝ վճարեք կրիպտոարժույթով։", "narrow": "Նեղ", "network": "Ցանց", - "networks": "Ցանցեր", "network_address_detected": "Հայտնաբերվել է ${network} հասցե", "network_prefix_on": "վրա", "network_switch_failed": "Չհաջողվեց անցնել պահանջվող ցանցին: Վճարումը չի կիրառվել, խնդրում ենք կրկին փորձել:", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash-ը ենթարկվել է ցանցի արդիականացման, որը կոչվում է Ironwood, որը պահանջում է միջոցների միգրացիա՝ նախքան դրանք ծախսելը: Այս միգրացիան ավտոմատ է, պահպանում է գաղտնիությունը և սկսվում է ձեր Zcash դրամապանակը բացելուց անմիջապես հետո:", "zcash_network_upgrade_desc_2": "Երբ միգրացիան ավարտվի, ձեր հասանելի մնացորդը կվերադառնա նորմալ, և դուք կարող եք շարունակել ուղարկել և ստանալ Zcash, ինչպես միշտ:", "zcash_network_upgrade_desc_3": "Խնդրում ենք թույլ տալ, որ ձեր Zcash դրամապանակն ավարտի համաժամացումը, այնուհետև թողեք հավելվածը բաց, մինչև ավարտվի միգրացիան, որը կարող է տևել մի քանի րոպե՝ կախված մնացորդից:" -} +} \ No newline at end of file diff --git a/res/values/strings_id.arb b/res/values/strings_id.arb index 952afe604..6574a60cb 100644 --- a/res/values/strings_id.arb +++ b/res/values/strings_id.arb @@ -238,8 +238,6 @@ "color_theme": "Tema warna", "coming_soon_tag": "Segera hadir", "commit_transaction_amount_fee": "Konfirmasi transaksi\nJumlah: ${amount}\nBiaya: ${fee}", - "compatible_services": "Layanan yang Kompatibel", - "compatible_services_desc": "Gunakan alat Pindai untuk membaca jenis kode QR berikut yang didukung di Cake Wallet:", "compatible_networks": "Jaringan yang Kompatibel", "compatible_services": "Layanan yang Kompatibel", "compatible_services_desc": "Gunakan alat Pindai untuk membaca jenis kode QR berikut yang didukung di Cake Wallet:", @@ -699,6 +697,7 @@ "moralis_nft_error": "Terjadi kesalahan saat mengambil NFT. Silakan periksa koneksi internet Anda dan coba lagi.", "more_details": "Detail Selengkapnya", "more_options": "Opsi Lainnya", + "multiple_accounts": "Banyak Akun", "multiple_addresses_detected": "Beberapa alamat terdeteksi", "mweb_ad": "Aktifkan MWEB, lapisan privasi untuk Litecoin yang menambahkan anonimitas ekstra pada koin Anda", "mweb_confirmed": "MWEB Terkonfirmasi", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Semua model terbaru (GPT-5.3, Claude 4.6).\nTanpa langganan, bayar dengan kripto.", "narrow": "Sempit", "network": "Jaringan", - "networks": "Jaringan", "network_address_detected": "Alamat ${network} terdeteksi", "network_prefix_on": "pada", "network_switch_failed": "Gagal beralih ke jaringan yang diminta. Pembayaran tidak diterapkan, silakan coba lagi.", @@ -1584,4 +1582,4 @@ "zcash_network_upgrade_desc_1": "Zcash telah mengalami peningkatan jaringan yang disebut Ironwood yang memerlukan migrasi dana sebelum dapat dibelanjakan. Migrasi ini bersifat otomatis, menjaga privasi, dan dimulai segera setelah Anda membuka dompet Zcash Anda.", "zcash_network_upgrade_desc_2": "Ketika migrasi selesai, Saldo Tersedia Anda akan kembali normal, dan Anda dapat terus mengirim dan menerima Zcash seperti biasa.", "zcash_network_upgrade_desc_3": "Harap izinkan dompet Zcash Anda menyelesaikan sinkronisasi, lalu biarkan aplikasi terbuka hingga migrasi selesai yang dapat memakan waktu beberapa menit tergantung pada saldo." -} +} \ No newline at end of file diff --git a/res/values/strings_it.arb b/res/values/strings_it.arb index 554781556..2d7b7262f 100644 --- a/res/values/strings_it.arb +++ b/res/values/strings_it.arb @@ -238,8 +238,6 @@ "color_theme": "Tema colore", "coming_soon_tag": "Prossimamente", "commit_transaction_amount_fee": "Conferma transazione\nImporto: ${amount}\nCommissione: ${fee}", - "compatible_services": "Servizi compatibili", - "compatible_services_desc": "Usa lo strumento Scansione per leggere i seguenti tipi di codici QR supportati su Cake Wallet:", "compatible_networks": "Reti compatibili", "compatible_services": "Servizi compatibili", "compatible_services_desc": "Usa lo strumento Scansione per leggere i seguenti tipi di codici QR supportati su Cake Wallet:", @@ -699,6 +697,7 @@ "moralis_nft_error": "Si è verificato un errore durante il recupero degli NFT. Controlla la tua connessione Internet e riprova.", "more_details": "Maggiori dettagli", "more_options": "Altre opzioni", + "multiple_accounts": "Conti multipli", "multiple_addresses_detected": "Rilevati più indirizzi", "mweb_ad": "Abilita MWEB, il livello di privacy per Litecoin che aggiunge ulteriore anonimato alle tue monete", "mweb_confirmed": "MWEB confermato", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Tutti i modelli più recenti (GPT-5.3, Claude 4.6).\nNessun abbonamento, paga con cripto.", "narrow": "Stretto", "network": "Rete", - "networks": "Reti", "network_address_detected": "Indirizzo ${network} rilevato", "network_prefix_on": "SU", "network_switch_failed": "Impossibile passare alla rete richiesta. Il pagamento non è stato applicato, riprova.", @@ -1584,4 +1582,4 @@ "zcash_network_upgrade_desc_1": "Zcash ha subito un aggiornamento della rete chiamato Ironwood che richiede la migrazione dei fondi prima che possano essere spesi. Questa migrazione è automatica, rispetta la privacy e inizia non appena apri il tuo portafoglio Zcash.", "zcash_network_upgrade_desc_2": "Al termine della migrazione, il tuo saldo disponibile tornerà alla normalità e potrai continuare a inviare e ricevere Zcash come al solito.", "zcash_network_upgrade_desc_3": "Consenti al tuo portafoglio Zcash di completare la sincronizzazione, quindi lascia l'app aperta fino al termine della migrazione, che può richiedere alcuni minuti a seconda del saldo." -} +} \ No newline at end of file diff --git a/res/values/strings_ja.arb b/res/values/strings_ja.arb index 240229ecf..65aba10dc 100644 --- a/res/values/strings_ja.arb +++ b/res/values/strings_ja.arb @@ -238,8 +238,6 @@ "color_theme": "カラーテーマ", "coming_soon_tag": "近日公開", "commit_transaction_amount_fee": "トランザクションを確定\n金額: ${amount}\n手数料: ${fee}", - "compatible_services": "対応サービス", - "compatible_services_desc": "スキャンツールを使用して、Cake Walletで対応している以下の種類のQRコードを読み取れます:", "compatible_networks": "互換性のあるネットワーク", "compatible_services": "対応サービス", "compatible_services_desc": "スキャンツールを使用して、Cake Walletで対応している以下の種類のQRコードを読み取れます:", @@ -700,6 +698,7 @@ "moralis_nft_error": "NFTの取得中にエラーが発生しました。インターネット接続を確認して、もう一度お試しください。", "more_details": "詳細を見る", "more_options": "その他のオプション", + "multiple_accounts": "複数のアカウント", "multiple_addresses_detected": "複数のアドレスが検出されました", "mweb_ad": "Litecoin のプライバシーレイヤーである MWEB を有効にして、コインの匿名性をさらに高めます", "mweb_confirmed": "確認済みMWEB", @@ -714,7 +713,6 @@ "nanogpt_subtitle": "すべての最新モデル(GPT-5.3、Claude 4.6)。\nサブスクリプション不要、暗号資産で支払い。", "narrow": "狭い", "network": "ネットワーク", - "networks": "ネットワーク", "network_address_detected": "${network}アドレスが検出されました", "network_prefix_on": "の上", "network_switch_failed": "要求されたネットワークへの切り替えに失敗しました。支払いは適用されませんでした。もう一度お試しください。", @@ -1582,4 +1580,4 @@ "zcash_network_upgrade_desc_1": "Zcash は、Ironwood と呼ばれるネットワーク アップグレードを受けており、資金を使用する前に資金の移行が必要です。この移行は自動的に行われ、プライバシーが保護され、Zcash ウォレットを開くとすぐに開始されます。", "zcash_network_upgrade_desc_2": "移行が完了すると、利用可能残高は通常の状態に戻り、引き続き通常どおり Zcash を送受信できます。", "zcash_network_upgrade_desc_3": "Zcash ウォレットの同期が完了するまで待ってから、移行が完了するまでアプリを開いたままにしてください。残高によっては数分かかる場合があります。" -} +} \ No newline at end of file diff --git a/res/values/strings_ko.arb b/res/values/strings_ko.arb index 0dd006a6c..ab25aea5d 100644 --- a/res/values/strings_ko.arb +++ b/res/values/strings_ko.arb @@ -238,8 +238,6 @@ "color_theme": "색상 테마", "coming_soon_tag": "출시 예정", "commit_transaction_amount_fee": "트랜잭션 제출\n금액: ${amount}\n수수료: ${fee}", - "compatible_services": "호환 서비스", - "compatible_services_desc": "스캔 도구를 사용하여 Cake Wallet에서 지원하는 다음 유형의 QR 코드를 읽으세요:", "compatible_networks": "호환 가능한 네트워크", "compatible_services": "호환 서비스", "compatible_services_desc": "스캔 도구를 사용하여 Cake Wallet에서 지원하는 다음 유형의 QR 코드를 읽으세요:", @@ -699,6 +697,7 @@ "moralis_nft_error": "NFT를 가져오는 중 오류가 발생했습니다. 인터넷 연결을 확인한 후 다시 시도해 주세요.", "more_details": "자세히 보기", "more_options": "추가 옵션", + "multiple_accounts": "여러 계정", "multiple_addresses_detected": "여러 주소가 감지되었습니다", "mweb_ad": "코인에 추가적인 익명성을 더해주는 Litecoin의 프라이버시 레이어인 MWEB를 활성화하세요", "mweb_confirmed": "확인된 MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "최신 모델 모두(GPT-5.3, Claude 4.6).\n구독 없이 암호화폐로 결제.", "narrow": "좁게", "network": "네트워크", - "networks": "네트워크", "network_address_detected": "${network} 주소가 감지되었습니다", "network_prefix_on": "~에", "network_switch_failed": "요청한 네트워크로 전환하지 못했습니다. 결제가 적용되지 않았습니다. 다시 시도해 주세요.", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash는 자금을 사용하기 전에 자금을 이전해야 하는 Ironwood라는 네트워크 업그레이드를 거쳤습니다. 이 마이그레이션은 자동으로 이루어지며 개인 정보가 보호되며 Zcash 지갑을 열자마자 시작됩니다.", "zcash_network_upgrade_desc_2": "마이그레이션이 완료되면 사용 가능한 잔액이 정상으로 돌아가고 평소처럼 Zcash를 계속 보내고 받을 수 있습니다.", "zcash_network_upgrade_desc_3": "Zcash 지갑 동기화가 완료될 때까지 기다린 후 마이그레이션이 완료될 때까지 앱을 열어두세요. 마이그레이션은 잔액에 따라 몇 분 정도 걸릴 수 있습니다." -} +} \ No newline at end of file diff --git a/res/values/strings_my.arb b/res/values/strings_my.arb index 67fce097f..91e541138 100644 --- a/res/values/strings_my.arb +++ b/res/values/strings_my.arb @@ -238,8 +238,6 @@ "color_theme": "အရောင်အပြင်အဆင်", "coming_soon_tag": "မကြာမီလာမည်", "commit_transaction_amount_fee": "ငွေလွှဲမှုကို အတည်ပြုပါ\nပမာဏ: ${amount}\nအခကြေးငွေ: ${fee}", - "compatible_services": "တွဲဖက်အသုံးပြုနိုင်သော ဝန်ဆောင်မှုများ", - "compatible_services_desc": "Cake Wallet တွင် ပံ့ပိုးထားသော အောက်ပါ QR ကုဒ်အမျိုးအစားများကို ဖတ်ရန် Scan tool ကို အသုံးပြုပါ:", "compatible_networks": "လိုက်ဖက်သောကွန်ရက်များ", "compatible_services": "တွဲဖက်အသုံးပြုနိုင်သော ဝန်ဆောင်မှုများ", "compatible_services_desc": "Cake Wallet တွင် ပံ့ပိုးထားသော အောက်ပါ QR ကုဒ်အမျိုးအစားများကို ဖတ်ရန် Scan tool ကို အသုံးပြုပါ:", @@ -699,6 +697,7 @@ "moralis_nft_error": "NFTs ရယူနေစဉ် အမှားတစ်ခု ဖြစ်ပွားခဲ့သည်။ သင့်အင်တာနက်ချိတ်ဆက်မှုကို စစ်ဆေးပြီး ထပ်မံကြိုးစားပါ။", "more_details": "အသေးစိတ်အချက်အလက်များ", "more_options": "နောက်ထပ် ရွေးချယ်စရာများ", + "multiple_accounts": "အကောင့်များစွာ", "multiple_addresses_detected": "လိပ်စာများစွာကို တွေ့ရှိခဲ့သည်", "mweb_ad": "သင့်ဒင်္ဂါးများအတွက် အမည်ဝှက်မှုကို ပိုမိုမြှင့်တင်ပေးသည့် Litecoin ၏ သီးသန့်အလွှာ MWEB ကို ဖွင့်ပါ", "mweb_confirmed": "အတည်ပြုပြီးသော MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "နောက်ဆုံးပေါ်မော်ဒယ်များအားလုံး (GPT-5.3, Claude 4.6)။\nစာရင်းသွင်းမှုမလို၊ crypto ဖြင့်ပေးချေပါ။", "narrow": "ကျဉ်း", "network": "ကွန်ရက်", - "networks": "ကွန်ရက်များ", "network_address_detected": "${network} လိပ်စာကို တွေ့ရှိသည်", "network_prefix_on": "on", "network_switch_failed": "တောင်းဆိုထားသောကွန်ရက်သို့ ပြောင်း၍မရပါ။ ငွေပေးချေမှုကို မသက်ရောက်ပါ၊ ထပ်စမ်းကြည့်ပါ။", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash သည် ၎င်းတို့ကို အသုံးမချမီ ရန်ပုံငွေများ ရွှေ့ပြောင်းရန် လိုအပ်သော Ironwood ဟုခေါ်သော ကွန်ရက်အဆင့်မြှင့်တင်မှုကို လုပ်ဆောင်ခဲ့သည်။ ဤရွေ့ပြောင်းမှုသည် အလိုအလျောက်ဖြစ်ပြီး၊ လျှို့ဝှက်ရေးထိန်းသိမ်းမှုဖြစ်ပြီး၊ သင်၏ Zcash ပိုက်ဆံအိတ်ကို သင်ဖွင့်ပြီးသည်နှင့် စတင်သည်။", "zcash_network_upgrade_desc_2": "ပြောင်းရွှေ့ခြင်းပြီးဆုံးသောအခါ၊ သင်၏ရရှိနိုင်သောလက်ကျန်ငွေသည် ပုံမှန်အတိုင်းပြန်လည်ရောက်ရှိမည်ဖြစ်ပြီး ပုံမှန်အတိုင်း Zcash ကို ဆက်လက်ပေးပို့နိုင်ပြီး လက်ခံနိုင်ပါသည်။", "zcash_network_upgrade_desc_3": "သင်၏ Zcash ပိုက်ဆံအိတ်ကို စင့်ခ်လုပ်ခြင်းကို အပြီးသတ်ရန် ခွင့်ပြုပါ၊ ထို့နောက် လက်ကျန်ငွေပေါ်မူတည်၍ မိနစ်အနည်းငယ်ကြာနိုင်ပြီး ပြောင်းရွှေ့မှုပြီးဆုံးသည်အထိ အက်ပ်ကိုဖွင့်ထားလိုက်ပါ။" -} +} \ No newline at end of file diff --git a/res/values/strings_nl.arb b/res/values/strings_nl.arb index a0f661000..e2ed5ea04 100644 --- a/res/values/strings_nl.arb +++ b/res/values/strings_nl.arb @@ -238,8 +238,6 @@ "color_theme": "Kleurthema", "coming_soon_tag": "Binnenkort", "commit_transaction_amount_fee": "Bevestig transactie\nBedrag: ${amount}\nKosten: ${fee}", - "compatible_services": "Compatibele services", - "compatible_services_desc": "Gebruik de Scan-tool om de volgende soorten QR-codes te lezen die door Cake Wallet worden ondersteund:", "compatible_networks": "Compatibele netwerken", "compatible_services": "Compatibele services", "compatible_services_desc": "Gebruik de Scan-tool om de volgende soorten QR-codes te lezen die door Cake Wallet worden ondersteund:", @@ -699,6 +697,7 @@ "moralis_nft_error": "Er is een fout opgetreden bij het ophalen van NFT's. Controleer je internetverbinding en probeer het opnieuw.", "more_details": "Meer details", "more_options": "Meer opties", + "multiple_accounts": "Meerdere accounts", "multiple_addresses_detected": "Meerdere adressen gedetecteerd", "mweb_ad": "Schakel MWEB in, de privacylaag voor Litecoin die extra anonimiteit aan je fondsen toevoegt", "mweb_confirmed": "Bevestigde MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Alle nieuwste modellen (GPT-5.3, Claude 4.6).\nGeen abonnement, betaal met crypto.", "narrow": "Smal", "network": "Netwerk", - "networks": "Netwerken", "network_address_detected": "${network}-adres gedetecteerd", "network_prefix_on": "op", "network_switch_failed": "Kan niet overschakelen naar het gevraagde netwerk. De betaling is niet toegepast. Probeer het opnieuw.", @@ -1582,4 +1580,4 @@ "zcash_network_upgrade_desc_1": "Zcash heeft een netwerkupgrade ondergaan genaamd Ironwood, die migratie van fondsen vereist voordat ze kunnen worden uitgegeven. Deze migratie is automatisch, behoud van de privacy en begint zodra u uw Zcash-portemonnee opent.", "zcash_network_upgrade_desc_2": "Wanneer de migratie is voltooid, wordt uw beschikbare saldo weer normaal en kunt u zoals gewoonlijk Zcash blijven verzenden en ontvangen.", "zcash_network_upgrade_desc_3": "Wacht tot de synchronisatie van uw Zcash-portemonnee is voltooid en laat de app open totdat de migratie is voltooid. Dit kan enkele minuten duren, afhankelijk van het saldo." -} +} \ No newline at end of file diff --git a/res/values/strings_pl.arb b/res/values/strings_pl.arb index e3675bfcb..d874155be 100644 --- a/res/values/strings_pl.arb +++ b/res/values/strings_pl.arb @@ -238,8 +238,6 @@ "color_theme": "Motyw kolorystyczny", "coming_soon_tag": "Wkrótce", "commit_transaction_amount_fee": "Zatwierdź transakcję\nKwota: ${amount}\nOpłata: ${fee}", - "compatible_services": "Zgodne usługi", - "compatible_services_desc": "Użyj narzędzia Skanuj, aby odczytać następujące typy kodów QR obsługiwane w Cake Wallet:", "compatible_networks": "Kompatybilne sieci", "compatible_services": "Zgodne usługi", "compatible_services_desc": "Użyj narzędzia Skanuj, aby odczytać następujące typy kodów QR obsługiwane w Cake Wallet:", @@ -475,7 +473,6 @@ "etherscan_history": "Historia w Etherscan", "event": "Zdarzenie", "events": "Zdarzenia", - "evm_ecosystem_description": "W razie potrzeby zmień sieć i token, których chcesz użyć do tej płatności.", "evm_address_detected": "Wykryto adres EVM", "evm_ecosystem_description": "W razie potrzeby zmień sieć i token, których chcesz użyć do tej płatności.", "exchange": "Wymiana", @@ -700,6 +697,7 @@ "moralis_nft_error": "Wystąpił błąd podczas pobierania NFT. Sprawdź połączenie internetowe i spróbuj ponownie.", "more_details": "Więcej szczegółów", "more_options": "Więcej opcji", + "multiple_accounts": "Wiele kont", "multiple_addresses_detected": "Wykryto wiele adresów", "mweb_ad": "Włącz MWEB, warstwę prywatności dla Litecoina, która zapewnia dodatkową anonimowość Twoim monetom", "mweb_confirmed": "Potwierdzone MWEB", @@ -714,7 +712,6 @@ "nanogpt_subtitle": "Wszystkie najnowsze modele (GPT-5.3, Claude 4.6).\nBez subskrypcji, płać kryptowalutą.", "narrow": "Wąski", "network": "Sieć", - "networks": "Sieci", "network_address_detected": "Wykryto adres ${network}", "network_prefix_on": "NA", "network_switch_failed": "Nie udało się przełączyć na żądaną sieć. Płatność nie została zastosowana. Spróbuj ponownie.", @@ -1582,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash przeszedł modernizację sieci o nazwie Ironwood, która wymaga migracji środków, zanim będzie można je wydać. Ta migracja jest automatyczna, chroni prywatność i rozpoczyna się natychmiast po otwarciu portfela Zcash.", "zcash_network_upgrade_desc_2": "Po zakończeniu migracji Twoje dostępne saldo powróci do normy i będziesz mógł nadal wysyłać i odbierać Zcash jak zwykle.", "zcash_network_upgrade_desc_3": "Poczekaj, aż Twój portfel Zcash dokończy synchronizację, a następnie pozostaw aplikację otwartą do czasu zakończenia migracji, co może zająć kilka minut, w zależności od salda." -} +} \ No newline at end of file diff --git a/res/values/strings_pt.arb b/res/values/strings_pt.arb index 3e01398f8..52fd4d042 100644 --- a/res/values/strings_pt.arb +++ b/res/values/strings_pt.arb @@ -238,8 +238,6 @@ "color_theme": "Tema de cores", "coming_soon_tag": "Em breve", "commit_transaction_amount_fee": "Confirmar transação\nValor: ${amount}\nTaxa: ${fee}", - "compatible_services": "Serviços compatíveis", - "compatible_services_desc": "Use a ferramenta de digitalização para ler os seguintes tipos de códigos QR suportados na Cake Wallet:", "compatible_networks": "Redes Compatíveis", "compatible_services": "Serviços compatíveis", "compatible_services_desc": "Use a ferramenta de digitalização para ler os seguintes tipos de códigos QR suportados na Cake Wallet:", @@ -700,6 +698,7 @@ "moralis_nft_error": "Ocorreu um erro ao buscar NFTs. Por favor, verifique sua conexão com a internet e tente novamente.", "more_details": "Mais detalhes", "more_options": "Mais opções", + "multiple_accounts": "Várias contas", "multiple_addresses_detected": "Vários endereços detectados", "mweb_ad": "Ative o MWEB, a camada de privacidade do Litecoin que adiciona anonimato extra às suas moedas", "mweb_confirmed": "MWEB confirmado", @@ -714,7 +713,6 @@ "nanogpt_subtitle": "Todos os modelos mais recentes (GPT-5.3, Claude 4.6).\nSem assinatura, pague com cripto.", "narrow": "Estreito", "network": "Rede", - "networks": "Redes", "network_address_detected": "Endereço ${network} detectado", "network_prefix_on": "sobre", "network_switch_failed": "Falha ao mudar para a rede solicitada. O pagamento não foi aplicado, tente novamente.", @@ -1584,4 +1582,4 @@ "zcash_network_upgrade_desc_1": "Zcash passou por uma atualização de rede chamada Ironwood que requer migração de fundos antes que possam ser gastos. Esta migração é automática, preserva a privacidade e começa assim que você abre sua carteira Zcash.", "zcash_network_upgrade_desc_2": "Quando a migração terminar, o seu Saldo Disponível voltará ao normal e você poderá continuar a enviar e receber Zcash normalmente.", "zcash_network_upgrade_desc_3": "Aguarde a sincronização da sua carteira Zcash e deixe o aplicativo aberto até a migração terminar, o que pode levar alguns minutos dependendo do saldo." -} +} \ No newline at end of file diff --git a/res/values/strings_ru.arb b/res/values/strings_ru.arb index e7c1a6116..8d8d39d54 100644 --- a/res/values/strings_ru.arb +++ b/res/values/strings_ru.arb @@ -238,8 +238,6 @@ "color_theme": "Цветовая тема", "coming_soon_tag": "Скоро", "commit_transaction_amount_fee": "Подтвердить транзакцию\nСумма: ${amount}\nКомиссия: ${fee}", - "compatible_services": "Совместимые сервисы", - "compatible_services_desc": "Используйте инструмент сканирования, чтобы считывать следующие типы QR-кодов, поддерживаемые в Cake Wallet:", "compatible_networks": "Совместимые сети", "compatible_services": "Совместимые сервисы", "compatible_services_desc": "Используйте инструмент сканирования, чтобы считывать следующие типы QR-кодов, поддерживаемые в Cake Wallet:", @@ -699,6 +697,7 @@ "moralis_nft_error": "Произошла ошибка при получении NFT. Пожалуйста, проверьте подключение к интернету и попробуйте еще раз.", "more_details": "Подробнее", "more_options": "Дополнительные опции", + "multiple_accounts": "Несколько учетных записей", "multiple_addresses_detected": "Обнаружено несколько адресов", "mweb_ad": "Включите MWEB — уровень конфиденциальности Litecoin, который добавляет вашим монетам дополнительную анонимность.", "mweb_confirmed": "Подтверждено MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Все новейшие модели (GPT-5.3, Claude 4.6).\nБез подписки, оплата криптовалютой.", "narrow": "Узкий", "network": "Сеть", - "networks": "Сети", "network_address_detected": "Обнаружен адрес ${network}", "network_prefix_on": "на", "network_switch_failed": "Не удалось переключиться на запрошенную сеть. Платеж не был применен, попробуйте еще раз.", @@ -1582,4 +1580,4 @@ "zcash_network_upgrade_desc_1": "Zcash претерпел обновление сети под названием Ironwood, которое требует миграции средств, прежде чем их можно будет потратить. Эта миграция является автоматической, сохраняет конфиденциальность и начинается, как только вы открываете свой кошелек Zcash.", "zcash_network_upgrade_desc_2": "Когда миграция завершится, ваш доступный баланс вернется к нормальному состоянию, и вы сможете продолжать отправлять и получать Zcash в обычном режиме.", "zcash_network_upgrade_desc_3": "Пожалуйста, дайте вашему кошельку Zcash завершить синхронизацию, а затем оставьте приложение открытым до завершения миграции, что может занять несколько минут в зависимости от баланса." -} +} \ No newline at end of file diff --git a/res/values/strings_th.arb b/res/values/strings_th.arb index c58b64043..25f7c0ce2 100644 --- a/res/values/strings_th.arb +++ b/res/values/strings_th.arb @@ -238,8 +238,6 @@ "color_theme": "ธีมสี", "coming_soon_tag": "เร็ว ๆ นี้", "commit_transaction_amount_fee": "ยืนยันธุรกรรม\nจำนวน: ${amount}\nค่าธรรมเนียม: ${fee}", - "compatible_services": "บริการที่รองรับ", - "compatible_services_desc": "ใช้เครื่องมือสแกนเพื่ออ่าน QR Code ประเภทต่อไปนี้ที่ Cake Wallet รองรับ:", "compatible_networks": "เครือข่ายที่เข้ากันได้", "compatible_services": "บริการที่รองรับ", "compatible_services_desc": "ใช้เครื่องมือสแกนเพื่ออ่าน QR Code ประเภทต่อไปนี้ที่ Cake Wallet รองรับ:", @@ -699,6 +697,7 @@ "moralis_nft_error": "เกิดข้อผิดพลาดขณะดึงข้อมูล NFT กรุณาตรวจสอบการเชื่อมต่ออินเทอร์เน็ตของคุณแล้วลองอีกครั้ง", "more_details": "รายละเอียดเพิ่มเติม", "more_options": "ตัวเลือกเพิ่มเติม", + "multiple_accounts": "หลายบัญชี", "multiple_addresses_detected": "ตรวจพบที่อยู่หลายรายการ", "mweb_ad": "เปิดใช้งาน MWEB เลเยอร์ความเป็นส่วนตัวสำหรับ Litecoin ที่เพิ่มความไม่เปิดเผยตัวตนให้กับเหรียญของคุณ", "mweb_confirmed": "MWEB ที่ยืนยันแล้ว", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "โมเดลใหม่ล่าสุดทั้งหมด (GPT-5.3, Claude 4.6)\nไม่ต้องสมัครสมาชิก จ่ายด้วยคริปโต", "narrow": "แคบ", "network": "เครือข่าย", - "networks": "เครือข่าย", "network_address_detected": "ตรวจพบที่อยู่ ${network}", "network_prefix_on": "บน", "network_switch_failed": "ไม่สามารถสลับไปยังเครือข่ายที่ร้องขอได้ การชำระเงินไม่ได้ใช้ โปรดลองอีกครั้ง", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash ได้รับการอัปเกรดเครือข่ายที่เรียกว่า Ironwood ซึ่งต้องมีการโยกย้ายเงินทุนก่อนจึงจะสามารถใช้งานได้ การโยกย้ายนี้เป็นไปโดยอัตโนมัติ รักษาความเป็นส่วนตัว และเริ่มต้นทันทีที่คุณเปิดกระเป๋าเงิน Zcash", "zcash_network_upgrade_desc_2": "เมื่อการย้ายข้อมูลเสร็จสิ้น ยอดคงเหลือที่มีอยู่ของคุณจะกลับมาเป็นปกติ และคุณสามารถส่งและรับ Zcash ได้ตามปกติ", "zcash_network_upgrade_desc_3": "โปรดอนุญาตให้กระเป๋าเงิน Zcash ของคุณทำการซิงค์ให้เสร็จสิ้น จากนั้นเปิดแอปทิ้งไว้จนกว่าการโยกย้ายจะเสร็จสิ้นซึ่งอาจใช้เวลาสักครู่ขึ้นอยู่กับยอดเงินคงเหลือ" -} +} \ No newline at end of file diff --git a/res/values/strings_tl.arb b/res/values/strings_tl.arb index 7354522d5..d624aafbb 100644 --- a/res/values/strings_tl.arb +++ b/res/values/strings_tl.arb @@ -238,8 +238,6 @@ "color_theme": "Tema ng kulay", "coming_soon_tag": "Paparating na", "commit_transaction_amount_fee": "I-commit ang transaksyon\nHalaga: ${amount}\nBayad: ${fee}", - "compatible_services": "Mga Katugmang Serbisyo", - "compatible_services_desc": "Gamitin ang Scan tool upang basahin ang mga sumusunod na uri ng QR code na sinusuportahan sa Cake Wallet:", "compatible_networks": "Mga Katugmang Network", "compatible_services": "Mga Katugmang Serbisyo", "compatible_services_desc": "Gamitin ang Scan tool upang basahin ang mga sumusunod na uri ng QR code na sinusuportahan sa Cake Wallet:", @@ -699,6 +697,7 @@ "moralis_nft_error": "May naganap na error habang kinukuha ang mga NFT. Pakisuri ang iyong koneksyon sa internet at subukang muli.", "more_details": "Higit pang Detalye", "more_options": "Higit pang mga opsyon", + "multiple_accounts": "Maramihang Mga Account", "multiple_addresses_detected": "Maramihang address ang natukoy", "mweb_ad": "I-enable ang MWEB, ang privacy layer para sa Litecoin na nagdaragdag ng karagdagang anonymity sa iyong mga coin", "mweb_confirmed": "Nakumpirmang MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Lahat ng pinakabagong modelo (GPT-5.3, Claude 4.6).\nWalang subscription, magbayad gamit ang crypto.", "narrow": "Makitid", "network": "Network", - "networks": "Mga Network", "network_address_detected": "Natukoy ang ${network} address", "network_prefix_on": "sa", "network_switch_failed": "Nabigong lumipat sa hiniling na network. Hindi nailapat ang pagbabayad, pakisubukang muli.", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Sumailalim ang Zcash sa isang network upgrade na tinatawag na Ironwood na nangangailangan ng paglipat ng mga pondo bago sila magastos. Ang paglipat na ito ay awtomatiko, pinapanatili ang privacy, at magsisimula sa sandaling mabuksan mo ang iyong Zcash wallet.", "zcash_network_upgrade_desc_2": "Kapag natapos na ang paglipat, babalik sa normal ang iyong Available na Balanse, at maaari kang magpatuloy sa pagpapadala at pagtanggap ng Zcash gaya ng dati.", "zcash_network_upgrade_desc_3": "Mangyaring payagan ang iyong Zcash wallet na matapos ang pag-sync, at pagkatapos ay iwanang bukas ang app hanggang sa matapos ang paglipat na maaaring tumagal ng ilang minuto depende sa balanse." -} +} \ No newline at end of file diff --git a/res/values/strings_tr.arb b/res/values/strings_tr.arb index 95740662e..c7adc0d02 100644 --- a/res/values/strings_tr.arb +++ b/res/values/strings_tr.arb @@ -238,8 +238,6 @@ "color_theme": "Renk teması", "coming_soon_tag": "Yakında", "commit_transaction_amount_fee": "İşlemi onayla\nTutar: ${amount}\nÜcret: ${fee}", - "compatible_services": "Uyumlu Hizmetler", - "compatible_services_desc": "Cake Wallet'ta desteklenen aşağıdaki QR kodu türlerini okumak için Tarama aracını kullanın:", "compatible_networks": "Uyumlu Ağlar", "compatible_services": "Uyumlu Hizmetler", "compatible_services_desc": "Cake Wallet'ta desteklenen aşağıdaki QR kodu türlerini okumak için Tarama aracını kullanın:", @@ -699,6 +697,7 @@ "moralis_nft_error": "NFT'ler alınırken bir hata oluştu. Lütfen internet bağlantınızı kontrol edin ve tekrar deneyin.", "more_details": "Daha Fazla Ayrıntı", "more_options": "Daha Fazla Seçenek", + "multiple_accounts": "Çoklu Hesaplar", "multiple_addresses_detected": "Birden fazla adres algılandı", "mweb_ad": "Coin’lerinize ekstra anonimlik ekleyen Litecoin gizlilik katmanı MWEB’i etkinleştirin", "mweb_confirmed": "Onaylanmış MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "En yeni modellerin tümü (GPT-5.3, Claude 4.6).\nAbonelik yok, kripto ile ödeyin.", "narrow": "Dar", "network": "Ağ", - "networks": "Ağlar", "network_address_detected": "${network} adresi algılandı", "network_prefix_on": "Açık", "network_switch_failed": "İstenilen ağa geçiş yapılamadı. Ödeme uygulanmadı, lütfen tekrar deneyin.", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash, Ironwood adında, fonların harcanmadan önce taşınmasını gerektiren bir ağ yükseltmesinden geçti. Bu geçiş otomatiktir, gizliliği korur ve Zcash cüzdanınızı açar açmaz başlar.", "zcash_network_upgrade_desc_2": "Geçiş tamamlandığında Kullanılabilir Bakiyeniz normale dönecek ve her zamanki gibi Zcash gönderip almaya devam edebileceksiniz.", "zcash_network_upgrade_desc_3": "Lütfen Zcash cüzdanınızın senkronizasyonu bitirmesine izin verin ve ardından bakiyeye bağlı olarak birkaç dakika sürebilecek taşıma işlemi bitene kadar uygulamayı açık bırakın." -} +} \ No newline at end of file diff --git a/res/values/strings_uk.arb b/res/values/strings_uk.arb index 6db2374b6..268bd88bb 100644 --- a/res/values/strings_uk.arb +++ b/res/values/strings_uk.arb @@ -238,8 +238,6 @@ "color_theme": "Колірна тема", "coming_soon_tag": "Скоро", "commit_transaction_amount_fee": "Підтвердити транзакцію\nСума: ${amount}\nКомісія: ${fee}", - "compatible_services": "Сумісні сервіси", - "compatible_services_desc": "Скористайтеся інструментом сканування, щоб зчитати такі типи QR-кодів, які підтримує Cake Wallet:", "compatible_networks": "Сумісні мережі", "compatible_services": "Сумісні сервіси", "compatible_services_desc": "Скористайтеся інструментом сканування, щоб зчитати такі типи QR-кодів, які підтримує Cake Wallet:", @@ -699,6 +697,7 @@ "moralis_nft_error": "Під час отримання NFT сталася помилка. Будь ласка, перевірте підключення до Інтернету та спробуйте ще раз.", "more_details": "Більше деталей", "more_options": "Більше опцій", + "multiple_accounts": "Кілька облікових записів", "multiple_addresses_detected": "Виявлено кілька адрес", "mweb_ad": "Увімкніть MWEB — рівень конфіденційності для Litecoin, який додає додаткову анонімність вашим монетам", "mweb_confirmed": "Підтверджено MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "Усі найновіші моделі (GPT-5.3, Claude 4.6).\nБез підписки, оплата криптовалютою.", "narrow": "Вузький", "network": "Мережа", - "networks": "Мережі", "network_address_detected": "Виявлено адресу ${network}", "network_prefix_on": "на", "network_switch_failed": "Не вдалося переключитися на потрібну мережу. Платіж не застосовано, спробуйте ще раз.", @@ -1582,4 +1580,4 @@ "zcash_network_upgrade_desc_1": "Zcash пройшов оновлену мережу під назвою Ironwood, яка вимагає міграції коштів, перш ніж їх можна буде витратити. Ця міграція відбувається автоматично, зберігає конфіденційність і починається, щойно ви відкриваєте гаманець Zcash.", "zcash_network_upgrade_desc_2": "Після завершення міграції ваш доступний баланс повернеться до нормального, і ви зможете продовжувати надсилати й отримувати Zcash, як зазвичай.", "zcash_network_upgrade_desc_3": "Дочекайтеся завершення синхронізації вашого гаманця Zcash, а потім залиште програму відкритою, доки не завершиться міграція, яка може зайняти кілька хвилин залежно від балансу." -} +} \ No newline at end of file diff --git a/res/values/strings_ur.arb b/res/values/strings_ur.arb index 49d576e9c..921c87b1f 100644 --- a/res/values/strings_ur.arb +++ b/res/values/strings_ur.arb @@ -238,8 +238,6 @@ "color_theme": "رنگ تھیم", "coming_soon_tag": "جلد آ رہا ہے", "commit_transaction_amount_fee": "لین دین کی تصدیق کریں\nرقم: ${amount}\nفیس: ${fee}", - "compatible_services": "موافق سروسز", - "compatible_services_desc": "Cake Wallet پر معاون درج ذیل اقسام کے QR کوڈز پڑھنے کے لیے Scan ٹول استعمال کریں:", "compatible_networks": "ہم آہنگ نیٹ ورکس", "compatible_services": "موافق سروسز", "compatible_services_desc": "Cake Wallet پر معاون درج ذیل اقسام کے QR کوڈز پڑھنے کے لیے Scan ٹول استعمال کریں:", @@ -699,6 +697,7 @@ "moralis_nft_error": "NFTs حاصل کرتے وقت ایک خرابی پیش آگئی۔ براہِ کرم اپنا انٹرنیٹ کنکشن چیک کریں اور دوبارہ کوشش کریں۔", "more_details": "مزید تفصیلات", "more_options": "مزید اختیارات", + "multiple_accounts": "متعدد اکاؤنٹس", "multiple_addresses_detected": "متعدد پتے کا پتہ چلا", "mweb_ad": "MWEB کو فعال کریں، Litecoin کے لیے رازداری کی پرت جو آپ کے کوائنز میں اضافی گمنامی شامل کرتی ہے۔", "mweb_confirmed": "تصدیق شدہ MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "تمام جدید ترین ماڈلز (GPT-5.3، Claude 4.6)۔\nکوئی سبسکرپشن نہیں، کرپٹو کے ساتھ ادائیگی کریں۔", "narrow": "تنگ", "network": "نیٹ ورک", - "networks": "نیٹ ورکس", "network_address_detected": "${network} ایڈریس کا پتہ چلا", "network_prefix_on": "پر", "network_switch_failed": "درخواست کردہ نیٹ ورک پر سوئچ کرنے میں ناکام۔ ادائیگی لاگو نہیں ہوئی، براہ کرم دوبارہ کوشش کریں۔", @@ -1583,4 +1581,4 @@ "zcash_network_upgrade_desc_1": "Zcash نے Ironwood نامی ایک نیٹ ورک اپ گریڈ کیا ہے جس کے لیے فنڈز خرچ کیے جانے سے پہلے ان کی منتقلی کی ضرورت ہوتی ہے۔ یہ منتقلی خودکار ہے، رازداری کو محفوظ رکھتی ہے، اور آپ کے Zcash والیٹ کو کھولتے ہی شروع ہو جاتی ہے۔", "zcash_network_upgrade_desc_2": "منتقلی ختم ہونے پر، آپ کا دستیاب بیلنس معمول پر آجائے گا، اور آپ ہمیشہ کی طرح Zcash بھیجنا اور وصول کرنا جاری رکھ سکتے ہیں۔", "zcash_network_upgrade_desc_3": "براہ کرم اپنے Zcash والیٹ کو مطابقت پذیری مکمل کرنے کی اجازت دیں، اور پھر ایپ کو مائیگریشن مکمل ہونے تک کھلا چھوڑ دیں جس میں بیلنس کے لحاظ سے چند منٹ لگ سکتے ہیں۔" -} +} \ No newline at end of file diff --git a/res/values/strings_vi.arb b/res/values/strings_vi.arb index a29ca098c..d060f1fb3 100644 --- a/res/values/strings_vi.arb +++ b/res/values/strings_vi.arb @@ -238,8 +238,6 @@ "color_theme": "Chủ đề màu", "coming_soon_tag": "Sắp ra mắt", "commit_transaction_amount_fee": "Xác nhận giao dịch\nSố tiền: ${amount}\nPhí: ${fee}", - "compatible_services": "Dịch vụ tương thích", - "compatible_services_desc": "Sử dụng công cụ Quét để đọc các loại mã QR sau được hỗ trợ trên Cake Wallet:", "compatible_networks": "Mạng tương thích", "compatible_services": "Dịch vụ tương thích", "compatible_services_desc": "Sử dụng công cụ Quét để đọc các loại mã QR sau được hỗ trợ trên Cake Wallet:", @@ -700,6 +698,7 @@ "moralis_nft_error": "Đã xảy ra lỗi khi tải NFT. Vui lòng kiểm tra kết nối Internet của bạn và thử lại.", "more_details": "Xem thêm chi tiết", "more_options": "Tùy chọn khác", + "multiple_accounts": "Nhiều tài khoản", "multiple_addresses_detected": "Đã phát hiện nhiều địa chỉ", "mweb_ad": "Bật MWEB, lớp quyền riêng tư cho Litecoin giúp tăng thêm tính ẩn danh cho coin của bạn", "mweb_confirmed": "MWEB đã xác nhận", @@ -714,7 +713,6 @@ "nanogpt_subtitle": "Tất cả các mô hình mới nhất (GPT-5.3, Claude 4.6).\nKhông cần đăng ký, thanh toán bằng crypto.", "narrow": "Hẹp", "network": "Mạng", - "networks": "Mạng", "network_address_detected": "Đã phát hiện địa chỉ ${network}", "network_prefix_on": "TRÊN", "network_switch_failed": "Không thể chuyển sang mạng được yêu cầu. Thanh toán chưa được áp dụng, vui lòng thử lại.", @@ -1582,4 +1580,4 @@ "zcash_network_upgrade_desc_1": "Zcash đã trải qua quá trình nâng cấp mạng có tên Ironwood, yêu cầu di chuyển tiền trước khi chúng có thể được chi tiêu. Quá trình di chuyển này diễn ra tự động, bảo vệ quyền riêng tư và bắt đầu ngay khi bạn mở ví Zcash của mình.", "zcash_network_upgrade_desc_2": "Khi quá trình di chuyển kết thúc, Số dư khả dụng của bạn sẽ trở lại bình thường và bạn có thể tiếp tục gửi và nhận Zcash như bình thường.", "zcash_network_upgrade_desc_3": "Vui lòng cho phép ví Zcash của bạn hoàn tất đồng bộ hóa, sau đó để ứng dụng mở cho đến khi quá trình di chuyển hoàn tất, quá trình này có thể mất vài phút tùy thuộc vào số dư." -} +} \ No newline at end of file diff --git a/res/values/strings_yo.arb b/res/values/strings_yo.arb index 459eccc87..5b97c28a1 100644 --- a/res/values/strings_yo.arb +++ b/res/values/strings_yo.arb @@ -238,8 +238,6 @@ "color_theme": "Àkòrí àwọ̀", "coming_soon_tag": "N bọ laipẹ", "commit_transaction_amount_fee": "Fọwọ́sí ìdúnàdúrà\nIye: ${amount}\nOwó ìdúnàdúrà: ${fee}", - "compatible_services": "Àwọn Iṣẹ́ Tó Bámu", - "compatible_services_desc": "Lo irinṣẹ́ Scan láti ka àwọn irú kóòdù QR wọ̀nyí tí a ṣe àtìlẹ́yìn fún lórí Cake Wallet:", "compatible_networks": "Awọn nẹtiwọki ibaramu", "compatible_services": "Àwọn Iṣẹ́ Tó Bámu", "compatible_services_desc": "Lo irinṣẹ́ Scan láti ka àwọn irú kóòdù QR wọ̀nyí tí a ṣe àtìlẹ́yìn fún lórí Cake Wallet:", @@ -700,6 +698,7 @@ "moralis_nft_error": "Aṣiṣe kan ṣẹlẹ nígbà tí a ń gba àwọn NFT. Jọ̀wọ́ ṣàyẹ̀wò asopọ intanẹẹti rẹ, kí o sì tún gbìyànjú lẹ́ẹ̀kansi.", "more_details": "Alaye diẹ sii", "more_options": "Àwọn àṣàyàn míì", + "multiple_accounts": "Awọn iroyin pupọ", "multiple_addresses_detected": "A ti rí ọ̀pọ̀ àdírẹ́sì", "mweb_ad": "Mu MWEB ṣiṣẹ, ipele aṣiri fún Litecoin tó ń fi àìdánimọ kún àwọn owó rẹ", "mweb_confirmed": "MWEB tí a ti jẹ́rìí sí", @@ -714,7 +713,6 @@ "nanogpt_subtitle": "Gbogbo àwọn àwòṣe tuntun jùlọ (GPT-5.3, Claude 4.6).\nKò sí ìforúkọsílẹ̀, sanwó pẹ̀lú crypto.", "narrow": "Dín", "network": "Nẹ́tíwọ́ọ̀kì", - "networks": "Àwọn Nẹ́tíwọ́ọ̀kì", "network_address_detected": "A ti rí àdírẹ́sì ${network}", "network_prefix_on": "lori", "network_switch_failed": "Kuna lati yipada si nẹtiwọki ti o beere. Owo sisan ko lo, jọwọ gbiyanju lẹẹkansi.", @@ -1582,4 +1580,4 @@ "zcash_network_upgrade_desc_1": "Zcash ti ṣe iṣagbega nẹtiwọọki kan ti a pe ni Ironwood ti o nilo ijira awọn owo ṣaaju ki wọn to le lo. Iṣilọ yii jẹ aifọwọyi, ipamọ-aṣiri, o si bẹrẹ ni kete ti o ṣii apamọwọ Zcash rẹ.", "zcash_network_upgrade_desc_2": "Nigbati ijira ba pari, Iwontunws.funfun Wa rẹ yoo pada si deede, ati pe o le tẹsiwaju lati firanṣẹ ati gba Zcash bi igbagbogbo.", "zcash_network_upgrade_desc_3": "Jọwọ gba apamọwọ Zcash rẹ lati pari mimuuṣiṣẹpọ, lẹhinna fi ohun elo naa silẹ ni sisi titi ti ijira yoo pari eyiti o le gba iṣẹju diẹ ti o da lori iwọntunwọnsi." -} +} \ No newline at end of file diff --git a/res/values/strings_zh.arb b/res/values/strings_zh.arb index c37b5f1b3..6839eb08b 100644 --- a/res/values/strings_zh.arb +++ b/res/values/strings_zh.arb @@ -238,8 +238,6 @@ "color_theme": "颜色主题", "coming_soon_tag": "即将推出", "commit_transaction_amount_fee": "提交交易\n金额:${amount}\n手续费:${fee}", - "compatible_services": "兼容的服务", - "compatible_services_desc": "使用扫描工具读取 Cake Wallet 支持的以下类型二维码:", "compatible_networks": "兼容网络", "compatible_services": "兼容的服务", "compatible_services_desc": "使用扫描工具读取 Cake Wallet 支持的以下类型二维码:", @@ -699,6 +697,7 @@ "moralis_nft_error": "获取 NFT 时发生错误。请检查您的网络连接,然后重试。", "more_details": "更多详情", "more_options": "更多选项", + "multiple_accounts": "多个账户", "multiple_addresses_detected": "检测到多个地址", "mweb_ad": "启用 MWEB(莱特币的隐私层),为您的币添加额外的匿名性", "mweb_confirmed": "已确认的 MWEB", @@ -713,7 +712,6 @@ "nanogpt_subtitle": "所有最新模型(GPT-5.3、Claude 4.6)。\n无需订阅,用加密货币支付。", "narrow": "窄", "network": "网络", - "networks": "网络", "network_address_detected": "检测到 ${network} 地址", "network_prefix_on": "在", "network_switch_failed": "无法切换到请求的网络。付款未成功,请重试。", @@ -1581,4 +1579,4 @@ "zcash_network_upgrade_desc_1": "Zcash 进行了名为 Ironwood 的网络升级,需要先迁移资金才能使用。此迁移是自动的、保护隐私的,并且在您打开 Zcash 钱包时立即开始。", "zcash_network_upgrade_desc_2": "迁移完成后,您的可用余额将恢复正常,您可以继续照常发送和接收 Zcash。", "zcash_network_upgrade_desc_3": "请让您的 Zcash 钱包完成同步,然后让应用程序保持打开状态,直到迁移完成,这可能需要几分钟的时间,具体取决于余额。" -} +} \ No newline at end of file diff --git a/tool/configure.dart b/tool/configure.dart index 5e0bc9a0c..398e9a92e 100644 --- a/tool/configure.dart +++ b/tool/configure.dart @@ -103,6 +103,8 @@ import 'dart:typed_data'; import 'package:bitcoin_base/bitcoin_base.dart'; import 'package:cake_wallet/view_model/hardware_wallet/ledger_view_model.dart'; import 'package:cake_wallet/view_model/send/output.dart'; +import 'package:cw_bitcoin/electrum_balance.dart'; +import "package:cw_core/account.dart"; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/hardware/hardware_account_data.dart'; import 'package:cw_core/hardware/hardware_wallet_service.dart'; @@ -301,6 +303,11 @@ abstract class Bitcoin { Future getLightningUsername(Object wallet); Future getLightningInvoice(Object wallet, BigInt amount); String? getBreezSdkError(Object exception); + Future getCurrentAccount(Object wallet); + ElectrumBalance balanceForAccount(Object wallet, int accountIndex); + Map accountBalancesSnapshot(Object wallet); + Future setCurrentAccount(Object wallet, int accountIndex); + List getCurrentAccountBitcoinTransactions(Object wallet); } """; @@ -324,6 +331,7 @@ abstract class Bitcoin { Future generateMonero(bool hasImplementation) async { final outputFile = File(moneroOutputPath); const moneroCommonHeaders = """ +import 'package:cw_core/account.dart'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/unspent_transaction_output.dart'; @@ -372,12 +380,6 @@ import 'package:cw_monero/pending_monero_transaction.dart'; """; const moneroCwPart = "part 'cw_monero.dart';"; const moneroContent = """ -class Account { - Account({required this.id, required this.label, this.balance}); - final int id; - final String label; - final String? balance; -} class Subaddress { Subaddress({ @@ -536,6 +538,7 @@ abstract class MoneroAccountList { Future generateWownero(bool hasImplementation) async { final outputFile = File(wowneroOutputPath); const wowneroCommonHeaders = """ +import 'package:cw_core/account.dart'; import 'package:cw_core/amount/money.dart'; import 'package:cw_core/crypto_currency.dart'; import 'package:cw_core/unspent_transaction_output.dart'; @@ -585,12 +588,6 @@ import 'package:cw_wownero/pending_wownero_transaction.dart'; """; const wowneroCwPart = "part 'cw_wownero.dart';"; const wowneroContent = """ -class Account { - Account({required this.id, required this.label, this.balance}); - final int id; - final String label; - final String? balance; -} class Subaddress { Subaddress({