import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../../app/providers.dart'; import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_dimens.dart'; import '../../../core/utils/formatters.dart'; import '../../../data/sync/sync_engine.dart'; import '../../sync/providers/sync_controller.dart'; import '../widgets/module_widgets.dart'; /// What this terminal has traded, and what has reached the server. /// /// Read-only. Uploading is the sync engine's job — it pushes after every sale /// and retries on its own — so this page reports rather than drives. The /// warning banner at the top is the exception: a bill the engine has given up /// on is the one thing here that needs a person to notice it. class EventsView extends ConsumerWidget { const EventsView({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final report = ref.watch(todayReportProvider); final pending = ref.watch(unsyncedCountProvider).value ?? 0; final rows = ref.watch(orderSyncRowsProvider).value ?? const []; // The engine may not have emitted yet on a cold start, so fall back to // its current value rather than showing nothing. This is the same state // that drives the header pill — it is what actually knows whether the // automatic push right after a sale succeeded, not just what the manual // "Sync" button on this page last did. final engine = ref.watch(syncEngineStateProvider).value ?? ref.watch(syncEngineProvider).state; final r = report.value; return ModulePage( children: [ if (engine.lastError != null && pending > 0) _EngineWarningBanner(engine: engine), Wrap( spacing: AppSpacing.lg, runSpacing: AppSpacing.lg, children: [ StatTile( label: 'Bills Today', value: '${r?.billCount ?? 0}', icon: Icons.receipt_long_rounded, caption: r?.firstBillAt == null ? 'no sales yet' : '${Formatters.time(r!.firstBillAt!)} – ' '${Formatters.time(r.lastBillAt!)}', ), StatTile( label: 'Items Sold', value: (r?.itemCount ?? 0).toStringAsFixed(0), icon: Icons.shopping_basket_rounded, color: AppColors.info, caption: 'units across all bills', ), StatTile( label: "Today's Sales", value: Formatters.money(r?.grossSales ?? 0), icon: Icons.payments_rounded, color: AppColors.success, caption: 'gross takings', ), StatTile( label: 'Awaiting Sync', value: '$pending', icon: Icons.cloud_off_rounded, color: pending > 0 ? AppColors.warning : AppColors.success, caption: pending > 0 ? 'held on this terminal' : 'everything uploaded', ), ], ), const SizedBox(height: AppSpacing.lg), PanelCard( title: 'Orders', subtitle: '${rows.length} stored \u00b7 $pending awaiting upload', child: ResponsiveTable( columns: const [ TableCol('Invoice', flex: 3), TableCol('Date & time', flex: 3, priority: 1), TableCol('Items', flex: 1, numeric: true, priority: 2), TableCol('Total', flex: 2, numeric: true), TableCol('Sync', flex: 2, numeric: true), ], rows: rows .map((o) => [ Cell(o.invoiceNumber, bold: true, mono: true), // Date sits with the time because this table outlives the // day it was rung on — bills stay on the terminal until // they are purged, so a bare clock time is ambiguous the // moment the shop opens again. Cell( '${Formatters.date(o.createdAt)} \u00b7 ' '${Formatters.time(o.createdAt)}', color: AppColors.textTertiary, ), Cell(_units(o.itemCount), mono: true), Cell(Formatters.money(o.total), mono: true, bold: true), TagChip( o.isSynced ? 'Synced' : 'Pending', color: o.isSynced ? AppColors.success : AppColors.warning, ), ],) .toList(), ), ), ], ); } /// Units on a bill. Whole where they are whole — loose goods are sold by /// weight, so "2.5" is a real answer here and rounding it would be a lie. static String _units(double count) => count % 1 == 0 ? count.toStringAsFixed(0) : count.toStringAsFixed(2); } /// Flags a bill that could not reach the server on its own. /// /// Sits above everything else on the page because a bill stuck here is the /// one thing on this screen that needs a person to notice it, rather than /// just waiting for the next background retry. class _EngineWarningBanner extends StatelessWidget { const _EngineWarningBanner({required this.engine}); final SyncEngineState engine; @override Widget build(BuildContext context) { final halted = engine.isHalted; final color = halted ? AppColors.danger : AppColors.warning; final surface = halted ? AppColors.dangerSurface : AppColors.warningSurface; final retry = engine.nextAttemptAt; final retryNote = halted ? 'Retrying will not help until this is fixed.' : retry == null ? 'It will retry automatically.' : 'It will retry automatically at ${Formatters.time(retry)}.'; return Container( margin: const EdgeInsets.only(bottom: AppSpacing.lg), padding: const EdgeInsets.all(AppSpacing.md), decoration: BoxDecoration(color: surface, borderRadius: AppRadius.brLg), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.wifi_off_rounded, size: 20, color: color), const SizedBox(width: AppSpacing.md), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( halted ? 'Sync halted — ${engine.pending} bill(s) not sent' : "Couldn't reach the server — " '${engine.pending} bill(s) not sent', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: color, ), ), const SizedBox(height: 2), Text( engine.lastError ?? 'The last upload attempt failed.', style: TextStyle( fontSize: 12.5, color: color, height: 1.45, ), ), const SizedBox(height: 4), Text( '$retryNote Every bill is still safe on this terminal — ' 'nothing is lost while it waits.', style: const TextStyle( fontSize: 12, color: AppColors.textSecondary, height: 1.45, ), ), ], ), ), ], ), ); } }