Compare commits

...

17 Commits

Author SHA1 Message Date
42af39b442 fix issue with version and app id
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m42s
Release / web-build (push) Successful in 5m38s
Release / android-build (push) Successful in 15m17s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 28s
2026-01-02 19:10:32 +00:00
2fa66ff9c6 add admin page, fix no legs infinite load on dashboard
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m49s
Release / web-build (push) Successful in 6m23s
Release / android-build (push) Successful in 16m56s
Release / release-master (push) Successful in 30s
Release / release-dev (push) Successful in 32s
2026-01-02 18:49:14 +00:00
29cecf0ded add android bundle release
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m32s
Release / web-build (push) Successful in 5m50s
Release / android-build (push) Successful in 20m43s
Release / release-master (push) Successful in 26s
Release / release-dev (push) Successful in 28s
2026-01-02 14:34:11 +00:00
f9c392bb07 new app icon
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 7m21s
Release / web-build (push) Successful in 7m30s
Release / android-build (push) Successful in 19m56s
Release / release-dev (push) Successful in 25s
Release / release-master (push) Successful in 24s
2026-01-02 00:18:57 +00:00
e5b145b4b2 increment verison
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m28s
Release / web-build (push) Successful in 6m54s
Release / android-build (push) Successful in 18m9s
Release / release-master (push) Successful in 38s
Release / release-dev (push) Successful in 41s
2026-01-01 23:13:18 +00:00
59458484aa add build step for flutter web, add persistent pagination in the traction list
Some checks failed
Release / meta (push) Successful in 14s
Release / web-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-01 23:08:22 +00:00
648872acf1 re add mileograph tags
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m40s
Release / android-build (push) Successful in 14m28s
Release / release-master (push) Successful in 32s
Release / release-dev (push) Successful in 36s
2026-01-01 18:06:50 +00:00
b427ed0bd3 fix failed flutter install
Some checks failed
Release / meta (push) Successful in 2s
Release / android-build (push) Failing after 2m7s
Release / linux-build (push) Failing after 2m6s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2026-01-01 17:26:54 +00:00
66a1d149f0 remove mileograph tag
Some checks failed
Release / meta (push) Successful in 26s
Release / android-build (push) Failing after 2m38s
Release / linux-build (push) Failing after 2m58s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2026-01-01 17:11:55 +00:00
cea483ae0b Add ability to select distance unit
Some checks failed
Release / android-build (push) Blocked by required conditions
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m39s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-01 15:28:11 +00:00
7139cfcc99 add stats page
All checks were successful
Release / meta (push) Successful in 20s
Release / linux-build (push) Successful in 7m21s
Release / android-build (push) Successful in 16m39s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s
2026-01-01 12:50:27 +00:00
1c15546b66 support new fields in adding
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m54s
Release / android-build (push) Successful in 23m30s
Release / release-master (push) Successful in 30s
Release / release-dev (push) Successful in 32s
2025-12-31 18:23:37 +00:00
e1ad1ea685 remove windows
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m55s
Release / android-build (push) Successful in 22m33s
Release / release-dev (push) Successful in 26s
Release / release-master (push) Successful in 25s
2025-12-30 12:18:37 +00:00
9b307ab56b add windows build
Some checks failed
Release / meta (push) Successful in 18s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / android-build (push) Has been cancelled
2025-12-30 12:12:17 +00:00
8cf43c76e2 re add calculator page
Some checks failed
Release / meta (push) Successful in 11s
Release / linux-build (push) Successful in 8m31s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
2025-12-30 11:55:46 +00:00
2600e90efa adjust loggin in indicator
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 7m20s
Release / android-build (push) Successful in 25m10s
Release / release-dev (push) Successful in 30s
Release / release-master (push) Successful in 28s
2025-12-27 15:00:57 +00:00
a9bc6c306c add password reset on settings page
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 8m26s
Release / android-build (push) Successful in 21m31s
Release / release-master (push) Successful in 25s
Release / release-dev (push) Successful in 28s
2025-12-27 14:34:44 +00:00
86 changed files with 3970 additions and 527 deletions

View File

@@ -10,8 +10,9 @@ env:
JAVA_VERSION: "17"
ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk"
FLUTTER_VERSION: "3.38.5"
BUILD_WINDOWS: "false" # set to "true" when you actually want Windows builds
BUILD_WINDOWS: "false" # Windows build disabled (no runner available)
GITEA_BASE_URL: https://git.tgj.services
WEB_IMAGE: "git.tgj.services/petegregoryy/mileograph-web"
jobs:
meta:
@@ -20,6 +21,7 @@ jobs:
outputs:
base_version: ${{ steps.meta.outputs.base }}
release_tag: ${{ steps.meta.outputs.release_tag }}
dev_suffix: ${{ steps.meta.outputs.dev_suffix }}
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -29,12 +31,24 @@ jobs:
run: |
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
BASE_VERSION=${RAW_VERSION%%+*}
TAG="v${BASE_VERSION}"
VERSION="${BASE_VERSION}"
TAG="v${VERSION}"
DEV_SUFFIX=""
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
TAG="v${BASE_VERSION}-dev"
DEV_ITER="${GITHUB_RUN_NUMBER:-}"
if [ -z "$DEV_ITER" ]; then
DEV_ITER=$(git rev-list --count HEAD)
fi
DEV_SUFFIX="-dev.${DEV_ITER}"
VERSION="${BASE_VERSION}${DEV_SUFFIX}"
TAG="v${VERSION}"
fi
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "dev_suffix=${DEV_SUFFIX}" >> "$GITHUB_OUTPUT"
- name: Fail if release already exists
env:
@@ -110,6 +124,8 @@ jobs:
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
# Avoid git ownership issues when Flutter checks out deps.
git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
@@ -139,6 +155,18 @@ jobs:
- name: Build Android App Bundle (release)
run: flutter build appbundle --release
- name: Archive Android App Bundle
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
run: |
set -euo pipefail
BUNDLE_SRC="build/app/outputs/bundle/release/app-release.aab"
if [ ! -f "$BUNDLE_SRC" ]; then
echo "Bundle not found at $BUNDLE_SRC"
exit 1
fi
cp "$BUNDLE_SRC" "mileograph-${BASE_VERSION}.aab"
- name: Download bundletool
run: |
BUNDLETOOL_VERSION=1.15.6
@@ -185,6 +213,12 @@ jobs:
name: android-apk
path: mileograph-${{ needs.meta.outputs.base_version }}.apk
- name: Upload Android AAB artifact
uses: actions/upload-artifact@v3
with:
name: android-aab
path: mileograph-${{ needs.meta.outputs.base_version }}.aab
linux-build:
runs-on:
- mileograph
@@ -207,6 +241,8 @@ jobs:
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
# Avoid git ownership issues when Flutter checks out deps.
git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
@@ -238,6 +274,108 @@ jobs:
name: linux-bundle
path: app-linux-x64.tar.gz
web-build:
runs-on:
- mileograph
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install OS deps (Web)
run: |
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq docker.io
if ! docker info >/dev/null 2>&1; then
$SUDO systemctl start docker 2>/dev/null || $SUDO service docker start 2>/dev/null || true
fi
- name: Install Flutter SDK
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
tar -C "$HOME" -xf /tmp/flutter.tar.xz
fi
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
"$FLUTTER_HOME/bin/flutter" --version
- name: Allow all git directories (CI)
run: git config --global --add safe.directory '*'
- name: Set pub cache path
run: echo "PUB_CACHE=${GITHUB_WORKSPACE}/.pub-cache" >> "$GITHUB_ENV"
- name: Flutter dependencies
run: flutter pub get
- name: Enable Flutter web
run: flutter config --enable-web
- name: Build Flutter web (release)
run: |
flutter build web --release --base-href=/
tar -C build/web -czf app-web.tar.gz .
- name: Upload Web artifact
uses: actions/upload-artifact@v3
with:
name: web-build
path: app-web.tar.gz
- name: Compute web image tags
id: web_meta
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
DEV_SUFFIX: ${{ needs.meta.outputs.dev_suffix }}
run: |
IMAGE="${WEB_IMAGE}"
TAG=""
ALIAS=""
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
TAG="${BASE_VERSION}${DEV_SUFFIX}"
ALIAS="dev"
elif [ "${GITHUB_REF}" = "refs/heads/master" ]; then
TAG="${BASE_VERSION}"
ALIAS="latest"
fi
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "alias=${ALIAS}" >> "$GITHUB_OUTPUT"
- name: Login to registry
if: ${{ secrets.DOCKERHUB_TOKEN != '' && steps.web_meta.outputs.tag != '' }}
env:
REGISTRY_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login git.tgj.services -u petegregoryy --password-stdin
- name: Build and push web image
if: ${{ secrets.DOCKERHUB_TOKEN != '' && steps.web_meta.outputs.tag != '' }}
env:
IMAGE: ${{ steps.web_meta.outputs.image }}
TAG: ${{ steps.web_meta.outputs.tag }}
ALIAS: ${{ steps.web_meta.outputs.alias }}
run: |
docker buildx create --name buildx --driver=docker-container --use || docker buildx use buildx
TAG_ARGS=(-t "${IMAGE}:${TAG}")
if [ -n "$ALIAS" ]; then
TAG_ARGS+=(-t "${IMAGE}:${ALIAS}")
fi
docker buildx build --builder buildx --platform linux/amd64 \
-f Dockerfile.web \
--push \
"${TAG_ARGS[@]}" .
release-dev:
runs-on:
- mileograph
@@ -245,6 +383,7 @@ jobs:
- meta
- android-build
- linux-build
- web-build
steps:
- name: Install jq
run: |
@@ -262,17 +401,35 @@ jobs:
name: android-apk
path: artifacts
- name: Download Android AAB
if: ${{ github.ref == 'refs/heads/dev' }}
uses: actions/download-artifact@v3
with:
name: android-aab
path: artifacts
- name: Prepare APK and tag
if: ${{ github.ref == 'refs/heads/dev' }}
id: bundle
run: |
BASE="${{ needs.meta.outputs.base_version }}"
TAG="${{ needs.meta.outputs.release_tag }}"
DEV_SUFFIX="${{ needs.meta.outputs.dev_suffix }}"
if [ -z "$DEV_SUFFIX" ]; then
echo "dev_suffix is empty; expected '-dev.<n>'"
exit 1
fi
mv "artifacts/mileograph-${BASE}.apk" "artifacts/mileograph-${BASE}-dev.apk"
VERSION="${BASE}${DEV_SUFFIX}"
APK_NAME="mileograph-${VERSION}.apk"
AAB_NAME="mileograph-${VERSION}.aab"
mv "artifacts/mileograph-${BASE}.apk" "artifacts/${APK_NAME}"
mv "artifacts/mileograph-${BASE}.aab" "artifacts/${AAB_NAME}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "apk=artifacts/mileograph-${BASE}-dev.apk" >> "$GITHUB_OUTPUT"
echo "apk=artifacts/${APK_NAME}" >> "$GITHUB_OUTPUT"
echo "aab=artifacts/${AAB_NAME}" >> "$GITHUB_OUTPUT"
- name: Create prerelease on Gitea
if: ${{ github.ref == 'refs/heads/dev' }}
@@ -313,6 +470,18 @@ jobs:
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null
# Attach AAB
AAB="${{ steps.bundle.outputs.aab }}"
NAME_AAB=$(basename "$AAB")
echo "Uploading $NAME_AAB"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${AAB}" \
-F "name=${NAME_AAB}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null
release-master:
runs-on:
- mileograph
@@ -320,6 +489,7 @@ jobs:
- meta
- android-build
- linux-build
- web-build
steps:
- name: Install jq
run: |
@@ -337,6 +507,13 @@ jobs:
name: android-apk
path: artifacts
- name: Download Android AAB
if: ${{ github.ref == 'refs/heads/master' }}
uses: actions/download-artifact@v3
with:
name: android-aab
path: artifacts
- name: Prepare APK and tag
if: ${{ github.ref == 'refs/heads/master' }}
id: bundle
@@ -346,6 +523,7 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT"
echo "aab=artifacts/mileograph-${BASE}.aab" >> "$GITHUB_OUTPUT"
- name: Create release on Gitea
if: ${{ github.ref == 'refs/heads/master' }}
@@ -384,3 +562,15 @@ jobs:
-F "name=${NAME}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null
# Attach AAB
AAB="${{ steps.bundle.outputs.aab }}"
NAME_AAB=$(basename "$AAB")
echo "Uploading $NAME_AAB"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${AAB}" \
-F "name=${NAME_AAB}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null

10
Dockerfile.web Normal file
View File

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

View File

@@ -1,16 +1,67 @@
# mileograph_flutter
# Mileograph (Flutter)
A new Flutter project.
Mileograph is a Flutter client for logging and analysing railway journeys. It lets you record legs, group them into trips, track locomotive mileage, and view stats and leaderboards.
## Getting Started
## Features
- Add and edit journey legs with traction, timings, routes, notes, and delays.
- Group legs into trips and see mileage totals and traction stats.
- Browse traction, view loco details, mileage leaderboards, timelines, and legs.
- Dashboard with homepage stats, “On This Day”, recent traction changes, and trips.
- Profile badges, class clearance progress, and traction progress.
- Offline-friendly UI built with Provider, GoRouter, and Material 3 styling.
This project is a starting point for a Flutter application.
## Project layout
- `lib/objects/objects.dart` — shared model classes and helpers.
- `lib/services/` — API, data loading, auth, endpoints, distance units.
- `lib/components/` — UI pages and widgets (entries, traction, dashboard, trips, settings, etc.).
- `assets/` — icons/fonts and other bundled assets.
A few resources to get you started if this is your first Flutter project:
## Prerequisites
- Flutter SDK (3.x or later recommended).
- Dart SDK (bundled with Flutter).
- A Mileograph API endpoint (set in Settings within the app).
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
## Setup
1) Install Flutter: follow https://docs.flutter.dev/get-started/install and ensure `flutter doctor` is green.
2) Get dependencies:
```bash
flutter pub get
```
3) Configure an API endpoint:
- Run the app, open Settings, and set the base URL for your Mileograph backend.
- The app probes the endpoint for a version string before saving.
For help getting started with Flutter development, view the
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
## Running
- Debug (mobile/web depending on your toolchain):
```bash
flutter run
```
- Release build (example for Android):
```bash
flutter build apk --release
```
## Testing and linting
- Static analysis: `flutter analyze`
- Unit/widget tests (if present): `flutter test`
## Contributing
1) Fork or branch from `main`.
2) Make changes with clear, small commits.
3) Add tests where feasible and keep `flutter analyze` clean.
4) Submit a PR describing:
- What changed and why.
- How to test or reproduce.
- Any API or migration notes.
### Coding conventions
- Prefer stateless widgets where possible; keep state localized.
- Use existing services in `lib/services` for API access; add new endpoints there.
- Keep models in `objects.dart` (or nearby files) and use helper parsers for defensive JSON handling.
- Follow Material theming already in use; keep strings user-facing and concise.
### Issue reporting
Include device/OS, Flutter version (`flutter --version`), steps to reproduce, expected vs. actual behaviour, and logs if available.
## License
Copyright © Mileograph contributors. See repository terms if provided.

