diff --git a/bdk_demo/README.md b/bdk_demo/README.md index ce25e86..859fdfa 100644 --- a/bdk_demo/README.md +++ b/bdk_demo/README.md @@ -1,16 +1,82 @@ -# bdk_demo +# BDK-Dart Wallet (Flutter) -A new Flutter project. +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. 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 + +| Feature | Status | +|---|---| +| Create wallet (P2WPKH / P2TR) | - | +| Recover wallet (phrase / descriptor) | - | +| Multi-wallet support | - | +| Esplora sync (Regtest) | - | +| Electrum sync (Testnet / Signet) | - | +| Wallet balance (BTC / sats toggle) | - | +| Receive (address generation + QR) | - | +| Send (single recipient + fee rate) | - | +| Transaction history | - | +| Transaction detail | - | +| Recovery data viewer | - | +| Theme toggle (light / dark) | - | +| In-app log viewer | - | + +## Architecture + +Clean Architecture + Riverpod: + +``` +lib/ +├── app/ # App shell (MaterialApp, bootstrap) +├── core/ # Theme, router, constants, logging, utils +├── models/ # WalletRecord, TxDetails, CurrencyUnit +├── services/ # WalletService, BlockchainService, StorageService +├── providers/ # Riverpod providers (wallet, blockchain, settings) +└── features/ # Feature pages and widgets +``` + +**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 ## Getting Started -This project is a starting point for a Flutter application. +```bash +# Clone and navigate to the demo app +cd bdk_demo + +# Install dependencies +flutter pub get + +# Run on a connected device or emulator +flutter run +``` + +> **Note:** This app depends on `bdk_dart` via a local path (`../`). Make sure the parent `bdk-dart` repository is set up and the native Rust build toolchain is available. See the [bdk-dart README](../README.md) for build prerequisites. + +## Supported Networks + +| Network | Blockchain Client | Default Endpoint | +|---|---|---| +| Signet | Electrum | `ssl://mempool.space:60602` | +| Testnet 3 | Electrum | `ssl://electrum.blockstream.info:60002` | +| Regtest | Esplora | `http://localhost:3002` | + + +## Address Types -A few resources to get you started if this is your first Flutter project: +| Type | Standard | Default | +|---|---|---| +| P2TR (Taproot) | BIP-86 | - | +| P2WPKH (Native SegWit) | BIP-84 | - | -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +## License -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +See [LICENSE](../LICENSE). diff --git a/bdk_demo/lib/app/app.dart b/bdk_demo/lib/app/app.dart new file mode 100644 index 0000000..daedd5d --- /dev/null +++ b/bdk_demo/lib/app/app.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../providers/router_provider.dart'; +import '../core/theme/app_theme.dart'; +import '../providers/settings_providers.dart'; + +class App extends ConsumerWidget { + const App({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final themeMode = ref.watch(themeModeProvider); + final router = ref.watch(appRouterProvider); + + return MaterialApp.router( + title: 'BDK-Dart Wallet', + debugShowCheckedModeBanner: false, + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: themeMode, + routerConfig: router, + ); + } +} diff --git a/bdk_demo/lib/app/bootstrap.dart b/bdk_demo/lib/app/bootstrap.dart new file mode 100644 index 0000000..56fe6b3 --- /dev/null +++ b/bdk_demo/lib/app/bootstrap.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../core/logging/app_logger.dart'; +import '../providers/settings_providers.dart'; +import '../services/storage_service.dart'; +import 'app.dart'; + +Future bootstrap() async { + WidgetsFlutterBinding.ensureInitialized(); + + final prefs = await SharedPreferences.getInstance(); + final storageService = StorageService(prefs: prefs); + + AppLogger.instance.info('BDK-Dart Wallet app started'); + + runApp( + ProviderScope( + overrides: [storageServiceProvider.overrideWithValue(storageService)], + child: const App(), + ), + ); +} diff --git a/bdk_demo/lib/core/constants/app_constants.dart b/bdk_demo/lib/core/constants/app_constants.dart new file mode 100644 index 0000000..a1c67ab --- /dev/null +++ b/bdk_demo/lib/core/constants/app_constants.dart @@ -0,0 +1,39 @@ +import 'package:bdk_demo/models/wallet_record.dart'; + +abstract final class AppConstants { + static const appVersion = '0.1.0'; + + static const walletLookahead = 25; + + static const fullScanStopGap = 25; + + static const syncParallelRequests = 4; + + static const maxRecipients = 4; + + static const maxLogEntries = 5000; +} + +enum ClientType { esplora, electrum } + +class EndpointConfig { + final ClientType clientType; + final String url; + + const EndpointConfig({required this.clientType, required this.url}); +} + +const Map defaultEndpoints = { + WalletNetwork.signet: EndpointConfig( + clientType: ClientType.electrum, + url: 'ssl://mempool.space:60602', + ), + WalletNetwork.testnet: EndpointConfig( + clientType: ClientType.electrum, + url: 'ssl://electrum.blockstream.info:60002', + ), + WalletNetwork.regtest: EndpointConfig( + clientType: ClientType.esplora, + url: 'http://localhost:3002', + ), +}; diff --git a/bdk_demo/lib/core/logging/app_logger.dart b/bdk_demo/lib/core/logging/app_logger.dart new file mode 100644 index 0000000..6ea821b --- /dev/null +++ b/bdk_demo/lib/core/logging/app_logger.dart @@ -0,0 +1,36 @@ +import 'dart:collection'; + +import 'package:bdk_demo/core/constants/app_constants.dart'; + +enum LogLevel { info, warn, error } + +class AppLogger { + AppLogger._(); + + static final AppLogger instance = AppLogger._(); + + final _entries = Queue(); + + int get maxEntries => AppConstants.maxLogEntries; + + void log(LogLevel level, String message) { + final timestamp = DateTime.now().toIso8601String(); + final label = switch (level) { + LogLevel.info => 'INFO', + LogLevel.warn => 'WARN', + LogLevel.error => 'ERROR', + }; + _entries.addFirst('$timestamp [$label] $message'); + while (_entries.length > maxEntries) { + _entries.removeLast(); + } + } + + void info(String message) => log(LogLevel.info, message); + void warn(String message) => log(LogLevel.warn, message); + void error(String message) => log(LogLevel.error, message); + + List getLogs() => _entries.toList(); + + void clear() => _entries.clear(); +} diff --git a/bdk_demo/lib/core/router/app_router.dart b/bdk_demo/lib/core/router/app_router.dart new file mode 100644 index 0000000..5d8d4fa --- /dev/null +++ b/bdk_demo/lib/core/router/app_router.dart @@ -0,0 +1,106 @@ +import 'package:go_router/go_router.dart'; +import 'package:bdk_demo/features/shared/widgets/placeholder_page.dart'; +import 'package:bdk_demo/features/wallet_setup/wallet_choice_page.dart'; + +abstract final class AppRoutes { + static const walletChoice = '/'; + static const activeWallets = '/active-wallets'; + static const createWallet = '/create-wallet'; + static const recoverWallet = '/recover-wallet'; + static const home = '/home'; + static const receive = '/receive'; + static const send = '/send'; + static const transactionHistory = '/transactions'; + static const transactionDetail = '/transactions/:txid'; + static const settings = '/settings'; + static const about = '/about'; + static const theme = '/theme'; + static const logs = '/logs'; + static const recoveryData = '/recovery-data'; +} + +GoRouter createRouter() => GoRouter( + initialLocation: AppRoutes.walletChoice, + routes: [ + GoRoute( + path: AppRoutes.walletChoice, + name: 'walletChoice', + builder: (context, state) => const WalletChoicePage(), + ), + GoRoute( + path: AppRoutes.activeWallets, + name: 'activeWallets', + builder: (context, state) => + const PlaceholderPage(title: 'Active Wallets'), + ), + GoRoute( + path: AppRoutes.createWallet, + name: 'createWallet', + builder: (context, state) => + const PlaceholderPage(title: 'Create Wallet'), + ), + GoRoute( + path: AppRoutes.recoverWallet, + name: 'recoverWallet', + builder: (context, state) => + const PlaceholderPage(title: 'Recover Wallet'), + ), + + GoRoute( + path: AppRoutes.home, + name: 'home', + builder: (context, state) => const PlaceholderPage(title: 'Home'), + ), + GoRoute( + path: AppRoutes.receive, + name: 'receive', + builder: (context, state) => const PlaceholderPage(title: 'Receive'), + ), + GoRoute( + path: AppRoutes.send, + name: 'send', + builder: (context, state) => const PlaceholderPage(title: 'Send'), + ), + GoRoute( + path: AppRoutes.transactionHistory, + name: 'transactionHistory', + builder: (context, state) => + const PlaceholderPage(title: 'Transaction History'), + ), + GoRoute( + path: AppRoutes.transactionDetail, + name: 'transactionDetail', + builder: (context, state) { + final txid = state.pathParameters['txid'] ?? ''; + return PlaceholderPage(title: 'Transaction $txid'); + }, + ), + + GoRoute( + path: AppRoutes.settings, + name: 'settings', + builder: (context, state) => const PlaceholderPage(title: 'Settings'), + ), + GoRoute( + path: AppRoutes.about, + name: 'about', + builder: (context, state) => const PlaceholderPage(title: 'About'), + ), + GoRoute( + path: AppRoutes.theme, + name: 'theme', + builder: (context, state) => const PlaceholderPage(title: 'Theme'), + ), + GoRoute( + path: AppRoutes.logs, + name: 'logs', + builder: (context, state) => const PlaceholderPage(title: 'Logs'), + ), + GoRoute( + path: AppRoutes.recoveryData, + name: 'recoveryData', + builder: (context, state) => + const PlaceholderPage(title: 'Recovery Data'), + ), + ], +); diff --git a/bdk_demo/lib/core/theme/app_colors.dart b/bdk_demo/lib/core/theme/app_colors.dart new file mode 100644 index 0000000..0bf4a31 --- /dev/null +++ b/bdk_demo/lib/core/theme/app_colors.dart @@ -0,0 +1,20 @@ +import 'package:flutter/material.dart'; + +abstract final class AppColors { + static const dayGlowPrimary = Color(0xFFF7931A); + static const dayGlowOnPrimary = Colors.white; + static const dayGlowBackground = Color(0xFFFAFAFA); + static const dayGlowSurface = Colors.white; + static const dayGlowHistoryAccent = Color(0xFFF5A623); + + static const nightGlowPrimary = Color(0xFFF7931A); + static const nightGlowOnPrimary = Colors.white; + static const nightGlowBackground = Color(0xFF121212); + static const nightGlowSurface = Color(0xFF1E1E1E); + static const nightGlowSubtle = Color(0xFF2A2A2A); + + static const pendingOrange = Color(0xFFF5A623); + static const confirmedGreen = Color(0xFF8FD998); + static const offlineRed = Color(0xFFE76F51); + static const errorRed = Color(0xFFCF6679); +} diff --git a/bdk_demo/lib/core/theme/app_theme.dart b/bdk_demo/lib/core/theme/app_theme.dart new file mode 100644 index 0000000..a044b8c --- /dev/null +++ b/bdk_demo/lib/core/theme/app_theme.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'app_colors.dart'; + +abstract final class AppTheme { + static ThemeData get light => ThemeData( + useMaterial3: true, + brightness: Brightness.light, + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.dayGlowPrimary, + brightness: Brightness.light, + surface: AppColors.dayGlowSurface, + ), + scaffoldBackgroundColor: AppColors.dayGlowBackground, + textTheme: GoogleFonts.interTextTheme(ThemeData.light().textTheme), + appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0), + cardTheme: CardThemeData( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.grey.shade200), + ), + ), + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + ); + + static ThemeData get dark => ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: ColorScheme.fromSeed( + seedColor: AppColors.nightGlowPrimary, + brightness: Brightness.dark, + surface: AppColors.nightGlowSurface, + ), + scaffoldBackgroundColor: AppColors.nightGlowBackground, + textTheme: GoogleFonts.interTextTheme(ThemeData.dark().textTheme), + appBarTheme: const AppBarTheme(centerTitle: true, elevation: 0), + cardTheme: CardThemeData( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.grey.shade800), + ), + ), + snackBarTheme: const SnackBarThemeData(behavior: SnackBarBehavior.floating), + ); + + static TextStyle get monoStyle => GoogleFonts.sourceCodePro(); +} diff --git a/bdk_demo/lib/core/utils/clipboard_util.dart b/bdk_demo/lib/core/utils/clipboard_util.dart new file mode 100644 index 0000000..378cfb8 --- /dev/null +++ b/bdk_demo/lib/core/utils/clipboard_util.dart @@ -0,0 +1,23 @@ +import 'package:flutter/services.dart'; +import 'package:flutter/material.dart'; + +abstract final class ClipboardUtil { + static Future copyAndNotify( + BuildContext context, + String text, { + String message = 'Copied to clipboard', + }) async { + await Clipboard.setData(ClipboardData(text: text)); + if (context.mounted) { + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text(message), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + ), + ); + } + } +} diff --git a/bdk_demo/lib/core/utils/formatters.dart b/bdk_demo/lib/core/utils/formatters.dart new file mode 100644 index 0000000..f99fdf0 --- /dev/null +++ b/bdk_demo/lib/core/utils/formatters.dart @@ -0,0 +1,48 @@ +import 'package:bdk_demo/models/currency_unit.dart'; + +abstract final class Formatters { + static String formatBalance(int satoshis, CurrencyUnit unit) => + switch (unit) { + CurrencyUnit.bitcoin => (satoshis / 100000000).toStringAsFixed(8), + CurrencyUnit.satoshi => '$satoshis sat', + }; + + static String formatAddress(String address) => + address.splitByLength(4).join(' '); + + static String formatTimestamp(int unixSeconds) { + final dt = DateTime.fromMillisecondsSinceEpoch(unixSeconds * 1000); + final months = [ + 'January', + 'February', + 'March', + 'April', + 'May', + 'June', + 'July', + 'August', + 'September', + 'October', + 'November', + 'December', + ]; + final month = months[dt.month - 1]; + final hour = dt.hour.toString().padLeft(2, '0'); + final minute = dt.minute.toString().padLeft(2, '0'); + 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)}' + : txid; +} + +extension StringChunking on String { + List splitByLength(int size) { + final chunks = []; + for (var i = 0; i < length; i += size) { + chunks.add(substring(i, i + size > length ? length : i + size)); + } + return chunks; + } +} diff --git a/bdk_demo/lib/features/shared/widgets/neutral_button.dart b/bdk_demo/lib/features/shared/widgets/neutral_button.dart new file mode 100644 index 0000000..390dca3 --- /dev/null +++ b/bdk_demo/lib/features/shared/widgets/neutral_button.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; + +class NeutralButton extends StatelessWidget { + final String label; + final VoidCallback? onPressed; + final IconData? icon; + + const NeutralButton({ + super.key, + required this.label, + required this.onPressed, + this.icon, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: onPressed, + style: OutlinedButton.styleFrom( + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (icon != null) ...[ + Icon(icon, size: 20), + const SizedBox(width: 8), + ], + Text(label, style: const TextStyle(fontSize: 16)), + ], + ), + ), + ); + } +} diff --git a/bdk_demo/lib/features/shared/widgets/placeholder_page.dart b/bdk_demo/lib/features/shared/widgets/placeholder_page.dart new file mode 100644 index 0000000..233462d --- /dev/null +++ b/bdk_demo/lib/features/shared/widgets/placeholder_page.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +import 'package:bdk_demo/features/shared/widgets/secondary_app_bar.dart'; + +/// Temporary placeholder page for routes not yet implemented. +class PlaceholderPage extends StatelessWidget { + final String title; + + const PlaceholderPage({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: SecondaryAppBar(title: title), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.construction, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + title, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text('Coming soon', style: TextStyle(color: Colors.grey.shade500)), + ], + ), + ), + ); + } +} diff --git a/bdk_demo/lib/features/shared/widgets/secondary_app_bar.dart b/bdk_demo/lib/features/shared/widgets/secondary_app_bar.dart new file mode 100644 index 0000000..6ac8e0d --- /dev/null +++ b/bdk_demo/lib/features/shared/widgets/secondary_app_bar.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +class SecondaryAppBar extends StatelessWidget implements PreferredSizeWidget { + final String title; + + const SecondaryAppBar({super.key, required this.title}); + + @override + Widget build(BuildContext context) { + return AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => Navigator.of(context).maybePop(), + ), + title: Text(title), + centerTitle: true, + ); + } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); +} diff --git a/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart b/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart new file mode 100644 index 0000000..9bac63c --- /dev/null +++ b/bdk_demo/lib/features/wallet_setup/wallet_choice_page.dart @@ -0,0 +1,126 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:bdk_demo/core/router/app_router.dart'; + +class WalletChoicePage extends StatelessWidget { + const WalletChoicePage({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + body: SafeArea( + child: Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: theme.colorScheme.primaryContainer, + ), + child: Center( + child: Text( + '₿', + style: TextStyle( + fontSize: 40, + fontWeight: FontWeight.bold, + color: theme.colorScheme.primary, + ), + ), + ), + ), + const SizedBox(height: 48), + + _ChoiceCard( + icon: Icons.account_balance_wallet, + title: 'Use an Active Wallet', + subtitle: 'Load a previously created wallet', + onTap: () => context.push(AppRoutes.activeWallets), + ), + const SizedBox(height: 16), + _ChoiceCard( + icon: Icons.add_circle_outline, + title: 'Create a New Wallet', + subtitle: 'Generate a new wallet with a fresh mnemonic', + onTap: () => context.push(AppRoutes.createWallet), + ), + const SizedBox(height: 16), + _ChoiceCard( + icon: Icons.restore, + title: 'Recover an Existing Wallet', + subtitle: 'Restore from recovery phrase or descriptor', + onTap: () => context.push(AppRoutes.recoverWallet), + ), + ], + ), + ), + ), + ), + ); + } +} + +class _ChoiceCard extends StatelessWidget { + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + const _ChoiceCard({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Card( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(16), + child: Padding( + padding: const EdgeInsets.all(20), + child: Row( + children: [ + Icon(icon, size: 36, color: theme.colorScheme.primary), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + subtitle, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(153), + ), + ), + ], + ), + ), + Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurface.withAlpha(102), + ), + ], + ), + ), + ), + ); + } +} diff --git a/bdk_demo/lib/main.dart b/bdk_demo/lib/main.dart index 8c72951..c562b9d 100644 --- a/bdk_demo/lib/main.dart +++ b/bdk_demo/lib/main.dart @@ -1,126 +1,3 @@ -import 'package:bdk_dart/bdk.dart' as bdk; -import 'package:flutter/material.dart'; +import 'app/bootstrap.dart'; -void main() { - runApp(const MyApp()); -} - -class MyApp extends StatelessWidget { - const MyApp({super.key}); - - @override - Widget build(BuildContext context) { - return MaterialApp( - title: 'BDK Dart Demo', - theme: ThemeData( - colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange), - useMaterial3: true, - ), - home: const MyHomePage(title: 'BDK Dart Proof of Concept'), - ); - } -} - -class MyHomePage extends StatefulWidget { - const MyHomePage({super.key, required this.title}); - final String title; - - @override - State createState() => _MyHomePageState(); -} - -class _MyHomePageState extends State { - String? _networkName; - String? _descriptorSnippet; - String? _error; - - void _showSignetNetwork() { - try { - final network = bdk.Network.testnet; - final descriptor = bdk.Descriptor( - descriptor: - 'wpkh(tprv8ZgxMBicQKsPf2qfrEygW6fdYseJDDrVnDv26PH5BHdvSuG6ecCbHqLVof9yZcMoM31z9ur3tTYbSnr1WBqbGX97CbXcmp5H6qeMpyvx35B/' - '84h/1h/0h/0/*)', - network: network, - ); - - setState(() { - _networkName = network.name; - _descriptorSnippet = descriptor.toString().substring(0, 32); - _error = null; - }); - } catch (e) { - setState(() { - _error = e.toString(); - _networkName = null; - _descriptorSnippet = null; - }); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Theme.of(context).colorScheme.inversePrimary, - title: Text(widget.title), - ), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - _error != null - ? Icons.error_outline - : _networkName != null - ? Icons.check_circle - : Icons.network_check, - size: 80, - color: _error != null - ? Colors.red - : _networkName != null - ? Colors.green - : Colors.grey, - ), - const SizedBox(height: 20), - const Text('BDK bindings status', style: TextStyle(fontSize: 20)), - if (_networkName != null) ...[ - Text( - 'Network: $_networkName', - style: Theme.of(context).textTheme.headlineMedium?.copyWith( - color: Colors.orange, - fontWeight: FontWeight.bold, - ), - ), - if (_descriptorSnippet != null) - Padding( - padding: const EdgeInsets.only(top: 12), - child: Text( - 'Descriptor sample: $_descriptorSnippet…', - style: const TextStyle(fontFamily: 'monospace'), - ), - ), - ] else if (_error != null) ...[ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Text( - _error!, - style: const TextStyle(color: Colors.red), - textAlign: TextAlign.center, - ), - ), - ] else ...[ - const Text('Press the button to load bindings'), - ], - ], - ), - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: _showSignetNetwork, - backgroundColor: Colors.orange, - icon: const Icon(Icons.play_circle_fill), - label: const Text('Load Dart binding'), - ), - ); - } -} +void main() => bootstrap(); diff --git a/bdk_demo/lib/models/currency_unit.dart b/bdk_demo/lib/models/currency_unit.dart new file mode 100644 index 0000000..8c2daee --- /dev/null +++ b/bdk_demo/lib/models/currency_unit.dart @@ -0,0 +1,14 @@ +enum CurrencyUnit { + bitcoin, + satoshi; + + String get label => switch (this) { + CurrencyUnit.bitcoin => 'BTC', + CurrencyUnit.satoshi => 'sat', + }; + + CurrencyUnit get toggled => switch (this) { + CurrencyUnit.bitcoin => CurrencyUnit.satoshi, + CurrencyUnit.satoshi => CurrencyUnit.bitcoin, + }; +} diff --git a/bdk_demo/lib/models/tx_details.dart b/bdk_demo/lib/models/tx_details.dart new file mode 100644 index 0000000..a55820e --- /dev/null +++ b/bdk_demo/lib/models/tx_details.dart @@ -0,0 +1,27 @@ +class TxDetails { + 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({ + required this.txid, + required this.sent, + required this.received, + this.fee = 0, + this.feeRate, + this.pending = true, + this.blockHeight, + this.confirmationTime, + }); + + int get netAmount => received - sent; + + String get shortTxid => txid.length > 16 + ? '${txid.substring(0, 8)}...${txid.substring(txid.length - 8)}' + : txid; +} diff --git a/bdk_demo/lib/models/wallet_record.dart b/bdk_demo/lib/models/wallet_record.dart new file mode 100644 index 0000000..2c2a798 --- /dev/null +++ b/bdk_demo/lib/models/wallet_record.dart @@ -0,0 +1,112 @@ +import 'dart:convert'; + +enum WalletNetwork { + signet, + testnet, + regtest; + + String get displayName => switch (this) { + WalletNetwork.signet => 'Signet', + WalletNetwork.testnet => 'Testnet 3', + WalletNetwork.regtest => 'Regtest', + }; +} + +enum ScriptType { + p2wpkh, + p2tr, + unknown; + + String get displayName => switch (this) { + ScriptType.p2wpkh => 'P2WPKH (Native SegWit)', + ScriptType.p2tr => 'P2TR (Taproot)', + ScriptType.unknown => 'Unknown', + }; + + String get shortName => switch (this) { + ScriptType.p2wpkh => 'P2WPKH', + ScriptType.p2tr => 'P2TR', + ScriptType.unknown => 'Unknown', + }; +} + +class WalletRecord { + final String id; + final String name; + final WalletNetwork network; + final ScriptType scriptType; + final bool fullScanCompleted; + + const WalletRecord({ + required this.id, + required this.name, + required this.network, + required this.scriptType, + this.fullScanCompleted = false, + }); + + WalletRecord copyWith({ + String? id, + String? name, + WalletNetwork? network, + ScriptType? scriptType, + bool? fullScanCompleted, + }) { + return WalletRecord( + id: id ?? this.id, + name: name ?? this.name, + network: network ?? this.network, + scriptType: scriptType ?? this.scriptType, + fullScanCompleted: fullScanCompleted ?? this.fullScanCompleted, + ); + } + + Map toJson() => { + 'id': id, + 'name': name, + 'network': network.name, + 'scriptType': scriptType.name, + 'fullScanCompleted': fullScanCompleted, + }; + + factory WalletRecord.fromJson(Map json) => WalletRecord( + id: json['id'] as String, + name: json['name'] as String, + network: WalletNetwork.values.byName(json['network'] as String), + scriptType: ScriptType.values.byName(json['scriptType'] as String), + fullScanCompleted: json['fullScanCompleted'] as bool? ?? false, + ); + + static String encodeList(List records) => + jsonEncode(records.map((r) => r.toJson()).toList()); + + static List decodeList(String encoded) => + (jsonDecode(encoded) as List) + .cast>() + .map(WalletRecord.fromJson) + .toList(); +} + +class WalletSecrets { + final String descriptor; + final String changeDescriptor; + final String recoveryPhrase; + + const WalletSecrets({ + required this.descriptor, + required this.changeDescriptor, + this.recoveryPhrase = '', + }); + + Map toJson() => { + 'descriptor': descriptor, + 'changeDescriptor': changeDescriptor, + 'recoveryPhrase': recoveryPhrase, + }; + + factory WalletSecrets.fromJson(Map json) => WalletSecrets( + descriptor: json['descriptor'] as String, + changeDescriptor: json['changeDescriptor'] as String, + recoveryPhrase: json['recoveryPhrase'] as String? ?? '', + ); +} diff --git a/bdk_demo/lib/providers/blockchain_providers.dart b/bdk_demo/lib/providers/blockchain_providers.dart new file mode 100644 index 0000000..3e046ec --- /dev/null +++ b/bdk_demo/lib/providers/blockchain_providers.dart @@ -0,0 +1,16 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +enum SyncStatus { idle, syncing, synced, error } + +final syncStatusProvider = NotifierProvider( + SyncStatusNotifier.new, +); + +class SyncStatusNotifier extends Notifier { + @override + SyncStatus build() => SyncStatus.idle; + + void set(SyncStatus status) => state = status; +} + +// TODO: Add blockchainServiceProvider, esploraClientProvider, etc. diff --git a/bdk_demo/lib/providers/connectivity_provider.dart b/bdk_demo/lib/providers/connectivity_provider.dart new file mode 100644 index 0000000..0fbe6aa --- /dev/null +++ b/bdk_demo/lib/providers/connectivity_provider.dart @@ -0,0 +1,15 @@ +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final connectivityProvider = StreamProvider>((ref) { + return Connectivity().onConnectivityChanged; +}); + +final isOnlineProvider = Provider((ref) { + final connectivity = ref.watch(connectivityProvider); + return connectivity.when( + data: (results) => results.any((r) => r != ConnectivityResult.none), + loading: () => true, + error: (_, __) => false, + ); +}); diff --git a/bdk_demo/lib/providers/router_provider.dart b/bdk_demo/lib/providers/router_provider.dart new file mode 100644 index 0000000..36b34f6 --- /dev/null +++ b/bdk_demo/lib/providers/router_provider.dart @@ -0,0 +1,10 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../core/router/app_router.dart'; + +final appRouterProvider = Provider((ref) { + final router = createRouter(); + ref.onDispose(router.dispose); + return router; +}); diff --git a/bdk_demo/lib/providers/settings_providers.dart b/bdk_demo/lib/providers/settings_providers.dart new file mode 100644 index 0000000..b207ee8 --- /dev/null +++ b/bdk_demo/lib/providers/settings_providers.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/storage_service.dart'; + +final storageServiceProvider = Provider((ref) { + throw UnimplementedError( + 'storageServiceProvider must be overridden with a ProviderScope override ' + 'after SharedPreferences is initialized in bootstrap().', + ); +}); + +final themeModeProvider = NotifierProvider( + ThemeModeNotifier.new, +); + +class ThemeModeNotifier extends Notifier { + @override + ThemeMode build() { + final storage = ref.watch(storageServiceProvider); + return storage.getDarkTheme() ? ThemeMode.dark : ThemeMode.light; + } + + void toggle() { + final isDark = state == ThemeMode.dark; + state = isDark ? ThemeMode.light : ThemeMode.dark; + ref.read(storageServiceProvider).setDarkTheme(!isDark); + } + + void setThemeMode(ThemeMode mode) { + state = mode; + ref.read(storageServiceProvider).setDarkTheme(mode == ThemeMode.dark); + } +} + +final introDoneProvider = Provider((ref) { + final storage = ref.watch(storageServiceProvider); + return storage.getIntroDone(); +}); diff --git a/bdk_demo/lib/providers/wallet_providers.dart b/bdk_demo/lib/providers/wallet_providers.dart new file mode 100644 index 0000000..7038d9c --- /dev/null +++ b/bdk_demo/lib/providers/wallet_providers.dart @@ -0,0 +1,52 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../models/wallet_record.dart'; +import 'settings_providers.dart'; + +final activeWalletRecordProvider = + NotifierProvider( + ActiveWalletRecordNotifier.new, + ); + +class ActiveWalletRecordNotifier extends Notifier { + @override + WalletRecord? build() => null; + + void set(WalletRecord record) => state = record; + void clear() => state = null; +} + +final walletRecordsProvider = + NotifierProvider>( + WalletRecordsNotifier.new, + ); + +class WalletRecordsNotifier extends Notifier> { + @override + List build() { + final storage = ref.watch(storageServiceProvider); + return storage.getWalletRecords(); + } + + Future addWalletRecord( + WalletRecord record, + WalletSecrets secrets, + ) async { + final storage = ref.read(storageServiceProvider); + await storage.addWalletRecord(record, secrets); + state = storage.getWalletRecords(); + } + + Future setFullScanCompleted(String walletId) async { + final storage = ref.read(storageServiceProvider); + await storage.setFullScanCompleted(walletId); + state = storage.getWalletRecords(); + } + + void refresh() { + state = ref.read(storageServiceProvider).getWalletRecords(); + } +} + +// TODO: Add activeWalletProvider. +// TODO: Add balanceProvider, syncStateProvider. diff --git a/bdk_demo/lib/services/blockchain_service.dart b/bdk_demo/lib/services/blockchain_service.dart new file mode 100644 index 0000000..aeb8bb9 --- /dev/null +++ b/bdk_demo/lib/services/blockchain_service.dart @@ -0,0 +1,5 @@ +class BlockchainService { + // TODO: Implement Esplora/Electrum client creation. + // TODO: Implement sync/fullScan with fullScanCompleted logic. + // TODO: Implement broadcast and fee estimation. +} diff --git a/bdk_demo/lib/services/storage_service.dart b/bdk_demo/lib/services/storage_service.dart new file mode 100644 index 0000000..0b30073 --- /dev/null +++ b/bdk_demo/lib/services/storage_service.dart @@ -0,0 +1,84 @@ +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../models/wallet_record.dart'; + +abstract final class _PrefKeys { + static const introDone = 'intro_done'; + static const darkTheme = 'dark_theme'; + static const walletRecords = 'wallet_records'; +} + +abstract final class _SecureKeys { + static String secrets(String walletId) => 'wallet_secrets_$walletId'; +} + +class StorageService { + final SharedPreferences _prefs; + final FlutterSecureStorage _secure; + + StorageService({ + required SharedPreferences prefs, + FlutterSecureStorage? secure, + }) : _prefs = prefs, + _secure = secure ?? const FlutterSecureStorage(); + + bool getIntroDone() => _prefs.getBool(_PrefKeys.introDone) ?? false; + + Future setIntroDone() => _prefs.setBool(_PrefKeys.introDone, true); + + bool getDarkTheme() => _prefs.getBool(_PrefKeys.darkTheme) ?? false; + + Future setDarkTheme(bool isDark) => + _prefs.setBool(_PrefKeys.darkTheme, isDark); + + List getWalletRecords() { + final encoded = _prefs.getString(_PrefKeys.walletRecords); + if (encoded == null) return []; + return WalletRecord.decodeList(encoded); + } + + Future addWalletRecord( + WalletRecord record, + WalletSecrets secrets, + ) async { + final secretsKey = _SecureKeys.secrets(record.id); + + await _secure.write(key: secretsKey, value: jsonEncode(secrets.toJson())); + + final records = getWalletRecords(); + records.add(record); + + try { + final didPersist = await _prefs.setString( + _PrefKeys.walletRecords, + WalletRecord.encodeList(records), + ); + if (!didPersist) { + throw StateError('Failed to persist wallet metadata.'); + } + } catch (_) { + await _secure.delete(key: secretsKey); + rethrow; + } + } + + Future getSecrets(String walletId) async { + final encoded = await _secure.read(key: _SecureKeys.secrets(walletId)); + if (encoded == null) return null; + return WalletSecrets.fromJson(jsonDecode(encoded) as Map); + } + + Future setFullScanCompleted(String walletId) async { + final records = getWalletRecords(); + final updated = records.map((r) { + if (r.id == walletId) return r.copyWith(fullScanCompleted: true); + return r; + }).toList(); + await _prefs.setString( + _PrefKeys.walletRecords, + WalletRecord.encodeList(updated), + ); + } +} diff --git a/bdk_demo/lib/services/wallet_service.dart b/bdk_demo/lib/services/wallet_service.dart new file mode 100644 index 0000000..5f7b79e --- /dev/null +++ b/bdk_demo/lib/services/wallet_service.dart @@ -0,0 +1,4 @@ +class WalletService { + // TODO: Implement wallet creation, recovery, and loading. + // TODO: Implement sync/fullScan integration. +} diff --git a/bdk_demo/pubspec.yaml b/bdk_demo/pubspec.yaml index f631c13..f5a8cb1 100644 --- a/bdk_demo/pubspec.yaml +++ b/bdk_demo/pubspec.yaml @@ -33,19 +33,26 @@ dependencies: bdk_dart: path: ../ - # The following adds the Cupertino Icons font to your application. - # Use with the CupertinoIcons class for iOS style icons. + flutter_riverpod: ^3.2.1 + + go_router: ^17.1.0 + + pretty_qr_code: ^3.6.0 + + shared_preferences: ^2.3.4 + flutter_secure_storage: ^10.0.0 + + connectivity_plus: ^7.0.0 + + google_fonts: ^8.0.2 + + uuid: ^4.5.3 + intl: ^0.20.2 cupertino_icons: ^1.0.8 dev_dependencies: flutter_test: sdk: flutter - - # The "flutter_lints" package below contains a set of recommended lints to - # encourage good coding practices. The lint set provided by the package is - # activated in the `analysis_options.yaml` file located at the root of your - # package. See that file for information about deactivating specific lint - # rules and activating additional ones. flutter_lints: ^5.0.0 # For information on the generic Dart part of this file, see the diff --git a/bdk_demo/test/widget_test.dart b/bdk_demo/test/widget_test.dart index b2a716f..4acb26f 100644 --- a/bdk_demo/test/widget_test.dart +++ b/bdk_demo/test/widget_test.dart @@ -1,30 +1,52 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -import 'package:bdk_demo/main.dart'; +import 'package:bdk_demo/app/app.dart'; +import 'package:bdk_demo/providers/settings_providers.dart'; +import 'package:bdk_demo/services/storage_service.dart'; void main() { - testWidgets('BDK bindings demo test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(const MyApp()); + testWidgets('App builds and shows WalletChoicePage', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); - // Verify initial state shows the prompt message. - expect(find.text('Press the button to load bindings'), findsOneWidget); - expect(find.text('BDK bindings status'), findsOneWidget); - expect(find.byIcon(Icons.network_check), findsOneWidget); + await tester.pumpWidget( + ProviderScope( + overrides: [ + storageServiceProvider.overrideWithValue( + StorageService(prefs: prefs), + ), + ], + child: const App(), + ), + ); + await tester.pumpAndSettle(); - // Verify the button exists. - expect(find.text('Load Dart binding'), findsOneWidget); - expect(find.byIcon(Icons.play_circle_fill), findsOneWidget); + 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('Theme defaults to light mode', (tester) async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); - // Tap the 'Load Dart binding' button and trigger a frame. - await tester.tap(find.byType(FloatingActionButton)); - await tester.pump(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + storageServiceProvider.overrideWithValue( + StorageService(prefs: prefs), + ), + ], + child: const App(), + ), + ); + await tester.pumpAndSettle(); - // Verify that the network and descriptor are displayed. - expect(find.textContaining('Network:'), findsOneWidget); - expect(find.textContaining('testnet'), findsOneWidget); - expect(find.textContaining('Descriptor sample:'), findsOneWidget); - expect(find.byIcon(Icons.check_circle), findsOneWidget); + final materialApp = tester.widget(find.byType(MaterialApp)); + expect(materialApp.themeMode, ThemeMode.light); }); }