From d4e68872767314a21d1728158409f3e77ce6a8ef Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Mon, 15 Jun 2026 18:22:35 +0100 Subject: [PATCH 1/6] docs: design demo receive page --- .../specs/2026-06-15-receive-page-design.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-15-receive-page-design.md diff --git a/docs/superpowers/specs/2026-06-15-receive-page-design.md b/docs/superpowers/specs/2026-06-15-receive-page-design.md new file mode 100644 index 0000000..8deb245 --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-receive-page-design.md @@ -0,0 +1,86 @@ +# Receive Page Design + +## Context + +The Flutter demo is a reference application for showing how Flutter developers can use `bdk_dart`. Address generation and persistence were added in #78 and PR #79, but the `/receive` route still displays a placeholder. Issue #83 tracks the focused UI follow-up. + +## Goal + +Add a small Receive page that demonstrates the complete receive-address flow: request the next persisted external address, display it as text and a QR code, show its derivation index, and let the user copy it. + +## Scope + +- Replace the `/receive` placeholder with `ReceivePage`. +- Read `currentReceiveAddressProvider` for the active wallet. +- Trigger generation through `generateForActiveWallet()`. +- Display loading, initial, success, and error states. +- Render the generated address with `PrettyQrView.data`. +- Display the external derivation index. +- Copy the address through the existing `ClipboardUtil.copyAndNotify` helper. +- Add route and widget tests. + +The feature will not add BIP21 amount or label fields, address sharing, new persistence behavior, or send/broadcast changes. + +## User Flow + +1. The user opens Receive from the wallet home page. +2. If no address has been generated for the active wallet, the page explains the action and presents a `Generate address` button. +3. While generation is running, the action is disabled and a progress indicator is shown. +4. On success, the page shows the QR code, full address, derivation index, and a copy action. +5. The user may request another address with a clearly labeled `Generate new address` action. +6. On failure, the page keeps any previously successful address visible when provider state supplies one, shows a concise error message, and allows retrying. +7. If there is no active wallet, the page shows a safe empty state and does not attempt generation. + +## Architecture + +### ReceivePage + +`ReceivePage` will be a `ConsumerWidget` in `bdk_demo/lib/features/receive/receive_page.dart`. It owns presentation only and watches: + +- `activeWalletRecordProvider` to determine whether a wallet is active and show its network context. +- `currentReceiveAddressProvider` for address, index, loading, and error state. + +Button actions call `currentReceiveAddressProvider.notifier.generateForActiveWallet()`. The page does not call `WalletService` directly and does not duplicate persistence logic. + +### Routing + +The existing `/receive` route will construct `ReceivePage` instead of `PlaceholderPage`. No route names or navigation paths change. + +### QR And Clipboard + +The existing `pretty_qr_code` dependency will render the raw Bitcoin address. BIP21 encoding is intentionally excluded. The existing clipboard utility will provide the copy operation and snackbar confirmation. + +## State Presentation + +- **No active wallet:** Informational wallet-empty state with no generation action. +- **Initial:** Explanation plus `Generate address` action. +- **Loading:** Progress indicator and disabled generation action. +- **Success:** QR code, selectable address text, derivation index, copy action, and `Generate new address` action. +- **Error without address:** Error text and retry action. +- **Error with previous address:** Keep the QR/address visible, show the error separately, and retain retry capability. + +Provider error strings may contain exception prefixes. The page will present them without adding new domain-level error mapping in this PR. + +## Testing + +Widget tests will cover: + +- Initial state with an active wallet. +- Successful address presentation, including QR payload and derivation index. +- Loading state and disabled duplicate action. +- Error state and retry action. +- Copy action and confirmation snackbar. +- No-active-wallet state. + +Router tests will verify `/receive` resolves to `ReceivePage` rather than `PlaceholderPage`. + +The implementation is complete when `flutter analyze` and the full `flutter test` suite pass in `bdk_demo`. + +## File Impact + +- Add `bdk_demo/lib/features/receive/receive_page.dart`. +- Add `bdk_demo/test/presentation/receive_page_test.dart`. +- Update `bdk_demo/lib/core/router/app_router.dart`. +- Update `bdk_demo/test/presentation/router_wiring_test.dart`. + +No service, generated binding, native, CI, send-feature, or platform-runner files are part of this change. From 215df730c34be9af6bf0fd078733a20d4fff4801 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Mon, 15 Jun 2026 18:41:21 +0100 Subject: [PATCH 2/6] docs: plan demo receive page --- .../plans/2026-06-15-receive-page.md | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-15-receive-page.md diff --git a/docs/superpowers/plans/2026-06-15-receive-page.md b/docs/superpowers/plans/2026-06-15-receive-page.md new file mode 100644 index 0000000..03d24c8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-receive-page.md @@ -0,0 +1,352 @@ +# Receive Page 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's `/receive` placeholder with a tested Receive page that generates, displays, QR-encodes, and copies the active wallet's persisted receive address. + +**Architecture:** Add one presentation-only `ConsumerWidget` that watches the existing active-wallet and receive-address providers. Keep address generation and persistence in the existing notifier/service, and use provider overrides in widget tests so UI behavior is deterministic and isolated. + +**Tech Stack:** Flutter, Dart, Riverpod 3, GoRouter, `pretty_qr_code`, `flutter_test` + +--- + +## File Structure + +- Create `bdk_demo/lib/features/receive/receive_page.dart`: Receive UI and state rendering. +- Create `bdk_demo/test/presentation/receive_page_test.dart`: Widget coverage using a fake receive notifier. +- Modify `bdk_demo/lib/core/router/app_router.dart`: Replace the Receive placeholder route. +- Modify `bdk_demo/test/presentation/router_wiring_test.dart`: Verify `/receive` resolves to `ReceivePage`. + +### Task 1: Receive Page States And Actions + +**Files:** +- Create: `bdk_demo/test/presentation/receive_page_test.dart` +- Create: `bdk_demo/lib/features/receive/receive_page.dart` + +- [ ] **Step 1: Write the failing widget tests** + +Create a fake notifier that exposes a chosen `ReceiveAddressState` and records generation calls: + +```dart +class _FakeReceiveAddressNotifier extends CurrentReceiveAddressNotifier { + _FakeReceiveAddressNotifier(this.initialState); + + final ReceiveAddressState initialState; + var generationCalls = 0; + + @override + ReceiveAddressState build() => initialState; + + @override + Future generateForActiveWallet() async { + generationCalls += 1; + } +} +``` + +Pump `ReceivePage` inside `ProviderScope` with overrides for `currentReceiveAddressProvider` and `activeWalletRecordProvider`. Add tests that assert: + +```dart +testWidgets('shows generate action for an active wallet', (tester) async { + final notifier = _FakeReceiveAddressNotifier(ReceiveAddressState.empty); + await pumpReceivePage(tester, notifier: notifier, activeWallet: testRecord); + + expect(find.text('Generate address'), findsOneWidget); + await tester.tap(find.text('Generate address')); + await tester.pump(); + expect(notifier.generationCalls, 1); +}); + +testWidgets('shows QR, address, index, and new-address action', (tester) async { + final notifier = _FakeReceiveAddressNotifier( + const ReceiveAddressState( + walletId: 'wallet-1', + address: testAddress, + index: 7, + ), + ); + await pumpReceivePage(tester, notifier: notifier, activeWallet: testRecord); + + expect(find.byKey(const Key('receive-address-qr')), findsOneWidget); + expect(find.text(testAddress), findsOneWidget); + expect(find.text('External index 7'), findsOneWidget); + expect(find.text('Generate new address'), findsOneWidget); +}); + +testWidgets('shows loading and disables generation', (tester) async { + final notifier = _FakeReceiveAddressNotifier( + const ReceiveAddressState(walletId: 'wallet-1', isGenerating: true), + ); + await pumpReceivePage(tester, notifier: notifier, activeWallet: testRecord); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(tester.widget(find.byType(FilledButton)).onPressed, isNull); +}); + +testWidgets('shows provider error and retry action', (tester) async { + final notifier = _FakeReceiveAddressNotifier( + const ReceiveAddressState( + walletId: 'wallet-1', + errorMessage: 'StateError: generation failed', + ), + ); + await pumpReceivePage(tester, notifier: notifier, activeWallet: testRecord); + + expect(find.textContaining('generation failed'), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); +}); + +testWidgets('shows safe state without an active wallet', (tester) async { + final notifier = _FakeReceiveAddressNotifier(ReceiveAddressState.empty); + await pumpReceivePage(tester, notifier: notifier); + + expect(find.text('No active wallet'), findsOneWidget); + expect(find.text('Generate address'), findsNothing); +}); +``` + +Add a clipboard test by installing a mock handler for `SystemChannels.platform`, tapping the copy button, asserting the `Clipboard.setData` payload equals `testAddress`, and checking for the `Address copied` snackbar. + +- [ ] **Step 2: Run the new tests and verify they fail** + +Run: + +```bash +cd bdk_demo +flutter test test/presentation/receive_page_test.dart +``` + +Expected: FAIL because `ReceivePage` does not exist. + +- [ ] **Step 3: Implement the minimal Receive page** + +Create `ReceivePage` as a `ConsumerWidget` with: + +```dart +class ReceivePage extends ConsumerWidget { + const ReceivePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final record = ref.watch(activeWalletRecordProvider); + final receiveState = ref.watch(currentReceiveAddressProvider); + + return Scaffold( + appBar: const SecondaryAppBar(title: 'Receive'), + body: SafeArea( + child: record == null + ? const WalletStateCard( + icon: Icons.account_balance_wallet_outlined, + title: 'No active wallet', + message: 'Load a wallet before generating a receive address.', + centered: true, + ) + : ListView( + padding: const EdgeInsets.all(24), + children: [ + Text('Receive on ${record.network.displayName}'), + const SizedBox(height: 16), + if (receiveState.address case final address?) + _ReceiveAddressCard( + address: address, + index: receiveState.index, + ) + else + WalletStateCard( + icon: Icons.qr_code_2, + title: receiveState.isGenerating + ? 'Generating address' + : 'Ready to receive', + message: receiveState.isGenerating + ? 'Revealing and saving the next external address.' + : 'Generate a fresh external address for this wallet.', + showSpinner: receiveState.isGenerating, + ), + if (receiveState.errorMessage case final error?) ...[ + const SizedBox(height: 12), + Text(error, key: const Key('receive-error')), + ], + const SizedBox(height: 20), + FilledButton.icon( + onPressed: receiveState.isGenerating + ? null + : () => ref + .read(currentReceiveAddressProvider.notifier) + .generateForActiveWallet(), + icon: const Icon(Icons.add_location_alt_outlined), + label: Text( + receiveState.address != null + ? 'Generate new address' + : receiveState.errorMessage != null + ? 'Try again' + : 'Generate address', + ), + ), + ], + ), + ), + ); + } +} +``` + +Implement `_ReceiveAddressCard` in the same file using `PrettyQrView.data(data: address, key: const Key('receive-address-qr'))`, `SelectableText(address)`, `External index $index`, and an `OutlinedButton.icon` that calls: + +```dart +ClipboardUtil.copyAndNotify( + context, + address, + message: 'Address copied', +); +``` + +- [ ] **Step 4: Format and run the focused tests** + +Run: + +```bash +dart format lib/features/receive/receive_page.dart test/presentation/receive_page_test.dart +flutter test test/presentation/receive_page_test.dart +``` + +Expected: all Receive page tests pass. + +- [ ] **Step 5: Commit the Receive page** + +```bash +git add bdk_demo/lib/features/receive/receive_page.dart bdk_demo/test/presentation/receive_page_test.dart +git commit -m "feat(demo): add receive address page" +``` + +### Task 2: Route Wiring + +**Files:** +- Modify: `bdk_demo/lib/core/router/app_router.dart` +- Modify: `bdk_demo/test/presentation/router_wiring_test.dart` + +- [ ] **Step 1: Write the failing route test** + +Import `ReceivePage` and add: + +```dart +testWidgets('/receive resolves to ReceivePage', (tester) async { + await pumpRouterAt( + tester, + AppRoutes.receive, + seedActiveWallet: true, + ); + + expect(find.byType(ReceivePage), findsOneWidget); + expect(find.byType(PlaceholderPage), findsNothing); +}); +``` + +- [ ] **Step 2: Run the route test and verify it fails** + +Run: + +```bash +cd bdk_demo +flutter test test/presentation/router_wiring_test.dart --plain-name '/receive resolves to ReceivePage' +``` + +Expected: FAIL because `/receive` still builds `PlaceholderPage`. + +- [ ] **Step 3: Replace the placeholder route** + +Import the page and update the route builder: + +```dart +import 'package:bdk_demo/features/receive/receive_page.dart'; + +GoRoute( + path: AppRoutes.receive, + name: 'receive', + builder: (context, state) => const ReceivePage(), +), +``` + +- [ ] **Step 4: Format and run routing tests** + +Run: + +```bash +dart format lib/core/router/app_router.dart test/presentation/router_wiring_test.dart +flutter test test/presentation/router_wiring_test.dart +``` + +Expected: all router wiring tests pass. + +- [ ] **Step 5: Commit route wiring** + +```bash +git add bdk_demo/lib/core/router/app_router.dart bdk_demo/test/presentation/router_wiring_test.dart +git commit -m "feat(demo): route receive screen" +``` + +### Task 3: Verification And Draft PR + +**Files:** +- Verify all files changed by Tasks 1-2. + +- [ ] **Step 1: Run formatting checks** + +```bash +cd bdk_demo +dart format --output=none --set-exit-if-changed lib test +``` + +Expected: exit code 0 with no changed files. + +- [ ] **Step 2: Run static analysis** + +```bash +flutter analyze +``` + +Expected: `No issues found!` + +- [ ] **Step 3: Run the complete demo test suite** + +```bash +flutter test +``` + +Expected: all tests pass. + +- [ ] **Step 4: Inspect final scope** + +```bash +git diff upstream/main...HEAD --stat +git diff --check upstream/main...HEAD +git status --short --branch +``` + +Expected: only the design/plan, Receive page, Receive page tests, router, and router tests are changed; the worktree is clean. + +- [ ] **Step 5: Push and open a draft PR** + +```bash +git push -u origin feat/receive-page +gh pr create \ + --repo bitcoindevkit/bdk-dart \ + --head j-kon:feat/receive-page \ + --base main \ + --draft \ + --title "bdk_demo: add receive address screen" \ + --body "## Summary + +- replace the Receive placeholder with a focused receive-address screen +- render the generated address as QR and selectable text +- add copy, loading, empty, error, and retry states +- cover the page and route with widget tests + +Closes #83 + +## Testing + +- flutter analyze +- flutter test" +``` + +Expected: GitHub returns a new PR URL and the PR is marked Draft. From 5d27e3459313e38a958b58237b2c492c659cfe92 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Mon, 15 Jun 2026 18:55:22 +0100 Subject: [PATCH 3/6] feat(demo): add receive address page --- .../lib/features/receive/receive_page.dart | 182 ++++++++++++++++++ .../test/presentation/receive_page_test.dart | 181 +++++++++++++++++ 2 files changed, 363 insertions(+) create mode 100644 bdk_demo/lib/features/receive/receive_page.dart create mode 100644 bdk_demo/test/presentation/receive_page_test.dart diff --git a/bdk_demo/lib/features/receive/receive_page.dart b/bdk_demo/lib/features/receive/receive_page.dart new file mode 100644 index 0000000..c1ca583 --- /dev/null +++ b/bdk_demo/lib/features/receive/receive_page.dart @@ -0,0 +1,182 @@ +import 'package:bdk_demo/core/theme/app_theme.dart'; +import 'package:bdk_demo/core/utils/clipboard_util.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/providers/address_providers.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:pretty_qr_code/pretty_qr_code.dart'; + +class ReceivePage extends ConsumerWidget { + const ReceivePage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final record = ref.watch(activeWalletRecordProvider); + final receiveState = ref.watch(currentReceiveAddressProvider); + + return Scaffold( + appBar: const SecondaryAppBar(title: 'Receive'), + body: SafeArea( + child: record == null + ? const WalletStateCard( + icon: Icons.account_balance_wallet_outlined, + title: 'No active wallet', + message: 'Load a wallet before generating a receive address.', + centered: true, + ) + : ListView( + padding: const EdgeInsets.all(24), + children: [ + Text( + 'Receive on ${record.network.displayName}', + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 6), + Text( + record.name, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: 20), + if (receiveState.address case final address?) + _ReceiveAddressCard( + address: address, + index: receiveState.index, + ) + else + WalletStateCard( + icon: Icons.qr_code_2, + title: receiveState.isGenerating + ? 'Generating address' + : 'Ready to receive', + message: receiveState.isGenerating + ? 'Revealing and saving the next external address.' + : 'Generate a fresh external address for this wallet.', + showSpinner: receiveState.isGenerating, + ), + if (receiveState.errorMessage case final error?) ...[ + const SizedBox(height: 12), + _ReceiveError(message: error), + ], + const SizedBox(height: 20), + FilledButton.icon( + onPressed: receiveState.isGenerating + ? null + : () => ref + .read(currentReceiveAddressProvider.notifier) + .generateForActiveWallet(), + icon: const Icon(Icons.add_location_alt_outlined), + label: Text( + receiveState.address != null + ? 'Generate new address' + : receiveState.errorMessage != null + ? 'Try again' + : 'Generate address', + ), + ), + ], + ), + ), + ); + } +} + +class _ReceiveAddressCard extends StatelessWidget { + const _ReceiveAddressCard({required this.address, required this.index}); + + final String address; + final int? index; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Container( + width: 240, + height: 240, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: PrettyQrView.data( + key: const Key('receive-address-qr'), + data: address, + ), + ), + const SizedBox(height: 20), + SelectableText( + address, + textAlign: TextAlign.center, + style: AppTheme.monoStyle.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + if (index case final addressIndex?) ...[ + const SizedBox(height: 10), + Text( + 'External index $addressIndex', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + ], + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => ClipboardUtil.copyAndNotify( + context, + address, + message: 'Address copied', + ), + icon: const Icon(Icons.copy_outlined), + label: const Text('Copy address'), + ), + ], + ), + ), + ); + } +} + +class _ReceiveError extends StatelessWidget { + const _ReceiveError({required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + key: const Key('receive-error'), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, color: theme.colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ); + } +} diff --git a/bdk_demo/test/presentation/receive_page_test.dart b/bdk_demo/test/presentation/receive_page_test.dart new file mode 100644 index 0000000..bbed2b2 --- /dev/null +++ b/bdk_demo/test/presentation/receive_page_test.dart @@ -0,0 +1,181 @@ +import 'package:bdk_demo/features/receive/receive_page.dart'; +import 'package:bdk_demo/models/wallet_record.dart'; +import 'package:bdk_demo/providers/address_providers.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _testAddress = 'tb1qfm5n6w9u7r8ct3q3c2eqcdshw8f8hy5sjzlx6t'; + +const _testRecord = WalletRecord( + id: 'wallet-1', + name: 'Receive Wallet', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, +); + +class _FakeReceiveAddressNotifier extends CurrentReceiveAddressNotifier { + _FakeReceiveAddressNotifier(this.initialState); + + final ReceiveAddressState initialState; + var generationCalls = 0; + + @override + ReceiveAddressState build() => initialState; + + @override + Future generateForActiveWallet() async { + generationCalls += 1; + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Future pumpReceivePage( + WidgetTester tester, { + required _FakeReceiveAddressNotifier notifier, + WalletRecord? activeWallet, + }) async { + final container = ProviderContainer( + overrides: [currentReceiveAddressProvider.overrideWith(() => notifier)], + ); + addTearDown(container.dispose); + + if (activeWallet != null) { + container.read(activeWalletRecordProvider.notifier).set(activeWallet); + } + + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: const MaterialApp(home: ReceivePage()), + ), + ); + await tester.pump(); + return container; + } + + testWidgets('shows generate action for an active wallet', (tester) async { + final notifier = _FakeReceiveAddressNotifier(ReceiveAddressState.empty); + await pumpReceivePage( + tester, + notifier: notifier, + activeWallet: _testRecord, + ); + + expect(find.text('Generate address'), findsOneWidget); + + await tester.tap(find.text('Generate address')); + await tester.pump(); + + expect(notifier.generationCalls, 1); + }); + + testWidgets('shows QR, address, index, and new-address action', ( + tester, + ) async { + final notifier = _FakeReceiveAddressNotifier( + const ReceiveAddressState( + walletId: 'wallet-1', + address: _testAddress, + index: 7, + ), + ); + await pumpReceivePage( + tester, + notifier: notifier, + activeWallet: _testRecord, + ); + + expect(find.byKey(const Key('receive-address-qr')), findsOneWidget); + expect(find.text(_testAddress), findsOneWidget); + expect(find.text('External index 7'), findsOneWidget); + + await tester.drag(find.byType(ListView), const Offset(0, -320)); + await tester.pumpAndSettle(); + + expect(find.text('Generate new address'), findsOneWidget); + }); + + testWidgets('shows loading and disables generation', (tester) async { + final notifier = _FakeReceiveAddressNotifier( + const ReceiveAddressState(walletId: 'wallet-1', isGenerating: true), + ); + await pumpReceivePage( + tester, + notifier: notifier, + activeWallet: _testRecord, + ); + + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect( + tester.widget(find.byType(FilledButton)).onPressed, + isNull, + ); + }); + + testWidgets('shows provider error and retry action', (tester) async { + final notifier = _FakeReceiveAddressNotifier( + const ReceiveAddressState( + walletId: 'wallet-1', + errorMessage: 'StateError: generation failed', + ), + ); + await pumpReceivePage( + tester, + notifier: notifier, + activeWallet: _testRecord, + ); + + expect(find.textContaining('generation failed'), findsOneWidget); + expect(find.text('Try again'), findsOneWidget); + }); + + testWidgets('copies the address and confirms the action', (tester) async { + final clipboardCalls = []; + tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'Clipboard.setData') clipboardCalls.add(call); + return null; + }, + ); + addTearDown( + () => tester.binding.defaultBinaryMessenger.setMockMethodCallHandler( + SystemChannels.platform, + null, + ), + ); + + final notifier = _FakeReceiveAddressNotifier( + const ReceiveAddressState( + walletId: 'wallet-1', + address: _testAddress, + index: 0, + ), + ); + await pumpReceivePage( + tester, + notifier: notifier, + activeWallet: _testRecord, + ); + + await tester.tap(find.text('Copy address')); + await tester.pump(); + + expect(clipboardCalls, hasLength(1)); + expect(clipboardCalls.single.arguments, {'text': _testAddress}); + expect(find.text('Address copied'), findsOneWidget); + }); + + testWidgets('shows safe state without an active wallet', (tester) async { + final notifier = _FakeReceiveAddressNotifier(ReceiveAddressState.empty); + await pumpReceivePage(tester, notifier: notifier); + + expect(find.text('No active wallet'), findsOneWidget); + expect(find.text('Generate address'), findsNothing); + }); +} From 3d9c9665a1367a146383cddd533fe8f3a49ad974 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Mon, 15 Jun 2026 18:57:40 +0100 Subject: [PATCH 4/6] feat(demo): route receive screen --- bdk_demo/lib/core/router/app_router.dart | 3 ++- bdk_demo/test/presentation/router_wiring_test.dart | 8 ++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/bdk_demo/lib/core/router/app_router.dart b/bdk_demo/lib/core/router/app_router.dart index 3b4fbd9..3d37272 100644 --- a/bdk_demo/lib/core/router/app_router.dart +++ b/bdk_demo/lib/core/router/app_router.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/misc.dart'; import 'package:go_router/go_router.dart'; import 'package:bdk_demo/features/home/home_page.dart'; +import 'package:bdk_demo/features/receive/receive_page.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/shared/widgets/placeholder_page.dart'; @@ -71,7 +72,7 @@ GoRouter createRouter(RouterRead read) => GoRouter( GoRoute( path: AppRoutes.receive, name: 'receive', - builder: (context, state) => const PlaceholderPage(title: 'Receive'), + builder: (context, state) => const ReceivePage(), ), GoRoute( path: AppRoutes.send, diff --git a/bdk_demo/test/presentation/router_wiring_test.dart b/bdk_demo/test/presentation/router_wiring_test.dart index 206b820..26be0f5 100644 --- a/bdk_demo/test/presentation/router_wiring_test.dart +++ b/bdk_demo/test/presentation/router_wiring_test.dart @@ -1,6 +1,7 @@ import 'package:bdk_dart/bdk.dart'; import 'package:bdk_demo/core/router/app_router.dart'; import 'package:bdk_demo/features/home/home_page.dart'; +import 'package:bdk_demo/features/receive/receive_page.dart'; import 'package:bdk_demo/features/transactions/transactions_list_page.dart'; import 'package:bdk_demo/features/shared/widgets/placeholder_page.dart'; import 'package:bdk_demo/models/wallet_record.dart'; @@ -119,6 +120,13 @@ void main() { expect(find.byType(PlaceholderPage), findsNothing); }); + testWidgets('/receive resolves to ReceivePage', (tester) async { + await pumpRouterAt(tester, AppRoutes.receive, seedActiveWallet: true); + + expect(find.byType(ReceivePage), findsOneWidget); + expect(find.byType(PlaceholderPage), findsNothing); + }); + testWidgets('/send redirects to HomePage when offline', (tester) async { await pumpRouterAt( tester, From ed0f0f80cdf91a46fbc639b6a99e1b532d9e9d85 Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Mon, 15 Jun 2026 19:03:44 +0100 Subject: [PATCH 5/6] refactor(demo): split receive page widgets --- .../receive/receive_address_card.dart | 70 ++++++ .../features/receive/receive_error_panel.dart | 36 ++++ .../lib/features/receive/receive_page.dart | 200 +++++------------- 3 files changed, 159 insertions(+), 147 deletions(-) create mode 100644 bdk_demo/lib/features/receive/receive_address_card.dart create mode 100644 bdk_demo/lib/features/receive/receive_error_panel.dart diff --git a/bdk_demo/lib/features/receive/receive_address_card.dart b/bdk_demo/lib/features/receive/receive_address_card.dart new file mode 100644 index 0000000..d4a9046 --- /dev/null +++ b/bdk_demo/lib/features/receive/receive_address_card.dart @@ -0,0 +1,70 @@ +import 'package:bdk_demo/core/theme/app_theme.dart'; +import 'package:bdk_demo/core/utils/clipboard_util.dart'; +import 'package:flutter/material.dart'; +import 'package:pretty_qr_code/pretty_qr_code.dart'; + +class ReceiveAddressCard extends StatelessWidget { + const ReceiveAddressCard({ + super.key, + required this.address, + required this.index, + }); + + final String address; + final int? index; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + Container( + width: 240, + height: 240, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: PrettyQrView.data( + key: const Key('receive-address-qr'), + data: address, + ), + ), + const SizedBox(height: 20), + SelectableText( + address, + textAlign: TextAlign.center, + style: AppTheme.monoStyle.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + if (index case final addressIndex?) ...[ + const SizedBox(height: 10), + Text( + 'External index $addressIndex', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + ], + const SizedBox(height: 16), + OutlinedButton.icon( + onPressed: () => ClipboardUtil.copyAndNotify( + context, + address, + message: 'Address copied', + ), + icon: const Icon(Icons.copy_outlined), + label: const Text('Copy address'), + ), + ], + ), + ), + ); + } +} diff --git a/bdk_demo/lib/features/receive/receive_error_panel.dart b/bdk_demo/lib/features/receive/receive_error_panel.dart new file mode 100644 index 0000000..03baf81 --- /dev/null +++ b/bdk_demo/lib/features/receive/receive_error_panel.dart @@ -0,0 +1,36 @@ +import 'package:flutter/material.dart'; + +class ReceiveErrorPanel extends StatelessWidget { + const ReceiveErrorPanel({super.key, required this.message}); + + final String message; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + key: const Key('receive-error'), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, color: theme.colorScheme.error), + const SizedBox(width: 12), + Expanded( + child: Text( + message, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onErrorContainer, + ), + ), + ), + ], + ), + ); + } +} diff --git a/bdk_demo/lib/features/receive/receive_page.dart b/bdk_demo/lib/features/receive/receive_page.dart index c1ca583..87cf51e 100644 --- a/bdk_demo/lib/features/receive/receive_page.dart +++ b/bdk_demo/lib/features/receive/receive_page.dart @@ -1,12 +1,12 @@ -import 'package:bdk_demo/core/theme/app_theme.dart'; -import 'package:bdk_demo/core/utils/clipboard_util.dart'; +import 'package:bdk_demo/features/receive/receive_address_card.dart'; +import 'package:bdk_demo/features/receive/receive_error_panel.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/models/wallet_record.dart'; import 'package:bdk_demo/providers/address_providers.dart'; import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:pretty_qr_code/pretty_qr_code.dart'; class ReceivePage extends ConsumerWidget { const ReceivePage({super.key}); @@ -26,157 +26,63 @@ class ReceivePage extends ConsumerWidget { message: 'Load a wallet before generating a receive address.', centered: true, ) - : ListView( - padding: const EdgeInsets.all(24), - children: [ - Text( - 'Receive on ${record.network.displayName}', - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 6), - Text( - record.name, - style: Theme.of(context).textTheme.bodyMedium, - ), - const SizedBox(height: 20), - if (receiveState.address case final address?) - _ReceiveAddressCard( - address: address, - index: receiveState.index, - ) - else - WalletStateCard( - icon: Icons.qr_code_2, - title: receiveState.isGenerating - ? 'Generating address' - : 'Ready to receive', - message: receiveState.isGenerating - ? 'Revealing and saving the next external address.' - : 'Generate a fresh external address for this wallet.', - showSpinner: receiveState.isGenerating, - ), - if (receiveState.errorMessage case final error?) ...[ - const SizedBox(height: 12), - _ReceiveError(message: error), - ], - const SizedBox(height: 20), - FilledButton.icon( - onPressed: receiveState.isGenerating - ? null - : () => ref - .read(currentReceiveAddressProvider.notifier) - .generateForActiveWallet(), - icon: const Icon(Icons.add_location_alt_outlined), - label: Text( - receiveState.address != null - ? 'Generate new address' - : receiveState.errorMessage != null - ? 'Try again' - : 'Generate address', - ), - ), - ], - ), + : _buildWalletContent(context, ref, record, receiveState), ), ); } -} - -class _ReceiveAddressCard extends StatelessWidget { - const _ReceiveAddressCard({required this.address, required this.index}); - - final String address; - final int? index; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Card( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - Container( - width: 240, - height: 240, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - ), - child: PrettyQrView.data( - key: const Key('receive-address-qr'), - data: address, - ), - ), - const SizedBox(height: 20), - SelectableText( - address, - textAlign: TextAlign.center, - style: AppTheme.monoStyle.copyWith( - color: theme.colorScheme.onSurface, - ), - ), - if (index case final addressIndex?) ...[ - const SizedBox(height: 10), - Text( - 'External index $addressIndex', - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(170), - ), - ), - ], - const SizedBox(height: 16), - OutlinedButton.icon( - onPressed: () => ClipboardUtil.copyAndNotify( - context, - address, - message: 'Address copied', - ), - icon: const Icon(Icons.copy_outlined), - label: const Text('Copy address'), - ), - ], + Widget _buildWalletContent( + BuildContext context, + WidgetRef ref, + WalletRecord record, + ReceiveAddressState receiveState, + ) { + return ListView( + padding: const EdgeInsets.all(24), + children: [ + Text( + 'Receive on ${record.network.displayName}', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), ), - ), - ); - } -} - -class _ReceiveError extends StatelessWidget { - const _ReceiveError({required this.message}); - - final String message; - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Container( - key: const Key('receive-error'), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: theme.colorScheme.errorContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.error_outline, color: theme.colorScheme.error), - const SizedBox(width: 12), - Expanded( - child: Text( - message, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onErrorContainer, - ), - ), + const SizedBox(height: 6), + Text(record.name, style: Theme.of(context).textTheme.bodyMedium), + const SizedBox(height: 20), + if (receiveState.address case final address?) + ReceiveAddressCard(address: address, index: receiveState.index) + else + WalletStateCard( + icon: Icons.qr_code_2, + title: receiveState.isGenerating + ? 'Generating address' + : 'Ready to receive', + message: receiveState.isGenerating + ? 'Revealing and saving the next external address.' + : 'Generate a fresh external address for this wallet.', + showSpinner: receiveState.isGenerating, ), + if (receiveState.errorMessage case final error?) ...[ + const SizedBox(height: 12), + ReceiveErrorPanel(message: error), ], - ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: receiveState.isGenerating + ? null + : () => ref + .read(currentReceiveAddressProvider.notifier) + .generateForActiveWallet(), + icon: const Icon(Icons.add_location_alt_outlined), + label: Text( + receiveState.address != null + ? 'Generate new address' + : receiveState.errorMessage != null + ? 'Try again' + : 'Generate address', + ), + ), + ], ); } } From 21b77885af3b8a0009b86aa6457f6752d0d3af1f Mon Sep 17 00:00:00 2001 From: Jeremiah Jacob Date: Mon, 15 Jun 2026 19:04:59 +0100 Subject: [PATCH 6/6] docs: align receive page implementation notes --- docs/superpowers/plans/2026-06-15-receive-page.md | 2 ++ docs/superpowers/specs/2026-06-15-receive-page-design.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/docs/superpowers/plans/2026-06-15-receive-page.md b/docs/superpowers/plans/2026-06-15-receive-page.md index 03d24c8..e219d8b 100644 --- a/docs/superpowers/plans/2026-06-15-receive-page.md +++ b/docs/superpowers/plans/2026-06-15-receive-page.md @@ -13,6 +13,8 @@ ## File Structure - Create `bdk_demo/lib/features/receive/receive_page.dart`: Receive UI and state rendering. +- Create `bdk_demo/lib/features/receive/receive_address_card.dart`: QR, address, index, and copy presentation. +- Create `bdk_demo/lib/features/receive/receive_error_panel.dart`: Provider error presentation. - Create `bdk_demo/test/presentation/receive_page_test.dart`: Widget coverage using a fake receive notifier. - Modify `bdk_demo/lib/core/router/app_router.dart`: Replace the Receive placeholder route. - Modify `bdk_demo/test/presentation/router_wiring_test.dart`: Verify `/receive` resolves to `ReceivePage`. diff --git a/docs/superpowers/specs/2026-06-15-receive-page-design.md b/docs/superpowers/specs/2026-06-15-receive-page-design.md index 8deb245..7a5317a 100644 --- a/docs/superpowers/specs/2026-06-15-receive-page-design.md +++ b/docs/superpowers/specs/2026-06-15-receive-page-design.md @@ -79,6 +79,8 @@ The implementation is complete when `flutter analyze` and the full `flutter test ## File Impact - Add `bdk_demo/lib/features/receive/receive_page.dart`. +- Add `bdk_demo/lib/features/receive/receive_address_card.dart`. +- Add `bdk_demo/lib/features/receive/receive_error_panel.dart`. - Add `bdk_demo/test/presentation/receive_page_test.dart`. - Update `bdk_demo/lib/core/router/app_router.dart`. - Update `bdk_demo/test/presentation/router_wiring_test.dart`.