feat: add admin page
This commit is contained in:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user