cb_prestige_qr/lib/features/analysis/presentation/pages/analysis_page.dart
2026-04-09 13:17:03 +06:30

579 lines
16 KiB
Dart

import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../manager/analysis_range.dart';
import '../manager/analysis_chart_metric.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('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, 12, 16, 24),
children: [
_RangeSelector(
selected: rangePreset,
onSelected: (preset) => ref
.read(analysisRangeNotifierProvider.notifier)
.setRange(preset),
),
const SizedBox(height: 10),
_ChartMetricSelector(
selected: chartMetric,
onSelected: (metric) => ref
.read(analysisChartMetricNotifierProvider.notifier)
.setMetric(metric),
),
const SizedBox(height: 12),
stateAsync.when(
data: (state) =>
_AnalysisBody(state: state, chartMetric: chartMetric),
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>(
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 {
const _AnalysisBody({required this.state, required this.chartMetric});
final AnalysisUiState state;
final AnalysisChartMetric chartMetric;
@override
Widget build(BuildContext context) {
final summary = state.summary ?? AnalysisSummaryUiModel.empty;
final series = state.series ?? const <AnalysisSeriesPointUiModel>[];
final rangeLabel = state.rangeLabel ?? '';
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,
};
final xLabels = series.map((p) => p.label).toList(growable: false);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_ChartCard(
title: chartMetric.label,
subtitle: rangeLabel,
totalValue: totalValue,
chart: AnalysisLineChart(values: values),
xLabels: xLabels,
),
const SizedBox(height: 12),
_InsightRow(state: state),
const SizedBox(height: 12),
_KpiGrid(summary: summary),
],
);
}
}
class _ChartCard extends StatelessWidget {
const _ChartCard({
required this.title,
required this.subtitle,
required this.totalValue,
required this.chart,
required this.xLabels,
});
final String title;
final String subtitle;
final int totalValue;
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(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 14,
offset: const Offset(0, 6),
),
],
),
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(
title,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
subtitle,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
totalValue.toString(),
style: textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 4),
const SizedBox.shrink(),
],
),
],
),
const SizedBox(height: 12),
chart,
if (xLabels.isNotEmpty) ...[
const SizedBox(height: 10),
_XAxisLabels(labels: xLabels),
],
],
),
),
);
}
}
class _KpiGrid extends StatelessWidget {
const _KpiGrid({required this.summary});
final AnalysisSummaryUiModel summary;
@override
Widget build(BuildContext context) {
return GridView.count(
crossAxisCount: 2,
crossAxisSpacing: 12,
mainAxisSpacing: 12,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
childAspectRatio: 1.5,
children: [
_KpiCard(
title: 'Scans',
value: summary.scans.toString(),
icon: Icons.qr_code_scanner_rounded,
),
_KpiCard(
title: 'Users',
value: summary.users.toString(),
icon: Icons.people_alt_rounded,
),
_KpiCard(
title: 'Active Users',
value: summary.activeUsers.toString(),
icon: Icons.person_pin_circle_rounded,
),
_KpiCard(
title: 'Points Used',
value: summary.pointsUsed.toString(),
icon: Icons.stars_rounded,
),
_KpiCard(
title: 'Points/User',
value: summary.users == 0
? '0'
: (summary.pointsUsed / summary.users).toStringAsFixed(2),
icon: Icons.functions_rounded,
),
_KpiCard(
title: 'Success Rate',
value: '${summary.successRatePercent}%',
icon: Icons.verified_rounded,
),
],
);
}
}
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>(
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);
},
);
}
}
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),
),
),
);
}),
);
}
}
class _InsightRow extends StatelessWidget {
const _InsightRow({required this.state});
final AnalysisUiState state;
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final textTheme = Theme.of(context).textTheme;
final averageScansPerDay = state.averageScansPerDay ?? 0;
final averagePointsPerDay = state.averagePointsPerDay ?? 0;
final pointsPerScan = state.pointsPerScan ?? 0;
final activeUserRatePercent = state.activeUserRatePercent ?? 0;
return Wrap(
spacing: 12,
runSpacing: 12,
children:
[
_InsightCard(
title: 'Avg scans/day',
value: averageScansPerDay.toStringAsFixed(1),
icon: Icons.stacked_line_chart_rounded,
),
_InsightCard(
title: 'Avg points/day',
value: averagePointsPerDay.toStringAsFixed(1),
icon: Icons.trending_up_rounded,
),
_InsightCard(
title: 'Points/scan',
value: pointsPerScan.toStringAsFixed(2),
icon: Icons.bolt_rounded,
),
_InsightCard(
title: 'Active rate',
value: '$activeUserRatePercent%',
icon: Icons.percent_rounded,
),
]
.map((w) {
return SizedBox(
width: (MediaQuery.of(context).size.width - 16 * 2 - 12) / 2,
child: w,
);
})
.toList(growable: false),
);
}
}
class _InsightCard extends StatelessWidget {
const _InsightCard({
required this.title,
required this.value,
required this.icon,
});
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 Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 14,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
child: Row(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Icon(
icon,
color: colorScheme.onPrimaryContainer,
size: 20,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
title,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
],
),
),
);
}
}
class _KpiCard extends StatelessWidget {
const _KpiCard({
required this.title,
required this.value,
required this.icon,
});
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 Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: colorScheme.outlineVariant.withOpacity(0.5)),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.06),
blurRadius: 14,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 14, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
shape: BoxShape.circle,
),
alignment: Alignment.center,
child: Icon(
icon,
color: colorScheme.onPrimaryContainer,
size: 20,
),
),
const Spacer(),
Text(
value,
style: textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(
title,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
],
),
),
);
}
}
class _LoadingBody extends StatelessWidget {
const _LoadingBody();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(top: 40),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: const [
CircularProgressIndicator(),
SizedBox(height: 12),
Text('Loading analytics...'),
],
),
),
);
}
}
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 analytics'),
const SizedBox(height: 10),
TextButton(onPressed: onRetry, child: const Text('Retry')),
],
),
),
);
}
}