Compare commits

...

26 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
54026aa93a make percentage cleared cards shorter
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m46s
Release / android-build (push) Successful in 14m42s
Release / release-dev (push) Successful in 26s
Release / release-master (push) Successful in 24s
2025-12-26 22:51:16 +00:00
0971124fd4 badge percentage support 2025-12-26 22:49:43 +00:00
4bd6f0bbed add support for badges and notifications, adjust nav pages
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m49s
Release / android-build (push) Successful in 15m55s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 26s
2025-12-26 18:36:37 +00:00
44d79e7c28 Improve entries page and latest changes panel, units on events and timeline
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 8m3s
Release / android-build (push) Successful in 19m21s
Release / release-master (push) Successful in 40s
Release / release-dev (push) Successful in 42s
2025-12-23 17:41:21 +00:00
29959f7580 remove hero buttons
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m50s
Release / android-build (push) Successful in 16m21s
Release / release-dev (push) Successful in 33s
Release / release-master (push) Successful in 31s
2025-12-22 23:24:46 +00:00
d5d204dd19 add filter panel to calculator 2025-12-22 23:16:54 +00:00
950978b021 new settings panel for url pickup
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 7m42s
Release / android-build (push) Successful in 16m34s
Release / release-dev (push) Successful in 38s
Release / release-master (push) Successful in 37s
2025-12-22 22:45:33 +00:00
dc5ed2567f fix api endpoint
Some checks failed
Release / meta (push) Failing after 10s
Release / android-build (push) Has been skipped
Release / linux-build (push) Has been skipped
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2025-12-22 21:39:06 +00:00
b1a8f7baf4 dashboard overhaul
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 7m15s
Release / android-build (push) Successful in 18m37s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 26s
2025-12-22 19:39:50 +00:00
95 changed files with 8641 additions and 1296 deletions

View File

