diff --git a/Kiosk/lib/screens/main_screen.dart b/Kiosk/lib/screens/main_screen.dart index 01c5adc..691665d 100644 --- a/Kiosk/lib/screens/main_screen.dart +++ b/Kiosk/lib/screens/main_screen.dart @@ -9,6 +9,7 @@ import 'package:kiosk/models/product.dart'; import 'package:kiosk/models/order.dart' as model; // Alias to avoid conflict if needed import 'package:kiosk/screens/payment_screen.dart'; import 'package:kiosk/screens/manual_barcode_dialog.dart'; +import 'package:kiosk/screens/pin_input_dialog.dart'; import 'package:kiosk/screens/no_barcode_products_dialog.dart'; import 'package:kiosk/screens/settings_screen.dart'; import 'package:mobile_scanner/mobile_scanner.dart'; @@ -182,42 +183,12 @@ class _MainScreenState extends State { final expected = _settingsService.getPin(); if (expected == null || expected.isEmpty) return true; - final l10n = AppLocalizations.of(context)!; - final controller = TextEditingController(); - final ok = await showDialog( + final result = await showDialog( context: context, barrierDismissible: false, - builder: (dialogContext) { - return AlertDialog( - title: Text(l10n.adminConfirm), - content: TextField( - controller: controller, - obscureText: true, - keyboardType: TextInputType.number, - decoration: InputDecoration(labelText: l10n.enterPin), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(dialogContext, false), - child: Text(l10n.cancel), - ), - TextButton( - onPressed: () { - if (controller.text.trim() == expected) { - Navigator.pop(dialogContext, true); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.invalidPin)), - ); - } - }, - child: Text(l10n.confirm), - ), - ], - ); - }, + builder: (dialogContext) => PinInputDialog(expectedPin: expected), ); - return ok ?? false; + return result ?? false; } Future _resumePendingPaymentIfAny() async { diff --git a/Kiosk/lib/screens/pin_input_dialog.dart b/Kiosk/lib/screens/pin_input_dialog.dart new file mode 100644 index 0000000..700bc0f --- /dev/null +++ b/Kiosk/lib/screens/pin_input_dialog.dart @@ -0,0 +1,192 @@ +import 'package:flutter/material.dart'; +import 'package:kiosk/l10n/app_localizations.dart'; + +class PinInputDialog extends StatefulWidget { + final String? expectedPin; + + const PinInputDialog({ + super.key, + this.expectedPin, + }); + + @override + State createState() => _PinInputDialogState(); +} + +class _PinInputDialogState extends State { + String _input = ''; + + void _onKeyTap(String key) { + if (_input.length >= 6) return; + setState(() { + _input += key; + }); + } + + void _onBackspace() { + if (_input.isNotEmpty) { + setState(() { + _input = _input.substring(0, _input.length - 1); + }); + } + } + + void _onClear() { + setState(() { + _input = ''; + }); + } + + void _onConfirm() { + if (widget.expectedPin != null) { + if (_input == widget.expectedPin) { + Navigator.pop(context, true); + } else { + final l10n = AppLocalizations.of(context)!; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.invalidPin)), + ); + _onClear(); + } + } else { + Navigator.pop(context, _input); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 420, + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.adminConfirm, + style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + Text(l10n.enterPin, style: const TextStyle(color: Colors.grey)), + const Divider(height: 20), + Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _input.isEmpty ? '----' : '*' * _input.length, + style: TextStyle( + fontSize: 32, + color: _input.isEmpty ? Colors.grey : Colors.black, + letterSpacing: 8, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 12), + SizedBox( + height: 240, + child: GridView.count( + crossAxisCount: 3, + childAspectRatio: 2.35, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + physics: const NeverScrollableScrollPhysics(), + children: [ + for (var i = 1; i <= 9; i++) _buildKey(i.toString()), + _buildBackspaceKey(), + _buildKey('0'), + _buildActionButton(l10n.clear, Colors.orange, _onClear), + ], + ), + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: SizedBox( + height: 56, + child: _buildActionButton( + l10n.cancel, + Colors.grey, + () => Navigator.pop(context, false), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: SizedBox( + height: 56, + child: _buildActionButton(l10n.confirm, Colors.blue, _onConfirm), + ), + ), + ], + ), + ], + ), + ), + ), + ); + } + + Widget _buildBackspaceKey() { + return InkWell( + onTap: _onBackspace, + borderRadius: BorderRadius.circular(8), + child: Container( + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: const Icon(Icons.backspace, size: 24), + ), + ); + } + + Widget _buildKey(String label, {VoidCallback? onTap}) { + return InkWell( + onTap: onTap ?? () => _onKeyTap(label), + borderRadius: BorderRadius.circular(8), + child: Container( + decoration: BoxDecoration( + color: Colors.grey[200], + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 2, + offset: const Offset(1, 1), + ), + ], + ), + alignment: Alignment.center, + child: Text(label, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)), + ), + ); + } + + Widget _buildActionButton(String label, Color color, VoidCallback onTap) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(8), + ), + alignment: Alignment.center, + child: Text(label, style: const TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.bold)), + ), + ); + } +} diff --git a/Kiosk/lib/screens/pin_setup_screen.dart b/Kiosk/lib/screens/pin_setup_screen.dart index 7b445cd..50f114b 100644 --- a/Kiosk/lib/screens/pin_setup_screen.dart +++ b/Kiosk/lib/screens/pin_setup_screen.dart @@ -11,20 +11,73 @@ class PinSetupScreen extends StatefulWidget { } class _PinSetupScreenState extends State { - final TextEditingController _pinController = TextEditingController(); - final TextEditingController _confirmPinController = TextEditingController(); final SettingsService _settingsService = SettingsService(); - final _formKey = GlobalKey(); - - void _savePin() async { - if (_formKey.currentState!.validate()) { - await _settingsService.setPin(_pinController.text); - if (mounted) { - Navigator.of(context).pushReplacement( - MaterialPageRoute(builder: (_) => const MainScreen()), - ); + String _pin = ''; + String _confirmPin = ''; + bool _editingConfirm = false; + + void _onKeyTap(String key) { + final value = _editingConfirm ? _confirmPin : _pin; + if (value.length >= 6) return; + setState(() { + if (_editingConfirm) { + _confirmPin += key; + } else { + _pin += key; + } + }); + } + + void _onBackspace() { + setState(() { + if (_editingConfirm) { + if (_confirmPin.isNotEmpty) { + _confirmPin = _confirmPin.substring(0, _confirmPin.length - 1); + } + } else { + if (_pin.isNotEmpty) { + _pin = _pin.substring(0, _pin.length - 1); + } } + }); + } + + void _onClear() { + setState(() { + if (_editingConfirm) { + _confirmPin = ''; + } else { + _pin = ''; + } + }); + } + + Future _savePin() async { + final l10n = AppLocalizations.of(context)!; + if (_pin.isEmpty) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.pinRequired)), + ); + return; + } + if (_pin.length < 4) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.pinLength)), + ); + return; } + if (_confirmPin != _pin) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(l10n.pinMismatch)), + ); + return; + } + + await _settingsService.setPin(_pin); + if (!mounted) return; + Navigator.of(context).pushReplacement( + MaterialPageRoute(builder: (_) => const MainScreen()), + ); } @override @@ -41,75 +94,184 @@ class _PinSetupScreenState extends State { child: Container( constraints: const BoxConstraints(maxWidth: 400), padding: const EdgeInsets.all(24), - child: Form( - key: _formKey, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - l10n.setAdminPin, - style: theme.textTheme.headlineMedium, - textAlign: TextAlign.center, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.setAdminPin, + style: theme.textTheme.headlineMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + Text( + l10n.pinDescription, + style: theme.textTheme.bodyMedium, + textAlign: TextAlign.center, + ), + const SizedBox(height: 24), + _PinDisplay( + label: l10n.enterPin, + value: _pin, + selected: !_editingConfirm, + icon: Icons.lock, + onTap: () => setState(() => _editingConfirm = false), + ), + const SizedBox(height: 12), + _PinDisplay( + label: l10n.confirmPin, + value: _confirmPin, + selected: _editingConfirm, + icon: Icons.lock_outline, + onTap: () => setState(() => _editingConfirm = true), + ), + const SizedBox(height: 20), + SizedBox( + height: 240, + child: GridView.count( + crossAxisCount: 3, + childAspectRatio: 2.35, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + physics: const NeverScrollableScrollPhysics(), + children: [ + for (var i = 1; i <= 9; i++) + _PinKey( + label: i.toString(), + onTap: () => _onKeyTap(i.toString()), + ), + _PinKey( + onTap: _onBackspace, + child: const Icon(Icons.backspace, size: 24), + ), + _PinKey(label: '0', onTap: () => _onKeyTap('0')), + _PinKey( + label: l10n.clear, + color: Colors.orange, + textStyle: const TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.bold, + ), + onTap: _onClear, + ), + ], ), - const SizedBox(height: 16), - Text( - l10n.pinDescription, - style: theme.textTheme.bodyMedium, - textAlign: TextAlign.center, + ), + const SizedBox(height: 20), + ElevatedButton( + onPressed: _savePin, + style: ElevatedButton.styleFrom( + minimumSize: const Size.fromHeight(56), ), - const SizedBox(height: 32), - TextFormField( - controller: _pinController, - decoration: InputDecoration( - labelText: l10n.enterPin, - border: const OutlineInputBorder(), - prefixIcon: const Icon(Icons.lock), - ), - keyboardType: TextInputType.number, - obscureText: true, - validator: (value) { - if (value == null || value.isEmpty) { - return l10n.pinRequired; - } - if (value.length < 4) { - return l10n.pinLength; - } - return null; - }, - ), - const SizedBox(height: 16), - TextFormField( - controller: _confirmPinController, - decoration: InputDecoration( - labelText: l10n.confirmPin, - border: const OutlineInputBorder(), - prefixIcon: const Icon(Icons.lock_outline), - ), - keyboardType: TextInputType.number, - obscureText: true, - validator: (value) { - if (value != _pinController.text) { - return l10n.pinMismatch; - } - return null; - }, + child: Text( + l10n.saveAndContinue, + style: const TextStyle(fontSize: 20), ), - const SizedBox(height: 32), - ElevatedButton( - onPressed: _savePin, - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.symmetric(vertical: 16), + ), + ], + ), + ), + ), + ); + } +} + +class _PinDisplay extends StatelessWidget { + final String label; + final String value; + final bool selected; + final IconData icon; + final VoidCallback onTap; + + const _PinDisplay({ + required this.label, + required this.value, + required this.selected, + required this.icon, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final borderColor = selected ? theme.colorScheme.primary : Colors.grey; + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14), + decoration: BoxDecoration( + border: Border.all(color: borderColor), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon(icon, color: selected ? theme.colorScheme.primary : Colors.grey), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: theme.textTheme.labelMedium?.copyWith(color: Colors.grey), ), - child: Text( - l10n.saveAndContinue, - style: const TextStyle(fontSize: 18), + const SizedBox(height: 6), + Text( + value.isEmpty ? '----' : '*' * value.length, + style: const TextStyle(fontSize: 22, letterSpacing: 4), ), - ), - ], + ], + ), ), - ), + ], + ), + ), + ); + } +} + +class _PinKey extends StatelessWidget { + final String? label; + final Widget? child; + final VoidCallback onTap; + final Color? color; + final TextStyle? textStyle; + + const _PinKey({ + this.label, + this.child, + required this.onTap, + this.color, + this.textStyle, + }) : assert(label != null || child != null); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(8), + child: Container( + decoration: BoxDecoration( + color: color ?? Colors.grey[200], + borderRadius: BorderRadius.circular(8), + boxShadow: color == null + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 2, + offset: const Offset(1, 1), + ), + ] + : null, ), + alignment: Alignment.center, + child: child ?? + Text( + label!, + style: textStyle ?? const TextStyle(fontSize: 28, fontWeight: FontWeight.bold), + ), ), ); } diff --git a/Kiosk/lib/screens/settings_screen.dart b/Kiosk/lib/screens/settings_screen.dart index ffbc109..3f30e68 100644 --- a/Kiosk/lib/screens/settings_screen.dart +++ b/Kiosk/lib/screens/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:kiosk/services/server/kiosk_server.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'package:kiosk/l10n/app_localizations.dart'; +import 'package:kiosk/screens/pin_input_dialog.dart'; import 'package:kiosk/services/android_launcher_service.dart'; import 'package:kiosk/services/android_network_service.dart'; @@ -84,40 +85,11 @@ class _SettingsScreenState extends State with WidgetsBindingObse } Future _confirmPin() async { - final l10n = AppLocalizations.of(context)!; final expected = _settingsService.getPin() ?? _pinController.text.trim(); - final controller = TextEditingController(); final ok = await showDialog( context: context, - builder: (context) { - return AlertDialog( - title: Text(l10n.adminConfirm), - content: TextField( - controller: controller, - obscureText: true, - keyboardType: TextInputType.number, - decoration: InputDecoration(labelText: l10n.enterPin), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(context, false), - child: Text(l10n.cancel), - ), - TextButton( - onPressed: () { - if (controller.text.trim() == expected) { - Navigator.pop(context, true); - } else { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.invalidPin)), - ); - } - }, - child: Text(l10n.confirm), - ), - ], - ); - }, + barrierDismissible: false, + builder: (context) => PinInputDialog(expectedPin: expected), ); return ok ?? false; }