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..23cb4e6 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,10 @@ 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 transactionAsync = ref.watch( + transactionDetailsProvider((walletId: activeWalletId, txid: txid)), + ); return Scaffold( appBar: const SecondaryAppBar(title: 'Transaction Detail'), @@ -37,14 +41,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 +58,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 +87,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..668c3e0 --- /dev/null +++ b/bdk_demo/lib/features/transactions/transaction_history_mapper.dart @@ -0,0 +1,52 @@ +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 { + final int? timestamp; + + const UnconfirmedTransactionPosition({this.timestamp}); +} + +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..a39cafa 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,71 +19,155 @@ 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 transactionDetailsProvider = FutureProvider.autoDispose + .family(( + ref, + arg, + ) { + final repository = ref.watch(transactionsRepositoryProvider); + 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() { + if (walletId == null) { + return const TransactionsState( + status: TransactionsLoadState.noWallet, + transactions: [], + statusMessage: + 'Create or load a wallet before viewing transaction history.', + ); + } + + ref.listen(activeWalletProvider, (previous, next) { + if (next != null) { + final isSuccess = state.status == TransactionsLoadState.success; + loadTransactions(isBackgroundRefresh: isSuccess); + } + }); + + Future.microtask(() => loadTransactions()); + + return const TransactionsState.idle(); + } + + Future loadTransactions({bool isBackgroundRefresh = false}) async { + if (walletId == null) { + state = const TransactionsState( + status: TransactionsLoadState.noWallet, + transactions: [], + statusMessage: + 'Create or load a wallet before viewing transaction history.', + ); + return; + } + + if (_inFlightLoad != null) { + _hasPendingRefresh = true; + if (!isBackgroundRefresh) { + _pendingIsBackground = false; + } + return _inFlightLoad; + } + + _inFlightLoad = _performLoad(isBackgroundRefresh: isBackgroundRefresh); + try { + 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 transactions = await ref .read(transactionsRepositoryProvider) .loadTransactions(); + if (!ref.mounted) { + 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 (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), + ); + } } } diff --git a/bdk_demo/lib/features/transactions/transactions_list_page.dart b/bdk_demo/lib/features/transactions/transactions_list_page.dart index 63acc02..3488b84 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,14 @@ 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 controllerProvider = transactionsControllerProvider(activeWalletId); + final state = ref.watch(controllerProvider); final isLoading = state.status == TransactionsLoadState.loading; + final canLoad = activeWalletId != null && !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 +58,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 +90,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 +101,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 +114,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 +124,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 +152,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 +212,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..d327c09 100644 --- a/bdk_demo/lib/features/transactions/transactions_repository.dart +++ b/bdk_demo/lib/features/transactions/transactions_repository.dart @@ -1,53 +1,185 @@ -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); + 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 wallet = ref.watch(activeWalletProvider); + return WalletTransactionsRepository( + source: wallet == null ? null : BdkWalletTransactionSource(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 TransactionHistorySource? source}) + : _source = source; + + final TransactionHistorySource? _source; @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 transactions = await loadTransactions(); - for (final transaction in transactions) { - if (transaction.txid == txid) return transaction; + 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 + 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 = UnconfirmedTransactionPosition( + timestamp: position.timestamp, + ); + } 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/providers/wallet_providers.dart b/bdk_demo/lib/providers/wallet_providers.dart index 1f5dfdf..a875a1c 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,6 +36,10 @@ final activeWalletProvider = NotifierProvider( ActiveWalletNotifier.new, ); +final hasActiveWalletProvider = Provider((ref) { + return ref.watch(activeWalletProvider) != null; +}); + class ActiveWalletNotifier extends Notifier { late WalletDisposer _walletDisposer; Wallet? _currentWallet; 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..461321c --- /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(timestamp: 1704164640), + ); + + 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..53f2871 --- /dev/null +++ b/bdk_demo/test/features/transactions/transactions_controller_test.dart @@ -0,0 +1,384 @@ +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 + 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 + 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) { + final container = ProviderContainer(overrides: overrides); + 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, + ); + }), + ]); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + 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); + 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), + ]); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + 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); + 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 repo = CountingTransactionsRepository( + transactions: [createTx('tx-a', 10000)], + ); + + final container = createContainer([ + transactionsRepositoryProvider.overrideWithValue(repo), + ]); + + 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(); + final initialLoadCount = repo.loadCount; + + container.read(activeWalletProvider.notifier).set(wallet2); + await container.pump(); + + expect(repo.loadCount, equals(initialLoadCount + 1)); + }, + ); + + 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], + ); + }), + ]); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + + final detailA = await container.read( + transactionDetailsProvider(( + walletId: 'wallet-a', + txid: 'tx-123', + )).future, + ); + expect(detailA?.netAmount, 10000); + + container.read(activeWalletRecordProvider.notifier).set(recordB); + + 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..08186aa --- /dev/null +++ b/bdk_demo/test/features/transactions/transactions_repository_test.dart @@ -0,0 +1,90 @@ +import 'package:bdk_demo/features/transactions/transaction_history_mapper.dart'; +import 'package:bdk_demo/features/transactions/transactions_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; + +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('returns empty history when no active wallet is available', () async { + final repository = WalletTransactionsRepository(source: null); + + final transactions = await repository.loadTransactions(); + + expect(transactions, isEmpty); + }); + + test('maps wallet transaction records into history items', () async { + final repository = WalletTransactionsRepository( + 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( + 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..30da3ed 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,11 @@ class FakeTransactionsRepository implements TransactionsRepository { this.throwOnLoad = false, }); - final List transactions; + final List transactions; final bool throwOnLoad; @override - Future> loadTransactions() async { + Future> loadTransactions() async { if (throwOnLoad) { throw Exception('forced transaction load failure'); } @@ -19,7 +19,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/transactions/transaction_detail_page_test.dart b/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart index 3123a27..b5a87b8 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,49 @@ +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'; 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'), + ], + child: MaterialApp( + home: TransactionDetailPage( + key: const ValueKey('detail-page'), + txid: txid, + ), + ), + ), + ); + } await tester.pumpAndSettle(); } @@ -31,9 +52,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 +72,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 +92,7 @@ void main() { await _pumpDetailPage( tester, repository: repository, - txid: placeholderTransactions.last.txid, + txid: transactionHistoryItems.last.txid, ); expect( @@ -101,4 +122,87 @@ void main() { expect(find.text('Transaction not found'), findsOneWidget); expect(find.textContaining('missing-txid'), findsOneWidget); }); + + 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); + + // 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); + 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..94ac185 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,69 @@ +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 + 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 + 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 +79,52 @@ 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, + ), + ], + 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 +133,57 @@ 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'), + ], + 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 +192,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 +205,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 +220,238 @@ 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.widgetWithText( + FilledButton, + 'Load Transaction History', + ); + 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(), + ), + ]; + + container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWith((ref) { + final activeId = ref.watch(activeWalletIdProvider); + return FakeTransactionsRepository( + transactions: activeId == 'wallet-a' ? txsA : txsB, + ); + }), + ], + ); + addTearDown(container.dispose); + + container.read(activeWalletRecordProvider.notifier).set(recordA); + + 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); + await tester.pumpAndSettle(); + + // Verify A's rows are gone, and B's rows loaded automatically without build-time exceptions + expect(find.text('+10000 sat'), findsNothing); + expect(find.textContaining('tx-a'), findsNothing); + 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); + + 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); + 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); + }, + ); }