Add ability to select distance unit
Some checks failed
Some checks failed
This commit is contained in:
@@ -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);
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -76,9 +77,12 @@ class TopTractionPanel extends StatelessWidget {
|
|||||||
style: textTheme.bodySmall?.copyWith(
|
style: textTheme.bodySmall?.copyWith(
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|||||||
@@ -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(' • ');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -126,25 +131,29 @@ class _StatsPageState extends State<StatsPage> {
|
|||||||
title: 'Top classes',
|
title: 'Top classes',
|
||||||
items: year.topClasses,
|
items: year.topClasses,
|
||||||
emptyLabel: 'No class data',
|
emptyLabel: 'No class data',
|
||||||
itemBuilder: (item, index) => ListTile(
|
itemBuilder: (item, index) => ListTile(
|
||||||
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>(
|
||||||
context,
|
context,
|
||||||
title: 'Top networks',
|
title: 'Top networks',
|
||||||
items: year.topNetworks,
|
items: year.topNetworks,
|
||||||
emptyLabel: 'No network data',
|
emptyLabel: 'No network data',
|
||||||
itemBuilder: (item, index) => ListTile(
|
itemBuilder: (item, index) => ListTile(
|
||||||
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>(
|
||||||
context,
|
context,
|
||||||
title: 'Top stations',
|
title: 'Top stations',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
211
lib/services/distance_unit_service.dart
Normal file
211
lib/services/distance_unit_service.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user