From 7139cfcc9936fb07f691c00c49d1afcbc27abd7e Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Thu, 1 Jan 2026 12:50:27 +0000 Subject: [PATCH] add stats page --- .gitea/workflows/release.yml | 29 ++- lib/components/pages/more.dart | 13 ++ lib/components/pages/stats.dart | 216 ++++++++++++++++++ lib/objects/objects.dart | 202 ++++++++++++++++ lib/services/api_service.dart | 44 +++- lib/services/data_service/data_service.dart | 1 + .../data_service/data_service_core.dart | 4 + .../data_service/data_service_stats.dart | 28 +++ lib/ui/app_shell.dart | 5 + 9 files changed, 537 insertions(+), 5 deletions(-) create mode 100644 lib/components/pages/stats.dart create mode 100644 lib/services/data_service/data_service_stats.dart diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 1a47060..d3d5f4b 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -20,6 +20,7 @@ jobs: outputs: base_version: ${{ steps.meta.outputs.base }} release_tag: ${{ steps.meta.outputs.release_tag }} + dev_suffix: ${{ steps.meta.outputs.dev_suffix }} steps: - name: Checkout uses: actions/checkout@v4 @@ -29,12 +30,24 @@ jobs: run: | RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml) BASE_VERSION=${RAW_VERSION%%+*} - TAG="v${BASE_VERSION}" + VERSION="${BASE_VERSION}" + TAG="v${VERSION}" + DEV_SUFFIX="" + if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then - TAG="v${BASE_VERSION}-dev" + DEV_ITER="${GITHUB_RUN_NUMBER:-}" + if [ -z "$DEV_ITER" ]; then + DEV_ITER=$(git rev-list --count HEAD) + fi + + DEV_SUFFIX="-dev.${DEV_ITER}" + VERSION="${BASE_VERSION}${DEV_SUFFIX}" + TAG="v${VERSION}" fi + echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT" echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "dev_suffix=${DEV_SUFFIX}" >> "$GITHUB_OUTPUT" - name: Fail if release already exists env: @@ -268,11 +281,19 @@ jobs: run: | BASE="${{ needs.meta.outputs.base_version }}" TAG="${{ needs.meta.outputs.release_tag }}" + DEV_SUFFIX="${{ needs.meta.outputs.dev_suffix }}" + if [ -z "$DEV_SUFFIX" ]; then + echo "dev_suffix is empty; expected '-dev.'" + exit 1 + fi - mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk" + VERSION="${BASE}${DEV_SUFFIX}" + APK_NAME="mileograph-${VERSION}.apk" + + mv "artifacts/mileograph-${BASE}.apk" "artifacts/${APK_NAME}" echo "tag=${TAG}" >> "$GITHUB_OUTPUT" - echo "apk=artifacts/mileograph-${BASE}-dev.apk" >> "$GITHUB_OUTPUT" + echo "apk=artifacts/${APK_NAME}" >> "$GITHUB_OUTPUT" - name: Create prerelease on Gitea if: ${{ github.ref == 'refs/heads/dev' }} diff --git a/lib/components/pages/more.dart b/lib/components/pages/more.dart index c22389f..5b6eb01 100644 --- a/lib/components/pages/more.dart +++ b/lib/components/pages/more.dart @@ -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'), diff --git a/lib/components/pages/stats.dart b/lib/components/pages/stats.dart new file mode 100644 index 0000000..db62485 --- /dev/null +++ b/lib/components/pages/stats.dart @@ -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 createState() => _StatsPageState(); +} + +class _StatsPageState extends State { + final NumberFormat _mileageFormat = NumberFormat('#,##0.##'); + final NumberFormat _countFormat = NumberFormat.decimalPattern(); + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + _loadStats(); + }); + } + + Future _loadStats({bool force = false}) { + return context.read().fetchAboutStats(force: force); + } + + @override + Widget build(BuildContext context) { + final data = context.watch(); + 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( + 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( + 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( + 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( + BuildContext context, { + required String title, + required List 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(), + ), + ); + } +} diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index b5fd0d1..fe0acce 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -168,6 +168,208 @@ class YearlyMileage { ); } +class StatsAbout { + final Map years; + + StatsAbout({required this.years}); + + factory StatsAbout.fromJson(Map json) { + final mileageByYear = {}; + final classByYear = >{}; + final networkByYear = >{}; + final stationByYear = >{}; + final winnersByYear = {}; + + void addYearMileage(dynamic entry) { + if (entry is Map) { + 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) { + addYearMileage(entry); + } else if (entry is Map) { + addYearMileage(entry + .map((key, value) => MapEntry(key.toString(), value))); + } + } + } + + List parseClassList(dynamic value) { + if (value is List) { + return value + .whereType() + .map((e) => StatsClassMileage.fromJson( + e.map((key, value) => MapEntry(key.toString(), value)))) + .toList(); + } + return const []; + } + + List parseNetworkList(dynamic value) { + if (value is List) { + return value + .whereType() + .map((e) => StatsNetworkMileage.fromJson( + e.map((key, value) => MapEntry(key.toString(), value)))) + .toList(); + } + return const []; + } + + List parseStationList(dynamic value) { + if (value is List) { + return value + .whereType() + .map((e) => StatsStationVisits.fromJson( + e.map((key, value) => MapEntry(key.toString(), value)))) + .toList(); + } + return const []; + } + + void parseYearMap( + dynamic source, + Map 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>( + json['top_classes'], + classByYear, + parseClassList, + ); + parseYearMap>( + json['top_networks'], + networkByYear, + parseNetworkList, + ); + parseYearMap>( + 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 = { + ...mileageByYear.keys, + ...classByYear.keys, + ...networkByYear.keys, + ...stationByYear.keys, + ...winnersByYear.keys, + }..removeWhere((year) => year == 0); + + final yearMap = {}; + 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 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 topClasses; + final List topNetworks; + final List 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 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 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 json) => + StatsStationVisits( + station: _asString(json['station'], 'Unknown'), + visits: _asInt(json['visits']), + ); +} + class Loco { final int id; final String type, number, locoClass; diff --git a/lib/services/api_service.dart b/lib/services/api_service.dart index fee6f25..3dc8865 100644 --- a/lib/services/api_service.dart +++ b/lib/services/api_service.dart @@ -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) { + 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.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'; } diff --git a/lib/services/data_service/data_service.dart b/lib/services/data_service/data_service.dart index 2acfbfa..b298da1 100644 --- a/lib/services/data_service/data_service.dart +++ b/lib/services/data_service/data_service.dart @@ -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'; diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 9638715..6a5d774 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -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 _legs = []; diff --git a/lib/services/data_service/data_service_stats.dart b/lib/services/data_service/data_service_stats.dart new file mode 100644 index 0000000..58d7f76 --- /dev/null +++ b/lib/services/data_service/data_service_stats.dart @@ -0,0 +1,28 @@ +part of 'data_service.dart'; + +extension DataServiceStats on DataService { + Future 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) { + _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(); + } + } +} diff --git a/lib/ui/app_shell.dart b/lib/ui/app_shell.dart index ecd6426..b4a28a0 100644 --- a/lib/ui/app_shell.dart +++ b/lib/ui/app_shell.dart @@ -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 { 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(),