From 71d07adbe21f8c93d4ce86b3d4420d358dac57bf Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Tue, 30 Jun 2026 13:42:28 +0100 Subject: [PATCH 1/7] 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 2/7] 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 3/7] 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 4/7] 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 5/7] 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 6/7] 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 7/7] 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);