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>
823 lines
27 KiB
Dart
823 lines
27 KiB
Dart
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<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,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|