From cea483ae0bdacb5c2ea5ba10873e94aba1efed5b Mon Sep 17 00:00:00 2001 From: Pete Gregory Date: Thu, 1 Jan 2026 15:28:11 +0000 Subject: [PATCH] Add ability to select distance unit --- lib/app.dart | 4 + .../calculator/route_summary_widget.dart | 10 +- .../dashboard/leaderboard_panel.dart | 7 +- .../dashboard/top_traction_panel.dart | 10 +- lib/components/legs/leg_card.dart | 4 +- lib/components/pages/dashboard.dart | 41 ++-- lib/components/pages/legs.dart | 18 +- lib/components/pages/new_entry/new_entry.dart | 1 + .../new_entry/new_entry_draft_logic.dart | 14 +- .../pages/new_entry/new_entry_drafts.dart | 4 +- .../pages/new_entry/new_entry_page.dart | 101 ++++++++- .../new_entry/new_entry_submit_logic.dart | 6 +- lib/components/pages/settings.dart | 32 ++- lib/components/pages/stats.dart | 45 ++-- lib/components/pages/traction/traction.dart | 1 + .../pages/traction/traction_page.dart | 25 ++- lib/components/pages/trips.dart | 31 ++- lib/components/traction/traction_card.dart | 21 +- lib/services/distance_unit_service.dart | 211 ++++++++++++++++++ pubspec.yaml | 4 +- 20 files changed, 505 insertions(+), 85 deletions(-) create mode 100644 lib/services/distance_unit_service.dart diff --git a/lib/app.dart b/lib/app.dart index ad9ed53..c721ea3 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/endpoint_service.dart'; import 'package:mileograph_flutter/ui/app_shell.dart'; import 'package:provider/provider.dart'; @@ -16,6 +17,9 @@ class App extends StatelessWidget { ChangeNotifierProvider( create: (_) => EndpointService(), ), + ChangeNotifierProvider( + create: (_) => DistanceUnitService(), + ), ProxyProvider( update: (_, endpoint, api) { final service = api ?? ApiService(baseUrl: endpoint.baseUrl); diff --git a/lib/components/calculator/route_summary_widget.dart b/lib/components/calculator/route_summary_widget.dart index 9845b88..b0c2d24 100644 --- a/lib/components/calculator/route_summary_widget.dart +++ b/lib/components/calculator/route_summary_widget.dart @@ -1,4 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; +import 'package:provider/provider.dart'; class RouteSummaryWidget extends StatelessWidget { final double distance; @@ -12,13 +14,14 @@ class RouteSummaryWidget extends StatelessWidget { @override Widget build(BuildContext context) { + final distanceUnits = context.watch(); return Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Expanded( child: Text( - "Total Distance: ${distance.toStringAsFixed(2)} mi", + "Total Distance: ${distanceUnits.format(distance, decimals: 2)}", style: Theme.of(context).textTheme.titleMedium, ), ), @@ -48,6 +51,7 @@ class RouteDetailsView extends StatelessWidget { @override Widget build(BuildContext context) { + final distanceUnits = context.watch(); final highlightColor = Theme.of(context).colorScheme.primary; final mutedColor = Theme.of(context).colorScheme.outlineVariant; return Column( @@ -78,7 +82,9 @@ class RouteDetailsView extends StatelessWidget { ? TextStyle(color: highlightColor, fontWeight: FontWeight.w600) : null, ), - trailing: Text("${costs[index].toStringAsFixed(2)} mi"), + trailing: Text( + distanceUnits.format(costs[index], decimals: 2), + ), ); }, ), diff --git a/lib/components/dashboard/leaderboard_panel.dart b/lib/components/dashboard/leaderboard_panel.dart index b69eb42..5fa6871 100644 --- a/lib/components/dashboard/leaderboard_panel.dart +++ b/lib/components/dashboard/leaderboard_panel.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; @@ -9,6 +10,7 @@ class LeaderboardPanel extends StatelessWidget { @override Widget build(BuildContext context) { final data = context.watch(); + final distanceUnits = context.watch(); final leaderboard = data.homepageStats?.leaderboard ?? []; final textTheme = Theme.of(context).textTheme; if (data.isHomepageLoading && leaderboard.isEmpty) { @@ -82,7 +84,10 @@ class LeaderboardPanel extends StatelessWidget { ), ), trailing: Text( - '${leaderboard[index].mileage.toStringAsFixed(1)} mi', + distanceUnits.format( + leaderboard[index].mileage, + decimals: 1, + ), style: textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), diff --git a/lib/components/dashboard/top_traction_panel.dart b/lib/components/dashboard/top_traction_panel.dart index f752462..c60636e 100644 --- a/lib/components/dashboard/top_traction_panel.dart +++ b/lib/components/dashboard/top_traction_panel.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/services/data_service.dart'; - +import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; class TopTractionPanel extends StatelessWidget { @@ -9,6 +9,7 @@ class TopTractionPanel extends StatelessWidget { @override Widget build(BuildContext context) { final data = context.watch(); + final distanceUnits = context.watch(); final stats = data.homepageStats; final locos = stats?.topLocos ?? []; final textTheme = Theme.of(context).textTheme; @@ -76,9 +77,12 @@ class TopTractionPanel extends StatelessWidget { style: textTheme.bodySmall?.copyWith( fontStyle: FontStyle.italic, ), - ), + ), trailing: Text( - '${locos[index].mileage?.toStringAsFixed(1)} mi', + distanceUnits.format( + locos[index].mileage ?? 0, + decimals: 1, + ), style: textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), diff --git a/lib/components/legs/leg_card.dart b/lib/components/legs/leg_card.dart index bb00682..63b1ca8 100644 --- a/lib/components/legs/leg_card.dart +++ b/lib/components/legs/leg_card.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; class LegCard extends StatefulWidget { @@ -26,6 +27,7 @@ class _LegCardState extends State { @override Widget build(BuildContext context) { final leg = widget.leg; + final distanceUnits = context.watch(); final routeSegments = _parseRouteSegments(leg.route); final textTheme = Theme.of(context).textTheme; return Card( @@ -181,7 +183,7 @@ class _LegCardState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - '${leg.mileage.toStringAsFixed(1)} mi', + distanceUnits.format(leg.mileage, decimals: 1), style: textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w700, ), diff --git a/lib/components/pages/dashboard.dart b/lib/components/pages/dashboard.dart index b5fdfed..823dc03 100644 --- a/lib/components/pages/dashboard.dart +++ b/lib/components/pages/dashboard.dart @@ -7,6 +7,7 @@ import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart' import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; class Dashboard extends StatefulWidget { @@ -23,6 +24,7 @@ class _DashboardState extends State { Widget build(BuildContext context) { final data = context.watch(); final auth = context.watch(); + final distanceUnits = context.watch(); final stats = data.homepageStats; final isInitialLoading = data.isHomepageLoading || stats == null; @@ -46,9 +48,15 @@ class _DashboardState extends State { ListView( padding: const EdgeInsets.all(16), children: [ - _buildHero(context, auth, data, stats), + _buildHero(context, auth, data, stats, distanceUnits), const SizedBox(height: spacing), - _buildTiles(context, data, maxWidth, spacing), + _buildTiles( + context, + data, + distanceUnits, + maxWidth, + spacing, + ), ], ), if (isInitialLoading) @@ -81,6 +89,7 @@ class _DashboardState extends State { AuthService auth, DataService data, HomepageStats? stats, + DistanceUnitService distanceUnits, ) { final colorScheme = Theme.of(context).colorScheme; final greetingName = @@ -119,14 +128,14 @@ class _DashboardState extends State { _metricTile( context, label: 'Total mileage', - value: '${totalMileage.toStringAsFixed(1)} mi', + value: distanceUnits.format(totalMileage, decimals: 1), icon: Icons.route, color: colorScheme.onPrimaryContainer, ), _metricTile( context, label: 'This year', - value: '${currentYearMileage.toStringAsFixed(1)} mi', + value: distanceUnits.format(currentYearMileage, decimals: 1), icon: Icons.calendar_today, color: colorScheme.onPrimaryContainer, ), @@ -215,6 +224,7 @@ class _DashboardState extends State { Widget _buildTiles( BuildContext context, DataService data, + DistanceUnitService distanceUnits, double maxWidth, double spacing, ) { @@ -229,9 +239,9 @@ class _DashboardState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildOnThisDayCard(context, data), + _buildOnThisDayCard(context, data, distanceUnits), const SizedBox(height: 16), - _buildTripsCard(context, data), + _buildTripsCard(context, data, distanceUnits), const SizedBox(height: 16), const LatestLocoChangesPanel(expanded: true), ], @@ -256,13 +266,13 @@ class _DashboardState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildOnThisDayCard(context, data), + _buildOnThisDayCard(context, data, distanceUnits), const SizedBox(height: 16), const TopTractionPanel(), const SizedBox(height: 16), const LeaderboardPanel(), const SizedBox(height: 16), - _buildTripsCard(context, data), + _buildTripsCard(context, data, distanceUnits), const SizedBox(height: 16), const LatestLocoChangesPanel(), ], @@ -296,7 +306,8 @@ class _DashboardState extends State { ); } - Widget _buildOnThisDayCard(BuildContext context, DataService data) { + Widget _buildOnThisDayCard( + BuildContext context, DataService data, DistanceUnitService distanceUnits) { final filtered = data.onThisDay .where((leg) => leg.beginTime.year != DateTime.now().year) .toList(); @@ -329,7 +340,7 @@ class _DashboardState extends State { : Column( children: [ for (int idx = 0; idx < visible.length; idx++) ...[ - _otdRow(context, visible[idx], textTheme), + _otdRow(context, visible[idx], textTheme, distanceUnits), if (idx != visible.length - 1) const Divider(height: 12), ], ], @@ -337,7 +348,8 @@ class _DashboardState extends State { ); } - Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme) { + Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme, + DistanceUnitService distanceUnits) { final traction = leg.locos; return Row( crossAxisAlignment: CrossAxisAlignment.center, @@ -443,7 +455,7 @@ class _DashboardState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - '${leg.mileage.toStringAsFixed(1)} mi', + distanceUnits.format(leg.mileage, decimals: 1), style: textTheme.labelLarge?.copyWith( fontWeight: FontWeight.w800, ), @@ -498,7 +510,8 @@ class _DashboardState extends State { ); } - Widget _buildTripsCard(BuildContext context, DataService data) { + Widget _buildTripsCard( + BuildContext context, DataService data, DistanceUnitService distanceUnits) { final tripsUnsorted = data.trips; List trips = []; if (tripsUnsorted.isNotEmpty) { @@ -543,7 +556,7 @@ class _DashboardState extends State { ?.copyWith(fontWeight: FontWeight.w700), ), Text( - '${trip.tripMileage.toStringAsFixed(1)} mi', + distanceUnits.format(trip.tripMileage, decimals: 1), style: Theme.of(context).textTheme.labelMedium, ), ], diff --git a/lib/components/pages/legs.dart b/lib/components/pages/legs.dart index b66d285..fbec1ea 100644 --- a/lib/components/pages/legs.dart +++ b/lib/components/pages/legs.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/components/legs/leg_card.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; class LegsPage extends StatefulWidget { @@ -90,6 +91,7 @@ class _LegsPageState extends State { @override Widget build(BuildContext context) { final data = context.watch(); + final distanceUnits = context.watch(); final legs = data.legs; final pageMileage = _pageMileage(legs); @@ -121,7 +123,7 @@ class _LegsPageState extends State { children: [ Text('Page mileage', style: Theme.of(context).textTheme.labelSmall), - Text('${pageMileage.toStringAsFixed(1)} mi', + Text(distanceUnits.format(pageMileage, decimals: 1), style: Theme.of(context) .textTheme .titleMedium @@ -212,7 +214,7 @@ class _LegsPageState extends State { else Column( children: [ - ..._buildLegsWithDividers(context, legs), + ..._buildLegsWithDividers(context, legs, distanceUnits), const SizedBox(height: 8), if (data.legsHasMore || data.isLegsLoading) Align( @@ -239,7 +241,11 @@ class _LegsPageState extends State { ); } - List _buildLegsWithDividers(BuildContext context, List legs) { + List _buildLegsWithDividers( + BuildContext context, + List legs, + DistanceUnitService distanceUnits, + ) { final widgets = []; String? currentDate; double dayMileage = 0; @@ -261,10 +267,8 @@ class _LegsPageState extends State { ), ), ), - Text( - '${dayMileage.toStringAsFixed(1)} mi', - style: Theme.of(context).textTheme.labelMedium, - ), + Text(distanceUnits.format(dayMileage, decimals: 1), + style: Theme.of(context).textTheme.labelMedium), ], ), ), diff --git a/lib/components/pages/new_entry/new_entry.dart b/lib/components/pages/new_entry/new_entry.dart index 7d2124d..db6c4ba 100644 --- a/lib/components/pages/new_entry/new_entry.dart +++ b/lib/components/pages/new_entry/new_entry.dart @@ -11,6 +11,7 @@ import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; diff --git a/lib/components/pages/new_entry/new_entry_draft_logic.dart b/lib/components/pages/new_entry/new_entry_draft_logic.dart index c9f88d9..98cb908 100644 --- a/lib/components/pages/new_entry/new_entry_draft_logic.dart +++ b/lib/components/pages/new_entry/new_entry_draft_logic.dart @@ -235,6 +235,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState { required String id, bool includeTimestamp = true, }) { + final units = _distanceUnits(context); final routeStations = _routeResult?.calculatedRoute ?? []; final endTime = _legEndDateTime; final originTime = _originDateTime; @@ -249,7 +250,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState { ? _endController.text.trim() : (routeStations.isNotEmpty ? routeStations.last : ''); final mileageVal = _useManualMileage - ? double.tryParse(_mileageController.text.trim()) ?? 0 + ? (units.milesFromInput(_mileageController.text.trim()) ?? 0) : (_routeResult?.distance ?? 0); final tractionPayload = _buildTractionPayload(); final commonPayload = { @@ -338,6 +339,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState { final destination = payload['leg_destination'] as String? ?? ''; final tripRaw = payload['leg_trip']; final tripId = tripRaw is num ? tripRaw.toInt() : null; + final units = _distanceUnits(context); List routeStations = []; RouteResult? restoredRouteResult; @@ -416,14 +418,20 @@ extension _NewEntryDraftLogic on _NewEntryPageState { final miles = (payload['leg_distance'] as num?)?.toDouble(); _mileageController.text = miles == null || miles == 0 ? '' - : miles.toStringAsFixed(2); + : units.format( + miles, + decimals: 2, + includeUnit: false, + ); } else { _startController.text = routeStations.isNotEmpty ? routeStations.first : ''; _endController.text = routeStations.isNotEmpty ? routeStations.last : ''; final dist = _routeResult?.distance ?? 0; - _mileageController.text = dist == 0 ? '' : dist.toStringAsFixed(2); + _mileageController.text = dist == 0 + ? '' + : units.format(dist, decimals: 2, includeUnit: false); } final tractionRaw = data['tractionItems']; diff --git a/lib/components/pages/new_entry/new_entry_drafts.dart b/lib/components/pages/new_entry/new_entry_drafts.dart index 1ca8339..ccbc8c8 100644 --- a/lib/components/pages/new_entry/new_entry_drafts.dart +++ b/lib/components/pages/new_entry/new_entry_drafts.dart @@ -150,6 +150,7 @@ class _DraftListBodyState extends State<_DraftListBody> { final payload = draft.data['payload']; if (payload is! Map) return ''; final map = Map.from(payload); + final units = context.read(); final parts = []; if ((map['leg_trip'] as int? ?? 0) != 0) { parts.add('Trip ${map['leg_trip']}'); @@ -164,7 +165,7 @@ class _DraftListBodyState extends State<_DraftListBody> { (map['leg_distance'] as num?)?.toDouble() ?? (map['leg_mileage'] as num?)?.toDouble(); if (mileage != null && mileage > 0) { - parts.add('${mileage.toStringAsFixed(1)} mi'); + parts.add(units.format(mileage, decimals: 1)); } else if (map['leg_route'] is List && (map['leg_route'] as List).isNotEmpty) { parts.add('Route ${(map['leg_route'] as List).length} stops'); @@ -176,4 +177,3 @@ class _DraftListBodyState extends State<_DraftListBody> { return parts.join(' • '); } } - diff --git a/lib/components/pages/new_entry/new_entry_page.dart b/lib/components/pages/new_entry/new_entry_page.dart index 2f0087b..2907100 100644 --- a/lib/components/pages/new_entry/new_entry_page.dart +++ b/lib/components/pages/new_entry/new_entry_page.dart @@ -51,6 +51,7 @@ class _NewEntryPageState extends State { final DeepCollectionEquality _snapshotEquality = const DeepCollectionEquality(); String? _activeDraftId; + DistanceUnit? _lastDistanceUnit; bool get _isEditing => widget.editLegId != null; bool get _draftPersistenceEnabled => @@ -100,6 +101,58 @@ class _NewEntryPageState extends State { setState(fn); } + DistanceUnitService _distanceUnits(BuildContext context) => + context.read(); + + String _formatDistance( + DistanceUnitService units, + double miles, { + int decimals = 1, + bool includeUnit = true, + }) { + return units.format( + miles, + decimals: decimals, + includeUnit: includeUnit, + ); + } + + String _manualMileageLabel(DistanceUnit unit) { + switch (unit) { + case DistanceUnit.milesDecimal: + return 'Mileage (mi)'; + case DistanceUnit.kilometers: + return 'Mileage (km)'; + case DistanceUnit.milesChains: + return 'Mileage (m.ch)'; + } + } + + double _manualMilesFromInput(DistanceUnitService units) { + return units.milesFromInput(_mileageController.text) ?? 0; + } + + double _milesFromInputWithUnit(DistanceUnit unit) { + return DistanceFormatter(unit) + .parseInputMiles(_mileageController.text.trim()) ?? + 0; + } + + void _syncManualFieldUnit(DistanceUnit currentUnit) { + if (!_useManualMileage) { + _lastDistanceUnit = currentUnit; + return; + } + final previousUnit = _lastDistanceUnit ?? currentUnit; + if (previousUnit == currentUnit) return; + + final miles = _milesFromInputWithUnit(previousUnit); + final nextText = DistanceFormatter(currentUnit) + .format(miles, decimals: 2, includeUnit: false); + _mileageController.text = nextText; + _lastDistanceUnit = currentUnit; + } + Widget _buildTripSelector(BuildContext context) { final trips = context.watch().tripList; final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId)); @@ -224,9 +277,15 @@ class _NewEntryPageState extends State { ), ); if (result != null) { + final units = _distanceUnits(context); setState(() { _routeResult = result; - _mileageController.text = result.distance.toStringAsFixed(2); + _mileageController.text = _formatDistance( + units, + result.distance, + decimals: 2, + includeUnit: false, + ); _useManualMileage = false; }); _saveDraft(); @@ -426,6 +485,7 @@ class _NewEntryPageState extends State { final endTime = DateTime.tryParse(json['leg_end_time'] ?? ''); final routeStations = _parseRouteStations(json['leg_route']); final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0; + final units = _distanceUnits(context); final useManual = routeStations.isEmpty; final routeResult = useManual ? null @@ -484,7 +544,12 @@ class _NewEntryPageState extends State { _endDelayController.text = endDelay.toString(); _mileageController.text = mileageVal == 0 ? '' - : mileageVal.toStringAsFixed(2); + : _formatDistance( + units, + mileageVal, + decimals: 2, + includeUnit: false, + ); _tractionItems ..clear() ..addAll(tractionItems); @@ -728,11 +793,15 @@ class _NewEntryPageState extends State { key: _formKey, child: LayoutBuilder( builder: (context, constraints) { + final distanceUnitService = context.watch(); + final currentDistanceUnit = distanceUnitService.unit; + _syncManualFieldUnit(currentDistanceUnit); final twoCol = !isMobile && constraints.maxWidth > 1000; final tractionEmpty = _tractionItems.length == 1; final mileageEmpty = !_useManualMileage && _routeResult == null; final balancePanels = twoCol && tractionEmpty && mileageEmpty; final balancedHeight = balancePanels ? 165.0 : null; + final mileageLabel = _manualMileageLabel(currentDistanceUnit); final entryPanel = _section('Entry', [ Row( @@ -948,9 +1017,13 @@ class _NewEntryPageState extends State { keyboardType: const TextInputType.numberWithOptions( decimal: true, ), - decoration: const InputDecoration( - labelText: 'Mileage (mi)', - border: OutlineInputBorder(), + decoration: InputDecoration( + labelText: mileageLabel, + helperText: currentDistanceUnit == + DistanceUnit.milesChains + ? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)' + : null, + border: const OutlineInputBorder(), ), ) else if (_routeResult != null) @@ -958,7 +1031,11 @@ class _NewEntryPageState extends State { contentPadding: EdgeInsets.zero, title: const Text('Calculated mileage'), subtitle: Text( - '${_routeResult!.distance.toStringAsFixed(2)} mi', + _formatDistance( + distanceUnitService, + _routeResult!.distance, + decimals: 2, + ), ), ) else @@ -973,7 +1050,17 @@ class _NewEntryPageState extends State { label: Text(_useManualMileage ? 'Manual' : 'Automatic'), selected: _useManualMileage, onSelected: (val) { - setState(() => _useManualMileage = val); + setState(() { + _useManualMileage = val; + if (val && _routeResult != null) { + _mileageController.text = _formatDistance( + distanceUnitService, + _routeResult!.distance, + decimals: 2, + includeUnit: false, + ); + } + }); _saveDraft(); _scheduleMatchUpdate(); }, 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 beafd20..b43f828 100644 --- a/lib/components/pages/new_entry/new_entry_submit_logic.dart +++ b/lib/components/pages/new_entry/new_entry_submit_logic.dart @@ -4,11 +4,12 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { Future _validateRequiredFields() async { final missing = []; + final units = _distanceUnits(context); if (_useManualMileage) { if (_startController.text.trim().isEmpty) missing.add('From'); if (_endController.text.trim().isEmpty) missing.add('To'); final mileageText = _mileageController.text.trim(); - if (double.tryParse(mileageText) == null) { + if (mileageText.isEmpty || units.milesFromInput(mileageText) == null) { missing.add('Mileage'); } } else { @@ -58,8 +59,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState { final endVal = _useManualMileage ? _endController.text.trim() : (routeStations.isNotEmpty ? routeStations.last : ''); + final units = _distanceUnits(context); final mileageVal = _useManualMileage - ? double.tryParse(_mileageController.text.trim()) ?? 0 + ? (units.milesFromInput(_mileageController.text.trim()) ?? 0) : (_routeResult?.distance ?? 0); final tractionPayload = _buildTractionPayload(); final endTime = _legEndDateTime; diff --git a/lib/components/pages/settings.dart b/lib/components/pages/settings.dart index dab0537..e00218d 100644 --- a/lib/components/pages/settings.dart +++ b/lib/components/pages/settings.dart @@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart'; import 'package:http/http.dart' as http; import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/api_service.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/endpoint_service.dart'; import 'package:mileograph_flutter/services/data_service.dart'; import 'package:provider/provider.dart'; @@ -175,10 +176,11 @@ class _SettingsPageState extends State { @override Widget build(BuildContext context) { final endpointService = context.watch(); + final distanceUnitService = context.watch(); final loggedIn = context.select( (auth) => auth.isLoggedIn, ); - if (!endpointService.isLoaded) { + if (!endpointService.isLoaded || !distanceUnitService.isLoaded) { return const Scaffold( body: Center(child: CircularProgressIndicator()), ); @@ -204,6 +206,34 @@ class _SettingsPageState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text( + 'Distance units', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 8), + Text( + 'Choose how distances are displayed across the app.', + style: Theme.of(context).textTheme.bodySmall, + ), + const SizedBox(height: 12), + SegmentedButton( + segments: DistanceUnit.values + .map( + (unit) => ButtonSegment( + value: unit, + label: Text(unit.label), + ), + ) + .toList(), + selected: {distanceUnitService.unit}, + onSelectionChanged: (selection) { + final next = selection.first; + distanceUnitService.setUnit(next); + }, + ), + const SizedBox(height: 24), Text( 'API endpoint', style: Theme.of(context).textTheme.titleMedium?.copyWith( diff --git a/lib/components/pages/stats.dart b/lib/components/pages/stats.dart index db62485..d25bf17 100644 --- a/lib/components/pages/stats.dart +++ b/lib/components/pages/stats.dart @@ -2,6 +2,7 @@ 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:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; class StatsPage extends StatefulWidget { @@ -12,7 +13,6 @@ class StatsPage extends StatefulWidget { } class _StatsPageState extends State { - final NumberFormat _mileageFormat = NumberFormat('#,##0.##'); final NumberFormat _countFormat = NumberFormat.decimalPattern(); @override @@ -30,16 +30,20 @@ class _StatsPageState extends State { @override Widget build(BuildContext context) { final data = context.watch(); + final distanceUnits = context.watch(); return Scaffold( appBar: AppBar(title: const Text('Stats')), body: RefreshIndicator( onRefresh: () => _loadStats(force: true), - child: _buildContent(data), + child: _buildContent(data, distanceUnits), ), ); } - Widget _buildContent(DataService data) { + Widget _buildContent( + DataService data, + DistanceUnitService distanceUnits, + ) { final stats = data.aboutStats; final loading = data.isAboutStatsLoading; @@ -79,13 +83,14 @@ class _StatsPageState extends State { itemBuilder: (context, index) { return Padding( padding: EdgeInsets.only(bottom: index == years.length - 1 ? 0 : 12), - child: _buildYearCard(context, years[index]), + child: _buildYearCard(context, years[index], distanceUnits), ); }, ); } - Widget _buildYearCard(BuildContext context, StatsYear year) { + Widget _buildYearCard( + BuildContext context, StatsYear year, DistanceUnitService distanceUnits) { final theme = Theme.of(context); return Card( child: Padding( @@ -109,7 +114,7 @@ class _StatsPageState extends State { _buildInfoChip( context, label: 'Mileage', - value: '${_mileageFormat.format(year.mileage)} mi', + value: distanceUnits.format(year.mileage, decimals: 1), ), _buildInfoChip( context, @@ -126,25 +131,29 @@ class _StatsPageState extends State { 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'), + itemBuilder: (item, index) => ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + title: Text(item.locoClass), + trailing: Text( + distanceUnits.format(item.mileage, decimals: 1), + ), + ), ), - ), _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'), + itemBuilder: (item, index) => ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric(horizontal: 8), + title: Text(item.network), + trailing: Text( + distanceUnits.format(item.mileage, decimals: 1), + ), + ), ), - ), _buildSection( context, title: 'Top stations', diff --git a/lib/components/pages/traction/traction.dart b/lib/components/pages/traction/traction.dart index d6ddb05..939ce6f 100644 --- a/lib/components/pages/traction/traction.dart +++ b/lib/components/pages/traction/traction.dart @@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart'; import 'package:mileograph_flutter/components/traction/traction_card.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; import 'package:shared_preferences/shared_preferences.dart'; diff --git a/lib/components/pages/traction/traction_page.dart b/lib/components/pages/traction/traction_page.dart index 00143b2..8739439 100644 --- a/lib/components/pages/traction/traction_page.dart +++ b/lib/components/pages/traction/traction_page.dart @@ -639,6 +639,7 @@ class _TractionPageState extends State { Widget _buildClassStatsCard(BuildContext context) { final scheme = Theme.of(context).colorScheme; + final distanceUnits = context.watch(); if (_classStatsLoading) { return Card( child: Padding( @@ -721,9 +722,27 @@ class _TractionPageState extends State { children: [ _metricTile('Had', hadCount), _metricTile('Entries', entriesWithClass), - _metricTile('Avg mi / loco had', avgMileagePerLoco.toStringAsFixed(2)), - _metricTile('Avg mi / entry', avgMileagePerEntry.toStringAsFixed(2)), - _metricTile('Total mileage', totalMileage.toStringAsFixed(2)), + _metricTile( + 'Avg distance / loco had', + distanceUnits.format( + avgMileagePerLoco, + decimals: 2, + ), + ), + _metricTile( + 'Avg distance / entry', + distanceUnits.format( + avgMileagePerEntry, + decimals: 2, + ), + ), + _metricTile( + 'Total distance', + distanceUnits.format( + totalMileage, + decimals: 2, + ), + ), ], ), const SizedBox(height: 12), diff --git a/lib/components/pages/trips.dart b/lib/components/pages/trips.dart index cfd0859..e270d2c 100644 --- a/lib/components/pages/trips.dart +++ b/lib/components/pages/trips.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/services/data_service.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:provider/provider.dart'; class TripsPage extends StatefulWidget { @@ -99,6 +100,7 @@ class _TripsPageState extends State { @override Widget build(BuildContext context) { final data = context.watch(); + final distanceUnits = context.watch(); final tripDetails = data.tripDetails; final tripSummaries = data.tripList; final summaryById = { @@ -188,7 +190,8 @@ class _TripsPageState extends State { return Card( child: ListTile( title: Text(trip.tripName), - subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'), + subtitle: + Text(distanceUnits.format(trip.tripMileage, decimals: 1)), ), ); } @@ -206,6 +209,7 @@ class _TripsPageState extends State { TripDetail trip, TripSummary? summary, ) { + final distanceUnits = context.watch(); final legs = trip.legs; final legCount = trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length; @@ -245,18 +249,12 @@ class _TripsPageState extends State { crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( - trip.mileage.toStringAsFixed(1), + distanceUnits.format(trip.mileage, decimals: 1), style: Theme.of(context).textTheme.headlineSmall?.copyWith( fontWeight: FontWeight.w800, ), ), - Text( - 'miles', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).textTheme.bodySmall?.color, - ), - ), ], ), ], @@ -367,6 +365,7 @@ class _TripsPageState extends State { } void _showTripDetail(BuildContext context, TripDetail trip) { + final distanceUnits = context.read(); showModalBottomSheet( context: context, isScrollControlled: true, @@ -491,7 +490,9 @@ class _TripsPageState extends State { ), ], const SizedBox(width: 4), - Text('${trip.mileage.toStringAsFixed(1)} mi'), + Text( + distanceUnits.format(trip.mileage, decimals: 1), + ), ], ), const SizedBox(height: 8), @@ -506,7 +507,12 @@ class _TripsPageState extends State { title: Text('${leg.start} → ${leg.end}'), subtitle: Text(_formatDate(leg.beginTime)), trailing: Text( - leg.mileage?.toStringAsFixed(1) ?? '-', + leg.mileage == null + ? '-' + : distanceUnits.format( + leg.mileage!, + decimals: 1, + ), style: Theme.of(context).textTheme.labelLarge ?.copyWith(fontWeight: FontWeight.bold), ), @@ -529,6 +535,7 @@ class _TripsPageState extends State { TripDetail trip, TripSummary? summary, ) { + final distanceUnits = context.read(); showModalBottomSheet( context: context, isScrollControlled: true, @@ -561,7 +568,9 @@ class _TripsPageState extends State { ?.copyWith(fontWeight: FontWeight.bold), ), const Spacer(), - Text('${trip.mileage.toStringAsFixed(1)} mi'), + Text( + distanceUnits.format(trip.mileage, decimals: 1), + ), ], ), const SizedBox(height: 8), diff --git a/lib/components/traction/traction_card.dart b/lib/components/traction/traction_card.dart index 9bf9118..337832a 100644 --- a/lib/components/traction/traction_card.dart +++ b/lib/components/traction/traction_card.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mileograph_flutter/objects/objects.dart'; +import 'package:mileograph_flutter/services/distance_unit_service.dart'; +import 'package:provider/provider.dart'; class TractionCard extends StatelessWidget { const TractionCard({ @@ -28,6 +30,7 @@ class TractionCard extends StatelessWidget { final domain = loco.domain ?? ''; final hasMileageOrTrips = _hasMileageOrTrips(loco); final statusColors = _statusChipColors(context, status); + final distanceUnits = context.watch(); return Card( child: Padding( @@ -151,8 +154,11 @@ class TractionCard extends StatelessWidget { children: [ _statPill( context, - label: 'Miles', - value: _formatNumber(loco.mileage), + label: 'Distance', + value: distanceUnits.format( + loco.mileage ?? 0, + decimals: 1, + ), ), _statPill( context, @@ -203,6 +209,7 @@ Future showTractionDetails( LocoSummary loco, ) async { final hasMileageOrTrips = _hasMileageOrTrips(loco); + final distanceUnits = context.read(); await showModalBottomSheet( context: context, isScrollControlled: true, @@ -275,7 +282,10 @@ Future showTractionDetails( _detailRow( context, 'Mileage', - _formatNumber(loco.mileage ?? 0), + distanceUnits.format( + loco.mileage ?? 0, + decimals: 1, + ), ), _detailRow( context, @@ -368,8 +378,3 @@ bool _hasMileageOrTrips(LocoSummary loco) { final trips = loco.trips ?? loco.journeys ?? 0; return mileage > 0 || trips > 0; } - -String _formatNumber(double? value) { - if (value == null) return '0'; - return value.toStringAsFixed(1); -} diff --git a/lib/services/distance_unit_service.dart b/lib/services/distance_unit_service.dart new file mode 100644 index 0000000..e71589e --- /dev/null +++ b/lib/services/distance_unit_service.dart @@ -0,0 +1,211 @@ +import 'package:flutter/foundation.dart'; +import 'package:intl/intl.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum DistanceUnit { + milesDecimal, + milesChains, + kilometers, +} + +extension DistanceUnitLabels on DistanceUnit { + String get label { + switch (this) { + case DistanceUnit.milesDecimal: + return 'Miles (decimal)'; + case DistanceUnit.milesChains: + return 'Miles & chains'; + case DistanceUnit.kilometers: + return 'Kilometers'; + } + } + + String get shortLabel { + switch (this) { + case DistanceUnit.milesDecimal: + return 'mi'; + case DistanceUnit.milesChains: + return 'm.ch'; + case DistanceUnit.kilometers: + return 'km'; + } + } + + String get _prefsValue => toString().split('.').last; + + static DistanceUnit fromPrefs(String raw) { + return DistanceUnit.values.firstWhere( + (u) => u._prefsValue == raw, + orElse: () => DistanceUnit.milesDecimal, + ); + } +} + +class DistanceUnitService extends ChangeNotifier { + static const _prefsKey = 'distance_unit'; + static const double kmPerMile = 1.609344; + static const double chainsPerMile = 80.0; + + DistanceUnitService() { + _load(); + } + + DistanceUnit _unit = DistanceUnit.milesDecimal; + bool _loaded = false; + + DistanceUnit get unit => _unit; + bool get isLoaded => _loaded; + + Future _load() async { + final prefs = await SharedPreferences.getInstance(); + final saved = prefs.getString(_prefsKey); + if (saved != null && saved.trim().isNotEmpty) { + _unit = DistanceUnitLabels.fromPrefs(saved.trim()); + } + _loaded = true; + notifyListeners(); + } + + Future setUnit(DistanceUnit unit) async { + _unit = unit; + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_prefsKey, unit._prefsValue); + notifyListeners(); + } + + double? milesFromInput(String input) => + DistanceFormatter(_unit).parseInputMiles(input); + + String format(double miles, + {int decimals = 1, bool includeUnit = true}) => + DistanceFormatter(_unit) + .format(miles, decimals: decimals, includeUnit: includeUnit); + + double toDisplay(double miles, {int decimals = 1}) => + DistanceFormatter(_unit).convertMiles(miles, decimals: decimals); +} + +class DistanceFormatter { + DistanceFormatter(this.unit); + + final DistanceUnit unit; + + String format(double miles, + {int decimals = 1, bool includeUnit = true}) { + decimals = decimals.clamp(1, 2); + if (unit == DistanceUnit.milesChains) { + // Always show chains with two decimals. + decimals = 2; + } + switch (unit) { + case DistanceUnit.milesDecimal: + final value = _numberFormat(decimals).format(miles); + return includeUnit ? '$value mi' : value; + case DistanceUnit.kilometers: + final kms = miles * DistanceUnitService.kmPerMile; + final value = _numberFormat(decimals).format(kms); + return includeUnit ? '$value km' : value; + case DistanceUnit.milesChains: + final value = _formatMilesChains(miles); + return includeUnit ? '$value mi' : value; + } + } + + double convertMiles(double miles, {int decimals = 1}) { + decimals = decimals.clamp(1, 2); + switch (unit) { + case DistanceUnit.milesDecimal: + return double.parse(miles.toStringAsFixed(decimals)); + case DistanceUnit.kilometers: + final kms = miles * DistanceUnitService.kmPerMile; + return double.parse(kms.toStringAsFixed(decimals)); + case DistanceUnit.milesChains: + // Return miles again; chains representation handled by format. + return double.parse(miles.toStringAsFixed(decimals)); + } + } + + double milesFromDisplayValue(double value) { + switch (unit) { + case DistanceUnit.milesDecimal: + return value; + case DistanceUnit.kilometers: + return value / DistanceUnitService.kmPerMile; + case DistanceUnit.milesChains: + // Value already represents miles when parsed via parseMilesChains. + return value; + } + } + + double? parseInputMiles(String input) { + final trimmed = input.trim(); + if (trimmed.isEmpty) return null; + switch (unit) { + case DistanceUnit.milesDecimal: + return double.tryParse(trimmed.replaceAll(',', '')); + case DistanceUnit.kilometers: + final km = double.tryParse(trimmed.replaceAll(',', '')); + if (km == null) return null; + return km / DistanceUnitService.kmPerMile; + case DistanceUnit.milesChains: + return _parseMilesChains(trimmed); + } + } + + NumberFormat _numberFormat(int decimals) { + final pattern = + decimals == 1 ? '#,##0.0' : '#,##0.00'; + return NumberFormat(pattern); + } + + String _formatMilesChains(double miles, {int decimals = 1}) { + final totalChains = miles * DistanceUnitService.chainsPerMile; + var milesPart = totalChains ~/ DistanceUnitService.chainsPerMile; + final chainRemainder = + totalChains - (milesPart * DistanceUnitService.chainsPerMile); + + // Always show chains as two digits (00-79), rounded to the nearest chain. + var roundedChains = chainRemainder.roundToDouble(); + if (roundedChains >= DistanceUnitService.chainsPerMile) { + milesPart += 1; + roundedChains -= DistanceUnitService.chainsPerMile; + } + final chainText = NumberFormat('00').format(roundedChains); + return '$milesPart.$chainText'; + } + + double? _parseMilesChains(String raw) { + final cleaned = raw + .toLowerCase() + .replaceAll(',', '') + .replaceAll('m', '.') + .replaceAll('c', '.') + .replaceAll(RegExp(r'\s+'), '.') + .replaceAll(RegExp(r'\.+'), '.') + .trim(); + if (cleaned.isEmpty) return null; + + final parts = cleaned.split('.'); + if (parts.isEmpty) return null; + + final milesPart = + int.tryParse(parts[0].isEmpty ? '0' : parts[0]); + if (milesPart == null) return null; + double chainsPart = 0; + if (parts.length >= 2) { + final chainRaw = parts + .sublist(1) + .join() + .trim(); + if (chainRaw.isNotEmpty) { + final parsedChains = double.tryParse(chainRaw); + if (parsedChains == null) return null; + chainsPart = parsedChains; + } + } + final totalMiles = + milesPart + + (chainsPart / DistanceUnitService.chainsPerMile); + return totalMiles; + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 94b0661..a56b76b 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.0+1 +version: 0.5.1+1 environment: sdk: ^3.8.1 @@ -103,6 +103,6 @@ flutter_launcher_icons: android: true ios: true image_path: assets/icons/app_icon.png - adaptive_icon_background: "#ffffff" + adaptive_icon_background: "#000000" adaptive_icon_foreground: assets/icons/app_icon.png min_sdk_android: 21