import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/widgets/primary_button.dart'; import '../../../data/local/staff_dao.dart'; import '../../../domain/entities/store_account.dart'; import '../../auth/providers/auth_controller.dart'; /// Manage who can sign in at this till. Future showStaffDialog(BuildContext context) => showDialog( context: context, builder: (_) => const _StaffDialog(), ); class _StaffDialog extends ConsumerWidget { const _StaffDialog(); @override Widget build(BuildContext context, WidgetRef ref) { final me = ref.watch(currentUserProvider); final storeAsync = ref.watch(storeAccountProvider); // Only an admin can change who works here, or what they may do. if (me?.role != StaffRole.admin) { return AlertDialog( title: const Text('Users & roles'), content: const Text( 'Only an admin can add or change staff accounts. Ask a manager to ' 'sign in first.', ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Close'), ), ], ); } return AlertDialog( title: const Text('Users & roles'), content: SizedBox( width: 560, child: storeAsync.when( loading: () => const Center(child: CircularProgressIndicator()), error: (e, _) => Text('Could not load staff: $e'), data: (store) => SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final user in store.staff) _StaffRow(user: user, isMe: user.id == me?.id), const SizedBox(height: AppSpacing.md), OutlinedButton.icon( onPressed: () => _openEditor(context, ref, null), icon: const Icon(Icons.person_add_alt_1_rounded, size: 17), label: const Text('Add staff member'), ), ], ), ), ), ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Done'), ), ], ); } } class _StaffRow extends ConsumerWidget { const _StaffRow({required this.user, required this.isMe}); final StaffUser user; final bool isMe; @override Widget build(BuildContext context, WidgetRef ref) { return Padding( padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs), child: Row( children: [ Expanded( flex: 4, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( user.name, style: const TextStyle(fontWeight: FontWeight.w600), ), Text( user.role.label, style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ], ), ), if (user.mustChangePin) const Tooltip( message: 'Still on the PIN this terminal was set up with', child: Icon(Icons.warning_amber_rounded, size: 17, color: AppColors.warning,), ), if (isMe) const Padding( padding: EdgeInsets.symmetric(horizontal: AppSpacing.sm), child: Text( 'Signed in', style: TextStyle(fontSize: 11, color: AppColors.success), ), ), IconButton( tooltip: 'Edit', icon: const Icon(Icons.edit_outlined, size: 17), onPressed: () => _openEditor(context, ref, user), ), IconButton( tooltip: 'Remove', icon: const Icon(Icons.person_off_outlined, size: 17), color: AppColors.danger, onPressed: () => _confirmDeactivate(context, ref, user), ), ], ), ); } } Future _confirmDeactivate( BuildContext context, WidgetRef ref, StaffUser user, ) async { final confirmed = await showDialog( context: context, builder: (dialogContext) => AlertDialog( title: Text('Remove ${user.name}?'), content: const Text( 'They will no longer be able to sign in. Bills they have already rung ' 'keep their name, so shift reports stay correct.', ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(false), child: const Text('Cancel'), ), TextButton( onPressed: () => Navigator.of(dialogContext).pop(true), style: TextButton.styleFrom(foregroundColor: AppColors.danger), child: const Text('Remove'), ), ], ), ); if (confirmed != true || !context.mounted) return; try { await ref.read(localStoreProvider).staff.deactivate(user.id); await ref.read(authControllerProvider.notifier).refreshStore(); } on StaffException catch (e) { if (context.mounted) _showError(context, e.message); } } Future _openEditor( BuildContext context, WidgetRef ref, StaffUser? existing, ) => showDialog( context: context, builder: (_) => _StaffEditor(existing: existing), ); void _showError(BuildContext context, String message) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar(SnackBar( backgroundColor: AppColors.danger, content: Text(message), ),); } /// Add a staff member, or change one's name, role or PIN. class _StaffEditor extends ConsumerStatefulWidget { const _StaffEditor({this.existing}); final StaffUser? existing; @override ConsumerState<_StaffEditor> createState() => _StaffEditorState(); } class _StaffEditorState extends ConsumerState<_StaffEditor> { final _formKey = GlobalKey(); late final TextEditingController _name; final _pin = TextEditingController(); final _confirmPin = TextEditingController(); late StaffRole _role; bool _saving = false; String? _error; bool get _isNew => widget.existing == null; @override void initState() { super.initState(); _name = TextEditingController(text: widget.existing?.name ?? ''); _role = widget.existing?.role ?? StaffRole.cashier; } @override void dispose() { _name.dispose(); _pin.dispose(); _confirmPin.dispose(); super.dispose(); } Future _save() async { if (!(_formKey.currentState?.validate() ?? false)) return; setState(() { _saving = true; _error = null; }); final staff = ref.read(localStoreProvider).staff; try { if (_isNew) { await staff.create( name: _name.text, role: _role, pin: _pin.text, ); } else { await staff.updateDetails( id: widget.existing!.id, name: _name.text, role: _role, ); // Blank means "leave it alone" — an admin editing a role should not be // forced to know or reset someone's PIN. if (_pin.text.isNotEmpty) { await staff.setPin( widget.existing!.id, _pin.text, // An admin setting someone else's PIN is a reset, so the person is // asked to choose their own at next sign-in. mustChangePin: widget.existing!.id != ref.read(currentUserProvider)?.id, ); } } await ref.read(authControllerProvider.notifier).refreshStore(); if (mounted) Navigator.of(context).pop(); } on StaffException catch (e) { setState(() { _saving = false; _error = e.message; }); } } @override Widget build(BuildContext context) { return AlertDialog( title: Text(_isNew ? 'Add staff member' : 'Edit ${widget.existing!.name}'), content: SizedBox( width: 420, child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextFormField( controller: _name, autofocus: true, decoration: const InputDecoration(labelText: 'Name'), validator: (v) => (v == null || v.trim().isEmpty) ? 'A staff member needs a name' : null, ), const SizedBox(height: AppSpacing.md), DropdownButtonFormField( initialValue: _role, // Without this the item is laid out at its natural width and // "Manager — Sales, inventory and reports" runs 222px past the // edge of the dialog. isExpanded: true, decoration: const InputDecoration(labelText: 'Role'), items: [ for (final role in StaffRole.values) DropdownMenuItem( value: role, child: Text( '${role.label} — ${role.description}', overflow: TextOverflow.ellipsis, ), ), ], onChanged: (v) => setState(() => _role = v ?? _role), ), // Spelled out below rather than squeezed into the dropdown, so // the permissions being granted are actually readable. Padding( padding: const EdgeInsets.only(top: AppSpacing.xs), child: Text( _role.description, style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, ), ), ), const SizedBox(height: AppSpacing.md), TextFormField( controller: _pin, obscureText: true, keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6), ], decoration: InputDecoration( labelText: _isNew ? 'PIN' : 'New PIN', helperText: _isNew ? 'At least four digits, and not guessable across a ' 'counter' : 'Leave blank to keep the current PIN', ), validator: (v) { final text = v ?? ''; if (_isNew && text.isEmpty) return 'A PIN is required'; if (text.isNotEmpty && text.length < 4) { return 'At least four digits'; } return null; }, ), const SizedBox(height: AppSpacing.md), TextFormField( controller: _confirmPin, obscureText: true, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration(labelText: 'Confirm PIN'), validator: (v) { // A mistyped PIN nobody can verify locks the account out — // there is no email to reset it with. if (_pin.text.isEmpty) return null; return v != _pin.text ? 'The two PINs do not match' : null; }, ), if (_error != null) ...[ const SizedBox(height: AppSpacing.md), Text( _error!, style: const TextStyle( color: AppColors.danger, fontSize: 12.5, ), ), ], ], ), ), ), actions: [ TextButton( onPressed: _saving ? null : () => Navigator.of(context).pop(), child: const Text('Cancel'), ), PrimaryButton( label: _isNew ? 'Add' : 'Save', expanded: false, busy: _saving, onPressed: _save, ), ], ); } } /// Forces someone off a PIN they did not choose. /// /// Shown after sign-in while [StaffUser.mustChangePin] is set. Not dismissable: /// the seeded PINs are in the source of an open-source build, so a shop still /// running one is effectively unprotected. Future showForcedPinChange(BuildContext context) => showDialog( context: context, barrierDismissible: false, builder: (_) => const _ForcedPinChange(), ); class _ForcedPinChange extends ConsumerStatefulWidget { const _ForcedPinChange(); @override ConsumerState<_ForcedPinChange> createState() => _ForcedPinChangeState(); } class _ForcedPinChangeState extends ConsumerState<_ForcedPinChange> { final _formKey = GlobalKey(); final _pin = TextEditingController(); final _confirm = TextEditingController(); bool _saving = false; String? _error; @override void dispose() { _pin.dispose(); _confirm.dispose(); super.dispose(); } Future _save() async { if (!(_formKey.currentState?.validate() ?? false)) return; setState(() { _saving = true; _error = null; }); final me = ref.read(currentUserProvider); if (me == null) return; try { await ref.read(localStoreProvider).staff.setPin(me.id, _pin.text); await ref.read(authControllerProvider.notifier).refreshStore(); if (mounted) Navigator.of(context).pop(); } on StaffException catch (e) { setState(() { _saving = false; _error = e.message; }); } } @override Widget build(BuildContext context) { final me = ref.watch(currentUserProvider); return PopScope( canPop: false, child: AlertDialog( title: const Text('Choose your PIN'), content: SizedBox( width: 420, child: Form( key: _formKey, child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Text( '${me?.name ?? 'This account'} is still using the PIN this ' 'terminal was set up with. Those are the same on every new ' 'install, so please pick your own before ringing a sale.', style: const TextStyle( fontSize: 13, height: 1.45, color: AppColors.textSecondary, ), ), const SizedBox(height: AppSpacing.lg), TextFormField( controller: _pin, autofocus: true, obscureText: true, keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(6), ], decoration: const InputDecoration(labelText: 'New PIN'), validator: (v) => (v == null || v.length < 4) ? 'At least four digits' : null, ), const SizedBox(height: AppSpacing.md), TextFormField( controller: _confirm, obscureText: true, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], decoration: const InputDecoration(labelText: 'Confirm PIN'), validator: (v) => v != _pin.text ? 'The two PINs do not match' : null, ), if (_error != null) ...[ const SizedBox(height: AppSpacing.md), Text( _error!, style: const TextStyle( color: AppColors.danger, fontSize: 12.5, ), ), ], ], ), ), ), actions: [ PrimaryButton( label: 'Set PIN', expanded: false, busy: _saving, onPressed: _save, ), ], ), ); } }