View File

@@ -31,7 +31,7 @@ val hasReleaseKeystore = listOf(
android {
namespace = "com.example.mileograph_flutter"
compileSdk = flutter.compileSdkVersion
compileSdk = 36
ndkVersion = "27.0.12077973"
compileOptions {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 689 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

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

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 598 B

After

Width:  |  Height:  |  Size: 346 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 738 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 B

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 697 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1017 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 497 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 876 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 632 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 698 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 834 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 841 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.3 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

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

View File

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

View File

@@ -51,7 +51,7 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
const SizedBox(width: 8),
Expanded(
child: Text(
'Latest loco changes',
'Latest Loco Changes',
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),

View File

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

View File

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

View File

@@ -1,9 +1,8 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class LegCard extends StatefulWidget {
@@ -28,6 +27,7 @@ class _LegCardState extends State<LegCard> {
@override
Widget build(BuildContext context) {
final leg = widget.leg;
final distanceUnits = context.watch<DistanceUnitService>();
final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme;
return Card(
@@ -38,19 +38,72 @@ class _LegCardState extends State<LegCard> {
title: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 520;
final routeText = Text('${leg.start}${leg.end}');
final timeText =
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
final beginTimeWidget = _timeWithDelay(
context,
leg.beginTime,
leg.beginDelayMinutes,
includeDate: widget.showDate,
);
final endTimeWidget = leg.endTime == null
? null
: _timeWithDelay(
context,
leg.endTime!,
leg.endDelayMinutes,
includeDate: widget.showDate,
);
final routeText = Text(
'${leg.start}${leg.end}',
softWrap: true,
);
if (!isWide) {
return routeText;
final timeStyle = Theme.of(context).textTheme.labelSmall;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
routeText,
const SizedBox(height: 2),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_timeWithDelay(
context,
leg.beginTime,
leg.beginDelayMinutes,
includeDate: widget.showDate,
style: timeStyle,
),
if (endTimeWidget != null) ...[
const Text('·'),
_timeWithDelay(
context,
leg.endTime!,
leg.endDelayMinutes,
includeDate: widget.showDate,
style: timeStyle,
),
],
],
),
],
);
}
return Row(
return Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
timeText,
const SizedBox(width: 6),
beginTimeWidget,
const Text('·'),
const SizedBox(width: 6),
Expanded(child: routeText),
routeText,
if (endTimeWidget != null) ...[
const Text('·'),
endTimeWidget,
],
],
);
},
@@ -58,8 +111,6 @@ class _LegCardState extends State<LegCard> {
subtitle: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 520;
final timeWidget =
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
final tractionWrap = !_expanded && leg.locos.isNotEmpty
? Wrap(
spacing: 8,
@@ -90,9 +141,7 @@ class _LegCardState extends State<LegCard> {
children.add(tractionWrap);
}
} else {
children.add(timeWidget);
if (tractionWrap != null) {
children.add(const SizedBox(height: 4));
children.add(tractionWrap);
}
}
@@ -128,7 +177,7 @@ class _LegCardState extends State<LegCard> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${leg.mileage.toStringAsFixed(1)} mi',
distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
@@ -191,6 +240,12 @@ class _LegCardState extends State<LegCard> {
),
const SizedBox(height: 12),
],
if (_hasTrainDetails(leg)) ...[
Text('Train', style: textTheme.titleSmall),
const SizedBox(height: 6),
..._buildTrainDetails(leg, textTheme),
const SizedBox(height: 12),
],
if (routeSegments.isNotEmpty) ...[
Text('Route', style: textTheme.titleSmall),
const SizedBox(height: 6),
@@ -238,6 +293,40 @@ class _LegCardState extends State<LegCard> {
}
}
Widget _timeWithDelay(
BuildContext context,
DateTime time,
int? delay, {
bool includeDate = true,
TextStyle? style,
}) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final delayMinutes = delay ?? 0;
final delayText =
delayMinutes == 0 ? null : '${delayMinutes > 0 ? '+' : ''}$delayMinutes';
final delayColor = delayMinutes == 0
? null
: (delayMinutes < 0 ? Colors.green : colorScheme.error);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatDateTime(time, includeDate: includeDate),
style: style,
),
if (delayText != null) ...[
const SizedBox(width: 4),
Text(
'$delayText m',
style:
(style ?? textTheme.labelSmall)?.copyWith(color: delayColor),
),
],
],
);
}
String _formatDate(DateTime? date) {
if (date == null) return '';
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
@@ -286,6 +375,51 @@ class _LegCardState extends State<LegCard> {
.toList();
}
bool _hasTrainDetails(Leg leg) {
return leg.headcode.isNotEmpty ||
leg.origin.isNotEmpty ||
leg.destination.isNotEmpty ||
leg.originTime != null ||
leg.destinationTime != null;
}
List<Widget> _buildTrainDetails(Leg leg, TextTheme textTheme) {
final widgets = <Widget>[];
if (leg.headcode.isNotEmpty) {
widgets.add(
Text(
'Headcode: ${leg.headcode}',
style: textTheme.bodyMedium,
),
);
}
final originLine = _locationLine(
'Origin',
leg.origin,
leg.originTime,
);
if (originLine != null) {
widgets.add(Text(originLine, style: textTheme.bodyMedium));
}
final destinationLine = _locationLine(
'Destination',
leg.destination,
leg.destinationTime,
);
if (destinationLine != null) {
widgets.add(Text(destinationLine, style: textTheme.bodyMedium));
}
return widgets;
}
String? _locationLine(String label, String location, DateTime? time) {
final parts = <String>[];
if (location.trim().isNotEmpty) parts.add(location.trim());
if (time != null) parts.add(_formatDateTime(time));
if (parts.isEmpty) return null;
return '$label: ${parts.join(' · ')}';
}
Widget _buildRouteList(List<String> segments) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -306,38 +440,7 @@ class _LegCardState extends State<LegCard> {
);
}
List<String> _parseRouteSegments(String route) {
final trimmed = route.trim();
if (trimmed.isEmpty) return [];
try {
final decoded = jsonDecode(trimmed);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {}
if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
try {
final replaced = trimmed.replaceAll("'", '"');
final decoded = jsonDecode(replaced);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {}
}
if (trimmed.contains('->')) {
return trimmed
.split('->')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
if (trimmed.contains(',')) {
return trimmed
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
return [trimmed];
List<String> _parseRouteSegments(List<String> route) {
return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList();
}
}

View File

@@ -17,7 +17,9 @@ class _LoginScreenState extends State<LoginScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkExistingSession());
WidgetsBinding.instance.addPostFrameCallback(
(_) => _checkExistingSession(),
);
}
Future<void> _checkExistingSession() async {
@@ -71,15 +73,6 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
),
if (_checkingSession)
const Padding(
padding: EdgeInsets.only(top: 12),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
const SizedBox(height: 50),
const LoginPanel(),
const SizedBox(height: 16),
@@ -95,6 +88,24 @@ class _LoginScreenState extends State<LoginScreen> {
);
},
),
if (_checkingSession) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(
'Trying to log in',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
],
],
),
),
@@ -193,9 +204,9 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
setState(() {
_loggingIn = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Login failed: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Login failed: $e')));
}
}
@@ -306,14 +317,16 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
);
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Registration successful. Please log in.')),
const SnackBar(
content: Text('Registration successful. Please log in.'),
),
);
widget.onBack();
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Registration failed: $e')),
);
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('Registration failed: $e')));
} finally {
if (mounted) setState(() => _registering = false);
}

View File

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

View File

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

View File

@@ -21,8 +21,7 @@ class LogbookPage extends StatelessWidget {
children: [
TabBar(
onTap: (index) {
final dest =
index == 0 ? '/logbook/entries' : '/logbook/trips';
final dest = index == 0 ? '/logbook/entries' : '/logbook/trips';
final current = GoRouterState.of(context).uri.path;
if (current != dest) {
context.go(dest);
@@ -34,12 +33,7 @@ class LogbookPage extends StatelessWidget {
],
),
Expanded(
child: TabBarView(
children: const [
LegsPage(),
TripsPage(),
],
),
child: TabBarView(children: const [LegsPage(), TripsPage()]),
),
],
),

View File

@@ -1,68 +1,13 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/pages/profile.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/more/more_home_page.dart';
export 'more/admin_page.dart';
class MorePage extends StatelessWidget {
const MorePage({super.key});
@override
Widget build(BuildContext context) {
return Navigator(
onGenerateRoute: (settings) {
final name = settings.name ?? '/';
Widget page;
switch (name) {
case '/settings':
page = const SettingsPage();
break;
case '/profile':
page = const ProfilePage();
break;
case '/more/settings':
page = const SettingsPage();
break;
case '/more/profile':
page = const ProfilePage();
break;
case '/':
default:
page = _MoreHome();
}
return MaterialPageRoute(builder: (_) => page, settings: settings);
},
);
}
}
class _MoreHome extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'More',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.emoji_events),
title: const Text('Badges'),
onTap: () => Navigator.of(context).pushNamed('/more/profile'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Settings'),
onTap: () => Navigator.of(context).pushNamed('/more/settings'),
),
],
),
),
],
);
return const MoreHomePage();
}
}

View File

@@ -0,0 +1,476 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
class AdminPage extends StatefulWidget {
const AdminPage({super.key});
@override
State<AdminPage> createState() => _AdminPageState();
}
class _AdminPageState extends State<AdminPage> {
final TextEditingController _titleController = TextEditingController();
final TextEditingController _bodyController = TextEditingController();
final List<UserSummary> _selectedUsers = [];
List<UserSummary> _userOptions = [];
List<String> _channels = [];
String? _selectedChannel;
String? _channelError;
bool _loadingChannels = false;
String? _userError;
bool _sending = false;
@override
void initState() {
super.initState();
_loadChannels();
}
@override
void dispose() {
_titleController.dispose();
_bodyController.dispose();
super.dispose();
}
Future<void> _loadChannels() async {
setState(() {
_loadingChannels = true;
_channelError = null;
});
try {
final api = context.read<ApiService>();
final json = await api.get('/notifications/channels');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['channels', 'data']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
final parsed =
list?.map((e) => e.toString()).where((e) => e.isNotEmpty).toList() ??
const [];
setState(() {
_channels = parsed;
_selectedChannel = parsed.isNotEmpty ? parsed.first : null;
});
} catch (e) {
setState(() {
_channelError = 'Failed to load channels';
});
} finally {
if (mounted) setState(() => _loadingChannels = false);
}
}
Future<List<UserSummary>> _fetchUserSuggestions(
ApiService api,
String query,
) async {
final encoded = Uri.encodeComponent(query);
final candidates = [
'/users/search?q=$encoded',
'/users/search?query=$encoded',
'/users?search=$encoded',
];
for (final path in candidates) {
try {
final json = await api.get(path);
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['users', 'data', 'results', 'items']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
if (list != null) {
return list
.whereType<Map>()
.map((e) => UserSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
}
} catch (_) {
// Try next endpoint
}
}
return const [];
}
void _removeUser(UserSummary user) {
setState(() {
_selectedUsers.removeWhere((u) => u.userId == user.userId);
});
}
Future<void> _openUserPicker() async {
final api = context.read<ApiService>();
var tempSelected = List<UserSummary>.from(_selectedUsers);
var options = List<UserSummary>.from(_userOptions);
String query = '';
bool loading = false;
String? error = _userError;
Future<void> runSearch(String q, void Function(void Function()) setModalState) async {
setModalState(() {
query = q;
loading = true;
error = null;
});
try {
final results = await _fetchUserSuggestions(api, q);
setModalState(() {
options = results;
loading = false;
error = null;
});
if (mounted) {
setState(() {
_userOptions = results;
_userError = null;
});
}
} catch (e) {
setModalState(() {
loading = false;
error = 'Failed to search users';
});
if (mounted) {
setState(() {
_userError = 'Failed to search users';
});
}
}
}
var initialFetchTriggered = false;
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
if (!initialFetchTriggered && !loading && options.isEmpty) {
initialFetchTriggered = true;
WidgetsBinding.instance.addPostFrameCallback(
(_) => runSearch('', setModalState),
);
}
final lowerQuery = query.toLowerCase();
final filtered = lowerQuery.isEmpty
? options
: options.where((u) {
return u.displayName.toLowerCase().contains(lowerQuery) ||
u.username.toLowerCase().contains(lowerQuery) ||
u.email.toLowerCase().contains(lowerQuery);
}).toList();
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: MediaQuery.of(ctx).viewInsets.bottom + 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Select recipients',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
TextButton(
onPressed: () {
setModalState(() {
tempSelected.clear();
});
setState(() => _selectedUsers.clear());
},
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'Search users',
border: OutlineInputBorder(),
),
onChanged: (val) => runSearch(val, setModalState),
),
if (loading)
const Padding(
padding: EdgeInsets.only(top: 8.0),
child: LinearProgressIndicator(minHeight: 2),
),
if (error != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
error!,
style:
TextStyle(color: Theme.of(context).colorScheme.error),
),
),
const SizedBox(height: 12),
SizedBox(
height: 340,
child: filtered.isEmpty
? const Center(child: Text('No users yet.'))
: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, index) {
final user = filtered[index];
final selected =
tempSelected.any((u) => u.userId == user.userId);
return CheckboxListTile(
value: selected,
title: Text(user.displayName),
subtitle: user.email.isNotEmpty
? Text(user.email)
: (user.username.isNotEmpty
? Text(user.username)
: null),
onChanged: (val) {
setModalState(() {
if (val == true) {
if (!tempSelected
.any((u) => u.userId == user.userId)) {
tempSelected.add(user);
}
} else {
tempSelected.removeWhere(
(u) => u.userId == user.userId);
}
});
if (mounted) {
setState(() {
_selectedUsers
..clear()
..addAll(tempSelected);
});
}
},
);
},
),
),
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: const Text('Done'),
),
),
],
),
),
);
},
);
},
);
}
Future<void> _sendNotification() async {
final channel = _selectedChannel;
if (channel == null || channel.isEmpty) {
_showSnack('Select a channel first.');
return;
}
if (_selectedUsers.isEmpty) {
_showSnack('Select at least one user.');
return;
}
final title = _titleController.text.trim();
final body = _bodyController.text.trim();
if (title.isEmpty || body.isEmpty) {
_showSnack('Title and body are required.');
return;
}
setState(() => _sending = true);
try {
final api = context.read<ApiService>();
await api.post('/notifications/new', {
'user_ids': _selectedUsers.map((e) => e.userId).toList(),
'channel': channel,
'title': title,
'body': body,
});
if (!mounted) return;
_showSnack('Notification sent');
setState(() {
_selectedUsers.clear();
_titleController.clear();
_bodyController.clear();
_userOptions.clear();
});
} catch (e) {
_showSnack('Failed to send: $e');
} finally {
if (mounted) setState(() => _sending = false);
}
}
void _showSnack(String message) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(message)));
}
@override
Widget build(BuildContext context) {
final isAdmin = context.select<AuthService, bool>((auth) => auth.isElevated);
if (!isAdmin) {
return const Scaffold(
body: Center(child: Text('You do not have access to this page.')),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Admin'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Send notification',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 12),
_buildUserPicker(),
const SizedBox(height: 12),
DropdownButtonFormField<String>(
value: _selectedChannel,
decoration: const InputDecoration(
labelText: 'Channel',
border: OutlineInputBorder(),
),
items: _channels
.map(
(c) => DropdownMenuItem(
value: c,
child: Text(c),
),
)
.toList(),
onChanged:
_loadingChannels ? null : (val) => setState(() => _selectedChannel = val),
),
if (_loadingChannels)
const Padding(
padding: EdgeInsets.only(top: 8.0),
child: LinearProgressIndicator(minHeight: 2),
),
if (_channelError != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
_channelError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
const SizedBox(height: 12),
TextField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'Title',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 12),
TextField(
controller: _bodyController,
minLines: 3,
maxLines: 6,
decoration: const InputDecoration(
labelText: 'Body',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: _sending ? null : _sendNotification,
icon: _sending
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_sending ? 'Sending...' : 'Send notification'),
),
),
],
),
);
}
Widget _buildUserPicker() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Recipients',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: _selectedUsers
.map(
(u) => InputChip(
label: Text(u.displayName),
onDeleted: () => _removeUser(u),
),
)
.toList(),
),
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: _openUserPicker,
icon: const Icon(Icons.person_search),
label: const Text('Select users'),
),
if (_userError != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
_userError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
],
);
}
}

