Files
nearle_pos/lib/presentation/sync/widgets/sign_out_dialog.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

306 lines
10 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 '../../../core/utils/formatters.dart';
import '../../../core/widgets/primary_button.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 '../../../domain/repositories/sync_repository.dart';
import '../providers/sync_controller.dart';
/// End-of-shift flow.
///
/// The day's takings are pushed here — the second and last moment this
/// terminal needs a connection. Signing out without pushing is allowed, but
/// the report stays queued locally rather than being discarded.
Future<void> showSignOutDialog(BuildContext context, WidgetRef ref) {
return showDialog<void>(
context: context,
barrierDismissible: false,
builder: (_) => const _SignOutDialog(),
);
}
class _SignOutDialog extends ConsumerStatefulWidget {
const _SignOutDialog();
@override
ConsumerState<_SignOutDialog> createState() => _SignOutDialogState();
}
class _SignOutDialogState extends ConsumerState<_SignOutDialog> {
SyncOutcome? _result;
Future<void> _finish() async {
ref.read(cartControllerProvider.notifier).reset();
// Read before signing out — the session that decides this is gone by the
// time signOut returns.
final cleared =
ref.read(authControllerProvider.notifier).clearsCatalogueOnSignOut;
await ref.read(authControllerProvider.notifier).signOut();
// Mirrors what a successful import does on the way in: bump the version so
// catalogueReadyProvider re-reads hasCatalogue, and drop the cached product
// lists so the next session's grid doesn't flash this session's data before
// it re-fetches.
//
// Run either way. After an admin sign-out the catalogue is still there and
// these simply re-read it — which is the point: the next session must see
// what is on disk now, not what this one had in memory.
ref.read(catalogueVersionProvider.notifier).state++;
ref.invalidate(allProductsProvider);
ref.invalidate(visibleProductsProvider);
ref.invalidate(categoryCountsProvider);
ref.invalidate(lowStockProductsProvider);
// The next session starts on the till, never wherever this one left off.
ref.read(activeModuleProvider.notifier).state = PosModule.pos;
ref.read(searchQueryProvider.notifier).state = '';
ref.read(selectedCategoryProvider.notifier).state = null;
if (!mounted) return;
// Resolved while this context is still mounted. The messenger itself 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);
if (cleared) {
messenger
..hideCurrentSnackBar()
..showSnackBar(const SnackBar(
content: Text(
'Signed out. The product catalogue has been removed from this '
'terminal.',
),
),);
}
}
Future<void> _pushThenFinish() async {
final outcome = await ref.read(orderSyncProvider.notifier).run();
if (!mounted) return;
setState(() => _result = outcome);
if (outcome.isSuccess) {
await Future<void>.delayed(const Duration(milliseconds: 700));
if (mounted) await _finish();
}
}
@override
Widget build(BuildContext context) {
// The operator is settling their own till, so this covers only their bills.
final report = ref.watch(myShiftReportProvider).value;
final pending = ref.watch(unsyncedCountProvider).value ?? 0;
final cart = ref.watch(cartControllerProvider);
final pushing = ref.watch(orderSyncProvider) is SyncRunning;
final failed = _result != null && !_result!.isSuccess;
final cashier = ref.watch(isCashierModeProvider);
return AlertDialog(
title: Text(cashier ? 'End shift' : 'Sign out'),
contentPadding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
AppSpacing.lg,
AppSpacing.xxl,
AppSpacing.sm,
),
content: SizedBox(
// Never wider than the viewport allows.
width: (MediaQuery.sizeOf(context).width - 96).clamp(280.0, 420.0),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// What happens to the products is the difference between the two
// sign-outs, so it is said plainly rather than left to be
// discovered at the next login.
_Banner(
icon: cashier
? Icons.delete_sweep_outlined
: Icons.inventory_2_outlined,
color: cashier ? AppColors.warning : AppColors.info,
background: cashier
? AppColors.warningSurface
: AppColors.infoSurface,
message: cashier
? 'The product catalogue will be removed from this '
'terminal. An admin imports it again for the next '
'shift.'
: 'The imported products stay on this terminal, so a '
'cashier can sign in and start billing without a '
'connection.',
),
if (cart.isNotEmpty)
_Banner(
icon: Icons.warning_amber_rounded,
color: AppColors.warning,
background: AppColors.warningSurface,
message: 'The current bill has ${cart.lineCount} item(s) '
'and will be cleared. Park it first if you need it.',
),
if (pending == 0)
const _Banner(
icon: Icons.check_circle_outline_rounded,
color: AppColors.success,
background: AppColors.successSurface,
message: 'Every bill has already been uploaded. Nothing is '
'waiting on this terminal.',
)
else ...[
const Text(
'Waiting to upload',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.sm),
_row('Bills pending sync', '$pending'),
if (report != null) ...[
_row('Bills today', '${report.billCount}'),
_row('Items sold', report.itemCount.toStringAsFixed(0)),
_row('Gross sales', Formatters.money(report.grossSales)),
_row('GST collected',
Formatters.money(report.taxCollected),),
],
],
if (failed) ...[
const SizedBox(height: AppSpacing.md),
_Banner(
icon: Icons.wifi_off_rounded,
color: AppColors.danger,
background: AppColors.dangerSurface,
message: _result?.error ??
'Upload failed. Every bill is still stored on this '
'terminal and can be retried from Events.',
),
],
],
),
),
),
actionsPadding: const EdgeInsets.fromLTRB(
AppSpacing.xxl,
0,
AppSpacing.xxl,
AppSpacing.lg,
),
actions: [
// Wrap keeps three actions from overflowing a narrow dialog.
Wrap(
alignment: WrapAlignment.end,
spacing: AppSpacing.sm,
runSpacing: AppSpacing.sm,
children: [
TextButton(
onPressed: pushing ? null : () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
TextButton(
onPressed: pushing ? null : _finish,
style: TextButton.styleFrom(
foregroundColor: AppColors.textSecondary,
),
child: Text(
pending == 0 ? 'Sign out' : 'Sign out without syncing',
),
),
if (pending > 0)
SizedBox(
width: 190,
child: PrimaryButton(
label: failed ? 'Retry sync' : 'Sync & sign out',
icon: Icons.cloud_upload_rounded,
busy: pushing,
onPressed: _pushThenFinish,
),
),
],
),
],
);
}
Widget _row(String label, String value) => Padding(
padding: const EdgeInsets.symmetric(vertical: 3),
child: Row(
children: [
Expanded(
child: Text(
label,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
),
),
),
Text(
value,
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w700,
),
),
],
),
);
}
class _Banner extends StatelessWidget {
const _Banner({
required this.icon,
required this.color,
required this.background,
required this.message,
});
final IconData icon;
final Color color;
final Color background;
final String message;
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: AppSpacing.md),
padding: const EdgeInsets.all(AppSpacing.md),
decoration: BoxDecoration(
color: background,
borderRadius: AppRadius.brSm,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18, color: color),
const SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
message,
style: TextStyle(fontSize: 12.5, color: color, height: 1.45),
),
),
],
),
);
}
}