import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../manager/analysis_chart_metric.dart'; import '../manager/analysis_range.dart'; 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( title: const Text('Cashier Analysis'), backgroundColor: theme.scaffoldBackgroundColor, surfaceTintColor: theme.scaffoldBackgroundColor, elevation: 0, scrolledUnderElevation: 0, ), body: RefreshIndicator( onRefresh: () async => ref.invalidate(analysisViewModelProvider), child: ListView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.fromLTRB(16, 8, 16, 96), children: [ _RangeSelector( selected: rangePreset, onSelected: (preset) => ref .read(analysisRangeNotifierProvider.notifier) .setRange(preset), ), const SizedBox(height: 12), stateAsync.when( data: (state) => _AnalysisBody( state: state, chartMetric: chartMetric, onMetricSelected: (metric) => ref .read(analysisChartMetricNotifierProvider.notifier) .setMetric(metric), ), 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 onSelected; @override Widget build(BuildContext context) { return SegmentedButton( showSelectedIcon: false, segments: AnalysisRangePreset.values .map( (preset) => ButtonSegment(value: preset, label: Text(preset.label)), ) .toList(growable: false), selected: {selected}, onSelectionChanged: (selection) { if (selection.isEmpty) return; onSelected(selection.first); }, ); } } class _AnalysisBody extends StatelessWidget { const _AnalysisBody({ required this.state, required this.chartMetric, required this.onMetricSelected, }); final AnalysisUiState state; final AnalysisChartMetric chartMetric; final ValueChanged onMetricSelected; @override Widget build(BuildContext context) { final summary = state.summary ?? AnalysisSummaryUiModel.empty; final series = state.series ?? const []; 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: [ _ShiftSummaryCard(state: state), const SizedBox(height: 12), _CashierActionGrid(summary: summary, state: state), const SizedBox(height: 12), _ChartCard( selectedMetric: chartMetric, onMetricSelected: onMetricSelected, totalValue: totalValue, rangeLabel: state.rangeLabel ?? '', chart: AnalysisLineChart(values: values, height: 132), xLabels: series.map((p) => p.label).toList(growable: false), ), const SizedBox(height: 12), _OperationalNotes(state: state), ], ); } } 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(); } } class _ChartCard extends StatelessWidget { const _ChartCard({ required this.selectedMetric, required this.onMetricSelected, required this.totalValue, required this.rangeLabel, required this.chart, required this.xLabels, }); final AnalysisChartMetric selectedMetric; final ValueChanged onMetricSelected; final int totalValue; final String rangeLabel; final Widget chart; final List xLabels; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; return Container( decoration: _cardDecoration(context), 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( 'Counter trend', style: textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w800, ), ), const SizedBox(height: 2), Text( rangeLabel, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), ], ), ), Text( totalValue.toString(), style: textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w900, ), ), ], ), const SizedBox(height: 12), _ChartMetricSelector( selected: selectedMetric, onSelected: onMetricSelected, ), const SizedBox(height: 14), 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 onSelected; @override Widget build(BuildContext context) { return SegmentedButton( showSelectedIcon: false, segments: AnalysisChartMetric.values .map( (metric) => ButtonSegment(value: metric, label: Text(metric.label)), ) .toList(growable: false), selected: {selected}, onSelectionChanged: (selection) { if (selection.isEmpty) return; onSelected(selection.first); }, ); } } class _OperationalNotes extends StatelessWidget { const _OperationalNotes({required this.state}); final AnalysisUiState state; @override Widget build(BuildContext context) { 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; 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, ), ), 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.', ), ], ), ), ); } } class _HeroMetric extends StatelessWidget { const _HeroMetric({ required this.label, required this.value, required this.icon, }); final String label; final String value; final IconData icon; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; 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, ), ), ], ), ); } } class _ActionMetricTile extends StatelessWidget { const _ActionMetricTile({ required this.width, required this.title, required this.value, required this.icon, }); final double width; 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; 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, ), ), 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, ), ), const SizedBox(height: 2), Text( title, maxLines: 2, overflow: TextOverflow.ellipsis, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, fontWeight: FontWeight.w600, ), ), ], ), ), ], ), ), ), ); } } class _InlineStatBar extends StatelessWidget { const _InlineStatBar({required this.items}); 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; final String value; @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; final IconData icon; @override Widget build(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; return Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), decoration: BoxDecoration( 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, ), ), ], ), ); } } 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, ), ), const SizedBox(height: 2), Text( body, style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, height: 1.35, ), ), ], ), ), ], ); } } class _XAxisLabels extends StatelessWidget { const _XAxisLabels({required this.labels}); final List 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 = {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), ), ), ); }), ); } } class _LoadingBody extends StatelessWidget { const _LoadingBody(); @override Widget build(BuildContext context) { return const Padding( padding: EdgeInsets.only(top: 40), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ CircularProgressIndicator(), SizedBox(height: 12), Text('Loading cashier loyalty analysis...'), ], ), ), ); } } 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: [ const Text('Unable to load cashier analysis'), const SizedBox(height: 10), TextButton(onPressed: onRetry, child: const Text('Retry')), ], ), ), ); } } 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), ), ], ); }