236 lines
8.3 KiB
Dart
236 lines
8.3 KiB
Dart
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.
|
|
///
|
|
/// Both cashier paths clear the products — the till never keeps a catalogue
|
|
/// across a sign-out, whatever the reason. What they differ on is the drawer:
|
|
/// a temporary logout locks the screen and leaves the money alone, while
|
|
/// ending the shift counts it and reconciles it against what was rung.
|
|
/// Treating both as one button meant either the drawer was never settled, or a
|
|
/// cashier stepping away for ten minutes had to count it first.
|
|
///
|
|
/// Admins see the plain sign-out dialog: they have no drawer to settle, and
|
|
/// their sign-out deliberately leaves the catalogue in place for whoever picks
|
|
/// the terminal up.
|
|
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();
|
|
|
|
/// Locks the screen and returns to the login screen.
|
|
///
|
|
/// Explicitly *not* a shift end — the drawer is left alone, unsynced bills
|
|
/// stay queued, and today's totals keep accumulating against the same day.
|
|
/// The catalogue still goes: a terminal sitting unattended at a login screen
|
|
/// must not be holding a shop's prices and stock, and an admin re-imports in
|
|
/// seconds.
|
|
Future<void> _temporaryLogout(BuildContext context, WidgetRef ref) async {
|
|
ref.read(cartControllerProvider.notifier).reset();
|
|
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 (!context.mounted) return;
|
|
Navigator.of(context).pop();
|
|
context.go(AppRoutes.login);
|
|
}
|
|
|
|
@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 '
|
|
'the imported products are cleared either way.',
|
|
style: const TextStyle(
|
|
fontSize: 12.5,
|
|
color: AppColors.warning,
|
|
height: 1.45,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
_Choice(
|
|
icon: Icons.lock_outline_rounded,
|
|
tone: AppColors.info,
|
|
title: 'Temporary logout',
|
|
body: 'Locks the screen. The drawer is left as it is and the '
|
|
'day keeps running — an admin re-imports the products when '
|
|
'you come back.',
|
|
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. It has to match what was rung before '
|
|
'the till can be handed over.'
|
|
: 'Count the drawer and upload the $pending bill(s) still '
|
|
'held here. The count has to match before you can sign '
|
|
'out.',
|
|
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,),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|