Compare commits

...

14 Commits

Author SHA1 Message Date
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
84 changed files with 3890 additions and 597 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

@@ -45,7 +45,7 @@ android {
defaultConfig { defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId = "com.petegregoryy.mileograph_flutter" applicationId = "com.iwdac.mileograph_flutter"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = flutter.minSdkVersion

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

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,
), ),
), ),
@@ -48,6 +51,7 @@ class RouteDetailsView extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final distanceUnits = context.watch<DistanceUnitService>();
final highlightColor = Theme.of(context).colorScheme.primary; final highlightColor = Theme.of(context).colorScheme.primary;
final mutedColor = Theme.of(context).colorScheme.outlineVariant; final mutedColor = Theme.of(context).colorScheme.outlineVariant;
return Column( return Column(
@@ -78,7 +82,9 @@ class RouteDetailsView extends StatelessWidget {
? TextStyle(color: highlightColor, fontWeight: FontWeight.w600) ? TextStyle(color: highlightColor, fontWeight: FontWeight.w600)
: null, : null,
), ),
trailing: Text("${costs[index].toStringAsFixed(2)} mi"), trailing: Text(
distanceUnits.format(costs[index], decimals: 2),
),
); );
}, },
), ),

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,6 +10,7 @@ class LeaderboardPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final leaderboard = data.homepageStats?.leaderboard ?? []; final leaderboard = data.homepageStats?.leaderboard ?? [];
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && leaderboard.isEmpty) { if (data.isHomepageLoading && leaderboard.isEmpty) {
@@ -82,7 +84,10 @@ class LeaderboardPanel extends StatelessWidget {
), ),
), ),
trailing: Text( trailing: Text(
'${leaderboard[index].mileage.toStringAsFixed(1)} mi', distanceUnits.format(
leaderboard[index].mileage,
decimals: 1,
),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),

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,6 +9,7 @@ class TopTractionPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats; final stats = data.homepageStats;
final locos = stats?.topLocos ?? []; final locos = stats?.topLocos ?? [];
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
@@ -78,7 +79,10 @@ class TopTractionPanel extends StatelessWidget {
), ),
), ),
trailing: Text( trailing: Text(
'${locos[index].mileage?.toStringAsFixed(1)} mi', distanceUnits.format(
locos[index].mileage ?? 0,
decimals: 1,
),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),

View File

@@ -1,9 +1,8 @@
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/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
class LegCard extends StatefulWidget { class LegCard extends StatefulWidget {
@@ -28,6 +27,7 @@ class _LegCardState extends State<LegCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final leg = widget.leg; final leg = widget.leg;
final distanceUnits = context.watch<DistanceUnitService>();
final routeSegments = _parseRouteSegments(leg.route); final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Card( return Card(
@@ -38,19 +38,72 @@ class _LegCardState extends State<LegCard> {
title: LayoutBuilder( title: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth > 520; final isWide = constraints.maxWidth > 520;
final routeText = Text('${leg.start}${leg.end}'); final beginTimeWidget = _timeWithDelay(
final timeText = context,
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate)); 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) { if (!isWide) {
return routeText; final timeStyle = Theme.of(context).textTheme.labelSmall;
} return Column(
return Row( crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
timeText, routeText,
const SizedBox(width: 6), 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('·'), const Text('·'),
const SizedBox(width: 6), _timeWithDelay(
Expanded(child: routeText), 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,
],
], ],
); );
}, },
@@ -58,8 +111,6 @@ class _LegCardState extends State<LegCard> {
subtitle: LayoutBuilder( subtitle: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth > 520; final isWide = constraints.maxWidth > 520;
final timeWidget =
Text(_formatDateTime(leg.beginTime, includeDate: widget.showDate));
final tractionWrap = !_expanded && leg.locos.isNotEmpty final tractionWrap = !_expanded && leg.locos.isNotEmpty
? Wrap( ? Wrap(
spacing: 8, spacing: 8,
@@ -90,9 +141,7 @@ class _LegCardState extends State<LegCard> {
children.add(tractionWrap); children.add(tractionWrap);
} }
} else { } else {
children.add(timeWidget);
if (tractionWrap != null) { if (tractionWrap != null) {
children.add(const SizedBox(height: 4));
children.add(tractionWrap); children.add(tractionWrap);
} }
} }
@@ -128,7 +177,7 @@ class _LegCardState extends State<LegCard> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
'${leg.mileage.toStringAsFixed(1)} mi', distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
@@ -191,6 +240,12 @@ class _LegCardState extends State<LegCard> {
), ),
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),
@@ -238,6 +293,40 @@ class _LegCardState extends State<LegCard> {
} }
} }
Widget _timeWithDelay(
BuildContext context,
DateTime time,
int? delay, {
bool includeDate = true,
TextStyle? style,
}) {
final textTheme = Theme.of(context).textTheme;
final colorScheme = Theme.of(context).colorScheme;
final delayMinutes = delay ?? 0;
final delayText =
delayMinutes == 0 ? null : '${delayMinutes > 0 ? '+' : ''}$delayMinutes';
final delayColor = delayMinutes == 0
? null
: (delayMinutes < 0 ? Colors.green : colorScheme.error);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
_formatDateTime(time, includeDate: includeDate),
style: style,
),
if (delayText != null) ...[
const SizedBox(width: 4),
Text(
'$delayText m',
style:
(style ?? textTheme.labelSmall)?.copyWith(color: delayColor),
),
],
],
);
}
String _formatDate(DateTime? date) { 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')}';
@@ -286,6 +375,51 @@ class _LegCardState extends State<LegCard> {
.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,
@@ -306,38 +440,7 @@ class _LegCardState extends State<LegCard> {
); );
} }
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

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

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,7 +241,11 @@ class _LegsPageState extends State<LegsPage> {
); );
} }
List<Widget> _buildLegsWithDividers(BuildContext context, List<Leg> legs) { List<Widget> _buildLegsWithDividers(
BuildContext context,
List<Leg> legs,
DistanceUnitService distanceUnits,
) {
final widgets = <Widget>[]; final widgets = <Widget>[];
String? currentDate; String? currentDate;
double dayMileage = 0; double dayMileage = 0;
@@ -261,10 +267,8 @@ class _LegsPageState extends State<LegsPage> {
), ),
), ),
), ),
Text( Text(distanceUnits.format(dayMileage, decimals: 1),
'${dayMileage.toStringAsFixed(1)} mi', style: Theme.of(context).textTheme.labelMedium),
style: Theme.of(context).textTheme.labelMedium,
),
], ],
), ),
), ),

