615 lines
21 KiB
Dart
615 lines
21 KiB
Dart
import 'dart:io';
|
|
import 'dart:math' as math;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
|
|
|
import '../../domain/models.dart';
|
|
import '../../state/mining_controller.dart';
|
|
import '../../state/providers.dart';
|
|
import '../l10n/app_localizations_ext.dart';
|
|
|
|
class MiningScreen extends ConsumerStatefulWidget {
|
|
const MiningScreen({super.key});
|
|
|
|
@override
|
|
ConsumerState<MiningScreen> createState() => _MiningScreenState();
|
|
}
|
|
|
|
class _MiningScreenState extends ConsumerState<MiningScreen> {
|
|
late MiningMode _mode;
|
|
late int _cpuThreads;
|
|
late final TextEditingController _apiPortController;
|
|
bool _dirty = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
final config = ref.read(appConfigControllerProvider).miningConfig;
|
|
_mode = config.mode;
|
|
_cpuThreads = config.cpuThreads;
|
|
_apiPortController = TextEditingController(text: config.apiPort.toString());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_apiPortController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final theme = Theme.of(context);
|
|
final config = ref.watch(appConfigControllerProvider);
|
|
final walletState = ref.watch(walletControllerProvider);
|
|
final miningState = ref.watch(miningControllerProvider);
|
|
final maxThreads = math.max(1, math.min(Platform.numberOfProcessors, 64));
|
|
|
|
return SingleChildScrollView(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 1120),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
l10n.miningTitle,
|
|
style: theme.textTheme.headlineMedium?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
l10n.miningScreenSubtitle,
|
|
style: theme.textTheme.bodyLarge?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 24),
|
|
_SectionCard(
|
|
title: l10n.miningControlsTitle,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: miningState.busy || miningState.running
|
|
? null
|
|
: _startMining,
|
|
icon: const Icon(Symbols.play_arrow),
|
|
label: Text(l10n.miningStartAction),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: miningState.busy || !miningState.running
|
|
? null
|
|
: _stopMining,
|
|
icon: const Icon(Symbols.stop),
|
|
label: Text(l10n.miningStopAction),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: miningState.busy || !miningState.running
|
|
? null
|
|
: _restartMining,
|
|
icon: const Icon(Symbols.refresh),
|
|
label: Text(l10n.localNodeRestartAction),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _showSettingsDialog(maxThreads),
|
|
icon: const Icon(Symbols.settings),
|
|
label: Text(l10n.miningSettingsAction),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
miningState.error ??
|
|
(miningState.running
|
|
? l10n.miningStatusRunning
|
|
: l10n.miningStatusStopped),
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: miningState.error == null
|
|
? theme.colorScheme.onSurfaceVariant
|
|
: theme.colorScheme.error,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_SectionCard(
|
|
title: l10n.miningCurrentConfigTitle,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_InfoTile(
|
|
label: l10n.miningWalletAddressLabel,
|
|
value: walletState.walletInfo?.address.isNotEmpty == true
|
|
? walletState.walletInfo!.address
|
|
: l10n.miningNoWalletAddress,
|
|
icon: Symbols.account_balance_wallet,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_InfoTile(
|
|
label: l10n.miningModeLabel,
|
|
value: config.miningConfig.mode == MiningMode.solo
|
|
? l10n.miningModeSolo
|
|
: l10n.miningModePool,
|
|
icon: config.miningConfig.mode == MiningMode.solo
|
|
? Symbols.lan
|
|
: Symbols.hub,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_InfoTile(
|
|
label: l10n.miningTargetLabel,
|
|
value: config.miningConfig.mode == MiningMode.solo
|
|
? l10n.miningSoloTargetValue
|
|
: '${config.miningConfig.poolHost}:${config.miningConfig.poolPort}',
|
|
icon: Symbols.route,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_InfoTile(
|
|
label: l10n.miningThreadsLabel,
|
|
value: config.miningConfig.cpuThreads.toString(),
|
|
icon: Symbols.memory,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_InfoTile(
|
|
label: l10n.miningApiPortLabel,
|
|
value: config.miningConfig.apiPort.toString(),
|
|
icon: Symbols.settings_ethernet,
|
|
),
|
|
const SizedBox(height: 12),
|
|
_InfoTile(
|
|
label: l10n.miningCurrentDiffLabel,
|
|
value: miningState.summary?.currentDifficulty.toString() ??
|
|
l10n.miningDataUnknownValue,
|
|
icon: Symbols.speed,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
_SectionCard(
|
|
title: l10n.miningStatsTitle,
|
|
child: Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
_StatTile(
|
|
label: l10n.transactionStatusLabel,
|
|
value: miningState.running
|
|
? l10n.miningStatusRunning
|
|
: l10n.miningStatusStopped,
|
|
),
|
|
_StatTile(
|
|
label: l10n.miningHashrate10sLabel,
|
|
value: _formatHashrate(miningState.summary?.hashrate10s, l10n),
|
|
),
|
|
_StatTile(
|
|
label: l10n.miningHashrate1mLabel,
|
|
value: _formatHashrate(miningState.summary?.hashrate1m, l10n),
|
|
),
|
|
_StatTile(
|
|
label: l10n.miningLabelHashrate15m,
|
|
value: _formatHashrate(miningState.summary?.hashrate15m, l10n),
|
|
),
|
|
_StatTile(
|
|
label: l10n.miningJobsLabel,
|
|
value: l10n.miningDataUnknownValue,
|
|
),
|
|
_StatTile(
|
|
label: l10n.miningAcceptedSharesLabel,
|
|
value: miningState.summary?.acceptedShares.toString() ??
|
|
l10n.miningDataUnknownValue,
|
|
),
|
|
_StatTile(
|
|
label: l10n.miningRejectedSharesLabel,
|
|
value: miningState.summary?.rejectedShares.toString() ??
|
|
l10n.miningDataUnknownValue,
|
|
),
|
|
_StatTile(
|
|
label: l10n.miningLabelUptime,
|
|
value: _formatUptime(
|
|
miningState.summary?.uptimeSeconds,
|
|
l10n,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
void _resetDraft() {
|
|
final config = ref.read(appConfigControllerProvider).miningConfig;
|
|
setState(() {
|
|
_mode = config.mode;
|
|
_cpuThreads = config.cpuThreads;
|
|
_apiPortController.text = config.apiPort.toString();
|
|
_dirty = false;
|
|
});
|
|
}
|
|
|
|
Future<void> _saveConfig() async {
|
|
final l10n = context.l10n;
|
|
final apiPort = int.tryParse(_apiPortController.text.trim());
|
|
if (apiPort == null || apiPort <= 0) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.miningConfigInvalid)),
|
|
);
|
|
return;
|
|
}
|
|
|
|
final next = MiningConfig(
|
|
mode: _mode,
|
|
cpuThreads: _cpuThreads,
|
|
poolHost: 'peya.cryptohash.top',
|
|
poolPort: 3333,
|
|
apiPort: apiPort,
|
|
);
|
|
await ref.read(appConfigControllerProvider.notifier).setMiningConfig(next);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
setState(() => _dirty = false);
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text(l10n.miningSettingsSaved)),
|
|
);
|
|
await ref.read(miningControllerProvider.notifier).refresh();
|
|
}
|
|
|
|
Future<void> _showSettingsDialog(int maxThreads) async {
|
|
final l10n = context.l10n;
|
|
final theme = Theme.of(context);
|
|
await showDialog<void>(
|
|
context: context,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
backgroundColor: theme.colorScheme.surface,
|
|
title: Text(l10n.miningSettingsAction),
|
|
content: SizedBox(
|
|
width: 520,
|
|
child: StatefulBuilder(
|
|
builder: (context, setDialogState) {
|
|
final nodeMode = ref.read(appConfigControllerProvider).nodeConfig.mode;
|
|
return Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
DropdownButtonFormField<MiningMode>(
|
|
value: _mode,
|
|
dropdownColor: theme.colorScheme.surface,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.miningModeLabel,
|
|
),
|
|
items: [
|
|
DropdownMenuItem(
|
|
value: MiningMode.solo,
|
|
child: Text(l10n.miningModeSolo),
|
|
),
|
|
DropdownMenuItem(
|
|
value: MiningMode.pool,
|
|
child: Text(l10n.miningModePool),
|
|
),
|
|
],
|
|
onChanged: (value) {
|
|
if (value == null) {
|
|
return;
|
|
}
|
|
setState(() {
|
|
_mode = value;
|
|
_dirty = true;
|
|
});
|
|
setDialogState(() {});
|
|
},
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (_mode == MiningMode.solo) ...[
|
|
_InfoTile(
|
|
label: l10n.miningTargetLabel,
|
|
value: l10n.miningSoloTargetValue,
|
|
icon: Symbols.lan,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
nodeMode == NodeMode.local
|
|
? l10n.miningSoloHint
|
|
: l10n.miningSoloNeedsLocalNode,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: nodeMode == NodeMode.local
|
|
? theme.colorScheme.onSurfaceVariant
|
|
: theme.colorScheme.error,
|
|
),
|
|
),
|
|
] else ...[
|
|
_InfoTile(
|
|
label: l10n.miningTargetLabel,
|
|
value: 'peya.cryptohash.top:3333',
|
|
icon: Symbols.hub,
|
|
),
|
|
const SizedBox(height: 12),
|
|
Text(
|
|
l10n.miningPoolFixedHint,
|
|
style: theme.textTheme.bodyMedium?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
],
|
|
const SizedBox(height: 20),
|
|
Text(
|
|
'${l10n.miningThreadsLabel}: $_cpuThreads',
|
|
style: theme.textTheme.titleSmall?.copyWith(
|
|
fontWeight: FontWeight.w600,
|
|
),
|
|
),
|
|
Slider(
|
|
value: _cpuThreads.toDouble(),
|
|
min: 1,
|
|
max: maxThreads.toDouble(),
|
|
divisions: math.max(0, maxThreads - 1),
|
|
label: _cpuThreads.toString(),
|
|
onChanged: (value) {
|
|
final next = value.round();
|
|
setState(() {
|
|
_cpuThreads = next;
|
|
_dirty = true;
|
|
});
|
|
setDialogState(() {});
|
|
},
|
|
),
|
|
const SizedBox(height: 8),
|
|
TextFormField(
|
|
controller: _apiPortController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: InputDecoration(
|
|
labelText: l10n.miningApiPortLabel,
|
|
),
|
|
onChanged: (_) {
|
|
setState(() => _dirty = true);
|
|
setDialogState(() {});
|
|
},
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () {
|
|
_resetDraft();
|
|
Navigator.of(dialogContext).pop();
|
|
},
|
|
child: Text(l10n.miningResetDraftAction),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(),
|
|
child: Text(l10n.cancelAction),
|
|
),
|
|
FilledButton(
|
|
onPressed: () {
|
|
final apiPort = int.tryParse(_apiPortController.text.trim());
|
|
return apiPort != null && apiPort > 0;
|
|
}()
|
|
? () async {
|
|
await _saveConfig();
|
|
if (mounted) {
|
|
Navigator.of(dialogContext).pop();
|
|
}
|
|
}
|
|
: null,
|
|
child: Text(l10n.saveAction),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<void> _startMining() async {
|
|
final l10n = context.l10n;
|
|
final started = await ref.read(miningControllerProvider.notifier).start();
|
|
final miningState = ref.read(miningControllerProvider);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
started
|
|
? l10n.miningMinerStartSuccess
|
|
: (miningState.error?.isNotEmpty == true
|
|
? '${l10n.miningMinerStartFailure}: ${miningState.error}'
|
|
: l10n.miningMinerStartFailure),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _stopMining() async {
|
|
final l10n = context.l10n;
|
|
final stopped = await ref.read(miningControllerProvider.notifier).stop();
|
|
final miningState = ref.read(miningControllerProvider);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
stopped
|
|
? l10n.miningMinerStopSuccess
|
|
: (miningState.error?.isNotEmpty == true
|
|
? '${l10n.miningMinerStopFailure}: ${miningState.error}'
|
|
: l10n.miningMinerStopFailure),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _restartMining() async {
|
|
final l10n = context.l10n;
|
|
final restarted = await ref.read(miningControllerProvider.notifier).restart();
|
|
final miningState = ref.read(miningControllerProvider);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
restarted
|
|
? l10n.miningMinerRestartSuccess
|
|
: (miningState.error?.isNotEmpty == true
|
|
? '${l10n.miningMinerRestartFailure}: ${miningState.error}'
|
|
: l10n.miningMinerRestartFailure),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _formatHashrate(double? value, dynamic l10n) {
|
|
if (value == null) {
|
|
return l10n.miningDataUnknownValue;
|
|
}
|
|
return '${value.toStringAsFixed(1)} H/s';
|
|
}
|
|
|
|
String _formatUptime(int? seconds, dynamic l10n) {
|
|
if (seconds == null) {
|
|
return l10n.miningDataUnknownValue;
|
|
}
|
|
final hours = seconds ~/ 3600;
|
|
final minutes = (seconds % 3600) ~/ 60;
|
|
final secs = seconds % 60;
|
|
if (hours > 0) {
|
|
return '${hours}h ${minutes}m';
|
|
}
|
|
if (minutes > 0) {
|
|
return '${minutes}m ${secs}s';
|
|
}
|
|
return '${secs}s';
|
|
}
|
|
}
|
|
|
|
class _SectionCard extends StatelessWidget {
|
|
const _SectionCard({required this.title, required this.child});
|
|
|
|
final String title;
|
|
final Widget child;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return SizedBox(
|
|
width: double.infinity,
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
title,
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
child,
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _StatTile extends StatelessWidget {
|
|
const _StatTile({required this.label, required this.value});
|
|
|
|
final String label;
|
|
final String value;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Container(
|
|
width: 180,
|
|
padding: const EdgeInsets.all(16),
|
|
decoration: BoxDecoration(
|
|
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
value,
|
|
style: theme.textTheme.titleLarge?.copyWith(
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _InfoTile extends StatelessWidget {
|
|
const _InfoTile({
|
|
required this.label,
|
|
required this.value,
|
|
required this.icon,
|
|
});
|
|
|
|
final String label;
|
|
final String value;
|
|
final IconData icon;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 20, color: theme.colorScheme.primary),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.bodySmall?.copyWith(
|
|
color: theme.colorScheme.onSurfaceVariant,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
SelectableText(
|
|
value,
|
|
style: theme.textTheme.bodyLarge,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|