pos changes
This commit is contained in:
823
lib/presentation/shift/screens/end_shift_screen.dart
Normal file
823
lib/presentation/shift/screens/end_shift_screen.dart
Normal 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,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
283
lib/presentation/shift/widgets/session_end_sheet.dart
Normal file
283
lib/presentation/shift/widgets/session_end_sheet.dart
Normal file
@@ -0,0 +1,283 @@
|
||||
import 'package:flutter/material.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 '../../auth/providers/auth_controller.dart';
|
||||
import '../../pos/providers/cart_controller.dart';
|
||||
import '../../pos/providers/catalog_providers.dart';
|
||||
import '../../pos/providers/navigation_provider.dart';
|
||||
import '../../sync/providers/sync_controller.dart';
|
||||
import '../../sync/widgets/sign_out_dialog.dart';
|
||||
|
||||
/// Asks what "signing out" means before doing it.
|
||||
///
|
||||
/// A cashier stepping off the counter for a few minutes and a cashier
|
||||
/// finishing for the day want different things from the *shift*, not from the
|
||||
/// terminal. A temporary logout leaves the shift open — bills stay queued and
|
||||
/// today's totals keep accumulating — and simply locks the screen. Ending the
|
||||
/// shift means counting the drawer, reconciling it, and handing the till back.
|
||||
///
|
||||
/// What both do identically: the products come off this terminal. A cashier
|
||||
/// session never leaves a catalogue sitting on an unattended screen, so an
|
||||
/// admin re-imports it before the counter is worked again.
|
||||
///
|
||||
/// Admins see the plain sign-out dialog: they have no drawer to settle.
|
||||
Future<void> showSessionEndSheet(BuildContext context, WidgetRef ref) async {
|
||||
if (!ref.read(isCashierModeProvider)) {
|
||||
return showSignOutDialog(context, ref);
|
||||
}
|
||||
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: true,
|
||||
builder: (_) => const _SessionEndDialog(),
|
||||
);
|
||||
}
|
||||
|
||||
class _SessionEndDialog extends ConsumerWidget {
|
||||
const _SessionEndDialog();
|
||||
|
||||
/// Closes the session without closing the shift.
|
||||
///
|
||||
/// Explicitly *not* a shift end: unsynced bills stay queued and today's
|
||||
/// totals keep accumulating against the same day, so the drawer is still
|
||||
/// settled once, at the end. What it does not leave behind is the
|
||||
/// catalogue — every cashier sign-out takes the products with it, this one
|
||||
/// included, so an admin imports them again before billing resumes.
|
||||
Future<void> _temporaryLogout(BuildContext context, WidgetRef ref) async {
|
||||
ref.read(cartControllerProvider.notifier).reset();
|
||||
await ref.read(authControllerProvider.notifier).signOut();
|
||||
|
||||
// Bump the version so catalogueReadyProvider re-reads hasCatalogue, and
|
||||
// drop the cached lists so the next session's grid does not flash this
|
||||
// session's products before it re-checks what is on disk.
|
||||
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 (!context.mounted) return;
|
||||
|
||||
// Resolved while this context is still mounted — the messenger lives above
|
||||
// the router, so the bar survives the route change below.
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
|
||||
Navigator.of(context).pop();
|
||||
context.go(AppRoutes.login);
|
||||
|
||||
messenger
|
||||
..hideCurrentSnackBar()
|
||||
..showSnackBar(const SnackBar(
|
||||
content: Text(
|
||||
'Logged out. The shift is still open, and the product catalogue has '
|
||||
'been removed from this terminal.',
|
||||
),
|
||||
),);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
|
||||
final cart = ref.watch(cartControllerProvider);
|
||||
|
||||
return AlertDialog(
|
||||
title: const Text('Leaving the till'),
|
||||
contentPadding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.lg,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.sm,
|
||||
),
|
||||
content: SizedBox(
|
||||
width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 460.0),
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (cart.isNotEmpty)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.warningSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.warning_amber_rounded,
|
||||
size: 18, color: AppColors.warning,),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'The current bill has ${cart.lineCount} item(s) and '
|
||||
'will be cleared either way.',
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.warning,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// The one thing both choices do, said once here rather than
|
||||
// repeated in each card and discovered at the next login.
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: AppSpacing.md),
|
||||
padding: const EdgeInsets.all(AppSpacing.md),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.infoSurface,
|
||||
borderRadius: AppRadius.brSm,
|
||||
),
|
||||
child: const Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.delete_sweep_outlined,
|
||||
size: 18, color: AppColors.info,),
|
||||
SizedBox(width: AppSpacing.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Either way, the product catalogue is removed from '
|
||||
'this terminal. An admin imports it again before the '
|
||||
'counter is worked.',
|
||||
style: TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.info,
|
||||
height: 1.45,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
_Choice(
|
||||
icon: Icons.lock_clock_outlined,
|
||||
tone: AppColors.info,
|
||||
title: 'Temporary logout',
|
||||
body: 'Locks the terminal without closing the shift. Bills '
|
||||
'stay queued and today’s totals keep running, so the '
|
||||
'drawer is still counted once at the end.',
|
||||
onTap: () => _temporaryLogout(context, ref),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.md),
|
||||
_Choice(
|
||||
icon: Icons.point_of_sale_rounded,
|
||||
tone: AppColors.primary,
|
||||
title: 'End shift',
|
||||
body: pending == 0
|
||||
? 'Count the drawer, check it against what was rung, then '
|
||||
'hand the till over.'
|
||||
: 'Count the drawer, check it against what was rung, then '
|
||||
'upload the $pending bill(s) still held here.',
|
||||
onTap: () {
|
||||
Navigator.of(context).pop();
|
||||
context.push(AppRoutes.endShift);
|
||||
},
|
||||
emphasised: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actionsPadding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.xxl,
|
||||
0,
|
||||
AppSpacing.xxl,
|
||||
AppSpacing.lg,
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Stay signed in'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Choice extends StatelessWidget {
|
||||
const _Choice({
|
||||
required this.icon,
|
||||
required this.tone,
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.onTap,
|
||||
this.emphasised = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final Color tone;
|
||||
final String title;
|
||||
final String body;
|
||||
final VoidCallback onTap;
|
||||
final bool emphasised;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: emphasised ? AppColors.primarySurface : AppColors.surfaceAlt,
|
||||
borderRadius: AppRadius.brLg,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.brLg,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpacing.lg),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: AppRadius.brLg,
|
||||
border: Border.all(
|
||||
color: emphasised ? AppColors.primaryBorder : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: tone),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
body,
|
||||
style: const TextStyle(
|
||||
fontSize: 12.5,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.sm),
|
||||
const Icon(Icons.chevron_right_rounded,
|
||||
size: 20, color: AppColors.textTertiary,),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user