2026-04-09 06:47:03 +00:00
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
2026-04-23 04:29:34 +00:00
|
|
|
|
2026-04-09 06:47:03 +00:00
|
|
|
import '../manager/analysis_chart_metric.dart';
|
2026-04-23 04:29:34 +00:00
|
|
|
import '../manager/analysis_range.dart';
|
2026-04-09 06:47:03 +00:00
|
|
|
import '../manager/analysis_ui_state.dart';
|
|
|
|
|
import '../manager/analysis_view_model.dart';
|
|
|
|
|
import '../widgets/analysis_line_chart.dart';
|
|
|
|
|
|
|
|
|
|
class AnalysisPage extends ConsumerWidget {
|
|
|
|
|
const AnalysisPage({super.key});
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
|
|
|
final theme = Theme.of(context);
|
|
|
|
|
final rangePreset = ref.watch(analysisRangeNotifierProvider);
|
|
|
|
|
final chartMetric = ref.watch(analysisChartMetricNotifierProvider);
|
|
|
|
|
final stateAsync = ref.watch(analysisViewModelProvider);
|
|
|
|
|
|
|
|
|
|
return Scaffold(
|
|
|
|
|
backgroundColor: theme.scaffoldBackgroundColor,
|
|
|
|
|
appBar: AppBar(
|
2026-04-23 04:29:34 +00:00
|
|
|
title: const Text('Cashier Analysis'),
|
2026-04-09 06:47:03 +00:00
|
|
|
backgroundColor: theme.scaffoldBackgroundColor,
|
|
|
|
|
surfaceTintColor: theme.scaffoldBackgroundColor,
|
|
|
|
|
elevation: 0,
|
|
|
|
|
scrolledUnderElevation: 0,
|
|
|
|
|
),
|
|
|
|
|
body: RefreshIndicator(
|
|
|
|
|
onRefresh: () async => ref.invalidate(analysisViewModelProvider),
|
|
|
|
|
child: ListView(
|
|
|
|
|
physics: const AlwaysScrollableScrollPhysics(),
|
2026-04-23 04:29:34 +00:00
|
|
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 96),
|
2026-04-09 06:47:03 +00:00
|
|
|
children: [
|
|
|
|
|
_RangeSelector(
|
|
|
|
|
selected: rangePreset,
|
|
|
|
|
onSelected: (preset) => ref
|
|
|
|
|
.read(analysisRangeNotifierProvider.notifier)
|
|
|
|
|
.setRange(preset),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
stateAsync.when(
|
2026-04-23 04:29:34 +00:00
|
|
|
data: (state) => _AnalysisBody(
|
|
|
|
|
state: state,
|
|
|
|
|
chartMetric: chartMetric,
|
|
|
|
|
onMetricSelected: (metric) => ref
|
|
|
|
|
.read(analysisChartMetricNotifierProvider.notifier)
|
|
|
|
|
.setMetric(metric),
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
loading: () => const _LoadingBody(),
|
|
|
|
|
error: (error, stackTrace) => _ErrorBody(
|
|
|
|
|
onRetry: () => ref.invalidate(analysisViewModelProvider),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _RangeSelector extends StatelessWidget {
|
|
|
|
|
const _RangeSelector({required this.selected, required this.onSelected});
|
|
|
|
|
|
|
|
|
|
final AnalysisRangePreset selected;
|
|
|
|
|
final ValueChanged<AnalysisRangePreset> onSelected;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return SegmentedButton<AnalysisRangePreset>(
|
2026-04-23 04:29:34 +00:00
|
|
|
showSelectedIcon: false,
|
2026-04-09 06:47:03 +00:00
|
|
|
segments: AnalysisRangePreset.values
|
|
|
|
|
.map(
|
|
|
|
|
(preset) => ButtonSegment(value: preset, label: Text(preset.label)),
|
|
|
|
|
)
|
|
|
|
|
.toList(growable: false),
|
|
|
|
|
selected: <AnalysisRangePreset>{selected},
|
|
|
|
|
onSelectionChanged: (selection) {
|
|
|
|
|
if (selection.isEmpty) return;
|
|
|
|
|
onSelected(selection.first);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _AnalysisBody extends StatelessWidget {
|
2026-04-23 04:29:34 +00:00
|
|
|
const _AnalysisBody({
|
|
|
|
|
required this.state,
|
|
|
|
|
required this.chartMetric,
|
|
|
|
|
required this.onMetricSelected,
|
|
|
|
|
});
|
2026-04-09 06:47:03 +00:00
|
|
|
|
|
|
|
|
final AnalysisUiState state;
|
|
|
|
|
final AnalysisChartMetric chartMetric;
|
2026-04-23 04:29:34 +00:00
|
|
|
final ValueChanged<AnalysisChartMetric> onMetricSelected;
|
2026-04-09 06:47:03 +00:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final summary = state.summary ?? AnalysisSummaryUiModel.empty;
|
|
|
|
|
final series = state.series ?? const <AnalysisSeriesPointUiModel>[];
|
|
|
|
|
final values = series
|
|
|
|
|
.map(
|
|
|
|
|
(p) => switch (chartMetric) {
|
|
|
|
|
AnalysisChartMetric.scans => p.scanCount.toDouble(),
|
|
|
|
|
AnalysisChartMetric.pointsUsed => p.pointsUsed.toDouble(),
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
.toList(growable: false);
|
|
|
|
|
final totalValue = switch (chartMetric) {
|
|
|
|
|
AnalysisChartMetric.scans => summary.scans,
|
|
|
|
|
AnalysisChartMetric.pointsUsed => summary.pointsUsed,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
2026-04-23 04:29:34 +00:00
|
|
|
_ShiftSummaryCard(state: state),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_CashierActionGrid(summary: summary, state: state),
|
|
|
|
|
const SizedBox(height: 12),
|
2026-04-09 06:47:03 +00:00
|
|
|
_ChartCard(
|
2026-04-23 04:29:34 +00:00
|
|
|
selectedMetric: chartMetric,
|
|
|
|
|
onMetricSelected: onMetricSelected,
|
2026-04-09 06:47:03 +00:00
|
|
|
totalValue: totalValue,
|
2026-04-23 04:29:34 +00:00
|
|
|
rangeLabel: state.rangeLabel ?? '',
|
|
|
|
|
chart: AnalysisLineChart(values: values, height: 132),
|
|
|
|
|
xLabels: series.map((p) => p.label).toList(growable: false),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
2026-04-23 04:29:34 +00:00
|
|
|
_OperationalNotes(state: state),
|
2026-04-09 06:47:03 +00:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
class _ShiftSummaryCard extends StatelessWidget {
|
|
|
|
|
const _ShiftSummaryCard({required this.state});
|
|
|
|
|
|
|
|
|
|
final AnalysisUiState state;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
|
final summary = state.summary ?? AnalysisSummaryUiModel.empty;
|
|
|
|
|
final pointsPerScan = state.pointsPerScan ?? 0;
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
decoration: _cardDecoration(context),
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
width: 44,
|
|
|
|
|
height: 44,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: colorScheme.primaryContainer,
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
),
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: Icon(
|
|
|
|
|
Icons.point_of_sale_rounded,
|
|
|
|
|
color: colorScheme.onPrimaryContainer,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
'Loyalty counter',
|
|
|
|
|
style: textTheme.labelLarge?.copyWith(
|
|
|
|
|
color: colorScheme.onSurfaceVariant,
|
|
|
|
|
fontWeight: FontWeight.w700,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 2),
|
|
|
|
|
Text(
|
|
|
|
|
state.rangeLabel ?? 'Selected range',
|
|
|
|
|
style: textTheme.titleLarge?.copyWith(
|
|
|
|
|
fontWeight: FontWeight.w900,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
_StatusPill(
|
|
|
|
|
label: '${summary.successRatePercent}% success',
|
|
|
|
|
icon: Icons.verified_rounded,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 18),
|
|
|
|
|
Row(
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: _HeroMetric(
|
|
|
|
|
label: 'Member scans',
|
|
|
|
|
value: summary.scans.toString(),
|
|
|
|
|
icon: Icons.qr_code_scanner_rounded,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(width: 12),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: _HeroMetric(
|
|
|
|
|
label: 'Points redeemed',
|
|
|
|
|
value: summary.pointsUsed.toString(),
|
|
|
|
|
icon: Icons.redeem_rounded,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_InlineStatBar(
|
|
|
|
|
items: [
|
|
|
|
|
_InlineStatItem(
|
|
|
|
|
label: 'Active members',
|
|
|
|
|
value: summary.activeUsers.toString(),
|
|
|
|
|
),
|
|
|
|
|
_InlineStatItem(
|
|
|
|
|
label: 'Coins/scan',
|
|
|
|
|
value: pointsPerScan.toStringAsFixed(1),
|
|
|
|
|
),
|
|
|
|
|
_InlineStatItem(
|
|
|
|
|
label: 'Range users',
|
|
|
|
|
value: summary.users.toString(),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _CashierActionGrid extends StatelessWidget {
|
|
|
|
|
const _CashierActionGrid({required this.summary, required this.state});
|
|
|
|
|
|
|
|
|
|
final AnalysisSummaryUiModel summary;
|
|
|
|
|
final AnalysisUiState state;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final averageScansPerDay = state.averageScansPerDay ?? 0;
|
|
|
|
|
final averagePointsPerDay = state.averagePointsPerDay ?? 0;
|
|
|
|
|
final activeUserRatePercent = state.activeUserRatePercent ?? 0;
|
|
|
|
|
|
|
|
|
|
return LayoutBuilder(
|
|
|
|
|
builder: (context, constraints) {
|
|
|
|
|
final tileWidth = (constraints.maxWidth - 10) / 2;
|
|
|
|
|
|
|
|
|
|
return Wrap(
|
|
|
|
|
spacing: 10,
|
|
|
|
|
runSpacing: 10,
|
|
|
|
|
children: [
|
|
|
|
|
_ActionMetricTile(
|
|
|
|
|
width: tileWidth,
|
|
|
|
|
title: 'Avg scans/day',
|
|
|
|
|
value: averageScansPerDay.toStringAsFixed(1),
|
|
|
|
|
icon: Icons.timeline_rounded,
|
|
|
|
|
),
|
|
|
|
|
_ActionMetricTile(
|
|
|
|
|
width: tileWidth,
|
|
|
|
|
title: 'Avg coins/day',
|
|
|
|
|
value: averagePointsPerDay.toStringAsFixed(1),
|
|
|
|
|
icon: Icons.savings_rounded,
|
|
|
|
|
),
|
|
|
|
|
_ActionMetricTile(
|
|
|
|
|
width: tileWidth,
|
|
|
|
|
title: 'Active rate',
|
|
|
|
|
value: '$activeUserRatePercent%',
|
|
|
|
|
icon: Icons.people_alt_rounded,
|
|
|
|
|
),
|
|
|
|
|
_ActionMetricTile(
|
|
|
|
|
width: tileWidth,
|
|
|
|
|
title: 'Scan issues',
|
|
|
|
|
value: _failedScanCount(summary).toString(),
|
|
|
|
|
icon: Icons.error_outline_rounded,
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
int _failedScanCount(AnalysisSummaryUiModel summary) {
|
|
|
|
|
final failed = summary.scans * (100 - summary.successRatePercent) / 100;
|
|
|
|
|
return failed.round().clamp(0, summary.scans).toInt();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-09 06:47:03 +00:00
|
|
|
class _ChartCard extends StatelessWidget {
|
|
|
|
|
const _ChartCard({
|
2026-04-23 04:29:34 +00:00
|
|
|
required this.selectedMetric,
|
|
|
|
|
required this.onMetricSelected,
|
2026-04-09 06:47:03 +00:00
|
|
|
required this.totalValue,
|
2026-04-23 04:29:34 +00:00
|
|
|
required this.rangeLabel,
|
2026-04-09 06:47:03 +00:00
|
|
|
required this.chart,
|
|
|
|
|
required this.xLabels,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
final AnalysisChartMetric selectedMetric;
|
|
|
|
|
final ValueChanged<AnalysisChartMetric> onMetricSelected;
|
2026-04-09 06:47:03 +00:00
|
|
|
final int totalValue;
|
2026-04-23 04:29:34 +00:00
|
|
|
final String rangeLabel;
|
2026-04-09 06:47:03 +00:00
|
|
|
final Widget chart;
|
|
|
|
|
final List<String> xLabels;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
|
|
|
|
|
|
return Container(
|
2026-04-23 04:29:34 +00:00
|
|
|
decoration: _cardDecoration(context),
|
2026-04-09 06:47:03 +00:00
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
2026-04-23 04:29:34 +00:00
|
|
|
'Counter trend',
|
2026-04-09 06:47:03 +00:00
|
|
|
style: textTheme.titleMedium?.copyWith(
|
2026-04-23 04:29:34 +00:00
|
|
|
fontWeight: FontWeight.w800,
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
|
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
const SizedBox(height: 2),
|
2026-04-09 06:47:03 +00:00
|
|
|
Text(
|
2026-04-23 04:29:34 +00:00
|
|
|
rangeLabel,
|
2026-04-09 06:47:03 +00:00
|
|
|
style: textTheme.bodySmall?.copyWith(
|
|
|
|
|
color: colorScheme.onSurfaceVariant,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
Text(
|
|
|
|
|
totalValue.toString(),
|
|
|
|
|
style: textTheme.headlineSmall?.copyWith(
|
|
|
|
|
fontWeight: FontWeight.w900,
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 12),
|
2026-04-23 04:29:34 +00:00
|
|
|
_ChartMetricSelector(
|
|
|
|
|
selected: selectedMetric,
|
|
|
|
|
onSelected: onMetricSelected,
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 14),
|
2026-04-09 06:47:03 +00:00
|
|
|
chart,
|
|
|
|
|
if (xLabels.isNotEmpty) ...[
|
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
_XAxisLabels(labels: xLabels),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _ChartMetricSelector extends StatelessWidget {
|
|
|
|
|
const _ChartMetricSelector({
|
|
|
|
|
required this.selected,
|
|
|
|
|
required this.onSelected,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
final AnalysisChartMetric selected;
|
|
|
|
|
final ValueChanged<AnalysisChartMetric> onSelected;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return SegmentedButton<AnalysisChartMetric>(
|
2026-04-23 04:29:34 +00:00
|
|
|
showSelectedIcon: false,
|
2026-04-09 06:47:03 +00:00
|
|
|
segments: AnalysisChartMetric.values
|
|
|
|
|
.map(
|
|
|
|
|
(metric) => ButtonSegment(value: metric, label: Text(metric.label)),
|
|
|
|
|
)
|
|
|
|
|
.toList(growable: false),
|
|
|
|
|
selected: <AnalysisChartMetric>{selected},
|
|
|
|
|
onSelectionChanged: (selection) {
|
|
|
|
|
if (selection.isEmpty) return;
|
|
|
|
|
onSelected(selection.first);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
class _OperationalNotes extends StatelessWidget {
|
|
|
|
|
const _OperationalNotes({required this.state});
|
2026-04-09 06:47:03 +00:00
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
final AnalysisUiState state;
|
2026-04-09 06:47:03 +00:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-04-23 04:29:34 +00:00
|
|
|
final summary = state.summary ?? AnalysisSummaryUiModel.empty;
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
|
final pointsPerScan = state.pointsPerScan ?? 0;
|
|
|
|
|
final successRate = summary.successRatePercent;
|
|
|
|
|
final needsScanCheck = successRate < 98;
|
|
|
|
|
final hasHighRedemption = pointsPerScan >= 1.2;
|
2026-04-09 06:47:03 +00:00
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
return Container(
|
|
|
|
|
decoration: _cardDecoration(context),
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(16),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
'Cashier notes',
|
|
|
|
|
style: textTheme.titleMedium?.copyWith(
|
|
|
|
|
fontWeight: FontWeight.w800,
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
const SizedBox(height: 12),
|
|
|
|
|
_NoteRow(
|
|
|
|
|
icon: needsScanCheck
|
|
|
|
|
? Icons.build_circle_rounded
|
|
|
|
|
: Icons.check_circle_rounded,
|
|
|
|
|
title: needsScanCheck ? 'Check QR retry flow' : 'QR scans stable',
|
|
|
|
|
body: needsScanCheck
|
|
|
|
|
? 'Success is below target. Confirm lighting and member QR focus at the counter.'
|
|
|
|
|
: 'Scan success is within the expected cashier range.',
|
|
|
|
|
),
|
|
|
|
|
Divider(height: 24, color: colorScheme.outlineVariant),
|
|
|
|
|
_NoteRow(
|
|
|
|
|
icon: hasHighRedemption
|
|
|
|
|
? Icons.local_offer_rounded
|
|
|
|
|
: Icons.payments_rounded,
|
|
|
|
|
title: hasHighRedemption
|
|
|
|
|
? 'Redemption demand is high'
|
|
|
|
|
: 'Redemption pace is normal',
|
|
|
|
|
body: hasHighRedemption
|
|
|
|
|
? 'Keep reward terms visible before checkout to reduce voids.'
|
|
|
|
|
: 'Coin use is tracking cleanly against member scans.',
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
class _HeroMetric extends StatelessWidget {
|
|
|
|
|
const _HeroMetric({
|
|
|
|
|
required this.label,
|
|
|
|
|
required this.value,
|
|
|
|
|
required this.icon,
|
|
|
|
|
});
|
2026-04-09 06:47:03 +00:00
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
final String label;
|
|
|
|
|
final String value;
|
|
|
|
|
final IconData icon;
|
2026-04-09 06:47:03 +00:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
return Container(
|
|
|
|
|
constraints: const BoxConstraints(minHeight: 104),
|
|
|
|
|
padding: const EdgeInsets.all(12),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: colorScheme.surfaceContainerHighest.withOpacity(0.42),
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
),
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(icon, color: colorScheme.primary, size: 22),
|
|
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
Text(
|
|
|
|
|
value,
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: textTheme.headlineSmall?.copyWith(
|
|
|
|
|
fontWeight: FontWeight.w900,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 2),
|
|
|
|
|
Text(
|
|
|
|
|
label,
|
|
|
|
|
maxLines: 2,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: textTheme.bodySmall?.copyWith(
|
|
|
|
|
color: colorScheme.onSurfaceVariant,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
class _ActionMetricTile extends StatelessWidget {
|
|
|
|
|
const _ActionMetricTile({
|
|
|
|
|
required this.width,
|
2026-04-09 06:47:03 +00:00
|
|
|
required this.title,
|
|
|
|
|
required this.value,
|
|
|
|
|
required this.icon,
|
|
|
|
|
});
|
|
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
final double width;
|
2026-04-09 06:47:03 +00:00
|
|
|
final String title;
|
|
|
|
|
final String value;
|
|
|
|
|
final IconData icon;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
return SizedBox(
|
|
|
|
|
width: width,
|
|
|
|
|
child: Container(
|
|
|
|
|
constraints: const BoxConstraints(minHeight: 96),
|
|
|
|
|
decoration: _cardDecoration(context),
|
|
|
|
|
child: Padding(
|
|
|
|
|
padding: const EdgeInsets.all(12),
|
|
|
|
|
child: Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Container(
|
|
|
|
|
width: 36,
|
|
|
|
|
height: 36,
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
color: colorScheme.primaryContainer,
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
),
|
|
|
|
|
alignment: Alignment.center,
|
|
|
|
|
child: Icon(
|
|
|
|
|
icon,
|
|
|
|
|
color: colorScheme.onPrimaryContainer,
|
|
|
|
|
size: 20,
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
value,
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: textTheme.titleLarge?.copyWith(
|
|
|
|
|
fontWeight: FontWeight.w900,
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
const SizedBox(height: 2),
|
|
|
|
|
Text(
|
|
|
|
|
title,
|
|
|
|
|
maxLines: 2,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: textTheme.bodySmall?.copyWith(
|
|
|
|
|
color: colorScheme.onSurfaceVariant,
|
|
|
|
|
fontWeight: FontWeight.w600,
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
],
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
],
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
class _InlineStatBar extends StatelessWidget {
|
|
|
|
|
const _InlineStatBar({required this.items});
|
2026-04-09 06:47:03 +00:00
|
|
|
|
2026-04-23 04:29:34 +00:00
|
|
|
final List<_InlineStatItem> items;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
|
|
|
|
|
return Container(
|
|
|
|
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
|
|
|
|
decoration: BoxDecoration(
|
|
|
|
|
border: Border(
|
|
|
|
|
top: BorderSide(color: colorScheme.outlineVariant.withOpacity(0.7)),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
children: [
|
|
|
|
|
for (var i = 0; i < items.length; i++) ...[
|
|
|
|
|
if (i > 0)
|
|
|
|
|
SizedBox(
|
|
|
|
|
height: 34,
|
|
|
|
|
child: VerticalDivider(
|
|
|
|
|
width: 16,
|
|
|
|
|
color: colorScheme.outlineVariant,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
Expanded(child: items[i]),
|
|
|
|
|
],
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _InlineStatItem extends StatelessWidget {
|
|
|
|
|
const _InlineStatItem({required this.label, required this.value});
|
|
|
|
|
|
|
|
|
|
final String label;
|
2026-04-09 06:47:03 +00:00
|
|
|
final String value;
|
2026-04-23 04:29:34 +00:00
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
|
|
|
|
|
|
return Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
value,
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w900),
|
|
|
|
|
),
|
|
|
|
|
const SizedBox(height: 2),
|
|
|
|
|
Text(
|
|
|
|
|
label,
|
|
|
|
|
maxLines: 2,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: textTheme.labelSmall?.copyWith(
|
|
|
|
|
color: colorScheme.onSurfaceVariant,
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _StatusPill extends StatelessWidget {
|
|
|
|
|
const _StatusPill({required this.label, required this.icon});
|
|
|
|
|
|
|
|
|
|
final String label;
|
2026-04-09 06:47:03 +00:00
|
|
|
final IconData icon;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
|
|
|
|
|
|
return Container(
|
2026-04-23 04:29:34 +00:00
|
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
2026-04-09 06:47:03 +00:00
|
|
|
decoration: BoxDecoration(
|
2026-04-23 04:29:34 +00:00
|
|
|
color: colorScheme.secondaryContainer.withOpacity(0.78),
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
),
|
|
|
|
|
child: Row(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(icon, size: 15, color: colorScheme.onSecondaryContainer),
|
|
|
|
|
const SizedBox(width: 5),
|
|
|
|
|
Text(
|
|
|
|
|
label,
|
|
|
|
|
style: textTheme.labelMedium?.copyWith(
|
|
|
|
|
color: colorScheme.onSecondaryContainer,
|
|
|
|
|
fontWeight: FontWeight.w800,
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _NoteRow extends StatelessWidget {
|
|
|
|
|
const _NoteRow({required this.icon, required this.title, required this.body});
|
|
|
|
|
|
|
|
|
|
final IconData icon;
|
|
|
|
|
final String title;
|
|
|
|
|
final String body;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
final textTheme = Theme.of(context).textTheme;
|
|
|
|
|
|
|
|
|
|
return Row(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Icon(icon, color: colorScheme.primary, size: 22),
|
|
|
|
|
const SizedBox(width: 10),
|
|
|
|
|
Expanded(
|
|
|
|
|
child: Column(
|
|
|
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
|
|
|
children: [
|
|
|
|
|
Text(
|
|
|
|
|
title,
|
|
|
|
|
style: textTheme.titleSmall?.copyWith(
|
|
|
|
|
fontWeight: FontWeight.w800,
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
const SizedBox(height: 2),
|
|
|
|
|
Text(
|
|
|
|
|
body,
|
|
|
|
|
style: textTheme.bodySmall?.copyWith(
|
|
|
|
|
color: colorScheme.onSurfaceVariant,
|
|
|
|
|
height: 1.35,
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
],
|
|
|
|
|
),
|
2026-04-09 06:47:03 +00:00
|
|
|
),
|
2026-04-23 04:29:34 +00:00
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _XAxisLabels extends StatelessWidget {
|
|
|
|
|
const _XAxisLabels({required this.labels});
|
|
|
|
|
|
|
|
|
|
final List<String> labels;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
final textStyle = Theme.of(context).textTheme.labelSmall;
|
|
|
|
|
final onSurfaceVariant = Theme.of(context).colorScheme.onSurfaceVariant;
|
|
|
|
|
|
|
|
|
|
final count = labels.length;
|
|
|
|
|
final desiredTicks = 4;
|
|
|
|
|
final step = count <= desiredTicks ? 1 : (count - 1) ~/ (desiredTicks - 1);
|
|
|
|
|
|
|
|
|
|
final ticks = <int>{0, count - 1};
|
|
|
|
|
for (var i = 0; i < count; i += step) {
|
|
|
|
|
ticks.add(i);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return Row(
|
|
|
|
|
children: List.generate(count, (i) {
|
|
|
|
|
final show = ticks.contains(i);
|
|
|
|
|
return Expanded(
|
|
|
|
|
child: Align(
|
|
|
|
|
alignment: i == 0
|
|
|
|
|
? Alignment.centerLeft
|
|
|
|
|
: i == count - 1
|
|
|
|
|
? Alignment.centerRight
|
|
|
|
|
: Alignment.center,
|
|
|
|
|
child: Text(
|
|
|
|
|
show ? labels[i] : '',
|
|
|
|
|
maxLines: 1,
|
|
|
|
|
overflow: TextOverflow.ellipsis,
|
|
|
|
|
style: textStyle?.copyWith(color: onSurfaceVariant),
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}),
|
2026-04-09 06:47:03 +00:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _LoadingBody extends StatelessWidget {
|
|
|
|
|
const _LoadingBody();
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
2026-04-23 04:29:34 +00:00
|
|
|
return const Padding(
|
|
|
|
|
padding: EdgeInsets.only(top: 40),
|
2026-04-09 06:47:03 +00:00
|
|
|
child: Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
2026-04-23 04:29:34 +00:00
|
|
|
children: [
|
2026-04-09 06:47:03 +00:00
|
|
|
CircularProgressIndicator(),
|
|
|
|
|
SizedBox(height: 12),
|
2026-04-23 04:29:34 +00:00
|
|
|
Text('Loading cashier loyalty analysis...'),
|
2026-04-09 06:47:03 +00:00
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
class _ErrorBody extends StatelessWidget {
|
|
|
|
|
const _ErrorBody({required this.onRetry});
|
|
|
|
|
|
|
|
|
|
final VoidCallback onRetry;
|
|
|
|
|
|
|
|
|
|
@override
|
|
|
|
|
Widget build(BuildContext context) {
|
|
|
|
|
return Padding(
|
|
|
|
|
padding: const EdgeInsets.only(top: 40),
|
|
|
|
|
child: Center(
|
|
|
|
|
child: Column(
|
|
|
|
|
mainAxisSize: MainAxisSize.min,
|
|
|
|
|
children: [
|
2026-04-23 04:29:34 +00:00
|
|
|
const Text('Unable to load cashier analysis'),
|
2026-04-09 06:47:03 +00:00
|
|
|
const SizedBox(height: 10),
|
|
|
|
|
TextButton(onPressed: onRetry, child: const Text('Retry')),
|
|
|
|
|
],
|
|
|
|
|
),
|
|
|
|
|
),
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-04-23 04:29:34 +00:00
|
|
|
|
|
|
|
|
BoxDecoration _cardDecoration(BuildContext context) {
|
|
|
|
|
final colorScheme = Theme.of(context).colorScheme;
|
|
|
|
|
|
|
|
|
|
return BoxDecoration(
|
|
|
|
|
color: colorScheme.surface,
|
|
|
|
|
borderRadius: BorderRadius.circular(8),
|
|
|
|
|
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.55)),
|
|
|
|
|
boxShadow: [
|
|
|
|
|
BoxShadow(
|
|
|
|
|
color: Colors.black.withOpacity(0.05),
|
|
|
|
|
blurRadius: 12,
|
|
|
|
|
offset: const Offset(0, 5),
|
|
|
|
|
),
|
|
|
|
|
],
|
|
|
|
|
);
|
|
|
|
|
}
|