add stats page
All checks were successful
Release / meta (push) Successful in 20s
Release / linux-build (push) Successful in 7m21s
Release / android-build (push) Successful in 16m39s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s

This commit is contained in:
2026-01-01 12:50:27 +00:00
parent 1c15546b66
commit 7139cfcc99
9 changed files with 537 additions and 5 deletions

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/pages/profile.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/stats.dart';
class MorePage extends StatelessWidget {
const MorePage({super.key});
@@ -18,12 +19,18 @@ class MorePage extends StatelessWidget {
case '/profile':
page = const ProfilePage();
break;
case '/stats':
page = const StatsPage();
break;
case '/more/settings':
page = const SettingsPage();
break;
case '/more/profile':
page = const ProfilePage();
break;
case '/more/stats':
page = const StatsPage();
break;
case '/':
default:
page = _MoreHome();
@@ -54,6 +61,12 @@ class _MoreHome extends StatelessWidget {
onTap: () => Navigator.of(context).pushNamed('/more/profile'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.bar_chart),
title: const Text('Stats'),
onTap: () => Navigator.of(context).pushNamed('/more/stats'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Settings'),

View 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(),
),
);
}
}

View File

@@ -168,6 +168,208 @@ class YearlyMileage {
);
}
class StatsAbout {
final Map<int, StatsYear> years;
StatsAbout({required this.years});
factory StatsAbout.fromJson(Map<String, dynamic> json) {
final mileageByYear = <int, double>{};
final classByYear = <int, List<StatsClassMileage>>{};
final networkByYear = <int, List<StatsNetworkMileage>>{};
final stationByYear = <int, List<StatsStationVisits>>{};
final winnersByYear = <int, int>{};
void addYearMileage(dynamic entry) {
if (entry is Map<String, dynamic>) {
final year = entry['year'] is int
? entry['year'] as int
: int.tryParse('${entry['year']}');
if (year != null) {
mileageByYear[year] = _asDouble(entry['mileage']);
}
}
}
if (json['year_mileages'] is List) {
for (final entry in json['year_mileages']) {
if (entry is Map<String, dynamic>) {
addYearMileage(entry);
} else if (entry is Map) {
addYearMileage(entry
.map((key, value) => MapEntry(key.toString(), value)));
}
}
}
List<StatsClassMileage> parseClassList(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map((e) => StatsClassMileage.fromJson(
e.map((key, value) => MapEntry(key.toString(), value))))
.toList();
}
return const [];
}
List<StatsNetworkMileage> parseNetworkList(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map((e) => StatsNetworkMileage.fromJson(
e.map((key, value) => MapEntry(key.toString(), value))))
.toList();
}
return const [];
}
List<StatsStationVisits> parseStationList(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map((e) => StatsStationVisits.fromJson(
e.map((key, value) => MapEntry(key.toString(), value))))
.toList();
}
return const [];
}
void parseYearMap<T>(
dynamic source,
Map<int, T> target,
T Function(dynamic value) mapper,
) {
if (source is Map) {
source.forEach((key, value) {
final year = int.tryParse(key.toString());
if (year == null) return;
target[year] = mapper(value);
});
}
}
parseYearMap<List<StatsClassMileage>>(
json['top_classes'],
classByYear,
parseClassList,
);
parseYearMap<List<StatsNetworkMileage>>(
json['top_networks'],
networkByYear,
parseNetworkList,
);
parseYearMap<List<StatsStationVisits>>(
json['top_stations'],
stationByYear,
parseStationList,
);
if (json['year_winners'] is Map) {
(json['year_winners'] as Map).forEach((key, value) {
final year = int.tryParse(key.toString());
if (year == null) return;
if (value is List) {
winnersByYear[year] = value.length;
}
});
}
final years = <int>{
...mileageByYear.keys,
...classByYear.keys,
...networkByYear.keys,
...stationByYear.keys,
...winnersByYear.keys,
}..removeWhere((year) => year == 0);
final yearMap = <int, StatsYear>{};
for (final year in years) {
yearMap[year] = StatsYear(
year: year,
mileage: mileageByYear[year] ?? 0,
topClasses: classByYear[year] ?? const [],
topNetworks: networkByYear[year] ?? const [],
topStations: stationByYear[year] ?? const [],
winnerCount: winnersByYear[year] ?? 0,
);
}
return StatsAbout(years: yearMap);
}
List<StatsYear> get sortedYears {
final list = years.values.toList();
list.sort((a, b) => b.year.compareTo(a.year));
return list;
}
}
class StatsYear {
final int year;
final double mileage;
final List<StatsClassMileage> topClasses;
final List<StatsNetworkMileage> topNetworks;
final List<StatsStationVisits> topStations;
final int winnerCount;
StatsYear({
required this.year,
required this.mileage,
required this.topClasses,
required this.topNetworks,
required this.topStations,
required this.winnerCount,
});
}
class StatsClassMileage {
final String locoClass;
final double mileage;
StatsClassMileage({
required this.locoClass,
required this.mileage,
});
factory StatsClassMileage.fromJson(Map<String, dynamic> json) =>
StatsClassMileage(
locoClass: _asString(json['loco_class'], 'Unknown'),
mileage: _asDouble(json['mileage']),
);
}
class StatsNetworkMileage {
final String network;
final double mileage;
StatsNetworkMileage({
required this.network,
required this.mileage,
});
factory StatsNetworkMileage.fromJson(Map<String, dynamic> json) =>
StatsNetworkMileage(
network: _asString(json['network'], 'Unknown'),
mileage: _asDouble(json['mileage']),
);
}
class StatsStationVisits {
final String station;
final int visits;
StatsStationVisits({
required this.station,
required this.visits,
});
factory StatsStationVisits.fromJson(Map<String, dynamic> json) =>
StatsStationVisits(
station: _asString(json['station'], 'Unknown'),
visits: _asInt(json['visits']),
);
}
class Loco {
final int id;
final String type, number, locoClass;

View File

@@ -128,7 +128,12 @@ class ApiService {
await _onUnauthorized!();
}
throw Exception('API error ${res.statusCode}: $body');
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: res.statusCode,
message: message,
body: body,
);
}
dynamic _decodeBody(http.Response res) {
@@ -149,4 +154,41 @@ class ApiService {
return res.body;
}
}
String _extractErrorMessage(dynamic body) {
if (body == null) return 'No response body';
if (body is String) return body;
if (body is Map<String, dynamic>) {
for (final key in ['message', 'error', 'detail', 'msg']) {
final val = body[key];
if (val is String && val.trim().isNotEmpty) return val;
}
return body.toString();
}
if (body is List) {
final parts = body
.map((e) => e is Map
? _extractErrorMessage(Map<String, dynamic>.from(e))
: e.toString())
.where((e) => e.trim().isNotEmpty)
.toList();
if (parts.isNotEmpty) return parts.join('; ');
}
return body.toString();
}
}
class ApiException implements Exception {
final int statusCode;
final String message;
final dynamic body;
ApiException({
required this.statusCode,
required this.message,
this.body,
});
@override
String toString() => 'API error $statusCode: $message';
}

