feat: add admin page

This commit is contained in:
2026-08-07 03:09:59 +02:00
parent 2c734493cf
commit 1dd7e69aa8
19 changed files with 891 additions and 65 deletions
+177
View File
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../utils/navigation.dart';
import '../viewmodels/admin_pin_view_model.dart';
class AdminScreenView extends StatelessWidget {
const AdminScreenView({super.key});
void _leave(BuildContext context) {
final adminPinViewModel = context.read<AdminPinViewModel>();
context.popOrGo('/bar');
adminPinViewModel.lock();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PopScope(
onPopInvokedWithResult: (didPop, result) {
if (didPop) context.read<AdminPinViewModel>().lock();
},
child: Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => _leave(context),
icon: const Icon(Icons.arrow_back),
),
title: Text(l10n.admin),
actions: [
IconButton(
tooltip: l10n.logout,
onPressed: () {
final adminPinViewModel = context.read<AdminPinViewModel>();
context.go('/bar');
adminPinViewModel.lock();
},
icon: const Icon(Icons.logout),
),
],
),
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
_AdminSection(
title: l10n.adminTools,
children: [
_AdminTile(
icon: Icons.inventory_2_outlined,
title: l10n.products,
subtitle: l10n.manageProducts,
onTap: () => context.push('/products'),
),
_AdminTile(
icon: Icons.file_upload_outlined,
title: l10n.importData,
subtitle: l10n.importNamesDescription,
onTap: () => context.push('/admin/import'),
),
],
),
],
),
),
);
}
}
class _AdminSection extends StatelessWidget {
final String title;
final List<Widget> children;
const _AdminSection({required this.title, required this.children});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title.toUpperCase(),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 0.5,
color: scheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: scheme.onSurface.withValues(alpha: 0.04),
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: scheme.onSurface.withValues(alpha: 0.06),
),
),
clipBehavior: Clip.antiAlias,
child: Column(children: children),
),
],
),
);
}
}
class _AdminTile extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final VoidCallback? onTap;
const _AdminTile({
required this.icon,
required this.title,
this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
children: [
Icon(
icon,
size: 22,
color: scheme.onSurface.withValues(alpha: 0.7),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: TextStyle(
fontWeight: FontWeight.w600,
color: scheme.onSurface,
),
),
if (subtitle != null)
Text(
subtitle!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: scheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
),
if (onTap != null)
Icon(
Icons.chevron_right_rounded,
color: scheme.onSurface.withValues(alpha: 0.3),
),
],
),
),
),
);
}
}
+39 -39
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:kooltab2/viewmodels/admin_pin_view_model.dart';
import 'package:kooltab2/viewmodels/pin_lock_view_model.dart';
import 'package:kooltab2/viewmodels/product_list_view_model.dart';
import 'package:kooltab2/views/dialogs/close_tab_dialog.dart';
@@ -52,9 +53,9 @@ class _BarScreenViewState extends State<BarScreenView> {
title: Text(l10n.barTabs),
actions: [
IconButton(
tooltip: l10n.manageProducts,
onPressed: () => context.push('/products'),
icon: const Icon(Icons.inventory_2_outlined),
tooltip: l10n.admin,
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.admin_panel_settings_outlined),
),
const SizedBox(width: 6),
IconButton(
@@ -80,6 +81,7 @@ class _BarScreenViewState extends State<BarScreenView> {
tooltip: l10n.logout,
onPressed: () {
Provider.of<PinLockViewModel>(context, listen: false).lock();
Provider.of<AdminPinViewModel>(context, listen: false).lock();
},
icon: const Icon(Icons.logout),
),
@@ -230,9 +232,9 @@ class _ProductGrid extends StatelessWidget {
),
const SizedBox(height: 20),
FilledButton.icon(
onPressed: () => context.push('/products/new'),
icon: const Icon(Icons.add),
label: Text(l10n.addProduct),
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.admin_panel_settings_outlined),
label: Text(l10n.admin),
),
],
),
@@ -323,8 +325,6 @@ class _ProductGrid extends StatelessWidget {
product: product,
enabled: hasSelectedTab,
onTap: () => onProductTap(product),
onLongPress: () =>
context.push('/products/${product.id}/edit'),
);
},
);
@@ -440,37 +440,37 @@ class _TabPanelState extends State<_TabPanel> {
],
),
const SizedBox(height: 18),
Row(
children: [
Text(
l10n.openTabs.toUpperCase(),
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
letterSpacing: 0.8,
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 2,
),
decoration: BoxDecoration(
color: scheme.onSurface.withValues(alpha: 0.06),
borderRadius: BorderRadius.circular(999),
),
child: Text(
'${filteredTabs.length}',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
],
),
//
// const SizedBox(height: 18),
//
// Row(
// children: [
// Text(
// l10n.openTabs.toUpperCase(),
// style: Theme.of(context).textTheme.bodySmall?.copyWith(
// fontWeight: FontWeight.w700,
// letterSpacing: 0.8,
// ),
// ),
// const SizedBox(width: 8),
// Container(
// padding: const EdgeInsets.symmetric(
// horizontal: 8,
// vertical: 2,
// ),
// decoration: BoxDecoration(
// color: scheme.onSurface.withValues(alpha: 0.06),
// borderRadius: BorderRadius.circular(999),
// ),
// child: Text(
// '${filteredTabs.length}',
// style: Theme.of(context).textTheme.bodySmall?.copyWith(
// fontWeight: FontWeight.w700,
// ),
// ),
// ),
// ],
// ),
const SizedBox(height: 8),
SizedBox(
+1
View File
@@ -68,6 +68,7 @@ class _DevMenuViewState extends State<DevMenuView> {
body: ListView(
padding: const EdgeInsets.symmetric(vertical: 12),
children: [
const Text("Hallo Matteo", style: TextStyle(color: Colors.pinkAccent),),
_Section(
title: l10n.dataManagement,
children: [
+59
View File
@@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import '../l10n/app_localizations.dart';
import '../utils/navigation.dart';
class ImportNamesView extends StatelessWidget {
const ImportNamesView({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final scheme = Theme.of(context).colorScheme;
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.popOrGo('/admin'),
icon: const Icon(Icons.arrow_back),
),
title: Text(l10n.importData),
),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 560),
child: Padding(
padding: const EdgeInsets.all(24),
child: Card(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.file_upload_outlined,
size: 48,
color: scheme.primary,
),
const SizedBox(height: 20),
Text(
l10n.importNames,
style: Theme.of(context).textTheme.titleLarge,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
l10n.importPlaceholder,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
),
),
);
}
}
+48 -6
View File
@@ -15,8 +15,20 @@ const _pinLength = 4;
class PinEntryView extends StatefulWidget {
final PinEntryMode mode;
final VoidCallback? onSuccess;
final bool isAdmin;
final Future<bool> Function(String)? onVerify;
final Future<bool> Function(String)? onSet;
final String? Function()? errorMessage;
const PinEntryView({super.key, required this.mode, this.onSuccess});
const PinEntryView({
super.key,
required this.mode,
this.onSuccess,
this.isAdmin = false,
this.onVerify,
this.onSet,
this.errorMessage,
});
@override
State<PinEntryView> createState() => _PinEntryViewState();
@@ -33,12 +45,22 @@ class _PinEntryViewState extends State<PinEntryView> {
bool get _isCreateFlow => widget.mode == PinEntryMode.create;
String _title(AppLocalizations l10n) {
if (widget.isAdmin) {
if (!_isCreateFlow) return l10n.enterAdminPin;
return _isConfirmStep ? l10n.confirmAdminPin : l10n.createAdminPin;
}
if (!_isCreateFlow) return l10n.enterPin;
return _isConfirmStep ? l10n.confirmPin : l10n.createPin;
}
String? _subtitle(AppLocalizations l10n) {
if (!_isCreateFlow) return null;
if (widget.isAdmin) {
return _isConfirmStep ? l10n.confirmPinSubtitle : l10n.adminPinSubtitle;
}
return _isConfirmStep ? l10n.confirmPinSubtitle : l10n.enterPinSubtitle;
}
@@ -64,7 +86,6 @@ class _PinEntryViewState extends State<PinEntryView> {
}
Future<void> _handleComplete() async {
final viewModel = context.read<PinLockViewModel>();
final l10n = AppLocalizations.of(context);
if (_isCreateFlow && !_isConfirmStep) {
@@ -90,7 +111,7 @@ class _PinEntryViewState extends State<PinEntryView> {
}
setState(() => _isSubmitting = true);
final ok = await viewModel.setPin(_digits);
final ok = await _setPin(_digits);
if (!mounted) return;
setState(() => _isSubmitting = false);
@@ -101,24 +122,45 @@ class _PinEntryViewState extends State<PinEntryView> {
_firstEntry = null;
_isConfirmStep = false;
});
_fail(l10n.localizedError(viewModel.errorMessage));
_fail(l10n.localizedError(_errorMessage()));
}
return;
}
// Unlock flow.
setState(() => _isSubmitting = true);
final ok = await viewModel.verify(_digits);
final ok = await _verify(_digits);
if (!mounted) return;
setState(() => _isSubmitting = false);
if (ok) {
widget.onSuccess?.call();
} else {
_fail(l10n.localizedError(viewModel.errorMessage));
_fail(l10n.localizedError(_errorMessage()));
}
}
Future<bool> _setPin(String pin) {
final callback = widget.onSet;
if (callback != null) return callback(pin);
return context.read<PinLockViewModel>().setPin(pin);
}
Future<bool> _verify(String pin) {
final callback = widget.onVerify;
if (callback != null) return callback(pin);
return context.read<PinLockViewModel>().verify(pin);
}
String? _errorMessage() {
final callback = widget.errorMessage;
if (callback != null) return callback();
return context.read<PinLockViewModel>().errorMessage;
}
void _fail(String message) {
setState(() {
_localError = message;
+1 -1
View File
@@ -34,7 +34,7 @@ class _ProductListViewState extends State<ProductListView> {
return Scaffold(
appBar: AppBar(
leading: IconButton(
onPressed: () => context.popOrGo('/bar'),
onPressed: () => context.popOrGo('/admin'),
icon: const Icon(Icons.arrow_back),
),
title: Text(l10n.products),
+67 -2
View File
@@ -8,6 +8,7 @@ import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
import '../models/settings.dart';
import '../viewmodels/admin_pin_view_model.dart';
import '../utils/app_update_util.dart';
import '../utils/navigation.dart';
import '../viewmodels/pin_lock_view_model.dart';
@@ -33,6 +34,7 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SettingsViewModel>().ensureLoaded();
context.read<PinLockViewModel>().ensureLoaded();
context.read<AdminPinViewModel>().ensureLoaded();
_loadVersion();
});
}
@@ -190,11 +192,62 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
}
}
Future<void> _configureAdminPin() async {
final l10n = AppLocalizations.of(context);
final adminPinViewModel = context.read<AdminPinViewModel>();
if (!adminPinViewModel.isPinSet) {
final pin = await _promptPin(l10n.setAdminPin, hint: l10n.fourDigits);
if (pin == null) return;
if (pin.length != 4) {
_showError(l10n.pinExactlyFour);
return;
}
final success = await adminPinViewModel.setPin(pin);
if (!success) {
_showError(l10n.localizedError(adminPinViewModel.errorMessage));
return;
}
adminPinViewModel.lock();
return;
}
final current = await _promptPin(l10n.enterCurrentAdminPin);
if (current == null) return;
final newPin = await _promptPin(
l10n.enterNewAdminPin,
hint: l10n.fourDigits,
);
if (newPin == null) return;
if (newPin.length != 4) {
_showError(l10n.pinExactlyFour);
return;
}
final success = await adminPinViewModel.changePin(
currentPin: current,
newPin: newPin,
);
if (!success) {
_showError(l10n.localizedError(adminPinViewModel.errorMessage));
return;
}
adminPinViewModel.lock();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final settingsViewModel = context.watch<SettingsViewModel>();
final pinLockViewModel = context.watch<PinLockViewModel>();
final adminPinViewModel = context.watch<AdminPinViewModel>();
return Scaffold(
appBar: AppBar(
@@ -207,9 +260,13 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
body: Builder(
builder: (context) {
final isLoading =
settingsViewModel.isLoading || pinLockViewModel.isLoading;
settingsViewModel.isLoading ||
pinLockViewModel.isLoading ||
adminPinViewModel.isLoading;
final hasLoaded =
settingsViewModel.hasLoaded && pinLockViewModel.hasLoaded;
settingsViewModel.hasLoaded &&
pinLockViewModel.hasLoaded &&
adminPinViewModel.hasLoaded;
if (isLoading && !hasLoaded) {
return const Center(
@@ -251,6 +308,14 @@ class _SettingsScreenViewState extends State<SettingsScreenView> {
title: l10n.changePin,
onTap: _changePin,
),
_SettingsTile(
icon: Icons.admin_panel_settings_outlined,
title: l10n.adminPin,
subtitle: adminPinViewModel.isPinSet
? l10n.adminPinConfigured
: l10n.adminPinNotSet,
onTap: _configureAdminPin,
),
],
),
_SettingsSection(