add stats page
All checks were successful
All checks were successful
This commit is contained in:
216
lib/components/pages/stats.dart
Normal file
216
lib/components/pages/stats.dart
Normal file
@@ -0,0 +1,216 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:mileograph_flutter/objects/objects.dart';
|
||||
import 'package:mileograph_flutter/services/data_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
class StatsPage extends StatefulWidget {
|
||||
const StatsPage({super.key});
|
||||
|
||||
@override
|
||||
State<StatsPage> createState() => _StatsPageState();
|
||||
}
|
||||
|
||||
class _StatsPageState extends State<StatsPage> {
|
||||
final NumberFormat _mileageFormat = NumberFormat('#,##0.##');
|
||||
final NumberFormat _countFormat = NumberFormat.decimalPattern();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_loadStats();
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadStats({bool force = false}) {
|
||||
return context.read<DataService>().fetchAboutStats(force: force);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final data = context.watch<DataService>();
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Stats')),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () => _loadStats(force: true),
|
||||
child: _buildContent(data),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(DataService data) {
|
||||
final stats = data.aboutStats;
|
||||
final loading = data.isAboutStatsLoading;
|
||||
|
||||
if (loading && stats == null) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(height: 140),
|
||||
Center(child: CircularProgressIndicator()),
|
||||
SizedBox(height: 140),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
if (stats == null || stats.sortedYears.isEmpty) {
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
const SizedBox(height: 40),
|
||||
const Center(child: Text('No stats available yet.')),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _loadStats(force: true),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final years = stats.sortedYears;
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: years.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(bottom: index == years.length - 1 ? 0 : 12),
|
||||
child: _buildYearCard(context, years[index]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildYearCard(BuildContext context, StatsYear year) {
|
||||
final theme = Theme.of(context);
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
year.year.toString(),
|
||||
style: theme.textTheme.titleLarge,
|
||||
),
|
||||
const Spacer(),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
alignment: WrapAlignment.end,
|
||||
children: [
|
||||
_buildInfoChip(
|
||||
context,
|
||||
label: 'Mileage',
|
||||
value: '${_mileageFormat.format(year.mileage)} mi',
|
||||
),
|
||||
_buildInfoChip(
|
||||
context,
|
||||
label: 'Winners',
|
||||
value: _countFormat.format(year.winnerCount),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildSection<StatsClassMileage>(
|
||||
context,
|
||||
title: 'Top classes',
|
||||
items: year.topClasses,
|
||||
emptyLabel: 'No class data',
|
||||
itemBuilder: (item, index) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
title: Text(item.locoClass),
|
||||
trailing: Text('${_mileageFormat.format(item.mileage)} mi'),
|
||||
),
|
||||
),
|
||||
_buildSection<StatsNetworkMileage>(
|
||||
context,
|
||||
title: 'Top networks',
|
||||
items: year.topNetworks,
|
||||
emptyLabel: 'No network data',
|
||||
itemBuilder: (item, index) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
title: Text(item.network),
|
||||
trailing: Text('${_mileageFormat.format(item.mileage)} mi'),
|
||||
),
|
||||
),
|
||||
_buildSection<StatsStationVisits>(
|
||||
context,
|
||||
title: 'Top stations',
|
||||
items: year.topStations,
|
||||
emptyLabel: 'No station data',
|
||||
itemBuilder: (item, index) => ListTile(
|
||||
dense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
title: Text(item.station),
|
||||
trailing: Text(
|
||||
'${_countFormat.format(item.visits)} visit${item.visits == 1 ? '' : 's'}',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoChip(BuildContext context,
|
||||
{required String label, required String value}) {
|
||||
final theme = Theme.of(context);
|
||||
return Chip(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
label: Text(
|
||||
'$label: $value',
|
||||
style: theme.textTheme.labelLarge,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSection<T>(
|
||||
BuildContext context, {
|
||||
required String title,
|
||||
required List<T> items,
|
||||
required Widget Function(T item, int index) itemBuilder,
|
||||
String emptyLabel = 'No data',
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: ExpansionTile(
|
||||
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
childrenPadding:
|
||||
const EdgeInsets.only(left: 8, right: 8, bottom: 8),
|
||||
title: Text(
|
||||
title,
|
||||
style: theme.textTheme.titleMedium,
|
||||
),
|
||||
children: items.isEmpty
|
||||
? [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Text(
|
||||
emptyLabel,
|
||||
style: theme.textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
]
|
||||
: items
|
||||
.asMap()
|
||||
.entries
|
||||
.map((entry) => itemBuilder(entry.value, entry.key))
|
||||
.toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user