Compare commits

...

42 Commits

Author SHA1 Message Date
06bed86a49 Add accent colour picker, fix empty user card when accepting friend request, add button to transfer allocations
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 56s
Release / web-build (push) Successful in 2m15s
Release / android-build (push) Successful in 6m47s
Release / release-master (push) Successful in 19s
Release / release-dev (push) Successful in 21s
2026-01-06 00:21:19 +00:00
d5083e1cc7 add ability for non admins to add new traction, pending approval. Various QoL updates
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 57s
Release / web-build (push) Successful in 1m14s
Release / android-build (push) Successful in 5m33s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s
2026-01-05 22:11:02 +00:00
a755644c31 revert web build changes
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 1m2s
Release / web-build (push) Successful in 1m19s
Release / android-build (push) Successful in 5m12s
Release / release-dev (push) Successful in 20s
Release / release-master (push) Successful in 19s
2026-01-05 01:30:52 +00:00
a14faeedbe update web build
Some checks failed
Release / meta (push) Successful in 6s
Release / web-build (push) Failing after 35s
Release / linux-build (push) Successful in 46s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-05 01:21:40 +00:00
8ab3f53c0d Add accepted leg edit notification and class leaderboard
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 1m0s
Release / web-build (push) Successful in 2m6s
Release / android-build (push) Successful in 6m8s
Release / release-master (push) Successful in 16s
Release / release-dev (push) Successful in 19s
2026-01-05 01:09:43 +00:00
42ac7a97e1 add profile page, privacy options
All checks were successful
Release / meta (push) Successful in 11s
Release / linux-build (push) Successful in 1m0s
Release / web-build (push) Successful in 2m29s
Release / android-build (push) Successful in 10m26s
Release / release-master (push) Successful in 37s
Release / release-dev (push) Successful in 49s
2026-01-04 19:50:06 +00:00
af37e25692 fix saving draft with shared user, display user shared to/from in expanded leg card
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 58s
Release / web-build (push) Successful in 2m0s
Release / android-build (push) Successful in 9m58s
Release / release-master (push) Successful in 37s
Release / release-dev (push) Successful in 44s
2026-01-03 23:26:33 +00:00
Jack
1689869ce5 Update release.yml
Some checks failed
Release / android-build (push) Blocked by required conditions
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 58s
Release / web-build (push) Successful in 1m18s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-03 22:58:02 +00:00
Jack
425ab46656 Update release.yml
Some checks failed
Release / meta (push) Successful in 7s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / web-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
2026-01-03 22:57:32 +00:00
196511dfab unify pipeline, load friends leaderboard from homepage data
Some checks failed
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 57s
Release / web-build (push) Failing after 1m11s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:55:03 +00:00
Jack
23f294c6f0 Update README.md
All checks were successful
Release / meta (push) Successful in 14s
Release / linux-build (push) Successful in 1m9s
Release / web-build (push) Successful in 1m15s
Release / android-build (push) Successful in 5m14s
Release / release-dev (push) Successful in 17s
2026-01-03 22:48:36 +00:00
Jack
8e1bd05040 Update dev.yml
Some checks failed
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 46s
Release / web-build (push) Successful in 2m11s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:44:19 +00:00
Jack
2b8cb8342a Update dev.yml
Some checks failed
Release / meta (push) Successful in 21s
Release / web-build (push) Failing after 1m10s
Release / linux-build (push) Successful in 1m13s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:39:29 +00:00
Jack
f8bc962c84 Update dev.yml
Some checks failed
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 46s
Release / web-build (push) Failing after 1m46s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:34:09 +00:00
Jack
0650a140c3 Update dev.yml
Some checks failed
Release / meta (push) Successful in 17s
Release / linux-build (push) Successful in 44s
Release / web-build (push) Failing after 2m4s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:31:28 +00:00
Jack
7d4db0af5f Update dev.yml
Some checks failed
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 50s
Release / web-build (push) Failing after 1m3s
Release / android-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
2026-01-03 22:27:24 +00:00
Jack
a4ab84e9a9 Update dev.yml
Some checks failed
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 44s
Release / web-build (push) Failing after 2m4s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:23:03 +00:00
Jack
e3f35cf1a0 Update dev.yml
Some checks failed
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 1m2s
Release / web-build (push) Failing after 1m16s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:19:53 +00:00
Jack
b5fc68c6f6 Update dev.yml
Some checks failed
Release / meta (push) Successful in 21s
Release / linux-build (push) Successful in 1m8s
Release / web-build (push) Failing after 2m35s
Release / release-dev (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-03 22:11:00 +00:00
Jack
30fab18946 Testing the build flow
Some checks failed
Release / meta (push) Successful in 2s
Release / android-build (push) Successful in 5m19s
Release / linux-build (push) Successful in 5m57s
Release / web-build (push) Failing after 7m30s
Release / release-dev (push) Has been skipped
2026-01-03 21:48:26 +00:00
ff38c3f838 fix leaderboard formatting, save shared users to drafts, display shared legs
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m59s
Release / web-build (push) Successful in 6m37s
Release / android-build (push) Successful in 18m3s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s
2026-01-03 14:14:31 +00:00
69bd6f688a Add friends leaderboard, reverse button in add page
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 7m11s
Release / web-build (push) Successful in 5m3s
Release / android-build (push) Successful in 18m23s
Release / release-master (push) Successful in 22s
Release / release-dev (push) Successful in 25s
2026-01-03 13:22:43 +00:00
89b760508d Add new friends system, and sharing legs support
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m37s
Release / web-build (push) Successful in 5m29s
Release / android-build (push) Successful in 15m58s
Release / release-master (push) Successful in 20s
Release / release-dev (push) Successful in 26s
2026-01-03 01:07:08 +00:00
42af39b442 fix issue with version and app id
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m42s
Release / web-build (push) Successful in 5m38s
Release / android-build (push) Successful in 15m17s
Release / release-master (push) Successful in 24s
Release / release-dev (push) Successful in 28s
2026-01-02 19:10:32 +00:00
2fa66ff9c6 add admin page, fix no legs infinite load on dashboard
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m49s
Release / web-build (push) Successful in 6m23s
Release / android-build (push) Successful in 16m56s
Release / release-master (push) Successful in 30s
Release / release-dev (push) Successful in 32s
2026-01-02 18:49:14 +00:00
29cecf0ded add android bundle release
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m32s
Release / web-build (push) Successful in 5m50s
Release / android-build (push) Successful in 20m43s
Release / release-master (push) Successful in 26s
Release / release-dev (push) Successful in 28s
2026-01-02 14:34:11 +00:00
f9c392bb07 new app icon
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 7m21s
Release / web-build (push) Successful in 7m30s
Release / android-build (push) Successful in 19m56s
Release / release-dev (push) Successful in 25s
Release / release-master (push) Successful in 24s
2026-01-02 00:18:57 +00:00
e5b145b4b2 increment verison
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 6m28s
Release / web-build (push) Successful in 6m54s
Release / android-build (push) Successful in 18m9s
Release / release-master (push) Successful in 38s
Release / release-dev (push) Successful in 41s
2026-01-01 23:13:18 +00:00
59458484aa add build step for flutter web, add persistent pagination in the traction list
Some checks failed
Release / meta (push) Successful in 14s
Release / web-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / android-build (push) Has been cancelled
2026-01-01 23:08:22 +00:00
648872acf1 re add mileograph tags
All checks were successful
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m40s
Release / android-build (push) Successful in 14m28s
Release / release-master (push) Successful in 32s
Release / release-dev (push) Successful in 36s
2026-01-01 18:06:50 +00:00
b427ed0bd3 fix failed flutter install
Some checks failed
Release / meta (push) Successful in 2s
Release / android-build (push) Failing after 2m7s
Release / linux-build (push) Failing after 2m6s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2026-01-01 17:26:54 +00:00
66a1d149f0 remove mileograph tag
Some checks failed
Release / meta (push) Successful in 26s
Release / android-build (push) Failing after 2m38s
Release / linux-build (push) Failing after 2m58s
Release / release-dev (push) Has been skipped
Release / release-master (push) Has been skipped
2026-01-01 17:11:55 +00:00
cea483ae0b Add ability to select distance unit
Some checks failed
Release / android-build (push) Blocked by required conditions
Release / meta (push) Successful in 10s
Release / linux-build (push) Successful in 6m39s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-01 15:28:11 +00:00
7139cfcc99 add stats page
All checks were successful
Release / meta (push) Successful in 20s
Release / linux-build (push) Successful in 7m21s
Release / android-build (push) Successful in 16m39s
Release / release-master (push) Successful in 23s
Release / release-dev (push) Successful in 25s
2026-01-01 12:50:27 +00:00
1c15546b66 support new fields in adding
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 6m54s
Release / android-build (push) Successful in 23m30s
Release / release-master (push) Successful in 30s
Release / release-dev (push) Successful in 32s
2025-12-31 18:23:37 +00:00
e1ad1ea685 remove windows
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m55s
Release / android-build (push) Successful in 22m33s
Release / release-dev (push) Successful in 26s
Release / release-master (push) Successful in 25s
2025-12-30 12:18:37 +00:00
9b307ab56b add windows build
Some checks failed
Release / meta (push) Successful in 18s
Release / windows-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / android-build (push) Has been cancelled
2025-12-30 12:12:17 +00:00
8cf43c76e2 re add calculator page
Some checks failed
Release / meta (push) Successful in 11s
Release / linux-build (push) Successful in 8m31s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
2025-12-30 11:55:46 +00:00
2600e90efa adjust loggin in indicator
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 7m20s
Release / android-build (push) Successful in 25m10s
Release / release-dev (push) Successful in 30s
Release / release-master (push) Successful in 28s
2025-12-27 15:00:57 +00:00
a9bc6c306c add password reset on settings page
All checks were successful
Release / meta (push) Successful in 9s
Release / linux-build (push) Successful in 8m26s
Release / android-build (push) Successful in 21m31s
Release / release-master (push) Successful in 25s
Release / release-dev (push) Successful in 28s
2025-12-27 14:34:44 +00:00
54026aa93a make percentage cleared cards shorter
All checks were successful
Release / meta (push) Successful in 8s
Release / linux-build (push) Successful in 6m46s
Release / android-build (push) Successful in 14m42s
Release / release-dev (push) Successful in 26s
Release / release-master (push) Successful in 24s
2025-12-26 22:51:16 +00:00
0971124fd4 badge percentage support 2025-12-26 22:49:43 +00:00
103 changed files with 12120 additions and 1083 deletions

411
.gitea/workflows/dev.yml Normal file
View File

@@ -0,0 +1,411 @@
name: Release
on:
push:
branches:
- dev-other
env:
JAVA_VERSION: "17"
ANDROID_SDK_ROOT: "${{ github.workspace }}/android-sdk"
FLUTTER_VERSION: "3.38.5"
BUILD_WINDOWS: "false" # Windows build disabled (no runner available)
GITEA_BASE_URL: https://git.tgj.services
WEB_IMAGE: "git.tgj.services/petegregoryy/mileograph-web"
REGISTRY: git.tgj.services
jobs:
meta:
runs-on:
- tgj-arc
outputs:
base_version: ${{ steps.meta.outputs.base }}
release_tag: ${{ steps.meta.outputs.release_tag }}
dev_suffix: ${{ steps.meta.outputs.dev_suffix }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Determine version
id: meta
run: |
RAW_VERSION=$(awk '/^version:/{print $2}' pubspec.yaml)
BASE_VERSION=${RAW_VERSION%%+*}
VERSION="${BASE_VERSION}"
TAG="v${VERSION}"
DEV_SUFFIX=""
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
DEV_ITER="${GITHUB_RUN_NUMBER:-}"
if [ -z "$DEV_ITER" ]; then
DEV_ITER=$(git rev-list --count HEAD)
fi
DEV_SUFFIX="-dev.${DEV_ITER}"
VERSION="${BASE_VERSION}${DEV_SUFFIX}"
TAG="v${VERSION}"
fi
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
echo "release_tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "dev_suffix=${DEV_SUFFIX}" >> "$GITHUB_OUTPUT"
- name: Fail if release already exists
env:
TAG: ${{ steps.meta.outputs.release_tag }}
run: |
set -euo pipefail
if ! command -v curl >/dev/null 2>&1; then
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y curl ca-certificates
fi
URL="${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/tags/${TAG}"
CODE="$(curl -sS -o /dev/null -w "%{http_code}" -H "Authorization: token ${{ secrets.GITEA_TOKEN }}" "$URL" || true)"
if [ "$CODE" = "200" ]; then
echo "Release already exists for tag ${TAG}; refusing to re-release."
exit 1
fi
if [ "$CODE" != "404" ]; then
echo "Unexpected response checking existing release (${CODE}) at ${URL}"
exit 1
fi
android-build:
runs-on:
- tgj-arc
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
#
# - name: Install OS deps (Android)
# 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
- name: Setup Java
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: ${{ env.JAVA_VERSION }}
- name: Install Android SDK
run: |
mkdir -p "$ANDROID_SDK_ROOT"/cmdline-tools
curl -fsSL https://dl.google.com/android/repository/commandlinetools-linux-11076708_latest.zip -o /tmp/cli-tools.zip
unzip -q /tmp/cli-tools.zip -d "$ANDROID_SDK_ROOT"/cmdline-tools
mv "$ANDROID_SDK_ROOT"/cmdline-tools/cmdline-tools "$ANDROID_SDK_ROOT"/cmdline-tools/latest
# Accept licences (ignore SIGPIPE exit 141)
yes | "$ANDROID_SDK_ROOT"/cmdline-tools/latest/bin/sdkmanager --sdk_root="$ANDROID_SDK_ROOT" --licenses || true
# Install required packages (also ignore SIGPIPE)
yes | "$ANDROID_SDK_ROOT"/cmdline-tools/latest/bin/sdkmanager --sdk_root="$ANDROID_SDK_ROOT" \
"platform-tools" "platforms;android-33" "build-tools;33.0.2" || true
echo "ANDROID_SDK_ROOT=$ANDROID_SDK_ROOT" >> "$GITHUB_ENV"
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
echo "$ANDROID_SDK_ROOT/build-tools/33.0.2" >> "$GITHUB_PATH"
# - name: Install Flutter SDK
# run: |
# set -euo pipefail
# FLUTTER_HOME="$HOME/flutter"
# # Avoid git ownership issues when Flutter checks out deps.
# git config --global --add safe.directory "$FLUTTER_HOME" || true
# if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
# rm -rf "$FLUTTER_HOME"
# curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
# 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: Prepare Android keystore (optional)
if: ${{ secrets.ANDROID_KEYSTORE_BASE64 != '' }}
run: |
echo "${{ secrets.ANDROID_KEYSTORE_BASE64 }}" | base64 -d > android-release-key.jks
echo "ANDROID_KEYSTORE_PATH=$PWD/android-release-key.jks" >> "$GITHUB_ENV"
echo "ANDROID_KEYSTORE_PASSWORD=${{ secrets.ANDROID_KEYSTORE_PASSWORD }}" >> "$GITHUB_ENV"
echo "ANDROID_KEY_ALIAS=${{ secrets.ANDROID_KEY_ALIAS }}" >> "$GITHUB_ENV"
echo "ANDROID_KEY_PASSWORD=${{ secrets.ANDROID_KEY_PASSWORD }}" >> "$GITHUB_ENV"
- name: Build Android App Bundle (release)
run: flutter build appbundle --release
- name: Archive Android App Bundle
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
run: |
set -euo pipefail
BUNDLE_SRC="build/app/outputs/bundle/release/app-release.aab"
if [ ! -f "$BUNDLE_SRC" ]; then
echo "Bundle not found at $BUNDLE_SRC"
exit 1
fi
cp "$BUNDLE_SRC" "mileograph-${BASE_VERSION}.aab"
- name: Download bundletool
run: |
BUNDLETOOL_VERSION=1.15.6
curl -fsSL -o bundletool.jar "https://github.com/google/bundletool/releases/download/${BUNDLETOOL_VERSION}/bundletool-all-${BUNDLETOOL_VERSION}.jar"
- name: Extract universal APK from bundle
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
run: |
set -euo pipefail
BUNDLE="build/app/outputs/bundle/release/app-release.aab"
OUTPUT_APKS="app-release.apks"
APK_NAME="mileograph-${BASE_VERSION}.apk"
if [ ! -f "$BUNDLE" ]; then
echo "Bundle not found at $BUNDLE"
exit 1
fi
SIGNING_ARGS=()
if [ -n "${ANDROID_KEYSTORE_PATH:-}" ] && [ -f "$ANDROID_KEYSTORE_PATH" ]; then
SIGNING_ARGS+=(--ks="$ANDROID_KEYSTORE_PATH")
SIGNING_ARGS+=(--ks-pass="pass:${ANDROID_KEYSTORE_PASSWORD}")
SIGNING_ARGS+=(--ks-key-alias="${ANDROID_KEY_ALIAS}")
KEY_PASS="${ANDROID_KEY_PASSWORD:-$ANDROID_KEYSTORE_PASSWORD}"
SIGNING_ARGS+=(--key-pass="pass:${KEY_PASS}")
else
echo "No release keystore provided; bundletool will sign with the debug keystore."
fi
java -jar bundletool.jar build-apks \
--bundle="$BUNDLE" \
--output="$OUTPUT_APKS" \
--mode=universal \
"${SIGNING_ARGS[@]}"
unzip -p "$OUTPUT_APKS" universal.apk > "$APK_NAME"
ls -lh "$APK_NAME"
- name: Upload Android APK artifact
uses: actions/upload-artifact@v3
with:
name: android-apk
path: mileograph-${{ needs.meta.outputs.base_version }}.apk
- name: Upload Android AAB artifact
uses: actions/upload-artifact@v3
with:
name: android-aab
path: mileograph-${{ needs.meta.outputs.base_version }}.aab
linux-build:
runs-on:
- tgj-arc
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- 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 Linux desktop
run: flutter config --enable-linux-desktop
- name: Build Linux binary (release)
run: |
flutter build linux --release
tar -C build/linux/x64/release/bundle -czf app-linux-x64.tar.gz .
- name: Upload Linux artifact
uses: actions/upload-artifact@v3
with:
name: linux-bundle
path: app-linux-x64.tar.gz
web-build:
runs-on:
- tgj-arc
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- 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: Set up docker buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: dmeta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/petegregoryy/railframe-web
flavor: latest=false
tags: |
type=sha,prefix=
type=raw,value=${{ needs.meta.outputs.base_version }}${{ needs.meta.outputs.dev_suffix }}
- name: Login to the docker registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: petegregoryy
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
with:
file: Dockerfile.web
context: .
push: true
tags: ${{ steps.dmeta.outputs.tags }}
labels: ${{ steps.dmeta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/petegregoryy/railframe-web:buildcache-dev
cache-to: type=registry,ref=${{ env.REGISTRY }}/petegregoryy/railframe-web:buildcache-dev,mode=max
release-dev:
runs-on:
- tgj-arc
needs:
- meta
- android-build
- linux-build
- web-build
steps:
- name: Download Android APK
if: ${{ github.ref == 'refs/heads/dev' }}
uses: actions/download-artifact@v3
with:
name: android-apk
path: artifacts
- name: Download Android AAB
if: ${{ github.ref == 'refs/heads/dev' }}
uses: actions/download-artifact@v3
with:
name: android-aab
path: artifacts
- name: Prepare APK and tag
if: ${{ github.ref == 'refs/heads/dev' }}
id: bundle
run: |
BASE="${{ needs.meta.outputs.base_version }}"
TAG="${{ needs.meta.outputs.release_tag }}"
DEV_SUFFIX="${{ needs.meta.outputs.dev_suffix }}"
if [ -z "$DEV_SUFFIX" ]; then
echo "dev_suffix is empty; expected '-dev.<n>'"
exit 1
fi
VERSION="${BASE}${DEV_SUFFIX}"
APK_NAME="mileograph-${VERSION}.apk"
AAB_NAME="mileograph-${VERSION}.aab"
mv "artifacts/mileograph-${BASE}.apk" "artifacts/${APK_NAME}"
mv "artifacts/mileograph-${BASE}.aab" "artifacts/${AAB_NAME}"
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "apk=artifacts/${APK_NAME}" >> "$GITHUB_OUTPUT"
echo "aab=artifacts/${AAB_NAME}" >> "$GITHUB_OUTPUT"
- name: Create prerelease on Gitea
if: ${{ github.ref == 'refs/heads/dev' }}
uses: ncipollo/release-action@v1
with:
tag: ${{ steps.bundle.outputs.tag }}
name: ${{ steps.bundle.outputs.tag }}
prerelease: true
commit: ${{ github.sha }}
token: ${{ secrets.GITEA_TOKEN }}
# NOTE: no `artifacts:` here
- name: Attach APK to Gitea release
if: ${{ github.ref == 'refs/heads/dev' }}
run: |
set -euo pipefail
TAG="${{ steps.bundle.outputs.tag }}"
APK="${{ steps.bundle.outputs.apk }}"
# 1. Find release ID by tag
RELEASE_JSON=$(curl -sS \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/tags/${TAG}")
RELEASE_ID=$(echo "$RELEASE_JSON" | jq -r '.id')
echo "Release ID: $RELEASE_ID"
# 2. Upload APK with multipart/form-data
NAME=$(basename "$APK")
echo "Uploading $NAME"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${APK}" \
-F "name=${NAME}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null
# Attach AAB
AAB="${{ steps.bundle.outputs.aab }}"
NAME_AAB=$(basename "$AAB")
echo "Uploading $NAME_AAB"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${AAB}" \
-F "name=${NAME_AAB}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null

View File

@@ -10,8 +10,10 @@ 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"
REGISTRY: git.tgj.services
jobs: jobs:
meta: meta:
@@ -20,6 +22,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 +32,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="${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:
@@ -66,21 +81,21 @@ jobs:
android-build: android-build:
runs-on: runs-on:
- mileograph - tgj-arc
needs: meta needs: meta
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
#
- name: Install OS deps (Android) # - name: Install OS deps (Android)
run: | # run: |
if command -v sudo >/dev/null 2>&1; then # if command -v sudo >/dev/null 2>&1; then
SUDO="sudo" # SUDO="sudo"
else # else
SUDO="" # SUDO=""
fi # fi
$SUDO apt-get update # $SUDO apt-get update
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq # $SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v4
@@ -106,17 +121,19 @@ jobs:
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH" echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
echo "$ANDROID_SDK_ROOT/build-tools/33.0.2" >> "$GITHUB_PATH" echo "$ANDROID_SDK_ROOT/build-tools/33.0.2" >> "$GITHUB_PATH"
- name: Install Flutter SDK # - name: Install Flutter SDK
run: | # run: |
set -euo pipefail # set -euo pipefail
FLUTTER_HOME="$HOME/flutter" # FLUTTER_HOME="$HOME/flutter"
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then # # Avoid git ownership issues when Flutter checks out deps.
rm -rf "$FLUTTER_HOME" # git config --global --add safe.directory "$FLUTTER_HOME" || true
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" # if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
tar -C "$HOME" -xf /tmp/flutter.tar.xz # rm -rf "$FLUTTER_HOME"
fi # curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH" # tar -C "$HOME" -xf /tmp/flutter.tar.xz
"$FLUTTER_HOME/bin/flutter" --version # fi
# echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
# "$FLUTTER_HOME/bin/flutter" --version
- name: Allow all git directories (CI) - name: Allow all git directories (CI)
run: git config --global --add safe.directory '*' run: git config --global --add safe.directory '*'
@@ -139,6 +156,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,36 +214,20 @@ 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 - tgj-arc
needs: meta needs: meta
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install OS deps (Linux desktop)
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 libglu1-mesa clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev liblzma-dev curl jq
- name: Install Flutter SDK
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
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) - name: Allow all git directories (CI)
run: git config --global --add safe.directory '*' run: git config --global --add safe.directory '*'
@@ -238,6 +251,70 @@ jobs:
name: linux-bundle name: linux-bundle
path: app-linux-x64.tar.gz path: app-linux-x64.tar.gz
web-build:
runs-on:
- tgj-arc
needs: meta
steps:
- name: Checkout
uses: actions/checkout@v4
- 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: Set up docker buildx
uses: docker/setup-buildx-action@v3
- name: Docker meta
id: dmeta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/petegregoryy/railframe-web
flavor: latest=false
tags: |
type=sha,prefix=
type=raw,value=${{ needs.meta.outputs.base_version }}${{ needs.meta.outputs.dev_suffix }}
type=raw,value=dev
- name: Login to the docker registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: petegregoryy
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v6
with:
file: Dockerfile.web
context: .
push: true
tags: ${{ steps.dmeta.outputs.tags }}
labels: ${{ steps.dmeta.outputs.labels }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/petegregoryy/railframe-web:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/petegregoryy/railframe-web:buildcache,mode=max
release-dev: release-dev:
runs-on: runs-on:
- mileograph - mileograph
@@ -245,6 +322,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 +340,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 +409,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 +428,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 +446,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 +462,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 +501,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,74 @@
# 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
This project is a starting point for a Flutter application. ## 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.
A few resources to get you started if this is your first Flutter project: ## 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.
- [Lab: Write your first Flutter app](https://docs.flutter.dev/get-started/codelab) ## Prerequisites
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook) - Flutter SDK (3.x or later recommended).
- Dart SDK (bundled with Flutter).
- A Mileograph API endpoint (set in Settings within the app).
For help getting started with Flutter development, view the ## Setup
[online documentation](https://docs.flutter.dev/), which offers tutorials, 1) Install Flutter: follow https://docs.flutter.dev/get-started/install and ensure `flutter doctor` is green.
samples, guidance on mobile development, and a full API reference. 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.
## Running
- Debug (mobile/web depending on your toolchain):
```bash
flutter run
```
- Release build (example for Android):
```bash
flutter build apk --release
```
- Web (release + CanvasKit renderer for best performance/icons):
```bash
flutter build web --release --web-renderer canvaskit --tree-shake-icons
# or for local profiling:
flutter run -d chrome --profile --web-renderer canvaskit
```
## 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

@@ -1,3 +1,4 @@
import java.io.File
import java.util.Properties import java.util.Properties
plugins { plugins {
@@ -22,8 +23,21 @@ val releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD") val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
?: keystoreProperties.getProperty("keyPassword") ?: keystoreProperties.getProperty("keyPassword")
val releaseStoreFilePath: File? = releaseStoreFile?.let { path ->
val candidate = File(path)
if (candidate.isAbsolute) return@let candidate
val candidates = listOfNotNull(
rootProject.file(path),
rootProject.projectDir.parentFile?.let { File(it, path) },
project.file(path),
)
candidates.firstOrNull { it.exists() }
}
val hasReleaseKeystore = listOf( val hasReleaseKeystore = listOf(
releaseStoreFile, releaseStoreFilePath?.path,
releaseStorePassword, releaseStorePassword,
releaseKeyAlias, releaseKeyAlias,
releaseKeyPassword releaseKeyPassword
@@ -31,7 +45,7 @@ val hasReleaseKeystore = listOf(
android { android {
namespace = "com.example.mileograph_flutter" namespace = "com.example.mileograph_flutter"
compileSdk = flutter.compileSdkVersion compileSdk = 36
ndkVersion = "27.0.12077973" ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
@@ -48,8 +62,8 @@ android {
applicationId = "com.petegregoryy.mileograph_flutter" applicationId = "com.petegregoryy.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 = 24
targetSdk = flutter.targetSdkVersion targetSdk = 36
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
@@ -57,7 +71,7 @@ android {
signingConfigs { signingConfigs {
if (hasReleaseKeystore) { if (hasReleaseKeystore) {
create("release") { create("release") {
storeFile = file(releaseStoreFile!!) storeFile = releaseStoreFilePath
storePassword = releaseStorePassword storePassword = releaseStorePassword
keyAlias = releaseKeyAlias!! keyAlias = releaseKeyAlias!!
keyPassword = releaseKeyPassword keyPassword = releaseKeyPassword

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,7 +2,10 @@ 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/accent_color_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/theme_mode_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 +19,15 @@ class App extends StatelessWidget {
ChangeNotifierProvider<EndpointService>( ChangeNotifierProvider<EndpointService>(
create: (_) => EndpointService(), create: (_) => EndpointService(),
), ),
ChangeNotifierProvider<AccentColorService>(
create: (_) => AccentColorService(),
),
ChangeNotifierProvider<DistanceUnitService>(
create: (_) => DistanceUnitService(),
),
ChangeNotifierProvider<ThemeModeService>(
create: (_) => ThemeModeService(),
),
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);
@@ -27,8 +39,17 @@ class App extends StatelessWidget {
ChangeNotifierProvider<AuthService>( ChangeNotifierProvider<AuthService>(
create: (context) => AuthService(api: context.read<ApiService>()), create: (context) => AuthService(api: context.read<ApiService>()),
), ),
ChangeNotifierProvider<DataService>( ChangeNotifierProxyProvider<AuthService, DataService>(
create: (context) => DataService(api: context.read<ApiService>()), create: (context) => DataService(api: context.read<ApiService>()),
update: (context, auth, data) {
data ??= DataService(api: context.read<ApiService>());
data.handleAuthChanged(
auth.userId,
entriesVisibility: auth.entriesVisibility,
mileageVisibility: auth.mileageVisibility,
);
return data;
},
), ),
], ],
child: const MyApp(), child: const MyApp(),

View File

@@ -61,14 +61,7 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
controller: textEditingController, controller: textEditingController,
focusNode: focusNode, focusNode: focusNode,
textInputAction: TextInputAction.done, textInputAction: TextInputAction.done,
onSubmitted: (_) { onSubmitted: (_) => onFieldSubmitted(),
final matches = _findTopMatches(textEditingController.text);
final firstMatch = matches.isEmpty ? null : matches.first;
if (firstMatch == null) return;
_controller.text = firstMatch;
widget.onChanged(firstMatch);
focusNode.unfocus(); // optionally close keyboard
},
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Select station', labelText: 'Select station',
border: OutlineInputBorder(), border: OutlineInputBorder(),
@@ -181,6 +174,14 @@ class _RouteCalculatorState extends State<RouteCalculator> {
} }
Future<void> _calculateRoute(List<String> stations) async { Future<void> _calculateRoute(List<String> stations) async {
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
if (cleaned.length < 2) {
setState(() {
_routeResult = null;
_errorMessage = 'Add at least two stations before calculating.';
});
return;
}
setState(() { setState(() {
_errorMessage = null; _errorMessage = null;
_routeResult = null; _routeResult = null;
@@ -188,7 +189,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final api = context.read<ApiService>(); // context is valid here final api = context.read<ApiService>(); // context is valid here
try { try {
final res = await api.post('/route/distance2', { final res = await api.post('/route/distance2', {
'route': stations.where((s) => s.trim().isNotEmpty).toList(), 'route': cleaned,
}); });
if (res is Map && res['error'] == false) { if (res is Map && res['error'] == false) {
@@ -232,11 +233,28 @@ class _RouteCalculatorState extends State<RouteCalculator> {
}); });
} }
void _clearCalculator() {
final data = context.read<DataService>();
setState(() {
data.stations = [''];
_routeResult = null;
_errorMessage = null;
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
return Column( return Column(
children: [ children: [
Align(
alignment: Alignment.centerRight,
child: IconButton(
tooltip: 'Clear calculator',
icon: const Icon(Icons.clear_all),
onPressed: _clearCalculator,
),
),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Wrap( child: Wrap(
@@ -366,18 +384,34 @@ class _RouteCalculatorState extends State<RouteCalculator> {
spacing: 12, spacing: 12,
runSpacing: 8, runSpacing: 8,
children: [ children: [
ElevatedButton.icon( ...(() {
final reverseButton = ElevatedButton.icon(
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations);
},
);
final addButton = ElevatedButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Add Station'), label: const Text('Add Station'),
onPressed: _addStation, onPressed: _addStation,
), );
ElevatedButton.icon( final calculateButton = ElevatedButton.icon(
icon: const Icon(Icons.route), icon: const Icon(Icons.route),
label: const Text('Calculate Route'), label: const Text('Calculate Route'),
onPressed: () async { onPressed: () async {
await _calculateRoute(data.stations); await _calculateRoute(data.stations);
}, },
), );
final isMobile = MediaQuery.of(context).size.width < 600;
return isMobile
? [addButton, reverseButton, calculateButton]
: [reverseButton, addButton, calculateButton];
})(),
], ],
), ),
), ),

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

@@ -51,7 +51,7 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: Text( child: Text(
'Latest loco changes', 'Latest Loco Changes',
style: textTheme.titleMedium?.copyWith( style: textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
), ),
@@ -286,7 +286,7 @@ class _LatestLocoChangesPanelState extends State<LatestLocoChangesPanel> {
], ],
); );
}, },
separatorBuilder: (_, __) => const Divider(height: 8), separatorBuilder: (_, index) => const Divider(height: 8),
itemCount: grouped.length, itemCount: grouped.length,
); );

View File

@@ -1,17 +1,33 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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';
enum _LeaderboardScope { global, friends }
class LeaderboardPanel extends StatelessWidget { class LeaderboardPanel extends StatefulWidget {
const LeaderboardPanel({super.key}); const LeaderboardPanel({super.key});
@override
State<LeaderboardPanel> createState() => _LeaderboardPanelState();
}
class _LeaderboardPanelState extends State<LeaderboardPanel> {
_LeaderboardScope _scope = _LeaderboardScope.global;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final leaderboard = data.homepageStats?.leaderboard ?? []; final distanceUnits = context.watch<DistanceUnitService>();
final leaderboard = _scope == _LeaderboardScope.global
? (data.homepageStats?.leaderboard ?? [])
: data.friendsLeaderboard;
final loading = _scope == _LeaderboardScope.global
? data.isHomepageLoading
: data.isFriendsLeaderboardLoading;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && leaderboard.isEmpty) { if (loading && leaderboard.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
@@ -36,7 +52,8 @@ class LeaderboardPanel extends StatelessWidget {
), ),
), ),
), ),
if (leaderboard.isNotEmpty) if (leaderboard.isNotEmpty &&
MediaQuery.of(context).size.width > 600)
Container( Container(
padding: padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4), const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -52,6 +69,40 @@ class LeaderboardPanel extends StatelessWidget {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Center(
child: SegmentedButton<_LeaderboardScope>(
segments: const [
ButtonSegment(
value: _LeaderboardScope.global,
label: Text('Global'),
),
ButtonSegment(
value: _LeaderboardScope.friends,
label: Text('Friends'),
),
],
selected: {_scope},
onSelectionChanged: (vals) async {
if (vals.isEmpty) return;
final selected = vals.first;
setState(() => _scope = selected);
if (selected == _LeaderboardScope.friends &&
data.friendsLeaderboard.isEmpty &&
!data.isFriendsLeaderboardLoading) {
await data.fetchFriendsLeaderboard();
} else if (selected == _LeaderboardScope.global &&
(data.homepageStats?.leaderboard.isEmpty ?? true) &&
!data.isHomepageLoading) {
await data.fetchHomepageStats();
}
},
style: SegmentedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
),
const SizedBox(height: 8),
if (leaderboard.isEmpty) if (leaderboard.isEmpty)
const Padding( const Padding(
padding: EdgeInsets.all(8.0), padding: EdgeInsets.all(8.0),
@@ -81,12 +132,39 @@ class LeaderboardPanel extends StatelessWidget {
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
trailing: Text( trailing: Wrap(
'${leaderboard[index].mileage.toStringAsFixed(1)} mi', crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
Text(
distanceUnits.format(
leaderboard[index].mileage,
decimals: 1,
),
style: textTheme.labelLarge?.copyWith( style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
), ),
), ),
Builder(
builder: (ctx) => IconButton(
tooltip: 'View profile',
icon: const Icon(Icons.open_in_new, size: 20),
onPressed: () {
final auth = ctx.read<AuthService>();
final userId = leaderboard[index].userId;
if (auth.userId == userId) {
ctx.go('/more/profile');
} else {
ctx.pushNamed(
'user-profile',
queryParameters: {'user_id': userId},
);
}
},
),
),
],
),
), ),
if (index != leaderboard.length - 1) const Divider(height: 12), if (index != leaderboard.length - 1) const Divider(height: 12),
], ],

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,9 +27,17 @@ class _LegCardState extends State<LegCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final leg = widget.leg; final leg = widget.leg;
final isShared = leg.legShareId != null && leg.legShareId!.isNotEmpty;
final sharedFrom = leg.sharedFrom;
final sharedTo = leg.sharedTo;
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(
clipBehavior: Clip.antiAlias,
elevation: 1,
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile( child: ExpansionTile(
onExpansionChanged: (v) => setState(() => _expanded = v), onExpansionChanged: (v) => setState(() => _expanded = v),
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -38,19 +45,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 +118,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 +148,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 +184,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,
), ),
@@ -144,6 +200,10 @@ class _LegCardState extends State<LegCard> {
], ],
], ],
), ),
if (isShared || sharedFrom != null || (sharedTo.isNotEmpty)) ...[
const SizedBox(width: 8),
_SharedIcons(sharedFrom: sharedFrom, sharedTo: sharedTo, isShared: isShared),
],
if (widget.showEditButton) ...[ if (widget.showEditButton) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(
@@ -191,6 +251,40 @@ 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 (sharedFrom != null)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
'Shared from ${sharedFrom.sharedFromDisplay.isNotEmpty ? sharedFrom.sharedFromDisplay : 'another user'}.',
style: textTheme.bodyMedium,
),
),
if (sharedTo.isNotEmpty) ...[
Text(
'Shared to:',
style: textTheme.bodyMedium,
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
children: sharedTo
.map((s) => Chip(
label: Text(s.sharedToDisplay.isNotEmpty
? s.sharedToDisplay
: s.sharedToUserId),
visualDensity: VisualDensity.compact,
))
.toList(),
),
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),
@@ -201,6 +295,7 @@ class _LegCardState extends State<LegCard> {
), ),
], ],
), ),
),
); );
} }
@@ -224,6 +319,7 @@ class _LegCardState extends State<LegCard> {
); );
if (confirmed != true) return; if (confirmed != true) return;
if (!context.mounted) return;
final data = context.read<DataService>(); final data = context.read<DataService>();
final messenger = ScaffoldMessenger.of(context); final messenger = ScaffoldMessenger.of(context);
try { try {
@@ -237,6 +333,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')}';
@@ -285,6 +415,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,
@@ -305,38 +480,65 @@ 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 (_) {}
class _SharedIcons extends StatelessWidget {
const _SharedIcons({
required this.sharedFrom,
required this.sharedTo,
required this.isShared,
});
final LegShareMeta? sharedFrom;
final List<LegShareMeta> sharedTo;
final bool isShared;
@override
Widget build(BuildContext context) {
final icons = <Widget>[];
if (isShared || sharedFrom != null) {
final fromName = sharedFrom?.sharedFromDisplay ?? '';
icons.add(
Tooltip(
message: fromName.isNotEmpty ? 'Shared from $fromName' : 'Shared entry',
child: Icon(
Icons.share,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
),
);
} }
if (trimmed.contains('->')) { if (sharedTo.isNotEmpty) {
return trimmed final names = sharedTo
.split('->') .map((s) => s.sharedToDisplay)
.map((e) => e.trim()) .where((name) => name.isNotEmpty)
.where((e) => e.isNotEmpty)
.toList(); .toList();
final tooltip = names.isEmpty
? 'Shared to others'
: 'Shared to: ${names.join(', ')}';
icons.add(
Tooltip(
message: tooltip,
child: Icon(
Icons.group,
size: 18,
color: Theme.of(context).colorScheme.tertiary,
),
),
);
} }
if (trimmed.contains(',')) { return Row(
return trimmed mainAxisSize: MainAxisSize.min,
.split(',') children: [
.map((e) => e.trim()) for (int i = 0; i < icons.length; i++) ...[
.where((e) => e.isNotEmpty) if (i > 0) const SizedBox(width: 6),
.toList(); icons[i],
} ],
return [trimmed]; ],
);
} }
} }

View File

@@ -17,7 +17,9 @@ class _LoginScreenState extends State<LoginScreen> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkExistingSession()); WidgetsBinding.instance.addPostFrameCallback(
(_) => _checkExistingSession(),
);
} }
Future<void> _checkExistingSession() async { Future<void> _checkExistingSession() async {
@@ -71,15 +73,6 @@ class _LoginScreenState extends State<LoginScreen> {
), ),
), ),
), ),
if (_checkingSession)
const Padding(
padding: EdgeInsets.only(top: 12),
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
const SizedBox(height: 50), const SizedBox(height: 50),
const LoginPanel(), const LoginPanel(),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -95,6 +88,24 @@ class _LoginScreenState extends State<LoginScreen> {
); );
}, },
), ),
if (_checkingSession) ...[
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 8),
Text(
'Trying to log in',
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
],
], ],
), ),
), ),
@@ -193,9 +204,9 @@ class _LoginPanelContentState extends State<LoginPanelContent> {
setState(() { setState(() {
_loggingIn = false; _loggingIn = false;
}); });
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Login failed: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Login failed: $e')));
} }
} }
@@ -283,6 +294,7 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _inviteController = TextEditingController(); final _inviteController = TextEditingController();
bool _registering = false; bool _registering = false;
String? _usernameError;
@override @override
void dispose() { void dispose() {
@@ -295,10 +307,21 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
} }
Future<void> _register() async { Future<void> _register() async {
final username = _usernameController.text.trim();
if (username.contains(' ') || username.contains('@')) {
setState(() {
_usernameError = 'Username cannot contain spaces or @';
});
return;
} else {
setState(() {
_usernameError = null;
});
}
setState(() => _registering = true); setState(() => _registering = true);
try { try {
await widget.authService.register( await widget.authService.register(
username: _usernameController.text.trim(), username: username,
email: _emailController.text.trim(), email: _emailController.text.trim(),
fullName: _displayNameController.text.trim(), fullName: _displayNameController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
@@ -306,14 +329,16 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
); );
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Registration successful. Please log in.')), const SnackBar(
content: Text('Registration successful. Please log in.'),
),
); );
widget.onBack(); widget.onBack();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(
SnackBar(content: Text('Registration failed: $e')), context,
); ).showSnackBar(SnackBar(content: Text('Registration failed: $e')));
} finally { } finally {
if (mounted) setState(() => _registering = false); if (mounted) setState(() => _registering = false);
} }
@@ -350,6 +375,21 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: "Username", labelText: "Username",
), ),
onChanged: (_) {
if (_usernameError != null &&
!_usernameController.text.contains(' ') &&
!_usernameController.text.contains('@')) {
setState(() => _usernameError = null);
}
},
),
if (_usernameError != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
_usernameError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(

View File

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

View File

@@ -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 {
@@ -16,6 +17,8 @@ class _LegsPageState extends State<LegsPage> {
DateTime? _startDate; DateTime? _startDate;
DateTime? _endDate; DateTime? _endDate;
bool _initialised = false; bool _initialised = false;
bool _unallocatedOnly = false;
bool _showMoreFilters = false;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -32,6 +35,7 @@ class _LegsPageState extends State<LegsPage> {
sortDirection: _sortDirection, sortDirection: _sortDirection,
dateRangeStart: _formatDate(_startDate), dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate), dateRangeEnd: _formatDate(_endDate),
unallocatedOnly: _unallocatedOnly,
); );
} }
@@ -43,6 +47,7 @@ class _LegsPageState extends State<LegsPage> {
dateRangeEnd: _formatDate(_endDate), dateRangeEnd: _formatDate(_endDate),
offset: data.legs.length, offset: data.legs.length,
append: true, append: true,
unallocatedOnly: _unallocatedOnly,
); );
} }
@@ -83,6 +88,8 @@ class _LegsPageState extends State<LegsPage> {
_startDate = null; _startDate = null;
_endDate = null; _endDate = null;
_sortDirection = 0; _sortDirection = 0;
_unallocatedOnly = false;
_showMoreFilters = false;
}); });
_refreshLegs(); _refreshLegs();
} }
@@ -90,6 +97,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 +129,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
@@ -175,8 +183,46 @@ class _LegsPageState extends State<LegsPage> {
: _formatDate(_endDate!)!, : _formatDate(_endDate!)!,
), ),
), ),
TextButton.icon(
onPressed: () => setState(
() => _showMoreFilters = !_showMoreFilters,
),
icon: Icon(
_showMoreFilters
? Icons.expand_less
: Icons.expand_more,
),
label: Text(
_showMoreFilters ? 'Hide filters' : 'More filters',
),
),
], ],
), ),
AnimatedCrossFade(
crossFadeState: _showMoreFilters
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
firstChild: Padding(
padding: const EdgeInsets.only(top: 12.0),
child: Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilterChip(
avatar: const Icon(Icons.flash_off),
label: const Text('Unallocated only'),
selected: _unallocatedOnly,
onSelected: (selected) async {
setState(() => _unallocatedOnly = selected);
await _refreshLegs();
},
),
],
),
),
secondChild: const SizedBox.shrink(),
),
], ],
), ),
), ),
@@ -212,7 +258,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 +285,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 +311,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

