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/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<EndpointService>(
create: (_) => EndpointService(),
),
ChangeNotifierProvider<DistanceUnitService>(
create: (_) => DistanceUnitService(),
),
ProxyProvider<EndpointService, ApiService>(
update: (_, endpoint, api) {
final service = api ?? ApiService(baseUrl: endpoint.baseUrl);

View File

@@ -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<DistanceUnitService>();
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<DistanceUnitService>();
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),
),
);
},
),

View File

@@ -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<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
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,
),

View File

@@ -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<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
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,
),

View File

@@ -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<LegCard> {
@override
Widget build(BuildContext context) {
final leg = widget.leg;
final distanceUnits = context.watch<DistanceUnitService>();
final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme;
return Card(
@@ -181,7 +183,7 @@ class _LegCardState extends State<LegCard> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${leg.mileage.toStringAsFixed(1)} mi',
distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith(
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/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<Dashboard> {
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final auth = context.watch<AuthService>();
final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats;
final isInitialLoading = data.isHomepageLoading || stats == null;
@@ -46,9 +48,15 @@ class _DashboardState extends State<Dashboard> {
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<Dashboard> {
AuthService auth,
DataService data,
HomepageStats? stats,
DistanceUnitService distanceUnits,
) {
final colorScheme = Theme.of(context).colorScheme;
final greetingName =
@@ -119,14 +128,14 @@ class _DashboardState extends State<Dashboard> {
_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<Dashboard> {
Widget _buildTiles(
BuildContext context,
DataService data,
DistanceUnitService distanceUnits,
double maxWidth,
double spacing,
) {
@@ -229,9 +239,9 @@ class _DashboardState extends State<Dashboard> {
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<Dashboard> {
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<Dashboard> {
);
}
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<Dashboard> {
: 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<Dashboard> {
);
}
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<Dashboard> {
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<Dashboard> {
);
}
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<Dashboard> {
?.copyWith(fontWeight: FontWeight.w700),
),
Text(
'${trip.tripMileage.toStringAsFixed(1)} mi',
distanceUnits.format(trip.tripMileage, decimals: 1),
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/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<LegsPage> {
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final legs = data.legs;
final pageMileage = _pageMileage(legs);
@@ -121,7 +123,7 @@ class _LegsPageState extends State<LegsPage> {
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<LegsPage> {
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<LegsPage> {
);
}
List<Widget> _buildLegsWithDividers(BuildContext context, List<Leg> legs) {
List<Widget> _buildLegsWithDividers(
BuildContext context,
List<Leg> legs,
DistanceUnitService distanceUnits,
) {
final widgets = <Widget>[];
String? currentDate;
double dayMileage = 0;
@@ -261,10 +267,8 @@ class _LegsPageState extends State<LegsPage> {
),
),
),
Text(
'${dayMileage.toStringAsFixed(1)} mi',
style: Theme.of(context).textTheme.labelMedium,
),
Text(distanceUnits.format(dayMileage, decimals: 1),
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/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';

View File

@@ -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<String> 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'];

View File

@@ -150,6 +150,7 @@ class _DraftListBodyState extends State<_DraftListBody> {
final payload = draft.data['payload'];
if (payload is! Map) return '';
final map = Map<String, dynamic>.from(payload);
final units = context.read<DistanceUnitService>();
final parts = <String>[];
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('');
}
}

View File

@@ -51,6 +51,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
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<NewEntryPage> {
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) {
final trips = context.watch<DataService>().tripList;
final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId));
@@ -224,9 +277,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
),
);
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<NewEntryPage> {
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<NewEntryPage> {
_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<NewEntryPage> {
key: _formKey,
child: LayoutBuilder(
builder: (context, constraints) {
final distanceUnitService = context.watch<DistanceUnitService>();
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<NewEntryPage> {
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<NewEntryPage> {
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<NewEntryPage> {
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();
},

View File

@@ -4,11 +4,12 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
Future<bool> _validateRequiredFields() async {
final missing = <String>[];
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;

View File

@@ -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<SettingsPage> {
@override
Widget build(BuildContext context) {
final endpointService = context.watch<EndpointService>();
final distanceUnitService = context.watch<DistanceUnitService>();
final loggedIn = context.select<AuthService, bool>(
(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<SettingsPage> {
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<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(
'API endpoint',
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: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<StatsPage> {
final NumberFormat _mileageFormat = NumberFormat('#,##0.##');
final NumberFormat _countFormat = NumberFormat.decimalPattern();
@override
@@ -30,16 +30,20 @@ class _StatsPageState extends State<StatsPage> {
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
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<StatsPage> {
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<StatsPage> {
_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<StatsPage> {
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<StatsNetworkMileage>(
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<StatsStationVisits>(
context,
title: 'Top stations',

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/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';

View File

@@ -639,6 +639,7 @@ class _TractionPageState extends State<TractionPage> {
Widget _buildClassStatsCard(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final distanceUnits = context.watch<DistanceUnitService>();
if (_classStatsLoading) {
return Card(
child: Padding(
@@ -721,9 +722,27 @@ class _TractionPageState extends State<TractionPage> {
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),

View File

@@ -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<TripsPage> {
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final tripDetails = data.tripDetails;
final tripSummaries = data.tripList;
final summaryById = {
@@ -188,7 +190,8 @@ class _TripsPageState extends State<TripsPage> {
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<TripsPage> {
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.watch<DistanceUnitService>();
final legs = trip.legs;
final legCount =
trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length;
@@ -245,18 +249,12 @@ class _TripsPageState extends State<TripsPage> {
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<TripsPage> {
}
void _showTripDetail(BuildContext context, TripDetail trip) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -491,7 +490,9 @@ class _TripsPageState extends State<TripsPage> {
),
],
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<TripsPage> {
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<TripsPage> {
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -561,7 +568,9 @@ class _TripsPageState extends State<TripsPage> {
?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
],
),
const SizedBox(height: 8),

View File

@@ -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<DistanceUnitService>();
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<void> showTractionDetails(
LocoSummary loco,
) async {
final hasMileageOrTrips = _hasMileageOrTrips(loco);
final distanceUnits = context.read<DistanceUnitService>();
await showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -275,7 +282,10 @@ Future<void> 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);
}

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
# 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