pos changes

This commit is contained in:
2026-08-06 19:26:53 +05:30
parent cb065a0f69
commit eebd10da6d
42 changed files with 3451 additions and 2531 deletions

View File

@@ -0,0 +1,823 @@
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 '../../../domain/repositories/sync_repository.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,
],
),
);
}
}