Files
nearle_pos/lib/presentation/shift/screens/end_shift_screen.dart
Suriya 829e5a8188 Take the terminal's role from the back office, not from which tab was clicked
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>
2026-08-07 11:06:00 +05:30

823 lines
27 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../core/router/app_router.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/theme/app_typography.dart';
import '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.dart';
import '../../../domain/entities/shift_report.dart';
import '../../../domain/entities/transaction.dart';
import '../../auth/providers/auth_controller.dart';
import '../../payment/screens/payment_screen.dart' show methodIcon;
import '../../pos/providers/cart_controller.dart';
import '../../pos/providers/catalog_providers.dart';
import '../../pos/providers/navigation_provider.dart';
import '../../sync/providers/sync_controller.dart';
/// Closing the till.
///
/// Three things have to happen at the end of a shift and they have to happen
/// in this order: count what is physically in the drawer, compare it against
/// what the terminal says was taken in cash, then push the day up and hand the
/// terminal back. Doing it as a dialog meant the count was a single guessed
/// number typed into a box; a shift is worth its own screen.
///
/// The variance is the whole point. A till that is short is worth knowing
/// about while the person who worked it is still standing there.
class EndShiftScreen extends ConsumerStatefulWidget {
const EndShiftScreen({super.key});
/// Below this the two columns stack.
static const double twoColumnAbove = 1000;
@override
ConsumerState<EndShiftScreen> createState() => _EndShiftScreenState();
}
/// What a till drawer actually holds, largest first.
const _denominations = <int>[2000, 500, 200, 100, 50, 20, 10, 5, 2, 1];
class _EndShiftScreenState extends ConsumerState<EndShiftScreen> {
/// Note or coin value → how many were counted.
final Map<int, int> _counted = {};
final _openingFloat = TextEditingController(text: '0');
bool _pushing = false;
String? _error;
@override
void dispose() {
_openingFloat.dispose();
super.dispose();
}
double get _countedTotal => _counted.entries
.fold(0.0, (sum, e) => sum + e.key * e.value);
double get _float => double.tryParse(_openingFloat.text.trim()) ?? 0;
int get _noteCount => _counted.values.fold(0, (sum, n) => sum + n);
void _set(int denomination, int count) {
setState(() {
if (count <= 0) {
_counted.remove(denomination);
} else {
_counted[denomination] = count;
}
});
}
/// Cash the terminal believes was taken, from the tender records — not from
/// the bill totals, which include card and UPI.
double _cashTaken(ShiftReport? report) =>
report?.paymentBreakdown[PaymentMethod.cash] ?? 0;
/// Pushes what is still held, then ends the session and clears the terminal.
Future<void> _finish({required bool sync}) async {
setState(() {
_pushing = true;
_error = null;
});
if (sync) {
final outcome = await ref.read(orderSyncProvider.notifier).run();
if (!mounted) return;
if (!outcome.isSuccess) {
setState(() {
_pushing = false;
_error = outcome.error ??
'Upload failed. Every bill is still stored on this terminal.';
});
return;
}
}
ref.read(cartControllerProvider.notifier).reset();
// The real end of shift: the catalogue goes with it, so the next person
// bills against a fresh import rather than this morning's prices.
await ref.read(authControllerProvider.notifier).signOut();
ref.read(catalogueVersionProvider.notifier).state++;
ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider);
ref.invalidate(categoryCountsProvider);
ref.invalidate(lowStockProductsProvider);
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
ref.read(searchQueryProvider.notifier).state = '';
ref.read(selectedCategoryProvider.notifier).state = null;
if (!mounted) return;
context.go(AppRoutes.login);
}
@override
Widget build(BuildContext context) {
final report = ref.watch(myShiftReportProvider).value;
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
final user = ref.watch(currentUserProvider);
final cashTaken = _cashTaken(report);
final expected = _float + cashTaken;
final variance = _countedTotal - expected;
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
title: const Text('End shift'),
leading: IconButton(
icon: const Icon(Icons.arrow_back_rounded),
onPressed: _pushing ? null : () => context.pop(),
tooltip: 'Back to the till',
),
),
body: LayoutBuilder(
builder: (context, box) {
final twoColumn = box.maxWidth >= EndShiftScreen.twoColumnAbove;
final pad = box.maxWidth < 700 ? AppSpacing.lg : AppSpacing.xxl;
final count = _CountPanel(
counted: _counted,
openingFloat: _openingFloat,
enabled: !_pushing,
total: _countedTotal,
noteCount: _noteCount,
onChanged: _set,
onFloatChanged: () => setState(() {}),
);
final review = _ReviewPanel(
report: report,
user: user?.name,
openingFloat: _float,
cashTaken: cashTaken,
expected: expected,
counted: _countedTotal,
variance: variance,
pending: pending,
error: _error,
);
final minHeight = box.maxHeight.isFinite
? (box.maxHeight - pad * 2).clamp(0.0, double.infinity)
: 0.0;
return SingleChildScrollView(
padding: EdgeInsets.all(pad),
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: minHeight),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1240),
child: twoColumn
? Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: count),
const SizedBox(width: AppSpacing.lg),
Expanded(child: review),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
count,
const SizedBox(height: AppSpacing.lg),
review,
],
),
),
),
),
);
},
),
bottomNavigationBar: _bottomBar(pending, variance),
);
}
Widget _bottomBar(int pending, double variance) {
final counted = _noteCount > 0;
final short = variance < -0.5;
final over = variance > 0.5;
// The gate. A shift closes on a drawer that reconciles and on nothing
// else — an uncounted or mismatched till is settled at the counter, with
// the person who worked it still there, not discovered by the back office
// the next morning.
final balanced = counted && !short && !over;
return SafeArea(
child: Container(
padding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.md,
AppSpacing.xxl,
AppSpacing.lg,
),
decoration: const BoxDecoration(
color: AppColors.surface,
border: Border(top: BorderSide(color: AppColors.border)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
Icon(
counted
? (short || over
? Icons.error_outline_rounded
: Icons.check_circle_outline_rounded)
: Icons.info_outline_rounded,
size: 16,
color: !counted
? AppColors.textTertiary
: (short
? AppColors.danger
: (over ? AppColors.warning : AppColors.success)),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
!counted
? 'Count the drawer. The shift cannot be ended until '
'it matches the expected amount.'
: short
? 'The drawer is '
'${Formatters.money(variance.abs())} short. '
'Recount, or settle the difference — the '
'shift cannot be ended while it is off.'
: over
? 'The drawer is '
'${Formatters.money(variance)} over. '
'Recount — the shift cannot be ended '
'while it is off.'
: 'The drawer matches what was rung. Ready to '
'end the shift.',
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
),
),
),
],
),
const SizedBox(height: AppSpacing.sm),
PrimaryButton(
label: !balanced
? 'Drawer must match to end shift'
: pending > 0
? 'Upload $pending bill(s) & end shift'
: 'End shift',
icon: balanced ? Icons.logout_rounded : Icons.lock_outline_rounded,
large: true,
busy: _pushing,
onPressed: (_pushing || !balanced)
? null
: () => _finish(sync: pending > 0),
),
if (pending > 0) ...[
const SizedBox(height: AppSpacing.xs),
TextButton(
// Skipping the upload is still allowed; skipping the count is
// not, so this is gated on the same condition.
onPressed:
(_pushing || !balanced) ? null : () => _finish(sync: false),
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
),
child: const Text('End shift without uploading'),
),
],
],
),
),
);
}
}
// ------------------------------------------------------------------- Count
class _CountPanel extends StatelessWidget {
const _CountPanel({
required this.counted,
required this.openingFloat,
required this.enabled,
required this.total,
required this.noteCount,
required this.onChanged,
required this.onFloatChanged,
});
final Map<int, int> counted;
final TextEditingController openingFloat;
final bool enabled;
final double total;
final int noteCount;
final void Function(int denomination, int count) onChanged;
final VoidCallback onFloatChanged;
@override
Widget build(BuildContext context) {
return _Panel(
title: 'Count the drawer',
subtitle: 'Tap the notes and coins you are holding. Nothing is '
'submitted until you end the shift.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: openingFloat,
enabled: enabled,
keyboardType: TextInputType.number,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
onChanged: (_) => onFloatChanged(),
decoration: const InputDecoration(
labelText: 'Opening float',
helperText: 'What was in the drawer before trading started',
prefixText: '',
isDense: true,
),
),
const SizedBox(height: AppSpacing.lg),
const Divider(height: 1),
const SizedBox(height: AppSpacing.sm),
for (final value in _denominations)
_DenominationRow(
value: value,
count: counted[value] ?? 0,
enabled: enabled,
onChanged: (n) => onChanged(value, n),
),
const SizedBox(height: AppSpacing.sm),
const Divider(height: 1),
const SizedBox(height: AppSpacing.md),
Row(
children: [
Expanded(
child: Text(
noteCount == 0
? 'Counted so far'
: 'Counted so far · $noteCount piece(s)',
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
),
Text(
Formatters.money(total),
style: AppTypography.money(20, color: AppColors.textPrimary),
),
],
),
],
),
);
}
}
class _DenominationRow extends StatelessWidget {
const _DenominationRow({
required this.value,
required this.count,
required this.enabled,
required this.onChanged,
});
final int value;
final int count;
final bool enabled;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
final subtotal = value * count;
final active = count > 0;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
SizedBox(
width: 74,
child: Text(
'$value',
style: AppTypography.money(
15,
color: active ? AppColors.textPrimary : AppColors.textTertiary,
),
),
),
const Text(
'×',
style: TextStyle(fontSize: 12, color: AppColors.textTertiary),
),
const SizedBox(width: AppSpacing.sm),
_Stepper(
count: count,
enabled: enabled,
onChanged: onChanged,
),
const Spacer(),
Text(
active ? Formatters.money(subtotal.toDouble()) : '',
style: AppTypography.money(
14,
color: active ? AppColors.textPrimary : AppColors.textTertiary,
),
),
],
),
);
}
}
class _Stepper extends StatelessWidget {
const _Stepper({
required this.count,
required this.enabled,
required this.onChanged,
});
final int count;
final bool enabled;
final ValueChanged<int> onChanged;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppRadius.brSm,
border: Border.all(color: AppColors.border),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_btn(
Icons.remove_rounded,
enabled && count > 0 ? () => onChanged(count - 1) : null,
),
Container(
constraints: const BoxConstraints(minWidth: 38),
alignment: Alignment.center,
child: Text('$count', style: AppTypography.money(14.5)),
),
_btn(
Icons.add_rounded,
enabled ? () => onChanged(count + 1) : null,
),
],
),
);
}
Widget _btn(IconData icon, VoidCallback? onTap) => Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.brSm,
child: SizedBox(
width: 32,
height: 32,
child: Icon(
icon,
size: 16,
color: onTap == null
? AppColors.textTertiary.withValues(alpha: 0.5)
: AppColors.primary,
),
),
),
);
}
// ------------------------------------------------------------------ Review
class _ReviewPanel extends StatelessWidget {
const _ReviewPanel({
required this.report,
required this.user,
required this.openingFloat,
required this.cashTaken,
required this.expected,
required this.counted,
required this.variance,
required this.pending,
required this.error,
});
final ShiftReport? report;
final String? user;
final double openingFloat;
final double cashTaken;
final double expected;
final double counted;
final double variance;
final int pending;
final String? error;
@override
Widget build(BuildContext context) {
final short = variance < -0.5;
final over = variance > 0.5;
final tone = counted == 0
? AppColors.textTertiary
: (short ? AppColors.danger : (over ? AppColors.warning
: AppColors.success));
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_Panel(
title: 'Cash drawer',
subtitle: 'What the terminal expects, against what you counted.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_row('Opening float', Formatters.money(openingFloat)),
_row('Cash sales today', Formatters.money(cashTaken)),
const Divider(height: AppSpacing.xl),
_row(
'Expected in drawer',
Formatters.money(expected),
strong: true,
),
_row('You counted', Formatters.money(counted)),
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: counted == 0
? AppColors.surfaceAlt
: (short
? AppColors.dangerSurface
: (over
? AppColors.warningSurface
: AppColors.successSurface)),
borderRadius: AppRadius.brMd,
),
child: Row(
children: [
Expanded(
child: Text(
counted == 0
? 'Not counted yet'
: (short
? 'Short'
: (over ? 'Over' : 'Balanced')),
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: tone,
),
),
),
Text(
counted == 0
? ''
: '${variance >= 0 ? '+' : ''}'
'${Formatters.money(variance.abs())}',
style: AppTypography.money(20, color: tone),
),
],
),
),
],
),
),
const SizedBox(height: AppSpacing.lg),
_Panel(
title: 'Today at this till',
subtitle: user == null ? null : 'Rung by $user',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_row('Bills', '${report?.billCount ?? 0}'),
_row('Items sold',
(report?.itemCount ?? 0).toStringAsFixed(0),),
_row('Gross sales',
Formatters.money(report?.grossSales ?? 0),),
_row('GST collected',
Formatters.money(report?.taxCollected ?? 0),),
if ((report?.paymentBreakdown ?? const {}).isNotEmpty) ...[
const Divider(height: AppSpacing.xl),
for (final e in report!.paymentBreakdown.entries)
Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Icon(methodIcon(e.key),
size: 16, color: AppColors.textSecondary,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
e.key.label,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
),
),
),
Text(Formatters.money(e.value),
style: AppTypography.money(13.5),),
],
),
),
],
],
),
),
const SizedBox(height: AppSpacing.lg),
_Panel(
title: 'Before you hand it over',
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_checkRow(
pending == 0,
pending == 0
? 'Every bill has been uploaded.'
: '$pending bill(s) still on this terminal — they upload '
'when you end the shift.',
),
_checkRow(
!short && !over && counted > 0,
counted == 0
? 'Drawer not counted yet — required before the shift can '
'be ended.'
: (short || over)
? 'Drawer does not match the expected amount. The '
'shift stays open until it does.'
: 'Drawer matches the expected amount.',
),
_checkRow(
false,
'Products are removed from this terminal at the end of a '
'shift. An admin imports them again tomorrow.',
neutral: true,
),
if (error != null) ...[
const SizedBox(height: AppSpacing.md),
Container(
padding: const EdgeInsets.all(AppSpacing.md),
decoration: const BoxDecoration(
color: AppColors.dangerSurface,
borderRadius: AppRadius.brSm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.wifi_off_rounded,
size: 18, color: AppColors.danger,),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
error!,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.danger,
height: 1.45,
),
),
),
],
),
),
],
],
),
),
],
);
}
Widget _row(String label, String value, {bool strong = false}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(
label,
style: TextStyle(
fontSize: strong ? 14 : 13.5,
fontWeight: strong ? FontWeight.w600 : FontWeight.w400,
color: strong
? AppColors.textPrimary
: AppColors.textSecondary,
),
),
),
Text(
value,
style: AppTypography.money(
strong ? 16 : 13.5,
color: strong ? AppColors.primary : null,
),
),
],
),
);
Widget _checkRow(bool done, String text, {bool neutral = false}) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
neutral
? Icons.info_outline_rounded
: (done
? Icons.check_circle_outline_rounded
: Icons.radio_button_unchecked_rounded),
size: 16,
color: neutral
? AppColors.textTertiary
: (done ? AppColors.success : AppColors.textTertiary),
),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.5,
),
),
),
],
),
);
}
/// One card shape for every block on this screen, so the two columns line up
/// row for row instead of each panel inventing its own padding.
class _Panel extends StatelessWidget {
const _Panel({
required this.title,
required this.child,
this.subtitle,
});
final String title;
final String? subtitle;
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(AppSpacing.xl),
decoration: BoxDecoration(
color: AppColors.surface,
borderRadius: AppRadius.brXl,
border: Border.all(color: AppColors.border),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
color: AppColors.textPrimary,
),
),
if (subtitle != null) ...[
const SizedBox(height: 2),
Text(
subtitle!,
style: const TextStyle(
fontSize: 12.5,
color: AppColors.textSecondary,
height: 1.45,
),
),
],
const SizedBox(height: AppSpacing.lg),
child,
],
),
);
}
}