@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.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';
@@ -29,6 +30,7 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
final List<_EventDraft> _draftEvents = []; final List<_EventDraft> _draftEvents = [];
bool _isSaving = false; bool _isSaving = false;
bool _isDeleting = false; bool _isDeleting = false;
bool _isModerating = false;
@override @override
void initState() { void initState() {
@@ -59,8 +61,12 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
Future<void> _load() { Future<void> _load() {
final data = context.read<DataService>(); final data = context.read<DataService>();
final auth = context.read<AuthService>();
data.fetchEventFields(); data.fetchEventFields();
return data.fetchLocoTimeline(widget.locoId); return data.fetchLocoTimeline(
widget.locoId,
includeAllPending: auth.isElevated,
);
} }
void _addDraftEvent() { void _addDraftEvent() {
@@ -151,8 +157,18 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
Future<void> _deleteEntry(LocoAttrVersion entry) async { Future<void> _deleteEntry(LocoAttrVersion entry) async {
if (_isDeleting) return; if (_isDeleting) return;
final isPending = entry.isPending;
final blockId = entry.versionId; final blockId = entry.versionId;
if (blockId == null) { final pendingEventId = entry.sourceEventId;
if (isPending && pendingEventId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cannot delete: pending timeline block has no event ID.'),
),
);
return;
}
if (!isPending && blockId == null) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Cannot delete: timeline block has no ID.')), const SnackBar(content: Text('Cannot delete: timeline block has no ID.')),
); );
@@ -193,13 +209,23 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
_isDeleting = true; _isDeleting = true;
}); });
try { try {
if (isPending && pendingEventId != null) {
await data.deletePendingEvent(eventId: pendingEventId);
} else if (blockId != null) {
await data.deleteTimelineBlock( await data.deleteTimelineBlock(
blockId: blockId, blockId: blockId,
); );
}
await _load(); await _load();
if (mounted) { if (mounted) {
messenger.showSnackBar( messenger.showSnackBar(
const SnackBar(content: Text('Timeline block deleted')), SnackBar(
content: Text(
isPending
? 'Pending timeline block deleted'
: 'Timeline block deleted',
),
),
); );
} }
} catch (e) { } catch (e) {
@@ -217,6 +243,79 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
} }
} }
Future<void> _moderatePendingEntry(
LocoAttrVersion entry,
_PendingModerationAction action,
) async {
if (_isModerating) return;
final eventId = entry.sourceEventId;
if (eventId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Cannot moderate: pending timeline block has no event ID.'),
),
);
return;
}
final data = context.read<DataService>();
final approve = action == _PendingModerationAction.approve;
final messenger = ScaffoldMessenger.of(context);
final verb = approve ? 'approve' : 'reject';
final ok = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: Text('${approve ? 'Approve' : 'Reject'} pending event?'),
content: Text(
'Are you sure you want to $verb this pending timeline block?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
child: Text(approve ? 'Approve' : 'Reject'),
),
],
),
);
if (ok != true || !mounted) return;
setState(() {
_isModerating = true;
});
try {
if (approve) {
await data.approvePendingEvent(eventId: eventId);
} else {
await data.rejectPendingEvent(eventId: eventId);
}
await _load();
if (mounted) {
messenger.showSnackBar(
SnackBar(
content: Text(
'Pending timeline block ${approve ? 'approved' : 'rejected'}.',
),
),
);
}
} catch (e) {
if (mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to $verb pending timeline block: $e')),
);
}
} finally {
if (mounted) {
setState(() {
_isModerating = false;
});
}
}
}
void _removeDraftAt(int index) { void _removeDraftAt(int index) {
if (index < 0 || index >= _draftEvents.length) return; if (index < 0 || index >= _draftEvents.length) return;
final draft = _draftEvents.removeAt(index); final draft = _draftEvents.removeAt(index);
@@ -248,6 +347,9 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
_isSaving = true; _isSaving = true;
}); });
try { try {
final existingPending =
await data.fetchUserPendingEvents(widget.locoId);
final clearedEventIds = <int>{};
final invalid = <String>[]; final invalid = <String>[];
for (final draft in _draftEvents) { for (final draft in _draftEvents) {
final dateStr = draft.dateController.text.trim(); final dateStr = draft.dateController.text.trim();
@@ -274,6 +376,13 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
invalid.add('Add at least one value'); invalid.add('Add at least one value');
continue; continue;
} }
await _clearDuplicatePending(
existingPending,
clearedEventIds,
values.keys,
dateStr,
data,
);
await data.createLocoEvent( await data.createLocoEvent(
locoId: widget.locoId, locoId: widget.locoId,
eventDate: dateStr, eventDate: dateStr,
@@ -312,6 +421,42 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
} }
} }
Future<void> _clearDuplicatePending(
List<LocoAttrVersion> existingPending,
Set<int> clearedEventIds,
Iterable<String> attrs,
String dateStr,
DataService data,
) async {
final trimmedDate = dateStr.trim().toLowerCase();
final attrSet = attrs.map((e) => e.toLowerCase()).toSet();
for (final pending in existingPending) {
final attrMatch = attrSet.contains(pending.attrCode.toLowerCase());
if (!attrMatch) continue;
final matchesDate = _dateMatchesPending(trimmedDate, pending);
if (!matchesDate) continue;
final eventId = pending.sourceEventId;
if (eventId == null || clearedEventIds.contains(eventId)) continue;
await data.deletePendingEvent(eventId: eventId);
clearedEventIds.add(eventId);
}
}
bool _dateMatchesPending(String draftDateLower, LocoAttrVersion pending) {
final masked = pending.maskedValidFrom?.trim().toLowerCase();
if (masked != null && masked.isNotEmpty && masked == draftDateLower) {
return true;
}
final draftDate = DateTime.tryParse(draftDateLower);
final pendingDate = pending.validFrom;
if (draftDate != null && pendingDate != null) {
return draftDate.year == pendingDate.year &&
draftDate.month == pendingDate.month &&
draftDate.day == pendingDate.day;
}
return false;
}
bool _isValidDateString(String input) { bool _isValidDateString(String input) {
final trimmed = input.trim(); final trimmed = input.trim();
final regex = RegExp(r'^\d{4}-(\d{2}|xx|XX)-(\d{2}|xx|XX)$'); final regex = RegExp(r'^\d{4}-(\d{2}|xx|XX)-(\d{2}|xx|XX)$');
@@ -412,6 +557,8 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
data.eventFields, data.eventFields,
), ),
onDeleteEntry: _deleteEntry, onDeleteEntry: _deleteEntry,
onModeratePending: _moderatePendingEntry,
pendingActionsBusy: _isModerating,
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
_EventEditor( _EventEditor(

View File

@@ -23,6 +23,7 @@ class _EventEditor extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasDrafts = drafts.isNotEmpty;
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -37,12 +38,14 @@ class _EventEditor extends StatelessWidget {
), ),
Row( Row(
children: [ children: [
if (!hasDrafts) ...[
OutlinedButton.icon( OutlinedButton.icon(
onPressed: onAddEvent, onPressed: onAddEvent,
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('New event'), label: const Text('New event'),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
],
FilledButton.icon( FilledButton.icon(
onPressed: (!canSave || isSaving) ? null : onSave, onPressed: (!canSave || isSaving) ? null : onSave,
icon: isSaving icon: isSaving
@@ -147,6 +150,14 @@ class _EventEditor extends StatelessWidget {
); );
}, },
), ),
if (hasDrafts) ...[
const SizedBox(height: 8),
OutlinedButton.icon(
onPressed: onAddEvent,
icon: const Icon(Icons.add),
label: const Text('New event'),
),
],
], ],
); );
} }
@@ -299,7 +310,6 @@ class _FieldInput extends StatelessWidget {
final name = field.name.toLowerCase(); final name = field.name.toLowerCase();
if (name == 'max_speed') { if (name == 'max_speed') {
final unit = entry.unit ?? 'kph'; final unit = entry.unit ?? 'kph';
final isNumber = true;
return Row( return Row(
children: [ children: [
Expanded( Expanded(
@@ -307,7 +317,7 @@ class _FieldInput extends StatelessWidget {
initialValue: value?.toString(), initialValue: value?.toString(),
onChanged: (val) { onChanged: (val) {
final parsed = double.tryParse(val); final parsed = double.tryParse(val);
onChanged(isNumber ? parsed : val, unit: unit); onChanged(parsed, unit: unit);
}, },
decoration: const InputDecoration( decoration: const InputDecoration(
border: OutlineInputBorder(), border: OutlineInputBorder(),

View File

@@ -2,16 +2,25 @@ part of 'package:mileograph_flutter/components/pages/loco_timeline.dart';
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd'); final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
enum _PendingModerationAction { approve, reject }
class _TimelineGrid extends StatefulWidget { class _TimelineGrid extends StatefulWidget {
const _TimelineGrid({ const _TimelineGrid({
required this.entries, required this.entries,
this.onEditEntry, this.onEditEntry,
this.onDeleteEntry, this.onDeleteEntry,
this.onModeratePending,
this.pendingActionsBusy = false,
}); });
final List<LocoAttrVersion> entries; final List<LocoAttrVersion> entries;
final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry;
final Future<void> Function(
LocoAttrVersion entry,
_PendingModerationAction action,
)? onModeratePending;
final bool pendingActionsBusy;
@override @override
State<_TimelineGrid> createState() => _TimelineGridState(); State<_TimelineGrid> createState() => _TimelineGridState();
@@ -191,6 +200,8 @@ class _TimelineGridState extends State<_TimelineGrid> {
viewportWidth: axisWidth, viewportWidth: axisWidth,
onEditEntry: widget.onEditEntry, onEditEntry: widget.onEditEntry,
onDeleteEntry: widget.onDeleteEntry, onDeleteEntry: widget.onDeleteEntry,
onModeratePending: widget.onModeratePending,
pendingActionsBusy: widget.pendingActionsBusy,
), ),
); );
}, },
@@ -276,6 +287,8 @@ class _AttrRow extends StatelessWidget {
required this.viewportWidth, required this.viewportWidth,
this.onEditEntry, this.onEditEntry,
this.onDeleteEntry, this.onDeleteEntry,
this.onModeratePending,
this.pendingActionsBusy = false,
}); });
final double rowHeight; final double rowHeight;
@@ -285,6 +298,11 @@ class _AttrRow extends StatelessWidget {
final double viewportWidth; final double viewportWidth;
final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry;
final Future<void> Function(
LocoAttrVersion entry,
_PendingModerationAction action,
)? onModeratePending;
final bool pendingActionsBusy;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -310,6 +328,8 @@ class _AttrRow extends StatelessWidget {
block: block, block: block,
onEditEntry: onEditEntry, onEditEntry: onEditEntry,
onDeleteEntry: onDeleteEntry, onDeleteEntry: onDeleteEntry,
onModeratePending: onModeratePending,
pendingActionsBusy: pendingActionsBusy,
), ),
), ),
if (activeBlock != null) if (activeBlock != null)
@@ -326,6 +346,7 @@ class _AttrRow extends StatelessWidget {
width: stickyWidth, width: stickyWidth,
), ),
clipLeftEdge: scrollOffset > activeBlock.left + 0.1, clipLeftEdge: scrollOffset > activeBlock.left + 0.1,
pendingActionsBusy: pendingActionsBusy,
), ),
), ),
), ),
@@ -347,15 +368,17 @@ class _ValueBlockView extends StatelessWidget {
const _ValueBlockView({ const _ValueBlockView({
required this.block, required this.block,
this.clipLeftEdge = false, this.clipLeftEdge = false,
this.pendingActionsBusy = false,
}); });
final _ValueBlock block; final _ValueBlock block;
final bool clipLeftEdge; final bool clipLeftEdge;
final bool pendingActionsBusy;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final color = block.cell.color.withValues(alpha: 0.9); final color = block.cell.color;
final textColor = ThemeData.estimateBrightnessForColor(color) == final textColor = ThemeData.estimateBrightnessForColor(color) ==
Brightness.dark Brightness.dark
? Colors.white ? Colors.white
@@ -386,10 +409,35 @@ class _ValueBlockView extends StatelessWidget {
fit: BoxFit.scaleDown, fit: BoxFit.scaleDown,
child: ConstrainedBox( child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 1, minHeight: 1), constraints: const BoxConstraints(minWidth: 1, minHeight: 1),
child: Column( child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (block.cell.isPending)
Padding(
padding: const EdgeInsets.only(right: 6),
child: SizedBox(
width: 16,
height: 16,
child: pendingActionsBusy
? CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation(textColor),
)
: Icon(
Icons.pending,
size: 16,
color: textColor,
),
),
),
Text( Text(
block.cell.value, block.cell.value,
maxLines: 1, maxLines: 1,
@@ -403,6 +451,8 @@ class _ValueBlockView extends StatelessWidget {
color: textColor, color: textColor,
), ),
), ),
],
),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
block.cell.rangeLabel, block.cell.rangeLabel,
@@ -411,7 +461,11 @@ class _ValueBlockView extends StatelessWidget {
style: theme.textTheme.labelSmall?.copyWith( style: theme.textTheme.labelSmall?.copyWith(
color: textColor.withValues(alpha: 0.9), color: textColor.withValues(alpha: 0.9),
) ?? ) ??
TextStyle(color: textColor.withValues(alpha: 0.9)), TextStyle(
color: textColor.withValues(alpha: 0.9),
),
),
],
), ),
], ],
), ),
@@ -422,26 +476,45 @@ class _ValueBlockView extends StatelessWidget {
} }
} }
enum _TimelineBlockAction { edit, delete } enum _TimelineBlockAction { edit, delete, approve, reject }
class _ValueBlockMenu extends StatelessWidget { class _ValueBlockMenu extends StatelessWidget {
const _ValueBlockMenu({ const _ValueBlockMenu({
required this.block, required this.block,
this.onEditEntry, this.onEditEntry,
this.onDeleteEntry, this.onDeleteEntry,
this.onModeratePending,
this.pendingActionsBusy = false,
}); });
final _ValueBlock block; final _ValueBlock block;
final void Function(LocoAttrVersion entry)? onEditEntry; final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry; final void Function(LocoAttrVersion entry)? onDeleteEntry;
final Future<void> Function(
LocoAttrVersion entry,
_PendingModerationAction action,
)? onModeratePending;
final bool pendingActionsBusy;
bool get _hasActions => onEditEntry != null || onDeleteEntry != null; bool get _hasActions {
final canModerate = block.entry?.isPending == true &&
block.entry?.canModeratePending == true &&
onModeratePending != null;
final canEdit = onEditEntry != null && block.entry?.isPending != true;
return onDeleteEntry != null || canModerate || canEdit;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (!_hasActions || block.entry == null) { if (!_hasActions || block.entry == null) {
return _ValueBlockView(block: block); return _ValueBlockView(
block: block,
);
} }
final canModerate = block.entry?.isPending == true &&
block.entry?.canModeratePending == true &&
onModeratePending != null;
final canEdit = onEditEntry != null && block.entry?.isPending != true;
Future<void> showContextMenuAt(Offset globalPosition) async { Future<void> showContextMenuAt(Offset globalPosition) async {
final overlay = Overlay.of(context); final overlay = Overlay.of(context);
@@ -459,11 +532,23 @@ class _ValueBlockMenu extends StatelessWidget {
context: context, context: context,
position: position, position: position,
items: [ items: [
if (onEditEntry != null) if (canEdit)
const PopupMenuItem( const PopupMenuItem(
value: _TimelineBlockAction.edit, value: _TimelineBlockAction.edit,
child: Text('Edit'), child: Text('Edit'),
), ),
if (canModerate)
PopupMenuItem(
value: _TimelineBlockAction.approve,
enabled: !pendingActionsBusy,
child: const Text('Approve pending'),
),
if (canModerate)
PopupMenuItem(
value: _TimelineBlockAction.reject,
enabled: !pendingActionsBusy,
child: const Text('Reject pending'),
),
if (onDeleteEntry != null) if (onDeleteEntry != null)
const PopupMenuItem( const PopupMenuItem(
value: _TimelineBlockAction.delete, value: _TimelineBlockAction.delete,
@@ -481,11 +566,17 @@ class _ValueBlockMenu extends StatelessWidget {
case _TimelineBlockAction.delete: case _TimelineBlockAction.delete:
onDeleteEntry?.call(entry); onDeleteEntry?.call(entry);
break; break;
case _TimelineBlockAction.approve:
onModeratePending?.call(entry, _PendingModerationAction.approve);
break;
case _TimelineBlockAction.reject:
onModeratePending?.call(entry, _PendingModerationAction.reject);
break;
} }
} }
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.deferToChild,
onLongPressStart: (details) async { onLongPressStart: (details) async {
if (defaultTargetPlatform == TargetPlatform.android) { if (defaultTargetPlatform == TargetPlatform.android) {
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
@@ -495,7 +586,10 @@ class _ValueBlockMenu extends StatelessWidget {
onSecondaryTapDown: (details) async { onSecondaryTapDown: (details) async {
await showContextMenuAt(details.globalPosition); await showContextMenuAt(details.globalPosition);
}, },
child: _ValueBlockView(block: block), child: _ValueBlockView(
block: block,
pendingActionsBusy: pendingActionsBusy,
),
); );
} }
} }
@@ -518,7 +612,28 @@ String _formatAttrLabel(String code) {
DateTime? _parseDateString(String? value) { DateTime? _parseDateString(String? value) {
if (value == null || value.isEmpty) return null; if (value == null || value.isEmpty) return null;
return DateTime.tryParse(value); final direct = DateTime.tryParse(value);
if (direct != null) return direct;
final maskedMatch =
RegExp(r'^(\\d{4})-(\\d{2}|xx|XX)-(\\d{2}|xx|XX)\$').firstMatch(value);
if (maskedMatch != null) {
final year = int.tryParse(maskedMatch.group(1) ?? '');
if (year == null) return null;
String normalize(String part, int fallback) {
final lower = part.toLowerCase();
if (lower == 'xx') return fallback.toString().padLeft(2, '0');
return part;
}
final month = int.tryParse(normalize(maskedMatch.group(2) ?? '01', 1)) ?? 1;
final day = int.tryParse(normalize(maskedMatch.group(3) ?? '01', 1)) ?? 1;
try {
return DateTime(year, month.clamp(1, 12), day.clamp(1, 31));
} catch (_) {
return null;
}
}
return null;
} }
DateTime? _effectiveStart(LocoAttrVersion entry) { DateTime? _effectiveStart(LocoAttrVersion entry) {
@@ -550,8 +665,11 @@ class _TimelineModel {
}); });
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) { factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
final effectiveEntries = entries
.where((e) => _effectiveStart(e) != null)
.toList();
final grouped = <String, List<LocoAttrVersion>>{}; final grouped = <String, List<LocoAttrVersion>>{};
for (final entry in entries) { for (final entry in effectiveEntries) {
grouped.putIfAbsent(entry.attrCode, () => []).add(entry); grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
} }
final now = DateTime.now(); final now = DateTime.now();
@@ -774,11 +892,13 @@ class _RowCell {
final String value; final String value;
final String rangeLabel; final String rangeLabel;
final Color color; final Color color;
final bool isPending;
const _RowCell({ const _RowCell({
required this.value, required this.value,
required this.rangeLabel, required this.rangeLabel,
required this.color, required this.color,
this.isPending = false,
}); });
factory _RowCell.fromSegment(_ValueSegment seg) { factory _RowCell.fromSegment(_ValueSegment seg) {
@@ -787,6 +907,7 @@ class _RowCell {
value: '', value: '',
rangeLabel: '', rangeLabel: '',
color: Colors.transparent, color: Colors.transparent,
isPending: false,
); );
} }
final entry = seg.entry; final entry = seg.entry;
@@ -802,6 +923,7 @@ class _RowCell {
value: seg.value, value: seg.value,
rangeLabel: displayStart, rangeLabel: displayStart,
color: _colorForValue(seg.value), color: _colorForValue(seg.value),
isPending: entry?.isPending ?? false,
); );
} }
} }

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,489 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
final navigator = Navigator.of(context);
if (navigator.canPop()) {
navigator.maybePop();
} else {
context.go('/more');
}
},
tooltip: 'Back',
),
),
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,59 @@
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.person),
title: const Text('Profile'),
onTap: () => context.go('/more/profile'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.emoji_events),
title: const Text('Badges'),
onTap: () => context.go('/more/badges'),
),
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

