diff --git a/bdk_demo/lib/core/router/app_router.dart b/bdk_demo/lib/core/router/app_router.dart index cce7ac9..f83c47e 100644 --- a/bdk_demo/lib/core/router/app_router.dart +++ b/bdk_demo/lib/core/router/app_router.dart @@ -1,4 +1,6 @@ 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'; @@ -64,15 +66,14 @@ 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, 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/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/models/tx_details.dart b/bdk_demo/lib/features/transactions/models/demo_tx_details.dart similarity index 58% rename from bdk_demo/lib/models/tx_details.dart rename to bdk_demo/lib/features/transactions/models/demo_tx_details.dart index a55820e..4be76c1 100644 --- a/bdk_demo/lib/models/tx_details.dart +++ b/bdk_demo/lib/features/transactions/models/demo_tx_details.dart @@ -1,19 +1,17 @@ -class TxDetails { +import 'package:bdk_demo/core/utils/formatters.dart'; + +class DemoTxDetails { final String txid; final int sent; final int received; - final int fee; - final double? feeRate; 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.pending = true, this.blockHeight, this.confirmationTime, @@ -21,7 +19,7 @@ class TxDetails { int get netAmount => received - sent; - String get shortTxid => txid.length > 16 - ? '${txid.substring(0, 8)}...${txid.substring(txid.length - 8)}' - : txid; + String get shortTxid => Formatters.abbreviateTxid(txid); + + String get statusLabel => pending ? 'pending' : 'confirmed'; } diff --git a/bdk_demo/lib/features/transactions/transaction_detail_page.dart b/bdk_demo/lib/features/transactions/transaction_detail_page.dart new file mode 100644 index 0000000..a48f154 --- /dev/null +++ b/bdk_demo/lib/features/transactions/transaction_detail_page.dart @@ -0,0 +1,163 @@ +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'; + +class TransactionDetailPage extends ConsumerWidget { + final String txid; + + const TransactionDetailPage({super.key, required this.txid}); + + String _formatAmount(DemoTxDetails 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, WidgetRef ref) { + final theme = Theme.of(context); + final transactionAsync = ref.watch(transactionDetailsProvider(txid)); + + return Scaffold( + appBar: const SecondaryAppBar(title: 'Transaction Detail'), + body: SafeArea( + 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$txid', + centered: true, + ); + } + + 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, + ), + ), + ), + WalletStatusChip(status: transaction.statusLabel), + ], + ), + const SizedBox(height: 8), + Text( + 'Standalone transaction detail view for the selected placeholder 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: [ + WalletDetailRow( + label: 'Amount', + value: _formatAmount(transaction), + ), + const SizedBox(height: 12), + WalletDetailRow( + label: 'Status', + value: transaction.statusLabel, + ), + if (transaction.blockHeight != null) ...[ + const SizedBox(height: 12), + WalletDetailRow( + label: 'Block height', + value: '${transaction.blockHeight}', + ), + ], + if (transaction.confirmationTime != null) ...[ + const SizedBox(height: 12), + WalletDetailRow( + label: 'Timestamp', + value: _formatTimestamp( + transaction.confirmationTime!, + ), + ), + ], + ], + ), + ), + ), + ], + ); + }, + ), + ), + ); + } +} 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/wallet_choice_page.dart b/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart index 9bac63c..3709340 100644 --- a/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart +++ b/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart @@ -44,6 +44,14 @@ class WalletChoicePage extends StatelessWidget { 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 bc0e29e..f8bd223 100644 --- a/bdk_demo/lib/services/wallet_service.dart +++ b/bdk_demo/lib/services/wallet_service.dart @@ -1,5 +1,6 @@ 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/wallet_record.dart'; import 'package:bdk_demo/services/storage_service.dart'; 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 a5ecbb6..5bc575b 100644 --- a/bdk_demo/test/presentation/active_wallets_page_test.dart +++ b/bdk_demo/test/presentation/active_wallets_page_test.dart @@ -1,6 +1,7 @@ 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/features/wallet_setup/active_wallets_page.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'; 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, + ); + }); +}