fix: alot
This commit is contained in:
@@ -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'),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user