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