fix: alot

This commit is contained in:
2026-10-19 21:41:34 +02:00
parent 30dd74af90
commit b0074aeb3f
40 changed files with 1979 additions and 405 deletions
+14 -19
View File
@@ -33,11 +33,8 @@ class AdminScreenView extends StatelessWidget {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Disconnect from server'),
content: const Text(
'This will unlink the device from the remote server. '
'Open tabs will no longer be synced. Continue?',
),
title: Text(l10n.disconnectFromServer),
content: Text(l10n.disconnectFromServerDescription),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
@@ -46,7 +43,7 @@ class AdminScreenView extends StatelessWidget {
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
style: FilledButton.styleFrom(backgroundColor: Colors.red),
child: const Text('Disconnect'),
child: Text(l10n.disconnect),
),
],
),
@@ -109,35 +106,33 @@ class AdminScreenView extends StatelessWidget {
subtitle: l10n.closedTabs,
onTap: () => context.push('/history'),
),
_AdminTile(
icon: Icons.file_upload_outlined,
title: l10n.importData,
subtitle: l10n.importNamesDescription,
onTap: () => context.push('/admin/import'),
),
],
),
const SizedBox(height: 8),
_AdminSection(
title: 'Remote Server',
title: l10n.remoteServer,
children: [
if (remoteSync.isConfigured) ...[
_AdminTile(
icon: Icons.cloud_done_outlined,
title: 'Connected',
subtitle: 'Device linked to remote server',
title: l10n.connected,
subtitle: l10n.deviceLinked,
trailing: TextButton(
onPressed: () => _confirmUnlink(context),
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Disconnect'),
child: Text(l10n.disconnect),
),
onTap: null,
),
] else
_AdminTile(
icon: Icons.cloud_outlined,
title: 'Not connected',
subtitle: 'Link to remote dashboard',
icon: remoteSync.wasRevoked
? Icons.cloud_off_rounded
: Icons.cloud_outlined,
title: l10n.notConnected,
subtitle: remoteSync.wasRevoked
? l10n.deviceAccessRevoked
: l10n.linkToDashboard,
onTap: () => context.push('/register'),
),
],
+9
View File
@@ -144,6 +144,15 @@ class _BarScreenViewState extends State<BarScreenView> {
location: '/history',
),
],
if (context.watch<RemoteSyncViewModel>().wasRevoked) ...[
const SizedBox(width: 6),
IconButton(
tooltip: l10n.deviceAccessRevoked,
color: Theme.of(context).colorScheme.error,
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.cloud_off_rounded),
),
],
const SizedBox(width: 6),
IconButton(
tooltip: l10n.refresh,
+60 -6
View File
@@ -45,6 +45,36 @@ class _DevMenuViewState extends State<DevMenuView> {
).showSnackBar(SnackBar(content: Text(l10n.devAction(message))));
}
Future<void> _confirmThen(
String title,
Future<void> Function() action,
) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(l10n.actionCannotBeUndone),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: FilledButton.styleFrom(
backgroundColor: Theme.of(dialogContext).colorScheme.error,
),
child: Text(l10n.confirm),
),
],
),
);
if (confirmed == true) await action();
}
@override
void dispose() {
_vm?.removeListener(_onVmChange);
@@ -78,12 +108,19 @@ class _DevMenuViewState extends State<DevMenuView> {
_Tile(
icon: Icons.delete_sweep_rounded,
title: l10n.clearOpenTabs,
onTap: vm.isLoading ? null : () => vm.clearOpenTabs(),
onTap: vm.isLoading
? null
: () => _confirmThen(l10n.clearOpenTabs, vm.clearOpenTabs),
),
_Tile(
icon: Icons.history_rounded,
title: l10n.clearClosedHistory,
onTap: vm.isLoading ? null : () => vm.clearClosedTabHistory(),
onTap: vm.isLoading
? null
: () => _confirmThen(
l10n.clearClosedHistory,
vm.clearClosedTabHistory,
),
),
_Tile(
icon: Icons.add_box_rounded,
@@ -95,18 +132,33 @@ class _DevMenuViewState extends State<DevMenuView> {
icon: Icons.restart_alt_rounded,
title: l10n.clearAndReseedProducts,
subtitle: l10n.wipesAndReseeds,
onTap: vm.isLoading ? null : () => vm.clearAndReseedProducts(),
onTap: vm.isLoading
? null
: () => _confirmThen(
l10n.clearAndReseedProducts,
vm.clearAndReseedProducts,
),
),
_Tile(
icon: Icons.delete_forever_rounded,
title: l10n.clearAllProducts,
onTap: vm.isLoading ? null : () => vm.clearAllProducts(),
onTap: vm.isLoading
? null
: () => _confirmThen(
l10n.clearAllProducts,
vm.clearAllProducts,
),
),
_Tile(
icon: Icons.image_rounded,
title: l10n.clearProductImages,
subtitle: l10n.deletesProductImages,
onTap: vm.isLoading ? null : () => vm.clearProductImages(),
onTap: vm.isLoading
? null
: () => _confirmThen(
l10n.clearProductImages,
vm.clearProductImages,
),
),
],
),
@@ -117,7 +169,9 @@ class _DevMenuViewState extends State<DevMenuView> {
icon: Icons.lock_reset_rounded,
title: l10n.resetPin,
subtitle: l10n.removePinLock,
onTap: vm.isLoading ? null : () => vm.resetPin(),
onTap: vm.isLoading
? null
: () => _confirmThen(l10n.resetPin, vm.resetPin),
),
_Tile(
icon: Icons.bug_report_rounded,
@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
Future<bool> confirmDuplicateTabName(BuildContext context, String name) async {
final l10n = AppLocalizations.of(context);
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(l10n.duplicateTabName),
content: Text(l10n.duplicateTabNameDescription(name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: Text(l10n.confirm),
),
],
),
);
return confirmed == true;
}
+132 -20
View File
@@ -2,8 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../l10n/app_localizations_helpers.dart';
import '../../models/app_error.dart';
import '../../models/bar_tab.dart';
import '../../services/pin_lock_service.dart';
import '../../viewmodels/admin_pin_view_model.dart';
import '../../viewmodels/bar_screen_view_model.dart';
import 'duplicate_tab_name_dialog.dart';
Future<void> showEditTabDialog(BuildContext context, BarTab tab) async {
final l10n = AppLocalizations.of(context);
@@ -13,10 +18,19 @@ Future<void> showEditTabDialog(BuildContext context, BarTab tab) async {
builder: (_) => _EditTabDialog(initialName: tab.customerName),
);
if (name == null || name.trim().isEmpty || !context.mounted) return;
final trimmed = name?.trim() ?? '';
if (trimmed.isEmpty || trimmed == tab.customerName || !context.mounted) {
return;
}
final viewModel = context.read<BarScreenViewModel>();
if (viewModel.hasOpenTabNamed(trimmed, exceptTabId: tab.id) &&
!await confirmDuplicateTabName(context, trimmed)) {
return;
}
try {
await context.read<BarScreenViewModel>().renameTab(tab.id, name.trim());
await viewModel.renameTab(tab.id, trimmed);
} catch (_) {
if (!context.mounted) return;
ScaffoldMessenger.of(
@@ -78,30 +92,37 @@ class _EditTabDialogState extends State<_EditTabDialog> {
}
}
/// Deleting a tab with drinks on it leaves no trace in history, so it needs
/// the admin PIN when one is set.
Future<void> confirmDeleteTab(BuildContext context, BarTab tab) async {
final l10n = AppLocalizations.of(context);
final adminPinViewModel = context.read<AdminPinViewModel>();
final needsAdminPin = tab.items.isNotEmpty && adminPinViewModel.isPinSet;
final confirmed = await showDialog<bool>(
final result = await showDialog<_DeleteTabResult>(
context: context,
builder: (dialogContext) {
return AlertDialog(
title: Text(l10n.deleteTab),
content: Text(l10n.deleteTabDescription),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: Text(l10n.delete),
),
],
);
},
builder: (_) => _DeleteTabDialog(
description: tab.items.isEmpty
? l10n.deleteTabDescription
: l10n.deleteTabWithItemsDescription,
needsAdminPin: needsAdminPin,
),
);
if (confirmed != true || !context.mounted) return;
if (result == null || !context.mounted) return;
if (needsAdminPin) {
final error = await _verifyAdminPin(adminPinViewModel, result.pin);
if (error != null) {
if (!context.mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.errorText(error))));
return;
}
}
if (!context.mounted) return;
try {
await context.read<BarScreenViewModel>().deleteTab(tab.id);
@@ -112,3 +133,94 @@ Future<void> confirmDeleteTab(BuildContext context, BarTab tab) async {
).showSnackBar(SnackBar(content: Text(l10n.couldNotDeleteTab)));
}
}
Future<AppError?> _verifyAdminPin(
AdminPinViewModel adminPinViewModel,
String pin,
) async {
try {
final matches = await adminPinViewModel.adminPinService.verifyPin(pin);
return matches ? null : AppError.incorrectPin;
} on PinLockedOutException {
return AppError.tooManyPinAttempts;
} catch (_) {
return AppError.verifyPin;
}
}
class _DeleteTabResult {
final String pin;
const _DeleteTabResult(this.pin);
}
class _DeleteTabDialog extends StatefulWidget {
final String description;
final bool needsAdminPin;
const _DeleteTabDialog({
required this.description,
required this.needsAdminPin,
});
@override
State<_DeleteTabDialog> createState() => _DeleteTabDialogState();
}
class _DeleteTabDialogState extends State<_DeleteTabDialog> {
final _pinController = TextEditingController();
@override
void dispose() {
_pinController.dispose();
super.dispose();
}
void _confirm() {
Navigator.of(context).pop(_DeleteTabResult(_pinController.text));
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return AlertDialog(
title: Text(l10n.deleteTab),
content: SizedBox(
width: 360,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(widget.description),
if (widget.needsAdminPin) ...[
const SizedBox(height: 16),
TextField(
controller: _pinController,
autofocus: true,
obscureText: true,
keyboardType: TextInputType.number,
maxLength: 4,
decoration: InputDecoration(labelText: l10n.adminPin),
onSubmitted: (_) => _confirm(),
),
],
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
FilledButton(
onPressed: _confirm,
style: FilledButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.error,
),
child: Text(l10n.delete),
),
],
);
}
}
+8 -1
View File
@@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../viewmodels/bar_screen_view_model.dart';
import 'duplicate_tab_name_dialog.dart';
/// Returns the name of the opened tab, or null when nothing was opened.
Future<String?> showNewTabDialog(BuildContext context) async {
@@ -42,8 +43,14 @@ Future<String?> showNewTabDialog(BuildContext context) async {
final trimmed = name?.trim() ?? '';
if (trimmed.isEmpty || !context.mounted) return null;
final viewModel = context.read<BarScreenViewModel>();
if (viewModel.hasOpenTabNamed(trimmed) &&
!await confirmDuplicateTabName(context, trimmed)) {
return null;
}
try {
await context.read<BarScreenViewModel>().createTab(trimmed);
await viewModel.createTab(trimmed);
return trimmed;
} catch (_) {
if (context.mounted) {
-59
View File
@@ -1,59 +0,0 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../utils/navigation.dart';
class ImportNamesView extends StatelessWidget {
const ImportNamesView({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.popOrGo('/admin'),
icon: const Icon(Icons.arrow_back),
),
title: Text(l10n.importData),
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Padding(
padding: const EdgeInsets.all(24),
child: Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.file_upload_outlined,
size: 48,
color: scheme.primary,
),
const SizedBox(height: 20),
Text(
l10n.importNames,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.importPlaceholder,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
),
),
);
}
}
+3 -19
View File
@@ -9,6 +9,7 @@ import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../l10n/app_localizations_helpers.dart';
import '../models/product.dart';
import '../utils/money.dart';
import '../utils/navigation.dart';
import '../viewmodels/product_list_view_model.dart';
@@ -148,24 +149,10 @@ class _ProductFormViewState extends State<ProductFormView> {
});
}
int _parsePriceToCents(String value) {
final normalized = value.replaceAll(',', '.');
final euros = double.tryParse(normalized) ?? 0;
return (euros * 100).round();
}
Future<void> _save() async {
final l10n = AppLocalizations.of(context);
if (!_formKey.currentState!.validate()) return;
if (_imagePath == null || _imagePath!.isEmpty) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(l10n.chooseImage)));
return;
}
if (_selectedCategory == null) {
ScaffoldMessenger.of(
context,
@@ -179,7 +166,7 @@ class _ProductFormViewState extends State<ProductFormView> {
final name = _nameController.text.trim();
final category = _selectedCategory!;
final priceInCents = _parsePriceToCents(_priceController.text);
final priceInCents = parseEuroToCents(_priceController.text)!;
final stockQuantity = int.tryParse(_stockController.text) ?? 0;
final lowStockThreshold = int.tryParse(_lowStockController.text) ?? 0;
@@ -422,10 +409,7 @@ class _ProductFormViewState extends State<ProductFormView> {
return l10n.enterPrice;
}
final normalized = value.replaceAll(',', '.');
final price = double.tryParse(normalized);
if (price == null || price < 0) {
if (parseEuroToCents(value) == null) {
return l10n.enterValidPrice;
}
+33 -23
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../utils/navigation.dart';
import '../viewmodels/registration_view_model.dart';
import '../viewmodels/remote_sync_view_model.dart';
@@ -25,6 +26,8 @@ class _RegistrationViewState extends State<RegistrationView> {
});
}
AppLocalizations get _l10n => AppLocalizations.of(context);
@override
void dispose() {
_deviceNameController.dispose();
@@ -39,6 +42,14 @@ class _RegistrationViewState extends State<RegistrationView> {
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surface,
appBar: AppBar(
backgroundColor: Colors.transparent,
leading: IconButton(
tooltip: l10n.cancel,
onPressed: () => context.popOrGo('/admin'),
icon: const Icon(Icons.close_rounded),
),
),
body: SafeArea(
child: Center(
child: SingleChildScrollView(
@@ -87,7 +98,7 @@ class _RegistrationViewState extends State<RegistrationView> {
const CircularProgressIndicator(strokeWidth: 3),
const SizedBox(height: 16),
Text(
'Connecting to server…',
_l10n.connectingToServer,
style: Theme.of(context).textTheme.titleMedium,
),
],
@@ -106,12 +117,12 @@ class _RegistrationViewState extends State<RegistrationView> {
),
const SizedBox(height: 24),
Text(
'Failed to connect',
_l10n.failedToConnect,
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
Text(
vm.errorMessage ?? 'Unknown error',
vm.errorMessage ?? _l10n.unknownError,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
@@ -123,7 +134,7 @@ class _RegistrationViewState extends State<RegistrationView> {
FilledButton.icon(
onPressed: () => vm.retry(),
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
label: Text(_l10n.retry),
),
],
);
@@ -142,13 +153,13 @@ class _RegistrationViewState extends State<RegistrationView> {
),
const SizedBox(height: 16),
Text(
'Connect to remote server',
_l10n.connectToRemoteServer,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Select your venue to link this device',
_l10n.selectVenueToLink,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
@@ -158,7 +169,7 @@ class _RegistrationViewState extends State<RegistrationView> {
),
const SizedBox(height: 32),
Text(
'Select venue',
_l10n.selectVenue,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
color: Theme.of(
context,
@@ -217,7 +228,7 @@ class _RegistrationViewState extends State<RegistrationView> {
const SizedBox(height: 24),
TextButton(
onPressed: () => vm.retry(),
child: const Text('Refresh venues'),
child: Text(_l10n.refreshVenues),
),
],
);
@@ -244,7 +255,7 @@ class _RegistrationViewState extends State<RegistrationView> {
),
const SizedBox(height: 8),
Text(
'Enter device details to complete pairing',
_l10n.enterDeviceDetails,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(
@@ -255,20 +266,19 @@ class _RegistrationViewState extends State<RegistrationView> {
const SizedBox(height: 32),
TextFormField(
controller: _deviceNameController,
decoration: const InputDecoration(
labelText: 'Device name',
decoration: InputDecoration(
labelText: _l10n.deviceName,
hintText: 'e.g. Bar terminal 1',
border: OutlineInputBorder(),
),
validator: (v) => v == null || v.trim().isEmpty
? 'Device name is required'
: null,
validator: (v) =>
v == null || v.trim().isEmpty ? _l10n.deviceNameRequired : null,
),
const SizedBox(height: 16),
TextFormField(
controller: _regCodeController,
decoration: const InputDecoration(
labelText: 'Registration code',
decoration: InputDecoration(
labelText: _l10n.registrationCode,
hintText: '6-character code from dashboard',
border: OutlineInputBorder(),
counterText: '',
@@ -276,7 +286,7 @@ class _RegistrationViewState extends State<RegistrationView> {
maxLength: 6,
textCapitalization: TextCapitalization.characters,
validator: (v) => v == null || v.trim().length < 6
? 'Enter the 6-character code'
? _l10n.enterRegistrationCode
: null,
),
const SizedBox(height: 8),
@@ -298,7 +308,7 @@ class _RegistrationViewState extends State<RegistrationView> {
onPressed: () {
vm.reset();
},
child: const Text('Back'),
child: Text(_l10n.back),
),
const SizedBox(width: 12),
Expanded(
@@ -312,7 +322,7 @@ class _RegistrationViewState extends State<RegistrationView> {
.toUpperCase(),
);
},
child: const Text('Register'),
child: Text(_l10n.register),
),
),
],
@@ -330,7 +340,7 @@ class _RegistrationViewState extends State<RegistrationView> {
const CircularProgressIndicator(strokeWidth: 3),
const SizedBox(height: 24),
Text(
'Registering device…',
_l10n.registeringDevice,
style: Theme.of(context).textTheme.titleMedium,
),
],
@@ -349,7 +359,7 @@ class _RegistrationViewState extends State<RegistrationView> {
),
const SizedBox(height: 24),
Text(
'Device registered!',
_l10n.deviceRegistered,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineSmall,
),
@@ -365,7 +375,7 @@ class _RegistrationViewState extends State<RegistrationView> {
),
const SizedBox(height: 8),
Text(
'Open tabs will sync to the dashboard.',
_l10n.openTabsWillSync,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(
@@ -378,7 +388,7 @@ class _RegistrationViewState extends State<RegistrationView> {
// Once configured, the router redirects to /bar by itself.
onPressed: () => context.read<RemoteSyncViewModel>().activate(),
icon: const Icon(Icons.arrow_forward),
label: const Text('Start using KoolTap'),
label: Text(_l10n.done),
),
],
);
+176 -169
View File
@@ -53,6 +53,12 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
});
}
void _leave() {
final adminPinViewModel = context.read<AdminPinViewModel>();
context.popOrGo('/bar');
adminPinViewModel.lock();
}
void _cancelVersionHold() {
_versionHoldTimer?.cancel();
_versionHoldTimer = null;
@@ -225,7 +231,6 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
return;
}
adminPinViewModel.lock();
return;
}
@@ -250,10 +255,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
if (!success) {
_showError(l10n.errorText(adminPinViewModel.error));
return;
}
adminPinViewModel.lock();
}
@override
@@ -263,184 +265,189 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
final pinLockViewModel = context.watch<PinLockViewModel>();
final adminPinViewModel = context.watch<AdminPinViewModel>();
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.popOrGo('/bar'),
icon: const Icon(Icons.arrow_back),
return PopScope(
onPopInvokedWithResult: (didPop, result) {
if (didPop) context.read<AdminPinViewModel>().lock();
},
child: Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: _leave,
icon: const Icon(Icons.arrow_back),
),
title: Text(l10n.settingsTitle),
),
title: Text(l10n.settingsTitle),
),
body: Builder(
builder: (context) {
final isLoading =
settingsViewModel.isLoading ||
pinLockViewModel.isLoading ||
adminPinViewModel.isLoading;
final hasLoaded =
settingsViewModel.hasLoaded &&
pinLockViewModel.hasLoaded &&
adminPinViewModel.hasLoaded;
body: Builder(
builder: (context) {
final isLoading =
settingsViewModel.isLoading ||
pinLockViewModel.isLoading ||
adminPinViewModel.isLoading;
final hasLoaded =
settingsViewModel.hasLoaded &&
pinLockViewModel.hasLoaded &&
adminPinViewModel.hasLoaded;
if (isLoading && !hasLoaded) {
return const Center(
child: CircularProgressIndicator(strokeWidth: 2.5),
);
}
if (isLoading && !hasLoaded) {
return const Center(
child: CircularProgressIndicator(strokeWidth: 2.5),
);
}
final settings = settingsViewModel.settings;
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();
}
},
),
_SettingsSwitchTile(
icon: Icons.lock_clock_rounded,
title: l10n.autoLockOnExit,
value: settings.autoLockEnabled,
onChanged: (value) {
settingsViewModel.updateAutoLockEnabled(value);
},
),
if (settings.pinRequired)
_SettingsTile(
icon: Icons.pin_rounded,
title: l10n.changePin,
onTap: _changePin,
),
_SettingsTile(
icon: Icons.admin_panel_settings_outlined,
title: l10n.adminPin,
subtitle: adminPinViewModel.isPinSet
? l10n.adminPinConfigured
: l10n.adminPinNotSet,
onTap: _configureAdminPin,
),
],
),
_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 _pickOption(
context,
options: AppLanguage.values,
selected: settings.language,
label: (language) => switch (language) {
AppLanguage.system => l10n.languageSystem,
AppLanguage.english => l10n.languageEnglish,
AppLanguage.dutch => l10n.languageDutch,
},
);
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 _pickOption(
context,
options: const [2, 3, 4, 5, 6],
selected: settings.barGridRows,
label: l10n.rowsCount,
);
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 _pickOption(
context,
options: AppThemeMode.values,
selected: settings.themeMode,
label: (mode) => switch (mode) {
AppThemeMode.system => l10n.system,
AppThemeMode.light => l10n.light,
AppThemeMode.dark => l10n.dark,
AppThemeMode.ugly => l10n.ugly,
},
);
if (selected != null) {
await settingsViewModel.updateThemeMode(selected);
}
},
),
],
),
if (Platform.isAndroid)
return ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
_SettingsSection(
title: l10n.updates,
title: l10n.security,
children: [
_SettingsSwitchTile(
icon: Icons.lock_outline_rounded,
title: l10n.pinRequired,
value: settings.pinRequired,
onChanged: (value) {
if (value) {
_enablePin();
} else {
_disablePin();
}
},
),
_SettingsSwitchTile(
icon: Icons.lock_clock_rounded,
title: l10n.autoLockOnExit,
value: settings.autoLockEnabled,
onChanged: (value) {
settingsViewModel.updateAutoLockEnabled(value);
},
),
if (settings.pinRequired)
_SettingsTile(
icon: Icons.pin_rounded,
title: l10n.changePin,
onTap: _changePin,
),
_SettingsTile(
icon: Icons.system_update_alt_rounded,
title: settingsViewModel.checkingForUpdates
? l10n.checkingForUpdates
: l10n.checkForUpdates,
onTap: settingsViewModel.checkingForUpdates
? null
: _checkForUpdates,
icon: Icons.admin_panel_settings_outlined,
title: l10n.adminPin,
subtitle: adminPinViewModel.isPinSet
? l10n.adminPinConfigured
: l10n.adminPinNotSet,
onTap: _configureAdminPin,
),
],
),
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),
_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 _pickOption(
context,
options: AppLanguage.values,
selected: settings.language,
label: (language) => switch (language) {
AppLanguage.system => l10n.languageSystem,
AppLanguage.english => l10n.languageEnglish,
AppLanguage.dutch => l10n.languageDutch,
},
);
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 _pickOption(
context,
options: const [2, 3, 4, 5, 6],
selected: settings.barGridRows,
label: l10n.rowsCount,
);
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 _pickOption(
context,
options: AppThemeMode.values,
selected: settings.themeMode,
label: (mode) => switch (mode) {
AppThemeMode.system => l10n.system,
AppThemeMode.light => l10n.light,
AppThemeMode.dark => l10n.dark,
AppThemeMode.ugly => l10n.ugly,
},
);
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),
],
);
},
const SizedBox(height: 20),
],
);
},
),
),
);
}