View File

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

View File

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

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);
@@ -148,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
@@ -202,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

@@ -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

@@ -3,7 +3,9 @@ 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:http/http.dart' as http; 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/api_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/endpoint_service.dart'; import 'package:mileograph_flutter/services/endpoint_service.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
@@ -174,7 +176,11 @@ class _SettingsPageState extends State<SettingsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final endpointService = context.watch<EndpointService>(); final endpointService = context.watch<EndpointService>();
if (!endpointService.isLoaded) { final distanceUnitService = context.watch<DistanceUnitService>();
final loggedIn = context.select<AuthService, bool>(
(auth) => auth.isLoggedIn,
);
if (!endpointService.isLoaded || !distanceUnitService.isLoaded) {
return const Scaffold( return const Scaffold(
body: Center(child: CircularProgressIndicator()), body: Center(child: CircularProgressIndicator()),
); );
@@ -200,6 +206,34 @@ class _SettingsPageState extends State<SettingsPage> {
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(
'Distance units',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Choose how distances are displayed across the app.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
SegmentedButton<DistanceUnit>(
segments: DistanceUnit.values
.map(
(unit) => ButtonSegment<DistanceUnit>(
value: unit,
label: Text(unit.label),
),
)
.toList(),
selected: {distanceUnitService.unit},
onSelectionChanged: (selection) {
final next = selection.first;
distanceUnitService.setUnit(next);
},
),
const SizedBox(height: 24),
Text( Text(
'API endpoint', 'API endpoint',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
@@ -251,6 +285,7 @@ class _SettingsPageState extends State<SettingsPage> {
'Current: ${endpointService.baseUrl}', 'Current: ${endpointService.baseUrl}',
style: Theme.of(context).textTheme.labelSmall, style: Theme.of(context).textTheme.labelSmall,
), ),
if (loggedIn) ...[
const SizedBox(height: 32), const SizedBox(height: 32),
Text( Text(
'Account', 'Account',
@@ -343,6 +378,7 @@ class _SettingsPageState extends State<SettingsPage> {
), ),
), ),
], ],
],
), ),
), ),
); );