View File

@@ -0,0 +1,53 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/services/authservice.dart';
class MoreHomePage extends StatelessWidget {
const MoreHomePage({super.key});
@override
Widget build(BuildContext context) {
final isAdmin = context.select<AuthService, bool>((auth) => auth.isElevated);
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'More',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
Card(
child: Column(
children: [
ListTile(
leading: const Icon(Icons.emoji_events),
title: const Text('Badges'),
onTap: () => context.go('/more/profile'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.bar_chart),
title: const Text('Stats'),
onTap: () => context.go('/more/stats'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Settings'),
onTap: () => context.go('/more/settings'),
),
if (isAdmin) const Divider(height: 1),
if (isAdmin)
ListTile(
leading: const Icon(Icons.admin_panel_settings),
title: const Text('Admin'),
onTap: () => context.go('/more/admin'),
),
],
),
),
],
);
}
}

View File

@@ -11,6 +11,7 @@ import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -13,6 +13,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
if (choice == _ExitChoice.save) {
await _saveDraftEntry(draftId: _activeDraftId);
} else if (choice == _ExitChoice.discard) {
// Delay reset to avoid setState during the dialog/build phase.
await Future<void>.delayed(Duration.zero);
if (!mounted) return false;
await _resetFormState(clearDraft: true);
_activeDraftId = null;
}
@@ -29,12 +32,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
}
bool _formIsEmpty() {
final beginDelayVal = _parseDelayMinutes(_beginDelayController.text);
final endDelayVal = _parseDelayMinutes(_endDelayController.text);
return _startController.text.trim().isEmpty &&
_endController.text.trim().isEmpty &&
_headcodeController.text.trim().isEmpty &&
_notesController.text.trim().isEmpty &&
_networkController.text.trim().isEmpty &&
_mileageController.text.trim().isEmpty &&
_originController.text.trim().isEmpty &&
_destinationController.text.trim().isEmpty &&
beginDelayVal == 0 &&
endDelayVal == 0 &&
!_hasOriginTime &&
!_hasDestinationTime &&
!_hasEndTime &&
_routeResult == null &&
_tractionItems.length <= 1;
}
@@ -122,6 +134,30 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
"notes": _notesController.text,
"mileage": _mileageController.text,
"network": _networkController.text,
"origin": _originController.text,
"destination": _destinationController.text,
"hasEndTime": _hasEndTime,
"hasOriginTime": _hasOriginTime,
"hasDestinationTime": _hasDestinationTime,
"endDate": _selectedEndDate.toIso8601String(),
"endTime": {
"hour": _selectedEndTime.hour,
"minute": _selectedEndTime.minute,
},
"originDate": _selectedOriginDate.toIso8601String(),
"originTime": {
"hour": _selectedOriginTime.hour,
"minute": _selectedOriginTime.minute,
},
"destinationDate": _selectedDestinationDate.toIso8601String(),
"destinationTime": {
"hour": _selectedDestinationTime.hour,
"minute": _selectedDestinationTime.minute,
},
"matchOriginToEntry": _matchOriginToEntry,
"matchDestinationToEntry": _matchDestinationToEntry,
"beginDelay": _parseDelayMinutes(_beginDelayController.text),
"endDelay": _parseDelayMinutes(_endDelayController.text),
"useManualMileage": _useManualMileage,
"selectedTripId": _selectedTripId,
"routeResult": _routeResult == null
@@ -199,7 +235,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
required String id,
bool includeTimestamp = true,
}) {
final units = _distanceUnits(context);
final routeStations = _routeResult?.calculatedRoute ?? [];
final endTime = _legEndDateTime;
final originTime = _originDateTime;
final destinationTime = _destinationDateTime;
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final startVal = _useManualMileage
? _startController.text.trim()
: (routeStations.isNotEmpty ? routeStations.first : '');
@@ -207,30 +250,36 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : '');
final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0
? (units.milesFromInput(_mileageController.text.trim()) ?? 0)
: (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload();
final commonPayload = {
"leg_trip": _selectedTripId,
"leg_begin_time": _legDateTime.toIso8601String(),
if (endTime != null) "leg_end_time": endTime.toIso8601String(),
if (originTime != null) "leg_origin_time": originTime.toIso8601String(),
if (destinationTime != null)
"leg_destination_time": destinationTime.toIso8601String(),
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(),
"leg_origin": _originController.text.trim(),
"leg_destination": _destinationController.text.trim(),
"leg_begin_delay": beginDelay,
if (_hasEndTime) "leg_end_delay": endDelay,
"locos": tractionPayload,
};
final payload = _useManualMileage
? {
"leg_trip": _selectedTripId,
...commonPayload,
"leg_start": startVal,
"leg_end": endVal,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_network": _networkController.text.trim(),
"leg_distance": mileageVal,
"isKilometers": false,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"locos": tractionPayload,
}
: {
"leg_trip": _selectedTripId,
"leg_begin_time": _legDateTime.toIso8601String(),
...commonPayload,
"leg_route": routeStations,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(),
"locos": tractionPayload,
"leg_mileage": _routeResult?.distance ?? mileageVal,
};
return {
@@ -265,8 +314,32 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
final beginTime = beginStr == null
? DateTime.now()
: DateTime.tryParse(beginStr) ?? DateTime.now();
final originTimeStr = payload['leg_origin_time'] as String?;
final destinationTimeStr = payload['leg_destination_time'] as String?;
final originTime =
originTimeStr == null ? null : DateTime.tryParse(originTimeStr);
final destinationTime = destinationTimeStr == null
? null
: DateTime.tryParse(destinationTimeStr);
final endStr = payload['leg_end_time'] as String?;
final endTime =
endStr == null ? null : DateTime.tryParse(endStr);
final beginDelay =
_parseDelayMinutes('${payload['leg_begin_delay'] ?? ''}');
final endDelay =
_parseDelayMinutes('${payload['leg_end_delay'] ?? ''}');
final hasEndTime = endTime != null || endDelay != 0;
final matchOrigin = data['matchOriginToEntry'] == true;
final matchDestination = data['matchDestinationToEntry'] == true;
final hasOriginTime =
originTime != null || data['hasOriginTime'] == true;
final hasDestinationTime =
destinationTime != null || data['hasDestinationTime'] == true;
final origin = payload['leg_origin'] as String? ?? '';
final destination = payload['leg_destination'] as String? ?? '';
final tripRaw = payload['leg_trip'];
final tripId = tripRaw is num ? tripRaw.toInt() : null;
final units = _distanceUnits(context);
List<String> routeStations = [];
RouteResult? restoredRouteResult;
@@ -312,6 +385,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
_useManualMileage = useManual;
_selectedDate = beginTime;
_selectedTime = TimeOfDay.fromDateTime(beginTime);
_selectedEndDate = endTime ?? beginTime;
_selectedEndTime = TimeOfDay.fromDateTime(endTime ?? beginTime);
_hasEndTime = hasEndTime;
_matchOriginToEntry = matchOrigin;
_matchDestinationToEntry = matchDestination;
_selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime =
TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate =
destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime = TimeOfDay.fromDateTime(
destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime;
_selectedTripId = tripId == null || tripId == 0 ? null : tripId;
_routeResult = restoredRouteResult;
_headcodeController.text = (payload['leg_headcode'] as String? ?? '')
@@ -319,6 +407,10 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
_networkController.text = (payload['leg_network'] as String? ?? '')
.toUpperCase();
_notesController.text = payload['leg_notes'] ?? '';
_originController.text = origin;
_destinationController.text = destination;
_beginDelayController.text = beginDelay.toString();
_endDelayController.text = endDelay.toString();
if (useManual) {
_startController.text = payload['leg_start'] ?? '';
@@ -326,14 +418,20 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
final miles = (payload['leg_distance'] as num?)?.toDouble();
_mileageController.text = miles == null || miles == 0
? ''
: miles.toStringAsFixed(2);
: units.format(
miles,
decimals: 2,
includeUnit: false,
);
} else {
_startController.text =
routeStations.isNotEmpty ? routeStations.first : '';
_endController.text =
routeStations.isNotEmpty ? routeStations.last : '';
final dist = _routeResult?.distance ?? 0;
_mileageController.text = dist == 0 ? '' : dist.toStringAsFixed(2);
_mileageController.text = dist == 0
? ''
: units.format(dist, decimals: 2, includeUnit: false);
}
final tractionRaw = data['tractionItems'];
@@ -359,6 +457,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
includeTimestamp: false,
);
_restoringDraft = false;
_scheduleMatchUpdate();
}
Future<void> _loadDraft() async {

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,12 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
Future<bool> _validateRequiredFields() async {
final missing = <String>[];
final units = _distanceUnits(context);
if (_useManualMileage) {
if (_startController.text.trim().isEmpty) missing.add('From');
if (_endController.text.trim().isEmpty) missing.add('To');
final mileageText = _mileageController.text.trim();
if (double.tryParse(mileageText) == null) {
if (mileageText.isEmpty || units.milesFromInput(mileageText) == null) {
missing.add('Mileage');
}
} else {
@@ -51,6 +52,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
if (form == null) return;
if (!form.validate()) return;
if (!await _validateRequiredFields()) return;
if (!mounted) return;
final routeStations = _routeResult?.calculatedRoute ?? [];
final startVal = _useManualMileage
? _startController.text.trim()
@@ -58,10 +60,17 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final endVal = _useManualMileage
? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : '');
final units = _distanceUnits(context);
final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0
? (units.milesFromInput(_mileageController.text.trim()) ?? 0)
: (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload();
final endTime = _legEndDateTime;
final originTime = _originDateTime;
final destinationTime = _destinationDateTime;
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final snapshot = _buildSubmissionSnapshot(
routeStations: routeStations,
startVal: startVal,
@@ -82,19 +91,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final isEditingExisting = _isEditing && widget.editLegId != null;
try {
final commonPayload = {
if (isEditingExisting) "leg_id": widget.editLegId,
"leg_trip": _selectedTripId,
"leg_begin_time": _legDateTime.toIso8601String(),
if (endTime != null) "leg_end_time": endTime.toIso8601String(),
if (originTime != null)
"leg_origin_time": originTime.toIso8601String(),
if (destinationTime != null)
"leg_destination_time": destinationTime.toIso8601String(),
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(),
"leg_origin": _originController.text.trim(),
"leg_destination": _destinationController.text.trim(),
"leg_begin_delay": beginDelay,
if (_hasEndTime) "leg_end_delay": endDelay,
"locos": tractionPayload,
};
if (_useManualMileage) {
final body = {
if (isEditingExisting) "leg_id": widget.editLegId,
"leg_trip": _selectedTripId,
...commonPayload,
"leg_start": startVal,
"leg_end": endVal,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_network": _networkController.text.trim(),
"leg_distance": mileageVal,
"isKilometers": false,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"locos": tractionPayload,
};
if (isEditingExisting) {
await api.put('/update', body);
@@ -103,14 +124,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
}
} else {
final body = {
if (isEditingExisting) "leg_id": widget.editLegId,
"leg_trip": _selectedTripId,
"leg_begin_time": _legDateTime.toIso8601String(),
...commonPayload,
"leg_route": routeStations,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(),
"locos": tractionPayload,
};
if (isEditingExisting) {
await api.put('/update', body);
@@ -148,18 +163,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
required double mileageVal,
required List<Map<String, dynamic>> tractionPayload,
}) {
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
return {
"legId": widget.editLegId,
"useManualMileage": _useManualMileage,
"tripId": _selectedTripId,
"legDateTime": _legDateTime.toIso8601String(),
"legEndTime": _legEndDateTime?.toIso8601String(),
"hasEndTime": _hasEndTime,
"legOriginTime": _originDateTime?.toIso8601String(),
"hasOriginTime": _hasOriginTime,
"legDestinationTime": _destinationDateTime?.toIso8601String(),
"hasDestinationTime": _hasDestinationTime,
"start": startVal,
"end": endVal,
"origin": _originController.text.trim(),
"destination": _destinationController.text.trim(),
"routeStations": routeStations,
"mileage": mileageVal,
"network": _networkController.text.trim(),
"notes": _notesController.text.trim(),
"headcode": _headcodeController.text.trim(),
"beginDelay": beginDelay,
"endDelay": endDelay,
"locos": tractionPayload,
"routeResult": _routeResult == null
? null
@@ -202,11 +230,27 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
_notesController.clear();
_mileageController.clear();
_networkController.clear();
_originController.clear();
_destinationController.clear();
_beginDelayController.text = '0';
_endDelayController.text = '0';
final now = DateTime.now();
_setState(() {
_selectedDate = now;
_selectedTime = TimeOfDay.fromDateTime(now);
_selectedEndDate = now;
_selectedEndTime = TimeOfDay.fromDateTime(now);
_selectedOriginDate = now;
_selectedOriginTime = TimeOfDay.fromDateTime(now);
_selectedDestinationDate = now;
_selectedDestinationTime = TimeOfDay.fromDateTime(now);
_useManualMileage = false;
_hasEndTime = false;
_hasOriginTime = false;
_hasDestinationTime = false;
_matchOriginToEntry = false;
_matchDestinationToEntry = false;
_matchUpdateScheduled = false;
_routeResult = null;
_tractionItems
..clear()

View File

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

View File

@@ -3,6 +3,9 @@ import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
@@ -17,16 +20,27 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _endpointController;
bool _saving = false;
bool _changingPassword = false;
final _passwordFormKey = GlobalKey<FormState>();
late final TextEditingController _currentPasswordController;
late final TextEditingController _newPasswordController;
late final TextEditingController _confirmPasswordController;
@override
void initState() {
super.initState();
final endpoint = context.read<EndpointService>().baseUrl;
_endpointController = TextEditingController(text: endpoint);
_currentPasswordController = TextEditingController();
_newPasswordController = TextEditingController();
_confirmPasswordController = TextEditingController();
}
@override
void dispose() {
_currentPasswordController.dispose();
_newPasswordController.dispose();
_confirmPasswordController.dispose();
_endpointController.dispose();
super.dispose();
}
@@ -125,10 +139,48 @@ class _SettingsPageState extends State<SettingsPage> {
}
}
Future<void> _changePassword() async {
final messenger = ScaffoldMessenger.of(context);
final formState = _passwordFormKey.currentState;
if (formState == null || !formState.validate()) return;
FocusScope.of(context).unfocus();
setState(() => _changingPassword = true);
try {
final api = context.read<ApiService>();
await api.post('/user/password/change', {
'old_password': _currentPasswordController.text,
'new_password': _newPasswordController.text,
});
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('Password updated successfully.')),
);
formState.reset();
_currentPasswordController.clear();
_newPasswordController.clear();
_confirmPasswordController.clear();
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to change password: $e')),
);
}
} finally {
if (mounted) {
setState(() => _changingPassword = false);
}
}
}
@override
Widget build(BuildContext context) {
final endpointService = context.watch<EndpointService>();
if (!endpointService.isLoaded) {
final distanceUnitService = context.watch<DistanceUnitService>();
final loggedIn = context.select<AuthService, bool>(
(auth) => auth.isLoggedIn,
);
if (!endpointService.isLoaded || !distanceUnitService.isLoaded) {
return const Scaffold(
body: Center(child: CircularProgressIndicator()),
);
@@ -149,11 +201,39 @@ class _SettingsPageState extends State<SettingsPage> {
},
),
),
body: Padding(
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Distance units',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Choose how distances are displayed across the app.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
SegmentedButton<DistanceUnit>(
segments: DistanceUnit.values
.map(
(unit) => ButtonSegment<DistanceUnit>(
value: unit,
label: Text(unit.label),
),
)
.toList(),
selected: {distanceUnitService.unit},
onSelectionChanged: (selection) {
final next = selection.first;
distanceUnitService.setUnit(next);
},
),
const SizedBox(height: 24),
Text(
'API endpoint',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
@@ -205,6 +285,99 @@ class _SettingsPageState extends State<SettingsPage> {
'Current: ${endpointService.baseUrl}',
style: Theme.of(context).textTheme.labelSmall,
),
if (loggedIn) ...[
const SizedBox(height: 32),
Text(
'Account',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Change your password for this account.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Form(
key: _passwordFormKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
controller: _currentPasswordController,
decoration: const InputDecoration(
labelText: 'Current password',
border: OutlineInputBorder(),
),
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofillHints: const [AutofillHints.password],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your current password.';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _newPasswordController,
decoration: const InputDecoration(
labelText: 'New password',
border: OutlineInputBorder(),
),
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofillHints: const [AutofillHints.newPassword],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter a new password.';
}
return null;
},
),
const SizedBox(height: 12),
TextFormField(
controller: _confirmPasswordController,
decoration: const InputDecoration(
labelText: 'Confirm new password',
border: OutlineInputBorder(),
),
obscureText: true,
enableSuggestions: false,
autocorrect: false,
autofillHints: const [AutofillHints.newPassword],
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please confirm the new password.';
}
if (value != _newPasswordController.text) {
return 'New passwords do not match.';
}
return null;
},
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _changingPassword ? null : _changePassword,
icon: _changingPassword
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.lock_reset),
label: Text(
_changingPassword ? 'Updating...' : 'Change password',
),
),
],
),
),
],
],
),
),

