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 '../../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(),
|
||||||
),
|
),
|
||||||
|
|||||||
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_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();
|
||||||
|
|||||||
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