Bills were persisted correctly but read back wrong. The read path rebuilt a cart from its lines alone, dropping bill-level discounts and loyalty, so every figure derived from a stored bill was overstated: the upload payload, the day archive and the shift report. A discounted 529 bill read back as 620. Money and data integrity - order_dao: restore bill_discount and points_redeemed when rebuilding a cart; keep the reconstruction tier-less so the membership discount is not applied twice. Trust the recorded total and points via SaleTransaction.storedTotal. - checkout_sale + order_dao.commitSale: write the bill, its stock movement and the loyalty update in one transaction. Previously a failure part-way through left a persisted bill the cashier believed had failed, inviting a duplicate. - checkout_sale: re-check every line against live stock. A parked bill resumed after its stock was sold passed validation and oversold. - catalogue_dao: allocate the invoice sequence in one transaction; the previous read-modify-write could hand two sales the same number and fail UNIQUE. - local_store: replay unsynced sales after a catalogue import, so a mid-shift re-import cannot restore stock that has already been sold. - payment_controller: stamp the signed-in operator on the bill instead of the hardcoded seed session, and pass the terminal id through. - cart: reconcile per-slab GST against the bill total so the parts sum to the whole on a tax invoice. Sync and reporting - sync_repository: drain unsynced bills in a loop rather than silently capping at one page; stop on rejection so rejected rows cannot loop forever. - sync_log_dao (new): persist the sync history to the sync_log table, which the schema already defined but nothing used. It was in memory, so the only record that bills had been uploaded died at restart. - Scope shift reports by cashier. day_archive is re-keyed to (business_date, cashier_name) so a till stays settleable after its bills are uploaded and deleted. Schema v4 with a migration that carries v3 rows across. Input and UI - barcode_service: consume machine-paced keystrokes so a scan cannot also land in the focused field, and raise the bar to 60ms/char while a text field has focus so typing a mobile number is not read as a scan. Clock and focus check injected so the behaviour is testable. - primary_button: make the label flexible; label plus trailing total overflowed the Charge button by up to 131px. - app_router: redirect instead of null-casting when the receipt route is entered without its transaction. - customer_repository: reduce the search query to digits so a punctuated mobile number matches. Cleanup - Remove TransactionRepository.save, CustomerRepository.recordSale and OrderDao.insertOrder, all superseded by commitSale. - dart fix across the tree; 251 analyzer issues down to 3 info-level. Tests: 23 passing / 15 failing -> 90 passing. Fixed the two defects that broke the existing suite (containsAll type argument, reset() needing a catalogue) and deleted the leftover template test. Added coverage for the order round trip, the day archive after a real sync, stock safety, checkout atomicity, the v3->v4 migration, scanner-versus-human input, and an app-level smoke test that renders every module. Note: bills already uploaded with a discount went up overstated. This stops it happening again but does not correct historical server data. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
313 lines
11 KiB
Dart
313 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.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 '../../../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);
|
||
|
||
final r = report.value;
|
||
|
||
return ModulePage(
|
||
children: [
|
||
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,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|