Initial setup: Flutter blockchain explorer app

- Clean architecture with feature-based organization
- Riverpod state management, Dio HTTP, GoRouter navigation
- Dashboard, blocks, wallet, transactions, mining screens
- Dark crypto theme with JetBrains Mono font
This commit is contained in:
StillHammer 2026-02-01 10:12:34 +08:00
commit 5a8300c5ea
15 changed files with 604 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
*.class
*.lock
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.dart_tool/
.flutter-plugins
.flutter-plugins-dependencies
.packages
.pub-cache/
.pub/
build/
flutter_*.png
linked_*.ds
unlinked.ds
unlinked_spec.ds
.idea/
.vagrant/
.sconsign.dblite
.svn/
*.iml
*.ipr
*.iws
.env

100
README.md Normal file
View File

@ -0,0 +1,100 @@
# blockchain-flutter
Flutter app for blockchain explorer, wallet management, and mining. Connects to the blockchain node via REST API.
## Architecture
Clean Architecture with feature-based organization:
```
lib/
├── main.dart
├── core/
│ ├── network/
│ │ └── api_client.dart # Dio HTTP client setup
│ ├── theme/
│ │ └── app_theme.dart # Dark theme, crypto aesthetic
│ └── utils/
│ └── formatters.dart # Amount, hash, date formatting
├── features/
│ ├── dashboard/ # Home: chain stats, recent blocks
│ │ ├── dashboard_screen.dart
│ │ └── dashboard_provider.dart
│ ├── blocks/ # Block explorer (list + detail)
│ │ ├── blocks_screen.dart
│ │ ├── block_detail_screen.dart
│ │ └── blocks_provider.dart
│ ├── wallet/ # Create, view balance, list wallets
│ │ ├── wallet_screen.dart
│ │ └── wallet_provider.dart
│ ├── transactions/ # Send tx, pending list
│ │ ├── transactions_screen.dart
│ │ ├── send_tx_screen.dart
│ │ └── transactions_provider.dart
│ └── mining/ # Mine button, difficulty info
│ ├── mining_screen.dart
│ └── mining_provider.dart
└── routing/
└── app_router.dart # GoRouter navigation
```
## Tech Stack
| Component | Choice | Rationale |
|-----------|--------|-----------|
| State management | Riverpod | Less boilerplate than BLoC, familiar |
| HTTP client | Dio | Interceptors, retry support |
| Navigation | GoRouter | Declarative routing |
| Models | Freezed + json_serializable | Immutable data classes |
| Secure storage | flutter_secure_storage | Wallet private keys |
| Charts | fl_chart | Chain visualization |
| Real-time | Polling (5-10s) | Simple, no WebSocket needed |
## Features
- **Dashboard**: chain height, difficulty, block reward, recent blocks
- **Block Explorer**: browse blocks, view transactions per block
- **Wallet**: generate keypairs, check balances, manage multiple wallets
- **Transactions**: send signed transactions, view pending pool
- **Mining**: one-tap mining, difficulty display, reward info
## Screens
1. **Dashboard** - overview with chain stats and recent activity
2. **Blocks** - paginated block list with expandable details
3. **Block Detail** - full block info with transaction list
4. **Wallets** - wallet list with balances, create new
5. **Send Transaction** - form: from, to, amount, sign & submit
6. **Pending Transactions** - current mempool
7. **Mining** - mine button with result display
## Quick Start
```bash
# Install dependencies
flutter pub get
# Generate code (Freezed models)
dart run build_runner build --delete-conflicting-outputs
# Run (blockchain-node must be running on localhost:3000)
flutter run
# Run on web
flutter run -d chrome
```
## Configuration
Node URL is configured in `lib/core/network/api_client.dart`. Defaults to `http://localhost:3000`.
For Android emulator, use `http://10.0.2.2:3000` to reach host localhost.
## Related Repos
- [blockchain-core](../blockchain-core) - Core library + REST API node
- [blockchain-cli](../blockchain-cli) - Rust CLI tool
## License
MIT

View File