View File

@@ -0,0 +1,225 @@
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class StatsPage extends StatefulWidget {
const StatsPage({super.key});
@override
State<StatsPage> createState() => _StatsPageState();
}
class _StatsPageState extends State<StatsPage> {
final NumberFormat _countFormat = NumberFormat.decimalPattern();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadStats();
});
}
Future<void> _loadStats({bool force = false}) {
return context.read<DataService>().fetchAboutStats(force: force);
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
return Scaffold(
appBar: AppBar(title: const Text('Stats')),
body: RefreshIndicator(
onRefresh: () => _loadStats(force: true),
child: _buildContent(data, distanceUnits),
),
);
}
Widget _buildContent(
DataService data,
DistanceUnitService distanceUnits,
) {
final stats = data.aboutStats;
final loading = data.isAboutStatsLoading;
if (loading && stats == null) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 140),
Center(child: CircularProgressIndicator()),
SizedBox(height: 140),
],
);
}
if (stats == null || stats.sortedYears.isEmpty) {
return ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(24),
children: [
const SizedBox(height: 40),
const Center(child: Text('No stats available yet.')),
const SizedBox(height: 12),
Center(
child: OutlinedButton(
onPressed: () => _loadStats(force: true),
child: const Text('Retry'),
),
),
],
);
}
final years = stats.sortedYears;
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: years.length,
itemBuilder: (context, index) {
return Padding(
padding: EdgeInsets.only(bottom: index == years.length - 1 ? 0 : 12),
child: _buildYearCard(context, years[index], distanceUnits),
);
},
);
}
Widget _buildYearCard(
BuildContext context, StatsYear year, DistanceUnitService distanceUnits) {
final theme = Theme.of(context);
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
year.year.toString(),
style: theme.textTheme.titleLarge,
),
const Spacer(),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
_buildInfoChip(
context,
label: 'Mileage',
value: distanceUnits.format(year.mileage, decimals: 1),
),
_buildInfoChip(
context,
label: 'Winners',
value: _countFormat.format(year.winnerCount),
),
],
),
],
),
const SizedBox(height: 8),
_buildSection<StatsClassMileage>(
context,
title: 'Top classes',
items: year.topClasses,
emptyLabel: 'No class data',
itemBuilder: (item, index) => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.locoClass),
trailing: Text(
distanceUnits.format(item.mileage, decimals: 1),
),
),
),
_buildSection<StatsNetworkMileage>(
context,
title: 'Top networks',
items: year.topNetworks,
emptyLabel: 'No network data',
itemBuilder: (item, index) => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.network),
trailing: Text(
distanceUnits.format(item.mileage, decimals: 1),
),
),
),
_buildSection<StatsStationVisits>(
context,
title: 'Top stations',
items: year.topStations,
emptyLabel: 'No station data',
itemBuilder: (item, index) => ListTile(
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.station),
trailing: Text(
'${_countFormat.format(item.visits)} visit${item.visits == 1 ? '' : 's'}',
),
),
),
],
),
),
);
}
Widget _buildInfoChip(BuildContext context,
{required String label, required String value}) {
final theme = Theme.of(context);
return Chip(
padding: const EdgeInsets.symmetric(horizontal: 8),
label: Text(
'$label: $value',
style: theme.textTheme.labelLarge,
),
);
}
Widget _buildSection<T>(
BuildContext context, {
required String title,
required List<T> items,
required Widget Function(T item, int index) itemBuilder,
String emptyLabel = 'No data',
}) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 4),
child: ExpansionTile(
tilePadding: const EdgeInsets.symmetric(horizontal: 8),
childrenPadding:
const EdgeInsets.only(left: 8, right: 8, bottom: 8),
title: Text(
title,
style: theme.textTheme.titleMedium,
),
children: items.isEmpty
? [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Text(
emptyLabel,
style: theme.textTheme.bodySmall,
),
),
]
: items
.asMap()
.entries
.map((entry) => itemBuilder(entry.value, entry.key))
.toList(),
),
);
}
}

View File

