217 lines
6.6 KiB
Dart
217 lines
6.6 KiB
Dart
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(),
|
|
),
|
|
);
|
|
}
|
|
}
|