892 lines
29 KiB
Dart
892 lines
29 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:path/path.dart' as p;
|
|
import 'package:window_manager/window_manager.dart';
|
|
|
|
import 'domain/models.dart';
|
|
import 'package:salvium_wallet/l10n/app_localizations.dart';
|
|
import 'services/app_paths.dart';
|
|
import 'services/local_node_service.dart';
|
|
import 'state/providers.dart';
|
|
import 'state/wallet_controller.dart';
|
|
import 'ui/screens/home_shell.dart';
|
|
import 'ui/screens/wallet_setup_screen.dart';
|
|
import 'ui/widgets/password_prompt_dialog.dart';
|
|
|
|
class PeyaApp extends ConsumerStatefulWidget {
|
|
const PeyaApp({super.key});
|
|
|
|
@override
|
|
ConsumerState<PeyaApp> createState() => _PeyaAppState();
|
|
}
|
|
|
|
class _PeyaAppState extends ConsumerState<PeyaApp> with WindowListener {
|
|
static const _bgPanel = Color(0xFF0C1824);
|
|
static const _bgPanelStrong = Color(0xFF0F1C2B);
|
|
static const _line = Color(0x1F88FFE1);
|
|
static const _textMain = Color(0xFFEFFAF8);
|
|
static const _textSoft = Color(0xFF9DB7B2);
|
|
static const _accentMint = Color(0xFF42F5C8);
|
|
static const _accentCyan = Color(0xFF4CC8FF);
|
|
static const _accentGold = Color(0xFFD4FF69);
|
|
|
|
bool _didInit = false;
|
|
bool _didApplyStartMinimized = false;
|
|
ProviderSubscription<AppConfig>? _configSubscription;
|
|
ProviderSubscription<WalletState>? _walletSubscription;
|
|
final GlobalKey<ScaffoldMessengerState> _scaffoldMessengerKey =
|
|
GlobalKey<ScaffoldMessengerState>();
|
|
final GlobalKey<NavigatorState> _navigatorKey = GlobalKey<NavigatorState>();
|
|
String? _lastErrorMessage;
|
|
String? _lastStatusMessage;
|
|
bool _statusSnackVisible = false;
|
|
bool _desktopCloseHookInitialized = false;
|
|
bool _handlingWindowClose = false;
|
|
|
|
Future<void> _logCloseDebug(String message) async {
|
|
try {
|
|
final file = File(p.join((await AppPaths.appSupportDir()).path, 'close-debug.log'));
|
|
await file.parent.create(recursive: true);
|
|
await file.writeAsString(
|
|
'[${DateTime.now().toIso8601String()}] $message\n',
|
|
mode: FileMode.writeOnlyAppend,
|
|
);
|
|
} catch (_) {}
|
|
}
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_bootstrap();
|
|
_configSubscription = ref
|
|
.listenManual<AppConfig>(appConfigControllerProvider, (previous, next) {
|
|
_updateTrayMenu();
|
|
_connectNodeIfPossible(next);
|
|
if (!_didApplyStartMinimized) {
|
|
_applyStartMinimized(next);
|
|
}
|
|
});
|
|
_walletSubscription = ref
|
|
.listenManual<WalletState>(walletControllerProvider, (previous, next) {
|
|
_handleOperationStatusChange(previous, next);
|
|
if (previous?.walletInfo == null && next.walletInfo != null) {
|
|
final config = ref.read(appConfigControllerProvider);
|
|
_connectNodeIfPossible(config);
|
|
}
|
|
final error = next.error;
|
|
if (error == null || error.isEmpty || error == _lastErrorMessage) {
|
|
if (error == null) {
|
|
_lastErrorMessage = null;
|
|
}
|
|
return;
|
|
}
|
|
_lastErrorMessage = error;
|
|
_showErrorSnackBar(error);
|
|
});
|
|
}
|
|
|
|
void _handleOperationStatusChange(WalletState? previous, WalletState next) {
|
|
final messenger = _scaffoldMessengerKey.currentState;
|
|
if (messenger == null) {
|
|
return;
|
|
}
|
|
final shouldShowStatus = !next.isBlockingOperation &&
|
|
next.operationMessage != null &&
|
|
next.operationMessage!.isNotEmpty;
|
|
|
|
if (shouldShowStatus) {
|
|
final message = _localizeOperationStatus(next.operationMessage!);
|
|
if (_statusSnackVisible && _lastStatusMessage == message) {
|
|
return;
|
|
}
|
|
messenger.clearSnackBars();
|
|
messenger.removeCurrentSnackBar();
|
|
messenger.showSnackBar(
|
|
SnackBar(
|
|
content: Text(message),
|
|
duration: const Duration(days: 1),
|
|
),
|
|
);
|
|
_statusSnackVisible = true;
|
|
_lastStatusMessage = message;
|
|
return;
|
|
}
|
|
|
|
if (_statusSnackVisible) {
|
|
messenger.clearSnackBars();
|
|
messenger.removeCurrentSnackBar();
|
|
_statusSnackVisible = false;
|
|
_lastStatusMessage = null;
|
|
}
|
|
}
|
|
|
|
String _localizeOperationStatus(String message) {
|
|
final l10n = AppLocalizations.of(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
);
|
|
if (l10n == null) {
|
|
return message;
|
|
}
|
|
|
|
switch (message) {
|
|
case 'Preparing wallet directory...':
|
|
return 'Preparing wallet directory...'.startsWith('Preparing') &&
|
|
Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Przygotowywanie katalogu portfela...'
|
|
: message;
|
|
case 'Creating wallet...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Tworzenie portfela...'
|
|
: message;
|
|
case 'Preparing temporary wallet...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Przygotowywanie tymczasowego portfela...'
|
|
: message;
|
|
case 'Generating seed phrase...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Generowanie seedu...'
|
|
: message;
|
|
case 'Restoring wallet from seed...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Przywracanie portfela z seedu...'
|
|
: message;
|
|
case 'Checking wallet files...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Sprawdzanie plików portfela...'
|
|
: message;
|
|
case 'Opening wallet...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Otwieranie portfela...'
|
|
: message;
|
|
case 'Loading wallet data...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Wczytywanie danych portfela...'
|
|
: message;
|
|
case 'Connecting to node...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Łączenie z nodem...'
|
|
: message;
|
|
case 'Creating subaddress...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Tworzenie subadresu...'
|
|
: message;
|
|
case 'Updating subaddress...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Aktualizowanie subadresu...'
|
|
: message;
|
|
case 'Preparing transaction...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Przygotowywanie transakcji...'
|
|
: message;
|
|
case 'Submitting transaction...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Wysyłanie transakcji...'
|
|
: message;
|
|
case 'Preparing stake...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Przygotowywanie stake...'
|
|
: message;
|
|
case 'Submitting stake...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Wysyłanie stake...'
|
|
: message;
|
|
case 'Refreshing balances...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Odświeżanie sald...'
|
|
: message;
|
|
case 'Loading transactions...':
|
|
return Localizations.localeOf(
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context,
|
|
).languageCode ==
|
|
'pl'
|
|
? 'Wczytywanie transakcji...'
|
|
: message;
|
|
default:
|
|
return message;
|
|
}
|
|
}
|
|
|
|
@override
|
|
void didChangeDependencies() {
|
|
super.didChangeDependencies();
|
|
_updateTrayMenu();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
if (_desktopCloseHookInitialized) {
|
|
windowManager.removeListener(this);
|
|
}
|
|
_configSubscription?.close();
|
|
_walletSubscription?.close();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _bootstrap() async {
|
|
if (_didInit) {
|
|
return;
|
|
}
|
|
_didInit = true;
|
|
final tray = ref.read(trayServiceProvider);
|
|
await tray.init();
|
|
await _initDesktopCloseHook();
|
|
ref.read(syncSchedulerProvider);
|
|
ref.read(walletConfigSyncProvider);
|
|
|
|
final config = ref.read(appConfigControllerProvider);
|
|
final requiresPassword =
|
|
await ref.read(walletControllerProvider.notifier).tryAutoOpen(config);
|
|
if (requiresPassword && config.lastWallet != null) {
|
|
await _promptForSavedWalletPassword(config.lastWallet!);
|
|
}
|
|
await _connectNodeIfPossible(config);
|
|
await _updateTrayMenu();
|
|
}
|
|
|
|
Future<void> _initDesktopCloseHook() async {
|
|
if (_desktopCloseHookInitialized ||
|
|
!(Platform.isLinux || Platform.isWindows)) {
|
|
await _logCloseDebug(
|
|
'initDesktopCloseHook skipped: initialized=$_desktopCloseHookInitialized platform=${Platform.operatingSystem}',
|
|
);
|
|
return;
|
|
}
|
|
await windowManager.ensureInitialized();
|
|
windowManager.addListener(this);
|
|
await windowManager.setPreventClose(true);
|
|
final preventClose = await windowManager.isPreventClose();
|
|
_desktopCloseHookInitialized = true;
|
|
await _logCloseDebug(
|
|
'initDesktopCloseHook ready: platform=${Platform.operatingSystem} preventClose=$preventClose',
|
|
);
|
|
}
|
|
|
|
@override
|
|
void onWindowClose() {
|
|
unawaited(_logCloseDebug('onWindowClose fired'));
|
|
unawaited(_handleNativeWindowClose());
|
|
}
|
|
|
|
Future<void> _handleNativeWindowClose() async {
|
|
if (_handlingWindowClose) {
|
|
await _logCloseDebug('handleNativeWindowClose skipped: already handling');
|
|
return;
|
|
}
|
|
_handlingWindowClose = true;
|
|
try {
|
|
await _logCloseDebug('handleNativeWindowClose entered');
|
|
final shouldClose = await _handleAppCloseRequest();
|
|
await _logCloseDebug('handleNativeWindowClose decision: shouldClose=$shouldClose');
|
|
if (!shouldClose) {
|
|
return;
|
|
}
|
|
await windowManager.setPreventClose(false);
|
|
await _logCloseDebug('handleNativeWindowClose destroying window');
|
|
await windowManager.destroy();
|
|
} catch (error, stack) {
|
|
await _logCloseDebug('handleNativeWindowClose error: $error\n$stack');
|
|
} finally {
|
|
await _logCloseDebug('handleNativeWindowClose finished');
|
|
_handlingWindowClose = false;
|
|
}
|
|
}
|
|
|
|
Future<void> _promptForSavedWalletPassword(WalletInfo wallet) async {
|
|
await WidgetsBinding.instance.endOfFrame;
|
|
while (mounted && ref.read(walletControllerProvider).walletInfo == null) {
|
|
final l10n = AppLocalizations.of(context);
|
|
if (l10n == null) {
|
|
return;
|
|
}
|
|
final password = await promptForPassword(
|
|
context: context,
|
|
title: l10n.openWalletAction,
|
|
label: l10n.passwordLabel,
|
|
confirmLabel: l10n.openAction,
|
|
cancelLabel: l10n.cancelAction,
|
|
message: wallet.name,
|
|
);
|
|
if (password == null) {
|
|
return;
|
|
}
|
|
try {
|
|
await ref.read(walletControllerProvider.notifier).openWallet(
|
|
path: wallet.path,
|
|
password: password,
|
|
);
|
|
return;
|
|
} catch (error) {
|
|
_showErrorSnackBar(error.toString());
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _connectNodeIfPossible(AppConfig config) async {
|
|
final walletState = ref.read(walletControllerProvider);
|
|
if (walletState.walletInfo == null) {
|
|
return;
|
|
}
|
|
if (config.nodeConfig.mode == NodeMode.local) {
|
|
final localNode = ref.read(localNodeServiceProvider);
|
|
final started = await localNode.ensureRunning(
|
|
config: LocalNodeConfig(extraArgs: config.localNodeArgs),
|
|
);
|
|
if (!started) {
|
|
ref.read(loggerProvider).w('Failed to ensure local node is running.');
|
|
return;
|
|
}
|
|
}
|
|
final connected = await _connectAndSyncWithRetry(
|
|
config,
|
|
attempts: config.nodeConfig.mode == NodeMode.local ? 10 : 1,
|
|
);
|
|
if (!connected) {
|
|
ref.read(loggerProvider).w('Failed to connect node after retries.');
|
|
return;
|
|
}
|
|
_scheduleBurstSync();
|
|
}
|
|
|
|
Future<bool> _connectAndSyncWithRetry(
|
|
AppConfig config, {
|
|
required int attempts,
|
|
}) async {
|
|
var sawConnected = false;
|
|
for (var attempt = 0; attempt < attempts; attempt++) {
|
|
try {
|
|
await ref
|
|
.read(walletControllerProvider.notifier)
|
|
.connectNode(config.nodeConfig);
|
|
await ref
|
|
.read(walletControllerProvider.notifier)
|
|
.syncNow(announceStages: true);
|
|
final syncedState = ref.read(walletControllerProvider);
|
|
if (syncedState.syncStatus.lastSync != null) {
|
|
return true;
|
|
}
|
|
} catch (_) {}
|
|
sawConnected = await ref.read(walletRepositoryProvider).isConnected();
|
|
if (sawConnected) {
|
|
final currentState = ref.read(walletControllerProvider);
|
|
if (currentState.syncStatus.lastSync != null) {
|
|
return true;
|
|
}
|
|
}
|
|
if (attempt + 1 < attempts) {
|
|
await Future.delayed(const Duration(milliseconds: 500));
|
|
}
|
|
}
|
|
return sawConnected;
|
|
}
|
|
|
|
void _scheduleBurstSync() {
|
|
unawaited(
|
|
ref.read(syncSchedulerProvider).burstSync(
|
|
count: 4,
|
|
interval: const Duration(seconds: 2),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _applyStartMinimized(AppConfig config) async {
|
|
if (_didApplyStartMinimized) {
|
|
return;
|
|
}
|
|
_didApplyStartMinimized = true;
|
|
if (config.startMinimized && config.minimizeToTray) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
|
await ref.read(trayServiceProvider).hideWindow();
|
|
});
|
|
}
|
|
}
|
|
|
|
Future<void> _updateTrayMenu() async {
|
|
final tray = ref.read(trayServiceProvider);
|
|
if (!tray.isAvailable) {
|
|
return;
|
|
}
|
|
final l10n = AppLocalizations.of(context);
|
|
if (l10n == null) {
|
|
return;
|
|
}
|
|
final visible = await windowManager.isVisible();
|
|
await tray.updateMenu(
|
|
isWindowVisible: visible,
|
|
showLabel: l10n.trayShow,
|
|
hideLabel: l10n.trayHide,
|
|
syncLabel: l10n.traySyncNow,
|
|
quitLabel: l10n.trayQuit,
|
|
onToggleVisibility: () async {
|
|
if (await windowManager.isVisible()) {
|
|
await tray.hideWindow();
|
|
} else {
|
|
await tray.showWindow();
|
|
}
|
|
await _updateTrayMenu();
|
|
},
|
|
onSyncNow: () => ref.read(walletControllerProvider.notifier).syncNow(),
|
|
onQuit: () async {
|
|
final shouldClose = await _handleAppCloseRequest();
|
|
if (!shouldClose) {
|
|
return;
|
|
}
|
|
await tray.dispose();
|
|
await windowManager.setPreventClose(false);
|
|
await windowManager.close();
|
|
},
|
|
);
|
|
}
|
|
|
|
Future<bool> _handleAppCloseRequest() async {
|
|
final config = ref.read(appConfigControllerProvider);
|
|
final localNodeService = ref.read(localNodeServiceProvider);
|
|
final shouldPromptForLocalNode = config.nodeConfig.mode == NodeMode.local &&
|
|
((await localNodeService.isRunning(
|
|
config: LocalNodeConfig(extraArgs: config.localNodeArgs),
|
|
)) ||
|
|
(await ref.read(walletRepositoryProvider).isConnected()));
|
|
await _logCloseDebug(
|
|
'handleAppCloseRequest: mode=${config.nodeConfig.mode} prompt=$shouldPromptForLocalNode mounted=$mounted',
|
|
);
|
|
var shouldStopLocalNode = false;
|
|
if (shouldPromptForLocalNode) {
|
|
if (!mounted) {
|
|
await _logCloseDebug('handleAppCloseRequest aborted: not mounted');
|
|
return false;
|
|
}
|
|
final dialogContext =
|
|
_navigatorKey.currentContext ?? _scaffoldMessengerKey.currentContext ?? context;
|
|
final l10n = AppLocalizations.of(dialogContext);
|
|
await _logCloseDebug('handleAppCloseRequest showing dialog');
|
|
final decision = await showDialog<bool?>(
|
|
context: dialogContext,
|
|
builder: (dialogContext) {
|
|
return AlertDialog(
|
|
title: Text(
|
|
l10n?.closeWalletNodeDialogTitle ?? 'Close wallet?',
|
|
),
|
|
content: Text(
|
|
l10n?.closeWalletNodeDialogBody ??
|
|
'A local node is still running. Do you want to keep it running after closing the wallet?',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(null),
|
|
child: Text(l10n?.cancelAction ?? 'Cancel'),
|
|
),
|
|
TextButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(false),
|
|
child: Text(
|
|
l10n?.closeWalletKeepNodeAction ?? 'Keep node running',
|
|
),
|
|
),
|
|
ElevatedButton(
|
|
onPressed: () => Navigator.of(dialogContext).pop(true),
|
|
child: Text(
|
|
l10n?.closeWalletStopNodeAction ?? 'Stop node and close',
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
if (decision == null) {
|
|
await _logCloseDebug('handleAppCloseRequest dialog cancelled');
|
|
return false;
|
|
}
|
|
shouldStopLocalNode = decision;
|
|
await _logCloseDebug('handleAppCloseRequest dialog decision: stopNode=$shouldStopLocalNode');
|
|
}
|
|
if (shouldStopLocalNode) {
|
|
final stopped = await localNodeService.stop();
|
|
await _logCloseDebug('handleAppCloseRequest stop result: $stopped');
|
|
if (!stopped) {
|
|
if (mounted) {
|
|
final l10n = AppLocalizations.of(context);
|
|
_scaffoldMessengerKey.currentState?.showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
l10n?.localNodeStopFailure ?? 'Failed to stop local node',
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
}
|
|
await ref.read(walletRepositoryProvider).closeWallet();
|
|
await _logCloseDebug('handleAppCloseRequest wallet closed');
|
|
return true;
|
|
}
|
|
|
|
void _showErrorSnackBar(String message) {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
final messenger = _scaffoldMessengerKey.currentState;
|
|
if (messenger == null) {
|
|
return;
|
|
}
|
|
final l10n = AppLocalizations.of(context);
|
|
final text = l10n?.errorMessage(message) ?? message;
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(text)),
|
|
);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final config = ref.watch(appConfigControllerProvider);
|
|
|
|
return MaterialApp(
|
|
onGenerateTitle: (context) => AppLocalizations.of(context)!.appTitle,
|
|
debugShowCheckedModeBanner: false,
|
|
scaffoldMessengerKey: _scaffoldMessengerKey,
|
|
navigatorKey: _navigatorKey,
|
|
theme: _buildTheme(),
|
|
locale: config.resolveLocale(),
|
|
supportedLocales: const [
|
|
Locale('en'),
|
|
Locale('pl'),
|
|
],
|
|
localizationsDelegates: const [
|
|
AppLocalizations.delegate,
|
|
GlobalMaterialLocalizations.delegate,
|
|
GlobalWidgetsLocalizations.delegate,
|
|
GlobalCupertinoLocalizations.delegate,
|
|
],
|
|
builder: (context, child) {
|
|
return DecoratedBox(
|
|
decoration: const BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [
|
|
Color(0xFF04090D),
|
|
Color(0xFF09131C),
|
|
Color(0xFF060D14),
|
|
],
|
|
stops: [0.0, 0.55, 1.0],
|
|
),
|
|
),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
const Positioned(
|
|
top: -120,
|
|
left: -80,
|
|
child: _GlowOrb(
|
|
size: 320,
|
|
color: Color(0x334CC8FF),
|
|
),
|
|
),
|
|
const Positioned(
|
|
top: -60,
|
|
right: -40,
|
|
child: _GlowOrb(
|
|
size: 260,
|
|
color: Color(0x2942F5C8),
|
|
),
|
|
),
|
|
if (child != null) child,
|
|
],
|
|
),
|
|
);
|
|
},
|
|
home: _RootRouter(),
|
|
);
|
|
}
|
|
|
|
ThemeData _buildTheme() {
|
|
const colorScheme = ColorScheme.dark(
|
|
brightness: Brightness.dark,
|
|
primary: _accentMint,
|
|
onPrimary: Color(0xFF02120E),
|
|
secondary: _accentCyan,
|
|
onSecondary: Color(0xFF03131A),
|
|
tertiary: _accentGold,
|
|
onTertiary: Color(0xFF111600),
|
|
error: Color(0xFFFF8F8F),
|
|
onError: Color(0xFF290000),
|
|
surface: _bgPanel,
|
|
onSurface: _textMain,
|
|
surfaceContainerHighest: _bgPanelStrong,
|
|
onSurfaceVariant: _textSoft,
|
|
outline: _line,
|
|
outlineVariant: Color(0x1AFFFFFF),
|
|
);
|
|
|
|
final base = ThemeData(
|
|
useMaterial3: true,
|
|
brightness: Brightness.dark,
|
|
colorScheme: colorScheme,
|
|
scaffoldBackgroundColor: Colors.transparent,
|
|
canvasColor: Colors.transparent,
|
|
splashFactory: InkSparkle.splashFactory,
|
|
);
|
|
|
|
return base.copyWith(
|
|
textTheme: base.textTheme.apply(
|
|
bodyColor: _textMain,
|
|
displayColor: _textMain,
|
|
),
|
|
appBarTheme: const AppBarTheme(
|
|
backgroundColor: Colors.transparent,
|
|
foregroundColor: _textMain,
|
|
elevation: 0,
|
|
scrolledUnderElevation: 0,
|
|
),
|
|
cardTheme: CardThemeData(
|
|
color: _bgPanel,
|
|
elevation: 0,
|
|
margin: EdgeInsets.zero,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(26),
|
|
side: const BorderSide(color: _line),
|
|
),
|
|
),
|
|
dialogTheme: DialogThemeData(
|
|
backgroundColor: _bgPanelStrong,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(28),
|
|
side: const BorderSide(color: _line),
|
|
),
|
|
),
|
|
dividerTheme: const DividerThemeData(
|
|
color: Color(0x1AFFFFFF),
|
|
space: 1,
|
|
thickness: 1,
|
|
),
|
|
snackBarTheme: SnackBarThemeData(
|
|
backgroundColor: _bgPanelStrong,
|
|
contentTextStyle: base.textTheme.bodyMedium?.copyWith(color: _textMain),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
side: const BorderSide(color: _line),
|
|
),
|
|
behavior: SnackBarBehavior.floating,
|
|
),
|
|
listTileTheme: const ListTileThemeData(
|
|
iconColor: _accentCyan,
|
|
textColor: _textMain,
|
|
),
|
|
navigationRailTheme: NavigationRailThemeData(
|
|
backgroundColor: const Color(0x880C1824),
|
|
selectedIconTheme: const IconThemeData(color: _accentMint),
|
|
selectedLabelTextStyle: base.textTheme.labelMedium?.copyWith(
|
|
color: _accentMint,
|
|
fontWeight: FontWeight.w700,
|
|
),
|
|
unselectedIconTheme: const IconThemeData(color: _textSoft),
|
|
unselectedLabelTextStyle: base.textTheme.labelMedium?.copyWith(
|
|
color: _textSoft,
|
|
),
|
|
indicatorColor: const Color(0x2242F5C8),
|
|
),
|
|
inputDecorationTheme: InputDecorationTheme(
|
|
filled: true,
|
|
fillColor: const Color(0xFF122031),
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
borderSide: const BorderSide(color: _line),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
borderSide: const BorderSide(color: _line),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
borderSide: const BorderSide(color: _accentCyan),
|
|
),
|
|
labelStyle: const TextStyle(color: _textSoft),
|
|
),
|
|
dropdownMenuTheme: DropdownMenuThemeData(
|
|
menuStyle: MenuStyle(
|
|
backgroundColor: WidgetStatePropertyAll(_bgPanelStrong),
|
|
surfaceTintColor: const WidgetStatePropertyAll(Colors.transparent),
|
|
shape: WidgetStatePropertyAll(
|
|
RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
side: const BorderSide(color: _line),
|
|
),
|
|
),
|
|
),
|
|
textStyle: const TextStyle(color: _textMain),
|
|
inputDecorationTheme: InputDecorationTheme(
|
|
filled: true,
|
|
fillColor: _bgPanel,
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
borderSide: const BorderSide(color: _line),
|
|
),
|
|
enabledBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
borderSide: const BorderSide(color: _line),
|
|
),
|
|
focusedBorder: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
borderSide: const BorderSide(color: _accentCyan),
|
|
),
|
|
),
|
|
),
|
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: _accentMint,
|
|
foregroundColor: const Color(0xFF03120D),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
|
),
|
|
),
|
|
outlinedButtonTheme: OutlinedButtonThemeData(
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: _accentCyan,
|
|
side: const BorderSide(color: _line),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 16),
|
|
),
|
|
),
|
|
textButtonTheme: TextButtonThemeData(
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: _accentGold,
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(18),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _GlowOrb extends StatelessWidget {
|
|
const _GlowOrb({
|
|
required this.size,
|
|
required this.color,
|
|
});
|
|
|
|
final double size;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return IgnorePointer(
|
|
child: Container(
|
|
width: size,
|
|
height: size,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [
|
|
color,
|
|
color.withValues(alpha: 0),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _RootRouter extends ConsumerWidget {
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final state = ref.watch(walletControllerProvider);
|
|
final config = ref.watch(appConfigControllerProvider);
|
|
final child = state.walletInfo == null
|
|
? WalletSetupScreen(key: const Key('wallet-setup'))
|
|
: HomeShell(key: Key('home-shell-${config.languagePreference.name}'));
|
|
|
|
return Stack(
|
|
children: [
|
|
child,
|
|
if (state.isLoading && state.isBlockingOperation)
|
|
Positioned.fill(
|
|
child: ColoredBox(
|
|
color: const Color(0x66000000),
|
|
child: Center(
|
|
child: Card(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 24,
|
|
vertical: 20,
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(height: 16),
|
|
Text(
|
|
state.operationMessage ??
|
|
AppLocalizations.of(context)
|
|
?.walletOperationInProgress ??
|
|
'Working...',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|