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/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 new file mode 100644 index 0000000..87cf51e --- /dev/null +++ b/bdk_demo/lib/features/receive/receive_page.dart @@ -0,0 +1,88 @@ +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'; + +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, + ) + : _buildWalletContent(context, ref, record, receiveState), + ), + ); + } + + 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), + ), + 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', + ), + ), + ], + ); + } +} 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); + }); +} 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, 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..e219d8b --- /dev/null +++ b/docs/superpowers/plans/2026-06-15-receive-page.md @@ -0,0 +1,354 @@ +# 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/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`. + +### 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. 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..7a5317a --- /dev/null +++ b/docs/superpowers/specs/2026-06-15-receive-page-design.md @@ -0,0 +1,88 @@ +# 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/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`. + +No service, generated binding, native, CI, send-feature, or platform-runner files are part of this change.