View File

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

View File

@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart'; import 'package:mileograph_flutter/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

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

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 {
@@ -27,10 +28,6 @@ class _TripsPageState extends State<TripsPage> {
_tripLocoStatsFutures.clear(); _tripLocoStatsFutures.clear();
final data = context.read<DataService>(); final data = context.read<DataService>();
await data.fetchTripDetails(); await data.fetchTripDetails();
if (!mounted) return;
for (final trip in data.tripDetails) {
_tripStatsFuture(trip.id);
}
} }
Future<void> _renameTrip(TripDetail trip, String newName) async { Future<void> _renameTrip(TripDetail trip, String newName) async {
@@ -42,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')),
@@ -54,10 +48,24 @@ class _TripsPageState extends State<TripsPage> {
} }
} }
Future<List<TripLocoStat>> _tripStatsFuture(int tripId) { List<TripLocoStat> _cachedTripStats(
TripDetail trip,
TripSummary? summary,
) {
if (trip.locoStats.isNotEmpty) return trip.locoStats;
if (summary?.locoStats.isNotEmpty == true) return summary!.locoStats;
return const [];
}
Future<List<TripLocoStat>> _loadTripStats(
TripDetail trip,
TripSummary? summary,
) {
final cached = _cachedTripStats(trip, summary);
if (cached.isNotEmpty) return Future.value(cached);
return _tripLocoStatsFutures.putIfAbsent( return _tripLocoStatsFutures.putIfAbsent(
tripId, trip.id,
() => context.read<DataService>().fetchTripLocoStats(tripId), () => context.read<DataService>().fetchTripLocoStats(trip.id),
); );
} }
@@ -92,8 +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 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(
@@ -178,24 +190,33 @@ 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); final summary = summaryById[trip.id];
return _buildTripCard(context, trip, summary);
}, },
), ),
); );
} }
Widget _buildTripCard(BuildContext context, TripDetail trip) { Widget _buildTripCard(
BuildContext context,
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.watch<DistanceUnitService>();
final legs = trip.legs; final legs = trip.legs;
final legCount = trip.legCount > 0 ? trip.legCount : legs.length; final legCount =
trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length;
final dateRange = _formatDateRange(legs); final dateRange = _formatDateRange(legs);
final endpoints = _formatEndpoints(legs); final endpoints = _formatEndpoints(legs);
final statsFuture = _tripStatsFuture(trip.id); final stats = _cachedTripStats(trip, summary);
final winnerCount = stats.where((e) => e.won).length;
return Card( return Card(
child: Padding( child: Padding(
@@ -211,7 +232,7 @@ class _TripsPageState extends State<TripsPage> {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Trip', 'Trip #${trip.id}',
style: Theme.of(context).textTheme.labelMedium, style: Theme.of(context).textTheme.labelMedium,
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -228,67 +249,36 @@ class _TripsPageState extends State<TripsPage> {
crossAxisAlignment: CrossAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
Text( Text(
trip.mileage.toStringAsFixed(1), distanceUnits.format(trip.mileage, decimals: 1),
style: style:
Theme.of(context).textTheme.headlineSmall?.copyWith( Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
), ),
Text(
'miles',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
], ],
), ),
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
FutureBuilder<List<TripLocoStat>>( Wrap(
future: statsFuture, spacing: 8,
builder: (context, snapshot) { runSpacing: 8,
final chips = <Widget>[ children: [
_buildMetaChip(context, Icons.timeline, '$legCount legs'), _buildMetaChip(context, Icons.timeline, '$legCount legs'),
if (dateRange != null) if (dateRange != null)
_buildMetaChip(context, Icons.calendar_month, dateRange), _buildMetaChip(context, Icons.calendar_month, dateRange),
if (endpoints != null) if (endpoints != null)
_buildMetaChip(context, Icons.route, endpoints), _buildMetaChip(context, Icons.route, endpoints),
]; if (stats.isNotEmpty) ...[
final stats = snapshot.data ?? const [];
final hasStats = stats.isNotEmpty;
final loading =
snapshot.connectionState == ConnectionState.waiting;
if (loading && !hasStats) {
chips.add(
_buildMetaChip(context, Icons.train, 'Loading traction...'),
);
} else if (hasStats) {
final winnerCount = stats.where((e) => e.won).length;
chips.add(
_buildMetaChip(context, Icons.train, '${stats.length} had'), _buildMetaChip(context, Icons.train, '${stats.length} had'),
);
chips.add(
_buildMetaChip( _buildMetaChip(
context, context,
Icons.emoji_events_outlined, Icons.emoji_events_outlined,
'$winnerCount winners', '$winnerCount winners',
), ),
); ] else
} else if (snapshot.connectionState == ConnectionState.done) {
chips.add(
_buildMetaChip(context, Icons.train, 'No traction yet'), _buildMetaChip(context, Icons.train, 'No traction yet'),
); ],
}
return Wrap(
spacing: 8,
runSpacing: 8,
children: chips,
);
},
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Align( Align(
@@ -301,7 +291,7 @@ class _TripsPageState extends State<TripsPage> {
OutlinedButton.icon( OutlinedButton.icon(
icon: const Icon(Icons.train), icon: const Icon(Icons.train),
label: const Text('Locos'), label: const Text('Locos'),
onPressed: () => _showTripWinners(context, trip), onPressed: () => _showTripWinners(context, trip, summary),
), ),
FilledButton.icon( FilledButton.icon(
icon: const Icon(Icons.open_in_new), icon: const Icon(Icons.open_in_new),
@@ -375,6 +365,7 @@ class _TripsPageState extends State<TripsPage> {
} }
void _showTripDetail(BuildContext context, TripDetail trip) { void _showTripDetail(BuildContext context, TripDetail trip) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -499,7 +490,9 @@ class _TripsPageState extends State<TripsPage> {
), ),
], ],
const SizedBox(width: 4), const SizedBox(width: 4),
Text('${trip.mileage.toStringAsFixed(1)} mi'), Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -514,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),
), ),
@@ -532,14 +530,20 @@ class _TripsPageState extends State<TripsPage> {
); );
} }
void _showTripWinners(BuildContext context, TripDetail trip) { void _showTripWinners(
BuildContext context,
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (_) { builder: (_) {
return SafeArea( return SafeArea(
child: FutureBuilder<List<TripLocoStat>>( child: FutureBuilder<List<TripLocoStat>>(
future: _tripStatsFuture(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 =
@@ -564,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(
@@ -151,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,
@@ -203,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,
@@ -275,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,
@@ -285,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(),
);
},
),
], ],
), ),
), ),
@@ -297,6 +370,105 @@ Future<void> showTractionDetails(
); );
} }
Future<List<LeaderboardEntry>> _fetchLocoLeaderboard(
ApiService api,
int locoId,
) async {
try {
final json = await api.get('/loco/leaderboard/id/$locoId');
Iterable<dynamic>? raw;
if (json is List) {
raw = json;
} else if (json is Map) {
for (final key in ['data', 'leaderboard', 'results']) {
final value = json[key];
if (value is List) {
raw = value;
break;
}
}
}
if (raw == null) return const [];
return raw.whereType<Map>().map((e) {
return LeaderboardEntry.fromJson(
e.map((key, value) => MapEntry(key.toString(), value)),
);
}).toList();
} catch (e) {
debugPrint('Failed to fetch loco leaderboard for $locoId: $e');
rethrow;
}
}
int? _leaderboardId(LocoSummary loco) {
int? parse(dynamic value) {
if (value == null) return null;
if (value is int) return value == 0 ? null : value;
if (value is num) return value.toInt() == 0 ? null : value.toInt();
return int.tryParse(value.toString());
}
return parse(loco.extra['loco_id']) ??
parse(loco.extra['id']) ??
parse(loco.id);
}
Widget _leaderboardRow(
BuildContext context,
int rank,
LeaderboardEntry entry,
DistanceUnitService distanceUnits,
) {
final theme = Theme.of(context);
final primaryName =
entry.userFullName.isNotEmpty ? entry.userFullName : entry.username;
final mileageLabel = distanceUnits.format(entry.mileage, decimals: 1);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'#$rank',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
primaryName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
Text(
mileageLabel,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Widget _detailRow(BuildContext context, String label, String value) { 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(
@@ -368,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;
@@ -210,7 +509,7 @@ class LocoSummary extends Loco {
); );
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'] ?? "",
@@ -487,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({
@@ -523,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']),
@@ -543,7 +936,16 @@ 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 {
@@ -625,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>))
@@ -649,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>))
@@ -712,6 +1159,26 @@ class TripLocoStat {
); );
} }
static List<TripLocoStat> listFromJson(dynamic json) {
List<dynamic>? list;
if (json is List) {
list = json.expand((e) => e is List ? e : [e]).toList();
} else if (json is Map) {
for (final key in ['locos', 'stats', 'data', 'trip_locos']) {
final candidate = json[key];
if (candidate is List) {
list = candidate.expand((e) => e is List ? e : [e]).toList();
break;
}
}
}
if (list == null) return const [];
return list
.whereType<Map>()
.map((e) => TripLocoStat.fromJson(Map<String, dynamic>.from(e)))
.toList();
}
static bool _parseWonFlag(dynamic value) { 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;
@@ -755,6 +1222,7 @@ class UserNotification {
final int id; final int id;
final String title; final String title;
final String body; final String body;
final String channel;
final DateTime? createdAt; final DateTime? createdAt;
final bool dismissed; final bool dismissed;
@@ -762,6 +1230,7 @@ class UserNotification {
required this.id, required this.id,
required this.title, required this.title,
required this.body, required this.body,
required this.channel,
required this.createdAt, required this.createdAt,
required this.dismissed, required this.dismissed,
}); });
@@ -778,6 +1247,7 @@ class UserNotification {
id: _asInt(json['notification_id'] ?? json['id']), id: _asInt(json['notification_id'] ?? json['id']),
title: _asString(json['title']), title: _asString(json['title']),
body: _asString(json['body']), body: _asString(json['body']),
channel: _asString(json['channel']),
createdAt: createdAt, createdAt: createdAt,
dismissed: _asBool(json['dismissed'] ?? false, false), dismissed: _asBool(json['dismissed'] ?? false, false),
); );

