import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:package_info_plus/package_info_plus.dart'; import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../models/settings.dart'; import '../utils/app_update_util.dart'; import '../viewmodels/pin_lock_view_model.dart'; import '../viewmodels/settings_view_model.dart'; import '../l10n/app_localizations.dart'; import '../l10n/app_localizations_helpers.dart'; class SettingsScreenView extends StatefulWidget { const SettingsScreenView({super.key}); @override State createState() => _SettingsScreenViewState(); } class _SettingsScreenViewState extends State { String _appVersion = ''; Timer? _versionHoldTimer; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { context.read().ensureLoaded(); context.read().ensureLoaded(); _loadVersion(); }); } Future currentVersion() async { final info = await PackageInfo.fromPlatform(); debugPrint("Current app version: ${info.version}"); return info.version; } void _startVersionHold() { _versionHoldTimer?.cancel(); _versionHoldTimer = Timer(const Duration(seconds: 1), () { _versionHoldTimer = null; if (mounted) context.push('/dev'); }); } void _cancelVersionHold() { _versionHoldTimer?.cancel(); _versionHoldTimer = null; } @override void dispose() { _cancelVersionHold(); super.dispose(); } Future _loadVersion() async { final version = await currentVersion(); if (!mounted) return; setState(() { _appVersion = version; }); } Future _promptPin(String title, {String? hint}) { final controller = TextEditingController(); final l10n = AppLocalizations.of(context); return showDialog( context: context, builder: (context) => AlertDialog( title: Text(title), contentPadding: const EdgeInsets.fromLTRB(24, 20, 24, 0), content: SizedBox( width: 320, child: TextField( controller: controller, autofocus: true, obscureText: true, keyboardType: TextInputType.number, maxLength: 4, decoration: InputDecoration(hintText: hint), ), ), actionsPadding: const EdgeInsets.fromLTRB(16, 8, 16, 12), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text(l10n.cancel), ), const SizedBox(width: 12), FilledButton( onPressed: () => Navigator.pop(context, controller.text), child: Text(l10n.confirm), ), ], ), ); } void _showError(String message) { if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(message))); } Future _enablePin() async { final l10n = AppLocalizations.of(context); final pin = await _promptPin(l10n.setPinCode, hint: l10n.fourDigits); if (pin == null) return; if (pin.length != 4) { _showError(l10n.pinExactlyFour); return; } final pinLockViewModel = context.read(); final success = await pinLockViewModel.setPin(pin); if (!success) { _showError(l10n.localizedError(pinLockViewModel.errorMessage)); return; } pinLockViewModel.setPinRequired(true); try { await context.read().updatePinRequired(true); } catch (e, stack) { Sentry.captureException(e, stackTrace: stack); _showError(l10n.couldNotSavePin); } } Future _changePin() async { final l10n = AppLocalizations.of(context); final current = await _promptPin(l10n.enterCurrentPin); if (current == null) return; final newPin = await _promptPin(l10n.enterNewPin, hint: l10n.fourDigits); if (newPin == null) return; if (newPin.length != 4) { _showError(l10n.pinExactlyFour); return; } final pinLockViewModel = context.read(); final success = await pinLockViewModel.changePin( currentPin: current, newPin: newPin, ); if (!success) { _showError(l10n.localizedError(pinLockViewModel.errorMessage)); } } Future _disablePin() async { final l10n = AppLocalizations.of(context); final current = await _promptPin(l10n.enterCurrentPin); if (current == null) return; final pinLockViewModel = context.read(); final success = await pinLockViewModel.disablePin(current); if (!success) { _showError(l10n.localizedError(pinLockViewModel.errorMessage)); return; } pinLockViewModel.setPinRequired(false); try { await context.read().updatePinRequired(false); } catch (e, stack) { Sentry.captureException(e, stackTrace: stack); _showError(l10n.couldNotSavePin); } } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final settingsViewModel = context.watch(); final pinLockViewModel = context.watch(); return Scaffold( appBar: AppBar( title: Row( children: [ IconButton( onPressed: () => context.go('/bar'), icon: const Icon(Icons.arrow_back), ), const SizedBox(width: 5), Text(l10n.settingsTitle), ], ), ), body: Builder( builder: (context) { final isLoading = settingsViewModel.isLoading || pinLockViewModel.isLoading; final hasLoaded = settingsViewModel.hasLoaded && pinLockViewModel.hasLoaded; if (isLoading && !hasLoaded) { return const Center( child: CircularProgressIndicator(strokeWidth: 2.5), ); } final settings = settingsViewModel.settings; return ListView( padding: const EdgeInsets.symmetric(vertical: 12), children: [ _SettingsSection( title: l10n.security, children: [ _SettingsSwitchTile( icon: Icons.lock_outline_rounded, title: l10n.pinRequired, value: settings.pinRequired, onChanged: (value) { if (value) { _enablePin(); } else { _disablePin(); } }, ), if (settings.pinRequired) _SettingsTile( icon: Icons.pin_rounded, title: l10n.changePin, onTap: _changePin, ), ], ), _SettingsSection( title: l10n.appearance, children: [ _SettingsTile( icon: Icons.language_rounded, title: l10n.language, subtitle: switch (settings.language) { AppLanguage.system => l10n.languageSystem, AppLanguage.english => l10n.languageEnglish, AppLanguage.dutch => l10n.languageDutch, }, onTap: () async { final selected = await showModalBottomSheet( context: context, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: AppLanguage.values.map((language) { return RadioListTile( value: language, groupValue: settings.language, title: Text(switch (language) { AppLanguage.system => l10n.languageSystem, AppLanguage.english => l10n.languageEnglish, AppLanguage.dutch => l10n.languageDutch, }), onChanged: (value) => Navigator.pop(context, value), ); }).toList(), ), ), ); if (selected != null) { await settingsViewModel.updateLanguage(selected); } }, ), _SettingsTile( icon: Icons.grid_view_rounded, title: l10n.productGridRows, subtitle: l10n.rowsCount(settings.barGridRows), onTap: () async { final selected = await showModalBottomSheet( context: context, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: [ for (final rows in [2, 3, 4, 5, 6]) RadioListTile( value: rows, groupValue: settings.barGridRows, title: Text(l10n.rowsCount(rows)), onChanged: (value) => Navigator.pop(context, value), ), ], ), ), ); if (selected != null) { await settingsViewModel.updateBarGridRows(selected); } }, ), _SettingsTile( icon: Icons.brightness_6_rounded, title: l10n.theme, subtitle: switch (settings.themeMode) { AppThemeMode.system => l10n.system, AppThemeMode.light => l10n.light, AppThemeMode.dark => l10n.dark, AppThemeMode.ugly => l10n.ugly, }, onTap: () async { final selected = await showModalBottomSheet( context: context, builder: (context) => SafeArea( child: Column( mainAxisSize: MainAxisSize.min, children: AppThemeMode.values.map((mode) { return RadioListTile( value: mode, groupValue: settings.themeMode, title: Text(switch (mode) { AppThemeMode.system => l10n.system, AppThemeMode.light => l10n.light, AppThemeMode.dark => l10n.dark, AppThemeMode.ugly => l10n.ugly, }), onChanged: (value) => Navigator.pop(context, value), ); }).toList(), ), ), ); if (selected != null) { await settingsViewModel.updateThemeMode(selected); } }, ), ], ), if (Platform.isAndroid) _SettingsSection( title: l10n.updates, children: [ _SettingsTile( icon: Icons.system_update_alt_rounded, title: settingsViewModel.checkingForUpdates ? l10n.checkingForUpdates : l10n.checkForUpdates, onTap: settingsViewModel.checkingForUpdates ? null : _checkForUpdates, ), ], ), const SizedBox(height: 40), GestureDetector( onTapDown: (_) => _startVersionHold(), onTapUp: (_) => _cancelVersionHold(), onTapCancel: _cancelVersionHold, child: Center( child: Text( l10n.version(_appVersion), style: Theme.of(context).textTheme.bodySmall?.copyWith( color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.5), ), ), ), ), const SizedBox(height: 20), ], ); }, ), ); } Future _checkForUpdates() async { if (!Platform.isAndroid) return; final l10n = AppLocalizations.of(context); final vm = context.read(); try { final update = await vm.checkForUpdates(); if (!mounted) return; if (update == null) { ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.latestVersion))); return; } _showUpdateDialog(update); } catch (e, stack) { Sentry.captureException(e, stackTrace: stack); if (!mounted) return; ScaffoldMessenger.of( context, ).showSnackBar(SnackBar(content: Text(l10n.updateCheckFailed))); } } void _showUpdateDialog(UpdateInfo update) { final l10n = AppLocalizations.of(context); showDialog( context: context, barrierDismissible: !update.mandatory, builder: (dialogContext) => AlertDialog( title: Text(l10n.updateAvailable), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(l10n.versionAvailable(update.version)), const SizedBox(height: 12), Text(update.notes), ], ), actions: [ if (!update.mandatory) TextButton( onPressed: () => Navigator.pop(dialogContext), child: Text(l10n.later), ), FilledButton( onPressed: () { Navigator.pop(dialogContext); context.push('/update-progress', extra: update); }, child: Text(l10n.update), ), ], ), ); } } class _SettingsSection extends StatelessWidget { final String title; final List children; const _SettingsSection({required this.title, required this.children}); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( title.toUpperCase(), style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, letterSpacing: 0.5, color: scheme.onSurface.withValues(alpha: 0.5), ), ), const SizedBox(height: 8), Container( decoration: BoxDecoration( color: scheme.onSurface.withValues(alpha: 0.04), borderRadius: BorderRadius.circular(14), border: Border.all( color: scheme.onSurface.withValues(alpha: 0.06), ), ), clipBehavior: Clip.antiAlias, child: Column(children: children), ), ], ), ); } } class _SettingsTile extends StatelessWidget { final IconData icon; final String title; final String? subtitle; final VoidCallback? onTap; const _SettingsTile({ required this.icon, required this.title, this.subtitle, this.onTap, }); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Material( color: Colors.transparent, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ Icon( icon, size: 22, color: scheme.onSurface.withValues(alpha: 0.7), ), const SizedBox(width: 14), Expanded( child: Text( title, style: TextStyle( fontWeight: FontWeight.w600, color: scheme.onSurface, ), ), ), if (subtitle != null) ...[ Text(subtitle!, style: Theme.of(context).textTheme.bodySmall), const SizedBox(width: 4), ], if (onTap != null) Icon( Icons.chevron_right_rounded, color: scheme.onSurface.withValues(alpha: 0.3), ), ], ), ), ), ); } } class _SettingsSwitchTile extends StatelessWidget { final IconData icon; final String title; final bool value; final ValueChanged onChanged; const _SettingsSwitchTile({ required this.icon, required this.title, required this.value, required this.onChanged, }); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), child: Row( children: [ Icon(icon, size: 22, color: scheme.onSurface.withValues(alpha: 0.7)), const SizedBox(width: 14), Expanded( child: Text( title, style: TextStyle( fontWeight: FontWeight.w600, color: scheme.onSurface, ), ), ), Switch(value: value, onChanged: onChanged), ], ), ); } }