Compare commits

..

7 Commits

Author SHA1 Message Date
f9c392bb07 new app icon
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 7m21s
Release / web-build (push) Successful in 7m30s
Release / android-build (push) Successful in 19m56s
Release / release-dev (push) Successful in 25s
Release / release-master (push) Successful in 24s
2026-01-02 00:18:57 +00:00
e5b145b4b2 increment verison
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m28s
Release / web-build (push) Successful in 6m54s
Release / android-build (push) Successful in 18m9s
Release / release-master (push) Successful in 38s
Release / release-dev (push) Successful in 41s
2026-01-01 23:13:18 +00:00
59458484aa add build step for flutter web, add persistent pagination in the traction list
Some checks failed
Release / meta (push) Successful in 14s
Release / web-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-01 23:08:22 +00:00
648872acf1 re add mileograph tags
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m40s
Release / android-build (push) Successful in 14m28s
Release / release-master (push) Successful in 32s
Release / release-dev (push) Successful in 36s
2026-01-01 18:06:50 +00:00
b427ed0bd3 fix failed flutter install
Some checks failed
Release / meta (push) Successful in 2s
Release / android-build (push) Failing after 2m7s
Release / linux-build (push) Failing after 2m6s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2026-01-01 17:26:54 +00:00
66a1d149f0 remove mileograph tag
Some checks failed
Release / meta (push) Successful in 26s
Release / android-build (push) Failing after 2m38s
Release / linux-build (push) Failing after 2m58s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2026-01-01 17:11:55 +00:00
cea483ae0b 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
2026-01-01 15:28:11 +00:00
68 changed files with 870 additions and 169 deletions

View File

@@ -12,6 +12,7 @@ env:
FLUTTER_VERSION: "3.38.5"
BUILD_WINDOWS: "false" # Windows build disabled (no runner available)
GITEA_BASE_URL: https://git.tgj.services
WEB_IMAGE: "git.tgj.services/petegregoryy/mileograph-web"
jobs:
meta:
@@ -123,6 +124,8 @@ jobs:
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
# Avoid git ownership issues when Flutter checks out deps.
git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
@@ -220,6 +223,8 @@ jobs:
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
# Avoid git ownership issues when Flutter checks out deps.
git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
@@ -251,6 +256,108 @@ jobs:
name: linux-bundle
path: app-linux-x64.tar.gz
web-build:
runs-on:
- mileograph
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install OS deps (Web)
run: |
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq docker.io
if ! docker info >/dev/null 2>&1; then
$SUDO systemctl start docker 2>/dev/null || $SUDO service docker start 2>/dev/null || true
fi
- name: Install Flutter SDK
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
tar -C "$HOME" -xf /tmp/flutter.tar.xz
fi
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
"$FLUTTER_HOME/bin/flutter" --version
- name: Allow all git directories (CI)
run: git config --global --add safe.directory '*'
- name: Set pub cache path
run: echo "PUB_CACHE=${GITHUB_WORKSPACE}/.pub-cache" >> "$GITHUB_ENV"
- name: Flutter dependencies
run: flutter pub get
- name: Enable Flutter web
run: flutter config --enable-web
- name: Build Flutter web (release)
run: |
flutter build web --release --base-href=/
tar -C build/web -czf app-web.tar.gz .
- name: Upload Web artifact
uses: actions/upload-artifact@v3
with:
name: web-build
path: app-web.tar.gz
- name: Compute web image tags
id: web_meta
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
DEV_SUFFIX: ${{ needs.meta.outputs.dev_suffix }}
run: |
IMAGE="${WEB_IMAGE}"
TAG=""
ALIAS=""
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
TAG="${BASE_VERSION}${DEV_SUFFIX}"
ALIAS="dev"
elif [ "${GITHUB_REF}" = "refs/heads/master" ]; then
TAG="${BASE_VERSION}"
ALIAS="latest"
fi
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "alias=${ALIAS}" >> "$GITHUB_OUTPUT"
- name: Login to registry
if: ${{ secrets.DOCKERHUB_TOKEN != '' && steps.web_meta.outputs.tag != '' }}
env:
REGISTRY_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login git.tgj.services -u petegregoryy --password-stdin
- name: Build and push web image
if: ${{ secrets.DOCKERHUB_TOKEN != '' && steps.web_meta.outputs.tag != '' }}
env:
IMAGE: ${{ steps.web_meta.outputs.image }}
TAG: ${{ steps.web_meta.outputs.tag }}
ALIAS: ${{ steps.web_meta.outputs.alias }}
run: |
docker buildx create --name buildx --driver=docker-container --use || docker buildx use buildx
TAG_ARGS=(-t "${IMAGE}:${TAG}")
if [ -n "$ALIAS" ]; then
TAG_ARGS+=(-t "${IMAGE}:${ALIAS}")
fi
docker buildx build --builder buildx --platform linux/amd64 \
-f Dockerfile.web \
--push \
"${TAG_ARGS[@]}" .
release-dev:
runs-on:
- mileograph
@@ -258,6 +365,7 @@ jobs:
- meta
- android-build
- linux-build
- web-build
steps:
- name: Install jq
run: |
@@ -341,6 +449,7 @@ jobs:
- meta
- android-build
- linux-build
- web-build
steps:
- name: Install jq
run: |