@ -0,0 +1,105 @@
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
class ApiClient {
static const String _defaultBaseUrl = 'http://localhost:3000';
// Use 10.0.2.2 for Android emulator to reach host localhost
static const String _androidEmulatorUrl = 'http://10.0.2.2:3000';
late final Dio dio;
ApiClient({String? baseUrl}) {
final url = baseUrl ?? _resolveBaseUrl();
dio = Dio(BaseOptions(
baseUrl: url,
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 10),
headers: {'Content-Type': 'application/json'},
));
dio.interceptors.add(LogInterceptor(
requestBody: true,
responseBody: true,
logPrint: (log) => debugPrint(log.toString()),
));
}
static String _resolveBaseUrl() {
// Android emulator uses 10.0.2.2 to reach host machine
if (defaultTargetPlatform == TargetPlatform.android) {
return _androidEmulatorUrl;
}
return _defaultBaseUrl;
}
// Chain
Future<Map<String, dynamic>> getChainInfo() async {
final response = await dio.get('/api/chain/info');
return response.data;
}
Future<Map<String, dynamic>> validateChain() async {
final response = await dio.post('/api/chain/validate');
return response.data;
}
// Blocks
Future<Map<String, dynamic>> getBlocks({int offset = 0, int limit = 10}) async {
final response = await dio.get('/api/blocks', queryParameters: {
'offset': offset,
'limit': limit,
});
return response.data;
}
Future<Map<String, dynamic>> getBlock(String hash) async {
final response = await dio.get('/api/blocks/$hash');
return response.data;
}
// Wallets
Future<Map<String, dynamic>> createWallet() async {
final response = await dio.post('/api/wallets');
return response.data;
}
Future<Map<String, dynamic>> getBalance(String address) async {
final response = await dio.get('/api/wallets/$address/balance');
return response.data;
}
// Transactions
Future<Map<String, dynamic>> submitTransaction(Map<String, dynamic> tx) async {
final response = await dio.post('/api/transactions', data: tx);
return response.data;
}
Future<List<dynamic>> getPendingTransactions() async {
final response = await dio.get('/api/transactions/pending');
return response.data;
}
// Mining
Future<Map<String, dynamic>> mine(String minerAddress) async {
final response = await dio.post('/api/mine', data: {
'miner_address': minerAddress,
});
return response.data;
}
Future<Map<String, dynamic>> getMiningStatus() async {
final response = await dio.get('/api/mining/status');
return response.data;
}
// Health
Future<bool> healthCheck() async {
try {
await dio.get('/api/health');
return true;
} catch (_) {
return false;
}
}
}

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
class AppTheme {
static const _primaryColor = Color(0xFF6C63FF);
static const _accentColor = Color(0xFF00D9FF);
static const _backgroundColor = Color(0xFF0D1117);
static const _surfaceColor = Color(0xFF161B22);
static const _cardColor = Color(0xFF21262D);
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: const ColorScheme.dark(
primary: _primaryColor,
secondary: _accentColor,
surface: _surfaceColor,
),
scaffoldBackgroundColor: _backgroundColor,
cardColor: _cardColor,
textTheme: GoogleFonts.jetBrainsMonoTextTheme(
ThemeData.dark().textTheme,
),
appBarTheme: const AppBarTheme(
backgroundColor: _surfaceColor,
elevation: 0,
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: _primaryColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
),
),
cardTheme: CardTheme(
color: _cardColor,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: Colors.white.withAlpha(25)),
),
),
);
}
}

View File

@ -0,0 +1,31 @@
import 'package:intl/intl.dart';
class Formatters {
/// Format a hash for display (first 8 + ... + last 8).
static String truncateHash(String hash, {int chars = 8}) {
if (hash.length <= chars * 2) return hash;
return '${hash.substring(0, chars)}...${hash.substring(hash.length - chars)}';
}
/// Format amount from smallest unit to display unit.
/// 50_000_000 -> "50.000000"
static String formatAmount(int amount, {int decimals = 6}) {
final value = amount / 1000000;
return value.toStringAsFixed(decimals);
}
/// Format a timestamp string to readable date.
static String formatTimestamp(String timestamp) {
try {
final dt = DateTime.parse(timestamp);
return DateFormat('yyyy-MM-dd HH:mm:ss').format(dt.toLocal());
} catch (_) {
return timestamp;
}
}
/// Format large numbers with comma separators.
static String formatNumber(int number) {
return NumberFormat('#,###').format(number);
}
}

View File

@ -0,0 +1,21 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class BlockDetailScreen extends ConsumerWidget {
final String hash;
const BlockDetailScreen({super.key, required this.hash});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Block Detail')),
body: Center(
child: Text(
'Block Detail\n\nHash: $hash',
textAlign: TextAlign.center,
),
),
);
}
}

View File

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class BlocksScreen extends ConsumerWidget {
const BlocksScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Block Explorer')),
body: const Center(
child: Text(
'Block Explorer\n\nBrowse blocks with pagination.',
textAlign: TextAlign.center,
),
),
);
}
}

View File

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class DashboardScreen extends ConsumerWidget {
const DashboardScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Dashboard')),
body: const Center(
child: Text(
'Blockchain Dashboard\n\nChain stats, recent blocks, and network overview.',
textAlign: TextAlign.center,
),
),
);
}
}

View File

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class MiningScreen extends ConsumerWidget {
const MiningScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Mining')),
body: const Center(
child: Text(
'Mining\n\nMine blocks, view difficulty and rewards.',
textAlign: TextAlign.center,
),
),
);
}
}

