import 'package:flutter/material.dart'; import '../core/app_controller.dart'; import '../core/failure.dart'; import '../l10n/strings.dart'; import '../services/ssh_service.dart'; import 'common.dart'; import 'password_fields.dart'; Widget languagePicker(BuildContext context, AppController c) => DropdownButtonFormField( initialValue: c.locale?.languageCode ?? 'system', isExpanded: true, decoration: InputDecoration(labelText: context.tr('language')), items: ['system', 'cs', 'de', 'en'] .map( (v) => DropdownMenuItem( value: v, child: Text(switch (v) { 'cs' => 'Čeština', 'de' => 'Deutsch', 'en' => 'English', _ => context.tr('system'), }), ), ) .toList(), onChanged: (v) async { c.locale = v == 'system' ? null : Locale(v!); try { await c.saveSettings(); } catch (e) { if (context.mounted) showFailure(context, e); } }, ); class Onboarding extends StatefulWidget { final AppController controller; const Onboarding(this.controller, {super.key}); @override State createState() => _OnboardingState(); } class _OnboardingState extends State { int step = 0; bool consent = false, busy = false, declined = false; @override Widget build(BuildContext context) { final c = widget.controller; final lang = Localizations.localeOf(context).languageCode; return Scaffold( appBar: AppBar(title: const Text('CH-J · SM Mobile')), body: SafeArea( child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 760), child: Padding( padding: const EdgeInsets.all(20), child: LayoutBuilder( builder: (context, constraints) => SingleChildScrollView( child: SizedBox( height: constraints.maxHeight.clamp( 600 * MediaQuery.textScalerOf(context).scale(1), double.infinity, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (step == 0) ...[ Text( context.tr('welcome'), style: Theme.of(context).textTheme.headlineSmall, ), const SizedBox(height: 16), languagePicker(context, c), const SizedBox(height: 16), Text(context.tr('localOnly')), const SizedBox(height: 16), ], Text( context.tr(step == 0 ? 'guidance' : 'license'), style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), Expanded( child: Card( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: SelectableText( step == 0 ? c.legal.guidance[lang] ?? c.legal.guidance['en']! : c.legal.license, ), ), ), ), if (step == 1) CheckboxListTile( value: consent, onChanged: busy ? null : (v) => setState(() => consent = v!), title: Text(context.tr('consent')), contentPadding: EdgeInsets.zero, ), if (declined) Text( context.tr('licenseDeclined'), style: TextStyle( color: Theme.of(context).colorScheme.error, ), ), Wrap( spacing: 12, alignment: WrapAlignment.end, children: [ if (step == 1) TextButton( onPressed: busy ? null : () => setState(() { declined = true; consent = false; }), child: Text(context.tr('decline')), ), FilledButton( onPressed: busy || (step == 1 && !consent) ? null : () async { if (step == 0) { setState(() => step = 1); return; } setState(() => busy = true); try { await c.acceptLicense(); } catch (e) { if (context.mounted) { showFailure(context, e); } } finally { if (mounted) { setState(() => busy = false); } } }, child: Text( context.tr( step == 0 ? 'continueAction' : 'accept', ), ), ), ], ), ], ), ), ), ), ), ), ), ), ); } } class VaultGate extends StatefulWidget { final AppController controller; const VaultGate(this.controller, {super.key}); @override State createState() => _VaultGateState(); } class _VaultGateState extends State { final password = TextEditingController(), repeated = TextEditingController(); bool busy = false; // Why the last biometric attempt did not unlock (localization key). String? biometricError; AppController get c => widget.controller; bool get _biometricReady => c.initialized && c.biometric && c.biometricVersion >= 2; @override void initState() { super.initState(); c.addListener(_maybeAutoBiometric); WidgetsBinding.instance.addPostFrameCallback((_) => _maybeAutoBiometric()); } /// Starts the biometric prompt by itself when the lock screen appears in /// the foreground — once per locked period, so a cancelled or failed /// prompt is never repeated in a loop. void _maybeAutoBiometric() { if (!mounted || busy || !_biometricReady || !c.foreground || c.authenticating || c.biometricAttempted || !c.accepted) { return; } WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && !busy && !c.biometricAttempted && c.foreground) { open(biometric: true); } }); } Future open({bool biometric = false}) async { setState(() { busy = true; if (biometric) biometricError = null; }); try { if (biometric) { await c.biometricUnlock(context.tr('biometricReason')); } else { if (!c.initialized && password.text != repeated.text) { throw const Failure('passwordMismatch'); } await c.openVault(password.text, create: !c.initialized); } password.clear(); repeated.clear(); } catch (e) { if (!mounted) return; if (biometric) { // Shown on the page; the password field stays ready below. setState(() => biometricError = failureCode(e)); } else { showFailure(context, e); } } finally { if (mounted) setState(() => busy = false); } } @override void dispose() { c.removeListener(_maybeAutoBiometric); password.dispose(); repeated.dispose(); super.dispose(); } @override Widget build(BuildContext context) { c.biometricReasonText = context.tr('biometricReason'); c.biometricNegativeText = context.tr('usePassword'); final scheme = Theme.of(context).colorScheme; final notice = biometricError ?? c.biometricNotice; return Scaffold( appBar: AppBar(title: const Text('SM Mobile')), body: SafeArea( child: Center( child: SingleChildScrollView( padding: const EdgeInsets.all(24), child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 450), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Icon(Icons.lock_outline, size: 56, color: scheme.primary), const SizedBox(height: 20), Text( context.tr(c.initialized ? 'vault' : 'createVault'), style: Theme.of(context).textTheme.headlineSmall, textAlign: TextAlign.center, ), const SizedBox(height: 20), if (!c.initialized) ...[ Text(context.tr('vaultAdvice')), const SizedBox(height: 20), ], if (notice != null && c.initialized) ...[ Card( color: scheme.secondaryContainer, child: Padding( padding: const EdgeInsets.all(12), child: Text(context.tr(notice)), ), ), const SizedBox(height: 12), ], PasswordField( controller: password, label: context.tr('masterPassword'), action: c.initialized ? TextInputAction.done : TextInputAction.next, onChanged: (_) { if (!c.initialized) setState(() {}); }, onSubmitted: (_) { if (!busy && c.initialized) open(); }, ), if (!c.initialized) ...[ const SizedBox(height: 8), PasswordAdvice(password.text), const SizedBox(height: 16), PasswordField( controller: repeated, label: context.tr('confirmPassword'), action: TextInputAction.done, onSubmitted: (_) { if (!busy) open(); }, ), ], const SizedBox(height: 20), FilledButton( onPressed: busy ? null : open, child: Text( context.tr(c.initialized ? 'unlock' : 'createVault'), ), ), if (_biometricReady) ...[ const SizedBox(height: 8), OutlinedButton.icon( onPressed: busy ? null : () => open(biometric: true), icon: const Icon(Icons.fingerprint), label: Text( context.tr( c.biometricAttempted ? 'tryBiometricAgain' : 'unlockBiometric', ), ), ), ], if (busy) const LinearProgressIndicator(), ], ), ), ), ), ), ); } }