Build staff management, store details editing, and a forced PIN change
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<SettingsView> {
|
||||
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<SettingsView> {
|
||||
|
||||
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<SettingsView> {
|
||||
.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(),
|
||||
),
|
||||
|
||||
538
lib/presentation/modules/widgets/staff_dialogs.dart
Normal file
538
lib/presentation/modules/widgets/staff_dialogs.dart
Normal file
@@ -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<void> showStaffDialog(BuildContext context) => showDialog<void>(
|
||||
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<void> _confirmDeactivate(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
StaffUser user,
|
||||
) async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
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<void> _openEditor(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
StaffUser? existing,
|
||||
) =>
|
||||
showDialog<void>(
|
||||
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<FormState>();
|
||||
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<void> _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<StaffRole>(
|
||||
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<void> showForcedPinChange(BuildContext context) => showDialog<void>(
|
||||
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<FormState>();
|
||||
final _pin = TextEditingController();
|
||||
final _confirm = TextEditingController();
|
||||
bool _saving = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pin.dispose();
|
||||
_confirm.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
180
lib/presentation/modules/widgets/store_details_dialog.dart
Normal file
180
lib/presentation/modules/widgets/store_details_dialog.dart
Normal file
@@ -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<void> showStoreDetailsDialog(BuildContext context) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const _StoreDetailsDialog(),
|
||||
);
|
||||
|
||||
class _StoreDetailsDialog extends ConsumerStatefulWidget {
|
||||
const _StoreDetailsDialog();
|
||||
|
||||
@override
|
||||
ConsumerState<_StoreDetailsDialog> createState() =>
|
||||
_StoreDetailsDialogState();
|
||||
}
|
||||
|
||||
class _StoreDetailsDialogState extends ConsumerState<_StoreDetailsDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
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<void> _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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<PosDashboardScreen> {
|
||||
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<PosDashboardScreen> {
|
||||
)..attach();
|
||||
}
|
||||
|
||||
Future<void> _maybePromptForPin() async {
|
||||
if (_promptedForPin || !mounted) return;
|
||||
if (!ref.read(mustChangePinProvider)) return;
|
||||
|
||||
_promptedForPin = true;
|
||||
await showForcedPinChange(context);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_barcode.dispose();
|
||||
|
||||
198
test/widget/admin_dialogs_test.dart
Normal file
198
test/widget/admin_dialogs_test.dart
Normal file
@@ -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<StaffUser> 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<void> open(
|
||||
WidgetTester tester, {
|
||||
required StaffUser who,
|
||||
required Future<void> Function(BuildContext) show,
|
||||
List<StaffUser> 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<void> 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.');
|
||||
}
|
||||
Reference in New Issue
Block a user