feat: add admin page

This commit is contained in:
2026-08-07 03:09:59 +02:00
parent 2c734493cf
commit 1dd7e69aa8
19 changed files with 891 additions and 65 deletions
+5 -1
View File
@@ -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<KoolTabApp> {
super.initState();
_lifecycleLockObserver = AppLifecycleLockObserver(
onLock: () => context.read<PinLockViewModel>().lock(),
onLock: () {
context.read<PinLockViewModel>().lock();
context.read<AdminPinViewModel>().lock();
},
shouldLock: () =>
context.read<SettingsViewModel>().settings.autoLockEnabled,
);
+55 -10
View File
@@ -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<PageRoute> routeObserver = RouteObserver<PageRoute>();
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(),
+17
View File
@@ -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": "Youll 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": "Youll use this to access admin tools",
"pinsDidNotMatch": "PINs didnt match. Try again.",
"somethingWentWrong": "Something went wrong.",
"incorrectPin": "Incorrect PIN.",
+102
View File
@@ -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:
/// **'Youll use this to access admin tools'**
String get adminPinSubtitle;
/// No description provided for @pinsDidNotMatch.
///
/// In en, this message translates to:
+54
View File
@@ -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 => 'Youll use this to access admin tools';
@override
String get pinsDidNotMatch => 'PINs didnt match. Try again.';
+53
View File
@@ -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.';
+17
View File
@@ -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.",
+15 -2
View File
@@ -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<void> main() async {
@@ -41,7 +43,15 @@ Future<void> 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<void> main() async {
ChangeNotifierProvider<PinLockViewModel>.value(
value: pinLockViewModel,
),
ChangeNotifierProvider<AdminPinViewModel>.value(
value: adminPinViewModel,
),
ChangeNotifierProvider<SettingsViewModel>(
create: (context) => SettingsViewModel(
settingsService: context.read<SettingsService>(),
@@ -116,4 +129,4 @@ Future<void> main() async {
),
),
);
}
}
+6
View File
@@ -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');
}
+11 -4
View File
@@ -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<bool> hasPin() async {
final hash = await _storage.read(key: _hashKey);
+112
View File
@@ -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<void> ensureLoaded() async {
if (_hasLoaded || _isLoading) return;
await load();
}
Future<void> 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<bool> 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<bool> 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<bool> 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();
}
}
+177
View File
@@ -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<AdminPinViewModel>();
context.popOrGo('/bar');
adminPinViewModel.lock();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PopScope(
onPopInvokedWithResult: (didPop, result) {
if (didPop) context.read<AdminPinViewModel>().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<AdminPinViewModel>();
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<Widget> 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),
),
],
),
),
),
);
}
}
+39 -39
View File
@@ -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<BarScreenView> {
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<BarScreenView> {
tooltip: l10n.logout,
onPressed: () {
Provider.of<PinLockViewModel>(context, listen: false).lock();
Provider.of<AdminPinViewModel>(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(
+1
View File
@@ -68,6 +68,7 @@ class _DevMenuViewState extends State<DevMenuView> {
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
const Text("Hallo Matteo", style: TextStyle(color: Colors.pinkAccent),),
_Section(
title: l10n.dataManagement,
children: [
+59
View File
@@ -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,
),
],
),
),
),
),
),
),
);
}
}
+48 -6
View File
@@ -15,8 +15,20 @@ const _pinLength = 4;
class PinEntryView extends StatefulWidget {
final PinEntryMode mode;
final VoidCallback? onSuccess;
final bool isAdmin;
final Future<bool> Function(String)? onVerify;
final Future<bool> 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<PinEntryView> createState() => _PinEntryViewState();
@@ -33,12 +45,22 @@ class _PinEntryViewState extends State<PinEntryView> {
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<PinEntryView> {
}
Future<void> _handleComplete() async {
final viewModel = context.read<PinLockViewModel>();
final l10n = AppLocalizations.of(context);
if (_isCreateFlow && !_isConfirmStep) {
@@ -90,7 +111,7 @@ class _PinEntryViewState extends State<PinEntryView> {
}
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<PinEntryView> {
_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<bool> _setPin(String pin) {
final callback = widget.onSet;
if (callback != null) return callback(pin);
return context.read<PinLockViewModel>().setPin(pin);
}
Future<bool> _verify(String pin) {
final callback = widget.onVerify;
if (callback != null) return callback(pin);
return context.read<PinLockViewModel>().verify(pin);
}
String? _errorMessage() {
final callback = widget.errorMessage;
if (callback != null) return callback();
return context.read<PinLockViewModel>().errorMessage;
}
void _fail(String message) {
setState(() {
_localError = message;
+1 -1
View File
@@ -34,7 +34,7 @@ class _ProductListViewState extends State<ProductListView> {
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.popOrGo('/bar'),
onPressed: () => context.popOrGo('/admin'),
icon: const Icon(Icons.arrow_back),
),
title: Text(l10n.products),
+67 -2
View File
@@ -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<SettingsScreenView> {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SettingsViewModel>().ensureLoaded();
context.read<PinLockViewModel>().ensureLoaded();
context.read<AdminPinViewModel>().ensureLoaded();
_loadVersion();
});
}
@@ -190,11 +192,62 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
}
}
Future<void> _configureAdminPin() async {
final l10n = AppLocalizations.of(context);
final adminPinViewModel = context.read<AdminPinViewModel>();
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<SettingsViewModel>();
final pinLockViewModel = context.watch<PinLockViewModel>();
final adminPinViewModel = context.watch<AdminPinViewModel>();
return Scaffold(
appBar: AppBar(
@@ -207,9 +260,13 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
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<SettingsScreenView> {
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(
+52
View File
@@ -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'),
),
);
});
}