added local db

This commit is contained in:
2026-07-29 13:06:39 +05:30
parent d72522e737
commit d9886880cc
38 changed files with 2895 additions and 2261 deletions

View File

@@ -5,27 +5,28 @@ 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/sync_event.dart';
import '../../../domain/entities/transaction.dart';
import '../../../domain/repositories/sync_repository.dart';
import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart';
/// The terminal's outbound half: what today produced, and what has been sent.
/// 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});
static Color statusColor(SyncStatus s) => switch (s) {
SyncStatus.synced => AppColors.success,
SyncStatus.failed => AppColors.danger,
SyncStatus.syncing => AppColors.info,
SyncStatus.pending => AppColors.warning,
};
@override
Widget build(BuildContext context, WidgetRef ref) {
final report = ref.watch(shiftReportProvider);
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 pushing = ref.watch(reportPushProvider);
final r = report.value;
return ModulePage(
children: [
@@ -35,112 +36,110 @@ class EventsView extends ConsumerWidget {
children: [
StatTile(
label: 'Bills Today',
value: '${report.billCount}',
value: '${r?.billCount ?? 0}',
icon: Icons.receipt_long_rounded,
caption: report.firstBillAt == null
caption: r?.firstBillAt == null
? 'no sales yet'
: '${Formatters.time(report.firstBillAt!)} '
'${Formatters.time(report.lastBillAt!)}',
: '${Formatters.time(r!.firstBillAt!)} '
'${Formatters.time(r.lastBillAt!)}',
),
StatTile(
label: 'Items Sold',
value: report.itemCount.toStringAsFixed(0),
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(report.grossSales),
value: Formatters.money(r?.grossSales ?? 0),
icon: Icons.payments_rounded,
color: AppColors.success,
caption: 'gross takings',
),
StatTile(
label: 'Average Basket',
value: Formatters.money(report.averageBasket),
icon: Icons.trending_up_rounded,
color: AppColors.tierGold,
caption: 'per bill',
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: 'Shift report',
subtitle: '${Formatters.date(report.businessDate)} · '
'${report.cashierName} · ${report.terminalId}',
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(
report.isEmpty ? 'Nothing to send' : 'Ready to push',
color: report.isEmpty ? AppColors.textSecondary : AppColors.warning,
pending > 0 ? '$pending pending' : 'All synced',
color: pending > 0 ? AppColors.warning : AppColors.success,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
_row('Bills', '${report.billCount}'),
_row('Items sold', report.itemCount.toStringAsFixed(0)),
_row('Gross sales', Formatters.money(report.grossSales)),
_row('Net of tax', Formatters.money(report.netOfTax)),
_row('GST collected', Formatters.money(report.taxCollected)),
_row('Discount given', Formatters.money(report.discountGiven)),
_row('Round off', Formatters.money(report.roundOff)),
_row('Points issued', '${report.loyaltyPointsIssued}'),
_row('Points redeemed', '${report.loyaltyPointsRedeemed}'),
if (report.paymentBreakdown.isNotEmpty) ...[
const Divider(height: AppSpacing.xxl),
const Align(
alignment: Alignment.centerLeft,
child: Text(
'By payment method',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
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),
for (final e in report.paymentBreakdown.entries)
ProgressRow(
label: '${e.key.emoji} ${e.key.label}',
value: Formatters.money(e.value),
fraction: report.grossSales <= 0
? 0
: e.value / report.grossSales,
color: _methodColor(e.key),
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),
],
const SizedBox(height: AppSpacing.xl),
if (syncState is SyncFinished)
_outcomeBanner(syncState.outcome),
PrimaryButton(
label: 'Push report to server',
label: pending > 0
? 'Sync $pending bill${pending == 1 ? '' : 's'}'
: 'Nothing to sync',
icon: Icons.cloud_upload_rounded,
large: true,
busy: pushing,
onPressed: report.isEmpty
busy: syncState is SyncRunning,
onPressed: pending == 0
? null
: () async {
final event = await ref
.read(reportPushProvider.notifier)
.pushToday();
if (!context.mounted) return;
final ok = event.status == SyncStatus.synced;
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
backgroundColor:
ok ? AppColors.success : AppColors.danger,
content: Text(
ok
? 'Shift report sent.'
: 'Push failed — the report is still saved '
'on this terminal.',
),
));
},
: () => ref.read(orderSyncProvider.notifier).run(),
),
const SizedBox(height: AppSpacing.md),
const Row(
@@ -151,8 +150,9 @@ class EventsView extends ConsumerWidget {
SizedBox(width: AppSpacing.sm),
Expanded(
child: Text(
'A failed push never discards data. The report stays '
'queued below and can be retried at any time.',
'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,
@@ -168,64 +168,113 @@ class EventsView extends ConsumerWidget {
const SizedBox(height: AppSpacing.lg),
PanelCard(
title: 'Event log',
subtitle: '${events.length} recorded · '
'${events.where((e) => e.status != SyncStatus.synced).length} '
'outstanding',
child: events.isEmpty
? const Padding(
padding: EdgeInsets.symmetric(vertical: AppSpacing.lg),
child: Text(
'No sync activity yet. Importing the catalogue or pushing '
'a report will appear here.',
style: TextStyle(color: AppColors.textTertiary),
),
)
: ResponsiveTable(
columns: const [
TableCol('Event', flex: 3),
TableCol('Detail', flex: 5, priority: 1),
TableCol('Time', flex: 2, numeric: true, priority: 1),
TableCol('Status', 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),
e.status == SyncStatus.failed
? _RetryButton(eventId: e.id)
: TagChip(
e.status.label,
color: statusColor(e.status),
),
])
.toList(),
),
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,
@@ -242,6 +291,7 @@ class EventsView extends ConsumerWidget {
Expanded(
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13.5,
color: AppColors.textSecondary,
@@ -254,34 +304,9 @@ class EventsView extends ConsumerWidget {
style: const TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
);
}
class _RetryButton extends ConsumerWidget {
const _RetryButton({required this.eventId});
final String eventId;
@override
Widget build(BuildContext context, WidgetRef ref) {
final busy = ref.watch(reportPushProvider);
return TextButton.icon(
onPressed: busy
? null
: () => ref.read(reportPushProvider.notifier).retry(eventId),
icon: const Icon(Icons.refresh_rounded, size: 15),
label: const Text('Retry', style: TextStyle(fontSize: 12.5)),
style: TextButton.styleFrom(
foregroundColor: AppColors.danger,
minimumSize: const Size(0, 30),
padding: const EdgeInsets.symmetric(horizontal: AppSpacing.sm),
),
);
}
}

View File

@@ -7,7 +7,6 @@ import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_dimens.dart';
import '../../../core/utils/formatters.dart';
import '../../../domain/entities/store_account.dart';
import '../../../domain/entities/sync_event.dart';
import '../../auth/providers/auth_controller.dart';
import '../../sync/providers/sync_controller.dart';
import '../widgets/module_widgets.dart';
@@ -196,10 +195,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
Widget _connectivityCard() {
final ready = ref.watch(catalogueReadyProvider);
final lastImport = ref.watch(lastImportAtProvider);
final outstanding = ref
.watch(syncEventsProvider)
.where((e) => e.status != SyncStatus.synced)
.length;
final outstanding = ref.watch(unsyncedCountProvider).value ?? 0;
return PanelCard(
title: 'Connectivity & sync',
@@ -213,7 +209,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
'Last import',
lastImport == null ? 'Never' : Formatters.dateTime(lastImport),
),
_row('Outstanding pushes', '$outstanding'),
_row('Unsynced bills', '$outstanding'),
_toggle(
'Simulate offline',
'Forces import and push to fail, so you can confirm nothing is '
@@ -222,7 +218,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
(v) {
setState(() => _offline = v);
ref.read(remoteCatalogueProvider).simulateOffline = v;
ref.read(remoteReportSinkProvider).simulateOffline = v;
ref.read(remoteOrderSinkProvider).simulateOffline = v;
},
),
],
@@ -237,7 +233,7 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
children: [
_row('Application', '${AppConstants.appName} 1.0.0'),
_row('Terminal', 'TERM-01'),
_row('Data store', 'Local — offline first'),
_row('Data store', 'SQLite (on device)'),
const SizedBox(height: AppSpacing.md),
SizedBox(
width: double.infinity,
@@ -256,20 +252,24 @@ class _SettingsViewState extends ConsumerState<SettingsView> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 148,
Flexible(
flex: 3,
child: Text(
label,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
),
Expanded(
const SizedBox(width: AppSpacing.md),
Flexible(
flex: 4,
child: Text(
value,
textAlign: TextAlign.right,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,