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/router/app_router.dart b/bdk_demo/lib/core/router/app_router.dart index 5d8d4fa..f4b2a5d 100644 --- a/bdk_demo/lib/core/router/app_router.dart +++ b/bdk_demo/lib/core/router/app_router.dart @@ -1,5 +1,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/transaction_detail_page.dart'; import 'package:bdk_demo/features/wallet_setup/wallet_choice_page.dart'; abstract final class AppRoutes { @@ -30,8 +32,7 @@ GoRouter createRouter() => GoRouter( GoRoute( path: AppRoutes.activeWallets, name: 'activeWallets', - builder: (context, state) => - const PlaceholderPage(title: 'Active Wallets'), + builder: (context, state) => const ActiveWalletsPage(), ), GoRoute( path: AppRoutes.createWallet, @@ -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/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 new file mode 100644 index 0000000..b329b07 --- /dev/null +++ b/bdk_demo/lib/features/wallet_setup/active_wallets_page.dart @@ -0,0 +1,583 @@ +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}); + + @override + ConsumerState createState() => _ActiveWalletsPageState(); +} + +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; + + Future _loadReferenceWallet() async { + final walletService = ref.read(walletServiceProvider); + + setState(() { + _walletState = _LoadState.loading; + _transactionState = _LoadState.idle; + _walletInfo = null; + _transactions = const []; + _walletError = null; + _transactionError = null; + _statusMessage = 'Preparing the wallet scaffold...'; + }); + + try { + final walletInfo = await walletService.loadReferenceWallet(); + if (!mounted) return; + + setState(() { + _walletState = _LoadState.success; + _transactionState = _LoadState.loading; + _walletInfo = walletInfo; + _statusMessage = 'Scaffold ready. Loading placeholder transactions...'; + }); + } catch (error) { + if (!mounted) return; + + 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.'; + }); + } + } + + 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}, + ); + } + + @override + Widget build(BuildContext context) { + 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), + 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], + 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), + ), + ), + ], + ); + } +} + +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; + + 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), + _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/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/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/providers/wallet_providers.dart b/bdk_demo/lib/providers/wallet_providers.dart index 7038d9c..736cece 100644 --- a/bdk_demo/lib/providers/wallet_providers.dart +++ b/bdk_demo/lib/providers/wallet_providers.dart @@ -1,8 +1,15 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../models/wallet_record.dart'; +import '../services/wallet_service.dart'; import 'settings_providers.dart'; +final walletServiceProvider = Provider((ref) { + final service = WalletService(); + ref.onDispose(service.dispose); + return service; +}); + final activeWalletRecordProvider = NotifierProvider( ActiveWalletRecordNotifier.new, diff --git a/bdk_demo/lib/services/wallet_service.dart b/bdk_demo/lib/services/wallet_service.dart index 5f7b79e..a3d7412 100644 --- a/bdk_demo/lib/services/wallet_service.dart +++ b/bdk_demo/lib/services/wallet_service.dart @@ -1,4 +1,67 @@ +import '../models/tx_details.dart'; +import '../models/wallet_record.dart'; + +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 { - // TODO: Implement wallet creation, recovery, and loading. - // TODO: Implement sync/fullScan integration. + 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, + ), + ]; + + 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() {} } diff --git a/bdk_demo/test/widget_test.dart b/bdk_demo/test/widget_test.dart index 4acb26f..8ae0dc9 100644 --- a/bdk_demo/test/widget_test.dart +++ b/bdk_demo/test/widget_test.dart @@ -4,25 +4,79 @@ 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 { - 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); @@ -31,22 +85,130 @@ void main() { }); testWidgets('Theme defaults to light mode', (tester) async { - SharedPreferences.setMockInitialValues({}); - final prefs = await SharedPreferences.getInstance(); + 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 handles a missing tx gracefully', ( + tester, + ) async { + final fakeWalletService = FakeWalletService( + walletInfo: _testWalletInfo, + transactions: const [], + ); await tester.pumpWidget( ProviderScope( - overrides: [ - storageServiceProvider.overrideWithValue( - StorageService(prefs: prefs), - ), - ], - child: const App(), + overrides: [walletServiceProvider.overrideWithValue(fakeWalletService)], + child: const MaterialApp( + home: TransactionDetailPage(txid: 'missing-txid'), + ), ), ); await tester.pumpAndSettle(); - final materialApp = tester.widget(find.byType(MaterialApp)); - expect(materialApp.themeMode, ThemeMode.light); + expect(find.text('Transaction not found'), findsOneWidget); + expect(find.textContaining('missing-txid'), findsOneWidget); }); }