View File

@@ -11,3 +11,4 @@ part 'data_service_traction.dart';
part 'data_service_trips.dart';
part 'data_service_notifications.dart';
part 'data_service_badges.dart';
part 'data_service_stats.dart';

View File

@@ -28,6 +28,10 @@ class DataService extends ChangeNotifier {
// Homepage Data
HomepageStats? _homepageStats;
HomepageStats? get homepageStats => _homepageStats;
StatsAbout? _aboutStats;
StatsAbout? get aboutStats => _aboutStats;
bool _isAboutStatsLoading = false;
bool get isAboutStatsLoading => _isAboutStatsLoading;
// Legs Data
List<Leg> _legs = [];

View File

@@ -0,0 +1,28 @@
part of 'data_service.dart';
extension DataServiceStats on DataService {
Future<void> fetchAboutStats({bool force = false}) async {
if (_isAboutStatsLoading) return;
if (!force && _aboutStats != null) return;
_isAboutStatsLoading = true;
_notifyAsync();
try {
final json = await api.get('/stats/about');
if (json is Map<String, dynamic>) {
_aboutStats = StatsAbout.fromJson(json);
} else if (json is Map) {
_aboutStats = StatsAbout.fromJson(
json.map((key, value) => MapEntry(key.toString(), value)),
);
} else {
throw Exception('Unexpected stats response: $json');
}
} catch (e) {
debugPrint('Failed to fetch about stats: $e');
_aboutStats = null;
} finally {
_isAboutStatsLoading = false;
_notifyAsync();
}
}
}

View File

@@ -15,6 +15,7 @@ import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/profile.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/stats.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
@@ -212,6 +213,10 @@ class _MyAppState extends State<MyApp> {
path: '/more/profile',
builder: (context, state) => const ProfilePage(),
),
GoRoute(
path: '/more/stats',
builder: (context, state) => const StatsPage(),
),
GoRoute(
path: '/more/settings',
builder: (context, state) => const SettingsPage(),