@@ -0,0 +1,529 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
class UserProfilePage extends StatefulWidget {
const UserProfilePage({super.key, this.userId, this.initialUser});
final String? userId;
final UserSummary? initialUser;
@override
State<UserProfilePage> createState() => _UserProfilePageState();
}
class _UserProfilePageState extends State<UserProfilePage> {
static const int _pageSize = 22;
UserProfileDetail? _profile;
List<Leg> _legs = const [];
bool _loading = false;
bool _loadingMore = false;
bool _hasMore = false;
bool _lastFetchReturnedData = true;
Friendship? _friendship;
bool _actionsLoading = false;
String? get _userId => widget.initialUser?.userId ?? widget.userId;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_loadProfile();
});
}
Future<void> _loadProfile() async {
final userId = _userId;
if (userId == null || userId.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('No user selected.')));
context.pop();
}
return;
}
setState(() {
_loading = true;
_hasMore = false;
_legs = const [];
});
final data = context.read<DataService>();
try {
final profile = await data.fetchUserProfileDetail(userId);
final friendship = await data.fetchFriendshipStatus(userId);
if (!mounted) return;
if (profile == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Failed to load user profile.')),
);
return;
}
final legs = profile.legs;
setState(() {
_profile = profile;
_legs = legs;
_lastFetchReturnedData = legs.isNotEmpty;
_hasMore = _lastFetchReturnedData && _legs.length >= _pageSize;
_friendship = friendship;
});
} finally {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _loadMore() async {
final userId = _userId;
if (userId == null || userId.isEmpty || _loadingMore || !_hasMore) return;
setState(() => _loadingMore = true);
final data = context.read<DataService>();
try {
final more = await data.fetchUserLegs(
userId: userId,
offset: _legs.length,
limit: _pageSize,
);
if (!mounted) return;
setState(() {
_legs = [..._legs, ...more];
_lastFetchReturnedData = more.isNotEmpty;
_hasMore = _lastFetchReturnedData && _legs.length >= _pageSize;
});
} finally {
if (mounted) setState(() => _loadingMore = false);
}
}
void _handleBack() {
final router = GoRouter.of(context);
if (router.canPop()) {
router.pop();
} else {
router.go('/more/profile');
}
}
Widget _buildProfileHeader(ThemeData theme) {
final profile = _profile;
final username = profile?.username ?? widget.initialUser?.username ?? '';
final fullName = profile?.fullName ?? widget.initialUser?.fullName ?? '';
final mileage = profile?.mileage;
final privacy = profile?.privacyInfo;
final mileageHidden =
(mileage == null || mileage == 0) &&
privacy != null &&
privacy.isNotEmpty;
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const CircleAvatar(child: Icon(Icons.person)),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
fullName.isNotEmpty ? fullName : username,
style: theme.textTheme.titleMedium,
),
if (username.isNotEmpty)
Text('@$username', style: theme.textTheme.bodySmall),
],
),
],
),
const SizedBox(height: 12),
Text(
mileageHidden
? 'Mileage hidden'
: 'Mileage: ${(mileage ?? 0).toStringAsFixed(1)}',
),
],
),
),
);
}
Widget _buildTopLocos() {
final profile = _profile;
if (profile == null || profile.topLocos.isEmpty) {
return const SizedBox.shrink();
}
final topTen = [...profile.topLocos]
..sort((a, b) => (b.mileage ?? 0).compareTo(a.mileage ?? 0));
final displayLocos = topTen.take(10).toList();
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Top locos by mileage',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: displayLocos.length,
separatorBuilder: (context, index) => const Divider(height: 1),
itemBuilder: (context, index) {
final loco = displayLocos[index];
final mileage = loco.mileage ?? 0;
return ListTile(
leading: CircleAvatar(
backgroundColor: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.1),
child: Text(
'${index + 1}',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
title: Text(
loco.number.isNotEmpty ? loco.number : 'Unknown',
style: Theme.of(context).textTheme.bodyLarge,
),
subtitle: Text(loco.locoClass),
trailing: Text(
'${mileage.toStringAsFixed(1)} mi',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
);
},
),
],
),
),
);
}
List<Widget> _buildLegsWithDividers(
BuildContext context,
List<Leg> legs, {
required bool showEditButton,
}) {
final widgets = <Widget>[];
String? currentDate;
final dayLegs = <Leg>[];
void flushDay() {
final date = currentDate;
if (date == null) return;
widgets.add(
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
date,
style: Theme.of(
context,
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700),
),
),
);
widgets.add(const Divider());
widgets.addAll(
dayLegs.map(
(leg) => LegCard(
leg: leg,
showDate: false,
showEditButton: showEditButton,
),
),
);
dayLegs.clear();
}
for (final leg in legs) {
final dateStr = _formatDate(leg.beginTime) ?? '';
if (currentDate != null && dateStr != currentDate) {
flushDay();
}
currentDate = dateStr;
dayLegs.add(leg);
}
flushDay();
return widgets;
}
String? _formatDate(DateTime? date) {
if (date == null) return null;
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
}
Widget _buildFriendSection(AuthService auth) {
final friendship = _friendship;
if (friendship == null) {
return const SizedBox.shrink();
}
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Friendship',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(width: 8),
_buildStatusChip(friendship, auth),
],
),
const SizedBox(height: 8),
_buildActions(friendship, auth),
],
),
),
);
}
Widget _buildStatusChip(Friendship status, AuthService auth) {
String label = status.status;
Color color = Colors.grey;
switch (status.status.toLowerCase()) {
case 'accepted':
label = 'Friends';
color = Colors.green;
break;
case 'pending':
final isRequester = status.requesterId == auth.userId;
label = isRequester
? 'Pending (you sent)'
: 'Pending (needs your reply)';
color = Colors.orange;
break;
case 'blocked':
color = Colors.red;
label = 'Blocked';
break;
case 'declined':
case 'rejected':
label = 'Declined';
break;
default:
label = 'Not friends';
}
final bg = Color.alphaBlend(
color.withValues(alpha: 0.15),
Theme.of(context).colorScheme.surface,
);
return Container(
margin: const EdgeInsets.only(left: 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(20),
),
child: Text(label),
);
}
Widget _buildActions(Friendship status, AuthService auth) {
final targetUserId = _userId;
final isSelf = targetUserId != null && targetUserId == auth.userId;
if (isSelf) return const Text('This is you.');
final isRequester = status.requesterId == auth.userId;
final id = status.id;
final buttons = <Widget>[];
Future<void> run(Future<void> Function() action) async {
setState(() => _actionsLoading = true);
try {
await action();
} finally {
if (mounted) setState(() => _actionsLoading = false);
}
}
final data = context.read<DataService>();
if (status.isNone || status.isDeclined) {
buttons.add(
ElevatedButton.icon(
onPressed: _actionsLoading
? null
: () => run(() async {
final updated = await data.requestFriendship(
status.addresseeId,
);
if (!mounted) return;
setState(() => _friendship = updated);
}),
icon: const Icon(Icons.person_add),
label: const Text('Send friend request'),
),
);
} else if (status.isPending) {
if (isRequester) {
buttons.add(
OutlinedButton(
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
await data.cancelFriendship(id);
if (!mounted) return;
setState(
() => _friendship = status.copyWith(status: 'none'),
);
}),
child: const Text('Cancel request'),
),
);
} else {
buttons.add(
ElevatedButton(
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
final updated = await data.acceptFriendship(id);
if (!mounted) return;
setState(() => _friendship = updated);
}),
child: const Text('Accept'),
),
);
buttons.add(
OutlinedButton(
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
final updated = await data.rejectFriendship(id);
if (!mounted) return;
setState(() => _friendship = updated);
}),
child: const Text('Reject'),
),
);
}
} else if (status.isAccepted) {
buttons.add(
ElevatedButton.icon(
onPressed: _actionsLoading || id == null || id.isEmpty
? null
: () => run(() async {
await data.deleteFriendship(id);
if (!mounted) return;
setState(() => _friendship = status.copyWith(status: 'none'));
}),
icon: const Icon(Icons.person_remove),
label: const Text('Unfriend'),
),
);
}
if (buttons.isEmpty) return const SizedBox.shrink();
return Wrap(spacing: 8, runSpacing: 8, children: buttons);
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthService>();
final theme = Theme.of(context);
final canEdit = auth.userId != null && auth.userId == _userId;
return Scaffold(
appBar: AppBar(
title: const Text('User profile'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: _handleBack,
),
),
body: RefreshIndicator(
onRefresh: _loadProfile,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildProfileHeader(theme),
const SizedBox(height: 12),
_buildFriendSection(auth),
const SizedBox(height: 12),
_buildTopLocos(),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Entries', style: theme.textTheme.titleMedium),
if (_loading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
],
),
const SizedBox(height: 8),
if (_loading && _legs.isEmpty)
const Center(
child: Padding(
padding: EdgeInsets.symmetric(vertical: 16.0),
child: CircularProgressIndicator(),
),
)
else if (_legs.isEmpty)
Text(
(_profile?.privacyInfo.isNotEmpty ?? false)
? 'Hidden due to privacy settings.'
: 'No entries found.',
)
else ...[
..._buildLegsWithDividers(
context,
_legs,
showEditButton: canEdit,
),
const SizedBox(height: 8),
if ((_hasMore || _loadingMore) && _legs.isNotEmpty)
Align(
alignment: Alignment.center,
child: OutlinedButton.icon(
onPressed: _loadingMore ? null : _loadMore,
icon: _loadingMore
? const SizedBox(
height: 14,
width: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
),
)
: const Icon(Icons.expand_more),
label: Text(
_loadingMore ? 'Loading...' : 'Load more',
),
),
),
],
],
),
),
),
],
),
),
);
}
}

View File

@@ -10,7 +10,9 @@ import 'package:mileograph_flutter/components/calculator/calculator.dart';
import 'package:mileograph_flutter/components/pages/traction.dart'; 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/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/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

