From 29cecf0ded6202db11716d4c575a273dd0ef3f75 Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Fri, 2 Jan 2026 14:34:11 +0000 Subject: [PATCH] add android bundle release --- .gitea/workflows/release.yml | 60 +++++++ README.md | 71 ++++++-- lib/components/pages/dashboard.dart | 4 +- lib/components/pages/more.dart | 2 + .../pages/new_entry/new_entry_page.dart | 2 +- lib/components/pages/trips.dart | 2 +- lib/components/traction/traction_card.dart | 163 ++++++++++++++++++ lib/objects/objects.dart | 132 ++++++++++++-- .../data_service/data_service_core.dart | 3 +- .../data_service/data_service_trips.dart | 12 +- pubspec.yaml | 2 +- 11 files changed, 419 insertions(+), 34 deletions(-) diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index 923d6c3..8eb8948 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -155,6 +155,18 @@ jobs: - name: Build Android App Bundle (release) run: flutter build appbundle --release + - name: Archive Android App Bundle + env: + BASE_VERSION: ${{ needs.meta.outputs.base_version }} + run: | + set -euo pipefail + BUNDLE_SRC="build/app/outputs/bundle/release/app-release.aab" + if [ ! -f "$BUNDLE_SRC" ]; then + echo "Bundle not found at $BUNDLE_SRC" + exit 1 + fi + cp "$BUNDLE_SRC" "mileograph-${BASE_VERSION}.aab" + - name: Download bundletool run: | BUNDLETOOL_VERSION=1.15.6 @@ -201,6 +213,12 @@ jobs: name: android-apk path: mileograph-${{ needs.meta.outputs.base_version }}.apk + - name: Upload Android AAB artifact + uses: actions/upload-artifact@v3 + with: + name: android-aab + path: mileograph-${{ needs.meta.outputs.base_version }}.aab + linux-build: runs-on: - mileograph @@ -383,6 +401,13 @@ jobs: name: android-apk path: artifacts + - name: Download Android AAB + if: ${{ github.ref == 'refs/heads/dev' }} + uses: actions/download-artifact@v3 + with: + name: android-aab + path: artifacts + - name: Prepare APK and tag if: ${{ github.ref == 'refs/heads/dev' }} id: bundle @@ -397,11 +422,14 @@ jobs: VERSION="${BASE}${DEV_SUFFIX}" APK_NAME="mileograph-${VERSION}.apk" + AAB_NAME="mileograph-${VERSION}.aab" mv "artifacts/mileograph-${BASE}.apk" "artifacts/${APK_NAME}" + mv "artifacts/mileograph-${BASE}.aab" "artifacts/${AAB_NAME}" echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "apk=artifacts/${APK_NAME}" >> "$GITHUB_OUTPUT" + echo "aab=artifacts/${AAB_NAME}" >> "$GITHUB_OUTPUT" - name: Create prerelease on Gitea if: ${{ github.ref == 'refs/heads/dev' }} @@ -442,6 +470,18 @@ jobs: "${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \ >/dev/null + # Attach AAB + AAB="${{ steps.bundle.outputs.aab }}" + NAME_AAB=$(basename "$AAB") + echo "Uploading $NAME_AAB" + + curl -sS -X POST \ + -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ + -F "attachment=@${AAB}" \ + -F "name=${NAME_AAB}" \ + "${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \ + >/dev/null + release-master: runs-on: - mileograph @@ -467,6 +507,13 @@ jobs: name: android-apk path: artifacts + - name: Download Android AAB + if: ${{ github.ref == 'refs/heads/master' }} + uses: actions/download-artifact@v3 + with: + name: android-aab + path: artifacts + - name: Prepare APK and tag if: ${{ github.ref == 'refs/heads/master' }} id: bundle @@ -476,6 +523,7 @@ jobs: echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT" + echo "aab=artifacts/mileograph-${BASE}.aab" >> "$GITHUB_OUTPUT" - name: Create release on Gitea if: ${{ github.ref == 'refs/heads/master' }} @@ -514,3 +562,15 @@ jobs: -F "name=${NAME}" \ "${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \ >/dev/null + + # Attach AAB + AAB="${{ steps.bundle.outputs.aab }}" + NAME_AAB=$(basename "$AAB") + echo "Uploading $NAME_AAB" + + curl -sS -X POST \ + -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \ + -F "attachment=@${AAB}" \ + -F "name=${NAME_AAB}" \ + "${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \ + >/dev/null diff --git a/README.md b/README.md index cf62f62..90b1976 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,67 @@ -# mileograph_flutter +# Mileograph (Flutter) -A new Flutter project. +Mileograph is a Flutter client for logging and analysing railway journeys. It lets you record legs, group them into trips, track locomotive mileage, and view stats and leaderboards. -## Getting Started +## Features +- Add and edit journey legs with traction, timings, routes, notes, and delays. +- Group legs into trips and see mileage totals and traction stats. +- Browse traction, view loco details, mileage leaderboards, timelines, and legs. +- Dashboard with homepage stats, “On This Day”, recent traction changes, and trips. +- Profile badges, class clearance progress, and traction progress. +- Offline-friendly UI built with Provider, GoRouter, and Material 3 styling. -This project is a starting point for a Flutter application. +## Project layout +- `lib/objects/objects.dart` — shared model classes and helpers. +- `lib/services/` — API, data loading, auth, endpoints, distance units. +- `lib/components/` — UI pages and widgets (entries, traction, dashboard, trips, settings, etc.). +- `assets/` — icons/fonts and other bundled assets. -A few resources to get you started if this is your first Flutter project: +## Prerequisites +- Flutter SDK (3.x or later recommended). +- Dart SDK (bundled with Flutter). +- A Mileograph API endpoint (set in Settings within the app). -- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) -- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) +## Setup +1) Install Flutter: follow https://docs.flutter.dev/get-started/install and ensure `flutter doctor` is green. +2) Get dependencies: + ```bash + flutter pub get + ``` +3) Configure an API endpoint: + - Run the app, open Settings, and set the base URL for your Mileograph backend. + - The app probes the endpoint for a version string before saving. -For help getting started with Flutter development, view the -[online documentation](https://docs.flutter.dev/), which offers tutorials, -samples, guidance on mobile development, and a full API reference. +## Running +- Debug (mobile/web depending on your toolchain): + ```bash + flutter run + ``` +- Release build (example for Android): + ```bash + flutter build apk --release + ``` + +## Testing and linting +- Static analysis: `flutter analyze` +- Unit/widget tests (if present): `flutter test` + +## Contributing +1) Fork or branch from `main`. +2) Make changes with clear, small commits. +3) Add tests where feasible and keep `flutter analyze` clean. +4) Submit a PR describing: + - What changed and why. + - How to test or reproduce. + - Any API or migration notes. + +### Coding conventions +- Prefer stateless widgets where possible; keep state localized. +- Use existing services in `lib/services` for API access; add new endpoints there. +- Keep models in `objects.dart` (or nearby files) and use helper parsers for defensive JSON handling. +- Follow Material theming already in use; keep strings user-facing and concise. + +### Issue reporting +Include device/OS, Flutter version (`flutter --version`), steps to reproduce, expected vs. actual behaviour, and logs if available. + +## License +Copyright © Mileograph contributors. See repository terms if provided. diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index 823dc03..889d339 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -513,9 +513,9 @@ class _DashboardState extends State { Widget _buildTripsCard( BuildContext context, DataService data, DistanceUnitService distanceUnits) { final tripsUnsorted = data.trips; - List trips = []; + List trips = []; if (tripsUnsorted.isNotEmpty) { - trips = [...tripsUnsorted]..sort((a, b) => b.tripId.compareTo(a.tripId)); + trips = [...tripsUnsorted]..sort(TripSummary.compareByDateDesc); } return _panel( context, diff --git a/lib/components/pages/more.dart b/lib/components/pages/more.dart index 5b6eb01..ffae2e0 100644 --- a/lib/components/pages/more.dart +++ b/lib/components/pages/more.dart @@ -42,6 +42,8 @@ class MorePage extends StatelessWidget { } class _MoreHome extends StatelessWidget { + const _MoreHome({super.key}); + @override Widget build(BuildContext context) { return ListView( diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart index 9604815..4be4983 100644 --- a/lib/components/pages/new_entry/new_entry_page.dart +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -151,7 +151,7 @@ class _NewEntryPageState extends State { Widget _buildTripSelector(BuildContext context) { final trips = context.watch().tripList; - final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId)); + final sorted = [...trips]..sort(TripSummary.compareByDateDesc); final tripIds = sorted.map((t) => t.tripId).toSet(); final selectedValue = (_selectedTripId != null && tripIds.contains(_selectedTripId)) diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index e270d2c..5576e2b 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -232,7 +232,7 @@ class _TripsPageState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - 'Trip', + 'Trip #${trip.id}', style: Theme.of(context).textTheme.labelMedium, ), const SizedBox(height: 4), diff --git a/lib/components/traction/traction_card.dart b/lib/components/traction/traction_card.dart index 337832a..4dd84b1 100644 --- a/lib/components/traction/traction_card.dart +++ b/lib/components/traction/traction_card.dart @@ -1,5 +1,7 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; @@ -210,6 +212,11 @@ Future showTractionDetails( ) async { final hasMileageOrTrips = _hasMileageOrTrips(loco); final distanceUnits = context.read(); + final api = context.read(); + final leaderboardId = _leaderboardId(loco); + final leaderboardFuture = leaderboardId == null + ? Future.value(const []) + : _fetchLocoLeaderboard(api, leaderboardId); await showModalBottomSheet( context: context, isScrollControlled: true, @@ -295,6 +302,63 @@ Future showTractionDetails( _detailRow(context, 'EVN', loco.evn ?? ''), if (loco.notes != null && loco.notes!.isNotEmpty) _detailRow(context, 'Notes', loco.notes!), + const SizedBox(height: 16), + Text( + 'Leaderboard', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + FutureBuilder>( + future: leaderboardFuture, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 12.0), + child: Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ), + ); + } + if (snapshot.hasError) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + 'Failed to load leaderboard', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.error, + ), + ), + ); + } + final entries = snapshot.data ?? const []; + if (entries.isEmpty) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8.0), + child: Text( + 'No mileage leaderboard yet.', + style: Theme.of(context).textTheme.bodyMedium, + ), + ); + } + return Column( + children: entries.asMap().entries.map((entry) { + final rank = entry.key + 1; + return _leaderboardRow( + context, + rank, + entry.value, + distanceUnits, + ); + }).toList(), + ); + }, + ), ], ), ), @@ -307,6 +371,105 @@ Future showTractionDetails( ); } +Future> _fetchLocoLeaderboard( + ApiService api, + int locoId, +) async { + try { + final json = await api.get('/loco/leaderboard/id/$locoId'); + Iterable? raw; + if (json is List) { + raw = json; + } else if (json is Map) { + for (final key in ['data', 'leaderboard', 'results']) { + final value = json[key]; + if (value is List) { + raw = value; + break; + } + } + } + if (raw == null) return const []; + return raw.whereType().map((e) { + return LeaderboardEntry.fromJson( + e.map((key, value) => MapEntry(key.toString(), value)), + ); + }).toList(); + } catch (e) { + debugPrint('Failed to fetch loco leaderboard for $locoId: $e'); + rethrow; + } +} + +int? _leaderboardId(LocoSummary loco) { + int? parse(dynamic value) { + if (value == null) return null; + if (value is int) return value == 0 ? null : value; + if (value is num) return value.toInt() == 0 ? null : value.toInt(); + return int.tryParse(value.toString()); + } + + return parse(loco.extra['loco_id']) ?? + parse(loco.extra['id']) ?? + parse(loco.id); +} + +Widget _leaderboardRow( + BuildContext context, + int rank, + LeaderboardEntry entry, + DistanceUnitService distanceUnits, +) { + final theme = Theme.of(context); + final primaryName = + entry.userFullName.isNotEmpty ? entry.userFullName : entry.username; + final mileageLabel = distanceUnits.format(entry.mileage, decimals: 1); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Row( + children: [ + Container( + width: 36, + height: 36, + alignment: Alignment.center, + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '#$rank', + style: theme.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + color: theme.colorScheme.onPrimaryContainer, + ), + ), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + primaryName, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + Text( + mileageLabel, + style: theme.textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ); +} + Widget _detailRow(BuildContext context, String label, String value) { if (value.isEmpty) return const SizedBox.shrink(); return Padding( diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index fe0acce..c7b999e 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -66,6 +66,29 @@ DateTime _asDateTime(dynamic value, [DateTime? fallback]) { return parsed ?? (fallback ?? DateTime.fromMillisecondsSinceEpoch(0)); } +DateTime? _asNullableDateTime(dynamic value) { + if (value == null) return null; + if (value is DateTime) return value; + return DateTime.tryParse(value.toString()); +} + +int compareTripsByDateDesc( + DateTime? aDate, + DateTime? bDate, + int aId, + int bId, +) { + if (aDate != null && bDate != null) { + final cmp = bDate.compareTo(aDate); + if (cmp != 0) return cmp; + } else if (aDate != null) { + return -1; + } else if (bDate != null) { + return 1; + } + return bId.compareTo(aId); +} + class DestinationObject { const DestinationObject( this.label, @@ -443,7 +466,7 @@ class LocoSummary extends Loco { ); factory LocoSummary.fromJson(Map json) => LocoSummary( - locoId: json['loco_id'] ?? json['id'] ?? 0, + locoId: _asInt(json['loco_id'] ?? json['id']), locoType: json['type'] ?? json['loco_type'] ?? '', locoNumber: json['number'] ?? json['loco_number'] ?? '', locoName: json['name'] ?? json['loco_name'] ?? "", @@ -722,9 +745,12 @@ class TripSummary { final double tripMileage; final int legCount; final List locoStats; + final DateTime? startDate; + final DateTime? endDate; int get locoHadCount => locoStats.length; int get winnersCount => locoStats.where((e) => e.won).length; + DateTime? get primaryDate => endDate ?? startDate; TripSummary({ required this.tripId, @@ -732,20 +758,72 @@ class TripSummary { required this.tripMileage, this.legCount = 0, List? locoStats, + this.startDate, + this.endDate, }) : locoStats = locoStats ?? const []; - factory TripSummary.fromJson(Map json) => TripSummary( - tripId: _asInt(json['trip_id']), - tripName: _asString(json['trip_name']), - tripMileage: _asDouble(json['trip_mileage']), - legCount: _asInt( - json['leg_count'], - (json['trip_legs'] as List?)?.length ?? 0, - ), - locoStats: TripLocoStat.listFromJson( - json['stats'] ?? json['trip_locos'] ?? json['locos'], - ), - ); + static int compareByDateDesc(TripSummary a, TripSummary b) => + compareTripsByDateDesc(a.primaryDate, b.primaryDate, a.tripId, b.tripId); + + factory TripSummary.fromJson(Map json) { + DateTime? startDate; + DateTime? endDate; + + DateTime? parseDate(dynamic value) => _asNullableDateTime(value); + + for (final key in [ + 'trip_begin_time', + 'trip_start', + 'trip_start_time', + 'trip_date', + 'start_date', + 'date', + ]) { + startDate ??= parseDate(json[key]); + } + + for (final key in [ + 'trip_end_time', + 'trip_finish_time', + 'trip_end', + 'end_date', + ]) { + endDate ??= parseDate(json[key]); + } + + if (json['trip_legs'] is List) { + for (final leg in json['trip_legs'] as List) { + DateTime? begin; + if (leg is TripLeg) { + begin = leg.beginTime; + } else if (leg is Map) { + begin = parseDate(leg['leg_begin_time']); + } + if (begin == null) continue; + if (startDate == null || begin.isBefore(startDate)) { + startDate = begin; + } + if (endDate == null || begin.isAfter(endDate)) { + endDate = begin; + } + } + } + + return TripSummary( + tripId: _asInt(json['trip_id']), + tripName: _asString(json['trip_name']), + tripMileage: _asDouble(json['trip_mileage']), + legCount: _asInt( + json['leg_count'], + (json['trip_legs'] as List?)?.length ?? 0, + ), + locoStats: TripLocoStat.listFromJson( + json['stats'] ?? json['trip_locos'] ?? json['locos'], + ), + startDate: startDate, + endDate: endDate, + ); + } } class Leg { @@ -941,6 +1019,34 @@ class TripDetail { int get locoHadCount => locoStats.length; int get winnersCount => locoStats.where((e) => e.won).length; + DateTime? get startDate { + DateTime? earliest; + for (final leg in legs) { + final begin = leg.beginTime; + if (begin == null) continue; + if (earliest == null || begin.isBefore(earliest)) { + earliest = begin; + } + } + return earliest; + } + + DateTime? get endDate { + DateTime? latest; + for (final leg in legs) { + final begin = leg.beginTime; + if (begin == null) continue; + if (latest == null || begin.isAfter(latest)) { + latest = begin; + } + } + return latest; + } + + DateTime? get primaryDate => endDate ?? startDate; + + static int compareByDateDesc(TripDetail a, TripDetail b) => + compareTripsByDateDesc(a.primaryDate, b.primaryDate, a.id, b.id); TripDetail({ required this.id, diff --git a/lib/services/data_service/data_service_core.dart b/lib/services/data_service/data_service_core.dart index 6a5d774..b171419 100644 --- a/lib/services/data_service/data_service_core.dart +++ b/lib/services/data_service/data_service_core.dart @@ -151,7 +151,8 @@ class DataService extends ChangeNotifier { try { final json = await api.get('/stats/homepage'); _homepageStats = HomepageStats.fromJson(json); - _trips = _homepageStats?.trips ?? []; + _trips = [...(_homepageStats?.trips ?? const [])] + ..sort(TripSummary.compareByDateDesc); } catch (e) { debugPrint('Failed to fetch homepage stats: $e'); _homepageStats = null; diff --git a/lib/services/data_service/data_service_trips.dart b/lib/services/data_service/data_service_trips.dart index a2332b7..9223a6d 100644 --- a/lib/services/data_service/data_service_trips.dart +++ b/lib/services/data_service/data_service_trips.dart @@ -6,7 +6,7 @@ extension DataServiceTrips on DataService { try { final json = await api.get('/trips/info'); final tripDetails = _parseTripInfoList(json); - _tripDetails = [...tripDetails]..sort((a, b) => b.id.compareTo(a.id)); + _tripDetails = [...tripDetails]..sort(TripDetail.compareByDateDesc); _tripList = tripDetails .map( (detail) => TripSummary( @@ -15,10 +15,12 @@ extension DataServiceTrips on DataService { tripMileage: detail.mileage, legCount: detail.legCount, locoStats: detail.locoStats, + startDate: detail.startDate, + endDate: detail.endDate, ), ) .toList() - ..sort((a, b) => b.tripId.compareTo(a.tripId)); + ..sort(TripSummary.compareByDateDesc); } catch (e) { debugPrint('Failed to fetch trip_map: $e'); _tripDetails = []; @@ -49,7 +51,7 @@ extension DataServiceTrips on DataService { .map((e) => TripSummary.fromJson(e)) .toList(); - _tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId)); + _tripList = [...tripMap]..sort(TripSummary.compareByDateDesc); } else { debugPrint('Unexpected trip list response: $json'); _tripList = []; @@ -83,7 +85,7 @@ extension DataServiceTrips on DataService { .map((e) => TripSummary.fromJson(e)) .toList(); - _tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId)); + _tripList = [...tripMap]..sort(TripSummary.compareByDateDesc); } else { debugPrint('Unexpected trip list response: $json'); _tripList = []; @@ -105,7 +107,7 @@ extension DataServiceTrips on DataService { } else { _tripList = [trip, ..._tripList]; } - _tripList.sort((a, b) => b.tripId.compareTo(a.tripId)); + _tripList.sort(TripSummary.compareByDateDesc); _notifyAsync(); } diff --git a/pubspec.yaml b/pubspec.yaml index fdbbfca..dba6829 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 0.5.3+1 +version: 0.5.4+1 environment: sdk: ^3.8.1