@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -37,6 +37,9 @@ class _TractionPageState extends State<TractionPage> {
final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {};
bool _restoredFromPrefs = false;
static const int _pageSize = 100;
int _lastTractionOffset = 0;
String? _lastQuerySignature;
@override
void initState() {
@@ -59,6 +62,9 @@ class _TractionPageState extends State<TractionPage> {
Future<void> _initialLoad() async {
final data = context.read<DataService>();
await _restoreSearchState();
if (_lastTractionOffset == 0 && data.traction.length > _pageSize) {
_lastTractionOffset = data.traction.length - _pageSize;
}
data.fetchClassList();
data.fetchEventFields();
await _refreshTraction();
@@ -103,7 +109,29 @@ class _TractionPageState extends State<TractionPage> {
dynamicFieldsUsed;
}
Future<void> _refreshTraction({bool append = false}) async {
String _tractionQuerySignature(
Map<String, dynamic> filters,
bool hadOnly,
) {
final sortedKeys = filters.keys.toList()..sort();
final filterSignature = sortedKeys
.map((key) => '$key=${filters[key]}')
.join('|');
final classQuery = (_selectedClass ?? _classController.text).trim();
return [
'class=$classQuery',
'number=${_numberController.text.trim()}',
'name=${_nameController.text.trim()}',
'mileageFirst=$_mileageFirst',
'hadOnly=$hadOnly',
'filters=$filterSignature',
].join(';');
}
Future<void> _refreshTraction({
bool append = false,
bool preservePosition = true,
}) async {
final data = context.read<DataService>();
final filters = <String, dynamic>{};
final name = _nameController.text.trim();
@@ -118,15 +146,49 @@ class _TractionPageState extends State<TractionPage> {
}
});
final hadOnly = !_hasFilters;
final signature = _tractionQuerySignature(filters, hadOnly);
final queryChanged =
_lastQuerySignature != null && signature != _lastQuerySignature;
_lastQuerySignature = signature;
if (queryChanged && !append) {
_lastTractionOffset = 0;
}
final shouldPreservePosition = preservePosition &&
!append &&
!queryChanged &&
_lastTractionOffset > 0;
int limit;
int offset;
if (append) {
offset = data.traction.length;
limit = _pageSize;
_lastTractionOffset = offset;
} else if (shouldPreservePosition) {
offset = 0;
limit = _pageSize + _lastTractionOffset;
} else {
offset = 0;
limit = _pageSize;
}
await data.fetchTraction(
hadOnly: hadOnly,
locoClass: _selectedClass ?? _classController.text.trim(),
locoNumber: _numberController.text.trim(),
offset: append ? data.traction.length : 0,
offset: offset,
limit: limit,
append: append,
filters: filters,
mileageFirst: _mileageFirst,
);
if (!append && !shouldPreservePosition) {
_lastTractionOffset = 0;
}
await _persistSearchState();
}
@@ -639,6 +701,7 @@ class _TractionPageState extends State<TractionPage> {
Widget _buildClassStatsCard(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final distanceUnits = context.watch<DistanceUnitService>();
if (_classStatsLoading) {
return Card(
child: Padding(
@@ -721,9 +784,27 @@ class _TractionPageState extends State<TractionPage> {
children: [
_metricTile('Had', hadCount),
_metricTile('Entries', entriesWithClass),
_metricTile('Avg mi / loco had', avgMileagePerLoco.toStringAsFixed(2)),
_metricTile('Avg mi / entry', avgMileagePerEntry.toStringAsFixed(2)),
_metricTile('Total mileage', totalMileage.toStringAsFixed(2)),
_metricTile(
'Avg distance / loco had',
distanceUnits.format(
avgMileagePerLoco,
decimals: 2,
),
),
_metricTile(
'Avg distance / entry',
distanceUnits.format(
avgMileagePerEntry,
decimals: 2,
),
),
_metricTile(
'Total distance',
distanceUnits.format(
totalMileage,
decimals: 2,
),
),
],
),
const SizedBox(height: 12),

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class TripsPage extends StatefulWidget {
@@ -27,10 +28,6 @@ class _TripsPageState extends State<TripsPage> {
_tripLocoStatsFutures.clear();
final data = context.read<DataService>();
await data.fetchTripDetails();
if (!mounted) return;
for (final trip in data.tripDetails) {
_tripStatsFuture(trip.id);
}
}
Future<void> _renameTrip(TripDetail trip, String newName) async {
@@ -42,10 +39,7 @@ class _TripsPageState extends State<TripsPage> {
"trip_id": trip.id,
"trip_name": newName,
});
await Future.wait([
data.fetchTripDetails(),
data.fetchTrips(),
]);
await data.fetchTripDetails();
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to rename trip: $e')),
@@ -54,10 +48,24 @@ class _TripsPageState extends State<TripsPage> {
}
}
Future<List<TripLocoStat>> _tripStatsFuture(int tripId) {
List<TripLocoStat> _cachedTripStats(
TripDetail trip,
TripSummary? summary,
) {
if (trip.locoStats.isNotEmpty) return trip.locoStats;
if (summary?.locoStats.isNotEmpty == true) return summary!.locoStats;
return const [];
}
Future<List<TripLocoStat>> _loadTripStats(
TripDetail trip,
TripSummary? summary,
) {
final cached = _cachedTripStats(trip, summary);
if (cached.isNotEmpty) return Future.value(cached);
return _tripLocoStatsFutures.putIfAbsent(
tripId,
() => context.read<DataService>().fetchTripLocoStats(tripId),
trip.id,
() => context.read<DataService>().fetchTripLocoStats(trip.id),
);
}
@@ -92,8 +100,12 @@ class _TripsPageState extends State<TripsPage> {
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final tripDetails = data.tripDetails;
final tripSummaries = data.trips;
final tripSummaries = data.tripList;
final summaryById = {
for (final summary in tripSummaries) summary.tripId: summary,
};
final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty;
return RefreshIndicator(
@@ -178,24 +190,33 @@ class _TripsPageState extends State<TripsPage> {
return Card(
child: ListTile(
title: Text(trip.tripName),
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'),
subtitle:
Text(distanceUnits.format(trip.tripMileage, decimals: 1)),
),
);
}
final trip = tripDetails[index - 1];
return _buildTripCard(context, trip);
final summary = summaryById[trip.id];
return _buildTripCard(context, trip, summary);
},
),
);
}
Widget _buildTripCard(BuildContext context, TripDetail trip) {
Widget _buildTripCard(
BuildContext context,
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.watch<DistanceUnitService>();
final legs = trip.legs;
final legCount = trip.legCount > 0 ? trip.legCount : legs.length;
final legCount =
trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length;
final dateRange = _formatDateRange(legs);
final endpoints = _formatEndpoints(legs);
final statsFuture = _tripStatsFuture(trip.id);
final stats = _cachedTripStats(trip, summary);
final winnerCount = stats.where((e) => e.won).length;
return Card(
child: Padding(
@@ -211,7 +232,7 @@ class _TripsPageState extends State<TripsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Trip',
'Trip #${trip.id}',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
@@ -228,67 +249,36 @@ class _TripsPageState extends State<TripsPage> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
trip.mileage.toStringAsFixed(1),
distanceUnits.format(trip.mileage, decimals: 1),
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
Text(
'miles',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
],
),
],
),
const SizedBox(height: 12),
FutureBuilder<List<TripLocoStat>>(
future: statsFuture,
builder: (context, snapshot) {
final chips = <Widget>[
_buildMetaChip(context, Icons.timeline, '$legCount legs'),
if (dateRange != null)
_buildMetaChip(context, Icons.calendar_month, dateRange),
if (endpoints != null)
_buildMetaChip(context, Icons.route, endpoints),
];
final stats = snapshot.data ?? const [];
final hasStats = stats.isNotEmpty;
final loading =
snapshot.connectionState == ConnectionState.waiting;
if (loading && !hasStats) {
chips.add(
_buildMetaChip(context, Icons.train, 'Loading traction...'),
);
} else if (hasStats) {
final winnerCount = stats.where((e) => e.won).length;
chips.add(
_buildMetaChip(context, Icons.train, '${stats.length} had'),
);
chips.add(
_buildMetaChip(
context,
Icons.emoji_events_outlined,
'$winnerCount winners',
),
);
} else if (snapshot.connectionState == ConnectionState.done) {
chips.add(
_buildMetaChip(context, Icons.train, 'No traction yet'),
);
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: chips,
);
},
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildMetaChip(context, Icons.timeline, '$legCount legs'),
if (dateRange != null)
_buildMetaChip(context, Icons.calendar_month, dateRange),
if (endpoints != null)
_buildMetaChip(context, Icons.route, endpoints),
if (stats.isNotEmpty) ...[
_buildMetaChip(context, Icons.train, '${stats.length} had'),
_buildMetaChip(
context,
Icons.emoji_events_outlined,
'$winnerCount winners',
),
] else
_buildMetaChip(context, Icons.train, 'No traction yet'),
],
),
const SizedBox(height: 12),
Align(
@@ -301,7 +291,7 @@ class _TripsPageState extends State<TripsPage> {
OutlinedButton.icon(
icon: const Icon(Icons.train),
label: const Text('Locos'),
onPressed: () => _showTripWinners(context, trip),
onPressed: () => _showTripWinners(context, trip, summary),
),
FilledButton.icon(
icon: const Icon(Icons.open_in_new),
@@ -375,6 +365,7 @@ class _TripsPageState extends State<TripsPage> {
}
void _showTripDetail(BuildContext context, TripDetail trip) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -499,7 +490,9 @@ class _TripsPageState extends State<TripsPage> {
),
],
const SizedBox(width: 4),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
],
),
const SizedBox(height: 8),
@@ -514,7 +507,12 @@ class _TripsPageState extends State<TripsPage> {
title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)),
trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-',
leg.mileage == null
? '-'
: distanceUnits.format(
leg.mileage!,
decimals: 1,
),
style: Theme.of(context).textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
),
@@ -532,14 +530,20 @@ class _TripsPageState extends State<TripsPage> {
);
}
void _showTripWinners(BuildContext context, TripDetail trip) {
void _showTripWinners(
BuildContext context,
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (_) {
return SafeArea(
child: FutureBuilder<List<TripLocoStat>>(
future: _tripStatsFuture(trip.id),
future: _loadTripStats(trip, summary),
initialData: _cachedTripStats(trip, summary),
builder: (ctx, snapshot) {
final items = snapshot.data ?? [];
final loading =
@@ -564,7 +568,9 @@ class _TripsPageState extends State<TripsPage> {
?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
],
),
const SizedBox(height: 8),

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class TractionCard extends StatelessWidget {
const TractionCard({
@@ -28,6 +31,7 @@ class TractionCard extends StatelessWidget {
final domain = loco.domain ?? '';
final hasMileageOrTrips = _hasMileageOrTrips(loco);
final statusColors = _statusChipColors(context, status);
final distanceUnits = context.watch<DistanceUnitService>();
return Card(
child: Padding(
@@ -151,8 +155,11 @@ class TractionCard extends StatelessWidget {
children: [
_statPill(
context,
label: 'Miles',
value: _formatNumber(loco.mileage),
label: 'Distance',
value: distanceUnits.format(
loco.mileage ?? 0,
decimals: 1,
),
),
_statPill(
context,
@@ -203,6 +210,12 @@ Future<void> showTractionDetails(
LocoSummary loco,
) async {
final hasMileageOrTrips = _hasMileageOrTrips(loco);
final distanceUnits = context.read<DistanceUnitService>();
final api = context.read<ApiService>();
final leaderboardId = _leaderboardId(loco);
final leaderboardFuture = leaderboardId == null
? Future.value(const <LeaderboardEntry>[])
: _fetchLocoLeaderboard(api, leaderboardId);
await showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -275,7 +288,10 @@ Future<void> showTractionDetails(
_detailRow(
context,
'Mileage',
_formatNumber(loco.mileage ?? 0),
distanceUnits.format(
loco.mileage ?? 0,
decimals: 1,
),
),
_detailRow(
context,
@@ -285,6 +301,63 @@ Future<void> showTractionDetails(
_detailRow(context, 'EVN', loco.evn ?? ''),
if (loco.notes != null && loco.notes!.isNotEmpty)
_detailRow(context, 'Notes', loco.notes!),
const SizedBox(height: 16),
Text(
'Leaderboard',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
FutureBuilder<List<LeaderboardEntry>>(
future: leaderboardFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'Failed to load leaderboard',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
);
}
final entries = snapshot.data ?? const <LeaderboardEntry>[];
if (entries.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'No mileage leaderboard yet.',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
return Column(
children: entries.asMap().entries.map((entry) {
final rank = entry.key + 1;
return _leaderboardRow(
context,
rank,
entry.value,
distanceUnits,
);
}).toList(),
);
},
),
],
),
),
@@ -297,6 +370,105 @@ Future<void> showTractionDetails(
);
}
Future<List<LeaderboardEntry>> _fetchLocoLeaderboard(
ApiService api,
int locoId,
) async {
try {
final json = await api.get('/loco/leaderboard/id/$locoId');
Iterable<dynamic>? raw;
if (json is List) {
raw = json;
} else if (json is Map) {
for (final key in ['data', 'leaderboard', 'results']) {
final value = json[key];
if (value is List) {
raw = value;
break;
}
}
}
if (raw == null) return const [];
return raw.whereType<Map>().map((e) {
return LeaderboardEntry.fromJson(
e.map((key, value) => MapEntry(key.toString(), value)),
);
}).toList();
} catch (e) {
debugPrint('Failed to fetch loco leaderboard for $locoId: $e');
rethrow;
}
}
int? _leaderboardId(LocoSummary loco) {
int? parse(dynamic value) {
if (value == null) return null;
if (value is int) return value == 0 ? null : value;
if (value is num) return value.toInt() == 0 ? null : value.toInt();
return int.tryParse(value.toString());
}
return parse(loco.extra['loco_id']) ??
parse(loco.extra['id']) ??
parse(loco.id);
}
Widget _leaderboardRow(
BuildContext context,
int rank,
LeaderboardEntry entry,
DistanceUnitService distanceUnits,
) {
final theme = Theme.of(context);
final primaryName =
entry.userFullName.isNotEmpty ? entry.userFullName : entry.username;
final mileageLabel = distanceUnits.format(entry.mileage, decimals: 1);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'#$rank',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
primaryName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
Text(
mileageLabel,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Widget _detailRow(BuildContext context, String label, String value) {
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
@@ -368,8 +540,3 @@ bool _hasMileageOrTrips(LocoSummary loco) {
final trips = loco.trips ?? loco.journeys ?? 0;
return mileage > 0 || trips > 0;
}
String _formatNumber(double? value) {
if (value == null) return '0';
return value.toStringAsFixed(1);
}

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
@@ -20,6 +22,35 @@ String _asString(dynamic value, [String fallback = '']) {
return (str == null) ? fallback : str;
}
List<String> _asStringList(dynamic value) {
if (value is List) {
return value.map((e) => e.toString()).toList();
}
final trimmed = value?.toString().trim() ?? '';
if (trimmed.isEmpty) return const [];
try {
final decoded = jsonDecode(trimmed);
if (decoded is List) {
return decoded.map((e) => e.toString()).toList();
}
} catch (_) {}
if (trimmed.contains('->')) {
return trimmed
.split('->')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
if (trimmed.contains(',')) {
return trimmed
.split(',')
.map((e) => e.trim())
.where((e) => e.isNotEmpty)
.toList();
}
return [trimmed];
}
bool _asBool(dynamic value, [bool fallback = false]) {
if (value is bool) return value;
if (value is num) return value != 0;
@@ -35,6 +66,29 @@ DateTime _asDateTime(dynamic value, [DateTime? fallback]) {
return parsed ?? (fallback ?? DateTime.fromMillisecondsSinceEpoch(0));
}
DateTime? _asNullableDateTime(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
int compareTripsByDateDesc(
DateTime? aDate,
DateTime? bDate,
int aId,
int bId,
) {
if (aDate != null && bDate != null) {
final cmp = bDate.compareTo(aDate);
if (cmp != 0) return cmp;
} else if (aDate != null) {
return -1;
} else if (bDate != null) {
return 1;
}
return bId.compareTo(aId);
}
class DestinationObject {
const DestinationObject(
this.label,
@@ -50,24 +104,64 @@ class DestinationObject {
}
class UserData {
const UserData(this.username, this.fullName, this.userId, this.email);
const UserData({
required this.username,
required this.fullName,
required this.userId,
required this.email,
bool? elevated,
bool? disabled,
}) : elevated = elevated ?? false,
disabled = disabled ?? false;
final String userId;
final String username;
final String fullName;
final String email;
final bool elevated;
final bool disabled;
}
class AuthenticatedUserData extends UserData {
const AuthenticatedUserData({
required String userId,
required String username,
required String fullName,
required String email,
required super.userId,
required super.username,
required super.fullName,
required super.email,
bool? elevated,
bool? isElevated,
bool? disabled,
bool? isDisabled,
required this.accessToken,
}) : super(username, fullName, userId, email);
}) : super(
elevated: (elevated ?? false) || (isElevated ?? false),
disabled: (disabled ?? false) || (isDisabled ?? false),
);
final String accessToken;
bool get isElevated => elevated;
}
class UserSummary extends UserData {
const UserSummary({
required super.username,
required super.fullName,
required super.userId,
required super.email,
super.elevated = false,
super.disabled = false,
});
String get displayName => fullName.isNotEmpty ? fullName : username;
factory UserSummary.fromJson(Map<String, dynamic> json) => UserSummary(
username: _asString(json['username'] ?? json['user_name']),
fullName: _asString(json['full_name'] ?? json['name']),
userId: _asString(json['user_id'] ?? json['id']),
email: _asString(json['email']),
elevated: _asBool(json['elevated'] ?? json['is_elevated'], false),
disabled: _asBool(json['disabled'], false),
);
}
class HomepageStats {
@@ -116,10 +210,13 @@ class HomepageStats {
user: userData == null
? null
: UserData(
userData['username'] ?? '',
userData['full_name'] ?? '',
userData['user_id'] ?? '',
userData['email'] ?? '',
username: userData['username'] ?? '',
fullName: userData['full_name'] ?? '',
userId: userData['user_id'] ?? '',
email: userData['email'] ?? '',
elevated:
_asBool(userData['elevated'] ?? userData['is_elevated'], false),
disabled: _asBool(userData['disabled'], false),
),
);
}
@@ -137,6 +234,208 @@ class YearlyMileage {
);
}
class StatsAbout {
final Map<int, StatsYear> years;
StatsAbout({required this.years});
factory StatsAbout.fromJson(Map<String, dynamic> json) {
final mileageByYear = <int, double>{};
final classByYear = <int, List<StatsClassMileage>>{};
final networkByYear = <int, List<StatsNetworkMileage>>{};
final stationByYear = <int, List<StatsStationVisits>>{};
final winnersByYear = <int, int>{};
void addYearMileage(dynamic entry) {
if (entry is Map<String, dynamic>) {
final year = entry['year'] is int
? entry['year'] as int
: int.tryParse('${entry['year']}');
if (year != null) {
mileageByYear[year] = _asDouble(entry['mileage']);
}
}
}
if (json['year_mileages'] is List) {
for (final entry in json['year_mileages']) {
if (entry is Map<String, dynamic>) {
addYearMileage(entry);
} else if (entry is Map) {
addYearMileage(entry
.map((key, value) => MapEntry(key.toString(), value)));
}
}
}
List<StatsClassMileage> parseClassList(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map((e) => StatsClassMileage.fromJson(
e.map((key, value) => MapEntry(key.toString(), value))))
.toList();
}
return const [];
}
List<StatsNetworkMileage> parseNetworkList(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map((e) => StatsNetworkMileage.fromJson(
e.map((key, value) => MapEntry(key.toString(), value))))
.toList();
}
return const [];
}
List<StatsStationVisits> parseStationList(dynamic value) {
if (value is List) {
return value
.whereType<Map>()
.map((e) => StatsStationVisits.fromJson(
e.map((key, value) => MapEntry(key.toString(), value))))
.toList();
}
return const [];
}
void parseYearMap<T>(
dynamic source,
Map<int, T> target,
T Function(dynamic value) mapper,
) {
if (source is Map) {
source.forEach((key, value) {
final year = int.tryParse(key.toString());
if (year == null) return;
target[year] = mapper(value);
});
}
}
parseYearMap<List<StatsClassMileage>>(
json['top_classes'],
classByYear,
parseClassList,
);
parseYearMap<List<StatsNetworkMileage>>(
json['top_networks'],
networkByYear,
parseNetworkList,
);
parseYearMap<List<StatsStationVisits>>(
json['top_stations'],
stationByYear,
parseStationList,
);
if (json['year_winners'] is Map) {
(json['year_winners'] as Map).forEach((key, value) {
final year = int.tryParse(key.toString());
if (year == null) return;
if (value is List) {
winnersByYear[year] = value.length;
}
});
}
final years = <int>{
...mileageByYear.keys,
...classByYear.keys,
...networkByYear.keys,
...stationByYear.keys,
...winnersByYear.keys,
}..removeWhere((year) => year == 0);
final yearMap = <int, StatsYear>{};
for (final year in years) {
yearMap[year] = StatsYear(
year: year,
mileage: mileageByYear[year] ?? 0,
topClasses: classByYear[year] ?? const [],
topNetworks: networkByYear[year] ?? const [],
topStations: stationByYear[year] ?? const [],
winnerCount: winnersByYear[year] ?? 0,
);
}
return StatsAbout(years: yearMap);
}
List<StatsYear> get sortedYears {
final list = years.values.toList();
list.sort((a, b) => b.year.compareTo(a.year));
return list;
}
}
class StatsYear {
final int year;
final double mileage;
final List<StatsClassMileage> topClasses;
final List<StatsNetworkMileage> topNetworks;
final List<StatsStationVisits> topStations;
final int winnerCount;
StatsYear({
required this.year,
required this.mileage,
required this.topClasses,
required this.topNetworks,
required this.topStations,
required this.winnerCount,
});
}
class StatsClassMileage {
final String locoClass;
final double mileage;
StatsClassMileage({
required this.locoClass,
required this.mileage,
});
factory StatsClassMileage.fromJson(Map<String, dynamic> json) =>
StatsClassMileage(
locoClass: _asString(json['loco_class'], 'Unknown'),
mileage: _asDouble(json['mileage']),
);
}
class StatsNetworkMileage {
final String network;
final double mileage;
StatsNetworkMileage({
required this.network,
required this.mileage,
});
factory StatsNetworkMileage.fromJson(Map<String, dynamic> json) =>
StatsNetworkMileage(
network: _asString(json['network'], 'Unknown'),
mileage: _asDouble(json['mileage']),
);
}
class StatsStationVisits {
final String station;
final int visits;
StatsStationVisits({
required this.station,
required this.visits,
});
factory StatsStationVisits.fromJson(Map<String, dynamic> json) =>
StatsStationVisits(
station: _asString(json['station'], 'Unknown'),
visits: _asInt(json['visits']),
);
}
class Loco {
final int id;
final String type, number, locoClass;
@@ -210,7 +509,7 @@ class LocoSummary extends Loco {
);
factory LocoSummary.fromJson(Map<String, dynamic> json) => LocoSummary(
locoId: json['loco_id'] ?? json['id'] ?? 0,
locoId: _asInt(json['loco_id'] ?? json['id']),
locoType: json['type'] ?? json['loco_type'] ?? '',
locoNumber: json['number'] ?? json['loco_number'] ?? '',
locoName: json['name'] ?? json['loco_name'] ?? "",
@@ -487,25 +786,100 @@ class TripSummary {
final int tripId;
final String tripName;
final double tripMileage;
final int legCount;
final List<TripLocoStat> locoStats;
final DateTime? startDate;
final DateTime? endDate;
int get locoHadCount => locoStats.length;
int get winnersCount => locoStats.where((e) => e.won).length;
DateTime? get primaryDate => endDate ?? startDate;
TripSummary({
required this.tripId,
required this.tripName,
required this.tripMileage,
});
this.legCount = 0,
List<TripLocoStat>? locoStats,
this.startDate,
this.endDate,
}) : locoStats = locoStats ?? const [];
factory TripSummary.fromJson(Map<String, dynamic> json) => TripSummary(
tripId: _asInt(json['trip_id']),
tripName: _asString(json['trip_name']),
tripMileage: _asDouble(json['trip_mileage']),
);
static int compareByDateDesc(TripSummary a, TripSummary b) =>
compareTripsByDateDesc(a.primaryDate, b.primaryDate, a.tripId, b.tripId);
factory TripSummary.fromJson(Map<String, dynamic> json) {
DateTime? startDate;
DateTime? endDate;
DateTime? parseDate(dynamic value) => _asNullableDateTime(value);
for (final key in [
'trip_begin_time',
'trip_start',
'trip_start_time',
'trip_date',
'start_date',
'date',
]) {
startDate ??= parseDate(json[key]);
}
for (final key in [
'trip_end_time',
'trip_finish_time',
'trip_end',
'end_date',
]) {
endDate ??= parseDate(json[key]);
}
if (json['trip_legs'] is List) {
for (final leg in json['trip_legs'] as List) {
DateTime? begin;
if (leg is TripLeg) {
begin = leg.beginTime;
} else if (leg is Map) {
begin = parseDate(leg['leg_begin_time']);
}
if (begin == null) continue;
if (startDate == null || begin.isBefore(startDate)) {
startDate = begin;
}
if (endDate == null || begin.isAfter(endDate)) {
endDate = begin;
}
}
}
return TripSummary(
tripId: _asInt(json['trip_id']),
tripName: _asString(json['trip_name']),
tripMileage: _asDouble(json['trip_mileage']),
legCount: _asInt(
json['leg_count'],
(json['trip_legs'] as List?)?.length ?? 0,
),
locoStats: TripLocoStat.listFromJson(
json['stats'] ?? json['trip_locos'] ?? json['locos'],
),
startDate: startDate,
endDate: endDate,
);
}
}
class Leg {
final int id, tripId, timezone, driving;
final String start, end, route, network, notes, headcode, user;
final String start, end, network, notes, headcode, user;
final String origin, destination;
final List<String> route;
final DateTime beginTime;
final DateTime? endTime;
final DateTime? originTime;
final DateTime? destinationTime;
final double mileage;
final int? beginDelayMinutes, endDelayMinutes;
final List<Loco> locos;
Leg({
@@ -523,27 +897,55 @@ class Leg {
required this.driving,
required this.user,
required this.locos,
this.endTime,
this.originTime,
this.destinationTime,
this.beginDelayMinutes,
this.endDelayMinutes,
this.origin = '',
this.destination = '',
});
factory Leg.fromJson(Map<String, dynamic> json) => Leg(
id: _asInt(json['leg_id']),
tripId: _asInt(json['leg_trip']),
start: _asString(json['leg_start']),
end: _asString(json['leg_end']),
beginTime: _asDateTime(json['leg_begin_time']),
timezone: _asInt(json['leg_timezone']),
network: _asString(json['leg_network']),
route: _asString(json['leg_route']),
mileage: _asDouble(json['leg_mileage']),
notes: _asString(json['leg_notes']),
headcode: _asString(json['leg_headcode']),
driving: _asInt(json['leg_driving']),
user: _asString(json['leg_user']),
locos: (json['locos'] is List ? (json['locos'] as List) : const [])
.whereType<Map>()
.map((e) => Loco.fromJson(Map<String, dynamic>.from(e)))
.toList(),
);
factory Leg.fromJson(Map<String, dynamic> json) {
final endTimeRaw = json['leg_end_time'];
final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty)
? null
: _asDateTime(endTimeRaw);
return Leg(
id: _asInt(json['leg_id']),
tripId: _asInt(json['leg_trip']),
start: _asString(json['leg_start']),
end: _asString(json['leg_end']),
beginTime: _asDateTime(json['leg_begin_time']),
endTime: parsedEndTime,
originTime: json['leg_origin_time'] == null
? null
: _asDateTime(json['leg_origin_time']),
destinationTime: json['leg_destination_time'] == null
? null
: _asDateTime(json['leg_destination_time']),
timezone: _asInt(json['leg_timezone']),
network: _asString(json['leg_network']),
route: _asStringList(json['leg_route']),
mileage: _asDouble(json['leg_mileage']),
notes: _asString(json['leg_notes']),
headcode: _asString(json['leg_headcode']),
driving: _asInt(json['leg_driving']),
user: _asString(json['leg_user']),
locos: (json['locos'] is List ? (json['locos'] as List) : const [])
.whereType<Map>()
.map((e) => Loco.fromJson(Map<String, dynamic>.from(e)))
.toList(),
beginDelayMinutes: json['leg_begin_delay'] == null
? null
: _asInt(json['leg_begin_delay']),
endDelayMinutes: json['leg_end_delay'] == null
? null
: _asInt(json['leg_end_delay']),
origin: _asString(json['leg_origin']),
destination: _asString(json['leg_destination']),
);
}
}
class RouteError {
@@ -625,17 +1027,23 @@ class TripLeg {
});
factory TripLeg.fromJson(Map<String, dynamic> json) => TripLeg(
id: json['leg_id'],
start: json['leg_start'] ?? '',
end: json['leg_end'] ?? '',
id: _asInt(json['leg_id']),
start: _asString(json['leg_start']),
end: _asString(json['leg_end']),
beginTime:
json['leg_begin_time'] != null && json['leg_begin_time'] is String
? DateTime.tryParse(json['leg_begin_time'])
: (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null),
network: json['leg_network'],
route: json['leg_route'],
network: _asString(json['leg_network'], ''),
route: () {
final route = json['leg_route'];
if (route is List) {
return route.whereType<String>().join('');
}
return _asString(route, '');
}(),
mileage: (json['leg_mileage'] as num?)?.toDouble(),
notes: json['leg_notes'],
notes: _asString(json['leg_notes'], ''),
locos:
(json['locos'] as List?)
?.map((e) => Loco.fromJson(e as Map<String, dynamic>))
@@ -649,21 +1057,60 @@ class TripDetail {
final String name;
final double mileage;
final int legCount;
final List<TripLocoStat> locoStats;
final List<TripLeg> legs;
int get locoHadCount => locoStats.length;
int get winnersCount => locoStats.where((e) => e.won).length;
DateTime? get startDate {
DateTime? earliest;
for (final leg in legs) {
final begin = leg.beginTime;
if (begin == null) continue;
if (earliest == null || begin.isBefore(earliest)) {
earliest = begin;
}
}
return earliest;
}
DateTime? get endDate {
DateTime? latest;
for (final leg in legs) {
final begin = leg.beginTime;
if (begin == null) continue;
if (latest == null || begin.isAfter(latest)) {
latest = begin;
}
}
return latest;
}
DateTime? get primaryDate => endDate ?? startDate;
static int compareByDateDesc(TripDetail a, TripDetail b) =>
compareTripsByDateDesc(a.primaryDate, b.primaryDate, a.id, b.id);
TripDetail({
required this.id,
required this.name,
required this.mileage,
required this.legCount,
required this.legs,
});
List<TripLocoStat>? locoStats,
}) : locoStats = locoStats ?? const [];
factory TripDetail.fromJson(Map<String, dynamic> json) => TripDetail(
id: json['trip_id'] ?? json['id'] ?? 0,
name: json['trip_name'] ?? '',
mileage: (json['trip_mileage'] as num?)?.toDouble() ?? 0,
legCount: json['leg_count'] ?? ((json['trip_legs'] as List?)?.length ?? 0),
legCount: _asInt(
json['leg_count'],
(json['trip_legs'] as List?)?.length ?? 0,
),
locoStats: TripLocoStat.listFromJson(
json['stats'] ?? json['trip_locos'] ?? json['locos'],
),
legs:
(json['trip_legs'] as List?)
?.map((e) => TripLeg.fromJson(e as Map<String, dynamic>))
@@ -712,6 +1159,26 @@ class TripLocoStat {
);
}
static List<TripLocoStat> listFromJson(dynamic json) {
List<dynamic>? list;
if (json is List) {
list = json.expand((e) => e is List ? e : [e]).toList();
} else if (json is Map) {
for (final key in ['locos', 'stats', 'data', 'trip_locos']) {
final candidate = json[key];
if (candidate is List) {
list = candidate.expand((e) => e is List ? e : [e]).toList();
break;
}
}
}
if (list == null) return const [];
return list
.whereType<Map>()
.map((e) => TripLocoStat.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
static bool _parseWonFlag(dynamic value) {
if (value == null) return false;
if (value is bool) return value;
@@ -755,6 +1222,7 @@ class UserNotification {
final int id;
final String title;
final String body;
final String channel;
final DateTime? createdAt;
final bool dismissed;
@@ -762,6 +1230,7 @@ class UserNotification {
required this.id,
required this.title,
required this.body,
required this.channel,
required this.createdAt,
required this.dismissed,
});
@@ -778,6 +1247,7 @@ class UserNotification {
id: _asInt(json['notification_id'] ?? json['id']),
title: _asString(json['title']),
body: _asString(json['body']),
channel: _asString(json['channel']),
createdAt: createdAt,
dismissed: _asBool(json['dismissed'] ?? false, false),
);

View File

@@ -128,7 +128,12 @@ class ApiService {
await _onUnauthorized!();
}
throw Exception('API error ${res.statusCode}: $body');
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: res.statusCode,
message: message,
body: body,
);
}
dynamic _decodeBody(http.Response res) {
@@ -149,4 +154,41 @@ class ApiService {
return res.body;
}
}
String _extractErrorMessage(dynamic body) {
if (body == null) return 'No response body';
if (body is String) return body;
if (body is Map<String, dynamic>) {
for (final key in ['message', 'error', 'detail', 'msg']) {
final val = body[key];
if (val is String && val.trim().isNotEmpty) return val;
}
return body.toString();
}
if (body is List) {
final parts = body
.map((e) => e is Map
? _extractErrorMessage(Map<String, dynamic>.from(e))
: e.toString())
.where((e) => e.trim().isNotEmpty)
.toList();
if (parts.isNotEmpty) return parts.join('; ');
}
return body.toString();
}
}
class ApiException implements Exception {
final int statusCode;
final String message;
final dynamic body;
ApiException({
required this.statusCode,
required this.message,
this.body,
});
@override
String toString() => 'API error $statusCode: $message';
}

View File

@@ -21,6 +21,9 @@ class AuthService extends ChangeNotifier {
String? get userId => _user?.userId;
String? get username => _user?.username;
String? get fullName => _user?.fullName;
bool get isElevated => _user?.isElevated ?? false;
bool get isAdmin => isElevated; // alias for old name
bool get isDisabled => _user?.disabled ?? false;
void setLoginData({
required String userId,
@@ -28,6 +31,8 @@ class AuthService extends ChangeNotifier {
required String fullName,
required String accessToken,
required String email,
bool isElevated = false,
bool isDisabled = false,
}) {
_user = AuthenticatedUserData(
userId: userId,
@@ -35,6 +40,8 @@ class AuthService extends ChangeNotifier {
fullName: fullName,
accessToken: accessToken,
email: email,
isElevated: isElevated,
disabled: isDisabled,
);
_persistToken(accessToken);
notifyListeners();
@@ -70,6 +77,8 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'],
accessToken: accessToken,
email: userResponse['email'],
isElevated: _parseIsElevated(userResponse),
isDisabled: _parseIsDisabled(userResponse),
);
}
@@ -95,6 +104,8 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'],
accessToken: token,
email: userResponse['email'],
isElevated: _parseIsElevated(userResponse),
isDisabled: _parseIsDisabled(userResponse),
);
} catch (_) {
await _clearToken();
@@ -157,4 +168,60 @@ class AuthService extends ChangeNotifier {
void logout() {
handleTokenExpired(); // reuse
}
bool _parseIsElevated(Map<String, dynamic> json) {
dynamic value = json['is_elevated'] ??
json['elevated'] ??
json['is_admin'] ??
json['admin'] ??
json['isAdmin'] ??
json['admin_user'] ??
json['role'] ??
json['roles'] ??
json['permissions'] ??
json['scopes'] ??
json['is_staff'] ??
json['staff'] ??
json['is_superuser'] ??
json['superuser'] ??
json['groups'];
bool parseBoolish(dynamic v) {
if (v is bool) return v;
if (v is num) return v != 0;
final str = v?.toString().toLowerCase().trim();
if (str == null || str.isEmpty) return false;
if (['1', 'true', 'yes', 'y', 'admin', 'superuser', 'staff', 'elevated'].contains(str)) {
return true;
}
return str.contains('admin') || str.contains('superuser') || str.contains('staff');
}
if (value is List) {
for (final entry in value) {
if (parseBoolish(entry)) return true;
final s = entry?.toString().toLowerCase();
if (s != null &&
(s.contains('admin') ||
s.contains('superuser') ||
s.contains('staff') ||
s.contains('elevated') ||
s == 'root')) {
return true;
}
}
return false;
}
return parseBoolish(value);
}
bool _parseIsDisabled(Map<String, dynamic> json) {
dynamic value = json['disabled'] ?? json['is_disabled'];
if (value is bool) return value;
if (value is num) return value != 0;
final str = value?.toString().toLowerCase().trim();
if (str == null || str.isEmpty) return false;
return ['1', 'true', 'yes', 'y', 'disabled'].contains(str);
}
}

View File

@@ -11,3 +11,4 @@ part 'data_service_traction.dart';
part 'data_service_trips.dart';
part 'data_service_notifications.dart';
part 'data_service_badges.dart';
part 'data_service_stats.dart';

View File

@@ -28,6 +28,10 @@ class DataService extends ChangeNotifier {
// Homepage Data
HomepageStats? _homepageStats;
HomepageStats? get homepageStats => _homepageStats;
StatsAbout? _aboutStats;
StatsAbout? get aboutStats => _aboutStats;
bool _isAboutStatsLoading = false;
bool get isAboutStatsLoading => _isAboutStatsLoading;
// Legs Data
List<Leg> _legs = [];
@@ -147,7 +151,8 @@ class DataService extends ChangeNotifier {
try {
final json = await api.get('/stats/homepage');
_homepageStats = HomepageStats.fromJson(json);
_trips = _homepageStats?.trips ?? [];
_trips = [...(_homepageStats?.trips ?? const [])]
..sort(TripSummary.compareByDateDesc);
} catch (e) {
debugPrint('Failed to fetch homepage stats: $e');
_homepageStats = null;

View File

@@ -20,7 +20,7 @@ extension DataServiceNotifications on DataService {
final parsed = list
?.whereType<Map<String, dynamic>>()
.map(UserNotification.fromJson)
.where((n) => !n.dismissed)
.where((n) => !n.dismissed && n.channel.toLowerCase() != 'web')
.toList();
if (parsed != null) {

View File

@@ -0,0 +1,28 @@
part of 'data_service.dart';
extension DataServiceStats on DataService {
Future<void> fetchAboutStats({bool force = false}) async {
if (_isAboutStatsLoading) return;
if (!force && _aboutStats != null) return;
_isAboutStatsLoading = true;
_notifyAsync();
try {
final json = await api.get('/stats/about');
if (json is Map<String, dynamic>) {
_aboutStats = StatsAbout.fromJson(json);
} else if (json is Map) {
_aboutStats = StatsAbout.fromJson(
json.map((key, value) => MapEntry(key.toString(), value)),
);
} else {
throw Exception('Unexpected stats response: $json');
}
} catch (e) {
debugPrint('Failed to fetch about stats: $e');
_aboutStats = null;
} finally {
_isAboutStatsLoading = false;
_notifyAsync();
}
}
}

View File

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

View File

@@ -4,16 +4,27 @@ extension DataServiceTrips on DataService {
Future<void> fetchTripDetails() async {
_isTripDetailsLoading = true;
try {
final json = await api.get('/trips/legs-and-stats');
if (json is List) {
final tripMap = json.map((e) => TripDetail.fromJson(e)).toList();
_tripDetails = [...tripMap]..sort((a, b) => b.id.compareTo(a.id));
} else {
_tripDetails = [];
}
final json = await api.get('/trips/info');
final tripDetails = _parseTripInfoList(json);
_tripDetails = [...tripDetails]..sort(TripDetail.compareByDateDesc);
_tripList = tripDetails
.map(
(detail) => TripSummary(
tripId: detail.id,
tripName: detail.name,
tripMileage: detail.mileage,
legCount: detail.legCount,
locoStats: detail.locoStats,
startDate: detail.startDate,
endDate: detail.endDate,
),
)
.toList()
..sort(TripSummary.compareByDateDesc);
} catch (e) {
debugPrint('Failed to fetch trip_map: $e');
_tripDetails = [];
_tripList = [];
} finally {
_isTripDetailsLoading = false;
_notifyAsync();
@@ -23,55 +34,24 @@ extension DataServiceTrips on DataService {
Future<List<TripLocoStat>> fetchTripLocoStats(int tripId) async {
try {
final json = await api.get('/trips/stats/$tripId');
return _parseTripLocoStats(json);
return TripLocoStat.listFromJson(json);
} catch (e) {
debugPrint('Failed to fetch trip loco stats: $e');
return [];
}
}
List<TripLocoStat> _parseTripLocoStats(dynamic json) {
List<dynamic>? list;
if (json is List) {
list = json.expand((e) => e is List ? e : [e]).toList();
} else if (json is Map) {
for (final key in ['locos', 'stats', 'data', 'trip_locos']) {
final candidate = json[key];
if (candidate is List) {
list = candidate.expand((e) => e is List ? e : [e]).toList();
break;
}
}
}
if (list == null) return [];
return list
.whereType<Map<String, dynamic>>()
.map((e) => TripLocoStat.fromJson(e))
.toList();
}
Future<void> fetchTrips() async {
try {
final json = await api.get('/trips/mileage');
Iterable<dynamic>? raw;
if (json is List) {
raw = json;
} else if (json is Map) {
for (final key in ['trips', 'trip_data', 'data']) {
final value = json[key];
if (value is List) {
raw = value;
break;
}
}
}
final json = await api.get('/trips/info');
final raw = _extractTrips(json);
if (raw != null) {
final tripMap = raw
.whereType<Map<String, dynamic>>()
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId));
_tripList = [...tripMap]..sort(TripSummary.compareByDateDesc);
} else {
debugPrint('Unexpected trip list response: $json');
_tripList = [];
@@ -105,7 +85,7 @@ extension DataServiceTrips on DataService {
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId));
_tripList = [...tripMap]..sort(TripSummary.compareByDateDesc);
} else {
debugPrint('Unexpected trip list response: $json');
_tripList = [];
@@ -119,14 +99,35 @@ extension DataServiceTrips on DataService {
}
void upsertTripSummary(TripSummary trip) {
final existingIndex =
_tripList.indexWhere((element) => element.tripId == trip.tripId);
final existingIndex = _tripList.indexWhere(
(element) => element.tripId == trip.tripId,
);
if (existingIndex >= 0) {
_tripList[existingIndex] = trip;
} else {
_tripList = [trip, ..._tripList];
}
_tripList.sort((a, b) => b.tripId.compareTo(a.tripId));
_tripList.sort(TripSummary.compareByDateDesc);
_notifyAsync();
}
Iterable<dynamic>? _extractTrips(dynamic json) {
if (json is List) return json;
if (json is Map) {
for (final key in ['trips', 'trip_data', 'data', 'trip_info']) {
final value = json[key];
if (value is List) return value;
}
}
return null;
}
List<TripDetail> _parseTripInfoList(dynamic json) {
final raw = _extractTrips(json);
if (raw == null) return const [];
return raw
.whereType<Map<String, dynamic>>()
.map((e) => TripDetail.fromJson(e))
.toList();
}
}

View File

@@ -0,0 +1,211 @@
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
enum DistanceUnit {
milesDecimal,
milesChains,
kilometers,
}
extension DistanceUnitLabels on DistanceUnit {
String get label {
switch (this) {
case DistanceUnit.milesDecimal:
return 'Miles (decimal)';
case DistanceUnit.milesChains:
return 'Miles & chains';
case DistanceUnit.kilometers:
return 'Kilometers';
}
}
String get shortLabel {
switch (this) {
case DistanceUnit.milesDecimal:
return 'mi';
case DistanceUnit.milesChains:
return 'm.ch';
case DistanceUnit.kilometers:
return 'km';
}
}
String get _prefsValue => toString().split('.').last;
static DistanceUnit fromPrefs(String raw) {
return DistanceUnit.values.firstWhere(
(u) => u._prefsValue == raw,
orElse: () => DistanceUnit.milesDecimal,
);
}
}
class DistanceUnitService extends ChangeNotifier {
static const _prefsKey = 'distance_unit';
static const double kmPerMile = 1.609344;
static const double chainsPerMile = 80.0;
DistanceUnitService() {
_load();
}
DistanceUnit _unit = DistanceUnit.milesDecimal;
bool _loaded = false;
DistanceUnit get unit => _unit;
bool get isLoaded => _loaded;
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_prefsKey);
if (saved != null && saved.trim().isNotEmpty) {
_unit = DistanceUnitLabels.fromPrefs(saved.trim());
}
_loaded = true;
notifyListeners();
}
Future<void> setUnit(DistanceUnit unit) async {
_unit = unit;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, unit._prefsValue);
notifyListeners();
}
double? milesFromInput(String input) =>
DistanceFormatter(_unit).parseInputMiles(input);
String format(double miles,
{int decimals = 1, bool includeUnit = true}) =>
DistanceFormatter(_unit)
.format(miles, decimals: decimals, includeUnit: includeUnit);
double toDisplay(double miles, {int decimals = 1}) =>
DistanceFormatter(_unit).convertMiles(miles, decimals: decimals);
}
class DistanceFormatter {
DistanceFormatter(this.unit);
final DistanceUnit unit;
String format(double miles,
{int decimals = 1, bool includeUnit = true}) {
decimals = decimals.clamp(1, 2);
if (unit == DistanceUnit.milesChains) {
// Always show chains with two decimals.
decimals = 2;
}
switch (unit) {
case DistanceUnit.milesDecimal:
final value = _numberFormat(decimals).format(miles);
return includeUnit ? '$value mi' : value;
case DistanceUnit.kilometers:
final kms = miles * DistanceUnitService.kmPerMile;
final value = _numberFormat(decimals).format(kms);
return includeUnit ? '$value km' : value;
case DistanceUnit.milesChains:
final value = _formatMilesChains(miles);
return includeUnit ? '$value mi' : value;
}
}
double convertMiles(double miles, {int decimals = 1}) {
decimals = decimals.clamp(1, 2);
switch (unit) {
case DistanceUnit.milesDecimal:
return double.parse(miles.toStringAsFixed(decimals));
case DistanceUnit.kilometers:
final kms = miles * DistanceUnitService.kmPerMile;
return double.parse(kms.toStringAsFixed(decimals));
case DistanceUnit.milesChains:
// Return miles again; chains representation handled by format.
return double.parse(miles.toStringAsFixed(decimals));
}
}
double milesFromDisplayValue(double value) {
switch (unit) {
case DistanceUnit.milesDecimal:
return value;
case DistanceUnit.kilometers:
return value / DistanceUnitService.kmPerMile;
case DistanceUnit.milesChains:
// Value already represents miles when parsed via parseMilesChains.
return value;
}
}
double? parseInputMiles(String input) {
final trimmed = input.trim();
if (trimmed.isEmpty) return null;
switch (unit) {
case DistanceUnit.milesDecimal:
return double.tryParse(trimmed.replaceAll(',', ''));
case DistanceUnit.kilometers:
final km = double.tryParse(trimmed.replaceAll(',', ''));
if (km == null) return null;
return km / DistanceUnitService.kmPerMile;
case DistanceUnit.milesChains:
return _parseMilesChains(trimmed);
}
}
NumberFormat _numberFormat(int decimals) {
final pattern =
decimals == 1 ? '#,##0.0' : '#,##0.00';
return NumberFormat(pattern);
}
String _formatMilesChains(double miles) {
final totalChains = miles * DistanceUnitService.chainsPerMile;
var milesPart = totalChains ~/ DistanceUnitService.chainsPerMile;
final chainRemainder =
totalChains - (milesPart * DistanceUnitService.chainsPerMile);
// Always show chains as two digits (00-79), rounded to the nearest chain.
var roundedChains = chainRemainder.roundToDouble();
if (roundedChains >= DistanceUnitService.chainsPerMile) {
milesPart += 1;
roundedChains -= DistanceUnitService.chainsPerMile;
}
final chainText = NumberFormat('00').format(roundedChains);
return '$milesPart.$chainText';
}
double? _parseMilesChains(String raw) {
final cleaned = raw
.toLowerCase()
.replaceAll(',', '')
.replaceAll('m', '.')
.replaceAll('c', '.')
.replaceAll(RegExp(r'\s+'), '.')
.replaceAll(RegExp(r'\.+'), '.')
.trim();
if (cleaned.isEmpty) return null;
final parts = cleaned.split('.');
if (parts.isEmpty) return null;
final milesPart =
int.tryParse(parts[0].isEmpty ? '0' : parts[0]);
if (milesPart == null) return null;
double chainsPart = 0;
if (parts.length >= 2) {
final chainRaw = parts
.sublist(1)
.join()
.trim();
if (chainRaw.isNotEmpty) {
final parsedChains = double.tryParse(chainRaw);
if (parsedChains == null) return null;
chainsPart = parsedChains;
}
}
final totalMiles =
milesPart +
(chainsPart / DistanceUnitService.chainsPerMile);
return totalMiles;
}
}

View File

@@ -1,8 +1,12 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/pages/calculator.dart';
import 'package:mileograph_flutter/components/pages/calculator_details.dart';
import 'package:mileograph_flutter/components/login/login.dart';
import 'package:mileograph_flutter/components/pages/dashboard.dart';
import 'package:mileograph_flutter/components/pages/loco_legs.dart';
@@ -13,16 +17,19 @@ import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/profile.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/stats.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:provider/provider.dart';
final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>();
final GlobalKey<NavigatorState> _shellNavigatorKey =
GlobalKey<NavigatorState>();
const List<String> _contentPages = [
"/dashboard",
"/calculator",
"/logbook",
"/traction",
"/add",
@@ -31,13 +38,14 @@ const List<String> _contentPages = [
const List<String> _defaultTabDestinations = [
"/dashboard",
"/calculator",
"/logbook/entries",
"/traction",
"/add",
"/more",
];
const int _addTabIndex = 3;
const int _addTabIndex = 4;
class _NavItem {
final String label;
@@ -47,6 +55,7 @@ class _NavItem {
const List<_NavItem> _navItems = [
_NavItem("Home", Icons.home),
_NavItem("Calculator", Icons.route),
_NavItem("Logbook", Icons.menu_book),
_NavItem("Traction", Icons.train),
_NavItem("Add", Icons.add),
@@ -112,10 +121,7 @@ class _MyAppState extends State<MyApp> {
return null;
},
routes: [
GoRoute(
path: '/',
redirect: (context, state) => '/dashboard',
),
GoRoute(path: '/', redirect: (context, state) => '/dashboard'),
ShellRoute(
navigatorKey: _shellNavigatorKey,
builder: (context, state, child) => MyHomePage(child: child),
@@ -124,6 +130,17 @@ class _MyAppState extends State<MyApp> {
path: '/dashboard',
builder: (context, state) => const Dashboard(),
),
GoRoute(
path: '/calculator',
builder: (context, state) => const CalculatorPage(),
routes: [
GoRoute(
path: 'details',
builder: (context, state) =>
CalculatorDetailsPage(result: state.extra),
),
],
),
GoRoute(
path: '/logbook',
redirect: (context, state) => '/logbook/entries',
@@ -198,10 +215,18 @@ class _MyAppState extends State<MyApp> {
path: '/more/profile',
builder: (context, state) => const ProfilePage(),
),
GoRoute(
path: '/more/stats',
builder: (context, state) => const StatsPage(),
),
GoRoute(
path: '/more/settings',
builder: (context, state) => const SettingsPage(),
),
GoRoute(
path: '/more/admin',
builder: (context, state) => const AdminPage(),
),
GoRoute(
path: '/legs/edit/:id',
builder: (_, state) {
@@ -212,7 +237,10 @@ class _MyAppState extends State<MyApp> {
),
],
),
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()),
GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute(
path: '/settings',
builder: (context, state) => const SettingsPage(),
@@ -285,6 +313,7 @@ class _MyHomePageState extends State<MyHomePage> {
bool _fetched = false;
bool _railCollapsed = false;
Timer? _notificationsTimer;
@override
void didChangeDependencies() {
@@ -321,10 +350,28 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.notifications.isEmpty) {
data.fetchNotifications();
}
_startNotificationPolling();
});
});
}
void _startNotificationPolling() {
_notificationsTimer?.cancel();
final auth = context.read<AuthService>();
if (!auth.isLoggedIn) return;
_notificationsTimer = Timer.periodic(const Duration(minutes: 2), (_) async {
if (!mounted) return;
final auth = context.read<AuthService>();
if (!auth.isLoggedIn) return;
final data = context.read<DataService>();
try {
await data.fetchNotifications();
} catch (_) {
// Errors already logged inside data service.
}
});
}
@override
Widget build(BuildContext context) {
final uri = GoRouterState.of(context).uri;
@@ -373,7 +420,10 @@ class _MyHomePageState extends State<MyHomePage> {
TextSpan(
children: const [
TextSpan(text: "Mile"),
TextSpan(text: "O", style: TextStyle(color: Colors.red)),
TextSpan(
text: "O",
style: TextStyle(color: Colors.red),
),
TextSpan(text: "graph"),
],
style: const TextStyle(
@@ -390,7 +440,10 @@ class _MyHomePageState extends State<MyHomePage> {
onPressed: () => context.go('/more/settings'),
icon: const Icon(Icons.settings),
),
IconButton(onPressed: auth.logout, icon: const Icon(Icons.logout)),
IconButton(
onPressed: auth.logout,
icon: const Icon(Icons.logout),
),
],
),
bottomNavigationBar: isWide
@@ -448,7 +501,8 @@ class _MyHomePageState extends State<MyHomePage> {
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.browserBack): const _BackIntent(),
LogicalKeySet(LogicalKeyboardKey.browserForward): const _ForwardIntent(),
LogicalKeySet(LogicalKeyboardKey.browserForward):
const _ForwardIntent(),
},
child: Actions(
actions: {
@@ -474,7 +528,10 @@ class _MyHomePageState extends State<MyHomePage> {
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
await _handleBackNavigation(allowExit: true, recordForward: false);
await _handleBackNavigation(
allowExit: true,
recordForward: false,
);
},
child: scaffold,
),
@@ -494,7 +551,9 @@ class _MyHomePageState extends State<MyHomePage> {
}
Widget _buildRailToggleButton(bool railExtended) {
final collapseIcon = railExtended ? Icons.chevron_left : Icons.chevron_right;
final collapseIcon = railExtended
? Icons.chevron_left
: Icons.chevron_right;
final collapseLabel = railExtended ? 'Collapse' : 'Expand';
if (railExtended) {
@@ -587,8 +646,9 @@ class _MyHomePageState extends State<MyHomePage> {
final data = context.watch<DataService>();
final notifications = data.notifications;
final loading = data.isNotificationsLoading;
final listHeight =
isWide ? 380.0 : MediaQuery.of(context).size.height * 0.6;
final listHeight = isWide
? 380.0
: MediaQuery.of(context).size.height * 0.6;
Widget body;
if (loading && notifications.isEmpty) {
@@ -628,9 +688,7 @@ class _MyHomePageState extends State<MyHomePage> {
item.title.isNotEmpty
? item.title
: 'Notification',
style: Theme.of(context)
.textTheme
.titleMedium
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
@@ -642,18 +700,15 @@ class _MyHomePageState extends State<MyHomePage> {
const SizedBox(height: 6),
Text(
_formatNotificationTime(item.createdAt!),
style: Theme.of(context)
.textTheme
.bodySmall
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
color: () {
final baseColor = Theme.of(context)
.textTheme
.bodySmall
?.color;
final baseColor = Theme.of(
context,
).textTheme.bodySmall?.color;
if (baseColor == null) return null;
final newAlpha =
(baseColor.a * 0.7).clamp(0.0, 1.0);
final newAlpha = (baseColor.a * 0.7)
.clamp(0.0, 1.0);
return baseColor.withValues(
alpha: newAlpha,
);
@@ -666,10 +721,8 @@ class _MyHomePageState extends State<MyHomePage> {
),
const SizedBox(width: 8),
TextButton(
onPressed: () => _dismissNotifications(
context,
[item.id],
),
onPressed: () =>
_dismissNotifications(context, [item.id]),
child: const Text('Dismiss'),
),
],
@@ -695,19 +748,18 @@ class _MyHomePageState extends State<MyHomePage> {
children: [
Text(
'Notifications',
style: Theme.of(context)
.textTheme
.titleLarge
?.copyWith(fontWeight: FontWeight.bold),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
TextButton(
onPressed: notifications.isEmpty
? null
: () => _dismissNotifications(
context,
notifications.map((e) => e.id).toList(),
),
context,
notifications.map((e) => e.id).toList(),
),
child: const Text('Dismiss all'),
),
],
@@ -729,9 +781,7 @@ class _MyHomePageState extends State<MyHomePage> {
try {
await context.read<DataService>().dismissNotifications(ids);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to dismiss: $e')),
);
messenger?.showSnackBar(SnackBar(content: Text('Failed to dismiss: $e')));
}
}
@@ -751,9 +801,7 @@ class _MyHomePageState extends State<MyHomePage> {
color: Colors.redAccent,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(
minWidth: 20,
),
constraints: const BoxConstraints(minWidth: 20),
child: Text(
label,
style: const TextStyle(
@@ -872,4 +920,10 @@ class _MyHomePageState extends State<MyHomePage> {
_forwardHistory.clear();
context.go(tabDestinations[index]);
}
@override
void dispose() {
_notificationsTimer?.cancel();
super.dispose();
}
}

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix.
version: 0.4.1+1
version: 0.5.6+2
environment:
sdk: ^3.8.1
@@ -102,7 +102,16 @@ flutter:
flutter_launcher_icons:
android: true
ios: true
remove_alpha_ios: true
linux:
generate: true
web:
generate: true
background_colour: "#000000"
theme_color: "#0175C2"
windows:
generate: true
image_path: assets/icons/app_icon.png
adaptive_icon_background: "#ffffff"
adaptive_icon_background: "#000000"
adaptive_icon_foreground: assets/icons/app_icon.png
min_sdk_android: 21

View File

@@ -8,13 +8,12 @@ void main() {
expect(tabIndexForPath('/calculator/details'), 1);
expect(tabIndexForPath('/legs'), 2);
expect(tabIndexForPath('/traction/12/timeline'), 3);
expect(tabIndexForPath('/trips'), 4);
expect(tabIndexForPath('/add'), 5);
expect(tabIndexForPath('/trips'), 2);
expect(tabIndexForPath('/add'), 4);
});
test('tabIndexForPath ignores query when parsing uri', () {
expect(tabIndexForPath(Uri.parse('/trips?sort=desc').path), 4);
expect(tabIndexForPath(Uri.parse('/trips?sort=desc').path), 2);
expect(tabIndexForPath(Uri.parse('/calculator/details?x=1').path), 1);
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 917 B

After

Width:  |  Height:  |  Size: 304 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 6.5 KiB

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 711 B