diff --git a/bdk_demo/lib/features/send/send_page.dart b/bdk_demo/lib/features/send/send_page.dart index abeb518..18b7a20 100644 --- a/bdk_demo/lib/features/send/send_page.dart +++ b/bdk_demo/lib/features/send/send_page.dart @@ -2,6 +2,7 @@ import 'dart:math' as math; import 'package:bdk_demo/core/router/app_router.dart'; import 'package:bdk_demo/features/shared/widgets/secondary_app_bar.dart'; import 'package:bdk_demo/features/shared/widgets/wallet_ui_helpers.dart'; +import 'package:bdk_demo/features/transactions/transactions_controller.dart'; import 'package:bdk_demo/providers/blockchain_providers.dart'; import 'package:bdk_demo/providers/connectivity_provider.dart'; import 'package:bdk_demo/providers/send_providers.dart'; @@ -351,6 +352,7 @@ class _SendPageState extends ConsumerState { ref .read(balanceSnapshotProvider.notifier) .applyFromWallet(wallet, record.id); + ref.invalidate(transactionsControllerProvider(record.id)); _showSnackBar('Transaction broadcast successfully.'); context.go(AppRoutes.home); } catch (_) { diff --git a/bdk_demo/lib/features/transactions/models/demo_tx_details.dart b/bdk_demo/lib/features/transactions/models/transaction_history_item.dart similarity index 89% rename from bdk_demo/lib/features/transactions/models/demo_tx_details.dart rename to bdk_demo/lib/features/transactions/models/transaction_history_item.dart index 4be76c1..34a9185 100644 --- a/bdk_demo/lib/features/transactions/models/demo_tx_details.dart +++ b/bdk_demo/lib/features/transactions/models/transaction_history_item.dart @@ -1,6 +1,6 @@ import 'package:bdk_demo/core/utils/formatters.dart'; -class DemoTxDetails { +class TransactionHistoryItem { final String txid; final int sent; final int received; @@ -8,7 +8,7 @@ class DemoTxDetails { final int? blockHeight; final DateTime? confirmationTime; - const DemoTxDetails({ + const TransactionHistoryItem({ required this.txid, required this.sent, required this.received, diff --git a/bdk_demo/lib/features/transactions/transaction_detail_page.dart b/bdk_demo/lib/features/transactions/transaction_detail_page.dart index a48f154..ac84d20 100644 --- a/bdk_demo/lib/features/transactions/transaction_detail_page.dart +++ b/bdk_demo/lib/features/transactions/transaction_detail_page.dart @@ -2,9 +2,10 @@ import 'package:bdk_demo/core/theme/app_theme.dart'; import 'package:bdk_demo/core/utils/formatters.dart'; import 'package:bdk_demo/features/shared/widgets/secondary_app_bar.dart'; import 'package:bdk_demo/features/shared/widgets/wallet_ui_helpers.dart'; -import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; import 'package:bdk_demo/features/transactions/transactions_controller.dart'; import 'package:bdk_demo/models/currency_unit.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,7 +14,7 @@ class TransactionDetailPage extends ConsumerWidget { const TransactionDetailPage({super.key, required this.txid}); - String _formatAmount(DemoTxDetails transaction) { + String _formatAmount(TransactionHistoryItem transaction) { final amount = transaction.netAmount; final prefix = amount >= 0 ? '+' : '-'; final value = Formatters.formatBalance(amount.abs(), CurrencyUnit.satoshi); @@ -28,7 +29,25 @@ class TransactionDetailPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final transactionAsync = ref.watch(transactionDetailsProvider(txid)); + final activeWalletId = ref.watch(activeWalletIdProvider); + final hasActiveWallet = ref.watch(hasActiveTransactionWalletProvider); + if (!hasActiveWallet) { + return const Scaffold( + appBar: SecondaryAppBar(title: 'Transaction Detail'), + body: SafeArea( + child: WalletStateCard( + icon: Icons.account_balance_wallet_outlined, + title: 'No active wallet', + message: + 'Create or load a wallet before viewing transaction details.', + centered: true, + ), + ), + ); + } + final transactionAsync = ref.watch( + transactionDetailsProvider((walletId: activeWalletId, txid: txid)), + ); return Scaffold( appBar: const SecondaryAppBar(title: 'Transaction Detail'), @@ -37,14 +56,14 @@ class TransactionDetailPage extends ConsumerWidget { loading: () => const WalletStateCard( icon: Icons.hourglass_bottom, title: 'Loading transaction', - message: 'Preparing placeholder transaction details...', + message: 'Reading wallet transaction details...', showSpinner: true, centered: true, ), error: (_, __) => WalletStateCard( icon: Icons.error_outline, title: 'Transaction unavailable', - message: 'The demo could not load placeholder transaction details.', + message: 'The wallet transaction details could not be loaded.', accentColor: theme.colorScheme.error, centered: true, ), @@ -54,7 +73,7 @@ class TransactionDetailPage extends ConsumerWidget { icon: Icons.search_off, title: 'Transaction not found', message: - 'No placeholder transaction was found for this txid.\n\n$txid', + 'No wallet transaction was found for this txid.\n\n$txid', centered: true, ); } @@ -83,7 +102,7 @@ class TransactionDetailPage extends ConsumerWidget { ), const SizedBox(height: 8), Text( - 'Standalone transaction detail view for the selected placeholder transaction.', + 'Transaction detail for the selected wallet transaction.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withAlpha(170), ), diff --git a/bdk_demo/lib/features/transactions/transaction_history_mapper.dart b/bdk_demo/lib/features/transactions/transaction_history_mapper.dart new file mode 100644 index 0000000..a20c1e6 --- /dev/null +++ b/bdk_demo/lib/features/transactions/transaction_history_mapper.dart @@ -0,0 +1,50 @@ +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; + +sealed class TransactionHistoryPosition { + const TransactionHistoryPosition(); +} + +class ConfirmedTransactionPosition extends TransactionHistoryPosition { + final int blockHeight; + final int confirmationTime; + + const ConfirmedTransactionPosition({ + required this.blockHeight, + required this.confirmationTime, + }); +} + +class UnconfirmedTransactionPosition extends TransactionHistoryPosition { + const UnconfirmedTransactionPosition(); +} + +class TransactionHistoryMapper { + const TransactionHistoryMapper._(); + + static TransactionHistoryItem fromWalletData({ + required String txid, + required int sent, + required int received, + required TransactionHistoryPosition position, + }) { + return switch (position) { + ConfirmedTransactionPosition() => TransactionHistoryItem( + txid: txid, + sent: sent, + received: received, + pending: false, + blockHeight: position.blockHeight, + confirmationTime: DateTime.fromMillisecondsSinceEpoch( + position.confirmationTime * 1000, + isUtc: true, + ), + ), + UnconfirmedTransactionPosition() => TransactionHistoryItem( + txid: txid, + sent: sent, + received: received, + pending: true, + ), + }; + } +} diff --git a/bdk_demo/lib/features/transactions/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index 79aadb1..325bd99 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -1,12 +1,13 @@ -import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; import 'package:bdk_demo/features/transactions/transactions_repository.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -enum TransactionsLoadState { idle, loading, success, error } +enum TransactionsLoadState { idle, loading, success, error, noWallet } class TransactionsState { final TransactionsLoadState status; - final List transactions; + final List transactions; final String statusMessage; final String? errorMessage; @@ -18,74 +19,189 @@ class TransactionsState { }); const TransactionsState.idle() - : this( - status: TransactionsLoadState.idle, - transactions: const [], - statusMessage: - 'Load the transaction demo to preview list and detail states.', - ); + : status = TransactionsLoadState.idle, + transactions = const [], + statusMessage = 'Ready to load transactions.', + errorMessage = null; + + static const _unset = Object(); TransactionsState copyWith({ TransactionsLoadState? status, - List? transactions, + List? transactions, String? statusMessage, - String? errorMessage, + Object? errorMessage = _unset, }) { return TransactionsState( status: status ?? this.status, transactions: transactions ?? this.transactions, statusMessage: statusMessage ?? this.statusMessage, - errorMessage: errorMessage, + errorMessage: identical(errorMessage, _unset) + ? this.errorMessage + : errorMessage as String?, ); } } -final transactionsControllerProvider = - NotifierProvider( +final transactionsControllerProvider = NotifierProvider.autoDispose + .family( TransactionsController.new, ); -final transactionDetailsProvider = - FutureProvider.family((ref, txid) { - final repository = ref.read(transactionsRepositoryProvider); - return repository.loadTransactionByTxid(txid); +final hasActiveTransactionWalletProvider = Provider((ref) { + final walletId = ref.watch(activeWalletIdProvider); + final binding = ref.watch(activeWalletBindingProvider); + final repository = ref.watch(transactionsRepositoryProvider); + return binding?.walletId == walletId && + repository.isAvailableForWallet(walletId); +}); + +final transactionDetailsProvider = FutureProvider.autoDispose + .family(( + ref, + arg, + ) { + final binding = ref.watch(activeWalletBindingProvider); + final repository = ref.watch(transactionsRepositoryProvider); + if (binding?.walletId != arg.walletId || + !repository.isAvailableForWallet(arg.walletId)) { + return Future.value(null); + } + return repository.loadTransactionByTxid(arg.txid); }); class TransactionsController extends Notifier { + TransactionsController(this.walletId); + + final String? walletId; + Future? _inFlightLoad; + bool _hasPendingRefresh = false; + bool _pendingIsBackground = true; + @override - TransactionsState build() => const TransactionsState.idle(); - - Future loadTransactions() async { - state = state.copyWith( - status: TransactionsLoadState.loading, - transactions: const [], - statusMessage: 'Loading placeholder transactions...', - errorMessage: null, - ); + TransactionsState build() { + ref.listen(activeWalletBindingProvider, (previous, next) { + if (next?.walletId != walletId) { + state = _noWalletState; + return; + } + + final isSuccess = state.status == TransactionsLoadState.success; + loadTransactions(isBackgroundRefresh: isSuccess); + }); + + final repository = ref.read(transactionsRepositoryProvider); + if (!_isWalletAvailable(repository)) { + return _noWalletState; + } + + Future.microtask(() => loadTransactions()); + + return const TransactionsState.idle(); + } + + Future loadTransactions({bool isBackgroundRefresh = false}) async { + if (!_isWalletAvailable(ref.read(transactionsRepositoryProvider))) { + state = _noWalletState; + return; + } + + if (_inFlightLoad != null) { + _hasPendingRefresh = true; + if (!isBackgroundRefresh) { + _pendingIsBackground = false; + } + return _inFlightLoad; + } + _inFlightLoad = _performLoad(isBackgroundRefresh: isBackgroundRefresh); try { - final transactions = await ref - .read(transactionsRepositoryProvider) - .loadTransactions(); + await _inFlightLoad; + } finally { + _inFlightLoad = null; + if (ref.mounted && _hasPendingRefresh) { + final isBg = _pendingIsBackground; + _hasPendingRefresh = false; + _pendingIsBackground = true; + await loadTransactions(isBackgroundRefresh: isBg); + } + } + } + + Future _performLoad({required bool isBackgroundRefresh}) async { + if (!isBackgroundRefresh) { + state = state.copyWith( + status: TransactionsLoadState.loading, + transactions: const [], + statusMessage: 'Loading transaction history...', + errorMessage: null, + ); + } + + try { + final repository = ref.read(transactionsRepositoryProvider); + if (!_isWalletAvailable(repository)) { + state = _noWalletState; + return; + } + final transactions = await repository.loadTransactions(); + + if (!ref.mounted) { + return; + } + if (!_isWalletAvailable(ref.read(transactionsRepositoryProvider))) { + state = _noWalletState; + return; + } state = state.copyWith( status: TransactionsLoadState.success, transactions: transactions, statusMessage: transactions.isEmpty - ? 'Transaction demo loaded. No transactions yet.' - : 'Transaction demo loaded. Showing placeholder transaction rows.', + ? 'Transaction history loaded. No transactions yet.' + : 'Transaction history loaded.', errorMessage: null, ); } catch (error) { - state = state.copyWith( - status: TransactionsLoadState.error, - transactions: const [], - statusMessage: 'The transaction demo could not be loaded.', - errorMessage: _readableError(error), - ); + if (!ref.mounted) { + return; + } + if (!_isWalletAvailable(ref.read(transactionsRepositoryProvider))) { + state = _noWalletState; + return; + } + + if (isBackgroundRefresh && + state.status == TransactionsLoadState.success) { + state = state.copyWith( + status: TransactionsLoadState.success, + transactions: state.transactions, + errorMessage: _readableError(error), + ); + } else { + state = state.copyWith( + status: TransactionsLoadState.error, + transactions: const [], + statusMessage: 'Transaction history could not be loaded.', + errorMessage: _readableError(error), + ); + } } } String _readableError(Object error) => error.toString().replaceFirst('Exception: ', ''); + + bool _isWalletAvailable(TransactionsRepository repository) { + final binding = ref.read(activeWalletBindingProvider); + return binding?.walletId == walletId && + repository.isAvailableForWallet(walletId); + } + + TransactionsState get _noWalletState => const TransactionsState( + status: TransactionsLoadState.noWallet, + transactions: [], + statusMessage: + 'Create or load a wallet before viewing transaction history.', + ); } diff --git a/bdk_demo/lib/features/transactions/transactions_list_page.dart b/bdk_demo/lib/features/transactions/transactions_list_page.dart index 63acc02..109c3d1 100644 --- a/bdk_demo/lib/features/transactions/transactions_list_page.dart +++ b/bdk_demo/lib/features/transactions/transactions_list_page.dart @@ -2,9 +2,10 @@ import 'package:bdk_demo/core/theme/app_theme.dart'; import 'package:bdk_demo/core/utils/formatters.dart'; import 'package:bdk_demo/features/shared/widgets/secondary_app_bar.dart'; import 'package:bdk_demo/features/shared/widgets/wallet_ui_helpers.dart'; -import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; import 'package:bdk_demo/features/transactions/transactions_controller.dart'; import 'package:bdk_demo/models/currency_unit.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; @@ -12,7 +13,10 @@ import 'package:go_router/go_router.dart'; class TransactionsListPage extends ConsumerWidget { const TransactionsListPage({super.key}); - void _openTransactionDetail(BuildContext context, DemoTxDetails transaction) { + void _openTransactionDetail( + BuildContext context, + TransactionHistoryItem transaction, + ) { context.pushNamed( 'transactionDetail', pathParameters: {'txid': transaction.txid}, @@ -22,11 +26,15 @@ class TransactionsListPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final state = ref.watch(transactionsControllerProvider); + final activeWalletId = ref.watch(activeWalletIdProvider); + final hasActiveWallet = ref.watch(hasActiveTransactionWalletProvider); + final controllerProvider = transactionsControllerProvider(activeWalletId); + final state = ref.watch(controllerProvider); final isLoading = state.status == TransactionsLoadState.loading; + final canLoad = hasActiveWallet && !isLoading; return Scaffold( - appBar: const SecondaryAppBar(title: 'Transactions Demo'), + appBar: const SecondaryAppBar(title: 'Transaction History'), body: SafeArea( child: ListView( padding: const EdgeInsets.all(24), @@ -51,25 +59,25 @@ class TransactionsListPage extends ConsumerWidget { ), const SizedBox(height: 16), Text( - 'Transactions Demo', + 'Transaction History', style: theme.textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w700, ), ), const SizedBox(height: 8), Text( - 'Preview placeholder transaction list and detail states in a standalone transactions feature. This demo does not sync a real wallet or query the blockchain.', + 'View transactions from the currently loaded wallet. Sync the wallet to refresh balance and history.', style: theme.textTheme.bodyMedium?.copyWith( color: theme.colorScheme.onSurface.withAlpha(180), ), ), const SizedBox(height: 20), FilledButton.icon( - onPressed: isLoading - ? null - : () => ref - .read(transactionsControllerProvider.notifier) - .loadTransactions(), + onPressed: canLoad + ? () => ref + .read(controllerProvider.notifier) + .loadTransactions() + : null, icon: isLoading ? SizedBox( width: 16, @@ -83,8 +91,8 @@ class TransactionsListPage extends ConsumerWidget { label: Text( state.status == TransactionsLoadState.success || state.status == TransactionsLoadState.error - ? 'Reload Transactions' - : 'Load Transactions Demo', + ? 'Reload Transaction History' + : 'Load Transaction History', ), ), ], @@ -94,7 +102,7 @@ class TransactionsListPage extends ConsumerWidget { const SizedBox(height: 24), const _SectionHeading( title: 'Transactions', - subtitle: 'Placeholder transaction list and detail navigation', + subtitle: 'Active wallet transaction list and detail navigation', ), const SizedBox(height: 12), _TransactionsBody(state: state, onTap: _openTransactionDetail), @@ -107,7 +115,8 @@ class TransactionsListPage extends ConsumerWidget { class _TransactionsBody extends StatelessWidget { final TransactionsState state; - final void Function(BuildContext context, DemoTxDetails transaction) onTap; + final void Function(BuildContext context, TransactionHistoryItem transaction) + onTap; const _TransactionsBody({required this.state, required this.onTap}); @@ -116,20 +125,25 @@ class _TransactionsBody extends StatelessWidget { final theme = Theme.of(context); return switch (state.status) { + TransactionsLoadState.noWallet => const WalletStateCard( + icon: Icons.account_balance_wallet_outlined, + title: 'No active wallet', + message: 'Create or load a wallet before viewing transaction history.', + ), TransactionsLoadState.idle => WalletStateCard( icon: Icons.info_outline, - title: 'Transactions not loaded yet', + title: 'Transaction history not loaded yet', message: state.statusMessage, ), TransactionsLoadState.loading => const WalletStateCard( icon: Icons.hourglass_bottom, - title: 'Loading placeholder transactions...', - message: 'Preparing scaffolded transaction rows.', + title: 'Loading transaction history...', + message: 'Reading wallet transactions.', showSpinner: true, ), TransactionsLoadState.error => WalletStateCard( icon: Icons.error_outline, - title: 'Transaction demo failed', + title: 'Transaction history failed', message: state.errorMessage ?? state.statusMessage, accentColor: theme.colorScheme.error, ), @@ -139,7 +153,7 @@ class _TransactionsBody extends StatelessWidget { icon: Icons.history_toggle_off, title: 'No transactions yet', message: - 'The transaction demo loaded successfully, but no placeholder transactions are configured yet.', + 'The active wallet has no transactions yet. Sync the wallet or receive funds to populate history.', ) : Card( child: Padding( @@ -199,7 +213,7 @@ class _SectionHeading extends StatelessWidget { } class _TransactionRow extends StatelessWidget { - final DemoTxDetails transaction; + final TransactionHistoryItem transaction; final VoidCallback onTap; const _TransactionRow({required this.transaction, required this.onTap}); diff --git a/bdk_demo/lib/features/transactions/transactions_repository.dart b/bdk_demo/lib/features/transactions/transactions_repository.dart index 7f579af..0f7b1eb 100644 --- a/bdk_demo/lib/features/transactions/transactions_repository.dart +++ b/bdk_demo/lib/features/transactions/transactions_repository.dart @@ -1,53 +1,193 @@ -import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; +import 'package:bdk_dart/bdk.dart' as bdk; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; +import 'package:bdk_demo/features/transactions/transaction_history_mapper.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; abstract interface class TransactionsRepository { - Future> loadTransactions(); - Future loadTransactionByTxid(String txid); + bool isAvailableForWallet(String? walletId); + Future> loadTransactions(); + Future loadTransactionByTxid(String txid); } -final transactionsRepositoryProvider = Provider( - (ref) => DemoTransactionsRepository(), -); - -class DemoTransactionsRepository implements TransactionsRepository { - DemoTransactionsRepository({ - this.delay = const Duration(milliseconds: 150), - List? transactions, - }) : _transactions = transactions ?? _defaultTransactions; - - final Duration delay; - final List _transactions; - - static final _defaultTransactions = [ - DemoTxDetails( - txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', - sent: 0, - received: 42000, - pending: false, - blockHeight: 120, - confirmationTime: DateTime(2024, 1, 2, 3, 4), - ), - const DemoTxDetails( - txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - sent: 1600, - received: 0, - pending: true, - ), - ]; +final transactionsRepositoryProvider = Provider((ref) { + final binding = ref.watch(activeWalletBindingProvider); + return WalletTransactionsRepository( + walletId: binding?.walletId, + source: binding == null ? null : BdkWalletTransactionSource(binding.wallet), + ); +}); + +abstract interface class TransactionHistorySource { + List transactions(); + + TransactionHistoryRecord? transactionByTxid(String txid); +} + +class TransactionHistoryRecord { + final String txid; + final int sent; + final int received; + final TransactionHistoryPosition position; + + const TransactionHistoryRecord({ + required this.txid, + required this.sent, + required this.received, + required this.position, + }); +} + +class WalletTransactionsRepository implements TransactionsRepository { + WalletTransactionsRepository({ + required String? walletId, + required TransactionHistorySource? source, + }) : _walletId = walletId, + _source = source; + + final String? _walletId; + final TransactionHistorySource? _source; + + @override + bool isAvailableForWallet(String? walletId) => + walletId != null && walletId == _walletId && _source != null; @override - Future> loadTransactions() async { - await Future.delayed(delay); - return List.unmodifiable(_transactions); + Future> loadTransactions() async { + final source = _source; + if (source == null) return const []; + + return source.transactions().map(_mapRecord).toList(growable: false); + } + + @override + Future loadTransactionByTxid(String txid) async { + final source = _source; + if (source == null) return null; + + final record = source.transactionByTxid(txid); + return record == null ? null : _mapRecord(record); } + TransactionHistoryItem _mapRecord(TransactionHistoryRecord record) { + return TransactionHistoryMapper.fromWalletData( + txid: record.txid, + sent: record.sent, + received: record.received, + position: record.position, + ); + } +} + +class BdkWalletTransactionSource implements TransactionHistorySource { + BdkWalletTransactionSource(this._wallet); + + final bdk.Wallet _wallet; + @override - Future loadTransactionByTxid(String txid) async { - final transactions = await loadTransactions(); - for (final transaction in transactions) { - if (transaction.txid == txid) return transaction; + List transactions() { + final list = _wallet.transactions(); + var index = 0; + var entered = false; + try { + final records = []; + for (index = 0; index < list.length; index++) { + entered = true; + records.add(_recordFromCanonicalTx(list[index])); + entered = false; + } + return records; + } finally { + final startDisposeIndex = entered ? index + 1 : index; + for (var i = startDisposeIndex; i < list.length; i++) { + _disposeCanonicalTx(list[i]); + } } - return null; } + + @override + TransactionHistoryRecord? transactionByTxid(String txid) { + try { + final parsedTxid = bdk.Txid.fromString(hex: txid); + try { + final canonicalTx = _wallet.getTx(txid: parsedTxid); + if (canonicalTx != null) return _recordFromCanonicalTx(canonicalTx); + } finally { + parsedTxid.dispose(); + } + } catch (_) { + // If the txid cannot be parsed or fetched directly, fall back to the + // wallet transaction list so the detail page still behaves gracefully. + } + + return _findTransactionByTxid(transactions(), txid); + } + + TransactionHistoryRecord _recordFromCanonicalTx(bdk.CanonicalTx canonicalTx) { + bdk.Transaction? transaction; + bdk.Txid? txid; + bdk.SentAndReceivedValues? sentAndReceived; + bdk.BlockHash? blockHash; + bdk.Txid? transitively; + + transaction = canonicalTx.transaction; + final position = canonicalTx.chainPosition; + if (position is bdk.ConfirmedChainPosition) { + blockHash = position.confirmationBlockTime.blockId.hash; + transitively = position.transitively; + } + + try { + sentAndReceived = _wallet.sentAndReceived(tx: transaction); + txid = transaction.computeTxid(); + final txidText = txid.toString(); + final sentSat = sentAndReceived.sent.toSat(); + final receivedSat = sentAndReceived.received.toSat(); + + TransactionHistoryPosition mappedPosition; + if (position is bdk.ConfirmedChainPosition) { + mappedPosition = ConfirmedTransactionPosition( + blockHeight: position.confirmationBlockTime.blockId.height, + confirmationTime: position.confirmationBlockTime.confirmationTime, + ); + } else if (position is bdk.UnconfirmedChainPosition) { + mappedPosition = const UnconfirmedTransactionPosition(); + } else { + throw StateError('Unsupported transaction chain position: $position'); + } + + return TransactionHistoryRecord( + txid: txidText, + sent: sentSat, + received: receivedSat, + position: mappedPosition, + ); + } finally { + txid?.dispose(); + sentAndReceived?.sent.dispose(); + sentAndReceived?.received.dispose(); + transaction.dispose(); + blockHash?.dispose(); + transitively?.dispose(); + } + } + + void _disposeCanonicalTx(bdk.CanonicalTx canonicalTx) { + canonicalTx.transaction.dispose(); + final pos = canonicalTx.chainPosition; + if (pos is bdk.ConfirmedChainPosition) { + pos.confirmationBlockTime.blockId.hash.dispose(); + pos.transitively?.dispose(); + } + } +} + +TransactionHistoryRecord? _findTransactionByTxid( + List transactions, + String txid, +) { + for (final transaction in transactions) { + if (transaction.txid == txid) return transaction; + } + return null; } diff --git a/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart index 27de792..ca5d5df 100644 --- a/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart +++ b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart @@ -32,7 +32,7 @@ class _ActiveWalletsPageState extends ConsumerState { return; } - ref.read(activeWalletProvider.notifier).set(wallet); + ref.read(activeWalletProvider.notifier).set(wallet, walletId: record.id); ref.read(activeWalletRecordProvider.notifier).set(record); context.push(AppRoutes.home); } on StateError { diff --git a/bdk_demo/lib/features/wallet_setup/create_wallet_page.dart b/bdk_demo/lib/features/wallet_setup/create_wallet_page.dart index f62bf8d..444c0e3 100644 --- a/bdk_demo/lib/features/wallet_setup/create_wallet_page.dart +++ b/bdk_demo/lib/features/wallet_setup/create_wallet_page.dart @@ -56,7 +56,7 @@ class _CreateWalletPageState extends ConsumerState { return; } - ref.read(activeWalletProvider.notifier).set(wallet); + ref.read(activeWalletProvider.notifier).set(wallet, walletId: record.id); ref.read(activeWalletRecordProvider.notifier).set(record); ref.read(walletRecordsProvider.notifier).refresh(); diff --git a/bdk_demo/lib/features/wallet_setup/recover_wallet_page.dart b/bdk_demo/lib/features/wallet_setup/recover_wallet_page.dart index 91296d9..6405b07 100644 --- a/bdk_demo/lib/features/wallet_setup/recover_wallet_page.dart +++ b/bdk_demo/lib/features/wallet_setup/recover_wallet_page.dart @@ -173,7 +173,7 @@ class _RecoverWalletPageState extends ConsumerState } void _activateRecoveredWallet(WalletRecord record, Wallet wallet) { - ref.read(activeWalletProvider.notifier).set(wallet); + ref.read(activeWalletProvider.notifier).set(wallet, walletId: record.id); ref.read(activeWalletRecordProvider.notifier).set(record); ref.read(walletRecordsProvider.notifier).refresh(); } diff --git a/bdk_demo/lib/providers/address_providers.dart b/bdk_demo/lib/providers/address_providers.dart index 02b362c..64b23bf 100644 --- a/bdk_demo/lib/providers/address_providers.dart +++ b/bdk_demo/lib/providers/address_providers.dart @@ -115,7 +115,9 @@ class CurrentReceiveAddressNotifier extends Notifier { return; } - ref.read(activeWalletProvider.notifier).replaceWallet(updatedWallet); + ref + .read(activeWalletProvider.notifier) + .replaceWallet(updatedWallet, walletId: record.id); state = successState; } catch (error) { final errorState = ReceiveAddressState( diff --git a/bdk_demo/lib/providers/blockchain_providers.dart b/bdk_demo/lib/providers/blockchain_providers.dart index f69957e..46ced2e 100644 --- a/bdk_demo/lib/providers/blockchain_providers.dart +++ b/bdk_demo/lib/providers/blockchain_providers.dart @@ -343,7 +343,9 @@ class SyncController extends Notifier { } final syncedWallet = reloadedWallet; - ref.read(activeWalletProvider.notifier).replaceWallet(syncedWallet); + ref + .read(activeWalletProvider.notifier) + .replaceWallet(syncedWallet, walletId: walletId); transferredWallet = true; ref .read(balanceSnapshotProvider.notifier) diff --git a/bdk_demo/lib/providers/wallet_providers.dart b/bdk_demo/lib/providers/wallet_providers.dart index 1f5dfdf..43054e3 100644 --- a/bdk_demo/lib/providers/wallet_providers.dart +++ b/bdk_demo/lib/providers/wallet_providers.dart @@ -20,6 +20,10 @@ final activeWalletRecordProvider = ActiveWalletRecordNotifier.new, ); +final activeWalletIdProvider = Provider((ref) { + return ref.watch(activeWalletRecordProvider)?.id; +}); + class ActiveWalletRecordNotifier extends Notifier { @override WalletRecord? build() => null; @@ -32,9 +36,26 @@ final activeWalletProvider = NotifierProvider( ActiveWalletNotifier.new, ); +class ActiveWalletBinding { + const ActiveWalletBinding({required this.walletId, required this.wallet}); + + final String walletId; + final Wallet wallet; +} + +final activeWalletBindingProvider = Provider((ref) { + final wallet = ref.watch(activeWalletProvider); + final walletId = ref.read(activeWalletProvider.notifier).walletId; + if (wallet == null || walletId == null) return null; + return ActiveWalletBinding(walletId: walletId, wallet: wallet); +}); + class ActiveWalletNotifier extends Notifier { late WalletDisposer _walletDisposer; Wallet? _currentWallet; + String? _currentWalletId; + + String? get walletId => _currentWalletId; void _disposeWallet(Wallet? wallet) { if (wallet == null) return; @@ -45,24 +66,30 @@ class ActiveWalletNotifier extends Notifier { Wallet? build() { _walletDisposer = ref.read(walletDisposerProvider); _currentWallet = null; + _currentWalletId = null; ref.onDispose(() => _disposeWallet(_currentWallet)); return null; } - void set(Wallet wallet) { + void set(Wallet wallet, {String? walletId}) { + final resolvedWalletId = walletId ?? ref.read(activeWalletIdProvider); if (identical(_currentWallet, wallet)) { + _currentWalletId = resolvedWalletId; return; } _disposeWallet(_currentWallet); _currentWallet = wallet; + _currentWalletId = resolvedWalletId; state = wallet; } - void replaceWallet(Wallet wallet) => set(wallet); + void replaceWallet(Wallet wallet, {String? walletId}) => + set(wallet, walletId: walletId); void clear() { _disposeWallet(_currentWallet); _currentWallet = null; + _currentWalletId = null; state = null; } } diff --git a/bdk_demo/test/features/transactions/transaction_history_mapper_test.dart b/bdk_demo/test/features/transactions/transaction_history_mapper_test.dart new file mode 100644 index 0000000..a5d4fc2 --- /dev/null +++ b/bdk_demo/test/features/transactions/transaction_history_mapper_test.dart @@ -0,0 +1,51 @@ +import 'package:bdk_demo/features/transactions/transaction_history_mapper.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('TransactionHistoryMapper', () { + test('maps confirmed wallet transaction data', () { + final item = TransactionHistoryMapper.fromWalletData( + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + sent: 1200, + received: 42000, + position: const ConfirmedTransactionPosition( + blockHeight: 120, + confirmationTime: 1704164640, + ), + ); + + expect( + item.txid, + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + ); + expect(item.sent, 1200); + expect(item.received, 42000); + expect(item.netAmount, 40800); + expect(item.pending, isFalse); + expect(item.blockHeight, 120); + expect( + item.confirmationTime, + DateTime.fromMillisecondsSinceEpoch(1704164640000, isUtc: true), + ); + expect(item.statusLabel, 'confirmed'); + }); + + test('maps unconfirmed wallet transaction data as pending', () { + final item = TransactionHistoryMapper.fromWalletData( + txid: + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + sent: 1600, + received: 0, + position: const UnconfirmedTransactionPosition(), + ); + + expect(item.sent, 1600); + expect(item.received, 0); + expect(item.netAmount, -1600); + expect(item.pending, isTrue); + expect(item.blockHeight, isNull); + expect(item.confirmationTime, isNull); + expect(item.statusLabel, 'pending'); + }); + }); +} diff --git a/bdk_demo/test/features/transactions/transactions_controller_test.dart b/bdk_demo/test/features/transactions/transactions_controller_test.dart new file mode 100644 index 0000000..267afed --- /dev/null +++ b/bdk_demo/test/features/transactions/transactions_controller_test.dart @@ -0,0 +1,531 @@ +import 'dart:async'; +import 'package:bdk_dart/bdk.dart' as bdk; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; +import 'package:bdk_demo/features/transactions/transactions_controller.dart'; +import 'package:bdk_demo/features/transactions/transactions_repository.dart'; +import 'package:bdk_demo/models/wallet_record.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_riverpod/misc.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import '../../helpers/fakes/fake_transactions_repository.dart'; + +class FakeWallet extends Fake implements bdk.Wallet { + @override + void dispose() {} +} + +class CountingTransactionsRepository implements TransactionsRepository { + int loadCount = 0; + List transactions; + Object? error; + + CountingTransactionsRepository({required this.transactions, this.error}); + + @override + bool isAvailableForWallet(String? walletId) => walletId != null; + + @override + Future> loadTransactions() async { + loadCount++; + final currentError = error; + if (currentError != null) throw currentError; + return transactions; + } + + @override + Future loadTransactionByTxid(String txid) async { + if (error != null) throw error!; + for (final tx in transactions) { + if (tx.txid == txid) return tx; + } + return null; + } +} + +class DelayedTransactionsRepository implements TransactionsRepository { + final Future> delayedResult; + + DelayedTransactionsRepository(this.delayedResult); + + @override + bool isAvailableForWallet(String? walletId) => walletId != null; + + @override + Future> loadTransactions() async { + return delayedResult; + } + + @override + Future loadTransactionByTxid(String txid) async { + final list = await delayedResult; + for (final tx in list) { + if (tx.txid == txid) return tx; + } + return null; + } +} + +void main() { + WalletRecord createRecord(String id, String name) { + return WalletRecord( + id: id, + name: name, + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + } + + TransactionHistoryItem createTx(String txid, int received) { + return TransactionHistoryItem( + txid: txid, + sent: 0, + received: received, + pending: false, + ); + } + + ProviderContainer createContainer( + List overrides, { + bool overrideWalletBinding = true, + }) { + final container = ProviderContainer( + overrides: [ + ...overrides, + if (overrideWalletBinding) + activeWalletBindingProvider.overrideWithValue( + ActiveWalletBinding(walletId: 'wallet-a', wallet: FakeWallet()), + ), + ], + ); + addTearDown(container.dispose); + return container; + } + + void keepControllerAlive(ProviderContainer container, String? walletId) { + final subscription = container.listen( + transactionsControllerProvider(walletId), + (_, __) {}, + ); + addTearDown(subscription.close); + } + + group('TransactionsController & transactionDetailsProvider', () { + test('no active wallet returns the no-wallet state', () { + final container = createContainer([]); + keepControllerAlive(container, null); + + final state = container.read(transactionsControllerProvider(null)); + expect(state.status, TransactionsLoadState.noWallet); + expect(state.transactions, isEmpty); + }); + + test('an active wallet can load its transaction history', () async { + final txs = [createTx('tx-1', 5000)]; + final container = createContainer([ + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository(transactions: txs), + ), + ]); + keepControllerAlive(container, 'wallet-a'); + + await container + .read(transactionsControllerProvider('wallet-a').notifier) + .loadTransactions(); + + final state = container.read(transactionsControllerProvider('wallet-a')); + expect(state.status, TransactionsLoadState.success); + expect(state.transactions, hasLength(1)); + expect(state.transactions.first.txid, 'tx-1'); + }); + + test('successful loading clears an old error', () async { + final repo = CountingTransactionsRepository( + transactions: [createTx('tx-1', 5000)], + error: Exception('Initial error'), + ); + final container = createContainer([ + transactionsRepositoryProvider.overrideWithValue(repo), + ]); + keepControllerAlive(container, 'wallet-a'); + + final notifier = container.read( + transactionsControllerProvider('wallet-a').notifier, + ); + + await notifier.loadTransactions(); + var state = container.read(transactionsControllerProvider('wallet-a')); + expect(state.status, TransactionsLoadState.error); + expect(state.errorMessage, 'Initial error'); + + repo.error = null; + await notifier.loadTransactions(); + state = container.read(transactionsControllerProvider('wallet-a')); + expect(state.status, TransactionsLoadState.success); + expect(state.errorMessage, isNull); + }); + + test('foreground failure produces the error state', () async { + final repo = CountingTransactionsRepository( + transactions: [], + error: Exception('Network failure'), + ); + final container = createContainer([ + transactionsRepositoryProvider.overrideWithValue(repo), + ]); + keepControllerAlive(container, 'wallet-a'); + + await container + .read(transactionsControllerProvider('wallet-a').notifier) + .loadTransactions(); + + final state = container.read(transactionsControllerProvider('wallet-a')); + expect(state.status, TransactionsLoadState.error); + expect(state.transactions, isEmpty); + expect(state.errorMessage, 'Network failure'); + }); + + test('background-refresh failure preserves existing rows', () async { + final repo = CountingTransactionsRepository( + transactions: [createTx('tx-1', 5000)], + ); + final container = createContainer([ + transactionsRepositoryProvider.overrideWithValue(repo), + ]); + keepControllerAlive(container, 'wallet-a'); + + final notifier = container.read( + transactionsControllerProvider('wallet-a').notifier, + ); + await notifier.loadTransactions(); + + var state = container.read(transactionsControllerProvider('wallet-a')); + expect(state.status, TransactionsLoadState.success); + expect(state.transactions, hasLength(1)); + + repo.error = Exception('Refresh failed'); + await notifier.loadTransactions(isBackgroundRefresh: true); + + state = container.read(transactionsControllerProvider('wallet-a')); + expect(state.status, TransactionsLoadState.success); + expect(state.transactions, hasLength(1)); + expect(state.transactions.first.txid, 'tx-1'); + expect(state.errorMessage, 'Refresh failed'); + }); + + test( + 'a refresh requested while another transaction load is running is queued rather than discarded', + () async { + final repo = CountingTransactionsRepository(transactions: []); + + final container = createContainer([ + transactionsRepositoryProvider.overrideWith((ref) => repo), + ]); + keepControllerAlive(container, 'wallet-a'); + + final notifier = container.read( + transactionsControllerProvider('wallet-a').notifier, + ); + + final load1 = notifier.loadTransactions(); + final load2 = notifier.loadTransactions(isBackgroundRefresh: true); + + await Future.wait([load1, load2]); + expect(repo.loadCount, 2); + }, + ); + + test( + 'switching the logical active wallet ID from A to B clears A\'s transaction list', + () async { + final recordA = createRecord('wallet-a', 'Wallet A'); + final recordB = createRecord('wallet-b', 'Wallet B'); + + final txsA = [createTx('tx-a', 10000)]; + final txsB = [createTx('tx-b', 20000)]; + + final container = createContainer([ + transactionsRepositoryProvider.overrideWith((ref) { + final activeId = ref.watch(activeWalletIdProvider); + return FakeTransactionsRepository( + transactions: activeId == 'wallet-a' ? txsA : txsB, + ); + }), + ], overrideWalletBinding: false); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + final walletAId = container.read(activeWalletIdProvider); + keepControllerAlive(container, walletAId); + + await container + .read(transactionsControllerProvider(walletAId).notifier) + .loadTransactions(); + expect( + container.read(transactionsControllerProvider(walletAId)).status, + TransactionsLoadState.success, + ); + expect( + container + .read(transactionsControllerProvider(walletAId)) + .transactions + .first + .txid, + 'tx-a', + ); + + container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + final walletBId = container.read(activeWalletIdProvider); + keepControllerAlive(container, walletBId); + + final stateAfterSwitch = container.read( + transactionsControllerProvider(walletBId), + ); + expect(stateAfterSwitch.transactions, isEmpty); + }, + ); + + test( + 'an asynchronous result started for wallet A is ignored if the active wallet changes to B before it completes', + () async { + final recordA = createRecord('wallet-a', 'Wallet A'); + final recordB = createRecord('wallet-b', 'Wallet B'); + + final completer = Completer>(); + final delayedRepo = DelayedTransactionsRepository(completer.future); + + final container = createContainer([ + transactionsRepositoryProvider.overrideWithValue(delayedRepo), + ], overrideWalletBinding: false); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + final walletAId = container.read(activeWalletIdProvider); + final walletASubscription = container.listen( + transactionsControllerProvider(walletAId), + (_, __) {}, + ); + + final future = container + .read(transactionsControllerProvider(walletAId).notifier) + .loadTransactions(); + + container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + final walletBId = container.read(activeWalletIdProvider); + keepControllerAlive(container, walletBId); + walletASubscription.close(); + await container.pump(); + + completer.complete([createTx('tx-a', 10000)]); + await future; + + final finalState = container.read( + transactionsControllerProvider(walletBId), + ); + expect(finalState.transactions, isEmpty); + }, + ); + + test( + 'replacing the FFI Wallet object while retaining the same wallet record ID refreshes data', + () async { + final recordA = createRecord('wallet-a', 'Wallet A'); + + final wallet1 = FakeWallet(); + final wallet2 = FakeWallet(); + + final repo1 = CountingTransactionsRepository( + transactions: [createTx('tx-a', 10000)], + ); + final repo2 = CountingTransactionsRepository( + transactions: [createTx('tx-b', 20000)], + ); + + final container = createContainer([ + transactionsRepositoryProvider.overrideWith((ref) { + final wallet = ref.watch(activeWalletProvider); + return identical(wallet, wallet1) ? repo1 : repo2; + }), + ], overrideWalletBinding: false); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(wallet1); + final walletAId = container.read(activeWalletIdProvider); + keepControllerAlive(container, walletAId); + + await container + .read(transactionsControllerProvider(walletAId).notifier) + .loadTransactions(); + expect( + container + .read(transactionsControllerProvider(walletAId)) + .transactions + .single + .txid, + 'tx-a', + ); + + container.read(activeWalletProvider.notifier).set(wallet2); + await container.pump(); + await container.pump(); + + expect(repo2.loadCount, greaterThan(0)); + expect( + container + .read(transactionsControllerProvider(walletAId)) + .transactions + .single + .txid, + 'tx-b', + ); + }, + ); + + test( + 'clearing the FFI wallet clears transaction rows while the record remains active', + () async { + final record = createRecord('wallet-a', 'Wallet A'); + final wallet = FakeWallet(); + final repo = CountingTransactionsRepository( + transactions: [createTx('tx-a', 10000)], + ); + final container = createContainer([ + transactionsRepositoryProvider.overrideWithValue(repo), + ], overrideWalletBinding: false); + + container.read(activeWalletRecordProvider.notifier).set(record); + container.read(activeWalletProvider.notifier).set(wallet); + keepControllerAlive(container, record.id); + + await container + .read(transactionsControllerProvider(record.id).notifier) + .loadTransactions(); + expect( + container + .read(transactionsControllerProvider(record.id)) + .transactions, + isNotEmpty, + ); + + container.read(activeWalletProvider.notifier).clear(); + await container.pump(); + + final state = container.read(transactionsControllerProvider(record.id)); + expect(state.status, TransactionsLoadState.noWallet); + expect(state.transactions, isEmpty); + }, + ); + + test( + 'a stale load failure cannot replace no-wallet state after the wallet is cleared', + () async { + final record = createRecord('wallet-a', 'Wallet A'); + final completer = Completer>(); + final container = createContainer([ + transactionsRepositoryProvider.overrideWithValue( + DelayedTransactionsRepository(completer.future), + ), + ], overrideWalletBinding: false); + + container.read(activeWalletRecordProvider.notifier).set(record); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + keepControllerAlive(container, record.id); + final load = container + .read(transactionsControllerProvider(record.id).notifier) + .loadTransactions(); + + container.read(activeWalletProvider.notifier).clear(); + completer.completeError(Exception('stale failure')); + await load; + + final state = container.read(transactionsControllerProvider(record.id)); + expect(state.status, TransactionsLoadState.noWallet); + expect(state.transactions, isEmpty); + }, + ); + + test( + 'wallet B data cannot update the controller keyed to wallet A', + () async { + final recordA = createRecord('wallet-a', 'Wallet A'); + final recordB = createRecord('wallet-b', 'Wallet B'); + final walletA = FakeWallet(); + final walletB = FakeWallet(); + final repoA = CountingTransactionsRepository( + transactions: [createTx('tx-a', 10000)], + ); + final repoB = CountingTransactionsRepository( + transactions: [createTx('tx-b', 20000)], + ); + final container = createContainer([ + transactionsRepositoryProvider.overrideWith((ref) { + final wallet = ref.watch(activeWalletProvider); + return identical(wallet, walletA) ? repoA : repoB; + }), + ], overrideWalletBinding: false); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(walletA); + keepControllerAlive(container, recordA.id); + await container + .read(transactionsControllerProvider(recordA.id).notifier) + .loadTransactions(); + + container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(walletB); + await container.pump(); + + final walletAState = container.read( + transactionsControllerProvider(recordA.id), + ); + expect(walletAState.status, TransactionsLoadState.noWallet); + expect(walletAState.transactions, isEmpty); + }, + ); + + test( + 'a transaction detail from wallet A is not reused after switching to wallet B', + () async { + final recordA = createRecord('wallet-a', 'Wallet A'); + final recordB = createRecord('wallet-b', 'Wallet B'); + + final txA = createTx('tx-123', 10000); + final txB = createTx('tx-123', 20000); + + final container = createContainer([ + transactionsRepositoryProvider.overrideWith((ref) { + final activeId = ref.watch(activeWalletIdProvider); + return FakeTransactionsRepository( + transactions: activeId == 'wallet-a' ? [txA] : [txB], + ); + }), + ], overrideWalletBinding: false); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + + final detailA = await container.read( + transactionDetailsProvider(( + walletId: 'wallet-a', + txid: 'tx-123', + )).future, + ); + expect(detailA?.netAmount, 10000); + + container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + + final detailB = await container.read( + transactionDetailsProvider(( + walletId: 'wallet-b', + txid: 'tx-123', + )).future, + ); + expect(detailB?.netAmount, 20000); + }, + ); + }); +} diff --git a/bdk_demo/test/features/transactions/transactions_repository_test.dart b/bdk_demo/test/features/transactions/transactions_repository_test.dart new file mode 100644 index 0000000..8c74e00 --- /dev/null +++ b/bdk_demo/test/features/transactions/transactions_repository_test.dart @@ -0,0 +1,133 @@ +import 'package:bdk_dart/bdk.dart' as bdk; +import 'package:bdk_demo/features/transactions/transaction_history_mapper.dart'; +import 'package:bdk_demo/features/transactions/transactions_repository.dart'; +import 'package:bdk_demo/models/wallet_record.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +class _FakeWallet extends Fake implements bdk.Wallet { + @override + void dispose() {} +} + +class _FakeTransactionHistorySource implements TransactionHistorySource { + _FakeTransactionHistorySource(this.records); + + final List records; + + @override + List transactions() => records; + + @override + TransactionHistoryRecord? transactionByTxid(String txid) { + for (final transaction in records) { + if (transaction.txid == txid) return transaction; + } + return null; + } +} + +void main() { + group('WalletTransactionsRepository', () { + test('binds the production repository to the loaded wallet ID', () { + const recordA = WalletRecord( + id: 'wallet-a', + name: 'Wallet A', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + const recordB = WalletRecord( + id: 'wallet-b', + name: 'Wallet B', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + final container = ProviderContainer(); + addTearDown(container.dispose); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(_FakeWallet()); + final repositoryA = container.read(transactionsRepositoryProvider); + expect(repositoryA.isAvailableForWallet(recordA.id), isTrue); + expect(repositoryA.isAvailableForWallet(recordB.id), isFalse); + + container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(_FakeWallet()); + final repositoryB = container.read(transactionsRepositoryProvider); + expect(repositoryB.isAvailableForWallet(recordA.id), isFalse); + expect(repositoryB.isAvailableForWallet(recordB.id), isTrue); + }); + + test('returns empty history when no active wallet is available', () async { + final repository = WalletTransactionsRepository( + walletId: null, + source: null, + ); + + final transactions = await repository.loadTransactions(); + + expect(transactions, isEmpty); + }); + + test('maps wallet transaction records into history items', () async { + final repository = WalletTransactionsRepository( + walletId: 'wallet-a', + source: _FakeTransactionHistorySource([ + const TransactionHistoryRecord( + txid: + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + sent: 1200, + received: 42000, + position: ConfirmedTransactionPosition( + blockHeight: 120, + confirmationTime: 1704164640, + ), + ), + const TransactionHistoryRecord( + txid: + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + sent: 1600, + received: 0, + position: UnconfirmedTransactionPosition(), + ), + ]), + ); + + final transactions = await repository.loadTransactions(); + + expect(transactions, hasLength(2)); + expect(transactions.first.txid, startsWith('123456')); + expect(transactions.first.netAmount, 40800); + expect(transactions.first.pending, isFalse); + expect(transactions.first.blockHeight, 120); + expect(transactions.last.netAmount, -1600); + expect(transactions.last.pending, isTrue); + }); + + test('loads a transaction detail by txid from wallet records', () async { + final repository = WalletTransactionsRepository( + walletId: 'wallet-a', + source: _FakeTransactionHistorySource([ + const TransactionHistoryRecord( + txid: + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + sent: 0, + received: 42000, + position: ConfirmedTransactionPosition( + blockHeight: 120, + confirmationTime: 1704164640, + ), + ), + ]), + ); + + final transaction = await repository.loadTransactionByTxid( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + ); + + expect(transaction, isNotNull); + expect(transaction!.received, 42000); + }); + }); +} diff --git a/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart b/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart index 7a7d0ea..6aa1ac4 100644 --- a/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart +++ b/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart @@ -1,4 +1,4 @@ -import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; import 'package:bdk_demo/features/transactions/transactions_repository.dart'; class FakeTransactionsRepository implements TransactionsRepository { @@ -7,11 +7,14 @@ class FakeTransactionsRepository implements TransactionsRepository { this.throwOnLoad = false, }); - final List transactions; + final List transactions; final bool throwOnLoad; @override - Future> loadTransactions() async { + bool isAvailableForWallet(String? walletId) => walletId != null; + + @override + Future> loadTransactions() async { if (throwOnLoad) { throw Exception('forced transaction load failure'); } @@ -19,7 +22,7 @@ class FakeTransactionsRepository implements TransactionsRepository { } @override - Future loadTransactionByTxid(String txid) async { + Future loadTransactionByTxid(String txid) async { final items = await loadTransactions(); for (final transaction in items) { if (transaction.txid == txid) return transaction; diff --git a/bdk_demo/test/helpers/fixtures/placeholder_transactions.dart b/bdk_demo/test/helpers/fixtures/transaction_history_items.dart similarity index 63% rename from bdk_demo/test/helpers/fixtures/placeholder_transactions.dart rename to bdk_demo/test/helpers/fixtures/transaction_history_items.dart index bb7d8c1..79c0032 100644 --- a/bdk_demo/test/helpers/fixtures/placeholder_transactions.dart +++ b/bdk_demo/test/helpers/fixtures/transaction_history_items.dart @@ -1,7 +1,7 @@ -import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; -final placeholderTransactions = [ - DemoTxDetails( +final transactionHistoryItems = [ + TransactionHistoryItem( txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', sent: 0, received: 42000, @@ -9,7 +9,7 @@ final placeholderTransactions = [ blockHeight: 120, confirmationTime: DateTime(2024, 1, 2, 3, 4), ), - const DemoTxDetails( + const TransactionHistoryItem( txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', sent: 1600, received: 0, diff --git a/bdk_demo/test/presentation/send_page_test.dart b/bdk_demo/test/presentation/send_page_test.dart index 5e62c8b..484a444 100644 --- a/bdk_demo/test/presentation/send_page_test.dart +++ b/bdk_demo/test/presentation/send_page_test.dart @@ -1,6 +1,9 @@ import 'package:bdk_dart/bdk.dart' hide Key; import 'package:bdk_demo/core/router/app_router.dart'; import 'package:bdk_demo/features/send/send_page.dart'; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; +import 'package:bdk_demo/features/transactions/transactions_controller.dart'; +import 'package:bdk_demo/features/transactions/transactions_repository.dart'; import 'package:bdk_demo/models/wallet_record.dart'; import 'package:bdk_demo/providers/connectivity_provider.dart'; import 'package:bdk_demo/providers/send_providers.dart'; @@ -44,6 +47,7 @@ void main() { bool seedActiveWallet = true, SendTransactionDraftBuilder? draftBuilder, BlockchainClientFactory? blockchainClientFactory, + TransactionsRepository? transactionsRepository, }) async { SharedPreferences.setMockInitialValues({}); final prefs = await SharedPreferences.getInstance(); @@ -63,6 +67,10 @@ void main() { blockchainClientFactoryProvider.overrideWithValue( blockchainClientFactory, ), + if (transactionsRepository != null) + transactionsRepositoryProvider.overrideWithValue( + transactionsRepository, + ), ], ); addTearDown(container.dispose); @@ -388,6 +396,47 @@ void main() { expect(find.text('Home route'), findsOneWidget); }); + testWidgets('confirm refreshes an already-mounted transaction history', ( + tester, + ) async { + final repo = _MutableTransactionsRepository(); + final fake = _SendFlowFake( + onBroadcast: () { + repo.transactions = [ + TransactionHistoryItem( + txid: 'broadcast-tx', + sent: 1000, + received: 0, + pending: true, + ), + ]; + }, + ); + final container = await createContainer( + draftBuilder: fake.build, + blockchainClientFactory: (_) => _FakeBlockchainClient(), + transactionsRepository: repo, + ); + final subscription = container.listen( + transactionsControllerProvider('send-wallet'), + (_, __) {}, + ); + addTearDown(subscription.close); + await container + .read(transactionsControllerProvider('send-wallet').notifier) + .loadTransactions(); + + await pumpSendPageWithRouter(tester, container); + await fillSendForm(tester); + await tapReview(tester); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, 'Confirm')); + await tester.pumpAndSettle(); + + final state = container.read(transactionsControllerProvider('send-wallet')); + expect(state.transactions.single.txid, 'broadcast-tx'); + }); + testWidgets('build failure shows friendly snackbar and stays on SendPage', ( tester, ) async { @@ -436,10 +485,15 @@ void main() { } final class _SendFlowFake { - _SendFlowFake({this.failBuild = false, this.failBroadcast = false}); + _SendFlowFake({ + this.failBuild = false, + this.failBroadcast = false, + this.onBroadcast, + }); final bool failBuild; final bool failBroadcast; + final VoidCallback? onBroadcast; int buildCount = 0; int broadcastCount = 0; int? builtAmountSat; @@ -463,12 +517,31 @@ final class _SendFlowFake { if (failBroadcast) { throw StateError('broadcast failed'); } + onBroadcast?.call(); return 'fake-txid'; }, ); } } +final class _MutableTransactionsRepository implements TransactionsRepository { + List transactions = const []; + + @override + bool isAvailableForWallet(String? walletId) => walletId != null; + + @override + Future loadTransactionByTxid(String txid) async { + for (final transaction in transactions) { + if (transaction.txid == txid) return transaction; + } + return null; + } + + @override + Future> loadTransactions() async => transactions; +} + final class _FakeBlockchainClient implements BlockchainClient { @override BlockchainBackend get backend => BlockchainBackend.electrum; diff --git a/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart b/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart index 3123a27..9d6f96a 100644 --- a/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart @@ -1,28 +1,58 @@ +import 'package:bdk_dart/bdk.dart' as bdk; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; import 'package:bdk_demo/features/transactions/transaction_detail_page.dart'; import 'package:bdk_demo/features/transactions/transactions_repository.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:bdk_demo/models/wallet_record.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import '../../helpers/fakes/fake_transactions_repository.dart'; -import '../../helpers/fixtures/placeholder_transactions.dart'; +import '../../helpers/fixtures/transaction_history_items.dart'; + +class FakeWallet extends Fake implements bdk.Wallet { + @override + void dispose() {} +} Future _pumpDetailPage( WidgetTester tester, { required TransactionsRepository repository, required String txid, + ProviderContainer? container, }) async { - await tester.pumpWidget( - ProviderScope( - overrides: [transactionsRepositoryProvider.overrideWithValue(repository)], - child: MaterialApp( - home: TransactionDetailPage( - key: const ValueKey('detail-page'), - txid: txid, + if (container != null) { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp( + home: TransactionDetailPage( + key: const ValueKey('detail-page'), + txid: txid, + ), ), ), - ), - ); + ); + } else { + await tester.pumpWidget( + ProviderScope( + overrides: [ + transactionsRepositoryProvider.overrideWithValue(repository), + activeWalletIdProvider.overrideWithValue('wallet-a'), + activeWalletBindingProvider.overrideWithValue( + ActiveWalletBinding(walletId: 'wallet-a', wallet: FakeWallet()), + ), + ], + child: MaterialApp( + home: TransactionDetailPage( + key: const ValueKey('detail-page'), + txid: txid, + ), + ), + ), + ); + } await tester.pumpAndSettle(); } @@ -31,9 +61,9 @@ void main() { await _pumpDetailPage( tester, repository: FakeTransactionsRepository( - transactions: placeholderTransactions, + transactions: transactionHistoryItems, ), - txid: placeholderTransactions.first.txid, + txid: transactionHistoryItems.first.txid, ); expect(find.text('Transaction Detail'), findsOneWidget); @@ -51,13 +81,13 @@ void main() { testWidgets('updates when the txid changes', (tester) async { final repository = FakeTransactionsRepository( - transactions: placeholderTransactions, + transactions: transactionHistoryItems, ); await _pumpDetailPage( tester, repository: repository, - txid: placeholderTransactions.first.txid, + txid: transactionHistoryItems.first.txid, ); expect( @@ -71,7 +101,7 @@ void main() { await _pumpDetailPage( tester, repository: repository, - txid: placeholderTransactions.last.txid, + txid: transactionHistoryItems.last.txid, ); expect( @@ -101,4 +131,122 @@ void main() { expect(find.text('Transaction not found'), findsOneWidget); expect(find.textContaining('missing-txid'), findsOneWidget); }); + + testWidgets('distinguishes no active wallet from a missing transaction', ( + tester, + ) async { + final container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository(transactions: const []), + ), + ], + ); + addTearDown(container.dispose); + container + .read(activeWalletRecordProvider.notifier) + .set( + const WalletRecord( + id: 'wallet-a', + name: 'Wallet A', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ), + ); + + await _pumpDetailPage( + tester, + repository: FakeTransactionsRepository(transactions: const []), + txid: 'missing-txid', + container: container, + ); + + expect(find.text('No active wallet'), findsOneWidget); + expect(find.text('Transaction not found'), findsNothing); + }); + + testWidgets( + 'transaction detail from wallet A is not reused after switching to wallet B', + (tester) async { + late final ProviderContainer container; + + final recordA = WalletRecord( + id: 'wallet-a', + name: 'Wallet A', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + + final recordB = WalletRecord( + id: 'wallet-b', + name: 'Wallet B', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + + final txA = TransactionHistoryItem( + txid: 'tx-123', + sent: 0, + received: 10000, + pending: false, + blockHeight: 100, + confirmationTime: DateTime.now(), + ); + + final txB = TransactionHistoryItem( + txid: 'tx-123', + sent: 0, + received: 20000, + pending: false, + blockHeight: 101, + confirmationTime: DateTime.now(), + ); + + final dynamicRepository = FakeTransactionsRepository( + transactions: const [], + ); + + container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWith((ref) { + final activeId = ref.watch(activeWalletIdProvider); + return FakeTransactionsRepository( + transactions: activeId == 'wallet-a' ? [txA] : [txB], + ); + }), + ], + ); + addTearDown(container.dispose); + + // Set initial wallet record to Wallet A + container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + + // 1. Pump with wallet A active + await _pumpDetailPage( + tester, + repository: dynamicRepository, + txid: 'tx-123', + container: container, + ); + + // Verify wallet A's detail is rendered + expect(find.text('+10000 sat'), findsNWidgets(2)); + expect(find.text('+20000 sat'), findsNothing); + + // 2. Switch logical active wallet ID to wallet B + container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + await tester.pump(); // Start rebuild + + // Verify it doesn't immediately reuse wallet A's detail + expect(find.text('+10000 sat'), findsNothing); + + await tester.pumpAndSettle(); + + // Verify wallet B's detail is rendered now + expect(find.text('+20000 sat'), findsNWidgets(2)); + expect(find.text('+10000 sat'), findsNothing); + }, + ); } diff --git a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart index f0940b2..e8472df 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -1,17 +1,75 @@ +import 'dart:async'; +import 'package:bdk_dart/bdk.dart' as bdk; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; import 'package:bdk_demo/features/transactions/transaction_detail_page.dart'; import 'package:bdk_demo/features/transactions/transactions_list_page.dart'; import 'package:bdk_demo/features/transactions/transactions_repository.dart'; +import 'package:bdk_demo/models/wallet_record.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import '../../helpers/fakes/fake_transactions_repository.dart'; -import '../../helpers/fixtures/placeholder_transactions.dart'; +import '../../helpers/fixtures/transaction_history_items.dart'; + +class FakeWallet extends Fake implements bdk.Wallet { + @override + void dispose() {} +} + +class DelayedTransactionsRepository implements TransactionsRepository { + final Future> delayedResult; + + DelayedTransactionsRepository(this.delayedResult); + + @override + bool isAvailableForWallet(String? walletId) => walletId != null; + + @override + Future> loadTransactions() async { + return delayedResult; + } + + @override + Future loadTransactionByTxid(String txid) async { + final list = await delayedResult; + for (final tx in list) { + if (tx.txid == txid) return tx; + } + return null; + } +} + +class MutableTransactionsRepository implements TransactionsRepository { + List transactions; + + MutableTransactionsRepository(this.transactions); + + @override + bool isAvailableForWallet(String? walletId) => walletId != null; + + @override + Future> loadTransactions() async { + return transactions; + } + + @override + Future loadTransactionByTxid(String txid) async { + for (final tx in transactions) { + if (tx.txid == txid) return tx; + } + return null; + } +} Future _pumpTransactionsFlow( WidgetTester tester, { required TransactionsRepository repository, + bool hasActiveWallet = true, + ProviderContainer? container, + bool settle = true, }) async { final router = GoRouter( initialLocation: '/transactions', @@ -27,43 +85,60 @@ Future _pumpTransactionsFlow( builder: (context, state) => TransactionDetailPage(txid: state.pathParameters['txid'] ?? ''), ), + GoRoute( + path: '/other', + name: 'other', + builder: (context, state) => const Scaffold(body: Text('Other Page')), + ), ], ); - await tester.pumpWidget( - ProviderScope( - overrides: [transactionsRepositoryProvider.overrideWithValue(repository)], - child: MaterialApp.router(routerConfig: router), - ), - ); - await tester.pumpAndSettle(); -} - -void main() { - testWidgets('shows intro before loading transactions', (tester) async { - await _pumpTransactionsFlow( - tester, - repository: FakeTransactionsRepository( - transactions: placeholderTransactions, + if (container != null) { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp.router(routerConfig: router), ), ); + } else { + await tester.pumpWidget( + ProviderScope( + overrides: [ + transactionsRepositoryProvider.overrideWithValue(repository), + activeWalletIdProvider.overrideWithValue( + hasActiveWallet ? 'wallet-a' : null, + ), + activeWalletBindingProvider.overrideWithValue( + hasActiveWallet + ? ActiveWalletBinding( + walletId: 'wallet-a', + wallet: FakeWallet(), + ) + : null, + ), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + } + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } +} - expect(find.text('Transactions Demo'), findsNWidgets(2)); - expect(find.text('Load Transactions Demo'), findsOneWidget); - expect(find.text('Transactions not loaded yet'), findsOneWidget); - }); - - testWidgets('loads and renders placeholder transactions', (tester) async { +void main() { + testWidgets('automatically loads and renders wallet transactions', ( + tester, + ) async { await _pumpTransactionsFlow( tester, repository: FakeTransactionsRepository( - transactions: placeholderTransactions, + transactions: transactionHistoryItems, ), ); - await tester.tap(find.text('Load Transactions Demo')); - await tester.pumpAndSettle(); - expect(find.text('+42000 sat'), findsOneWidget); expect(find.text('-1600 sat'), findsOneWidget); expect(find.text('123456...abcd'), findsOneWidget); @@ -72,6 +147,60 @@ void main() { expect(find.text('pending'), findsOneWidget); }); + testWidgets( + 'seamlessly preserves/refreshes state on navigation away and back', + (tester) async { + final router = GoRouter( + initialLocation: '/transactions', + routes: [ + GoRoute( + path: '/transactions', + name: 'transactionHistory', + builder: (context, state) => const TransactionsListPage(), + ), + GoRoute( + path: '/other', + name: 'other', + builder: (context, state) => + const Scaffold(body: Text('Other Page')), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository(transactions: transactionHistoryItems), + ), + activeWalletIdProvider.overrideWithValue('wallet-a'), + activeWalletBindingProvider.overrideWithValue( + ActiveWalletBinding(walletId: 'wallet-a', wallet: FakeWallet()), + ), + ], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); + + // 1. Verify initially loaded + expect(find.text('+42000 sat'), findsOneWidget); + + // 2. Navigate away + router.go('/other'); + await tester.pumpAndSettle(); + expect(find.text('+42000 sat'), findsNothing); + expect(find.text('Other Page'), findsOneWidget); + + // 3. Navigate back + router.go('/transactions'); + await tester.pumpAndSettle(); + + // 4. Verify automatically loaded again (no reload tap required) + expect(find.text('+42000 sat'), findsOneWidget); + }, + ); + testWidgets('shows empty state when no transactions are returned', ( tester, ) async { @@ -80,13 +209,10 @@ void main() { repository: FakeTransactionsRepository(transactions: const []), ); - await tester.tap(find.text('Load Transactions Demo')); - await tester.pumpAndSettle(); - expect(find.text('No transactions yet'), findsOneWidget); expect( find.text( - 'The transaction demo loaded successfully, but no placeholder transactions are configured yet.', + 'The active wallet has no transactions yet. Sync the wallet or receive funds to populate history.', ), findsOneWidget, ); @@ -96,13 +222,10 @@ void main() { await _pumpTransactionsFlow( tester, repository: FakeTransactionsRepository( - transactions: placeholderTransactions, + transactions: transactionHistoryItems, ), ); - await tester.tap(find.text('Load Transactions Demo')); - await tester.pumpAndSettle(); - await tester.tap(find.text('123456...abcd')); await tester.pumpAndSettle(); @@ -114,4 +237,289 @@ void main() { findsOneWidget, ); }); + + testWidgets( + 'no active wallet shows the no-wallet state and disables load button', + (tester) async { + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository(transactions: const []), + hasActiveWallet: false, + ); + + expect(find.text('No active wallet'), findsOneWidget); + expect( + find.text( + 'Create or load a wallet before viewing transaction history.', + ), + findsOneWidget, + ); + + final buttonFinder = find.ancestor( + of: find.text('Load Transaction History'), + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton, + ), + ); + expect(tester.widget(buttonFinder).onPressed, isNull); + }, + ); + + testWidgets( + 'wallet record without an FFI wallet shows no-wallet state and disables loading', + (tester) async { + final container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository(transactions: const []), + ), + ], + ); + addTearDown(container.dispose); + container + .read(activeWalletRecordProvider.notifier) + .set( + const WalletRecord( + id: 'wallet-a', + name: 'Wallet A', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ), + ); + + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository(transactions: const []), + container: container, + ); + + expect(find.text('No active wallet'), findsOneWidget); + final buttonFinder = find.ancestor( + of: find.text('Load Transaction History'), + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton, + ), + ); + expect(tester.widget(buttonFinder).onPressed, isNull); + }, + ); + + testWidgets( + 'switching logical active wallet ID from A to B clears A\'s transaction list and loads B\'s automatically', + (tester) async { + late final ProviderContainer container; + + final recordA = WalletRecord( + id: 'wallet-a', + name: 'Wallet A', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + + final recordB = WalletRecord( + id: 'wallet-b', + name: 'Wallet B', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + + final txsA = [ + TransactionHistoryItem( + txid: 'tx-a', + sent: 0, + received: 10000, + pending: false, + blockHeight: 100, + confirmationTime: DateTime.now(), + ), + ]; + + final txsB = [ + TransactionHistoryItem( + txid: 'tx-b', + sent: 0, + received: 20000, + pending: false, + blockHeight: 101, + confirmationTime: DateTime.now(), + ), + ]; + final walletBTransactions = Completer>(); + + container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWith((ref) { + final activeId = ref.watch(activeWalletIdProvider); + return activeId == 'wallet-a' + ? FakeTransactionsRepository(transactions: txsA) + : DelayedTransactionsRepository(walletBTransactions.future); + }), + ], + ); + addTearDown(container.dispose); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository(transactions: const []), + container: container, + ); + + // Verify A's transactions are rendered + expect(find.text('+10000 sat'), findsOneWidget); + expect(find.textContaining('tx-a'), findsOneWidget); + + // Switch logical active wallet ID from A to B + container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + await tester.pump(); + + // Wallet A must disappear before wallet B's delayed load completes. + expect(find.text('+10000 sat'), findsNothing); + expect(find.textContaining('tx-a'), findsNothing); + + walletBTransactions.complete(txsB); + await tester.pumpAndSettle(); + + // Verify B's rows load automatically without build-time exceptions. + expect(find.text('+20000 sat'), findsOneWidget); + expect(find.textContaining('tx-b'), findsOneWidget); + }, + ); + + testWidgets( + 'pending transaction updates to confirmed automatically after wallet sync without manual reload', + (tester) async { + late final ProviderContainer container; + + final record = WalletRecord( + id: 'wallet-a', + name: 'Wallet A', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + + final txPending = TransactionHistoryItem( + txid: 'tx-1', + sent: 0, + received: 10000, + pending: true, + blockHeight: null, + confirmationTime: null, + ); + + final txConfirmed = TransactionHistoryItem( + txid: 'tx-1', + sent: 0, + received: 10000, + pending: false, + blockHeight: 200, + confirmationTime: DateTime.now(), + ); + + final repo = MutableTransactionsRepository([txPending]); + + container = ProviderContainer( + overrides: [transactionsRepositoryProvider.overrideWithValue(repo)], + ); + addTearDown(container.dispose); + + container.read(activeWalletRecordProvider.notifier).set(record); + final walletA = FakeWallet(); + container.read(activeWalletProvider.notifier).set(walletA); + + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository(transactions: const []), + container: container, + ); + + // Confirm UI displays: Awaiting confirmation + expect(find.text('Awaiting confirmation'), findsOneWidget); + expect(find.text('Block 200'), findsNothing); + + // Simulate a successful wallet sync (replace wallet instance and update mock data) + repo.transactions = [txConfirmed]; + final walletB = FakeWallet(); + container.read(activeWalletProvider.notifier).set(walletB); + + await tester.pumpAndSettle(); + + // Confirm Awaiting confirmation is gone, and confirmed state shows block height + expect(find.text('Awaiting confirmation'), findsNothing); + expect(find.text('Block 200'), findsOneWidget); + }, + ); + + testWidgets( + 'stale async results from previous wallet A do not overwrite wallet B state', + (tester) async { + late final ProviderContainer container; + + final recordA = WalletRecord( + id: 'wallet-a', + name: 'Wallet A', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + + final recordB = WalletRecord( + id: 'wallet-b', + name: 'Wallet B', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + + final completerA = Completer>(); + final completerB = Completer>(); + + container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWith((ref) { + final activeId = ref.watch(activeWalletIdProvider); + if (activeId == 'wallet-a') { + return DelayedTransactionsRepository(completerA.future); + } else { + return DelayedTransactionsRepository(completerB.future); + } + }), + ], + ); + addTearDown(container.dispose); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository(transactions: const []), + container: container, + settle: false, + ); + + // Verify wallet A is loading + expect(find.text('Loading transaction history...'), findsOneWidget); + + // Switch active wallet to B + container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + await tester.pump(); + + // Complete A's future + completerA.complete([ + TransactionHistoryItem( + txid: 'tx-a', + sent: 0, + received: 10000, + pending: false, + ), + ]); + await tester.pump(); + + // Wallet B's state shouldn't render A's transaction + expect(find.text('+10000 sat'), findsNothing); + }, + ); }