404 lines
15 KiB
Dart
404 lines
15 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 '../../../core/widgets/primary_button.dart';
|
||
import '../../../data/sync/sync_engine.dart';
|
||
import '../../../domain/entities/transaction.dart';
|
||
import '../../../domain/repositories/sync_repository.dart';
|
||
import '../../sync/providers/sync_controller.dart';
|
||
import '../widgets/module_widgets.dart';
|
||
|
||
/// End-of-day sync.
|
||
///
|
||
/// Shows what the terminal produced today and uploads every bill still at
|
||
/// `sync_status = 0`. Accepted bills flip to 1; anything that fails stays at 0
|
||
/// and is retried on the next tap.
|
||
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 [];
|
||
final syncState = ref.watch(orderSyncProvider);
|
||
final events = ref.watch(syncEventsProvider);
|
||
|
||
// 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: 'Upload bills to server',
|
||
subtitle: r == null
|
||
? 'Reading today\u2019s trading from SQLite\u2026'
|
||
: '${Formatters.date(r.businessDate)} \u00b7 ${r.cashierName} '
|
||
'\u00b7 ${r.terminalId}',
|
||
action: TagChip(
|
||
pending > 0 ? '$pending pending' : 'All synced',
|
||
color: pending > 0 ? AppColors.warning : AppColors.success,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
if (r != null && !r.isEmpty) ...[
|
||
_row('Bills', '${r.billCount}'),
|
||
_row('Items sold', r.itemCount.toStringAsFixed(0)),
|
||
_row('Gross sales', Formatters.money(r.grossSales)),
|
||
_row('GST collected', Formatters.money(r.taxCollected)),
|
||
_row('Discount given', Formatters.money(r.discountGiven)),
|
||
_row('Average basket', Formatters.money(r.averageBasket)),
|
||
if (r.paymentBreakdown.isNotEmpty) ...[
|
||
const Divider(height: AppSpacing.xxl),
|
||
for (final e in r.paymentBreakdown.entries)
|
||
ProgressRow(
|
||
label: '${e.key.emoji} ${e.key.label}',
|
||
value: Formatters.money(e.value),
|
||
fraction:
|
||
r.grossSales <= 0 ? 0 : e.value / r.grossSales,
|
||
color: _methodColor(e.key),
|
||
),
|
||
],
|
||
const SizedBox(height: AppSpacing.lg),
|
||
],
|
||
|
||
if (syncState is SyncRunning) ...[
|
||
Text(
|
||
syncState.stage,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.sm),
|
||
ClipRRect(
|
||
borderRadius: AppRadius.brPill,
|
||
child: LinearProgressIndicator(
|
||
value: syncState.progress,
|
||
minHeight: 8,
|
||
backgroundColor: AppColors.divider,
|
||
valueColor:
|
||
const AlwaysStoppedAnimation<Color>(AppColors.primary),
|
||
),
|
||
),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
],
|
||
|
||
if (syncState is SyncFinished)
|
||
_outcomeBanner(syncState.outcome),
|
||
|
||
PrimaryButton(
|
||
label: pending > 0
|
||
? 'Sync $pending bill${pending == 1 ? '' : 's'}'
|
||
: 'Nothing to sync',
|
||
icon: Icons.cloud_upload_rounded,
|
||
large: true,
|
||
busy: syncState is SyncRunning,
|
||
onPressed: pending == 0
|
||
? null
|
||
: () => ref.read(orderSyncProvider.notifier).run(),
|
||
),
|
||
const SizedBox(height: AppSpacing.md),
|
||
const Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(Icons.shield_outlined,
|
||
size: 15, color: AppColors.textTertiary,),
|
||
SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Text(
|
||
'Bills are written to SQLite the moment a sale '
|
||
'completes. A failed upload changes nothing on disk — '
|
||
'every bill stays until the server confirms it.',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: AppColors.textTertiary,
|
||
height: 1.5,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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('Time', flex: 2, priority: 1),
|
||
TableCol('Total', flex: 2, numeric: true),
|
||
TableCol('Sync', flex: 2, numeric: true),
|
||
],
|
||
rows: rows
|
||
.map((o) => [
|
||
Cell(o.invoiceNumber, bold: true, mono: true),
|
||
Cell(Formatters.time(o.createdAt),
|
||
color: AppColors.textTertiary,),
|
||
Cell(Formatters.money(o.total), mono: true, bold: true),
|
||
TagChip(
|
||
o.isSynced ? 'Synced' : 'Pending',
|
||
color:
|
||
o.isSynced ? AppColors.success : AppColors.warning,
|
||
),
|
||
],)
|
||
.toList(),
|
||
),
|
||
),
|
||
|
||
if (events.isNotEmpty) ...[
|
||
const SizedBox(height: AppSpacing.lg),
|
||
PanelCard(
|
||
title: 'Sync history',
|
||
subtitle: 'This session',
|
||
child: ResponsiveTable(
|
||
columns: const [
|
||
TableCol('Event', flex: 3),
|
||
TableCol('Detail', flex: 5, priority: 1),
|
||
TableCol('Time', flex: 2, numeric: true),
|
||
],
|
||
rows: events
|
||
.map((e) => [
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(
|
||
e.type.isInbound
|
||
? Icons.cloud_download_rounded
|
||
: Icons.cloud_upload_rounded,
|
||
size: 15,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Flexible(child: Cell(e.type.label, bold: true)),
|
||
],
|
||
),
|
||
Cell(
|
||
e.error ?? e.summary,
|
||
color: e.error != null
|
||
? AppColors.danger
|
||
: AppColors.textSecondary,
|
||
),
|
||
Cell(Formatters.time(e.createdAt),
|
||
color: AppColors.textTertiary,),
|
||
],)
|
||
.toList(),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _outcomeBanner(SyncOutcome outcome) {
|
||
final ok = outcome.isSuccess;
|
||
final uploaded = outcome.uploaded;
|
||
final attempted = outcome.attempted;
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: AppSpacing.lg),
|
||
padding: const EdgeInsets.all(AppSpacing.md),
|
||
decoration: BoxDecoration(
|
||
color: ok ? AppColors.successSurface : AppColors.dangerSurface,
|
||
borderRadius: AppRadius.brSm,
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Icon(
|
||
ok ? Icons.check_circle_outline_rounded : Icons.wifi_off_rounded,
|
||
size: 18,
|
||
color: ok ? AppColors.success : AppColors.danger,
|
||
),
|
||
const SizedBox(width: AppSpacing.sm),
|
||
Expanded(
|
||
child: Text(
|
||
ok
|
||
? '$uploaded of $attempted bills uploaded and marked synced.'
|
||
: '${outcome.error}',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
height: 1.45,
|
||
color: ok ? AppColors.success : AppColors.danger,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
static Color _methodColor(PaymentMethod m) => switch (m) {
|
||
PaymentMethod.cash => AppColors.success,
|
||
PaymentMethod.card => AppColors.info,
|
||
PaymentMethod.upi => AppColors.primary,
|
||
PaymentMethod.wallet => AppColors.warning,
|
||
PaymentMethod.giftCard => AppColors.tierGold,
|
||
PaymentMethod.loyalty => AppColors.tierSilver,
|
||
};
|
||
|
||
Widget _row(String label, String value) => Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: AppSpacing.xs + 2),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
label,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 13.5,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: AppSpacing.md),
|
||
Text(
|
||
value,
|
||
style: const TextStyle(
|
||
fontSize: 13.5,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Flags a bill that could not reach the server on its own — the automatic
|
||
/// push right after checkout, not the manual "Sync" button below.
|
||
///
|
||
/// 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 — press Sync below '
|
||
'once it is sorted.'
|
||
: retry == null
|
||
? 'It will retry automatically, or press Sync below to try now.'
|
||
: 'It will retry automatically at ${Formatters.time(retry)}, or '
|
||
'press Sync below to try now.';
|
||
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|