579 lines
16 KiB
Dart
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')),
|
||
|
|
],
|
||
|
|
),
|
||
|
|
),
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|