From 030bf8591d517abb5dad40036b969f64902f287e Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 13:42:28 +0100 Subject: [PATCH 01/19] docs: plan real transaction history --- ...06-30-bdk-demo-real-transaction-history.md | 173 ++++++++++++++++++ ...dk-demo-real-transaction-history-design.md | 49 +++++ 2 files changed, 222 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-30-bdk-demo-real-transaction-history.md create mode 100644 docs/superpowers/specs/2026-06-30-bdk-demo-real-transaction-history-design.md diff --git a/docs/superpowers/plans/2026-06-30-bdk-demo-real-transaction-history.md b/docs/superpowers/plans/2026-06-30-bdk-demo-real-transaction-history.md new file mode 100644 index 0000000..7347bb6 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-bdk-demo-real-transaction-history.md @@ -0,0 +1,173 @@ +# BDK Demo Real Transaction History Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the demo app transaction history placeholder rows with real active-wallet transaction data. + +**Architecture:** Keep the existing standalone `features/transactions/` module from PR #62. Replace the default repository with a wallet-backed repository that maps active BDK wallet data into app-side transaction rows, while tests continue to use fakes. + +**Tech Stack:** Dart, Flutter, Riverpod, GoRouter, BDK Dart bindings. + +## Global Constraints + +- Branch, PR title, and new document names must follow project naming and must not use restricted tool-specific naming. +- Do not place transaction-history UI logic inside `WalletService`. +- Keep feature code under `bdk_demo/lib/features/transactions/`. +- Use TDD: write the failing test before production changes. +- Keep fake repositories in `bdk_demo/test/helpers/fakes/`. + +--- + +### Task 1: Rename Transaction Model and Copy + +**Files:** +- Rename: `bdk_demo/lib/features/transactions/models/demo_tx_details.dart` to `bdk_demo/lib/features/transactions/models/transaction_history_item.dart` +- Modify: `bdk_demo/lib/features/transactions/transactions_controller.dart` +- Modify: `bdk_demo/lib/features/transactions/transactions_list_page.dart` +- Modify: `bdk_demo/lib/features/transactions/transaction_detail_page.dart` +- Modify: `bdk_demo/test/helpers/fakes/fake_transactions_repository.dart` +- Modify: `bdk_demo/test/helpers/fixtures/placeholder_transactions.dart` +- Modify: `bdk_demo/test/presentation/transactions/transactions_list_page_test.dart` +- Modify: `bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart` + +**Interfaces:** +- Produces: `TransactionHistoryItem` with `txid`, `sent`, `received`, `pending`, `blockHeight`, `confirmationTime`, `netAmount`, `shortTxid`, and `statusLabel`. + +- [ ] **Step 1: Write failing tests** + +Update the transaction widget tests to expect real-history wording: + +```dart +expect(find.text('Transaction History'), findsOneWidget); +expect(find.text('Load Transaction History'), findsOneWidget); +expect(find.text('Transaction history not loaded yet'), findsOneWidget); +``` + +- [ ] **Step 2: Run failing tests** + +Run: `flutter test bdk_demo/test/presentation/transactions` + +Expected: FAIL because the UI still says "Transactions Demo" and imports `DemoTxDetails`. + +- [ ] **Step 3: Rename model and update copy** + +Rename the model and update imports/types from `DemoTxDetails` to `TransactionHistoryItem`. Update user-facing copy from placeholder/demo wording to active-wallet transaction-history wording. + +- [ ] **Step 4: Run passing tests** + +Run: `flutter test bdk_demo/test/presentation/transactions` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add bdk_demo/lib/features/transactions bdk_demo/test/helpers bdk_demo/test/presentation/transactions +git commit -m "refactor: rename transaction history model" +``` + +### Task 2: Add Wallet-Backed Mapping + +**Files:** +- Create: `bdk_demo/lib/features/transactions/transaction_history_mapper.dart` +- Modify: `bdk_demo/lib/features/transactions/transactions_repository.dart` +- Test: `bdk_demo/test/features/transactions/transaction_history_mapper_test.dart` + +**Interfaces:** +- Consumes: `TransactionHistoryItem`. +- Produces: mapping helpers that convert BDK wallet transaction data into `TransactionHistoryItem`. + +- [ ] **Step 1: Write failing mapper tests** + +Test confirmed and unconfirmed mapping, including sent/received values and confirmation metadata. + +- [ ] **Step 2: Run failing tests** + +Run: `flutter test bdk_demo/test/features/transactions/transaction_history_mapper_test.dart` + +Expected: FAIL because the mapper does not exist. + +- [ ] **Step 3: Implement mapper** + +Create a focused mapper that turns txid strings, sent/received sats, and chain-position metadata into `TransactionHistoryItem`. + +- [ ] **Step 4: Run passing tests** + +Run: `flutter test bdk_demo/test/features/transactions/transaction_history_mapper_test.dart` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add bdk_demo/lib/features/transactions bdk_demo/test/features/transactions +git commit -m "feat: map wallet transactions for history" +``` + +### Task 3: Replace Default Repository With Active Wallet Data + +**Files:** +- Modify: `bdk_demo/lib/features/transactions/transactions_repository.dart` +- Modify: `bdk_demo/lib/features/transactions/transactions_controller.dart` +- Test: `bdk_demo/test/features/transactions/transactions_repository_test.dart` +- Test: `bdk_demo/test/presentation/transactions/transactions_list_page_test.dart` + +**Interfaces:** +- Consumes: `activeWalletProvider` and BDK wallet methods. +- Produces: `WalletTransactionsRepository` as the default repository implementation. + +- [ ] **Step 1: Write failing repository tests** + +Test that no active wallet returns an empty list and that injected wallet transaction readers return mapped rows. + +- [ ] **Step 2: Run failing tests** + +Run: `flutter test bdk_demo/test/features/transactions/transactions_repository_test.dart` + +Expected: FAIL because the repository still returns hardcoded placeholder data. + +- [ ] **Step 3: Implement wallet-backed repository** + +Default provider reads `activeWalletProvider`. The repository maps `wallet.transactions()` and `wallet.sentAndReceived(tx:)`; detail lookup uses `wallet.txDetails(txid:)` when available and falls back to the transaction list. + +- [ ] **Step 4: Run passing tests** + +Run: `flutter test bdk_demo/test/features/transactions/transactions_repository_test.dart` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add bdk_demo/lib/features/transactions bdk_demo/test/features/transactions bdk_demo/test/presentation/transactions +git commit -m "feat: load real wallet transaction history" +``` + +### Task 4: Verification and PR + +**Files:** +- No production files expected. + +**Interfaces:** +- Consumes: all previous tasks. +- Produces: pushed branch and draft PR. + +- [ ] **Step 1: Format** + +Run: `dart format --output=none --set-exit-if-changed lib test example bdk_demo/lib bdk_demo/test` + +- [ ] **Step 2: Analyze** + +Run: `dart analyze --fatal-infos --fatal-warnings lib test example` + +- [ ] **Step 3: Test root package** + +Run: `dart test` + +- [ ] **Step 4: Test demo app** + +Run: `flutter test bdk_demo/test` + +- [ ] **Step 5: Push and open draft PR** + +Push branch `feat/bdk-demo-real-transaction-history` and open a draft PR titled `feat: load real transaction history in demo app`. diff --git a/docs/superpowers/specs/2026-06-30-bdk-demo-real-transaction-history-design.md b/docs/superpowers/specs/2026-06-30-bdk-demo-real-transaction-history-design.md new file mode 100644 index 0000000..c38c052 --- /dev/null +++ b/docs/superpowers/specs/2026-06-30-bdk-demo-real-transaction-history-design.md @@ -0,0 +1,49 @@ +# BDK Demo Real Transaction History Design + +## Goal + +Continue PR #62 by replacing the transaction history placeholder data with real data from the active BDK wallet while preserving the standalone `features/transactions/` module structure requested during review. + +## Scope + +- Use the active wallet already managed by `activeWalletProvider`. +- Keep transaction history presentation inside `bdk_demo/lib/features/transactions/`. +- Keep fake repositories only for tests. +- Do not move transaction-history UI concerns into `WalletService`. +- Do not add blockchain syncing to the transaction page; syncing remains owned by the existing sync controller and home refresh flow. + +## Architecture + +The default `transactionsRepositoryProvider` will become wallet-backed. It will read the current active wallet and map BDK transaction surface data into the app-side transaction model: + +- `wallet.transactions()` provides canonical wallet transactions. +- `wallet.sentAndReceived(tx:)` provides wallet-specific sent and received values. +- `wallet.txDetails(txid:)` is used for direct detail lookup when available. +- `CanonicalTx.chainPosition` provides pending versus confirmed status, block height, and confirmation timestamp. + +The transaction model will be renamed away from demo wording so the UI reflects real wallet data. Existing widget tests will keep overriding the repository with fake data. + +## User Flow + +When the user opens the transaction history screen: + +- If no active wallet is loaded, the screen shows an unavailable state asking the user to load or create a wallet. +- If an active wallet exists but has no transactions, the screen shows an empty wallet-history state. +- If transactions exist, the screen renders real transaction rows derived from the active wallet. +- Tapping a row opens the detail screen for that real transaction txid. + +The page copy will no longer claim that the screen is only a placeholder demo. + +## Error Handling + +Repository errors will continue flowing through `TransactionsController` into the existing error state. Missing detail lookups return `null`, preserving the current "Transaction not found" behavior. + +## Testing + +Tests will stay feature-scoped: + +- Unit tests for mapping BDK-like transaction records into app transaction items. +- Controller tests for no active wallet, empty history, and loaded real-history data. +- Widget tests updated from placeholder wording to active-wallet history wording. + +The implementation will use TDD: each behavior gets a failing test before production changes. From c5ffc470e100ac83feb832220b83168f86378abb Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 14:00:33 +0100 Subject: [PATCH 02/19] refactor: rename transaction history model --- ...ils.dart => transaction_history_item.dart} | 4 +-- .../transactions/transaction_detail_page.dart | 12 +++---- .../transactions/transactions_controller.dart | 19 +++++------ .../transactions/transactions_list_page.dart | 34 +++++++++++-------- .../transactions/transactions_repository.dart | 20 +++++------ .../fakes/fake_transactions_repository.dart | 8 ++--- ...ns.dart => transaction_history_items.dart} | 8 ++--- .../transaction_detail_page_test.dart | 12 +++---- .../transactions_list_page_test.dart | 26 +++++++------- 9 files changed, 73 insertions(+), 70 deletions(-) rename bdk_demo/lib/features/transactions/models/{demo_tx_details.dart => transaction_history_item.dart} (89%) rename bdk_demo/test/helpers/fixtures/{placeholder_transactions.dart => transaction_history_items.dart} (63%) 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..720fb76 100644 --- a/bdk_demo/lib/features/transactions/transaction_detail_page.dart +++ b/bdk_demo/lib/features/transactions/transaction_detail_page.dart @@ -2,7 +2,7 @@ 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:flutter/material.dart'; @@ -13,7 +13,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); @@ -37,14 +37,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 +54,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 +83,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/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index 79aadb1..335e4ca 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.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'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -6,7 +6,7 @@ enum TransactionsLoadState { idle, loading, success, error } class TransactionsState { final TransactionsLoadState status; - final List transactions; + final List transactions; final String statusMessage; final String? errorMessage; @@ -21,13 +21,12 @@ class TransactionsState { : this( status: TransactionsLoadState.idle, transactions: const [], - statusMessage: - 'Load the transaction demo to preview list and detail states.', + statusMessage: 'Load the active wallet transaction history.', ); TransactionsState copyWith({ TransactionsLoadState? status, - List? transactions, + List? transactions, String? statusMessage, String? errorMessage, }) { @@ -46,7 +45,7 @@ final transactionsControllerProvider = ); final transactionDetailsProvider = - FutureProvider.family((ref, txid) { + FutureProvider.family((ref, txid) { final repository = ref.read(transactionsRepositoryProvider); return repository.loadTransactionByTxid(txid); }); @@ -59,7 +58,7 @@ class TransactionsController extends Notifier { state = state.copyWith( status: TransactionsLoadState.loading, transactions: const [], - statusMessage: 'Loading placeholder transactions...', + statusMessage: 'Loading transaction history...', errorMessage: null, ); @@ -72,15 +71,15 @@ class TransactionsController extends Notifier { 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.', + 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..ae38b8e 100644 --- a/bdk_demo/lib/features/transactions/transactions_list_page.dart +++ b/bdk_demo/lib/features/transactions/transactions_list_page.dart @@ -2,7 +2,7 @@ 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:flutter/material.dart'; @@ -12,7 +12,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}, @@ -26,7 +29,7 @@ class TransactionsListPage extends ConsumerWidget { final isLoading = state.status == TransactionsLoadState.loading; return Scaffold( - appBar: const SecondaryAppBar(title: 'Transactions Demo'), + appBar: const SecondaryAppBar(title: 'Transaction History'), body: SafeArea( child: ListView( padding: const EdgeInsets.all(24), @@ -51,14 +54,14 @@ 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), ), @@ -83,8 +86,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 +97,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 +110,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}); @@ -118,18 +122,18 @@ class _TransactionsBody extends StatelessWidget { return switch (state.status) { 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 +143,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 +203,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..a40aafa 100644 --- a/bdk_demo/lib/features/transactions/transactions_repository.dart +++ b/bdk_demo/lib/features/transactions/transactions_repository.dart @@ -1,9 +1,9 @@ -import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.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( @@ -13,14 +13,14 @@ final transactionsRepositoryProvider = Provider( class DemoTransactionsRepository implements TransactionsRepository { DemoTransactionsRepository({ this.delay = const Duration(milliseconds: 150), - List? transactions, + List? transactions, }) : _transactions = transactions ?? _defaultTransactions; final Duration delay; - final List _transactions; + final List _transactions; - static final _defaultTransactions = [ - DemoTxDetails( + static final _defaultTransactions = [ + TransactionHistoryItem( txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', sent: 0, received: 42000, @@ -28,7 +28,7 @@ class DemoTransactionsRepository implements TransactionsRepository { blockHeight: 120, confirmationTime: DateTime(2024, 1, 2, 3, 4), ), - const DemoTxDetails( + const TransactionHistoryItem( txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', sent: 1600, received: 0, @@ -37,13 +37,13 @@ class DemoTransactionsRepository implements TransactionsRepository { ]; @override - Future> loadTransactions() async { + Future> loadTransactions() async { await Future.delayed(delay); return List.unmodifiable(_transactions); } @override - Future loadTransactionByTxid(String txid) async { + Future loadTransactionByTxid(String txid) async { final transactions = await loadTransactions(); for (final transaction in transactions) { if (transaction.txid == txid) return transaction; 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..883f909 100644 --- a/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart @@ -5,7 +5,7 @@ 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, { @@ -31,9 +31,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 +51,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 +71,7 @@ void main() { await _pumpDetailPage( tester, repository: repository, - txid: placeholderTransactions.last.txid, + txid: transactionHistoryItems.last.txid, ); expect( 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..a8e81de 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -7,7 +7,7 @@ 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'; Future _pumpTransactionsFlow( WidgetTester tester, { @@ -40,28 +40,28 @@ Future _pumpTransactionsFlow( } void main() { - testWidgets('shows intro before loading transactions', (tester) async { + testWidgets('shows intro before loading transaction history', (tester) async { await _pumpTransactionsFlow( tester, repository: FakeTransactionsRepository( - transactions: placeholderTransactions, + transactions: transactionHistoryItems, ), ); - expect(find.text('Transactions Demo'), findsNWidgets(2)); - expect(find.text('Load Transactions Demo'), findsOneWidget); - expect(find.text('Transactions not loaded yet'), findsOneWidget); + expect(find.text('Transaction History'), findsNWidgets(2)); + expect(find.text('Load Transaction History'), findsOneWidget); + expect(find.text('Transaction history not loaded yet'), findsOneWidget); }); - testWidgets('loads and renders placeholder transactions', (tester) async { + testWidgets('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.tap(find.text('Load Transaction History')); await tester.pumpAndSettle(); expect(find.text('+42000 sat'), findsOneWidget); @@ -80,13 +80,13 @@ void main() { repository: FakeTransactionsRepository(transactions: const []), ); - await tester.tap(find.text('Load Transactions Demo')); + await tester.tap(find.text('Load Transaction History')); 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,11 +96,11 @@ void main() { await _pumpTransactionsFlow( tester, repository: FakeTransactionsRepository( - transactions: placeholderTransactions, + transactions: transactionHistoryItems, ), ); - await tester.tap(find.text('Load Transactions Demo')); + await tester.tap(find.text('Load Transaction History')); await tester.pumpAndSettle(); await tester.tap(find.text('123456...abcd')); From 872f36255b5e781557850f437903422209a4d6f7 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 14:02:48 +0100 Subject: [PATCH 03/19] feat: map wallet transactions for history --- .../transaction_history_mapper.dart | 51 +++++++++++++++++++ .../transaction_history_mapper_test.dart | 51 +++++++++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 bdk_demo/lib/features/transactions/transaction_history_mapper.dart create mode 100644 bdk_demo/test/features/transactions/transaction_history_mapper_test.dart 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..63cecdd --- /dev/null +++ b/bdk_demo/lib/features/transactions/transaction_history_mapper.dart @@ -0,0 +1,51 @@ +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, + ), + ), + UnconfirmedTransactionPosition() => TransactionHistoryItem( + txid: txid, + sent: sent, + received: received, + pending: true, + ), + }; + } +} 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..284c9f9 --- /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), + ); + 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'); + }); + }); +} From 4fe1f201ced6a4d43022d9266ded6271d11f394f Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 14:06:18 +0100 Subject: [PATCH 04/19] feat: load real wallet transaction history --- .../transactions/transactions_repository.dart | 160 ++++++++++++++---- .../transactions_repository_test.dart | 90 ++++++++++ 2 files changed, 215 insertions(+), 35 deletions(-) create mode 100644 bdk_demo/test/features/transactions/transactions_repository_test.dart diff --git a/bdk_demo/lib/features/transactions/transactions_repository.dart b/bdk_demo/lib/features/transactions/transactions_repository.dart index a40aafa..976755e 100644 --- a/bdk_demo/lib/features/transactions/transactions_repository.dart +++ b/bdk_demo/lib/features/transactions/transactions_repository.dart @@ -1,4 +1,7 @@ +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 { @@ -6,48 +9,135 @@ abstract interface class TransactionsRepository { 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 = [ - TransactionHistoryItem( - txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', - sent: 0, - received: 42000, - pending: false, - blockHeight: 120, - confirmationTime: DateTime(2024, 1, 2, 3, 4), - ), - const TransactionHistoryItem( - 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); + 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; + 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() { + return _wallet + .transactions() + .map(_recordFromCanonicalTx) + .toList(growable: false); + } + + @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 null; + + return _findTransactionByTxid(transactions(), txid); + } + + TransactionHistoryRecord _recordFromCanonicalTx(bdk.CanonicalTx canonicalTx) { + final transaction = canonicalTx.transaction; + final sentAndReceived = _wallet.sentAndReceived(tx: transaction); + final txid = transaction.computeTxid(); + final txidText = txid.toString(); + txid.dispose(); + + return TransactionHistoryRecord( + txid: txidText, + sent: sentAndReceived.sent.toSat(), + received: sentAndReceived.received.toSat(), + position: _positionFromBdk(canonicalTx.chainPosition), + ); + } + + TransactionHistoryPosition _positionFromBdk(bdk.ChainPosition position) { + if (position is bdk.ConfirmedChainPosition) { + final confirmation = position.confirmationBlockTime; + return ConfirmedTransactionPosition( + blockHeight: confirmation.blockId.height, + confirmationTime: confirmation.confirmationTime, + ); + } + + if (position is bdk.UnconfirmedChainPosition) { + return UnconfirmedTransactionPosition(timestamp: position.timestamp); + } + + throw StateError('Unsupported transaction chain position: $position'); + } +} + +TransactionHistoryRecord? _findTransactionByTxid( + List transactions, + String txid, +) { + for (final transaction in transactions) { + if (transaction.txid == txid) return transaction; } + return null; } 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); + }); + }); +} From 08b201a4581e7fcd4bc6b7a3d735598f1dfbfc66 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 14:44:03 +0100 Subject: [PATCH 05/19] chore: clean up PR 102, delete docs, fix UTC time, handle no wallet state and native resource disposal --- .../transaction_history_mapper.dart | 1 + .../transactions/transactions_controller.dart | 27 ++- .../transactions/transactions_list_page.dart | 16 +- .../transactions/transactions_repository.dart | 10 +- .../transaction_history_mapper_test.dart | 2 +- .../transactions_list_page_test.dart | 90 ++++++++- ...06-30-bdk-demo-real-transaction-history.md | 173 ------------------ ...dk-demo-real-transaction-history-design.md | 49 ----- 8 files changed, 136 insertions(+), 232 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-30-bdk-demo-real-transaction-history.md delete mode 100644 docs/superpowers/specs/2026-06-30-bdk-demo-real-transaction-history-design.md diff --git a/bdk_demo/lib/features/transactions/transaction_history_mapper.dart b/bdk_demo/lib/features/transactions/transaction_history_mapper.dart index 63cecdd..668c3e0 100644 --- a/bdk_demo/lib/features/transactions/transaction_history_mapper.dart +++ b/bdk_demo/lib/features/transactions/transaction_history_mapper.dart @@ -38,6 +38,7 @@ class TransactionHistoryMapper { blockHeight: position.blockHeight, confirmationTime: DateTime.fromMillisecondsSinceEpoch( position.confirmationTime * 1000, + isUtc: true, ), ), UnconfirmedTransactionPosition() => TransactionHistoryItem( diff --git a/bdk_demo/lib/features/transactions/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index 335e4ca..6fc7d08 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -1,8 +1,9 @@ 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; @@ -52,9 +53,31 @@ final transactionDetailsProvider = class TransactionsController extends Notifier { @override - TransactionsState build() => const TransactionsState.idle(); + TransactionsState build() { + final hasWallet = ref.watch(activeWalletProvider) != null; + if (!hasWallet) { + return const TransactionsState( + status: TransactionsLoadState.noWallet, + transactions: [], + statusMessage: + 'Create or load a wallet before viewing transaction history.', + ); + } + return const TransactionsState.idle(); + } Future loadTransactions() async { + final hasWallet = ref.read(activeWalletProvider) != null; + if (!hasWallet) { + state = const TransactionsState( + status: TransactionsLoadState.noWallet, + transactions: [], + statusMessage: + 'Create or load a wallet before viewing transaction history.', + ); + return; + } + state = state.copyWith( status: TransactionsLoadState.loading, transactions: const [], diff --git a/bdk_demo/lib/features/transactions/transactions_list_page.dart b/bdk_demo/lib/features/transactions/transactions_list_page.dart index ae38b8e..82c56c8 100644 --- a/bdk_demo/lib/features/transactions/transactions_list_page.dart +++ b/bdk_demo/lib/features/transactions/transactions_list_page.dart @@ -5,6 +5,7 @@ import 'package:bdk_demo/features/shared/widgets/wallet_ui_helpers.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'; @@ -26,7 +27,9 @@ class TransactionsListPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final state = ref.watch(transactionsControllerProvider); + final hasWallet = ref.watch(activeWalletProvider) != null; final isLoading = state.status == TransactionsLoadState.loading; + final canLoad = hasWallet && !isLoading; return Scaffold( appBar: const SecondaryAppBar(title: 'Transaction History'), @@ -68,11 +71,11 @@ class TransactionsListPage extends ConsumerWidget { ), const SizedBox(height: 20), FilledButton.icon( - onPressed: isLoading - ? null - : () => ref + onPressed: canLoad + ? () => ref .read(transactionsControllerProvider.notifier) - .loadTransactions(), + .loadTransactions() + : null, icon: isLoading ? SizedBox( width: 16, @@ -120,6 +123,11 @@ 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: 'Transaction history not loaded yet', diff --git a/bdk_demo/lib/features/transactions/transactions_repository.dart b/bdk_demo/lib/features/transactions/transactions_repository.dart index 976755e..6efa1b2 100644 --- a/bdk_demo/lib/features/transactions/transactions_repository.dart +++ b/bdk_demo/lib/features/transactions/transactions_repository.dart @@ -105,12 +105,18 @@ class BdkWalletTransactionSource implements TransactionHistorySource { final sentAndReceived = _wallet.sentAndReceived(tx: transaction); final txid = transaction.computeTxid(); final txidText = txid.toString(); + final sentSat = sentAndReceived.sent.toSat(); + final receivedSat = sentAndReceived.received.toSat(); + txid.dispose(); + transaction.dispose(); + sentAndReceived.sent.dispose(); + sentAndReceived.received.dispose(); return TransactionHistoryRecord( txid: txidText, - sent: sentAndReceived.sent.toSat(), - received: sentAndReceived.received.toSat(), + sent: sentSat, + received: receivedSat, position: _positionFromBdk(canonicalTx.chainPosition), ); } diff --git a/bdk_demo/test/features/transactions/transaction_history_mapper_test.dart b/bdk_demo/test/features/transactions/transaction_history_mapper_test.dart index 284c9f9..461321c 100644 --- a/bdk_demo/test/features/transactions/transaction_history_mapper_test.dart +++ b/bdk_demo/test/features/transactions/transaction_history_mapper_test.dart @@ -25,7 +25,7 @@ void main() { expect(item.blockHeight, 120); expect( item.confirmationTime, - DateTime.fromMillisecondsSinceEpoch(1704164640000), + DateTime.fromMillisecondsSinceEpoch(1704164640000, isUtc: true), ); expect(item.statusLabel, 'confirmed'); }); 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 a8e81de..4831a79 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -1,6 +1,8 @@ +import 'package:bdk_dart/bdk.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/providers/wallet_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -9,9 +11,39 @@ import 'package:go_router/go_router.dart'; import '../../helpers/fakes/fake_transactions_repository.dart'; import '../../helpers/fixtures/transaction_history_items.dart'; +const _testExtendedPrivKey = + 'tprv8ZgxMBicQKsPf2qfrEygW6fdYseJDDrVnDv26PH5BHdvSuG6ecCbHqLVof9yZcMoM31z9ur3tTYbSnr1WBqbGX97CbXcmp5H6qeMpyvx35B'; + +class FakeActiveWalletNotifier extends ActiveWalletNotifier { + final Wallet? _wallet; + FakeActiveWalletNotifier(this._wallet); + + @override + Wallet? build() => _wallet; +} + +Wallet _createTestWallet() { + final descriptor = Descriptor( + descriptor: 'wpkh($_testExtendedPrivKey/84h/1h/0h/0/*)', + networkKind: NetworkKind.test, + ); + final changeDescriptor = Descriptor( + descriptor: 'wpkh($_testExtendedPrivKey/84h/1h/0h/1/*)', + networkKind: NetworkKind.test, + ); + return Wallet( + descriptor: descriptor, + changeDescriptor: changeDescriptor, + network: Network.testnet, + persister: Persister.newInMemory(), + lookahead: 25, + ); +} + Future _pumpTransactionsFlow( WidgetTester tester, { required TransactionsRepository repository, + bool seedActiveWallet = true, }) async { final router = GoRouter( initialLocation: '/transactions', @@ -32,7 +64,13 @@ Future _pumpTransactionsFlow( await tester.pumpWidget( ProviderScope( - overrides: [transactionsRepositoryProvider.overrideWithValue(repository)], + overrides: [ + transactionsRepositoryProvider.overrideWithValue(repository), + if (seedActiveWallet) + activeWalletProvider.overrideWith( + () => FakeActiveWalletNotifier(_createTestWallet()), + ), + ], child: MaterialApp.router(routerConfig: router), ), ); @@ -114,4 +152,54 @@ 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 []), + seedActiveWallet: false, + ); + + expect(find.text('No active wallet'), findsOneWidget); + expect( + find.text( + 'Create or load a wallet before viewing transaction history.', + ), + findsOneWidget, + ); + + // Verify button is disabled + final buttonFinder = find.widgetWithText( + FilledButton, + 'Load Transaction History', + ); + expect(tester.widget(buttonFinder).onPressed, isNull); + }, + ); + + testWidgets( + 'active wallet with no transactions still shows the normal empty-history state after loading', + (tester) async { + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository(transactions: const []), + seedActiveWallet: true, + ); + + expect(find.text('Transaction history not loaded yet'), findsOneWidget); + + await tester.tap(find.text('Load Transaction History')); + await tester.pumpAndSettle(); + + expect(find.text('No transactions yet'), findsOneWidget); + expect( + find.text( + 'The active wallet has no transactions yet. Sync the wallet or receive funds to populate history.', + ), + findsOneWidget, + ); + }, + ); } diff --git a/docs/superpowers/plans/2026-06-30-bdk-demo-real-transaction-history.md b/docs/superpowers/plans/2026-06-30-bdk-demo-real-transaction-history.md deleted file mode 100644 index 7347bb6..0000000 --- a/docs/superpowers/plans/2026-06-30-bdk-demo-real-transaction-history.md +++ /dev/null @@ -1,173 +0,0 @@ -# BDK Demo Real Transaction History Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Replace the demo app transaction history placeholder rows with real active-wallet transaction data. - -**Architecture:** Keep the existing standalone `features/transactions/` module from PR #62. Replace the default repository with a wallet-backed repository that maps active BDK wallet data into app-side transaction rows, while tests continue to use fakes. - -**Tech Stack:** Dart, Flutter, Riverpod, GoRouter, BDK Dart bindings. - -## Global Constraints - -- Branch, PR title, and new document names must follow project naming and must not use restricted tool-specific naming. -- Do not place transaction-history UI logic inside `WalletService`. -- Keep feature code under `bdk_demo/lib/features/transactions/`. -- Use TDD: write the failing test before production changes. -- Keep fake repositories in `bdk_demo/test/helpers/fakes/`. - ---- - -### Task 1: Rename Transaction Model and Copy - -**Files:** -- Rename: `bdk_demo/lib/features/transactions/models/demo_tx_details.dart` to `bdk_demo/lib/features/transactions/models/transaction_history_item.dart` -- Modify: `bdk_demo/lib/features/transactions/transactions_controller.dart` -- Modify: `bdk_demo/lib/features/transactions/transactions_list_page.dart` -- Modify: `bdk_demo/lib/features/transactions/transaction_detail_page.dart` -- Modify: `bdk_demo/test/helpers/fakes/fake_transactions_repository.dart` -- Modify: `bdk_demo/test/helpers/fixtures/placeholder_transactions.dart` -- Modify: `bdk_demo/test/presentation/transactions/transactions_list_page_test.dart` -- Modify: `bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart` - -**Interfaces:** -- Produces: `TransactionHistoryItem` with `txid`, `sent`, `received`, `pending`, `blockHeight`, `confirmationTime`, `netAmount`, `shortTxid`, and `statusLabel`. - -- [ ] **Step 1: Write failing tests** - -Update the transaction widget tests to expect real-history wording: - -```dart -expect(find.text('Transaction History'), findsOneWidget); -expect(find.text('Load Transaction History'), findsOneWidget); -expect(find.text('Transaction history not loaded yet'), findsOneWidget); -``` - -- [ ] **Step 2: Run failing tests** - -Run: `flutter test bdk_demo/test/presentation/transactions` - -Expected: FAIL because the UI still says "Transactions Demo" and imports `DemoTxDetails`. - -- [ ] **Step 3: Rename model and update copy** - -Rename the model and update imports/types from `DemoTxDetails` to `TransactionHistoryItem`. Update user-facing copy from placeholder/demo wording to active-wallet transaction-history wording. - -- [ ] **Step 4: Run passing tests** - -Run: `flutter test bdk_demo/test/presentation/transactions` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add bdk_demo/lib/features/transactions bdk_demo/test/helpers bdk_demo/test/presentation/transactions -git commit -m "refactor: rename transaction history model" -``` - -### Task 2: Add Wallet-Backed Mapping - -**Files:** -- Create: `bdk_demo/lib/features/transactions/transaction_history_mapper.dart` -- Modify: `bdk_demo/lib/features/transactions/transactions_repository.dart` -- Test: `bdk_demo/test/features/transactions/transaction_history_mapper_test.dart` - -**Interfaces:** -- Consumes: `TransactionHistoryItem`. -- Produces: mapping helpers that convert BDK wallet transaction data into `TransactionHistoryItem`. - -- [ ] **Step 1: Write failing mapper tests** - -Test confirmed and unconfirmed mapping, including sent/received values and confirmation metadata. - -- [ ] **Step 2: Run failing tests** - -Run: `flutter test bdk_demo/test/features/transactions/transaction_history_mapper_test.dart` - -Expected: FAIL because the mapper does not exist. - -- [ ] **Step 3: Implement mapper** - -Create a focused mapper that turns txid strings, sent/received sats, and chain-position metadata into `TransactionHistoryItem`. - -- [ ] **Step 4: Run passing tests** - -Run: `flutter test bdk_demo/test/features/transactions/transaction_history_mapper_test.dart` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add bdk_demo/lib/features/transactions bdk_demo/test/features/transactions -git commit -m "feat: map wallet transactions for history" -``` - -### Task 3: Replace Default Repository With Active Wallet Data - -**Files:** -- Modify: `bdk_demo/lib/features/transactions/transactions_repository.dart` -- Modify: `bdk_demo/lib/features/transactions/transactions_controller.dart` -- Test: `bdk_demo/test/features/transactions/transactions_repository_test.dart` -- Test: `bdk_demo/test/presentation/transactions/transactions_list_page_test.dart` - -**Interfaces:** -- Consumes: `activeWalletProvider` and BDK wallet methods. -- Produces: `WalletTransactionsRepository` as the default repository implementation. - -- [ ] **Step 1: Write failing repository tests** - -Test that no active wallet returns an empty list and that injected wallet transaction readers return mapped rows. - -- [ ] **Step 2: Run failing tests** - -Run: `flutter test bdk_demo/test/features/transactions/transactions_repository_test.dart` - -Expected: FAIL because the repository still returns hardcoded placeholder data. - -- [ ] **Step 3: Implement wallet-backed repository** - -Default provider reads `activeWalletProvider`. The repository maps `wallet.transactions()` and `wallet.sentAndReceived(tx:)`; detail lookup uses `wallet.txDetails(txid:)` when available and falls back to the transaction list. - -- [ ] **Step 4: Run passing tests** - -Run: `flutter test bdk_demo/test/features/transactions/transactions_repository_test.dart` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add bdk_demo/lib/features/transactions bdk_demo/test/features/transactions bdk_demo/test/presentation/transactions -git commit -m "feat: load real wallet transaction history" -``` - -### Task 4: Verification and PR - -**Files:** -- No production files expected. - -**Interfaces:** -- Consumes: all previous tasks. -- Produces: pushed branch and draft PR. - -- [ ] **Step 1: Format** - -Run: `dart format --output=none --set-exit-if-changed lib test example bdk_demo/lib bdk_demo/test` - -- [ ] **Step 2: Analyze** - -Run: `dart analyze --fatal-infos --fatal-warnings lib test example` - -- [ ] **Step 3: Test root package** - -Run: `dart test` - -- [ ] **Step 4: Test demo app** - -Run: `flutter test bdk_demo/test` - -- [ ] **Step 5: Push and open draft PR** - -Push branch `feat/bdk-demo-real-transaction-history` and open a draft PR titled `feat: load real transaction history in demo app`. diff --git a/docs/superpowers/specs/2026-06-30-bdk-demo-real-transaction-history-design.md b/docs/superpowers/specs/2026-06-30-bdk-demo-real-transaction-history-design.md deleted file mode 100644 index c38c052..0000000 --- a/docs/superpowers/specs/2026-06-30-bdk-demo-real-transaction-history-design.md +++ /dev/null @@ -1,49 +0,0 @@ -# BDK Demo Real Transaction History Design - -## Goal - -Continue PR #62 by replacing the transaction history placeholder data with real data from the active BDK wallet while preserving the standalone `features/transactions/` module structure requested during review. - -## Scope - -- Use the active wallet already managed by `activeWalletProvider`. -- Keep transaction history presentation inside `bdk_demo/lib/features/transactions/`. -- Keep fake repositories only for tests. -- Do not move transaction-history UI concerns into `WalletService`. -- Do not add blockchain syncing to the transaction page; syncing remains owned by the existing sync controller and home refresh flow. - -## Architecture - -The default `transactionsRepositoryProvider` will become wallet-backed. It will read the current active wallet and map BDK transaction surface data into the app-side transaction model: - -- `wallet.transactions()` provides canonical wallet transactions. -- `wallet.sentAndReceived(tx:)` provides wallet-specific sent and received values. -- `wallet.txDetails(txid:)` is used for direct detail lookup when available. -- `CanonicalTx.chainPosition` provides pending versus confirmed status, block height, and confirmation timestamp. - -The transaction model will be renamed away from demo wording so the UI reflects real wallet data. Existing widget tests will keep overriding the repository with fake data. - -## User Flow - -When the user opens the transaction history screen: - -- If no active wallet is loaded, the screen shows an unavailable state asking the user to load or create a wallet. -- If an active wallet exists but has no transactions, the screen shows an empty wallet-history state. -- If transactions exist, the screen renders real transaction rows derived from the active wallet. -- Tapping a row opens the detail screen for that real transaction txid. - -The page copy will no longer claim that the screen is only a placeholder demo. - -## Error Handling - -Repository errors will continue flowing through `TransactionsController` into the existing error state. Missing detail lookups return `null`, preserving the current "Transaction not found" behavior. - -## Testing - -Tests will stay feature-scoped: - -- Unit tests for mapping BDK-like transaction records into app transaction items. -- Controller tests for no active wallet, empty history, and loaded real-history data. -- Widget tests updated from placeholder wording to active-wallet history wording. - -The implementation will use TDD: each behavior gets a failing test before production changes. From 65397872bf3566f318a070e80e4ae83d32f57499 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 15:04:22 +0100 Subject: [PATCH 06/19] refactor: extract hasActiveWalletProvider and remove real BDK wallet from widget tests --- .../transactions/transactions_controller.dart | 4 +-- .../transactions/transactions_list_page.dart | 2 +- bdk_demo/lib/providers/wallet_providers.dart | 4 +++ .../transactions_list_page_test.dart | 35 +------------------ 4 files changed, 8 insertions(+), 37 deletions(-) diff --git a/bdk_demo/lib/features/transactions/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index 6fc7d08..50e372d 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -54,7 +54,7 @@ final transactionDetailsProvider = class TransactionsController extends Notifier { @override TransactionsState build() { - final hasWallet = ref.watch(activeWalletProvider) != null; + final hasWallet = ref.watch(hasActiveWalletProvider); if (!hasWallet) { return const TransactionsState( status: TransactionsLoadState.noWallet, @@ -67,7 +67,7 @@ class TransactionsController extends Notifier { } Future loadTransactions() async { - final hasWallet = ref.read(activeWalletProvider) != null; + final hasWallet = ref.read(hasActiveWalletProvider); if (!hasWallet) { state = const TransactionsState( status: TransactionsLoadState.noWallet, diff --git a/bdk_demo/lib/features/transactions/transactions_list_page.dart b/bdk_demo/lib/features/transactions/transactions_list_page.dart index 82c56c8..55c6143 100644 --- a/bdk_demo/lib/features/transactions/transactions_list_page.dart +++ b/bdk_demo/lib/features/transactions/transactions_list_page.dart @@ -27,7 +27,7 @@ class TransactionsListPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final state = ref.watch(transactionsControllerProvider); - final hasWallet = ref.watch(activeWalletProvider) != null; + final hasWallet = ref.watch(hasActiveWalletProvider); final isLoading = state.status == TransactionsLoadState.loading; final canLoad = hasWallet && !isLoading; diff --git a/bdk_demo/lib/providers/wallet_providers.dart b/bdk_demo/lib/providers/wallet_providers.dart index 1f5dfdf..6474c9d 100644 --- a/bdk_demo/lib/providers/wallet_providers.dart +++ b/bdk_demo/lib/providers/wallet_providers.dart @@ -32,6 +32,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/presentation/transactions/transactions_list_page_test.dart b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart index 4831a79..c92b1a3 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -1,4 +1,3 @@ -import 'package:bdk_dart/bdk.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'; @@ -11,35 +10,6 @@ import 'package:go_router/go_router.dart'; import '../../helpers/fakes/fake_transactions_repository.dart'; import '../../helpers/fixtures/transaction_history_items.dart'; -const _testExtendedPrivKey = - 'tprv8ZgxMBicQKsPf2qfrEygW6fdYseJDDrVnDv26PH5BHdvSuG6ecCbHqLVof9yZcMoM31z9ur3tTYbSnr1WBqbGX97CbXcmp5H6qeMpyvx35B'; - -class FakeActiveWalletNotifier extends ActiveWalletNotifier { - final Wallet? _wallet; - FakeActiveWalletNotifier(this._wallet); - - @override - Wallet? build() => _wallet; -} - -Wallet _createTestWallet() { - final descriptor = Descriptor( - descriptor: 'wpkh($_testExtendedPrivKey/84h/1h/0h/0/*)', - networkKind: NetworkKind.test, - ); - final changeDescriptor = Descriptor( - descriptor: 'wpkh($_testExtendedPrivKey/84h/1h/0h/1/*)', - networkKind: NetworkKind.test, - ); - return Wallet( - descriptor: descriptor, - changeDescriptor: changeDescriptor, - network: Network.testnet, - persister: Persister.newInMemory(), - lookahead: 25, - ); -} - Future _pumpTransactionsFlow( WidgetTester tester, { required TransactionsRepository repository, @@ -66,10 +36,7 @@ Future _pumpTransactionsFlow( ProviderScope( overrides: [ transactionsRepositoryProvider.overrideWithValue(repository), - if (seedActiveWallet) - activeWalletProvider.overrideWith( - () => FakeActiveWalletNotifier(_createTestWallet()), - ), + hasActiveWalletProvider.overrideWithValue(seedActiveWallet), ], child: MaterialApp.router(routerConfig: router), ), From d7f3842c6fdbc87d02f0ce8d08dadcd6f02fb3ce Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 15:15:29 +0100 Subject: [PATCH 07/19] test: rename helper parameter to hasActiveWallet in transactions_list_page_test.dart --- .../transactions/transactions_list_page_test.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 c92b1a3..3ceab16 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -13,7 +13,7 @@ import '../../helpers/fixtures/transaction_history_items.dart'; Future _pumpTransactionsFlow( WidgetTester tester, { required TransactionsRepository repository, - bool seedActiveWallet = true, + bool hasActiveWallet = true, }) async { final router = GoRouter( initialLocation: '/transactions', @@ -36,7 +36,7 @@ Future _pumpTransactionsFlow( ProviderScope( overrides: [ transactionsRepositoryProvider.overrideWithValue(repository), - hasActiveWalletProvider.overrideWithValue(seedActiveWallet), + hasActiveWalletProvider.overrideWithValue(hasActiveWallet), ], child: MaterialApp.router(routerConfig: router), ), @@ -126,7 +126,7 @@ void main() { await _pumpTransactionsFlow( tester, repository: FakeTransactionsRepository(transactions: const []), - seedActiveWallet: false, + hasActiveWallet: false, ); expect(find.text('No active wallet'), findsOneWidget); @@ -152,7 +152,7 @@ void main() { await _pumpTransactionsFlow( tester, repository: FakeTransactionsRepository(transactions: const []), - seedActiveWallet: true, + hasActiveWallet: true, ); expect(find.text('Transaction history not loaded yet'), findsOneWidget); From 720ce421c475b8b3b6c093a558aab1e2274b6d97 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 14 Jul 2026 02:16:16 +0100 Subject: [PATCH 08/19] Scope transactions controller and details to active logical wallet ID --- .../transactions/transaction_detail_page.dart | 6 +- .../transactions/transactions_controller.dart | 31 +- .../transactions/transactions_list_page.dart | 4 +- bdk_demo/lib/providers/wallet_providers.dart | 4 + .../transactions_controller_test.dart | 371 ++++++++++++++++++ .../transaction_detail_page_test.dart | 121 +++++- .../transactions_list_page_test.dart | 133 ++++++- 7 files changed, 641 insertions(+), 29 deletions(-) create mode 100644 bdk_demo/test/features/transactions/transactions_controller_test.dart diff --git a/bdk_demo/lib/features/transactions/transaction_detail_page.dart b/bdk_demo/lib/features/transactions/transaction_detail_page.dart index 720fb76..b582c21 100644 --- a/bdk_demo/lib/features/transactions/transaction_detail_page.dart +++ b/bdk_demo/lib/features/transactions/transaction_detail_page.dart @@ -5,6 +5,7 @@ import 'package:bdk_demo/features/shared/widgets/wallet_ui_helpers.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'; @@ -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'), diff --git a/bdk_demo/lib/features/transactions/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index 50e372d..61742e0 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -45,17 +45,24 @@ final transactionsControllerProvider = 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 activeWalletId = ref.watch(activeWalletIdProvider); + if (activeWalletId != arg.walletId) { + return Future.value(null); + } + final repository = ref.watch(transactionsRepositoryProvider); + return repository.loadTransactionByTxid(arg.txid); }); class TransactionsController extends Notifier { @override TransactionsState build() { - final hasWallet = ref.watch(hasActiveWalletProvider); - if (!hasWallet) { + final activeWalletId = ref.watch(activeWalletIdProvider); + if (activeWalletId == null) { return const TransactionsState( status: TransactionsLoadState.noWallet, transactions: [], @@ -67,8 +74,8 @@ class TransactionsController extends Notifier { } Future loadTransactions() async { - final hasWallet = ref.read(hasActiveWalletProvider); - if (!hasWallet) { + final activeWalletId = ref.read(activeWalletIdProvider); + if (activeWalletId == null) { state = const TransactionsState( status: TransactionsLoadState.noWallet, transactions: [], @@ -90,6 +97,10 @@ class TransactionsController extends Notifier { .read(transactionsRepositoryProvider) .loadTransactions(); + if (ref.read(activeWalletIdProvider) != activeWalletId) { + return; + } + state = state.copyWith( status: TransactionsLoadState.success, transactions: transactions, @@ -99,6 +110,10 @@ class TransactionsController extends Notifier { errorMessage: null, ); } catch (error) { + if (ref.read(activeWalletIdProvider) != activeWalletId) { + return; + } + state = state.copyWith( status: TransactionsLoadState.error, transactions: const [], diff --git a/bdk_demo/lib/features/transactions/transactions_list_page.dart b/bdk_demo/lib/features/transactions/transactions_list_page.dart index 55c6143..58b2e28 100644 --- a/bdk_demo/lib/features/transactions/transactions_list_page.dart +++ b/bdk_demo/lib/features/transactions/transactions_list_page.dart @@ -27,9 +27,9 @@ class TransactionsListPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); final state = ref.watch(transactionsControllerProvider); - final hasWallet = ref.watch(hasActiveWalletProvider); + final activeWalletId = ref.watch(activeWalletIdProvider); final isLoading = state.status == TransactionsLoadState.loading; - final canLoad = hasWallet && !isLoading; + final canLoad = activeWalletId != null && !isLoading; return Scaffold( appBar: const SecondaryAppBar(title: 'Transaction History'), diff --git a/bdk_demo/lib/providers/wallet_providers.dart b/bdk_demo/lib/providers/wallet_providers.dart index 6474c9d..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; 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..de05b33 --- /dev/null +++ b/bdk_demo/test/features/transactions/transactions_controller_test.dart @@ -0,0 +1,371 @@ +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_test/flutter_test.dart'; + +import '../../helpers/fakes/fake_transactions_repository.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; + } +} + +void main() { + group('TransactionsController & transactionDetailsProvider', () { + test('no active wallet returns the no-wallet state', () { + final container = ProviderContainer( + overrides: [activeWalletIdProvider.overrideWithValue(null)], + ); + addTearDown(container.dispose); + + final state = container.read(transactionsControllerProvider); + expect(state.status, TransactionsLoadState.noWallet); + expect(state.transactions, isEmpty); + }); + + test('an active wallet can load its transaction history', () async { + final txs = [ + TransactionHistoryItem( + txid: 'tx-1', + sent: 0, + received: 5000, + pending: false, + ), + ]; + final container = ProviderContainer( + overrides: [ + activeWalletIdProvider.overrideWithValue('wallet-a'), + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository(transactions: txs), + ), + ], + ); + addTearDown(container.dispose); + + // Initially idle + expect( + container.read(transactionsControllerProvider).status, + TransactionsLoadState.idle, + ); + + // Load transactions + await container + .read(transactionsControllerProvider.notifier) + .loadTransactions(); + + final state = container.read(transactionsControllerProvider); + expect(state.status, TransactionsLoadState.success); + expect(state.transactions, hasLength(1)); + expect(state.transactions.first.txid, 'tx-1'); + }); + + test( + 'switching the logical active wallet ID from A to B clears A\'s transaction list', + () async { + 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, + ), + ]; + final txsB = [ + TransactionHistoryItem( + txid: 'tx-b', + sent: 0, + received: 20000, + pending: false, + ), + ]; + + final container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWith((ref) { + final activeId = ref.watch(activeWalletIdProvider); + return FakeTransactionsRepository( + transactions: activeId == 'wallet-a' ? txsA : txsB, + ); + }), + ], + ); + addTearDown(container.dispose); + + // Set initial wallet record to Wallet A + container.read(activeWalletRecordProvider.notifier).set(recordA); + + // Load Wallet A transactions + await container + .read(transactionsControllerProvider.notifier) + .loadTransactions(); + expect( + container.read(transactionsControllerProvider).status, + TransactionsLoadState.success, + ); + expect( + container + .read(transactionsControllerProvider) + .transactions + .first + .txid, + 'tx-a', + ); + + // Switch active wallet to Wallet B + container.read(activeWalletRecordProvider.notifier).set(recordB); + + // Verify that Wallet A's transaction state is cleared and we are back to idle + final stateAfterSwitch = container.read(transactionsControllerProvider); + expect(stateAfterSwitch.status, TransactionsLoadState.idle); + 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 = 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 completer = Completer>(); + final delayedRepo = DelayedTransactionsRepository(completer.future); + + final container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWithValue(delayedRepo), + ], + ); + addTearDown(container.dispose); + + // Set initial wallet record to Wallet A + container.read(activeWalletRecordProvider.notifier).set(recordA); + + // Start loading + final future = container + .read(transactionsControllerProvider.notifier) + .loadTransactions(); + + // State is loading + expect( + container.read(transactionsControllerProvider).status, + TransactionsLoadState.loading, + ); + + // Switch active wallet to Wallet B (this rebuilds provider, returning idle state) + container.read(activeWalletRecordProvider.notifier).set(recordB); + + // Allow microtasks + await Future.value(); + expect( + container.read(transactionsControllerProvider).status, + TransactionsLoadState.idle, + ); + + // Complete async request for Wallet A + completer.complete([ + TransactionHistoryItem( + txid: 'tx-a', + sent: 0, + received: 10000, + pending: false, + ), + ]); + + await future; + + // State must remain idle for Wallet B + final finalState = container.read(transactionsControllerProvider); + expect(finalState.status, TransactionsLoadState.idle); + expect(finalState.transactions, isEmpty); + }, + ); + + test( + 'replacing the FFI Wallet object while retaining the same wallet record ID does not reset state', + () async { + final recordA = WalletRecord( + id: 'wallet-a', + name: 'Wallet A', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); + + final wallet1 = FakeWallet(); + final wallet2 = FakeWallet(); + + final container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository( + transactions: [ + TransactionHistoryItem( + txid: 'tx-a', + sent: 0, + received: 10000, + pending: false, + ), + ], + ), + ), + ], + ); + addTearDown(container.dispose); + + // Set initial wallet record and FFI Wallet instance + container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(wallet1); + + // Load transactions + await container + .read(transactionsControllerProvider.notifier) + .loadTransactions(); + expect( + container.read(transactionsControllerProvider).status, + TransactionsLoadState.success, + ); + expect( + container.read(transactionsControllerProvider).transactions, + isNotEmpty, + ); + + // Replace the wallet object instance (same logical ID) + container.read(activeWalletProvider.notifier).set(wallet2); + + // State must not reset + expect( + container.read(transactionsControllerProvider).status, + TransactionsLoadState.success, + ); + expect( + container.read(transactionsControllerProvider).transactions, + isNotEmpty, + ); + }, + ); + + test( + 'a transaction detail from wallet A is not reused after switching to wallet B', + () async { + 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, + ); + final txB = TransactionHistoryItem( + txid: 'tx-123', + sent: 0, + received: 20000, + pending: false, + ); + + final 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. Read detail for key (walletId: 'wallet-a', txid: 'tx-123') + final detailA = await container.read( + transactionDetailsProvider(( + walletId: 'wallet-a', + txid: 'tx-123', + )).future, + ); + expect(detailA?.netAmount, 10000); + + // 2. Switch wallet to B + container.read(activeWalletRecordProvider.notifier).set(recordB); + + // 3. Read detail for key (walletId: 'wallet-a', txid: 'tx-123') again. + // Because activeWalletId is now 'wallet-b', reading key 'wallet-a' should return null (stale/not matching active wallet). + final detailAAfterSwitch = await container.read( + transactionDetailsProvider(( + walletId: 'wallet-a', + txid: 'tx-123', + )).future, + ); + expect(detailAAfterSwitch, isNull); + + // 4. Read detail for key (walletId: 'wallet-b', txid: 'tx-123') + final detailB = await container.read( + transactionDetailsProvider(( + walletId: 'wallet-b', + txid: 'tx-123', + )).future, + ); + expect(detailB?.netAmount, 20000); + }, + ); + }); +} 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 883f909..1847dbc 100644 --- a/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart @@ -1,5 +1,8 @@ +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'; @@ -11,18 +14,36 @@ 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(); } @@ -101,4 +122,86 @@ 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], + ); + }), + ], + ); + + // 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 3ceab16..b05ac28 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -1,6 +1,8 @@ +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'; @@ -14,6 +16,7 @@ Future _pumpTransactionsFlow( WidgetTester tester, { required TransactionsRepository repository, bool hasActiveWallet = true, + ProviderContainer? container, }) async { final router = GoRouter( initialLocation: '/transactions', @@ -32,15 +35,26 @@ Future _pumpTransactionsFlow( ], ); - await tester.pumpWidget( - ProviderScope( - overrides: [ - transactionsRepositoryProvider.overrideWithValue(repository), - hasActiveWalletProvider.overrideWithValue(hasActiveWallet), - ], - child: MaterialApp.router(routerConfig: router), - ), - ); + 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), + ), + ); + } await tester.pumpAndSettle(); } @@ -169,4 +183,105 @@ void main() { ); }, ); + + testWidgets( + 'switching logical active wallet ID from A to B clears A\'s transaction list and does not render A\'s transaction rows before loading 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 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 dynamicRepository = FakeTransactionsRepository( + transactions: const [], + ); + + container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWith((ref) { + final activeId = ref.watch(activeWalletIdProvider); + return FakeTransactionsRepository( + transactions: activeId == 'wallet-a' ? txsA : txsB, + ); + }), + ], + ); + + // Set initial wallet record to Wallet A + container.read(activeWalletRecordProvider.notifier).set(recordA); + + // 1. Initial pump with wallet A active + await _pumpTransactionsFlow( + tester, + repository: dynamicRepository, + container: container, + ); + + expect(find.text('Transaction history not loaded yet'), findsOneWidget); + + // 2. Load wallet A transactions + await tester.tap(find.text('Load Transaction History')); + await tester.pumpAndSettle(); + + // Verify A's transactions are rendered + expect(find.text('+10000 sat'), findsOneWidget); + expect( + find.text('tx-a...short'), + findsNothing, + ); // Wait, shortTxid for 'tx-a' is 'tx-a' or whatever Formatters.abbreviateTxid returns. + // Let's check how shortTxid abbreviates 'tx-a'. It probably returns 'tx-a' if it is short. Let's just find.textContaining('tx-a'). + expect(find.textContaining('tx-a'), findsOneWidget); + + // 3. Switch logical active wallet ID from A to B + container.read(activeWalletRecordProvider.notifier).set(recordB); + await tester.pumpAndSettle(); + + // 4. Verify wallet A's transaction rows are cleared immediately and not rendered + expect(find.text('+10000 sat'), findsNothing); + expect(find.textContaining('tx-a'), findsNothing); + expect(find.text('Transaction history not loaded yet'), findsOneWidget); + + // 5. Load wallet B transactions + await tester.tap(find.text('Load Transaction History')); + await tester.pumpAndSettle(); + + // Verify B's transactions are rendered + expect(find.text('+20000 sat'), findsOneWidget); + expect(find.textContaining('tx-b'), findsOneWidget); + expect(find.text('+10000 sat'), findsNothing); + }, + ); } From 1bbf82ad906681c38ba476651008075fbb4c3439 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 14 Jul 2026 12:53:42 +0100 Subject: [PATCH 09/19] fix(demo): clean up transaction history resources --- .../transactions/transactions_repository.dart | 102 +++++--- .../transactions_controller_test.dart | 221 ++++++------------ .../transaction_detail_page_test.dart | 1 + .../transactions_list_page_test.dart | 6 +- 4 files changed, 145 insertions(+), 185 deletions(-) diff --git a/bdk_demo/lib/features/transactions/transactions_repository.dart b/bdk_demo/lib/features/transactions/transactions_repository.dart index 6efa1b2..d327c09 100644 --- a/bdk_demo/lib/features/transactions/transactions_repository.dart +++ b/bdk_demo/lib/features/transactions/transactions_repository.dart @@ -76,10 +76,23 @@ class BdkWalletTransactionSource implements TransactionHistorySource { @override List transactions() { - return _wallet - .transactions() - .map(_recordFromCanonicalTx) - .toList(growable: false); + 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]); + } + } } @override @@ -101,40 +114,63 @@ class BdkWalletTransactionSource implements TransactionHistorySource { } TransactionHistoryRecord _recordFromCanonicalTx(bdk.CanonicalTx canonicalTx) { - final transaction = canonicalTx.transaction; - final sentAndReceived = _wallet.sentAndReceived(tx: transaction); - final txid = transaction.computeTxid(); - final txidText = txid.toString(); - final sentSat = sentAndReceived.sent.toSat(); - final receivedSat = sentAndReceived.received.toSat(); - - txid.dispose(); - transaction.dispose(); - sentAndReceived.sent.dispose(); - sentAndReceived.received.dispose(); - - return TransactionHistoryRecord( - txid: txidText, - sent: sentSat, - received: receivedSat, - position: _positionFromBdk(canonicalTx.chainPosition), - ); - } - - TransactionHistoryPosition _positionFromBdk(bdk.ChainPosition position) { + 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) { - final confirmation = position.confirmationBlockTime; - return ConfirmedTransactionPosition( - blockHeight: confirmation.blockId.height, - confirmationTime: confirmation.confirmationTime, - ); + blockHash = position.confirmationBlockTime.blockId.hash; + transitively = position.transitively; } - if (position is bdk.UnconfirmedChainPosition) { - return UnconfirmedTransactionPosition(timestamp: position.timestamp); + 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(); } + } - throw StateError('Unsupported transaction chain position: $position'); + 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(); + } } } diff --git a/bdk_demo/test/features/transactions/transactions_controller_test.dart b/bdk_demo/test/features/transactions/transactions_controller_test.dart index de05b33..f0bbc71 100644 --- a/bdk_demo/test/features/transactions/transactions_controller_test.dart +++ b/bdk_demo/test/features/transactions/transactions_controller_test.dart @@ -6,6 +6,7 @@ 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'; @@ -36,12 +37,35 @@ class DelayedTransactionsRepository implements TransactionsRepository { } 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; + } + group('TransactionsController & transactionDetailsProvider', () { test('no active wallet returns the no-wallet state', () { - final container = ProviderContainer( - overrides: [activeWalletIdProvider.overrideWithValue(null)], - ); - addTearDown(container.dispose); + final container = createContainer([ + activeWalletIdProvider.overrideWithValue(null), + ]); final state = container.read(transactionsControllerProvider); expect(state.status, TransactionsLoadState.noWallet); @@ -49,23 +73,13 @@ void main() { }); test('an active wallet can load its transaction history', () async { - final txs = [ - TransactionHistoryItem( - txid: 'tx-1', - sent: 0, - received: 5000, - pending: false, + final txs = [createTx('tx-1', 5000)]; + final container = createContainer([ + activeWalletIdProvider.overrideWithValue('wallet-a'), + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository(transactions: txs), ), - ]; - final container = ProviderContainer( - overrides: [ - activeWalletIdProvider.overrideWithValue('wallet-a'), - transactionsRepositoryProvider.overrideWithValue( - FakeTransactionsRepository(transactions: txs), - ), - ], - ); - addTearDown(container.dispose); + ]); // Initially idle expect( @@ -87,47 +101,20 @@ void main() { test( 'switching the logical active wallet ID from A to B clears A\'s transaction list', () async { - 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, - ), - ]; - final txsB = [ - TransactionHistoryItem( - txid: 'tx-b', - sent: 0, - received: 20000, - pending: false, - ), - ]; - - final container = ProviderContainer( - overrides: [ - transactionsRepositoryProvider.overrideWith((ref) { - final activeId = ref.watch(activeWalletIdProvider); - return FakeTransactionsRepository( - transactions: activeId == 'wallet-a' ? txsA : txsB, - ); - }), - ], - ); - addTearDown(container.dispose); + 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, + ); + }), + ]); // Set initial wallet record to Wallet A container.read(activeWalletRecordProvider.notifier).set(recordA); @@ -162,28 +149,15 @@ void main() { test( 'an asynchronous result started for wallet A is ignored if the active wallet changes to B before it completes', () async { - 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 recordA = createRecord('wallet-a', 'Wallet A'); + final recordB = createRecord('wallet-b', 'Wallet B'); final completer = Completer>(); final delayedRepo = DelayedTransactionsRepository(completer.future); - final container = ProviderContainer( - overrides: [ - transactionsRepositoryProvider.overrideWithValue(delayedRepo), - ], - ); - addTearDown(container.dispose); + final container = createContainer([ + transactionsRepositoryProvider.overrideWithValue(delayedRepo), + ]); // Set initial wallet record to Wallet A container.read(activeWalletRecordProvider.notifier).set(recordA); @@ -210,14 +184,7 @@ void main() { ); // Complete async request for Wallet A - completer.complete([ - TransactionHistoryItem( - txid: 'tx-a', - sent: 0, - received: 10000, - pending: false, - ), - ]); + completer.complete([createTx('tx-a', 10000)]); await future; @@ -231,33 +198,16 @@ void main() { test( 'replacing the FFI Wallet object while retaining the same wallet record ID does not reset state', () async { - final recordA = WalletRecord( - id: 'wallet-a', - name: 'Wallet A', - network: WalletNetwork.testnet, - scriptType: ScriptType.p2wpkh, - ); + final recordA = createRecord('wallet-a', 'Wallet A'); final wallet1 = FakeWallet(); final wallet2 = FakeWallet(); - final container = ProviderContainer( - overrides: [ - transactionsRepositoryProvider.overrideWithValue( - FakeTransactionsRepository( - transactions: [ - TransactionHistoryItem( - txid: 'tx-a', - sent: 0, - received: 10000, - pending: false, - ), - ], - ), - ), - ], - ); - addTearDown(container.dispose); + final container = createContainer([ + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository(transactions: [createTx('tx-a', 10000)]), + ), + ]); // Set initial wallet record and FFI Wallet instance container.read(activeWalletRecordProvider.notifier).set(recordA); @@ -294,43 +244,20 @@ void main() { test( 'a transaction detail from wallet A is not reused after switching to wallet B', () async { - 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, - ); - final txB = TransactionHistoryItem( - txid: 'tx-123', - sent: 0, - received: 20000, - pending: false, - ); - - final container = ProviderContainer( - overrides: [ - transactionsRepositoryProvider.overrideWith((ref) { - final activeId = ref.watch(activeWalletIdProvider); - return FakeTransactionsRepository( - transactions: activeId == 'wallet-a' ? [txA] : [txB], - ); - }), - ], - ); - addTearDown(container.dispose); + 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], + ); + }), + ]); // Set initial wallet record to Wallet A container.read(activeWalletRecordProvider.notifier).set(recordA); 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 1847dbc..b5a87b8 100644 --- a/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart @@ -174,6 +174,7 @@ void main() { }), ], ); + addTearDown(container.dispose); // Set initial wallet record to Wallet A container.read(activeWalletRecordProvider.notifier).set(recordA); 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 b05ac28..7139796 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -239,6 +239,7 @@ void main() { }), ], ); + addTearDown(container.dispose); // Set initial wallet record to Wallet A container.read(activeWalletRecordProvider.notifier).set(recordA); @@ -258,11 +259,6 @@ void main() { // Verify A's transactions are rendered expect(find.text('+10000 sat'), findsOneWidget); - expect( - find.text('tx-a...short'), - findsNothing, - ); // Wait, shortTxid for 'tx-a' is 'tx-a' or whatever Formatters.abbreviateTxid returns. - // Let's check how shortTxid abbreviates 'tx-a'. It probably returns 'tx-a' if it is short. Let's just find.textContaining('tx-a'). expect(find.textContaining('tx-a'), findsOneWidget); // 3. Switch logical active wallet ID from A to B From 2bfc3ff8227298631b09d471e28c973e16af8121 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Sat, 18 Jul 2026 14:36:33 +0100 Subject: [PATCH 10/19] fix(demo): scope transaction state by wallet ID --- .../transactions/transaction_detail_page.dart | 2 +- .../transactions/transactions_controller.dart | 24 ++--- .../transactions/transactions_list_page.dart | 5 +- .../transactions_controller_test.dart | 97 +++++++++++-------- .../transactions_list_page_test.dart | 4 +- 5 files changed, 78 insertions(+), 54 deletions(-) diff --git a/bdk_demo/lib/features/transactions/transaction_detail_page.dart b/bdk_demo/lib/features/transactions/transaction_detail_page.dart index b582c21..23cb4e6 100644 --- a/bdk_demo/lib/features/transactions/transaction_detail_page.dart +++ b/bdk_demo/lib/features/transactions/transaction_detail_page.dart @@ -29,7 +29,7 @@ class TransactionDetailPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); - final activeWalletId = ref.watch(activeWalletIdProvider) ?? ''; + final activeWalletId = ref.watch(activeWalletIdProvider); final transactionAsync = ref.watch( transactionDetailsProvider((walletId: activeWalletId, txid: txid)), ); diff --git a/bdk_demo/lib/features/transactions/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index 61742e0..325ebce 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -1,6 +1,5 @@ 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, noWallet } @@ -40,18 +39,17 @@ class TransactionsState { } } -final transactionsControllerProvider = - NotifierProvider( +final transactionsControllerProvider = NotifierProvider.autoDispose + .family( TransactionsController.new, ); final transactionDetailsProvider = FutureProvider.autoDispose - .family(( + .family(( ref, arg, ) { - final activeWalletId = ref.watch(activeWalletIdProvider); - if (activeWalletId != arg.walletId) { + if (arg.walletId == null) { return Future.value(null); } final repository = ref.watch(transactionsRepositoryProvider); @@ -59,10 +57,13 @@ final transactionDetailsProvider = FutureProvider.autoDispose }); class TransactionsController extends Notifier { + TransactionsController(this.walletId); + + final String? walletId; + @override TransactionsState build() { - final activeWalletId = ref.watch(activeWalletIdProvider); - if (activeWalletId == null) { + if (walletId == null) { return const TransactionsState( status: TransactionsLoadState.noWallet, transactions: [], @@ -74,8 +75,7 @@ class TransactionsController extends Notifier { } Future loadTransactions() async { - final activeWalletId = ref.read(activeWalletIdProvider); - if (activeWalletId == null) { + if (walletId == null) { state = const TransactionsState( status: TransactionsLoadState.noWallet, transactions: [], @@ -97,7 +97,7 @@ class TransactionsController extends Notifier { .read(transactionsRepositoryProvider) .loadTransactions(); - if (ref.read(activeWalletIdProvider) != activeWalletId) { + if (!ref.mounted) { return; } @@ -110,7 +110,7 @@ class TransactionsController extends Notifier { errorMessage: null, ); } catch (error) { - if (ref.read(activeWalletIdProvider) != activeWalletId) { + if (!ref.mounted) { return; } diff --git a/bdk_demo/lib/features/transactions/transactions_list_page.dart b/bdk_demo/lib/features/transactions/transactions_list_page.dart index 58b2e28..3488b84 100644 --- a/bdk_demo/lib/features/transactions/transactions_list_page.dart +++ b/bdk_demo/lib/features/transactions/transactions_list_page.dart @@ -26,8 +26,9 @@ 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; @@ -73,7 +74,7 @@ class TransactionsListPage extends ConsumerWidget { FilledButton.icon( onPressed: canLoad ? () => ref - .read(transactionsControllerProvider.notifier) + .read(controllerProvider.notifier) .loadTransactions() : null, icon: isLoading diff --git a/bdk_demo/test/features/transactions/transactions_controller_test.dart b/bdk_demo/test/features/transactions/transactions_controller_test.dart index f0bbc71..f6b1991 100644 --- a/bdk_demo/test/features/transactions/transactions_controller_test.dart +++ b/bdk_demo/test/features/transactions/transactions_controller_test.dart @@ -61,13 +61,20 @@ void main() { 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([ - activeWalletIdProvider.overrideWithValue(null), - ]); + final container = createContainer([]); + keepControllerAlive(container, null); - final state = container.read(transactionsControllerProvider); + final state = container.read(transactionsControllerProvider(null)); expect(state.status, TransactionsLoadState.noWallet); expect(state.transactions, isEmpty); }); @@ -75,24 +82,24 @@ void main() { test('an active wallet can load its transaction history', () async { final txs = [createTx('tx-1', 5000)]; final container = createContainer([ - activeWalletIdProvider.overrideWithValue('wallet-a'), transactionsRepositoryProvider.overrideWithValue( FakeTransactionsRepository(transactions: txs), ), ]); + keepControllerAlive(container, 'wallet-a'); // Initially idle expect( - container.read(transactionsControllerProvider).status, + container.read(transactionsControllerProvider('wallet-a')).status, TransactionsLoadState.idle, ); // Load transactions await container - .read(transactionsControllerProvider.notifier) + .read(transactionsControllerProvider('wallet-a').notifier) .loadTransactions(); - final state = container.read(transactionsControllerProvider); + final state = container.read(transactionsControllerProvider('wallet-a')); expect(state.status, TransactionsLoadState.success); expect(state.transactions, hasLength(1)); expect(state.transactions.first.txid, 'tx-1'); @@ -118,18 +125,20 @@ void main() { // Set initial wallet record to Wallet A container.read(activeWalletRecordProvider.notifier).set(recordA); + final walletAId = container.read(activeWalletIdProvider); + keepControllerAlive(container, walletAId); // Load Wallet A transactions await container - .read(transactionsControllerProvider.notifier) + .read(transactionsControllerProvider(walletAId).notifier) .loadTransactions(); expect( - container.read(transactionsControllerProvider).status, + container.read(transactionsControllerProvider(walletAId)).status, TransactionsLoadState.success, ); expect( container - .read(transactionsControllerProvider) + .read(transactionsControllerProvider(walletAId)) .transactions .first .txid, @@ -138,9 +147,13 @@ void main() { // Switch active wallet to Wallet B container.read(activeWalletRecordProvider.notifier).set(recordB); + final walletBId = container.read(activeWalletIdProvider); + keepControllerAlive(container, walletBId); // Verify that Wallet A's transaction state is cleared and we are back to idle - final stateAfterSwitch = container.read(transactionsControllerProvider); + final stateAfterSwitch = container.read( + transactionsControllerProvider(walletBId), + ); expect(stateAfterSwitch.status, TransactionsLoadState.idle); expect(stateAfterSwitch.transactions, isEmpty); }, @@ -161,25 +174,32 @@ void main() { // Set initial wallet record to Wallet A container.read(activeWalletRecordProvider.notifier).set(recordA); + final walletAId = container.read(activeWalletIdProvider); + final walletASubscription = container.listen( + transactionsControllerProvider(walletAId), + (_, __) {}, + ); // Start loading final future = container - .read(transactionsControllerProvider.notifier) + .read(transactionsControllerProvider(walletAId).notifier) .loadTransactions(); // State is loading expect( - container.read(transactionsControllerProvider).status, + container.read(transactionsControllerProvider(walletAId)).status, TransactionsLoadState.loading, ); - // Switch active wallet to Wallet B (this rebuilds provider, returning idle state) + // Switch active wallet to Wallet B and begin observing B's isolated state. container.read(activeWalletRecordProvider.notifier).set(recordB); + final walletBId = container.read(activeWalletIdProvider); + keepControllerAlive(container, walletBId); + walletASubscription.close(); + await container.pump(); - // Allow microtasks - await Future.value(); expect( - container.read(transactionsControllerProvider).status, + container.read(transactionsControllerProvider(walletBId)).status, TransactionsLoadState.idle, ); @@ -189,7 +209,9 @@ void main() { await future; // State must remain idle for Wallet B - final finalState = container.read(transactionsControllerProvider); + final finalState = container.read( + transactionsControllerProvider(walletBId), + ); expect(finalState.status, TransactionsLoadState.idle); expect(finalState.transactions, isEmpty); }, @@ -204,25 +226,32 @@ void main() { final wallet2 = FakeWallet(); final container = createContainer([ - transactionsRepositoryProvider.overrideWithValue( - FakeTransactionsRepository(transactions: [createTx('tx-a', 10000)]), - ), + transactionsRepositoryProvider.overrideWith((ref) { + ref.watch(activeWalletProvider); + return FakeTransactionsRepository( + transactions: [createTx('tx-a', 10000)], + ); + }), ]); // Set initial wallet record and FFI Wallet instance container.read(activeWalletRecordProvider.notifier).set(recordA); container.read(activeWalletProvider.notifier).set(wallet1); + final walletAId = container.read(activeWalletIdProvider); + keepControllerAlive(container, walletAId); // Load transactions await container - .read(transactionsControllerProvider.notifier) + .read(transactionsControllerProvider(walletAId).notifier) .loadTransactions(); expect( - container.read(transactionsControllerProvider).status, + container.read(transactionsControllerProvider(walletAId)).status, TransactionsLoadState.success, ); expect( - container.read(transactionsControllerProvider).transactions, + container + .read(transactionsControllerProvider(walletAId)) + .transactions, isNotEmpty, ); @@ -231,11 +260,13 @@ void main() { // State must not reset expect( - container.read(transactionsControllerProvider).status, + container.read(transactionsControllerProvider(walletAId)).status, TransactionsLoadState.success, ); expect( - container.read(transactionsControllerProvider).transactions, + container + .read(transactionsControllerProvider(walletAId)) + .transactions, isNotEmpty, ); }, @@ -274,17 +305,7 @@ void main() { // 2. Switch wallet to B container.read(activeWalletRecordProvider.notifier).set(recordB); - // 3. Read detail for key (walletId: 'wallet-a', txid: 'tx-123') again. - // Because activeWalletId is now 'wallet-b', reading key 'wallet-a' should return null (stale/not matching active wallet). - final detailAAfterSwitch = await container.read( - transactionDetailsProvider(( - walletId: 'wallet-a', - txid: 'tx-123', - )).future, - ); - expect(detailAAfterSwitch, isNull); - - // 4. Read detail for key (walletId: 'wallet-b', txid: 'tx-123') + // 3. Read the same txid through wallet B's isolated cache key. final detailB = await container.read( transactionDetailsProvider(( walletId: 'wallet-b', 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 7139796..506779e 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -251,6 +251,7 @@ void main() { container: container, ); + expect(tester.takeException(), isNull); expect(find.text('Transaction history not loaded yet'), findsOneWidget); // 2. Load wallet A transactions @@ -263,9 +264,10 @@ void main() { // 3. Switch logical active wallet ID from A to B container.read(activeWalletRecordProvider.notifier).set(recordB); - await tester.pumpAndSettle(); + await tester.pump(); // 4. Verify wallet A's transaction rows are cleared immediately and not rendered + expect(tester.takeException(), isNull); expect(find.text('+10000 sat'), findsNothing); expect(find.textContaining('tx-a'), findsNothing); expect(find.text('Transaction history not loaded yet'), findsOneWidget); From ac5acbb179d76d8a35e6799a780469641af9c2dd Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 21 Jul 2026 16:12:41 +0100 Subject: [PATCH 11/19] feat: automatically load and refresh transaction history --- .../transactions/transactions_controller.dart | 29 +- .../transactions_controller_test.dart | 6 +- .../transactions_list_page_test.dart | 482 ++++++++++++------ 3 files changed, 342 insertions(+), 175 deletions(-) diff --git a/bdk_demo/lib/features/transactions/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index 325ebce..56e219d 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -1,5 +1,6 @@ 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, noWallet } @@ -71,10 +72,20 @@ class TransactionsController extends Notifier { '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() async { + Future loadTransactions({bool isBackgroundRefresh = false}) async { if (walletId == null) { state = const TransactionsState( status: TransactionsLoadState.noWallet, @@ -85,12 +96,14 @@ class TransactionsController extends Notifier { return; } - state = state.copyWith( - status: TransactionsLoadState.loading, - transactions: const [], - statusMessage: 'Loading transaction history...', - errorMessage: null, - ); + if (!isBackgroundRefresh) { + state = state.copyWith( + status: TransactionsLoadState.loading, + transactions: const [], + statusMessage: 'Loading transaction history...', + errorMessage: null, + ); + } try { final transactions = await ref @@ -116,7 +129,7 @@ class TransactionsController extends Notifier { state = state.copyWith( status: TransactionsLoadState.error, - transactions: const [], + transactions: isBackgroundRefresh ? state.transactions : const [], statusMessage: 'Transaction history could not be loaded.', errorMessage: _readableError(error), ); diff --git a/bdk_demo/test/features/transactions/transactions_controller_test.dart b/bdk_demo/test/features/transactions/transactions_controller_test.dart index f6b1991..92ddd09 100644 --- a/bdk_demo/test/features/transactions/transactions_controller_test.dart +++ b/bdk_demo/test/features/transactions/transactions_controller_test.dart @@ -200,7 +200,7 @@ void main() { expect( container.read(transactionsControllerProvider(walletBId)).status, - TransactionsLoadState.idle, + TransactionsLoadState.loading, ); // Complete async request for Wallet A @@ -208,11 +208,11 @@ void main() { await future; - // State must remain idle for Wallet B + // State must remain loading for Wallet B final finalState = container.read( transactionsControllerProvider(walletBId), ); - expect(finalState.status, TransactionsLoadState.idle); + expect(finalState.status, TransactionsLoadState.loading); expect(finalState.transactions, isEmpty); }, ); 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 506779e..0ef0eb5 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -1,3 +1,5 @@ +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'; @@ -12,11 +14,56 @@ import 'package:go_router/go_router.dart'; import '../../helpers/fakes/fake_transactions_repository.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', @@ -32,6 +79,11 @@ Future _pumpTransactionsFlow( builder: (context, state) => TransactionDetailPage(txid: state.pathParameters['txid'] ?? ''), ), + GoRoute( + path: '/other', + name: 'other', + builder: (context, state) => const Scaffold(body: Text('Other Page')), + ), ], ); @@ -55,11 +107,15 @@ Future _pumpTransactionsFlow( ), ); } - await tester.pumpAndSettle(); + if (settle) { + await tester.pumpAndSettle(); + } else { + await tester.pump(); + } } void main() { - testWidgets('shows intro before loading transaction history', (tester) async { + testWidgets('automatically loads and renders wallet transactions', (tester) async { await _pumpTransactionsFlow( tester, repository: FakeTransactionsRepository( @@ -67,41 +123,67 @@ void main() { ), ); - expect(find.text('Transaction History'), findsNWidgets(2)); - expect(find.text('Load Transaction History'), findsOneWidget); - expect(find.text('Transaction history not loaded yet'), findsOneWidget); + expect(find.text('+42000 sat'), findsOneWidget); + expect(find.text('-1600 sat'), findsOneWidget); + expect(find.text('123456...abcd'), findsOneWidget); + expect(find.text('abcdef...7890'), findsOneWidget); + expect(find.text('confirmed'), findsOneWidget); + expect(find.text('pending'), findsOneWidget); }); - testWidgets('loads and renders wallet transactions', (tester) async { - await _pumpTransactionsFlow( - tester, - repository: FakeTransactionsRepository( - transactions: transactionHistoryItems, + 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); - await tester.tap(find.text('Load Transaction History')); + // 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); - expect(find.text('-1600 sat'), findsOneWidget); - expect(find.text('123456...abcd'), findsOneWidget); - expect(find.text('abcdef...7890'), findsOneWidget); - expect(find.text('confirmed'), findsOneWidget); - expect(find.text('pending'), findsOneWidget); }); - testWidgets('shows empty state when no transactions are returned', ( - tester, - ) async { + testWidgets('shows empty state when no transactions are returned', (tester) async { await _pumpTransactionsFlow( tester, repository: FakeTransactionsRepository(transactions: const []), ); - await tester.tap(find.text('Load Transaction History')); - await tester.pumpAndSettle(); - expect(find.text('No transactions yet'), findsOneWidget); expect( find.text( @@ -119,9 +201,6 @@ void main() { ), ); - await tester.tap(find.text('Load Transaction History')); - await tester.pumpAndSettle(); - await tester.tap(find.text('123456...abcd')); await tester.pumpAndSettle(); @@ -134,152 +213,227 @@ void main() { ); }); - 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, - ); - - // Verify button is disabled - final buttonFinder = find.widgetWithText( - FilledButton, - 'Load Transaction History', - ); - expect(tester.widget(buttonFinder).onPressed, isNull); - }, - ); + 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, + ); - testWidgets( - 'active wallet with no transactions still shows the normal empty-history state after loading', - (tester) async { - await _pumpTransactionsFlow( - tester, - repository: FakeTransactionsRepository(transactions: const []), - hasActiveWallet: true, - ); + final buttonFinder = find.widgetWithText( + FilledButton, + 'Load Transaction History', + ); + expect(tester.widget(buttonFinder).onPressed, isNull); + }); - expect(find.text('Transaction history not loaded yet'), findsOneWidget); + 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; - await tester.tap(find.text('Load Transaction History')); - await tester.pumpAndSettle(); + final recordA = WalletRecord( + id: 'wallet-a', + name: 'Wallet A', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); - expect(find.text('No transactions yet'), findsOneWidget); - expect( - find.text( - 'The active wallet has no transactions yet. Sync the wallet or receive funds to populate history.', - ), - findsOneWidget, - ); - }, - ); + final recordB = WalletRecord( + id: 'wallet-b', + name: 'Wallet B', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); - testWidgets( - 'switching logical active wallet ID from A to B clears A\'s transaction list and does not render A\'s transaction rows before loading 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 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 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); - final dynamicRepository = FakeTransactionsRepository( - transactions: const [], - ); + container.read(activeWalletRecordProvider.notifier).set(recordA); - container = ProviderContainer( - overrides: [ - transactionsRepositoryProvider.overrideWith((ref) { - final activeId = ref.watch(activeWalletIdProvider); - return FakeTransactionsRepository( - transactions: activeId == 'wallet-a' ? txsA : txsB, - ); - }), - ], - ); - addTearDown(container.dispose); + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository(transactions: const []), + container: container, + ); - // Set initial wallet record to Wallet A - container.read(activeWalletRecordProvider.notifier).set(recordA); + // Verify A's transactions are rendered + expect(find.text('+10000 sat'), findsOneWidget); + expect(find.textContaining('tx-a'), findsOneWidget); - // 1. Initial pump with wallet A active - await _pumpTransactionsFlow( - tester, - repository: dynamicRepository, - container: container, - ); - - expect(tester.takeException(), isNull); - expect(find.text('Transaction history not loaded yet'), findsOneWidget); - - // 2. Load wallet A transactions - await tester.tap(find.text('Load Transaction History')); - await tester.pumpAndSettle(); - - // Verify A's transactions are rendered - expect(find.text('+10000 sat'), findsOneWidget); - expect(find.textContaining('tx-a'), findsOneWidget); - - // 3. Switch logical active wallet ID from A to B - container.read(activeWalletRecordProvider.notifier).set(recordB); - await tester.pump(); - - // 4. Verify wallet A's transaction rows are cleared immediately and not rendered - expect(tester.takeException(), isNull); - expect(find.text('+10000 sat'), findsNothing); - expect(find.textContaining('tx-a'), findsNothing); - expect(find.text('Transaction history not loaded yet'), findsOneWidget); - - // 5. Load wallet B transactions - await tester.tap(find.text('Load Transaction History')); - await tester.pumpAndSettle(); - - // Verify B's transactions are rendered - expect(find.text('+20000 sat'), findsOneWidget); - expect(find.textContaining('tx-b'), findsOneWidget); - expect(find.text('+10000 sat'), findsNothing); - }, - ); + // 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); + }); } From 82e246d86a48e3aa01a964f047c86ca54a59724c Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 21 Jul 2026 18:48:58 +0100 Subject: [PATCH 12/19] feat: auto-reload transactions in background after broadcast and sync completion --- bdk_demo/lib/features/send/send_page.dart | 4 +++ .../transactions/transactions_controller.dart | 28 +++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/bdk_demo/lib/features/send/send_page.dart b/bdk_demo/lib/features/send/send_page.dart index abeb518..8786f8b 100644 --- a/bdk_demo/lib/features/send/send_page.dart +++ b/bdk_demo/lib/features/send/send_page.dart @@ -6,6 +6,7 @@ import 'package:bdk_demo/providers/blockchain_providers.dart'; import 'package:bdk_demo/providers/connectivity_provider.dart'; import 'package:bdk_demo/providers/send_providers.dart'; import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:bdk_demo/features/transactions/transactions_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -351,6 +352,9 @@ class _SendPageState extends ConsumerState { ref .read(balanceSnapshotProvider.notifier) .applyFromWallet(wallet, record.id); + ref + .read(transactionsControllerProvider(record.id).notifier) + .loadTransactions(isBackgroundRefresh: true); _showSnackBar('Transaction broadcast successfully.'); context.go(AppRoutes.home); } catch (_) { diff --git a/bdk_demo/lib/features/transactions/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index 56e219d..cbb8315 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -1,5 +1,6 @@ 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/blockchain_providers.dart'; import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -19,11 +20,10 @@ class TransactionsState { }); const TransactionsState.idle() - : this( - status: TransactionsLoadState.idle, - transactions: const [], - statusMessage: 'Load the active wallet transaction history.', - ); + : status = TransactionsLoadState.idle, + transactions = const [], + statusMessage = 'Ready to load transactions.', + errorMessage = null; TransactionsState copyWith({ TransactionsLoadState? status, @@ -35,7 +35,7 @@ class TransactionsState { status: status ?? this.status, transactions: transactions ?? this.transactions, statusMessage: statusMessage ?? this.statusMessage, - errorMessage: errorMessage, + errorMessage: errorMessage ?? this.errorMessage, ); } } @@ -50,12 +50,9 @@ final transactionDetailsProvider = FutureProvider.autoDispose ref, arg, ) { - if (arg.walletId == null) { - return Future.value(null); - } - final repository = ref.watch(transactionsRepositoryProvider); - return repository.loadTransactionByTxid(arg.txid); - }); + final repository = ref.watch(transactionsRepositoryProvider); + return repository.loadTransactionByTxid(arg.txid); +}); class TransactionsController extends Notifier { TransactionsController(this.walletId); @@ -80,6 +77,13 @@ class TransactionsController extends Notifier { } }); + ref.listen(syncStatusProvider, (previous, next) { + if (next == SyncStatus.synced) { + final isSuccess = state.status == TransactionsLoadState.success; + loadTransactions(isBackgroundRefresh: isSuccess); + } + }); + Future.microtask(() => loadTransactions()); return const TransactionsState.idle(); From ec8434df1453898bba48802990c099e5df1643b4 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Wed, 22 Jul 2026 07:33:04 +0100 Subject: [PATCH 13/19] fix: stabilize transaction history refresh lifecycle --- bdk_demo/lib/features/send/send_page.dart | 4 - .../transactions/transactions_controller.dart | 50 +- .../transactions_controller_test.dart | 196 ++++--- .../transactions_list_page_test.dart | 500 +++++++++--------- 4 files changed, 422 insertions(+), 328 deletions(-) diff --git a/bdk_demo/lib/features/send/send_page.dart b/bdk_demo/lib/features/send/send_page.dart index 8786f8b..abeb518 100644 --- a/bdk_demo/lib/features/send/send_page.dart +++ b/bdk_demo/lib/features/send/send_page.dart @@ -6,7 +6,6 @@ import 'package:bdk_demo/providers/blockchain_providers.dart'; import 'package:bdk_demo/providers/connectivity_provider.dart'; import 'package:bdk_demo/providers/send_providers.dart'; import 'package:bdk_demo/providers/wallet_providers.dart'; -import 'package:bdk_demo/features/transactions/transactions_controller.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -352,9 +351,6 @@ class _SendPageState extends ConsumerState { ref .read(balanceSnapshotProvider.notifier) .applyFromWallet(wallet, record.id); - ref - .read(transactionsControllerProvider(record.id).notifier) - .loadTransactions(isBackgroundRefresh: true); _showSnackBar('Transaction broadcast successfully.'); context.go(AppRoutes.home); } catch (_) { diff --git a/bdk_demo/lib/features/transactions/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index cbb8315..3deb972 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -1,6 +1,5 @@ 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/blockchain_providers.dart'; import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -20,22 +19,26 @@ class TransactionsState { }); const TransactionsState.idle() - : status = TransactionsLoadState.idle, - transactions = const [], - statusMessage = 'Ready to load transactions.', - errorMessage = null; + : status = TransactionsLoadState.idle, + transactions = const [], + statusMessage = 'Ready to load transactions.', + errorMessage = null; + + static const _unset = Object(); TransactionsState copyWith({ TransactionsLoadState? status, 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 ?? this.errorMessage, + errorMessage: identical(errorMessage, _unset) + ? this.errorMessage + : errorMessage as String?, ); } } @@ -50,14 +53,15 @@ final transactionDetailsProvider = FutureProvider.autoDispose ref, arg, ) { - final repository = ref.watch(transactionsRepositoryProvider); - return repository.loadTransactionByTxid(arg.txid); -}); + final repository = ref.watch(transactionsRepositoryProvider); + return repository.loadTransactionByTxid(arg.txid); + }); class TransactionsController extends Notifier { TransactionsController(this.walletId); final String? walletId; + bool _isLoading = false; @override TransactionsState build() { @@ -77,13 +81,6 @@ class TransactionsController extends Notifier { } }); - ref.listen(syncStatusProvider, (previous, next) { - if (next == SyncStatus.synced) { - final isSuccess = state.status == TransactionsLoadState.success; - loadTransactions(isBackgroundRefresh: isSuccess); - } - }); - Future.microtask(() => loadTransactions()); return const TransactionsState.idle(); @@ -100,6 +97,11 @@ class TransactionsController extends Notifier { return; } + if (_isLoading) { + return; + } + _isLoading = true; + if (!isBackgroundRefresh) { state = state.copyWith( status: TransactionsLoadState.loading, @@ -131,12 +133,24 @@ class TransactionsController extends Notifier { return; } + if (isBackgroundRefresh && + state.status == TransactionsLoadState.success) { + state = state.copyWith( + status: TransactionsLoadState.success, + transactions: state.transactions, + errorMessage: _readableError(error), + ); + return; + } + state = state.copyWith( status: TransactionsLoadState.error, - transactions: isBackgroundRefresh ? state.transactions : const [], + transactions: const [], statusMessage: 'Transaction history could not be loaded.', errorMessage: _readableError(error), ); + } finally { + _isLoading = false; } } diff --git a/bdk_demo/test/features/transactions/transactions_controller_test.dart b/bdk_demo/test/features/transactions/transactions_controller_test.dart index 92ddd09..65507d1 100644 --- a/bdk_demo/test/features/transactions/transactions_controller_test.dart +++ b/bdk_demo/test/features/transactions/transactions_controller_test.dart @@ -16,6 +16,31 @@ class FakeWallet extends Fake implements bdk.Wallet { 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; @@ -88,23 +113,112 @@ void main() { ]); keepControllerAlive(container, 'wallet-a'); - // Initially idle - expect( - container.read(transactionsControllerProvider('wallet-a')).status, - TransactionsLoadState.idle, + 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'); - // Load transactions 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( + 'duplicate concurrent load calls do not execute duplicate repository requests', + () 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(); + + await Future.wait([load1, load2]); + expect(repo.loadCount, 1); + }, + ); + test( 'switching the logical active wallet ID from A to B clears A\'s transaction list', () async { @@ -123,12 +237,10 @@ void main() { }), ]); - // Set initial wallet record to Wallet A container.read(activeWalletRecordProvider.notifier).set(recordA); final walletAId = container.read(activeWalletIdProvider); keepControllerAlive(container, walletAId); - // Load Wallet A transactions await container .read(transactionsControllerProvider(walletAId).notifier) .loadTransactions(); @@ -145,16 +257,13 @@ void main() { 'tx-a', ); - // Switch active wallet to Wallet B container.read(activeWalletRecordProvider.notifier).set(recordB); final walletBId = container.read(activeWalletIdProvider); keepControllerAlive(container, walletBId); - // Verify that Wallet A's transaction state is cleared and we are back to idle final stateAfterSwitch = container.read( transactionsControllerProvider(walletBId), ); - expect(stateAfterSwitch.status, TransactionsLoadState.idle); expect(stateAfterSwitch.transactions, isEmpty); }, ); @@ -172,7 +281,6 @@ void main() { transactionsRepositoryProvider.overrideWithValue(delayedRepo), ]); - // Set initial wallet record to Wallet A container.read(activeWalletRecordProvider.notifier).set(recordA); final walletAId = container.read(activeWalletIdProvider); final walletASubscription = container.listen( @@ -180,95 +288,57 @@ void main() { (_, __) {}, ); - // Start loading final future = container .read(transactionsControllerProvider(walletAId).notifier) .loadTransactions(); - // State is loading - expect( - container.read(transactionsControllerProvider(walletAId)).status, - TransactionsLoadState.loading, - ); - - // Switch active wallet to Wallet B and begin observing B's isolated state. container.read(activeWalletRecordProvider.notifier).set(recordB); final walletBId = container.read(activeWalletIdProvider); keepControllerAlive(container, walletBId); walletASubscription.close(); await container.pump(); - expect( - container.read(transactionsControllerProvider(walletBId)).status, - TransactionsLoadState.loading, - ); - - // Complete async request for Wallet A completer.complete([createTx('tx-a', 10000)]); - await future; - // State must remain loading for Wallet B final finalState = container.read( transactionsControllerProvider(walletBId), ); - expect(finalState.status, TransactionsLoadState.loading); expect(finalState.transactions, isEmpty); }, ); test( - 'replacing the FFI Wallet object while retaining the same wallet record ID does not reset state', + '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.overrideWith((ref) { - ref.watch(activeWalletProvider); - return FakeTransactionsRepository( - transactions: [createTx('tx-a', 10000)], - ); - }), + transactionsRepositoryProvider.overrideWithValue(repo), ]); - // Set initial wallet record and FFI Wallet instance container.read(activeWalletRecordProvider.notifier).set(recordA); - container.read(activeWalletProvider.notifier).set(wallet1); final walletAId = container.read(activeWalletIdProvider); keepControllerAlive(container, walletAId); - // Load transactions await container .read(transactionsControllerProvider(walletAId).notifier) .loadTransactions(); - expect( - container.read(transactionsControllerProvider(walletAId)).status, - TransactionsLoadState.success, - ); - expect( - container - .read(transactionsControllerProvider(walletAId)) - .transactions, - isNotEmpty, - ); + final initialLoadCount = repo.loadCount; - // Replace the wallet object instance (same logical ID) - container.read(activeWalletProvider.notifier).set(wallet2); + container.read(activeWalletProvider.notifier).set(wallet1); + await container + .read(transactionsControllerProvider(walletAId).notifier) + .loadTransactions(isBackgroundRefresh: true); - // State must not reset - expect( - container.read(transactionsControllerProvider(walletAId)).status, - TransactionsLoadState.success, - ); - expect( - container - .read(transactionsControllerProvider(walletAId)) - .transactions, - isNotEmpty, - ); + expect(repo.loadCount, greaterThan(initialLoadCount)); }, ); @@ -290,10 +360,8 @@ void main() { }), ]); - // Set initial wallet record to Wallet A container.read(activeWalletRecordProvider.notifier).set(recordA); - // 1. Read detail for key (walletId: 'wallet-a', txid: 'tx-123') final detailA = await container.read( transactionDetailsProvider(( walletId: 'wallet-a', @@ -302,10 +370,8 @@ void main() { ); expect(detailA?.netAmount, 10000); - // 2. Switch wallet to B container.read(activeWalletRecordProvider.notifier).set(recordB); - // 3. Read the same txid through wallet B's isolated cache key. final detailB = await container.read( transactionDetailsProvider(( walletId: 'wallet-b', 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 0ef0eb5..94ac185 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -115,7 +115,9 @@ Future _pumpTransactionsFlow( } void main() { - testWidgets('automatically loads and renders wallet transactions', (tester) async { + testWidgets('automatically loads and renders wallet transactions', ( + tester, + ) async { await _pumpTransactionsFlow( tester, repository: FakeTransactionsRepository( @@ -131,54 +133,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), + 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')), ), - activeWalletIdProvider.overrideWithValue('wallet-a'), ], - child: MaterialApp.router(routerConfig: router), - ), - ); - await tester.pumpAndSettle(); + ); + + 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); + // 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); + // 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(); + // 3. Navigate back + router.go('/transactions'); + await tester.pumpAndSettle(); - // 4. Verify automatically loaded again (no reload tap required) - expect(find.text('+42000 sat'), findsOneWidget); - }); + // 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 { + testWidgets('shows empty state when no transactions are returned', ( + tester, + ) async { await _pumpTransactionsFlow( tester, repository: FakeTransactionsRepository(transactions: const []), @@ -213,227 +221,237 @@ void main() { ); }); - testWidgets('no active wallet shows the no-wallet state and disables load button', (tester) async { - await _pumpTransactionsFlow( - tester, - repository: FakeTransactionsRepository(transactions: const []), - hasActiveWallet: false, - ); + 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); + }, + ); - expect(find.text('No active wallet'), findsOneWidget); - expect( - find.text( - 'Create or load a wallet before viewing transaction history.', - ), - findsOneWidget, - ); + 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 buttonFinder = find.widgetWithText( - FilledButton, - 'Load Transaction History', - ); - expect(tester.widget(buttonFinder).onPressed, isNull); - }); + container = ProviderContainer( + overrides: [ + transactionsRepositoryProvider.overrideWith((ref) { + final activeId = ref.watch(activeWalletIdProvider); + return FakeTransactionsRepository( + transactions: activeId == 'wallet-a' ? txsA : txsB, + ); + }), + ], + ); + addTearDown(container.dispose); - 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; + container.read(activeWalletRecordProvider.notifier).set(recordA); - final recordA = WalletRecord( - id: 'wallet-a', - name: 'Wallet A', - network: WalletNetwork.testnet, - scriptType: ScriptType.p2wpkh, - ); + 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); + }, + ); - final recordB = WalletRecord( - id: 'wallet-b', - name: 'Wallet B', - network: WalletNetwork.testnet, - scriptType: ScriptType.p2wpkh, - ); + testWidgets( + 'pending transaction updates to confirmed automatically after wallet sync without manual reload', + (tester) async { + late final ProviderContainer container; - final txsA = [ - TransactionHistoryItem( - txid: 'tx-a', + 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: false, - blockHeight: 100, - confirmationTime: DateTime.now(), - ), - ]; + pending: true, + blockHeight: null, + confirmationTime: null, + ); - final txsB = [ - TransactionHistoryItem( - txid: 'tx-b', + final txConfirmed = TransactionHistoryItem( + txid: 'tx-1', sent: 0, - received: 20000, + received: 10000, pending: false, - blockHeight: 101, + blockHeight: 200, 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); + final repo = MutableTransactionsRepository([txPending]); - // 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); - }); + container = ProviderContainer( + overrides: [transactionsRepositoryProvider.overrideWithValue(repo)], + ); + addTearDown(container.dispose); - 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(), - ); + container.read(activeWalletRecordProvider.notifier).set(record); + final walletA = FakeWallet(); + container.read(activeWalletProvider.notifier).set(walletA); - 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, - ); + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository(transactions: const []), + container: container, + ); - final recordB = WalletRecord( - id: 'wallet-b', - name: 'Wallet B', - network: WalletNetwork.testnet, - scriptType: ScriptType.p2wpkh, - ); + // Confirm UI displays: Awaiting confirmation + expect(find.text('Awaiting confirmation'), findsOneWidget); + expect(find.text('Block 200'), findsNothing); - 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); + // Simulate a successful wallet sync (replace wallet instance and update mock data) + repo.transactions = [txConfirmed]; + final walletB = FakeWallet(); + container.read(activeWalletProvider.notifier).set(walletB); - container.read(activeWalletRecordProvider.notifier).set(recordA); + await tester.pumpAndSettle(); - await _pumpTransactionsFlow( - tester, - repository: FakeTransactionsRepository(transactions: const []), - container: container, - settle: false, - ); + // Confirm Awaiting confirmation is gone, and confirmed state shows block height + expect(find.text('Awaiting confirmation'), findsNothing); + expect(find.text('Block 200'), findsOneWidget); + }, + ); - // Verify wallet A is loading - expect(find.text('Loading transaction history...'), 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); - // Switch active wallet to B - container.read(activeWalletRecordProvider.notifier).set(recordB); - await tester.pump(); + container.read(activeWalletRecordProvider.notifier).set(recordA); - // Complete A's future - completerA.complete([ - TransactionHistoryItem( - txid: 'tx-a', - sent: 0, - received: 10000, - pending: false, - ) - ]); - await tester.pump(); + 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); - }); + // Wallet B's state shouldn't render A's transaction + expect(find.text('+10000 sat'), findsNothing); + }, + ); } From eb368c5b39c7338e490bd5d18a1bde377b2c09cf Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Wed, 22 Jul 2026 07:52:38 +0100 Subject: [PATCH 14/19] ci: trigger fresh build on all platforms From ce9da323384405627184ff6d8819274dae370306 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Sat, 25 Jul 2026 05:10:51 +0100 Subject: [PATCH 15/19] fix: queue pending transaction refreshes and resolve analyzer warning --- .../transactions/transactions_controller.dart | 45 +++++++++++++------ .../transactions_controller_test.dart | 15 +++---- 2 files changed, 38 insertions(+), 22 deletions(-) diff --git a/bdk_demo/lib/features/transactions/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index 3deb972..a39cafa 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -61,7 +61,9 @@ class TransactionsController extends Notifier { TransactionsController(this.walletId); final String? walletId; - bool _isLoading = false; + Future? _inFlightLoad; + bool _hasPendingRefresh = false; + bool _pendingIsBackground = true; @override TransactionsState build() { @@ -97,11 +99,29 @@ class TransactionsController extends Notifier { return; } - if (_isLoading) { - return; + if (_inFlightLoad != null) { + _hasPendingRefresh = true; + if (!isBackgroundRefresh) { + _pendingIsBackground = false; + } + return _inFlightLoad; } - _isLoading = true; + _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, @@ -140,17 +160,14 @@ class TransactionsController extends Notifier { transactions: state.transactions, errorMessage: _readableError(error), ); - return; + } else { + state = state.copyWith( + status: TransactionsLoadState.error, + transactions: const [], + statusMessage: 'Transaction history could not be loaded.', + errorMessage: _readableError(error), + ); } - - state = state.copyWith( - status: TransactionsLoadState.error, - transactions: const [], - statusMessage: 'Transaction history could not be loaded.', - errorMessage: _readableError(error), - ); - } finally { - _isLoading = false; } } diff --git a/bdk_demo/test/features/transactions/transactions_controller_test.dart b/bdk_demo/test/features/transactions/transactions_controller_test.dart index 65507d1..53f2871 100644 --- a/bdk_demo/test/features/transactions/transactions_controller_test.dart +++ b/bdk_demo/test/features/transactions/transactions_controller_test.dart @@ -198,7 +198,7 @@ void main() { }); test( - 'duplicate concurrent load calls do not execute duplicate repository requests', + 'a refresh requested while another transaction load is running is queued rather than discarded', () async { final repo = CountingTransactionsRepository(transactions: []); @@ -212,10 +212,10 @@ void main() { ); final load1 = notifier.loadTransactions(); - final load2 = notifier.loadTransactions(); + final load2 = notifier.loadTransactions(isBackgroundRefresh: true); await Future.wait([load1, load2]); - expect(repo.loadCount, 1); + expect(repo.loadCount, 2); }, ); @@ -325,6 +325,7 @@ void main() { ]); container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(wallet1); final walletAId = container.read(activeWalletIdProvider); keepControllerAlive(container, walletAId); @@ -333,12 +334,10 @@ void main() { .loadTransactions(); final initialLoadCount = repo.loadCount; - container.read(activeWalletProvider.notifier).set(wallet1); - await container - .read(transactionsControllerProvider(walletAId).notifier) - .loadTransactions(isBackgroundRefresh: true); + container.read(activeWalletProvider.notifier).set(wallet2); + await container.pump(); - expect(repo.loadCount, greaterThan(initialLoadCount)); + expect(repo.loadCount, equals(initialLoadCount + 1)); }, ); From 36c8a827eee533eaacf932ca722f970ad8afcdd0 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Mon, 10 Aug 2026 08:17:07 +0100 Subject: [PATCH 16/19] fix(demo): address transaction history review feedback --- bdk_demo/lib/features/send/send_page.dart | 2 + .../transactions/transaction_detail_page.dart | 15 ++ .../transaction_history_mapper.dart | 4 +- .../transactions/transactions_controller.dart | 77 +++++--- .../transactions/transactions_list_page.dart | 3 +- .../transactions/transactions_repository.dart | 22 ++- .../wallet_setup/active_wallets_page.dart | 2 +- .../wallet_setup/create_wallet_page.dart | 2 +- .../wallet_setup/recover_wallet_page.dart | 2 +- bdk_demo/lib/providers/address_providers.dart | 4 +- .../lib/providers/blockchain_providers.dart | 4 +- bdk_demo/lib/providers/wallet_providers.dart | 27 ++- .../transaction_history_mapper_test.dart | 2 +- .../transactions_controller_test.dart | 167 ++++++++++++++++-- .../transactions_repository_test.dart | 45 ++++- .../fakes/fake_transactions_repository.dart | 3 + .../test/presentation/send_page_test.dart | 75 +++++++- .../transaction_detail_page_test.dart | 44 +++++ .../transactions_list_page_test.dart | 86 ++++++++- 19 files changed, 521 insertions(+), 65 deletions(-) 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/transaction_detail_page.dart b/bdk_demo/lib/features/transactions/transaction_detail_page.dart index 23cb4e6..ac84d20 100644 --- a/bdk_demo/lib/features/transactions/transaction_detail_page.dart +++ b/bdk_demo/lib/features/transactions/transaction_detail_page.dart @@ -30,6 +30,21 @@ class TransactionDetailPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); 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)), ); diff --git a/bdk_demo/lib/features/transactions/transaction_history_mapper.dart b/bdk_demo/lib/features/transactions/transaction_history_mapper.dart index 668c3e0..a20c1e6 100644 --- a/bdk_demo/lib/features/transactions/transaction_history_mapper.dart +++ b/bdk_demo/lib/features/transactions/transaction_history_mapper.dart @@ -15,9 +15,7 @@ class ConfirmedTransactionPosition extends TransactionHistoryPosition { } class UnconfirmedTransactionPosition extends TransactionHistoryPosition { - final int? timestamp; - - const UnconfirmedTransactionPosition({this.timestamp}); + const UnconfirmedTransactionPosition(); } class TransactionHistoryMapper { diff --git a/bdk_demo/lib/features/transactions/transactions_controller.dart b/bdk_demo/lib/features/transactions/transactions_controller.dart index a39cafa..325bd99 100644 --- a/bdk_demo/lib/features/transactions/transactions_controller.dart +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -48,12 +48,25 @@ final transactionsControllerProvider = NotifierProvider.autoDispose TransactionsController.new, ); +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); }); @@ -67,35 +80,29 @@ class TransactionsController extends Notifier { @override 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); + 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 (walletId == null) { - state = const TransactionsState( - status: TransactionsLoadState.noWallet, - transactions: [], - statusMessage: - 'Create or load a wallet before viewing transaction history.', - ); + if (!_isWalletAvailable(ref.read(transactionsRepositoryProvider))) { + state = _noWalletState; return; } @@ -132,13 +139,20 @@ class TransactionsController extends Notifier { } try { - final transactions = await ref - .read(transactionsRepositoryProvider) - .loadTransactions(); + 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, @@ -152,6 +166,10 @@ class TransactionsController extends Notifier { if (!ref.mounted) { return; } + if (!_isWalletAvailable(ref.read(transactionsRepositoryProvider))) { + state = _noWalletState; + return; + } if (isBackgroundRefresh && state.status == TransactionsLoadState.success) { @@ -173,4 +191,17 @@ class TransactionsController extends Notifier { 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 3488b84..109c3d1 100644 --- a/bdk_demo/lib/features/transactions/transactions_list_page.dart +++ b/bdk_demo/lib/features/transactions/transactions_list_page.dart @@ -27,10 +27,11 @@ class TransactionsListPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); 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 = activeWalletId != null && !isLoading; + final canLoad = hasActiveWallet && !isLoading; return Scaffold( appBar: const SecondaryAppBar(title: 'Transaction History'), diff --git a/bdk_demo/lib/features/transactions/transactions_repository.dart b/bdk_demo/lib/features/transactions/transactions_repository.dart index d327c09..0f7b1eb 100644 --- a/bdk_demo/lib/features/transactions/transactions_repository.dart +++ b/bdk_demo/lib/features/transactions/transactions_repository.dart @@ -5,14 +5,16 @@ import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; abstract interface class TransactionsRepository { + bool isAvailableForWallet(String? walletId); Future> loadTransactions(); Future loadTransactionByTxid(String txid); } final transactionsRepositoryProvider = Provider((ref) { - final wallet = ref.watch(activeWalletProvider); + final binding = ref.watch(activeWalletBindingProvider); return WalletTransactionsRepository( - source: wallet == null ? null : BdkWalletTransactionSource(wallet), + walletId: binding?.walletId, + source: binding == null ? null : BdkWalletTransactionSource(binding.wallet), ); }); @@ -37,11 +39,19 @@ class TransactionHistoryRecord { } class WalletTransactionsRepository implements TransactionsRepository { - WalletTransactionsRepository({required TransactionHistorySource? source}) - : _source = source; + 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 { final source = _source; @@ -141,9 +151,7 @@ class BdkWalletTransactionSource implements TransactionHistorySource { confirmationTime: position.confirmationBlockTime.confirmationTime, ); } else if (position is bdk.UnconfirmedChainPosition) { - mappedPosition = UnconfirmedTransactionPosition( - timestamp: position.timestamp, - ); + mappedPosition = const UnconfirmedTransactionPosition(); } else { throw StateError('Unsupported transaction chain position: $position'); } 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 a875a1c..43054e3 100644 --- a/bdk_demo/lib/providers/wallet_providers.dart +++ b/bdk_demo/lib/providers/wallet_providers.dart @@ -36,13 +36,26 @@ final activeWalletProvider = NotifierProvider( ActiveWalletNotifier.new, ); -final hasActiveWalletProvider = Provider((ref) { - return ref.watch(activeWalletProvider) != null; +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; @@ -53,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 index 461321c..a5d4fc2 100644 --- a/bdk_demo/test/features/transactions/transaction_history_mapper_test.dart +++ b/bdk_demo/test/features/transactions/transaction_history_mapper_test.dart @@ -36,7 +36,7 @@ void main() { 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', sent: 1600, received: 0, - position: const UnconfirmedTransactionPosition(timestamp: 1704164640), + position: const UnconfirmedTransactionPosition(), ); expect(item.sent, 1600); diff --git a/bdk_demo/test/features/transactions/transactions_controller_test.dart b/bdk_demo/test/features/transactions/transactions_controller_test.dart index 53f2871..267afed 100644 --- a/bdk_demo/test/features/transactions/transactions_controller_test.dart +++ b/bdk_demo/test/features/transactions/transactions_controller_test.dart @@ -23,6 +23,9 @@ class CountingTransactionsRepository implements TransactionsRepository { CountingTransactionsRepository({required this.transactions, this.error}); + @override + bool isAvailableForWallet(String? walletId) => walletId != null; + @override Future> loadTransactions() async { loadCount++; @@ -46,6 +49,9 @@ class DelayedTransactionsRepository implements TransactionsRepository { DelayedTransactionsRepository(this.delayedResult); + @override + bool isAvailableForWallet(String? walletId) => walletId != null; + @override Future> loadTransactions() async { return delayedResult; @@ -80,8 +86,19 @@ void main() { ); } - ProviderContainer createContainer(List overrides) { - final container = ProviderContainer(overrides: overrides); + 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; } @@ -235,9 +252,10 @@ void main() { 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); @@ -258,6 +276,7 @@ void main() { ); container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(FakeWallet()); final walletBId = container.read(activeWalletIdProvider); keepControllerAlive(container, walletBId); @@ -279,9 +298,10 @@ void main() { 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), @@ -293,6 +313,7 @@ void main() { .loadTransactions(); container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(FakeWallet()); final walletBId = container.read(activeWalletIdProvider); keepControllerAlive(container, walletBId); walletASubscription.close(); @@ -316,13 +337,19 @@ void main() { final wallet1 = FakeWallet(); final wallet2 = FakeWallet(); - final repo = CountingTransactionsRepository( + final repo1 = CountingTransactionsRepository( transactions: [createTx('tx-a', 10000)], ); + final repo2 = CountingTransactionsRepository( + transactions: [createTx('tx-b', 20000)], + ); final container = createContainer([ - transactionsRepositoryProvider.overrideWithValue(repo), - ]); + 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); @@ -332,12 +359,130 @@ void main() { await container .read(transactionsControllerProvider(walletAId).notifier) .loadTransactions(); - final initialLoadCount = repo.loadCount; + 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(); - expect(repo.loadCount, equals(initialLoadCount + 1)); + final walletAState = container.read( + transactionsControllerProvider(recordA.id), + ); + expect(walletAState.status, TransactionsLoadState.noWallet); + expect(walletAState.transactions, isEmpty); }, ); @@ -357,9 +502,10 @@ void main() { 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(( @@ -370,6 +516,7 @@ void main() { expect(detailA?.netAmount, 10000); container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(FakeWallet()); final detailB = await container.read( transactionDetailsProvider(( diff --git a/bdk_demo/test/features/transactions/transactions_repository_test.dart b/bdk_demo/test/features/transactions/transactions_repository_test.dart index 08186aa..8c74e00 100644 --- a/bdk_demo/test/features/transactions/transactions_repository_test.dart +++ b/bdk_demo/test/features/transactions/transactions_repository_test.dart @@ -1,7 +1,16 @@ +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); @@ -21,8 +30,40 @@ class _FakeTransactionHistorySource implements TransactionHistorySource { 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(source: null); + final repository = WalletTransactionsRepository( + walletId: null, + source: null, + ); final transactions = await repository.loadTransactions(); @@ -31,6 +72,7 @@ void main() { test('maps wallet transaction records into history items', () async { final repository = WalletTransactionsRepository( + walletId: 'wallet-a', source: _FakeTransactionHistorySource([ const TransactionHistoryRecord( txid: @@ -65,6 +107,7 @@ void main() { test('loads a transaction detail by txid from wallet records', () async { final repository = WalletTransactionsRepository( + walletId: 'wallet-a', source: _FakeTransactionHistorySource([ const TransactionHistoryRecord( txid: diff --git a/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart b/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart index 30da3ed..6aa1ac4 100644 --- a/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart +++ b/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart @@ -10,6 +10,9 @@ class FakeTransactionsRepository implements TransactionsRepository { final List transactions; final bool throwOnLoad; + @override + bool isAvailableForWallet(String? walletId) => walletId != null; + @override Future> loadTransactions() async { if (throwOnLoad) { 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 b5a87b8..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,3 +1,4 @@ +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'; @@ -10,6 +11,11 @@ import 'package:flutter_test/flutter_test.dart'; import '../../helpers/fakes/fake_transactions_repository.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, @@ -34,6 +40,9 @@ Future _pumpDetailPage( overrides: [ transactionsRepositoryProvider.overrideWithValue(repository), activeWalletIdProvider.overrideWithValue('wallet-a'), + activeWalletBindingProvider.overrideWithValue( + ActiveWalletBinding(walletId: 'wallet-a', wallet: FakeWallet()), + ), ], child: MaterialApp( home: TransactionDetailPage( @@ -123,6 +132,39 @@ void main() { 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 { @@ -178,6 +220,7 @@ void main() { // 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( @@ -193,6 +236,7 @@ void main() { // 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 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 94ac185..e8472df 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -24,6 +24,9 @@ class DelayedTransactionsRepository implements TransactionsRepository { DelayedTransactionsRepository(this.delayedResult); + @override + bool isAvailableForWallet(String? walletId) => walletId != null; + @override Future> loadTransactions() async { return delayedResult; @@ -44,6 +47,9 @@ class MutableTransactionsRepository implements TransactionsRepository { MutableTransactionsRepository(this.transactions); + @override + bool isAvailableForWallet(String? walletId) => walletId != null; + @override Future> loadTransactions() async { return transactions; @@ -102,6 +108,14 @@ Future _pumpTransactionsFlow( activeWalletIdProvider.overrideWithValue( hasActiveWallet ? 'wallet-a' : null, ), + activeWalletBindingProvider.overrideWithValue( + hasActiveWallet + ? ActiveWalletBinding( + walletId: 'wallet-a', + wallet: FakeWallet(), + ) + : null, + ), ], child: MaterialApp.router(routerConfig: router), ), @@ -160,6 +174,9 @@ void main() { FakeTransactionsRepository(transactions: transactionHistoryItems), ), activeWalletIdProvider.overrideWithValue('wallet-a'), + activeWalletBindingProvider.overrideWithValue( + ActiveWalletBinding(walletId: 'wallet-a', wallet: FakeWallet()), + ), ], child: MaterialApp.router(routerConfig: router), ), @@ -238,11 +255,52 @@ void main() { findsOneWidget, ); - final buttonFinder = find.widgetWithText( - FilledButton, - 'Load Transaction History', + final buttonFinder = find.ancestor( + of: find.text('Load Transaction History'), + matching: find.byWidgetPredicate( + (widget) => widget is ButtonStyleButton, + ), ); - expect(tester.widget(buttonFinder).onPressed, isNull); + 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); }, ); @@ -286,20 +344,22 @@ void main() { confirmationTime: DateTime.now(), ), ]; + final walletBTransactions = Completer>(); container = ProviderContainer( overrides: [ transactionsRepositoryProvider.overrideWith((ref) { final activeId = ref.watch(activeWalletIdProvider); - return FakeTransactionsRepository( - transactions: activeId == 'wallet-a' ? txsA : txsB, - ); + 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, @@ -313,11 +373,17 @@ void main() { // Switch logical active wallet ID from A to B container.read(activeWalletRecordProvider.notifier).set(recordB); - await tester.pumpAndSettle(); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + await tester.pump(); - // Verify A's rows are gone, and B's rows loaded automatically without build-time exceptions + // 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); }, @@ -424,6 +490,7 @@ void main() { addTearDown(container.dispose); container.read(activeWalletRecordProvider.notifier).set(recordA); + container.read(activeWalletProvider.notifier).set(FakeWallet()); await _pumpTransactionsFlow( tester, @@ -437,6 +504,7 @@ void main() { // Switch active wallet to B container.read(activeWalletRecordProvider.notifier).set(recordB); + container.read(activeWalletProvider.notifier).set(FakeWallet()); await tester.pump(); // Complete A's future From 3d441b4aa383700a5fdc198c2c0446820699fd08 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Thu, 27 Aug 2026 11:23:22 +0100 Subject: [PATCH 17/19] fix(demo): address transaction history follow-up --- .../transactions/transactions_list_page.dart | 132 ++++++------------ .../transactions/transactions_repository.dart | 30 ++-- .../transactions_repository_test.dart | 129 +++++++++++++++++ .../transactions_list_page_test.dart | 61 +++++--- 4 files changed, 229 insertions(+), 123 deletions(-) diff --git a/bdk_demo/lib/features/transactions/transactions_list_page.dart b/bdk_demo/lib/features/transactions/transactions_list_page.dart index 109c3d1..4856c34 100644 --- a/bdk_demo/lib/features/transactions/transactions_list_page.dart +++ b/bdk_demo/lib/features/transactions/transactions_list_page.dart @@ -25,13 +25,9 @@ class TransactionsListPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final theme = Theme.of(context); 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: 'Transaction History'), @@ -39,67 +35,6 @@ class TransactionsListPage extends ConsumerWidget { child: ListView( padding: const EdgeInsets.all(24), children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: theme.colorScheme.primaryContainer, - ), - child: Icon( - Icons.receipt_long_outlined, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(height: 16), - Text( - 'Transaction History', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - '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: canLoad - ? () => ref - .read(controllerProvider.notifier) - .loadTransactions() - : null, - icon: isLoading - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.onPrimary, - ), - ) - : const Icon(Icons.download_rounded), - label: Text( - state.status == TransactionsLoadState.success || - state.status == TransactionsLoadState.error - ? 'Reload Transaction History' - : 'Load Transaction History', - ), - ), - ], - ), - ), - ), - const SizedBox(height: 24), const _SectionHeading( title: 'Transactions', subtitle: 'Active wallet transaction list and detail navigation', @@ -147,36 +82,49 @@ class _TransactionsBody extends StatelessWidget { message: state.errorMessage ?? state.statusMessage, accentColor: theme.colorScheme.error, ), - TransactionsLoadState.success => - state.transactions.isEmpty - ? const WalletStateCard( - icon: Icons.history_toggle_off, - title: 'No transactions yet', - message: - 'The active wallet has no transactions yet. Sync the wallet or receive funds to populate history.', - ) - : Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - for ( - var index = 0; - index < state.transactions.length; - index++ - ) ...[ - _TransactionRow( - transaction: state.transactions[index], - onTap: () => - onTap(context, state.transactions[index]), - ), - if (index < state.transactions.length - 1) - const SizedBox(height: 12), - ], + TransactionsLoadState.success => Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (state.errorMessage != null) ...[ + WalletStateCard( + icon: Icons.sync_problem_outlined, + title: 'Transaction history may be out of date', + message: state.errorMessage!, + accentColor: theme.colorScheme.error, + ), + const SizedBox(height: 12), + ], + if (state.transactions.isEmpty) + const WalletStateCard( + icon: Icons.history_toggle_off, + title: 'No transactions yet', + message: + 'The active wallet has no transactions yet. Sync the wallet or receive funds to populate history.', + ) + else + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + for ( + var index = 0; + index < state.transactions.length; + index++ + ) ...[ + _TransactionRow( + transaction: state.transactions[index], + onTap: () => onTap(context, state.transactions[index]), + ), + if (index < state.transactions.length - 1) + const SizedBox(height: 12), ], - ), + ], ), ), + ), + ], + ), }; } } diff --git a/bdk_demo/lib/features/transactions/transactions_repository.dart b/bdk_demo/lib/features/transactions/transactions_repository.dart index 0f7b1eb..05219e9 100644 --- a/bdk_demo/lib/features/transactions/transactions_repository.dart +++ b/bdk_demo/lib/features/transactions/transactions_repository.dart @@ -1,3 +1,5 @@ +import 'dart:isolate'; + 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'; @@ -57,7 +59,8 @@ class WalletTransactionsRepository implements TransactionsRepository { final source = _source; if (source == null) return const []; - return source.transactions().map(_mapRecord).toList(growable: false); + final records = await Isolate.run(source.transactions); + return records.map(_mapRecord).toList(growable: false); } @override @@ -65,8 +68,12 @@ class WalletTransactionsRepository implements TransactionsRepository { final source = _source; if (source == null) return null; - final record = source.transactionByTxid(txid); - return record == null ? null : _mapRecord(record); + final directRecord = source.transactionByTxid(txid); + if (directRecord != null) return _mapRecord(directRecord); + + final records = await Isolate.run(source.transactions); + final fallbackRecord = _findTransactionByTxid(records, txid); + return fallbackRecord == null ? null : _mapRecord(fallbackRecord); } TransactionHistoryItem _mapRecord(TransactionHistoryRecord record) { @@ -107,20 +114,13 @@ class BdkWalletTransactionSource implements TransactionHistorySource { @override TransactionHistoryRecord? transactionByTxid(String txid) { + final parsedTxid = bdk.Txid.fromString(hex: 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. + final canonicalTx = _wallet.getTx(txid: parsedTxid); + return canonicalTx == null ? null : _recordFromCanonicalTx(canonicalTx); + } finally { + parsedTxid.dispose(); } - - return _findTransactionByTxid(transactions(), txid); } TransactionHistoryRecord _recordFromCanonicalTx(bdk.CanonicalTx canonicalTx) { diff --git a/bdk_demo/test/features/transactions/transactions_repository_test.dart b/bdk_demo/test/features/transactions/transactions_repository_test.dart index 8c74e00..dfef311 100644 --- a/bdk_demo/test/features/transactions/transactions_repository_test.dart +++ b/bdk_demo/test/features/transactions/transactions_repository_test.dart @@ -1,3 +1,5 @@ +import 'dart:isolate'; + 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'; @@ -6,11 +8,30 @@ import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +const _testExtendedPrivKey = + 'tprv8ZgxMBicQKsPf2qfrEygW6fdYseJDDrVnDv26PH5BHdvSuG6ecCbHqLVof9yZcMoM31z9ur3tTYbSnr1WBqbGX97CbXcmp5H6qeMpyvx35B'; + class _FakeWallet extends Fake implements bdk.Wallet { @override void dispose() {} } +class _LookupWallet extends Fake implements bdk.Wallet { + _LookupWallet({this.getTxError}); + + final Object? getTxError; + + @override + bdk.CanonicalTx? getTx({required bdk.Txid txid}) { + final error = getTxError; + if (error != null) throw error; + return null; + } + + @override + List transactions() => const []; +} + class _FakeTransactionHistorySource implements TransactionHistorySource { _FakeTransactionHistorySource(this.records); @@ -28,6 +49,22 @@ class _FakeTransactionHistorySource implements TransactionHistorySource { } } +class _IsolateRecordingTransactionHistorySource + implements TransactionHistorySource { + @override + List transactions() => [ + TransactionHistoryRecord( + txid: Isolate.current.debugName ?? 'unnamed-isolate', + sent: 0, + received: 1, + position: const UnconfirmedTransactionPosition(), + ), + ]; + + @override + TransactionHistoryRecord? transactionByTxid(String txid) => null; +} + void main() { group('WalletTransactionsRepository', () { test('binds the production repository to the loaded wallet ID', () { @@ -129,5 +166,97 @@ void main() { expect(transaction, isNotNull); expect(transaction!.received, 42000); }); + + test('runs full transaction scans outside the UI isolate', () async { + final repository = WalletTransactionsRepository( + walletId: 'wallet-a', + source: _IsolateRecordingTransactionHistorySource(), + ); + final uiIsolateName = Isolate.current.debugName; + + final transactions = await repository.loadTransactions(); + + expect(transactions.single.txid, isNot(uiIsolateName)); + }); + + test('scans a real BDK wallet from the background isolate', () async { + final descriptor = bdk.Descriptor( + descriptor: 'wpkh($_testExtendedPrivKey/84h/1h/0h/0/*)', + networkKind: bdk.NetworkKind.test, + ); + final changeDescriptor = bdk.Descriptor( + descriptor: 'wpkh($_testExtendedPrivKey/84h/1h/0h/1/*)', + networkKind: bdk.NetworkKind.test, + ); + final persister = bdk.Persister.newInMemory(); + final wallet = bdk.Wallet( + descriptor: descriptor, + changeDescriptor: changeDescriptor, + network: bdk.Network.testnet, + persister: persister, + lookahead: 25, + ); + addTearDown(() { + wallet.dispose(); + persister.dispose(); + descriptor.dispose(); + changeDescriptor.dispose(); + }); + final repository = WalletTransactionsRepository( + walletId: 'wallet-a', + source: BdkWalletTransactionSource(wallet), + ); + + final transactions = await repository.loadTransactions(); + + expect(transactions, isEmpty); + }); + + test( + 'falls back to a full scan only when direct lookup returns null', + () async { + const expectedTxid = + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd'; + final repository = WalletTransactionsRepository( + walletId: 'wallet-a', + source: _FakeTransactionHistorySource([ + const TransactionHistoryRecord( + txid: expectedTxid, + sent: 0, + received: 42000, + position: UnconfirmedTransactionPosition(), + ), + ]), + ); + + final transaction = await repository.loadTransactionByTxid( + expectedTxid, + ); + + expect(transaction?.txid, expectedTxid); + }, + ); + }); + + group('BdkWalletTransactionSource', () { + test('surfaces invalid txid errors', () { + final source = BdkWalletTransactionSource(_LookupWallet()); + + expect( + () => source.transactionByTxid('not-a-txid'), + throwsA(isA()), + ); + }); + + test('surfaces FFI lookup failures', () { + final source = BdkWalletTransactionSource( + _LookupWallet(getTxError: StateError('FFI lookup failed')), + ); + + expect( + () => source.transactionByTxid('0' * 64), + throwsA(isA()), + ); + }); }); } 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 e8472df..ff77e39 100644 --- a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -2,6 +2,7 @@ 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_controller.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'; @@ -44,6 +45,7 @@ class DelayedTransactionsRepository implements TransactionsRepository { class MutableTransactionsRepository implements TransactionsRepository { List transactions; + Object? error; MutableTransactionsRepository(this.transactions); @@ -52,6 +54,8 @@ class MutableTransactionsRepository implements TransactionsRepository { @override Future> loadTransactions() async { + final currentError = error; + if (currentError != null) throw currentError; return transactions; } @@ -145,6 +149,9 @@ void main() { expect(find.text('abcdef...7890'), findsOneWidget); expect(find.text('confirmed'), findsOneWidget); expect(find.text('pending'), findsOneWidget); + expect(find.text('Transaction History'), findsOneWidget); + expect(find.text('Load Transaction History'), findsNothing); + expect(find.text('Reload Transaction History'), findsNothing); }); testWidgets( @@ -239,7 +246,7 @@ void main() { }); testWidgets( - 'no active wallet shows the no-wallet state and disables load button', + 'no active wallet shows the no-wallet state without a load button', (tester) async { await _pumpTransactionsFlow( tester, @@ -255,18 +262,13 @@ void main() { findsOneWidget, ); - final buttonFinder = find.ancestor( - of: find.text('Load Transaction History'), - matching: find.byWidgetPredicate( - (widget) => widget is ButtonStyleButton, - ), - ); - expect(tester.widget(buttonFinder).onPressed, isNull); + expect(find.text('Load Transaction History'), findsNothing); + expect(find.text('Reload Transaction History'), findsNothing); }, ); testWidgets( - 'wallet record without an FFI wallet shows no-wallet state and disables loading', + 'wallet record without an FFI wallet shows no-wallet state without a load button', (tester) async { final container = ProviderContainer( overrides: [ @@ -294,16 +296,43 @@ void main() { ); 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); + expect(find.text('Load Transaction History'), findsNothing); + expect(find.text('Reload Transaction History'), findsNothing); }, ); + testWidgets('shows a warning when a background refresh fails', ( + tester, + ) async { + final repo = MutableTransactionsRepository(transactionHistoryItems); + final container = ProviderContainer( + overrides: [transactionsRepositoryProvider.overrideWithValue(repo)], + ); + addTearDown(container.dispose); + container + .read(activeWalletRecordProvider.notifier) + .set( + const WalletRecord( + id: 'wallet-a', + name: 'Wallet A', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ), + ); + container.read(activeWalletProvider.notifier).set(FakeWallet()); + + await _pumpTransactionsFlow(tester, repository: repo, container: container); + repo.error = Exception('Refresh failed'); + await container + .read(transactionsControllerProvider('wallet-a').notifier) + .loadTransactions(isBackgroundRefresh: true); + await tester.pump(); + + expect(find.text('Transaction history may be out of date'), findsOneWidget); + expect(find.text('Refresh failed'), findsOneWidget); + expect(find.text('+42000 sat'), findsOneWidget); + }); + testWidgets( 'switching logical active wallet ID from A to B clears A\'s transaction list and loads B\'s automatically', (tester) async { From aace3a5ed8fc66060c0a0bbf9a7037265bfebc83 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Mon, 31 Aug 2026 11:59:35 +0100 Subject: [PATCH 18/19] style(demo): remove redundant section heading from transaction history list --- .../transactions/transactions_list_page.dart | 36 ------------------- 1 file changed, 36 deletions(-) diff --git a/bdk_demo/lib/features/transactions/transactions_list_page.dart b/bdk_demo/lib/features/transactions/transactions_list_page.dart index 4856c34..61c0234 100644 --- a/bdk_demo/lib/features/transactions/transactions_list_page.dart +++ b/bdk_demo/lib/features/transactions/transactions_list_page.dart @@ -35,11 +35,6 @@ class TransactionsListPage extends ConsumerWidget { child: ListView( padding: const EdgeInsets.all(24), children: [ - const _SectionHeading( - title: 'Transactions', - subtitle: 'Active wallet transaction list and detail navigation', - ), - const SizedBox(height: 12), _TransactionsBody(state: state, onTap: _openTransactionDetail), ], ), @@ -129,37 +124,6 @@ class _TransactionsBody extends StatelessWidget { } } -class _SectionHeading extends StatelessWidget { - final String title; - final String subtitle; - - const _SectionHeading({required this.title, required this.subtitle}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(170), - ), - ), - ], - ); - } -} - class _TransactionRow extends StatelessWidget { final TransactionHistoryItem transaction; final VoidCallback onTap; From 0cb3375c9759d27366c68e198859d5c13a43296b Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Mon, 31 Aug 2026 12:22:54 +0100 Subject: [PATCH 19/19] style(demo): remove redundant subtitle text from transaction detail header --- .../lib/features/transactions/transaction_detail_page.dart | 7 ------- 1 file changed, 7 deletions(-) diff --git a/bdk_demo/lib/features/transactions/transaction_detail_page.dart b/bdk_demo/lib/features/transactions/transaction_detail_page.dart index ac84d20..58c0849 100644 --- a/bdk_demo/lib/features/transactions/transaction_detail_page.dart +++ b/bdk_demo/lib/features/transactions/transaction_detail_page.dart @@ -100,13 +100,6 @@ class TransactionDetailPage extends ConsumerWidget { WalletStatusChip(status: transaction.statusLabel), ], ), - const SizedBox(height: 8), - Text( - 'Transaction detail for the selected wallet transaction.', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(170), - ), - ), ], ), ),