@@ -4,15 +4,28 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
Future<bool> _handleExitIntent() async { Future<bool> _handleExitIntent() async {
if (!mounted) return false; if (!mounted) return false;
if (_isEditing) return true; if (_isEditing) return true;
if (_activeLegShare != null) return true;
if (_formIsEmpty()) return true; if (_formIsEmpty()) return true;
if (_activeDraftId != null && !_draftChangedFromBaseline()) { if (_activeDraftId != null && !_draftChangedFromBaseline()) {
return true; return true;
} }
final currentSnapshot = _currentSubmissionSnapshot();
if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, currentSnapshot)) {
return true;
}
final choice = await _promptSaveDraft(); final choice = await _promptSaveDraft();
if (choice == _ExitChoice.cancel) return false; if (choice == _ExitChoice.cancel) return false;
if (choice == _ExitChoice.save) { if (choice == _ExitChoice.save) {
try {
await _saveDraftEntry(draftId: _activeDraftId); await _saveDraftEntry(draftId: _activeDraftId);
} catch (_) {
return true;
}
} 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,18 +42,28 @@ 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;
} }
Future<_ExitChoice> _promptSaveDraft() async { Future<_ExitChoice> _promptSaveDraft() async {
if (!mounted) return _ExitChoice.cancel; if (!mounted) return _ExitChoice.cancel;
try {
final result = await showDialog<_ExitChoice>( final result = await showDialog<_ExitChoice>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
@@ -66,10 +89,15 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
], ],
), ),
); );
if (!mounted) return _ExitChoice.cancel;
return result ?? _ExitChoice.cancel; return result ?? _ExitChoice.cancel;
} catch (_) {
return _ExitChoice.cancel;
}
} }
Future<void> _openDrafts() async { Future<void> _openDrafts() async {
if (_activeLegShare != null) return;
final selected = await Navigator.of(context).push<_StoredDraft>( final selected = await Navigator.of(context).push<_StoredDraft>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => _DraftListPage( builder: (_) => _DraftListPage(
@@ -85,6 +113,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
} }
Future<void> _saveDraftManually() async { Future<void> _saveDraftManually() async {
if (_activeLegShare != null) return;
if (_savingDraft) return; if (_savingDraft) return;
if (_formIsEmpty()) { if (_formIsEmpty()) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar( ScaffoldMessenger.maybeOf(context)?.showSnackBar(
@@ -111,7 +140,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
} }
Future<void> _saveDraft() async { Future<void> _saveDraft() async {
if (_restoringDraft || !_draftPersistenceEnabled) return; if (_restoringDraft || !_draftPersistenceEnabled || _activeLegShare != null) {
return;
}
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final draft = { final draft = {
"date": _selectedDate.toIso8601String(), "date": _selectedDate.toIso8601String(),
@@ -122,6 +153,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
@@ -133,6 +188,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
"distance": _routeResult!.distance, "distance": _routeResult!.distance,
}, },
"tractionItems": _serializeTractionItems(), "tractionItems": _serializeTractionItems(),
"shareUserIds": _shareUserIds.toList(),
"shareUsers": _shareUsers
.map((u) => {
"user_id": u.userId,
"username": u.username,
"full_name": u.fullName,
})
.toList(),
}; };
await prefs.setString(_kDraftPrefsKey, jsonEncode(draft)); await prefs.setString(_kDraftPrefsKey, jsonEncode(draft));
} }
@@ -199,7 +262,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 +277,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 {
@@ -248,6 +324,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
"distance": _routeResult!.distance, "distance": _routeResult!.distance,
}, },
"tractionItems": _serializeTractionItems(), "tractionItems": _serializeTractionItems(),
"shareUserIds": _shareUserIds.toList(),
"shareUsers": _shareUsers
.map((u) => {
"user_id": u.userId,
"username": u.username,
"full_name": u.fullName,
})
.toList(),
}; };
} }
@@ -265,8 +349,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 +420,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 +442,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 +453,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'];
@@ -346,6 +479,19 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
..clear() ..clear()
..add(_TractionItem.marker()); ..add(_TractionItem.marker());
} }
final shareIdsRaw = data['shareUserIds'];
final shareUsersRaw = data['shareUsers'];
_shareUserIds = shareIdsRaw is List
? shareIdsRaw.map((e) => e.toString()).toSet()
: {};
_shareUsers = shareUsersRaw is List
? shareUsersRaw
.whereType<Map>()
.map((e) => UserSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList()
: [];
_lastSubmittedSnapshot = null; _lastSubmittedSnapshot = null;
final idRaw = data['id']; final idRaw = data['id'];
if (idRaw != null) { if (idRaw != null) {
@@ -359,6 +505,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,16 +60,25 @@ 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 snapshot = _buildSubmissionSnapshot( final endTime = _legEndDateTime;
final originTime = _originDateTime;
final destinationTime = _destinationDateTime;
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final snapshot = _currentSubmissionSnapshot(
routeStations: routeStations, routeStations: routeStations,
startVal: startVal, startVal: startVal,
endVal: endVal, endVal: endVal,
mileageVal: mileageVal, mileageVal: mileageVal,
tractionPayload: tractionPayload, tractionPayload: tractionPayload,
beginDelay: beginDelay,
endDelay: endDelay,
); );
if (_lastSubmittedSnapshot != null && if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) { _snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) {
@@ -82,19 +93,33 @@ 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 (_activeLegShare != null) "leg_share_id": _activeLegShare!.id,
"share_user_ids": _shareUserIds.toList(),
};
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);
@@ -103,14 +128,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);
@@ -121,6 +140,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
if (!mounted) return; if (!mounted) return;
dataService.refreshLegs(); dataService.refreshLegs();
await dataService.fetchNotifications(); await dataService.fetchNotifications();
if (_shareNotificationId != null) {
await dataService.dismissNotifications([_shareNotificationId!]);
}
if (!mounted) return; if (!mounted) return;
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar( SnackBar(
@@ -141,26 +163,60 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
} }
} }
Map<String, dynamic> _buildSubmissionSnapshot({ Map<String, dynamic> _currentSubmissionSnapshot({
required List<String> routeStations, List<String>? routeStations,
required String startVal, String? startVal,
required String endVal, String? endVal,
required double mileageVal, double? mileageVal,
required List<Map<String, dynamic>> tractionPayload, List<Map<String, dynamic>>? tractionPayload,
int? beginDelay,
int? endDelay,
}) { }) {
final stations = routeStations ?? (_routeResult?.calculatedRoute ?? []);
final start = startVal ??
(_useManualMileage
? _startController.text.trim()
: (stations.isNotEmpty ? stations.first : ''));
final end = endVal ??
(_useManualMileage
? _endController.text.trim()
: (stations.isNotEmpty ? stations.last : ''));
final mileage = mileageVal ??
(_useManualMileage
? (_distanceUnits(context)
.milesFromInput(_mileageController.text.trim()) ??
0)
: (_routeResult?.distance ?? 0));
final traction = tractionPayload ?? _buildTractionPayload();
final begin = beginDelay ?? _parseDelayMinutes(_beginDelayController.text);
final endDelayVal =
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(),
"start": startVal, "legEndTime": _legEndDateTime?.toIso8601String(),
"end": endVal, "hasEndTime": _hasEndTime,
"routeStations": routeStations, "legOriginTime": _originDateTime?.toIso8601String(),
"mileage": mileageVal, "hasOriginTime": _hasOriginTime,
"legDestinationTime": _destinationDateTime?.toIso8601String(),
"hasDestinationTime": _hasDestinationTime,
"start": start,
"end": end,
"origin": _originController.text.trim(),
"destination": _destinationController.text.trim(),
"routeStations": stations,
"mileage": mileage,
"network": _networkController.text.trim(), "network": _networkController.text.trim(),
"notes": _notesController.text.trim(), "notes": _notesController.text.trim(),
"headcode": _headcodeController.text.trim(), "headcode": _headcodeController.text.trim(),
"locos": tractionPayload, "beginDelay": begin,
"endDelay": endDelayVal,
"legShareId": _activeLegShare?.id,
"shareUserIds": _shareUserIds.toList(),
"locos": traction,
"routeResult": _routeResult == null "routeResult": _routeResult == null
? null ? null
: { : {
@@ -176,16 +232,16 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
if (!mounted) return false; if (!mounted) return false;
final result = await showDialog<bool>( final result = await showDialog<bool>(
context: context, context: context,
builder: (_) => AlertDialog( builder: (dialogContext) => AlertDialog(
title: const Text('Duplicate entry?'), title: const Text('Duplicate entry?'),
content: const Text('Entry already added, are you sure?'), content: const Text('Entry already added, are you sure?'),
actions: [ actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(false), onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
ElevatedButton( ElevatedButton(
onPressed: () => Navigator.of(context).pop(true), onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Submit anyway'), child: const Text('Submit anyway'),
), ),
], ],
@@ -195,6 +251,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
} }
Future<void> _resetFormState({bool clearDraft = false}) async { Future<void> _resetFormState({bool clearDraft = false}) async {
final hadShare = _activeLegShare != null || widget.legShare != null;
_formKey.currentState?.reset(); _formKey.currentState?.reset();
_startController.clear(); _startController.clear();
_endController.clear(); _endController.clear();
@@ -202,12 +259,33 @@ 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;
_activeLegShare = null;
_sharedFromUser = null;
_shareNotificationId = null;
_shareUserIds.clear();
_shareUsers.clear();
_tractionItems _tractionItems
..clear() ..clear()
..add(_TractionItem.marker()); ..add(_TractionItem.marker());
@@ -217,6 +295,10 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
_savingDraft = false; _savingDraft = false;
_loadedDraftSnapshot = null; _loadedDraftSnapshot = null;
}); });
if (hadShare && mounted) {
// Clear any share params from the URL when resetting.
GoRouter.of(context).go('/add');
}
if (clearDraft) { if (clearDraft) {
await _clearDraft(); await _clearDraft();
} }

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(

File diff suppressed because it is too large Load Diff

View File

@@ -3,7 +3,10 @@ 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/accent_color_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/theme_mode_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';
@@ -17,6 +20,18 @@ class SettingsPage extends StatefulWidget {
class _SettingsPageState extends State<SettingsPage> { class _SettingsPageState extends State<SettingsPage> {
late final TextEditingController _endpointController; late final TextEditingController _endpointController;
bool _saving = false; bool _saving = false;
static const List<Color> _accentPalette = [
Colors.red,
Colors.pink,
Colors.orange,
Colors.amber,
Colors.green,
Colors.teal,
Colors.blue,
Colors.indigo,
Colors.purple,
Colors.cyan,
];
@override @override
void initState() { void initState() {
@@ -128,7 +143,13 @@ 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 accentService = context.watch<AccentColorService>();
final themeModeService = context.watch<ThemeModeService>();
if (!endpointService.isLoaded ||
!distanceUnitService.isLoaded ||
!accentService.isLoaded ||
!themeModeService.isLoaded) {
return const Scaffold( return const Scaffold(
body: Center(child: CircularProgressIndicator()), body: Center(child: CircularProgressIndicator()),
); );
@@ -144,16 +165,111 @@ class _SettingsPageState extends State<SettingsPage> {
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.pop(); navigator.pop();
} else { } else {
context.go('/'); context.go('/more');
} }
}, },
), ),
), ),
body: Padding( body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
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(
'Accent colour',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
Text(
'Choose your preferred accent colour or use system colours.',
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
OutlinedButton.icon(
onPressed:
accentService.useSystem ? null : () => accentService.setUseSystem(true),
icon: const Icon(Icons.phone_android),
label: const Text('Use system colours'),
),
..._accentPalette.map(
(color) => _AccentSwatchButton(
color: color,
selected:
!accentService.useSystem &&
accentService.seedColor.toARGB32() == color.toARGB32(),
onTap: () => accentService.setSeedColor(color),
),
),
],
),
const SizedBox(height: 24),
Text(
'Theme mode',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
SegmentedButton<ThemeMode>(
segments: const [
ButtonSegment(
value: ThemeMode.system,
icon: Icon(Icons.settings_suggest),
label: Text('System'),
),
ButtonSegment(
value: ThemeMode.light,
icon: Icon(Icons.light_mode),
label: Text('Light'),
),
ButtonSegment(
value: ThemeMode.dark,
icon: Icon(Icons.dark_mode),
label: Text('Dark'),
),
],
selected: {themeModeService.mode},
onSelectionChanged: (selection) {
final mode = selection.first;
themeModeService.setMode(mode);
},
),
const SizedBox(height: 24),
Text( Text(
'API endpoint', 'API endpoint',
style: Theme.of(context).textTheme.titleMedium?.copyWith( style: Theme.of(context).textTheme.titleMedium?.copyWith(
@@ -211,3 +327,54 @@ class _SettingsPageState extends State<SettingsPage> {
); );
} }
} }
class _AccentSwatchButton extends StatelessWidget {
const _AccentSwatchButton({
required this.color,
required this.selected,
required this.onTap,
});
final Color color;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final borderColor = selected
? Theme.of(context).colorScheme.onSurface
: Colors.black26;
return InkWell(
onTap: onTap,
customBorder: const CircleBorder(),
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
width: selected ? 3 : 1,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: selected
? const Center(
child: Icon(
Icons.check,
size: 18,
color: Colors.white,
),
)
: null,
),
);
}
}

View File

@@ -0,0 +1,240 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.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(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
final nav = Navigator.of(context);
if (nav.canPop()) {
nav.maybePop();
} else {
context.go('/more');
}
},
tooltip: 'Back',
),
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,8 @@ 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:mileograph_flutter/services/authservice.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

@@ -1,14 +1,26 @@
part of 'traction.dart'; part of 'traction.dart';
enum _TractionMoreAction {
classStats,
classLeaderboard,
adminPending,
}
class TractionPage extends StatefulWidget { class TractionPage extends StatefulWidget {
const TractionPage({ const TractionPage({
super.key, super.key,
this.selectionMode = false, this.selectionMode = false,
this.selectionSingle = false,
this.replacementPendingLocoId,
this.transferFromLocoId,
this.onSelect, this.onSelect,
this.selectedKeys = const {}, this.selectedKeys = const {},
}); });
final bool selectionMode; final bool selectionMode;
final bool selectionSingle;
final int? replacementPendingLocoId;
final int? transferFromLocoId;
final ValueChanged<LocoSummary>? onSelect; final ValueChanged<LocoSummary>? onSelect;
final Set<String> selectedKeys; final Set<String> selectedKeys;
@@ -23,6 +35,7 @@ class _TractionPageState extends State<TractionPage> {
final _nameController = TextEditingController(); final _nameController = TextEditingController();
bool _mileageFirst = true; bool _mileageFirst = true;
bool _initialised = false; bool _initialised = false;
int? get _transferFromLocoId => widget.transferFromLocoId;
bool _showAdvancedFilters = false; bool _showAdvancedFilters = false;
String? _selectedClass; String? _selectedClass;
late Set<String> _selectedKeys; late Set<String> _selectedKeys;
@@ -33,10 +46,21 @@ class _TractionPageState extends State<TractionPage> {
String? _classStatsError; String? _classStatsError;
String? _classStatsForClass; String? _classStatsForClass;
Map<String, dynamic>? _classStats; Map<String, dynamic>? _classStats;
bool _showClassLeaderboardPanel = false;
bool _classLeaderboardLoading = false;
String? _classLeaderboardError;
String? _classLeaderboardForClass;
String? _classFriendsLeaderboardForClass;
List<LeaderboardEntry> _classLeaderboard = [];
List<LeaderboardEntry> _classFriendsLeaderboard = [];
_ClassLeaderboardScope _classLeaderboardScope = _ClassLeaderboardScope.global;
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 +83,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 +130,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 +167,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();
} }
@@ -149,6 +232,13 @@ class _TractionPageState extends State<TractionPage> {
_classStats = null; _classStats = null;
_classStatsError = null; _classStatsError = null;
_classStatsForClass = null; _classStatsForClass = null;
_showClassLeaderboardPanel = false;
_classLeaderboard = [];
_classFriendsLeaderboard = [];
_classLeaderboardError = null;
_classLeaderboardForClass = null;
_classFriendsLeaderboardForClass = null;
_classLeaderboardScope = _ClassLeaderboardScope.global;
}); });
_refreshTraction(); _refreshTraction();
} }
@@ -161,6 +251,7 @@ class _TractionPageState extends State<TractionPage> {
}); });
} }
_refreshClassStatsIfOpen(); _refreshClassStatsIfOpen();
_refreshClassLeaderboardIfOpen();
} }
List<EventField> _activeEventFields(List<EventField> fields) { List<EventField> _activeEventFields(List<EventField> fields) {
@@ -343,6 +434,7 @@ class _TractionPageState extends State<TractionPage> {
}); });
_refreshTraction(); _refreshTraction();
_refreshClassStatsIfOpen(immediate: true); _refreshClassStatsIfOpen(immediate: true);
_refreshClassLeaderboardIfOpen(immediate: true);
}, },
), ),
), ),
@@ -456,6 +548,19 @@ class _TractionPageState extends State<TractionPage> {
), ),
), ),
), ),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
sliver: SliverToBoxAdapter(
child: AnimatedCrossFade(
crossFadeState: (_showClassLeaderboardPanel && _hasClassQuery)
? CrossFadeState.showFirst
: CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
firstChild: _buildClassLeaderboardCard(context),
secondChild: const SizedBox.shrink(),
),
),
),
SliverPadding( SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
sliver: _buildTractionSliver(context, data, traction), sliver: _buildTractionSliver(context, data, traction),
@@ -507,23 +612,14 @@ class _TractionPageState extends State<TractionPage> {
} }
Widget _buildHeaderActions(BuildContext context, bool isMobile) { Widget _buildHeaderActions(BuildContext context, bool isMobile) {
final isElevated = context.read<AuthService>().isElevated;
final refreshButton = IconButton( final refreshButton = IconButton(
tooltip: 'Refresh', tooltip: 'Refresh',
onPressed: _refreshTraction, onPressed: _refreshTraction,
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
); );
final classStatsButton = !_hasClassQuery final hasClassActions = _hasClassQuery;
? null
: FilledButton.tonalIcon(
onPressed: _toggleClassStatsPanel,
icon: Icon(
_showClassStatsPanel ? Icons.bar_chart : Icons.insights,
),
label: Text(
_showClassStatsPanel ? 'Hide class stats' : 'Class stats',
),
);
final newTractionButton = FilledButton.icon( final newTractionButton = FilledButton.icon(
onPressed: () async { onPressed: () async {
@@ -543,15 +639,86 @@ class _TractionPageState extends State<TractionPage> {
label: const Text('New Traction'), label: const Text('New Traction'),
); );
final hasAdminActions = isElevated;
final hasMoreMenu = hasClassActions || hasAdminActions;
final moreButton = !hasMoreMenu
? null
: PopupMenuButton<_TractionMoreAction>(
tooltip: 'More options',
onSelected: (action) async {
switch (action) {
case _TractionMoreAction.classStats:
_toggleClassStatsPanel();
break;
case _TractionMoreAction.classLeaderboard:
_toggleClassLeaderboardPanel();
break;
case _TractionMoreAction.adminPending:
final messenger = ScaffoldMessenger.of(context);
try {
await context.push('/traction/pending');
if (!mounted) return;
} catch (_) {
if (!mounted) return;
messenger.showSnackBar(
const SnackBar(
content: Text('Unable to open pending locos'),
),
);
}
break;
}
},
itemBuilder: (context) {
final items = <PopupMenuEntry<_TractionMoreAction>>[];
if (hasClassActions) {
items.add(
const PopupMenuItem(
value: _TractionMoreAction.classStats,
child: Text('Class stats'),
),
);
}
if (hasClassActions) {
items.add(
const PopupMenuItem(
value: _TractionMoreAction.classLeaderboard,
child: Text('Class leaderboard'),
),
);
}
if (items.isNotEmpty && hasAdminActions) {
items.add(const PopupMenuDivider());
}
if (hasAdminActions) {
items.add(
const PopupMenuItem(
value: _TractionMoreAction.adminPending,
child: Text('Pending locos'),
),
);
}
return items;
},
child: IgnorePointer(
child: FilledButton.tonalIcon(
onPressed: () {},
icon: const Icon(Icons.more_horiz),
label: const Text('More'),
),
),
);
final desktopActions = [ final desktopActions = [
refreshButton, refreshButton,
if (classStatsButton != null) classStatsButton,
newTractionButton, newTractionButton,
if (moreButton != null) moreButton,
]; ];
final mobileActions = [ final mobileActions = [
if (moreButton != null) moreButton,
newTractionButton, newTractionButton,
if (classStatsButton != null) classStatsButton,
refreshButton, refreshButton,
]; ];
@@ -639,6 +806,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 +889,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),
@@ -860,6 +1046,42 @@ class _TractionPageState extends State<TractionPage> {
return total; return total;
} }
Widget _placementBadge(BuildContext context, int index) {
const size = 32.0;
const iconSize = 18.0;
if (index == 0) {
return CircleAvatar(
radius: size / 2,
backgroundColor: Colors.amber.shade400,
child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize),
);
}
if (index == 1) {
return CircleAvatar(
radius: size / 2,
backgroundColor: Colors.blueGrey.shade200,
child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize),
);
}
if (index == 2) {
return CircleAvatar(
radius: size / 2,
backgroundColor: Colors.brown.shade300,
child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize),
);
}
return CircleAvatar(
radius: size / 2,
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
child: Text(
'${index + 1}',
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
),
);
}
Color _statusColor(String status, ColorScheme scheme) { Color _statusColor(String status, ColorScheme scheme) {
final key = status.toLowerCase(); final key = status.toLowerCase();
if (key.contains('scrap')) return Colors.red.shade600; if (key.contains('scrap')) return Colors.red.shade600;
@@ -891,6 +1113,12 @@ class _TractionPageState extends State<TractionPage> {
if (widget.onSelect != null) { if (widget.onSelect != null) {
widget.onSelect!(loco); widget.onSelect!(loco);
} }
if (widget.selectionMode && widget.selectionSingle) {
if (mounted) {
context.pop(loco);
}
return;
}
setState(() { setState(() {
if (_selectedKeys.contains(keyVal)) { if (_selectedKeys.contains(keyVal)) {
_selectedKeys.remove(keyVal); _selectedKeys.remove(keyVal);
@@ -900,6 +1128,128 @@ class _TractionPageState extends State<TractionPage> {
}); });
} }
Future<void> _confirmReplacePending(LocoSummary replacement) async {
final pendingId = widget.replacementPendingLocoId;
if (pendingId == null) return;
final navContext = context;
final messenger = ScaffoldMessenger.of(navContext);
String rejectionReason = '';
final confirmed = await showDialog<bool>(
context: navContext,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
final canSubmit = rejectionReason.trim().isNotEmpty;
return AlertDialog(
title: const Text('Replace pending loco?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Replace pending loco with ${replacement.locoClass} ${replacement.number}?',
),
const SizedBox(height: 12),
TextField(
autofocus: true,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Rejection reason',
hintText: 'Reason for replacing this loco',
),
onChanged: (val) => setState(() {
rejectionReason = val;
}),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed:
canSubmit ? () => Navigator.of(dialogContext).pop(true) : null,
child: const Text('Replace'),
),
],
);
},
);
},
);
if (confirmed != true) return;
if (!navContext.mounted) return;
try {
final data = navContext.read<DataService>();
await data.rejectPendingLoco(
locoId: pendingId,
replacementLocoId: replacement.id,
rejectedReason: rejectionReason,
);
if (navContext.mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Pending loco replaced')),
);
navContext.pop();
}
} catch (e) {
if (navContext.mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to replace loco: $e')),
);
}
}
}
Future<void> _confirmTransfer(LocoSummary target) async {
final fromId = _transferFromLocoId;
if (fromId == null) return;
final navContext = context;
final messenger = ScaffoldMessenger.of(navContext);
final confirmed = await showDialog<bool>(
context: navContext,
builder: (dialogContext) {
return AlertDialog(
title: const Text('Transfer allocations?'),
content: Text(
'Transfer all allocations from this loco to ${target.locoClass} ${target.number}?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Transfer'),
),
],
);
},
);
if (confirmed != true) return;
if (!navContext.mounted) return;
try {
final data = navContext.read<DataService>();
await data.transferAllocations(fromLocoId: fromId, toLocoId: target.id);
if (navContext.mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Allocations transferred')),
);
}
await _refreshTraction(preservePosition: true);
if (navContext.mounted) navContext.pop();
} catch (e) {
if (navContext.mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to transfer allocations: $e')),
);
}
}
}
bool _isSelected(LocoSummary loco) { bool _isSelected(LocoSummary loco) {
final keyVal = '${loco.locoClass}-${loco.number}'; final keyVal = '${loco.locoClass}-${loco.number}';
return _selectedKeys.contains(keyVal); return _selectedKeys.contains(keyVal);
@@ -1036,11 +1386,29 @@ class _TractionPageState extends State<TractionPage> {
loco: loco, loco: loco,
selectionMode: widget.selectionMode, selectionMode: widget.selectionMode,
isSelected: _isSelected(loco), isSelected: _isSelected(loco),
onShowInfo: () => showTractionDetails(context, loco), onShowInfo: () => showTractionDetails(
context,
loco,
onActionComplete: () => _refreshTraction(preservePosition: true),
),
onOpenTimeline: () => _openTimeline(loco), onOpenTimeline: () => _openTimeline(loco),
onOpenLegs: () => _openLegs(loco), onOpenLegs: () => _openLegs(loco),
onToggleSelect: onActionComplete: _refreshTraction,
widget.selectionMode ? () => _toggleSelection(loco) : null, onToggleSelect: widget.selectionMode &&
widget.replacementPendingLocoId == null
? () => _toggleSelection(loco)
: null,
onReplacePending: widget.selectionMode &&
widget.selectionSingle &&
widget.replacementPendingLocoId != null
? () => _confirmReplacePending(loco)
: null,
onTransferAllocations: widget.selectionMode &&
widget.selectionSingle &&
widget.transferFromLocoId != null &&
widget.transferFromLocoId != loco.id
? () => _confirmTransfer(loco)
: null,
); );
} }
@@ -1066,4 +1434,212 @@ class _TractionPageState extends State<TractionPage> {
), ),
); );
} }
Future<void> _toggleClassLeaderboardPanel() async {
if (!_hasClassQuery) return;
final targetState = !_showClassLeaderboardPanel;
setState(() {
_showClassLeaderboardPanel = targetState;
});
if (targetState) {
await _loadClassLeaderboard(friends: _classLeaderboardScope == _ClassLeaderboardScope.friends);
} }
}
void _refreshClassLeaderboardIfOpen({bool immediate = false}) {
if (!_showClassLeaderboardPanel || !_hasClassQuery) return;
final query = (_selectedClass ?? _classController.text).trim();
final scope = _classLeaderboardScope;
final currentData = scope == _ClassLeaderboardScope.global
? _classLeaderboard
: _classFriendsLeaderboard;
final currentClass = scope == _ClassLeaderboardScope.global
? _classLeaderboardForClass
: _classFriendsLeaderboardForClass;
if (!immediate && currentClass == query && currentData.isNotEmpty) {
return;
}
_loadClassLeaderboard(
friends: scope == _ClassLeaderboardScope.friends,
);
}
Future<void> _loadClassLeaderboard({required bool friends}) async {
final query = (_selectedClass ?? _classController.text).trim();
if (query.isEmpty) return;
final currentClass = friends ? _classFriendsLeaderboardForClass : _classLeaderboardForClass;
final currentData = friends ? _classFriendsLeaderboard : _classLeaderboard;
if (currentClass == query && currentData.isNotEmpty) return;
setState(() {
_classLeaderboardLoading = true;
_classLeaderboardError = null;
if (friends && _classFriendsLeaderboardForClass != query) {
_classFriendsLeaderboard = [];
} else if (!friends && _classLeaderboardForClass != query) {
_classLeaderboard = [];
}
});
try {
final data = context.read<DataService>();
final leaderboard = await data.fetchClassLeaderboard(
query,
friends: friends,
);
if (!mounted) return;
setState(() {
if (friends) {
_classFriendsLeaderboard = leaderboard;
_classFriendsLeaderboardForClass = query;
} else {
_classLeaderboard = leaderboard;
_classLeaderboardForClass = query;
}
_classLeaderboardError = null;
});
} catch (e) {
if (!mounted) return;
setState(() {
_classLeaderboardError = 'Failed to load class leaderboard: $e';
});
} finally {
if (mounted) {
setState(() {
_classLeaderboardLoading = false;
});
}
}
}
Widget _buildClassLeaderboardCard(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final distanceUnits = context.watch<DistanceUnitService>();
final leaderboard = _classLeaderboardScope == _ClassLeaderboardScope.global
? _classLeaderboard
: _classFriendsLeaderboard;
final loading = _classLeaderboardLoading;
final error = _classLeaderboardError;
return Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
(_selectedClass ?? _classController.text).trim().isEmpty
? 'Class leaderboard'
: '${(_selectedClass ?? _classController.text).trim()} leaderboard',
style: Theme.of(context).textTheme.titleMedium,
),
),
SegmentedButton<_ClassLeaderboardScope>(
segments: const [
ButtonSegment(
value: _ClassLeaderboardScope.global,
label: Text('Global'),
),
ButtonSegment(
value: _ClassLeaderboardScope.friends,
label: Text('Friends'),
),
],
selected: {_classLeaderboardScope},
onSelectionChanged: (vals) async {
if (vals.isEmpty) return;
final selected = vals.first;
setState(() => _classLeaderboardScope = selected);
if (selected == _ClassLeaderboardScope.friends &&
_classFriendsLeaderboard.isEmpty &&
!_classLeaderboardLoading) {
await _loadClassLeaderboard(friends: true);
} else if (selected == _ClassLeaderboardScope.global &&
_classLeaderboard.isEmpty &&
!_classLeaderboardLoading) {
await _loadClassLeaderboard(friends: false);
}
},
style: SegmentedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
const SizedBox(width: 8),
IconButton(
tooltip: 'Refresh leaderboard',
icon: const Icon(Icons.refresh),
onPressed: () => _loadClassLeaderboard(
friends: _classLeaderboardScope == _ClassLeaderboardScope.friends,
),
),
],
),
const SizedBox(height: 12),
if (loading)
Row(
children: const [
SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 12),
Text('Loading leaderboard...'),
],
)
else if (error != null)
Text(
error,
style: TextStyle(color: scheme.error),
)
else if (leaderboard.isEmpty)
const Text('No leaderboard data yet.')
else
Column(
children: [
for (int i = 0; i < leaderboard.length; i++) ...[
ListTile(
contentPadding: EdgeInsets.zero,
dense: true,
leading: _placementBadge(context, i),
title: Text(
leaderboard[i].userFullName,
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
trailing: Text(
distanceUnits.format(
leaderboard[i].mileage,
decimals: 1,
),
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
onTap: () {
final auth = context.read<AuthService>();
final userId = leaderboard[i].userId;
if (auth.userId == userId) {
context.go('/more/profile');
} else {
context.pushNamed(
'user-profile',
queryParameters: {'user_id': userId},
);
}
},
),
if (i != leaderboard.length - 1) const Divider(height: 12),
],
],
),
],
),
),
);
}
}
enum _ClassLeaderboardScope { global, friends }

