diff --git a/lib/app/app.dart b/lib/app/app.dart index d88cd7c..d51ca0a 100644 --- a/lib/app/app.dart +++ b/lib/app/app.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import 'app_lifecycle_lock.dart'; import '../models/settings.dart'; +import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/pin_lock_view_model.dart'; import '../viewmodels/settings_view_model.dart'; import '../theme.dart'; @@ -26,7 +27,10 @@ class _KoolTabAppState extends State { super.initState(); _lifecycleLockObserver = AppLifecycleLockObserver( - onLock: () => context.read().lock(), + onLock: () { + context.read().lock(); + context.read().lock(); + }, shouldLock: () => context.read().settings.autoLockEnabled, ); diff --git a/lib/app/router.dart b/lib/app/router.dart index 6840c5d..eb17061 100644 --- a/lib/app/router.dart +++ b/lib/app/router.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; +import '../viewmodels/admin_pin_view_model.dart'; import '../viewmodels/pin_lock_view_model.dart'; +import '../views/admin_screen_view.dart'; import '../views/bar_screen_view.dart'; import '../views/dev_menu_view.dart'; import '../views/error_screen_view.dart'; import '../views/history_screen_view.dart'; +import '../views/import_names_view.dart'; import '../views/pin_lock_view.dart'; import '../views/product_form_view.dart'; import '../views/product_list_view.dart'; @@ -16,26 +19,45 @@ import '../utils/app_update_util.dart'; final RouteObserver routeObserver = RouteObserver(); -GoRouter createAppRouter(PinLockViewModel pinLockViewModel) { +GoRouter createAppRouter( + PinLockViewModel pinLockViewModel, { + required AdminPinViewModel adminPinViewModel, +}) { return GoRouter( initialLocation: '/bar', observers: [routeObserver, SentryNavigatorObserver()], - refreshListenable: pinLockViewModel, + refreshListenable: Listenable.merge([pinLockViewModel, adminPinViewModel]), redirect: (context, state) { - if (!pinLockViewModel.hasLoaded) return null; + if (!pinLockViewModel.hasLoaded || !adminPinViewModel.hasLoaded) { + return null; + } final needsPinSetup = !pinLockViewModel.isPinSet && pinLockViewModel.pinRequired; - final isLocked = pinLockViewModel.isPinSet && + final isLocked = + pinLockViewModel.isPinSet && !pinLockViewModel.isUnlocked && pinLockViewModel.pinRequired; final mustBeOnLock = needsPinSetup || isLocked; - final goingToLock = state.matchedLocation == '/lock'; + final location = state.uri.path; + final goingToLock = location == '/lock'; + final goingToAdminLock = location == '/admin/lock'; + final isAdminArea = + location == '/admin' || + location.startsWith('/admin/') || + location == '/products' || + location.startsWith('/products/'); if (mustBeOnLock && !goingToLock) return '/lock'; if (!mustBeOnLock && goingToLock) return '/bar'; + if (isAdminArea && !goingToAdminLock && !adminPinViewModel.isUnlocked) { + return '/admin/lock'; + } + + if (goingToAdminLock && adminPinViewModel.isUnlocked) return '/admin'; + return null; }, routes: [ @@ -54,6 +76,31 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) { GoRoute(path: '/bar', builder: (context, state) => const BarScreenView()), + GoRoute( + path: '/admin/lock', + builder: (context, state) => PinEntryView( + mode: adminPinViewModel.isPinSet + ? PinEntryMode.unlock + : PinEntryMode.create, + isAdmin: true, + onVerify: adminPinViewModel.verify, + onSet: adminPinViewModel.setPin, + errorMessage: () => adminPinViewModel.errorMessage, + onSuccess: () => context.go('/admin'), + ), + ), + + GoRoute( + path: '/admin', + builder: (context, state) => const AdminScreenView(), + routes: [ + GoRoute( + path: 'import', + builder: (context, state) => const ImportNamesView(), + ), + ], + ), + GoRoute( path: '/products', builder: (context, state) => const ProductListView(), @@ -87,7 +134,8 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) { builder: (context, state) { final update = state.extra as UpdateInfo; final simulate = state.uri.queryParameters['simulate'] == 'true'; - final simulateError = state.uri.queryParameters['simulateError'] == 'true'; + final simulateError = + state.uri.queryParameters['simulateError'] == 'true'; return UpdateProgressView( update: update, simulate: simulate, @@ -96,10 +144,7 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) { }, ), - GoRoute( - path: '/dev', - builder: (context, state) => const DevMenuView(), - ), + GoRoute(path: '/dev', builder: (context, state) => const DevMenuView()), GoRoute( path: '/error', builder: (context, state) => const ErrorScreenView(), diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 736b374..fc592a9 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -11,6 +11,13 @@ "refresh": "Refresh", "settings": "Settings", "logout": "Logout", + "admin": "Admin", + "adminDescription": "Restricted tools for managing products and importing names.", + "adminTools": "Admin tools", + "importData": "Import", + "importNames": "Import names", + "importNamesDescription": "Prepare a list of names to use as tabs later.", + "importPlaceholder": "Importing names will be available here later.", "openOrSelectTab": "Open or select a tab first.", "openNewTab": "Open new tab", "customerGroupName": "Customer / group name", @@ -28,6 +35,12 @@ "pinRequired": "PIN Required", "autoLockOnExit": "Auto-lock on phone lock or app exit", "changePin": "Change PIN", + "adminPin": "Admin PIN", + "adminPinConfigured": "Configured", + "adminPinNotSet": "Not set", + "setAdminPin": "Set admin PIN", + "enterCurrentAdminPin": "Enter current admin PIN", + "enterNewAdminPin": "Enter new admin PIN", "appearance": "Appearance", "language": "Language", "languageSystem": "System default", @@ -93,6 +106,10 @@ "enterPinSubtitle": "You’ll use this to unlock the app", "confirmPinSubtitle": "Enter the same PIN again", "createPin": "Create a PIN", + "enterAdminPin": "Enter admin PIN", + "createAdminPin": "Create admin PIN", + "confirmAdminPin": "Confirm admin PIN", + "adminPinSubtitle": "You’ll use this to access admin tools", "pinsDidNotMatch": "PINs didn’t match. Try again.", "somethingWentWrong": "Something went wrong.", "incorrectPin": "Incorrect PIN.", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index ef1a124..53c4059 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -164,6 +164,48 @@ abstract class AppLocalizations { /// **'Logout'** String get logout; + /// No description provided for @admin. + /// + /// In en, this message translates to: + /// **'Admin'** + String get admin; + + /// No description provided for @adminDescription. + /// + /// In en, this message translates to: + /// **'Restricted tools for managing products and importing names.'** + String get adminDescription; + + /// No description provided for @adminTools. + /// + /// In en, this message translates to: + /// **'Admin tools'** + String get adminTools; + + /// No description provided for @importData. + /// + /// In en, this message translates to: + /// **'Import'** + String get importData; + + /// No description provided for @importNames. + /// + /// In en, this message translates to: + /// **'Import names'** + String get importNames; + + /// No description provided for @importNamesDescription. + /// + /// In en, this message translates to: + /// **'Prepare a list of names to use as tabs later.'** + String get importNamesDescription; + + /// No description provided for @importPlaceholder. + /// + /// In en, this message translates to: + /// **'Importing names will be available here later.'** + String get importPlaceholder; + /// No description provided for @openOrSelectTab. /// /// In en, this message translates to: @@ -260,6 +302,42 @@ abstract class AppLocalizations { /// **'Change PIN'** String get changePin; + /// No description provided for @adminPin. + /// + /// In en, this message translates to: + /// **'Admin PIN'** + String get adminPin; + + /// No description provided for @adminPinConfigured. + /// + /// In en, this message translates to: + /// **'Configured'** + String get adminPinConfigured; + + /// No description provided for @adminPinNotSet. + /// + /// In en, this message translates to: + /// **'Not set'** + String get adminPinNotSet; + + /// No description provided for @setAdminPin. + /// + /// In en, this message translates to: + /// **'Set admin PIN'** + String get setAdminPin; + + /// No description provided for @enterCurrentAdminPin. + /// + /// In en, this message translates to: + /// **'Enter current admin PIN'** + String get enterCurrentAdminPin; + + /// No description provided for @enterNewAdminPin. + /// + /// In en, this message translates to: + /// **'Enter new admin PIN'** + String get enterNewAdminPin; + /// No description provided for @appearance. /// /// In en, this message translates to: @@ -620,6 +698,30 @@ abstract class AppLocalizations { /// **'Create a PIN'** String get createPin; + /// No description provided for @enterAdminPin. + /// + /// In en, this message translates to: + /// **'Enter admin PIN'** + String get enterAdminPin; + + /// No description provided for @createAdminPin. + /// + /// In en, this message translates to: + /// **'Create admin PIN'** + String get createAdminPin; + + /// No description provided for @confirmAdminPin. + /// + /// In en, this message translates to: + /// **'Confirm admin PIN'** + String get confirmAdminPin; + + /// No description provided for @adminPinSubtitle. + /// + /// In en, this message translates to: + /// **'You’ll use this to access admin tools'** + String get adminPinSubtitle; + /// No description provided for @pinsDidNotMatch. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 48bf43f..931d9be 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -41,6 +41,30 @@ class AppLocalizationsEn extends AppLocalizations { @override String get logout => 'Logout'; + @override + String get admin => 'Admin'; + + @override + String get adminDescription => + 'Restricted tools for managing products and importing names.'; + + @override + String get adminTools => 'Admin tools'; + + @override + String get importData => 'Import'; + + @override + String get importNames => 'Import names'; + + @override + String get importNamesDescription => + 'Prepare a list of names to use as tabs later.'; + + @override + String get importPlaceholder => + 'Importing names will be available here later.'; + @override String get openOrSelectTab => 'Open or select a tab first.'; @@ -91,6 +115,24 @@ class AppLocalizationsEn extends AppLocalizations { @override String get changePin => 'Change PIN'; + @override + String get adminPin => 'Admin PIN'; + + @override + String get adminPinConfigured => 'Configured'; + + @override + String get adminPinNotSet => 'Not set'; + + @override + String get setAdminPin => 'Set admin PIN'; + + @override + String get enterCurrentAdminPin => 'Enter current admin PIN'; + + @override + String get enterNewAdminPin => 'Enter new admin PIN'; + @override String get appearance => 'Appearance'; @@ -295,6 +337,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get createPin => 'Create a PIN'; + @override + String get enterAdminPin => 'Enter admin PIN'; + + @override + String get createAdminPin => 'Create admin PIN'; + + @override + String get confirmAdminPin => 'Confirm admin PIN'; + + @override + String get adminPinSubtitle => 'You’ll use this to access admin tools'; + @override String get pinsDidNotMatch => 'PINs didn’t match. Try again.'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 9bdb104..796097b 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -41,6 +41,29 @@ class AppLocalizationsNl extends AppLocalizations { @override String get logout => 'Uitloggen'; + @override + String get admin => 'Beheer'; + + @override + String get adminDescription => + 'Beveiligde tools voor het beheren van producten en importeren van namen.'; + + @override + String get adminTools => 'Beheertools'; + + @override + String get importData => 'Importeren'; + + @override + String get importNames => 'Namen importeren'; + + @override + String get importNamesDescription => + 'Bereid een lijst met namen voor om later als poefs te gebruiken.'; + + @override + String get importPlaceholder => 'Namen importeren is hier later beschikbaar.'; + @override String get openOrSelectTab => 'Open of selecteer eerst een poef.'; @@ -92,6 +115,24 @@ class AppLocalizationsNl extends AppLocalizations { @override String get changePin => 'PIN wijzigen'; + @override + String get adminPin => 'Beheer-PIN'; + + @override + String get adminPinConfigured => 'Ingesteld'; + + @override + String get adminPinNotSet => 'Niet ingesteld'; + + @override + String get setAdminPin => 'Beheer-PIN instellen'; + + @override + String get enterCurrentAdminPin => 'Huidige beheer-PIN invoeren'; + + @override + String get enterNewAdminPin => 'Nieuwe beheer-PIN invoeren'; + @override String get appearance => 'Uiterlijk'; @@ -296,6 +337,18 @@ class AppLocalizationsNl extends AppLocalizations { @override String get createPin => 'PIN aanmaken'; + @override + String get enterAdminPin => 'Beheer-PIN invoeren'; + + @override + String get createAdminPin => 'Beheer-PIN aanmaken'; + + @override + String get confirmAdminPin => 'Beheer-PIN bevestigen'; + + @override + String get adminPinSubtitle => 'Je gebruikt dit om beheertools te openen'; + @override String get pinsDidNotMatch => 'PINs kwamen niet overeen. Probeer het opnieuw.'; diff --git a/lib/l10n/app_nl.arb b/lib/l10n/app_nl.arb index 3a97ca6..0e03cd4 100644 --- a/lib/l10n/app_nl.arb +++ b/lib/l10n/app_nl.arb @@ -11,6 +11,13 @@ "refresh": "Vernieuwen", "settings": "Instellingen", "logout": "Uitloggen", + "admin": "Beheer", + "adminDescription": "Beveiligde tools voor het beheren van producten en importeren van namen.", + "adminTools": "Beheertools", + "importData": "Importeren", + "importNames": "Namen importeren", + "importNamesDescription": "Bereid een lijst met namen voor om later als poefs te gebruiken.", + "importPlaceholder": "Namen importeren is hier later beschikbaar.", "openOrSelectTab": "Open of selecteer eerst een poef.", "openNewTab": "Nieuw poef openen", "customerGroupName": "Naam klant / groep", @@ -28,6 +35,12 @@ "pinRequired": "PIN vereist", "autoLockOnExit": "Automatisch vergrendelen bij telefoonslot of afsluiten", "changePin": "PIN wijzigen", + "adminPin": "Beheer-PIN", + "adminPinConfigured": "Ingesteld", + "adminPinNotSet": "Niet ingesteld", + "setAdminPin": "Beheer-PIN instellen", + "enterCurrentAdminPin": "Huidige beheer-PIN invoeren", + "enterNewAdminPin": "Nieuwe beheer-PIN invoeren", "appearance": "Uiterlijk", "language": "Taal", "languageSystem": "Systeemstandaard", @@ -93,6 +106,10 @@ "enterPinSubtitle": "Je gebruikt dit om de app te ontgrendelen", "confirmPinSubtitle": "Voer dezelfde PIN opnieuw in", "createPin": "PIN aanmaken", + "enterAdminPin": "Beheer-PIN invoeren", + "createAdminPin": "Beheer-PIN aanmaken", + "confirmAdminPin": "Beheer-PIN bevestigen", + "adminPinSubtitle": "Je gebruikt dit om beheertools te openen", "pinsDidNotMatch": "PINs kwamen niet overeen. Probeer het opnieuw.", "somethingWentWrong": "Er is iets misgegaan.", "incorrectPin": "Onjuiste PIN.", diff --git a/lib/main.dart b/lib/main.dart index b07ea92..617122d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -17,9 +17,11 @@ import 'app/app.dart'; import 'app/app_bootstrap.dart'; import 'app/router.dart'; import 'database/app_database.dart'; +import 'services/admin_pin_service.dart'; import 'services/bar_tab_service.dart'; import 'services/product_service.dart'; import 'viewmodels/bar_screen_view_model.dart'; +import 'viewmodels/admin_pin_view_model.dart'; import 'viewmodels/product_list_view_model.dart'; Future main() async { @@ -41,7 +43,15 @@ Future main() async { ); await pinLockViewModel.ensureLoaded(); - final appRouter = createAppRouter(pinLockViewModel); + final adminPinViewModel = AdminPinViewModel( + adminPinService: AdminPinService(), + ); + await adminPinViewModel.ensureLoaded(); + + final appRouter = createAppRouter( + pinLockViewModel, + adminPinViewModel: adminPinViewModel, + ); await SentryFlutter.init( (options) { @@ -97,6 +107,9 @@ Future main() async { ChangeNotifierProvider.value( value: pinLockViewModel, ), + ChangeNotifierProvider.value( + value: adminPinViewModel, + ), ChangeNotifierProvider( create: (context) => SettingsViewModel( settingsService: context.read(), @@ -116,4 +129,4 @@ Future main() async { ), ), ); -} \ No newline at end of file +} diff --git a/lib/services/admin_pin_service.dart b/lib/services/admin_pin_service.dart new file mode 100644 index 0000000..2384636 --- /dev/null +++ b/lib/services/admin_pin_service.dart @@ -0,0 +1,6 @@ +import 'pin_lock_service.dart'; + +class AdminPinService extends PinLockService { + AdminPinService({super.storage}) + : super(saltKey: 'admin_pin_salt', hashKey: 'admin_pin_hash'); +} diff --git a/lib/services/pin_lock_service.dart b/lib/services/pin_lock_service.dart index 1c875a2..74b7a7d 100644 --- a/lib/services/pin_lock_service.dart +++ b/lib/services/pin_lock_service.dart @@ -5,13 +5,20 @@ import 'package:crypto/crypto.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; class PinLockService { - PinLockService({FlutterSecureStorage? storage}) - : _storage = storage ?? const FlutterSecureStorage(); + PinLockService({ + FlutterSecureStorage? storage, + String saltKey = _defaultSaltKey, + String hashKey = _defaultHashKey, + }) : _storage = storage ?? const FlutterSecureStorage(), + _saltKey = saltKey, + _hashKey = hashKey; - static const _saltKey = 'pin_salt'; - static const _hashKey = 'pin_hash'; + static const _defaultSaltKey = 'pin_salt'; + static const _defaultHashKey = 'pin_hash'; final FlutterSecureStorage _storage; + final String _saltKey; + final String _hashKey; Future hasPin() async { final hash = await _storage.read(key: _hashKey); diff --git a/lib/viewmodels/admin_pin_view_model.dart b/lib/viewmodels/admin_pin_view_model.dart new file mode 100644 index 0000000..1b1f0e1 --- /dev/null +++ b/lib/viewmodels/admin_pin_view_model.dart @@ -0,0 +1,112 @@ +import 'package:flutter/foundation.dart'; + +import '../services/admin_pin_service.dart'; + +class AdminPinViewModel extends ChangeNotifier { + final AdminPinService adminPinService; + + AdminPinViewModel({required this.adminPinService}); + + bool _isPinSet = false; + bool _isUnlocked = false; + bool _isLoading = false; + bool _hasLoaded = false; + String? _errorMessage; + + bool get isPinSet => _isPinSet; + + bool get isUnlocked => _isUnlocked; + + bool get isLoading => _isLoading; + + bool get hasLoaded => _hasLoaded; + + String? get errorMessage => _errorMessage; + + Future ensureLoaded() async { + if (_hasLoaded || _isLoading) return; + + await load(); + } + + Future load() async { + if (_isLoading) return; + + _isLoading = true; + _errorMessage = null; + notifyListeners(); + + try { + _isPinSet = await adminPinService.hasPin(); + } catch (e) { + debugPrint('AdminPinViewModel: load error: $e'); + _errorMessage = 'Could not check PIN status.'; + } finally { + _hasLoaded = true; + _isLoading = false; + notifyListeners(); + } + } + + Future setPin(String pin) async { + _errorMessage = null; + + try { + await adminPinService.setPin(pin); + _isPinSet = true; + _isUnlocked = true; + notifyListeners(); + return true; + } catch (e) { + debugPrint('AdminPinViewModel: setPin error: $e'); + _errorMessage = 'Could not save PIN.'; + notifyListeners(); + return false; + } + } + + Future verify(String pin) async { + _errorMessage = null; + + try { + final matches = await adminPinService.verifyPin(pin); + + if (matches) { + _isUnlocked = true; + notifyListeners(); + return true; + } + + _errorMessage = 'Incorrect PIN.'; + notifyListeners(); + return false; + } catch (e) { + debugPrint('AdminPinViewModel: verify error: $e'); + _errorMessage = 'Could not verify PIN.'; + notifyListeners(); + return false; + } + } + + Future changePin({ + required String currentPin, + required String newPin, + }) async { + final currentMatches = await adminPinService.verifyPin(currentPin); + + if (!currentMatches) { + _errorMessage = 'Current PIN is incorrect.'; + notifyListeners(); + return false; + } + + return setPin(newPin); + } + + void lock() { + if (!_isUnlocked) return; + + _isUnlocked = false; + notifyListeners(); + } +} diff --git a/lib/views/admin_screen_view.dart b/lib/views/admin_screen_view.dart new file mode 100644 index 0000000..3aa5925 --- /dev/null +++ b/lib/views/admin_screen_view.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../l10n/app_localizations.dart'; +import '../utils/navigation.dart'; +import '../viewmodels/admin_pin_view_model.dart'; + +class AdminScreenView extends StatelessWidget { + const AdminScreenView({super.key}); + + void _leave(BuildContext context) { + final adminPinViewModel = context.read(); + context.popOrGo('/bar'); + adminPinViewModel.lock(); + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + + return PopScope( + onPopInvokedWithResult: (didPop, result) { + if (didPop) context.read().lock(); + }, + child: Scaffold( + appBar: AppBar( + leading: IconButton( + onPressed: () => _leave(context), + icon: const Icon(Icons.arrow_back), + ), + title: Text(l10n.admin), + actions: [ + IconButton( + tooltip: l10n.logout, + onPressed: () { + final adminPinViewModel = context.read(); + context.go('/bar'); + adminPinViewModel.lock(); + }, + icon: const Icon(Icons.logout), + ), + ], + ), + body: ListView( + padding: const EdgeInsets.symmetric(vertical: 12), + children: [ + _AdminSection( + title: l10n.adminTools, + children: [ + _AdminTile( + icon: Icons.inventory_2_outlined, + title: l10n.products, + subtitle: l10n.manageProducts, + onTap: () => context.push('/products'), + ), + _AdminTile( + icon: Icons.file_upload_outlined, + title: l10n.importData, + subtitle: l10n.importNamesDescription, + onTap: () => context.push('/admin/import'), + ), + ], + ), + ], + ), + ), + ); + } +} + +class _AdminSection extends StatelessWidget { + final String title; + final List children; + + const _AdminSection({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 _AdminTile extends StatelessWidget { + final IconData icon; + final String title; + final String? subtitle; + final VoidCallback? onTap; + + const _AdminTile({ + required this.icon, + required this.title, + this.subtitle, + required 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: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: TextStyle( + fontWeight: FontWeight.w600, + color: scheme.onSurface, + ), + ), + if (subtitle != null) + Text( + subtitle!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: scheme.onSurface.withValues(alpha: 0.5), + ), + ), + ], + ), + ), + if (onTap != null) + Icon( + Icons.chevron_right_rounded, + color: scheme.onSurface.withValues(alpha: 0.3), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/views/bar_screen_view.dart b/lib/views/bar_screen_view.dart index 7853623..5a39989 100644 --- a/lib/views/bar_screen_view.dart +++ b/lib/views/bar_screen_view.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; +import 'package:kooltab2/viewmodels/admin_pin_view_model.dart'; import 'package:kooltab2/viewmodels/pin_lock_view_model.dart'; import 'package:kooltab2/viewmodels/product_list_view_model.dart'; import 'package:kooltab2/views/dialogs/close_tab_dialog.dart'; @@ -52,9 +53,9 @@ class _BarScreenViewState extends State { title: Text(l10n.barTabs), actions: [ IconButton( - tooltip: l10n.manageProducts, - onPressed: () => context.push('/products'), - icon: const Icon(Icons.inventory_2_outlined), + tooltip: l10n.admin, + onPressed: () => context.push('/admin'), + icon: const Icon(Icons.admin_panel_settings_outlined), ), const SizedBox(width: 6), IconButton( @@ -80,6 +81,7 @@ class _BarScreenViewState extends State { tooltip: l10n.logout, onPressed: () { Provider.of(context, listen: false).lock(); + Provider.of(context, listen: false).lock(); }, icon: const Icon(Icons.logout), ), @@ -230,9 +232,9 @@ class _ProductGrid extends StatelessWidget { ), const SizedBox(height: 20), FilledButton.icon( - onPressed: () => context.push('/products/new'), - icon: const Icon(Icons.add), - label: Text(l10n.addProduct), + onPressed: () => context.push('/admin'), + icon: const Icon(Icons.admin_panel_settings_outlined), + label: Text(l10n.admin), ), ], ), @@ -323,8 +325,6 @@ class _ProductGrid extends StatelessWidget { product: product, enabled: hasSelectedTab, onTap: () => onProductTap(product), - onLongPress: () => - context.push('/products/${product.id}/edit'), ); }, ); @@ -440,37 +440,37 @@ class _TabPanelState extends State<_TabPanel> { ], ), - const SizedBox(height: 18), - - Row( - children: [ - Text( - l10n.openTabs.toUpperCase(), - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w700, - letterSpacing: 0.8, - ), - ), - const SizedBox(width: 8), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 2, - ), - decoration: BoxDecoration( - color: scheme.onSurface.withValues(alpha: 0.06), - borderRadius: BorderRadius.circular(999), - ), - child: Text( - '${filteredTabs.length}', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - ], - ), - + // + // const SizedBox(height: 18), + // + // Row( + // children: [ + // Text( + // l10n.openTabs.toUpperCase(), + // style: Theme.of(context).textTheme.bodySmall?.copyWith( + // fontWeight: FontWeight.w700, + // letterSpacing: 0.8, + // ), + // ), + // const SizedBox(width: 8), + // Container( + // padding: const EdgeInsets.symmetric( + // horizontal: 8, + // vertical: 2, + // ), + // decoration: BoxDecoration( + // color: scheme.onSurface.withValues(alpha: 0.06), + // borderRadius: BorderRadius.circular(999), + // ), + // child: Text( + // '${filteredTabs.length}', + // style: Theme.of(context).textTheme.bodySmall?.copyWith( + // fontWeight: FontWeight.w700, + // ), + // ), + // ), + // ], + // ), const SizedBox(height: 8), SizedBox( diff --git a/lib/views/dev_menu_view.dart b/lib/views/dev_menu_view.dart index 7997ed7..fb15a57 100644 --- a/lib/views/dev_menu_view.dart +++ b/lib/views/dev_menu_view.dart @@ -68,6 +68,7 @@ class _DevMenuViewState extends State { body: ListView( padding: const EdgeInsets.symmetric(vertical: 12), children: [ + const Text("Hallo Matteo", style: TextStyle(color: Colors.pinkAccent),), _Section( title: l10n.dataManagement, children: [ diff --git a/lib/views/import_names_view.dart b/lib/views/import_names_view.dart new file mode 100644 index 0000000..829699c --- /dev/null +++ b/lib/views/import_names_view.dart @@ -0,0 +1,59 @@ +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, + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/views/pin_lock_view.dart b/lib/views/pin_lock_view.dart index 5dc2150..fd7590c 100644 --- a/lib/views/pin_lock_view.dart +++ b/lib/views/pin_lock_view.dart @@ -15,8 +15,20 @@ const _pinLength = 4; class PinEntryView extends StatefulWidget { final PinEntryMode mode; final VoidCallback? onSuccess; + final bool isAdmin; + final Future Function(String)? onVerify; + final Future Function(String)? onSet; + final String? Function()? errorMessage; - const PinEntryView({super.key, required this.mode, this.onSuccess}); + const PinEntryView({ + super.key, + required this.mode, + this.onSuccess, + this.isAdmin = false, + this.onVerify, + this.onSet, + this.errorMessage, + }); @override State createState() => _PinEntryViewState(); @@ -33,12 +45,22 @@ class _PinEntryViewState extends State { bool get _isCreateFlow => widget.mode == PinEntryMode.create; String _title(AppLocalizations l10n) { + if (widget.isAdmin) { + if (!_isCreateFlow) return l10n.enterAdminPin; + return _isConfirmStep ? l10n.confirmAdminPin : l10n.createAdminPin; + } + if (!_isCreateFlow) return l10n.enterPin; return _isConfirmStep ? l10n.confirmPin : l10n.createPin; } String? _subtitle(AppLocalizations l10n) { if (!_isCreateFlow) return null; + + if (widget.isAdmin) { + return _isConfirmStep ? l10n.confirmPinSubtitle : l10n.adminPinSubtitle; + } + return _isConfirmStep ? l10n.confirmPinSubtitle : l10n.enterPinSubtitle; } @@ -64,7 +86,6 @@ class _PinEntryViewState extends State { } Future _handleComplete() async { - final viewModel = context.read(); final l10n = AppLocalizations.of(context); if (_isCreateFlow && !_isConfirmStep) { @@ -90,7 +111,7 @@ class _PinEntryViewState extends State { } setState(() => _isSubmitting = true); - final ok = await viewModel.setPin(_digits); + final ok = await _setPin(_digits); if (!mounted) return; setState(() => _isSubmitting = false); @@ -101,24 +122,45 @@ class _PinEntryViewState extends State { _firstEntry = null; _isConfirmStep = false; }); - _fail(l10n.localizedError(viewModel.errorMessage)); + _fail(l10n.localizedError(_errorMessage())); } return; } // Unlock flow. setState(() => _isSubmitting = true); - final ok = await viewModel.verify(_digits); + final ok = await _verify(_digits); if (!mounted) return; setState(() => _isSubmitting = false); if (ok) { widget.onSuccess?.call(); } else { - _fail(l10n.localizedError(viewModel.errorMessage)); + _fail(l10n.localizedError(_errorMessage())); } } + Future _setPin(String pin) { + final callback = widget.onSet; + if (callback != null) return callback(pin); + + return context.read().setPin(pin); + } + + Future _verify(String pin) { + final callback = widget.onVerify; + if (callback != null) return callback(pin); + + return context.read().verify(pin); + } + + String? _errorMessage() { + final callback = widget.errorMessage; + if (callback != null) return callback(); + + return context.read().errorMessage; + } + void _fail(String message) { setState(() { _localError = message; diff --git a/lib/views/product_list_view.dart b/lib/views/product_list_view.dart index 37589ff..1a6b8e7 100644 --- a/lib/views/product_list_view.dart +++ b/lib/views/product_list_view.dart @@ -34,7 +34,7 @@ class _ProductListViewState extends State { return Scaffold( appBar: AppBar( leading: IconButton( - onPressed: () => context.popOrGo('/bar'), + onPressed: () => context.popOrGo('/admin'), icon: const Icon(Icons.arrow_back), ), title: Text(l10n.products), diff --git a/lib/views/settings_view.dart b/lib/views/settings_view.dart index 14d29a2..44bca58 100644 --- a/lib/views/settings_view.dart +++ b/lib/views/settings_view.dart @@ -8,6 +8,7 @@ import 'package:provider/provider.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import '../models/settings.dart'; +import '../viewmodels/admin_pin_view_model.dart'; import '../utils/app_update_util.dart'; import '../utils/navigation.dart'; import '../viewmodels/pin_lock_view_model.dart'; @@ -33,6 +34,7 @@ class _SettingsScreenViewState extends State { WidgetsBinding.instance.addPostFrameCallback((_) { context.read().ensureLoaded(); context.read().ensureLoaded(); + context.read().ensureLoaded(); _loadVersion(); }); } @@ -190,11 +192,62 @@ class _SettingsScreenViewState extends State { } } + Future _configureAdminPin() async { + final l10n = AppLocalizations.of(context); + final adminPinViewModel = context.read(); + + if (!adminPinViewModel.isPinSet) { + final pin = await _promptPin(l10n.setAdminPin, hint: l10n.fourDigits); + if (pin == null) return; + + if (pin.length != 4) { + _showError(l10n.pinExactlyFour); + return; + } + + final success = await adminPinViewModel.setPin(pin); + if (!success) { + _showError(l10n.localizedError(adminPinViewModel.errorMessage)); + return; + } + + adminPinViewModel.lock(); + return; + } + + final current = await _promptPin(l10n.enterCurrentAdminPin); + if (current == null) return; + + final newPin = await _promptPin( + l10n.enterNewAdminPin, + hint: l10n.fourDigits, + ); + if (newPin == null) return; + + if (newPin.length != 4) { + _showError(l10n.pinExactlyFour); + return; + } + + final success = await adminPinViewModel.changePin( + currentPin: current, + newPin: newPin, + ); + + if (!success) { + _showError(l10n.localizedError(adminPinViewModel.errorMessage)); + return; + } + + adminPinViewModel.lock(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final settingsViewModel = context.watch(); final pinLockViewModel = context.watch(); + final adminPinViewModel = context.watch(); return Scaffold( appBar: AppBar( @@ -207,9 +260,13 @@ class _SettingsScreenViewState extends State { body: Builder( builder: (context) { final isLoading = - settingsViewModel.isLoading || pinLockViewModel.isLoading; + settingsViewModel.isLoading || + pinLockViewModel.isLoading || + adminPinViewModel.isLoading; final hasLoaded = - settingsViewModel.hasLoaded && pinLockViewModel.hasLoaded; + settingsViewModel.hasLoaded && + pinLockViewModel.hasLoaded && + adminPinViewModel.hasLoaded; if (isLoading && !hasLoaded) { return const Center( @@ -251,6 +308,14 @@ class _SettingsScreenViewState extends State { 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( diff --git a/test/admin_pin_service_test.dart b/test/admin_pin_service_test.dart new file mode 100644 index 0000000..9a8c429 --- /dev/null +++ b/test/admin_pin_service_test.dart @@ -0,0 +1,52 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:kooltab2/services/admin_pin_service.dart'; +import 'package:mocktail/mocktail.dart'; + +class MockFlutterSecureStorage extends Mock implements FlutterSecureStorage {} + +void main() { + late AdminPinService service; + late MockFlutterSecureStorage mockStorage; + + setUp(() { + mockStorage = MockFlutterSecureStorage(); + service = AdminPinService(storage: mockStorage); + }); + + test('uses separate secure-storage keys for the admin PIN', () async { + when( + () => mockStorage.write( + key: any(named: 'key'), + value: any(named: 'value'), + ), + ).thenAnswer((_) async {}); + + await service.setPin('1234'); + + verify( + () => mockStorage.write( + key: 'admin_pin_salt', + value: any(named: 'value'), + ), + ).called(1); + verify( + () => mockStorage.write( + key: 'admin_pin_hash', + value: any(named: 'value'), + ), + ).called(1); + verifyNever( + () => mockStorage.write( + key: 'pin_salt', + value: any(named: 'value'), + ), + ); + verifyNever( + () => mockStorage.write( + key: 'pin_hash', + value: any(named: 'value'), + ), + ); + }); +}