@@ -10,8 +10,9 @@ env:
JAVA_VERSION: "17" JAVA_VERSION: "17"
ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk" ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk"
FLUTTER_VERSION: "3.38.5" 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 GITEA_BASE_URL: https://git.tgj.services
WEB_IMAGE: "git.tgj.services/petegregoryy/mileograph-web"
jobs: jobs:
meta: meta:
@@ -20,6 +21,7 @@ jobs:
outputs: outputs:
base_version: ${{ steps.meta.outputs.base }} base_version: ${{ steps.meta.outputs.base }}
release_tag: ${{ steps.meta.outputs.release_tag }} release_tag: ${{ steps.meta.outputs.release_tag }}
dev_suffix: ${{ steps.meta.outputs.dev_suffix }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@@ -29,12 +31,24 @@ jobs:
run: | run: |
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml) RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
BASE_VERSION=${RAW_VERSION%%+*} BASE_VERSION=${RAW_VERSION%%+*}
TAG="v${BASE_VERSION}" VERSION="${BASE_VERSION}"
TAG="v${VERSION}"
DEV_SUFFIX=""
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then 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 fi
DEV_SUFFIX="-dev.${DEV_ITER}"
VERSION="${BASE_VERSION}${DEV_SUFFIX}"
TAG="v${VERSION}"
fi
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT" echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT" echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "dev_suffix=${DEV_SUFFIX}" >> "$GITHUB_OUTPUT"
- name: Fail if release already exists - name: Fail if release already exists
env: env:
@@ -110,6 +124,8 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
FLUTTER_HOME="$HOME/flutter" FLUTTER_HOME="$HOME/flutter"
# Avoid git ownership issues when Flutter checks out deps.
git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME" rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
@@ -139,6 +155,18 @@ jobs:
- name: Build Android App Bundle (release) - name: Build Android App Bundle (release)
run: flutter build appbundle --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 - name: Download bundletool
run: | run: |
BUNDLETOOL_VERSION=1.15.6 BUNDLETOOL_VERSION=1.15.6
@@ -185,6 +213,12 @@ jobs:
name: android-apk name: android-apk
path: mileograph-${{ needs.meta.outputs.base_version }}.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: linux-build:
runs-on: runs-on:
- mileograph - mileograph
@@ -207,6 +241,8 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
FLUTTER_HOME="$HOME/flutter" FLUTTER_HOME="$HOME/flutter"
# Avoid git ownership issues when Flutter checks out deps.
git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME" rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
@@ -238,6 +274,108 @@ jobs:
name: linux-bundle name: linux-bundle
path: app-linux-x64.tar.gz path: app-linux-x64.tar.gz
web-build:
runs-on:
- mileograph
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install OS deps (Web)
run: |
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq docker.io
if ! docker info >/dev/null 2>&1; then
$SUDO systemctl start docker 2>/dev/null || $SUDO service docker start 2>/dev/null || true
fi
- name: Install Flutter SDK
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
tar -C "$HOME" -xf /tmp/flutter.tar.xz
fi
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
"$FLUTTER_HOME/bin/flutter" --version
- name: Allow all git directories (CI)
run: git config --global --add safe.directory '*'
- name: Set pub cache path
run: echo "PUB_CACHE=${GITHUB_WORKSPACE}/.pub-cache" >> "$GITHUB_ENV"
- name: Flutter dependencies
run: flutter pub get
- name: Enable Flutter web
run: flutter config --enable-web
- name: Build Flutter web (release)
run: |
flutter build web --release --base-href=/
tar -C build/web -czf app-web.tar.gz .
- name: Upload Web artifact
uses: actions/upload-artifact@v3
with:
name: web-build
path: app-web.tar.gz
- name: Compute web image tags
id: web_meta
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
DEV_SUFFIX: ${{ needs.meta.outputs.dev_suffix }}
run: |
IMAGE="${WEB_IMAGE}"
TAG=""
ALIAS=""
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
TAG="${BASE_VERSION}${DEV_SUFFIX}"
ALIAS="dev"
elif [ "${GITHUB_REF}" = "refs/heads/master" ]; then
TAG="${BASE_VERSION}"
ALIAS="latest"
fi
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "alias=${ALIAS}" >> "$GITHUB_OUTPUT"
- name: Login to registry
if: ${{ secrets.DOCKERHUB_TOKEN != '' && steps.web_meta.outputs.tag != '' }}
env:
REGISTRY_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
echo "$REGISTRY_TOKEN" | docker login git.tgj.services -u petegregoryy --password-stdin
- name: Build and push web image
if: ${{ secrets.DOCKERHUB_TOKEN != '' && steps.web_meta.outputs.tag != '' }}
env:
IMAGE: ${{ steps.web_meta.outputs.image }}
TAG: ${{ steps.web_meta.outputs.tag }}
ALIAS: ${{ steps.web_meta.outputs.alias }}
run: |
docker buildx create --name buildx --driver=docker-container --use || docker buildx use buildx
TAG_ARGS=(-t "${IMAGE}:${TAG}")
if [ -n "$ALIAS" ]; then
TAG_ARGS+=(-t "${IMAGE}:${ALIAS}")
fi
docker buildx build --builder buildx --platform linux/amd64 \
-f Dockerfile.web \
--push \
"${TAG_ARGS[@]}" .
release-dev: release-dev:
runs-on: runs-on:
- mileograph - mileograph
@@ -245,6 +383,7 @@ jobs:
- meta - meta
- android-build - android-build
- linux-build - linux-build
- web-build
steps: steps:
- name: Install jq - name: Install jq
run: | run: |
@@ -262,17 +401,35 @@ jobs:
name: android-apk name: android-apk
path: artifacts 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 - name: Prepare APK and tag
if: ${{ github.ref == 'refs/heads/dev' }} if: ${{ github.ref == 'refs/heads/dev' }}
id: bundle id: bundle
run: | run: |
BASE="${{ needs.meta.outputs.base_version }}" BASE="${{ needs.meta.outputs.base_version }}"
TAG="${{ needs.meta.outputs.release_tag }}" 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 "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 - name: Create prerelease on Gitea
if: ${{ github.ref == 'refs/heads/dev' }} if: ${{ github.ref == 'refs/heads/dev' }}
@@ -313,6 +470,18 @@ jobs:
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \ "${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null >/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: release-master:
runs-on: runs-on:
- mileograph - mileograph
@@ -320,6 +489,7 @@ jobs:
- meta - meta
- android-build - android-build
- linux-build - linux-build
- web-build
steps: steps:
- name: Install jq - name: Install jq
run: | run: |
@@ -337,6 +507,13 @@ jobs:
name: android-apk name: android-apk
path: artifacts 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 - name: Prepare APK and tag
if: ${{ github.ref == 'refs/heads/master' }} if: ${{ github.ref == 'refs/heads/master' }}
id: bundle id: bundle
@@ -346,6 +523,7 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT" echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT"
echo "aab=artifacts/mileograph-${BASE}.aab" >> "$GITHUB_OUTPUT"
- name: Create release on Gitea - name: Create release on Gitea
if: ${{ github.ref == 'refs/heads/master' }} if: ${{ github.ref == 'refs/heads/master' }}
@@ -384,3 +562,15 @@ jobs:
-F "name=${NAME}" \ -F "name=${NAME}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \ "${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null >/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) ## Setup
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) 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 ## Running
[online documentation](https://docs.flutter.dev/), which offers tutorials, - Debug (mobile/web depending on your toolchain):
samples, guidance on mobile development, and a full API reference. ```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 { android {
namespace = "com.example.mileograph_flutter" namespace = "com.example.mileograph_flutter"
compileSdk = flutter.compileSdkVersion compileSdk = 36
ndkVersion = "27.0.12077973" ndkVersion = "27.0.12077973"
compileOptions { 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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="ic_launcher_background">#ffffff</color> <color name="ic_launcher_background">#000000</color>
</resources> </resources>

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,8 @@ import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/ui/app_shell.dart'; import 'package:mileograph_flutter/ui/app_shell.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -12,14 +14,30 @@ class App extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MultiProvider( return MultiProvider(
providers: [ providers: [
Provider<ApiService>( ChangeNotifierProvider<EndpointService>(
create: (_) => ApiService(baseUrl: 'http://localhost:8000/api/v1'), create: (_) => EndpointService(),
),
ChangeNotifierProvider<DistanceUnitService>(
create: (_) => DistanceUnitService(),
),
ProxyProvider<EndpointService, ApiService>(
update: (_, endpoint, api) {
final service = api ?? ApiService(baseUrl: endpoint.baseUrl);
service.setBaseUrl(endpoint.baseUrl);
return service;
},
create: (_) => ApiService(baseUrl: EndpointService.defaultBaseUrl),
), ),
ChangeNotifierProvider<AuthService>( ChangeNotifierProvider<AuthService>(
create: (context) => AuthService(api: context.read<ApiService>()), create: (context) => AuthService(api: context.read<ApiService>()),
), ),
ChangeNotifierProvider<DataService>( ChangeNotifierProxyProvider<AuthService, DataService>(
create: (context) => DataService(api: context.read<ApiService>()), create: (context) => DataService(api: context.read<ApiService>()),
update: (context, auth, data) {
data ??= DataService(api: context.read<ApiService>());
data.handleAuthChanged(auth.userId);
return data;
},
), ),
], ],
child: const MyApp(), child: const MyApp(),

View File

@@ -133,6 +133,11 @@ class RouteCalculator extends StatefulWidget {
class _RouteCalculatorState extends State<RouteCalculator> { class _RouteCalculatorState extends State<RouteCalculator> {
List<Station> allStations = []; List<Station> allStations = [];
List<String> _networks = [];
List<String> _countries = [];
List<String> _selectedNetworks = [];
List<String> _selectedCountries = [];
bool _loadingStations = false;
RouteResult? _routeResult; RouteResult? _routeResult;
RouteResult? get result => _routeResult; RouteResult? get result => _routeResult;
@@ -150,14 +155,31 @@ class _RouteCalculatorState extends State<RouteCalculator> {
} }
WidgetsBinding.instance.addPostFrameCallback((_) async { WidgetsBinding.instance.addPostFrameCallback((_) async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final result = await data.fetchStations(); await data.fetchStationFilters();
if (mounted) { if (!mounted) return;
setState(() => allStations = result); setState(() {
} _networks = data.stationNetworks;
_countries = data.stationCountryNetworks.keys.toList();
});
await _loadStations();
}); });
} }
} }
Future<void> _loadStations() async {
setState(() => _loadingStations = true);
final data = context.read<DataService>();
final stations = await data.fetchStations(
countries: _selectedCountries,
networks: _selectedNetworks,
);
if (!mounted) return;
setState(() {
allStations = stations;
_loadingStations = false;
});
}
Future<void> _calculateRoute(List<String> stations) async { Future<void> _calculateRoute(List<String> stations) async {
setState(() { setState(() {
_errorMessage = null; _errorMessage = null;
@@ -215,6 +237,43 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
return Column( return Column(
children: [ children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Wrap(
spacing: 12,
runSpacing: 12,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_MultiSelectFilter(
label: 'Countries',
options: _countries,
selected: _selectedCountries,
onChanged: (vals) {
setState(() => _selectedCountries = vals);
_loadStations();
},
),
_MultiSelectFilter(
label: 'Networks',
options: _networks,
selected: _selectedNetworks,
onChanged: (vals) {
setState(() => _selectedNetworks = vals);
_loadStations();
},
),
if (_loadingStations)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
),
Expanded( Expanded(
child: ReorderableListView( child: ReorderableListView(
buildDefaultDragHandles: false, buildDefaultDragHandles: false,
@@ -300,21 +359,18 @@ class _RouteCalculatorState extends State<RouteCalculator> {
else else
SizedBox.shrink(), SizedBox.shrink(),
const SizedBox(height: 10), const SizedBox(height: 10),
LayoutBuilder( Padding(
builder: (context, constraints) { padding: const EdgeInsets.symmetric(horizontal: 16.0),
double screenWidth = constraints.maxWidth; child: Wrap(
alignment: WrapAlignment.center,
return Padding( spacing: 12,
padding: EdgeInsets.only(right: screenWidth < 450 ? 70 : 0), runSpacing: 8,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Add Station'), label: const Text('Add Station'),
onPressed: _addStation, onPressed: _addStation,
), ),
const SizedBox(width: 16),
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.route), icon: const Icon(Icons.route),
label: const Text('Calculate Route'), label: const Text('Calculate Route'),
@@ -324,8 +380,6 @@ class _RouteCalculatorState extends State<RouteCalculator> {
), ),
], ],
), ),
);
},
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -350,3 +404,159 @@ Widget debugPanel(List<String> stations) {
), ),
); );
} }
class _MultiSelectFilter extends StatefulWidget {
const _MultiSelectFilter({
required this.label,
required this.options,
required this.selected,
required this.onChanged,
});
final String label;
final List<String> options;
final List<String> selected;
final ValueChanged<List<String>> onChanged;
@override
State<_MultiSelectFilter> createState() => _MultiSelectFilterState();
}
class _MultiSelectFilterState extends State<_MultiSelectFilter> {
late List<String> _tempSelected;
String _query = '';
@override
void initState() {
super.initState();
_tempSelected = List.from(widget.selected);
}
@override
void didUpdateWidget(covariant _MultiSelectFilter oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selected != widget.selected) {
_tempSelected = List.from(widget.selected);
}
}
void _openPicker() async {
_tempSelected = List.from(widget.selected);
_query = '';
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (ctx, setModalState) {
final filtered = widget.options
.where((opt) =>
_query.isEmpty || opt.toLowerCase().contains(_query.toLowerCase()))
.toList();
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Select ${widget.label.toLowerCase()}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const Spacer(),
TextButton(
onPressed: () {
setModalState(() {
_tempSelected.clear();
});
Navigator.of(ctx).pop();
widget.onChanged(const []);
},
child: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
TextField(
decoration: const InputDecoration(
labelText: 'Search',
border: OutlineInputBorder(),
),
onChanged: (val) {
setModalState(() {
_query = val;
});
},
),
const SizedBox(height: 12),
SizedBox(
height: 320,
child: ListView.builder(
itemCount: filtered.length,
itemBuilder: (_, index) {
final option = filtered[index];
final selected = _tempSelected.contains(option);
return CheckboxListTile(
value: selected,
title: Text(option),
onChanged: (val) {
setModalState(() {
if (val == true) {
if (!_tempSelected.contains(option)) {
_tempSelected.add(option);
}
} else {
_tempSelected.removeWhere((e) => e == option);
}
});
widget.onChanged(List.from(_tempSelected.toSet()));
},
);
},
),
),
const SizedBox(height: 12),
Align(
alignment: Alignment.centerRight,
child: FilledButton.icon(
onPressed: () {
widget.onChanged(List.from(_tempSelected.toSet()));
Navigator.of(ctx).pop();
},
icon: const Icon(Icons.check),
label: const Text('Apply'),
),
),
],
),
),
);
},
);
},
);
}
@override
Widget build(BuildContext context) {
final hasSelection = widget.selected.isNotEmpty;
final display =
hasSelection ? widget.selected.join(', ') : 'Any ${widget.label.toLowerCase()}';
return OutlinedButton.icon(
onPressed: _openPicker,
icon: const Icon(Icons.filter_alt),
label: SizedBox(
width: 180,
child: Text(
'${widget.label}: $display',
overflow: TextOverflow.ellipsis,
),
),
);
}
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class RouteSummaryWidget extends StatelessWidget { class RouteSummaryWidget extends StatelessWidget {
final double distance; final double distance;
@@ -12,13 +14,14 @@ class RouteSummaryWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final distanceUnits = context.watch<DistanceUnitService>();
return Padding( return Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
"Total Distance: ${distance.toStringAsFixed(2)} mi", "Total Distance: ${distanceUnits.format(distance, decimals: 2)}",
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
), ),
@@ -36,16 +39,21 @@ class RouteDetailsView extends StatelessWidget {
final List<String> route; final List<String> route;
final List<double> costs; final List<double> costs;
final VoidCallback onBack; final VoidCallback onBack;
final Set<String> routingPoints;
const RouteDetailsView({ const RouteDetailsView({
super.key, super.key,
required this.route, required this.route,
required this.costs, required this.costs,
required this.onBack, required this.onBack,
this.routingPoints = const {},
}); });
@override @override
Widget build(BuildContext context) { 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( return Column(
children: [ children: [
Align( Align(
@@ -60,9 +68,23 @@ class RouteDetailsView extends StatelessWidget {
child: ListView.builder( child: ListView.builder(
itemCount: route.length, itemCount: route.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final label = route[index];
final isRoutingPoint = routingPoints.contains(label);
return ListTile( return ListTile(
title: Text(route[index]), leading: Icon(
trailing: Text("${costs[index].toStringAsFixed(2)} mi"), Icons.circle,
size: 12,
color: isRoutingPoint ? highlightColor : mutedColor,
),
title: Text(
label,
style: isRoutingPoint
? TextStyle(color: highlightColor, fontWeight: FontWeight.w600)
: null,
),
trailing: Text(
distanceUnits.format(costs[index], decimals: 2),
),
); );
}, },
), ),

View File

@@ -1,9 +1,13 @@
import 'package:flutter/material.dart'; 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/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LatestLocoChangesPanel extends StatefulWidget { class LatestLocoChangesPanel extends StatefulWidget {
const LatestLocoChangesPanel({super.key}); const LatestLocoChangesPanel({super.key, this.expanded = false});
final bool expanded;
@override @override
State<LatestLocoChangesPanel> createState() => _LatestLocoChangesPanelState(); State<LatestLocoChangesPanel> createState() => _LatestLocoChangesPanelState();
@@ -11,6 +15,9 @@ class LatestLocoChangesPanel extends StatefulWidget {
class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> { class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
late final ScrollController _controller; late final ScrollController _controller;
final Set<String> _collapsedDates = {};
final Set<String> _collapsedClasses = {};
final Set<String> _collapsedLocos = {};
@override @override
void initState() { void initState() {
@@ -38,9 +45,25 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Row(
'Latest loco changes', children: [
style: textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), const Icon(Icons.bolt, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
'Latest Loco Changes',
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
),
if (isLoading && changes.isNotEmpty)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
if (isLoading && changes.isEmpty) if (isLoading && changes.isEmpty)
@@ -57,51 +80,419 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
), ),
) )
else else
SizedBox( Column(
height: 260, crossAxisAlignment: CrossAxisAlignment.stretch,
child: Scrollbar(
controller: _controller,
child: ListView.separated(
controller: _controller,
itemCount: changes.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) {
final change = changes[index];
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
title: Text(
change.locoLabel,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('${change.changeLabel}: ${change.valueLabel}'), _buildChangesList(changes, textTheme),
Text( const SizedBox(height: 8),
change.approvedDateLabel, Align(
style: textTheme.labelSmall?.copyWith( alignment: Alignment.centerLeft,
color: textTheme.bodySmall?.color?.withValues(alpha: 0.7), child: OutlinedButton.icon(
onPressed: isLoading ? null : _loadMore,
icon: isLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(isLoading ? 'Loading...' : 'Show more'),
), ),
), ),
], ],
), ),
],
),
),
);
}
Widget _buildChangesList(List<LocoChange> changes, TextTheme textTheme) {
final grouped = _groupChanges(changes);
// Start with all locos collapsed by default.
if (_collapsedLocos.isEmpty) {
for (final group in grouped) {
for (final classGroup in group.classGroups) {
for (final locoGroup in classGroup.locoGroups) {
_collapsedLocos.add(
_locoKey(group.dateLabel, classGroup.classLabel, locoGroup.locoLabel),
);
}
}
}
}
final listView = ListView.separated(
controller: null,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, groupIndex) {
final group = grouped[groupIndex];
final dateCollapsed = _collapsedDates.contains(group.dateLabel);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
IconButton(
iconSize: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _toggleDate(group.dateLabel),
icon: Icon(
dateCollapsed ? Icons.chevron_right : Icons.expand_more,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
group.dateLabel,
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
TextButton(
onPressed: () => _collapseDateChildren(
group.dateLabel,
group.classGroups,
collapse: !_isDateFullyCollapsed(group),
),
child: Text(
_isDateFullyCollapsed(group) ? 'Expand all' : 'Collapse all',
),
),
],
),
),
if (!dateCollapsed)
...group.classGroups.map(
(classGroup) {
final classKey = _classKey(group.dateLabel, classGroup.classLabel);
final classCollapsed = _collapsedClasses.contains(classKey);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0, left: 12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
iconSize: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _toggleClass(classKey),
icon: Icon(
classCollapsed
? Icons.chevron_right
: Icons.expand_more,
),
),
const SizedBox(width: 4),
Expanded(
child: Text(
classGroup.classLabel,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
TextButton(
onPressed: () => _collapseClassChildren(
group.dateLabel,
classGroup.classLabel,
classGroup.locoGroups,
collapse:
!_isClassFullyCollapsed(classKey, classGroup, group.dateLabel),
),
child: Text(
_isClassFullyCollapsed(classKey, classGroup, group.dateLabel)
? 'Expand all'
: 'Collapse all',
),
),
],
),
if (!classCollapsed)
...classGroup.locoGroups.map(
(locoGroup) {
final locoKey =
_locoKey(group.dateLabel, classGroup.classLabel, locoGroup.locoLabel);
final locoCollapsed = _collapsedLocos.contains(locoKey);
return Padding(
padding:
const EdgeInsets.only(bottom: 4.0, left: 22.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
IconButton(
iconSize: 18,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () => _toggleLoco(locoKey),
icon: Icon(
locoCollapsed
? Icons.chevron_right
: Icons.expand_more,
),
),
const SizedBox(width: 4),
Text(
locoGroup.locoLabel,
style: textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
if (!locoCollapsed) ...[
const SizedBox(height: 2),
...locoGroup.changes.map(
(change) => ListTile(
dense: true,
visualDensity: const VisualDensity(
horizontal: 0,
vertical: -3,
),
minVerticalPadding: 0,
contentPadding: EdgeInsets.zero,
title: Text(
'${change.changeLabel}: ${change.valueLabel}',
style: textTheme.bodyMedium,
),
trailing: change.approvedBy.isEmpty trailing: change.approvedBy.isEmpty
? null ? null
: Text( : Text(
change.approvedBy, change.approvedBy,
style: textTheme.labelSmall, style: textTheme.labelSmall,
), ),
);
},
),
), ),
), ),
], ],
],
),
);
},
),
],
),
);
},
),
],
);
},
separatorBuilder: (_, index) => const Divider(height: 8),
itemCount: grouped.length,
);
if (widget.expanded) {
return listView;
}
return listView;
}
void _toggleDate(String date) {
setState(() {
if (_collapsedDates.contains(date)) {
_collapsedDates.remove(date);
} else {
_collapsedDates.add(date);
}
});
}
void _toggleClass(String key) {
setState(() {
if (_collapsedClasses.contains(key)) {
_collapsedClasses.remove(key);
} else {
_collapsedClasses.add(key);
}
});
}
void _toggleLoco(String key) {
setState(() {
if (_collapsedLocos.contains(key)) {
_collapsedLocos.remove(key);
} else {
_collapsedLocos.add(key);
}
});
}
void _collapseDateChildren(
String date,
List<_ClassGroup> classGroups, {
required bool collapse,
}) {
setState(() {
for (final classGroup in classGroups) {
final classKey = _classKey(date, classGroup.classLabel);
if (collapse) {
_collapsedClasses.add(classKey);
} else {
_collapsedClasses.remove(classKey);
}
for (final locoGroup in classGroup.locoGroups) {
final locoKey = _locoKey(date, classGroup.classLabel, locoGroup.locoLabel);
if (collapse) {
_collapsedLocos.add(locoKey);
} else {
_collapsedLocos.remove(locoKey);
}
}
}
});
}
void _collapseClassChildren(
String date,
String classLabel,
List<_LocoGroup> locos, {
required bool collapse,
}) {
setState(() {
final classKey = _classKey(date, classLabel);
if (collapse) {
_collapsedClasses.add(classKey);
} else {
_collapsedClasses.remove(classKey);
}
for (final locoGroup in locos) {
final locoKey = _locoKey(date, classLabel, locoGroup.locoLabel);
if (collapse) {
_collapsedLocos.add(locoKey);
} else {
_collapsedLocos.remove(locoKey);
}
}
});
}
bool _isDateFullyCollapsed(_ChangeGroup group) {
for (final classGroup in group.classGroups) {
final classKey = _classKey(group.dateLabel, classGroup.classLabel);
if (!_collapsedClasses.contains(classKey)) return false;
for (final loco in classGroup.locoGroups) {
final locoKey = _locoKey(group.dateLabel, classGroup.classLabel, loco.locoLabel);
if (!_collapsedLocos.contains(locoKey)) return false;
}
}
return true;
}
bool _isClassFullyCollapsed(String classKey, _ClassGroup classGroup, String date) {
if (!_collapsedClasses.contains(classKey)) return false;
for (final loco in classGroup.locoGroups) {
final locoKey = _locoKey(date, classGroup.classLabel, loco.locoLabel);
if (!_collapsedLocos.contains(locoKey)) return false;
}
return true;
}
String _classKey(String date, String classLabel) => '$date|$classLabel';
String _locoKey(String date, String classLabel, String locoLabel) =>
'$date|$classLabel|$locoLabel';
List<_ChangeGroup> _groupChanges(List<LocoChange> changes) {
final dateFormat = DateFormat('yyyy-MM-dd');
final Map<String, Map<String, Map<String, List<LocoChange>>>> grouped = {};
final filtered = changes.where((change) {
final code = change.attrCode.toLowerCase();
return code != 'build_prec' && code != 'operational' && code != 'gettable';
});
for (final change in filtered) {
final date = change.approvedAt ?? change.validFrom;
final dateKey = date != null ? dateFormat.format(date) : 'Unknown date';
final classKey = change.locoClass.isNotEmpty
? change.locoClass
: 'Unknown class';
final locoKey = _locoLabel(change);
grouped.putIfAbsent(dateKey, () => {});
grouped[dateKey]!.putIfAbsent(classKey, () => {});
grouped[dateKey]![classKey]!.putIfAbsent(locoKey, () => []);
grouped[dateKey]![classKey]![locoKey]!.add(change);
}
final sortedDates = grouped.keys.toList()
..sort((a, b) {
if (a == 'Unknown date') return 1;
if (b == 'Unknown date') return -1;
return b.compareTo(a); // newest first
});
return sortedDates
.map(
(dateKey) => _ChangeGroup(
dateLabel: dateKey,
classGroups: grouped[dateKey]!.entries
.map(
(classEntry) => _ClassGroup(
classLabel: classEntry.key,
locoGroups: classEntry.value.entries
.map(
(locoEntry) => _LocoGroup(
locoLabel: locoEntry.key,
changes: locoEntry.value
..sort(
(a, b) => (b.approvedAt ?? b.validFrom ?? DateTime(0))
.compareTo(a.approvedAt ?? a.validFrom ?? DateTime(0)),
), ),
), ),
)
.toList(),
),
)
.toList(),
),
)
.toList();
}
Future<void> _loadMore() async {
final data = context.read<DataService>();
await data.fetchLatestLocoChanges(
offset: data.latestLocoChanges.length,
append: true,
); );
} }
} }
class _ChangeGroup {
final String dateLabel;
final List<_ClassGroup> classGroups;
_ChangeGroup({required this.dateLabel, required this.classGroups});
}
class _LocoGroup {
final String locoLabel;
final List<LocoChange> changes;
_LocoGroup({required this.locoLabel, required this.changes});
}
class _ClassGroup {
final String classLabel;
final List<_LocoGroup> locoGroups;
_ClassGroup({required this.classLabel, required this.locoGroups});
}
String _locoLabel(LocoChange change) {
final number = change.locoNumber.trim();
final name = change.locoName.trim();
if (number.isNotEmpty && name.isNotEmpty) return '$number$name';
if (number.isNotEmpty) return number;
if (name.isNotEmpty) return name;
return 'Loco ${change.locoId}';
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -9,7 +10,9 @@ class LeaderboardPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final leaderboard = data.homepageStats?.leaderboard ?? []; final leaderboard = data.homepageStats?.leaderboard ?? [];
final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && leaderboard.isEmpty) { if (data.isHomepageLoading && leaderboard.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
@@ -23,13 +26,33 @@ class LeaderboardPanel extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Row(
children: [
const Icon(Icons.emoji_events, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
"Leaderboard", "Leaderboard",
style: TextStyle( style: textTheme.titleMedium?.copyWith(
fontSize: 18, fontWeight: FontWeight.w800,
fontWeight: FontWeight.bold,
), ),
), ),
),
if (leaderboard.isNotEmpty)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'Top ${leaderboard.length}',
style: textTheme.labelSmall,
),
),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
if (leaderboard.isEmpty) if (leaderboard.isEmpty)
const Padding( const Padding(
@@ -38,44 +61,42 @@ class LeaderboardPanel extends StatelessWidget {
) )
else else
Column( Column(
children: List.generate(
leaderboard.length,
(index) {
final leaderboardEntry = leaderboard[index];
return Container(
width: double.infinity,
margin: const EdgeInsets.symmetric(
horizontal: 0, vertical: 8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Text.rich( for (int index = 0; index < leaderboard.length; index++) ...[
TextSpan( ListTile(
children: [ contentPadding: EdgeInsets.zero,
TextSpan( dense: true,
text: '${index + 1}. ', leading: CircleAvatar(
style: const TextStyle( radius: 18,
fontWeight: FontWeight.bold, backgroundColor:
Theme.of(context).colorScheme.secondaryContainer,
child: Text(
'${index + 1}',
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
), ),
), ),
TextSpan(
text: leaderboardEntry.userFullName,
), ),
title: Text(
leaderboard[index].userFullName,
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
trailing: Text(
distanceUnits.format(
leaderboard[index].mileage,
decimals: 1,
),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (index != leaderboard.length - 1) const Divider(height: 12),
], ],
),
),
Text(
'${leaderboardEntry.mileage.toStringAsFixed(1)} mi',
),
], ],
), ),
),
);
},
),
),
], ],
), ),
), ),

View File

@@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class TopTractionPanel extends StatelessWidget { class TopTractionPanel extends StatelessWidget {
@@ -9,8 +9,10 @@ class TopTractionPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats; final stats = data.homepageStats;
final locos = stats?.topLocos ?? []; final locos = stats?.topLocos ?? [];
final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && locos.isEmpty) { if (data.isHomepageLoading && locos.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
@@ -24,13 +26,20 @@ class TopTractionPanel extends StatelessWidget {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Text( Row(
"Top Traction", children: [
style: TextStyle( const Icon(Icons.train, size: 20),
fontSize: 18, const SizedBox(width: 8),
fontWeight: FontWeight.bold, Expanded(
child: Text(
"Top traction",
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
), ),
), ),
),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
if (locos.isEmpty) if (locos.isEmpty)
const Padding( const Padding(
@@ -39,53 +48,50 @@ class TopTractionPanel extends StatelessWidget {
) )
else else
Column( Column(
children: List.generate(
locos.length,
(index) {
final loco = locos[index];
return Container(
width: double.infinity,
margin:
const EdgeInsets.symmetric(horizontal: 0, vertical: 8),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
Column( for (int index = 0; index < locos.length; index++) ...[
crossAxisAlignment: CrossAxisAlignment.start, ListTile(
children: [ contentPadding: EdgeInsets.zero,
Text.rich( dense: true,
TextSpan( leading: CircleAvatar(
children: [ radius: 18,
TextSpan( backgroundColor:
text: '${index + 1}. ', Theme.of(context).colorScheme.primaryContainer,
style: const TextStyle( child: Text(
fontWeight: FontWeight.bold, '${index + 1}',
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
), ),
), ),
TextSpan(
text:
'${loco.locoClass} ${loco.number}',
), ),
title: Text(
'${locos[index].locoClass} ${locos[index].number}',
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
subtitle: (locos[index].name ?? '').isEmpty
? null
: Text(
locos[index].name ?? '',
style: textTheme.bodySmall?.copyWith(
fontStyle: FontStyle.italic,
),
),
trailing: Text(
distanceUnits.format(
locos[index].mileage ?? 0,
decimals: 1,
),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (index != locos.length - 1) const Divider(height: 12),
], ],
),
),
Text(
loco.name ?? '',
style:
const TextStyle(fontStyle: FontStyle.italic),
),
], ],
), ),
Text('${loco.mileage?.toStringAsFixed(1)} mi'),
],
),
),
);
},
),
),
], ],
), ),
), ),

View File

@@ -1,10 +1,11 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class LegCard extends StatelessWidget { class LegCard extends StatefulWidget {
const LegCard({ const LegCard({
super.key, super.key,
required this.leg, required this.leg,
@@ -16,30 +17,156 @@ class LegCard extends StatelessWidget {
final bool showEditButton; final bool showEditButton;
final bool showDate; final bool showDate;
@override
State<LegCard> createState() => _LegCardState();
}
class _LegCardState extends State<LegCard> {
bool _expanded = false;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final leg = widget.leg;
final distanceUnits = context.watch<DistanceUnitService>();
final routeSegments = _parseRouteSegments(leg.route); final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Card( return Card(
child: ExpansionTile( child: ExpansionTile(
onExpansionChanged: (v) => setState(() => _expanded = v),
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: const Icon(Icons.train), leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'), title: LayoutBuilder(
subtitle: Column( builder: (context, constraints) {
final isWide = constraints.maxWidth > 520;
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) {
final timeStyle = Theme.of(context).textTheme.labelSmall;
return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (showDate) Text(_formatDateTime(leg.beginTime)), routeText,
if (leg.headcode.isNotEmpty) 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 Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
beginTimeWidget,
const Text('·'),
routeText,
if (endTimeWidget != null) ...[
const Text('·'),
endTimeWidget,
],
],
);
},
),
subtitle: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 520;
final tractionWrap = !_expanded && leg.locos.isNotEmpty
? Wrap(
spacing: 8,
runSpacing: 4,
children: leg.locos.map((loco) {
final iconColor = loco.powering
? Theme.of(context).colorScheme.primary
: Theme.of(context).hintColor;
final label = '${loco.locoClass} ${loco.number}'.trim();
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.train, size: 14, color: iconColor),
const SizedBox(width: 4),
Text(
label.isEmpty ? 'Loco ${loco.id}' : label,
style: textTheme.labelSmall,
),
],
);
}).toList(),
)
: null;
final children = <Widget>[];
if (isWide) {
if (tractionWrap != null) {
children.add(tractionWrap);
}
} else {
if (tractionWrap != null) {
children.add(tractionWrap);
}
}
if (leg.headcode.isNotEmpty) {
children.add(
Text( Text(
'Headcode: ${leg.headcode}', 'Headcode: ${leg.headcode}',
style: textTheme.labelSmall, style: textTheme.labelSmall,
), ),
if (leg.network.isNotEmpty) );
}
if (leg.network.isNotEmpty) {
children.add(
Text( Text(
leg.network, leg.network,
style: textTheme.labelSmall, style: textTheme.labelSmall,
), ),
], );
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
},
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -50,7 +177,7 @@ class LegCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
'${leg.mileage.toStringAsFixed(1)} mi', distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@@ -66,7 +193,7 @@ class LegCard extends StatelessWidget {
], ],
], ],
), ),
if (showEditButton) ...[ if (widget.showEditButton) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(
tooltip: 'Edit entry', tooltip: 'Edit entry',
@@ -76,6 +203,18 @@ class LegCard extends StatelessWidget {
constraints: const BoxConstraints(minWidth: 32, minHeight: 32), constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => context.push('/legs/edit/${leg.id}'), onPressed: () => context.push('/legs/edit/${leg.id}'),
), ),
if (_expanded) ...[
const SizedBox(width: 4),
IconButton(
tooltip: 'Delete entry',
icon: const Icon(Icons.delete_outline),
color: Theme.of(context).colorScheme.error,
visualDensity: VisualDensity.compact,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
onPressed: () => _confirmDelete(context, leg.id),
),
],
], ],
], ],
), ),
@@ -101,6 +240,12 @@ class LegCard extends StatelessWidget {
), ),
const SizedBox(height: 12), 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) ...[ if (routeSegments.isNotEmpty) ...[
Text('Route', style: textTheme.titleSmall), Text('Route', style: textTheme.titleSmall),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -114,15 +259,87 @@ class LegCard extends StatelessWidget {
); );
} }
Future<void> _confirmDelete(BuildContext context, int legId) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete entry?'),
content: const Text('Are you sure you want to delete this entry?'),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
),
);
if (confirmed != true) return;
if (!context.mounted) return;
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.of(context);
try {
await data.api.delete('/legs/delete?leg_id=$legId');
await data.refreshLegs();
messenger.showSnackBar(const SnackBar(content: Text('Entry deleted')));
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to delete entry: $e')),
);
}
}
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) { String _formatDate(DateTime? date) {
if (date == null) return ''; if (date == null) return '';
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
} }
String _formatDateTime(DateTime date) { String _formatTime(DateTime date) {
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
}
String _formatDateTime(DateTime date, {bool includeDate = true}) {
final timeStr = _formatTime(date);
if (!includeDate) return timeStr;
final dateStr = _formatDate(date); final dateStr = _formatDate(date);
final timeStr =
'${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
return '$dateStr · $timeStr'; return '$dateStr · $timeStr';
} }
@@ -140,7 +357,7 @@ class LegCard extends StatelessWidget {
: textTheme.bodyMedium?.copyWith(color: theme.disabledColor); : textTheme.bodyMedium?.copyWith(color: theme.disabledColor);
final background = powering final background = powering
? theme.colorScheme.surfaceContainerHighest ? theme.colorScheme.surfaceContainerHighest
: theme.colorScheme.surfaceVariant; : theme.colorScheme.surfaceContainerLow;
return Chip( return Chip(
label: Text( label: Text(
'${loco.locoClass} ${loco.number}', '${loco.locoClass} ${loco.number}',
@@ -158,6 +375,51 @@ class LegCard extends StatelessWidget {
.toList(); .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) { Widget _buildRouteList(List<String> segments) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -178,38 +440,7 @@ class LegCard extends StatelessWidget {
); );
} }
List<String> _parseRouteSegments(String route) { List<String> _parseRouteSegments(List<String> route) {
final trimmed = route.trim(); return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList();
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];
} }
} }

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LoginScreen extends StatefulWidget { class LoginScreen extends StatefulWidget {
@@ -16,7 +17,9 @@ class _LoginScreenState extends State<LoginScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkExistingSession()); WidgetsBinding.instance.addPostFrameCallback(
(_) => _checkExistingSession(),
);
} }
Future<void> _checkExistingSession() async { Future<void> _checkExistingSession() async {
@@ -26,7 +29,7 @@ class _LoginScreenState extends State<LoginScreen> {
if (!valid) return; if (!valid) return;
await auth.tryRestoreSession(); await auth.tryRestoreSession();
if (!mounted) return; if (!mounted) return;
context.go('/'); context.go('/dashboard');
} finally { } finally {
if (mounted) setState(() => _checkingSession = false); if (mounted) setState(() => _checkingSession = false);
} }
@@ -70,17 +73,39 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
), ),
if (_checkingSession) const SizedBox(height: 50),
const Padding( const LoginPanel(),
padding: EdgeInsets.only(top: 12), const SizedBox(height: 16),
child: SizedBox( IconButton(
icon: const Icon(Icons.settings, color: Colors.grey),
tooltip: 'Settings',
onPressed: () {
Navigator.of(context).push(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => const SettingsPage(),
),
);
},
),
if (_checkingSession) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 24, height: 24,
width: 24, width: 24,
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
), ),
const SizedBox(width: 8),
Text(
'Trying to log in',
style: Theme.of(context).textTheme.bodyMedium,
), ),
const SizedBox(height: 50), ],
const LoginPanel(), ),
],
], ],
), ),
), ),
@@ -173,14 +198,15 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
setState(() { setState(() {
_loggingIn = false; _loggingIn = false;
}); });
context.go('/dashboard');
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_loggingIn = false; _loggingIn = false;
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Login failed: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Login failed: $e')));
} }
} }
@@ -291,14 +317,16 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Registration successful. Please log in.')), const SnackBar(
content: Text('Registration successful. Please log in.'),
),
); );
widget.onBack(); widget.onBack();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Registration failed: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Registration failed: $e')));
} finally { } finally {
if (mounted) setState(() => _registering = false); if (mounted) setState(() => _registering = false);
} }

View File

@@ -39,9 +39,9 @@ class CalculatorDetailsPage extends StatelessWidget {
child: RouteDetailsView( child: RouteDetailsView(
route: parsed.calculatedRoute, route: parsed.calculatedRoute,
costs: parsed.costs, costs: parsed.costs,
routingPoints: parsed.inputRoute.toSet(),
onBack: () => context.pop(), onBack: () => context.pop(),
), ),
); );
} }
} }

View File

@@ -1,11 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart'; import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart';
import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart'; import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart';
import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart'; import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class Dashboard extends StatefulWidget { class Dashboard extends StatefulWidget {
@@ -22,9 +24,10 @@ class _DashboardState extends State<Dashboard> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final auth = context.watch<AuthService>(); final auth = context.watch<AuthService>();
final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats; final stats = data.homepageStats;
final isInitialLoading = data.isHomepageLoading || stats == null; final isInitialLoading = data.isHomepageLoading && stats == null;
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {
@@ -38,40 +41,21 @@ class _DashboardState extends State<Dashboard> {
}, },
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth > 1100; const spacing = 16.0;
final metricChips = _buildMetricChips( final maxWidth = constraints.maxWidth;
context,
totalMileage: stats?.totalMileage ?? 0,
currentYearMileage: data.getMileageForCurrentYear(),
legCount: stats?.legCount ?? data.trips.length,
);
return Stack( return Stack(
children: [ children: [
ListView( ListView(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
_buildHeader(context, auth, stats, data.isHomepageLoading), _buildHero(context, auth, data, stats, distanceUnits),
const SizedBox(height: 12), const SizedBox(height: spacing),
Wrap(spacing: 12, runSpacing: 12, children: metricChips), _buildTiles(
const SizedBox(height: 16), context,
isWide data,
? Row( distanceUnits,
crossAxisAlignment: CrossAxisAlignment.start, maxWidth,
children: [ spacing,
Expanded(child: _buildMainColumn(context, data)),
const SizedBox(width: 16),
SizedBox(
width: 360,
child: _buildSidebar(context, data),
),
],
)
: Column(
children: [
_buildMainColumn(context, data),
const SizedBox(height: 16),
_buildSidebar(context, data),
],
), ),
], ],
), ),
@@ -100,104 +84,240 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _buildHeader( Widget _buildHero(
BuildContext context, BuildContext context,
AuthService auth, AuthService auth,
DataService data,
HomepageStats? stats, HomepageStats? stats,
bool loading, DistanceUnitService distanceUnits,
) { ) {
final colorScheme = Theme.of(context).colorScheme;
final greetingName = final greetingName =
stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there'; stats?.user?.fullName ?? auth.fullName ?? auth.username ?? 'there';
return Row( final totalMileage = stats?.totalMileage ?? 0;
mainAxisAlignment: MainAxisAlignment.spaceBetween, final currentYearMileage = data.getMileageForCurrentYear();
final legCount = stats?.legCount ?? data.trips.length;
final progress = totalMileage == 0
? 0.0
: (currentYearMileage / totalMileage).clamp(0, 1).toDouble();
return Card(
clipBehavior: Clip.antiAlias,
elevation: 2,
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
colorScheme.primaryContainer,
colorScheme.secondaryContainer,
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_heroHeading(context, greetingName, colorScheme),
const SizedBox(height: 18),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
_metricTile(
context,
label: 'Total mileage',
value: distanceUnits.format(totalMileage, decimals: 1),
icon: Icons.route,
color: colorScheme.onPrimaryContainer,
),
_metricTile(
context,
label: 'This year',
value: distanceUnits.format(currentYearMileage, decimals: 1),
icon: Icons.calendar_today,
color: colorScheme.onPrimaryContainer,
),
_metricTile(
context,
label: 'Entries logged',
value: legCount.toString(),
icon: Icons.format_list_bulleted,
color: colorScheme.onPrimaryContainer,
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LinearProgressIndicator(
value: progress.isNaN ? 0 : progress,
minHeight: 10,
backgroundColor: colorScheme.onPrimaryContainer.withValues(
alpha: 0.2,
),
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 6),
Text(
totalMileage == 0
? 'Log a new entry to start your timeline.'
: 'Year-to-date is ${(progress * 100).toStringAsFixed(0)}% of all mileage.',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
),
],
),
),
);
}
Widget _metricTile(
BuildContext context, {
required String label,
required String value,
required IconData icon,
required Color color,
}) {
final bg = Colors.white.withValues(alpha: 0.14);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.white.withValues(alpha: 0.18)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: color),
const SizedBox(width: 10),
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text('Dashboard', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 2),
Text( Text(
'Welcome back, $greetingName', label,
style: Theme.of(context).textTheme.headlineSmall, style: Theme.of(context).textTheme.labelSmall?.copyWith(
), color: color.withValues(alpha: 0.85),
], letterSpacing: 0.4,
),
if (loading)
const Padding(
padding: EdgeInsets.only(right: 8.0),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
), ),
), ),
],
);
}
List<Widget> _buildMetricChips(
BuildContext context, {
required double totalMileage,
required double currentYearMileage,
required int legCount,
}) {
final textTheme = Theme.of(context).textTheme;
Widget metricCard(String label, String value) {
return Card(
elevation: 1,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label.toUpperCase(),
style: textTheme.labelSmall?.copyWith(
letterSpacing: 0.7,
color: textTheme.bodySmall?.color?.withValues(alpha: 0.7),
),
),
const SizedBox(height: 4),
Text( Text(
value, value,
style: textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
color: color,
), ),
), ),
], ],
), ),
],
), ),
); );
} }
return [ Widget _buildTiles(
metricCard('Total mileage', '${totalMileage.toStringAsFixed(1)} mi'), BuildContext context,
metricCard('This year', '${currentYearMileage.toStringAsFixed(1)} mi'), DataService data,
metricCard('Entries logged', legCount.toString()), DistanceUnitService distanceUnits,
]; double maxWidth,
double spacing,
) {
final isWide = maxWidth >= 1200;
if (isWide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildOnThisDayCard(context, data, distanceUnits),
const SizedBox(height: 16),
_buildTripsCard(context, data, distanceUnits),
const SizedBox(height: 16),
const LatestLocoChangesPanel(expanded: true),
],
),
),
const SizedBox(width: 16),
Expanded(
flex: 1,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: const [
TopTractionPanel(),
SizedBox(height: 16),
LeaderboardPanel(),
],
),
),
],
);
} }
Widget _buildMainColumn(BuildContext context, DataService data) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_buildCard( _buildOnThisDayCard(context, data, distanceUnits),
context, const SizedBox(height: 16),
title: 'On this day', const TopTractionPanel(),
action: const SizedBox(height: 16),
data.onThisDay const LeaderboardPanel(),
const SizedBox(height: 16),
_buildTripsCard(context, data, distanceUnits),
const SizedBox(height: 16),
const LatestLocoChangesPanel(),
],
);
}
Widget _heroHeading(
BuildContext context,
String greetingName,
ColorScheme colorScheme,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Dashboard',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: colorScheme.onPrimaryContainer,
letterSpacing: 0.4,
),
),
const SizedBox(height: 2),
Text(
'Welcome back, $greetingName',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w800,
),
),
],
);
}
Widget _buildOnThisDayCard(
BuildContext context, DataService data, DistanceUnitService distanceUnits) {
final filtered = data.onThisDay
.where((leg) => leg.beginTime.year != DateTime.now().year) .where((leg) => leg.beginTime.year != DateTime.now().year)
.length > .toList();
5 final textTheme = Theme.of(context).textTheme;
? TextButton( final showMore = filtered.length > 5;
onPressed: () => setState(() { final visible = _showAllOnThisDay ? filtered : filtered.take(6).toList();
_showAllOnThisDay = !_showAllOnThisDay; return _panel(
}), context,
child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'), icon: Icons.history_toggle_off,
) title: 'On this day',
: null,
trailing: data.isOnThisDayLoading trailing: data.isOnThisDayLoading
? const SizedBox( ? const SizedBox(
height: 18, height: 18,
@@ -205,34 +325,150 @@ class _DashboardState extends State<Dashboard> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: null, : null,
child: _buildLegList( action: showMore
context, ? TextButton(
data.onThisDay, onPressed: () =>
showAll: _showAllOnThisDay, setState(() => _showAllOnThisDay = !_showAllOnThisDay),
emptyMessage: 'No historical moves for today yet.', child: Text(_showAllOnThisDay ? 'Show less' : 'Show more'),
), )
), : null,
const SizedBox(height: 12), child: filtered.isEmpty
_buildTripsCard(context, data), ? Text(
], 'No historical moves for today yet.',
); style: textTheme.bodyMedium,
} )
: Column(
Widget _buildSidebar(BuildContext context, DataService data) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
const TopTractionPanel(), for (int idx = 0; idx < visible.length; idx++) ...[
const SizedBox(height: 12), _otdRow(context, visible[idx], textTheme, distanceUnits),
const LeaderboardPanel(), if (idx != visible.length - 1) const Divider(height: 12),
const SizedBox(height: 12), ],
const LatestLocoChangesPanel(), ],
),
);
}
Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme,
DistanceUnitService distanceUnits) {
final traction = leg.locos;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Container(
width: 64,
height: 56,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
leg.beginTime.year.toString(),
style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
),
),
const SizedBox(height: 2),
Text(_formatTime(leg.beginTime), style: textTheme.labelSmall),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${leg.start}${leg.end}',
style: textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
if (leg.headcode.isNotEmpty) ...[
const SizedBox(height: 4),
Row(
children: [
Text(
leg.headcode,
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color?.withValues(
alpha: 0.7,
),
),
),
],
),
],
const SizedBox(height: 4),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(width: 6),
Expanded(
child: traction.isEmpty
? Text(
'No traction recorded',
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color?.withValues(
alpha: 0.7,
),
),
)
: Wrap(
spacing: 8,
runSpacing: 4,
children: traction.map((loco) {
final iconColor = loco.powering
? Theme.of(context).colorScheme.primary
: Theme.of(context).hintColor;
final label = '${loco.locoClass} ${loco.number}';
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.train, size: 14, color: iconColor),
const SizedBox(width: 4),
Text(
label,
style: textTheme.labelSmall?.copyWith(
color: textTheme.bodySmall?.color
?.withValues(alpha: 0.85),
),
),
],
);
}).toList(),
),
),
],
),
],
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
],
),
], ],
); );
} }
Widget _buildCard( Widget _panel(
BuildContext context, { BuildContext context, {
required IconData icon,
required String title, required String title,
required Widget child, required Widget child,
Widget? trailing, Widget? trailing,
@@ -241,29 +477,29 @@ class _DashboardState extends State<Dashboard> {
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Padding( child: Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Icon(icon, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
title, title,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w800,
), ),
), ),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (action != null) action,
if (trailing != null) ...[
const SizedBox(width: 8),
trailing,
],
],
), ),
if (action != null)
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: action,
),
if (trailing != null) trailing,
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -274,59 +510,19 @@ class _DashboardState extends State<Dashboard> {
); );
} }
Widget _buildLegList( Widget _buildTripsCard(
BuildContext context, BuildContext context, DataService data, DistanceUnitService distanceUnits) {
List<Leg> legs, {
required String emptyMessage,
bool showAll = false,
}) {
final filtered = legs
.where((leg) => leg.beginTime.year != DateTime.now().year)
.toList();
if (filtered.isEmpty) {
return Text(emptyMessage, style: Theme.of(context).textTheme.bodyMedium);
}
final toShow = showAll ? filtered : filtered.take(5).toList();
return Column(
children: toShow.map((leg) {
return ListTile(
dense: true,
contentPadding: EdgeInsets.zero,
leading: CircleAvatar(
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: const Icon(Icons.train),
),
title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${leg.mileage.toStringAsFixed(1)} mi'),
if (leg.headcode.isNotEmpty)
Text(
leg.headcode,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(context).hintColor,
),
),
],
),
);
}).toList(),
);
}
Widget _buildTripsCard(BuildContext context, DataService data) {
final tripsUnsorted = data.trips; final tripsUnsorted = data.trips;
List trips = []; List<TripSummary> trips = [];
if (tripsUnsorted.isNotEmpty) { if (tripsUnsorted.isNotEmpty) {
trips = [...tripsUnsorted]..sort((a, b) => b.tripId.compareTo(a.tripId)); trips = [...tripsUnsorted]..sort(TripSummary.compareByDateDesc);
} }
return _buildCard( return _panel(
context, context,
icon: Icons.bookmark,
title: 'Trips', title: 'Trips',
action: TextButton( action: TextButton(
onPressed: () => context.push('/trips'), onPressed: () => context.push('/logbook/trips'),
child: const Text('View all'), child: const Text('View all'),
), ),
child: trips.isEmpty child: trips.isEmpty
@@ -335,19 +531,46 @@ class _DashboardState extends State<Dashboard> {
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
) )
: Column( : Column(
children: trips.take(5).map((trip) { children: trips.take(6).map((trip) {
return ListTile( return Padding(
contentPadding: EdgeInsets.zero, padding: const EdgeInsets.symmetric(vertical: 6.0),
title: Text(trip.tripName), child: Row(
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'), children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.book, size: 18),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
trip.tripName,
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700),
),
Text(
distanceUnits.format(trip.tripMileage, decimals: 1),
style: Theme.of(context).textTheme.labelMedium,
),
],
),
),
],
),
); );
}).toList(), }).toList(),
), ),
); );
} }
String _formatDate(DateTime? dt) { String _formatTime(DateTime date) {
if (dt == null) return ''; return DateFormat('HH:mm').format(date);
return '${dt.year.toString().padLeft(4, '0')}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')}';
} }
} }

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/legs/leg_card.dart'; import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LegsPage extends StatefulWidget { class LegsPage extends StatefulWidget {
@@ -90,6 +91,7 @@ class _LegsPageState extends State<LegsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final legs = data.legs; final legs = data.legs;
final pageMileage = _pageMileage(legs); final pageMileage = _pageMileage(legs);
@@ -121,7 +123,7 @@ class _LegsPageState extends State<LegsPage> {
children: [ children: [
Text('Page mileage', Text('Page mileage',
style: Theme.of(context).textTheme.labelSmall), style: Theme.of(context).textTheme.labelSmall),
Text('${pageMileage.toStringAsFixed(1)} mi', Text(distanceUnits.format(pageMileage, decimals: 1),
style: Theme.of(context) style: Theme.of(context)
.textTheme .textTheme
.titleMedium .titleMedium
@@ -212,7 +214,7 @@ class _LegsPageState extends State<LegsPage> {
else else
Column( Column(
children: [ children: [
..._buildLegsWithDividers(context, legs), ..._buildLegsWithDividers(context, legs, distanceUnits),
const SizedBox(height: 8), const SizedBox(height: 8),
if (data.legsHasMore || data.isLegsLoading) if (data.legsHasMore || data.isLegsLoading)
Align( Align(
@@ -239,14 +241,19 @@ class _LegsPageState extends State<LegsPage> {
); );
} }
List<Widget> _buildLegsWithDividers(BuildContext context, List<Leg> legs) { List<Widget> _buildLegsWithDividers(
BuildContext context,
List<Leg> legs,
DistanceUnitService distanceUnits,
) {
final widgets = <Widget>[]; final widgets = <Widget>[];
String? currentDate; String? currentDate;
double dayMileage = 0; double dayMileage = 0;
final dayLegs = <Leg>[]; final dayLegs = <Leg>[];
void flushDay() { void flushDay() {
if (currentDate == null) return; final date = currentDate;
if (date == null) return;
widgets.add( widgets.add(
Padding( Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0), padding: const EdgeInsets.symmetric(vertical: 8.0),
@@ -254,16 +261,14 @@ class _LegsPageState extends State<LegsPage> {
children: [ children: [
Expanded( Expanded(
child: Text( child: Text(
currentDate!, date,
style: Theme.of(context).textTheme.labelMedium?.copyWith( style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
), ),
Text( Text(distanceUnits.format(dayMileage, decimals: 1),
'${dayMileage.toStringAsFixed(1)} mi', style: Theme.of(context).textTheme.labelMedium),
style: Theme.of(context).textTheme.labelMedium,
),
], ],
), ),
), ),

View File

@@ -36,6 +36,21 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
WidgetsBinding.instance.addPostFrameCallback((_) => _load()); WidgetsBinding.instance.addPostFrameCallback((_) => _load());
} }
dynamic _normalizeFieldValue(_FieldEntry field) {
final name = field.field.name.toLowerCase();
final val = field.value;
if (name == 'max_speed') {
final numVal = val is num ? val.toDouble() : double.tryParse('$val');
if (numVal == null) return val;
final unit = (field.unit ?? 'kph').toLowerCase();
if (unit == 'mph') {
return numVal * 1.60934;
}
return numVal;
}
return val;
}
@override @override
void dispose() { void dispose() {
_disposeDrafts(_draftEvents); _disposeDrafts(_draftEvents);
@@ -57,7 +72,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
String? _eventDateForEntry(LocoAttrVersion entry) { String? _eventDateForEntry(LocoAttrVersion entry) {
final masked = entry.maskedValidFrom?.trim(); final masked = entry.maskedValidFrom?.trim();
if (masked != null && masked.isNotEmpty) return masked; if (masked != null && masked.isNotEmpty) return masked;
final from = entry.validFrom ?? entry.txnFrom; final from = entry.validFrom;
if (from == null) return null; if (from == null) return null;
return DateFormat('yyyy-MM-dd').format(from); return DateFormat('yyyy-MM-dd').format(from);
} }
@@ -115,7 +130,8 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
draft.details = ''; draft.details = '';
draft.fields.add( draft.fields.add(
_FieldEntry(field: field) _FieldEntry(field: field)
..value = _valueForEntry(entry), ..value = _valueForEntry(entry)
..unit = _guessUnit(field, entry.valueLabel),
); );
setState(() { setState(() {
@@ -123,6 +139,16 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
}); });
} }
String? _guessUnit(EventField field, String valueLabel) {
final name = field.name.toLowerCase();
if (name == 'max_speed') {
final val = valueLabel.toLowerCase();
if (val.contains('mph')) return 'mph';
return 'kph';
}
return _defaultUnitForField(field);
}
Future<void> _deleteEntry(LocoAttrVersion entry) async { Future<void> _deleteEntry(LocoAttrVersion entry) async {
if (_isDeleting) return; if (_isDeleting) return;
final blockId = entry.versionId; final blockId = entry.versionId;
@@ -241,7 +267,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
invalid.add('Field ${field.field.display} is empty'); invalid.add('Field ${field.field.display} is empty');
break; break;
} }
values[field.field.name] = val; values[field.field.name] = _normalizeFieldValue(field);
} }
if (invalid.isNotEmpty) continue; if (invalid.isNotEmpty) continue;
if (values.isEmpty) { if (values.isEmpty) {

View File

@@ -184,7 +184,9 @@ class _FieldList extends StatelessWidget {
value: null, value: null,
onChanged: (field) { onChanged: (field) {
if (field == null) return; if (field == null) return;
draft.fields.add(_FieldEntry(field: field)); draft.fields.add(
_FieldEntry(field: field)..unit = _defaultUnitForField(field),
);
onChange(); onChange();
}, },
items: availableFields items: availableFields
@@ -224,10 +226,10 @@ class _FieldList extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
_FieldInput( _FieldInput(
field: field.field, entry: field,
value: field.value, onChanged: (val, {String? unit}) {
onChanged: (val) {
field.value = val; field.value = val;
if (unit != null) field.unit = unit;
onChange(); onChange();
}, },
), ),
@@ -253,17 +255,18 @@ class _FieldList extends StatelessWidget {
class _FieldInput extends StatelessWidget { class _FieldInput extends StatelessWidget {
const _FieldInput({ const _FieldInput({
required this.field, required this.entry,
required this.value,
required this.onChanged, required this.onChanged,
}); });
final EventField field; final _FieldEntry entry;
final dynamic value; final void Function(dynamic value, {String? unit}) onChanged;
final ValueChanged<dynamic> onChanged;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final field = entry.field;
final value = entry.value;
if (field.enumValues != null && field.enumValues!.isNotEmpty) { if (field.enumValues != null && field.enumValues!.isNotEmpty) {
final options = field.enumValues!; final options = field.enumValues!;
return DropdownButtonFormField<String>( return DropdownButtonFormField<String>(
@@ -293,6 +296,118 @@ class _FieldInput extends StatelessWidget {
); );
} }
final name = field.name.toLowerCase();
if (name == 'max_speed') {
final unit = entry.unit ?? 'kph';
return Row(
children: [
Expanded(
child: TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed, unit: unit);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'kph/mph',
),
keyboardType: TextInputType.number,
),
),
const SizedBox(width: 8),
SizedBox(
width: 88,
child: DropdownButtonFormField<String>(
value: unit,
items: const [
DropdownMenuItem(value: 'kph', child: Text('kph')),
DropdownMenuItem(value: 'mph', child: Text('mph')),
],
onChanged: (val) {
if (val == null) return;
onChanged(value, unit: val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
labelText: 'Unit',
),
),
),
],
);
}
if ({
'height',
'length',
'width',
'track_gauge',
}.contains(name)) {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'mm',
),
keyboardType: TextInputType.number,
);
}
if (name == 'weight') {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'tonnes',
),
keyboardType: TextInputType.number,
);
}
if (name == 'power') {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'kW',
),
keyboardType: TextInputType.number,
);
}
if (name == 'tractive_effort') {
return TextFormField(
initialValue: value?.toString(),
onChanged: (val) {
final parsed = double.tryParse(val);
onChanged(parsed ?? val);
},
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: 'Enter value',
suffixText: 'kN',
),
keyboardType: TextInputType.number,
);
}
final isNumber = type == 'int' || type == 'integer'; final isNumber = type == 'int' || type == 'integer';
return TextFormField( return TextFormField(
initialValue: value?.toString(), initialValue: value?.toString(),
@@ -326,6 +441,13 @@ class _EventDraft {
class _FieldEntry { class _FieldEntry {
final EventField field; final EventField field;
dynamic value; dynamic value;
String? unit;
_FieldEntry({required this.field}); _FieldEntry({required this.field});
} }
String? _defaultUnitForField(EventField field) {
final name = field.name.toLowerCase();
if (name == 'max_speed') return 'kph';
return null;
}

View File

@@ -445,7 +445,7 @@ class _ValueBlockMenu extends StatelessWidget {
Future<void> showContextMenuAt(Offset globalPosition) async { Future<void> showContextMenuAt(Offset globalPosition) async {
final overlay = Overlay.of(context); final overlay = Overlay.of(context);
final renderBox = overlay?.context.findRenderObject() as RenderBox?; final renderBox = overlay.context.findRenderObject() as RenderBox?;
if (renderBox == null) return; if (renderBox == null) return;
// Translate from global screen coordinates into the overlay's local space // Translate from global screen coordinates into the overlay's local space
// so the menu appears where the gesture happened. // so the menu appears where the gesture happened.
@@ -577,7 +577,7 @@ class _TimelineModel {
_ValueSegment( _ValueSegment(
start: start, start: start,
end: end, end: end,
value: entry.valueLabel, value: _formatValueWithUnits(entry),
entry: entry, entry: entry,
), ),
); );
@@ -680,6 +680,53 @@ class _TimelineModel {
} }
} }
String _formatValueWithUnits(LocoAttrVersion entry) {
final raw = entry.valueLabel;
final code = entry.attrCode.toLowerCase();
final lowerRaw = raw.toLowerCase();
// Avoid double-appending if units already present.
final hasUnits = lowerRaw.contains('mm') ||
lowerRaw.contains('tonne') ||
lowerRaw.contains('kph') ||
lowerRaw.contains('mph');
double? asNumber = double.tryParse(raw);
String formatNumber(double value) {
if (value % 1 == 0) return value.toStringAsFixed(0);
return value.toStringAsFixed(2);
}
switch (code) {
case 'height':
case 'length':
case 'width':
case 'track_gauge':
if (hasUnits) return raw;
return asNumber != null ? '${formatNumber(asNumber)} mm' : '$raw mm';
case 'weight':
if (hasUnits) return raw;
return asNumber != null ? '${formatNumber(asNumber)} tonnes' : '$raw tonnes';
case 'power':
if (hasUnits) return raw;
return asNumber != null ? '${formatNumber(asNumber)} kW' : '$raw kW';
case 'tractive_effort':
if (hasUnits) return raw;
return asNumber != null ? '${formatNumber(asNumber)} kN' : '$raw kN';
case 'max_speed':
if (hasUnits) return raw;
if (asNumber != null) {
// Stored as kph.
final formatted = asNumber % 1 == 0
? asNumber.toStringAsFixed(0)
: asNumber.toStringAsFixed(1);
return '$formatted kph';
}
return '$raw kph';
default:
return raw;
}
}
class _AxisSegment { class _AxisSegment {
final DateTime start; final DateTime start;
final DateTime end; final DateTime end;
@@ -742,7 +789,15 @@ class _RowCell {
color: Colors.transparent, color: Colors.transparent,
); );
} }
final displayStart = _formatDate(seg.start) ?? ''; final entry = seg.entry;
String displayStart = '';
if (entry != null) {
if ((entry.maskedValidFrom ?? '').trim().isNotEmpty) {
displayStart = entry.maskedValidFrom!.trim();
} else if (entry.validFrom != null) {
displayStart = _formatDate(entry.validFrom) ?? '';
}
}
return _RowCell( return _RowCell(
value: seg.value, value: seg.value,
rangeLabel: displayStart, rangeLabel: displayStart,

View File

@@ -0,0 +1,42 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/pages/legs.dart';
import 'package:mileograph_flutter/components/pages/trips.dart';
enum LogbookTab { entries, trips }
class LogbookPage extends StatelessWidget {
const LogbookPage({super.key, this.initialTab = LogbookTab.entries});
final LogbookTab initialTab;
@override
Widget build(BuildContext context) {
final initialIndex = initialTab == LogbookTab.trips ? 1 : 0;
return DefaultTabController(
key: ValueKey(initialTab),
initialIndex: initialIndex,
length: 2,
child: Column(
children: [
TabBar(
onTap: (index) {
final dest = index == 0 ? '/logbook/entries' : '/logbook/trips';
final current = GoRouterState.of(context).uri.path;
if (current != dest) {
context.go(dest);
}
},
tabs: const [
Tab(text: 'Entries'),
Tab(text: 'Trips'),
],
),
Expanded(
child: TabBarView(children: const [LegsPage(), TripsPage()]),
),
],
),
);
}
}

View File

@@ -0,0 +1,13 @@
import 'package:flutter/material.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 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/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -13,6 +13,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
if (choice == _ExitChoice.save) { if (choice == _ExitChoice.save) {
await _saveDraftEntry(draftId: _activeDraftId); await _saveDraftEntry(draftId: _activeDraftId);
} else if (choice == _ExitChoice.discard) { } 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); await _resetFormState(clearDraft: true);
_activeDraftId = null; _activeDraftId = null;
} }
@@ -29,12 +32,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
} }
bool _formIsEmpty() { bool _formIsEmpty() {
final beginDelayVal = _parseDelayMinutes(_beginDelayController.text);
final endDelayVal = _parseDelayMinutes(_endDelayController.text);
return _startController.text.trim().isEmpty && return _startController.text.trim().isEmpty &&
_endController.text.trim().isEmpty && _endController.text.trim().isEmpty &&
_headcodeController.text.trim().isEmpty && _headcodeController.text.trim().isEmpty &&
_notesController.text.trim().isEmpty && _notesController.text.trim().isEmpty &&
_networkController.text.trim().isEmpty && _networkController.text.trim().isEmpty &&
_mileageController.text.trim().isEmpty && _mileageController.text.trim().isEmpty &&
_originController.text.trim().isEmpty &&
_destinationController.text.trim().isEmpty &&
beginDelayVal == 0 &&
endDelayVal == 0 &&
!_hasOriginTime &&
!_hasDestinationTime &&
!_hasEndTime &&
_routeResult == null && _routeResult == null &&
_tractionItems.length <= 1; _tractionItems.length <= 1;
} }
@@ -122,6 +134,30 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
"notes": _notesController.text, "notes": _notesController.text,
"mileage": _mileageController.text, "mileage": _mileageController.text,
"network": _networkController.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, "useManualMileage": _useManualMileage,
"selectedTripId": _selectedTripId, "selectedTripId": _selectedTripId,
"routeResult": _routeResult == null "routeResult": _routeResult == null
@@ -199,7 +235,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
required String id, required String id,
bool includeTimestamp = true, bool includeTimestamp = true,
}) { }) {
final units = _distanceUnits(context);
final routeStations = _routeResult?.calculatedRoute ?? []; final routeStations = _routeResult?.calculatedRoute ?? [];
final endTime = _legEndDateTime;
final originTime = _originDateTime;
final destinationTime = _destinationDateTime;
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final startVal = _useManualMileage final startVal = _useManualMileage
? _startController.text.trim() ? _startController.text.trim()
: (routeStations.isNotEmpty ? routeStations.first : ''); : (routeStations.isNotEmpty ? routeStations.first : '');
@@ -207,30 +250,36 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
? _endController.text.trim() ? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : ''); : (routeStations.isNotEmpty ? routeStations.last : '');
final mileageVal = _useManualMileage final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0 ? (units.milesFromInput(_mileageController.text.trim()) ?? 0)
: (_routeResult?.distance ?? 0); : (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload(); final tractionPayload = _buildTractionPayload();
final commonPayload = {
"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 final payload = _useManualMileage
? { ? {
"leg_trip": _selectedTripId, ...commonPayload,
"leg_start": startVal, "leg_start": startVal,
"leg_end": endVal, "leg_end": endVal,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_network": _networkController.text.trim(),
"leg_distance": mileageVal, "leg_distance": mileageVal,
"isKilometers": false, "isKilometers": false,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"locos": tractionPayload,
} }
: { : {
"leg_trip": _selectedTripId, ...commonPayload,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_route": routeStations, "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, "leg_mileage": _routeResult?.distance ?? mileageVal,
}; };
return { return {
@@ -265,8 +314,32 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
final beginTime = beginStr == null final beginTime = beginStr == null
? DateTime.now() ? DateTime.now()
: DateTime.tryParse(beginStr) ?? 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 tripRaw = payload['leg_trip'];
final tripId = tripRaw is num ? tripRaw.toInt() : null; final tripId = tripRaw is num ? tripRaw.toInt() : null;
final units = _distanceUnits(context);
List<String> routeStations = []; List<String> routeStations = [];
RouteResult? restoredRouteResult; RouteResult? restoredRouteResult;
@@ -312,6 +385,21 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
_useManualMileage = useManual; _useManualMileage = useManual;
_selectedDate = beginTime; _selectedDate = beginTime;
_selectedTime = TimeOfDay.fromDateTime(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; _selectedTripId = tripId == null || tripId == 0 ? null : tripId;
_routeResult = restoredRouteResult; _routeResult = restoredRouteResult;
_headcodeController.text = (payload['leg_headcode'] as String? ?? '') _headcodeController.text = (payload['leg_headcode'] as String? ?? '')
@@ -319,6 +407,10 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
_networkController.text = (payload['leg_network'] as String? ?? '') _networkController.text = (payload['leg_network'] as String? ?? '')
.toUpperCase(); .toUpperCase();
_notesController.text = payload['leg_notes'] ?? ''; _notesController.text = payload['leg_notes'] ?? '';
_originController.text = origin;
_destinationController.text = destination;
_beginDelayController.text = beginDelay.toString();
_endDelayController.text = endDelay.toString();
if (useManual) { if (useManual) {
_startController.text = payload['leg_start'] ?? ''; _startController.text = payload['leg_start'] ?? '';
@@ -326,14 +418,20 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
final miles = (payload['leg_distance'] as num?)?.toDouble(); final miles = (payload['leg_distance'] as num?)?.toDouble();
_mileageController.text = miles == null || miles == 0 _mileageController.text = miles == null || miles == 0
? '' ? ''
: miles.toStringAsFixed(2); : units.format(
miles,
decimals: 2,
includeUnit: false,
);
} else { } else {
_startController.text = _startController.text =
routeStations.isNotEmpty ? routeStations.first : ''; routeStations.isNotEmpty ? routeStations.first : '';
_endController.text = _endController.text =
routeStations.isNotEmpty ? routeStations.last : ''; routeStations.isNotEmpty ? routeStations.last : '';
final dist = _routeResult?.distance ?? 0; final dist = _routeResult?.distance ?? 0;
_mileageController.text = dist == 0 ? '' : dist.toStringAsFixed(2); _mileageController.text = dist == 0
? ''
: units.format(dist, decimals: 2, includeUnit: false);
} }
final tractionRaw = data['tractionItems']; final tractionRaw = data['tractionItems'];
@@ -359,6 +457,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
includeTimestamp: false, includeTimestamp: false,
); );
_restoringDraft = false; _restoringDraft = false;
_scheduleMatchUpdate();
} }
Future<void> _loadDraft() async { Future<void> _loadDraft() async {

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -4,11 +4,12 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
Future<bool> _validateRequiredFields() async { Future<bool> _validateRequiredFields() async {
final missing = <String>[]; final missing = <String>[];
final units = _distanceUnits(context);
if (_useManualMileage) { if (_useManualMileage) {
if (_startController.text.trim().isEmpty) missing.add('From'); if (_startController.text.trim().isEmpty) missing.add('From');
if (_endController.text.trim().isEmpty) missing.add('To'); if (_endController.text.trim().isEmpty) missing.add('To');
final mileageText = _mileageController.text.trim(); final mileageText = _mileageController.text.trim();
if (double.tryParse(mileageText) == null) { if (mileageText.isEmpty || units.milesFromInput(mileageText) == null) {
missing.add('Mileage'); missing.add('Mileage');
} }
} else { } else {
@@ -51,6 +52,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
if (form == null) return; if (form == null) return;
if (!form.validate()) return; if (!form.validate()) return;
if (!await _validateRequiredFields()) return; if (!await _validateRequiredFields()) return;
if (!mounted) return;
final routeStations = _routeResult?.calculatedRoute ?? []; final routeStations = _routeResult?.calculatedRoute ?? [];
final startVal = _useManualMileage final startVal = _useManualMileage
? _startController.text.trim() ? _startController.text.trim()
@@ -58,10 +60,17 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final endVal = _useManualMileage final endVal = _useManualMileage
? _endController.text.trim() ? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : ''); : (routeStations.isNotEmpty ? routeStations.last : '');
final units = _distanceUnits(context);
final mileageVal = _useManualMileage final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0 ? (units.milesFromInput(_mileageController.text.trim()) ?? 0)
: (_routeResult?.distance ?? 0); : (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload(); final tractionPayload = _buildTractionPayload();
final endTime = _legEndDateTime;
final originTime = _originDateTime;
final destinationTime = _destinationDateTime;
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final snapshot = _buildSubmissionSnapshot( final snapshot = _buildSubmissionSnapshot(
routeStations: routeStations, routeStations: routeStations,
startVal: startVal, startVal: startVal,
@@ -82,20 +91,32 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final isEditingExisting = _isEditing && widget.editLegId != null; final isEditingExisting = _isEditing && widget.editLegId != null;
try { try {
if (_useManualMileage) { final commonPayload = {
final body = {
if (isEditingExisting) "leg_id": widget.editLegId, if (isEditingExisting) "leg_id": widget.editLegId,
"leg_trip": _selectedTripId, "leg_trip": _selectedTripId,
"leg_start": startVal,
"leg_end": endVal,
"leg_begin_time": _legDateTime.toIso8601String(), "leg_begin_time": _legDateTime.toIso8601String(),
"leg_network": _networkController.text.trim(), if (endTime != null) "leg_end_time": endTime.toIso8601String(),
"leg_distance": mileageVal, if (originTime != null)
"isKilometers": false, "leg_origin_time": originTime.toIso8601String(),
if (destinationTime != null)
"leg_destination_time": destinationTime.toIso8601String(),
"leg_notes": _notesController.text.trim(), "leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.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, "locos": tractionPayload,
}; };
if (_useManualMileage) {
final body = {
...commonPayload,
"leg_start": startVal,
"leg_end": endVal,
"leg_distance": mileageVal,
"isKilometers": false,
};
if (isEditingExisting) { if (isEditingExisting) {
await api.put('/update', body); await api.put('/update', body);
} else { } else {
@@ -103,14 +124,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
} }
} else { } else {
final body = { final body = {
if (isEditingExisting) "leg_id": widget.editLegId, ...commonPayload,
"leg_trip": _selectedTripId,
"leg_begin_time": _legDateTime.toIso8601String(),
"leg_route": routeStations, "leg_route": routeStations,
"leg_notes": _notesController.text.trim(),
"leg_headcode": _headcodeController.text.trim(),
"leg_network": _networkController.text.trim(),
"locos": tractionPayload,
}; };
if (isEditingExisting) { if (isEditingExisting) {
await api.put('/update', body); await api.put('/update', body);
@@ -120,6 +135,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
} }
if (!mounted) return; if (!mounted) return;
dataService.refreshLegs(); dataService.refreshLegs();
await dataService.fetchNotifications();
if (!mounted) return; if (!mounted) return;
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar( SnackBar(
@@ -128,7 +144,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
); );
_lastSubmittedSnapshot = snapshot; _lastSubmittedSnapshot = snapshot;
_activeDraftId = null; _activeDraftId = null;
} catch (e) { } catch (e, st) {
debugPrint('Leg submit/update failed: $e');
debugPrintStack(stackTrace: st);
if (!mounted) return; if (!mounted) return;
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar(content: Text('Failed to submit: $e')), SnackBar(content: Text('Failed to submit: $e')),
@@ -145,18 +163,31 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
required double mileageVal, required double mileageVal,
required List<Map<String, dynamic>> tractionPayload, required List<Map<String, dynamic>> tractionPayload,
}) { }) {
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
return { return {
"legId": widget.editLegId, "legId": widget.editLegId,
"useManualMileage": _useManualMileage, "useManualMileage": _useManualMileage,
"tripId": _selectedTripId, "tripId": _selectedTripId,
"legDateTime": _legDateTime.toIso8601String(), "legDateTime": _legDateTime.toIso8601String(),
"legEndTime": _legEndDateTime?.toIso8601String(),
"hasEndTime": _hasEndTime,
"legOriginTime": _originDateTime?.toIso8601String(),
"hasOriginTime": _hasOriginTime,
"legDestinationTime": _destinationDateTime?.toIso8601String(),
"hasDestinationTime": _hasDestinationTime,
"start": startVal, "start": startVal,
"end": endVal, "end": endVal,
"origin": _originController.text.trim(),
"destination": _destinationController.text.trim(),
"routeStations": routeStations, "routeStations": routeStations,
"mileage": mileageVal, "mileage": mileageVal,
"network": _networkController.text.trim(), "network": _networkController.text.trim(),
"notes": _notesController.text.trim(), "notes": _notesController.text.trim(),
"headcode": _headcodeController.text.trim(), "headcode": _headcodeController.text.trim(),
"beginDelay": beginDelay,
"endDelay": endDelay,
"locos": tractionPayload, "locos": tractionPayload,
"routeResult": _routeResult == null "routeResult": _routeResult == null
? null ? null
@@ -199,11 +230,27 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
_notesController.clear(); _notesController.clear();
_mileageController.clear(); _mileageController.clear();
_networkController.clear(); _networkController.clear();
_originController.clear();
_destinationController.clear();
_beginDelayController.text = '0';
_endDelayController.text = '0';
final now = DateTime.now(); final now = DateTime.now();
_setState(() { _setState(() {
_selectedDate = now; _selectedDate = now;
_selectedTime = TimeOfDay.fromDateTime(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; _useManualMileage = false;
_hasEndTime = false;
_hasOriginTime = false;
_hasDestinationTime = false;
_matchOriginToEntry = false;
_matchDestinationToEntry = false;
_matchUpdateScheduled = false;
_routeResult = null; _routeResult = null;
_tractionItems _tractionItems
..clear() ..clear()

View File

@@ -73,6 +73,8 @@ extension _NewEntryTractionLogic on _NewEntryPageState {
for (var i = 0; i < _tractionItems.length; i++) { for (var i = 0; i < _tractionItems.length; i++) {
final item = _tractionItems[i]; final item = _tractionItems[i];
if (item.isMarker || item.loco == null) continue; if (item.isMarker || item.loco == null) continue;
final locoId = item.loco!.id;
if (locoId == 0) continue;
int allocPos; int allocPos;
if (i > markerIndex) { if (i > markerIndex) {
allocPos = -(i - markerIndex); allocPos = -(i - markerIndex);
@@ -80,8 +82,7 @@ extension _NewEntryTractionLogic on _NewEntryPageState {
allocPos = (markerIndex - 1) - i; allocPos = (markerIndex - 1) - i;
} }
payload.add({ payload.add({
"loco_type": item.loco!.type, "loco_id": locoId,
"loco_number": item.loco!.number,
"alloc_pos": allocPos, "alloc_pos": allocPos,
"alloc_powering": item.powering ? 1 : 0, "alloc_powering": item.powering ? 1 : 0,
}); });

View File

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

View File

@@ -0,0 +1,705 @@
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:provider/provider.dart';
class ProfilePage extends StatefulWidget {
const ProfilePage({super.key});
@override
State<ProfilePage> createState() => _ProfilePageState();
}
class _ProfilePageState extends State<ProfilePage> {
bool _initialised = false;
final Map<String, bool> _groupExpanded = {};
bool _loadingAwards = false;
bool _loadingClassProgress = false;
bool _loadingLocoProgress = false;
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_initialised) return;
_initialised = true;
_refreshAwards();
}
Future<void> _refreshAwards() {
_loadingAwards = false;
_loadingClassProgress = false;
_loadingLocoProgress = false;
final data = context.read<DataService>();
return Future.wait([
data.fetchBadgeAwards(limit: 20, badgeCode: 'class_clearance'),
data.fetchClassClearanceProgress(),
data.fetchLocoClearanceProgress(),
]);
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final awards = data.badgeAwards;
final loading = data.isBadgeAwardsLoading;
final classProgress = data.classClearanceProgress;
final classProgressLoading =
data.isClassClearanceProgressLoading || _loadingClassProgress;
final locoProgress = data.locoClearanceProgress;
final locoProgressLoading =
data.isLocoClearanceProgressLoading || _loadingLocoProgress;
final hasAnyData =
awards.isNotEmpty || classProgress.isNotEmpty || locoProgress.isNotEmpty;
return Scaffold(
appBar: AppBar(
title: const Text('Badges'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
} else {
context.go('/');
}
},
),
),
body: RefreshIndicator(
onRefresh: _refreshAwards,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if ((loading || classProgressLoading || locoProgressLoading) &&
!hasAnyData)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
)
else if (!hasAnyData)
const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: Text('No badges awarded yet.'),
)
else
..._buildGroupedAwards(
context,
awards,
classProgress,
locoProgress,
classProgressLoading,
locoProgressLoading,
data.classClearanceHasMore,
data.locoClearanceHasMore,
data.badgeAwardsHasMore,
loading,
),
],
),
),
);
}
List<Widget> _buildGroupedAwards(
BuildContext context,
List<BadgeAward> awards,
List<ClassClearanceProgress> classProgress,
List<LocoClearanceProgress> locoProgress,
bool classProgressLoading,
bool locoProgressLoading,
bool classProgressHasMore,
bool locoProgressHasMore,
bool badgeAwardsHasMore,
bool badgeAwardsLoading,
) {
final grouped = _groupAwards(awards);
if ((classProgress.isNotEmpty || classProgressLoading) &&
!grouped.containsKey('class_clearance')) {
grouped['class_clearance'] = [];
}
if ((locoProgress.isNotEmpty || locoProgressLoading) &&
!grouped.containsKey('loco_clearance')) {
grouped['loco_clearance'] = [];
}
final codes = _orderedBadgeCodes(grouped.keys.toList());
return codes.map((code) {
final items = grouped[code]!;
final expanded = _groupExpanded[code] ?? true;
final title = _formatBadgeName(code);
final isClass = code == 'class_clearance';
final isLoco = code == 'loco_clearance';
final classItems = isClass ? classProgress : <ClassClearanceProgress>[];
final locoItems = isLoco ? locoProgress : <LocoClearanceProgress>[];
final awardCount = isLoco
? locoItems.where((item) => item.awardedTiers.isNotEmpty).length
: items.length;
final isLoadingSection = isClass
? (classProgressLoading || badgeAwardsLoading || _loadingAwards)
: (isLoco ? locoProgressLoading : false);
final children = <Widget>[];
if (isClass && items.isNotEmpty) {
children.add(_buildSubheading(context, 'Awarded'));
children.addAll(
items.map(
(award) => Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: _buildAwardCard(context, award, compact: true),
),
),
);
if (badgeAwardsHasMore || badgeAwardsLoading || _loadingAwards) {
children.add(
Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
child: _buildLoadMoreButton(
context,
badgeAwardsLoading || _loadingAwards,
() => _loadMoreAwards(),
),
),
);
}
} else if (!isClass && !isLoco && items.isNotEmpty) {
children.add(_buildSubheading(context, 'Awarded'));
children.addAll(
items.map(
(award) => Padding(
padding:
const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4.0),
child: _buildAwardCard(context, award, compact: true),
),
),
);
}
if (isClass) {
children.addAll(
_buildClassProgressSection(
context,
classItems,
classProgressLoading,
classProgressHasMore,
),
);
}
if (isLoco) {
children.addAll(
_buildLocoProgressSection(
context,
locoItems,
locoProgressLoading,
locoProgressHasMore,
showHeading: false,
),
);
}
if (children.isEmpty && !isLoadingSection) {
children.add(
const Padding(
padding: EdgeInsets.symmetric(vertical: 6.0),
child: Text('No awards'),
),
);
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: ExpansionTile(
key: ValueKey(code),
tilePadding: const EdgeInsets.symmetric(horizontal: 12.0),
title: Row(
children: [
Expanded(child: Text(title)),
if (isLoadingSection) ...[
const SizedBox(width: 8),
const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
const SizedBox(width: 8),
_buildCountChip(context, awardCount),
],
),
initiallyExpanded: expanded,
onExpansionChanged: (isOpen) {
setState(() => _groupExpanded[code] = isOpen);
},
children: children,
),
);
}).toList();
}
Map<String, List<BadgeAward>> _groupAwards(List<BadgeAward> awards) {
final Map<String, List<BadgeAward>> grouped = {};
for (final award in awards) {
final code = award.badgeCode.toLowerCase();
grouped.putIfAbsent(code, () => []).add(award);
}
return grouped;
}
Widget _buildAwardCard(
BuildContext context,
BadgeAward award, {
bool compact = false,
}) {
final badgeName = _formatBadgeName(award.badgeCode);
final tier = award.badgeTier.isNotEmpty
? award.badgeTier[0].toUpperCase() + award.badgeTier.substring(1)
: '';
final tierIcon = _buildTierIcon(award.badgeTier);
final scope = _scopeToShow(award);
final content = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
if (tierIcon != null) ...[
tierIcon,
const SizedBox(width: 8),
],
Expanded(
child: Text(
'$badgeName$tier',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (award.awardedAt != null)
Text(
_formatAwardDate(award.awardedAt!),
style: Theme.of(context).textTheme.bodySmall,
),
],
),
if (scope != null && scope.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
scope,
style: Theme.of(context).textTheme.bodyMedium,
),
],
if (award.loco != null) ...[
const SizedBox(height: 6),
_buildLocoInfo(context, award.loco!),
],
],
);
if (compact) {
return content;
}
return Card(
child: Padding(
padding: const EdgeInsets.all(10.0),
child: content,
),
);
}
Widget _buildLocoInfo(BuildContext context, LocoSummary loco) {
final lines = <String>[];
final classNum = [
if (loco.locoClass.isNotEmpty) loco.locoClass,
if (loco.number.isNotEmpty) loco.number,
].join(' ');
if (classNum.isNotEmpty) lines.add(classNum);
if ((loco.name ?? '').isNotEmpty) lines.add(loco.name!);
if ((loco.livery ?? '').isNotEmpty) lines.add(loco.livery!);
if ((loco.location ?? '').isNotEmpty) lines.add(loco.location!);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.train, size: 20),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: lines.map((line) {
return Text(
line,
style: Theme.of(context).textTheme.bodyMedium,
);
}).toList(),
),
),
],
);
}
String _formatBadgeName(String code) {
if (code.isEmpty) return 'Badge';
const known = {
'class_clearance': 'Class Clearance',
'loco_clearance': 'Loco Clearance',
};
final lower = code.toLowerCase();
if (known.containsKey(lower)) return known[lower]!;
final parts = code.split(RegExp(r'[_\\s]+')).where((p) => p.isNotEmpty);
return parts
.map((p) => p[0].toUpperCase() + p.substring(1).toLowerCase())
.join(' ');
}
List<String> _orderedBadgeCodes(List<String> codes) {
final lowerCodes = codes.map((c) => c.toLowerCase()).toSet();
final ordered = <String>[];
for (final code in ['loco_clearance', 'class_clearance']) {
if (lowerCodes.remove(code)) ordered.add(code);
}
final remaining = lowerCodes.toList()
..sort((a, b) => _formatBadgeName(a).compareTo(_formatBadgeName(b)));
ordered.addAll(remaining);
return ordered;
}
Widget _buildSubheading(BuildContext context, String label) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 4),
child: Text(
label,
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(fontWeight: FontWeight.w700),
),
);
}
List<Widget> _buildClassProgressSection(
BuildContext context,
List<ClassClearanceProgress> progress,
bool isLoading,
bool hasMore,
) {
if (progress.isEmpty && !isLoading && !hasMore) return const [];
return [
_buildSubheading(context, 'In Progress'),
...progress.map(
(item) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 6.0),
child: _buildClassProgressCard(context, item),
),
),
if (hasMore || isLoading)
Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
child: _buildLoadMoreButton(
context,
isLoading,
() => _loadMoreClassProgress(),
),
),
if (progress.isNotEmpty) const SizedBox(height: 4),
];
}
List<Widget> _buildLocoProgressSection(
BuildContext context,
List<LocoClearanceProgress> progress,
bool isLoading,
bool hasMore,
{bool showHeading = true}
) {
if (progress.isEmpty && !isLoading && !hasMore) return const [];
return [
if (showHeading) _buildSubheading(context, 'In Progress'),
if (progress.isEmpty && isLoading)
Padding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
child: _buildLoadingIndicator(),
),
...progress.map(
(item) => Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 6.0),
child: _buildLocoProgressCard(context, item),
),
),
if (hasMore || isLoading)
Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 8.0),
child: _buildLoadMoreButton(
context,
isLoading,
() => _loadMoreLocoProgress(),
),
),
if (progress.isNotEmpty) const SizedBox(height: 4),
];
}
Widget _buildClassProgressCard(
BuildContext context,
ClassClearanceProgress progress,
) {
final pct = progress.percentComplete.clamp(0, 100);
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
progress.className,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Text(
'${pct.toStringAsFixed(0)}%',
style: Theme.of(context).textTheme.labelMedium,
),
],
),
const SizedBox(height: 4),
LinearProgressIndicator(
value: progress.total == 0 ? 0 : pct / 100,
minHeight: 6,
),
if (progress.total > 0)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
'${progress.completed}/${progress.total}',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(color: Theme.of(context).hintColor),
),
),
],
),
),
);
}
Widget _buildLocoProgressCard(
BuildContext context,
LocoClearanceProgress progress,
) {
final tierIcons = progress.awardedTiers
.map((tier) => _buildTierIcon(tier, size: 18))
.whereType<Widget>()
.toList();
final reachedTopTier = progress.nextTier.isEmpty;
final pct = progress.percent.clamp(0, 100);
final nextTier = progress.nextTier.isNotEmpty
? progress.nextTier[0].toUpperCase() + progress.nextTier.substring(1)
: 'Next';
final loco = progress.loco;
final title = [
if (loco.number.isNotEmpty) loco.number,
if (loco.locoClass.isNotEmpty) loco.locoClass,
].join('');
return Card(
margin: const EdgeInsets.symmetric(vertical: 4.0),
child: Padding(
padding: const EdgeInsets.all(10.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title.isNotEmpty ? title : 'Loco',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
if (tierIcons.isNotEmpty)
Row(
children: tierIcons
.expand((icon) sync* {
yield icon;
yield const SizedBox(width: 4);
})
.toList()
..removeLast(),
),
],
),
if ((loco.name ?? '').isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
loco.name ?? '',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (!reachedTopTier) ...[
const SizedBox(height: 4),
LinearProgressIndicator(
value: progress.required == 0 ? 0 : pct / 100,
minHeight: 6,
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
'${pct.toStringAsFixed(0)}% to $nextTier award',
style: Theme.of(context).textTheme.bodyMedium,
),
),
],
],
),
),
);
}
Widget _buildLoadMoreButton(
BuildContext context,
bool isLoading,
Future<void> Function() onPressed,
) {
return Align(
alignment: Alignment.center,
child: OutlinedButton.icon(
onPressed: isLoading
? null
: () {
onPressed();
},
icon: isLoading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(isLoading ? 'Loading...' : 'Load more'),
),
);
}
Widget _buildLoadingIndicator() {
return const Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
Widget _buildCountChip(BuildContext context, int count) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'$count',
style: Theme.of(context)
.textTheme
.labelMedium
?.copyWith(fontWeight: FontWeight.w700),
),
);
}
Future<void> _loadMoreClassProgress() {
final data = context.read<DataService>();
if (data.isClassClearanceProgressLoading || _loadingClassProgress) {
return Future.value();
}
setState(() => _loadingClassProgress = true);
return data
.fetchClassClearanceProgress(
offset: data.classClearanceProgress.length,
append: true,
)
.whenComplete(() {
if (mounted) setState(() => _loadingClassProgress = false);
});
}
Future<void> _loadMoreLocoProgress() {
final data = context.read<DataService>();
if (data.isLocoClearanceProgressLoading || _loadingLocoProgress) {
return Future.value();
}
setState(() => _loadingLocoProgress = true);
return data
.fetchLocoClearanceProgress(
offset: data.locoClearanceProgress.length,
append: true,
)
.whenComplete(() {
if (mounted) setState(() => _loadingLocoProgress = false);
});
}
Future<void> _loadMoreAwards() {
final data = context.read<DataService>();
if (data.isBadgeAwardsLoading || _loadingAwards) return Future.value();
setState(() => _loadingAwards = true);
return data
.fetchBadgeAwards(
offset: data.badgeAwards.length,
append: true,
badgeCode: 'class_clearance',
limit: 20,
)
.whenComplete(() {
if (mounted) setState(() => _loadingAwards = false);
});
}
String _formatAwardDate(DateTime date) {
final y = date.year.toString().padLeft(4, '0');
final m = date.month.toString().padLeft(2, '0');
final d = date.day.toString().padLeft(2, '0');
return '$y-$m-$d';
}
Widget? _buildTierIcon(String tier, {double size = 24}) {
final lower = tier.toLowerCase();
Color? color;
switch (lower) {
case 'bronze':
color = const Color(0xFFCD7F32);
break;
case 'silver':
color = const Color(0xFFC0C0C0);
break;
case 'gold':
color = const Color(0xFFFFD700);
break;
}
if (color == null) return null;
return Icon(Icons.emoji_events, color: color, size: size);
}
String? _scopeToShow(BadgeAward award) {
final scope = award.scopeValue?.trim() ?? '';
if (scope.isEmpty) return null;
final code = award.badgeCode.toLowerCase();
if (code == 'loco_clearance') {
// Hide numeric loco IDs; loco details are shown separately.
if (int.tryParse(scope) != null) return null;
}
return scope;
}
}

View File

@@ -0,0 +1,386 @@
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';
class SettingsPage extends StatefulWidget {
const SettingsPage({super.key});
@override
State<SettingsPage> createState() => _SettingsPageState();
}
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();
}
Future<String?> _probeVersion(String url) async {
try {
var uri = Uri.parse(url.trim());
if (uri.scheme.isEmpty) {
uri = Uri.parse('https://$url');
}
// Probe the provided API endpoint as-is.
final target = uri;
final res = await http.get(target).timeout(const Duration(seconds: 10));
debugPrint(
'Endpoint probe ${target.toString()} -> ${res.statusCode} ${res.body}',
);
if (res.statusCode < 200 || res.statusCode >= 300) return null;
final body = res.body.trim();
debugPrint('Endpoint probe body: $body');
// Try JSON first
String? version;
try {
final parsed = jsonDecode(body);
debugPrint('Endpoint probe parsed: $parsed');
if (parsed is Map && parsed['version'] is String) {
version = parsed['version'] as String;
} else if (parsed is String) {
final candidate = parsed.trim().replaceAll('"', '');
if (RegExp(r'^\d+\.\d+\.\d+$').hasMatch(candidate)) {
version = candidate;
}
}
} catch (_) {
// fall back to raw body parsing
}
version ??= body.split(RegExp(r'\s+')).firstWhere(
(part) => RegExp(r'^\d+\.\d+\.\d+$').hasMatch(part),
orElse: () => '',
);
if (version.isEmpty) return null;
final isValid = RegExp(r'^\d+\.\d+\.\d+$').hasMatch(version);
return isValid ? version : null;
} catch (_) {
return null;
}
}
Future<void> _save() async {
final endpointService = context.read<EndpointService>();
final dataService = context.read<DataService>();
final messenger = ScaffoldMessenger.of(context);
final value = _endpointController.text.trim();
if (value.isEmpty) {
messenger.showSnackBar(
const SnackBar(content: Text('Please enter an endpoint URL.')),
);
return;
}
setState(() => _saving = true);
try {
final version = await _probeVersion(value);
if (version == null) {
if (mounted) {
messenger.showSnackBar(
const SnackBar(
content: Text('Endpoint test failed: no valid version returned.'),
),
);
}
return;
}
await endpointService.setBaseUrl(value);
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Endpoint set to "$value" ($version)')),
);
await Future.wait([
dataService.fetchHomepageStats(),
dataService.fetchOnThisDay(),
dataService.fetchTrips(),
dataService.fetchHadTraction(),
dataService.fetchLatestLocoChanges(),
dataService.fetchLegs(),
]);
}
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to save endpoint: $e')),
);
}
} finally {
if (mounted) {
setState(() => _saving = false);
}
}
}
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>();
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()),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('Settings'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.pop();
} else {
context.go('/');
}
},
),
),
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(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Set the base URL for the Mileograph API. Leave blank to use the default.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 16),
TextField(
controller: _endpointController,
decoration: const InputDecoration(
labelText: 'Endpoint URL',
hintText: 'https://mileograph.co.uk/api/v1',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
Row(
children: [
FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.save),
label: const Text('Save endpoint'),
),
const SizedBox(width: 12),
TextButton(
onPressed: _saving
? null
: () {
_endpointController.text =
EndpointService.defaultBaseUrl;
},
child: const Text('Reset to default'),
),
],
),
const SizedBox(height: 12),
Text(
'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

@@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -5,6 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart'; import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -26,10 +26,20 @@ class _TractionPageState extends State<TractionPage> {
bool _showAdvancedFilters = false; bool _showAdvancedFilters = false;
String? _selectedClass; String? _selectedClass;
late Set<String> _selectedKeys; late Set<String> _selectedKeys;
String? _lastEventFieldsSignature;
Timer? _classStatsDebounce;
bool _showClassStatsPanel = false;
bool _classStatsLoading = false;
String? _classStatsError;
String? _classStatsForClass;
Map<String, dynamic>? _classStats;
final Map<String, TextEditingController> _dynamicControllers = {}; final Map<String, TextEditingController> _dynamicControllers = {};
final Map<String, String?> _enumSelections = {}; final Map<String, String?> _enumSelections = {};
bool _restoredFromPrefs = false; bool _restoredFromPrefs = false;
static const int _pageSize = 100;
int _lastTractionOffset = 0;
String? _lastQuerySignature;
@override @override
void initState() { void initState() {
@@ -52,6 +62,9 @@ class _TractionPageState extends State<TractionPage> {
Future<void> _initialLoad() async { Future<void> _initialLoad() async {
final data = context.read<DataService>(); final data = context.read<DataService>();
await _restoreSearchState(); await _restoreSearchState();
if (_lastTractionOffset == 0 && data.traction.length > _pageSize) {
_lastTractionOffset = data.traction.length - _pageSize;
}
data.fetchClassList(); data.fetchClassList();
data.fetchEventFields(); data.fetchEventFields();
await _refreshTraction(); await _refreshTraction();
@@ -68,6 +81,7 @@ class _TractionPageState extends State<TractionPage> {
for (final controller in _dynamicControllers.values) { for (final controller in _dynamicControllers.values) {
controller.dispose(); controller.dispose();
} }
_classStatsDebounce?.cancel();
super.dispose(); super.dispose();
} }
@@ -95,7 +109,29 @@ class _TractionPageState extends State<TractionPage> {
dynamicFieldsUsed; dynamicFieldsUsed;
} }
Future<void> _refreshTraction({bool append = false}) async { String _tractionQuerySignature(
Map<String, dynamic> filters,
bool hadOnly,
) {
final sortedKeys = filters.keys.toList()..sort();
final filterSignature = sortedKeys
.map((key) => '$key=${filters[key]}')
.join('|');
final classQuery = (_selectedClass ?? _classController.text).trim();
return [
'class=$classQuery',
'number=${_numberController.text.trim()}',
'name=${_nameController.text.trim()}',
'mileageFirst=$_mileageFirst',
'hadOnly=$hadOnly',
'filters=$filterSignature',
].join(';');
}
Future<void> _refreshTraction({
bool append = false,
bool preservePosition = true,
}) async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final filters = <String, dynamic>{}; final filters = <String, dynamic>{};
final name = _nameController.text.trim(); final name = _nameController.text.trim();
@@ -110,15 +146,49 @@ class _TractionPageState extends State<TractionPage> {
} }
}); });
final hadOnly = !_hasFilters; final hadOnly = !_hasFilters;
final signature = _tractionQuerySignature(filters, hadOnly);
final queryChanged =
_lastQuerySignature != null && signature != _lastQuerySignature;
_lastQuerySignature = signature;
if (queryChanged && !append) {
_lastTractionOffset = 0;
}
final shouldPreservePosition = preservePosition &&
!append &&
!queryChanged &&
_lastTractionOffset > 0;
int limit;
int offset;
if (append) {
offset = data.traction.length;
limit = _pageSize;
_lastTractionOffset = offset;
} else if (shouldPreservePosition) {
offset = 0;
limit = _pageSize + _lastTractionOffset;
} else {
offset = 0;
limit = _pageSize;
}
await data.fetchTraction( await data.fetchTraction(
hadOnly: hadOnly, hadOnly: hadOnly,
locoClass: _selectedClass ?? _classController.text.trim(), locoClass: _selectedClass ?? _classController.text.trim(),
locoNumber: _numberController.text.trim(), locoNumber: _numberController.text.trim(),
offset: append ? data.traction.length : 0, offset: offset,
limit: limit,
append: append, append: append,
filters: filters, filters: filters,
mileageFirst: _mileageFirst, mileageFirst: _mileageFirst,
); );
if (!append && !shouldPreservePosition) {
_lastTractionOffset = 0;
}
await _persistSearchState(); await _persistSearchState();
} }
@@ -137,6 +207,10 @@ class _TractionPageState extends State<TractionPage> {
setState(() { setState(() {
_selectedClass = null; _selectedClass = null;
_mileageFirst = true; _mileageFirst = true;
_showClassStatsPanel = false;
_classStats = null;
_classStatsError = null;
_classStatsForClass = null;
}); });
_refreshTraction(); _refreshTraction();
} }
@@ -148,6 +222,7 @@ class _TractionPageState extends State<TractionPage> {
_selectedClass = null; _selectedClass = null;
}); });
} }
_refreshClassStatsIfOpen();
} }
List<EventField> _activeEventFields(List<EventField> fields) { List<EventField> _activeEventFields(List<EventField> fields) {
@@ -164,6 +239,26 @@ class _TractionPageState extends State<TractionPage> {
.toList(); .toList();
} }
void _syncControllersForFields(List<EventField> fields) {
final signature = _eventFieldsSignature(fields);
if (signature == _lastEventFieldsSignature) return;
_lastEventFieldsSignature = signature;
_ensureControllersForFields(fields);
}
String _eventFieldsSignature(List<EventField> fields) {
final active = _activeEventFields(fields);
return active
.map(
(field) => [
field.name,
field.type ?? '',
if (field.enumValues != null) field.enumValues!.join('|'),
].join('::'),
)
.join(';');
}
void _ensureControllersForFields(List<EventField> fields) { void _ensureControllersForFields(List<EventField> fields) {
for (final field in fields) { for (final field in fields) {
if (field.enumValues != null) { if (field.enumValues != null) {
@@ -183,15 +278,15 @@ class _TractionPageState extends State<TractionPage> {
final traction = data.traction; final traction = data.traction;
final classOptions = data.locoClasses; final classOptions = data.locoClasses;
final isMobile = MediaQuery.of(context).size.width < 700; final isMobile = MediaQuery.of(context).size.width < 700;
_ensureControllersForFields(data.eventFields); _syncControllersForFields(data.eventFields);
final extraFields = _activeEventFields(data.eventFields); final extraFields = _activeEventFields(data.eventFields);
final listView = RefreshIndicator( final slivers = <Widget>[
onRefresh: _refreshTraction, SliverPadding(
child: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
padding: const EdgeInsets.all(16), sliver: SliverList(
physics: const AlwaysScrollableScrollPhysics(), delegate: SliverChildListDelegate(
children: [ [
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -211,35 +306,7 @@ class _TractionPageState extends State<TractionPage> {
], ],
), ),
), ),
Row( _buildHeaderActions(context, isMobile),
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Refresh',
onPressed: _refreshTraction,
icon: const Icon(Icons.refresh),
),
const SizedBox(width: 8),
FilledButton.icon(
onPressed: () async {
final createdClass = await context.push<String>(
'/traction/new',
);
if (createdClass != null && createdClass.isNotEmpty) {
_classController.text = createdClass;
_selectedClass = createdClass;
if (mounted) {
_refreshTraction();
}
} else if (mounted && createdClass == '') {
_refreshTraction();
}
},
icon: const Icon(Icons.add),
label: const Text('New Traction'),
),
],
),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
@@ -337,6 +404,7 @@ class _TractionPageState extends State<TractionPage> {
_classController.text = selection; _classController.text = selection;
}); });
_refreshTraction(); _refreshTraction();
_refreshClassStatsIfOpen(immediate: true);
}, },
), ),
), ),
@@ -433,68 +501,40 @@ class _TractionPageState extends State<TractionPage> {
), ),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Stack(
children: [
if (data.isTractionLoading && traction.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(child: CircularProgressIndicator()),
)
else if (traction.isEmpty)
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No traction found',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text('Try relaxing the filters or sync again.'),
], ],
), ),
), ),
) ),
else SliverPadding(
Column( padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
sliver: SliverToBoxAdapter(
child: AnimatedCrossFade(
crossFadeState: (_showClassStatsPanel && _hasClassQuery)
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
firstChild: _buildClassStatsCard(context),
secondChild: const SizedBox.shrink(),
),
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
sliver: _buildTractionSliver(context, data, traction),
),
];
final scrollView = RefreshIndicator(
onRefresh: _refreshTraction,
child: CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: slivers,
),
);
final content = Stack(
children: [ children: [
...traction.map( scrollView,
(loco) => TractionCard(
loco: loco,
selectionMode: widget.selectionMode,
isSelected: _isSelected(loco),
onShowInfo: () => showTractionDetails(context, loco),
onOpenTimeline: () => _openTimeline(loco),
onOpenLegs: () => _openLegs(loco),
onToggleSelect:
widget.selectionMode ? () => _toggleSelection(loco) : null,
),
),
if (data.tractionHasMore || data.isTractionLoading)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon(
onPressed: data.isTractionLoading
? null
: () => _refreshTraction(append: true),
icon: data.isTractionLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(
data.isTractionLoading ? 'Loading...' : 'Load more',
),
),
),
],
),
if (data.isTractionLoading) if (data.isTractionLoading)
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
@@ -505,37 +545,426 @@ class _TractionPageState extends State<TractionPage> {
), ),
), ),
], ],
),
],
),
); );
if (widget.selectionMode) { if (widget.selectionMode) {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leadingWidth: 140, leadingWidth: 56,
leading: Padding( leading: IconButton(
padding: const EdgeInsets.only(left: 8.0),
child: TextButton.icon(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.arrow_back), icon: const Icon(Icons.arrow_back),
label: const Text('Back'), onPressed: () => Navigator.of(context).pop(),
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
foregroundColor: Theme.of(context).colorScheme.onSurface,
),
),
), ),
title: null, title: null,
), ),
body: listView, body: content,
); );
} }
return listView; return content;
}
bool get _hasClassQuery {
return (_selectedClass ?? _classController.text).trim().isNotEmpty;
}
Widget _buildHeaderActions(BuildContext context, bool isMobile) {
final refreshButton = IconButton(
tooltip: 'Refresh',
onPressed: _refreshTraction,
icon: const Icon(Icons.refresh),
);
final classStatsButton = !_hasClassQuery
? null
: FilledButton.tonalIcon(
onPressed: _toggleClassStatsPanel,
icon: Icon(
_showClassStatsPanel ? Icons.bar_chart : Icons.insights,
),
label: Text(
_showClassStatsPanel ? 'Hide class stats' : 'Class stats',
),
);
final newTractionButton = FilledButton.icon(
onPressed: () async {
final createdClass = await context.push<String>(
'/traction/new',
);
if (!mounted) return;
if (createdClass != null && createdClass.isNotEmpty) {
_classController.text = createdClass;
_selectedClass = createdClass;
_refreshTraction();
} else if (createdClass == '') {
_refreshTraction();
}
},
icon: const Icon(Icons.add),
label: const Text('New Traction'),
);
final desktopActions = [
refreshButton,
if (classStatsButton != null) classStatsButton,
newTractionButton,
];
final mobileActions = [
newTractionButton,
if (classStatsButton != null) classStatsButton,
refreshButton,
];
if (isMobile) {
return Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var i = 0; i < mobileActions.length; i++) ...[
if (i > 0) const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: mobileActions[i],
),
],
],
);
}
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: desktopActions,
);
}
Future<void> _toggleClassStatsPanel() async {
if (!_hasClassQuery) return;
final targetState = !_showClassStatsPanel;
setState(() {
_showClassStatsPanel = targetState;
});
if (targetState) {
await _loadClassStats();
}
}
void _refreshClassStatsIfOpen({bool immediate = false}) {
if (!_showClassStatsPanel || !_hasClassQuery) return;
final query = (_selectedClass ?? _classController.text).trim();
if (!immediate && _classStatsForClass == query && _classStats != null) {
return;
}
_classStatsDebounce?.cancel();
if (immediate) {
_loadClassStats();
return;
}
_classStatsDebounce = Timer(
const Duration(milliseconds: 400),
() {
if (mounted) _loadClassStats();
},
);
}
Future<void> _loadClassStats() async {
final query = (_selectedClass ?? _classController.text).trim();
if (query.isEmpty) return;
if (_classStatsForClass == query && _classStats != null) return;
setState(() {
_classStatsLoading = true;
_classStatsError = null;
});
try {
final data = context.read<DataService>();
final stats = await data.fetchClassStats(query);
if (!mounted) return;
setState(() {
_classStatsForClass = query;
_classStats = stats;
_classStatsError = stats == null ? 'No stats returned.' : null;
});
} catch (e) {
if (!mounted) return;
setState(() {
_classStatsError = 'Failed to load stats: $e';
});
} finally {
if (mounted) {
setState(() => _classStatsLoading = false);
}
}
}
Widget _buildClassStatsCard(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final distanceUnits = context.watch<DistanceUnitService>();
if (_classStatsLoading) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
children: const [
SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Loading class stats...'),
],
),
),
);
}
if (_classStatsError != null) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
_classStatsError!,
style: TextStyle(color: scheme.error),
),
),
);
}
final stats = _classStats;
if (stats == null) {
return const SizedBox.shrink();
}
final totalMileage =
(stats['total_mileage_with_class'] as num?)?.toDouble() ?? 0.0;
final avgMileagePerEntry =
(stats['avg_mileage_per_entry'] as num?)?.toDouble() ?? 0.0;
final avgMileagePerLoco =
(stats['avg_mileage_per_loco_had'] as num?)?.toDouble() ?? 0.0;
final hadCount = stats['had_count']?.toString() ?? '0';
final entriesWithClass = stats['entries_with_class']?.toString() ?? '0';
final classStats = stats['class_stats'] is Map
? Map<String, dynamic>.from(stats['class_stats'])
: const <String, dynamic>{};
final totalCount = (classStats['total'] as num?)?.toInt() ??
_sumCounts(classStats['status']) ??
0;
final statusList = _normalizeStatList(classStats['status'], 'status');
final domainList = _normalizeStatList(classStats['domain'], 'domain');
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
stats['loco_class']?.toString() ?? 'Class stats',
style: Theme.of(context).textTheme.titleMedium,
),
),
TextButton.icon(
onPressed: _loadClassStats,
icon: const Icon(Icons.refresh),
label: const Text('Refresh'),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 8,
children: [
_metricTile('Had', hadCount),
_metricTile('Entries', entriesWithClass),
_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),
if (statusList.isNotEmpty)
_statBar(
context,
title: 'By status',
items: statusList,
total: totalCount,
colorFor: (label) => _statusColor(label, scheme),
),
if (domainList.isNotEmpty) ...[
const SizedBox(height: 10),
_statBar(
context,
title: 'By domain',
items: domainList,
total: totalCount,
colorFor: (label) => _domainColor(label, scheme),
),
],
],
),
),
);
}
Widget _metricTile(String label, String value) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: const TextStyle(fontSize: 12)),
const SizedBox(height: 4),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.w700),
),
],
),
);
}
Widget _statBar(
BuildContext context, {
required String title,
required List<Map<String, dynamic>> items,
required int total,
required Color Function(String) colorFor,
}) {
if (total <= 0) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: Theme.of(context).textTheme.labelMedium),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Row(
children: items.map((item) {
final label = item['label']?.toString() ?? '';
final count = (item['count'] as num?)?.toInt() ?? 0;
final pct = total == 0 ? 0.0 : (count / total) * 100;
final flex = count == 0 ? 1 : (count * 1000 / total).round();
return Expanded(
flex: flex,
child: Tooltip(
message:
'$label: $count (${pct.isNaN ? 0 : pct.toStringAsFixed(1)}%)',
child: Container(
height: 16,
color: colorFor(label),
),
),
);
}).toList(),
),
),
const SizedBox(height: 6),
Wrap(
spacing: 12,
runSpacing: 6,
children: items.map((item) {
final label = item['label']?.toString() ?? '';
final count = (item['count'] as num?)?.toInt() ?? 0;
final pct = total == 0 ? 0.0 : (count / total) * 100;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 10,
height: 10,
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration(
color: colorFor(label),
borderRadius: BorderRadius.circular(2),
),
),
Text('$label (${pct.isNaN ? 0 : pct.toStringAsFixed(1)}%, $count)'),
],
);
}).toList(),
),
],
);
}
List<Map<String, dynamic>> _normalizeStatList(dynamic list, String labelKey) {
if (list is! List) return const [];
return list
.whereType<Map>()
.map((item) => {
'label': item[labelKey]?.toString() ?? '',
'count': (item['count'] as num?)?.toInt() ?? 0,
})
.where((item) => (item['label'] ?? '').toString().isNotEmpty)
.toList();
}
int? _sumCounts(dynamic list) {
if (list is! List) return null;
int total = 0;
for (final item in list) {
final count = (item is Map ? item['count'] : null) as num?;
if (count != null) total += count.toInt();
}
return total;
}
Color _statusColor(String status, ColorScheme scheme) {
final key = status.toLowerCase();
if (key.contains('scrap')) return Colors.red.shade600;
if (key.contains('active')) return scheme.primary;
if (key.contains('overhaul')) return Colors.blueGrey;
if (key.contains('withdrawn')) return Colors.amber.shade700;
if (key.contains('stored')) return Colors.grey.shade600;
return scheme.tertiary;
}
Color _domainColor(String domain, ColorScheme scheme) {
final palette = [
scheme.primary,
scheme.secondary,
scheme.tertiary,
Colors.teal,
Colors.indigo,
Colors.orange,
Colors.pink,
Colors.brown,
];
if (domain.isEmpty) return scheme.surfaceContainerHighest;
final index = domain.hashCode.abs() % palette.length;
return palette[index];
} }
void _toggleSelection(LocoSummary loco) { void _toggleSelection(LocoSummary loco) {
@@ -638,4 +1067,84 @@ class _TractionPageState extends State<TractionPage> {
), ),
); );
} }
Widget _buildTractionSliver(
BuildContext context,
DataService data,
List<LocoSummary> traction,
) {
if (data.isTractionLoading && traction.isEmpty) {
return const SliverToBoxAdapter(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 32.0),
child: Center(child: CircularProgressIndicator()),
),
);
}
if (traction.isEmpty) {
return SliverToBoxAdapter(
child: Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'No traction found',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text('Try relaxing the filters or sync again.'),
],
),
),
),
);
}
final itemCount =
traction.length + ((data.tractionHasMore || data.isTractionLoading) ? 1 : 0);
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
if (index < traction.length) {
final loco = traction[index];
return TractionCard(
loco: loco,
selectionMode: widget.selectionMode,
isSelected: _isSelected(loco),
onShowInfo: () => showTractionDetails(context, loco),
onOpenTimeline: () => _openTimeline(loco),
onOpenLegs: () => _openLegs(loco),
onToggleSelect:
widget.selectionMode ? () => _toggleSelection(loco) : null,
);
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: OutlinedButton.icon(
onPressed:
data.isTractionLoading ? null : () => _refreshTraction(append: true),
icon: data.isTractionLoading
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.expand_more),
label: Text(
data.isTractionLoading ? 'Loading...' : 'Load more',
),
),
);
},
childCount: itemCount,
),
);
}
} }

View File

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class TripsPage extends StatefulWidget { class TripsPage extends StatefulWidget {
@@ -12,6 +13,7 @@ class TripsPage extends StatefulWidget {
class _TripsPageState extends State<TripsPage> { class _TripsPageState extends State<TripsPage> {
bool _initialised = false; bool _initialised = false;
final Map<int, Future<List<TripLocoStat>>> _tripLocoStatsFutures = {};
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -23,7 +25,9 @@ class _TripsPageState extends State<TripsPage> {
} }
Future<void> _refreshTrips() async { Future<void> _refreshTrips() async {
await context.read<DataService>().fetchTripDetails(); _tripLocoStatsFutures.clear();
final data = context.read<DataService>();
await data.fetchTripDetails();
} }
Future<void> _renameTrip(TripDetail trip, String newName) async { Future<void> _renameTrip(TripDetail trip, String newName) async {
@@ -35,10 +39,7 @@ class _TripsPageState extends State<TripsPage> {
"trip_id": trip.id, "trip_id": trip.id,
"trip_name": newName, "trip_name": newName,
}); });
await Future.wait([ await data.fetchTripDetails();
data.fetchTripDetails(),
data.fetchTrips(),
]);
} catch (e) { } catch (e) {
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar(content: Text('Failed to rename trip: $e')), SnackBar(content: Text('Failed to rename trip: $e')),
@@ -47,6 +48,27 @@ class _TripsPageState extends State<TripsPage> {
} }
} }
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(
trip.id,
() => context.read<DataService>().fetchTripLocoStats(trip.id),
);
}
Future<String?> _promptTripName(BuildContext context, String initial) async { Future<String?> _promptTripName(BuildContext context, String initial) async {
final controller = TextEditingController(text: initial); final controller = TextEditingController(text: initial);
final newName = await showDialog<String>( final newName = await showDialog<String>(
@@ -78,9 +100,12 @@ class _TripsPageState extends State<TripsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final tripDetails = data.tripDetails; final tripDetails = data.tripDetails;
final tripSummaries = data.trips; final tripSummaries = data.tripList;
final isMobile = MediaQuery.of(context).size.width < 700; final summaryById = {
for (final summary in tripSummaries) summary.tripId: summary,
};
final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty; final showLoading = data.isTripDetailsLoading && tripDetails.isEmpty;
return RefreshIndicator( return RefreshIndicator(
@@ -165,91 +190,116 @@ class _TripsPageState extends State<TripsPage> {
return Card( return Card(
child: ListTile( child: ListTile(
title: Text(trip.tripName), title: Text(trip.tripName),
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'), subtitle:
Text(distanceUnits.format(trip.tripMileage, decimals: 1)),
), ),
); );
} }
final trip = tripDetails[index - 1]; final trip = tripDetails[index - 1];
return _buildTripCard(context, trip, isMobile); final summary = summaryById[trip.id];
return _buildTripCard(context, trip, summary);
}, },
), ),
); );
} }
Widget _buildTripCard(BuildContext context, TripDetail trip, bool isMobile) { Widget _buildTripCard(
BuildContext context,
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.watch<DistanceUnitService>();
final legs = trip.legs; final legs = trip.legs;
final legCount =
trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length;
final dateRange = _formatDateRange(legs);
final endpoints = _formatEndpoints(legs);
final stats = _cachedTripStats(trip, summary);
final winnerCount = stats.where((e) => e.won).length;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(16.0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Trip #${trip.id}',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
Text( Text(
trip.name, trip.name,
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text( Text(
'${trip.mileage.toStringAsFixed(1)} mi · ${trip.legCount} legs', distanceUnits.format(trip.mileage, decimals: 1),
style: Theme.of(context).textTheme.bodyMedium, style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
), ),
], ],
), ),
Row( ],
crossAxisAlignment: CrossAxisAlignment.start,
children: [
IconButton(
icon: const Icon(Icons.train),
tooltip: 'Traction',
onPressed: () => _showTripWinners(context, trip),
), ),
IconButton( const SizedBox(height: 12),
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(
alignment: Alignment.centerRight,
child: Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.end,
children: [
OutlinedButton.icon(
icon: const Icon(Icons.train),
label: const Text('Locos'),
onPressed: () => _showTripWinners(context, trip, summary),
),
FilledButton.icon(
icon: const Icon(Icons.open_in_new), icon: const Icon(Icons.open_in_new),
tooltip: 'Details', label: const Text('Details'),
onPressed: () => _showTripDetail(context, trip), onPressed: () => _showTripDetail(context, trip),
), ),
], ],
), ),
],
),
const SizedBox(height: 8),
if (legs.isNotEmpty)
Column(
children: legs.take(isMobile ? 2 : 3).map((leg) {
return ListTile(
dense: isMobile,
contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.train),
title: Text('${leg.start}${leg.end}'),
subtitle: Text(
_formatDate(leg.beginTime),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
);
}).toList(),
),
if (legs.length > 3)
Padding(
padding: const EdgeInsets.only(top: 6.0),
child: Text(
'+${legs.length - 3} more legs',
style: Theme.of(context).textTheme.bodySmall,
),
), ),
], ],
), ),
@@ -257,17 +307,71 @@ class _TripsPageState extends State<TripsPage> {
); );
} }
Widget _buildMetaChip(BuildContext context, IconData icon, String label) {
return Chip(
avatar: Icon(icon, size: 16),
label: Text(label),
visualDensity: const VisualDensity(horizontal: -2, vertical: -2),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
}
String? _formatDateRange(List<TripLeg> legs) {
final beginTimes =
legs.map((e) => e.beginTime).whereType<DateTime>().toList();
if (beginTimes.isEmpty) return null;
final start = beginTimes.first;
final end = beginTimes.last;
final startStr = _formatFriendlyDate(start);
final endStr = _formatFriendlyDate(end);
if (startStr == endStr) return startStr;
return '$startStr - $endStr';
}
String _formatFriendlyDate(DateTime date) {
const months = [
'Jan',
'Feb',
'Mar',
'Apr',
'May',
'Jun',
'Jul',
'Aug',
'Sep',
'Oct',
'Nov',
'Dec',
];
final day = date.day.toString().padLeft(2, '0');
final monthIndex = (date.month - 1).clamp(0, months.length - 1).toInt();
final month = months[monthIndex];
return '$day $month ${date.year}';
}
String? _formatEndpoints(List<TripLeg> legs) {
if (legs.isEmpty) return null;
final start = legs.first.start;
final end = legs.last.end;
if (start.isEmpty && end.isEmpty) return null;
final startLabel = start.isNotEmpty ? start : '';
final endLabel = end.isNotEmpty ? end : '';
return '$startLabel$endLabel';
}
String _formatDate(DateTime? date) { String _formatDate(DateTime? date) {
if (date == null) return ''; if (date == null) return '';
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}'; return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
} }
void _showTripDetail(BuildContext context, TripDetail trip) { void _showTripDetail(BuildContext context, TripDetail trip) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) { builder: (_) {
bool renaming = false; bool renaming = false;
bool deleting = false;
String tripName = trip.name; String tripName = trip.name;
return StatefulBuilder( return StatefulBuilder(
builder: (sheetCtx, setSheetState) { builder: (sheetCtx, setSheetState) {
@@ -285,6 +389,59 @@ class _TripsPageState extends State<TripsPage> {
} }
} }
Future<void> handleDelete() async {
if (deleting || trip.legs.isNotEmpty) return;
final data = context.read<DataService>();
final api = data.api;
final messenger = ScaffoldMessenger.maybeOf(sheetCtx);
final navigator = Navigator.of(sheetCtx);
final ok = await showDialog<bool>(
context: sheetCtx,
builder: (ctx) {
return AlertDialog(
title: const Text('Delete trip?'),
content: Text(
'This will delete "${trip.name}". This cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(ctx).pop(true),
child: const Text('Delete'),
),
],
);
},
);
if (ok != true || !mounted) return;
setSheetState(() => deleting = true);
try {
await api.delete('/trips/delete/${trip.id}');
await Future.wait([
data.fetchTripDetails(),
data.fetchTrips(),
]);
_tripLocoStatsFutures.remove(trip.id);
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Deleted "${trip.name}"')),
);
navigator.pop();
} catch (e) {
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Failed to delete trip: $e')),
);
} finally {
if (mounted) setSheetState(() => deleting = false);
}
}
return SafeArea( return SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -317,8 +474,25 @@ class _TripsPageState extends State<TripsPage> {
tooltip: 'Rename trip', tooltip: 'Rename trip',
onPressed: renaming ? null : handleRename, onPressed: renaming ? null : handleRename,
), ),
if (trip.legs.isEmpty) ...[
const SizedBox(width: 4), const SizedBox(width: 4),
Text('${trip.mileage.toStringAsFixed(1)} mi'), IconButton(
icon: deleting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.delete_outline),
tooltip: 'Delete trip',
onPressed: deleting ? null : handleDelete,
color: Theme.of(context).colorScheme.error,
),
],
const SizedBox(width: 4),
Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -333,7 +507,12 @@ class _TripsPageState extends State<TripsPage> {
title: Text('${leg.start}${leg.end}'), title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)), subtitle: Text(_formatDate(leg.beginTime)),
trailing: Text( trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-', leg.mileage == null
? '-'
: distanceUnits.format(
leg.mileage!,
decimals: 1,
),
style: Theme.of(context).textTheme.labelLarge style: Theme.of(context).textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
), ),
@@ -351,15 +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( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) { builder: (_) {
final data = context.read<DataService>();
return SafeArea( return SafeArea(
child: FutureBuilder<List<TripLocoStat>>( child: FutureBuilder<List<TripLocoStat>>(
future: data.fetchTripLocoStats(trip.id), future: _loadTripStats(trip, summary),
initialData: _cachedTripStats(trip, summary),
builder: (ctx, snapshot) { builder: (ctx, snapshot) {
final items = snapshot.data ?? []; final items = snapshot.data ?? [];
final loading = final loading =
@@ -384,7 +568,9 @@ class _TripsPageState extends State<TripsPage> {
?.copyWith(fontWeight: FontWeight.bold), ?.copyWith(fontWeight: FontWeight.bold),
), ),
const Spacer(), const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'), Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),

View File

@@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class TractionCard extends StatelessWidget { class TractionCard extends StatelessWidget {
const TractionCard({ const TractionCard({
@@ -28,6 +31,7 @@ class TractionCard extends StatelessWidget {
final domain = loco.domain ?? ''; final domain = loco.domain ?? '';
final hasMileageOrTrips = _hasMileageOrTrips(loco); final hasMileageOrTrips = _hasMileageOrTrips(loco);
final statusColors = _statusChipColors(context, status); final statusColors = _statusChipColors(context, status);
final distanceUnits = context.watch<DistanceUnitService>();
return Card( return Card(
child: Padding( child: Padding(
@@ -82,30 +86,30 @@ class TractionCard extends StatelessWidget {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Row( LayoutBuilder(
children: [ builder: (context, constraints) {
final isNarrow = constraints.maxWidth < 520;
final buttons = [
TextButton.icon( TextButton.icon(
onPressed: onShowInfo, onPressed: onShowInfo,
icon: const Icon(Icons.info_outline), icon: const Icon(Icons.info_outline),
label: const Text('Details'), label: const Text('Details'),
), ),
const SizedBox(width: 8),
TextButton.icon( TextButton.icon(
onPressed: onOpenTimeline, onPressed: onOpenTimeline,
icon: const Icon(Icons.timeline), icon: const Icon(Icons.timeline),
label: const Text('Timeline'), label: const Text('Timeline'),
), ),
if (hasMileageOrTrips && onOpenLegs != null) ...[ if (hasMileageOrTrips && onOpenLegs != null)
const SizedBox(width: 8),
TextButton.icon( TextButton.icon(
onPressed: onOpenLegs, onPressed: onOpenLegs,
icon: const Icon(Icons.view_list), icon: const Icon(Icons.view_list),
label: const Text('Legs'), label: const Text('Legs'),
), ),
], ];
const Spacer(),
if (selectionMode && onToggleSelect != null) final addButton = selectionMode && onToggleSelect != null
TextButton.icon( ? TextButton.icon(
onPressed: onToggleSelect, onPressed: onToggleSelect,
icon: Icon( icon: Icon(
isSelected isSelected
@@ -113,8 +117,37 @@ class TractionCard extends StatelessWidget {
: Icons.add_circle_outline, : Icons.add_circle_outline,
), ),
label: Text(isSelected ? 'Remove' : 'Add to entry'), label: Text(isSelected ? 'Remove' : 'Add to entry'),
)
: null;
if (isNarrow) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 4,
children: buttons,
), ),
if (addButton != null) ...[
const SizedBox(height: 6),
addButton,
], ],
],
);
}
return Row(
children: [
...buttons.expand((btn) sync* {
yield btn;
yield const SizedBox(width: 8);
}).take(buttons.length * 2 - 1),
const Spacer(),
if (addButton != null) addButton,
],
);
},
), ),
Wrap( Wrap(
spacing: 8, spacing: 8,
@@ -122,8 +155,11 @@ class TractionCard extends StatelessWidget {
children: [ children: [
_statPill( _statPill(
context, context,
label: 'Miles', label: 'Distance',
value: _formatNumber(loco.mileage), value: distanceUnits.format(
loco.mileage ?? 0,
decimals: 1,
),
), ),
_statPill( _statPill(
context, context,
@@ -174,6 +210,12 @@ Future<void> showTractionDetails(
LocoSummary loco, LocoSummary loco,
) async { ) async {
final hasMileageOrTrips = _hasMileageOrTrips(loco); 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( await showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -246,7 +288,10 @@ Future<void> showTractionDetails(
_detailRow( _detailRow(
context, context,
'Mileage', 'Mileage',
_formatNumber(loco.mileage ?? 0), distanceUnits.format(
loco.mileage ?? 0,
decimals: 1,
),
), ),
_detailRow( _detailRow(
context, context,
@@ -256,6 +301,63 @@ Future<void> showTractionDetails(
_detailRow(context, 'EVN', loco.evn ?? ''), _detailRow(context, 'EVN', loco.evn ?? ''),
if (loco.notes != null && loco.notes!.isNotEmpty) if (loco.notes != null && loco.notes!.isNotEmpty)
_detailRow(context, 'Notes', loco.notes!), _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(),
);
},
),
], ],
), ),
), ),
@@ -268,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) { Widget _detailRow(BuildContext context, String label, String value) {
if (value.isEmpty) return const SizedBox.shrink(); if (value.isEmpty) return const SizedBox.shrink();
return Padding( return Padding(
@@ -339,8 +540,3 @@ bool _hasMileageOrTrips(LocoSummary loco) {
final trips = loco.trips ?? loco.journeys ?? 0; final trips = loco.trips ?? loco.journeys ?? 0;
return mileage > 0 || trips > 0; return mileage > 0 || trips > 0;
} }
String _formatNumber(double? value) {
if (value == null) return '0';
return value.toStringAsFixed(1);
}

View File

@@ -1,3 +1,5 @@
import 'dart:convert';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
@@ -20,6 +22,35 @@ String _asString(dynamic value, [String fallback = '']) {
return (str == null) ? fallback : str; 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]) { bool _asBool(dynamic value, [bool fallback = false]) {
if (value is bool) return value; if (value is bool) return value;
if (value is num) return value != 0; if (value is num) return value != 0;
@@ -35,6 +66,29 @@ DateTime _asDateTime(dynamic value, [DateTime? fallback]) {
return parsed ?? (fallback ?? DateTime.fromMillisecondsSinceEpoch(0)); 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 { class DestinationObject {
const DestinationObject( const DestinationObject(
this.label, this.label,
@@ -50,24 +104,64 @@ class DestinationObject {
} }
class UserData { 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 userId;
final String username; final String username;
final String fullName; final String fullName;
final String email; final String email;
final bool elevated;
final bool disabled;
} }
class AuthenticatedUserData extends UserData { class AuthenticatedUserData extends UserData {
const AuthenticatedUserData({ const AuthenticatedUserData({
required String userId, required super.userId,
required String username, required super.username,
required String fullName, required super.fullName,
required String email, required super.email,
bool? elevated,
bool? isElevated,
bool? disabled,
bool? isDisabled,
required this.accessToken, required this.accessToken,
}) : super(username, fullName, userId, email); }) : super(
elevated: (elevated ?? false) || (isElevated ?? false),
disabled: (disabled ?? false) || (isDisabled ?? false),
);
final String accessToken; 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 { class HomepageStats {
@@ -116,10 +210,13 @@ class HomepageStats {
user: userData == null user: userData == null
? null ? null
: UserData( : UserData(
userData['username'] ?? '', username: userData['username'] ?? '',
userData['full_name'] ?? '', fullName: userData['full_name'] ?? '',
userData['user_id'] ?? '', userId: userData['user_id'] ?? '',
userData['email'] ?? '', 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 { class Loco {
final int id; final int id;
final String type, number, locoClass; final String type, number, locoClass;
@@ -197,7 +496,7 @@ class LocoSummary extends Loco {
this.livery, this.livery,
this.location, this.location,
Map<String, dynamic>? extra, Map<String, dynamic>? extra,
bool powering = true, super.powering = true,
}) : extra = extra ?? const {}, }) : extra = extra ?? const {},
super( super(
id: locoId, id: locoId,
@@ -207,11 +506,10 @@ class LocoSummary extends Loco {
operator: locoOperator, operator: locoOperator,
notes: locoNotes, notes: locoNotes,
evn: locoEvn, evn: locoEvn,
powering: powering,
); );
factory LocoSummary.fromJson(Map<String, dynamic> json) => LocoSummary( 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'] ?? '', locoType: json['type'] ?? json['loco_type'] ?? '',
locoNumber: json['number'] ?? json['loco_number'] ?? '', locoNumber: json['number'] ?? json['loco_number'] ?? '',
locoName: json['name'] ?? json['loco_name'] ?? "", locoName: json['name'] ?? json['loco_name'] ?? "",
@@ -400,7 +698,7 @@ class LocoChange {
}); });
factory LocoChange.fromJson(Map<String, dynamic> json) { factory LocoChange.fromJson(Map<String, dynamic> json) {
String _clean(dynamic value) { String cleanValue(dynamic value) {
final str = value?.toString().trim() ?? ''; final str = value?.toString().trim() ?? '';
if (str.isEmpty || str == '-' || str == '?') return ''; if (str.isEmpty || str == '-' || str == '?') return '';
return str; return str;
@@ -417,15 +715,15 @@ class LocoChange {
final validFromRaw = json['valid_from'] ?? json['validFrom']; final validFromRaw = json['valid_from'] ?? json['validFrom'];
return LocoChange( return LocoChange(
locoId: _asInt(json['loco_id']), locoId: _asInt(json['loco_id']),
locoClass: _clean(json['loco_class']), locoClass: cleanValue(json['loco_class']),
locoNumber: _clean(json['loco_number']), locoNumber: cleanValue(json['loco_number']),
locoName: _clean(json['loco_name']), locoName: cleanValue(json['loco_name']),
attrCode: _asString(json['attr_code']), attrCode: _asString(json['attr_code']),
attrDisplay: _clean(json['attr_display']), attrDisplay: cleanValue(json['attr_display']),
valueDisplay: _clean(valueLabel), valueDisplay: cleanValue(valueLabel),
validFrom: DateTime.tryParse(validFromRaw?.toString() ?? ''), validFrom: DateTime.tryParse(validFromRaw?.toString() ?? ''),
approvedAt: DateTime.tryParse(approvedRaw?.toString() ?? ''), approvedAt: DateTime.tryParse(approvedRaw?.toString() ?? ''),
approvedBy: _clean(json['approved_by']), approvedBy: cleanValue(json['approved_by']),
); );
} }
@@ -488,25 +786,100 @@ class TripSummary {
final int tripId; final int tripId;
final String tripName; final String tripName;
final double tripMileage; 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({ TripSummary({
required this.tripId, required this.tripId,
required this.tripName, required this.tripName,
required this.tripMileage, required this.tripMileage,
}); this.legCount = 0,
List<TripLocoStat>? locoStats,
this.startDate,
this.endDate,
}) : locoStats = locoStats ?? const [];
factory TripSummary.fromJson(Map<String, dynamic> json) => TripSummary( 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']), tripId: _asInt(json['trip_id']),
tripName: _asString(json['trip_name']), tripName: _asString(json['trip_name']),
tripMileage: _asDouble(json['trip_mileage']), 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 { class Leg {
final int id, tripId, timezone, driving; 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 beginTime;
final DateTime? endTime;
final DateTime? originTime;
final DateTime? destinationTime;
final double mileage; final double mileage;
final int? beginDelayMinutes, endDelayMinutes;
final List<Loco> locos; final List<Loco> locos;
Leg({ Leg({
@@ -524,17 +897,36 @@ class Leg {
required this.driving, required this.driving,
required this.user, required this.user,
required this.locos, 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( 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']), id: _asInt(json['leg_id']),
tripId: _asInt(json['leg_trip']), tripId: _asInt(json['leg_trip']),
start: _asString(json['leg_start']), start: _asString(json['leg_start']),
end: _asString(json['leg_end']), end: _asString(json['leg_end']),
beginTime: _asDateTime(json['leg_begin_time']), 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']), timezone: _asInt(json['leg_timezone']),
network: _asString(json['leg_network']), network: _asString(json['leg_network']),
route: _asString(json['leg_route']), route: _asStringList(json['leg_route']),
mileage: _asDouble(json['leg_mileage']), mileage: _asDouble(json['leg_mileage']),
notes: _asString(json['leg_notes']), notes: _asString(json['leg_notes']),
headcode: _asString(json['leg_headcode']), headcode: _asString(json['leg_headcode']),
@@ -544,8 +936,17 @@ class Leg {
.whereType<Map>() .whereType<Map>()
.map((e) => Loco.fromJson(Map<String, dynamic>.from(e))) .map((e) => Loco.fromJson(Map<String, dynamic>.from(e)))
.toList(), .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 { class RouteError {
final String error; final String error;
@@ -626,17 +1027,23 @@ class TripLeg {
}); });
factory TripLeg.fromJson(Map<String, dynamic> json) => TripLeg( factory TripLeg.fromJson(Map<String, dynamic> json) => TripLeg(
id: json['leg_id'], id: _asInt(json['leg_id']),
start: json['leg_start'] ?? '', start: _asString(json['leg_start']),
end: json['leg_end'] ?? '', end: _asString(json['leg_end']),
beginTime: beginTime:
json['leg_begin_time'] != null && json['leg_begin_time'] is String json['leg_begin_time'] != null && json['leg_begin_time'] is String
? DateTime.tryParse(json['leg_begin_time']) ? DateTime.tryParse(json['leg_begin_time'])
: (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null), : (json['leg_begin_time'] is DateTime ? json['leg_begin_time'] : null),
network: json['leg_network'], network: _asString(json['leg_network'], ''),
route: json['leg_route'], 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(), mileage: (json['leg_mileage'] as num?)?.toDouble(),
notes: json['leg_notes'], notes: _asString(json['leg_notes'], ''),
locos: locos:
(json['locos'] as List?) (json['locos'] as List?)
?.map((e) => Loco.fromJson(e as Map<String, dynamic>)) ?.map((e) => Loco.fromJson(e as Map<String, dynamic>))
@@ -650,21 +1057,60 @@ class TripDetail {
final String name; final String name;
final double mileage; final double mileage;
final int legCount; final int legCount;
final List<TripLocoStat> locoStats;
final List<TripLeg> legs; 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({ TripDetail({
required this.id, required this.id,
required this.name, required this.name,
required this.mileage, required this.mileage,
required this.legCount, required this.legCount,
required this.legs, required this.legs,
}); List<TripLocoStat>? locoStats,
}) : locoStats = locoStats ?? const [];
factory TripDetail.fromJson(Map<String, dynamic> json) => TripDetail( factory TripDetail.fromJson(Map<String, dynamic> json) => TripDetail(
id: json['trip_id'] ?? json['id'] ?? 0, id: json['trip_id'] ?? json['id'] ?? 0,
name: json['trip_name'] ?? '', name: json['trip_name'] ?? '',
mileage: (json['trip_mileage'] as num?)?.toDouble() ?? 0, 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: legs:
(json['trip_legs'] as List?) (json['trip_legs'] as List?)
?.map((e) => TripLeg.fromJson(e as Map<String, dynamic>)) ?.map((e) => TripLeg.fromJson(e as Map<String, dynamic>))
@@ -713,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) { static bool _parseWonFlag(dynamic value) {
if (value == null) return false; if (value == null) return false;
if (value is bool) return value; if (value is bool) return value;
@@ -751,3 +1217,165 @@ class EventField {
); );
} }
} }
class UserNotification {
final int id;
final String title;
final String body;
final String channel;
final DateTime? createdAt;
final bool dismissed;
UserNotification({
required this.id,
required this.title,
required this.body,
required this.channel,
required this.createdAt,
required this.dismissed,
});
factory UserNotification.fromJson(Map<String, dynamic> json) {
final created = json['created_at'] ?? json['createdAt'];
DateTime? createdAt;
if (created is String) {
createdAt = DateTime.tryParse(created);
} else if (created is DateTime) {
createdAt = created;
}
return 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),
);
}
}
class BadgeAward {
final int id;
final int badgeId;
final String badgeCode;
final String badgeTier;
final String? scopeValue;
final DateTime? awardedAt;
final LocoSummary? loco;
BadgeAward({
required this.id,
required this.badgeId,
required this.badgeCode,
required this.badgeTier,
this.scopeValue,
this.awardedAt,
this.loco,
});
factory BadgeAward.fromJson(Map<String, dynamic> json) {
final awarded = json['awarded_at'] ?? json['awardedAt'];
DateTime? awardedAt;
if (awarded is String) {
awardedAt = DateTime.tryParse(awarded);
} else if (awarded is DateTime) {
awardedAt = awarded;
}
final locoJson = json['loco'];
LocoSummary? loco;
if (locoJson is Map<String, dynamic>) {
loco = LocoSummary.fromJson(Map<String, dynamic>.from(locoJson));
}
return BadgeAward(
id: _asInt(json['award_id'] ?? json['id']),
badgeId: _asInt(json['badge_id'] ?? 0),
badgeCode: _asString(json['badge_code']),
badgeTier: _asString(json['badge_tier']),
scopeValue: _asString(json['scope_value']),
awardedAt: awardedAt,
loco: loco,
);
}
}
class ClassClearanceProgress {
final String className;
final int completed;
final int total;
final double percentComplete;
ClassClearanceProgress({
required this.className,
required this.completed,
required this.total,
required this.percentComplete,
});
factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) {
final name = _asString(json['class'] ?? json['class_name'] ?? json['name']);
final completed = _asInt(
json['completed'] ?? json['done'] ?? json['count'] ?? json['had'],
);
final total = _asInt(json['total'] ?? json['required'] ?? json['goal']);
double percent = _asDouble(
json['percent_complete'] ??
json['percent'] ??
json['completion'] ??
json['pct'],
);
if (percent == 0 && total > 0) {
percent = (completed / total) * 100;
}
return ClassClearanceProgress(
className: name.isNotEmpty ? name : 'Class',
completed: completed,
total: total,
percentComplete: percent,
);
}
}
class LocoClearanceProgress {
final LocoSummary loco;
final double mileage;
final double required;
final String nextTier;
final List<String> awardedTiers;
final double percent;
LocoClearanceProgress({
required this.loco,
required this.mileage,
required this.required,
required this.nextTier,
required this.awardedTiers,
required this.percent,
});
factory LocoClearanceProgress.fromJson(Map<String, dynamic> json) {
final locoJson = json['loco'];
final loco = locoJson is Map<String, dynamic>
? LocoSummary.fromJson(Map<String, dynamic>.from(locoJson))
: LocoSummary(
locoId: _asInt(json['loco_id']),
locoType: _asString(json['loco_type']),
locoNumber: _asString(json['loco_number']),
locoName: _asString(json['loco_name']),
locoClass: _asString(json['loco_class']),
locoOperator: _asString(json['operator']),
powering: true,
locoNotes: null,
locoEvn: null,
);
return LocoClearanceProgress(
loco: loco,
mileage: _asDouble(json['mileage']),
required: _asDouble(json['required']),
nextTier: _asString(json['next_tier']),
awardedTiers: (json['awarded_tiers'] as List? ?? [])
.map((e) => e.toString())
.toList(),
percent: _asDouble(json['percent']),
);
}
}

View File

@@ -5,17 +5,24 @@ typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<void> Function(); typedef UnauthorizedHandler = Future<void> Function();
class ApiService { class ApiService {
final String baseUrl; String _baseUrl;
final http.Client _client; final http.Client _client;
final Duration timeout; final Duration timeout;
TokenProvider? _getToken; TokenProvider? _getToken;
UnauthorizedHandler? _onUnauthorized; UnauthorizedHandler? _onUnauthorized;
ApiService({ ApiService({
required this.baseUrl, required String baseUrl,
http.Client? client, http.Client? client,
this.timeout = const Duration(seconds: 30), this.timeout = const Duration(seconds: 30),
}) : _client = client ?? http.Client(); }) : _baseUrl = baseUrl,
_client = client ?? http.Client();
String get baseUrl => _baseUrl;
void setBaseUrl(String url) {
_baseUrl = url;
}
void setTokenProvider(TokenProvider provider) { void setTokenProvider(TokenProvider provider) {
_getToken = provider; _getToken = provider;
@@ -121,7 +128,12 @@ class ApiService {
await _onUnauthorized!(); 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) { dynamic _decodeBody(http.Response res) {
@@ -142,4 +154,41 @@ class ApiService {
return res.body; 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 userId => _user?.userId;
String? get username => _user?.username; String? get username => _user?.username;
String? get fullName => _user?.fullName; 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({ void setLoginData({
required String userId, required String userId,
@@ -28,6 +31,8 @@ class AuthService extends ChangeNotifier {
required String fullName, required String fullName,
required String accessToken, required String accessToken,
required String email, required String email,
bool isElevated = false,
bool isDisabled = false,
}) { }) {
_user = AuthenticatedUserData( _user = AuthenticatedUserData(
userId: userId, userId: userId,
@@ -35,6 +40,8 @@ class AuthService extends ChangeNotifier {
fullName: fullName, fullName: fullName,
accessToken: accessToken, accessToken: accessToken,
email: email, email: email,
isElevated: isElevated,
disabled: isDisabled,
); );
_persistToken(accessToken); _persistToken(accessToken);
notifyListeners(); notifyListeners();
@@ -70,6 +77,8 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'], fullName: userResponse['full_name'],
accessToken: accessToken, accessToken: accessToken,
email: userResponse['email'], email: userResponse['email'],
isElevated: _parseIsElevated(userResponse),
isDisabled: _parseIsDisabled(userResponse),
); );
} }
@@ -95,6 +104,8 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'], fullName: userResponse['full_name'],
accessToken: token, accessToken: token,
email: userResponse['email'], email: userResponse['email'],
isElevated: _parseIsElevated(userResponse),
isDisabled: _parseIsDisabled(userResponse),
); );
} catch (_) { } catch (_) {
await _clearToken(); await _clearToken();
@@ -157,4 +168,60 @@ class AuthService extends ChangeNotifier {
void logout() { void logout() {
handleTokenExpired(); // reuse 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

@@ -9,4 +9,6 @@ import 'package:mileograph_flutter/services/api_service.dart';
part 'data_service_core.dart'; part 'data_service_core.dart';
part 'data_service_traction.dart'; part 'data_service_traction.dart';
part 'data_service_trips.dart'; part 'data_service_trips.dart';
part 'data_service_notifications.dart';
part 'data_service_badges.dart';
part 'data_service_stats.dart';

View File

@@ -0,0 +1,130 @@
part of 'data_service.dart';
extension DataServiceBadges on DataService {
Future<void> fetchBadgeAwards({
int offset = 0,
int limit = 50,
bool append = false,
String badgeCode = 'class_clearance',
}) async {
_isBadgeAwardsLoading = true;
if (!append) _badgeAwards = [];
try {
final json = await api.get(
'/badge/awards/me?limit=$limit&offset=$offset&badge_code=$badgeCode',
);
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['awards', 'badge_awards', 'data']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
final parsed = list
?.whereType<Map<String, dynamic>>()
.map(BadgeAward.fromJson)
.toList();
final items = parsed ?? [];
_badgeAwards =
append ? [..._badgeAwards, ...items] : items;
_badgeAwards.sort((a, b) {
final aTs = a.awardedAt?.millisecondsSinceEpoch ?? 0;
final bTs = b.awardedAt?.millisecondsSinceEpoch ?? 0;
return bTs.compareTo(aTs);
});
_badgeAwardsHasMore = items.length >= limit;
} catch (e) {
debugPrint('Failed to fetch badge awards: $e');
if (!append) _badgeAwards = [];
_badgeAwardsHasMore = false;
} finally {
_isBadgeAwardsLoading = false;
_notifyAsync();
}
}
Future<void> fetchClassClearanceProgress({
int offset = 0,
int limit = 20,
bool append = false,
}) async {
_isClassClearanceProgressLoading = true;
if (!append) _classClearanceProgress = [];
try {
final json =
await api.get('/badge/completion/class?limit=$limit&offset=$offset');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['progress', 'data', 'items', 'classes']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
final parsed = list
?.whereType<Map<String, dynamic>>()
.map(ClassClearanceProgress.fromJson)
.toList();
final items = parsed ?? [];
_classClearanceProgress =
append ? [..._classClearanceProgress, ...items] : items;
_classClearanceHasMore = items.length >= limit;
} catch (e) {
debugPrint('Failed to fetch class clearance progress: $e');
if (!append) _classClearanceProgress = [];
_classClearanceHasMore = false;
} finally {
_isClassClearanceProgressLoading = false;
_notifyAsync();
}
}
Future<void> fetchLocoClearanceProgress({
int offset = 0,
int limit = 20,
bool append = false,
}) async {
_isLocoClearanceProgressLoading = true;
if (!append) _locoClearanceProgress = [];
try {
final json =
await api.get('/badge/completion/loco?limit=$limit&offset=$offset');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['progress', 'data', 'items', 'locos']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
final parsed = list
?.whereType<Map<String, dynamic>>()
.map(LocoClearanceProgress.fromJson)
.toList();
final items = parsed ?? [];
_locoClearanceProgress =
append ? [..._locoClearanceProgress, ...items] : items;
_locoClearanceHasMore = items.length >= limit;
} catch (e) {
debugPrint('Failed to fetch loco clearance progress: $e');
if (!append) _locoClearanceProgress = [];
_locoClearanceHasMore = false;
} finally {
_isLocoClearanceProgressLoading = false;
_notifyAsync();
}
}
}

View File

@@ -21,11 +21,17 @@ class DataService extends ChangeNotifier {
DataService({required this.api}); DataService({required this.api});
String? _currentUserId;
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions(); _LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
// Homepage Data // Homepage Data
HomepageStats? _homepageStats; HomepageStats? _homepageStats;
HomepageStats? get homepageStats => _homepageStats; HomepageStats? get homepageStats => _homepageStats;
StatsAbout? _aboutStats;
StatsAbout? get aboutStats => _aboutStats;
bool _isAboutStatsLoading = false;
bool get isAboutStatsLoading => _isAboutStatsLoading;
// Legs Data // Legs Data
List<Leg> _legs = []; List<Leg> _legs = [];
@@ -48,6 +54,9 @@ class DataService extends ChangeNotifier {
List<LocoChange> get latestLocoChanges => _latestLocoChanges; List<LocoChange> get latestLocoChanges => _latestLocoChanges;
bool _isLatestLocoChangesLoading = false; bool _isLatestLocoChangesLoading = false;
bool get isLatestLocoChangesLoading => _isLatestLocoChangesLoading; bool get isLatestLocoChangesLoading => _isLatestLocoChangesLoading;
bool _latestLocoChangesHasMore = false;
bool get latestLocoChangesHasMore => _latestLocoChangesHasMore;
int _latestLocoChangesFetched = 0;
final Map<int, List<LocoAttrVersion>> _locoTimelines = {}; final Map<int, List<LocoAttrVersion>> _locoTimelines = {};
final Map<int, bool> _isLocoTimelineLoading = {}; final Map<int, bool> _isLocoTimelineLoading = {};
List<LocoAttrVersion> timelineForLoco(int locoId) => List<LocoAttrVersion> timelineForLoco(int locoId) =>
@@ -72,9 +81,14 @@ class DataService extends ChangeNotifier {
bool get isEventFieldsLoading => _isEventFieldsLoading; bool get isEventFieldsLoading => _isEventFieldsLoading;
// Station Data // Station Data
List<Station>? _cachedStations; final Map<String, List<Station>> _stationCache = {};
DateTime? _stationsFetchedAt; final Map<String, Future<List<Station>>?> _stationInFlightByKey = {};
Future<List<Station>>? _stationsInFlight; List<String> _stationNetworks = [];
Map<String, List<String>> _stationCountryNetworks = {};
DateTime? _stationFiltersFetchedAt;
List<String> get stationNetworks => _stationNetworks;
Map<String, List<String>> get stationCountryNetworks =>
_stationCountryNetworks;
List<String> stations = [""]; List<String> stations = [""];
@@ -83,6 +97,36 @@ class DataService extends ChangeNotifier {
bool _isOnThisDayLoading = false; bool _isOnThisDayLoading = false;
bool get isOnThisDayLoading => _isOnThisDayLoading; bool get isOnThisDayLoading => _isOnThisDayLoading;
// Notifications
List<UserNotification> _notifications = [];
List<UserNotification> get notifications => _notifications;
bool _isNotificationsLoading = false;
bool get isNotificationsLoading => _isNotificationsLoading;
// Badges
List<BadgeAward> _badgeAwards = [];
List<BadgeAward> get badgeAwards => _badgeAwards;
bool _isBadgeAwardsLoading = false;
bool get isBadgeAwardsLoading => _isBadgeAwardsLoading;
bool _badgeAwardsHasMore = false;
bool get badgeAwardsHasMore => _badgeAwardsHasMore;
List<ClassClearanceProgress> _classClearanceProgress = [];
List<ClassClearanceProgress> get classClearanceProgress =>
_classClearanceProgress;
bool _isClassClearanceProgressLoading = false;
bool get isClassClearanceProgressLoading =>
_isClassClearanceProgressLoading;
bool _classClearanceHasMore = false;
bool get classClearanceHasMore => _classClearanceHasMore;
List<LocoClearanceProgress> _locoClearanceProgress = [];
List<LocoClearanceProgress> get locoClearanceProgress =>
_locoClearanceProgress;
bool _isLocoClearanceProgressLoading = false;
bool get isLocoClearanceProgressLoading =>
_isLocoClearanceProgressLoading;
bool _locoClearanceHasMore = false;
bool get locoClearanceHasMore => _locoClearanceHasMore;
static const List<EventField> _fallbackEventFields = [ static const List<EventField> _fallbackEventFields = [
EventField(name: 'operator', display: 'Operator'), EventField(name: 'operator', display: 'Operator'),
EventField(name: 'status', display: 'Status'), EventField(name: 'status', display: 'Status'),
@@ -107,7 +151,8 @@ class DataService extends ChangeNotifier {
try { try {
final json = await api.get('/stats/homepage'); final json = await api.get('/stats/homepage');
_homepageStats = HomepageStats.fromJson(json); _homepageStats = HomepageStats.fromJson(json);
_trips = _homepageStats?.trips ?? []; _trips = [...(_homepageStats?.trips ?? const [])]
..sort(TripSummary.compareByDateDesc);
} catch (e) { } catch (e) {
debugPrint('Failed to fetch homepage stats: $e'); debugPrint('Failed to fetch homepage stats: $e');
_homepageStats = null; _homepageStats = null;
@@ -337,6 +382,8 @@ class DataService extends ChangeNotifier {
} }
void clear() { void clear() {
_currentUserId = null;
_lastLegsFetch = const _LegFetchOptions();
_homepageStats = null; _homepageStats = null;
_legs = []; _legs = [];
_onThisDay = []; _onThisDay = [];
@@ -347,9 +394,44 @@ class DataService extends ChangeNotifier {
_isLocoTimelineLoading.clear(); _isLocoTimelineLoading.clear();
_latestLocoChanges = []; _latestLocoChanges = [];
_isLatestLocoChangesLoading = false; _isLatestLocoChangesLoading = false;
_isHomepageLoading = false;
_isOnThisDayLoading = false;
_legsHasMore = false;
_isLegsLoading = false;
_traction = [];
_isTractionLoading = false;
_tractionHasMore = false;
_latestLocoChangesHasMore = false;
_latestLocoChangesFetched = 0;
_isTripDetailsLoading = false;
_locoClasses = [];
_tripList = [];
_stationCache.clear();
_stationInFlightByKey.clear();
_stationNetworks = [];
_stationCountryNetworks = {};
_stationFiltersFetchedAt = null;
_notifications = [];
_isNotificationsLoading = false;
_badgeAwards = [];
_badgeAwardsHasMore = false;
_isBadgeAwardsLoading = false;
_classClearanceProgress = [];
_isClassClearanceProgressLoading = false;
_classClearanceHasMore = false;
_locoClearanceProgress = [];
_isLocoClearanceProgressLoading = false;
_locoClearanceHasMore = false;
_notifyAsync(); _notifyAsync();
} }
void handleAuthChanged(String? userId) {
if (_currentUserId == userId) return;
_currentUserId = userId;
clear();
_currentUserId = userId;
}
double getMileageForCurrentYear() { double getMileageForCurrentYear() {
final currentYear = DateTime.now().year; final currentYear = DateTime.now().year;
return getMileageForYear(currentYear) ?? 0; return getMileageForYear(currentYear) ?? 0;
@@ -365,37 +447,75 @@ class DataService extends ChangeNotifier {
0; 0;
} }
Future<List<Station>> fetchStations() async { Future<void> fetchStationFilters() async {
final now = DateTime.now(); final now = DateTime.now();
if (_stationFiltersFetchedAt != null &&
// If cache exists and is less than 30 minutes old, return it now.difference(_stationFiltersFetchedAt!) < const Duration(minutes: 30) &&
if (_cachedStations != null && _stationNetworks.isNotEmpty) {
_stationsFetchedAt != null && return;
now.difference(_stationsFetchedAt!) < Duration(minutes: 30)) { }
return _cachedStations!; try {
final response = await api.get('/stations/filter');
if (response is List && response.isNotEmpty && response.first is Map) {
final map = Map<String, dynamic>.from(response.first as Map);
final networks = (map['networks'] as List? ?? const [])
.whereType<String>()
.toList();
final countryNetworksRaw =
map['country_networks'] as Map? ?? const <String, dynamic>{};
final countryNetworks = <String, List<String>>{};
countryNetworksRaw.forEach((key, value) {
if (value is List) {
countryNetworks[key] = value.whereType<String>().toList();
}
});
_stationNetworks = networks;
_stationCountryNetworks = countryNetworks;
_stationFiltersFetchedAt = now;
}
} catch (e) {
debugPrint('Failed to fetch station filters: $e');
}
} }
if (_stationsInFlight != null) return _stationsInFlight!; String _stationKey(List<String> countries, List<String> networks) {
final c = countries..sort();
final n = networks..sort();
return 'c:${c.join('|')};n:${n.join('|')}';
}
_stationsInFlight = () async { Future<List<Station>> fetchStations({
List<String> countries = const [],
List<String> networks = const [],
}) async {
final key = _stationKey(List.from(countries), List.from(networks));
if (_stationCache.containsKey(key)) return _stationCache[key]!;
final inflight = _stationInFlightByKey[key];
if (inflight != null) return inflight;
final future = () async {
try { try {
final response = await api.get('/location'); final response = await api.post('/location', {
'countries_filter': countries,
'network_filter': networks,
});
if (response is! List) return const <Station>[]; if (response is! List) return const <Station>[];
final parsed = response final parsed = response
.whereType<Map>() .whereType<Map>()
.map((e) => Station.fromJson(Map<String, dynamic>.from(e))) .map((e) => Station.fromJson(Map<String, dynamic>.from(e)))
.toList(); .toList();
_cachedStations = parsed; _stationCache[key] = parsed;
_stationsFetchedAt = now;
return parsed; return parsed;
} catch (e) { } catch (e) {
debugPrint('Failed to fetch stations: $e'); debugPrint('Failed to fetch stations: $e');
return const <Station>[]; return const <Station>[];
} finally { } finally {
_stationsInFlight = null; _stationInFlightByKey.remove(key);
} }
}(); }();
return _stationsInFlight!; _stationInFlightByKey[key] = future;
return future;
} }
} }

View File

@@ -0,0 +1,62 @@
part of 'data_service.dart';
extension DataServiceNotifications on DataService {
Future<void> fetchNotifications() async {
_isNotificationsLoading = true;
try {
final json = await api.get('/notifications');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['notifications', 'data', 'items']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
final parsed = list
?.whereType<Map<String, dynamic>>()
.map(UserNotification.fromJson)
.where((n) => !n.dismissed && n.channel.toLowerCase() != 'web')
.toList();
if (parsed != null) {
parsed.sort((a, b) {
final aTs = a.createdAt?.millisecondsSinceEpoch ?? 0;
final bTs = b.createdAt?.millisecondsSinceEpoch ?? 0;
return bTs.compareTo(aTs);
});
_notifications = parsed;
} else {
_notifications = [];
}
} catch (e) {
debugPrint('Failed to fetch notifications: $e');
_notifications = [];
} finally {
_isNotificationsLoading = false;
_notifyAsync();
}
}
Future<void> dismissNotifications(List<int> notificationIds) async {
if (notificationIds.isEmpty) return;
try {
await api.put('/notifications/dismiss', {
"notification_ids": notificationIds,
"payload": {"dismissed": true},
});
_notifications = _notifications
.where((n) => !notificationIds.contains(n.id))
.toList();
} catch (e) {
debugPrint('Failed to dismiss notifications: $e');
rethrow;
} finally {
_notifyAsync();
}
}
}

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({ Future<void> fetchTraction({
bool hadOnly = false, bool hadOnly = false,
int offset = 0, int offset = 0,
int limit = 50, int limit = 100,
String? locoClass, String? locoClass,
String? locoNumber, String? locoNumber,
bool mileageFirst = true, bool mileageFirst = true,
@@ -115,7 +115,11 @@ extension DataServiceTraction on DataService {
return _locoClasses; return _locoClasses;
} }
Future<void> fetchLatestLocoChanges({int limit = 25, int offset = 0}) async { Future<void> fetchLatestLocoChanges({
int limit = 100,
int offset = 0,
bool append = false,
}) async {
_isLatestLocoChangesLoading = true; _isLatestLocoChangesLoading = true;
_notifyAsync(); _notifyAsync();
try { try {
@@ -138,16 +142,41 @@ extension DataServiceTraction on DataService {
); );
} }
} }
if (append) {
_latestLocoChanges = [..._latestLocoChanges, ...parsed];
} else {
_latestLocoChanges = parsed; _latestLocoChanges = parsed;
}
final fetchedCount = parsed.length;
_latestLocoChangesFetched = append
? offset + fetchedCount
: fetchedCount;
_latestLocoChangesHasMore = _latestLocoChangesFetched < 5000;
} else { } else {
throw Exception('Unexpected latest loco changes response: $json'); throw Exception('Unexpected latest loco changes response: $json');
} }
} catch (e) { } catch (e) {
debugPrint('Failed to fetch latest loco changes: $e'); debugPrint('Failed to fetch latest loco changes: $e');
_latestLocoChanges = []; _latestLocoChanges = [];
_latestLocoChangesHasMore = false;
_latestLocoChangesFetched = 0;
} finally { } finally {
_isLatestLocoChangesLoading = false; _isLatestLocoChangesLoading = false;
_notifyAsync(); _notifyAsync();
} }
} }
Future<Map<String, dynamic>?> fetchClassStats(String locoClass) async {
try {
final path = Uri.encodeComponent(locoClass);
final json = await api.get('/loco/class/stats/$path/user');
if (json is Map) {
return Map<String, dynamic>.from(json);
}
debugPrint('Unexpected class stats response for $locoClass: $json');
} catch (e) {
debugPrint('Failed to fetch class stats for $locoClass: $e');
}
return null;
}
} }

View File

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

@@ -0,0 +1,36 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class EndpointService extends ChangeNotifier {
EndpointService() {
_load();
}
static const String defaultBaseUrl = 'https://mileograph.co.uk/api/v1';
static const String _prefsKey = 'api_base_url';
String _baseUrl = defaultBaseUrl;
bool _loaded = false;
String get baseUrl => _baseUrl;
bool get isLoaded => _loaded;
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_prefsKey);
if (saved != null && saved.trim().isNotEmpty) {
_baseUrl = saved;
}
_loaded = true;
notifyListeners();
}
Future<void> setBaseUrl(String url) async {
final trimmed = url.trim();
if (trimmed.isEmpty) return;
_baseUrl = trimmed;
final prefs = await SharedPreferences.getInstance();
await prefs.setString(_prefsKey, _baseUrl);
notifyListeners();
}
}

View File

@@ -1,35 +1,51 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/login/login.dart';
import 'package:mileograph_flutter/components/pages/calculator.dart'; import 'package:mileograph_flutter/components/pages/calculator.dart';
import 'package:mileograph_flutter/components/pages/calculator_details.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/dashboard.dart';
import 'package:mileograph_flutter/components/pages/legs.dart';
import 'package:mileograph_flutter/components/pages/loco_legs.dart'; import 'package:mileograph_flutter/components/pages/loco_legs.dart';
import 'package:mileograph_flutter/components/pages/loco_timeline.dart'; import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
import 'package:mileograph_flutter/components/pages/logbook.dart';
import 'package:mileograph_flutter/components/pages/more.dart';
import 'package:mileograph_flutter/components/pages/new_entry.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.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/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/trips.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
final GlobalKey<NavigatorState> _shellNavigatorKey = GlobalKey<NavigatorState>(); final GlobalKey<NavigatorState> _shellNavigatorKey =
GlobalKey<NavigatorState>();
const List<String> _contentPages = [ const List<String> _contentPages = [
"/", "/dashboard",
"/calculator", "/calculator",
"/legs", "/logbook",
"/traction", "/traction",
"/trips",
"/add", "/add",
"/more",
]; ];
const int _addTabIndex = 5; const List<String> _defaultTabDestinations = [
"/dashboard",
"/calculator",
"/logbook/entries",
"/traction",
"/add",
"/more",
];
const int _addTabIndex = 4;
class _NavItem { class _NavItem {
final String label; final String label;
@@ -40,18 +56,32 @@ class _NavItem {
const List<_NavItem> _navItems = [ const List<_NavItem> _navItems = [
_NavItem("Home", Icons.home), _NavItem("Home", Icons.home),
_NavItem("Calculator", Icons.route), _NavItem("Calculator", Icons.route),
_NavItem("Entries", Icons.list), _NavItem("Logbook", Icons.menu_book),
_NavItem("Traction", Icons.train), _NavItem("Traction", Icons.train),
_NavItem("Trips", Icons.book),
_NavItem("Add", Icons.add), _NavItem("Add", Icons.add),
_NavItem("More", Icons.more_horiz),
]; ];
int tabIndexForPath(String path) { int tabIndexForPath(String path) {
final newIndex = _contentPages.indexWhere((routePath) { var matchPath = path;
if (path == routePath) return true; if (matchPath == '/') matchPath = '/dashboard';
if (routePath == '/') return path == '/'; if (matchPath.startsWith('/dashboard')) return 0;
return path.startsWith('$routePath/'); if (matchPath.startsWith('/legs')) {
}); matchPath = '/logbook/entries';
} else if (matchPath.startsWith('/trips')) {
matchPath = '/logbook/trips';
}
if (matchPath.startsWith('/logbook')) {
matchPath = '/logbook';
} else if (matchPath.startsWith('/profile') ||
matchPath.startsWith('/settings') ||
matchPath.startsWith('/more')) {
matchPath = '/more';
}
final newIndex = _contentPages.indexWhere(
(routePath) =>
matchPath == routePath || matchPath.startsWith('$routePath/'),
);
return newIndex < 0 ? 0 : newIndex; return newIndex < 0 ? 0 : newIndex;
} }
@@ -79,35 +109,67 @@ class _MyAppState extends State<MyApp> {
_routerInitialized = true; _routerInitialized = true;
final auth = context.read<AuthService>(); final auth = context.read<AuthService>();
_router = GoRouter( _router = GoRouter(
initialLocation: '/dashboard',
refreshListenable: auth, refreshListenable: auth,
redirect: (context, state) { redirect: (context, state) {
final loggedIn = auth.isLoggedIn; final loggedIn = auth.isLoggedIn;
final loggingIn = state.uri.toString() == '/login'; final loggingIn = state.uri.toString() == '/login';
final atSettings = state.uri.toString() == '/settings';
if (!loggedIn && !loggingIn) return '/login'; if (!loggedIn && !loggingIn && !atSettings) return '/login';
if (loggedIn && loggingIn) return '/'; if (loggedIn && loggingIn) return '/dashboard';
return null; return null;
}, },
routes: [ routes: [
GoRoute(path: '/', redirect: (context, state) => '/dashboard'),
ShellRoute( ShellRoute(
navigatorKey: _shellNavigatorKey, navigatorKey: _shellNavigatorKey,
builder: (context, state, child) => MyHomePage(child: child), builder: (context, state, child) => MyHomePage(child: child),
routes: [ routes: [
GoRoute(path: '/', builder: (context, state) => const Dashboard()),
GoRoute( GoRoute(
path: '/calculator', path: '/dashboard',
builder: (context, state) => CalculatorPage(), builder: (context, state) => const Dashboard(),
), ),
GoRoute( GoRoute(
path: '/calculator/details', path: '/calculator',
builder: (context, state) => const CalculatorPage(),
routes: [
GoRoute(
path: 'details',
builder: (context, state) => builder: (context, state) =>
CalculatorDetailsPage(result: state.extra), CalculatorDetailsPage(result: state.extra),
), ),
GoRoute(path: '/legs', builder: (context, state) => LegsPage()), ],
),
GoRoute(
path: '/logbook',
redirect: (context, state) => '/logbook/entries',
),
GoRoute(
path: '/logbook/entries',
builder: (context, state) => const LogbookPage(),
),
GoRoute(
path: '/logbook/trips',
builder: (context, state) =>
const LogbookPage(initialTab: LogbookTab.trips),
),
GoRoute(
path: '/trips',
redirect: (context, state) => '/logbook/trips',
),
GoRoute(
path: '/legs',
redirect: (context, state) => '/logbook/entries',
),
GoRoute( GoRoute(
path: '/traction', path: '/traction',
builder: (context, state) => TractionPage(), builder: (context, state) => TractionPage(),
), ),
GoRoute(
path: '/profile',
builder: (context, state) => const ProfilePage(),
),
GoRoute( GoRoute(
path: '/traction/:id/timeline', path: '/traction/:id/timeline',
builder: (_, state) { builder: (_, state) {
@@ -144,8 +206,27 @@ class _MyAppState extends State<MyApp> {
path: '/traction/new', path: '/traction/new',
builder: (context, state) => const NewTractionPage(), builder: (context, state) => const NewTractionPage(),
), ),
GoRoute(path: '/trips', builder: (context, state) => TripsPage()),
GoRoute(path: '/add', builder: (context, state) => NewEntryPage()), GoRoute(path: '/add', builder: (context, state) => NewEntryPage()),
GoRoute(
path: '/more',
builder: (context, state) => const MorePage(),
),
GoRoute(
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( GoRoute(
path: '/legs/edit/:id', path: '/legs/edit/:id',
builder: (_, state) { builder: (_, state) {
@@ -156,7 +237,14 @@ 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(),
),
], ],
); );
} }
@@ -183,6 +271,14 @@ class _MyAppState extends State<MyApp> {
} }
} }
class _BackIntent extends Intent {
const _BackIntent();
}
class _ForwardIntent extends Intent {
const _ForwardIntent();
}
class MyHomePage extends StatefulWidget { class MyHomePage extends StatefulWidget {
final Widget child; final Widget child;
const MyHomePage({super.key, required this.child}); const MyHomePage({super.key, required this.child});
@@ -192,23 +288,32 @@ class MyHomePage extends StatefulWidget {
} }
class _MyHomePageState extends State<MyHomePage> { class _MyHomePageState extends State<MyHomePage> {
List<String> get contentPages => _contentPages; List<String> get tabDestinations => _defaultTabDestinations;
Future<void> _onItemTapped(int index, int currentIndex) async { Future<void> _onItemTapped(int index, int currentIndex) async {
if (index < 0 || index >= contentPages.length || index == currentIndex) { if (index < 0 || index >= tabDestinations.length) {
return; return;
} }
final currentPath = GoRouterState.of(context).uri.path;
final targetPath = tabDestinations[index];
final alreadyAtTarget =
currentPath == targetPath || currentPath.startsWith('$targetPath/');
if (index == currentIndex && alreadyAtTarget) return;
await NavigationGuard.attemptNavigation(() async { await NavigationGuard.attemptNavigation(() async {
if (!mounted) return; if (!mounted) return;
context.go(contentPages[index]); _navigateToIndex(index);
}); });
} }
int? _lastTabIndex; final List<String> _history = [];
final List<int> _tabHistory = []; int _historyPosition = -1;
bool _handlingBackNavigation = false; final List<String> _forwardHistory = [];
bool _suppressRecord = false;
bool _fetched = false; bool _fetched = false;
bool _railCollapsed = false;
Timer? _notificationsTimer;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -242,59 +347,55 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.tripDetails.isEmpty) { if (data.tripDetails.isEmpty) {
data.fetchTripDetails(); data.fetchTripDetails();
} }
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uri = GoRouterState.of(context).uri; final uri = GoRouterState.of(context).uri;
final pageIndex = tabIndexForPath(uri.path); final pageIndex = tabIndexForPath(uri.path);
_recordTabChange(pageIndex); _syncHistory(uri.path);
if (pageIndex != _addTabIndex) { if (pageIndex != _addTabIndex) {
NavigationGuard.unregister(); NavigationGuard.unregister();
} }
final homepageReady = context.select<DataService, bool>( final homepageReady = context.select<DataService, bool>(
(data) => data.homepageStats != null || !data.isHomepageLoading, (data) => data.homepageStats != null || !data.isHomepageLoading,
); );
final data = context.watch<DataService>();
final auth = context.read<AuthService>(); final auth = context.read<AuthService>();
final currentPage = homepageReady final currentPage = homepageReady
? widget.child ? widget.child
: const Center(child: CircularProgressIndicator()); : const Center(child: CircularProgressIndicator());
return PopScope( final scaffold = LayoutBuilder(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
final shellNav = _shellNavigatorKey.currentState;
if (shellNav != null && shellNav.canPop()) {
shellNav.pop();
return;
}
if (_tabHistory.isNotEmpty) {
final previousTab = _tabHistory.removeLast();
if (!mounted) return;
_handlingBackNavigation = true;
context.go(contentPages[previousTab]);
return;
}
if (pageIndex != 0) {
if (!mounted) return;
_handlingBackNavigation = true;
context.go(contentPages[0]);
return;
}
SystemNavigator.pop();
},
child: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth >= 900; final isWide = constraints.maxWidth >= 900;
final railExtended = constraints.maxWidth >= 1400; final defaultRailExtended = constraints.maxWidth >= 1400;
final railExtended = defaultRailExtended && !_railCollapsed;
final showRailToggle = defaultRailExtended;
final navRailDestinations = _navItems final navRailDestinations = _navItems
.map( .map(
(item) => NavigationRailDestination( (item) => NavigationRailDestination(
@@ -319,7 +420,10 @@ class _MyHomePageState extends State<MyHomePage> {
TextSpan( TextSpan(
children: const [ children: const [
TextSpan(text: "Mile"), TextSpan(text: "Mile"),
TextSpan(text: "O", style: TextStyle(color: Colors.red)), TextSpan(
text: "O",
style: TextStyle(color: Colors.red),
),
TextSpan(text: "graph"), TextSpan(text: "graph"),
], ],
style: const TextStyle( style: const TextStyle(
@@ -330,11 +434,16 @@ class _MyHomePageState extends State<MyHomePage> {
), ),
), ),
actions: [ actions: [
const IconButton( _buildNotificationAction(context, data),
onPressed: null, IconButton(
icon: Icon(Icons.account_circle), tooltip: 'Settings',
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 bottomNavigationBar: isWide
@@ -349,6 +458,14 @@ class _MyHomePageState extends State<MyHomePage> {
? Row( ? Row(
children: [ children: [
SafeArea( SafeArea(
child: LayoutBuilder(
builder: (ctx, _) {
return Stack(
children: [
Padding(
padding: EdgeInsets.only(
bottom: showRailToggle ? 56.0 : 0.0,
),
child: NavigationRail( child: NavigationRail(
selectedIndex: pageIndex, selectedIndex: pageIndex,
extended: railExtended, extended: railExtended,
@@ -360,6 +477,18 @@ class _MyHomePageState extends State<MyHomePage> {
destinations: navRailDestinations, destinations: navRailDestinations,
), ),
), ),
if (showRailToggle)
Positioned(
left: 0,
right: 0,
bottom: 8,
child: _buildRailToggleButton(railExtended),
),
],
);
},
),
),
const VerticalDivider(width: 1), const VerticalDivider(width: 1),
Expanded(child: currentPage), Expanded(child: currentPage),
], ],
@@ -367,27 +496,434 @@ class _MyHomePageState extends State<MyHomePage> {
: currentPage, : currentPage,
); );
}, },
);
return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.browserBack): const _BackIntent(),
LogicalKeySet(LogicalKeyboardKey.browserForward):
const _ForwardIntent(),
},
child: Actions(
actions: {
_BackIntent: CallbackAction<_BackIntent>(
onInvoke: (_) {
_handleBackNavigation(allowExit: false, recordForward: true);
return null;
},
),
_ForwardIntent: CallbackAction<_ForwardIntent>(
onInvoke: (_) {
_handleForwardNavigation();
return null;
},
),
},
child: Focus(
autofocus: true,
child: Listener(
onPointerDown: _handlePointerButtons,
behavior: HitTestBehavior.opaque,
child: PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) async {
if (didPop) return;
await _handleBackNavigation(
allowExit: true,
recordForward: false,
);
},
child: scaffold,
),
),
),
), ),
); );
} }
void _recordTabChange(int pageIndex) { void _handlePointerButtons(PointerDownEvent event) {
final last = _lastTabIndex; // Support mouse back/forward buttons.
if (last == null) { if (event.buttons == kBackMouseButton) {
_lastTabIndex = pageIndex; _handleBackNavigation(allowExit: false, recordForward: true);
return; } else if (event.buttons == kForwardMouseButton) {
_handleForwardNavigation();
} }
if (last == pageIndex) return;
if (_handlingBackNavigation) {
_handlingBackNavigation = false;
_lastTabIndex = pageIndex;
return;
} }
if (_tabHistory.isEmpty || _tabHistory.last != last) { Widget _buildRailToggleButton(bool railExtended) {
_tabHistory.add(last); final collapseIcon = railExtended
? Icons.chevron_left
: Icons.chevron_right;
final collapseLabel = railExtended ? 'Collapse' : 'Expand';
if (railExtended) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: TextButton.icon(
onPressed: () => setState(() => _railCollapsed = !_railCollapsed),
icon: Icon(collapseIcon),
label: Text(collapseLabel),
),
);
} }
_lastTabIndex = pageIndex;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: IconButton(
icon: Icon(collapseIcon),
tooltip: collapseLabel,
onPressed: () => setState(() => _railCollapsed = !_railCollapsed),
),
);
}
Widget _buildNotificationAction(BuildContext context, DataService data) {
final count = data.notifications.length;
final hasBadge = count > 0;
final badgeText = count > 9 ? '9+' : '$count';
final isLoading = data.isNotificationsLoading;
return Stack(
clipBehavior: Clip.none,
children: [
IconButton(
tooltip: 'Notifications',
onPressed: () => _openNotificationsPanel(context),
icon: isLoading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.notifications_none),
),
if (hasBadge)
Positioned(
right: 6,
top: 8,
child: IgnorePointer(child: _buildBadge(badgeText)),
),
],
);
}
Future<void> _openNotificationsPanel(BuildContext context) async {
final data = context.read<DataService>();
final isWide = MediaQuery.sizeOf(context).width >= 900;
final sheetHeight = MediaQuery.sizeOf(context).height * 0.9;
try {
await data.fetchNotifications();
} catch (_) {
// Already logged inside data service.
}
if (!context.mounted) return;
if (isWide) {
await showDialog(
context: context,
builder: (dialogCtx) => Dialog(
insetPadding: const EdgeInsets.all(16),
child: _buildNotificationsContent(dialogCtx, isWide),
),
);
} else {
await showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (sheetCtx) {
return SizedBox(
height: sheetHeight,
child: SafeArea(
child: _buildNotificationsContent(sheetCtx, isWide),
),
);
},
);
}
}
Widget _buildNotificationsContent(BuildContext context, bool isWide) {
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;
Widget body;
if (loading && notifications.isEmpty) {
body = const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 24.0),
child: CircularProgressIndicator(),
),
);
} else if (notifications.isEmpty) {
body = const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: Text('No notifications right now.'),
);
} else {
body = SizedBox(
height: listHeight,
child: ListView.separated(
itemCount: notifications.length,
separatorBuilder: (_, index) => const SizedBox(height: 8),
itemBuilder: (ctx, index) {
final item = notifications[index];
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
item.title.isNotEmpty
? item.title
: 'Notification',
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 4),
Text(
item.body,
style: Theme.of(context).textTheme.bodyMedium,
),
if (item.createdAt != null) ...[
const SizedBox(height: 6),
Text(
_formatNotificationTime(item.createdAt!),
style: Theme.of(context).textTheme.bodySmall
?.copyWith(
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);
return baseColor.withValues(
alpha: newAlpha,
);
}(),
),
),
],
],
),
),
const SizedBox(width: 8),
TextButton(
onPressed: () =>
_dismissNotifications(context, [item.id]),
child: const Text('Dismiss'),
),
],
),
],
),
),
);
},
),
);
}
return SizedBox(
width: isWide ? 420 : double.infinity,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Notifications',
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(),
),
child: const Text('Dismiss all'),
),
],
),
const SizedBox(height: 12),
body,
],
),
),
);
}
Future<void> _dismissNotifications(
BuildContext context,
List<int> ids,
) async {
if (ids.isEmpty) return;
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await context.read<DataService>().dismissNotifications(ids);
} catch (e) {
messenger?.showSnackBar(SnackBar(content: Text('Failed to dismiss: $e')));
}
}
String _formatNotificationTime(DateTime dateTime) {
final y = dateTime.year.toString().padLeft(4, '0');
final m = dateTime.month.toString().padLeft(2, '0');
final d = dateTime.day.toString().padLeft(2, '0');
final hh = dateTime.hour.toString().padLeft(2, '0');
final mm = dateTime.minute.toString().padLeft(2, '0');
return '$y-$m-$d $hh:$mm';
}
Widget _buildBadge(String label) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.redAccent,
borderRadius: BorderRadius.circular(10),
),
constraints: const BoxConstraints(minWidth: 20),
child: Text(
label,
style: const TextStyle(
color: Colors.white,
fontSize: 11,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
);
}
Future<bool> _handleBackNavigation({
bool allowExit = false,
bool recordForward = false,
}) async {
final currentPath = GoRouterState.of(context).uri.path;
final shellNav = _shellNavigatorKey.currentState;
if (shellNav != null && shellNav.canPop()) {
if (recordForward) _pushForward(currentPath);
_alignHistoryAfterPop(currentPath);
shellNav.pop();
return true;
}
if (_historyPosition > 0) {
if (recordForward) _pushForward(currentPath);
_historyPosition -= 1;
_suppressRecord = true;
context.go(_history[_historyPosition]);
return true;
}
final homePath = tabDestinations.first;
if (currentPath != homePath) {
if (recordForward) _pushForward(currentPath);
_suppressRecord = true;
context.go(homePath);
return true;
}
if (allowExit) {
SystemNavigator.pop();
return true;
}
return false;
}
Future<bool> _handleForwardNavigation() async {
if (_forwardHistory.isEmpty) return false;
final nextPath = _forwardHistory.removeLast();
// Move cursor forward, keeping history in sync.
if (_historyPosition < _history.length - 1) {
_historyPosition += 1;
_history[_historyPosition] = nextPath;
if (_historyPosition < _history.length - 1) {
_history.removeRange(_historyPosition + 1, _history.length);
}
} else {
_history.add(nextPath);
_historyPosition = _history.length - 1;
}
_suppressRecord = true;
if (!mounted) return false;
context.go(nextPath);
return true;
}
void _pushForward(String path) {
if (_forwardHistory.isEmpty || _forwardHistory.last != path) {
_forwardHistory.add(path);
}
}
void _alignHistoryAfterPop(String currentPath) {
if (_history.isEmpty) return;
if (_historyPosition >= 0 &&
_historyPosition < _history.length &&
_history[_historyPosition] == currentPath) {
if (_historyPosition > 0) {
_historyPosition -= 1;
}
_history.removeRange(_historyPosition + 1, _history.length);
_suppressRecord = true;
}
}
void _syncHistory(String path) {
if (_history.isEmpty) {
_history.add(path);
_historyPosition = 0;
return;
}
if (_suppressRecord) {
_suppressRecord = false;
return;
}
if (_historyPosition >= 0 &&
_historyPosition < _history.length &&
_history[_historyPosition] == path) {
return;
}
if (_historyPosition < _history.length - 1) {
_history.removeRange(_historyPosition + 1, _history.length);
}
_history.add(path);
_historyPosition = _history.length - 1;
_forwardHistory.clear();
}
void _navigateToIndex(int index) {
_suppressRecord = false;
_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 # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.3.0+1 version: 0.5.6+2
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1
@@ -102,7 +102,16 @@ flutter:
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
ios: true ios: true
remove_alpha_ios: true
linux:
generate: true
web:
generate: true
background_colour: "#000000"
theme_color: "#0175C2"
windows:
generate: true
image_path: assets/icons/app_icon.png image_path: assets/icons/app_icon.png
adaptive_icon_background: "#ffffff" adaptive_icon_background: "#000000"
adaptive_icon_foreground: assets/icons/app_icon.png adaptive_icon_foreground: assets/icons/app_icon.png
min_sdk_android: 21 min_sdk_android: 21

View File

@@ -8,13 +8,12 @@ void main() {
expect(tabIndexForPath('/calculator/details'), 1); expect(tabIndexForPath('/calculator/details'), 1);
expect(tabIndexForPath('/legs'), 2); expect(tabIndexForPath('/legs'), 2);
expect(tabIndexForPath('/traction/12/timeline'), 3); expect(tabIndexForPath('/traction/12/timeline'), 3);
expect(tabIndexForPath('/trips'), 4); expect(tabIndexForPath('/trips'), 2);
expect(tabIndexForPath('/add'), 5); expect(tabIndexForPath('/add'), 4);
}); });
test('tabIndexForPath ignores query when parsing uri', () { 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); 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 charset="UTF-8">
<meta content="IE=Edge" http-equiv="X-UA-Compatible"> <meta content="IE=Edge" http-equiv="X-UA-Compatible">
<meta name="description" content="A new Flutter project."> <meta name="description" content="Log and explore your Mileograph journeys.">
<meta name="theme-color" content="#0175C2">
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black"> <meta name="apple-mobile-web-app-status-bar-style" content="black">
<meta name="apple-mobile-web-app-title" content="mileograph_flutter"> <meta name="apple-mobile-web-app-title" content="Mileograph">
<link rel="apple-touch-icon" href="icons/Icon-192.png"> <link rel="apple-touch-icon" href="icons/Icon-192.png">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" type="image/png" href="favicon.png"/> <link rel="icon" type="image/png" href="favicon.png"/>
<title>mileograph_flutter</title> <title>Mileograph</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
</head> </head>
<body> <body>

View File

@@ -1,11 +1,12 @@
{ {
"name": "mileograph_flutter", "name": "Mileograph",
"short_name": "mileograph_flutter", "short_name": "Mileograph",
"start_url": ".", "start_url": "/",
"scope": "/",
"display": "standalone", "display": "standalone",
"background_color": "#0175C2", "background_color": "#0175C2",
"theme_color": "#0175C2", "theme_color": "#0175C2",
"description": "A new Flutter project.", "description": "Log and explore your Mileograph journeys.",
"orientation": "portrait-primary", "orientation": "portrait-primary",
"prefer_related_applications": false, "prefer_related_applications": false,
"icons": [ "icons": [

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

After

Width:  |  Height:  |  Size: 711 B