View File

@@ -0,0 +1,140 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/traction/traction_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:provider/provider.dart';
class TractionPendingPage extends StatefulWidget {
const TractionPendingPage({super.key});
@override
State<TractionPendingPage> createState() => _TractionPendingPageState();
}
class _TractionPendingPageState extends State<TractionPendingPage> {
bool _isLoading = false;
String? _error;
List<LocoSummary> _locos = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final api = context.read<ApiService>();
final params = '?limit=200&offset=0';
final json = await api.get('/loco/pending$params');
if (json is List) {
setState(() {
_locos = json
.whereType<Map>()
.map((e) => LocoSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
});
} else {
setState(() {
_error = 'Unexpected response';
_locos = const [];
});
}
} catch (e) {
setState(() {
_error = e.toString();
_locos = const [];
});
} finally {
if (mounted) {
setState(() {
_isLoading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: const Text('Pending traction'),
),
body: RefreshIndicator(
onRefresh: _load,
child: _buildBody(context),
),
);
}
Widget _buildBody(BuildContext context) {
if (_isLoading && _locos.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (_error != null) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'Failed to load pending traction: $_error',
style: Theme.of(context)
.textTheme
.bodyMedium
?.copyWith(color: Theme.of(context).colorScheme.error),
),
const SizedBox(height: 12),
FilledButton.icon(
onPressed: _load,
icon: const Icon(Icons.refresh),
label: const Text('Retry'),
),
],
);
}
if (_locos.isEmpty) {
return ListView(
padding: const EdgeInsets.all(16),
children: const [
Text('No pending traction found.'),
],
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: _locos.length,
itemBuilder: (context, index) {
final loco = _locos[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: TractionCard(
loco: loco,
selectionMode: false,
isSelected: false,
onShowInfo: () => showTractionDetails(
context,
loco,
onActionComplete: _load,
),
onOpenTimeline: () => context.push(
'/traction/${loco.id}/timeline',
extra: {'label': '${loco.locoClass} ${loco.number}'.trim()},
),
onOpenLegs: () => context.push('/traction/${loco.id}/legs'),
onActionComplete: _load,
),
);
},
);
}
}

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,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class TractionCard extends StatelessWidget { class TractionCard extends StatelessWidget {
const TractionCard({ const TractionCard({
@@ -11,6 +18,9 @@ class TractionCard extends StatelessWidget {
required this.onOpenTimeline, required this.onOpenTimeline,
this.onOpenLegs, this.onOpenLegs,
this.onToggleSelect, this.onToggleSelect,
this.onReplacePending,
this.onActionComplete,
this.onTransferAllocations,
}); });
final LocoSummary loco; final LocoSummary loco;
@@ -20,14 +30,23 @@ class TractionCard extends StatelessWidget {
final VoidCallback onOpenTimeline; final VoidCallback onOpenTimeline;
final VoidCallback? onOpenLegs; final VoidCallback? onOpenLegs;
final VoidCallback? onToggleSelect; final VoidCallback? onToggleSelect;
final VoidCallback? onReplacePending;
final Future<void> Function()? onActionComplete;
final VoidCallback? onTransferAllocations;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final status = loco.status ?? 'Unknown'; final status = loco.status ?? 'Unknown';
final operatorName = loco.operator ?? ''; final operatorName = loco.operator ?? '';
final domain = loco.domain ?? ''; final domain = loco.domain ?? '';
final isVisibilityPending =
(loco.visibility ?? '').toLowerCase().trim() == 'pending';
final isRejected =
(loco.visibility ?? '').toLowerCase().contains('reject');
final isElevated = context.read<AuthService>().isElevated;
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(
@@ -41,23 +60,14 @@ class TractionCard extends StatelessWidget {
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( _LocoNumberWithHistory(
children: [ number: loco.number,
Text( matchedNumber: loco.matchedNumber,
loco.number, matchedNumberValidTo: loco.matchedNumberValidTo,
style: Theme.of(context).textTheme.headlineSmall hasMileageOrTrips: hasMileageOrTrips,
?.copyWith(fontWeight: FontWeight.w800), largeStyle: Theme.of(context).textTheme.headlineSmall,
), showPendingChip: isVisibilityPending,
if (hasMileageOrTrips) showRejectedChip: isRejected && !isVisibilityPending,
Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Icon(
Icons.check_circle,
size: 18,
color: Colors.green.shade600,
),
),
],
), ),
Text( Text(
loco.locoClass, loco.locoClass,
@@ -74,6 +84,36 @@ class TractionCard extends StatelessWidget {
), ),
], ],
), ),
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (isElevated && isVisibilityPending) ...[
PopupMenuButton<_PendingLocoAction>(
tooltip: 'Pending options',
onSelected: (action) => _handlePendingAction(
context,
action,
loco,
onActionComplete: onActionComplete,
),
itemBuilder: (context) => const [
PopupMenuItem(
value: _PendingLocoAction.accept,
child: Text('Accept loco'),
),
PopupMenuItem(
value: _PendingLocoAction.reject,
child: Text('Reject loco'),
),
PopupMenuItem(
value: _PendingLocoAction.replace,
child: Text('Replace...'),
),
],
icon: const Icon(Icons.more_vert),
),
const SizedBox(width: 6),
],
Chip( Chip(
label: Text(status), label: Text(status),
backgroundColor: statusColors.$1, backgroundColor: statusColors.$1,
@@ -81,6 +121,8 @@ class TractionCard extends StatelessWidget {
), ),
], ],
), ),
],
),
const SizedBox(height: 8), const SizedBox(height: 8),
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@@ -104,7 +146,20 @@ class TractionCard extends StatelessWidget {
), ),
]; ];
final addButton = selectionMode && onToggleSelect != null // Prefer replace action when picking a replacement loco.
final addButton = onTransferAllocations != null
? TextButton.icon(
onPressed: onTransferAllocations,
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer'),
)
: onReplacePending != null
? TextButton.icon(
onPressed: onReplacePending,
icon: const Icon(Icons.swap_horiz),
label: const Text('Replace'),
)
: (!isRejected && selectionMode && onToggleSelect != null)
? TextButton.icon( ? TextButton.icon(
onPressed: onToggleSelect, onPressed: onToggleSelect,
icon: Icon( icon: Icon(
@@ -112,7 +167,8 @@ class TractionCard extends StatelessWidget {
? Icons.remove_circle_outline ? Icons.remove_circle_outline
: Icons.add_circle_outline, : Icons.add_circle_outline,
), ),
label: Text(isSelected ? 'Remove' : 'Add to entry'), label:
Text(isSelected ? 'Remove' : 'Add to entry'),
) )
: null; : null;
@@ -151,8 +207,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,
@@ -198,11 +257,331 @@ class TractionCard extends StatelessWidget {
} }
} }
class _LocoNumberWithHistory extends StatelessWidget {
const _LocoNumberWithHistory({
required this.number,
required this.matchedNumber,
required this.matchedNumberValidTo,
required this.hasMileageOrTrips,
this.largeStyle,
this.showPendingChip = false,
this.showRejectedChip = false,
});
final String number;
final String? matchedNumber;
final DateTime? matchedNumberValidTo;
final bool hasMileageOrTrips;
final TextStyle? largeStyle;
final bool showPendingChip;
final bool showRejectedChip;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final historicNumber = matchedNumber?.trim() ?? '';
final hasHistoricDate = matchedNumberValidTo != null;
final showHistoric = historicNumber.isNotEmpty && hasHistoricDate;
final historicDate =
hasHistoricDate ? DateFormat('yyyy-MM-dd').format(matchedNumberValidTo!) : null;
return Row(
children: [
Text(
number,
style: (largeStyle ?? theme.textTheme.titleLarge)?.copyWith(
fontWeight: FontWeight.w800,
),
),
if (hasMileageOrTrips)
Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Icon(
Icons.check_circle,
size: 18,
color: Colors.green.shade600,
),
),
if (showPendingChip) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.pending, size: 14),
const SizedBox(width: 4),
Text(
'Pending',
style: theme.textTheme.labelSmall
?.copyWith(fontWeight: FontWeight.w700),
),
],
),
),
],
if (showRejectedChip) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: Colors.red.shade700,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.close, size: 14, color: Colors.white),
const SizedBox(width: 4),
Text(
'Rejected',
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
],
),
),
],
if (showHistoric) ...[
const SizedBox(width: 8),
Text(
historicNumber,
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w800,
color: theme.colorScheme.onSurfaceVariant,
),
),
if (historicDate != null) ...[
const SizedBox(width: 6),
Text(
'until $historicDate',
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
],
],
);
}
}
enum _PendingLocoAction { accept, reject, replace }
Future<void> _handlePendingAction(
BuildContext context,
_PendingLocoAction action,
LocoSummary loco, {
Future<void> Function()? onActionComplete,
}) async {
final navContext = context;
final messenger = ScaffoldMessenger.of(navContext);
final data = navContext.read<DataService>();
if (action == _PendingLocoAction.replace) {
final path = Uri(
path: '/traction',
queryParameters: {
'selection': 'single',
'replacementPendingLocoId': loco.id.toString(),
},
).toString();
final selected = await navContext.push<LocoSummary>(
path,
extra: {
'selection': 'single',
'replacementPendingLocoId': loco.id,
},
);
if (!navContext.mounted) return;
if (selected == null) return;
String rejectionReason = '';
final confirmed = await showDialog<bool>(
context: navContext,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
final canSubmit = rejectionReason.trim().isNotEmpty;
return AlertDialog(
title: const Text('Replace pending loco?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Replace ${loco.locoClass} ${loco.number} with ${selected.locoClass} ${selected.number}?',
),
const SizedBox(height: 12),
TextField(
autofocus: true,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Rejection reason',
hintText: 'Reason for replacing this loco',
),
onChanged: (val) => setState(() {
rejectionReason = val;
}),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: canSubmit
? () => Navigator.of(dialogContext).pop(true)
: null,
child: const Text('Replace'),
),
],
);
},
);
},
);
if (!navContext.mounted) return;
if (confirmed != true) return;
try {
await data.rejectPendingLoco(
locoId: loco.id,
replacementLocoId: selected.id,
rejectedReason: rejectionReason,
);
await data.fetchClassList(force: true);
if (navContext.mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Pending loco replaced')),
);
}
await onActionComplete?.call();
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to replace loco: $e')),
);
}
return;
}
if (action == _PendingLocoAction.reject) {
String rejectionReason = '';
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) {
return StatefulBuilder(
builder: (context, setState) {
final canSubmit = rejectionReason.trim().isNotEmpty;
return AlertDialog(
title: const Text('Reject loco?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
autofocus: true,
maxLines: 2,
decoration: const InputDecoration(
labelText: 'Rejection reason',
hintText: 'Why is this loco being rejected?',
),
onChanged: (val) => setState(() {
rejectionReason = val;
}),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: canSubmit
? () => Navigator.of(context).pop(true)
: null,
child: const Text('Reject'),
),
],
);
},
);
},
);
if (confirmed != true) return;
if (!navContext.mounted) return;
try {
await data.rejectPendingLoco(
locoId: loco.id,
rejectedReason: rejectionReason,
);
await data.fetchClassList(force: true);
if (navContext.mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Pending loco rejected')),
);
}
await onActionComplete?.call();
} catch (e) {
if (navContext.mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to reject loco: $e')),
);
}
}
return;
}
try {
await data.acceptPendingLoco(locoId: loco.id);
if (navContext.mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Pending loco accepted')),
);
}
await onActionComplete?.call();
} catch (e) {
if (navContext.mounted) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to accept loco: $e')),
);
}
}
}
Future<void> showTractionDetails( Future<void> showTractionDetails(
BuildContext context, BuildContext context,
LocoSummary loco, LocoSummary loco, {
) async { Future<void> Function()? onActionComplete,
}) async {
final navContext = context;
final hasMileageOrTrips = _hasMileageOrTrips(loco); final hasMileageOrTrips = _hasMileageOrTrips(loco);
final isVisibilityPending =
(loco.visibility ?? '').toLowerCase().trim() == 'pending';
final isRejected =
(loco.visibility ?? '').toLowerCase().contains('reject');
final rejectedReason =
loco.extra['rejected_reason']?.toString().trim() ?? '';
final distanceUnits = context.read<DistanceUnitService>();
final api = context.read<ApiService>();
final data = context.read<DataService>();
final auth = context.read<AuthService>();
final messenger = ScaffoldMessenger.of(context);
final userId = auth.userId;
final createdBy = loco.extra['created_by']?.toString();
final isOwnedByUser =
userId != null && createdBy != null && createdBy == userId;
final canDeleteAsOwner = isOwnedByUser && (isVisibilityPending || isRejected);
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,
@@ -227,23 +606,13 @@ Future<void> showTractionDetails(
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( _LocoNumberWithHistory(
children: [ number: loco.number,
Text( matchedNumber: loco.matchedNumber,
loco.number, matchedNumberValidTo: loco.matchedNumberValidTo,
style: Theme.of(context).textTheme.titleLarge hasMileageOrTrips: hasMileageOrTrips,
?.copyWith(fontWeight: FontWeight.w800), showPendingChip: isVisibilityPending,
), showRejectedChip: isRejected && !isVisibilityPending,
if (hasMileageOrTrips)
Padding(
padding: const EdgeInsets.only(left: 6.0),
child: Icon(
Icons.check_circle,
size: 18,
color: Colors.green.shade600,
),
),
],
), ),
Text( Text(
loco.locoClass, loco.locoClass,
@@ -266,6 +635,36 @@ Future<void> showTractionDetails(
child: ListView( child: ListView(
controller: controller, controller: controller,
children: [ children: [
FilledButton.icon(
onPressed: () {
Navigator.of(ctx).pop();
navContext.push(
Uri(
path: '/traction',
queryParameters: {
'selection': 'single',
'transferFromLocoId': loco.id.toString(),
},
).toString(),
extra: {
'selection': 'single',
'transferFromLocoId': loco.id,
},
);
},
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer allocations'),
),
const SizedBox(height: 12),
if (isRejected && rejectedReason.isNotEmpty)
...[
_detailRow(
context,
'Rejection reason',
rejectedReason,
),
const Divider(),
],
_detailRow(context, 'Status', loco.status ?? 'Unknown'), _detailRow(context, 'Status', loco.status ?? 'Unknown'),
_detailRow(context, 'Operator', loco.operator ?? ''), _detailRow(context, 'Operator', loco.operator ?? ''),
_detailRow(context, 'Domain', loco.domain ?? ''), _detailRow(context, 'Domain', loco.domain ?? ''),
@@ -275,7 +674,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 +687,116 @@ 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(),
);
},
),
if (auth.isElevated || canDeleteAsOwner) ...[
const SizedBox(height: 16),
FilledButton.tonal(
style: FilledButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.errorContainer,
foregroundColor:
Theme.of(context).colorScheme.onErrorContainer,
),
onPressed: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (context) {
return AlertDialog(
title: const Text('Delete loco?'),
content: const Text(
'This will permanently delete this loco. Are you sure?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(true),
style: FilledButton.styleFrom(
backgroundColor:
Theme.of(context).colorScheme.error,
),
child: const Text('Delete'),
),
],
);
},
);
if (confirmed != true) return;
try {
await data.adminDeleteLoco(locoId: loco.id);
messenger.showSnackBar(
const SnackBar(content: Text('Loco deleted')),
);
await onActionComplete?.call();
if (!context.mounted) return;
Navigator.of(ctx).pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to delete loco: $e')),
);
}
},
child: const Text('Delete loco'),
),
],
], ],
), ),
), ),
@@ -297,6 +809,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 +979,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

@@ -0,0 +1,127 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class FriendRequestNotificationCard extends StatelessWidget {
const FriendRequestNotificationCard({super.key, required this.notification});
final UserNotification notification;
@override
Widget build(BuildContext context) {
final data = context.read<DataService>();
final friendshipId = notification.body.trim();
if (friendshipId.isEmpty) {
return const Text('Invalid friend request notification.');
}
final future = data.fetchFriendshipById(friendshipId);
return FutureBuilder<Friendship?>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 6.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (snapshot.hasError) {
return const Text('Failed to load request details.');
}
final friendship = snapshot.data;
final requester = friendship?.requester;
if (friendship == null || requester == null) {
return const Text('Friend request details unavailable.');
}
final buttonStyle = ElevatedButton.styleFrom(
minimumSize: const Size(0, 36),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.person_add_alt, size: 18),
const SizedBox(width: 6),
Text(
requester.displayName.isNotEmpty
? requester.displayName
: '@${requester.username}',
style: Theme.of(context).textTheme.titleSmall,
),
],
),
const SizedBox(height: 6),
Wrap(
spacing: 8,
children: [
ElevatedButton(
style: buttonStyle,
onPressed: () =>
_respond(context, friendship.id, accept: true),
child: const Text('Accept'),
),
ElevatedButton(
style: buttonStyle,
onPressed: () =>
_respond(context, friendship.id, accept: false),
child: const Text('Reject'),
),
],
),
],
);
},
);
}
Future<void> _respond(
BuildContext context,
String? friendshipId, {
required bool accept,
}) async {
if (friendshipId == null || friendshipId.isEmpty) return;
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
if (accept) {
await data.acceptFriendship(friendshipId);
if (!context.mounted) return;
messenger?.showSnackBar(
const SnackBar(content: Text('Friend request accepted')),
);
await _dismissNotification(context, messenger);
} else {
await data.rejectFriendship(friendshipId);
if (!context.mounted) return;
messenger?.showSnackBar(
const SnackBar(content: Text('Friend request rejected')),
);
await _dismissNotification(context, messenger);
}
await data.fetchPendingFriendships();
} catch (e) {
if (!context.mounted) return;
messenger?.showSnackBar(
SnackBar(content: Text('Failed to respond: $e')),
);
}
}
Future<void> _dismissNotification(
BuildContext context,
ScaffoldMessengerState? messenger,
) async {
try {
await context.read<DataService>().dismissNotifications([notification.id]);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to dismiss notification: $e')),
);
}
}
}

View File

