From 71d07adbe21f8c93d4ce86b3d4420d358dac57bf Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 13:42:28 +0100 Subject: [PATCH 01/17] 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 3ae8a7d8a20c3f1f501afe55b622c5b88f78ca79 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 14:00:33 +0100 Subject: [PATCH 02/17] 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 45141adaae94e2c43f34c4b32f6220bb6864303a Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 14:02:48 +0100 Subject: [PATCH 03/17] 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 8b46b1e4ea15416faf848526f51a170087f9dcff Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 14:06:18 +0100 Subject: [PATCH 04/17] 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 7cad683bcbbe46567ee6a33cded7aa974bfd43df Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 14:44:03 +0100 Subject: [PATCH 05/17] 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 db69a39c6cf86ff584cc0b963a6180c5d35d402b Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 15:04:22 +0100 Subject: [PATCH 06/17] 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 af67b6ab564e95f351072ce30ee05d8950303523 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 15:15:29 +0100 Subject: [PATCH 07/17] 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 184bbfcefac6cde3b72c36bd191d36a720bf1441 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 14 Jul 2026 02:16:16 +0100 Subject: [PATCH 08/17] 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 e4c458ce58c748b464d8bd558f1a8ce990b790df Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 14 Jul 2026 12:53:42 +0100 Subject: [PATCH 09/17] 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 969da23888d984a81010bdbf9fa9c35e5dc04396 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Sat, 18 Jul 2026 14:36:33 +0100 Subject: [PATCH 10/17] 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 e9b32f0ba93072abab089d4095084375107a5e5a Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 21 Jul 2026 16:12:41 +0100 Subject: [PATCH 11/17] 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 1958c9dcd916112cf25cda456e2ecb6b7065d406 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 21 Jul 2026 18:48:58 +0100 Subject: [PATCH 12/17] 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 54114a7583ab573d8fe3fa60997c7d40f4b8bbd4 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Wed, 22 Jul 2026 07:33:04 +0100 Subject: [PATCH 13/17] 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 aa1fe5a9cf5843545e031c6e95bd5db678474b88 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Wed, 22 Jul 2026 07:52:38 +0100 Subject: [PATCH 14/17] ci: trigger fresh build on all platforms From 270a415c86276619f7cfce0d1ecf7c2a5c4dcf80 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Sat, 25 Jul 2026 05:10:51 +0100 Subject: [PATCH 15/17] 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 fdba83385075639e64bb82d443f988c376da83e5 Mon Sep 17 00:00:00 2001 From: Shamsudeen Adedokun Date: Sun, 23 Aug 2026 12:05:43 +0100 Subject: [PATCH 16/17] fix(demo): keep back stack when navigating home after wallet creation (#122) --- bdk_demo/lib/features/wallet_setup/create_wallet_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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..9614828 100644 --- a/bdk_demo/lib/features/wallet_setup/create_wallet_page.dart +++ b/bdk_demo/lib/features/wallet_setup/create_wallet_page.dart @@ -61,7 +61,7 @@ class _CreateWalletPageState extends ConsumerState { ref.read(walletRecordsProvider.notifier).refresh(); _showSnackBar('Wallet created'); - context.go(AppRoutes.home); + context.replace(AppRoutes.home); } on ArgumentError { if (!mounted) return; _showSnackBar('Invalid wallet name'); From 2a2a5f7c55ba5da5c14466a40b7a5e2d402a465b Mon Sep 17 00:00:00 2001 From: Shamsudeen Adedokun Date: Sun, 23 Aug 2026 12:05:53 +0100 Subject: [PATCH 17/17] feat: show spendable balance as Home hero and add pending activity card Home previously displayed wallet.balance().total as the hero figure, so an unconfirmed incoming transaction inflated the balance while Trusted spendable stayed at 0. - Drive the hero balance from trustedSpendableSat; show a "Total incl. pending" subtitle only when total differs from spendable - Add a pending activity card below Send/Receive while the balance snapshot has trusted/untrusted pending funds: summed pending amount, up to three pending transaction rows opening Transaction Detail, and a View all link into Transaction History - Keep card visibility snapshot-driven so it clears as soon as a post-confirmation sync applies the fresh balance - Add a pendingSat getter on WalletBalanceSnapshot and a @visibleForTesting applySnapshot seam on BalanceSnapshotNotifier - Cover untrusted-pending-only, no-pending, clear-after-sync, row-cap, and unit-toggle cases in home_page_test Closes #119 --- bdk_demo/lib/features/home/home_page.dart | 207 ++++++++++++++++-- .../lib/models/wallet_balance_snapshot.dart | 2 + .../lib/providers/blockchain_providers.dart | 3 + .../test/presentation/home_page_test.dart | 177 ++++++++++++++- 4 files changed, 371 insertions(+), 18 deletions(-) diff --git a/bdk_demo/lib/features/home/home_page.dart b/bdk_demo/lib/features/home/home_page.dart index 3d2a487..ee6b13b 100644 --- a/bdk_demo/lib/features/home/home_page.dart +++ b/bdk_demo/lib/features/home/home_page.dart @@ -1,9 +1,12 @@ import 'package:bdk_demo/core/constants/app_constants.dart'; import 'package:bdk_demo/core/router/app_router.dart'; +import 'package:bdk_demo/core/theme/app_theme.dart'; import 'package:bdk_demo/core/utils/formatters.dart'; import 'package:bdk_demo/features/home/network_endpoint_bottom_sheet.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/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/models/wallet_balance_snapshot.dart'; import 'package:bdk_demo/models/wallet_record.dart'; @@ -58,6 +61,9 @@ class _HomePageState extends ConsumerState { final syncProgress = ref.watch(syncProgressProvider); final isOnline = ref.watch(isOnlineProvider); + final guardedSnapshot = _matchingSnapshot(snapshot, record?.id); + final pendingSat = guardedSnapshot?.pendingSat ?? 0; + return Scaffold( appBar: const SecondaryAppBar(title: 'Home'), body: SafeArea( @@ -78,7 +84,7 @@ class _HomePageState extends ConsumerState { _WalletHeader(record: record), const SizedBox(height: 16), _BalanceCard( - snapshot: _matchingSnapshot(snapshot, record.id), + snapshot: guardedSnapshot, syncStatus: syncStatus, currencyUnit: _currencyUnit, onToggleUnit: () { @@ -97,6 +103,14 @@ class _HomePageState extends ConsumerState { ], const SizedBox(height: 16), _ActionRow(isOnline: isOnline), + if (pendingSat > 0) ...[ + const SizedBox(height: 16), + _PendingActivityCard( + pendingSat: pendingSat, + walletId: record.id, + currencyUnit: _currencyUnit, + ), + ], ], ), ), @@ -150,9 +164,9 @@ class _HomePageState extends ConsumerState { WalletBalanceSnapshot? _matchingSnapshot( WalletBalanceSnapshot? snapshot, - String walletId, + String? walletId, ) { - if (snapshot?.walletId != walletId) return null; + if (walletId == null || snapshot?.walletId != walletId) return null; return snapshot; } @@ -227,17 +241,23 @@ class _BalanceCard extends StatelessWidget { final CurrencyUnit currencyUnit; final VoidCallback onToggleUnit; + String? get _subtitle { + final snapshot = this.snapshot; + if (snapshot == null) { + return syncStatus == SyncStatus.syncing + ? 'Syncing wallet...' + : 'Balance will update after sync.'; + } + if (snapshot.totalSat == snapshot.trustedSpendableSat) return null; + return 'Total incl. pending: ' + '${Formatters.formatBalance(snapshot.totalSat, currencyUnit)}'; + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); - final totalSat = snapshot?.totalSat ?? 0; final trustedSpendableSat = snapshot?.trustedSpendableSat ?? 0; - final hasSnapshot = snapshot != null; - final subtitle = hasSnapshot - ? 'Trusted spendable: ${Formatters.formatBalance(trustedSpendableSat, currencyUnit)}' - : syncStatus == SyncStatus.syncing - ? 'Syncing wallet...' - : 'Balance will update after sync.'; + final subtitle = _subtitle; return Card( child: InkWell( @@ -269,18 +289,20 @@ class _BalanceCard extends StatelessWidget { ), const SizedBox(height: 12), Text( - Formatters.formatBalance(totalSat, currencyUnit), + Formatters.formatBalance(trustedSpendableSat, currencyUnit), style: theme.textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w800, ), ), - const SizedBox(height: 8), - Text( - subtitle, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(170), + if (subtitle != null) ...[ + const SizedBox(height: 8), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), ), - ), + ], const SizedBox(height: 12), Text( 'Tap balance to toggle units', @@ -600,3 +622,154 @@ class _ActionRow extends StatelessWidget { ); } } + +class _PendingActivityCard extends ConsumerWidget { + const _PendingActivityCard({ + required this.pendingSat, + required this.walletId, + required this.currencyUnit, + }); + + final int pendingSat; + final String walletId; + final CurrencyUnit currencyUnit; + + static const _maxRows = 3; + + void _openTransactionDetail( + BuildContext context, + TransactionHistoryItem transaction, + ) { + context.pushNamed( + 'transactionDetail', + pathParameters: {'txid': transaction.txid}, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final state = ref.watch(transactionsControllerProvider(walletId)); + final pendingTxs = state.transactions + .where((transaction) => transaction.pending) + .toList(growable: false); + final visibleTxs = pendingTxs.take(_maxRows).toList(growable: false); + + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + 'Pending', + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + ), + Text( + Formatters.formatBalance(pendingSat, currencyUnit), + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.secondary, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Awaiting confirmation', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + if (visibleTxs.isNotEmpty) ...[ + const SizedBox(height: 16), + for (var index = 0; index < visibleTxs.length; index++) ...[ + _PendingTransactionRow( + transaction: visibleTxs[index], + onTap: () => + _openTransactionDetail(context, visibleTxs[index]), + ), + if (index < visibleTxs.length - 1) const SizedBox(height: 12), + ], + ], + if (pendingTxs.length > _maxRows) ...[ + const SizedBox(height: 8), + TextButton( + onPressed: () => context.push(AppRoutes.transactionHistory), + child: Text('View all (${pendingTxs.length})'), + ), + ], + ], + ), + ), + ); + } +} + +class _PendingTransactionRow extends StatelessWidget { + const _PendingTransactionRow({ + required this.transaction, + required this.onTap, + }); + + final TransactionHistoryItem transaction; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final amount = transaction.netAmount; + final amountLabel = + '${amount >= 0 ? '+' : '-'}${Formatters.formatBalance(amount.abs(), CurrencyUnit.satoshi)}'; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: Ink( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + amountLabel, + style: theme.textTheme.titleSmall?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.secondary, + ), + ), + const SizedBox(height: 4), + Text( + transaction.shortTxid, + style: AppTheme.monoStyle.copyWith( + fontSize: 13, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + WalletStatusChip(status: transaction.statusLabel), + ], + ), + ), + ), + ); + } +} diff --git a/bdk_demo/lib/models/wallet_balance_snapshot.dart b/bdk_demo/lib/models/wallet_balance_snapshot.dart index 564efbf..2b04474 100644 --- a/bdk_demo/lib/models/wallet_balance_snapshot.dart +++ b/bdk_demo/lib/models/wallet_balance_snapshot.dart @@ -16,4 +16,6 @@ class WalletBalanceSnapshot { final int confirmedSat; final int trustedSpendableSat; final int totalSat; + + int get pendingSat => trustedPendingSat + untrustedPendingSat; } diff --git a/bdk_demo/lib/providers/blockchain_providers.dart b/bdk_demo/lib/providers/blockchain_providers.dart index f69957e..b8681c0 100644 --- a/bdk_demo/lib/providers/blockchain_providers.dart +++ b/bdk_demo/lib/providers/blockchain_providers.dart @@ -161,6 +161,9 @@ class BalanceSnapshotNotifier extends Notifier { void clear() => state = null; + @visibleForTesting + void applySnapshot(WalletBalanceSnapshot snapshot) => state = snapshot; + void applyFromWallet(Wallet wallet, String walletId) { final b = wallet.balance(); state = WalletBalanceSnapshot( diff --git a/bdk_demo/test/presentation/home_page_test.dart b/bdk_demo/test/presentation/home_page_test.dart index 60d90c8..044a04c 100644 --- a/bdk_demo/test/presentation/home_page_test.dart +++ b/bdk_demo/test/presentation/home_page_test.dart @@ -3,6 +3,9 @@ import 'dart:io'; import 'package:bdk_dart/bdk.dart'; import 'package:bdk_demo/core/utils/wallet_storage_paths.dart'; import 'package:bdk_demo/features/home/home_page.dart'; +import 'package:bdk_demo/features/transactions/models/transaction_history_item.dart'; +import 'package:bdk_demo/features/transactions/transactions_repository.dart'; +import 'package:bdk_demo/models/wallet_balance_snapshot.dart'; import 'package:bdk_demo/models/wallet_record.dart'; import 'package:bdk_demo/providers/blockchain_providers.dart'; import 'package:bdk_demo/providers/connectivity_provider.dart'; @@ -20,6 +23,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:uuid/uuid.dart'; +import '../helpers/fakes/fake_transactions_repository.dart'; + const _testExtendedPrivKey = 'tprv8ZgxMBicQKsPf2qfrEygW6fdYseJDDrVnDv26PH5BHdvSuG6ecCbHqLVof9yZcMoM31z9ur3tTYbSnr1WBqbGX97CbXcmp5H6qeMpyvx35B'; @@ -48,6 +53,19 @@ Future _noopSyncRunner(WalletSyncRequest request) async { ); } +TransactionHistoryItem _pendingTx({ + required String txid, + int sent = 0, + int received = 0, +}) { + return TransactionHistoryItem( + txid: txid, + sent: sent, + received: received, + pending: true, + ); +} + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -177,6 +195,29 @@ void main() { .applyFromWallet(wallet, walletId); } + void seedSnapshot( + ProviderContainer container, { + required String walletId, + int confirmed = 0, + int trustedPending = 0, + int untrustedPending = 0, + int immature = 0, + }) { + container + .read(balanceSnapshotProvider.notifier) + .applySnapshot( + WalletBalanceSnapshot( + walletId: walletId, + immatureSat: immature, + trustedPendingSat: trustedPending, + untrustedPendingSat: untrustedPending, + confirmedSat: confirmed, + trustedSpendableSat: confirmed + trustedPending, + totalSat: confirmed + trustedPending + untrustedPending + immature, + ), + ); + } + test('fake sync runner is invoked by SyncController', () async { var syncCalls = 0; final container = await createContainer( @@ -195,7 +236,7 @@ void main() { expect(syncCalls, 1); }); - testWidgets('renders total balance in BTC mode and toggles to sats', ( + testWidgets('renders spendable balance in BTC mode and toggles to sats', ( tester, ) async { final container = await createContainer(); @@ -705,6 +746,140 @@ void main() { expect(syncCalls, 0); }); + + testWidgets('keeps hero at zero and shows pending card for ' + 'untrusted-pending-only snapshot', (tester) async { + final container = await createContainer( + overrides: [ + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository( + transactions: [ + _pendingTx(txid: 'feedfacefeedfacefeedface0001', received: 25000), + ], + ), + ), + ], + ); + final (record, _) = await seedActiveWallet(container); + seedSnapshot(container, walletId: record.id, untrustedPending: 25000); + + await pumpHomePage(tester, container); + await tester.pump(); + + expect(find.text('0.00000000'), findsOneWidget); + expect(find.text('Total incl. pending: 0.00025000'), findsOneWidget); + expect(find.text('Pending'), findsOneWidget); + expect(find.text('0.00025000'), findsOneWidget); + expect(find.text('Awaiting confirmation'), findsOneWidget); + expect(find.text('+25000 sat'), findsOneWidget); + expect(find.text('pending'), findsOneWidget); + }); + + testWidgets('hides pending card when snapshot has no pending funds', ( + tester, + ) async { + final container = await createContainer(); + final (record, _) = await seedActiveWallet(container); + seedSnapshot(container, walletId: record.id, confirmed: 50000); + + await pumpHomePage(tester, container); + + expect(find.text('0.00050000'), findsOneWidget); + expect(find.text('Pending'), findsNothing); + expect(find.text('Awaiting confirmation'), findsNothing); + expect(find.textContaining('Total incl. pending'), findsNothing); + }); + + testWidgets('caps pending rows at three with a view-all link', ( + tester, + ) async { + final container = await createContainer( + overrides: [ + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository( + transactions: [ + _pendingTx(txid: 'feedfacefeedfacefeedface0001', received: 1000), + _pendingTx(txid: 'feedfacefeedfacefeedface0002', received: 2000), + _pendingTx(txid: 'feedfacefeedfacefeedface0003', received: 3000), + _pendingTx(txid: 'feedfacefeedfacefeedface0004', received: 4000), + ], + ), + ), + ], + ); + final (record, _) = await seedActiveWallet(container); + seedSnapshot(container, walletId: record.id, untrustedPending: 10000); + + await pumpHomePage(tester, container); + await tester.pump(); + + expect(find.text('+1000 sat'), findsOneWidget); + expect(find.text('+2000 sat'), findsOneWidget); + expect(find.text('+3000 sat'), findsOneWidget); + expect(find.text('+4000 sat'), findsNothing); + expect(find.text('View all (4)'), findsOneWidget); + }); + + testWidgets('pending card clears after confirmation and sync', ( + tester, + ) async { + final container = await createContainer( + overrides: [ + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository( + transactions: [ + _pendingTx(txid: 'feedfacefeedfacefeedface0001', received: 25000), + ], + ), + ), + ], + ); + final (record, _) = await seedActiveWallet(container); + seedSnapshot(container, walletId: record.id, untrustedPending: 25000); + + await pumpHomePage(tester, container); + await tester.pump(); + + expect(find.text('Pending'), findsOneWidget); + + container.read(activeWalletProvider.notifier).set(_createTestWallet()); + seedSnapshot(container, walletId: record.id, confirmed: 25000); + await tester.pump(); + await tester.pump(); + + expect(find.text('Pending'), findsNothing); + expect(find.text('0.00025000'), findsOneWidget); + }); + + testWidgets('pending header follows the currency unit toggle', ( + tester, + ) async { + final container = await createContainer( + overrides: [ + transactionsRepositoryProvider.overrideWithValue( + FakeTransactionsRepository( + transactions: [ + _pendingTx(txid: 'feedfacefeedfacefeedface0001', received: 25000), + ], + ), + ), + ], + ); + final (record, _) = await seedActiveWallet(container); + seedSnapshot(container, walletId: record.id, untrustedPending: 25000); + + await pumpHomePage(tester, container); + await tester.pump(); + + expect(find.text('0.00025000'), findsOneWidget); + + await tester.tap(find.text('0.00000000')); + await tester.pump(); + + expect(find.text('0 sat'), findsOneWidget); + expect(find.text('25000 sat'), findsOneWidget); + expect(find.text('+25000 sat'), findsOneWidget); + }); } class _HomePageTestWalletService extends WalletService {