From 0e9986f12cb1df8b9504ade811fc09b221df492e Mon Sep 17 00:00:00 2001 From: j-kon Date: Mon, 23 Mar 2026 10:03:45 +0100 Subject: [PATCH 1/8] feat: scaffold transaction presentation in bdk_demo active wallet flow --- bdk_demo/README.md | 14 +- bdk_demo/lib/core/utils/formatters.dart | 4 +- .../wallet_setup/active_wallets_page.dart | 642 ++++++++++++++---- .../wallet_setup/wallet_choice_page.dart | 3 +- bdk_demo/lib/models/tx_details.dart | 10 +- bdk_demo/lib/services/wallet_service.dart | 88 ++- .../test/presentation/app_shell_test.dart | 139 ++++ 7 files changed, 738 insertions(+), 162 deletions(-) diff --git a/bdk_demo/README.md b/bdk_demo/README.md index 859fdfa..ac2be2b 100644 --- a/bdk_demo/README.md +++ b/bdk_demo/README.md @@ -1,10 +1,10 @@ # BDK-Dart Wallet (Flutter) -The _BDK-Dart Wallet_ is a wallet built as a reference app for the [bitcoindevkit](https://github.com/bitcoindevkit) on Flutter using [bdk-dart](https://github.com/bitcoindevkit/bdk-dart). This repository is not intended to produce a production-ready wallet, the app only works on Signet, Testnet 3, and Regtest. +The _BDK-Dart Wallet_ is a Flutter reference app for [bitcoindevkit](https://github.com/bitcoindevkit) using [bdk-dart](https://github.com/bitcoindevkit/bdk-dart). It is intentionally a demo and scaffold, not a production-ready wallet, and currently targets Signet, Testnet 3, and Regtest. The demo app is built with the following goals in mind: 1. Be a reference application for the `bdk_dart` API on Flutter (iOS & Android). -2. Showcase the core features of the bitcoindevkit library: wallet creation, recovery, Esplora/Electrum sync, send, receive, and transaction history. +2. Sketch the wallet creation, recovery, sync, send, receive, and transaction-history flows the app can grow into over time. 3. Demonstrate a clean, testable Flutter architecture using Riverpod and GoRouter. ## Features @@ -19,12 +19,14 @@ The demo app is built with the following goals in mind: | Wallet balance (BTC / sats toggle) | - | | Receive (address generation + QR) | - | | Send (single recipient + fee rate) | - | -| Transaction history | - | +| Transaction history | Scaffolded placeholder UI | | Transaction detail | - | | Recovery data viewer | - | | Theme toggle (light / dark) | - | | In-app log viewer | - | +Today the active-wallet flow is deliberately small: it loads a wallet scaffold, shows placeholder wallet metadata, and renders placeholder transaction rows. No real wallet sync or transaction fetching is implemented yet. + ## Architecture Clean Architecture + Riverpod: @@ -42,9 +44,9 @@ lib/ **Note:** - **State management:** Riverpod - **Navigation:** GoRouter -- **Domain objects:** Uses `bdk_dart` types directly -- **Secure storage:** `flutter_secure_storage` for mnemonics and descriptors -- **BDK threading:** `Isolate.run()` for heavy sync operations +- **Domain objects:** Uses app-local scaffold models with room to grow into fuller `bdk_dart` integrations +- **Secure storage:** Planned for mnemonic and descriptor handling as wallet flows land +- **Heavy sync work:** Planned to move off the UI isolate when real sync is added ## Getting Started diff --git a/bdk_demo/lib/core/utils/formatters.dart b/bdk_demo/lib/core/utils/formatters.dart index f99fdf0..06d6ef7 100644 --- a/bdk_demo/lib/core/utils/formatters.dart +++ b/bdk_demo/lib/core/utils/formatters.dart @@ -32,8 +32,8 @@ abstract final class Formatters { return '$month ${dt.day} ${dt.year} $hour:$minute'; } - static String abbreviateTxid(String txid) => txid.length > 16 - ? '${txid.substring(0, 8)}...${txid.substring(txid.length - 8)}' + static String abbreviateTxid(String txid) => txid.length > 10 + ? '${txid.substring(0, 6)}...${txid.substring(txid.length - 4)}' : txid; } diff --git a/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart index 5ee8838..8b7ff7d 100644 --- a/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart +++ b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart @@ -1,10 +1,15 @@ -import 'package:bdk_demo/core/router/app_router.dart'; -import 'package:bdk_demo/features/shared/widgets/secondary_app_bar.dart'; -import 'package:bdk_demo/models/wallet_record.dart'; -import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:go_router/go_router.dart'; + +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/models/currency_unit.dart'; +import 'package:bdk_demo/models/tx_details.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:bdk_demo/services/wallet_service.dart'; + +enum _LoadState { idle, loading, success, error } class ActiveWalletsPage extends ConsumerStatefulWidget { const ActiveWalletsPage({super.key}); @@ -14,189 +19,542 @@ class ActiveWalletsPage extends ConsumerStatefulWidget { } class _ActiveWalletsPageState extends ConsumerState { - String? _loadingWalletId; + _LoadState _walletState = _LoadState.idle; + _LoadState _transactionState = _LoadState.idle; + DemoWalletInfo? _walletInfo; + List _transactions = const []; + String _statusMessage = + 'Load the reference scaffold to preview wallet details and transaction presentation.'; + String? _walletError; + String? _transactionError; - Future _onLoadWallet(WalletRecord record) async { - if (_loadingWalletId != null) return; + Future _loadReferenceWallet() async { + final walletService = ref.read(walletServiceProvider); - setState(() => _loadingWalletId = record.id); - final walletDisposer = ref.read(walletDisposerProvider); + setState(() { + _walletState = _LoadState.loading; + _transactionState = _LoadState.idle; + _walletInfo = null; + _transactions = const []; + _walletError = null; + _transactionError = null; + _statusMessage = 'Preparing the wallet scaffold...'; + }); try { - final wallet = await ref - .read(walletServiceProvider) - .loadWalletFromRecord(record); - - if (!mounted) { - walletDisposer(wallet); - return; - } - - ref.read(activeWalletProvider.notifier).set(wallet); - ref.read(activeWalletRecordProvider.notifier).set(record); - context.go(AppRoutes.home); - } on StateError { + final walletInfo = await walletService.loadReferenceWallet(); if (!mounted) return; - _showSnackBar('Secrets not found for this wallet'); - } catch (_) { + + setState(() { + _walletState = _LoadState.success; + _transactionState = _LoadState.loading; + _walletInfo = walletInfo; + _statusMessage = 'Scaffold ready. Loading placeholder transactions...'; + }); + } catch (error) { if (!mounted) return; - _showSnackBar('Failed to load wallet. Please try again.'); - } finally { - if (mounted) setState(() => _loadingWalletId = null); + + setState(() { + _walletState = _LoadState.error; + _walletError = _readableError(error); + _statusMessage = 'The wallet scaffold could not be loaded.'; + }); + return; + } + + await Future.delayed(Duration.zero); + + try { + final transactions = await walletService.loadTransactions(); + if (!mounted) return; + + setState(() { + _transactionState = _LoadState.success; + _transactions = transactions; + _statusMessage = transactions.isEmpty + ? 'Scaffold loaded. No transactions yet.' + : 'Scaffold loaded. Showing placeholder transaction rows for future UI work.'; + }); + } catch (error) { + if (!mounted) return; + + setState(() { + _transactionState = _LoadState.error; + _transactionError = _readableError(error); + _statusMessage = + 'The wallet scaffold loaded, but placeholder transactions could not be shown.'; + }); } } - void _showSnackBar(String message) { - ScaffoldMessenger.of(context) - ..hideCurrentSnackBar() - ..showSnackBar(SnackBar(content: Text(message))); + String _readableError(Object error) => + error.toString().replaceFirst('Exception: ', ''); + + String _descriptorPreview(String descriptor) { + if (descriptor.length <= 48) return descriptor; + return '${descriptor.substring(0, 24)}...${descriptor.substring(descriptor.length - 18)}'; } @override Widget build(BuildContext context) { - final records = ref.watch(walletRecordsProvider); final theme = Theme.of(context); + final isWalletLoading = _walletState == _LoadState.loading; return Scaffold( - appBar: const SecondaryAppBar(title: 'Active Wallets'), - body: records.isEmpty - ? _buildEmptyState(theme) - : _buildWalletList(records, theme), + appBar: const SecondaryAppBar(title: 'Reference Wallet Scaffold'), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: theme.colorScheme.primaryContainer, + ), + child: Icon( + Icons.wallet_outlined, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Text( + 'Reference Wallet Scaffold', + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Load a lightweight scaffold that previews wallet details and transaction rows. This is placeholder UI for future transaction visibility work, not a synced or functional wallet.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(180), + ), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: isWalletLoading ? null : _loadReferenceWallet, + icon: isWalletLoading + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onPrimary, + ), + ) + : const Icon(Icons.download_rounded), + label: Text( + _walletState == _LoadState.success || + _walletState == _LoadState.error + ? 'Reload Wallet Data' + : 'Load Reference Scaffold', + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + const _SectionHeading( + title: 'Wallet Snapshot', + subtitle: 'Network, descriptor preview, and current status', + ), + const SizedBox(height: 12), + _buildWalletSection(theme), + const SizedBox(height: 24), + const _SectionHeading( + title: 'Transactions', + subtitle: 'Placeholder transaction visibility for future work', + ), + const SizedBox(height: 12), + _buildTransactionsSection(theme), + ], + ), + ), + ); + } + + Widget _buildWalletSection(ThemeData theme) { + return switch (_walletState) { + _LoadState.idle => _InfoCard( + icon: Icons.info_outline, + title: 'Wallet not loaded yet', + message: _statusMessage, + ), + _LoadState.loading => const _LoadingCard( + title: 'Loading wallet', + message: 'Preparing placeholder wallet details...', + ), + _LoadState.error => _InfoCard( + icon: Icons.error_outline, + title: 'Wallet load failed', + message: _walletError ?? _statusMessage, + accentColor: theme.colorScheme.error, + ), + _LoadState.success => Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailRow(label: 'Wallet', value: _walletInfo!.title), + const SizedBox(height: 12), + _DetailRow( + label: 'Network', + value: _walletInfo!.network.displayName, + ), + const SizedBox(height: 12), + _DetailRow( + label: _walletInfo!.descriptorLabel, + value: _descriptorPreview(_walletInfo!.descriptor), + monospace: true, + ), + const SizedBox(height: 12), + _DetailRow(label: 'Status', value: _statusMessage), + ], + ), + ), + ), + }; + } + + Widget _buildTransactionsSection(ThemeData theme) { + if (_walletState == _LoadState.idle) { + return const _InfoCard( + icon: Icons.receipt_long_outlined, + title: 'Transactions will appear here', + message: + 'Load the scaffold first, then the demo will show placeholder transaction UI.', + ); + } + + if (_walletState == _LoadState.loading) { + return const _LoadingCard( + title: 'Waiting for wallet', + message: 'Transaction UI becomes available after the scaffold loads.', + ); + } + + if (_walletState == _LoadState.error) { + return const _InfoCard( + icon: Icons.receipt_long_outlined, + title: 'Transactions unavailable', + message: + 'Fix the scaffold load error before retrying placeholder transactions.', + ); + } + + return switch (_transactionState) { + _LoadState.idle => const _InfoCard( + icon: Icons.receipt_long_outlined, + title: 'Transactions not loaded yet', + message: + 'Placeholder transaction rows will appear after the scaffold finishes loading.', + ), + _LoadState.loading => const _LoadingCard( + title: 'Loading placeholder transactions...', + message: 'Preparing scaffolded transaction rows.', + ), + _LoadState.error => _InfoCard( + icon: Icons.error_outline, + title: 'Placeholder transactions failed', + message: + _transactionError ?? + 'Unable to load the placeholder transaction UI.', + accentColor: theme.colorScheme.error, + ), + _LoadState.success => + _transactions.isEmpty + ? const _InfoCard( + icon: Icons.history_toggle_off, + title: 'No transactions yet', + message: + 'The scaffold loaded successfully, but no placeholder transactions are configured yet.', + ) + : Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + for ( + var index = 0; + index < _transactions.length; + index++ + ) ...[ + _TransactionRow(transaction: _transactions[index]), + if (index < _transactions.length - 1) + const SizedBox(height: 12), + ], + ], + ), + ), + ), + }; + } +} + +class _SectionHeading extends StatelessWidget { + final String title; + final String subtitle; + + const _SectionHeading({required this.title, required this.subtitle}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + ], ); } +} + +class _InfoCard extends StatelessWidget { + final IconData icon; + final String title; + final String message; + final Color? accentColor; - Widget _buildEmptyState(ThemeData theme) { - return Center( + const _InfoCard({ + required this.icon, + required this.title, + required this.message, + this.accentColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = accentColor ?? theme.colorScheme.primary; + + return Card( child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 32), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Icon( - Icons.account_balance_wallet_outlined, - size: 64, - color: theme.colorScheme.onSurface.withAlpha(102), - ), - const SizedBox(height: 16), - Text( - 'No wallets yet', - style: theme.textTheme.titleMedium?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(153), + Icon(icon, color: color), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text(message, style: theme.textTheme.bodyMedium), + ], ), ), - const SizedBox(height: 24), - FilledButton.tonal( - onPressed: () => context.push(AppRoutes.createWallet), - child: const Text('Create a Wallet'), - ), ], ), ), ); } +} + +class _LoadingCard extends StatelessWidget { + final String title; + final String message; - Widget _buildWalletList(List records, ThemeData theme) { - return ListView.separated( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), - itemCount: records.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final record = records[index]; - final isLoading = _loadingWalletId == record.id; - final isDisabled = _loadingWalletId != null; - - return _WalletCard( - record: record, - isLoading: isLoading, - isDisabled: isDisabled, - onTap: () => _onLoadWallet(record), - ); - }, + const _LoadingCard({required this.title, required this.message}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text(message, style: theme.textTheme.bodyMedium), + ], + ), + ), + ], + ), + ), ); } } -class _WalletCard extends StatelessWidget { - final WalletRecord record; - final bool isLoading; - final bool isDisabled; - final VoidCallback onTap; - - const _WalletCard({ - required this.record, - required this.isLoading, - required this.isDisabled, - required this.onTap, +class _DetailRow extends StatelessWidget { + final String label; + final String value; + final bool monospace; + + const _DetailRow({ + required this.label, + required this.value, + this.monospace = false, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); - return Card( - child: InkWell( - onTap: isDisabled ? null : onTap, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + const SizedBox(height: 4), + Text( + value, + style: monospace + ? AppTheme.monoStyle.copyWith( + fontSize: 13, + color: theme.colorScheme.onSurface, + ) + : theme.textTheme.bodyLarge, + ), + ], + ); + } +} + +class _TransactionRow extends StatelessWidget { + final TxDetails transaction; + + const _TransactionRow({required this.transaction}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final amount = transaction.netAmount; + final isIncoming = amount >= 0; + final accentColor = transaction.pending + ? theme.colorScheme.secondary + : isIncoming + ? Colors.green.shade700 + : theme.colorScheme.primary; + final amountLabel = + '${amount >= 0 ? '+' : '-'}${Formatters.formatBalance(amount.abs(), CurrencyUnit.satoshi)}'; + final subtitle = transaction.pending + ? 'Awaiting confirmation' + : transaction.blockHeight == null + ? 'Confirmed' + : 'Block ${transaction.blockHeight}'; + + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - Icon( - Icons.account_balance_wallet, - size: 36, - color: theme.colorScheme.primary, - ), - const SizedBox(width: 16), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - record.name, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [ - Chip( - label: Text( - record.network.displayName, - style: theme.textTheme.labelSmall, - ), - visualDensity: VisualDensity.compact, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - ), - Chip( - label: Text( - record.scriptType.shortName, - style: theme.textTheme.labelSmall, - ), - visualDensity: VisualDensity.compact, - materialTapTargetSize: - MaterialTapTargetSize.shrinkWrap, - ), - ], - ), - ], + child: Text( + amountLabel, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: accentColor, + ), ), ), - if (isLoading) - const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator(strokeWidth: 2), - ) - else - Icon( - Icons.chevron_right, - color: theme.colorScheme.onSurface.withAlpha(102), - ), + _StatusChip(status: transaction.statusLabel), ], ), + const SizedBox(height: 8), + Text( + transaction.shortTxid, + style: AppTheme.monoStyle.copyWith( + fontSize: 13, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + ], + ), + ); + } +} + +class _StatusChip extends StatelessWidget { + final String status; + + const _StatusChip({required this.status}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isPending = status == 'pending'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: isPending + ? theme.colorScheme.secondaryContainer + : theme.colorScheme.primaryContainer, + ), + child: Text( + status, + style: theme.textTheme.labelMedium?.copyWith( + color: isPending + ? theme.colorScheme.onSecondaryContainer + : theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, ), ), ); diff --git a/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart b/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart index 9bac63c..2bf57e0 100644 --- a/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart +++ b/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart @@ -40,7 +40,8 @@ class WalletChoicePage extends StatelessWidget { _ChoiceCard( icon: Icons.account_balance_wallet, title: 'Use an Active Wallet', - subtitle: 'Load a previously created wallet', + subtitle: + 'Open the reference scaffold and inspect placeholder state', onTap: () => context.push(AppRoutes.activeWallets), ), const SizedBox(height: 16), diff --git a/bdk_demo/lib/models/tx_details.dart b/bdk_demo/lib/models/tx_details.dart index a55820e..dbd79f7 100644 --- a/bdk_demo/lib/models/tx_details.dart +++ b/bdk_demo/lib/models/tx_details.dart @@ -4,6 +4,7 @@ class TxDetails { final int received; final int fee; final double? feeRate; + final int? balanceDelta; final bool pending; final int? blockHeight; final DateTime? confirmationTime; @@ -14,14 +15,17 @@ class TxDetails { required this.received, this.fee = 0, this.feeRate, + this.balanceDelta, this.pending = true, this.blockHeight, this.confirmationTime, }); - int get netAmount => received - sent; + int get netAmount => balanceDelta ?? (received - sent); - String get shortTxid => txid.length > 16 - ? '${txid.substring(0, 8)}...${txid.substring(txid.length - 8)}' + String get shortTxid => txid.length > 10 + ? '${txid.substring(0, 6)}...${txid.substring(txid.length - 4)}' : txid; + + String get statusLabel => pending ? 'pending' : 'confirmed'; } diff --git a/bdk_demo/lib/services/wallet_service.dart b/bdk_demo/lib/services/wallet_service.dart index bc0e29e..8f5f65c 100644 --- a/bdk_demo/lib/services/wallet_service.dart +++ b/bdk_demo/lib/services/wallet_service.dart @@ -1,20 +1,55 @@ import 'package:bdk_dart/bdk.dart'; import 'package:uuid/uuid.dart'; import 'package:bdk_demo/core/constants/app_constants.dart'; +import 'package:bdk_demo/models/tx_details.dart'; import 'package:bdk_demo/models/wallet_record.dart'; import 'package:bdk_demo/services/storage_service.dart'; import 'package:bdk_demo/services/wallet_network_mapper.dart'; typedef WalletDisposer = void Function(Wallet wallet); +class DemoWalletInfo { + final String title; + final WalletNetwork network; + final String descriptor; + final String descriptorLabel; + + const DemoWalletInfo({ + required this.title, + required this.network, + required this.descriptor, + this.descriptorLabel = 'External descriptor', + }); +} + class WalletService { - final StorageService _storage; - final Uuid _uuid; + final StorageService? _storage; + final Uuid? _uuid; final WalletDisposer _walletDisposer; + static const _placeholderDescriptor = + 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#scafld00'; + static const _placeholderTransactions = [ + TxDetails( + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + sent: 0, + received: 42000, + balanceDelta: 42000, + pending: false, + blockHeight: 120, + ), + TxDetails( + txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + sent: 1600, + received: 0, + balanceDelta: -1600, + pending: true, + ), + ]; + WalletService({ - required StorageService storage, - required Uuid uuid, + StorageService? storage, + Uuid? uuid, WalletDisposer? walletDisposer, }) : _storage = storage, _uuid = uuid, @@ -22,17 +57,35 @@ class WalletService { static void _defaultDisposer(Wallet wallet) => wallet.dispose(); + StorageService get _requiredStorage { + final storage = _storage; + if (storage == null) { + throw StateError('WalletService requires StorageService for this action.'); + } + return storage; + } + + Uuid get _requiredUuid { + final uuid = _uuid; + if (uuid == null) { + throw StateError('WalletService requires Uuid for this action.'); + } + return uuid; + } + Future<(WalletRecord, Wallet)> createWallet( String name, WalletNetwork walletNetwork, ScriptType scriptType, ) async { + final storage = _requiredStorage; + final uuid = _requiredUuid; final trimmedName = name.trim(); if (trimmedName.isEmpty) { throw ArgumentError('Wallet name must not be empty.'); } - final existing = _storage.getWalletRecords(); + final existing = storage.getWalletRecords(); final duplicate = existing.any( (r) => r.name.toLowerCase() == trimmedName.toLowerCase(), ); @@ -72,7 +125,7 @@ class WalletService { ); final record = WalletRecord( - id: _uuid.v4(), + id: uuid.v4(), name: trimmedName, network: walletNetwork, scriptType: scriptType, @@ -85,7 +138,7 @@ class WalletService { ); try { - await _storage.addWalletRecord(record, secrets); + await storage.addWalletRecord(record, secrets); } catch (_) { _walletDisposer(wallet); rethrow; @@ -95,7 +148,8 @@ class WalletService { } Future loadWalletFromRecord(WalletRecord record) async { - final secrets = await _storage.getSecrets(record.id); + final storage = _requiredStorage; + final secrets = await storage.getSecrets(record.id); if (secrets == null) { throw StateError( 'No secrets found for wallet "${record.name}" (${record.id}). ' @@ -124,6 +178,24 @@ class WalletService { ); } + Future loadReferenceWallet() async { + await Future.delayed(const Duration(milliseconds: 150)); + + return const DemoWalletInfo( + title: 'Reference Wallet Scaffold', + network: WalletNetwork.testnet, + descriptor: _placeholderDescriptor, + descriptorLabel: 'Placeholder descriptor', + ); + } + + Future> loadTransactions() async { + await Future.delayed(const Duration(milliseconds: 150)); + return _placeholderTransactions; + } + + void dispose() {} + Descriptor _deriveDescriptor( DescriptorSecretKey secretKey, KeychainKind keychainKind, diff --git a/bdk_demo/test/presentation/app_shell_test.dart b/bdk_demo/test/presentation/app_shell_test.dart index 4acb26f..aba4607 100644 --- a/bdk_demo/test/presentation/app_shell_test.dart +++ b/bdk_demo/test/presentation/app_shell_test.dart @@ -4,8 +4,28 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:bdk_demo/app/app.dart'; +import 'package:bdk_demo/models/tx_details.dart'; +import 'package:bdk_demo/models/wallet_record.dart'; import 'package:bdk_demo/providers/settings_providers.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:bdk_demo/services/storage_service.dart'; +import 'package:bdk_demo/services/wallet_service.dart'; + +class FakeWalletService extends WalletService { + final DemoWalletInfo walletInfo; + final List transactions; + + FakeWalletService({required this.walletInfo, required this.transactions}); + + @override + Future loadReferenceWallet() async => walletInfo; + + @override + Future> loadTransactions() async => transactions; + + @override + void dispose() {} +} void main() { testWidgets('App builds and shows WalletChoicePage', (tester) async { @@ -49,4 +69,123 @@ void main() { final materialApp = tester.widget(find.byType(MaterialApp)); expect(materialApp.themeMode, ThemeMode.light); }); + + testWidgets('Reference wallet scaffold page shows placeholder transactions', ( + tester, + ) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final fakeWalletService = FakeWalletService( + walletInfo: const DemoWalletInfo( + title: 'Reference Wallet Scaffold', + network: WalletNetwork.testnet, + descriptor: 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#demo1234', + descriptorLabel: 'Placeholder descriptor', + ), + transactions: const [ + TxDetails( + txid: + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + sent: 0, + received: 42000, + balanceDelta: 42000, + pending: false, + blockHeight: 120, + ), + TxDetails( + txid: + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + sent: 1600, + received: 0, + balanceDelta: -1600, + pending: true, + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + storageServiceProvider.overrideWithValue( + StorageService(prefs: prefs), + ), + walletServiceProvider.overrideWithValue(fakeWalletService), + ], + child: const App(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Use an Active Wallet')); + await tester.pumpAndSettle(); + + expect(find.text('Reference Wallet Scaffold'), findsNWidgets(2)); + expect(find.text('Load Reference Scaffold'), findsOneWidget); + + await tester.tap(find.text('Load Reference Scaffold')); + await tester.pumpAndSettle(); + + expect(find.text('Wallet Snapshot'), findsOneWidget); + expect(find.text('Testnet 3'), findsOneWidget); + expect(find.text('Placeholder descriptor'), findsOneWidget); + + await tester.scrollUntilVisible( + find.text('confirmed'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(find.text('+42000 sat'), findsOneWidget); + expect(find.text('-1600 sat'), findsOneWidget); + expect(find.text('123456...abcd'), findsOneWidget); + expect(find.text('abcdef...7890'), findsOneWidget); + expect(find.text('confirmed'), findsOneWidget); + expect(find.text('pending'), findsOneWidget); + }); + + testWidgets( + 'Reference wallet scaffold supports the empty transaction state', + (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + final fakeWalletService = FakeWalletService( + walletInfo: const DemoWalletInfo( + title: 'Reference Wallet Scaffold', + network: WalletNetwork.testnet, + descriptor: + 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#demo1234', + descriptorLabel: 'Placeholder descriptor', + ), + transactions: const [], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + storageServiceProvider.overrideWithValue( + StorageService(prefs: prefs), + ), + walletServiceProvider.overrideWithValue(fakeWalletService), + ], + child: const App(), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Use an Active Wallet')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Load Reference Scaffold')); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('No transactions yet'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); + + expect(find.text('No transactions yet'), findsOneWidget); + }, + ); } From c6fcca9aa2af2e1641c89e53ae10cc982a8aef37 Mon Sep 17 00:00:00 2001 From: j-kon Date: Mon, 23 Mar 2026 11:08:11 +0100 Subject: [PATCH 2/8] feat: add transaction detail flow with navigation and tests --- bdk_demo/lib/core/router/app_router.dart | 3 +- .../wallet_setup/active_wallets_page.dart | 91 ++++-- .../wallet_setup/transaction_detail_page.dart | 304 ++++++++++++++++++ bdk_demo/lib/services/wallet_service.dart | 13 +- .../test/presentation/app_shell_test.dart | 207 ++++++------ 5 files changed, 489 insertions(+), 129 deletions(-) create mode 100644 bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart diff --git a/bdk_demo/lib/core/router/app_router.dart b/bdk_demo/lib/core/router/app_router.dart index cce7ac9..583fc62 100644 --- a/bdk_demo/lib/core/router/app_router.dart +++ b/bdk_demo/lib/core/router/app_router.dart @@ -2,6 +2,7 @@ import 'package:go_router/go_router.dart'; import 'package:bdk_demo/features/shared/widgets/placeholder_page.dart'; import 'package:bdk_demo/features/wallet_setup/active_wallets_page.dart'; import 'package:bdk_demo/features/wallet_setup/create_wallet_page.dart'; +import 'package:bdk_demo/features/wallet_setup/transaction_detail_page.dart'; import 'package:bdk_demo/features/wallet_setup/wallet_choice_page.dart'; abstract final class AppRoutes { @@ -72,7 +73,7 @@ GoRouter createRouter() => GoRouter( name: 'transactionDetail', builder: (context, state) { final txid = state.pathParameters['txid'] ?? ''; - return PlaceholderPage(title: 'Transaction $txid'); + return TransactionDetailPage(txid: txid); }, ), diff --git a/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart index 8b7ff7d..b329b07 100644 --- a/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart +++ b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; import 'package:bdk_demo/core/theme/app_theme.dart'; import 'package:bdk_demo/core/utils/formatters.dart'; @@ -95,6 +96,13 @@ class _ActiveWalletsPageState extends ConsumerState { return '${descriptor.substring(0, 24)}...${descriptor.substring(descriptor.length - 18)}'; } + void _openTransactionDetail(TxDetails transaction) { + context.pushNamed( + 'transactionDetail', + pathParameters: {'txid': transaction.txid}, + ); + } + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -289,7 +297,11 @@ class _ActiveWalletsPageState extends ConsumerState { index < _transactions.length; index++ ) ...[ - _TransactionRow(transaction: _transactions[index]), + _TransactionRow( + transaction: _transactions[index], + onTap: () => + _openTransactionDetail(_transactions[index]), + ), if (index < _transactions.length - 1) const SizedBox(height: 12), ], @@ -464,8 +476,9 @@ class _DetailRow extends StatelessWidget { class _TransactionRow extends StatelessWidget { final TxDetails transaction; + final VoidCallback onTap; - const _TransactionRow({required this.transaction}); + const _TransactionRow({required this.transaction, required this.onTap}); @override Widget build(BuildContext context) { @@ -485,46 +498,54 @@ class _TransactionRow extends StatelessWidget { ? 'Confirmed' : 'Block ${transaction.blockHeight}'; - return Container( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( + return Material( + color: Colors.transparent, + child: InkWell( borderRadius: BorderRadius.circular(16), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + onTap: onTap, + child: Ink( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Text( - amountLabel, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - color: accentColor, + Row( + children: [ + Expanded( + child: Text( + amountLabel, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: accentColor, + ), + ), ), + const SizedBox(width: 12), + _StatusChip(status: transaction.statusLabel), + ], + ), + const SizedBox(height: 8), + Text( + transaction.shortTxid, + style: AppTheme.monoStyle.copyWith( + fontSize: 13, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), ), ), - _StatusChip(status: transaction.statusLabel), ], ), - const SizedBox(height: 8), - Text( - transaction.shortTxid, - style: AppTheme.monoStyle.copyWith( - fontSize: 13, - color: theme.colorScheme.onSurface, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(170), - ), - ), - ], + ), ), ); } diff --git a/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart b/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart new file mode 100644 index 0000000..99769ab --- /dev/null +++ b/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +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/models/currency_unit.dart'; +import 'package:bdk_demo/models/tx_details.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; + +class TransactionDetailPage extends ConsumerStatefulWidget { + final String txid; + + const TransactionDetailPage({super.key, required this.txid}); + + @override + ConsumerState createState() => + _TransactionDetailPageState(); +} + +class _TransactionDetailPageState extends ConsumerState { + late final Future _transactionFuture; + + @override + void initState() { + super.initState(); + _transactionFuture = ref + .read(walletServiceProvider) + .loadTransactionByTxid(widget.txid); + } + + String _formatAmount(TxDetails transaction) { + final amount = transaction.netAmount; + final prefix = amount >= 0 ? '+' : '-'; + final value = Formatters.formatBalance(amount.abs(), CurrencyUnit.satoshi); + + return '$prefix$value'; + } + + String _formatTimestamp(DateTime timestamp) { + final unixSeconds = timestamp.millisecondsSinceEpoch ~/ 1000; + return Formatters.formatTimestamp(unixSeconds); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: const SecondaryAppBar(title: 'Transaction Detail'), + body: SafeArea( + child: FutureBuilder( + future: _transactionFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const _StateCard( + icon: Icons.hourglass_bottom, + title: 'Loading transaction', + message: 'Preparing placeholder transaction details...', + showSpinner: true, + ); + } + + if (snapshot.hasError) { + return _StateCard( + icon: Icons.error_outline, + title: 'Transaction unavailable', + message: + 'The scaffold could not load placeholder transaction details.', + accentColor: theme.colorScheme.error, + ); + } + + final transaction = snapshot.data; + if (transaction == null) { + return _StateCard( + icon: Icons.search_off, + title: 'Transaction not found', + message: + 'No placeholder transaction was found for this txid.\n\n${widget.txid}', + ); + } + + return ListView( + padding: const EdgeInsets.all(24), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + _formatAmount(transaction), + style: theme.textTheme.headlineSmall?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + _StatusChip(status: transaction.statusLabel), + ], + ), + const SizedBox(height: 8), + Text( + 'Scaffolded placeholder detail view for the selected transaction.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Full txid', + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + const SizedBox(height: 8), + SelectableText( + transaction.txid, + style: AppTheme.monoStyle.copyWith( + fontSize: 13, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _DetailRow( + label: 'Amount', + value: _formatAmount(transaction), + ), + const SizedBox(height: 12), + _DetailRow( + label: 'Status', + value: transaction.statusLabel, + ), + if (transaction.blockHeight != null) ...[ + const SizedBox(height: 12), + _DetailRow( + label: 'Block height', + value: '${transaction.blockHeight}', + ), + ], + if (transaction.confirmationTime != null) ...[ + const SizedBox(height: 12), + _DetailRow( + label: 'Timestamp', + value: _formatTimestamp( + transaction.confirmationTime!, + ), + ), + ], + ], + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _StateCard extends StatelessWidget { + final IconData icon; + final String title; + final String message; + final Color? accentColor; + final bool showSpinner; + + const _StateCard({ + required this.icon, + required this.title, + required this.message, + this.accentColor, + this.showSpinner = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = accentColor ?? theme.colorScheme.primary; + + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + showSpinner + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(icon, color: color), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text(message, style: theme.textTheme.bodyMedium), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _DetailRow extends StatelessWidget { + final String label; + final String value; + + const _DetailRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + const SizedBox(height: 4), + Text(value, style: theme.textTheme.bodyLarge), + ], + ); + } +} + +class _StatusChip extends StatelessWidget { + final String status; + + const _StatusChip({required this.status}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isPending = status == 'pending'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: isPending + ? theme.colorScheme.secondaryContainer + : theme.colorScheme.primaryContainer, + ), + child: Text( + status, + style: theme.textTheme.labelMedium?.copyWith( + color: isPending + ? theme.colorScheme.onSecondaryContainer + : theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/bdk_demo/lib/services/wallet_service.dart b/bdk_demo/lib/services/wallet_service.dart index 8f5f65c..d3f7253 100644 --- a/bdk_demo/lib/services/wallet_service.dart +++ b/bdk_demo/lib/services/wallet_service.dart @@ -29,7 +29,7 @@ class WalletService { static const _placeholderDescriptor = 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#scafld00'; - static const _placeholderTransactions = [ + static final _placeholderTransactions = [ TxDetails( txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', sent: 0, @@ -37,6 +37,7 @@ class WalletService { balanceDelta: 42000, pending: false, blockHeight: 120, + confirmationTime: DateTime(2024, 1, 2, 3, 4), ), TxDetails( txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', @@ -194,6 +195,16 @@ class WalletService { return _placeholderTransactions; } + Future loadTransactionByTxid(String txid) async { + final transactions = await loadTransactions(); + + for (final transaction in transactions) { + if (transaction.txid == txid) return transaction; + } + + return null; + } + void dispose() {} Descriptor _deriveDescriptor( diff --git a/bdk_demo/test/presentation/app_shell_test.dart b/bdk_demo/test/presentation/app_shell_test.dart index aba4607..8ae0dc9 100644 --- a/bdk_demo/test/presentation/app_shell_test.dart +++ b/bdk_demo/test/presentation/app_shell_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:bdk_demo/app/app.dart'; +import 'package:bdk_demo/features/wallet_setup/transaction_detail_page.dart'; import 'package:bdk_demo/models/tx_details.dart'; import 'package:bdk_demo/models/wallet_record.dart'; import 'package:bdk_demo/providers/settings_providers.dart'; @@ -11,6 +12,32 @@ import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:bdk_demo/services/storage_service.dart'; import 'package:bdk_demo/services/wallet_service.dart'; +const _testWalletInfo = DemoWalletInfo( + title: 'Reference Wallet Scaffold', + network: WalletNetwork.testnet, + descriptor: 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#demo1234', + descriptorLabel: 'Placeholder descriptor', +); + +final _placeholderTransactions = [ + TxDetails( + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + sent: 0, + received: 42000, + balanceDelta: 42000, + pending: false, + blockHeight: 120, + confirmationTime: DateTime(2024, 1, 2, 3, 4), + ), + TxDetails( + txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + sent: 1600, + received: 0, + balanceDelta: -1600, + pending: true, + ), +]; + class FakeWalletService extends WalletService { final DemoWalletInfo walletInfo; final List transactions; @@ -27,22 +54,29 @@ class FakeWalletService extends WalletService { void dispose() {} } +Future _pumpApp( + WidgetTester tester, { + WalletService? walletService, +}) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + + await tester.pumpWidget( + ProviderScope( + overrides: [ + storageServiceProvider.overrideWithValue(StorageService(prefs: prefs)), + if (walletService != null) + walletServiceProvider.overrideWithValue(walletService), + ], + child: const App(), + ), + ); + await tester.pumpAndSettle(); +} + void main() { testWidgets('App builds and shows WalletChoicePage', (tester) async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - storageServiceProvider.overrideWithValue( - StorageService(prefs: prefs), - ), - ], - child: const App(), - ), - ); - await tester.pumpAndSettle(); + await _pumpApp(tester); expect(find.byType(MaterialApp), findsOneWidget); expect(find.text('Use an Active Wallet'), findsOneWidget); @@ -51,20 +85,7 @@ void main() { }); testWidgets('Theme defaults to light mode', (tester) async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - storageServiceProvider.overrideWithValue( - StorageService(prefs: prefs), - ), - ], - child: const App(), - ), - ); - await tester.pumpAndSettle(); + await _pumpApp(tester); final materialApp = tester.widget(find.byType(MaterialApp)); expect(materialApp.themeMode, ThemeMode.light); @@ -73,55 +94,15 @@ void main() { testWidgets('Reference wallet scaffold page shows placeholder transactions', ( tester, ) async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); final fakeWalletService = FakeWalletService( - walletInfo: const DemoWalletInfo( - title: 'Reference Wallet Scaffold', - network: WalletNetwork.testnet, - descriptor: 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#demo1234', - descriptorLabel: 'Placeholder descriptor', - ), - transactions: const [ - TxDetails( - txid: - '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', - sent: 0, - received: 42000, - balanceDelta: 42000, - pending: false, - blockHeight: 120, - ), - TxDetails( - txid: - 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - sent: 1600, - received: 0, - balanceDelta: -1600, - pending: true, - ), - ], + walletInfo: _testWalletInfo, + transactions: _placeholderTransactions, ); - await tester.pumpWidget( - ProviderScope( - overrides: [ - storageServiceProvider.overrideWithValue( - StorageService(prefs: prefs), - ), - walletServiceProvider.overrideWithValue(fakeWalletService), - ], - child: const App(), - ), - ); - await tester.pumpAndSettle(); + await _pumpApp(tester, walletService: fakeWalletService); await tester.tap(find.text('Use an Active Wallet')); await tester.pumpAndSettle(); - - expect(find.text('Reference Wallet Scaffold'), findsNWidgets(2)); - expect(find.text('Load Reference Scaffold'), findsOneWidget); - await tester.tap(find.text('Load Reference Scaffold')); await tester.pumpAndSettle(); @@ -145,34 +126,54 @@ void main() { }); testWidgets( - 'Reference wallet scaffold supports the empty transaction state', + 'Tapping a transaction opens the detail page with the correct tx info', (tester) async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); final fakeWalletService = FakeWalletService( - walletInfo: const DemoWalletInfo( - title: 'Reference Wallet Scaffold', - network: WalletNetwork.testnet, - descriptor: - 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#demo1234', - descriptorLabel: 'Placeholder descriptor', - ), - transactions: const [], + walletInfo: _testWalletInfo, + transactions: _placeholderTransactions, ); - await tester.pumpWidget( - ProviderScope( - overrides: [ - storageServiceProvider.overrideWithValue( - StorageService(prefs: prefs), - ), - walletServiceProvider.overrideWithValue(fakeWalletService), - ], - child: const App(), - ), + await _pumpApp(tester, walletService: fakeWalletService); + + await tester.tap(find.text('Use an Active Wallet')); + await tester.pumpAndSettle(); + await tester.tap(find.text('Load Reference Scaffold')); + await tester.pumpAndSettle(); + + await tester.scrollUntilVisible( + find.text('123456...abcd'), + 200, + scrollable: find.byType(Scrollable).first, ); await tester.pumpAndSettle(); + await tester.tap(find.text('123456...abcd')); + await tester.pumpAndSettle(); + + expect(find.text('Transaction Detail'), findsOneWidget); + expect( + find.text( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + ), + findsOneWidget, + ); + expect(find.text('+42000 sat'), findsNWidgets(2)); + expect(find.text('confirmed'), findsNWidgets(2)); + expect(find.text('120'), findsOneWidget); + expect(find.text('January 2 2024 03:04'), findsOneWidget); + }, + ); + + testWidgets( + 'Reference wallet scaffold supports the empty transaction state', + (tester) async { + final fakeWalletService = FakeWalletService( + walletInfo: _testWalletInfo, + transactions: const [], + ); + + await _pumpApp(tester, walletService: fakeWalletService); + await tester.tap(find.text('Use an Active Wallet')); await tester.pumpAndSettle(); await tester.tap(find.text('Load Reference Scaffold')); @@ -188,4 +189,26 @@ void main() { expect(find.text('No transactions yet'), findsOneWidget); }, ); + + testWidgets('Transaction detail page handles a missing tx gracefully', ( + tester, + ) async { + final fakeWalletService = FakeWalletService( + walletInfo: _testWalletInfo, + transactions: const [], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [walletServiceProvider.overrideWithValue(fakeWalletService)], + child: const MaterialApp( + home: TransactionDetailPage(txid: 'missing-txid'), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Transaction not found'), findsOneWidget); + expect(find.textContaining('missing-txid'), findsOneWidget); + }); } From bebb956223c71d0e9e63ae5d11dabc9dc7ff9193 Mon Sep 17 00:00:00 2001 From: j-kon Date: Tue, 24 Mar 2026 18:45:06 +0100 Subject: [PATCH 3/8] feat: enhance transaction detail page to refresh on txid change with tests --- .../wallet_setup/transaction_detail_page.dart | 21 +++++-- .../test/presentation/app_shell_test.dart | 55 +++++++++++++++++++ 2 files changed, 72 insertions(+), 4 deletions(-) diff --git a/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart b/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart index 99769ab..be65aa4 100644 --- a/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart +++ b/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart @@ -19,16 +19,29 @@ class TransactionDetailPage extends ConsumerStatefulWidget { } class _TransactionDetailPageState extends ConsumerState { - late final Future _transactionFuture; + late Future _transactionFuture; - @override - void initState() { - super.initState(); + void _loadTransactionFuture() { _transactionFuture = ref .read(walletServiceProvider) .loadTransactionByTxid(widget.txid); } + @override + void initState() { + super.initState(); + _loadTransactionFuture(); + } + + @override + void didUpdateWidget(covariant TransactionDetailPage oldWidget) { + super.didUpdateWidget(oldWidget); + + if (oldWidget.txid != widget.txid) { + _loadTransactionFuture(); + } + } + String _formatAmount(TxDetails transaction) { final amount = transaction.netAmount; final prefix = amount >= 0 ? '+' : '-'; diff --git a/bdk_demo/test/presentation/app_shell_test.dart b/bdk_demo/test/presentation/app_shell_test.dart index 8ae0dc9..c4a38cb 100644 --- a/bdk_demo/test/presentation/app_shell_test.dart +++ b/bdk_demo/test/presentation/app_shell_test.dart @@ -190,6 +190,61 @@ void main() { }, ); + testWidgets('Transaction detail page refreshes when txid changes', ( + tester, + ) async { + final fakeWalletService = FakeWalletService( + walletInfo: _testWalletInfo, + transactions: _placeholderTransactions, + ); + + Future pumpDetail(String txid) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + walletServiceProvider.overrideWithValue(fakeWalletService), + ], + child: MaterialApp( + home: TransactionDetailPage( + key: const ValueKey('detail-page'), + txid: txid, + ), + ), + ), + ); + } + + await pumpDetail(_placeholderTransactions.first.txid); + await tester.pumpAndSettle(); + + expect( + find.text( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + ), + findsOneWidget, + ); + expect(find.text('January 2 2024 03:04'), findsOneWidget); + + await pumpDetail(_placeholderTransactions.last.txid); + await tester.pumpAndSettle(); + + expect( + find.text( + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ), + findsOneWidget, + ); + expect(find.text('-1600 sat'), findsNWidgets(2)); + expect(find.text('pending'), findsNWidgets(2)); + expect( + find.text( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + ), + findsNothing, + ); + expect(find.text('January 2 2024 03:04'), findsNothing); + }); + testWidgets('Transaction detail page handles a missing tx gracefully', ( tester, ) async { From 49ee27a3bd367d5fbace28256096b24b0afea5cb Mon Sep 17 00:00:00 2001 From: j-kon Date: Tue, 14 Apr 2026 14:26:51 +0100 Subject: [PATCH 4/8] feat: refine wallet service imports and enhance active wallets page tests --- bdk_demo/lib/services/wallet_service.dart | 2 +- .../active_wallets_page_test.dart | 366 ++++++------------ 2 files changed, 117 insertions(+), 251 deletions(-) diff --git a/bdk_demo/lib/services/wallet_service.dart b/bdk_demo/lib/services/wallet_service.dart index d3f7253..32181fb 100644 --- a/bdk_demo/lib/services/wallet_service.dart +++ b/bdk_demo/lib/services/wallet_service.dart @@ -1,4 +1,4 @@ -import 'package:bdk_dart/bdk.dart'; +import 'package:bdk_dart/bdk.dart' hide TxDetails; import 'package:uuid/uuid.dart'; import 'package:bdk_demo/core/constants/app_constants.dart'; import 'package:bdk_demo/models/tx_details.dart'; diff --git a/bdk_demo/test/presentation/active_wallets_page_test.dart b/bdk_demo/test/presentation/active_wallets_page_test.dart index a5ecbb6..4b07a58 100644 --- a/bdk_demo/test/presentation/active_wallets_page_test.dart +++ b/bdk_demo/test/presentation/active_wallets_page_test.dart @@ -1,282 +1,148 @@ -import 'dart:async'; import 'package:bdk_demo/features/wallet_setup/active_wallets_page.dart'; -import 'package:bdk_demo/core/router/app_router.dart'; +import 'package:bdk_demo/models/tx_details.dart'; import 'package:bdk_demo/models/wallet_record.dart'; -import 'package:bdk_demo/providers/settings_providers.dart'; import 'package:bdk_demo/providers/wallet_providers.dart'; -import 'package:bdk_demo/services/storage_service.dart'; import 'package:bdk_demo/services/wallet_service.dart'; -import 'package:bdk_dart/bdk.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:go_router/go_router.dart'; -import 'package:shared_preferences/shared_preferences.dart'; -import 'package:uuid/uuid.dart'; -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); +const _testWalletInfo = DemoWalletInfo( + title: 'Reference Wallet Scaffold', + network: WalletNetwork.testnet, + descriptor: 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#demo1234', + descriptorLabel: 'Placeholder descriptor', +); + +final _placeholderTransactions = [ + TxDetails( + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + sent: 0, + received: 42000, + balanceDelta: 42000, + pending: false, + blockHeight: 120, + confirmationTime: DateTime(2024, 1, 2, 3, 4), + ), + TxDetails( + txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + sent: 1600, + received: 0, + balanceDelta: -1600, + pending: true, + ), +]; + +class _FakeWalletService extends WalletService { + final DemoWalletInfo walletInfo; + final List transactions; + + _FakeWalletService({required this.walletInfo, required this.transactions}); - late StorageService storageService; + @override + Future loadReferenceWallet() async => walletInfo; - Future initStorage() async { - SharedPreferences.setMockInitialValues({}); - FlutterSecureStorage.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - return StorageService(prefs: prefs); - } + @override + Future> loadTransactions() async => transactions; +} - GoRouter testRouter() { - return GoRouter( - initialLocation: AppRoutes.activeWallets, - routes: [ - GoRoute( - path: AppRoutes.activeWallets, - builder: (context, state) => const ActiveWalletsPage(), - ), - GoRoute( - path: AppRoutes.createWallet, - builder: (context, state) => - const Scaffold(body: Text('Create Wallet Page')), - ), - GoRoute( - path: AppRoutes.home, - builder: (context, state) => const Scaffold(body: Text('Home')), - ), - ], - ); - } +Future _pumpPage( + WidgetTester tester, { + required WalletService walletService, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [walletServiceProvider.overrideWithValue(walletService)], + child: const MaterialApp(home: ActiveWalletsPage()), + ), + ); + await tester.pumpAndSettle(); +} - Future pumpActiveWalletsPage( - WidgetTester tester, - ProviderContainer container, - ) async { - await tester.pumpWidget( - UncontrolledProviderScope( - container: container, - child: MaterialApp.router(routerConfig: testRouter()), - ), - ); - await tester.pumpAndSettle(); - } +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); - group('ActiveWalletsPage empty state', () { - testWidgets('shows empty state and navigates to create wallet', ( + testWidgets('shows scaffold intro before loading', (tester) async { + await _pumpPage( tester, - ) async { - storageService = await initStorage(); - final container = ProviderContainer( - overrides: [storageServiceProvider.overrideWithValue(storageService)], - ); - addTearDown(container.dispose); - - await pumpActiveWalletsPage(tester, container); + walletService: _FakeWalletService( + walletInfo: _testWalletInfo, + transactions: _placeholderTransactions, + ), + ); - expect(find.text('No wallets yet'), findsOneWidget); - expect(find.text('Create a Wallet'), findsOneWidget); + expect(find.text('Reference Wallet Scaffold'), findsNWidgets(2)); + expect(find.text('Load Reference Scaffold'), findsOneWidget); + expect(find.text('Wallet not loaded yet'), findsOneWidget); - await tester.tap(find.text('Create a Wallet')); - await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('Transactions will appear here'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); - expect(find.text('Create Wallet Page'), findsOneWidget); - }); + expect(find.text('Transactions will appear here'), findsOneWidget); }); - group('ActiveWalletsPage with wallets', () { - testWidgets('renders wallet cards with name and chips', (tester) async { - storageService = await initStorage(); - - final container = ProviderContainer( - overrides: [storageServiceProvider.overrideWithValue(storageService)], - ); - addTearDown(container.dispose); - - await storageService.addWalletRecord( - WalletRecord( - id: 'w1', - name: 'Testnet Wallet', - network: WalletNetwork.testnet, - scriptType: ScriptType.p2wpkh, - ), - WalletSecrets( - descriptor: 'dummy-desc', - changeDescriptor: 'dummy-change', - ), - ); - await storageService.addWalletRecord( - WalletRecord( - id: 'w2', - name: 'Signet Taproot', - network: WalletNetwork.signet, - scriptType: ScriptType.p2tr, - ), - WalletSecrets( - descriptor: 'dummy-desc-2', - changeDescriptor: 'dummy-change-2', - ), - ); - - await pumpActiveWalletsPage(tester, container); - - expect(find.text('Testnet Wallet'), findsOneWidget); - expect(find.text('Testnet 3'), findsOneWidget); - expect(find.text('P2WPKH'), findsOneWidget); - - expect(find.text('Signet Taproot'), findsOneWidget); - expect(find.text('Signet'), findsOneWidget); - expect(find.text('P2TR'), findsOneWidget); - }); - - testWidgets('successful load sets active providers and navigates home', ( + testWidgets('loads and renders placeholder transactions', (tester) async { + await _pumpPage( tester, - ) async { - storageService = await initStorage(); - final walletService = WalletService( - storage: storageService, - uuid: const Uuid(), - ); - - final (record, createdWallet) = await walletService.createWallet( - 'Load Me', - WalletNetwork.testnet, - ScriptType.p2wpkh, - ); - createdWallet.dispose(); - - final container = ProviderContainer( - overrides: [storageServiceProvider.overrideWithValue(storageService)], - ); - addTearDown(container.dispose); + walletService: _FakeWalletService( + walletInfo: _testWalletInfo, + transactions: _placeholderTransactions, + ), + ); - await pumpActiveWalletsPage(tester, container); + await tester.tap(find.text('Load Reference Scaffold')); + await tester.pumpAndSettle(); - await tester.tap(find.text('Load Me')); - await tester.pumpAndSettle(); + expect(find.text('Wallet Snapshot'), findsOneWidget); + expect(find.text('Testnet 3'), findsOneWidget); + expect(find.text('Placeholder descriptor'), findsOneWidget); - expect(find.text('Home'), findsOneWidget); - expect(container.read(activeWalletRecordProvider)?.id, record.id); + await tester.scrollUntilVisible( + find.text('confirmed'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); - final activeWallet = container.read(activeWalletProvider); - expect(activeWallet, isNotNull); - }); + expect(find.text('+42000 sat'), findsOneWidget); + expect(find.text('-1600 sat'), findsOneWidget); + expect(find.text('123456...abcd'), findsOneWidget); + expect(find.text('abcdef...7890'), findsOneWidget); + expect(find.text('confirmed'), findsOneWidget); + expect(find.text('pending'), findsOneWidget); + }); - testWidgets('missing secrets shows error and does not navigate', ( + testWidgets('shows empty transaction state when no rows are returned', ( + tester, + ) async { + await _pumpPage( tester, - ) async { - storageService = await initStorage(); - - const record = WalletRecord( - id: 'missing-secrets-id', - name: 'Missing Secrets Wallet', - network: WalletNetwork.testnet, - scriptType: ScriptType.p2wpkh, - ); - - await storageService.addWalletRecord( - record, - const WalletSecrets( - descriptor: 'dummy-desc', - changeDescriptor: 'dummy-change', - ), - ); - - await const FlutterSecureStorage().delete( - key: 'wallet_secrets_${record.id}', - ); - expect(await storageService.getSecrets(record.id), isNull); - - final container = ProviderContainer( - overrides: [storageServiceProvider.overrideWithValue(storageService)], - ); - addTearDown(container.dispose); - - await pumpActiveWalletsPage(tester, container); - - await tester.tap(find.text('Missing Secrets Wallet')); - await tester.pumpAndSettle(); - - expect(find.text('Secrets not found for this wallet'), findsOneWidget); - expect(find.text('Home'), findsNothing); - expect(find.byType(ActiveWalletsPage), findsOneWidget); - }); - - testWidgets( - 'disposes loaded wallet if page unmounts before await returns', - (tester) async { - storageService = await initStorage(); - - const record = WalletRecord( - id: 'pending-load-id', - name: 'Pending Load Wallet', - network: WalletNetwork.signet, - scriptType: ScriptType.p2tr, - ); - - await storageService.addWalletRecord( - record, - const WalletSecrets( - descriptor: 'dummy-desc', - changeDescriptor: 'dummy-change', - ), - ); - - final realWalletService = WalletService( - storage: storageService, - uuid: const Uuid(), - ); - final (_, wallet) = await realWalletService.createWallet( - 'Load Candidate', - WalletNetwork.signet, - ScriptType.p2tr, - ); - - final completer = Completer(); - final delayedService = _DelayedLoadWalletService( - storage: storageService, - completer: completer, - ); - - var disposeCalls = 0; - final container = ProviderContainer( - overrides: [ - storageServiceProvider.overrideWithValue(storageService), - walletServiceProvider.overrideWithValue(delayedService), - walletDisposerProvider.overrideWithValue((wallet) { - disposeCalls += 1; - wallet.dispose(); - }), - ], - ); - addTearDown(container.dispose); - - await pumpActiveWalletsPage(tester, container); - - await tester.tap(find.text('Pending Load Wallet')); - await tester.pump(); + walletService: _FakeWalletService( + walletInfo: _testWalletInfo, + transactions: const [], + ), + ); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); + await tester.tap(find.text('Load Reference Scaffold')); + await tester.pumpAndSettle(); - completer.complete(wallet); - await tester.pumpAndSettle(); + await tester.scrollUntilVisible( + find.text('No transactions yet'), + 200, + scrollable: find.byType(Scrollable).first, + ); + await tester.pumpAndSettle(); - expect(disposeCalls, 1); - expect(container.read(activeWalletProvider), isNull); - expect(container.read(activeWalletRecordProvider), isNull); - }, + expect(find.text('No transactions yet'), findsOneWidget); + expect( + find.text( + 'The scaffold loaded successfully, but no placeholder transactions are configured yet.', + ), + findsOneWidget, ); }); } - -class _DelayedLoadWalletService extends WalletService { - _DelayedLoadWalletService({required super.storage, required this.completer}) - : super(uuid: const Uuid()); - - final Completer completer; - - @override - Future loadWalletFromRecord(WalletRecord record) { - return completer.future; - } -} From 8c77d9dd26d5039ae46bbd674284b4b43660d9bc Mon Sep 17 00:00:00 2001 From: j-kon Date: Tue, 14 Apr 2026 14:43:47 +0100 Subject: [PATCH 5/8] fix: reformat wallet_service.dart for consistency --- bdk_demo/lib/services/wallet_service.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/bdk_demo/lib/services/wallet_service.dart b/bdk_demo/lib/services/wallet_service.dart index 32181fb..beb785c 100644 --- a/bdk_demo/lib/services/wallet_service.dart +++ b/bdk_demo/lib/services/wallet_service.dart @@ -61,7 +61,9 @@ class WalletService { StorageService get _requiredStorage { final storage = _storage; if (storage == null) { - throw StateError('WalletService requires StorageService for this action.'); + throw StateError( + 'WalletService requires StorageService for this action.', + ); } return storage; } From 380d1e0f741f570534a1a20874151e5d7b27c9ae Mon Sep 17 00:00:00 2001 From: j-kon Date: Wed, 15 Apr 2026 02:38:28 +0100 Subject: [PATCH 6/8] chore: drop bdk_demo README changes from transaction scaffold PR --- bdk_demo/README.md | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/bdk_demo/README.md b/bdk_demo/README.md index ac2be2b..859fdfa 100644 --- a/bdk_demo/README.md +++ b/bdk_demo/README.md @@ -1,10 +1,10 @@ # BDK-Dart Wallet (Flutter) -The _BDK-Dart Wallet_ is a Flutter reference app for [bitcoindevkit](https://github.com/bitcoindevkit) using [bdk-dart](https://github.com/bitcoindevkit/bdk-dart). It is intentionally a demo and scaffold, not a production-ready wallet, and currently targets Signet, Testnet 3, and Regtest. +The _BDK-Dart Wallet_ is a wallet built as a reference app for the [bitcoindevkit](https://github.com/bitcoindevkit) on Flutter using [bdk-dart](https://github.com/bitcoindevkit/bdk-dart). This repository is not intended to produce a production-ready wallet, the app only works on Signet, Testnet 3, and Regtest. The demo app is built with the following goals in mind: 1. Be a reference application for the `bdk_dart` API on Flutter (iOS & Android). -2. Sketch the wallet creation, recovery, sync, send, receive, and transaction-history flows the app can grow into over time. +2. Showcase the core features of the bitcoindevkit library: wallet creation, recovery, Esplora/Electrum sync, send, receive, and transaction history. 3. Demonstrate a clean, testable Flutter architecture using Riverpod and GoRouter. ## Features @@ -19,14 +19,12 @@ The demo app is built with the following goals in mind: | Wallet balance (BTC / sats toggle) | - | | Receive (address generation + QR) | - | | Send (single recipient + fee rate) | - | -| Transaction history | Scaffolded placeholder UI | +| Transaction history | - | | Transaction detail | - | | Recovery data viewer | - | | Theme toggle (light / dark) | - | | In-app log viewer | - | -Today the active-wallet flow is deliberately small: it loads a wallet scaffold, shows placeholder wallet metadata, and renders placeholder transaction rows. No real wallet sync or transaction fetching is implemented yet. - ## Architecture Clean Architecture + Riverpod: @@ -44,9 +42,9 @@ lib/ **Note:** - **State management:** Riverpod - **Navigation:** GoRouter -- **Domain objects:** Uses app-local scaffold models with room to grow into fuller `bdk_dart` integrations -- **Secure storage:** Planned for mnemonic and descriptor handling as wallet flows land -- **Heavy sync work:** Planned to move off the UI isolate when real sync is added +- **Domain objects:** Uses `bdk_dart` types directly +- **Secure storage:** `flutter_secure_storage` for mnemonics and descriptors +- **BDK threading:** `Isolate.run()` for heavy sync operations ## Getting Started From d46ca6e9d9b0df0eaf8bfc4591732885575a2b02 Mon Sep 17 00:00:00 2001 From: j-kon Date: Wed, 15 Apr 2026 03:06:16 +0100 Subject: [PATCH 7/8] refactor: share wallet UI helpers in bdk_demo --- .../shared/widgets/wallet_ui_helpers.dart | 140 ++++++++++++ .../wallet_setup/active_wallets_page.dart | 200 ++---------------- .../wallet_setup/transaction_detail_page.dart | 139 ++---------- bdk_demo/lib/models/tx_details.dart | 6 +- 4 files changed, 177 insertions(+), 308 deletions(-) create mode 100644 bdk_demo/lib/features/shared/widgets/wallet_ui_helpers.dart diff --git a/bdk_demo/lib/features/shared/widgets/wallet_ui_helpers.dart b/bdk_demo/lib/features/shared/widgets/wallet_ui_helpers.dart new file mode 100644 index 0000000..540d772 --- /dev/null +++ b/bdk_demo/lib/features/shared/widgets/wallet_ui_helpers.dart @@ -0,0 +1,140 @@ +import 'package:flutter/material.dart'; + +import 'package:bdk_demo/core/theme/app_theme.dart'; + +class WalletStateCard extends StatelessWidget { + final IconData icon; + final String title; + final String message; + final Color? accentColor; + final bool showSpinner; + final bool centered; + + const WalletStateCard({ + super.key, + required this.icon, + required this.title, + required this.message, + this.accentColor, + this.showSpinner = false, + this.centered = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final color = accentColor ?? theme.colorScheme.primary; + + final card = Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + showSpinner + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Icon(icon, color: color), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text(message, style: theme.textTheme.bodyMedium), + ], + ), + ), + ], + ), + ), + ); + + if (!centered) return card; + + return Center( + child: Padding(padding: const EdgeInsets.all(24), child: card), + ); + } +} + +class WalletDetailRow extends StatelessWidget { + final String label; + final String value; + final bool monospace; + + const WalletDetailRow({ + super.key, + required this.label, + required this.value, + this.monospace = false, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + const SizedBox(height: 4), + Text( + value, + style: monospace + ? AppTheme.monoStyle.copyWith( + fontSize: 13, + color: theme.colorScheme.onSurface, + ) + : theme.textTheme.bodyLarge, + ), + ], + ); + } +} + +class WalletStatusChip extends StatelessWidget { + final String status; + + const WalletStatusChip({super.key, required this.status}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isPending = status == 'pending'; + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(999), + color: isPending + ? theme.colorScheme.secondaryContainer + : theme.colorScheme.primaryContainer, + ), + child: Text( + status, + style: theme.textTheme.labelMedium?.copyWith( + color: isPending + ? theme.colorScheme.onSecondaryContainer + : theme.colorScheme.onPrimaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ); + } +} diff --git a/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart index b329b07..2f1a4f9 100644 --- a/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart +++ b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; 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/models/currency_unit.dart'; import 'package:bdk_demo/models/tx_details.dart'; import 'package:bdk_demo/providers/wallet_providers.dart'; @@ -63,8 +64,6 @@ class _ActiveWalletsPageState extends ConsumerState { return; } - await Future.delayed(Duration.zero); - try { final transactions = await walletService.loadTransactions(); if (!mounted) return; @@ -192,16 +191,18 @@ class _ActiveWalletsPageState extends ConsumerState { Widget _buildWalletSection(ThemeData theme) { return switch (_walletState) { - _LoadState.idle => _InfoCard( + _LoadState.idle => WalletStateCard( icon: Icons.info_outline, title: 'Wallet not loaded yet', message: _statusMessage, ), - _LoadState.loading => const _LoadingCard( + _LoadState.loading => const WalletStateCard( + icon: Icons.hourglass_bottom, title: 'Loading wallet', message: 'Preparing placeholder wallet details...', + showSpinner: true, ), - _LoadState.error => _InfoCard( + _LoadState.error => WalletStateCard( icon: Icons.error_outline, title: 'Wallet load failed', message: _walletError ?? _statusMessage, @@ -213,20 +214,20 @@ class _ActiveWalletsPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _DetailRow(label: 'Wallet', value: _walletInfo!.title), + WalletDetailRow(label: 'Wallet', value: _walletInfo!.title), const SizedBox(height: 12), - _DetailRow( + WalletDetailRow( label: 'Network', value: _walletInfo!.network.displayName, ), const SizedBox(height: 12), - _DetailRow( + WalletDetailRow( label: _walletInfo!.descriptorLabel, value: _descriptorPreview(_walletInfo!.descriptor), monospace: true, ), const SizedBox(height: 12), - _DetailRow(label: 'Status', value: _statusMessage), + WalletDetailRow(label: 'Status', value: _statusMessage), ], ), ), @@ -236,7 +237,7 @@ class _ActiveWalletsPageState extends ConsumerState { Widget _buildTransactionsSection(ThemeData theme) { if (_walletState == _LoadState.idle) { - return const _InfoCard( + return const WalletStateCard( icon: Icons.receipt_long_outlined, title: 'Transactions will appear here', message: @@ -245,14 +246,16 @@ class _ActiveWalletsPageState extends ConsumerState { } if (_walletState == _LoadState.loading) { - return const _LoadingCard( + return const WalletStateCard( + icon: Icons.hourglass_bottom, title: 'Waiting for wallet', message: 'Transaction UI becomes available after the scaffold loads.', + showSpinner: true, ); } if (_walletState == _LoadState.error) { - return const _InfoCard( + return const WalletStateCard( icon: Icons.receipt_long_outlined, title: 'Transactions unavailable', message: @@ -261,17 +264,19 @@ class _ActiveWalletsPageState extends ConsumerState { } return switch (_transactionState) { - _LoadState.idle => const _InfoCard( + _LoadState.idle => const WalletStateCard( icon: Icons.receipt_long_outlined, title: 'Transactions not loaded yet', message: 'Placeholder transaction rows will appear after the scaffold finishes loading.', ), - _LoadState.loading => const _LoadingCard( + _LoadState.loading => const WalletStateCard( + icon: Icons.hourglass_bottom, title: 'Loading placeholder transactions...', message: 'Preparing scaffolded transaction rows.', + showSpinner: true, ), - _LoadState.error => _InfoCard( + _LoadState.error => WalletStateCard( icon: Icons.error_outline, title: 'Placeholder transactions failed', message: @@ -281,7 +286,7 @@ class _ActiveWalletsPageState extends ConsumerState { ), _LoadState.success => _transactions.isEmpty - ? const _InfoCard( + ? const WalletStateCard( icon: Icons.history_toggle_off, title: 'No transactions yet', message: @@ -344,136 +349,6 @@ class _SectionHeading extends StatelessWidget { } } -class _InfoCard extends StatelessWidget { - final IconData icon; - final String title; - final String message; - final Color? accentColor; - - const _InfoCard({ - required this.icon, - required this.title, - required this.message, - this.accentColor, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final color = accentColor ?? theme.colorScheme.primary; - - return Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, color: color), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text(message, style: theme.textTheme.bodyMedium), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _LoadingCard extends StatelessWidget { - final String title; - final String message; - - const _LoadingCard({required this.title, required this.message}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - children: [ - const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text(message, style: theme.textTheme.bodyMedium), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _DetailRow extends StatelessWidget { - final String label; - final String value; - final bool monospace; - - const _DetailRow({ - required this.label, - required this.value, - this.monospace = false, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(170), - ), - ), - const SizedBox(height: 4), - Text( - value, - style: monospace - ? AppTheme.monoStyle.copyWith( - fontSize: 13, - color: theme.colorScheme.onSurface, - ) - : theme.textTheme.bodyLarge, - ), - ], - ); - } -} - class _TransactionRow extends StatelessWidget { final TxDetails transaction; final VoidCallback onTap; @@ -525,7 +400,7 @@ class _TransactionRow extends StatelessWidget { ), ), const SizedBox(width: 12), - _StatusChip(status: transaction.statusLabel), + WalletStatusChip(status: transaction.statusLabel), ], ), const SizedBox(height: 8), @@ -550,34 +425,3 @@ class _TransactionRow extends StatelessWidget { ); } } - -class _StatusChip extends StatelessWidget { - final String status; - - const _StatusChip({required this.status}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isPending = status == 'pending'; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(999), - color: isPending - ? theme.colorScheme.secondaryContainer - : theme.colorScheme.primaryContainer, - ), - child: Text( - status, - style: theme.textTheme.labelMedium?.copyWith( - color: isPending - ? theme.colorScheme.onSecondaryContainer - : theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} diff --git a/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart b/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart index be65aa4..9bcd437 100644 --- a/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart +++ b/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; 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/models/currency_unit.dart'; import 'package:bdk_demo/models/tx_details.dart'; import 'package:bdk_demo/providers/wallet_providers.dart'; @@ -66,31 +67,34 @@ class _TransactionDetailPageState extends ConsumerState { future: _transactionFuture, builder: (context, snapshot) { if (snapshot.connectionState != ConnectionState.done) { - return const _StateCard( + return const WalletStateCard( icon: Icons.hourglass_bottom, title: 'Loading transaction', message: 'Preparing placeholder transaction details...', showSpinner: true, + centered: true, ); } if (snapshot.hasError) { - return _StateCard( + return WalletStateCard( icon: Icons.error_outline, title: 'Transaction unavailable', message: 'The scaffold could not load placeholder transaction details.', accentColor: theme.colorScheme.error, + centered: true, ); } final transaction = snapshot.data; if (transaction == null) { - return _StateCard( + return WalletStateCard( icon: Icons.search_off, title: 'Transaction not found', message: 'No placeholder transaction was found for this txid.\n\n${widget.txid}', + centered: true, ); } @@ -113,7 +117,7 @@ class _TransactionDetailPageState extends ConsumerState { ), ), ), - _StatusChip(status: transaction.statusLabel), + WalletStatusChip(status: transaction.statusLabel), ], ), const SizedBox(height: 8), @@ -159,25 +163,25 @@ class _TransactionDetailPageState extends ConsumerState { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _DetailRow( + WalletDetailRow( label: 'Amount', value: _formatAmount(transaction), ), const SizedBox(height: 12), - _DetailRow( + WalletDetailRow( label: 'Status', value: transaction.statusLabel, ), if (transaction.blockHeight != null) ...[ const SizedBox(height: 12), - _DetailRow( + WalletDetailRow( label: 'Block height', value: '${transaction.blockHeight}', ), ], if (transaction.confirmationTime != null) ...[ const SizedBox(height: 12), - _DetailRow( + WalletDetailRow( label: 'Timestamp', value: _formatTimestamp( transaction.confirmationTime!, @@ -196,122 +200,3 @@ class _TransactionDetailPageState extends ConsumerState { ); } } - -class _StateCard extends StatelessWidget { - final IconData icon; - final String title; - final String message; - final Color? accentColor; - final bool showSpinner; - - const _StateCard({ - required this.icon, - required this.title, - required this.message, - this.accentColor, - this.showSpinner = false, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final color = accentColor ?? theme.colorScheme.primary; - - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - showSpinner - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : Icon(icon, color: color), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - title, - style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(height: 6), - Text(message, style: theme.textTheme.bodyMedium), - ], - ), - ), - ], - ), - ), - ), - ), - ); - } -} - -class _DetailRow extends StatelessWidget { - final String label; - final String value; - - const _DetailRow({required this.label, required this.value}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: theme.textTheme.labelLarge?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(170), - ), - ), - const SizedBox(height: 4), - Text(value, style: theme.textTheme.bodyLarge), - ], - ); - } -} - -class _StatusChip extends StatelessWidget { - final String status; - - const _StatusChip({required this.status}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isPending = status == 'pending'; - - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(999), - color: isPending - ? theme.colorScheme.secondaryContainer - : theme.colorScheme.primaryContainer, - ), - child: Text( - status, - style: theme.textTheme.labelMedium?.copyWith( - color: isPending - ? theme.colorScheme.onSecondaryContainer - : theme.colorScheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ); - } -} diff --git a/bdk_demo/lib/models/tx_details.dart b/bdk_demo/lib/models/tx_details.dart index dbd79f7..9db5808 100644 --- a/bdk_demo/lib/models/tx_details.dart +++ b/bdk_demo/lib/models/tx_details.dart @@ -1,3 +1,5 @@ +import 'package:bdk_demo/core/utils/formatters.dart'; + class TxDetails { final String txid; final int sent; @@ -23,9 +25,7 @@ class TxDetails { int get netAmount => balanceDelta ?? (received - sent); - String get shortTxid => txid.length > 10 - ? '${txid.substring(0, 6)}...${txid.substring(txid.length - 4)}' - : txid; + String get shortTxid => Formatters.abbreviateTxid(txid); String get statusLabel => pending ? 'pending' : 'confirmed'; } From 5b46853c10a051c29cba2b6df7800a2a937055bb Mon Sep 17 00:00:00 2001 From: j-kon Date: Sun, 19 Apr 2026 09:44:36 +0100 Subject: [PATCH 8/8] refactor: split bdk_demo transactions into standalone feature --- bdk_demo/lib/core/router/app_router.dart | 6 +- .../transactions/models/demo_tx_details.dart} | 12 +- .../transaction_detail_page.dart | 91 +--- .../transactions/transactions_controller.dart | 91 ++++ .../transactions/transactions_list_page.dart | 276 ++++++++++ .../transactions/transactions_repository.dart | 53 ++ .../wallet_setup/active_wallets_page.dart | 499 +++++------------- .../wallet_setup/wallet_choice_page.dart | 11 +- bdk_demo/lib/services/wallet_service.dart | 104 +--- .../fakes/fake_transactions_repository.dart | 29 + .../fixtures/placeholder_transactions.dart | 18 + .../active_wallets_page_test.dart | 367 +++++++++---- .../test/presentation/app_shell_test.dart | 265 +--------- .../test/presentation/router_wiring_test.dart | 14 + .../transaction_detail_page_test.dart | 104 ++++ .../transactions_list_page_test.dart | 117 ++++ 16 files changed, 1166 insertions(+), 891 deletions(-) rename bdk_demo/lib/{models/tx_details.dart => features/transactions/models/demo_tx_details.dart} (68%) rename bdk_demo/lib/features/{wallet_setup => transactions}/transaction_detail_page.dart (72%) create mode 100644 bdk_demo/lib/features/transactions/transactions_controller.dart create mode 100644 bdk_demo/lib/features/transactions/transactions_list_page.dart create mode 100644 bdk_demo/lib/features/transactions/transactions_repository.dart create mode 100644 bdk_demo/test/helpers/fakes/fake_transactions_repository.dart create mode 100644 bdk_demo/test/helpers/fixtures/placeholder_transactions.dart create mode 100644 bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart create mode 100644 bdk_demo/test/presentation/transactions/transactions_list_page_test.dart diff --git a/bdk_demo/lib/core/router/app_router.dart b/bdk_demo/lib/core/router/app_router.dart index 583fc62..f83c47e 100644 --- a/bdk_demo/lib/core/router/app_router.dart +++ b/bdk_demo/lib/core/router/app_router.dart @@ -1,8 +1,9 @@ import 'package:go_router/go_router.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'; import 'package:bdk_demo/features/wallet_setup/active_wallets_page.dart'; import 'package:bdk_demo/features/wallet_setup/create_wallet_page.dart'; -import 'package:bdk_demo/features/wallet_setup/transaction_detail_page.dart'; import 'package:bdk_demo/features/wallet_setup/wallet_choice_page.dart'; abstract final class AppRoutes { @@ -65,8 +66,7 @@ GoRouter createRouter() => GoRouter( GoRoute( path: AppRoutes.transactionHistory, name: 'transactionHistory', - builder: (context, state) => - const PlaceholderPage(title: 'Transaction History'), + builder: (context, state) => const TransactionsListPage(), ), GoRoute( path: AppRoutes.transactionDetail, diff --git a/bdk_demo/lib/models/tx_details.dart b/bdk_demo/lib/features/transactions/models/demo_tx_details.dart similarity index 68% rename from bdk_demo/lib/models/tx_details.dart rename to bdk_demo/lib/features/transactions/models/demo_tx_details.dart index 9db5808..4be76c1 100644 --- a/bdk_demo/lib/models/tx_details.dart +++ b/bdk_demo/lib/features/transactions/models/demo_tx_details.dart @@ -1,29 +1,23 @@ import 'package:bdk_demo/core/utils/formatters.dart'; -class TxDetails { +class DemoTxDetails { final String txid; final int sent; final int received; - final int fee; - final double? feeRate; - final int? balanceDelta; final bool pending; final int? blockHeight; final DateTime? confirmationTime; - const TxDetails({ + const DemoTxDetails({ required this.txid, required this.sent, required this.received, - this.fee = 0, - this.feeRate, - this.balanceDelta, this.pending = true, this.blockHeight, this.confirmationTime, }); - int get netAmount => balanceDelta ?? (received - sent); + int get netAmount => received - sent; String get shortTxid => Formatters.abbreviateTxid(txid); diff --git a/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart b/bdk_demo/lib/features/transactions/transaction_detail_page.dart similarity index 72% rename from bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart rename to bdk_demo/lib/features/transactions/transaction_detail_page.dart index 9bcd437..a48f154 100644 --- a/bdk_demo/lib/features/wallet_setup/transaction_detail_page.dart +++ b/bdk_demo/lib/features/transactions/transaction_detail_page.dart @@ -1,53 +1,22 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_riverpod/flutter_riverpod.dart'; - 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/transactions_controller.dart'; import 'package:bdk_demo/models/currency_unit.dart'; -import 'package:bdk_demo/models/tx_details.dart'; -import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; -class TransactionDetailPage extends ConsumerStatefulWidget { +class TransactionDetailPage extends ConsumerWidget { final String txid; const TransactionDetailPage({super.key, required this.txid}); - @override - ConsumerState createState() => - _TransactionDetailPageState(); -} - -class _TransactionDetailPageState extends ConsumerState { - late Future _transactionFuture; - - void _loadTransactionFuture() { - _transactionFuture = ref - .read(walletServiceProvider) - .loadTransactionByTxid(widget.txid); - } - - @override - void initState() { - super.initState(); - _loadTransactionFuture(); - } - - @override - void didUpdateWidget(covariant TransactionDetailPage oldWidget) { - super.didUpdateWidget(oldWidget); - - if (oldWidget.txid != widget.txid) { - _loadTransactionFuture(); - } - } - - String _formatAmount(TxDetails transaction) { + String _formatAmount(DemoTxDetails transaction) { final amount = transaction.netAmount; final prefix = amount >= 0 ? '+' : '-'; final value = Formatters.formatBalance(amount.abs(), CurrencyUnit.satoshi); - return '$prefix$value'; } @@ -57,43 +26,35 @@ class _TransactionDetailPageState extends ConsumerState { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final theme = Theme.of(context); + final transactionAsync = ref.watch(transactionDetailsProvider(txid)); return Scaffold( appBar: const SecondaryAppBar(title: 'Transaction Detail'), body: SafeArea( - child: FutureBuilder( - future: _transactionFuture, - builder: (context, snapshot) { - if (snapshot.connectionState != ConnectionState.done) { - return const WalletStateCard( - icon: Icons.hourglass_bottom, - title: 'Loading transaction', - message: 'Preparing placeholder transaction details...', - showSpinner: true, - centered: true, - ); - } - - if (snapshot.hasError) { - return WalletStateCard( - icon: Icons.error_outline, - title: 'Transaction unavailable', - message: - 'The scaffold could not load placeholder transaction details.', - accentColor: theme.colorScheme.error, - centered: true, - ); - } - - final transaction = snapshot.data; + child: transactionAsync.when( + loading: () => const WalletStateCard( + icon: Icons.hourglass_bottom, + title: 'Loading transaction', + message: 'Preparing placeholder transaction details...', + showSpinner: true, + centered: true, + ), + error: (_, __) => WalletStateCard( + icon: Icons.error_outline, + title: 'Transaction unavailable', + message: 'The demo could not load placeholder transaction details.', + accentColor: theme.colorScheme.error, + centered: true, + ), + data: (transaction) { if (transaction == null) { return WalletStateCard( icon: Icons.search_off, title: 'Transaction not found', message: - 'No placeholder transaction was found for this txid.\n\n${widget.txid}', + 'No placeholder transaction was found for this txid.\n\n$txid', centered: true, ); } @@ -122,7 +83,7 @@ class _TransactionDetailPageState extends ConsumerState { ), const SizedBox(height: 8), Text( - 'Scaffolded placeholder detail view for the selected transaction.', + 'Standalone transaction detail view for the selected placeholder 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 new file mode 100644 index 0000000..79aadb1 --- /dev/null +++ b/bdk_demo/lib/features/transactions/transactions_controller.dart @@ -0,0 +1,91 @@ +import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; +import 'package:bdk_demo/features/transactions/transactions_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum TransactionsLoadState { idle, loading, success, error } + +class TransactionsState { + final TransactionsLoadState status; + final List transactions; + final String statusMessage; + final String? errorMessage; + + const TransactionsState({ + required this.status, + required this.transactions, + required this.statusMessage, + this.errorMessage, + }); + + const TransactionsState.idle() + : this( + status: TransactionsLoadState.idle, + transactions: const [], + statusMessage: + 'Load the transaction demo to preview list and detail states.', + ); + + TransactionsState copyWith({ + TransactionsLoadState? status, + List? transactions, + String? statusMessage, + String? errorMessage, + }) { + return TransactionsState( + status: status ?? this.status, + transactions: transactions ?? this.transactions, + statusMessage: statusMessage ?? this.statusMessage, + errorMessage: errorMessage, + ); + } +} + +final transactionsControllerProvider = + NotifierProvider( + TransactionsController.new, + ); + +final transactionDetailsProvider = + FutureProvider.family((ref, txid) { + final repository = ref.read(transactionsRepositoryProvider); + return repository.loadTransactionByTxid(txid); + }); + +class TransactionsController extends Notifier { + @override + TransactionsState build() => const TransactionsState.idle(); + + Future loadTransactions() async { + state = state.copyWith( + status: TransactionsLoadState.loading, + transactions: const [], + statusMessage: 'Loading placeholder transactions...', + errorMessage: null, + ); + + try { + final transactions = await ref + .read(transactionsRepositoryProvider) + .loadTransactions(); + + state = state.copyWith( + status: TransactionsLoadState.success, + transactions: transactions, + statusMessage: transactions.isEmpty + ? 'Transaction demo loaded. No transactions yet.' + : 'Transaction demo loaded. Showing placeholder transaction rows.', + errorMessage: null, + ); + } catch (error) { + state = state.copyWith( + status: TransactionsLoadState.error, + transactions: const [], + statusMessage: 'The transaction demo could not be loaded.', + errorMessage: _readableError(error), + ); + } + } + + String _readableError(Object error) => + error.toString().replaceFirst('Exception: ', ''); +} diff --git a/bdk_demo/lib/features/transactions/transactions_list_page.dart b/bdk_demo/lib/features/transactions/transactions_list_page.dart new file mode 100644 index 0000000..63acc02 --- /dev/null +++ b/bdk_demo/lib/features/transactions/transactions_list_page.dart @@ -0,0 +1,276 @@ +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/transactions_controller.dart'; +import 'package:bdk_demo/models/currency_unit.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +class TransactionsListPage extends ConsumerWidget { + const TransactionsListPage({super.key}); + + void _openTransactionDetail(BuildContext context, DemoTxDetails transaction) { + context.pushNamed( + 'transactionDetail', + pathParameters: {'txid': transaction.txid}, + ); + } + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = Theme.of(context); + final state = ref.watch(transactionsControllerProvider); + final isLoading = state.status == TransactionsLoadState.loading; + + return Scaffold( + appBar: const SecondaryAppBar(title: 'Transactions Demo'), + body: SafeArea( + child: ListView( + padding: const EdgeInsets.all(24), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: theme.colorScheme.primaryContainer, + ), + child: Icon( + Icons.receipt_long_outlined, + color: theme.colorScheme.primary, + ), + ), + const SizedBox(height: 16), + Text( + 'Transactions Demo', + 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.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(180), + ), + ), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: isLoading + ? null + : () => ref + .read(transactionsControllerProvider.notifier) + .loadTransactions(), + icon: isLoading + ? SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + strokeWidth: 2, + color: theme.colorScheme.onPrimary, + ), + ) + : const Icon(Icons.download_rounded), + label: Text( + state.status == TransactionsLoadState.success || + state.status == TransactionsLoadState.error + ? 'Reload Transactions' + : 'Load Transactions Demo', + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + const _SectionHeading( + title: 'Transactions', + subtitle: 'Placeholder transaction list and detail navigation', + ), + const SizedBox(height: 12), + _TransactionsBody(state: state, onTap: _openTransactionDetail), + ], + ), + ), + ); + } +} + +class _TransactionsBody extends StatelessWidget { + final TransactionsState state; + final void Function(BuildContext context, DemoTxDetails transaction) onTap; + + const _TransactionsBody({required this.state, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return switch (state.status) { + TransactionsLoadState.idle => WalletStateCard( + icon: Icons.info_outline, + title: 'Transactions not loaded yet', + message: state.statusMessage, + ), + TransactionsLoadState.loading => const WalletStateCard( + icon: Icons.hourglass_bottom, + title: 'Loading placeholder transactions...', + message: 'Preparing scaffolded transaction rows.', + showSpinner: true, + ), + TransactionsLoadState.error => WalletStateCard( + icon: Icons.error_outline, + title: 'Transaction demo failed', + message: state.errorMessage ?? state.statusMessage, + accentColor: theme.colorScheme.error, + ), + TransactionsLoadState.success => + state.transactions.isEmpty + ? const WalletStateCard( + icon: Icons.history_toggle_off, + title: 'No transactions yet', + message: + 'The transaction demo loaded successfully, but no placeholder transactions are configured yet.', + ) + : Card( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + children: [ + for ( + var index = 0; + index < state.transactions.length; + index++ + ) ...[ + _TransactionRow( + transaction: state.transactions[index], + onTap: () => + onTap(context, state.transactions[index]), + ), + if (index < state.transactions.length - 1) + const SizedBox(height: 12), + ], + ], + ), + ), + ), + }; + } +} + +class _SectionHeading extends StatelessWidget { + final String title; + final String subtitle; + + const _SectionHeading({required this.title, required this.subtitle}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + ], + ); + } +} + +class _TransactionRow extends StatelessWidget { + final DemoTxDetails transaction; + final VoidCallback onTap; + + const _TransactionRow({required this.transaction, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final amount = transaction.netAmount; + final isIncoming = amount >= 0; + final accentColor = transaction.pending + ? theme.colorScheme.secondary + : isIncoming + ? Colors.green.shade700 + : theme.colorScheme.primary; + final amountLabel = + '${amount >= 0 ? '+' : '-'}${Formatters.formatBalance(amount.abs(), CurrencyUnit.satoshi)}'; + final subtitle = transaction.pending + ? 'Awaiting confirmation' + : transaction.blockHeight == null + ? 'Confirmed' + : 'Block ${transaction.blockHeight}'; + + return Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + child: Ink( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + border: Border.all(color: theme.colorScheme.outlineVariant), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + amountLabel, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: accentColor, + ), + ), + ), + const SizedBox(width: 12), + WalletStatusChip(status: transaction.statusLabel), + ], + ), + const SizedBox(height: 8), + Text( + transaction.shortTxid, + style: AppTheme.monoStyle.copyWith( + fontSize: 13, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(170), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/bdk_demo/lib/features/transactions/transactions_repository.dart b/bdk_demo/lib/features/transactions/transactions_repository.dart new file mode 100644 index 0000000..7f579af --- /dev/null +++ b/bdk_demo/lib/features/transactions/transactions_repository.dart @@ -0,0 +1,53 @@ +import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +abstract interface class TransactionsRepository { + Future> loadTransactions(); + 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 = [ + DemoTxDetails( + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + sent: 0, + received: 42000, + pending: false, + blockHeight: 120, + confirmationTime: DateTime(2024, 1, 2, 3, 4), + ), + const DemoTxDetails( + txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + sent: 1600, + received: 0, + pending: true, + ), + ]; + + @override + Future> loadTransactions() async { + await Future.delayed(delay); + return List.unmodifiable(_transactions); + } + + @override + Future loadTransactionByTxid(String txid) async { + final transactions = await loadTransactions(); + for (final transaction in transactions) { + if (transaction.txid == txid) return transaction; + } + return null; + } +} diff --git a/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart index 2f1a4f9..5ee8838 100644 --- a/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart +++ b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart @@ -1,18 +1,11 @@ +import 'package:bdk_demo/core/router/app_router.dart'; +import 'package:bdk_demo/features/shared/widgets/secondary_app_bar.dart'; +import 'package:bdk_demo/models/wallet_record.dart'; +import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; -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/models/currency_unit.dart'; -import 'package:bdk_demo/models/tx_details.dart'; -import 'package:bdk_demo/providers/wallet_providers.dart'; -import 'package:bdk_demo/services/wallet_service.dart'; - -enum _LoadState { idle, loading, success, error } - class ActiveWalletsPage extends ConsumerStatefulWidget { const ActiveWalletsPage({super.key}); @@ -21,403 +14,187 @@ class ActiveWalletsPage extends ConsumerStatefulWidget { } class _ActiveWalletsPageState extends ConsumerState { - _LoadState _walletState = _LoadState.idle; - _LoadState _transactionState = _LoadState.idle; - DemoWalletInfo? _walletInfo; - List _transactions = const []; - String _statusMessage = - 'Load the reference scaffold to preview wallet details and transaction presentation.'; - String? _walletError; - String? _transactionError; + String? _loadingWalletId; - Future _loadReferenceWallet() async { - final walletService = ref.read(walletServiceProvider); + Future _onLoadWallet(WalletRecord record) async { + if (_loadingWalletId != null) return; - setState(() { - _walletState = _LoadState.loading; - _transactionState = _LoadState.idle; - _walletInfo = null; - _transactions = const []; - _walletError = null; - _transactionError = null; - _statusMessage = 'Preparing the wallet scaffold...'; - }); + setState(() => _loadingWalletId = record.id); + final walletDisposer = ref.read(walletDisposerProvider); try { - final walletInfo = await walletService.loadReferenceWallet(); + final wallet = await ref + .read(walletServiceProvider) + .loadWalletFromRecord(record); + + if (!mounted) { + walletDisposer(wallet); + return; + } + + ref.read(activeWalletProvider.notifier).set(wallet); + ref.read(activeWalletRecordProvider.notifier).set(record); + context.go(AppRoutes.home); + } on StateError { if (!mounted) return; - - setState(() { - _walletState = _LoadState.success; - _transactionState = _LoadState.loading; - _walletInfo = walletInfo; - _statusMessage = 'Scaffold ready. Loading placeholder transactions...'; - }); - } catch (error) { + _showSnackBar('Secrets not found for this wallet'); + } catch (_) { if (!mounted) return; - - setState(() { - _walletState = _LoadState.error; - _walletError = _readableError(error); - _statusMessage = 'The wallet scaffold could not be loaded.'; - }); - return; + _showSnackBar('Failed to load wallet. Please try again.'); + } finally { + if (mounted) setState(() => _loadingWalletId = null); } - - try { - final transactions = await walletService.loadTransactions(); - if (!mounted) return; - - setState(() { - _transactionState = _LoadState.success; - _transactions = transactions; - _statusMessage = transactions.isEmpty - ? 'Scaffold loaded. No transactions yet.' - : 'Scaffold loaded. Showing placeholder transaction rows for future UI work.'; - }); - } catch (error) { - if (!mounted) return; - - setState(() { - _transactionState = _LoadState.error; - _transactionError = _readableError(error); - _statusMessage = - 'The wallet scaffold loaded, but placeholder transactions could not be shown.'; - }); - } - } - - String _readableError(Object error) => - error.toString().replaceFirst('Exception: ', ''); - - String _descriptorPreview(String descriptor) { - if (descriptor.length <= 48) return descriptor; - return '${descriptor.substring(0, 24)}...${descriptor.substring(descriptor.length - 18)}'; } - void _openTransactionDetail(TxDetails transaction) { - context.pushNamed( - 'transactionDetail', - pathParameters: {'txid': transaction.txid}, - ); + void _showSnackBar(String message) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); } @override Widget build(BuildContext context) { + final records = ref.watch(walletRecordsProvider); final theme = Theme.of(context); - final isWalletLoading = _walletState == _LoadState.loading; return Scaffold( - appBar: const SecondaryAppBar(title: 'Reference Wallet Scaffold'), - body: SafeArea( - child: ListView( - padding: const EdgeInsets.all(24), + appBar: const SecondaryAppBar(title: 'Active Wallets'), + body: records.isEmpty + ? _buildEmptyState(theme) + : _buildWalletList(records, theme), + ); + } + + Widget _buildEmptyState(ThemeData theme) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 56, - height: 56, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: theme.colorScheme.primaryContainer, - ), - child: Icon( - Icons.wallet_outlined, - color: theme.colorScheme.primary, - ), - ), - const SizedBox(height: 16), - Text( - 'Reference Wallet Scaffold', - style: theme.textTheme.headlineSmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 8), - Text( - 'Load a lightweight scaffold that previews wallet details and transaction rows. This is placeholder UI for future transaction visibility work, not a synced or functional wallet.', - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(180), - ), - ), - const SizedBox(height: 20), - FilledButton.icon( - onPressed: isWalletLoading ? null : _loadReferenceWallet, - icon: isWalletLoading - ? SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - strokeWidth: 2, - color: theme.colorScheme.onPrimary, - ), - ) - : const Icon(Icons.download_rounded), - label: Text( - _walletState == _LoadState.success || - _walletState == _LoadState.error - ? 'Reload Wallet Data' - : 'Load Reference Scaffold', - ), - ), - ], - ), - ), + Icon( + Icons.account_balance_wallet_outlined, + size: 64, + color: theme.colorScheme.onSurface.withAlpha(102), ), - const SizedBox(height: 24), - const _SectionHeading( - title: 'Wallet Snapshot', - subtitle: 'Network, descriptor preview, and current status', + const SizedBox(height: 16), + Text( + 'No wallets yet', + style: theme.textTheme.titleMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(153), + ), ), - const SizedBox(height: 12), - _buildWalletSection(theme), const SizedBox(height: 24), - const _SectionHeading( - title: 'Transactions', - subtitle: 'Placeholder transaction visibility for future work', + FilledButton.tonal( + onPressed: () => context.push(AppRoutes.createWallet), + child: const Text('Create a Wallet'), ), - const SizedBox(height: 12), - _buildTransactionsSection(theme), ], ), ), ); } - Widget _buildWalletSection(ThemeData theme) { - return switch (_walletState) { - _LoadState.idle => WalletStateCard( - icon: Icons.info_outline, - title: 'Wallet not loaded yet', - message: _statusMessage, - ), - _LoadState.loading => const WalletStateCard( - icon: Icons.hourglass_bottom, - title: 'Loading wallet', - message: 'Preparing placeholder wallet details...', - showSpinner: true, - ), - _LoadState.error => WalletStateCard( - icon: Icons.error_outline, - title: 'Wallet load failed', - message: _walletError ?? _statusMessage, - accentColor: theme.colorScheme.error, - ), - _LoadState.success => Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - WalletDetailRow(label: 'Wallet', value: _walletInfo!.title), - const SizedBox(height: 12), - WalletDetailRow( - label: 'Network', - value: _walletInfo!.network.displayName, - ), - const SizedBox(height: 12), - WalletDetailRow( - label: _walletInfo!.descriptorLabel, - value: _descriptorPreview(_walletInfo!.descriptor), - monospace: true, - ), - const SizedBox(height: 12), - WalletDetailRow(label: 'Status', value: _statusMessage), - ], - ), - ), - ), - }; - } - - Widget _buildTransactionsSection(ThemeData theme) { - if (_walletState == _LoadState.idle) { - return const WalletStateCard( - icon: Icons.receipt_long_outlined, - title: 'Transactions will appear here', - message: - 'Load the scaffold first, then the demo will show placeholder transaction UI.', - ); - } - - if (_walletState == _LoadState.loading) { - return const WalletStateCard( - icon: Icons.hourglass_bottom, - title: 'Waiting for wallet', - message: 'Transaction UI becomes available after the scaffold loads.', - showSpinner: true, - ); - } - - if (_walletState == _LoadState.error) { - return const WalletStateCard( - icon: Icons.receipt_long_outlined, - title: 'Transactions unavailable', - message: - 'Fix the scaffold load error before retrying placeholder transactions.', - ); - } - - return switch (_transactionState) { - _LoadState.idle => const WalletStateCard( - icon: Icons.receipt_long_outlined, - title: 'Transactions not loaded yet', - message: - 'Placeholder transaction rows will appear after the scaffold finishes loading.', - ), - _LoadState.loading => const WalletStateCard( - icon: Icons.hourglass_bottom, - title: 'Loading placeholder transactions...', - message: 'Preparing scaffolded transaction rows.', - showSpinner: true, - ), - _LoadState.error => WalletStateCard( - icon: Icons.error_outline, - title: 'Placeholder transactions failed', - message: - _transactionError ?? - 'Unable to load the placeholder transaction UI.', - accentColor: theme.colorScheme.error, - ), - _LoadState.success => - _transactions.isEmpty - ? const WalletStateCard( - icon: Icons.history_toggle_off, - title: 'No transactions yet', - message: - 'The scaffold loaded successfully, but no placeholder transactions are configured yet.', - ) - : Card( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - children: [ - for ( - var index = 0; - index < _transactions.length; - index++ - ) ...[ - _TransactionRow( - transaction: _transactions[index], - onTap: () => - _openTransactionDetail(_transactions[index]), - ), - if (index < _transactions.length - 1) - const SizedBox(height: 12), - ], - ], - ), - ), - ), - }; - } -} - -class _SectionHeading extends StatelessWidget { - final String title; - final String subtitle; - - const _SectionHeading({required this.title, required this.subtitle}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: theme.textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - const SizedBox(height: 4), - Text( - subtitle, - style: theme.textTheme.bodyMedium?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(170), - ), - ), - ], + Widget _buildWalletList(List records, ThemeData theme) { + return ListView.separated( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + itemCount: records.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final record = records[index]; + final isLoading = _loadingWalletId == record.id; + final isDisabled = _loadingWalletId != null; + + return _WalletCard( + record: record, + isLoading: isLoading, + isDisabled: isDisabled, + onTap: () => _onLoadWallet(record), + ); + }, ); } } -class _TransactionRow extends StatelessWidget { - final TxDetails transaction; +class _WalletCard extends StatelessWidget { + final WalletRecord record; + final bool isLoading; + final bool isDisabled; final VoidCallback onTap; - const _TransactionRow({required this.transaction, required this.onTap}); + const _WalletCard({ + required this.record, + required this.isLoading, + required this.isDisabled, + required this.onTap, + }); @override Widget build(BuildContext context) { final theme = Theme.of(context); - final amount = transaction.netAmount; - final isIncoming = amount >= 0; - final accentColor = transaction.pending - ? theme.colorScheme.secondary - : isIncoming - ? Colors.green.shade700 - : theme.colorScheme.primary; - final amountLabel = - '${amount >= 0 ? '+' : '-'}${Formatters.formatBalance(amount.abs(), CurrencyUnit.satoshi)}'; - final subtitle = transaction.pending - ? 'Awaiting confirmation' - : transaction.blockHeight == null - ? 'Confirmed' - : 'Block ${transaction.blockHeight}'; - return Material( - color: Colors.transparent, + return Card( child: InkWell( + onTap: isDisabled ? null : onTap, borderRadius: BorderRadius.circular(16), - onTap: onTap, - child: Ink( - width: double.infinity, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - border: Border.all(color: theme.colorScheme.outlineVariant), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( children: [ - Row( - children: [ - Expanded( - child: Text( - amountLabel, + Icon( + Icons.account_balance_wallet, + size: 36, + color: theme.colorScheme.primary, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + record.name, style: theme.textTheme.titleMedium?.copyWith( - fontWeight: FontWeight.w700, - color: accentColor, + fontWeight: FontWeight.w600, ), ), - ), - const SizedBox(width: 12), - WalletStatusChip(status: transaction.statusLabel), - ], - ), - const SizedBox(height: 8), - Text( - transaction.shortTxid, - style: AppTheme.monoStyle.copyWith( - fontSize: 13, - color: theme.colorScheme.onSurface, + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + Chip( + label: Text( + record.network.displayName, + style: theme.textTheme.labelSmall, + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + Chip( + label: Text( + record.scriptType.shortName, + style: theme.textTheme.labelSmall, + ), + visualDensity: VisualDensity.compact, + materialTapTargetSize: + MaterialTapTargetSize.shrinkWrap, + ), + ], + ), + ], ), ), - const SizedBox(height: 4), - Text( - subtitle, - style: theme.textTheme.bodySmall?.copyWith( - color: theme.colorScheme.onSurface.withAlpha(170), + if (isLoading) + const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ) + else + Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurface.withAlpha(102), ), - ), ], ), ), diff --git a/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart b/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart index 2bf57e0..3709340 100644 --- a/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart +++ b/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart @@ -40,11 +40,18 @@ class WalletChoicePage extends StatelessWidget { _ChoiceCard( icon: Icons.account_balance_wallet, title: 'Use an Active Wallet', - subtitle: - 'Open the reference scaffold and inspect placeholder state', + subtitle: 'Load a previously created wallet', onTap: () => context.push(AppRoutes.activeWallets), ), const SizedBox(height: 16), + _ChoiceCard( + icon: Icons.receipt_long_outlined, + title: 'Preview Transactions', + subtitle: + 'Browse the standalone transaction list and detail demo', + onTap: () => context.push(AppRoutes.transactionHistory), + ), + const SizedBox(height: 16), _ChoiceCard( icon: Icons.add_circle_outline, title: 'Create a New Wallet', diff --git a/bdk_demo/lib/services/wallet_service.dart b/bdk_demo/lib/services/wallet_service.dart index beb785c..f8bd223 100644 --- a/bdk_demo/lib/services/wallet_service.dart +++ b/bdk_demo/lib/services/wallet_service.dart @@ -1,56 +1,21 @@ -import 'package:bdk_dart/bdk.dart' hide TxDetails; +import 'package:bdk_dart/bdk.dart'; import 'package:uuid/uuid.dart'; + import 'package:bdk_demo/core/constants/app_constants.dart'; -import 'package:bdk_demo/models/tx_details.dart'; import 'package:bdk_demo/models/wallet_record.dart'; import 'package:bdk_demo/services/storage_service.dart'; import 'package:bdk_demo/services/wallet_network_mapper.dart'; typedef WalletDisposer = void Function(Wallet wallet); -class DemoWalletInfo { - final String title; - final WalletNetwork network; - final String descriptor; - final String descriptorLabel; - - const DemoWalletInfo({ - required this.title, - required this.network, - required this.descriptor, - this.descriptorLabel = 'External descriptor', - }); -} - class WalletService { - final StorageService? _storage; - final Uuid? _uuid; + final StorageService _storage; + final Uuid _uuid; final WalletDisposer _walletDisposer; - static const _placeholderDescriptor = - 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#scafld00'; - static final _placeholderTransactions = [ - TxDetails( - txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', - sent: 0, - received: 42000, - balanceDelta: 42000, - pending: false, - blockHeight: 120, - confirmationTime: DateTime(2024, 1, 2, 3, 4), - ), - TxDetails( - txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - sent: 1600, - received: 0, - balanceDelta: -1600, - pending: true, - ), - ]; - WalletService({ - StorageService? storage, - Uuid? uuid, + required StorageService storage, + required Uuid uuid, WalletDisposer? walletDisposer, }) : _storage = storage, _uuid = uuid, @@ -58,37 +23,17 @@ class WalletService { static void _defaultDisposer(Wallet wallet) => wallet.dispose(); - StorageService get _requiredStorage { - final storage = _storage; - if (storage == null) { - throw StateError( - 'WalletService requires StorageService for this action.', - ); - } - return storage; - } - - Uuid get _requiredUuid { - final uuid = _uuid; - if (uuid == null) { - throw StateError('WalletService requires Uuid for this action.'); - } - return uuid; - } - Future<(WalletRecord, Wallet)> createWallet( String name, WalletNetwork walletNetwork, ScriptType scriptType, ) async { - final storage = _requiredStorage; - final uuid = _requiredUuid; final trimmedName = name.trim(); if (trimmedName.isEmpty) { throw ArgumentError('Wallet name must not be empty.'); } - final existing = storage.getWalletRecords(); + final existing = _storage.getWalletRecords(); final duplicate = existing.any( (r) => r.name.toLowerCase() == trimmedName.toLowerCase(), ); @@ -128,7 +73,7 @@ class WalletService { ); final record = WalletRecord( - id: uuid.v4(), + id: _uuid.v4(), name: trimmedName, network: walletNetwork, scriptType: scriptType, @@ -141,7 +86,7 @@ class WalletService { ); try { - await storage.addWalletRecord(record, secrets); + await _storage.addWalletRecord(record, secrets); } catch (_) { _walletDisposer(wallet); rethrow; @@ -151,8 +96,7 @@ class WalletService { } Future loadWalletFromRecord(WalletRecord record) async { - final storage = _requiredStorage; - final secrets = await storage.getSecrets(record.id); + final secrets = await _storage.getSecrets(record.id); if (secrets == null) { throw StateError( 'No secrets found for wallet "${record.name}" (${record.id}). ' @@ -181,34 +125,6 @@ class WalletService { ); } - Future loadReferenceWallet() async { - await Future.delayed(const Duration(milliseconds: 150)); - - return const DemoWalletInfo( - title: 'Reference Wallet Scaffold', - network: WalletNetwork.testnet, - descriptor: _placeholderDescriptor, - descriptorLabel: 'Placeholder descriptor', - ); - } - - Future> loadTransactions() async { - await Future.delayed(const Duration(milliseconds: 150)); - return _placeholderTransactions; - } - - Future loadTransactionByTxid(String txid) async { - final transactions = await loadTransactions(); - - for (final transaction in transactions) { - if (transaction.txid == txid) return transaction; - } - - return null; - } - - void dispose() {} - Descriptor _deriveDescriptor( DescriptorSecretKey secretKey, KeychainKind keychainKind, diff --git a/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart b/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart new file mode 100644 index 0000000..7a7d0ea --- /dev/null +++ b/bdk_demo/test/helpers/fakes/fake_transactions_repository.dart @@ -0,0 +1,29 @@ +import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; +import 'package:bdk_demo/features/transactions/transactions_repository.dart'; + +class FakeTransactionsRepository implements TransactionsRepository { + FakeTransactionsRepository({ + required this.transactions, + this.throwOnLoad = false, + }); + + final List transactions; + final bool throwOnLoad; + + @override + Future> loadTransactions() async { + if (throwOnLoad) { + throw Exception('forced transaction load failure'); + } + return transactions; + } + + @override + Future loadTransactionByTxid(String txid) async { + final items = await loadTransactions(); + for (final transaction in items) { + if (transaction.txid == txid) return transaction; + } + return null; + } +} diff --git a/bdk_demo/test/helpers/fixtures/placeholder_transactions.dart b/bdk_demo/test/helpers/fixtures/placeholder_transactions.dart new file mode 100644 index 0000000..bb7d8c1 --- /dev/null +++ b/bdk_demo/test/helpers/fixtures/placeholder_transactions.dart @@ -0,0 +1,18 @@ +import 'package:bdk_demo/features/transactions/models/demo_tx_details.dart'; + +final placeholderTransactions = [ + DemoTxDetails( + txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + sent: 0, + received: 42000, + pending: false, + blockHeight: 120, + confirmationTime: DateTime(2024, 1, 2, 3, 4), + ), + const DemoTxDetails( + txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + sent: 1600, + received: 0, + pending: true, + ), +]; diff --git a/bdk_demo/test/presentation/active_wallets_page_test.dart b/bdk_demo/test/presentation/active_wallets_page_test.dart index 4b07a58..5bc575b 100644 --- a/bdk_demo/test/presentation/active_wallets_page_test.dart +++ b/bdk_demo/test/presentation/active_wallets_page_test.dart @@ -1,148 +1,283 @@ +import 'dart:async'; + +import 'package:bdk_demo/core/router/app_router.dart'; import 'package:bdk_demo/features/wallet_setup/active_wallets_page.dart'; -import 'package:bdk_demo/models/tx_details.dart'; import 'package:bdk_demo/models/wallet_record.dart'; +import 'package:bdk_demo/providers/settings_providers.dart'; import 'package:bdk_demo/providers/wallet_providers.dart'; +import 'package:bdk_demo/services/storage_service.dart'; import 'package:bdk_demo/services/wallet_service.dart'; +import 'package:bdk_dart/bdk.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:go_router/go_router.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:uuid/uuid.dart'; -const _testWalletInfo = DemoWalletInfo( - title: 'Reference Wallet Scaffold', - network: WalletNetwork.testnet, - descriptor: 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#demo1234', - descriptorLabel: 'Placeholder descriptor', -); - -final _placeholderTransactions = [ - TxDetails( - txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', - sent: 0, - received: 42000, - balanceDelta: 42000, - pending: false, - blockHeight: 120, - confirmationTime: DateTime(2024, 1, 2, 3, 4), - ), - TxDetails( - txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - sent: 1600, - received: 0, - balanceDelta: -1600, - pending: true, - ), -]; - -class _FakeWalletService extends WalletService { - final DemoWalletInfo walletInfo; - final List transactions; - - _FakeWalletService({required this.walletInfo, required this.transactions}); - - @override - Future loadReferenceWallet() async => walletInfo; +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); - @override - Future> loadTransactions() async => transactions; -} + late StorageService storageService; -Future _pumpPage( - WidgetTester tester, { - required WalletService walletService, -}) async { - await tester.pumpWidget( - ProviderScope( - overrides: [walletServiceProvider.overrideWithValue(walletService)], - child: const MaterialApp(home: ActiveWalletsPage()), - ), - ); - await tester.pumpAndSettle(); -} + Future initStorage() async { + SharedPreferences.setMockInitialValues({}); + FlutterSecureStorage.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + return StorageService(prefs: prefs); + } -void main() { - TestWidgetsFlutterBinding.ensureInitialized(); + GoRouter testRouter() { + return GoRouter( + initialLocation: AppRoutes.activeWallets, + routes: [ + GoRoute( + path: AppRoutes.activeWallets, + builder: (context, state) => const ActiveWalletsPage(), + ), + GoRoute( + path: AppRoutes.createWallet, + builder: (context, state) => + const Scaffold(body: Text('Create Wallet Page')), + ), + GoRoute( + path: AppRoutes.home, + builder: (context, state) => const Scaffold(body: Text('Home')), + ), + ], + ); + } - testWidgets('shows scaffold intro before loading', (tester) async { - await _pumpPage( - tester, - walletService: _FakeWalletService( - walletInfo: _testWalletInfo, - transactions: _placeholderTransactions, + Future pumpActiveWalletsPage( + WidgetTester tester, + ProviderContainer container, + ) async { + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: MaterialApp.router(routerConfig: testRouter()), ), ); + await tester.pumpAndSettle(); + } + + group('ActiveWalletsPage empty state', () { + testWidgets('shows empty state and navigates to create wallet', ( + tester, + ) async { + storageService = await initStorage(); + final container = ProviderContainer( + overrides: [storageServiceProvider.overrideWithValue(storageService)], + ); + addTearDown(container.dispose); - expect(find.text('Reference Wallet Scaffold'), findsNWidgets(2)); - expect(find.text('Load Reference Scaffold'), findsOneWidget); - expect(find.text('Wallet not loaded yet'), findsOneWidget); + await pumpActiveWalletsPage(tester, container); - await tester.scrollUntilVisible( - find.text('Transactions will appear here'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); + expect(find.text('No wallets yet'), findsOneWidget); + expect(find.text('Create a Wallet'), findsOneWidget); + + await tester.tap(find.text('Create a Wallet')); + await tester.pumpAndSettle(); - expect(find.text('Transactions will appear here'), findsOneWidget); + expect(find.text('Create Wallet Page'), findsOneWidget); + }); }); - testWidgets('loads and renders placeholder transactions', (tester) async { - await _pumpPage( + group('ActiveWalletsPage with wallets', () { + testWidgets('renders wallet cards with name and chips', (tester) async { + storageService = await initStorage(); + + final container = ProviderContainer( + overrides: [storageServiceProvider.overrideWithValue(storageService)], + ); + addTearDown(container.dispose); + + await storageService.addWalletRecord( + WalletRecord( + id: 'w1', + name: 'Testnet Wallet', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ), + WalletSecrets( + descriptor: 'dummy-desc', + changeDescriptor: 'dummy-change', + ), + ); + await storageService.addWalletRecord( + WalletRecord( + id: 'w2', + name: 'Signet Taproot', + network: WalletNetwork.signet, + scriptType: ScriptType.p2tr, + ), + WalletSecrets( + descriptor: 'dummy-desc-2', + changeDescriptor: 'dummy-change-2', + ), + ); + + await pumpActiveWalletsPage(tester, container); + + expect(find.text('Testnet Wallet'), findsOneWidget); + expect(find.text('Testnet 3'), findsOneWidget); + expect(find.text('P2WPKH'), findsOneWidget); + + expect(find.text('Signet Taproot'), findsOneWidget); + expect(find.text('Signet'), findsOneWidget); + expect(find.text('P2TR'), findsOneWidget); + }); + + testWidgets('successful load sets active providers and navigates home', ( tester, - walletService: _FakeWalletService( - walletInfo: _testWalletInfo, - transactions: _placeholderTransactions, - ), - ); + ) async { + storageService = await initStorage(); + final walletService = WalletService( + storage: storageService, + uuid: const Uuid(), + ); - await tester.tap(find.text('Load Reference Scaffold')); - await tester.pumpAndSettle(); + final (record, createdWallet) = await walletService.createWallet( + 'Load Me', + WalletNetwork.testnet, + ScriptType.p2wpkh, + ); + createdWallet.dispose(); - expect(find.text('Wallet Snapshot'), findsOneWidget); - expect(find.text('Testnet 3'), findsOneWidget); - expect(find.text('Placeholder descriptor'), findsOneWidget); + final container = ProviderContainer( + overrides: [storageServiceProvider.overrideWithValue(storageService)], + ); + addTearDown(container.dispose); - await tester.scrollUntilVisible( - find.text('confirmed'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); + await pumpActiveWalletsPage(tester, container); - expect(find.text('+42000 sat'), findsOneWidget); - expect(find.text('-1600 sat'), findsOneWidget); - expect(find.text('123456...abcd'), findsOneWidget); - expect(find.text('abcdef...7890'), findsOneWidget); - expect(find.text('confirmed'), findsOneWidget); - expect(find.text('pending'), findsOneWidget); - }); + await tester.tap(find.text('Load Me')); + await tester.pumpAndSettle(); - testWidgets('shows empty transaction state when no rows are returned', ( - tester, - ) async { - await _pumpPage( + expect(find.text('Home'), findsOneWidget); + expect(container.read(activeWalletRecordProvider)?.id, record.id); + + final activeWallet = container.read(activeWalletProvider); + expect(activeWallet, isNotNull); + }); + + testWidgets('missing secrets shows error and does not navigate', ( tester, - walletService: _FakeWalletService( - walletInfo: _testWalletInfo, - transactions: const [], - ), - ); + ) async { + storageService = await initStorage(); - await tester.tap(find.text('Load Reference Scaffold')); - await tester.pumpAndSettle(); + const record = WalletRecord( + id: 'missing-secrets-id', + name: 'Missing Secrets Wallet', + network: WalletNetwork.testnet, + scriptType: ScriptType.p2wpkh, + ); - await tester.scrollUntilVisible( - find.text('No transactions yet'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); + await storageService.addWalletRecord( + record, + const WalletSecrets( + descriptor: 'dummy-desc', + changeDescriptor: 'dummy-change', + ), + ); - expect(find.text('No transactions yet'), findsOneWidget); - expect( - find.text( - 'The scaffold loaded successfully, but no placeholder transactions are configured yet.', - ), - findsOneWidget, + await const FlutterSecureStorage().delete( + key: 'wallet_secrets_${record.id}', + ); + expect(await storageService.getSecrets(record.id), isNull); + + final container = ProviderContainer( + overrides: [storageServiceProvider.overrideWithValue(storageService)], + ); + addTearDown(container.dispose); + + await pumpActiveWalletsPage(tester, container); + + await tester.tap(find.text('Missing Secrets Wallet')); + await tester.pumpAndSettle(); + + expect(find.text('Secrets not found for this wallet'), findsOneWidget); + expect(find.text('Home'), findsNothing); + expect(find.byType(ActiveWalletsPage), findsOneWidget); + }); + + testWidgets( + 'disposes loaded wallet if page unmounts before await returns', + (tester) async { + storageService = await initStorage(); + + const record = WalletRecord( + id: 'pending-load-id', + name: 'Pending Load Wallet', + network: WalletNetwork.signet, + scriptType: ScriptType.p2tr, + ); + + await storageService.addWalletRecord( + record, + const WalletSecrets( + descriptor: 'dummy-desc', + changeDescriptor: 'dummy-change', + ), + ); + + final realWalletService = WalletService( + storage: storageService, + uuid: const Uuid(), + ); + final (_, wallet) = await realWalletService.createWallet( + 'Load Candidate', + WalletNetwork.signet, + ScriptType.p2tr, + ); + + final completer = Completer(); + final delayedService = _DelayedLoadWalletService( + storage: storageService, + completer: completer, + ); + + var disposeCalls = 0; + final container = ProviderContainer( + overrides: [ + storageServiceProvider.overrideWithValue(storageService), + walletServiceProvider.overrideWithValue(delayedService), + walletDisposerProvider.overrideWithValue((wallet) { + disposeCalls += 1; + wallet.dispose(); + }), + ], + ); + addTearDown(container.dispose); + + await pumpActiveWalletsPage(tester, container); + + await tester.tap(find.text('Pending Load Wallet')); + await tester.pump(); + + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + + completer.complete(wallet); + await tester.pumpAndSettle(); + + expect(disposeCalls, 1); + expect(container.read(activeWalletProvider), isNull); + expect(container.read(activeWalletRecordProvider), isNull); + }, ); }); } + +class _DelayedLoadWalletService extends WalletService { + _DelayedLoadWalletService({required super.storage, required this.completer}) + : super(uuid: const Uuid()); + + final Completer completer; + + @override + Future loadWalletFromRecord(WalletRecord record) { + return completer.future; + } +} diff --git a/bdk_demo/test/presentation/app_shell_test.dart b/bdk_demo/test/presentation/app_shell_test.dart index c4a38cb..4acb26f 100644 --- a/bdk_demo/test/presentation/app_shell_test.dart +++ b/bdk_demo/test/presentation/app_shell_test.dart @@ -4,266 +4,49 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:bdk_demo/app/app.dart'; -import 'package:bdk_demo/features/wallet_setup/transaction_detail_page.dart'; -import 'package:bdk_demo/models/tx_details.dart'; -import 'package:bdk_demo/models/wallet_record.dart'; import 'package:bdk_demo/providers/settings_providers.dart'; -import 'package:bdk_demo/providers/wallet_providers.dart'; import 'package:bdk_demo/services/storage_service.dart'; -import 'package:bdk_demo/services/wallet_service.dart'; - -const _testWalletInfo = DemoWalletInfo( - title: 'Reference Wallet Scaffold', - network: WalletNetwork.testnet, - descriptor: 'wpkh([demo/84h/1h/0h]tpubReferenceScaffold/0/*)#demo1234', - descriptorLabel: 'Placeholder descriptor', -); - -final _placeholderTransactions = [ - TxDetails( - txid: '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', - sent: 0, - received: 42000, - balanceDelta: 42000, - pending: false, - blockHeight: 120, - confirmationTime: DateTime(2024, 1, 2, 3, 4), - ), - TxDetails( - txid: 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - sent: 1600, - received: 0, - balanceDelta: -1600, - pending: true, - ), -]; - -class FakeWalletService extends WalletService { - final DemoWalletInfo walletInfo; - final List transactions; - - FakeWalletService({required this.walletInfo, required this.transactions}); - - @override - Future loadReferenceWallet() async => walletInfo; - - @override - Future> loadTransactions() async => transactions; - - @override - void dispose() {} -} - -Future _pumpApp( - WidgetTester tester, { - WalletService? walletService, -}) async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); - - await tester.pumpWidget( - ProviderScope( - overrides: [ - storageServiceProvider.overrideWithValue(StorageService(prefs: prefs)), - if (walletService != null) - walletServiceProvider.overrideWithValue(walletService), - ], - child: const App(), - ), - ); - await tester.pumpAndSettle(); -} void main() { testWidgets('App builds and shows WalletChoicePage', (tester) async { - await _pumpApp(tester); - - expect(find.byType(MaterialApp), findsOneWidget); - expect(find.text('Use an Active Wallet'), findsOneWidget); - expect(find.text('Create a New Wallet'), findsOneWidget); - expect(find.text('Recover an Existing Wallet'), findsOneWidget); - }); + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); - testWidgets('Theme defaults to light mode', (tester) async { - await _pumpApp(tester); - - final materialApp = tester.widget(find.byType(MaterialApp)); - expect(materialApp.themeMode, ThemeMode.light); - }); - - testWidgets('Reference wallet scaffold page shows placeholder transactions', ( - tester, - ) async { - final fakeWalletService = FakeWalletService( - walletInfo: _testWalletInfo, - transactions: _placeholderTransactions, - ); - - await _pumpApp(tester, walletService: fakeWalletService); - - await tester.tap(find.text('Use an Active Wallet')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Load Reference Scaffold')); - await tester.pumpAndSettle(); - - expect(find.text('Wallet Snapshot'), findsOneWidget); - expect(find.text('Testnet 3'), findsOneWidget); - expect(find.text('Placeholder descriptor'), findsOneWidget); - - await tester.scrollUntilVisible( - find.text('confirmed'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); - - expect(find.text('+42000 sat'), findsOneWidget); - expect(find.text('-1600 sat'), findsOneWidget); - expect(find.text('123456...abcd'), findsOneWidget); - expect(find.text('abcdef...7890'), findsOneWidget); - expect(find.text('confirmed'), findsOneWidget); - expect(find.text('pending'), findsOneWidget); - }); - - testWidgets( - 'Tapping a transaction opens the detail page with the correct tx info', - (tester) async { - final fakeWalletService = FakeWalletService( - walletInfo: _testWalletInfo, - transactions: _placeholderTransactions, - ); - - await _pumpApp(tester, walletService: fakeWalletService); - - await tester.tap(find.text('Use an Active Wallet')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Load Reference Scaffold')); - await tester.pumpAndSettle(); - - await tester.scrollUntilVisible( - find.text('123456...abcd'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); - - await tester.tap(find.text('123456...abcd')); - await tester.pumpAndSettle(); - - expect(find.text('Transaction Detail'), findsOneWidget); - expect( - find.text( - '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', - ), - findsOneWidget, - ); - expect(find.text('+42000 sat'), findsNWidgets(2)); - expect(find.text('confirmed'), findsNWidgets(2)); - expect(find.text('120'), findsOneWidget); - expect(find.text('January 2 2024 03:04'), findsOneWidget); - }, - ); - - testWidgets( - 'Reference wallet scaffold supports the empty transaction state', - (tester) async { - final fakeWalletService = FakeWalletService( - walletInfo: _testWalletInfo, - transactions: const [], - ); - - await _pumpApp(tester, walletService: fakeWalletService); - - await tester.tap(find.text('Use an Active Wallet')); - await tester.pumpAndSettle(); - await tester.tap(find.text('Load Reference Scaffold')); - await tester.pumpAndSettle(); - - await tester.scrollUntilVisible( - find.text('No transactions yet'), - 200, - scrollable: find.byType(Scrollable).first, - ); - await tester.pumpAndSettle(); - - expect(find.text('No transactions yet'), findsOneWidget); - }, - ); - - testWidgets('Transaction detail page refreshes when txid changes', ( - tester, - ) async { - final fakeWalletService = FakeWalletService( - walletInfo: _testWalletInfo, - transactions: _placeholderTransactions, - ); - - Future pumpDetail(String txid) async { - await tester.pumpWidget( - ProviderScope( - overrides: [ - walletServiceProvider.overrideWithValue(fakeWalletService), - ], - child: MaterialApp( - home: TransactionDetailPage( - key: const ValueKey('detail-page'), - txid: txid, - ), + await tester.pumpWidget( + ProviderScope( + overrides: [ + storageServiceProvider.overrideWithValue( + StorageService(prefs: prefs), ), - ), - ); - } - - await pumpDetail(_placeholderTransactions.first.txid); - await tester.pumpAndSettle(); - - expect( - find.text( - '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + ], + child: const App(), ), - findsOneWidget, ); - expect(find.text('January 2 2024 03:04'), findsOneWidget); - - await pumpDetail(_placeholderTransactions.last.txid); await tester.pumpAndSettle(); - expect( - find.text( - 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', - ), - findsOneWidget, - ); - expect(find.text('-1600 sat'), findsNWidgets(2)); - expect(find.text('pending'), findsNWidgets(2)); - expect( - find.text( - '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', - ), - findsNothing, - ); - expect(find.text('January 2 2024 03:04'), findsNothing); + expect(find.byType(MaterialApp), findsOneWidget); + expect(find.text('Use an Active Wallet'), findsOneWidget); + expect(find.text('Create a New Wallet'), findsOneWidget); + expect(find.text('Recover an Existing Wallet'), findsOneWidget); }); - testWidgets('Transaction detail page handles a missing tx gracefully', ( - tester, - ) async { - final fakeWalletService = FakeWalletService( - walletInfo: _testWalletInfo, - transactions: const [], - ); + testWidgets('Theme defaults to light mode', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); await tester.pumpWidget( ProviderScope( - overrides: [walletServiceProvider.overrideWithValue(fakeWalletService)], - child: const MaterialApp( - home: TransactionDetailPage(txid: 'missing-txid'), - ), + overrides: [ + storageServiceProvider.overrideWithValue( + StorageService(prefs: prefs), + ), + ], + child: const App(), ), ); await tester.pumpAndSettle(); - expect(find.text('Transaction not found'), findsOneWidget); - expect(find.textContaining('missing-txid'), findsOneWidget); + final materialApp = tester.widget(find.byType(MaterialApp)); + expect(materialApp.themeMode, ThemeMode.light); }); } diff --git a/bdk_demo/test/presentation/router_wiring_test.dart b/bdk_demo/test/presentation/router_wiring_test.dart index 10a85af..0590a07 100644 --- a/bdk_demo/test/presentation/router_wiring_test.dart +++ b/bdk_demo/test/presentation/router_wiring_test.dart @@ -1,4 +1,5 @@ import 'package:bdk_demo/core/router/app_router.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/features/wallet_setup/active_wallets_page.dart'; import 'package:bdk_demo/features/wallet_setup/create_wallet_page.dart'; @@ -36,6 +37,10 @@ void main() { path: AppRoutes.activeWallets, builder: (context, state) => const ActiveWalletsPage(), ), + GoRoute( + path: AppRoutes.transactionHistory, + builder: (context, state) => const TransactionsListPage(), + ), ], ); @@ -62,4 +67,13 @@ void main() { expect(find.byType(ActiveWalletsPage), findsOneWidget); expect(find.byType(PlaceholderPage), findsNothing); }); + + testWidgets('/transactions resolves to TransactionsListPage', (tester) async { + final app = await buildRouterApp(AppRoutes.transactionHistory); + await tester.pumpWidget(app); + await tester.pumpAndSettle(); + + expect(find.byType(TransactionsListPage), findsOneWidget); + expect(find.byType(PlaceholderPage), findsNothing); + }); } diff --git a/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart b/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart new file mode 100644 index 0000000..3123a27 --- /dev/null +++ b/bdk_demo/test/presentation/transactions/transaction_detail_page_test.dart @@ -0,0 +1,104 @@ +import 'package:bdk_demo/features/transactions/transaction_detail_page.dart'; +import 'package:bdk_demo/features/transactions/transactions_repository.dart'; +import 'package:flutter/material.dart'; +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'; + +Future _pumpDetailPage( + WidgetTester tester, { + required TransactionsRepository repository, + required String txid, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [transactionsRepositoryProvider.overrideWithValue(repository)], + child: MaterialApp( + home: TransactionDetailPage( + key: const ValueKey('detail-page'), + txid: txid, + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('shows the correct tx info', (tester) async { + await _pumpDetailPage( + tester, + repository: FakeTransactionsRepository( + transactions: placeholderTransactions, + ), + txid: placeholderTransactions.first.txid, + ); + + expect(find.text('Transaction Detail'), findsOneWidget); + expect( + find.text( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + ), + findsOneWidget, + ); + expect(find.text('+42000 sat'), findsNWidgets(2)); + expect(find.text('confirmed'), findsNWidgets(2)); + expect(find.text('120'), findsOneWidget); + expect(find.text('January 2 2024 03:04'), findsOneWidget); + }); + + testWidgets('updates when the txid changes', (tester) async { + final repository = FakeTransactionsRepository( + transactions: placeholderTransactions, + ); + + await _pumpDetailPage( + tester, + repository: repository, + txid: placeholderTransactions.first.txid, + ); + + expect( + find.text( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + ), + findsOneWidget, + ); + expect(find.text('January 2 2024 03:04'), findsOneWidget); + + await _pumpDetailPage( + tester, + repository: repository, + txid: placeholderTransactions.last.txid, + ); + + expect( + find.text( + 'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', + ), + findsOneWidget, + ); + expect(find.text('-1600 sat'), findsNWidgets(2)); + expect(find.text('pending'), findsNWidgets(2)); + expect( + find.text( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + ), + findsNothing, + ); + expect(find.text('January 2 2024 03:04'), findsNothing); + }); + + testWidgets('handles a missing tx gracefully', (tester) async { + await _pumpDetailPage( + tester, + repository: FakeTransactionsRepository(transactions: const []), + txid: 'missing-txid', + ); + + expect(find.text('Transaction not found'), findsOneWidget); + expect(find.textContaining('missing-txid'), findsOneWidget); + }); +} diff --git a/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart new file mode 100644 index 0000000..f0940b2 --- /dev/null +++ b/bdk_demo/test/presentation/transactions/transactions_list_page_test.dart @@ -0,0 +1,117 @@ +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:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +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'; + +Future _pumpTransactionsFlow( + WidgetTester tester, { + required TransactionsRepository repository, +}) async { + final router = GoRouter( + initialLocation: '/transactions', + routes: [ + GoRoute( + path: '/transactions', + name: 'transactionHistory', + builder: (context, state) => const TransactionsListPage(), + ), + GoRoute( + path: '/transactions/:txid', + name: 'transactionDetail', + builder: (context, state) => + TransactionDetailPage(txid: state.pathParameters['txid'] ?? ''), + ), + ], + ); + + await tester.pumpWidget( + ProviderScope( + overrides: [transactionsRepositoryProvider.overrideWithValue(repository)], + child: MaterialApp.router(routerConfig: router), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + testWidgets('shows intro before loading transactions', (tester) async { + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository( + transactions: placeholderTransactions, + ), + ); + + expect(find.text('Transactions Demo'), findsNWidgets(2)); + expect(find.text('Load Transactions Demo'), findsOneWidget); + expect(find.text('Transactions not loaded yet'), findsOneWidget); + }); + + testWidgets('loads and renders placeholder transactions', (tester) async { + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository( + transactions: placeholderTransactions, + ), + ); + + await tester.tap(find.text('Load Transactions Demo')); + await tester.pumpAndSettle(); + + expect(find.text('+42000 sat'), findsOneWidget); + expect(find.text('-1600 sat'), findsOneWidget); + expect(find.text('123456...abcd'), findsOneWidget); + expect(find.text('abcdef...7890'), findsOneWidget); + expect(find.text('confirmed'), findsOneWidget); + expect(find.text('pending'), findsOneWidget); + }); + + testWidgets('shows empty state when no transactions are returned', ( + tester, + ) async { + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository(transactions: const []), + ); + + await tester.tap(find.text('Load Transactions Demo')); + 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.', + ), + findsOneWidget, + ); + }); + + testWidgets('tapping a transaction opens the detail page', (tester) async { + await _pumpTransactionsFlow( + tester, + repository: FakeTransactionsRepository( + transactions: placeholderTransactions, + ), + ); + + await tester.tap(find.text('Load Transactions Demo')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('123456...abcd')); + await tester.pumpAndSettle(); + + expect(find.text('Transaction Detail'), findsOneWidget); + expect( + find.text( + '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcd', + ), + findsOneWidget, + ); + }); +}