View File

@@ -128,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) {
@@ -149,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

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

View File

@@ -28,6 +28,10 @@ class DataService extends ChangeNotifier {
// 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 = [];
@@ -147,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;

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ extension DataServiceTraction on DataService {
Future<void> fetchTraction({ 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,

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

@@ -1,8 +1,12 @@
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/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/pages/calculator.dart';
import 'package:mileograph_flutter/components/pages/calculator_details.dart';
import 'package:mileograph_flutter/components/login/login.dart'; import 'package:mileograph_flutter/components/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/loco_legs.dart'; import 'package:mileograph_flutter/components/pages/loco_legs.dart';
@@ -13,16 +17,19 @@ import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/profile.dart'; import 'package:mileograph_flutter/components/pages/profile.dart';
import 'package:mileograph_flutter/components/pages/settings.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/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", "/dashboard",
"/calculator",
"/logbook", "/logbook",
"/traction", "/traction",
"/add", "/add",
@@ -31,13 +38,14 @@ const List<String> _contentPages = [
const List<String> _defaultTabDestinations = [ const List<String> _defaultTabDestinations = [
"/dashboard", "/dashboard",
"/calculator",
"/logbook/entries", "/logbook/entries",
"/traction", "/traction",
"/add", "/add",
"/more", "/more",
]; ];
const int _addTabIndex = 3; const int _addTabIndex = 4;
class _NavItem { class _NavItem {
final String label; final String label;
@@ -47,6 +55,7 @@ class _NavItem {
const List<_NavItem> _navItems = [ const List<_NavItem> _navItems = [
_NavItem("Home", Icons.home), _NavItem("Home", Icons.home),
_NavItem("Calculator", Icons.route),
_NavItem("Logbook", Icons.menu_book), _NavItem("Logbook", Icons.menu_book),
_NavItem("Traction", Icons.train), _NavItem("Traction", Icons.train),
_NavItem("Add", Icons.add), _NavItem("Add", Icons.add),
@@ -112,10 +121,7 @@ class _MyAppState extends State<MyApp> {
return null; return null;
}, },
routes: [ routes: [
GoRoute( GoRoute(path: '/', redirect: (context, state) => '/dashboard'),
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),
@@ -124,6 +130,17 @@ class _MyAppState extends State<MyApp> {
path: '/dashboard', path: '/dashboard',
builder: (context, state) => const Dashboard(), builder: (context, state) => const Dashboard(),
), ),
GoRoute(
path: '/calculator',
builder: (context, state) => const CalculatorPage(),
routes: [
GoRoute(
path: 'details',
builder: (context, state) =>
CalculatorDetailsPage(result: state.extra),
),
],
),
GoRoute( GoRoute(
path: '/logbook', path: '/logbook',
redirect: (context, state) => '/logbook/entries', redirect: (context, state) => '/logbook/entries',
@@ -198,10 +215,18 @@ class _MyAppState extends State<MyApp> {
path: '/more/profile', path: '/more/profile',
builder: (context, state) => const ProfilePage(), builder: (context, state) => const ProfilePage(),
), ),
GoRoute(
path: '/more/stats',
builder: (context, state) => const StatsPage(),
),
GoRoute( GoRoute(
path: '/more/settings', path: '/more/settings',
builder: (context, state) => const SettingsPage(), 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) {
@@ -212,7 +237,10 @@ class _MyAppState extends State<MyApp> {
), ),
], ],
), ),
GoRoute(path: '/login', builder: (context, state) => const LoginScreen()), GoRoute(
path: '/login',
builder: (context, state) => const LoginScreen(),
),
GoRoute( GoRoute(
path: '/settings', path: '/settings',
builder: (context, state) => const SettingsPage(), builder: (context, state) => const SettingsPage(),
@@ -285,6 +313,7 @@ class _MyHomePageState extends State<MyHomePage> {
bool _fetched = false; bool _fetched = false;
bool _railCollapsed = false; bool _railCollapsed = false;
Timer? _notificationsTimer;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -321,10 +350,28 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.notifications.isEmpty) { if (data.notifications.isEmpty) {
data.fetchNotifications(); 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;
@@ -373,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(
@@ -390,7 +440,10 @@ class _MyHomePageState extends State<MyHomePage> {
onPressed: () => context.go('/more/settings'), onPressed: () => context.go('/more/settings'),
icon: const Icon(Icons.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
@@ -448,7 +501,8 @@ class _MyHomePageState extends State<MyHomePage> {
return Shortcuts( return Shortcuts(
shortcuts: <LogicalKeySet, Intent>{ shortcuts: <LogicalKeySet, Intent>{
LogicalKeySet(LogicalKeyboardKey.browserBack): const _BackIntent(), LogicalKeySet(LogicalKeyboardKey.browserBack): const _BackIntent(),
LogicalKeySet(LogicalKeyboardKey.browserForward): const _ForwardIntent(), LogicalKeySet(LogicalKeyboardKey.browserForward):
const _ForwardIntent(),
}, },
child: Actions( child: Actions(
actions: { actions: {
@@ -474,7 +528,10 @@ class _MyHomePageState extends State<MyHomePage> {
canPop: false, canPop: false,
onPopInvokedWithResult: (didPop, _) async { onPopInvokedWithResult: (didPop, _) async {
if (didPop) return; if (didPop) return;
await _handleBackNavigation(allowExit: true, recordForward: false); await _handleBackNavigation(
allowExit: true,
recordForward: false,
);
}, },
child: scaffold, child: scaffold,
), ),
@@ -494,7 +551,9 @@ class _MyHomePageState extends State<MyHomePage> {
} }
Widget _buildRailToggleButton(bool railExtended) { Widget _buildRailToggleButton(bool railExtended) {
final collapseIcon = railExtended ? Icons.chevron_left : Icons.chevron_right; final collapseIcon = railExtended
? Icons.chevron_left
: Icons.chevron_right;
final collapseLabel = railExtended ? 'Collapse' : 'Expand'; final collapseLabel = railExtended ? 'Collapse' : 'Expand';
if (railExtended) { if (railExtended) {
@@ -587,8 +646,9 @@ class _MyHomePageState extends State<MyHomePage> {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final notifications = data.notifications; final notifications = data.notifications;
final loading = data.isNotificationsLoading; final loading = data.isNotificationsLoading;
final listHeight = final listHeight = isWide
isWide ? 380.0 : MediaQuery.of(context).size.height * 0.6; ? 380.0
: MediaQuery.of(context).size.height * 0.6;
Widget body; Widget body;
if (loading && notifications.isEmpty) { if (loading && notifications.isEmpty) {
@@ -628,9 +688,7 @@ class _MyHomePageState extends State<MyHomePage> {
item.title.isNotEmpty item.title.isNotEmpty
? item.title ? item.title
: 'Notification', : 'Notification',
style: Theme.of(context) style: Theme.of(context).textTheme.titleMedium
.textTheme
.titleMedium
?.copyWith(fontWeight: FontWeight.w700), ?.copyWith(fontWeight: FontWeight.w700),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -642,18 +700,15 @@ class _MyHomePageState extends State<MyHomePage> {
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
_formatNotificationTime(item.createdAt!), _formatNotificationTime(item.createdAt!),
style: Theme.of(context) style: Theme.of(context).textTheme.bodySmall
.textTheme
.bodySmall
?.copyWith( ?.copyWith(
color: () { color: () {
final baseColor = Theme.of(context) final baseColor = Theme.of(
.textTheme context,
.bodySmall ).textTheme.bodySmall?.color;
?.color;
if (baseColor == null) return null; if (baseColor == null) return null;
final newAlpha = final newAlpha = (baseColor.a * 0.7)
(baseColor.a * 0.7).clamp(0.0, 1.0); .clamp(0.0, 1.0);
return baseColor.withValues( return baseColor.withValues(
alpha: newAlpha, alpha: newAlpha,
); );
@@ -666,10 +721,8 @@ class _MyHomePageState extends State<MyHomePage> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
TextButton( TextButton(
onPressed: () => _dismissNotifications( onPressed: () =>
context, _dismissNotifications(context, [item.id]),
[item.id],
),
child: const Text('Dismiss'), child: const Text('Dismiss'),
), ),
], ],
@@ -695,10 +748,9 @@ class _MyHomePageState extends State<MyHomePage> {
children: [ children: [
Text( Text(
'Notifications', 'Notifications',
style: Theme.of(context) style: Theme.of(
.textTheme context,
.titleLarge ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold),
?.copyWith(fontWeight: FontWeight.bold),
), ),
const Spacer(), const Spacer(),
TextButton( TextButton(
@@ -729,9 +781,7 @@ class _MyHomePageState extends State<MyHomePage> {
try { try {
await context.read<DataService>().dismissNotifications(ids); await context.read<DataService>().dismissNotifications(ids);
} catch (e) { } catch (e) {
messenger?.showSnackBar( messenger?.showSnackBar(SnackBar(content: Text('Failed to dismiss: $e')));
SnackBar(content: Text('Failed to dismiss: $e')),
);
} }
} }
@@ -751,9 +801,7 @@ class _MyHomePageState extends State<MyHomePage> {
color: Colors.redAccent, color: Colors.redAccent,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
constraints: const BoxConstraints( constraints: const BoxConstraints(minWidth: 20),
minWidth: 20,
),
child: Text( child: Text(
label, label,
style: const TextStyle( style: const TextStyle(
@@ -872,4 +920,10 @@ class _MyHomePageState extends State<MyHomePage> {
_forwardHistory.clear(); _forwardHistory.clear();
context.go(tabDestinations[index]); 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.4.3+1 version: 0.5.5+1
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1
@@ -102,7 +102,16 @@ flutter:
flutter_launcher_icons: flutter_launcher_icons:
android: true android: true
ios: true ios: true
remove_alpha_ios: true
linux:
generate: true
web:
generate: true
background_colour: "#000000"
theme_color: "#0175C2"
windows:
generate: true
image_path: assets/icons/app_icon.png image_path: assets/icons/app_icon.png
adaptive_icon_background: "#ffffff" adaptive_icon_background: "#000000"
adaptive_icon_foreground: assets/icons/app_icon.png adaptive_icon_foreground: assets/icons/app_icon.png
min_sdk_android: 21 min_sdk_android: 21

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