View File

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class SendTxScreen extends ConsumerWidget {
const SendTxScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Send Transaction')),
body: const Center(
child: Text(
'Send Transaction\n\nForm: from, to, amount.',
textAlign: TextAlign.center,
),
),
);
}
}

View File

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class TransactionsScreen extends ConsumerWidget {
const TransactionsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Transactions')),
body: const Center(
child: Text(
'Pending Transactions\n\nView mempool.',
textAlign: TextAlign.center,
),
),
);
}
}

View File

@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class WalletScreen extends ConsumerWidget {
const WalletScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('Wallets')),
body: const Center(
child: Text(
'Wallet Management\n\nCreate wallets, view balances.',
textAlign: TextAlign.center,
),
),
);
}
}

25
lib/main.dart Normal file
View File

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'core/theme/app_theme.dart';
import 'routing/app_router.dart';
void main() {
runApp(const ProviderScope(child: BlockchainApp()));
}
class BlockchainApp extends ConsumerWidget {
const BlockchainApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = ref.watch(appRouterProvider);
return MaterialApp.router(
title: 'Blockchain Explorer',
theme: AppTheme.darkTheme,
routerConfig: router,
debugShowCheckedModeBanner: false,
);
}
}

View File

@ -0,0 +1,95 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../features/dashboard/dashboard_screen.dart';
import '../features/blocks/blocks_screen.dart';
import '../features/blocks/block_detail_screen.dart';
import '../features/wallet/wallet_screen.dart';
import '../features/transactions/transactions_screen.dart';
import '../features/transactions/send_tx_screen.dart';
import '../features/mining/mining_screen.dart';
final appRouterProvider = Provider<GoRouter>((ref) {
return GoRouter(
initialLocation: '/',
routes: [
ShellRoute(
builder: (context, state, child) => ScaffoldWithNav(child: child),
routes: [
GoRoute(
path: '/',
builder: (context, state) => const DashboardScreen(),
),
GoRoute(
path: '/blocks',
builder: (context, state) => const BlocksScreen(),
),
GoRoute(
path: '/blocks/:hash',
builder: (context, state) => BlockDetailScreen(
hash: state.pathParameters['hash']!,
),
),
GoRoute(
path: '/wallets',
builder: (context, state) => const WalletScreen(),
),
GoRoute(
path: '/transactions',
builder: (context, state) => const TransactionsScreen(),
),
GoRoute(
path: '/transactions/send',
builder: (context, state) => const SendTxScreen(),
),
GoRoute(
path: '/mining',
builder: (context, state) => const MiningScreen(),
),
],
),
],
);
});
class ScaffoldWithNav extends StatelessWidget {
final Widget child;
const ScaffoldWithNav({super.key, required this.child});
@override
Widget build(BuildContext context) {
return Scaffold(
body: child,
bottomNavigationBar: NavigationBar(
selectedIndex: _calculateIndex(GoRouterState.of(context).uri.path),
onDestinationSelected: (index) => _onTap(context, index),
destinations: const [
NavigationDestination(icon: Icon(Icons.dashboard), label: 'Dashboard'),
NavigationDestination(icon: Icon(Icons.view_in_ar), label: 'Blocks'),
NavigationDestination(icon: Icon(Icons.account_balance_wallet), label: 'Wallets'),
NavigationDestination(icon: Icon(Icons.swap_horiz), label: 'Transactions'),
NavigationDestination(icon: Icon(Icons.hardware), label: 'Mining'),
],
),
);
}
int _calculateIndex(String path) {
if (path.startsWith('/blocks')) return 1;
if (path.startsWith('/wallets')) return 2;
if (path.startsWith('/transactions')) return 3;
if (path.startsWith('/mining')) return 4;
return 0;
}
void _onTap(BuildContext context, int index) {
switch (index) {
case 0: context.go('/');
case 1: context.go('/blocks');
case 2: context.go('/wallets');
case 3: context.go('/transactions');
case 4: context.go('/mining');
}
}
}

34
pubspec.yaml Normal file
View File

@ -0,0 +1,34 @@
name: blockchain_flutter
description: Flutter app for blockchain explorer, wallet management, and mining.
publish_to: 'none'
version: 0.1.0
environment:
sdk: ^3.6.0
flutter: ">=3.27.0"
dependencies:
flutter:
sdk: flutter
flutter_riverpod: ^2.6.1
riverpod_annotation: ^2.6.1
dio: ^5.7.0
go_router: ^14.8.1
freezed_annotation: ^2.4.4
json_annotation: ^4.9.0
flutter_secure_storage: ^9.2.4
fl_chart: ^0.70.2
intl: ^0.19.0
google_fonts: ^6.2.1
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^5.0.0
build_runner: ^2.4.14
freezed: ^2.5.7
json_serializable: ^6.9.4
riverpod_generator: ^2.6.3
flutter:
uses-material-design: true