second commit
This commit is contained in:
287
lib/presentation/modules/screens/events_view.dart
Normal file
287
lib/presentation/modules/screens/events_view.dart
Normal file
@@ -0,0 +1,287 @@
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user