The role split was right; only its source was wrong. Signing in matched what was typed against two constants compiled into the app — admin@nearle.in and cashier@nearle.in — so which shell a person got was a property of the *build*. A shop could not add a third person, revoke either of the two it had, or stop anyone with the APK reading both passwords out of it. TerminalLogin survives unchanged in shape, because the shape was the good part: one flag the shell reads, a session that decides it, and a cashier sign-out that takes the catalogue with it while a supervisor's leaves it behind. Every consumer — visibleModulesProvider, resolvedModuleProvider, the sidebar, the page header, the sign-out dialog — is untouched. What changed is that the enum is now only constructible from a session the back office signed, so there is no path left where the terminal grants itself a permission the server did not send. It reads `can_manage_staff` rather than the role name or id. app_roles holds six rows for four distinct roles, a great many accounts carry a roleid that is not in the table at all, and the name comes back blank for most of them. Matching on either would mean shipping a copy of the role table in the app and keeping the two in step for ever. One boolean, decided server-side, cannot drift. It defaults to false, which matters on the restore path: a session saved by a build that predates the field comes back as a cashier, never silently as an admin. This also restores the sign-in layer itself — pos_auth_api, pos_session, session_store, the staff import and the bearer token — which an earlier commit removed wholesale from a stale checkout. Its parent was the commit that added them, so the deletion was a bad merge rather than a decision; the terminal has been running on the two constants since. The login screen loses its role tabs and its credential prefill. You do not choose what you are on the way in. The opener is now matched on the back office user id rather than on the first account with a matching role, so the first bill of a shift is attributed to whoever actually signed in. Tests: the smoke suite pinned only the supervisor shell, and it was passing for the wrong reason — the fake session omitted can_manage_staff, and the sidebar it asserted on was there because the role was hardcoded. Both halves are pinned now and the fake is parameterised. widget_test.dart was the stock Flutter counter template, restored by the same bad merge, testing a MyApp that has never existed in this repo. 292 tests pass; analyzer reports no errors and no warnings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
483 lines
16 KiB
Dart
483 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../../app/providers.dart';
|
|
import '../../../core/constants/app_constants.dart';
|
|
import '../../../core/theme/app_colors.dart';
|
|
import '../../../core/theme/app_dimens.dart';
|
|
import '../../../core/theme/app_typography.dart';
|
|
import '../../../core/widgets/numeric_keypad.dart';
|
|
import '../../../core/widgets/primary_button.dart';
|
|
import '../../../domain/entities/customer.dart';
|
|
import '../../pos/providers/cart_controller.dart';
|
|
|
|
/// Attaches a customer to the current bill: a mobile number, a name, or
|
|
/// neither.
|
|
///
|
|
/// Deliberately not a lookup screen. There is no search against the customers
|
|
/// already on the terminal, no recent list, and no membership tier — a cashier
|
|
/// at a queue keys the number, keys the name if the shopper gives one, and
|
|
/// carries on. A number that turns out to be registered already is reused
|
|
/// silently rather than being turned into a decision the counter has to make.
|
|
///
|
|
/// Nothing here blocks the sale: Skip closes it with a walk-in bill.
|
|
Future<void> showCustomerCaptureSheet(BuildContext context) {
|
|
return showModalBottomSheet<void>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
builder: (_) => const _CustomerCaptureSheet(),
|
|
);
|
|
}
|
|
|
|
class _CustomerCaptureSheet extends ConsumerStatefulWidget {
|
|
const _CustomerCaptureSheet();
|
|
|
|
@override
|
|
ConsumerState<_CustomerCaptureSheet> createState() =>
|
|
_CustomerCaptureSheetState();
|
|
}
|
|
|
|
class _CustomerCaptureSheetState extends ConsumerState<_CustomerCaptureSheet> {
|
|
String _digits = '';
|
|
final _name = TextEditingController();
|
|
bool _saving = false;
|
|
String? _error;
|
|
|
|
bool get _complete => _digits.length == AppConstants.mobileNumberLength;
|
|
|
|
@override
|
|
void dispose() {
|
|
_name.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
void _append(String d) {
|
|
if (_digits.length >= AppConstants.mobileNumberLength) return;
|
|
setState(() {
|
|
_digits += d;
|
|
_error = null;
|
|
});
|
|
}
|
|
|
|
void _backspace() {
|
|
if (_digits.isEmpty) return;
|
|
setState(() {
|
|
_digits = _digits.substring(0, _digits.length - 1);
|
|
_error = null;
|
|
});
|
|
}
|
|
|
|
void _clear() {
|
|
setState(() {
|
|
_digits = '';
|
|
_error = null;
|
|
});
|
|
}
|
|
|
|
void _attachAndClose(Customer? customer) {
|
|
ref.read(cartControllerProvider.notifier).attachCustomer(customer);
|
|
Navigator.of(context).pop();
|
|
}
|
|
|
|
/// Saves the number under the name given, then puts it on the bill.
|
|
///
|
|
/// The name is optional — a bare number is still worth keeping, because it
|
|
/// is what the WhatsApp bill is sent to.
|
|
///
|
|
/// A number already on file is the ordinary case, not an error: the existing
|
|
/// record is picked up and attached, and a freshly typed name is written
|
|
/// over the stored one so a correction at the counter sticks. The cashier
|
|
/// sees the same thing either way, which is the point of not having a lookup
|
|
/// step.
|
|
Future<void> _save() async {
|
|
if (!_complete) {
|
|
setState(
|
|
() => _error = 'Enter all '
|
|
'${AppConstants.mobileNumberLength} digits of the mobile number.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
final typed = _name.text.trim();
|
|
final name = typed.isEmpty
|
|
? 'Customer ${_digits.substring(_digits.length - 4)}'
|
|
: typed;
|
|
|
|
setState(() {
|
|
_saving = true;
|
|
_error = null;
|
|
});
|
|
|
|
final repo = ref.read(customerRepositoryProvider);
|
|
|
|
try {
|
|
final created = await repo.create(
|
|
Customer(id: '', name: name, mobile: _digits),
|
|
);
|
|
if (mounted) _attachAndClose(created);
|
|
} on StateError {
|
|
// Already registered. Reuse the row rather than making the counter
|
|
// reconcile it.
|
|
try {
|
|
final existing = await repo.findByMobile(_digits);
|
|
if (existing == null) throw StateError('lookup failed');
|
|
|
|
final updated = typed.isEmpty || typed == existing.name
|
|
? existing
|
|
: await repo.update(existing.copyWith(name: typed));
|
|
|
|
if (mounted) _attachAndClose(updated);
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_saving = false;
|
|
_error = 'Could not save customer. Try again.';
|
|
});
|
|
}
|
|
} catch (_) {
|
|
if (!mounted) return;
|
|
setState(() {
|
|
_saving = false;
|
|
_error = 'Could not save customer. Try again.';
|
|
});
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final attached = ref.watch(
|
|
cartControllerProvider.select((c) => c.customer),
|
|
);
|
|
|
|
return Padding(
|
|
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
|
|
child: Container(
|
|
constraints: BoxConstraints(
|
|
maxHeight: MediaQuery.sizeOf(context).height * 0.9,
|
|
),
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.surface,
|
|
borderRadius:
|
|
BorderRadius.vertical(top: Radius.circular(AppRadius.xxl)),
|
|
),
|
|
child: SafeArea(
|
|
top: false,
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_grabber(),
|
|
// Everything inside is capped and centred. A modal sheet on a
|
|
// 27-inch till used to run the full width of the screen, which
|
|
// put the keypad and the fields at opposite ends of the desk.
|
|
Flexible(
|
|
child: Center(
|
|
child: ConstrainedBox(
|
|
constraints: const BoxConstraints(maxWidth: 900),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_header(attached),
|
|
const Divider(height: 1),
|
|
Flexible(
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppSpacing.xxl,
|
|
AppSpacing.xl,
|
|
AppSpacing.xxl,
|
|
AppSpacing.xxl,
|
|
),
|
|
child: LayoutBuilder(
|
|
builder: (context, box) {
|
|
// Side by side once there is room for both.
|
|
final wide = box.maxWidth >= 660;
|
|
final entry = _entryColumn();
|
|
final details = _detailsColumn();
|
|
|
|
if (!wide) {
|
|
return Column(
|
|
children: [
|
|
entry,
|
|
const SizedBox(height: AppSpacing.xl),
|
|
details,
|
|
],
|
|
);
|
|
}
|
|
return Row(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
SizedBox(width: 300, child: entry),
|
|
const SizedBox(width: AppSpacing.xxl),
|
|
Expanded(child: details),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _grabber() => Container(
|
|
width: 40,
|
|
height: 4,
|
|
margin: const EdgeInsets.symmetric(vertical: AppSpacing.md),
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.border,
|
|
borderRadius: AppRadius.brPill,
|
|
),
|
|
);
|
|
|
|
Widget _header(Customer? attached) => Padding(
|
|
padding: const EdgeInsets.fromLTRB(
|
|
AppSpacing.xxl,
|
|
0,
|
|
AppSpacing.lg,
|
|
AppSpacing.lg,
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: AppColors.primarySurface,
|
|
borderRadius: AppRadius.brSm,
|
|
border: Border.all(color: AppColors.primaryBorder),
|
|
),
|
|
child: const Icon(Icons.person_add_alt_1_outlined,
|
|
size: 20, color: AppColors.primary,),
|
|
),
|
|
const SizedBox(width: AppSpacing.md),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(
|
|
attached == null ? 'Add customer' : 'Change customer',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w600,
|
|
letterSpacing: -0.3,
|
|
color: AppColors.textPrimary,
|
|
height: 1.2,
|
|
),
|
|
),
|
|
const Text(
|
|
'Optional — number and name, or skip',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 12.5,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.sm),
|
|
TextButton(
|
|
onPressed: _saving ? null : () => _attachAndClose(null),
|
|
style: TextButton.styleFrom(
|
|
foregroundColor: AppColors.textSecondary,
|
|
minimumSize: const Size(0, 38),
|
|
),
|
|
child: const Text('Skip'),
|
|
),
|
|
IconButton(
|
|
onPressed: _saving ? null : () => Navigator.of(context).pop(),
|
|
icon: const Icon(Icons.close_rounded),
|
|
color: AppColors.textTertiary,
|
|
tooltip: 'Close',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
// ----------------------------------------------------------------- Entry
|
|
Widget _entryColumn() => Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
_display(),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
Text(
|
|
_complete
|
|
? 'Number complete'
|
|
: '${AppConstants.mobileNumberLength - _digits.length} more '
|
|
'digit(s)',
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(
|
|
fontSize: 11.5,
|
|
color: AppColors.textTertiary,
|
|
),
|
|
),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
Center(
|
|
child: NumericKeypad(
|
|
maxWidth: 300,
|
|
onKey: _append,
|
|
onBackspace: _backspace,
|
|
onClear: _clear,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
|
|
/// The number as it is keyed, grouped 5 + 5 the way it is read aloud.
|
|
Widget _display() {
|
|
final filled = _digits.isNotEmpty;
|
|
final head = _digits.length <= 5 ? _digits : _digits.substring(0, 5);
|
|
final tail = _digits.length <= 5 ? '' : _digits.substring(5);
|
|
|
|
return Container(
|
|
height: 64,
|
|
padding: const EdgeInsets.only(left: AppSpacing.md, right: AppSpacing.xs),
|
|
decoration: BoxDecoration(
|
|
color: filled ? AppColors.surface : AppColors.surfaceAlt,
|
|
borderRadius: AppRadius.brLg,
|
|
border: Border.all(
|
|
color: filled ? AppColors.primary : AppColors.border,
|
|
width: filled ? 1.4 : 1,
|
|
),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: AppSpacing.sm,
|
|
vertical: 3,
|
|
),
|
|
decoration: const BoxDecoration(
|
|
color: AppColors.surfaceAlt,
|
|
borderRadius: AppRadius.brXs,
|
|
),
|
|
child: const Text(
|
|
'+91',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
color: AppColors.textSecondary,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: AppSpacing.md),
|
|
Expanded(
|
|
child: FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
alignment: Alignment.centerLeft,
|
|
child: filled
|
|
? Text(
|
|
tail.isEmpty ? head : '$head $tail',
|
|
style: AppTypography.money(23).copyWith(
|
|
letterSpacing: 1.5,
|
|
color: AppColors.textPrimary,
|
|
),
|
|
)
|
|
: Text(
|
|
'Mobile number',
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: AppColors.textTertiary.withValues(alpha: 0.9),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
if (filled)
|
|
IconButton(
|
|
onPressed: _saving ? null : _clear,
|
|
icon: const Icon(Icons.backspace_outlined, size: 18),
|
|
color: AppColors.textTertiary,
|
|
tooltip: 'Clear',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
// --------------------------------------------------------------- Details
|
|
Widget _detailsColumn() => Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
_hint(),
|
|
const SizedBox(height: AppSpacing.lg),
|
|
TextField(
|
|
controller: _name,
|
|
textCapitalization: TextCapitalization.words,
|
|
enabled: !_saving,
|
|
onSubmitted: (_) => _save(),
|
|
inputFormatters: [LengthLimitingTextInputFormatter(60)],
|
|
decoration: const InputDecoration(
|
|
labelText: 'Customer name',
|
|
hintText: 'Optional',
|
|
prefixIcon: Icon(Icons.person_outline_rounded),
|
|
),
|
|
),
|
|
if (_error != null) ...[
|
|
const SizedBox(height: AppSpacing.sm),
|
|
Text(
|
|
_error!,
|
|
style: const TextStyle(color: AppColors.danger, fontSize: 12.5),
|
|
),
|
|
],
|
|
const SizedBox(height: AppSpacing.lg),
|
|
PrimaryButton(
|
|
label: 'Save & use',
|
|
icon: Icons.person_add_alt_1_rounded,
|
|
large: true,
|
|
busy: _saving,
|
|
onPressed: _complete && !_saving ? _save : null,
|
|
),
|
|
const SizedBox(height: AppSpacing.sm),
|
|
PrimaryButton(
|
|
label: 'Skip — no customer',
|
|
tone: ButtonTone.neutral,
|
|
onPressed: _saving ? null : () => _attachAndClose(null),
|
|
),
|
|
],
|
|
);
|
|
|
|
Widget _hint() => Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(AppSpacing.lg),
|
|
decoration: BoxDecoration(
|
|
color: AppColors.surfaceAlt,
|
|
borderRadius: AppRadius.brLg,
|
|
border: Border.all(color: AppColors.border),
|
|
),
|
|
child: const Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.dialpad_rounded,
|
|
size: 19, color: AppColors.textTertiary,),
|
|
SizedBox(width: AppSpacing.md),
|
|
Expanded(
|
|
child: Text(
|
|
'Key in the mobile number, add a name if the shopper gives '
|
|
'one, then save. The name is optional and the whole step can '
|
|
'be skipped — the sale is never held up by it.',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: AppColors.textSecondary,
|
|
height: 1.5,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|