Compare commits
7 Commits
v0.5.0-dev
...
v0.5.3-dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f9c392bb07 | |||
| e5b145b4b2 | |||
| 59458484aa | |||
| 648872acf1 | |||
| b427ed0bd3 | |||
| 66a1d149f0 | |||
| cea483ae0b |
@@ -12,6 +12,7 @@ env:
|
|||||||
FLUTTER_VERSION: "3.38.5"
|
FLUTTER_VERSION: "3.38.5"
|
||||||
BUILD_WINDOWS: "false" # Windows build disabled (no runner available)
|
BUILD_WINDOWS: "false" # Windows build disabled (no runner available)
|
||||||
GITEA_BASE_URL: https://git.tgj.services
|
GITEA_BASE_URL: https://git.tgj.services
|
||||||
|
WEB_IMAGE: "git.tgj.services/petegregoryy/mileograph-web"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
meta:
|
meta:
|
||||||
@@ -123,6 +124,8 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
FLUTTER_HOME="$HOME/flutter"
|
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
|
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
|
||||||
rm -rf "$FLUTTER_HOME"
|
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"
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
FLUTTER_HOME="$HOME/flutter"
|
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
|
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
|
||||||
rm -rf "$FLUTTER_HOME"
|
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"
|
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
|
name: linux-bundle
|
||||||
path: app-linux-x64.tar.gz
|
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:
|
release-dev:
|
||||||
runs-on:
|
runs-on:
|
||||||
- mileograph
|
- mileograph
|
||||||
@@ -258,6 +365,7 @@ jobs:
|
|||||||
- meta
|
- meta
|
||||||
- android-build
|
- android-build
|
||||||
- linux-build
|
- linux-build
|
||||||
|
- web-build
|
||||||
steps:
|
steps:
|
||||||
- name: Install jq
|
- name: Install jq
|
||||||
run: |
|
run: |
|
||||||
@@ -341,6 +449,7 @@ jobs:
|
|||||||
- meta
|
- meta
|
||||||
- android-build
|
- android-build
|
||||||
- linux-build
|
- linux-build
|
||||||
|
- web-build
|
||||||
steps:
|
steps:
|
||||||
- name: Install jq
|
- name: Install jq
|
||||||
run: |
|
run: |
|
||||||
|
|||||||
10
Dockerfile.web
Normal 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;"]
|
||||||
|
Before Width: | Height: | Size: 4.7 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 5.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 1014 B |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 689 B |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 2.4 KiB |
@@ -1,4 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources>
|
<resources>
|
||||||
<color name="ic_launcher_background">#ffffff</color>
|
<color name="ic_launcher_background">#000000</color>
|
||||||
</resources>
|
</resources>
|
||||||
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 6.0 KiB |
BIN
assets/icons/app_icon_old.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
28
deploy/web/nginx.conf
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 598 B After Width: | Height: | Size: 346 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 497 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 738 B |
|
Before Width: | Height: | Size: 834 B After Width: | Height: | Size: 425 B |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 697 B |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 1017 B |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 497 B |
|
Before Width: | Height: | Size: 2.3 KiB After Width: | Height: | Size: 876 B |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 632 B |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 698 B |
|
Before Width: | Height: | Size: 3.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 5.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 834 B |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 841 B |
|
Before Width: | Height: | Size: 4.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -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(
|
||||||
@@ -109,12 +111,6 @@ class _LegCardState extends State<LegCard> {
|
|||||||
subtitle: LayoutBuilder(
|
subtitle: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
final isWide = constraints.maxWidth > 520;
|
final isWide = constraints.maxWidth > 520;
|
||||||
final timeWidget = _timeWithDelay(
|
|
||||||
context,
|
|
||||||
leg.beginTime,
|
|
||||||
leg.beginDelayMinutes,
|
|
||||||
includeDate: widget.showDate,
|
|
||||||
);
|
|
||||||
final tractionWrap = !_expanded && leg.locos.isNotEmpty
|
final tractionWrap = !_expanded && leg.locos.isNotEmpty
|
||||||
? Wrap(
|
? Wrap(
|
||||||
spacing: 8,
|
spacing: 8,
|
||||||
@@ -181,7 +177,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,54 @@ 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 _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));
|
||||||
@@ -223,10 +272,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (!mounted) return;
|
||||||
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 +482,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 +541,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 +790,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 +1014,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 +1028,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 +1047,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();
|
||||||
},
|
},
|
||||||
@@ -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 {
|
class _UpperCaseTextFormatter extends TextInputFormatter {
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -51,6 +52,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
|
|||||||
if (form == null) return;
|
if (form == null) return;
|
||||||
if (!form.validate()) return;
|
if (!form.validate()) return;
|
||||||
if (!await _validateRequiredFields()) return;
|
if (!await _validateRequiredFields()) return;
|
||||||
|
if (!mounted) return;
|
||||||
final routeStations = _routeResult?.calculatedRoute ?? [];
|
final routeStations = _routeResult?.calculatedRoute ?? [];
|
||||||
final startVal = _useManualMileage
|
final startVal = _useManualMileage
|
||||||
? _startController.text.trim()
|
? _startController.text.trim()
|
||||||
@@ -58,8 +60,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;
|
||||||
|
|||||||
@@ -84,6 +84,13 @@ class _NewTractionPageState extends State<NewTractionPage> {
|
|||||||
'traction_motors': TextEditingController(),
|
'traction_motors': TextEditingController(),
|
||||||
'build_date': TextEditingController(),
|
'build_date': TextEditingController(),
|
||||||
};
|
};
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!mounted) return;
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
if (data.locoClasses.isEmpty) {
|
||||||
|
data.fetchClassList();
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -254,6 +261,10 @@ class _NewTractionPageState extends State<NewTractionPage> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final isActive = _statusIsActive;
|
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 size = MediaQuery.of(context).size;
|
||||||
final isNarrow = size.width < 720;
|
final isNarrow = size.width < 720;
|
||||||
final fieldWidth = isNarrow ? double.infinity : 340.0;
|
final fieldWidth = isNarrow ? double.infinity : 340.0;
|
||||||
@@ -269,6 +280,89 @@ class _NewTractionPageState extends State<NewTractionPage> {
|
|||||||
double? widthOverride,
|
double? widthOverride,
|
||||||
String? Function(String?)? validator,
|
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(
|
return SizedBox(
|
||||||
width: widthOverride ?? fieldWidth,
|
width: widthOverride ?? fieldWidth,
|
||||||
child: TextFormField(
|
child: TextFormField(
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,9 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
final Map<String, TextEditingController> _dynamicControllers = {};
|
final Map<String, TextEditingController> _dynamicControllers = {};
|
||||||
final Map<String, String?> _enumSelections = {};
|
final Map<String, String?> _enumSelections = {};
|
||||||
bool _restoredFromPrefs = false;
|
bool _restoredFromPrefs = false;
|
||||||
|
static const int _pageSize = 100;
|
||||||
|
int _lastTractionOffset = 0;
|
||||||
|
String? _lastQuerySignature;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -59,6 +62,9 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
Future<void> _initialLoad() async {
|
Future<void> _initialLoad() async {
|
||||||
final data = context.read<DataService>();
|
final data = context.read<DataService>();
|
||||||
await _restoreSearchState();
|
await _restoreSearchState();
|
||||||
|
if (_lastTractionOffset == 0 && data.traction.length > _pageSize) {
|
||||||
|
_lastTractionOffset = data.traction.length - _pageSize;
|
||||||
|
}
|
||||||
data.fetchClassList();
|
data.fetchClassList();
|
||||||
data.fetchEventFields();
|
data.fetchEventFields();
|
||||||
await _refreshTraction();
|
await _refreshTraction();
|
||||||
@@ -103,7 +109,29 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
dynamicFieldsUsed;
|
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 data = context.read<DataService>();
|
||||||
final filters = <String, dynamic>{};
|
final filters = <String, dynamic>{};
|
||||||
final name = _nameController.text.trim();
|
final name = _nameController.text.trim();
|
||||||
@@ -118,15 +146,49 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
final hadOnly = !_hasFilters;
|
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(
|
await data.fetchTraction(
|
||||||
hadOnly: hadOnly,
|
hadOnly: hadOnly,
|
||||||
locoClass: _selectedClass ?? _classController.text.trim(),
|
locoClass: _selectedClass ?? _classController.text.trim(),
|
||||||
locoNumber: _numberController.text.trim(),
|
locoNumber: _numberController.text.trim(),
|
||||||
offset: append ? data.traction.length : 0,
|
offset: offset,
|
||||||
|
limit: limit,
|
||||||
append: append,
|
append: append,
|
||||||
filters: filters,
|
filters: filters,
|
||||||
mileageFirst: _mileageFirst,
|
mileageFirst: _mileageFirst,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!append && !shouldPreservePosition) {
|
||||||
|
_lastTractionOffset = 0;
|
||||||
|
}
|
||||||
|
|
||||||
await _persistSearchState();
|
await _persistSearchState();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -639,6 +701,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 +784,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),
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ extension _TractionPersistence on _TractionPageState {
|
|||||||
enumValues[entry.key.toString()] = entry.value?.toString();
|
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) {
|
for (final entry in dynamicValues.entries) {
|
||||||
_dynamicControllers.putIfAbsent(
|
_dynamicControllers.putIfAbsent(
|
||||||
@@ -76,6 +86,8 @@ extension _TractionPersistence on _TractionPageState {
|
|||||||
'showAdvancedFilters': _showAdvancedFilters,
|
'showAdvancedFilters': _showAdvancedFilters,
|
||||||
'dynamic': _dynamicControllers.map((k, v) => MapEntry(k, v.text)),
|
'dynamic': _dynamicControllers.map((k, v) => MapEntry(k, v.text)),
|
||||||
'enum': _enumSelections,
|
'enum': _enumSelections,
|
||||||
|
'lastOffset': _lastTractionOffset,
|
||||||
|
'querySignature': _lastQuerySignature,
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ extension DataServiceTraction on DataService {
|
|||||||
Future<void> fetchTraction({
|
Future<void> fetchTraction({
|
||||||
bool hadOnly = false,
|
bool hadOnly = false,
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
int limit = 50,
|
int limit = 100,
|
||||||
String? locoClass,
|
String? locoClass,
|
||||||
String? locoNumber,
|
String? locoNumber,
|
||||||
bool mileageFirst = true,
|
bool mileageFirst = true,
|
||||||
|
|||||||
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
pubspec.yaml
@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# 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.3+1
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
@@ -102,7 +102,16 @@ flutter:
|
|||||||
flutter_launcher_icons:
|
flutter_launcher_icons:
|
||||||
android: true
|
android: true
|
||||||
ios: 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
|
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
|
||||||
|
|||||||
BIN
web/favicon.png
|
Before Width: | Height: | Size: 917 B After Width: | Height: | Size: 304 B |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 8.1 KiB After Width: | Height: | Size: 6.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 6.5 KiB |
@@ -18,18 +18,19 @@
|
|||||||
|
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
<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 -->
|
<!-- iOS meta tags & icons -->
|
||||||
<meta name="mobile-web-app-capable" content="yes">
|
<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-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">
|
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||||
|
|
||||||
<title>mileograph_flutter</title>
|
<title>Mileograph</title>
|
||||||
<link rel="manifest" href="manifest.json">
|
<link rel="manifest" href="manifest.json">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,35 +1,36 @@
|
|||||||
{
|
{
|
||||||
"name": "mileograph_flutter",
|
"name": "Mileograph",
|
||||||
"short_name": "mileograph_flutter",
|
"short_name": "Mileograph",
|
||||||
"start_url": ".",
|
"start_url": "/",
|
||||||
"display": "standalone",
|
"scope": "/",
|
||||||
"background_color": "#0175C2",
|
"display": "standalone",
|
||||||
"theme_color": "#0175C2",
|
"background_color": "#0175C2",
|
||||||
"description": "A new Flutter project.",
|
"theme_color": "#0175C2",
|
||||||
"orientation": "portrait-primary",
|
"description": "Log and explore your Mileograph journeys.",
|
||||||
"prefer_related_applications": false,
|
"orientation": "portrait-primary",
|
||||||
"icons": [
|
"prefer_related_applications": false,
|
||||||
{
|
"icons": [
|
||||||
"src": "icons/Icon-192.png",
|
{
|
||||||
"sizes": "192x192",
|
"src": "icons/Icon-192.png",
|
||||||
"type": "image/png"
|
"sizes": "192x192",
|
||||||
},
|
"type": "image/png"
|
||||||
{
|
},
|
||||||
"src": "icons/Icon-512.png",
|
{
|
||||||
"sizes": "512x512",
|
"src": "icons/Icon-512.png",
|
||||||
"type": "image/png"
|
"sizes": "512x512",
|
||||||
},
|
"type": "image/png"
|
||||||
{
|
},
|
||||||
"src": "icons/Icon-maskable-192.png",
|
{
|
||||||
"sizes": "192x192",
|
"src": "icons/Icon-maskable-192.png",
|
||||||
"type": "image/png",
|
"sizes": "192x192",
|
||||||
"purpose": "maskable"
|
"type": "image/png",
|
||||||
},
|
"purpose": "maskable"
|
||||||
{
|
},
|
||||||
"src": "icons/Icon-maskable-512.png",
|
{
|
||||||
"sizes": "512x512",
|
"src": "icons/Icon-maskable-512.png",
|
||||||
"type": "image/png",
|
"sizes": "512x512",
|
||||||
"purpose": "maskable"
|
"type": "image/png",
|
||||||
}
|
"purpose": "maskable"
|
||||||
]
|
}
|
||||||
}
|
]
|
||||||
|
}
|
||||||
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 711 B |