@@ -0,0 +1,533 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LegShareEditNotificationCard extends StatefulWidget {
const LegShareEditNotificationCard({super.key, required this.notification});
final UserNotification notification;
@override
State<LegShareEditNotificationCard> createState() => _LegShareEditNotificationCardState();
}
class _LegShareEditNotificationCardState extends State<LegShareEditNotificationCard> {
Map<String, dynamic>? _changes;
int? _legId;
int? _shareId;
Leg? _currentLeg;
bool _loading = false;
static const int _summaryLimit = 3;
@override
void initState() {
super.initState();
_parseNotification();
}
@override
void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.notification != widget.notification) {
_parseNotification();
}
}
void _parseNotification() {
final rawBody = widget.notification.body.trim();
// Reset
_shareId = null;
_legId = null;
_currentLeg = null;
_changes = null;
final parsed = _decodeBody(rawBody);
if (parsed != null) {
_shareId = _parseInt(parsed['share_id']);
_legId = _parseInt(parsed['leg_id']);
final accepted = _asStringKeyedMap(parsed['accepted_changes']);
if (accepted != null) {
_changes = accepted;
}
}
// Fallback: extract share_id from raw string if still missing.
_shareId ??= _extractShareId(rawBody);
}
int? _parseInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value.trim());
return null;
}
Map<String, dynamic>? _decodeBody(String rawBody) {
final attempts = <String>[
rawBody,
_stripWrappingQuotes(rawBody),
_replaceSingleQuotes(rawBody),
].where((s) => s.trim().isNotEmpty).toSet();
for (final attempt in attempts) {
final parsed = _decodeJsonToMap(attempt);
if (parsed != null) return parsed;
}
return null;
}
Map<String, dynamic>? _decodeJsonToMap(String source) {
dynamic parsed = source;
for (int i = 0; i < 3 && parsed is String; i++) {
try {
parsed = jsonDecode(parsed);
} catch (e) {
parsed = null;
break;
}
}
if (parsed is Map) {
final map = parsed.map((k, v) => MapEntry(k.toString(), v));
return map;
}
return null;
}
String _stripWrappingQuotes(String input) {
final trimmed = input.trim();
if (trimmed.length >= 2 &&
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'")))) {
return trimmed.substring(1, trimmed.length - 1);
}
return input;
}
String _replaceSingleQuotes(String input) {
if (!input.contains("'")) return input;
return input.replaceAll(RegExp(r"(?<!\\)'"), '"');
}
Map<String, dynamic>? _asStringKeyedMap(dynamic value) {
if (value is Map) {
return value.map((k, v) => MapEntry(k.toString(), v));
}
if (value is String && value.trim().isNotEmpty) {
for (final attempt in [value, _replaceSingleQuotes(value)]) {
try {
final decoded = jsonDecode(attempt);
if (decoded is Map) {
return decoded.map((k, v) => MapEntry(k.toString(), v));
}
} catch (_) {
// Ignore; handled by caller.
}
}
}
return null;
}
int? _extractShareId(String raw) {
final patterns = [
RegExp(r'"share_id"\s*:\s*(\d+)'),
RegExp(r"'share_id'\s*:\s*(\d+)"),
RegExp(r'share_id\s*:\s*(\d+)'),
RegExp(r'"share_id"\s*:\s*"(\d+)"'),
];
for (final pattern in patterns) {
final match = pattern.firstMatch(raw);
if (match != null) {
final parsed = int.tryParse(match.group(1)!);
return parsed;
}
}
return null;
}
Future<void> _loadLegIdIfNeeded() async {
if (_legId != null) return;
if (_shareId == null) {
return;
}
try {
final share = await context.read<DataService>().fetchLegShare(_shareId!.toString());
if (!mounted) return;
_legId = share?.entry.id;
_currentLeg ??= _findCurrentLeg(_legId);
} catch (e) {
// ignore: avoid_empty_catches
}
}
Leg? _findCurrentLeg(int? legId) {
if (legId == null) return null;
final data = context.read<DataService>();
try {
return data.legs.firstWhere((l) => l.id == legId);
} catch (_) {
return null;
}
}
@override
Widget build(BuildContext context) {
final changes = _changes;
if (changes == null || changes.isEmpty) {
return const Text('No changes supplied.');
}
final entries = changes.entries.toList();
final shown = entries.take(_summaryLimit).toList();
final remaining = entries.length - shown.length;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...shown.map((e) => _changePreview(context, e)),
if (remaining > 0)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('+$remaining others…', style: Theme.of(context).textTheme.bodySmall),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: _loading ? null : () => _openDrawer(changes),
child: const Text('View changes'),
),
TextButton(
onPressed: _loading ? null : _dismiss,
child: const Text('Dismiss changes'),
),
],
),
],
);
}
Widget _changePreview(BuildContext context, MapEntry<String, dynamic> change) {
final key = _prettyField(change.key);
final value = change.value;
String display;
if (change.key == 'locos' && value is List) {
display = '${value.length} traction update${value.length == 1 ? '' : 's'}';
} else {
display = _stringify(value);
}
return Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text('$key: $display'),
);
}
String _prettyField(String raw) {
switch (raw) {
case 'leg_notes':
return 'Notes';
case 'locos':
return 'Traction';
default:
return raw.replaceAll('_', ' ');
}
}
dynamic _currentValueForField(Leg leg, String key) {
switch (key) {
case 'leg_begin_time':
return leg.beginTime;
case 'leg_end_time':
return leg.endTime;
case 'leg_origin_time':
return leg.originTime;
case 'leg_destination_time':
return leg.destinationTime;
case 'leg_notes':
return leg.notes;
case 'leg_headcode':
return leg.headcode;
case 'leg_network':
return leg.network;
case 'leg_start':
return leg.start;
case 'leg_end':
return leg.end;
case 'leg_origin':
return leg.origin;
case 'leg_destination':
return leg.destination;
case 'leg_route':
return leg.route;
case 'leg_mileage':
return leg.mileage;
case 'leg_begin_delay':
return leg.beginDelayMinutes;
case 'leg_end_delay':
return leg.endDelayMinutes;
case 'locos':
return leg.locos;
default:
return null;
}
}
Widget _buildChangeValueWidget(
String key,
dynamic newValue,
Leg? currentLeg,
Widget Function(List<dynamic>) buildLocos,
) {
final currentValue = currentLeg == null ? null : _currentValueForField(currentLeg, key);
if (key == 'locos' && newValue is List) {
final currentCount = (currentValue is List) ? currentValue.length : 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Current: $currentCount locos'),
const SizedBox(height: 4),
const Text('New:'),
buildLocos(newValue),
],
);
}
final currentStr = _stringify(currentValue);
final newStr = _stringify(newValue);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: Text(currentStr, maxLines: 3, overflow: TextOverflow.ellipsis)),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 6.0),
child: Icon(Icons.arrow_right_alt, size: 18),
),
Expanded(child: Text(newStr, maxLines: 3, overflow: TextOverflow.ellipsis)),
],
);
}
String _stringify(dynamic value) {
if (value is DateTime) return value.toIso8601String();
if (value == null) return '';
if (value is List || value is Map) {
return jsonEncode(value);
}
return value.toString();
}
Future<void> _openDrawer(Map<String, dynamic> changes) async {
setState(() => _loading = true);
await _loadLegIdIfNeeded();
_currentLeg ??= _findCurrentLeg(_legId);
if (!mounted) return;
setState(() => _loading = false);
final legId = _legId;
if (legId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Unable to load shared leg.')),
);
return;
}
final selected = Map<String, bool>.fromEntries(
changes.keys.map((k) => MapEntry(k, false)),
);
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (ctx) {
return StatefulBuilder(
builder: (context, setSheetState) {
Future<void> apply() async {
final payload = <String, dynamic>{};
for (final entry in changes.entries) {
if (selected[entry.key] == true) {
payload[entry.key] = entry.value;
}
}
if (payload.isEmpty) {
Navigator.of(context).pop();
return;
}
final messenger = ScaffoldMessenger.of(context);
setSheetState(() => _loading = true);
try {
final data = context.read<DataService>();
await data.applyLegPartialUpdates(
legId: legId,
updates: payload,
);
if (!context.mounted) return;
await data.dismissNotifications([widget.notification.id]);
if (!context.mounted) return;
messenger.showSnackBar(
const SnackBar(content: Text('Changes applied.')),
);
Navigator.of(context).pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to apply changes: $e')),
);
} finally {
setSheetState(() => _loading = false);
}
}
Widget buildLocos(List<dynamic> locos) {
final parsed = locos
.whereType<Map>()
.map((e) => e.map((k, v) => MapEntry(k.toString(), v)))
.toList();
parsed.sort((a, b) => (b['alloc_pos'] ?? 0).compareTo(a['alloc_pos'] ?? 0));
final leading = parsed.where((e) => (e['alloc_pos'] ?? 0) > 0).toList();
final trailing = parsed.where((e) => (e['alloc_pos'] ?? 0) <= 0).toList();
List<Widget> chipsFor(List<Map<String, dynamic>> list) {
return list
.map(
(loco) => Chip(
backgroundColor:
(loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
: Theme.of(context).colorScheme.surfaceContainerHighest,
label: Text('Loco ${loco['loco_id'] ?? '?'} (pos ${loco['alloc_pos'] ?? '?'}'),
avatar: Icon(
Icons.train,
size: 16,
color: (loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
? Theme.of(context).colorScheme.primary
: Theme.of(context).hintColor,
),
),
)
.toList();
}
return Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
...chipsFor(leading),
if (leading.isNotEmpty && trailing.isNotEmpty)
const SizedBox(
width: 24,
child: Center(child: Divider(height: 16)),
),
...chipsFor(trailing),
],
);
}
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Review changes', style: Theme.of(context).textTheme.titleMedium),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.of(context).pop(),
),
],
),
const SizedBox(height: 12),
Row(
children: [
TextButton(
onPressed: () => setSheetState(() {
for (final key in selected.keys) {
selected[key] = true;
}
}),
child: const Text('Select all'),
),
const Spacer(),
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
],
),
const SizedBox(height: 8),
...changes.entries.map((entry) {
final key = entry.key;
final prettyKey = _prettyField(key);
final value = entry.value;
final currentLeg = _currentLeg ?? _findCurrentLeg(_legId);
final valueWidget = _buildChangeValueWidget(
key,
value,
currentLeg,
buildLocos,
);
return Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Checkbox(
value: selected[key] ?? false,
onChanged: (v) => setSheetState(() {
selected[key] = v ?? false;
}),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(prettyKey, style: Theme.of(context).textTheme.titleSmall),
const SizedBox(height: 4),
valueWidget,
],
),
),
],
),
);
}),
Align(
alignment: Alignment.centerRight,
child: FilledButton(
onPressed: _loading ? null : apply,
child: _loading
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Apply changes'),
),
),
],
),
),
);
},
);
},
);
}
Future<void> _dismiss() async {
await context.read<DataService>().dismissNotifications([widget.notification.id]);
}
}

View File

@@ -0,0 +1,163 @@
import 'dart:async';
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:provider/provider.dart';
class LegShareNotificationCard extends StatelessWidget {
const LegShareNotificationCard({super.key, required this.notification});
final UserNotification notification;
@override
Widget build(BuildContext context) {
final data = context.read<DataService>();
final legShareId = _extractLegShareId(notification.body);
if (legShareId == null) {
return const Text('Invalid leg share notification.');
}
final future = data.fetchLegShare(legShareId);
return FutureBuilder<LegShareData?>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 6.0),
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
);
}
if (snapshot.hasError) {
return const Text('Failed to load shared entry.');
}
final share = snapshot.data;
final entry = share?.entry;
if (share == null || entry == null) {
return const Text('Shared entry unavailable.');
}
final begin = entry.beginTime;
final beginStr =
'${begin.year.toString().padLeft(4, '0')}-${begin.month.toString().padLeft(2, '0')}-${begin.day.toString().padLeft(2, '0')} ${begin.hour.toString().padLeft(2, '0')}:${begin.minute.toString().padLeft(2, '0')}';
final start = entry.route.isNotEmpty ? entry.route.first : entry.start;
final end = entry.route.isNotEmpty ? entry.route.last : entry.end;
final sharedAt = share.sharedAt;
final sharedAtStr = sharedAt == null
? null
: '${sharedAt.year.toString().padLeft(4, '0')}-${sharedAt.month.toString().padLeft(2, '0')}-${sharedAt.day.toString().padLeft(2, '0')} ${sharedAt.hour.toString().padLeft(2, '0')}:${sharedAt.minute.toString().padLeft(2, '0')}';
final from = share.sharedFromName;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('$beginStr$start$end'),
if (from.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'From $from',
style: Theme.of(context).textTheme.bodySmall,
),
),
if (sharedAtStr != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
'Shared at $sharedAtStr',
style: Theme.of(context).textTheme.bodySmall,
),
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
ElevatedButton(
onPressed: () => _accept(context, share),
child: const Text('Accept'),
),
OutlinedButton(
onPressed: () => _inspect(context, share),
child: const Text('Inspect'),
),
TextButton(
onPressed: () => _reject(context, share),
child: const Text('Reject'),
),
],
),
],
);
},
);
}
Future<void> _accept(BuildContext context, LegShareData share) async {
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await data.acceptLegShare(share);
if (!context.mounted) return;
await data.dismissNotifications([notification.id]);
// Refresh legs in the background.
unawaited(data.refreshLegs());
messenger?.showSnackBar(
const SnackBar(content: Text('Shared entry added to logbook')),
);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to add shared entry: $e')),
);
}
}
Future<void> _reject(BuildContext context, LegShareData share) async {
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await data.rejectLegShare(share.id);
if (!context.mounted) return;
await data.dismissNotifications([notification.id]);
messenger?.showSnackBar(
const SnackBar(content: Text('Share rejected')),
);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to reject share: $e')),
);
}
}
Future<void> _inspect(BuildContext context, LegShareData share) async {
final router = GoRouter.of(context);
// Close notifications panel if open.
if (Navigator.canPop(context)) {
Navigator.of(context).pop();
}
await Future<void>.delayed(Duration.zero);
final target = share.copyWith(notificationId: notification.id);
final ts = DateTime.now().millisecondsSinceEpoch;
final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts';
router.go(path, extra: target);
}
String? _extractLegShareId(String rawBody) {
final trimmed = rawBody.trim();
if (trimmed.isEmpty) return null;
if (RegExp(r'^[0-9]+$').hasMatch(trimmed)) return trimmed;
try {
final decoded = jsonDecode(trimmed);
if (decoded is Map) {
final id = decoded['share_id'] ?? decoded['leg_share_id'];
final str = id?.toString() ?? '';
if (RegExp(r'^[0-9]+$').hasMatch(str)) return str;
}
} catch (_) {}
return null;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AccentColorService extends ChangeNotifier {
static const _prefsKeyUseSystem = 'accent_use_system';
static const _prefsKeySeed = 'accent_seed';
static const Color defaultSeed = Colors.red;
bool _useSystem = true;
Color _seedColor = defaultSeed;
bool _hasSavedSeed = false;
bool _loaded = false;
bool get useSystem => _useSystem;
Color get seedColor => _seedColor;
bool get hasSavedSeed => _hasSavedSeed;
bool get isLoaded => _loaded;
AccentColorService() {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
_useSystem = prefs.getBool(_prefsKeyUseSystem) ?? true;
final seedValue = prefs.getInt(_prefsKeySeed);
if (seedValue != null) {
_seedColor = Color(seedValue);
_hasSavedSeed = true;
}
_loaded = true;
notifyListeners();
}
Future<void> setUseSystem(bool value) async {
_useSystem = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyUseSystem, _useSystem);
notifyListeners();
}
Future<void> setSeedColor(Color color) async {
_seedColor = color;
_useSystem = false;
_hasSavedSeed = true;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(_prefsKeySeed, color.toARGB32());
await prefs.setBool(_prefsKeyUseSystem, _useSystem);
notifyListeners();
}
}

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,11 @@ 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;
String get entriesVisibility => _user?.entriesVisibility ?? 'private';
String get mileageVisibility => _user?.mileageVisibility ?? 'private';
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 +33,10 @@ class AuthService extends ChangeNotifier {
required String fullName, required String fullName,
required String accessToken, required String accessToken,
required String email, required String email,
String entriesVisibility = 'private',
String mileageVisibility = 'private',
bool isElevated = false,
bool isDisabled = false,
}) { }) {
_user = AuthenticatedUserData( _user = AuthenticatedUserData(
userId: userId, userId: userId,
@@ -35,6 +44,10 @@ class AuthService extends ChangeNotifier {
fullName: fullName, fullName: fullName,
accessToken: accessToken, accessToken: accessToken,
email: email, email: email,
entriesVisibility: entriesVisibility,
mileageVisibility: mileageVisibility,
isElevated: isElevated,
disabled: isDisabled,
); );
_persistToken(accessToken); _persistToken(accessToken);
notifyListeners(); notifyListeners();
@@ -70,6 +83,16 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'], fullName: userResponse['full_name'],
accessToken: accessToken, accessToken: accessToken,
email: userResponse['email'], email: userResponse['email'],
entriesVisibility: _parseVisibility(
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
'private',
),
mileageVisibility: _parseVisibility(
userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'],
'private',
),
isElevated: _parseIsElevated(userResponse),
isDisabled: _parseIsDisabled(userResponse),
); );
} }
@@ -95,6 +118,16 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'], fullName: userResponse['full_name'],
accessToken: token, accessToken: token,
email: userResponse['email'], email: userResponse['email'],
entriesVisibility: _parseVisibility(
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
'private',
),
mileageVisibility: _parseVisibility(
userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'],
'private',
),
isElevated: _parseIsElevated(userResponse),
isDisabled: _parseIsDisabled(userResponse),
); );
} catch (_) { } catch (_) {
await _clearToken(); await _clearToken();
@@ -157,4 +190,67 @@ 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);
}
String _parseVisibility(dynamic value, String fallback) {
const allowed = ['private', 'friends', 'public'];
final str = value?.toString().toLowerCase().trim();
if (str != null && allowed.contains(str)) return str;
return fallback;
}
} }

View File

@@ -11,3 +11,6 @@ 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';
part 'data_service_friendships.dart';
part 'data_service_leg_share.dart';

View File

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

View File

