Add ability to select distance unit
Some checks failed
Release / android-build (push) Blocked by required conditions
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m39s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled

This commit is contained in:
2026-01-01 15:28:11 +00:00
parent 7139cfcc99
commit cea483ae0b
20 changed files with 505 additions and 85 deletions

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.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/services/endpoint_service.dart';
import 'package:mileograph_flutter/ui/app_shell.dart'; import 'package:mileograph_flutter/ui/app_shell.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -16,6 +17,9 @@ class App extends StatelessWidget {
ChangeNotifierProvider<EndpointService>( ChangeNotifierProvider<EndpointService>(
create: (_) => EndpointService(), create: (_) => EndpointService(),
), ),
ChangeNotifierProvider<DistanceUnitService>(
create: (_) => DistanceUnitService(),
),
ProxyProvider<EndpointService, ApiService>( ProxyProvider<EndpointService, ApiService>(
update: (_, endpoint, api) { update: (_, endpoint, api) {
final service = api ?? ApiService(baseUrl: endpoint.baseUrl); final service = api ?? ApiService(baseUrl: endpoint.baseUrl);

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class RouteSummaryWidget extends StatelessWidget { class RouteSummaryWidget extends StatelessWidget {
final double distance; final double distance;
@@ -12,13 +14,14 @@ class RouteSummaryWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final distanceUnits = context.watch<DistanceUnitService>();
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
"Total Distance: ${distance.toStringAsFixed(2)} mi", "Total Distance: ${distanceUnits.format(distance, decimals: 2)}",
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),
@@ -48,6 +51,7 @@ class RouteDetailsView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final distanceUnits = context.watch<DistanceUnitService>();
final highlightColor = Theme.of(context).colorScheme.primary; final highlightColor = Theme.of(context).colorScheme.primary;
final mutedColor = Theme.of(context).colorScheme.outlineVariant; final mutedColor = Theme.of(context).colorScheme.outlineVariant;
return Column( return Column(
@@ -78,7 +82,9 @@ class RouteDetailsView extends StatelessWidget {
? TextStyle(color: highlightColor, fontWeight: FontWeight.w600) ? TextStyle(color: highlightColor, fontWeight: FontWeight.w600)
: null, : null,
), ),
trailing: Text("${costs[index].toStringAsFixed(2)} mi"), trailing: Text(
distanceUnits.format(costs[index], decimals: 2),
),
); );
}, },
), ),

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/data_service.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:provider/provider.dart';
@@ -9,6 +10,7 @@ class LeaderboardPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final leaderboard = data.homepageStats?.leaderboard ?? []; final leaderboard = data.homepageStats?.leaderboard ?? [];
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && leaderboard.isEmpty) { if (data.isHomepageLoading && leaderboard.isEmpty) {
@@ -82,7 +84,10 @@ class LeaderboardPanel extends StatelessWidget {
), ),
), ),
trailing: Text( trailing: Text(
'${leaderboard[index].mileage.toStringAsFixed(1)} mi', distanceUnits.format(
leaderboard[index].mileage,
decimals: 1,
),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/data_service.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:provider/provider.dart';
class TopTractionPanel extends StatelessWidget { class TopTractionPanel extends StatelessWidget {
@@ -9,6 +9,7 @@ class TopTractionPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats; final stats = data.homepageStats;
final locos = stats?.topLocos ?? []; final locos = stats?.topLocos ?? [];
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -78,7 +79,10 @@ class TopTractionPanel extends StatelessWidget {
), ),
), ),
trailing: Text( trailing: Text(
'${locos[index].mileage?.toStringAsFixed(1)} mi', distanceUnits.format(
locos[index].mileage ?? 0,
decimals: 1,
),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.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:provider/provider.dart';
class LegCard extends StatefulWidget { class LegCard extends StatefulWidget {
@@ -26,6 +27,7 @@ class _LegCardState extends State<LegCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final leg = widget.leg; final leg = widget.leg;
final distanceUnits = context.watch<DistanceUnitService>();
final routeSegments = _parseRouteSegments(leg.route); final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Card( return Card(
@@ -181,7 +183,7 @@ class _LegCardState extends State<LegCard> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
'${leg.mileage.toStringAsFixed(1)} mi', distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),

View File

@@ -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/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.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:provider/provider.dart';
class Dashboard extends StatefulWidget { class Dashboard extends StatefulWidget {
@@ -23,6 +24,7 @@ class _DashboardState extends State<Dashboard> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final auth = context.watch<AuthService>(); final auth = context.watch<AuthService>();
final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats; final stats = data.homepageStats;
final isInitialLoading = data.isHomepageLoading || stats == null; final isInitialLoading = data.isHomepageLoading || stats == null;
@@ -46,9 +48,15 @@ class _DashboardState extends State<Dashboard> {
ListView( ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_buildHero(context, auth, data, stats), _buildHero(context, auth, data, stats, distanceUnits),
const SizedBox(height: spacing), const SizedBox(height: spacing),
_buildTiles(context, data, maxWidth, spacing), _buildTiles(
context,
data,
distanceUnits,
maxWidth,
spacing,
),
], ],
), ),
if (isInitialLoading) if (isInitialLoading)
@@ -81,6 +89,7 @@ class _DashboardState extends State<Dashboard> {
AuthService auth, AuthService auth,
DataService data, DataService data,
HomepageStats? stats, HomepageStats? stats,
DistanceUnitService distanceUnits,
) { ) {
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final greetingName = final greetingName =
@@ -119,14 +128,14 @@ class _DashboardState extends State<Dashboard> {
_metricTile( _metricTile(
context, context,
label: 'Total mileage', label: 'Total mileage',
value: '${totalMileage.toStringAsFixed(1)} mi', value: distanceUnits.format(totalMileage, decimals: 1),
icon: Icons.route, icon: Icons.route,
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
), ),
_metricTile( _metricTile(
context, context,
label: 'This year', label: 'This year',
value: '${currentYearMileage.toStringAsFixed(1)} mi', value: distanceUnits.format(currentYearMileage, decimals: 1),
icon: Icons.calendar_today, icon: Icons.calendar_today,
color: colorScheme.onPrimaryContainer, color: colorScheme.onPrimaryContainer,
), ),
@@ -215,6 +224,7 @@ class _DashboardState extends State<Dashboard> {
Widget _buildTiles( Widget _buildTiles(
BuildContext context, BuildContext context,
DataService data, DataService data,
DistanceUnitService distanceUnits,
double maxWidth, double maxWidth,
double spacing, double spacing,
) { ) {
@@ -229,9 +239,9 @@ class _DashboardState extends State<Dashboard> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildOnThisDayCard(context, data), _buildOnThisDayCard(context, data, distanceUnits),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTripsCard(context, data), _buildTripsCard(context, data, distanceUnits),
const SizedBox(height: 16), const SizedBox(height: 16),
const LatestLocoChangesPanel(expanded: true), const LatestLocoChangesPanel(expanded: true),
], ],
@@ -256,13 +266,13 @@ class _DashboardState extends State<Dashboard> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildOnThisDayCard(context, data), _buildOnThisDayCard(context, data, distanceUnits),
const SizedBox(height: 16), const SizedBox(height: 16),
const TopTractionPanel(), const TopTractionPanel(),
const SizedBox(height: 16), const SizedBox(height: 16),
const LeaderboardPanel(), const LeaderboardPanel(),
const SizedBox(height: 16), const SizedBox(height: 16),
_buildTripsCard(context, data), _buildTripsCard(context, data, distanceUnits),
const SizedBox(height: 16), const SizedBox(height: 16),
const LatestLocoChangesPanel(), const LatestLocoChangesPanel(),
], ],
@@ -296,7 +306,8 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _buildOnThisDayCard(BuildContext context, DataService data) { Widget _buildOnThisDayCard(
BuildContext context, DataService data, DistanceUnitService distanceUnits) {
final filtered = data.onThisDay final filtered = data.onThisDay
.where((leg) => leg.beginTime.year != DateTime.now().year) .where((leg) => leg.beginTime.year != DateTime.now().year)
.toList(); .toList();
@@ -329,7 +340,7 @@ class _DashboardState extends State<Dashboard> {
: Column( : Column(
children: [ children: [
for (int idx = 0; idx < visible.length; idx++) ...[ 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), if (idx != visible.length - 1) const Divider(height: 12),
], ],
], ],
@@ -337,7 +348,8 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme) { Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme,
DistanceUnitService distanceUnits) {
final traction = leg.locos; final traction = leg.locos;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
@@ -443,7 +455,7 @@ class _DashboardState extends State<Dashboard> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
'${leg.mileage.toStringAsFixed(1)} mi', distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
@@ -498,7 +510,8 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _buildTripsCard(BuildContext context, DataService data) { Widget _buildTripsCard(
BuildContext context, DataService data, DistanceUnitService distanceUnits) {
final tripsUnsorted = data.trips; final tripsUnsorted = data.trips;
List trips = []; List trips = [];
if (tripsUnsorted.isNotEmpty) { if (tripsUnsorted.isNotEmpty) {
@@ -543,7 +556,7 @@ class _DashboardState extends State<Dashboard> {
?.copyWith(fontWeight: FontWeight.w700), ?.copyWith(fontWeight: FontWeight.w700),
), ),
Text( Text(
'${trip.tripMileage.toStringAsFixed(1)} mi', distanceUnits.format(trip.tripMileage, decimals: 1),
style: Theme.of(context).textTheme.labelMedium, style: Theme.of(context).textTheme.labelMedium,
), ),
], ],

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/legs/leg_card.dart'; import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.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:provider/provider.dart';
class LegsPage extends StatefulWidget { class LegsPage extends StatefulWidget {
@@ -90,6 +91,7 @@ class _LegsPageState extends State<LegsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final legs = data.legs; final legs = data.legs;
final pageMileage = _pageMileage(legs); final pageMileage = _pageMileage(legs);
@@ -121,7 +123,7 @@ class _LegsPageState extends State<LegsPage> {
children: [ children: [
Text('Page mileage', Text('Page mileage',
style: Theme.of(context).textTheme.labelSmall), style: Theme.of(context).textTheme.labelSmall),
Text('${pageMileage.toStringAsFixed(1)} mi', Text(distanceUnits.format(pageMileage, decimals: 1),
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium .titleMedium
@@ -212,7 +214,7 @@ class _LegsPageState extends State<LegsPage> {
else else
Column( Column(
children: [ children: [
..._buildLegsWithDividers(context, legs), ..._buildLegsWithDividers(context, legs, distanceUnits),
const SizedBox(height: 8), const SizedBox(height: 8),
if (data.legsHasMore || data.isLegsLoading) if (data.legsHasMore || data.isLegsLoading)
Align( Align(
@@ -239,7 +241,11 @@ class _LegsPageState extends State<LegsPage> {
); );
} }
List<Widget> _buildLegsWithDividers(BuildContext context, List<Leg> legs) { List<Widget> _buildLegsWithDividers(
BuildContext context,
List<Leg> legs,
DistanceUnitService distanceUnits,
) {
final widgets = <Widget>[]; final widgets = <Widget>[];
String? currentDate; String? currentDate;
double dayMileage = 0; double dayMileage = 0;
@@ -261,10 +267,8 @@ class _LegsPageState extends State<LegsPage> {
), ),
), ),
), ),
Text( Text(distanceUnits.format(dayMileage, decimals: 1),
'${dayMileage.toStringAsFixed(1)} mi', style: Theme.of(context).textTheme.labelMedium),
style: Theme.of(context).textTheme.labelMedium,
),
], ],
), ),
), ),

View File

@@ -11,6 +11,7 @@ import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/data_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:mileograph_flutter/services/navigation_guard.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -235,6 +235,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
required String id, required String id,
bool includeTimestamp = true, bool includeTimestamp = true,
}) { }) {
final units = _distanceUnits(context);
final routeStations = _routeResult?.calculatedRoute ?? []; final routeStations = _routeResult?.calculatedRoute ?? [];
final endTime = _legEndDateTime; final endTime = _legEndDateTime;
final originTime = _originDateTime; final originTime = _originDateTime;
@@ -249,7 +250,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
? _endController.text.trim() ? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : ''); : (routeStations.isNotEmpty ? routeStations.last : '');
final mileageVal = _useManualMileage final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0 ? (units.milesFromInput(_mileageController.text.trim()) ?? 0)
: (_routeResult?.distance ?? 0); : (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload(); final tractionPayload = _buildTractionPayload();
final commonPayload = { final commonPayload = {
@@ -338,6 +339,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
final destination = payload['leg_destination'] as String? ?? ''; final destination = payload['leg_destination'] as String? ?? '';
final tripRaw = payload['leg_trip']; final tripRaw = payload['leg_trip'];
final tripId = tripRaw is num ? tripRaw.toInt() : null; final tripId = tripRaw is num ? tripRaw.toInt() : null;
final units = _distanceUnits(context);
List<String> routeStations = []; List<String> routeStations = [];
RouteResult? restoredRouteResult; RouteResult? restoredRouteResult;
@@ -416,14 +418,20 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
final miles = (payload['leg_distance'] as num?)?.toDouble(); final miles = (payload['leg_distance'] as num?)?.toDouble();
_mileageController.text = miles == null || miles == 0 _mileageController.text = miles == null || miles == 0
? '' ? ''
: miles.toStringAsFixed(2); : units.format(
miles,
decimals: 2,
includeUnit: false,
);
} else { } else {
_startController.text = _startController.text =
routeStations.isNotEmpty ? routeStations.first : ''; routeStations.isNotEmpty ? routeStations.first : '';
_endController.text = _endController.text =
routeStations.isNotEmpty ? routeStations.last : ''; routeStations.isNotEmpty ? routeStations.last : '';
final dist = _routeResult?.distance ?? 0; 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']; final tractionRaw = data['tractionItems'];

View File

@@ -150,6 +150,7 @@ class _DraftListBodyState extends State<_DraftListBody> {
final payload = draft.data['payload']; final payload = draft.data['payload'];
if (payload is! Map) return ''; if (payload is! Map) return '';
final map = Map<String, dynamic>.from(payload); final map = Map<String, dynamic>.from(payload);
final units = context.read<DistanceUnitService>();
final parts = <String>[]; final parts = <String>[];
if ((map['leg_trip'] as int? ?? 0) != 0) { if ((map['leg_trip'] as int? ?? 0) != 0) {
parts.add('Trip ${map['leg_trip']}'); parts.add('Trip ${map['leg_trip']}');
@@ -164,7 +165,7 @@ class _DraftListBodyState extends State<_DraftListBody> {
(map['leg_distance'] as num?)?.toDouble() ?? (map['leg_distance'] as num?)?.toDouble() ??
(map['leg_mileage'] as num?)?.toDouble(); (map['leg_mileage'] as num?)?.toDouble();
if (mileage != null && mileage > 0) { 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 && } else if (map['leg_route'] is List &&
(map['leg_route'] as List).isNotEmpty) { (map['leg_route'] as List).isNotEmpty) {
parts.add('Route ${(map['leg_route'] as List).length} stops'); parts.add('Route ${(map['leg_route'] as List).length} stops');
@@ -176,4 +177,3 @@ class _DraftListBodyState extends State<_DraftListBody> {
return parts.join(''); return parts.join('');
} }
} }

View File

@@ -51,6 +51,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final DeepCollectionEquality _snapshotEquality = final DeepCollectionEquality _snapshotEquality =
const DeepCollectionEquality(); const DeepCollectionEquality();
String? _activeDraftId; String? _activeDraftId;
DistanceUnit? _lastDistanceUnit;
bool get _isEditing => widget.editLegId != null; bool get _isEditing => widget.editLegId != null;
bool get _draftPersistenceEnabled => bool get _draftPersistenceEnabled =>
@@ -100,6 +101,58 @@ class _NewEntryPageState extends State<NewEntryPage> {
setState(fn); setState(fn);
} }
DistanceUnitService _distanceUnits(BuildContext context) =>
context.read<DistanceUnitService>();
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) { Widget _buildTripSelector(BuildContext context) {
final trips = context.watch<DataService>().tripList; final trips = context.watch<DataService>().tripList;
final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId)); final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId));
@@ -224,9 +277,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
), ),
); );
if (result != null) { if (result != null) {
final units = _distanceUnits(context);
setState(() { setState(() {
_routeResult = result; _routeResult = result;
_mileageController.text = result.distance.toStringAsFixed(2); _mileageController.text = _formatDistance(
units,
result.distance,
decimals: 2,
includeUnit: false,
);
_useManualMileage = false; _useManualMileage = false;
}); });
_saveDraft(); _saveDraft();
@@ -426,6 +485,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final endTime = DateTime.tryParse(json['leg_end_time'] ?? ''); final endTime = DateTime.tryParse(json['leg_end_time'] ?? '');
final routeStations = _parseRouteStations(json['leg_route']); final routeStations = _parseRouteStations(json['leg_route']);
final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0; final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0;
final units = _distanceUnits(context);
final useManual = routeStations.isEmpty; final useManual = routeStations.isEmpty;
final routeResult = useManual final routeResult = useManual
? null ? null
@@ -484,7 +544,12 @@ class _NewEntryPageState extends State<NewEntryPage> {
_endDelayController.text = endDelay.toString(); _endDelayController.text = endDelay.toString();
_mileageController.text = mileageVal == 0 _mileageController.text = mileageVal == 0
? '' ? ''
: mileageVal.toStringAsFixed(2); : _formatDistance(
units,
mileageVal,
decimals: 2,
includeUnit: false,
);
_tractionItems _tractionItems
..clear() ..clear()
..addAll(tractionItems); ..addAll(tractionItems);
@@ -728,11 +793,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
key: _formKey, key: _formKey,
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final distanceUnitService = context.watch<DistanceUnitService>();
final currentDistanceUnit = distanceUnitService.unit;
_syncManualFieldUnit(currentDistanceUnit);
final twoCol = !isMobile && constraints.maxWidth > 1000; final twoCol = !isMobile && constraints.maxWidth > 1000;
final tractionEmpty = _tractionItems.length == 1; final tractionEmpty = _tractionItems.length == 1;
final mileageEmpty = !_useManualMileage && _routeResult == null; final mileageEmpty = !_useManualMileage && _routeResult == null;
final balancePanels = twoCol && tractionEmpty && mileageEmpty; final balancePanels = twoCol && tractionEmpty && mileageEmpty;
final balancedHeight = balancePanels ? 165.0 : null; final balancedHeight = balancePanels ? 165.0 : null;
final mileageLabel = _manualMileageLabel(currentDistanceUnit);
final entryPanel = _section('Entry', [ final entryPanel = _section('Entry', [
Row( Row(
@@ -948,9 +1017,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
keyboardType: const TextInputType.numberWithOptions( keyboardType: const TextInputType.numberWithOptions(
decimal: true, decimal: true,
), ),
decoration: const InputDecoration( decoration: InputDecoration(
labelText: 'Mileage (mi)', labelText: mileageLabel,
border: OutlineInputBorder(), helperText: currentDistanceUnit ==
DistanceUnit.milesChains
? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)'
: null,
border: const OutlineInputBorder(),
), ),
) )
else if (_routeResult != null) else if (_routeResult != null)
@@ -958,7 +1031,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
title: const Text('Calculated mileage'), title: const Text('Calculated mileage'),
subtitle: Text( subtitle: Text(
'${_routeResult!.distance.toStringAsFixed(2)} mi', _formatDistance(
distanceUnitService,
_routeResult!.distance,
decimals: 2,
),
), ),
) )
else else
@@ -973,7 +1050,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
label: Text(_useManualMileage ? 'Manual' : 'Automatic'), label: Text(_useManualMileage ? 'Manual' : 'Automatic'),
selected: _useManualMileage, selected: _useManualMileage,
onSelected: (val) { onSelected: (val) {
setState(() => _useManualMileage = val); setState(() {
_useManualMileage = val;
if (val && _routeResult != null) {
_mileageController.text = _formatDistance(
distanceUnitService,
_routeResult!.distance,
decimals: 2,
includeUnit: false,
);
}
});
_saveDraft(); _saveDraft();
_scheduleMatchUpdate(); _scheduleMatchUpdate();
}, },

View File

@@ -4,11 +4,12 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
Future<bool> _validateRequiredFields() async { Future<bool> _validateRequiredFields() async {
final missing = <String>[]; final missing = <String>[];
final units = _distanceUnits(context);
if (_useManualMileage) { if (_useManualMileage) {
if (_startController.text.trim().isEmpty) missing.add('From'); if (_startController.text.trim().isEmpty) missing.add('From');
if (_endController.text.trim().isEmpty) missing.add('To'); if (_endController.text.trim().isEmpty) missing.add('To');
final mileageText = _mileageController.text.trim(); final mileageText = _mileageController.text.trim();
if (double.tryParse(mileageText) == null) { if (mileageText.isEmpty || units.milesFromInput(mileageText) == null) {
missing.add('Mileage'); missing.add('Mileage');
} }
} else { } else {
@@ -58,8 +59,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final endVal = _useManualMileage final endVal = _useManualMileage
? _endController.text.trim() ? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : ''); : (routeStations.isNotEmpty ? routeStations.last : '');
final units = _distanceUnits(context);
final mileageVal = _useManualMileage final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0 ? (units.milesFromInput(_mileageController.text.trim()) ?? 0)
: (_routeResult?.distance ?? 0); : (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload(); final tractionPayload = _buildTractionPayload();
final endTime = _legEndDateTime; final endTime = _legEndDateTime;

View File

@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/api_service.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/endpoint_service.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -175,10 +176,11 @@ class _SettingsPageState extends State<SettingsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final endpointService = context.watch<EndpointService>(); final endpointService = context.watch<EndpointService>();
final distanceUnitService = context.watch<DistanceUnitService>();
final loggedIn = context.select<AuthService, bool>( final loggedIn = context.select<AuthService, bool>(
(auth) => auth.isLoggedIn, (auth) => auth.isLoggedIn,
); );
if (!endpointService.isLoaded) { if (!endpointService.isLoaded || !distanceUnitService.isLoaded) {
return const Scaffold( return const Scaffold(
body: Center(child: CircularProgressIndicator()), body: Center(child: CircularProgressIndicator()),
); );
@@ -204,6 +206,34 @@ class _SettingsPageState extends State<SettingsPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ 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<DistanceUnit>(
segments: DistanceUnit.values
.map(
(unit) => ButtonSegment<DistanceUnit>(
value: unit,
label: Text(unit.label),
),
)
.toList(),
selected: {distanceUnitService.unit},
onSelectionChanged: (selection) {
final next = selection.first;
distanceUnitService.setUnit(next);
},
),
const SizedBox(height: 24),
Text( Text(
'API endpoint', 'API endpoint',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.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:provider/provider.dart';
class StatsPage extends StatefulWidget { class StatsPage extends StatefulWidget {
@@ -12,7 +13,6 @@ class StatsPage extends StatefulWidget {
} }
class _StatsPageState extends State<StatsPage> { class _StatsPageState extends State<StatsPage> {
final NumberFormat _mileageFormat = NumberFormat('#,##0.##');
final NumberFormat _countFormat = NumberFormat.decimalPattern(); final NumberFormat _countFormat = NumberFormat.decimalPattern();
@override @override
@@ -30,16 +30,20 @@ class _StatsPageState extends State<StatsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('Stats')), appBar: AppBar(title: const Text('Stats')),
body: RefreshIndicator( body: RefreshIndicator(
onRefresh: () => _loadStats(force: true), 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 stats = data.aboutStats;
final loading = data.isAboutStatsLoading; final loading = data.isAboutStatsLoading;
@@ -79,13 +83,14 @@ class _StatsPageState extends State<StatsPage> {
itemBuilder: (context, index) { itemBuilder: (context, index) {
return Padding( return Padding(
padding: EdgeInsets.only(bottom: index == years.length - 1 ? 0 : 12), 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); final theme = Theme.of(context);
return Card( return Card(
child: Padding( child: Padding(
@@ -109,7 +114,7 @@ class _StatsPageState extends State<StatsPage> {
_buildInfoChip( _buildInfoChip(
context, context,
label: 'Mileage', label: 'Mileage',
value: '${_mileageFormat.format(year.mileage)} mi', value: distanceUnits.format(year.mileage, decimals: 1),
), ),
_buildInfoChip( _buildInfoChip(
context, context,
@@ -130,7 +135,9 @@ class _StatsPageState extends State<StatsPage> {
dense: true, dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.locoClass), title: Text(item.locoClass),
trailing: Text('${_mileageFormat.format(item.mileage)} mi'), trailing: Text(
distanceUnits.format(item.mileage, decimals: 1),
),
), ),
), ),
_buildSection<StatsNetworkMileage>( _buildSection<StatsNetworkMileage>(
@@ -142,7 +149,9 @@ class _StatsPageState extends State<StatsPage> {
dense: true, dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8), contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.network), title: Text(item.network),
trailing: Text('${_mileageFormat.format(item.mileage)} mi'), trailing: Text(
distanceUnits.format(item.mileage, decimals: 1),
),
), ),
), ),
_buildSection<StatsStationVisits>( _buildSection<StatsStationVisits>(

View File

@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart'; import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -639,6 +639,7 @@ class _TractionPageState extends State<TractionPage> {
Widget _buildClassStatsCard(BuildContext context) { Widget _buildClassStatsCard(BuildContext context) {
final scheme = Theme.of(context).colorScheme; final scheme = Theme.of(context).colorScheme;
final distanceUnits = context.watch<DistanceUnitService>();
if (_classStatsLoading) { if (_classStatsLoading) {
return Card( return Card(
child: Padding( child: Padding(
@@ -721,9 +722,27 @@ class _TractionPageState extends State<TractionPage> {
children: [ children: [
_metricTile('Had', hadCount), _metricTile('Had', hadCount),
_metricTile('Entries', entriesWithClass), _metricTile('Entries', entriesWithClass),
_metricTile('Avg mi / loco had', avgMileagePerLoco.toStringAsFixed(2)), _metricTile(
_metricTile('Avg mi / entry', avgMileagePerEntry.toStringAsFixed(2)), 'Avg distance / loco had',
_metricTile('Total mileage', totalMileage.toStringAsFixed(2)), 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), const SizedBox(height: 12),

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.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:provider/provider.dart';
class TripsPage extends StatefulWidget { class TripsPage extends StatefulWidget {
@@ -99,6 +100,7 @@ class _TripsPageState extends State<TripsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final tripDetails = data.tripDetails; final tripDetails = data.tripDetails;
final tripSummaries = data.tripList; final tripSummaries = data.tripList;
final summaryById = { final summaryById = {
@@ -188,7 +190,8 @@ class _TripsPageState extends State<TripsPage> {
return Card( return Card(
child: ListTile( child: ListTile(
title: Text(trip.tripName), 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<TripsPage> {
TripDetail trip, TripDetail trip,
TripSummary? summary, TripSummary? summary,
) { ) {
final distanceUnits = context.watch<DistanceUnitService>();
final legs = trip.legs; final legs = trip.legs;
final legCount = final legCount =
trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length; trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length;
@@ -245,18 +249,12 @@ class _TripsPageState extends State<TripsPage> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
trip.mileage.toStringAsFixed(1), distanceUnits.format(trip.mileage, decimals: 1),
style: style:
Theme.of(context).textTheme.headlineSmall?.copyWith( Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800, 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<TripsPage> {
} }
void _showTripDetail(BuildContext context, TripDetail trip) { void _showTripDetail(BuildContext context, TripDetail trip) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -491,7 +490,9 @@ class _TripsPageState extends State<TripsPage> {
), ),
], ],
const SizedBox(width: 4), const SizedBox(width: 4),
Text('${trip.mileage.toStringAsFixed(1)} mi'), Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -506,7 +507,12 @@ class _TripsPageState extends State<TripsPage> {
title: Text('${leg.start}${leg.end}'), title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)), subtitle: Text(_formatDate(leg.beginTime)),
trailing: Text( trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-', leg.mileage == null
? '-'
: distanceUnits.format(
leg.mileage!,
decimals: 1,
),
style: Theme.of(context).textTheme.labelLarge style: Theme.of(context).textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
), ),
@@ -529,6 +535,7 @@ class _TripsPageState extends State<TripsPage> {
TripDetail trip, TripDetail trip,
TripSummary? summary, TripSummary? summary,
) { ) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -561,7 +568,9 @@ class _TripsPageState extends State<TripsPage> {
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
), ),
const Spacer(), const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'), Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.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 { class TractionCard extends StatelessWidget {
const TractionCard({ const TractionCard({
@@ -28,6 +30,7 @@ class TractionCard extends StatelessWidget {
final domain = loco.domain ?? ''; final domain = loco.domain ?? '';
final hasMileageOrTrips = _hasMileageOrTrips(loco); final hasMileageOrTrips = _hasMileageOrTrips(loco);
final statusColors = _statusChipColors(context, status); final statusColors = _statusChipColors(context, status);
final distanceUnits = context.watch<DistanceUnitService>();
return Card( return Card(
child: Padding( child: Padding(
@@ -151,8 +154,11 @@ class TractionCard extends StatelessWidget {
children: [ children: [
_statPill( _statPill(
context, context,
label: 'Miles', label: 'Distance',
value: _formatNumber(loco.mileage), value: distanceUnits.format(
loco.mileage ?? 0,
decimals: 1,
),
), ),
_statPill( _statPill(
context, context,
@@ -203,6 +209,7 @@ Future<void> showTractionDetails(
LocoSummary loco, LocoSummary loco,
) async { ) async {
final hasMileageOrTrips = _hasMileageOrTrips(loco); final hasMileageOrTrips = _hasMileageOrTrips(loco);
final distanceUnits = context.read<DistanceUnitService>();
await showModalBottomSheet( await showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -275,7 +282,10 @@ Future<void> showTractionDetails(
_detailRow( _detailRow(
context, context,
'Mileage', 'Mileage',
_formatNumber(loco.mileage ?? 0), distanceUnits.format(
loco.mileage ?? 0,
decimals: 1,
),
), ),
_detailRow( _detailRow(
context, context,
@@ -368,8 +378,3 @@ bool _hasMileageOrTrips(LocoSummary loco) {
final trips = loco.trips ?? loco.journeys ?? 0; final trips = loco.trips ?? loco.journeys ?? 0;
return mileage > 0 || trips > 0; return mileage > 0 || trips > 0;
} }
String _formatNumber(double? value) {
if (value == null) return '0';
return value.toStringAsFixed(1);
}

View File

@@ -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<void> _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<void> 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;
}
}

View File

@@ -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 # 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 # 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. # 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: environment:
sdk: ^3.8.1 sdk: ^3.8.1
@@ -103,6 +103,6 @@ flutter_launcher_icons:
android: true android: true
ios: true ios: true
image_path: assets/icons/app_icon.png image_path: assets/icons/app_icon.png
adaptive_icon_background: "#ffffff" adaptive_icon_background: "#000000"
adaptive_icon_foreground: assets/icons/app_icon.png adaptive_icon_foreground: assets/icons/app_icon.png
min_sdk_android: 21 min_sdk_android: 21