288 lines
11 KiB
Dart
288 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/sync_event.dart';
|
||
import '../../../domain/entities/transaction.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.
|
||
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 events = ref.watch(syncEventsProvider);
|
||
final pushing = ref.watch(reportPushProvider);
|
||
|
||
return ModulePage(
|
||
children: [
|
||
Wrap(
|
||
spacing: AppSpacing.lg,
|
||
runSpacing: AppSpacing.lg,
|
||
children: [
|
||
StatTile(
|
||
label: 'Bills Today',
|
||
value: '${report.billCount}',
|
||
icon: Icons.receipt_long_rounded,
|
||
caption: report.firstBillAt == null
|
||
? 'no sales yet'
|
||
: '${Formatters.time(report.firstBillAt!)} – '
|
||
'${Formatters.time(report.lastBillAt!)}',
|
||
),
|
||
StatTile(
|
||
label: 'Items Sold',
|
||
value: report.itemCount.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),
|
||
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',
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: AppSpacing.lg),
|
||
|
||
PanelCard(
|
||
title: 'Shift report',
|
||
subtitle: '${Formatters.date(report.businessDate)} · '
|
||
'${report.cashierName} · ${report.terminalId}',
|
||
action: TagChip(
|
||
report.isEmpty ? 'Nothing to send' : 'Ready to push',
|
||
color: report.isEmpty ? AppColors.textSecondary : AppColors.warning,
|
||
),
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
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),
|
||
),
|
||
],
|
||
|
||
const SizedBox(height: AppSpacing.xl),
|
||
PrimaryButton(
|
||
label: 'Push report to server',
|
||
icon: Icons.cloud_upload_rounded,
|
||
large: true,
|
||
busy: pushing,
|
||
onPressed: report.isEmpty
|
||
? 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.',
|
||
),
|
||
));
|
||
},
|
||
),
|
||
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(
|
||
'A failed push never discards data. The report stays '
|
||
'queued below and can be retried at any time.',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
color: AppColors.textTertiary,
|
||
height: 1.5,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
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(),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
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,
|
||
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,
|
||
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),
|
||
),
|
||
);
|
||
}
|
||
}
|