Files
nearle_pos/lib/presentation/modules/screens/events_view.dart
2026-07-29 13:06:39 +05:30

313 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
),
),
],
),
);
}