10
Dockerfile.web Normal file
View File

@@ -0,0 +1,10 @@
FROM nginx:1.27-alpine
# Use a minimal Nginx image to serve the built Flutter web app.
# Assumes `flutter build web` has already populated build/web/ in the build context.
COPY deploy/web/nginx.conf /etc/nginx/conf.d/default.conf
COPY build/web /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#ffffff</color>
<color name="ic_launcher_background">#000000</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

28
deploy/web/nginx.conf Normal file
View File

@@ -0,0 +1,28 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
include /etc/nginx/mime.types;
# Serve hashed assets aggressively; keep index/service worker cacheable but not immutable.
location /assets/ {
try_files $uri =404;
expires 30d;
add_header Cache-Control "public, max-age=2592000, immutable";
}
location /icons/ {
try_files $uri =404;
expires 30d;
add_header Cache-Control "public, max-age=2592000";
}
location = /flutter_service_worker.js {
add_header Cache-Control "no-cache";
try_files $uri =404;
}
location / {
try_files $uri $uri/ /index.html;
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 B

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 697 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1017 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

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(
@@ -109,12 +111,6 @@ class _LegCardState extends State<LegCard> {
subtitle: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 520;
final timeWidget = _timeWithDelay(
context,
leg.beginTime,
leg.beginDelayMinutes,
includeDate: widget.showDate,
);
final tractionWrap = !_expanded && leg.locos.isNotEmpty
? Wrap(
spacing: 8,
@@ -181,7 +177,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,54 @@ 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 _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));
@@ -223,10 +272,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
),
),
);
if (!mounted) return;
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 +482,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 +541,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 +790,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 +1014,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 +1028,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 +1047,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();
},
@@ -1449,43 +1533,6 @@ class _NewEntryPageState extends State<NewEntryPage> {
);
}
Widget _timeToggleBlock({
required String label,
required bool value,
required ValueChanged<bool?>? onChanged,
required String matchLabel,
required bool matchValue,
required ValueChanged<bool?>? onMatchChanged,
required bool showMatch,
Widget? picker,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CheckboxListTile(
value: value,
onChanged: onChanged,
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(label),
),
if (showMatch)
CheckboxListTile(
value: matchValue,
onChanged: onMatchChanged,
dense: true,
contentPadding: const EdgeInsets.only(left: 12),
controlAffinity: ListTileControlAffinity.leading,
title: Text(matchLabel),
),
if (picker != null) ...[
const SizedBox(height: 6),
picker,
],
],
);
}
}
class _UpperCaseTextFormatter extends TextInputFormatter {

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 {
@@ -51,6 +52,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
if (form == null) return;
if (!form.validate()) return;
if (!await _validateRequiredFields()) return;
if (!mounted) return;
final routeStations = _routeResult?.calculatedRoute ?? [];
final startVal = _useManualMileage
? _startController.text.trim()
@@ -58,8 +60,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

@@ -84,6 +84,13 @@ class _NewTractionPageState extends State<NewTractionPage> {
'traction_motors': TextEditingController(),
'build_date': TextEditingController(),
};
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final data = context.read<DataService>();
if (data.locoClasses.isEmpty) {
data.fetchClassList();
}
});
}
@override
@@ -254,6 +261,10 @@ class _NewTractionPageState extends State<NewTractionPage> {
@override
Widget build(BuildContext context) {
final isActive = _statusIsActive;
final data = context.watch<DataService>();
final classOptions = [...data.locoClasses]..sort(
(a, b) => a.toLowerCase().compareTo(b.toLowerCase()),
);
final size = MediaQuery.of(context).size;
final isNarrow = size.width < 720;
final fieldWidth = isNarrow ? double.infinity : 340.0;
@@ -269,6 +280,89 @@ class _NewTractionPageState extends State<NewTractionPage> {
double? widthOverride,
String? Function(String?)? validator,
}) {
// Special autocomplete for class field using existing loco classes.
if (key == 'class' && classOptions.isNotEmpty) {
return SizedBox(
width: widthOverride ?? fieldWidth,
child: Autocomplete<String>(
optionsBuilder: (TextEditingValue value) {
final query = value.text.trim().toLowerCase();
if (query.isEmpty) return classOptions;
return classOptions.where(
(c) => c.toLowerCase().contains(query),
);
},
onSelected: (selection) {
_controllers[key]?.text = selection;
_formKey.currentState?.validate();
},
fieldViewBuilder:
(context, textEditingController, focusNode, onFieldSubmitted) {
if (textEditingController.text != _controllers[key]?.text) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
if (textEditingController.text != _controllers[key]?.text) {
textEditingController.value =
_controllers[key]?.value ?? textEditingController.value;
}
});
}
return TextFormField(
controller: textEditingController,
focusNode: focusNode,
decoration: InputDecoration(
labelText: required ? '$label *' : label,
helperText: helper,
suffixText: suffixText,
border: const OutlineInputBorder(),
),
keyboardType: keyboardType,
maxLines: maxLines,
validator: (val) {
if (required && (val == null || val.trim().isEmpty)) {
return 'Required';
}
return validator?.call(val);
},
onChanged: (_) {
_controllers[key]?.text = textEditingController.text;
_formKey.currentState?.validate();
},
onFieldSubmitted: (_) => onFieldSubmitted(),
);
},
optionsViewBuilder: (context, onSelected, options) {
final opts = options.toList();
if (opts.isEmpty) return const SizedBox.shrink();
return Align(
alignment: Alignment.topLeft,
child: Material(
elevation: 4,
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: 280,
maxWidth: widthOverride ?? fieldWidth,
),
child: ListView.builder(
padding: EdgeInsets.zero,
itemCount: opts.length,
itemBuilder: (context, index) {
final option = opts[index];
return ListTile(
dense: true,
title: Text(option),
onTap: () => onSelected(option),
);
},
),
),
),
);
},
),
);
}
return SizedBox(
width: widthOverride ?? fieldWidth,
child: TextFormField(

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

@@ -37,6 +37,9 @@ class _TractionPageState extends State<TractionPage> {
final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {};
bool _restoredFromPrefs = false;
static const int _pageSize = 100;
int _lastTractionOffset = 0;
String? _lastQuerySignature;
@override
void initState() {
@@ -59,6 +62,9 @@ class _TractionPageState extends State<TractionPage> {
Future<void> _initialLoad() async {
final data = context.read<DataService>();
await _restoreSearchState();
if (_lastTractionOffset == 0 && data.traction.length > _pageSize) {
_lastTractionOffset = data.traction.length - _pageSize;
}
data.fetchClassList();
data.fetchEventFields();
await _refreshTraction();
@@ -103,7 +109,29 @@ class _TractionPageState extends State<TractionPage> {
dynamicFieldsUsed;
}
Future<void> _refreshTraction({bool append = false}) async {
String _tractionQuerySignature(
Map<String, dynamic> filters,
bool hadOnly,
) {
final sortedKeys = filters.keys.toList()..sort();
final filterSignature = sortedKeys
.map((key) => '$key=${filters[key]}')
.join('|');
final classQuery = (_selectedClass ?? _classController.text).trim();
return [
'class=$classQuery',
'number=${_numberController.text.trim()}',
'name=${_nameController.text.trim()}',
'mileageFirst=$_mileageFirst',
'hadOnly=$hadOnly',
'filters=$filterSignature',
].join(';');
}
Future<void> _refreshTraction({
bool append = false,
bool preservePosition = true,
}) async {
final data = context.read<DataService>();
final filters = <String, dynamic>{};
final name = _nameController.text.trim();
@@ -118,15 +146,49 @@ class _TractionPageState extends State<TractionPage> {
}
});
final hadOnly = !_hasFilters;
final signature = _tractionQuerySignature(filters, hadOnly);
final queryChanged =
_lastQuerySignature != null && signature != _lastQuerySignature;
_lastQuerySignature = signature;
if (queryChanged && !append) {
_lastTractionOffset = 0;
}
final shouldPreservePosition = preservePosition &&
!append &&
!queryChanged &&
_lastTractionOffset > 0;
int limit;
int offset;
if (append) {
offset = data.traction.length;
limit = _pageSize;
_lastTractionOffset = offset;
} else if (shouldPreservePosition) {
offset = 0;
limit = _pageSize + _lastTractionOffset;
} else {
offset = 0;
limit = _pageSize;
}
await data.fetchTraction(
hadOnly: hadOnly,
locoClass: _selectedClass ?? _classController.text.trim(),
locoNumber: _numberController.text.trim(),
offset: append ? data.traction.length : 0,
offset: offset,
limit: limit,
append: append,
filters: filters,
mileageFirst: _mileageFirst,
);
if (!append && !shouldPreservePosition) {
_lastTractionOffset = 0;
}
await _persistSearchState();
}
@@ -639,6 +701,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 +784,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

@@ -39,6 +39,16 @@ extension _TractionPersistence on _TractionPageState {
enumValues[entry.key.toString()] = entry.value?.toString();
}
}
final lastOffsetRaw = decoded['lastOffset'];
if (lastOffsetRaw is int) {
_lastTractionOffset = lastOffsetRaw;
} else if (lastOffsetRaw is num) {
_lastTractionOffset = lastOffsetRaw.toInt();
}
final lastSig = decoded['querySignature']?.toString();
if (lastSig != null && lastSig.isNotEmpty) {
_lastQuerySignature = lastSig;
}
for (final entry in dynamicValues.entries) {
_dynamicControllers.putIfAbsent(
@@ -76,6 +86,8 @@ extension _TractionPersistence on _TractionPageState {
'showAdvancedFilters': _showAdvancedFilters,
'dynamic': _dynamicControllers.map((k, v) => MapEntry(k, v.text)),
'enum': _enumSelections,
'lastOffset': _lastTractionOffset,
'querySignature': _lastQuerySignature,
};
try {

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

@@ -13,7 +13,7 @@ extension DataServiceTraction on DataService {
Future<void> fetchTraction({
bool hadOnly = false,
int offset = 0,
int limit = 50,
int limit = 100,
String? locoClass,
String? locoNumber,
bool mileageFirst = true,

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) {
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.3+1
environment:
sdk: ^3.8.1
@@ -102,7 +102,16 @@ flutter:
flutter_launcher_icons:
android: true
ios: true
remove_alpha_ios: true
linux:
generate: true
web:
generate: true
background_colour: "#000000"
theme_color: "#0175C2"
windows:
generate: 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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

@@ -18,18 +18,19 @@
<meta charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project.">
<meta name="description" content="Log and explore your Mileograph journeys.">
<meta name="theme-color" content="#0175C2">
<!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="mileograph_flutter">
<meta name="apple-mobile-web-app-title" content="Mileograph">
<link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/>
<title>mileograph_flutter</title>
<title>Mileograph</title>
<link rel="manifest" href="manifest.json">
</head>
<body>

View File

@@ -1,35 +1,36 @@
{
"name": "mileograph_flutter",
"short_name": "mileograph_flutter",
"start_url": ".",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "A new Flutter project.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
"name": "Mileograph",
"short_name": "Mileograph",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#0175C2",
"theme_color": "#0175C2",
"description": "Log and explore your Mileograph journeys.",
"orientation": "portrait-primary",
"prefer_related_applications": false,
"icons": [
{
"src": "icons/Icon-192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "icons/Icon-512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "icons/Icon-maskable-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "icons/Icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 711 B