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 '../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
@@ -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(),
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.';
|
||||
|
||||
|
||||
@@ -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.';
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+14
-1
@@ -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>(),
|
||||
|
||||
@@ -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';
|
||||
|
||||
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);
|
||||
|
||||
@@ -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: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(
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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 {
|
||||
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;
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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