From 45bd872b236faf94f54ad5c127dd8d1f3e4e137b Mon Sep 17 00:00:00 2001 From: petegregoryy Date: Tue, 27 Jan 2026 00:41:27 +0000 Subject: [PATCH] add support for network calculation from the calculator --- .../calculator/route_summary_widget.dart | 24 +++-- lib/components/legs/leg_card.dart | 56 ++++++++++- lib/components/login/login.dart | 2 +- lib/components/pages/calculator_details.dart | 88 ++++++++++++++++-- .../pages/loco_timeline/timeline_grid.dart | 8 +- .../pages/new_entry/new_entry_page.dart | 23 +++-- .../new_entry/new_entry_submit_logic.dart | 7 +- lib/components/pages/stats.dart | 15 +++ .../pages/traction/traction_page.dart | 14 ++- lib/objects/objects.dart | 93 +++++++++++++++++++ pubspec.yaml | 2 +- test/helpers/fake_services.dart | 22 ++--- 12 files changed, 299 insertions(+), 55 deletions(-) diff --git a/lib/components/calculator/route_summary_widget.dart b/lib/components/calculator/route_summary_widget.dart index b0c2d24..a25df51 100644 --- a/lib/components/calculator/route_summary_widget.dart +++ b/lib/components/calculator/route_summary_widget.dart @@ -40,6 +40,7 @@ class RouteDetailsView extends StatelessWidget { final List costs; final VoidCallback onBack; final Set routingPoints; + final VoidCallback? onNetworksPressed; const RouteDetailsView({ super.key, @@ -47,6 +48,7 @@ class RouteDetailsView extends StatelessWidget { required this.costs, required this.onBack, this.routingPoints = const {}, + this.onNetworksPressed, }); @override @@ -56,13 +58,21 @@ class RouteDetailsView extends StatelessWidget { final mutedColor = Theme.of(context).colorScheme.outlineVariant; return Column( children: [ - Align( - alignment: Alignment.centerLeft, - child: TextButton.icon( - onPressed: onBack, - icon: const Icon(Icons.arrow_back), - label: const Text('Back'), - ), + Row( + children: [ + TextButton.icon( + onPressed: onBack, + icon: const Icon(Icons.arrow_back), + label: const Text('Back'), + ), + const Spacer(), + if (onNetworksPressed != null) + TextButton.icon( + onPressed: onNetworksPressed, + icon: const Icon(Icons.account_tree), + label: const Text('Networks'), + ), + ], ), Expanded( child: ListView.builder( diff --git a/lib/components/legs/leg_card.dart b/lib/components/legs/leg_card.dart index 55257ad..19db11f 100644 --- a/lib/components/legs/leg_card.dart +++ b/lib/components/legs/leg_card.dart @@ -32,6 +32,8 @@ class _LegCardState extends State { final sharedTo = leg.sharedTo; final distanceUnits = context.watch(); final routeSegments = _parseRouteSegments(leg.route); + final networkMileage = _sortedNetworkMileage(leg); + final countryMileage = _sortedCountryMileage(leg); final textTheme = Theme.of(context).textTheme; return Card( clipBehavior: Clip.antiAlias, @@ -160,10 +162,11 @@ class _LegCardState extends State { ), ); } - if (leg.network.isNotEmpty) { + final networkSummary = _networkSummary(leg); + if (networkSummary != null) { children.add( Text( - leg.network, + networkSummary, style: textTheme.labelSmall, ), ); @@ -285,6 +288,28 @@ class _LegCardState extends State { ), const SizedBox(height: 12), ], + if (networkMileage.isNotEmpty || countryMileage.isNotEmpty) ...[ + Text('Network mileage', style: textTheme.titleSmall), + const SizedBox(height: 6), + ...networkMileage.map( + (entry) => Text( + '${entry.network}: ${distanceUnits.format(entry.miles, decimals: 1)}', + style: textTheme.bodyMedium, + ), + ), + if (countryMileage.isNotEmpty) ...[ + const SizedBox(height: 8), + Text('Country mileage', style: textTheme.titleSmall), + const SizedBox(height: 6), + ...countryMileage.map( + (entry) => Text( + '${entry.country}: ${distanceUnits.format(entry.miles, decimals: 1)}', + style: textTheme.bodyMedium, + ), + ), + ], + const SizedBox(height: 12), + ], if (routeSegments.isNotEmpty) ...[ Text('Route', style: textTheme.titleSmall), const SizedBox(height: 6), @@ -483,6 +508,33 @@ class _LegCardState extends State { List _parseRouteSegments(List route) { return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList(); } + + List _sortedNetworkMileage(Leg leg) { + final items = leg.networkMileage + .where((entry) => entry.network.trim().isNotEmpty) + .toList(); + items.sort((a, b) => b.miles.compareTo(a.miles)); + return items; + } + + List _sortedCountryMileage(Leg leg) { + final items = leg.countryMileage + .where((entry) => entry.country.trim().isNotEmpty) + .toList(); + items.sort((a, b) => b.miles.compareTo(a.miles)); + return items; + } + + String? _networkSummary(Leg leg) { + final networks = _sortedNetworkMileage(leg); + if (networks.isNotEmpty) { + return networks.map((entry) => entry.network).join(', '); + } + if (leg.network.trim().isNotEmpty) { + return leg.network; + } + return null; + } } class _SharedIcons extends StatelessWidget { diff --git a/lib/components/login/login.dart b/lib/components/login/login.dart index 8a9de53..d4f645f 100644 --- a/lib/components/login/login.dart +++ b/lib/components/login/login.dart @@ -192,7 +192,7 @@ class _LoginLogoState extends State<_LoginLogo> { } String _colorToHex(Color color) { - final hex = color.value.toRadixString(16).padLeft(8, '0'); + final hex = color.toARGB32().toRadixString(16).padLeft(8, '0'); return '#${hex.substring(2)}'; } } diff --git a/lib/components/pages/calculator_details.dart b/lib/components/pages/calculator_details.dart index 68f8181..6279fc8 100644 --- a/lib/components/pages/calculator_details.dart +++ b/lib/components/pages/calculator_details.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/components/calculator/route_summary_widget.dart'; import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; +import 'package:provider/provider.dart'; class CalculatorDetailsPage extends StatelessWidget { const CalculatorDetailsPage({ @@ -34,13 +36,85 @@ class CalculatorDetailsPage extends StatelessWidget { ); } - return Padding( - padding: const EdgeInsets.all(16.0), - child: RouteDetailsView( - route: parsed.calculatedRoute, - costs: parsed.costs, - routingPoints: parsed.inputRoute.toSet(), - onBack: () => context.pop(), + final networks = List.from(parsed.networkMileage) + ..sort((a, b) => b.miles.compareTo(a.miles)); + final countries = List.from(parsed.countryMileage) + ..sort((a, b) => b.miles.compareTo(a.miles)); + + return Scaffold( + endDrawer: _NetworksDrawer( + networks: networks, + countries: countries, + ), + body: Builder( + builder: (scaffoldContext) => Padding( + padding: const EdgeInsets.all(16.0), + child: RouteDetailsView( + route: parsed.calculatedRoute, + costs: parsed.costs, + routingPoints: parsed.inputRoute.toSet(), + onBack: () => context.pop(), + onNetworksPressed: () => + Scaffold.of(scaffoldContext).openEndDrawer(), + ), + ), + ), + ); + } +} + +class _NetworksDrawer extends StatelessWidget { + const _NetworksDrawer({ + required this.networks, + required this.countries, + }); + + final List networks; + final List countries; + + @override + Widget build(BuildContext context) { + final distanceUnits = context.watch(); + return Drawer( + child: SafeArea( + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text( + 'Networks', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (networks.isEmpty) + const Text('No network mileage data.') + else + ...networks.map( + (entry) => ListTile( + contentPadding: EdgeInsets.zero, + title: Text(entry.network), + trailing: + Text(distanceUnits.format(entry.miles, decimals: 2)), + ), + ), + const SizedBox(height: 16), + Text( + 'Countries', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + if (countries.isEmpty) + const Text('No country mileage data.') + else + ...countries.map( + (entry) => ListTile( + contentPadding: EdgeInsets.zero, + title: Text(entry.country), + trailing: + Text(distanceUnits.format(entry.miles, decimals: 2)), + ), + ), + ], + ), ), ); } diff --git a/lib/components/pages/loco_timeline/timeline_grid.dart b/lib/components/pages/loco_timeline/timeline_grid.dart index 8f083a4..ef1a6ab 100644 --- a/lib/components/pages/loco_timeline/timeline_grid.dart +++ b/lib/components/pages/loco_timeline/timeline_grid.dart @@ -813,17 +813,17 @@ List _buildBoundaries( for (final seg in segments) { boundaryDates.add(seg.start); boundaryDates.add(seg.end); - minStart = minStart == null || seg.start.isBefore(minStart!) + minStart = minStart == null || seg.start.isBefore(minStart) ? seg.start : minStart; - maxEnd = maxEnd == null || seg.end.isAfter(maxEnd!) ? seg.end : maxEnd; + maxEnd = maxEnd == null || seg.end.isAfter(maxEnd) ? seg.end : maxEnd; } - minStart ??= now.subtract(const Duration(days: 1)); + final effectiveMinStart = minStart ?? now.subtract(const Duration(days: 1)); final effectiveMaxEnd = maxEnd ?? now; boundaryDates.add(effectiveMaxEnd); var boundaries = boundaryDates.toList()..sort(); if (boundaries.length < 2) { - boundaries = [minStart!, effectiveMaxEnd]; + boundaries = [effectiveMinStart, effectiveMaxEnd]; } return boundaries; } diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart index 39cb2d9..4c60539 100644 --- a/lib/components/pages/new_entry/new_entry_page.dart +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -1399,16 +1399,18 @@ class _NewEntryPageState extends State { singleColumn: true, ), ), - const Divider(height: 24), - TextFormField( - controller: _networkController, - textCapitalization: TextCapitalization.characters, - inputFormatters: const [_UpperCaseTextFormatter()], - decoration: const InputDecoration( - labelText: 'Network', - border: OutlineInputBorder(), + if (_useManualMileage) ...[ + const Divider(height: 24), + TextFormField( + controller: _networkController, + textCapitalization: TextCapitalization.characters, + inputFormatters: const [_UpperCaseTextFormatter()], + decoration: const InputDecoration( + labelText: 'Network', + border: OutlineInputBorder(), + ), ), - ), + ], TextFormField( controller: _notesController, maxLines: 3, @@ -1520,6 +1522,9 @@ class _NewEntryPageState extends State { onSelected: (val) { setState(() { _useManualMileage = val; + if (!val) { + _networkController.clear(); + } if (val && _routeResult != null) { _mileageController.text = _formatDistance( distanceUnitService, diff --git a/lib/components/pages/new_entry/new_entry_submit_logic.dart b/lib/components/pages/new_entry/new_entry_submit_logic.dart index 1e5ad01..c344cb6 100644 --- a/lib/components/pages/new_entry/new_entry_submit_logic.dart +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -18,7 +18,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { } } - if (_networkController.text.trim().isEmpty) { + if (_useManualMileage && _networkController.text.trim().isEmpty) { missing.add('Network'); } @@ -93,7 +93,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { final isEditingExisting = _isEditing && widget.editLegId != null; try { - final commonPayload = { + final commonPayload = { if (isEditingExisting) "leg_id": widget.editLegId, "leg_trip": _selectedTripId, "leg_begin_time": _legDateTime.toIso8601String(), @@ -104,7 +104,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { "leg_destination_time": destinationTime.toIso8601String(), "leg_notes": _notesController.text.trim(), "leg_headcode": _headcodeController.text.trim(), - "leg_network": _networkController.text.trim(), + if (_useManualMileage) + "leg_network": _networkController.text.trim(), "leg_origin": _originController.text.trim(), "leg_destination": _destinationController.text.trim(), "leg_begin_delay": beginDelay, diff --git a/lib/components/pages/stats.dart b/lib/components/pages/stats.dart index 17a1189..4ed420b 100644 --- a/lib/components/pages/stats.dart +++ b/lib/components/pages/stats.dart @@ -170,6 +170,21 @@ class _StatsPageState extends State { ), ), ), + if (year.topCountries.isNotEmpty) + _buildSection( + context, + title: 'Top countries', + items: year.topCountries, + emptyLabel: 'No country data', + itemBuilder: (item, index) => ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + title: Text(item.country), + trailing: Text( + distanceUnits.format(item.mileage, decimals: 1), + ), + ), + ), _buildSection( context, title: 'Top stations', diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index a00b834..0bfd250 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -699,11 +699,8 @@ class _TractionPageState extends State { ); final hasAdminActions = isElevated; - final hasMoreMenu = true; - final moreButton = !hasMoreMenu - ? null - : PopupMenuButton<_TractionMoreAction>( + final moreButton = PopupMenuButton<_TractionMoreAction>( tooltip: 'More options', onSelected: (action) async { switch (action) { @@ -859,11 +856,11 @@ class _TractionPageState extends State { final desktopActions = [ refreshButton, newTractionButton, - if (moreButton != null) moreButton, + moreButton, ]; final mobileActions = [ - if (moreButton != null) moreButton, + moreButton, newTractionButton, refreshButton, ]; @@ -1041,8 +1038,9 @@ class _TractionPageState extends State { if (!mounted) return; errorMessage = e.toString(); } finally { - if (!mounted) return; - setModalState(() => uploading = false); + if (mounted) { + setModalState(() => uploading = false); + } } } diff --git a/lib/objects/objects.dart b/lib/objects/objects.dart index 12dc4d1..61af173 100644 --- a/lib/objects/objects.dart +++ b/lib/objects/objects.dart @@ -517,6 +517,7 @@ class StatsAbout { final mileageByYear = {}; final classByYear = >{}; final networkByYear = >{}; + final countryByYear = >{}; final stationByYear = >{}; final winnersByYear = {}; final winnerTypeCountsByYear = >{}; @@ -577,6 +578,17 @@ class StatsAbout { return const []; } + List parseCountryList(dynamic value) { + if (value is List) { + return value + .whereType() + .map((e) => StatsCountryMileage.fromJson( + e.map((key, value) => MapEntry(key.toString(), value)))) + .toList(); + } + return const []; + } + void parseYearMap( dynamic source, Map target, @@ -611,6 +623,11 @@ class StatsAbout { networkByYear, parseNetworkList, ); + parseYearMap>( + json['top_countries'], + countryByYear, + parseCountryList, + ); parseYearMap>( json['top_stations'], stationByYear, @@ -643,6 +660,7 @@ class StatsAbout { ...mileageByYear.keys, ...classByYear.keys, ...networkByYear.keys, + ...countryByYear.keys, ...stationByYear.keys, ...winnersByYear.keys, ...winnerTypeCountsByYear.keys, @@ -656,6 +674,7 @@ class StatsAbout { mileage: mileageByYear[year] ?? 0, topClasses: classByYear[year] ?? const [], topNetworks: networkByYear[year] ?? const [], + topCountries: countryByYear[year] ?? const [], topStations: stationByYear[year] ?? const [], winnerCount: winnersByYear[year] ?? 0, winnerTypeCounts: winnerTypeCountsByYear[year] ?? const {}, @@ -678,6 +697,7 @@ class StatsYear { final double mileage; final List topClasses; final List topNetworks; + final List topCountries; final List topStations; final int winnerCount; final Map winnerTypeCounts; @@ -688,6 +708,7 @@ class StatsYear { required this.mileage, required this.topClasses, required this.topNetworks, + required this.topCountries, required this.topStations, required this.winnerCount, required this.winnerTypeCounts, @@ -727,6 +748,22 @@ class StatsNetworkMileage { ); } +class StatsCountryMileage { + final String country; + final double mileage; + + StatsCountryMileage({ + required this.country, + required this.mileage, + }); + + factory StatsCountryMileage.fromJson(Map json) => + StatsCountryMileage( + country: _asString(json['country'], 'Unknown'), + mileage: _asDouble(json['mileage']), + ); +} + class StatsStationVisits { final String station; final int visits; @@ -1205,6 +1242,8 @@ class Leg { final String start, end, network, notes, headcode, user; final String origin, destination; final List route; + final List networkMileage; + final List countryMileage; final String? legShareId; final LegShareMeta? sharedFrom; final List sharedTo; @@ -1231,6 +1270,8 @@ class Leg { required this.driving, required this.user, required this.locos, + this.networkMileage = const [], + this.countryMileage = const [], this.endTime, this.originTime, this.destinationTime, @@ -1300,6 +1341,14 @@ class Leg { : _asInt(json['leg_end_delay']), origin: _asString(json['leg_origin']), destination: _asString(json['leg_destination']), + networkMileage: (json['network_mileage'] as List? ?? const []) + .whereType() + .map((e) => NetworkMileage.fromJson(Map.from(e))) + .toList(), + countryMileage: (json['country_mileage'] as List? ?? const []) + .whereType() + .map((e) => CountryMileage.fromJson(Map.from(e))) + .toList(), legShareId: _asString(json['leg_share_id']), sharedFrom: sharedFrom, sharedTo: sharedTo, @@ -1470,12 +1519,16 @@ class RouteResult { final List calculatedRoute; final List costs; final double distance; + final List networkMileage; + final List countryMileage; RouteResult({ required this.inputRoute, required this.calculatedRoute, required this.costs, required this.distance, + this.networkMileage = const [], + this.countryMileage = const [], }); factory RouteResult.fromJson(Map json) { @@ -1484,10 +1537,50 @@ class RouteResult { calculatedRoute: List.from(json['calculated_route']), costs: (json['costs'] as List).map((e) => (e as num).toDouble()).toList(), distance: (json['distance'] as num).toDouble(), + networkMileage: (json['network_mileage'] as List? ?? const []) + .whereType() + .map((e) => NetworkMileage.fromJson(Map.from(e))) + .toList(), + countryMileage: (json['country_mileage'] as List? ?? const []) + .whereType() + .map((e) => CountryMileage.fromJson(Map.from(e))) + .toList(), ); } } +class NetworkMileage { + final String network; + final double miles; + + NetworkMileage({ + required this.network, + required this.miles, + }); + + factory NetworkMileage.fromJson(Map json) => + NetworkMileage( + network: _asString(json['network']), + miles: _asDouble(json['miles']), + ); +} + +class CountryMileage { + final String country; + final double miles; + + CountryMileage({ + required this.country, + required this.miles, + }); + + factory CountryMileage.fromJson(Map json) => + CountryMileage( + country: _asString(json['country']), + miles: _asDouble(json['miles']), + ); +} + class Station { final int id; final String name; diff --git a/pubspec.yaml b/pubspec.yaml index de9b510..0fe74f8 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.7.8+16 +version: 0.8.0+17 environment: sdk: ^3.8.1 diff --git a/test/helpers/fake_services.dart b/test/helpers/fake_services.dart index f1b5624..3575d14 100644 --- a/test/helpers/fake_services.dart +++ b/test/helpers/fake_services.dart @@ -1,4 +1,3 @@ -import 'package:flutter/foundation.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/authservice.dart'; @@ -8,14 +7,18 @@ import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'test_data.dart'; class FakeApiService extends ApiService { - FakeApiService({String baseUrl = 'https://example.com'}) - : super(baseUrl: baseUrl); + FakeApiService({super.baseUrl = 'https://example.com'}); final Map getResponses = {}; final Map postResponses = {}; @override - Future get(String endpoint, {Map? headers}) async { + Future get( + String endpoint, { + Map? headers, + bool includeAuth = true, + bool allowRetry = true, + }) async { return getResponses[endpoint]; } @@ -24,6 +27,8 @@ class FakeApiService extends ApiService { String endpoint, dynamic data, { Map? headers, + bool includeAuth = true, + bool allowRetry = true, }) async { return postResponses[endpoint] ?? {}; } @@ -170,20 +175,16 @@ class FakeDataService extends DataService { @override Future fetchOnThisDay({DateTime? date}) async {} - @override Future fetchTripDetails() async {} - @override Future fetchHadTraction({int offset = 0, int limit = 100}) async {} - @override Future fetchLatestLocoChanges({ int limit = 100, int offset = 0, bool append = false, }) async {} - @override Future fetchClassClearanceProgress({ int offset = 0, int limit = 20, @@ -204,12 +205,10 @@ class FakeDataService extends DataService { List networkFilter = const [], }) async {} - @override Future> fetchTripLocoStats(int tripId) async { return const []; } - @override Future fetchTraction({ bool hadOnly = false, int offset = 0, @@ -221,7 +220,6 @@ class FakeDataService extends DataService { Map? filters, }) async {} - @override Future> fetchClassList({bool force = false}) async { return locoClassesValue; } @@ -235,12 +233,10 @@ class FakeDataService extends DataService { @override Future fetchStationNetworks() async {} - @override Future?> fetchClassStats(String locoClass) async { return TestData.classStats; } - @override Future> fetchClassLeaderboard( String locoClass, { bool friends = false,