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 createState() => _EndShiftScreenState(); } /// What a till drawer actually holds, largest first. const _denominations = [2000, 500, 200, 100, 50, 20, 10, 5, 2, 1]; class _EndShiftScreenState extends ConsumerState { /// Note or coin value → how many were counted. final Map _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 _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 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 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 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, ], ), ); } }