feat: add admin page
This commit is contained in:
+5
-1
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||||||
|
|
||||||
import 'app_lifecycle_lock.dart';
|
import 'app_lifecycle_lock.dart';
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
import '../viewmodels/admin_pin_view_model.dart';
|
||||||
import '../viewmodels/pin_lock_view_model.dart';
|
import '../viewmodels/pin_lock_view_model.dart';
|
||||||
import '../viewmodels/settings_view_model.dart';
|
import '../viewmodels/settings_view_model.dart';
|
||||||
import '../theme.dart';
|
import '../theme.dart';
|
||||||
@@ -26,7 +27,10 @@ class _KoolTabAppState extends State<KoolTabApp> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
|
|
||||||
_lifecycleLockObserver = AppLifecycleLockObserver(
|
_lifecycleLockObserver = AppLifecycleLockObserver(
|
||||||
onLock: () => context.read<PinLockViewModel>().lock(),
|
onLock: () {
|
||||||
|
context.read<PinLockViewModel>().lock();
|
||||||
|
context.read<AdminPinViewModel>().lock();
|
||||||
|
},
|
||||||
shouldLock: () =>
|
shouldLock: () =>
|
||||||
context.read<SettingsViewModel>().settings.autoLockEnabled,
|
context.read<SettingsViewModel>().settings.autoLockEnabled,
|
||||||
);
|
);
|
||||||
|
|||||||
+55
-10
@@ -2,11 +2,14 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
|
import '../viewmodels/admin_pin_view_model.dart';
|
||||||
import '../viewmodels/pin_lock_view_model.dart';
|
import '../viewmodels/pin_lock_view_model.dart';
|
||||||
|
import '../views/admin_screen_view.dart';
|
||||||
import '../views/bar_screen_view.dart';
|
import '../views/bar_screen_view.dart';
|
||||||
import '../views/dev_menu_view.dart';
|
import '../views/dev_menu_view.dart';
|
||||||
import '../views/error_screen_view.dart';
|
import '../views/error_screen_view.dart';
|
||||||
import '../views/history_screen_view.dart';
|
import '../views/history_screen_view.dart';
|
||||||
|
import '../views/import_names_view.dart';
|
||||||
import '../views/pin_lock_view.dart';
|
import '../views/pin_lock_view.dart';
|
||||||
import '../views/product_form_view.dart';
|
import '../views/product_form_view.dart';
|
||||||
import '../views/product_list_view.dart';
|
import '../views/product_list_view.dart';
|
||||||
@@ -16,26 +19,45 @@ import '../utils/app_update_util.dart';
|
|||||||
|
|
||||||
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
final RouteObserver<PageRoute> routeObserver = RouteObserver<PageRoute>();
|
||||||
|
|
||||||
GoRouter createAppRouter(PinLockViewModel pinLockViewModel) {
|
GoRouter createAppRouter(
|
||||||
|
PinLockViewModel pinLockViewModel, {
|
||||||
|
required AdminPinViewModel adminPinViewModel,
|
||||||
|
}) {
|
||||||
return GoRouter(
|
return GoRouter(
|
||||||
initialLocation: '/bar',
|
initialLocation: '/bar',
|
||||||
observers: [routeObserver, SentryNavigatorObserver()],
|
observers: [routeObserver, SentryNavigatorObserver()],
|
||||||
refreshListenable: pinLockViewModel,
|
refreshListenable: Listenable.merge([pinLockViewModel, adminPinViewModel]),
|
||||||
redirect: (context, state) {
|
redirect: (context, state) {
|
||||||
if (!pinLockViewModel.hasLoaded) return null;
|
if (!pinLockViewModel.hasLoaded || !adminPinViewModel.hasLoaded) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
final needsPinSetup =
|
final needsPinSetup =
|
||||||
!pinLockViewModel.isPinSet && pinLockViewModel.pinRequired;
|
!pinLockViewModel.isPinSet && pinLockViewModel.pinRequired;
|
||||||
final isLocked = pinLockViewModel.isPinSet &&
|
final isLocked =
|
||||||
|
pinLockViewModel.isPinSet &&
|
||||||
!pinLockViewModel.isUnlocked &&
|
!pinLockViewModel.isUnlocked &&
|
||||||
pinLockViewModel.pinRequired;
|
pinLockViewModel.pinRequired;
|
||||||
final mustBeOnLock = needsPinSetup || isLocked;
|
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 '/lock';
|
||||||
if (!mustBeOnLock && goingToLock) return '/bar';
|
if (!mustBeOnLock && goingToLock) return '/bar';
|
||||||
|
|
||||||
|
if (isAdminArea && !goingToAdminLock && !adminPinViewModel.isUnlocked) {
|
||||||
|
return '/admin/lock';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (goingToAdminLock && adminPinViewModel.isUnlocked) return '/admin';
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
routes: [
|
routes: [
|
||||||
@@ -54,6 +76,31 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) {
|
|||||||
|
|
||||||
GoRoute(path: '/bar', builder: (context, state) => const BarScreenView()),
|
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(
|
GoRoute(
|
||||||
path: '/products',
|
path: '/products',
|
||||||
builder: (context, state) => const ProductListView(),
|
builder: (context, state) => const ProductListView(),
|
||||||
@@ -87,7 +134,8 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) {
|
|||||||
builder: (context, state) {
|
builder: (context, state) {
|
||||||
final update = state.extra as UpdateInfo;
|
final update = state.extra as UpdateInfo;
|
||||||
final simulate = state.uri.queryParameters['simulate'] == 'true';
|
final simulate = state.uri.queryParameters['simulate'] == 'true';
|
||||||
final simulateError = state.uri.queryParameters['simulateError'] == 'true';
|
final simulateError =
|
||||||
|
state.uri.queryParameters['simulateError'] == 'true';
|
||||||
return UpdateProgressView(
|
return UpdateProgressView(
|
||||||
update: update,
|
update: update,
|
||||||
simulate: simulate,
|
simulate: simulate,
|
||||||
@@ -96,10 +144,7 @@ GoRouter createAppRouter(PinLockViewModel pinLockViewModel) {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
GoRoute(
|
GoRoute(path: '/dev', builder: (context, state) => const DevMenuView()),
|
||||||
path: '/dev',
|
|
||||||
builder: (context, state) => const DevMenuView(),
|
|
||||||
),
|
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/error',
|
path: '/error',
|
||||||
builder: (context, state) => const ErrorScreenView(),
|
builder: (context, state) => const ErrorScreenView(),
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"settings": "Settings",
|
"settings": "Settings",
|
||||||
"logout": "Logout",
|
"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.",
|
"openOrSelectTab": "Open or select a tab first.",
|
||||||
"openNewTab": "Open new tab",
|
"openNewTab": "Open new tab",
|
||||||
"customerGroupName": "Customer / group name",
|
"customerGroupName": "Customer / group name",
|
||||||
@@ -28,6 +35,12 @@
|
|||||||
"pinRequired": "PIN Required",
|
"pinRequired": "PIN Required",
|
||||||
"autoLockOnExit": "Auto-lock on phone lock or app exit",
|
"autoLockOnExit": "Auto-lock on phone lock or app exit",
|
||||||
"changePin": "Change PIN",
|
"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",
|
"appearance": "Appearance",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"languageSystem": "System default",
|
"languageSystem": "System default",
|
||||||
@@ -93,6 +106,10 @@
|
|||||||
"enterPinSubtitle": "You’ll use this to unlock the app",
|
"enterPinSubtitle": "You’ll use this to unlock the app",
|
||||||
"confirmPinSubtitle": "Enter the same PIN again",
|
"confirmPinSubtitle": "Enter the same PIN again",
|
||||||
"createPin": "Create a PIN",
|
"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.",
|
"pinsDidNotMatch": "PINs didn’t match. Try again.",
|
||||||
"somethingWentWrong": "Something went wrong.",
|
"somethingWentWrong": "Something went wrong.",
|
||||||
"incorrectPin": "Incorrect PIN.",
|
"incorrectPin": "Incorrect PIN.",
|
||||||
|
|||||||
@@ -164,6 +164,48 @@ abstract class AppLocalizations {
|
|||||||
/// **'Logout'**
|
/// **'Logout'**
|
||||||
String get 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.
|
/// No description provided for @openOrSelectTab.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -260,6 +302,42 @@ abstract class AppLocalizations {
|
|||||||
/// **'Change PIN'**
|
/// **'Change PIN'**
|
||||||
String get changePin;
|
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.
|
/// No description provided for @appearance.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
@@ -620,6 +698,30 @@ abstract class AppLocalizations {
|
|||||||
/// **'Create a PIN'**
|
/// **'Create a PIN'**
|
||||||
String get createPin;
|
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.
|
/// No description provided for @pinsDidNotMatch.
|
||||||
///
|
///
|
||||||
/// In en, this message translates to:
|
/// In en, this message translates to:
|
||||||
|
|||||||
@@ -41,6 +41,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get logout => 'Logout';
|
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
|
@override
|
||||||
String get openOrSelectTab => 'Open or select a tab first.';
|
String get openOrSelectTab => 'Open or select a tab first.';
|
||||||
|
|
||||||
@@ -91,6 +115,24 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get changePin => 'Change PIN';
|
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
|
@override
|
||||||
String get appearance => 'Appearance';
|
String get appearance => 'Appearance';
|
||||||
|
|
||||||
@@ -295,6 +337,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get createPin => 'Create a PIN';
|
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
|
@override
|
||||||
String get pinsDidNotMatch => 'PINs didn’t match. Try again.';
|
String get pinsDidNotMatch => 'PINs didn’t match. Try again.';
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,29 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get logout => 'Uitloggen';
|
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
|
@override
|
||||||
String get openOrSelectTab => 'Open of selecteer eerst een poef.';
|
String get openOrSelectTab => 'Open of selecteer eerst een poef.';
|
||||||
|
|
||||||
@@ -92,6 +115,24 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get changePin => 'PIN wijzigen';
|
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
|
@override
|
||||||
String get appearance => 'Uiterlijk';
|
String get appearance => 'Uiterlijk';
|
||||||
|
|
||||||
@@ -296,6 +337,18 @@ class AppLocalizationsNl extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get createPin => 'PIN aanmaken';
|
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
|
@override
|
||||||
String get pinsDidNotMatch =>
|
String get pinsDidNotMatch =>
|
||||||
'PINs kwamen niet overeen. Probeer het opnieuw.';
|
'PINs kwamen niet overeen. Probeer het opnieuw.';
|
||||||
|
|||||||
@@ -11,6 +11,13 @@
|
|||||||
"refresh": "Vernieuwen",
|
"refresh": "Vernieuwen",
|
||||||
"settings": "Instellingen",
|
"settings": "Instellingen",
|
||||||
"logout": "Uitloggen",
|
"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.",
|
"openOrSelectTab": "Open of selecteer eerst een poef.",
|
||||||
"openNewTab": "Nieuw poef openen",
|
"openNewTab": "Nieuw poef openen",
|
||||||
"customerGroupName": "Naam klant / groep",
|
"customerGroupName": "Naam klant / groep",
|
||||||
@@ -28,6 +35,12 @@
|
|||||||
"pinRequired": "PIN vereist",
|
"pinRequired": "PIN vereist",
|
||||||
"autoLockOnExit": "Automatisch vergrendelen bij telefoonslot of afsluiten",
|
"autoLockOnExit": "Automatisch vergrendelen bij telefoonslot of afsluiten",
|
||||||
"changePin": "PIN wijzigen",
|
"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",
|
"appearance": "Uiterlijk",
|
||||||
"language": "Taal",
|
"language": "Taal",
|
||||||
"languageSystem": "Systeemstandaard",
|
"languageSystem": "Systeemstandaard",
|
||||||
@@ -93,6 +106,10 @@
|
|||||||
"enterPinSubtitle": "Je gebruikt dit om de app te ontgrendelen",
|
"enterPinSubtitle": "Je gebruikt dit om de app te ontgrendelen",
|
||||||
"confirmPinSubtitle": "Voer dezelfde PIN opnieuw in",
|
"confirmPinSubtitle": "Voer dezelfde PIN opnieuw in",
|
||||||
"createPin": "PIN aanmaken",
|
"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.",
|
"pinsDidNotMatch": "PINs kwamen niet overeen. Probeer het opnieuw.",
|
||||||
"somethingWentWrong": "Er is iets misgegaan.",
|
"somethingWentWrong": "Er is iets misgegaan.",
|
||||||
"incorrectPin": "Onjuiste PIN.",
|
"incorrectPin": "Onjuiste PIN.",
|
||||||
|
|||||||
+15
-2
@@ -17,9 +17,11 @@ import 'app/app.dart';
|
|||||||
import 'app/app_bootstrap.dart';
|
import 'app/app_bootstrap.dart';
|
||||||
import 'app/router.dart';
|
import 'app/router.dart';
|
||||||
import 'database/app_database.dart';
|
import 'database/app_database.dart';
|
||||||
|
import 'services/admin_pin_service.dart';
|
||||||
import 'services/bar_tab_service.dart';
|
import 'services/bar_tab_service.dart';
|
||||||
import 'services/product_service.dart';
|
import 'services/product_service.dart';
|
||||||
import 'viewmodels/bar_screen_view_model.dart';
|
import 'viewmodels/bar_screen_view_model.dart';
|
||||||
|
import 'viewmodels/admin_pin_view_model.dart';
|
||||||
import 'viewmodels/product_list_view_model.dart';
|
import 'viewmodels/product_list_view_model.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
@@ -41,7 +43,15 @@ Future<void> main() async {
|
|||||||
);
|
);
|
||||||
await pinLockViewModel.ensureLoaded();
|
await pinLockViewModel.ensureLoaded();
|
||||||
|
|
||||||
final appRouter = createAppRouter(pinLockViewModel);
|
final adminPinViewModel = AdminPinViewModel(
|
||||||
|
adminPinService: AdminPinService(),
|
||||||
|
);
|
||||||
|
await adminPinViewModel.ensureLoaded();
|
||||||
|
|
||||||
|
final appRouter = createAppRouter(
|
||||||
|
pinLockViewModel,
|
||||||
|
adminPinViewModel: adminPinViewModel,
|
||||||
|
);
|
||||||
|
|
||||||
await SentryFlutter.init(
|
await SentryFlutter.init(
|
||||||
(options) {
|
(options) {
|
||||||
@@ -97,6 +107,9 @@ Future<void> main() async {
|
|||||||
ChangeNotifierProvider<PinLockViewModel>.value(
|
ChangeNotifierProvider<PinLockViewModel>.value(
|
||||||
value: pinLockViewModel,
|
value: pinLockViewModel,
|
||||||
),
|
),
|
||||||
|
ChangeNotifierProvider<AdminPinViewModel>.value(
|
||||||
|
value: adminPinViewModel,
|
||||||
|
),
|
||||||
ChangeNotifierProvider<SettingsViewModel>(
|
ChangeNotifierProvider<SettingsViewModel>(
|
||||||
create: (context) => SettingsViewModel(
|
create: (context) => SettingsViewModel(
|
||||||
settingsService: context.read<SettingsService>(),
|
settingsService: context.read<SettingsService>(),
|
||||||
@@ -116,4 +129,4 @@ Future<void> main() async {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -5,13 +5,20 @@ import 'package:crypto/crypto.dart';
|
|||||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
|
||||||
class PinLockService {
|
class PinLockService {
|
||||||
PinLockService({FlutterSecureStorage? storage})
|
PinLockService({
|
||||||
: _storage = storage ?? const FlutterSecureStorage();
|
FlutterSecureStorage? storage,
|
||||||
|
String saltKey = _defaultSaltKey,
|
||||||
|
String hashKey = _defaultHashKey,
|
||||||
|
}) : _storage = storage ?? const FlutterSecureStorage(),
|
||||||
|
_saltKey = saltKey,
|
||||||
|
_hashKey = hashKey;
|
||||||
|
|
||||||
static const _saltKey = 'pin_salt';
|
static const _defaultSaltKey = 'pin_salt';
|
||||||
static const _hashKey = 'pin_hash';
|
static const _defaultHashKey = 'pin_hash';
|
||||||
|
|
||||||
final FlutterSecureStorage _storage;
|
final FlutterSecureStorage _storage;
|
||||||
|
final String _saltKey;
|
||||||
|
final String _hashKey;
|
||||||
|
|
||||||
Future<bool> hasPin() async {
|
Future<bool> hasPin() async {
|
||||||
final hash = await _storage.read(key: _hashKey);
|
final hash = await _storage.read(key: _hashKey);
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.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/pin_lock_view_model.dart';
|
||||||
import 'package:kooltab2/viewmodels/product_list_view_model.dart';
|
import 'package:kooltab2/viewmodels/product_list_view_model.dart';
|
||||||
import 'package:kooltab2/views/dialogs/close_tab_dialog.dart';
|
import 'package:kooltab2/views/dialogs/close_tab_dialog.dart';
|
||||||
@@ -52,9 +53,9 @@ class _BarScreenViewState extends State<BarScreenView> {
|
|||||||
title: Text(l10n.barTabs),
|
title: Text(l10n.barTabs),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: l10n.manageProducts,
|
tooltip: l10n.admin,
|
||||||
onPressed: () => context.push('/products'),
|
onPressed: () => context.push('/admin'),
|
||||||
icon: const Icon(Icons.inventory_2_outlined),
|
icon: const Icon(Icons.admin_panel_settings_outlined),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -80,6 +81,7 @@ class _BarScreenViewState extends State<BarScreenView> {
|
|||||||
tooltip: l10n.logout,
|
tooltip: l10n.logout,
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
Provider.of<PinLockViewModel>(context, listen: false).lock();
|
Provider.of<PinLockViewModel>(context, listen: false).lock();
|
||||||
|
Provider.of<AdminPinViewModel>(context, listen: false).lock();
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.logout),
|
icon: const Icon(Icons.logout),
|
||||||
),
|
),
|
||||||
@@ -230,9 +232,9 @@ class _ProductGrid extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: () => context.push('/products/new'),
|
onPressed: () => context.push('/admin'),
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.admin_panel_settings_outlined),
|
||||||
label: Text(l10n.addProduct),
|
label: Text(l10n.admin),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -323,8 +325,6 @@ class _ProductGrid extends StatelessWidget {
|
|||||||
product: product,
|
product: product,
|
||||||
enabled: hasSelectedTab,
|
enabled: hasSelectedTab,
|
||||||
onTap: () => onProductTap(product),
|
onTap: () => onProductTap(product),
|
||||||
onLongPress: () =>
|
|
||||||
context.push('/products/${product.id}/edit'),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -440,37 +440,37 @@ class _TabPanelState extends State<_TabPanel> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 18),
|
//
|
||||||
|
// const SizedBox(height: 18),
|
||||||
Row(
|
//
|
||||||
children: [
|
// Row(
|
||||||
Text(
|
// children: [
|
||||||
l10n.openTabs.toUpperCase(),
|
// Text(
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
// l10n.openTabs.toUpperCase(),
|
||||||
fontWeight: FontWeight.w700,
|
// style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
letterSpacing: 0.8,
|
// fontWeight: FontWeight.w700,
|
||||||
),
|
// letterSpacing: 0.8,
|
||||||
),
|
// ),
|
||||||
const SizedBox(width: 8),
|
// ),
|
||||||
Container(
|
// const SizedBox(width: 8),
|
||||||
padding: const EdgeInsets.symmetric(
|
// Container(
|
||||||
horizontal: 8,
|
// padding: const EdgeInsets.symmetric(
|
||||||
vertical: 2,
|
// horizontal: 8,
|
||||||
),
|
// vertical: 2,
|
||||||
decoration: BoxDecoration(
|
// ),
|
||||||
color: scheme.onSurface.withValues(alpha: 0.06),
|
// decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(999),
|
// color: scheme.onSurface.withValues(alpha: 0.06),
|
||||||
),
|
// borderRadius: BorderRadius.circular(999),
|
||||||
child: Text(
|
// ),
|
||||||
'${filteredTabs.length}',
|
// child: Text(
|
||||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
// '${filteredTabs.length}',
|
||||||
fontWeight: FontWeight.w700,
|
// style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
),
|
// fontWeight: FontWeight.w700,
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
],
|
// ),
|
||||||
),
|
// ],
|
||||||
|
// ),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
|
||||||
SizedBox(
|
SizedBox(
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ class _DevMenuViewState extends State<DevMenuView> {
|
|||||||
body: ListView(
|
body: ListView(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
children: [
|
children: [
|
||||||
|
const Text("Hallo Matteo", style: TextStyle(color: Colors.pinkAccent),),
|
||||||
_Section(
|
_Section(
|
||||||
title: l10n.dataManagement,
|
title: l10n.dataManagement,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,8 +15,20 @@ const _pinLength = 4;
|
|||||||
class PinEntryView extends StatefulWidget {
|
class PinEntryView extends StatefulWidget {
|
||||||
final PinEntryMode mode;
|
final PinEntryMode mode;
|
||||||
final VoidCallback? onSuccess;
|
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
|
@override
|
||||||
State<PinEntryView> createState() => _PinEntryViewState();
|
State<PinEntryView> createState() => _PinEntryViewState();
|
||||||
@@ -33,12 +45,22 @@ class _PinEntryViewState extends State<PinEntryView> {
|
|||||||
bool get _isCreateFlow => widget.mode == PinEntryMode.create;
|
bool get _isCreateFlow => widget.mode == PinEntryMode.create;
|
||||||
|
|
||||||
String _title(AppLocalizations l10n) {
|
String _title(AppLocalizations l10n) {
|
||||||
|
if (widget.isAdmin) {
|
||||||
|
if (!_isCreateFlow) return l10n.enterAdminPin;
|
||||||
|
return _isConfirmStep ? l10n.confirmAdminPin : l10n.createAdminPin;
|
||||||
|
}
|
||||||
|
|
||||||
if (!_isCreateFlow) return l10n.enterPin;
|
if (!_isCreateFlow) return l10n.enterPin;
|
||||||
return _isConfirmStep ? l10n.confirmPin : l10n.createPin;
|
return _isConfirmStep ? l10n.confirmPin : l10n.createPin;
|
||||||
}
|
}
|
||||||
|
|
||||||
String? _subtitle(AppLocalizations l10n) {
|
String? _subtitle(AppLocalizations l10n) {
|
||||||
if (!_isCreateFlow) return null;
|
if (!_isCreateFlow) return null;
|
||||||
|
|
||||||
|
if (widget.isAdmin) {
|
||||||
|
return _isConfirmStep ? l10n.confirmPinSubtitle : l10n.adminPinSubtitle;
|
||||||
|
}
|
||||||
|
|
||||||
return _isConfirmStep ? l10n.confirmPinSubtitle : l10n.enterPinSubtitle;
|
return _isConfirmStep ? l10n.confirmPinSubtitle : l10n.enterPinSubtitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,7 +86,6 @@ class _PinEntryViewState extends State<PinEntryView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleComplete() async {
|
Future<void> _handleComplete() async {
|
||||||
final viewModel = context.read<PinLockViewModel>();
|
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
|
|
||||||
if (_isCreateFlow && !_isConfirmStep) {
|
if (_isCreateFlow && !_isConfirmStep) {
|
||||||
@@ -90,7 +111,7 @@ class _PinEntryViewState extends State<PinEntryView> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _isSubmitting = true);
|
setState(() => _isSubmitting = true);
|
||||||
final ok = await viewModel.setPin(_digits);
|
final ok = await _setPin(_digits);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isSubmitting = false);
|
setState(() => _isSubmitting = false);
|
||||||
|
|
||||||
@@ -101,24 +122,45 @@ class _PinEntryViewState extends State<PinEntryView> {
|
|||||||
_firstEntry = null;
|
_firstEntry = null;
|
||||||
_isConfirmStep = false;
|
_isConfirmStep = false;
|
||||||
});
|
});
|
||||||
_fail(l10n.localizedError(viewModel.errorMessage));
|
_fail(l10n.localizedError(_errorMessage()));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unlock flow.
|
// Unlock flow.
|
||||||
setState(() => _isSubmitting = true);
|
setState(() => _isSubmitting = true);
|
||||||
final ok = await viewModel.verify(_digits);
|
final ok = await _verify(_digits);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
setState(() => _isSubmitting = false);
|
setState(() => _isSubmitting = false);
|
||||||
|
|
||||||
if (ok) {
|
if (ok) {
|
||||||
widget.onSuccess?.call();
|
widget.onSuccess?.call();
|
||||||
} else {
|
} 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) {
|
void _fail(String message) {
|
||||||
setState(() {
|
setState(() {
|
||||||
_localError = message;
|
_localError = message;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class _ProductListViewState extends State<ProductListView> {
|
|||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
onPressed: () => context.popOrGo('/bar'),
|
onPressed: () => context.popOrGo('/admin'),
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
),
|
),
|
||||||
title: Text(l10n.products),
|
title: Text(l10n.products),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
|
|||||||
import 'package:sentry_flutter/sentry_flutter.dart';
|
import 'package:sentry_flutter/sentry_flutter.dart';
|
||||||
|
|
||||||
import '../models/settings.dart';
|
import '../models/settings.dart';
|
||||||
|
import '../viewmodels/admin_pin_view_model.dart';
|
||||||
import '../utils/app_update_util.dart';
|
import '../utils/app_update_util.dart';
|
||||||
import '../utils/navigation.dart';
|
import '../utils/navigation.dart';
|
||||||
import '../viewmodels/pin_lock_view_model.dart';
|
import '../viewmodels/pin_lock_view_model.dart';
|
||||||
@@ -33,6 +34,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
context.read<SettingsViewModel>().ensureLoaded();
|
context.read<SettingsViewModel>().ensureLoaded();
|
||||||
context.read<PinLockViewModel>().ensureLoaded();
|
context.read<PinLockViewModel>().ensureLoaded();
|
||||||
|
context.read<AdminPinViewModel>().ensureLoaded();
|
||||||
_loadVersion();
|
_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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = AppLocalizations.of(context);
|
final l10n = AppLocalizations.of(context);
|
||||||
final settingsViewModel = context.watch<SettingsViewModel>();
|
final settingsViewModel = context.watch<SettingsViewModel>();
|
||||||
final pinLockViewModel = context.watch<PinLockViewModel>();
|
final pinLockViewModel = context.watch<PinLockViewModel>();
|
||||||
|
final adminPinViewModel = context.watch<AdminPinViewModel>();
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -207,9 +260,13 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
|||||||
body: Builder(
|
body: Builder(
|
||||||
builder: (context) {
|
builder: (context) {
|
||||||
final isLoading =
|
final isLoading =
|
||||||
settingsViewModel.isLoading || pinLockViewModel.isLoading;
|
settingsViewModel.isLoading ||
|
||||||
|
pinLockViewModel.isLoading ||
|
||||||
|
adminPinViewModel.isLoading;
|
||||||
final hasLoaded =
|
final hasLoaded =
|
||||||
settingsViewModel.hasLoaded && pinLockViewModel.hasLoaded;
|
settingsViewModel.hasLoaded &&
|
||||||
|
pinLockViewModel.hasLoaded &&
|
||||||
|
adminPinViewModel.hasLoaded;
|
||||||
|
|
||||||
if (isLoading && !hasLoaded) {
|
if (isLoading && !hasLoaded) {
|
||||||
return const Center(
|
return const Center(
|
||||||
@@ -251,6 +308,14 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
|
|||||||
title: l10n.changePin,
|
title: l10n.changePin,
|
||||||
onTap: _changePin,
|
onTap: _changePin,
|
||||||
),
|
),
|
||||||
|
_SettingsTile(
|
||||||
|
icon: Icons.admin_panel_settings_outlined,
|
||||||
|
title: l10n.adminPin,
|
||||||
|
subtitle: adminPinViewModel.isPinSet
|
||||||
|
? l10n.adminPinConfigured
|
||||||
|
: l10n.adminPinNotSet,
|
||||||
|
onTap: _configureAdminPin,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
_SettingsSection(
|
_SettingsSection(
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user