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:
Suriya
2026-08-01 13:10:49 +05:30
parent e17937e8f1
commit 3513281a11
5 changed files with 950 additions and 6 deletions

View File

@@ -12,6 +12,8 @@ import '../../../domain/entities/store_account.dart';
import '../../auth/providers/auth_controller.dart'; import '../../auth/providers/auth_controller.dart';
import '../providers/printer_settings.dart'; import '../providers/printer_settings.dart';
import '../widgets/back_office_dialog.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 '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart'; import '../widgets/module_widgets.dart';
@@ -82,7 +84,10 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
Widget _storeCard(StoreAccount? store) => PanelCard( Widget _storeCard(StoreAccount? store) => PanelCard(
title: 'Store details', title: 'Store details',
subtitle: 'Printed on every invoice', subtitle: 'Printed on every invoice',
action: TextButton(onPressed: () {}, child: const Text('Edit')), action: TextButton(
onPressed: () => showStoreDetailsDialog(context),
child: const Text('Edit'),
),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -296,7 +301,10 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard( Widget _staffCard(StoreAccount? store, StaffUser? current) => PanelCard(
title: 'Users & roles', title: 'Users & roles',
action: TextButton(onPressed: () {}, child: const Text('Manage')), action: TextButton(
onPressed: () => showStaffDialog(context),
child: const Text('Manage'),
),
child: ResponsiveTable( child: ResponsiveTable(
stackBelow: 360, stackBelow: 360,
columns: const [ columns: const [
@@ -308,10 +316,12 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
.map((s) => [ .map((s) => [
Cell(s.name, bold: true), Cell(s.name, bold: true),
Cell(s.role.label, color: AppColors.textSecondary), Cell(s.role.label, color: AppColors.textSecondary),
s.id == current?.id if (s.id == current?.id)
? const TagChip('Signed in', const TagChip('Signed in', color: AppColors.success)
color: AppColors.success,) else if (s.mustChangePin)
: const SizedBox.shrink(), const TagChip('Default PIN', color: AppColors.warning)
else
const SizedBox.shrink(),
],) ],)
.toList(), .toList(),
), ),

View 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,
),
],
),
);
}
}

View 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,
),
],
);
}
}

View File

@@ -6,11 +6,13 @@ import '../../../core/services/barcode_service.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart'; import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_layout.dart'; import '../../../core/theme/app_layout.dart';
import '../../auth/providers/auth_controller.dart';
import '../../modules/screens/customers_view.dart'; import '../../modules/screens/customers_view.dart';
import '../../modules/screens/events_view.dart'; import '../../modules/screens/events_view.dart';
import '../../modules/screens/product_import_view.dart'; import '../../modules/screens/product_import_view.dart';
import '../../modules/screens/promos_view.dart'; import '../../modules/screens/promos_view.dart';
import '../../modules/screens/settings_view.dart'; import '../../modules/screens/settings_view.dart';
import '../../modules/widgets/staff_dialogs.dart';
import '../../sync/providers/sync_controller.dart'; import '../../sync/providers/sync_controller.dart';
import '../providers/cart_controller.dart'; import '../providers/cart_controller.dart';
import '../providers/catalog_providers.dart'; import '../providers/catalog_providers.dart';
@@ -45,10 +47,18 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
final FocusNode _searchFocus = FocusNode(); final FocusNode _searchFocus = FocusNode();
late final BarcodeService _barcode; late final BarcodeService _barcode;
/// Guards against re-opening the PIN dialog on every rebuild.
bool _promptedForPin = false;
@override @override
void initState() { void initState() {
super.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 // The scanner behaves like a keyboard, so listen globally rather than
// depending on any one field holding focus. A scan from another module // depending on any one field holding focus. A scan from another module
// jumps back to billing, which is what a cashier expects. // jumps back to billing, which is what a cashier expects.
@@ -65,6 +75,14 @@ class _PosDashboardScreenState extends ConsumerState<PosDashboardScreen> {
)..attach(); )..attach();
} }
Future<void> _maybePromptForPin() async {
if (_promptedForPin || !mounted) return;
if (!ref.read(mustChangePinProvider)) return;
_promptedForPin = true;
await showForcedPinChange(context);
}
@override @override
void dispose() { void dispose() {
_barcode.dispose(); _barcode.dispose();

View 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.');
}