From 3513281a11a3c9a5fa9c2cbea3debbc65ed2b8ad Mon Sep 17 00:00:00 2001 From: Suriya Date: Sat, 1 Aug 2026 13:10:49 +0530 Subject: [PATCH] Build staff management, store details editing, and a forced PIN change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the last two dead buttons in Settings and closes the loop on the credential work: hashed PINs are only worth having if a shop can actually change them. Users & roles (Manage) - Add, rename, re-role and remove staff. Admin-only at the door, because anyone who can edit staff can make themselves an admin. - PIN and confirmation are both required and must match. There is no email to reset a PIN with, so a typo nobody can verify locks the account out until an admin intervenes. - Editing someone leaves their PIN alone unless a new one is typed. An admin setting another person's PIN counts as a reset and re-arms must-change. - Removal is a deactivation with a confirmation that explains why: bills already rung keep the cashier's name, so shift reports stay correct. - Anyone still on a shipped PIN is flagged in the list and in Settings. Store details (Edit) - Name, address, GSTIN and phone now editable and persisted. GSTIN is format and state-code validated; it prints on every invoice as a legal requirement, so a typo is a compliance problem across hundreds of bills. - Admin-only: changing the GSTIN changes what every future invoice claims about who collected the tax. Forced PIN change - Shown once after sign-in while must-change is set, and not dismissable. The seeded PINs are in the source of the build, so a terminal still running one is effectively unprotected. Fixed while testing: the role dropdown laid its items out at natural width and "Manager — Sales, inventory and reports" overflowed the dialog by 222px. Now isExpanded with the description spelled out below, where it is readable. Tests: 160 -> 168. Covers both role guards, the mismatched and too-short PIN paths, the default-PIN flag, and GSTIN and seller-name validation. Co-Authored-By: Claude Opus 5 --- .../modules/screens/settings_view.dart | 22 +- .../modules/widgets/staff_dialogs.dart | 538 ++++++++++++++++++ .../modules/widgets/store_details_dialog.dart | 180 ++++++ .../pos/screens/pos_dashboard_screen.dart | 18 + test/widget/admin_dialogs_test.dart | 198 +++++++ 5 files changed, 950 insertions(+), 6 deletions(-) create mode 100644 lib/presentation/modules/widgets/staff_dialogs.dart create mode 100644 lib/presentation/modules/widgets/store_details_dialog.dart create mode 100644 test/widget/admin_dialogs_test.dart diff --git a/lib/presentation/modules/screens/settings_view.dart b/lib/presentation/modules/screens/settings_view.dart index 3055765..690313a 100644 --- a/lib/presentation/modules/screens/settings_view.dart +++ b/lib/presentation/modules/screens/settings_view.dart @@ -12,6 +12,8 @@ import '../../../domain/entities/store_account.dart'; import '../../auth/providers/auth_controller.dart'; import '../providers/printer_settings.dart'; import '../widgets/back_office_dialog.dart'; +import '../widgets/staff_dialogs.dart'; +import '../widgets/store_details_dialog.dart'; import '../../sync/providers/sync_controller.dart'; import '../widgets/module_widgets.dart'; @@ -82,7 +84,10 @@ class _SettingsViewState extends ConsumerState { Widget _storeCard(StoreAccount? store) => PanelCard( title: 'Store details', subtitle: 'Printed on every invoice', - action: TextButton(onPressed: () {}, child: const Text('Edit')), + action: TextButton( + onPressed: () => showStoreDetailsDialog(context), + child: const Text('Edit'), + ), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -296,7 +301,10 @@ class _SettingsViewState extends ConsumerState { Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard( title: 'Users & roles', - action: TextButton(onPressed: () {}, child: const Text('Manage')), + action: TextButton( + onPressed: () => showStaffDialog(context), + child: const Text('Manage'), + ), child: ResponsiveTable( stackBelow: 360, columns: const [ @@ -308,10 +316,12 @@ class _SettingsViewState extends ConsumerState { .map((s) => [ Cell(s.name, bold: true), Cell(s.role.label, color: AppColors.textSecondary), - s.id == current?.id - ? const TagChip('Signed in', - color: AppColors.success,) - : const SizedBox.shrink(), + if (s.id == current?.id) + const TagChip('Signed in', color: AppColors.success) + else if (s.mustChangePin) + const TagChip('Default PIN', color: AppColors.warning) + else + const SizedBox.shrink(), ],) .toList(), ), diff --git a/lib/presentation/modules/widgets/staff_dialogs.dart b/lib/presentation/modules/widgets/staff_dialogs.dart new file mode 100644 index 0000000..01ccec4 --- /dev/null +++ b/lib/presentation/modules/widgets/staff_dialogs.dart @@ -0,0 +1,538 @@ +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, + ), + ], + ), + ); + } +} diff --git a/lib/presentation/modules/widgets/store_details_dialog.dart b/lib/presentation/modules/widgets/store_details_dialog.dart new file mode 100644 index 0000000..3f61763 --- /dev/null +++ b/lib/presentation/modules/widgets/store_details_dialog.dart @@ -0,0 +1,180 @@ +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/repositories/store_repository_impl.dart'; +import '../../../domain/entities/store_account.dart'; +import '../../auth/providers/auth_controller.dart'; + +/// Edits what gets printed at the top of every invoice. +/// +/// These were compile-time constants, so a shop could only correct its own +/// address or GSTIN by having the app rebuilt — on a GST invoice those fields +/// are a legal requirement, not decoration. +Future showStoreDetailsDialog(BuildContext context) => showDialog( + context: context, + builder: (_) => const _StoreDetailsDialog(), + ); + +class _StoreDetailsDialog extends ConsumerStatefulWidget { + const _StoreDetailsDialog(); + + @override + ConsumerState<_StoreDetailsDialog> createState() => + _StoreDetailsDialogState(); +} + +class _StoreDetailsDialogState extends ConsumerState<_StoreDetailsDialog> { + final _formKey = GlobalKey(); + late final TextEditingController _name; + late final TextEditingController _address; + late final TextEditingController _gstin; + late final TextEditingController _phone; + + bool _saving = false; + + @override + void initState() { + super.initState(); + final store = ref.read(currentStoreProvider); + _name = TextEditingController(text: store?.name ?? ''); + _address = TextEditingController(text: store?.address ?? ''); + _gstin = TextEditingController(text: store?.gstin ?? ''); + _phone = TextEditingController(text: store?.phone ?? ''); + } + + @override + void dispose() { + for (final c in [_name, _address, _gstin, _phone]) { + c.dispose(); + } + super.dispose(); + } + + Future _save() async { + if (!(_formKey.currentState?.validate() ?? false)) return; + setState(() => _saving = true); + + await ref.read(storeRepositoryProvider).save( + name: _name.text, + address: _address.text, + gstin: _gstin.text, + phone: _phone.text, + ); + + await ref.read(authControllerProvider.notifier).refreshStore(); + if (mounted) Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final me = ref.watch(currentUserProvider); + + // Changing the GSTIN on a live till changes what every future invoice + // claims about who collected the tax. + if (me?.role != StaffRole.admin) { + return AlertDialog( + title: const Text('Store details'), + content: const Text( + 'Only an admin can change the details printed on invoices.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Close'), + ), + ], + ); + } + + return AlertDialog( + title: const Text('Store details'), + content: SizedBox( + width: 480, + child: Form( + key: _formKey, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Text( + 'Printed at the top of every invoice.', + style: TextStyle( + fontSize: 12.5, + color: AppColors.textSecondary, + ), + ), + const SizedBox(height: AppSpacing.lg), + + TextFormField( + controller: _name, + autofocus: true, + decoration: const InputDecoration(labelText: 'Store name'), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'An invoice must name the seller' + : null, + ), + const SizedBox(height: AppSpacing.md), + + TextFormField( + controller: _address, + maxLines: 2, + decoration: const InputDecoration(labelText: 'Address'), + validator: (v) => (v == null || v.trim().isEmpty) + ? 'An invoice must carry the place of supply' + : null, + ), + const SizedBox(height: AppSpacing.md), + + TextFormField( + controller: _gstin, + textCapitalization: TextCapitalization.characters, + inputFormatters: [ + LengthLimitingTextInputFormatter(15), + FilteringTextInputFormatter.allow(RegExp('[0-9a-zA-Z]')), + ], + decoration: const InputDecoration( + labelText: 'GSTIN', + hintText: '33AABCU9603R1ZM', + ), + validator: GstinValidator.validate, + ), + const SizedBox(height: AppSpacing.md), + + TextFormField( + controller: _phone, + keyboardType: TextInputType.phone, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(10), + ], + decoration: const InputDecoration(labelText: 'Phone'), + validator: (v) => (v == null || v.trim().length != 10) + ? 'Ten digits' + : null, + ), + ], + ), + ), + ), + ), + actions: [ + TextButton( + onPressed: _saving ? null : () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + PrimaryButton( + label: 'Save', + expanded: false, + busy: _saving, + onPressed: _save, + ), + ], + ); + } +} diff --git a/lib/presentation/pos/screens/pos_dashboard_screen.dart b/lib/presentation/pos/screens/pos_dashboard_screen.dart index 6b461fa..f2e4c8a 100644 --- a/lib/presentation/pos/screens/pos_dashboard_screen.dart +++ b/lib/presentation/pos/screens/pos_dashboard_screen.dart @@ -6,11 +6,13 @@ import '../../../core/services/barcode_service.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_layout.dart'; +import '../../auth/providers/auth_controller.dart'; import '../../modules/screens/customers_view.dart'; import '../../modules/screens/events_view.dart'; import '../../modules/screens/product_import_view.dart'; import '../../modules/screens/promos_view.dart'; import '../../modules/screens/settings_view.dart'; +import '../../modules/widgets/staff_dialogs.dart'; import '../../sync/providers/sync_controller.dart'; import '../providers/cart_controller.dart'; import '../providers/catalog_providers.dart'; @@ -45,10 +47,18 @@ class _PosDashboardScreenState extends ConsumerState { final FocusNode _searchFocus = FocusNode(); late final BarcodeService _barcode; + /// Guards against re-opening the PIN dialog on every rebuild. + bool _promptedForPin = false; + @override void initState() { super.initState(); + // Anyone still on a PIN this build shipped with is asked to choose their + // own before ringing a sale. Deferred to the first frame because it opens + // a dialog, which needs a Navigator that exists. + WidgetsBinding.instance.addPostFrameCallback((_) => _maybePromptForPin()); + // The scanner behaves like a keyboard, so listen globally rather than // depending on any one field holding focus. A scan from another module // jumps back to billing, which is what a cashier expects. @@ -65,6 +75,14 @@ class _PosDashboardScreenState extends ConsumerState { )..attach(); } + Future _maybePromptForPin() async { + if (_promptedForPin || !mounted) return; + if (!ref.read(mustChangePinProvider)) return; + + _promptedForPin = true; + await showForcedPinChange(context); + } + @override void dispose() { _barcode.dispose(); diff --git a/test/widget/admin_dialogs_test.dart b/test/widget/admin_dialogs_test.dart new file mode 100644 index 0000000..031a478 --- /dev/null +++ b/test/widget/admin_dialogs_test.dart @@ -0,0 +1,198 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:nearle_pos/app/providers.dart'; +import 'package:nearle_pos/data/datasources/local_store.dart'; +import 'package:nearle_pos/data/datasources/seed_data.dart'; +import 'package:nearle_pos/domain/entities/store_account.dart'; +import 'package:nearle_pos/presentation/auth/providers/auth_controller.dart'; +import 'package:nearle_pos/presentation/modules/widgets/staff_dialogs.dart'; +import 'package:nearle_pos/presentation/modules/widgets/store_details_dialog.dart'; + +/// The two admin screens that change what a till is and who may use it. +/// +/// Both guard on role, and both refuse input that would break something a shop +/// cannot recover from on its own — a locked-out account, or a GSTIN that is +/// wrong on every invoice printed after it. +void main() { + setUpAll(() { + GoogleFonts.config.allowRuntimeFetching = false; + LocalStore.registerSeed( + products: SeedData.products, + customers: SeedData.customers, + ); + }); + + setUp(() async { + await LocalStore.instance.reset(withCatalogue: true); + }); + + const admin = StaffUser(id: 'u1', name: 'Suriya', role: StaffRole.admin); + const cashier = StaffUser(id: 'u3', name: 'Rahul', role: StaffRole.cashier); + + StoreAccount storeWith(List staff) => StoreAccount( + id: 'store-001', + name: 'Nearle Daily', + email: 'admin@nearle.in', + address: '1 Test Street', + gstin: '33AABCU9603R1ZM', + phone: '9840000000', + staff: staff, + ); + + /// Mounts a dialog with [who] signed in. + Future open( + WidgetTester tester, { + required StaffUser who, + required Future Function(BuildContext) show, + List staff = const [admin, cashier], + }) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + storeAccountProvider.overrideWith((ref) async => storeWith(staff)), + authControllerProvider.overrideWith( + (ref) => _StubAuth(storeWith(staff), who), + ), + ], + child: MaterialApp( + home: Builder( + builder: (context) => Scaffold( + body: TextButton( + onPressed: () => show(context), + child: const Text('open'), + ), + ), + ), + ), + ), + ); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + } + + group('staff', () { + testWidgets('a cashier is refused', (tester) async { + // Anyone who can edit staff can make themselves an admin, so the check + // has to be at the door rather than on each action. + await open(tester, who: cashier, show: showStaffDialog); + + expect(find.textContaining('Only an admin'), findsOneWidget); + expect(find.text('Add staff member'), findsNothing); + }); + + testWidgets('an admin can manage staff', (tester) async { + await open(tester, who: admin, show: showStaffDialog); + + expect(find.text('Add staff member'), findsOneWidget); + expect(find.text('Suriya'), findsOneWidget); + expect(find.text('Rahul'), findsOneWidget); + }); + + testWidgets('a mistyped confirmation is caught before it locks an account', + (tester) async { + // There is no email to reset a PIN with. A typo nobody can verify means + // the account is simply gone until an admin resets it. + await open(tester, who: admin, show: showStaffDialog); + + await tester.tap(find.text('Add staff member')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField).first, 'Meena'); + await tester.enterText(find.byType(TextFormField).at(1), '7391'); + await tester.enterText(find.byType(TextFormField).at(2), '7392'); + + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + expect(find.text('The two PINs do not match'), findsOneWidget); + }); + + testWidgets('a too-short PIN is refused', (tester) async { + await open(tester, who: admin, show: showStaffDialog); + await tester.tap(find.text('Add staff member')); + await tester.pumpAndSettle(); + + await tester.enterText(find.byType(TextFormField).first, 'Meena'); + await tester.enterText(find.byType(TextFormField).at(1), '73'); + await tester.enterText(find.byType(TextFormField).at(2), '73'); + + await tester.tap(find.text('Add')); + await tester.pumpAndSettle(); + + expect(find.text('At least four digits'), findsOneWidget); + }); + + testWidgets('an account still on a shipped PIN is flagged', (tester) async { + await open( + tester, + who: admin, + show: showStaffDialog, + staff: const [ + admin, + StaffUser( + id: 'u9', + name: 'Newbie', + role: StaffRole.cashier, + mustChangePin: true, + ), + ], + ); + + expect(find.byIcon(Icons.warning_amber_rounded), findsOneWidget); + }); + }); + + group('store details', () { + testWidgets('a cashier cannot change what invoices claim', (tester) async { + await open(tester, who: cashier, show: showStoreDetailsDialog); + expect(find.textContaining('Only an admin'), findsOneWidget); + }); + + testWidgets('a malformed GSTIN is refused', (tester) async { + // It prints on every invoice as a legal requirement, so a typo is a + // compliance problem across hundreds of bills before anyone notices. + await open(tester, who: admin, show: showStoreDetailsDialog); + + await tester.enterText(find.byType(TextFormField).at(2), '99AABCU9603R1ZM'); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect( + find.text('The first two digits are not a valid state code.'), + findsOneWidget, + ); + }); + + testWidgets('an empty seller name is refused', (tester) async { + await open(tester, who: admin, show: showStoreDetailsDialog); + + await tester.enterText(find.byType(TextFormField).first, ''); + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + + expect(find.text('An invoice must name the seller'), findsOneWidget); + }); + }); +} + +/// Holds a fixed session so a test can choose who is signed in. +class _StubAuth extends AuthController { + _StubAuth(StoreAccount store, StaffUser user) : super(_throwingRef) { + state = Authenticated(store: store, user: user); + } + + @override + Future refreshStore() async {} +} + +/// The stub never reaches the real container, so a Ref is never used. +final Ref _throwingRef = _UnusedRef(); + +class _UnusedRef implements Ref { + @override + dynamic noSuchMethod(Invocation invocation) => + throw UnsupportedError('The stubbed AuthController does not read providers.'); +}