@@ -6,6 +6,7 @@ class _LegFetchOptions {
final int sortDirection; final int sortDirection;
final String? dateRangeStart; final String? dateRangeStart;
final String? dateRangeEnd; final String? dateRangeEnd;
final bool unallocatedOnly;
const _LegFetchOptions({ const _LegFetchOptions({
this.limit = 100, this.limit = 100,
@@ -13,6 +14,7 @@ class _LegFetchOptions {
this.sortDirection = 0, this.sortDirection = 0,
this.dateRangeStart, this.dateRangeStart,
this.dateRangeEnd, this.dateRangeEnd,
this.unallocatedOnly = false,
}); });
} }
@@ -21,11 +23,17 @@ class DataService extends ChangeNotifier {
DataService({required this.api}); DataService({required this.api});
String? _currentUserId;
_LegFetchOptions _lastLegsFetch = const _LegFetchOptions(); _LegFetchOptions _lastLegsFetch = const _LegFetchOptions();
// Homepage Data // Homepage Data
HomepageStats? _homepageStats; HomepageStats? _homepageStats;
HomepageStats? get homepageStats => _homepageStats; HomepageStats? get homepageStats => _homepageStats;
StatsAbout? _aboutStats;
StatsAbout? get aboutStats => _aboutStats;
bool _isAboutStatsLoading = false;
bool get isAboutStatsLoading => _isAboutStatsLoading;
// Legs Data // Legs Data
List<Leg> _legs = []; List<Leg> _legs = [];
@@ -58,6 +66,18 @@ class DataService extends ChangeNotifier {
bool isLocoTimelineLoading(int locoId) => bool isLocoTimelineLoading(int locoId) =>
_isLocoTimelineLoading[locoId] ?? false; _isLocoTimelineLoading[locoId] ?? false;
// Friendships
List<Friendship> _friendships = [];
List<Friendship> _pendingIncoming = [];
List<Friendship> _pendingOutgoing = [];
List<Friendship> get friendships => _friendships;
List<Friendship> get pendingIncoming => _pendingIncoming;
List<Friendship> get pendingOutgoing => _pendingOutgoing;
bool _isFriendshipsLoading = false;
bool get isFriendshipsLoading => _isFriendshipsLoading;
bool _isPendingFriendshipsLoading = false;
bool get isPendingFriendshipsLoading => _isPendingFriendshipsLoading;
// Trips // Trips
List<TripSummary> _trips = []; List<TripSummary> _trips = [];
List<TripSummary> get trips => _trips; List<TripSummary> get trips => _trips;
@@ -90,6 +110,10 @@ class DataService extends ChangeNotifier {
bool get isHomepageLoading => _isHomepageLoading; bool get isHomepageLoading => _isHomepageLoading;
bool _isOnThisDayLoading = false; bool _isOnThisDayLoading = false;
bool get isOnThisDayLoading => _isOnThisDayLoading; bool get isOnThisDayLoading => _isOnThisDayLoading;
List<LeaderboardEntry> _friendsLeaderboard = [];
List<LeaderboardEntry> get friendsLeaderboard => _friendsLeaderboard;
bool _isFriendsLeaderboardLoading = false;
bool get isFriendsLeaderboardLoading => _isFriendsLeaderboardLoading;
// Notifications // Notifications
List<UserNotification> _notifications = []; List<UserNotification> _notifications = [];
@@ -97,11 +121,39 @@ class DataService extends ChangeNotifier {
bool _isNotificationsLoading = false; bool _isNotificationsLoading = false;
bool get isNotificationsLoading => _isNotificationsLoading; bool get isNotificationsLoading => _isNotificationsLoading;
// Privacy
String _userEntriesVisibility = 'private';
String _userMileageVisibility = 'private';
bool _isPrivacyLoading = false;
bool _isPrivacySaving = false;
String get userEntriesVisibility => _userEntriesVisibility;
String get userMileageVisibility => _userMileageVisibility;
bool get isPrivacyLoading => _isPrivacyLoading;
bool get isPrivacySaving => _isPrivacySaving;
// Badges // Badges
List<BadgeAward> _badgeAwards = []; List<BadgeAward> _badgeAwards = [];
List<BadgeAward> get badgeAwards => _badgeAwards; List<BadgeAward> get badgeAwards => _badgeAwards;
bool _isBadgeAwardsLoading = false; bool _isBadgeAwardsLoading = false;
bool get isBadgeAwardsLoading => _isBadgeAwardsLoading; bool get isBadgeAwardsLoading => _isBadgeAwardsLoading;
bool _badgeAwardsHasMore = false;
bool get badgeAwardsHasMore => _badgeAwardsHasMore;
List<ClassClearanceProgress> _classClearanceProgress = [];
List<ClassClearanceProgress> get classClearanceProgress =>
_classClearanceProgress;
bool _isClassClearanceProgressLoading = false;
bool get isClassClearanceProgressLoading =>
_isClassClearanceProgressLoading;
bool _classClearanceHasMore = false;
bool get classClearanceHasMore => _classClearanceHasMore;
List<LocoClearanceProgress> _locoClearanceProgress = [];
List<LocoClearanceProgress> get locoClearanceProgress =>
_locoClearanceProgress;
bool _isLocoClearanceProgressLoading = false;
bool get isLocoClearanceProgressLoading =>
_isLocoClearanceProgressLoading;
bool _locoClearanceHasMore = false;
bool get locoClearanceHasMore => _locoClearanceHasMore;
static const List<EventField> _fallbackEventFields = [ static const List<EventField> _fallbackEventFields = [
EventField(name: 'operator', display: 'Operator'), EventField(name: 'operator', display: 'Operator'),
@@ -121,23 +173,197 @@ class DataService extends ChangeNotifier {
}); });
} }
int _visibilityRank(String value) {
switch (value.toLowerCase()) {
case 'public':
return 2;
case 'friends':
return 1;
default:
return 0;
}
}
String _normaliseVisibility(
dynamic value, {
required String fallback,
}) {
const allowed = ['private', 'friends', 'public'];
final str = value?.toString().toLowerCase().trim();
if (str != null && allowed.contains(str)) return str;
return fallback;
}
String _clampMileageVisibility(String entries, String mileage) {
return _visibilityRank(mileage) < _visibilityRank(entries)
? entries
: mileage;
}
void _applyPrivacy(dynamic source) {
String entries = _userEntriesVisibility;
String mileage = _userMileageVisibility;
if (source is Map) {
entries = _normaliseVisibility(
source['user_entries_visibility'] ?? source['entries_visibility'],
fallback: entries,
);
mileage = _normaliseVisibility(
source['user_mileage_visibility'] ?? source['mileage_visibility'],
fallback: mileage,
);
} else if (source is UserData) {
entries = _normaliseVisibility(
source.entriesVisibility,
fallback: entries,
);
mileage = _normaliseVisibility(
source.mileageVisibility,
fallback: mileage,
);
}
_userEntriesVisibility = entries;
_userMileageVisibility = _clampMileageVisibility(entries, mileage);
}
Future<void> fetchPrivacySettings({String? targetUserId}) async {
_isPrivacyLoading = true;
_notifyAsync();
try {
Map<String, dynamic>? payload;
final hasTarget = targetUserId?.isNotEmpty ?? false;
if (!hasTarget) {
try {
final json = await api.get('/users/me');
if (json is Map<String, dynamic>) {
payload = json;
} else if (json is Map) {
payload = json.map((k, v) => MapEntry(k.toString(), v));
}
} catch (e) {
debugPrint('Failed to fetch /users/me: $e');
}
}
if (payload == null) {
final query = hasTarget ? '?target_user_id=$targetUserId' : '';
try {
final json = await api.get('/users/privacy$query');
if (json is Map<String, dynamic>) {
payload = json;
} else if (json is Map) {
payload = json.map((k, v) => MapEntry(k.toString(), v));
}
} catch (e) {
debugPrint('Failed to fetch /users/privacy: $e');
}
}
if (payload != null) {
_applyPrivacy(payload);
}
} catch (e) {
debugPrint('Failed to fetch privacy settings: $e');
} finally {
_isPrivacyLoading = false;
_notifyAsync();
}
}
Future<void> updatePrivacySettings({
required String entriesVisibility,
required String mileageVisibility,
String? targetUserId,
}) async {
_isPrivacySaving = true;
_notifyAsync();
try {
final query = (targetUserId?.isNotEmpty ?? false)
? '?target_user_id=$targetUserId'
: '';
await api.post('/users/privacy$query', {
'user_entries_visibility': entriesVisibility,
'user_mileage_visibility': mileageVisibility,
});
_userEntriesVisibility = entriesVisibility;
_userMileageVisibility = mileageVisibility;
} catch (e) {
debugPrint('Failed to update privacy settings: $e');
rethrow;
} finally {
_isPrivacySaving = false;
_notifyAsync();
}
}
Future<void> fetchHomepageStats() async { Future<void> fetchHomepageStats() async {
_isHomepageLoading = true; _isHomepageLoading = true;
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);
_friendsLeaderboard = _homepageStats?.friendsLeaderboard ?? [];
if (_homepageStats?.user != null) {
_applyPrivacy(_homepageStats!.user!);
}
} catch (e) { } catch (e) {
debugPrint('Failed to fetch homepage stats: $e'); debugPrint('Failed to fetch homepage stats: $e');
_homepageStats = null; _homepageStats = null;
_trips = []; _trips = [];
_friendsLeaderboard = [];
} finally { } finally {
_isHomepageLoading = false; _isHomepageLoading = false;
_notifyAsync(); _notifyAsync();
} }
} }
Future<UserProfileDetail?> fetchUserProfileDetail(String userId) async {
try {
final json = await api.get('/user/$userId');
if (json is Map) {
return UserProfileDetail.fromJson(
json.map((k, v) => MapEntry(k.toString(), v)),
);
}
} catch (e) {
debugPrint('Failed to fetch user profile for $userId: $e');
}
return null;
}
Future<List<Leg>> fetchUserLegs({
required String userId,
int offset = 0,
int limit = 25,
}) async {
try {
final json =
await api.get('/legs/user/$userId?offset=$offset&limit=$limit');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['legs', 'data', 'results', 'items']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
if (list == null) return const [];
return list
.whereType<Map>()
.map((e) => Leg.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
} catch (e) {
debugPrint('Failed to fetch user legs for $userId: $e');
return const [];
}
}
Future<void> fetchLegs({ Future<void> fetchLegs({
int offset = 0, int offset = 0,
int limit = 100, int limit = 100,
@@ -146,6 +372,7 @@ class DataService extends ChangeNotifier {
String? dateRangeStart, String? dateRangeStart,
String? dateRangeEnd, String? dateRangeEnd,
bool append = false, bool append = false,
bool unallocatedOnly = false,
}) async { }) async {
_isLegsLoading = true; _isLegsLoading = true;
if (!append) { if (!append) {
@@ -155,6 +382,7 @@ class DataService extends ChangeNotifier {
sortDirection: sortDirection, sortDirection: sortDirection,
dateRangeStart: dateRangeStart, dateRangeStart: dateRangeStart,
dateRangeEnd: dateRangeEnd, dateRangeEnd: dateRangeEnd,
unallocatedOnly: unallocatedOnly,
); );
} }
final buffer = StringBuffer( final buffer = StringBuffer(
@@ -166,6 +394,9 @@ class DataService extends ChangeNotifier {
if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) { if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) {
buffer.write('&date_range_end=$dateRangeEnd'); buffer.write('&date_range_end=$dateRangeEnd');
} }
if (unallocatedOnly) {
buffer.write('&unallocated_only=true');
}
try { try {
final json = await api.get('/user/legs${buffer.toString()}'); final json = await api.get('/user/legs${buffer.toString()}');
@@ -194,6 +425,7 @@ class DataService extends ChangeNotifier {
sortDirection: _lastLegsFetch.sortDirection, sortDirection: _lastLegsFetch.sortDirection,
dateRangeStart: _lastLegsFetch.dateRangeStart, dateRangeStart: _lastLegsFetch.dateRangeStart,
dateRangeEnd: _lastLegsFetch.dateRangeEnd, dateRangeEnd: _lastLegsFetch.dateRangeEnd,
unallocatedOnly: _lastLegsFetch.unallocatedOnly,
); );
} }
@@ -356,7 +588,42 @@ class DataService extends ChangeNotifier {
} }
} }
Future<void> deletePendingEvent({
required int eventId,
}) async {
try {
await api.delete('/event/pending/$eventId');
} catch (e) {
debugPrint('Failed to delete pending event $eventId: $e');
rethrow;
}
}
Future<void> approvePendingEvent({
required int eventId,
}) async {
try {
await api.put('/event/approve/$eventId', null);
} catch (e) {
debugPrint('Failed to approve pending event $eventId: $e');
rethrow;
}
}
Future<void> rejectPendingEvent({
required int eventId,
}) async {
try {
await api.put('/event/reject/$eventId', null);
} catch (e) {
debugPrint('Failed to reject pending event $eventId: $e');
rethrow;
}
}
void clear() { void clear() {
_currentUserId = null;
_lastLegsFetch = const _LegFetchOptions();
_homepageStats = null; _homepageStats = null;
_legs = []; _legs = [];
_onThisDay = []; _onThisDay = [];
@@ -367,9 +634,61 @@ class DataService extends ChangeNotifier {
_isLocoTimelineLoading.clear(); _isLocoTimelineLoading.clear();
_latestLocoChanges = []; _latestLocoChanges = [];
_isLatestLocoChangesLoading = false; _isLatestLocoChangesLoading = false;
_isHomepageLoading = false;
_isOnThisDayLoading = false;
_legsHasMore = false;
_isLegsLoading = false;
_traction = [];
_isTractionLoading = false;
_tractionHasMore = false;
_latestLocoChangesHasMore = false;
_latestLocoChangesFetched = 0;
_isTripDetailsLoading = false;
_locoClasses = [];
_tripList = [];
_stationCache.clear();
_stationInFlightByKey.clear();
_stationNetworks = [];
_stationCountryNetworks = {};
_stationFiltersFetchedAt = null;
_notifications = [];
_isNotificationsLoading = false;
_userEntriesVisibility = 'private';
_userMileageVisibility = 'private';
_isPrivacyLoading = false;
_isPrivacySaving = false;
_badgeAwards = [];
_badgeAwardsHasMore = false;
_isBadgeAwardsLoading = false;
_classClearanceProgress = [];
_isClassClearanceProgressLoading = false;
_classClearanceHasMore = false;
_locoClearanceProgress = [];
_isLocoClearanceProgressLoading = false;
_locoClearanceHasMore = false;
_notifyAsync(); _notifyAsync();
} }
void handleAuthChanged(
String? userId, {
String? entriesVisibility,
String? mileageVisibility,
}) {
final sameUser = _currentUserId == userId;
_currentUserId = userId;
if (!sameUser) {
clear();
_currentUserId = userId;
}
if (entriesVisibility != null || mileageVisibility != null) {
_applyPrivacy({
'user_entries_visibility': entriesVisibility,
'user_mileage_visibility': mileageVisibility,
});
_notifyAsync();
}
}
double getMileageForCurrentYear() { double getMileageForCurrentYear() {
final currentYear = DateTime.now().year; final currentYear = DateTime.now().year;
return getMileageForYear(currentYear) ?? 0; return getMileageForYear(currentYear) ?? 0;

View File

@@ -0,0 +1,290 @@
part of 'data_service.dart';
extension DataServiceFriendships on DataService {
Future<void> fetchFriendships() async {
_isFriendshipsLoading = true;
try {
final json = await api.get('/friendships');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['friendships', 'data', 'items', 'results']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
final parsed = list
?.whereType<Map>()
.map((e) => Friendship.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.where((f) => f.isAccepted)
.toList();
_friendships = parsed ?? [];
} catch (e) {
debugPrint('Failed to fetch friendships: $e');
_friendships = [];
} finally {
_isFriendshipsLoading = false;
_notifyAsync();
}
}
Future<void> fetchPendingFriendships() async {
_isPendingFriendshipsLoading = true;
try {
final incoming = await _fetchPending('incoming');
final outgoing = await _fetchPending('outgoing');
_pendingIncoming = incoming;
_pendingOutgoing = outgoing;
} catch (e) {
debugPrint('Failed to fetch pending friendships: $e');
_pendingIncoming = [];
_pendingOutgoing = [];
} finally {
_isPendingFriendshipsLoading = false;
_notifyAsync();
}
}
Future<List<Friendship>> _fetchPending(String direction) async {
final json = await api.get('/friendships/pending?direction=$direction');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['friendships', 'data', 'items', 'results']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
return list
?.whereType<Map>()
.map((e) => Friendship.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.where((f) => f.isPending)
.toList() ??
const [];
}
Future<Friendship?> fetchFriendshipById(String friendshipId) async {
try {
final json = await api.get('/friendships/$friendshipId');
if (json is Map) {
return Friendship.fromJson(
json.map((k, v) => MapEntry(k.toString(), v)),
);
}
} catch (e) {
debugPrint('Failed to fetch friendship $friendshipId: $e');
}
return null;
}
Future<List<UserSummary>> searchUsers(
String query, {
bool friendsOnly = false,
}) async {
final encoded = Uri.encodeComponent(query);
final json = await api.get(
'/users/search?search=$encoded${friendsOnly ? '&friends_only=true' : ''}',
);
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 const [];
return list
.whereType<Map>()
.map((e) => UserSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
}
Future<Friendship> fetchFriendshipStatus(String targetUserId) async {
try {
final json = await api.get('/friendships/status/$targetUserId');
if (json is Map) {
return Friendship.fromStatusJson(
json.map((k, v) => MapEntry(k.toString(), v)),
targetUserId: targetUserId,
);
}
} catch (e) {
debugPrint('Failed to fetch friendship status for $targetUserId: $e');
}
return Friendship(
id: null,
status: 'none',
requesterId: '',
addresseeId: targetUserId,
);
}
Future<Friendship> requestFriendship(
String targetUserId, {
UserSummary? targetUser,
}) async {
final json = await api.post('/friendships/request', {
"target_user_id": targetUserId,
});
final friendship = _parseAndUpsertFriendship(
json,
fallbackStatus: 'pending',
overrideAddressee: targetUser,
);
_pendingOutgoing = [friendship, ..._pendingOutgoing];
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync();
return friendship;
}
Future<Friendship> acceptFriendship(String friendshipId) async {
final json = await api.post('/friendships/$friendshipId/accept', {});
final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'accepted');
_pendingIncoming = _pendingIncoming.where((f) => f.id != friendshipId).toList();
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync();
return friendship;
}
Future<Friendship> rejectFriendship(String friendshipId) async {
final json = await api.post('/friendships/$friendshipId/reject', {});
final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'declined');
_pendingIncoming = _pendingIncoming.where((f) => f.id != friendshipId).toList();
_notifyAsync();
return friendship;
}
Future<Friendship> cancelFriendship(String friendshipId) async {
final json = await api.post('/friendships/$friendshipId/cancel', {});
final friendship =
_parseAndRemoveFriendship(json, friendshipId, status: 'none');
_pendingOutgoing =
_pendingOutgoing.where((f) => f.id != friendshipId).toList();
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync();
return friendship;
}
Future<void> deleteFriendship(String friendshipId) async {
try {
await api.delete('/friendships/$friendshipId');
} catch (e) {
debugPrint('Failed to delete friendship $friendshipId: $e');
rethrow;
}
_friendships = _friendships.where((f) => f.id != friendshipId).toList();
_pendingIncoming =
_pendingIncoming.where((f) => f.id != friendshipId).toList();
_pendingOutgoing =
_pendingOutgoing.where((f) => f.id != friendshipId).toList();
await fetchFriendships();
await fetchPendingFriendships();
_notifyAsync();
}
Future<Friendship> blockUser(String targetUserId) async {
final json = await api.post('/friendships/block', {
"target_user_id": targetUserId,
});
final friendship = _parseAndUpsertFriendship(json, fallbackStatus: 'blocked');
_pendingIncoming = _pendingIncoming
.where((f) => f.addresseeId != targetUserId && f.requesterId != targetUserId)
.toList();
_pendingOutgoing = _pendingOutgoing
.where((f) => f.addresseeId != targetUserId && f.requesterId != targetUserId)
.toList();
_notifyAsync();
return friendship;
}
Friendship _parseAndUpsertFriendship(
dynamic json, {
String fallbackStatus = 'none',
UserSummary? overrideAddressee,
}) {
Friendship friendship;
if (json is Map) {
friendship = Friendship.fromJson(
json.map((k, v) => MapEntry(k.toString(), v)),
);
} else {
friendship = Friendship(
id: null,
status: fallbackStatus,
requesterId: '',
addresseeId: '',
);
}
if (overrideAddressee != null &&
(friendship.addressee == null ||
friendship.addressee!.userId.isEmpty ||
friendship.addressee!.displayName.isEmpty)) {
friendship = friendship.copyWith(
addressee: overrideAddressee,
addresseeId: overrideAddressee.userId,
);
}
_upsertFriendship(friendship);
return friendship;
}
Friendship _parseAndRemoveFriendship(
dynamic json,
String friendshipId, {
required String status,
}) {
Friendship friendship;
if (json is Map) {
friendship = Friendship.fromJson(
json.map((k, v) => MapEntry(k.toString(), v)),
);
} else {
friendship = Friendship(
id: friendshipId,
status: status,
requesterId: '',
addresseeId: '',
);
}
_friendships = _friendships.where((f) => f.id != friendshipId).toList();
_notifyAsync();
return friendship;
}
void _upsertFriendship(Friendship friendship) {
if (friendship.id == null || friendship.id!.isEmpty) {
_notifyAsync();
return;
}
final idx = _friendships.indexWhere((f) => f.id == friendship.id);
if (idx >= 0) {
_friendships[idx] = friendship;
} else {
_friendships = [friendship, ..._friendships];
}
_friendships = _friendships.where((f) => f.isAccepted).toList();
_notifyAsync();
}
}

View File

@@ -0,0 +1,109 @@
part of 'data_service.dart';
extension DataServiceLegShare on DataService {
Future<LegShareData?> fetchLegShare(String legShareId) async {
try {
final json = await api.get('/legs/share/$legShareId');
if (json is Map) {
return LegShareData.fromJson(
json.map((k, v) => MapEntry(k.toString(), v)),
);
}
} catch (e) {
debugPrint('Failed to fetch leg share $legShareId: $e');
rethrow;
}
return null;
}
Future<void> rejectLegShare(String legShareId) async {
try {
await api.post('/legs/share/$legShareId/reject', {});
} catch (e) {
debugPrint('Failed to reject leg share $legShareId: $e');
rethrow;
}
}
Future<int?> acceptLegShare(LegShareData share) async {
final entry = share.entry;
final hasRoute = entry.route.isNotEmpty;
final locosPayload = <Map<String, dynamic>>[];
for (var i = 0; i < entry.locos.length; i++) {
final loco = entry.locos[i];
final pos = loco.allocPos != 0 ? loco.allocPos : i;
locosPayload.add({
"loco_id": loco.id,
"alloc_pos": pos,
"alloc_powering": loco.powering ? 1 : 0,
});
}
final payload = <String, dynamic>{
"leg_share_id": share.id,
"leg_trip": null,
"leg_begin_time": entry.beginTime.toIso8601String(),
if (entry.endTime != null) "leg_end_time": entry.endTime!.toIso8601String(),
if (entry.originTime != null)
"leg_origin_time": entry.originTime!.toIso8601String(),
if (entry.destinationTime != null)
"leg_destination_time": entry.destinationTime!.toIso8601String(),
"leg_notes": entry.notes,
"leg_headcode": entry.headcode,
"leg_network": entry.network,
"leg_origin": entry.origin,
"leg_destination": entry.destination,
"leg_begin_delay": entry.beginDelayMinutes ?? 0,
if (entry.endDelayMinutes != null) "leg_end_delay": entry.endDelayMinutes,
"locos": locosPayload,
"share_user_ids": <String>[],
};
dynamic response;
if (hasRoute) {
response = await api.post(
'/add',
{
...payload,
"leg_route": entry.route,
"leg_mileage": entry.mileage,
},
);
} else {
response = await api.post(
'/add/manual',
{
...payload,
"leg_start": entry.start,
"leg_end": entry.end,
"leg_distance": entry.mileage,
"isKilometers": false,
},
);
}
final legId = _parseNullableInt(
response is Map ? response['leg_id'] : null,
);
if (legId != null) return legId;
return null;
}
Future<void> applyLegPartialUpdates({
required int legId,
required Map<String, dynamic> updates,
}) async {
try {
await api.post('/leg/update/$legId/partial', updates);
} catch (e) {
debugPrint('Failed to apply partial updates for leg $legId: $e');
rethrow;
}
}
int? _parseNullableInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return 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,62 @@
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();
}
}
Future<void> fetchFriendsLeaderboard() async {
if (_isFriendsLeaderboardLoading) return;
_isFriendsLeaderboardLoading = true;
_notifyAsync();
try {
final json = await api.get('/stats/leaderboard/friends');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['leaderboard', 'data', 'items', 'results']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
_friendsLeaderboard = list
?.whereType<Map>()
.map((e) => LeaderboardEntry.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList() ??
const [];
} catch (e) {
debugPrint('Failed to fetch friends leaderboard: $e');
_friendsLeaderboard = [];
} finally {
_isFriendsLeaderboardLoading = 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,
@@ -66,14 +66,68 @@ extension DataServiceTraction on DataService {
} }
} }
Future<List<LocoAttrVersion>> fetchLocoTimeline(int locoId) async { Future<List<LocoAttrVersion>> fetchLocoTimeline(
int locoId, {
bool includeAllPending = false,
}) async {
_isLocoTimelineLoading[locoId] = true; _isLocoTimelineLoading[locoId] = true;
_notifyAsync(); _notifyAsync();
try { try {
final json = await api.get('/loco/get-timeline/$locoId'); final baseJson = await api.get('/loco/get-timeline/$locoId');
final timeline = LocoAttrVersion.fromGroupedJson(json); final timeline = LocoAttrVersion.fromGroupedJson(baseJson);
_locoTimelines[locoId] = timeline; final baseKeys = timeline
return timeline; .map(_entryKey)
.where((key) => key.isNotEmpty)
.toSet();
final pendingEntries = <LocoAttrVersion>[];
final pendingSeen = <String>{};
void addPending(List<LocoAttrVersion> entries) {
for (final entry in entries) {
final key = _entryKey(entry);
if (pendingSeen.contains(key)) continue;
if (baseKeys.contains(key)) continue;
pendingSeen.add(key);
pendingEntries.add(entry);
baseKeys.add(key);
}
}
try {
final pendingJson =
await api.get('/event/pending/user?loco_id=$locoId');
addPending(
_parsePendingLocoEvents(
pendingJson,
locoId,
canModerate: false,
),
);
} catch (e) {
debugPrint('Failed to fetch pending loco events for $locoId: $e');
}
if (includeAllPending) {
try {
final pendingJson = await api.get('/event/pending?loco_id=$locoId');
addPending(
_parsePendingLocoEvents(
pendingJson,
locoId,
canModerate: true,
),
);
} catch (e) {
debugPrint('Failed to fetch all pending loco events for $locoId: $e');
}
}
final merged = [
...timeline,
...pendingEntries,
]..sort(LocoAttrVersion.compareByStart);
_locoTimelines[locoId] = merged;
return merged;
} catch (e) { } catch (e) {
debugPrint('Failed to fetch loco timeline for $locoId: $e'); debugPrint('Failed to fetch loco timeline for $locoId: $e');
_locoTimelines[locoId] = []; _locoTimelines[locoId] = [];
@@ -84,6 +138,173 @@ extension DataServiceTraction on DataService {
} }
} }
Future<List<LocoAttrVersion>> fetchUserPendingEvents(int locoId) async {
try {
final pendingJson = await api.get('/event/pending/user?loco_id=$locoId');
return _parsePendingLocoEvents(
pendingJson,
locoId,
canModerate: false,
);
} catch (e) {
debugPrint('Failed to fetch user pending events for $locoId: $e');
return const [];
}
}
List<LocoAttrVersion> _parsePendingLocoEvents(
dynamic json,
int fallbackLocoId, {
bool canModerate = false,
}) {
if (json is! List) return const <LocoAttrVersion>[];
final entries = <LocoAttrVersion>[];
final seen = <String>{};
for (final item in json) {
if (item is! Map) continue;
final map = _extractPendingEventMap(item);
final locoId = (map['loco_id'] as num?)?.toInt() ?? fallbackLocoId;
final maskedValidFrom = map['masked_valid_from']?.toString();
final precision = map['precision_level']?.toString();
final username = map['username']?.toString();
final sourceEventId = (map['loco_event_id'] as num?)?.toInt();
final startDate = _parsePendingDate(map);
final status = map['moderation_status']?.toString().toLowerCase();
if (status != null && status != 'pending') continue;
if (startDate == null &&
(maskedValidFrom == null || maskedValidFrom.trim().isEmpty)) {
continue;
}
final valueMap = _decodeEventValues(map['loco_event_value']);
if (valueMap.isEmpty) continue;
valueMap.forEach((attr, rawValue) {
final attrCode = attr.toString();
if (attrCode.isEmpty) return;
final key = [
sourceEventId?.toString() ?? '',
attrCode.toLowerCase(),
maskedValidFrom ?? '',
startDate?.toIso8601String() ?? '',
].join('|');
if (seen.contains(key)) return;
seen.add(key);
final parsedValue = _PendingTimelineValue.fromDynamic(rawValue);
entries.add(
LocoAttrVersion(
attrCode: attrCode,
locoId: locoId,
valueStr: parsedValue.valueStr,
valueInt: parsedValue.valueInt,
valueBool: parsedValue.valueBool,
valueDate: parsedValue.valueDate,
valueNorm: parsedValue.valueNorm ?? rawValue,
validFrom: startDate,
maskedValidFrom: maskedValidFrom,
precisionLevel: precision,
suggestedBy: username,
sourceEventId: sourceEventId,
isPending: true,
canModeratePending: canModerate,
),
);
});
}
return entries;
}
String _entryKey(LocoAttrVersion entry) {
final attr = entry.attrCode.toLowerCase();
final masked = entry.maskedValidFrom?.trim() ?? '';
final start = entry.validFrom?.toIso8601String() ?? '';
final source = entry.sourceEventId?.toString() ?? '';
return '$attr|$masked|$start|$source';
}
Map<String, dynamic> _extractPendingEventMap(Map raw) {
if (raw['event_info'] is Map) {
final eventInfo = Map<String, dynamic>.from(raw['event_info']);
final merged = {...eventInfo};
for (final key in [
'loco_id',
'masked_valid_from',
'precision_level',
'username',
'loco_event_id',
'earliest_date',
'valid_from',
'loco_event_date',
'event_year',
'event_month',
'event_day',
'loco_event_value',
]) {
merged.putIfAbsent(key, () => raw[key]);
}
return merged;
}
return Map<String, dynamic>.from(raw);
}
Map<String, dynamic> _decodeEventValues(dynamic raw) {
if (raw is Map) {
return raw.map((key, value) => MapEntry(key.toString(), value));
}
if (raw is String) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return const {};
try {
final decoded = jsonDecode(trimmed);
if (decoded is Map) {
return decoded.map((key, value) => MapEntry(key.toString(), value));
}
} catch (_) {}
}
return const <String, dynamic>{};
}
DateTime? _parsePendingDate(Map<String, dynamic> json) {
DateTime? parseDate(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
// Try masked date by normalising unknown parts to 01 so we can position on the axis.
final masked = json['masked_valid_from']?.toString();
if (masked is String && masked.contains('-')) {
final sanitized = masked
.replaceAll(RegExp(r'[Xx?]{2}'), '01')
.replaceAll(RegExp(r'[Xx?]'), '1');
final parsedMasked = DateTime.tryParse(sanitized);
if (parsedMasked != null) return parsedMasked;
}
for (final key in ['earliest_date', 'valid_from', 'loco_event_date']) {
final parsed = parseDate(json[key]);
if (parsed != null) return parsed;
}
final year = _asNullableInt(json['event_year']);
if (year != null && year > 0) {
final monthValue = _asNullableInt(json['event_month']) ?? 1;
final dayValue = _asNullableInt(json['event_day']) ?? 1;
final month = monthValue.clamp(1, 12).toInt();
final day = dayValue.clamp(1, 31).toInt();
try {
return DateTime(year, month, day);
} catch (_) {}
}
return null;
}
int? _asNullableInt(dynamic value) {
if (value == null) return null;
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value.toString());
}
Future<dynamic> createLoco(Map<String, dynamic> payload) async { Future<dynamic> createLoco(Map<String, dynamic> payload) async {
try { try {
final response = await api.put('/loco/new', payload); final response = await api.put('/loco/new', payload);
@@ -101,8 +322,9 @@ extension DataServiceTraction on DataService {
} }
} }
Future<List<String>> fetchClassList() async { Future<List<String>> fetchClassList({bool force = false}) async {
if (_locoClasses.isNotEmpty) return _locoClasses; if (!force && _locoClasses.isNotEmpty) return _locoClasses;
if (force) _locoClasses = [];
try { try {
final json = await api.get('/loco/classlist'); final json = await api.get('/loco/classlist');
if (json is List) { if (json is List) {
@@ -179,4 +401,140 @@ extension DataServiceTraction on DataService {
} }
return null; return null;
} }
Future<List<LeaderboardEntry>> fetchClassLeaderboard(
String locoClass, {
bool friends = false,
}) async {
try {
final path = Uri.encodeComponent(locoClass);
final suffix = friends ? '/friends' : '';
final json = await api.get('/stats/class/$path/leaderboard$suffix');
List<dynamic>? list;
if (json is List) {
list = json;
} else if (json is Map) {
for (final key in ['leaderboard', 'data', 'items', 'results']) {
final value = json[key];
if (value is List) {
list = value;
break;
}
}
}
return list
?.whereType<Map>()
.map((e) => LeaderboardEntry.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList() ??
const [];
} catch (e) {
debugPrint(
'Failed to fetch class leaderboard for $locoClass (friends=$friends): $e',
);
return const [];
}
}
Future<void> acceptPendingLoco({required int locoId}) async {
try {
await api.put('/loco/pending/approve/$locoId', null);
} catch (e) {
debugPrint('Failed to approve pending loco $locoId: $e');
rethrow;
}
}
Future<void> rejectPendingLoco({
required int locoId,
int? replacementLocoId,
String? rejectedReason,
}) async {
try {
final body = <String, dynamic>{};
if (replacementLocoId != null) {
body['replacement_loco_id'] = replacementLocoId;
}
if (rejectedReason != null && rejectedReason.trim().isNotEmpty) {
body['rejected_reason'] = rejectedReason.trim();
}
await api.put('/loco/pending/reject/$locoId', body.isEmpty ? null : body);
} catch (e) {
debugPrint('Failed to reject pending loco $locoId: $e');
rethrow;
}
}
Future<void> transferAllocations({
required int fromLocoId,
required int toLocoId,
}) async {
try {
await api.post('/loco/alloc/transfer', {
'loco_id': fromLocoId,
'to_loco_id': toLocoId,
});
} catch (e) {
debugPrint('Failed to transfer allocations $fromLocoId -> $toLocoId: $e');
rethrow;
}
}
Future<void> adminDeleteLoco({required int locoId}) async {
try {
await api.delete('/loco/admin/delete/$locoId');
} catch (e) {
debugPrint('Failed to delete loco $locoId as admin: $e');
rethrow;
}
}
}
class _PendingTimelineValue {
final String? valueStr;
final int? valueInt;
final bool? valueBool;
final DateTime? valueDate;
final dynamic valueNorm;
const _PendingTimelineValue({
this.valueStr,
this.valueInt,
this.valueBool,
this.valueDate,
this.valueNorm,
});
factory _PendingTimelineValue.fromDynamic(dynamic raw) {
if (raw is Map || raw is List) {
return _PendingTimelineValue(valueStr: jsonEncode(raw));
}
if (raw is bool) return _PendingTimelineValue(valueBool: raw);
if (raw is int) return _PendingTimelineValue(valueInt: raw);
if (raw is num) return _PendingTimelineValue(valueNorm: raw);
if (raw is String) {
final trimmed = raw.trim();
if (trimmed.isEmpty) return const _PendingTimelineValue(valueStr: '');
final lower = trimmed.toLowerCase();
if (['true', 'yes', 'y'].contains(lower)) {
return const _PendingTimelineValue(valueBool: true);
}
if (['false', 'no', 'n'].contains(lower)) {
return const _PendingTimelineValue(valueBool: false);
}
final intVal = int.tryParse(trimmed);
if (intVal != null) return _PendingTimelineValue(valueInt: intVal);
final doubleVal = double.tryParse(trimmed);
if (doubleVal != null) {
return _PendingTimelineValue(valueNorm: doubleVal);
}
final dateVal = DateTime.tryParse(trimmed);
if (dateVal != null) {
return _PendingTimelineValue(valueDate: dateVal);
}
return _PendingTimelineValue(valueStr: trimmed);
}
return _PendingTimelineValue(valueNorm: raw);
}
} }

View File

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

View File

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

View File

@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
class ThemeModeService extends ChangeNotifier {
static const _prefsKey = 'theme_mode_preference';
ThemeMode _mode = ThemeMode.system;
bool _loaded = false;
ThemeMode get mode => _mode;
bool get isLoaded => _loaded;
ThemeModeService() {
_load();
}
Future<void> _load() async {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_prefsKey);
if (saved != null) {
switch (saved) {
case 'light':
_mode = ThemeMode.light;
break;
case 'dark':
_mode = ThemeMode.dark;
break;
default:
_mode = ThemeMode.system;
}
}
_loaded = true;
notifyListeners();
}
Future<void> setMode(ThemeMode mode) async {
_mode = mode;
final prefs = await SharedPreferences.getInstance();
final value = switch (mode) {
ThemeMode.light => 'light',
ThemeMode.dark => 'dark',
_ => 'system',
};
await prefs.setString(_prefsKey, value);
notifyListeners();
}
}

View File

@@ -1,35 +1,60 @@
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';
import 'package:mileograph_flutter/components/pages/loco_timeline.dart'; import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
import 'package:mileograph_flutter/components/pages/logbook.dart'; import 'package:mileograph_flutter/components/pages/logbook.dart';
import 'package:mileograph_flutter/components/pages/more.dart'; import 'package:mileograph_flutter/components/pages/more.dart';
import 'package:mileograph_flutter/components/pages/badges.dart';
import 'package:mileograph_flutter/components/pages/new_entry.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/profile.dart'; import 'package:mileograph_flutter/components/pages/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/components/pages/traction/traction_pending_page.dart';
import 'package:mileograph_flutter/components/pages/more/user_profile_page.dart';
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.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/accent_color_service.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:mileograph_flutter/services/theme_mode_service.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",
"/traction",
"/add",
"/more",
];
const List<String> _defaultTabDestinations = [
"/dashboard",
"/calculator",
"/logbook/entries", "/logbook/entries",
"/traction", "/traction",
"/add", "/add",
"/more", "/more",
]; ];
const int _addTabIndex = 5; const int _addTabIndex = 4;
class _NavItem { class _NavItem {
final String label; final String label;
@@ -39,6 +64,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),
@@ -53,10 +79,9 @@ int tabIndexForPath(String path) {
matchPath = '/logbook/entries'; matchPath = '/logbook/entries';
} else if (matchPath.startsWith('/trips')) { } else if (matchPath.startsWith('/trips')) {
matchPath = '/logbook/trips'; matchPath = '/logbook/trips';
} else if (matchPath == '/logbook') { }
matchPath = '/logbook/entries'; if (matchPath.startsWith('/logbook')) {
} else if (matchPath.startsWith('/logbook/trips')) { matchPath = '/logbook';
matchPath = '/logbook/entries';
} else if (matchPath.startsWith('/profile') || } else if (matchPath.startsWith('/profile') ||
matchPath.startsWith('/settings') || matchPath.startsWith('/settings') ||
matchPath.startsWith('/more')) { matchPath.startsWith('/more')) {
@@ -80,12 +105,6 @@ class _MyAppState extends State<MyApp> {
late final GoRouter _router; late final GoRouter _router;
bool _routerInitialized = false; bool _routerInitialized = false;
final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red);
final ColorScheme defaultDark = ColorScheme.fromSeed(
seedColor: Colors.red,
brightness: Brightness.dark,
);
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
@@ -105,10 +124,7 @@ class _MyAppState extends State<MyApp> {
return null; return null;
}, },
routes: [ routes: [
GoRoute( GoRoute(path: '/', redirect: (context, state) => '/dashboard'),
path: '/',
redirect: (_, __) => '/dashboard',
),
ShellRoute( ShellRoute(
navigatorKey: _shellNavigatorKey, navigatorKey: _shellNavigatorKey,
builder: (context, state, child) => MyHomePage(child: child), builder: (context, state, child) => MyHomePage(child: child),
@@ -117,9 +133,20 @@ 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',
builder: (context, state) => const LogbookPage(), redirect: (context, state) => '/logbook/entries',
), ),
GoRoute( GoRoute(
path: '/logbook/entries', path: '/logbook/entries',
@@ -132,16 +159,62 @@ class _MyAppState extends State<MyApp> {
), ),
GoRoute( GoRoute(
path: '/trips', path: '/trips',
builder: (context, state) => redirect: (context, state) => '/logbook/trips',
const LogbookPage(initialTab: LogbookTab.trips),
), ),
GoRoute( GoRoute(
path: '/legs', path: '/legs',
builder: (context, state) => const LogbookPage(), redirect: (context, state) => '/logbook/entries',
), ),
GoRoute( GoRoute(
path: '/traction', path: '/traction',
builder: (context, state) => TractionPage(), builder: (context, state) {
final selectionParam =
state.uri.queryParameters['selection'] ??
(state.extra is Map
? (state.extra as Map)['selection']?.toString()
: null);
final replacementPendingLocoIdStr =
state.uri.queryParameters['replacementPendingLocoId'];
final replacementPendingLocoId = replacementPendingLocoIdStr != null
? int.tryParse(replacementPendingLocoIdStr)
: state.extra is Map
? int.tryParse(
(state.extra as Map)['replacementPendingLocoId']
?.toString() ??
'',
)
: null;
final transferFromLocoIdStr =
state.uri.queryParameters['transferFromLocoId'];
final transferFromLocoId = transferFromLocoIdStr != null
? int.tryParse(transferFromLocoIdStr)
: state.extra is Map
? int.tryParse(
(state.extra as Map)['transferFromLocoId']
?.toString() ??
'',
)
: null;
final selectionMode =
(selectionParam != null && selectionParam.isNotEmpty) ||
replacementPendingLocoId != null ||
transferFromLocoId != null;
final selectionSingle = replacementPendingLocoId != null ||
transferFromLocoId != null ||
selectionParam?.toLowerCase() == 'single' ||
selectionParam == '1' ||
selectionParam?.toLowerCase() == 'true';
return TractionPage(
selectionMode: selectionMode,
selectionSingle: selectionSingle,
replacementPendingLocoId: replacementPendingLocoId,
transferFromLocoId: transferFromLocoId,
);
},
),
GoRoute(
path: '/traction/pending',
builder: (context, state) => const TractionPendingPage(),
), ),
GoRoute( GoRoute(
path: '/profile', path: '/profile',
@@ -183,7 +256,15 @@ class _MyAppState extends State<MyApp> {
path: '/traction/new', path: '/traction/new',
builder: (context, state) => const NewTractionPage(), builder: (context, state) => const NewTractionPage(),
), ),
GoRoute(path: '/add', builder: (context, state) => NewEntryPage()), GoRoute(
path: '/add',
builder: (context, state) {
final extra = state.extra;
return NewEntryPage(
legShare: extra is LegShareData ? extra : null,
);
},
),
GoRoute( GoRoute(
path: '/more', path: '/more',
builder: (context, state) => const MorePage(), builder: (context, state) => const MorePage(),
@@ -192,10 +273,44 @@ class _MyAppState extends State<MyApp> {
path: '/more/profile', path: '/more/profile',
builder: (context, state) => const ProfilePage(), builder: (context, state) => const ProfilePage(),
), ),
GoRoute(
path: '/more/user-profile',
name: 'user-profile',
builder: (context, state) {
final extra = state.extra;
UserSummary? user;
String? userId;
if (extra is UserSummary) {
user = extra;
userId = extra.userId;
} else if (extra is Map) {
final value = extra['user'];
if (value is UserSummary) user = value;
userId = extra['userId']?.toString();
}
userId ??= state.uri.queryParameters['user_id'];
return UserProfilePage(
userId: userId,
initialUser: user,
);
},
),
GoRoute(
path: '/more/badges',
builder: (context, state) => const BadgesPage(),
),
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) {
@@ -206,7 +321,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(),
@@ -217,20 +335,34 @@ class _MyAppState extends State<MyApp> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accent = context.watch<AccentColorService>();
final themeModeService = context.watch<ThemeModeService>();
final seedColor =
accent.hasSavedSeed ? accent.seedColor : AccentColorService.defaultSeed;
final useSystemColors = accent.useSystem;
return DynamicColorBuilder( return DynamicColorBuilder(
builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) { builder: (ColorScheme? lightDynamic, ColorScheme? darkDynamic) {
final colorSchemeLight = useSystemColors && lightDynamic != null
? lightDynamic
: ColorScheme.fromSeed(seedColor: seedColor);
final colorSchemeDark = useSystemColors && darkDynamic != null
? darkDynamic
: ColorScheme.fromSeed(
seedColor: seedColor,
brightness: Brightness.dark,
);
return MaterialApp.router( return MaterialApp.router(
title: 'Mileograph', title: 'Mileograph',
routerConfig: _router, routerConfig: _router,
theme: ThemeData( theme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: lightDynamic ?? defaultLight, colorScheme: colorSchemeLight,
), ),
darkTheme: ThemeData( darkTheme: ThemeData(
useMaterial3: true, useMaterial3: true,
colorScheme: darkDynamic ?? defaultDark, colorScheme: colorSchemeDark,
), ),
themeMode: ThemeMode.system, themeMode: themeModeService.mode,
); );
}, },
); );
@@ -254,14 +386,14 @@ class MyHomePage extends StatefulWidget {
} }
class _MyHomePageState extends State<MyHomePage> { class _MyHomePageState extends State<MyHomePage> {
List<String> get contentPages => _contentPages; List<String> get tabDestinations => _defaultTabDestinations;
Future<void> _onItemTapped(int index, int currentIndex) async { Future<void> _onItemTapped(int index, int currentIndex) async {
if (index < 0 || index >= contentPages.length) { if (index < 0 || index >= tabDestinations.length) {
return; return;
} }
final currentPath = GoRouterState.of(context).uri.path; final currentPath = GoRouterState.of(context).uri.path;
final targetPath = contentPages[index]; final targetPath = tabDestinations[index];
final alreadyAtTarget = final alreadyAtTarget =
currentPath == targetPath || currentPath.startsWith('$targetPath/'); currentPath == targetPath || currentPath.startsWith('$targetPath/');
if (index == currentIndex && alreadyAtTarget) return; if (index == currentIndex && alreadyAtTarget) return;
@@ -272,13 +404,14 @@ class _MyHomePageState extends State<MyHomePage> {
}); });
} }
final List<int> _history = []; final List<String> _history = [];
int _historyPosition = -1; int _historyPosition = -1;
final List<int> _forwardHistory = []; final List<String> _forwardHistory = [];
bool _suppressRecord = false; bool _suppressRecord = false;
bool _fetched = false; bool _fetched = false;
bool _railCollapsed = false; bool _railCollapsed = false;
Timer? _notificationsTimer;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -315,15 +448,40 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.notifications.isEmpty) { if (data.notifications.isEmpty) {
data.fetchNotifications(); data.fetchNotifications();
} }
if (data.friendships.isEmpty && !data.isFriendshipsLoading) {
data.fetchFriendships();
}
if ((data.pendingIncoming.isEmpty && data.pendingOutgoing.isEmpty) &&
!data.isPendingFriendshipsLoading) {
data.fetchPendingFriendships();
}
_startNotificationPolling();
}); });
}); });
} }
void _startNotificationPolling() {
_notificationsTimer?.cancel();
final auth = context.read<AuthService>();
if (!auth.isLoggedIn) return;
_notificationsTimer = Timer.periodic(const Duration(minutes: 2), (_) async {
if (!mounted) return;
final auth = context.read<AuthService>();
if (!auth.isLoggedIn) return;
final data = context.read<DataService>();
try {
await data.fetchNotifications();
} catch (_) {
// Errors already logged inside data service.
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uri = GoRouterState.of(context).uri; final uri = GoRouterState.of(context).uri;
final pageIndex = tabIndexForPath(uri.path); final pageIndex = tabIndexForPath(uri.path);
_syncHistory(pageIndex); _syncHistory(uri.path);
if (pageIndex != _addTabIndex) { if (pageIndex != _addTabIndex) {
NavigationGuard.unregister(); NavigationGuard.unregister();
} }
@@ -367,7 +525,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(
@@ -379,12 +540,20 @@ class _MyHomePageState extends State<MyHomePage> {
), ),
actions: [ actions: [
_buildNotificationAction(context, data), _buildNotificationAction(context, data),
IconButton(
tooltip: 'Profile',
onPressed: () => context.go('/more/profile'),
icon: const Icon(Icons.person),
),
IconButton( IconButton(
tooltip: 'Settings', tooltip: 'Settings',
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
@@ -442,7 +611,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: {
@@ -468,7 +638,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,
), ),
@@ -488,7 +661,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) {
@@ -544,24 +719,21 @@ class _MyHomePageState extends State<MyHomePage> {
Future<void> _openNotificationsPanel(BuildContext context) async { Future<void> _openNotificationsPanel(BuildContext context) async {
final data = context.read<DataService>(); final data = context.read<DataService>();
final isWide = MediaQuery.sizeOf(context).width >= 900;
final sheetHeight = MediaQuery.sizeOf(context).height * 0.9;
try { try {
await data.fetchNotifications(); await data.fetchNotifications();
} catch (_) { } catch (_) {
// Already logged inside data service. // Already logged inside data service.
} }
if (!mounted) return; if (!context.mounted) return;
final isWide = MediaQuery.of(context).size.width >= 900;
final panelBuilder = (BuildContext ctx) {
return _buildNotificationsContent(ctx, isWide);
};
if (isWide) { if (isWide) {
await showDialog( await showDialog(
context: context, context: context,
builder: (dialogCtx) => Dialog( builder: (dialogCtx) => Dialog(
insetPadding: const EdgeInsets.all(16), insetPadding: const EdgeInsets.all(16),
child: panelBuilder(dialogCtx), child: _buildNotificationsContent(dialogCtx, isWide),
), ),
); );
} else { } else {
@@ -569,11 +741,10 @@ class _MyHomePageState extends State<MyHomePage> {
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
builder: (sheetCtx) { builder: (sheetCtx) {
final height = MediaQuery.of(context).size.height * 0.9;
return SizedBox( return SizedBox(
height: height, height: sheetHeight,
child: SafeArea( child: SafeArea(
child: panelBuilder(sheetCtx), child: _buildNotificationsContent(sheetCtx, isWide),
), ),
); );
}, },
@@ -584,9 +755,17 @@ class _MyHomePageState extends State<MyHomePage> {
Widget _buildNotificationsContent(BuildContext context, bool isWide) { Widget _buildNotificationsContent(BuildContext context, bool isWide) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final notifications = data.notifications; final notifications = data.notifications;
final dismissibleIds = notifications
.where(
(n) =>
!_isFriendRequestNotification(n) && !_isLegShareNotification(n),
)
.map((e) => e.id)
.toList();
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) {
@@ -606,9 +785,13 @@ class _MyHomePageState extends State<MyHomePage> {
height: listHeight, height: listHeight,
child: ListView.separated( child: ListView.separated(
itemCount: notifications.length, itemCount: notifications.length,
separatorBuilder: (_, __) => const SizedBox(height: 8), separatorBuilder: (_, index) => const SizedBox(height: 8),
itemBuilder: (ctx, index) { itemBuilder: (ctx, index) {
final item = notifications[index]; final item = notifications[index];
final isFriendRequest = _isFriendRequestNotification(item);
final isLegShare = _isLegShareNotification(item);
final isLegShareEdit = _isLegShareEditNotification(item);
final isSpecial = isFriendRequest || isLegShare || isLegShareEdit;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
@@ -626,44 +809,71 @@ 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),
Text( Text(
item.body, isSpecial
? isFriendRequest
? 'Accept to share entries'
: isLegShareEdit
? 'Shared leg edits below.'
: 'Shared entry details below.'
: item.body,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
if (item.createdAt != null) ...[ if (item.createdAt != null) ...[
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: Theme.of(context) color: () {
.textTheme final baseColor = Theme.of(
.bodySmall context,
?.color ).textTheme.bodySmall?.color;
?.withOpacity(0.7), if (baseColor == null) return null;
final newAlpha = (baseColor.a * 0.7)
.clamp(0.0, 1.0);
return baseColor.withValues(
alpha: newAlpha,
);
}(),
), ),
), ),
], ],
], ],
), ),
), ),
if (!isSpecial) ...[
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'),
), ),
], ],
],
),
if (isFriendRequest)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: FriendRequestNotificationCard(
notification: item,
),
),
if (_isLegShareEditNotification(item))
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LegShareEditNotificationCard(
notification: item,
),
),
if (isLegShare)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LegShareNotificationCard(notification: item),
), ),
], ],
), ),
@@ -686,19 +896,15 @@ 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(
onPressed: notifications.isEmpty onPressed: notifications.isEmpty
? null ? null
: () => _dismissNotifications( : () => _dismissNotifications(context, dismissibleIds),
context,
notifications.map((e) => e.id).toList(),
),
child: const Text('Dismiss all'), child: const Text('Dismiss all'),
), ),
], ],
@@ -720,9 +926,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')),
);
} }
} }
@@ -735,6 +939,44 @@ class _MyHomePageState extends State<MyHomePage> {
return '$y-$m-$d $hh:$mm'; return '$y-$m-$d $hh:$mm';
} }
bool _isFriendRequestNotification(UserNotification notification) {
final type = notification.type.trim().toLowerCase();
final channel = notification.channel.trim().toLowerCase();
final title = notification.title.trim().toLowerCase();
bool matchesChannel =
channel == 'friend_request' || channel == 'friend-request';
if (!matchesChannel) {
matchesChannel = channel.contains('friend_request') ||
channel.contains('friend-request') ||
channel.contains('friend');
}
return matchesChannel ||
type == 'friend_request' ||
type == 'friend-request' ||
title.contains('friend request');
}
bool _isLegShareNotification(UserNotification notification) {
final channel = notification.channel.trim().toLowerCase();
final type = notification.type.trim().toLowerCase();
final isAcceptEdits =
_isLegShareAcceptEdits(channel) || _isLegShareAcceptEdits(type);
return (channel.contains('leg_share') || type.contains('leg_share')) &&
!isAcceptEdits;
}
bool _isLegShareEditNotification(UserNotification notification) {
final channel = notification.channel.trim().toLowerCase();
final type = notification.type.trim().toLowerCase();
return _isLegShareAcceptEdits(channel) || _isLegShareAcceptEdits(type);
}
bool _isLegShareAcceptEdits(String value) {
final normalized = value.trim().toLowerCase();
// Match both singular/plural: leg_share_accept_edit / leg_share_accept_edits
return normalized.contains('leg_share_accept_edit');
}
Widget _buildBadge(String label) { Widget _buildBadge(String label) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
@@ -742,9 +984,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(
@@ -757,31 +997,32 @@ class _MyHomePageState extends State<MyHomePage> {
); );
} }
int get _currentPageIndex => tabIndexForPath(GoRouterState.of(context).uri.path);
Future<bool> _handleBackNavigation({ Future<bool> _handleBackNavigation({
bool allowExit = false, bool allowExit = false,
bool recordForward = false, bool recordForward = false,
}) async { }) async {
final pageIndex = _currentPageIndex; final currentPath = GoRouterState.of(context).uri.path;
final shellNav = _shellNavigatorKey.currentState; final shellNav = _shellNavigatorKey.currentState;
if (shellNav != null && shellNav.canPop()) { if (shellNav != null && shellNav.canPop()) {
if (recordForward) _pushForward(currentPath);
_alignHistoryAfterPop(currentPath);
shellNav.pop(); shellNav.pop();
return true; return true;
} }
if (_historyPosition > 0) { if (_historyPosition > 0) {
if (recordForward) _pushForward(pageIndex); if (recordForward) _pushForward(currentPath);
_historyPosition -= 1; _historyPosition -= 1;
_suppressRecord = true; _suppressRecord = true;
context.go(contentPages[_history[_historyPosition]]); context.go(_history[_historyPosition]);
return true; return true;
} }
if (pageIndex != 0) { final homePath = tabDestinations.first;
if (recordForward) _pushForward(pageIndex); if (currentPath != homePath) {
if (recordForward) _pushForward(currentPath);
_suppressRecord = true; _suppressRecord = true;
context.go(contentPages[0]); context.go(homePath);
return true; return true;
} }
@@ -795,35 +1036,48 @@ class _MyHomePageState extends State<MyHomePage> {
Future<bool> _handleForwardNavigation() async { Future<bool> _handleForwardNavigation() async {
if (_forwardHistory.isEmpty) return false; if (_forwardHistory.isEmpty) return false;
final nextTab = _forwardHistory.removeLast(); final nextPath = _forwardHistory.removeLast();
// Move cursor forward, keeping history in sync. // Move cursor forward, keeping history in sync.
if (_historyPosition < _history.length - 1) { if (_historyPosition < _history.length - 1) {
_historyPosition += 1; _historyPosition += 1;
_history[_historyPosition] = nextTab; _history[_historyPosition] = nextPath;
if (_historyPosition < _history.length - 1) { if (_historyPosition < _history.length - 1) {
_history.removeRange(_historyPosition + 1, _history.length); _history.removeRange(_historyPosition + 1, _history.length);
} }
} else { } else {
_history.add(nextTab); _history.add(nextPath);
_historyPosition = _history.length - 1; _historyPosition = _history.length - 1;
} }
_suppressRecord = true; _suppressRecord = true;
if (!mounted) return false; if (!mounted) return false;
context.go(contentPages[nextTab]); context.go(nextPath);
return true; return true;
} }
void _pushForward(int pageIndex) { void _pushForward(String path) {
if (_forwardHistory.isEmpty || _forwardHistory.last != pageIndex) { if (_forwardHistory.isEmpty || _forwardHistory.last != path) {
_forwardHistory.add(pageIndex); _forwardHistory.add(path);
} }
} }
void _syncHistory(int pageIndex) { void _alignHistoryAfterPop(String currentPath) {
if (_history.isEmpty) return;
if (_historyPosition >= 0 &&
_historyPosition < _history.length &&
_history[_historyPosition] == currentPath) {
if (_historyPosition > 0) {
_historyPosition -= 1;
}
_history.removeRange(_historyPosition + 1, _history.length);
_suppressRecord = true;
}
}
void _syncHistory(String path) {
if (_history.isEmpty) { if (_history.isEmpty) {
_history.add(pageIndex); _history.add(path);
_historyPosition = 0; _historyPosition = 0;
return; return;
} }
@@ -833,13 +1087,13 @@ class _MyHomePageState extends State<MyHomePage> {
} }
if (_historyPosition >= 0 && if (_historyPosition >= 0 &&
_historyPosition < _history.length && _historyPosition < _history.length &&
_history[_historyPosition] == pageIndex) { _history[_historyPosition] == path) {
return; return;
} }
if (_historyPosition < _history.length - 1) { if (_historyPosition < _history.length - 1) {
_history.removeRange(_historyPosition + 1, _history.length); _history.removeRange(_historyPosition + 1, _history.length);
} }
_history.add(pageIndex); _history.add(path);
_historyPosition = _history.length - 1; _historyPosition = _history.length - 1;
_forwardHistory.clear(); _forwardHistory.clear();
} }
@@ -847,6 +1101,12 @@ class _MyHomePageState extends State<MyHomePage> {
void _navigateToIndex(int index) { void _navigateToIndex(int index) {
_suppressRecord = false; _suppressRecord = false;
_forwardHistory.clear(); _forwardHistory.clear();
context.go(contentPages[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.0+1 version: 0.7.1+9
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

Some files were not shown because too many files have changed in this diff Show More