Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion bdk_demo/lib/core/router/app_router.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down
70 changes: 70 additions & 0 deletions bdk_demo/lib/features/receive/receive_address_card.dart
Original file line number Diff line number Diff line change
@@ -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'),
),
],
),
),
);
}
}
36 changes: 36 additions & 0 deletions bdk_demo/lib/features/receive/receive_error_panel.dart
Original file line number Diff line number Diff line change
@@ -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,
),
),
),
],
),
);
}
}
88 changes: 88 additions & 0 deletions bdk_demo/lib/features/receive/receive_page.dart
Original file line number Diff line number Diff line change
@@ -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',
),
),
],
);
}
}
181 changes: 181 additions & 0 deletions bdk_demo/test/presentation/receive_page_test.dart
Original file line number Diff line number Diff line change
@@ -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<void> generateForActiveWallet() async {
generationCalls += 1;
}
}

void main() {
TestWidgetsFlutterBinding.ensureInitialized();

Future<ProviderContainer> 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<FilledButton>(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 = <MethodCall>[];
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);
});
}
Loading
Loading