Compare commits

...

48 Commits

Author SHA1 Message Date
8340501f37 make carousel more stable
All checks were successful
Release / meta (push) Successful in 4s
Release / linux-build (push) Successful in 1m14s
Release / web-build (push) Successful in 1m30s
Release / android-build (push) Successful in 5m52s
Release / release-dev (push) Successful in 7s
Release / release-master (push) Successful in 4s
2026-01-23 18:24:34 +00:00
a527ecdb17 add logo to login page 2026-01-23 18:04:40 +00:00
f3fcf07b05 add refresh token support 2026-01-23 17:55:55 +00:00
9896b6f1f8 add traction import export
All checks were successful
Release / meta (push) Successful in 3s
Release / linux-build (push) Successful in 1m1s
Release / web-build (push) Successful in 1m22s
Release / android-build (push) Successful in 6m49s
Release / release-master (push) Successful in 5s
Release / release-dev (push) Successful in 8s
2026-01-23 13:21:08 +00:00
56cc7c0902 fix mileograph tag issue
All checks were successful
Release / meta (push) Successful in 2s
Release / linux-build (push) Successful in 1m36s
Release / web-build (push) Successful in 1m18s
Release / android-build (push) Successful in 6m41s
Release / release-master (push) Successful in 15s
Release / release-dev (push) Successful in 20s
2026-01-23 00:25:47 +00:00
917d020ef5 fix release pipelines
Some checks failed
Release / meta (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / web-build (push) Has been cancelled
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-23 00:22:56 +00:00
d8312a3f1b Added class clearance fixes and improvements
Some checks failed
Release / meta (push) Successful in 18s
Release / android-build (push) Successful in 11m48s
Release / linux-build (push) Successful in 1m24s
Release / web-build (push) Successful in 6m15s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
2026-01-22 23:17:15 +00:00
559f79b805 add transfer all button for admins
All checks were successful
Release / meta (push) Successful in 6s
Release / linux-build (push) Successful in 1m4s
Release / web-build (push) Successful in 2m47s
Release / android-build (push) Successful in 11m45s
Release / release-master (push) Successful in 26s
Release / release-dev (push) Successful in 30s
2026-01-12 17:11:37 +00:00
3b7ec31e5d add refresh button to pending page
All checks were successful
Release / meta (push) Successful in 7s
Release / linux-build (push) Successful in 50s
Release / web-build (push) Successful in 2m23s
Release / android-build (push) Successful in 7m48s
Release / release-master (push) Successful in 18s
Release / release-dev (push) Successful in 20s
2026-01-12 16:37:01 +00:00
e9b328e7e6 improve pending visibility, allow multiple users to have pending changes against locos. 2026-01-12 16:16:36 +00:00
45042b5001 new pending visibility
Some checks failed
Release / meta (push) Successful in 8s
Release / release-dev (push) Has been cancelled
Release / release-master (push) Has been cancelled
Release / android-build (push) Has been cancelled
Release / linux-build (push) Has been cancelled
Release / web-build (push) Has been cancelled
2026-01-12 16:03:47 +00:00
5c0043146f minor page tweaks 2026-01-12 15:30:29 +00:00
91f5391684 fix edit widget issues 2026-01-12 15:00:09 +00:00
f06a1c75b6 Fix transfer functoin, add display for numer of pending locos
All checks were successful
Release / meta (push) Successful in 12s
Release / linux-build (push) Successful in 1m10s
Release / web-build (push) Successful in 1m19s
Release / android-build (push) Successful in 9m2s
Release / release-master (push) Successful in 17s
Release / release-dev (push) Successful in 19s
2026-01-06 14:44:12 +00:00
5b94ab263b move transfer button, hide delete button better
All checks were successful
Release / meta (push) Successful in 28s
Release / linux-build (push) Successful in 54s
Release / web-build (push) Successful in 4m48s
Release / android-build (push) Successful in 15m12s
Release / release-master (push) Successful in 31s
Release / release-dev (push) Successful in 34s
2026-01-06 12:04:34 +00:00
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
119 changed files with 13880 additions and 1749 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

@@ -12,11 +12,13 @@ env:
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:
- mileograph
- tgj-arc
outputs:
base_version: ${{ steps.meta.outputs.base }}
release_tag: ${{ steps.meta.outputs.release_tag }}
@@ -42,7 +44,7 @@ jobs:
DEV_SUFFIX="-dev.${DEV_ITER}"
VERSION="${BASE_VERSION}${DEV_SUFFIX}"
TAG="v${VERSION}"
TAG="${VERSION}"
fi
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
@@ -79,21 +81,21 @@ jobs:
android-build:
runs-on:
- mileograph
- 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: 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
@@ -119,17 +121,19 @@ jobs:
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"
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: 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 '*'
@@ -152,6 +156,18 @@ jobs:
- name: Build Android App Bundle (release)
run: flutter build appbundle --release
- name: Archive Android App Bundle
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
run: |
set -euo pipefail
BUNDLE_SRC="build/app/outputs/bundle/release/app-release.aab"
if [ ! -f "$BUNDLE_SRC" ]; then
echo "Bundle not found at $BUNDLE_SRC"
exit 1
fi
cp "$BUNDLE_SRC" "mileograph-${BASE_VERSION}.aab"
- name: Download bundletool
run: |
BUNDLETOOL_VERSION=1.15.6
@@ -198,36 +214,20 @@ jobs:
name: android-apk
path: mileograph-${{ needs.meta.outputs.base_version }}.apk
- name: Upload Android AAB artifact
uses: actions/upload-artifact@v3
with:
name: android-aab
path: mileograph-${{ needs.meta.outputs.base_version }}.aab
linux-build:
runs-on:
- mileograph
- tgj-arc
needs: meta
steps:
- name: Checkout
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)
run: git config --global --add safe.directory '*'
@@ -251,13 +251,78 @@ jobs:
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 }}
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:
runs-on:
- mileograph
- tgj-arc
needs:
- meta
- android-build
- linux-build
- web-build
steps:
- name: Install jq
run: |
@@ -275,6 +340,13 @@ jobs:
name: android-apk
path: artifacts
- name: Download Android AAB
if: ${{ github.ref == 'refs/heads/dev' }}
uses: actions/download-artifact@v3
with:
name: android-aab
path: artifacts
- name: Prepare APK and tag
if: ${{ github.ref == 'refs/heads/dev' }}
id: bundle
@@ -289,11 +361,14 @@ jobs:
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' }}
@@ -334,13 +409,26 @@ jobs:
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null
# Attach AAB
AAB="${{ steps.bundle.outputs.aab }}"
NAME_AAB=$(basename "$AAB")
echo "Uploading $NAME_AAB"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${AAB}" \
-F "name=${NAME_AAB}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null
release-master:
runs-on:
- mileograph
- tgj-arc
needs:
- meta
- android-build
- linux-build
- web-build
steps:
- name: Install jq
run: |
@@ -358,6 +446,13 @@ jobs:
name: android-apk
path: artifacts
- name: Download Android AAB
if: ${{ github.ref == 'refs/heads/master' }}
uses: actions/download-artifact@v3
with:
name: android-aab
path: artifacts
- name: Prepare APK and tag
if: ${{ github.ref == 'refs/heads/master' }}
id: bundle
@@ -367,6 +462,7 @@ jobs:
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
echo "apk=artifacts/mileograph-${BASE}.apk" >> "$GITHUB_OUTPUT"
echo "aab=artifacts/mileograph-${BASE}.aab" >> "$GITHUB_OUTPUT"
- name: Create release on Gitea
if: ${{ github.ref == 'refs/heads/master' }}
@@ -405,3 +501,15 @@ jobs:
-F "name=${NAME}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null
# Attach AAB
AAB="${{ steps.bundle.outputs.aab }}"
NAME_AAB=$(basename "$AAB")
echo "Uploading $NAME_AAB"
curl -sS -X POST \
-H "Authorization: token ${{ secrets.GITEA_TOKEN }}" \
-F "attachment=@${AAB}" \
-F "name=${NAME_AAB}" \
"${GITEA_BASE_URL}/api/v1/repos/${{ github.repository }}/releases/${RELEASE_ID}/assets" \
>/dev/null

10
Dockerfile.web Normal file
View File

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

View File

@@ -1,16 +1,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)
- [Cookbook: Useful Flutter samples](https://docs.flutter.dev/cookbook)
## Prerequisites
- 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
[online documentation](https://docs.flutter.dev/), which offers tutorials,
samples, guidance on mobile development, and a full API reference.
## Setup
1) Install Flutter: follow https://docs.flutter.dev/get-started/install and ensure `flutter doctor` is green.
2) Get dependencies:
```bash
flutter pub get
```
3) Configure an API endpoint:
- Run the app, open Settings, and set the base URL for your Mileograph backend.
- The app probes the endpoint for a version string before saving.
## 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
plugins {
@@ -22,8 +23,21 @@ val releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
?: 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(
releaseStoreFile,
releaseStoreFilePath?.path,
releaseStorePassword,
releaseKeyAlias,
releaseKeyPassword
@@ -31,7 +45,7 @@ val hasReleaseKeystore = listOf(
android {
namespace = "com.example.mileograph_flutter"
compileSdk = flutter.compileSdkVersion
compileSdk = 36
ndkVersion = "27.0.12077973"
compileOptions {
@@ -48,8 +62,8 @@ android {
applicationId = "com.petegregoryy.mileograph_flutter"
// You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
minSdk = 24
targetSdk = 36
versionCode = flutter.versionCode
versionName = flutter.versionName
}
@@ -57,7 +71,7 @@ android {
signingConfigs {
if (hasReleaseKeystore) {
create("release") {
storeFile = file(releaseStoreFile!!)
storeFile = releaseStoreFilePath
storePassword = releaseStorePassword
keyAlias = releaseKeyAlias!!
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"?>
<resources>
<color name="ic_launcher_background">#ffffff</color>
<color name="ic_launcher_background">#000000</color>
</resources>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
width="120"
height="120"
viewBox="0 0 120 120"
version="1.1"
id="svg1"
xmlns="http://www.w3.org/2000/svg"
xmlns:svg="http://www.w3.org/2000/svg">
<defs
id="defs1" />
<g
id="layer1">
<path
style="fill:#00b7fd;stroke-width:4.40315"
d="M 42.563242,0 H 92.5 A 27.5,27.5 45 0 1 120,27.5 27.5,27.5 135 0 1 92.5,55 h -50 A 2.5,2.5 45 0 1 40,52.5 v -10 A 2.5,2.5 135 0 1 42.5,40 h 50 A 12.5,12.5 135 0 0 105,27.5 12.5,12.5 45 0 0 92.5,15 l -50,0 A 27.5,27.5 135 0 0 15,42.5 v 50 A 2.5,2.5 135 0 1 12.5,95 H 2.5 A 2.5,2.5 45 0 1 0,92.5 V 42.563242 A 42.563242,42.563242 135 0 1 42.563242,0 Z"
id="path1"
transform="translate(0,12.5)" />
<path
style="fill:#999999;stroke-width:4.40315"
d="m 42.5,60 h 60 A 17.5,17.5 45 0 1 120,77.5 17.5,17.5 135 0 1 102.5,95 h -60 A 22.5,22.5 45 0 1 20,72.5 v -30 A 22.5,22.5 135 0 1 42.5,20 h 50 a 7.5,7.5 45 0 1 7.5,7.5 7.5,7.5 135 0 1 -7.5,7.5 h -50 A 7.5,7.5 135 0 0 35,42.5 v 30 a 7.5,7.5 45 0 0 7.5,7.5 h 60 A 2.5,2.5 135 0 0 105,77.5 2.5,2.5 45 0 0 102.5,75 h -60 A 2.5,2.5 45 0 1 40,72.5 v -10 A 2.5,2.5 135 0 1 42.5,60 Z"
id="path2"
transform="translate(0,12.5)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 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/authservice.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/theme_mode_service.dart';
import 'package:mileograph_flutter/ui/app_shell.dart';
import 'package:provider/provider.dart';
@@ -16,6 +19,15 @@ class App extends StatelessWidget {
ChangeNotifierProvider<EndpointService>(
create: (_) => EndpointService(),
),
ChangeNotifierProvider<AccentColorService>(
create: (_) => AccentColorService(),
),
ChangeNotifierProvider<DistanceUnitService>(
create: (_) => DistanceUnitService(),
),
ChangeNotifierProvider<ThemeModeService>(
create: (_) => ThemeModeService(),
),
ProxyProvider<EndpointService, ApiService>(
update: (_, endpoint, api) {
final service = api ?? ApiService(baseUrl: endpoint.baseUrl);
@@ -31,7 +43,11 @@ class App extends StatelessWidget {
create: (context) => DataService(api: context.read<ApiService>()),
update: (context, auth, data) {
data ??= DataService(api: context.read<ApiService>());
data.handleAuthChanged(auth.userId);
data.handleAuthChanged(
auth.userId,
entriesVisibility: auth.entriesVisibility,
mileageVisibility: auth.mileageVisibility,
);
return data;
},
),

View File

@@ -61,14 +61,7 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
controller: textEditingController,
focusNode: focusNode,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
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
},
onSubmitted: (_) => onFieldSubmitted(),
decoration: const InputDecoration(
labelText: 'Select station',
border: OutlineInputBorder(),
@@ -140,6 +133,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
bool _loadingStations = false;
RouteResult? _routeResult;
List<String>? _calculatedStations;
RouteResult? get result => _routeResult;
String? _errorMessage;
@@ -181,19 +175,30 @@ class _RouteCalculatorState extends State<RouteCalculator> {
}
Future<void> _calculateRoute(List<String> stations) async {
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
if (cleaned.length < 2) {
setState(() {
_routeResult = null;
_calculatedStations = null;
_errorMessage = 'Add at least two stations before calculating.';
});
return;
}
setState(() {
_errorMessage = null;
_routeResult = null;
_calculatedStations = null;
});
final api = context.read<ApiService>(); // context is valid here
try {
final res = await api.post('/route/distance2', {
'route': stations.where((s) => s.trim().isNotEmpty).toList(),
'route': cleaned,
});
if (res is Map && res['error'] == false) {
setState(() {
_routeResult = RouteResult.fromJson(Map<String, dynamic>.from(res));
_calculatedStations = List.from(cleaned);
});
final distance = (_routeResult?.distance ?? 0);
widget.onDistanceComputed?.call(distance);
@@ -204,17 +209,30 @@ class _RouteCalculatorState extends State<RouteCalculator> {
).msg;
});
} else {
setState(() => _errorMessage = 'Failed to calculate route.');
setState(() {
_errorMessage = 'Failed to calculate route.';
_calculatedStations = null;
});
}
} catch (e) {
setState(() => _errorMessage = 'Failed to calculate route: $e');
setState(() {
_errorMessage = 'Failed to calculate route: $e';
_calculatedStations = null;
});
}
}
void _markRouteDirty() {
_routeResult = null;
_calculatedStations = null;
_errorMessage = null;
}
void _addStation() {
final data = context.read<DataService>();
setState(() {
data.stations.add('');
_markRouteDirty();
});
}
@@ -222,6 +240,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>();
setState(() {
data.stations.removeAt(index);
_markRouteDirty();
});
}
@@ -229,14 +248,109 @@ class _RouteCalculatorState extends State<RouteCalculator> {
final data = context.read<DataService>();
setState(() {
data.stations[index] = value;
_markRouteDirty();
});
}
void _clearCalculator() {
final data = context.read<DataService>();
setState(() {
data.stations = [''];
_markRouteDirty();
});
}
bool _isResultCurrent(List<String> stations) {
if (_routeResult == null || _calculatedStations == null) return false;
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
if (cleaned.length != _calculatedStations!.length) return false;
for (var i = 0; i < cleaned.length; i++) {
if (cleaned[i] != _calculatedStations![i]) return false;
}
return true;
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final isCompact = MediaQuery.of(context).size.width < 600;
final showApply =
widget.onApplyRoute != null && _isResultCurrent(data.stations);
final primaryPadding = EdgeInsets.symmetric(
horizontal: isCompact ? 14 : 20,
vertical: isCompact ? 10 : 14,
);
final secondaryPadding = EdgeInsets.symmetric(
horizontal: isCompact ? 10 : 16,
vertical: isCompact ? 8 : 12,
);
final primaryStyle = FilledButton.styleFrom(
padding: primaryPadding,
minimumSize: Size(0, isCompact ? 38 : 46),
);
final secondaryStyle = OutlinedButton.styleFrom(
padding: secondaryPadding,
minimumSize: Size(0, isCompact ? 34 : 42),
);
Widget buildSecondaryButton({
required IconData icon,
required String label,
required VoidCallback onPressed,
}) {
if (isCompact) {
return Tooltip(
message: label,
child: OutlinedButton(
onPressed: onPressed,
style: secondaryStyle,
child: Icon(icon, size: 20),
),
);
}
return OutlinedButton.icon(
onPressed: onPressed,
icon: Icon(icon, size: 20),
label: Text(label),
style: secondaryStyle,
);
}
Widget buildPrimaryAction({required bool fullWidth}) {
final button = showApply
? FilledButton.icon(
onPressed: () => widget.onApplyRoute!(_routeResult!),
icon: const Icon(Icons.check),
label: const Text('Apply to entry'),
style: primaryStyle,
)
: FilledButton.icon(
onPressed: () async {
await _calculateRoute(data.stations);
},
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
style: primaryStyle,
);
final key =
ValueKey<String>(showApply ? 'apply-primary-action' : 'calc-primary-action');
if (!fullWidth) return KeyedSubtree(key: key, child: button);
return KeyedSubtree(
key: key,
child: SizedBox(width: double.infinity, child: button),
);
}
return Column(
children: [
Align(
alignment: Alignment.centerRight,
child: IconButton(
tooltip: 'Clear calculator',
icon: const Icon(Icons.clear_all),
onPressed: _clearCalculator,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
child: Wrap(
@@ -283,6 +397,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
setState(() {
final moved = data.stations.removeAt(oldIndex);
data.stations.insert(newIndex, moved);
_markRouteDirty();
});
},
children: List.generate(data.stations.length, (index) {
@@ -346,38 +461,92 @@ class _RouteCalculatorState extends State<RouteCalculator> {
context.push('/calculator/details', extra: result);
},
),
if (widget.onApplyRoute != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: ElevatedButton.icon(
onPressed: () => widget.onApplyRoute!(_routeResult!),
icon: const Icon(Icons.check),
label: const Text('Apply to entry'),
),
),
]
else
SizedBox.shrink(),
const SizedBox(height: 10),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(
child: isCompact
? Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
buildSecondaryButton(
icon: Icons.swap_horiz,
label: 'Reverse route',
onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations);
},
),
const SizedBox(width: 12),
buildSecondaryButton(
icon: Icons.add,
label: 'Add station',
onPressed: _addStation,
),
],
),
const SizedBox(height: 10),
SizedBox(
width: double.infinity,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
);
return ScaleTransition(
scale:
Tween<double>(begin: 0.94, end: 1.0).animate(curved),
child: FadeTransition(opacity: animation, child: child),
);
},
child: buildPrimaryAction(fullWidth: true),
),
),
],
)
: Wrap(
alignment: WrapAlignment.center,
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
icon: const Icon(Icons.add),
label: const Text('Add Station'),
onPressed: _addStation,
),
ElevatedButton.icon(
icon: const Icon(Icons.route),
label: const Text('Calculate Route'),
buildSecondaryButton(
icon: Icons.swap_horiz,
label: 'Reverse route',
onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations);
},
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 220),
transitionBuilder: (child, animation) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutBack,
);
return ScaleTransition(
scale:
Tween<double>(begin: 0.94, end: 1.0).animate(curved),
child: FadeTransition(opacity: animation, child: child),
);
},
child: buildPrimaryAction(fullWidth: false),
),
buildSecondaryButton(
icon: Icons.add,
label: 'Add station',
onPressed: _addStation,
),
],
),
),

View File

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

View File

@@ -1,17 +1,34 @@
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/components/widgets/animated_count_text.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';
enum _LeaderboardScope { global, friends }
class LeaderboardPanel extends StatelessWidget {
class LeaderboardPanel extends StatefulWidget {
const LeaderboardPanel({super.key});
@override
State<LeaderboardPanel> createState() => _LeaderboardPanelState();
}
class _LeaderboardPanelState extends State<LeaderboardPanel> {
_LeaderboardScope _scope = _LeaderboardScope.global;
@override
Widget build(BuildContext context) {
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;
if (data.isHomepageLoading && leaderboard.isEmpty) {
if (loading && leaderboard.isEmpty) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()),
@@ -36,7 +53,8 @@ class LeaderboardPanel extends StatelessWidget {
),
),
),
if (leaderboard.isNotEmpty)
if (leaderboard.isNotEmpty &&
MediaQuery.of(context).size.width > 600)
Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -52,6 +70,40 @@ class LeaderboardPanel extends StatelessWidget {
],
),
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)
const Padding(
padding: EdgeInsets.all(8.0),
@@ -81,12 +133,38 @@ class LeaderboardPanel extends StatelessWidget {
fontWeight: FontWeight.w700,
),
),
trailing: Text(
'${leaderboard[index].mileage.toStringAsFixed(1)} mi',
trailing: Wrap(
crossAxisAlignment: WrapCrossAlignment.center,
spacing: 8,
children: [
AnimatedCountText(
value: leaderboard[index].mileage,
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
style: textTheme.labelLarge?.copyWith(
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),
],

View File

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

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class LegCard extends StatefulWidget {
@@ -26,9 +27,17 @@ class _LegCardState extends State<LegCard> {
@override
Widget build(BuildContext context) {
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 textTheme = Theme.of(context).textTheme;
return Card(
clipBehavior: Clip.antiAlias,
elevation: 1,
child: Theme(
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
child: ExpansionTile(
onExpansionChanged: (v) => setState(() => _expanded = v),
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
@@ -109,12 +118,6 @@ class _LegCardState extends State<LegCard> {
subtitle: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth > 520;
final timeWidget = _timeWithDelay(
context,
leg.beginTime,
leg.beginDelayMinutes,
includeDate: widget.showDate,
);
final tractionWrap = !_expanded && leg.locos.isNotEmpty
? Wrap(
spacing: 8,
@@ -181,7 +184,7 @@ class _LegCardState extends State<LegCard> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${leg.mileage.toStringAsFixed(1)} mi',
distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w700,
),
@@ -197,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) ...[
const SizedBox(width: 8),
IconButton(
@@ -250,6 +257,34 @@ class _LegCardState extends State<LegCard> {
..._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) ...[
Text('Route', style: textTheme.titleSmall),
const SizedBox(height: 6),
@@ -260,6 +295,7 @@ class _LegCardState extends State<LegCard> {
),
],
),
),
);
}
@@ -448,3 +484,61 @@ class _LegCardState extends State<LegCard> {
return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList();
}
}
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 (sharedTo.isNotEmpty) {
final names = sharedTo
.map((s) => s.sharedToDisplay)
.where((name) => name.isNotEmpty)
.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,
),
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (int i = 0; i < icons.length; i++) ...[
if (i > 0) const SizedBox(width: 6),
icons[i],
],
],
);
}
}

View File

@@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_svg/flutter_svg.dart';
import 'package:go_router/go_router.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
@@ -41,17 +43,29 @@ class _LoginScreenState extends State<LoginScreen> {
resizeToAvoidBottomInset: true,
body: Container(
color: Theme.of(context).scaffoldBackgroundColor,
child: SafeArea(
child: Column(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text.rich(
LayoutBuilder(
builder: (context, constraints) {
return SizedBox(
width: constraints.maxWidth,
child: FittedBox(
fit: BoxFit.scaleDown,
child: Text.rich(
TextSpan(
children: [
TextSpan(
text: "Mile",
style: TextStyle(
color: Theme.of(context).textTheme.bodyLarge?.color,
color: Theme.of(
context,
).textTheme.bodyLarge?.color,
),
),
const TextSpan(
@@ -61,7 +75,9 @@ class _LoginScreenState extends State<LoginScreen> {
TextSpan(
text: "graph",
style: TextStyle(
color: Theme.of(context).textTheme.bodyLarge?.color,
color: Theme.of(
context,
).textTheme.bodyLarge?.color,
),
),
],
@@ -72,6 +88,12 @@ class _LoginScreenState extends State<LoginScreen> {
fontSize: 50,
),
),
softWrap: false,
overflow: TextOverflow.visible,
),
),
);
},
),
const SizedBox(height: 50),
const LoginPanel(),
@@ -110,10 +132,69 @@ class _LoginScreenState extends State<LoginScreen> {
),
),
),
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: _LoginLogo(),
),
],
),
),
),
);
}
}
class _LoginLogo extends StatefulWidget {
const _LoginLogo();
@override
State<_LoginLogo> createState() => _LoginLogoState();
}
class _LoginLogoState extends State<_LoginLogo> {
late final Future<String> _svgFuture;
@override
void initState() {
super.initState();
_svgFuture = rootBundle.loadString('assets/logos/pg_logo_v2.svg');
}
@override
Widget build(BuildContext context) {
final accent = Theme.of(context).colorScheme.primary;
final grey = const Color(0xFF999999);
return SizedBox(
height: 42,
child: Opacity(
opacity: 0.75,
child: FutureBuilder<String>(
future: _svgFuture,
builder: (context, snapshot) {
final svg = snapshot.data;
if (svg == null) {
return const SizedBox.shrink();
}
final tinted = svg
.replaceAll('#00b7fd', _colorToHex(accent))
.replaceAll('#999999', _colorToHex(grey));
return SvgPicture.string(
tinted,
fit: BoxFit.contain,
semanticsLabel: 'Mileograph logo',
);
},
),
),
);
}
String _colorToHex(Color color) {
final hex = color.value.toRadixString(16).padLeft(8, '0');
return '#${hex.substring(2)}';
}
}
class LoginPanel extends StatefulWidget {
const LoginPanel({super.key});
@@ -294,6 +375,7 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
final _passwordController = TextEditingController();
final _inviteController = TextEditingController();
bool _registering = false;
String? _usernameError;
@override
void dispose() {
@@ -306,10 +388,21 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
}
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);
try {
await widget.authService.register(
username: _usernameController.text.trim(),
username: username,
email: _emailController.text.trim(),
fullName: _displayNameController.text.trim(),
password: _passwordController.text,
@@ -363,6 +456,21 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
border: OutlineInputBorder(),
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),
TextField(

View File

@@ -0,0 +1,768 @@
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);
final activePct = progress.activePercent.clamp(0, 100);
final showActive =
progress.activeTotal > 0 || progress.activeCompleted > 0;
final showActiveCrest =
progress.activeTotal > 0 && progress.activeCompleted == progress.activeTotal;
final scheme = Theme.of(context).colorScheme;
final total = progress.total;
final activeTotal = progress.activeTotal.clamp(0, total);
final had = progress.completed.clamp(0, total);
final activeHad = progress.activeCompleted.clamp(0, activeTotal).clamp(0, had);
final activeRemaining =
(activeTotal - activeHad).clamp(0, total - had);
final remaining =
(total - activeTotal - (had - activeHad)).clamp(0, total);
final hadColor = scheme.primary;
final activeColor = scheme.primary.withValues(alpha: 0.4);
final remainingColor = scheme.primary.withValues(alpha: 0.18);
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),
if (showActive)
Padding(
padding: const EdgeInsets.only(bottom: 6.0),
child: Row(
children: [
Text(
'Active: ${progress.activeCompleted}/${progress.activeTotal} (${activePct.toStringAsFixed(0)}%)',
style: Theme.of(context)
.textTheme
.labelSmall
?.copyWith(color: Theme.of(context).hintColor),
),
if (showActiveCrest) ...[
const SizedBox(width: 6),
Icon(
Icons.verified,
size: 14,
color: scheme.primary,
),
],
],
),
),
ClipRRect(
borderRadius: BorderRadius.circular(6),
child: SizedBox(
height: 6,
child: total <= 0
? Container(color: remainingColor)
: Row(
children: [
if (had > 0)
Expanded(
flex: had,
child: Container(color: hadColor),
),
if (showActive && activeRemaining > 0)
Expanded(
flex: activeRemaining,
child: Container(color: activeColor),
),
if (remaining > 0)
Expanded(
flex: remaining,
child: Container(color: remainingColor),
),
],
),
),
),
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

@@ -1,12 +1,17 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import 'package:mileograph_flutter/components/dashboard/latest_loco_changes_panel.dart';
import 'package:mileograph_flutter/components/dashboard/leaderboard_panel.dart';
import 'package:mileograph_flutter/components/dashboard/top_traction_panel.dart';
import 'package:mileograph_flutter/components/widgets/animated_count_text.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class Dashboard extends StatefulWidget {
@@ -18,27 +23,35 @@ class Dashboard extends StatefulWidget {
class _DashboardState extends State<Dashboard> {
bool _showAllOnThisDay = false;
bool _isCurrent = false;
Timer? _carouselTimer;
int _carouselIndex = 0;
int _carouselItemCount = 0;
final Random _carouselRandom = Random();
List<ClassClearanceProgress> _carouselItems = const [];
String _carouselSignature = '';
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final auth = context.watch<AuthService>();
final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats;
final isInitialLoading = data.isHomepageLoading || stats == null;
final isInitialLoading = data.isHomepageLoading && stats == null;
return RefreshIndicator(
onRefresh: () async {
await data.fetchHomepageStats();
await Future.wait([
data.fetchOnThisDay(),
data.fetchTripDetails(),
data.fetchHadTraction(),
data.fetchLatestLocoChanges(),
]);
await _refreshDashboardData(force: true);
},
child: LayoutBuilder(
builder: (context, constraints) {
_handleRouteFocus();
const spacing = 16.0;
final maxWidth = constraints.maxWidth;
return Stack(
@@ -46,9 +59,15 @@ class _DashboardState extends State<Dashboard> {
ListView(
padding: const EdgeInsets.all(16),
children: [
_buildHero(context, auth, data, stats),
_buildHero(context, auth, data, stats, distanceUnits),
const SizedBox(height: spacing),
_buildTiles(context, data, maxWidth, spacing),
_buildTiles(
context,
data,
distanceUnits,
maxWidth,
spacing,
),
],
),
if (isInitialLoading)
@@ -81,6 +100,7 @@ class _DashboardState extends State<Dashboard> {
AuthService auth,
DataService data,
HomepageStats? stats,
DistanceUnitService distanceUnits,
) {
final colorScheme = Theme.of(context).colorScheme;
final greetingName =
@@ -88,9 +108,6 @@ class _DashboardState extends State<Dashboard> {
final totalMileage = stats?.totalMileage ?? 0;
final currentYearMileage = data.getMileageForCurrentYear();
final legCount = stats?.legCount ?? data.trips.length;
final progress = totalMileage == 0
? 0.0
: (currentYearMileage / totalMileage).clamp(0, 1).toDouble();
return Card(
clipBehavior: Clip.antiAlias,
@@ -116,62 +133,248 @@ class _DashboardState extends State<Dashboard> {
spacing: 12,
runSpacing: 12,
children: [
_metricTile(
_animatedMetricTile(
context,
label: 'Total mileage',
value: '${totalMileage.toStringAsFixed(1)} mi',
value: totalMileage.toDouble(),
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
icon: Icons.route,
color: colorScheme.onPrimaryContainer,
),
_metricTile(
_animatedMetricTile(
context,
label: 'This year',
value: '${currentYearMileage.toStringAsFixed(1)} mi',
value: currentYearMileage.toDouble(),
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
icon: Icons.calendar_today,
color: colorScheme.onPrimaryContainer,
),
_metricTile(
_animatedMetricTile(
context,
label: 'Entries logged',
value: legCount.toString(),
value: legCount.toDouble(),
formatter: (val) => val.round().toString(),
icon: Icons.format_list_bulleted,
color: colorScheme.onPrimaryContainer,
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LinearProgressIndicator(
value: progress.isNaN ? 0 : progress,
minHeight: 10,
backgroundColor: colorScheme.onPrimaryContainer.withValues(
alpha: 0.2,
),
valueColor: AlwaysStoppedAnimation<Color>(
colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(height: 6),
Text(
totalMileage == 0
? 'Log a new entry to start your timeline.'
: 'Year-to-date is ${(progress * 100).toStringAsFixed(0)}% of all mileage.',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
),
_buildClassClearanceCarousel(context, data, colorScheme),
],
),
),
);
}
Widget _metricTile(
Widget _buildClassClearanceCarousel(
BuildContext context,
DataService data,
ColorScheme colorScheme,
) {
final items = data.classClearanceProgress;
final loading = data.isClassClearanceProgressLoading;
_refreshCarouselItems(items);
_startCarouselIfNeeded(_carouselItems.length);
if (loading && _carouselItems.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Center(
child: SizedBox(
height: 16,
width: 16,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (_carouselItems.isEmpty) {
return Text(
'No class clearance progress yet.',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Class clearance (in progress)',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer.withValues(alpha: 0.8),
),
),
const SizedBox(height: 6),
SizedBox(
height: 58,
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 450),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
final tween = Tween<Offset>(
begin: const Offset(0, 0.4),
end: Offset.zero,
);
return ClipRect(
child: SlideTransition(
position: animation.drive(tween),
child: FadeTransition(opacity: animation, child: child),
),
);
},
child: _buildClassClearanceSlide(
context,
_carouselItems[_carouselIndex % _carouselItems.length],
colorScheme,
key: ValueKey(_carouselIndex),
),
),
),
],
);
}
Widget _buildClassClearanceSlide(
BuildContext context,
ClassClearanceProgress progress,
ColorScheme colorScheme,
{Key? key}
) {
final pct = progress.percentComplete.clamp(0, 100);
final textTheme = Theme.of(context).textTheme;
final ratio = progress.total == 0
? 0.0
: (progress.completed / progress.total).clamp(0.0, 1.0);
final activeRatio = progress.total == 0
? 0.0
: (progress.activeTotal / progress.total).clamp(0.0, 1.0);
return TweenAnimationBuilder<double>(
key: key ?? ValueKey(progress.className),
tween: Tween(begin: 0, end: ratio),
duration: const Duration(milliseconds: 1200),
curve: Curves.easeOutCubic,
builder: (context, value, child) {
final ratioValue = ratio == 0 ? 0.0 : (value / ratio).clamp(0.0, 1.0);
final animatedHad = (progress.completed * ratioValue).round();
final animatedActive = (progress.activeTotal * ratioValue).round();
final animatedActiveRatio =
(activeRatio * ratioValue).clamp(0.0, activeRatio);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
progress.className,
style: textTheme.labelLarge?.copyWith(
color: colorScheme.onPrimaryContainer,
fontWeight: FontWeight.w700,
),
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
Text(
'${pct.toStringAsFixed(0)}% • $animatedHad/$animatedActive/${progress.total}',
style: textTheme.labelSmall?.copyWith(
color: colorScheme.onPrimaryContainer
.withValues(alpha: 0.8),
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: SizedBox(
height: 8,
child: Stack(
children: [
Container(
color: colorScheme.onPrimaryContainer.withValues(
alpha: 0.2,
),
),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: animatedActiveRatio.isNaN
? 0
: animatedActiveRatio,
child: Container(
color:
colorScheme.onPrimaryContainer.withValues(alpha: 0.5),
),
),
FractionallySizedBox(
alignment: Alignment.centerLeft,
widthFactor: value.isNaN ? 0 : value,
child: Container(
color: colorScheme.onPrimaryContainer,
),
),
],
),
),
),
],
);
},
);
}
void _startCarouselIfNeeded(int count) {
if (count <= 1) {
_stopCarousel();
return;
}
if (_carouselItemCount != count) {
_carouselItemCount = count;
_carouselIndex = 0;
}
if (_carouselTimer != null) return;
_carouselTimer = Timer.periodic(const Duration(seconds: 8), (_) {
if (!mounted || _carouselItemCount == 0) return;
setState(() {
_carouselIndex = (_carouselIndex + 1) % _carouselItemCount;
});
});
}
void _stopCarousel() {
_carouselTimer?.cancel();
_carouselTimer = null;
}
void _refreshCarouselItems(List<ClassClearanceProgress> items) {
final signature = items
.map((item) =>
'${item.className}:${item.completed}:${item.activeTotal}:${item.total}')
.join('|');
if (signature == _carouselSignature) return;
_carouselSignature = signature;
_carouselItems = List<ClassClearanceProgress>.from(items)
..shuffle(_carouselRandom);
}
@override
void dispose() {
_stopCarousel();
super.dispose();
}
Widget _animatedMetricTile(
BuildContext context, {
required String label,
required String value,
required double value,
required String Function(double) formatter,
required IconData icon,
required Color color,
}) {
@@ -198,8 +401,9 @@ class _DashboardState extends State<Dashboard> {
letterSpacing: 0.4,
),
),
Text(
value,
AnimatedCountText(
value: value,
formatter: formatter,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w800,
color: color,
@@ -215,6 +419,7 @@ class _DashboardState extends State<Dashboard> {
Widget _buildTiles(
BuildContext context,
DataService data,
DistanceUnitService distanceUnits,
double maxWidth,
double spacing,
) {
@@ -229,9 +434,9 @@ class _DashboardState extends State<Dashboard> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildOnThisDayCard(context, data),
_buildOnThisDayCard(context, data, distanceUnits),
const SizedBox(height: 16),
_buildTripsCard(context, data),
_buildTripsCard(context, data, distanceUnits),
const SizedBox(height: 16),
const LatestLocoChangesPanel(expanded: true),
],
@@ -256,13 +461,13 @@ class _DashboardState extends State<Dashboard> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildOnThisDayCard(context, data),
_buildOnThisDayCard(context, data, distanceUnits),
const SizedBox(height: 16),
const TopTractionPanel(),
const SizedBox(height: 16),
const LeaderboardPanel(),
const SizedBox(height: 16),
_buildTripsCard(context, data),
_buildTripsCard(context, data, distanceUnits),
const SizedBox(height: 16),
const LatestLocoChangesPanel(),
],
@@ -296,7 +501,8 @@ class _DashboardState extends State<Dashboard> {
);
}
Widget _buildOnThisDayCard(BuildContext context, DataService data) {
Widget _buildOnThisDayCard(
BuildContext context, DataService data, DistanceUnitService distanceUnits) {
final filtered = data.onThisDay
.where((leg) => leg.beginTime.year != DateTime.now().year)
.toList();
@@ -329,7 +535,7 @@ class _DashboardState extends State<Dashboard> {
: Column(
children: [
for (int idx = 0; idx < visible.length; idx++) ...[
_otdRow(context, visible[idx], textTheme),
_otdRow(context, visible[idx], textTheme, distanceUnits),
if (idx != visible.length - 1) const Divider(height: 12),
],
],
@@ -337,7 +543,8 @@ class _DashboardState extends State<Dashboard> {
);
}
Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme) {
Widget _otdRow(BuildContext context, Leg leg, TextTheme textTheme,
DistanceUnitService distanceUnits) {
final traction = leg.locos;
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
@@ -443,7 +650,7 @@ class _DashboardState extends State<Dashboard> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${leg.mileage.toStringAsFixed(1)} mi',
distanceUnits.format(leg.mileage, decimals: 1),
style: textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
@@ -498,11 +705,12 @@ class _DashboardState extends State<Dashboard> {
);
}
Widget _buildTripsCard(BuildContext context, DataService data) {
Widget _buildTripsCard(
BuildContext context, DataService data, DistanceUnitService distanceUnits) {
final tripsUnsorted = data.trips;
List trips = [];
List<TripSummary> trips = [];
if (tripsUnsorted.isNotEmpty) {
trips = [...tripsUnsorted]..sort((a, b) => b.tripId.compareTo(a.tripId));
trips = [...tripsUnsorted]..sort(TripSummary.compareByDateDesc);
}
return _panel(
context,
@@ -542,8 +750,10 @@ class _DashboardState extends State<Dashboard> {
style: Theme.of(context).textTheme.titleSmall
?.copyWith(fontWeight: FontWeight.w700),
),
Text(
'${trip.tripMileage.toStringAsFixed(1)} mi',
AnimatedCountText(
value: trip.tripMileage,
formatter: (val) =>
distanceUnits.format(val, decimals: 1),
style: Theme.of(context).textTheme.labelMedium,
),
],
@@ -560,4 +770,31 @@ class _DashboardState extends State<Dashboard> {
String _formatTime(DateTime date) {
return DateFormat('HH:mm').format(date);
}
Future<void> _refreshDashboardData({bool force = false}) async {
final data = context.read<DataService>();
await data.fetchHomepageStats();
await Future.wait([
data.fetchOnThisDay(),
data.fetchTripDetails(),
data.fetchHadTraction(),
data.fetchLatestLocoChanges(),
data.fetchClassClearanceProgress(limit: 75, onlyIncomplete: true),
]);
}
void _handleRouteFocus() {
final isCurrent = ModalRoute.of(context)?.isCurrent ?? true;
if (isCurrent && !_isCurrent) {
_isCurrent = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
_refreshDashboardData();
});
return;
}
if (!isCurrent && _isCurrent) {
_isCurrent = false;
}
}
}

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/legs/leg_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class LegsPage extends StatefulWidget {
@@ -16,6 +17,8 @@ class _LegsPageState extends State<LegsPage> {
DateTime? _startDate;
DateTime? _endDate;
bool _initialised = false;
bool _unallocatedOnly = false;
bool _showMoreFilters = false;
@override
void didChangeDependencies() {
@@ -32,6 +35,7 @@ class _LegsPageState extends State<LegsPage> {
sortDirection: _sortDirection,
dateRangeStart: _formatDate(_startDate),
dateRangeEnd: _formatDate(_endDate),
unallocatedOnly: _unallocatedOnly,
);
}
@@ -43,6 +47,7 @@ class _LegsPageState extends State<LegsPage> {
dateRangeEnd: _formatDate(_endDate),
offset: data.legs.length,
append: true,
unallocatedOnly: _unallocatedOnly,
);
}
@@ -83,6 +88,8 @@ class _LegsPageState extends State<LegsPage> {
_startDate = null;
_endDate = null;
_sortDirection = 0;
_unallocatedOnly = false;
_showMoreFilters = false;
});
_refreshLegs();
}
@@ -90,6 +97,7 @@ class _LegsPageState extends State<LegsPage> {
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final legs = data.legs;
final pageMileage = _pageMileage(legs);
@@ -121,7 +129,7 @@ class _LegsPageState extends State<LegsPage> {
children: [
Text('Page mileage',
style: Theme.of(context).textTheme.labelSmall),
Text('${pageMileage.toStringAsFixed(1)} mi',
Text(distanceUnits.format(pageMileage, decimals: 1),
style: Theme.of(context)
.textTheme
.titleMedium
@@ -175,8 +183,46 @@ class _LegsPageState extends State<LegsPage> {
: _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
Column(
children: [
..._buildLegsWithDividers(context, legs),
..._buildLegsWithDividers(context, legs, distanceUnits),
const SizedBox(height: 8),
if (data.legsHasMore || data.isLegsLoading)
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>[];
String? currentDate;
double dayMileage = 0;
@@ -261,10 +311,8 @@ class _LegsPageState extends State<LegsPage> {
),
),
),
Text(
'${dayMileage.toStringAsFixed(1)} mi',
style: Theme.of(context).textTheme.labelMedium,
),
Text(distanceUnits.format(dayMileage, decimals: 1),
style: Theme.of(context).textTheme.labelMedium),
],
),
),

View File

@@ -5,8 +5,10 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.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:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'loco_timeline/timeline_grid.dart';
part 'loco_timeline/event_editor.dart';
@@ -16,24 +18,41 @@ class LocoTimelinePage extends StatefulWidget {
super.key,
required this.locoId,
required this.locoLabel,
this.forceShowPending = false,
});
final int locoId;
final String locoLabel;
final bool forceShowPending;
@override
State<LocoTimelinePage> createState() => _LocoTimelinePageState();
}
class _LocoTimelinePageState extends State<LocoTimelinePage> {
static const String _prefsKeyShowPending = 'timeline_show_pending';
final List<_EventDraft> _draftEvents = [];
bool _isSaving = false;
bool _isDeleting = false;
final Set<int> _moderatingEventIds = {};
final Set<String> _expandedPendingAttrs = {};
bool _showPending = true;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (widget.forceShowPending) {
setState(() {
_showPending = true;
});
} else {
await _restorePendingVisibility();
}
if (!mounted) return;
await _load();
});
}
dynamic _normalizeFieldValue(_FieldEntry field) {
@@ -59,8 +78,37 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
Future<void> _load() {
final data = context.read<DataService>();
final auth = context.read<AuthService>();
data.fetchEventFields();
return data.fetchLocoTimeline(widget.locoId);
return data.fetchLocoTimeline(
widget.locoId,
includeAllPending: auth.isElevated && _showPending,
);
}
Future<void> _restorePendingVisibility() async {
final auth = context.read<AuthService>();
if (!auth.isElevated) return;
try {
final prefs = await SharedPreferences.getInstance();
final saved = prefs.getBool(_prefsKeyShowPending);
if (saved == null) return;
if (!mounted) return;
setState(() {
_showPending = saved;
});
} catch (_) {
// Ignore preference restore failures.
}
}
Future<void> _persistPendingVisibility(bool value) async {
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_prefsKeyShowPending, value);
} catch (_) {
// Ignore persistence failures.
}
}
void _addDraftEvent() {
@@ -151,8 +199,18 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
Future<void> _deleteEntry(LocoAttrVersion entry) async {
if (_isDeleting) return;
final isPending = entry.isPending;
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(
const SnackBar(content: Text('Cannot delete: timeline block has no ID.')),
);
@@ -193,13 +251,23 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
_isDeleting = true;
});
try {
if (isPending && pendingEventId != null) {
await data.deletePendingEvent(eventId: pendingEventId);
} else if (blockId != null) {
await data.deleteTimelineBlock(
blockId: blockId,
);
}
await _load();
if (mounted) {
messenger.showSnackBar(
const SnackBar(content: Text('Timeline block deleted')),
SnackBar(
content: Text(
isPending
? 'Pending timeline block deleted'
: 'Timeline block deleted',
),
),
);
}
} catch (e) {
@@ -217,6 +285,79 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
}
}
Future<void> _moderatePendingEntry(
LocoAttrVersion entry,
_PendingModerationAction action,
) async {
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;
}
if (_moderatingEventIds.contains(eventId)) 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(() {
_moderatingEventIds.add(eventId);
});
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(() {
_moderatingEventIds.remove(eventId);
});
}
}
}
void _removeDraftAt(int index) {
if (index < 0 || index >= _draftEvents.length) return;
final draft = _draftEvents.removeAt(index);
@@ -248,6 +389,9 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
_isSaving = true;
});
try {
final existingPending =
await data.fetchUserPendingEvents(widget.locoId);
final clearedEventIds = <int>{};
final invalid = <String>[];
for (final draft in _draftEvents) {
final dateStr = draft.dateController.text.trim();
@@ -274,6 +418,13 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
invalid.add('Add at least one value');
continue;
}
await _clearDuplicatePending(
existingPending,
clearedEventIds,
values.keys,
dateStr,
data,
);
await data.createLocoEvent(
locoId: widget.locoId,
eventDate: dateStr,
@@ -312,6 +463,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) {
final trimmed = input.trim();
final regex = RegExp(r'^\d{4}-(\d{2}|xx|XX)-(\d{2}|xx|XX)$');
@@ -354,7 +541,11 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final timeline = data.timelineForLoco(widget.locoId);
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
final isLoading = data.isLocoTimelineLoading(widget.locoId);
final visibleTimeline = (!isElevated || _showPending)
? timeline
: timeline.where((entry) => !entry.isPending).toList();
return Scaffold(
appBar: AppBar(
@@ -371,7 +562,32 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
if (isLoading && timeline.isEmpty) {
return const Center(child: CircularProgressIndicator());
}
if (timeline.isEmpty) {
if (visibleTimeline.isEmpty) {
if (timeline.isNotEmpty && isElevated && !_showPending) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Pending entries hidden',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
const Text(
'Enable "Show pending entries" to view pending timeline blocks.',
),
],
),
),
),
);
}
return Padding(
padding: const EdgeInsets.all(16.0),
child: Card(
@@ -405,13 +621,49 @@ class _LocoTimelinePageState extends State<LocoTimelinePage> {
return ListView(
padding: const EdgeInsets.all(16),
children: [
if (isElevated)
Row(
children: [
Expanded(
child: SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: const Text('Show pending entries'),
value: _showPending,
onChanged: (value) async {
setState(() {
_showPending = value;
});
await _persistPendingVisibility(value);
if (mounted) {
await _load();
}
},
),
),
IconButton(
tooltip: 'Refresh timeline',
onPressed: _load,
icon: const Icon(Icons.refresh),
),
],
),
_TimelineGrid(
entries: timeline,
entries: visibleTimeline,
onEditEntry: (entry) => _prefillDraftFromEntry(
entry,
data.eventFields,
),
onDeleteEntry: _deleteEntry,
onModeratePending: _moderatePendingEntry,
pendingActionEventIds: _moderatingEventIds,
expandedPendingAttrs: _expandedPendingAttrs,
onTogglePendingAttr: (attrCode) {
setState(() {
if (!_expandedPendingAttrs.add(attrCode)) {
_expandedPendingAttrs.remove(attrCode);
}
});
},
),
const SizedBox(height: 16),
_EventEditor(

View File

@@ -23,6 +23,7 @@ class _EventEditor extends StatelessWidget {
@override
Widget build(BuildContext context) {
final hasDrafts = drafts.isNotEmpty;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -37,12 +38,14 @@ class _EventEditor extends StatelessWidget {
),
Row(
children: [
if (!hasDrafts) ...[
OutlinedButton.icon(
onPressed: onAddEvent,
icon: const Icon(Icons.add),
label: const Text('New event'),
),
const SizedBox(width: 8),
],
FilledButton.icon(
onPressed: (!canSave || isSaving) ? null : onSave,
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'),
),
],
],
);
}

View File

@@ -2,16 +2,29 @@ part of 'package:mileograph_flutter/components/pages/loco_timeline.dart';
final DateFormat _dateFormat = DateFormat('yyyy-MM-dd');
enum _PendingModerationAction { approve, reject }
class _TimelineGrid extends StatefulWidget {
const _TimelineGrid({
required this.entries,
this.onEditEntry,
this.onDeleteEntry,
this.onModeratePending,
this.pendingActionEventIds = const {},
this.expandedPendingAttrs = const {},
this.onTogglePendingAttr,
});
final List<LocoAttrVersion> entries;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
final Future<void> Function(
LocoAttrVersion entry,
_PendingModerationAction action,
)? onModeratePending;
final Set<int> pendingActionEventIds;
final Set<String> expandedPendingAttrs;
final void Function(String attrCode)? onTogglePendingAttr;
@override
State<_TimelineGrid> createState() => _TimelineGridState();
@@ -77,12 +90,15 @@ class _TimelineGridState extends State<_TimelineGrid> {
'build_day',
}.contains(code);
}).toList();
final model = _TimelineModel.fromEntries(filteredEntries);
final model = _TimelineModel.fromEntries(
filteredEntries,
expandedAttrCodes: widget.expandedPendingAttrs,
);
final axisSegments = model.axisSegments;
const labelWidth = 110.0;
const rowHeight = 52.0;
const double axisHeight = 48;
final rows = model.attrRows.entries.toList();
final rows = model.rows;
final totalRowsHeight = rows.length * rowHeight;
final axisWidth = math.max(model.axisTotalWidth, 120.0);
final double viewHeight = totalRowsHeight + axisHeight + 8;
@@ -122,7 +138,12 @@ class _TimelineGridState extends State<_TimelineGrid> {
itemExtent: rowHeight,
itemCount: rows.length,
itemBuilder: (_, index) {
final label = _formatAttrLabel(rows[index].key);
final row = rows[index];
final label = row.isPrimary
? _formatAttrLabel(row.attrCode)
: (row.pendingUser?.trim().isNotEmpty == true
? row.pendingUser!.trim()
: 'Unknown');
return Container(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(
@@ -139,12 +160,49 @@ class _TimelineGridState extends State<_TimelineGrid> {
),
),
),
child: Row(
children: [
if (!row.isPrimary) ...[
Icon(
Icons.subdirectory_arrow_right,
size: 16,
color: Theme.of(context).hintColor,
),
const SizedBox(width: 6),
],
Expanded(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(fontWeight: FontWeight.w700),
?.copyWith(
fontWeight: row.isPrimary
? FontWeight.w700
: FontWeight.w600,
),
),
),
if (row.showExpandToggle)
IconButton(
onPressed: widget.onTogglePendingAttr == null
? null
: () => widget.onTogglePendingAttr?.call(
row.attrCode,
),
icon: Icon(
row.isExpanded
? Icons.expand_less
: Icons.expand_more,
),
tooltip: row.isExpanded
? 'Collapse pending rows'
: 'Expand pending rows',
visualDensity: VisualDensity.compact,
),
],
),
);
},
@@ -179,7 +237,7 @@ class _TimelineGridState extends State<_TimelineGrid> {
itemExtent: rowHeight,
itemCount: rows.length,
itemBuilder: (_, index) {
final blocks = rows[index].value;
final blocks = rows[index].blocks;
return Padding(
padding:
const EdgeInsets.symmetric(vertical: 2.0),
@@ -191,6 +249,8 @@ class _TimelineGridState extends State<_TimelineGrid> {
viewportWidth: axisWidth,
onEditEntry: widget.onEditEntry,
onDeleteEntry: widget.onDeleteEntry,
onModeratePending: widget.onModeratePending,
pendingActionEventIds: widget.pendingActionEventIds,
),
);
},
@@ -276,6 +336,8 @@ class _AttrRow extends StatelessWidget {
required this.viewportWidth,
this.onEditEntry,
this.onDeleteEntry,
this.onModeratePending,
this.pendingActionEventIds = const {},
});
final double rowHeight;
@@ -285,6 +347,11 @@ class _AttrRow extends StatelessWidget {
final double viewportWidth;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
final Future<void> Function(
LocoAttrVersion entry,
_PendingModerationAction action,
)? onModeratePending;
final Set<int> pendingActionEventIds;
@override
Widget build(BuildContext context) {
@@ -310,6 +377,8 @@ class _AttrRow extends StatelessWidget {
block: block,
onEditEntry: onEditEntry,
onDeleteEntry: onDeleteEntry,
onModeratePending: onModeratePending,
pendingActionEventIds: pendingActionEventIds,
),
),
if (activeBlock != null)
@@ -326,6 +395,7 @@ class _AttrRow extends StatelessWidget {
width: stickyWidth,
),
clipLeftEdge: scrollOffset > activeBlock.left + 0.1,
pendingActionEventIds: pendingActionEventIds,
),
),
),
@@ -347,20 +417,27 @@ class _ValueBlockView extends StatelessWidget {
const _ValueBlockView({
required this.block,
this.clipLeftEdge = false,
this.pendingActionEventIds = const {},
});
final _ValueBlock block;
final bool clipLeftEdge;
final Set<int> pendingActionEventIds;
@override
Widget build(BuildContext 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) ==
Brightness.dark
? Colors.white
: Colors.black87;
final entry = block.entry;
final eventId = entry?.sourceEventId;
final isPendingAction =
entry?.isPending == true && eventId != null && pendingActionEventIds.contains(eventId);
final radius = BorderRadius.only(
topLeft: Radius.circular(clipLeftEdge ? 0 : 12),
bottomLeft: Radius.circular(clipLeftEdge ? 0 : 12),
@@ -386,10 +463,35 @@ class _ValueBlockView extends StatelessWidget {
fit: BoxFit.scaleDown,
child: ConstrainedBox(
constraints: const BoxConstraints(minWidth: 1, minHeight: 1),
child: Column(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
if (block.cell.isPending)
Padding(
padding: const EdgeInsets.only(right: 6),
child: SizedBox(
width: 16,
height: 16,
child: isPendingAction
? CircularProgressIndicator(
strokeWidth: 2,
valueColor:
AlwaysStoppedAnimation(textColor),
)
: Icon(
Icons.pending,
size: 16,
color: textColor,
),
),
),
Text(
block.cell.value,
maxLines: 1,
@@ -403,6 +505,8 @@ class _ValueBlockView extends StatelessWidget {
color: textColor,
),
),
],
),
const SizedBox(height: 4),
Text(
block.cell.rangeLabel,
@@ -411,7 +515,11 @@ class _ValueBlockView extends StatelessWidget {
style: theme.textTheme.labelSmall?.copyWith(
color: textColor.withValues(alpha: 0.9),
) ??
TextStyle(color: textColor.withValues(alpha: 0.9)),
TextStyle(
color: textColor.withValues(alpha: 0.9),
),
),
],
),
],
),
@@ -422,26 +530,48 @@ class _ValueBlockView extends StatelessWidget {
}
}
enum _TimelineBlockAction { edit, delete }
enum _TimelineBlockAction { edit, delete, approve, reject }
class _ValueBlockMenu extends StatelessWidget {
const _ValueBlockMenu({
required this.block,
this.onEditEntry,
this.onDeleteEntry,
this.onModeratePending,
this.pendingActionEventIds = const {},
});
final _ValueBlock block;
final void Function(LocoAttrVersion entry)? onEditEntry;
final void Function(LocoAttrVersion entry)? onDeleteEntry;
final Future<void> Function(
LocoAttrVersion entry,
_PendingModerationAction action,
)? onModeratePending;
final Set<int> pendingActionEventIds;
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
Widget build(BuildContext context) {
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;
final eventId = block.entry?.sourceEventId;
final isPendingAction =
eventId != null && pendingActionEventIds.contains(eventId);
Future<void> showContextMenuAt(Offset globalPosition) async {
final overlay = Overlay.of(context);
@@ -459,11 +589,23 @@ class _ValueBlockMenu extends StatelessWidget {
context: context,
position: position,
items: [
if (onEditEntry != null)
if (canEdit)
const PopupMenuItem(
value: _TimelineBlockAction.edit,
child: Text('Edit'),
),
if (canModerate)
PopupMenuItem(
value: _TimelineBlockAction.approve,
enabled: !isPendingAction,
child: const Text('Approve pending'),
),
if (canModerate)
PopupMenuItem(
value: _TimelineBlockAction.reject,
enabled: !isPendingAction,
child: const Text('Reject pending'),
),
if (onDeleteEntry != null)
const PopupMenuItem(
value: _TimelineBlockAction.delete,
@@ -481,11 +623,17 @@ class _ValueBlockMenu extends StatelessWidget {
case _TimelineBlockAction.delete:
onDeleteEntry?.call(entry);
break;
case _TimelineBlockAction.approve:
onModeratePending?.call(entry, _PendingModerationAction.approve);
break;
case _TimelineBlockAction.reject:
onModeratePending?.call(entry, _PendingModerationAction.reject);
break;
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
behavior: HitTestBehavior.deferToChild,
onLongPressStart: (details) async {
if (defaultTargetPlatform == TargetPlatform.android) {
HapticFeedback.lightImpact();
@@ -495,7 +643,10 @@ class _ValueBlockMenu extends StatelessWidget {
onSecondaryTapDown: (details) async {
await showContextMenuAt(details.globalPosition);
},
child: _ValueBlockView(block: block),
child: _ValueBlockView(
block: block,
pendingActionEventIds: pendingActionEventIds,
),
);
}
}
@@ -518,7 +669,28 @@ String _formatAttrLabel(String code) {
DateTime? _parseDateString(String? value) {
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) {
@@ -534,44 +706,43 @@ DateTime _safeEnd(DateTime start, DateTime? end) {
return end;
}
class _TimelineModel {
final List<_AxisSegment> axisSegments;
final Map<String, List<_ValueBlock>> attrRows;
final String endLabel;
final List<DateTime> boundaries;
final double axisTotalWidth;
int _startKey(DateTime date) => date.year * 10000 + date.month * 100 + date.day;
_TimelineModel({
required this.axisSegments,
required this.attrRows,
required this.endLabel,
required this.boundaries,
required this.axisTotalWidth,
});
factory _TimelineModel.fromEntries(List<LocoAttrVersion> entries) {
final grouped = <String, List<LocoAttrVersion>>{};
for (final entry in entries) {
grouped.putIfAbsent(entry.attrCode, () => []).add(entry);
bool _isOverlappingStart(LocoAttrVersion entry, Set<int> approvedStartKeys) {
final start = _effectiveStart(entry);
if (start == null) return false;
return approvedStartKeys.contains(_startKey(start));
}
final now = DateTime.now();
DateTime? minStart;
DateTime? maxEnd;
final attrSegments = <String, List<_ValueSegment>>{};
grouped.forEach((attr, items) {
items.sort(
List<_ValueSegment> _segmentsForEntries(
List<LocoAttrVersion> items,
DateTime now, {
bool? clampToNextStart,
}) {
if (items.isEmpty) return const [];
final hasPending = items.any((e) => e.isPending);
final hasApproved = items.any((e) => !e.isPending);
final shouldClamp =
clampToNextStart ?? (hasPending && hasApproved);
final sorted = [...items];
sorted.sort(
(a, b) => (_effectiveStart(a) ?? now)
.compareTo(_effectiveStart(b) ?? now),
);
final segments = <_ValueSegment>[];
for (int i = 0; i < items.length; i++) {
final entry = items[i];
for (int i = 0; i < sorted.length; i++) {
final entry = sorted[i];
final start = _effectiveStart(entry) ?? now;
final nextStart = i < items.length - 1
? _effectiveStart(items[i + 1])
final nextStart = i < sorted.length - 1
? _effectiveStart(sorted[i + 1])
: null;
final rawEnd = entry.validTo ?? nextStart ?? now;
DateTime? rawEnd = entry.validTo;
if (nextStart != null) {
if (rawEnd == null || (shouldClamp && nextStart.isBefore(rawEnd))) {
rawEnd = nextStart;
}
}
rawEnd ??= now;
final end = _safeEnd(start, rawEnd);
segments.add(
_ValueSegment(
@@ -581,29 +752,264 @@ class _TimelineModel {
entry: entry,
),
);
minStart = minStart == null || start.isBefore(minStart!)
? start
: minStart;
maxEnd = maxEnd == null || end.isAfter(maxEnd!) ? end : maxEnd;
}
attrSegments[attr] = segments;
});
return segments;
}
minStart ??= now.subtract(const Duration(days: 1));
final effectiveMaxEnd = maxEnd ?? now;
List<LocoAttrVersion> _applyPendingOverrides(
List<LocoAttrVersion> approved,
List<LocoAttrVersion> pending,
) {
if (pending.isEmpty) return approved;
final pendingByStart = <int, LocoAttrVersion>{};
final extraPending = <LocoAttrVersion>[];
for (final entry in pending) {
final start = _effectiveStart(entry);
if (start == null) continue;
final key = _startKey(start);
pendingByStart[key] = entry;
}
final applied = <LocoAttrVersion>[];
final seenKeys = <int>{};
for (final entry in approved) {
final start = _effectiveStart(entry);
if (start == null) continue;
final key = _startKey(start);
if (pendingByStart.containsKey(key)) {
if (!seenKeys.contains(key)) {
applied.add(pendingByStart[key]!);
seenKeys.add(key);
}
} else {
applied.add(entry);
seenKeys.add(key);
}
}
for (final entry in pendingByStart.values) {
final start = _effectiveStart(entry);
if (start == null) continue;
final key = _startKey(start);
if (!seenKeys.contains(key)) {
extraPending.add(entry);
seenKeys.add(key);
}
}
if (extraPending.isNotEmpty) {
applied.addAll(extraPending);
}
return applied;
}
List<DateTime> _buildBoundaries(
List<_ValueSegment> segments,
DateTime now,
) {
DateTime? minStart;
DateTime? maxEnd;
final boundaryDates = <DateTime>{};
for (final segments in attrSegments.values) {
for (final seg in segments) {
boundaryDates.add(seg.start);
boundaryDates.add(seg.end);
minStart = minStart == null || seg.start.isBefore(minStart!)
? seg.start
: minStart;
maxEnd = maxEnd == null || seg.end.isAfter(maxEnd!) ? seg.end : maxEnd;
}
}
minStart ??= now.subtract(const Duration(days: 1));
final effectiveMaxEnd = maxEnd ?? now;
boundaryDates.add(effectiveMaxEnd);
var boundaries = boundaryDates.toList()..sort();
if (boundaries.length < 2) {
boundaries = [minStart!, effectiveMaxEnd];
}
return boundaries;
}
class _TimelineRowSpec {
final String id;
final String attrCode;
final List<_ValueSegment> segments;
final bool isPrimary;
final bool showExpandToggle;
final bool isExpanded;
final String? userLabel;
const _TimelineRowSpec._({
required this.id,
required this.attrCode,
required this.segments,
required this.isPrimary,
required this.showExpandToggle,
required this.isExpanded,
this.userLabel,
});
factory _TimelineRowSpec.primary({
required String attrCode,
required List<_ValueSegment> segments,
required bool showExpandToggle,
required bool isExpanded,
}) {
return _TimelineRowSpec._(
id: attrCode,
attrCode: attrCode,
segments: segments,
isPrimary: true,
showExpandToggle: showExpandToggle,
isExpanded: isExpanded,
);
}
factory _TimelineRowSpec.pending({
required String attrCode,
required String userLabel,
required List<_ValueSegment> segments,
}) {
return _TimelineRowSpec._(
id: '$attrCode::$userLabel',
attrCode: attrCode,
segments: segments,
isPrimary: false,
showExpandToggle: false,
isExpanded: false,
userLabel: userLabel,
);
}
}
class _TimelineRowData {
final String id;
final String attrCode;
final List<_ValueBlock> blocks;
final bool isPrimary;
final bool showExpandToggle;
final bool isExpanded;
final String? pendingUser;
const _TimelineRowData({
required this.id,
required this.attrCode,
required this.blocks,
required this.isPrimary,
required this.showExpandToggle,
required this.isExpanded,
this.pendingUser,
});
}
class _TimelineModel {
final List<_AxisSegment> axisSegments;
final List<_TimelineRowData> rows;
final String endLabel;
final List<DateTime> boundaries;
final double axisTotalWidth;
_TimelineModel({
required this.axisSegments,
required this.rows,
required this.endLabel,
required this.boundaries,
required this.axisTotalWidth,
});
factory _TimelineModel.fromEntries(
List<LocoAttrVersion> entries, {
Set<String> expandedAttrCodes = const {},
}) {
final effectiveEntries = entries
.where((e) => _effectiveStart(e) != null)
.toList();
final grouped = <String, List<LocoAttrVersion>>{};
final attrOrder = <String>[];
for (final entry in effectiveEntries) {
final key = entry.attrCode;
if (!grouped.containsKey(key)) {
attrOrder.add(key);
}
grouped.putIfAbsent(key, () => []).add(entry);
}
final now = DateTime.now();
final allSegments = <_ValueSegment>[];
final rowSpecs = <_TimelineRowSpec>[];
for (final attr in attrOrder) {
final items = grouped[attr] ?? const [];
final approved = items.where((e) => !e.isPending).toList();
final pending = items.where((e) => e.isPending).toList();
final approvedSegments = _segmentsForEntries(approved, now);
final approvedStartKeys = <int>{};
for (final entry in approved) {
final start = _effectiveStart(entry);
if (start == null) continue;
approvedStartKeys.add(_startKey(start));
}
final pendingByUser = <String, List<LocoAttrVersion>>{};
final overlapByUser = <String, List<LocoAttrVersion>>{};
for (final entry in pending) {
final user = (entry.suggestedBy ?? '').trim().isEmpty
? 'Unknown'
: entry.suggestedBy!.trim();
pendingByUser.putIfAbsent(user, () => []).add(entry);
final start = _effectiveStart(entry);
if (start == null) continue;
if (approvedStartKeys.contains(_startKey(start))) {
overlapByUser.putIfAbsent(user, () => []).add(entry);
}
}
final hasOverlap = overlapByUser.isNotEmpty;
final canToggle = pending.isNotEmpty && !hasOverlap;
final isExpanded = expandedAttrCodes.contains(attr);
final shouldShowPendingRows = isExpanded || hasOverlap;
final nonOverlapPending =
pending.where((e) => !_isOverlappingStart(e, approvedStartKeys)).toList();
final baseEntries =
shouldShowPendingRows ? approved : [...approved, ...nonOverlapPending];
final baseSegments = shouldShowPendingRows
? approvedSegments
: _segmentsForEntries(baseEntries, now);
rowSpecs.add(
_TimelineRowSpec.primary(
attrCode: attr,
segments: baseSegments,
showExpandToggle: canToggle,
isExpanded: isExpanded,
),
);
allSegments.addAll(baseSegments);
if (shouldShowPendingRows) {
final users = isExpanded
? pendingByUser.keys.toList()
: overlapByUser.keys.toList();
users.sort();
for (final user in users) {
final pendingEntries = isExpanded
? (pendingByUser[user] ?? const [])
: (overlapByUser[user] ?? const []);
if (pendingEntries.isEmpty) continue;
final appliedEntries =
_applyPendingOverrides(approved, pendingEntries);
final combinedSegments = _segmentsForEntries(appliedEntries, now);
rowSpecs.add(
_TimelineRowSpec.pending(
attrCode: attr,
userLabel: user,
segments: combinedSegments,
),
);
allSegments.addAll(combinedSegments);
}
}
}
final boundaries = _buildBoundaries(allSegments, now);
final axisSegments = <_AxisSegment>[];
const double yearWidth = 240.0;
@@ -627,10 +1033,10 @@ class _TimelineModel {
final axisTotalWidth =
axisSegments.fold<double>(0, (sum, seg) => sum + seg.width);
final attrRows = <String, List<_ValueBlock>>{};
for (final entry in attrSegments.entries) {
final rows = <_TimelineRowData>[];
for (final spec in rowSpecs) {
final blocks = <_ValueBlock>[];
for (final seg in entry.value) {
for (final seg in spec.segments) {
final left = _positionForDate(seg.start, boundaries, axisSegments);
final right = _positionForDate(seg.end, boundaries, axisSegments);
final span = right - left;
@@ -644,13 +1050,24 @@ class _TimelineModel {
),
);
}
attrRows[entry.key] = blocks;
rows.add(
_TimelineRowData(
id: spec.id,
attrCode: spec.attrCode,
blocks: blocks,
isPrimary: spec.isPrimary,
showExpandToggle: spec.showExpandToggle,
isExpanded: spec.isExpanded,
pendingUser: spec.userLabel,
),
);
}
final endLabel = _formatDate(effectiveMaxEnd) ?? 'Now';
final endLabel =
boundaries.isNotEmpty ? _formatDate(boundaries.last) ?? 'Now' : 'Now';
return _TimelineModel(
axisSegments: axisSegments,
attrRows: attrRows,
rows: rows,
endLabel: endLabel,
boundaries: boundaries,
axisTotalWidth: axisTotalWidth,
@@ -774,11 +1191,13 @@ class _RowCell {
final String value;
final String rangeLabel;
final Color color;
final bool isPending;
const _RowCell({
required this.value,
required this.rangeLabel,
required this.color,
this.isPending = false,
});
factory _RowCell.fromSegment(_ValueSegment seg) {
@@ -787,6 +1206,7 @@ class _RowCell {
value: '',
rangeLabel: '',
color: Colors.transparent,
isPending: false,
);
}
final entry = seg.entry;
@@ -802,6 +1222,7 @@ class _RowCell {
value: seg.value,
rangeLabel: displayStart,
color: _colorForValue(seg.value),
isPending: entry?.isPending ?? false,
);
}
}

View File

@@ -1,81 +1,13 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/pages/profile.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/stats.dart';
import 'package:mileograph_flutter/components/pages/more/more_home_page.dart';
export 'more/admin_page.dart';
class MorePage extends StatelessWidget {
const MorePage({super.key});
@override
Widget build(BuildContext context) {
return Navigator(
onGenerateRoute: (settings) {
final name = settings.name ?? '/';
Widget page;
switch (name) {
case '/settings':
page = const SettingsPage();
break;
case '/profile':
page = const ProfilePage();
break;
case '/stats':
page = const StatsPage();
break;
case '/more/settings':
page = const SettingsPage();
break;
case '/more/profile':
page = const ProfilePage();
break;
case '/more/stats':
page = const StatsPage();
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.bar_chart),
title: const Text('Stats'),
onTap: () => Navigator.of(context).pushNamed('/more/stats'),
),
const Divider(height: 1),
ListTile(
leading: const Icon(Icons.settings),
title: const Text('Settings'),
onTap: () => Navigator.of(context).pushNamed('/more/settings'),
),
],
),
),
],
);
return const MoreHomePage();
}
}

View File

@@ -0,0 +1,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/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:mileograph_flutter/services/navigation_guard.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

View File

@@ -4,14 +4,24 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
Future<bool> _handleExitIntent() async {
if (!mounted) return false;
if (_isEditing) return true;
if (_activeLegShare != null) return true;
if (_formIsEmpty()) return true;
if (_activeDraftId != null && !_draftChangedFromBaseline()) {
return true;
}
final currentSnapshot = _currentSubmissionSnapshot();
if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, currentSnapshot)) {
return true;
}
final choice = await _promptSaveDraft();
if (choice == _ExitChoice.cancel) return false;
if (choice == _ExitChoice.save) {
try {
await _saveDraftEntry(draftId: _activeDraftId);
} catch (_) {
return true;
}
} else if (choice == _ExitChoice.discard) {
// Delay reset to avoid setState during the dialog/build phase.
await Future<void>.delayed(Duration.zero);
@@ -53,6 +63,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
Future<_ExitChoice> _promptSaveDraft() async {
if (!mounted) return _ExitChoice.cancel;
try {
final result = await showDialog<_ExitChoice>(
context: context,
barrierDismissible: false,
@@ -78,10 +89,15 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
],
),
);
if (!mounted) return _ExitChoice.cancel;
return result ?? _ExitChoice.cancel;
} catch (_) {
return _ExitChoice.cancel;
}
}
Future<void> _openDrafts() async {
if (_activeLegShare != null) return;
final selected = await Navigator.of(context).push<_StoredDraft>(
MaterialPageRoute(
builder: (_) => _DraftListPage(
@@ -97,6 +113,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
}
Future<void> _saveDraftManually() async {
if (_activeLegShare != null) return;
if (_savingDraft) return;
if (_formIsEmpty()) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
@@ -123,7 +140,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
}
Future<void> _saveDraft() async {
if (_restoringDraft || !_draftPersistenceEnabled) return;
if (_restoringDraft || !_draftPersistenceEnabled || _activeLegShare != null) {
return;
}
final prefs = await SharedPreferences.getInstance();
final draft = {
"date": _selectedDate.toIso8601String(),
@@ -169,6 +188,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
"distance": _routeResult!.distance,
},
"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));
}
@@ -235,6 +262,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
required String id,
bool includeTimestamp = true,
}) {
final units = _distanceUnits(context);
final routeStations = _routeResult?.calculatedRoute ?? [];
final endTime = _legEndDateTime;
final originTime = _originDateTime;
@@ -249,7 +277,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : '');
final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0
? (units.milesFromInput(_mileageController.text.trim()) ?? 0)
: (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload();
final commonPayload = {
@@ -296,6 +324,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
"distance": _routeResult!.distance,
},
"tractionItems": _serializeTractionItems(),
"shareUserIds": _shareUserIds.toList(),
"shareUsers": _shareUsers
.map((u) => {
"user_id": u.userId,
"username": u.username,
"full_name": u.fullName,
})
.toList(),
};
}
@@ -338,6 +374,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
final destination = payload['leg_destination'] as String? ?? '';
final tripRaw = payload['leg_trip'];
final tripId = tripRaw is num ? tripRaw.toInt() : null;
final units = _distanceUnits(context);
List<String> routeStations = [];
RouteResult? restoredRouteResult;
@@ -416,14 +453,20 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
final miles = (payload['leg_distance'] as num?)?.toDouble();
_mileageController.text = miles == null || miles == 0
? ''
: miles.toStringAsFixed(2);
: units.format(
miles,
decimals: 2,
includeUnit: false,
);
} else {
_startController.text =
routeStations.isNotEmpty ? routeStations.first : '';
_endController.text =
routeStations.isNotEmpty ? routeStations.last : '';
final dist = _routeResult?.distance ?? 0;
_mileageController.text = dist == 0 ? '' : dist.toStringAsFixed(2);
_mileageController.text = dist == 0
? ''
: units.format(dist, decimals: 2, includeUnit: false);
}
final tractionRaw = data['tractionItems'];
@@ -436,6 +479,19 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
..clear()
..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;
final idRaw = data['id'];
if (idRaw != null) {

View File

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

View File

@@ -1,9 +1,10 @@
part of 'new_entry.dart';
class NewEntryPage extends StatefulWidget {
const NewEntryPage({super.key, this.editLegId});
const NewEntryPage({super.key, this.editLegId, this.legShare});
final int? editLegId;
final LegShareData? legShare;
@override
State<NewEntryPage> createState() => _NewEntryPageState();
@@ -51,9 +52,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
final DeepCollectionEquality _snapshotEquality =
const DeepCollectionEquality();
String? _activeDraftId;
DistanceUnit? _lastDistanceUnit;
Set<String> _shareUserIds = {};
List<UserSummary> _shareUsers = [];
LegShareData? _activeLegShare;
String? _sharedFromUser;
int? _shareNotificationId;
bool _routeReversedFlag = false;
bool get _isEditing => widget.editLegId != null;
bool get _draftPersistenceEnabled =>
_activeLegShare == null &&
false; // legacy single draft disabled in favor of draft list
@override
@@ -68,16 +77,28 @@ class _NewEntryPageState extends State<NewEntryPage> {
data.fetchClassList();
data.fetchTripOptions();
_loadStations();
if (_draftPersistenceEnabled) {
if (_draftPersistenceEnabled && widget.legShare == null) {
_loadDraft();
}
_loadStations();
if (_isEditing && widget.editLegId != null) {
_loadLegForEdit(widget.editLegId!);
} else if (widget.legShare != null) {
_applyLegShare(widget.legShare!);
}
});
}
@override
void didUpdateWidget(covariant NewEntryPage oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.legShare != null &&
(oldWidget.legShare == null ||
widget.legShare!.id != oldWidget.legShare!.id)) {
_applyLegShare(widget.legShare!);
}
}
@override
void dispose() {
NavigationGuard.unregister(_exitGuard);
@@ -100,9 +121,371 @@ class _NewEntryPageState extends State<NewEntryPage> {
setState(fn);
}
DistanceUnitService _distanceUnits(BuildContext context) =>
context.read<DistanceUnitService>();
String _formatDistance(
DistanceUnitService units,
double miles, {
int decimals = 1,
bool includeUnit = true,
}) {
return units.format(miles, decimals: decimals, includeUnit: includeUnit);
}
String _manualMileageLabel(DistanceUnit unit) {
switch (unit) {
case DistanceUnit.milesDecimal:
return 'Mileage (mi)';
case DistanceUnit.kilometers:
return 'Mileage (km)';
case DistanceUnit.milesChains:
return 'Mileage (m.ch)';
}
}
void _reverseRouteAndEndpoints() {
setState(() {
// Swap start/end and origin/destination fields
final startText = _startController.text;
final endText = _endController.text;
_startController.text = endText;
_endController.text = startText;
final originText = _originController.text;
final destText = _destinationController.text;
_originController.text = destText;
_destinationController.text = originText;
// Reverse route result if present
if (_routeResult != null) {
final reversedInput = _routeResult!.inputRoute.reversed.toList();
final reversedCalc = _routeResult!.calculatedRoute.reversed.toList();
final reversedCosts = _routeResult!.costs.reversed.toList();
_routeResult = RouteResult(
inputRoute: reversedInput,
calculatedRoute: reversedCalc,
costs: reversedCosts,
distance: _routeResult!.distance,
);
final units = _distanceUnits(context);
_mileageController.text = _formatDistance(
units,
_routeResult!.distance,
decimals: 2,
includeUnit: false,
);
_useManualMileage = false;
} else if (_useManualMileage &&
_mileageController.text.trim().isNotEmpty) {
// keep manual mileage, just swap endpoints
}
_routeReversedFlag = !_routeReversedFlag;
});
_saveDraft();
_scheduleMatchUpdate();
}
double _milesFromInputWithUnit(DistanceUnit unit) {
return DistanceFormatter(
unit,
).parseInputMiles(_mileageController.text.trim()) ??
0;
}
List<UserSummary> _friendsFromFriendships(DataService data, String? selfId) {
final friends = <UserSummary>[];
for (final f in data.friendships) {
final other = _friendFromFriendship(f, selfId);
if (other != null &&
other.userId.isNotEmpty &&
!friends.any((u) => u.userId == other.userId)) {
friends.add(other);
}
}
return friends;
}
UserSummary? _friendFromFriendship(Friendship friendship, String? selfId) {
final self = selfId ?? '';
if (friendship.requesterId == self) return friendship.addressee;
if (friendship.addresseeId == self) return friendship.requester;
if (friendship.addressee != null) return friendship.addressee;
if (friendship.requester != null) return friendship.requester;
return null;
}
Widget _buildShareSection(BuildContext context) {
final selected = _shareUsers;
final label = selected.isEmpty
? 'Share with friends'
: 'Shared with ${selected.length} friend${selected.length == 1 ? '' : 's'}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
OutlinedButton.icon(
onPressed: _submitting ? null : _openShareSheet,
icon: const Icon(Icons.share),
label: Text(label),
),
if (selected.isNotEmpty) ...[
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: selected
.map(
(u) => InputChip(
label: Text(u.displayName),
avatar: const Icon(Icons.person, size: 16),
onDeleted: _submitting
? null
: () {
setState(() {
_shareUserIds.remove(u.userId);
_shareUsers.removeWhere(
(item) => item.userId == u.userId,
);
});
},
),
)
.toList(),
),
],
],
);
}
Widget _buildSharedBanner() {
final from = _sharedFromUser;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.orange.withValues(alpha: 0.4)),
),
child: Row(
children: [
const Icon(Icons.share, color: Colors.orange),
const SizedBox(width: 8),
Expanded(
child: Text(
from != null && from.isNotEmpty
? 'This entry was shared by $from.'
: 'This entry was shared with you.',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
],
),
);
}
Future<void> _openShareSheet() async {
if (_activeLegShare != null) return;
final data = context.read<DataService>();
final auth = context.read<AuthService>();
try {
await data.fetchFriendships();
} catch (_) {}
if (!mounted) return;
final baseFriends = _friendsFromFriendships(data, auth.userId);
final initialSelectedIds = {..._shareUserIds};
final initialSelectedUsers = {for (final u in _shareUsers) u.userId: u};
final result = await showModalBottomSheet<List<UserSummary>>(
context: context,
isScrollControlled: true,
builder: (ctx) {
final searchController = TextEditingController();
List<UserSummary> searchResults = [];
bool searching = false;
String searchTerm = '';
String? searchError;
final selectedIds = {...initialSelectedIds};
final selectedUsers = {...initialSelectedUsers};
return StatefulBuilder(
builder: (modalContext, setModalState) {
Future<void> runSearch(String term) async {
final trimmed = term.trim();
if (trimmed.isEmpty) {
setModalState(() {
searchTerm = '';
searchResults = [];
searchError = null;
});
return;
}
setModalState(() {
searchTerm = trimmed;
searching = true;
searchError = null;
});
try {
final results = await data.searchUsers(
trimmed,
friendsOnly: true,
);
setModalState(() {
searchResults = results;
});
} catch (e) {
setModalState(() {
searchError = 'Search failed';
});
} finally {
setModalState(() {
searching = false;
});
}
}
final list = searchTerm.isNotEmpty ? searchResults : baseFriends;
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(modalContext).viewInsets.bottom + 16,
left: 16,
right: 16,
top: 16,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Expanded(
child: Text(
'Share with friends',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
),
IconButton(
onPressed: () => Navigator.of(modalContext).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 8),
TextField(
controller: searchController,
decoration: InputDecoration(
labelText: 'Search friends',
border: const OutlineInputBorder(),
suffixIcon: IconButton(
icon: const Icon(Icons.search),
onPressed: () => runSearch(searchController.text),
),
),
onSubmitted: runSearch,
),
if (searching)
const Padding(
padding: EdgeInsets.only(top: 8.0),
child: LinearProgressIndicator(minHeight: 2),
),
if (searchError != null)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
searchError!,
style: const TextStyle(color: Colors.red),
),
),
const SizedBox(height: 12),
SizedBox(
height: 320,
child: list.isEmpty
? const Center(child: Text('No friends found'))
: ListView.builder(
itemCount: list.length,
itemBuilder: (_, index) {
final user = list[index];
final isSelected = selectedIds.contains(
user.userId,
);
return CheckboxListTile(
value: isSelected,
title: Text(user.displayName),
subtitle: user.username.isNotEmpty
? Text('@${user.username}')
: null,
onChanged: (checked) {
setModalState(() {
if (checked == true) {
selectedIds.add(user.userId);
selectedUsers[user.userId] = user;
} else {
selectedIds.remove(user.userId);
selectedUsers.remove(user.userId);
}
});
},
);
},
),
),
const SizedBox(height: 12),
Row(
children: [
TextButton(
onPressed: () => Navigator.of(modalContext).pop(),
child: const Text('Cancel'),
),
const Spacer(),
ElevatedButton(
onPressed: () => Navigator.of(modalContext).pop(
selectedIds
.map((id) => selectedUsers[id])
.whereType<UserSummary>()
.toList(),
),
child: const Text('Save'),
),
],
),
],
),
);
},
);
},
);
if (result != null && mounted) {
setState(() {
_shareUsers = result;
_shareUserIds = result.map((u) => u.userId).toSet();
});
}
}
void _syncManualFieldUnit(DistanceUnit currentUnit) {
if (!_useManualMileage) {
_lastDistanceUnit = currentUnit;
return;
}
final previousUnit = _lastDistanceUnit ?? currentUnit;
if (previousUnit == currentUnit) return;
final miles = _milesFromInputWithUnit(previousUnit);
final nextText = DistanceFormatter(
currentUnit,
).format(miles, decimals: 2, includeUnit: false);
_mileageController.text = nextText;
_lastDistanceUnit = currentUnit;
}
Widget _buildTripSelector(BuildContext context) {
final trips = context.watch<DataService>().tripList;
final sorted = [...trips]..sort((a, b) => b.tripId.compareTo(a.tripId));
final sorted = [...trips]..sort(TripSummary.compareByDateDesc);
final tripIds = sorted.map((t) => t.tripId).toSet();
final selectedValue =
(_selectedTripId != null && tripIds.contains(_selectedTripId))
@@ -223,10 +606,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
),
),
);
if (!mounted) return;
if (result != null) {
final units = _distanceUnits(context);
setState(() {
_routeResult = result;
_mileageController.text = result.distance.toStringAsFixed(2);
_mileageController.text = _formatDistance(
units,
result.distance,
decimals: 2,
includeUnit: false,
);
_useManualMileage = false;
});
_saveDraft();
@@ -313,7 +703,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final enabled = value ?? false;
setState(() {
_matchDestinationToEntry = enabled;
if (enabled) _hasDestinationTime = true;
if (enabled && _hasEndTime) _hasDestinationTime = true;
});
_scheduleMatchUpdate();
_saveDraft();
@@ -426,6 +816,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final endTime = DateTime.tryParse(json['leg_end_time'] ?? '');
final routeStations = _parseRouteStations(json['leg_route']);
final mileageVal = (json['leg_mileage'] as num?)?.toDouble() ?? 0.0;
final units = _distanceUnits(context);
final useManual = routeStations.isEmpty;
final routeResult = useManual
? null
@@ -447,8 +838,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
final destination = json['leg_destination'] as String? ?? '';
final hasEndTime = endTime != null || endDelay != 0;
final originTime = DateTime.tryParse(json['leg_origin_time'] ?? '');
final destinationTime =
DateTime.tryParse(json['leg_destination_time'] ?? '');
final destinationTime = DateTime.tryParse(
json['leg_destination_time'] ?? '',
);
final hasOriginTime = originTime != null;
final hasDestinationTime = destinationTime != null;
@@ -465,8 +857,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
_selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime =
TimeOfDay.fromDateTime(destinationTime ?? endTime ?? beginTime);
_selectedDestinationTime = TimeOfDay.fromDateTime(
destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime;
_useManualMileage = useManual;
@@ -484,7 +877,12 @@ class _NewEntryPageState extends State<NewEntryPage> {
_endDelayController.text = endDelay.toString();
_mileageController.text = mileageVal == 0
? ''
: mileageVal.toStringAsFixed(2);
: _formatDistance(
units,
mileageVal,
decimals: 2,
includeUnit: false,
);
_tractionItems
..clear()
..addAll(tractionItems);
@@ -509,6 +907,94 @@ class _NewEntryPageState extends State<NewEntryPage> {
}
}
void _applyLegShare(LegShareData share) {
final entry = share.entry;
final beginTime = entry.beginTime;
final endTime = entry.endTime;
final routeStations = entry.route;
final mileageVal = entry.mileage;
final units = _distanceUnits(context);
final useManual = routeStations.isEmpty;
final routeResult = useManual
? null
: RouteResult(
inputRoute: routeStations,
calculatedRoute: routeStations,
costs: const <double>[],
distance: mileageVal,
);
final tractionItems = _buildTractionFromApi(
entry.locos
.map(
(l) => {
"loco_id": l.id,
"type": l.type,
"number": l.number,
"class": l.locoClass,
"name": l.name,
"operator": l.operator,
"notes": l.notes,
"evn": l.evn,
"alloc_pos": l.allocPos,
"alloc_powering": l.powering ? 1 : 0,
},
)
.toList(),
);
final beginDelay = entry.beginDelayMinutes ?? 0;
final endDelay = entry.endDelayMinutes ?? 0;
final originTime = entry.originTime;
final destinationTime = entry.destinationTime;
final hasOriginTime = originTime != null;
final hasDestinationTime = destinationTime != null;
final hasEndTime = endTime != null || endDelay != 0;
_restoringDraft = true;
setState(() {
_activeLegShare = share;
_sharedFromUser = share.sharedFromName;
_shareNotificationId = share.notificationId;
_selectedTripId = null;
_selectedDate = beginTime;
_selectedTime = TimeOfDay.fromDateTime(beginTime);
_selectedEndDate = endTime ?? beginTime;
_selectedEndTime = TimeOfDay.fromDateTime(endTime ?? beginTime);
_hasEndTime = hasEndTime;
_selectedOriginDate = originTime ?? beginTime;
_selectedOriginTime = TimeOfDay.fromDateTime(originTime ?? beginTime);
_selectedDestinationDate = destinationTime ?? endTime ?? beginTime;
_selectedDestinationTime = TimeOfDay.fromDateTime(
destinationTime ?? endTime ?? beginTime,
);
_hasOriginTime = hasOriginTime;
_hasDestinationTime = hasDestinationTime;
_useManualMileage = useManual;
_routeResult = routeResult;
_startController.text = entry.start;
_endController.text = entry.end;
_headcodeController.text = entry.headcode.toUpperCase();
_notesController.text = entry.notes;
_networkController.text = entry.network.toUpperCase();
_originController.text = entry.origin;
_destinationController.text = entry.destination;
_beginDelayController.text = beginDelay.toString();
_endDelayController.text = endDelay.toString();
_mileageController.text = mileageVal == 0
? ''
: _formatDistance(units, mileageVal, decimals: 2, includeUnit: false);
_tractionItems
..clear()
..addAll(tractionItems);
if (_tractionItems.where((e) => e.isMarker).isEmpty) {
_tractionItems.insert(0, _TractionItem.marker());
}
_lastSubmittedSnapshot = null;
_shareUserIds.clear();
_shareUsers.clear();
});
_restoringDraft = false;
}
List<String> _parseRouteStations(dynamic raw) {
if (raw is List) {
return raw.map((e) => e.toString()).toList();
@@ -637,7 +1123,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (_destinationController.text != endVal) {
_destinationController.text = endVal;
}
if (_hasDestinationTime) {
if (_hasDestinationTime && _hasEndTime) {
final endTime = _legEndDateTime ?? _legDateTime;
_selectedDestinationDate = DateTime(
endTime.year,
@@ -697,10 +1183,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
),
],
if (!matchValue) ...[
_stationField(
label: label,
controller: controller,
),
_stationField(label: label, controller: controller),
CheckboxListTile(
value: hasTime,
onChanged: onTimeChanged,
@@ -728,11 +1211,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
key: _formKey,
child: LayoutBuilder(
builder: (context, constraints) {
final distanceUnitService = context.watch<DistanceUnitService>();
final currentDistanceUnit = distanceUnitService.unit;
_syncManualFieldUnit(currentDistanceUnit);
final twoCol = !isMobile && constraints.maxWidth > 1000;
final tractionEmpty = _tractionItems.length == 1;
final mileageEmpty = !_useManualMileage && _routeResult == null;
final balancePanels = twoCol && tractionEmpty && mileageEmpty;
final balancedHeight = balancePanels ? 165.0 : null;
final mileageLabel = _manualMileageLabel(currentDistanceUnit);
final entryPanel = _section('Entry', [
Row(
@@ -743,7 +1230,9 @@ class _NewEntryPageState extends State<NewEntryPage> {
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _isEditing ? null : _openDrafts,
onPressed: _isEditing || _activeLegShare != null
? null
: _openDrafts,
icon: const Icon(Icons.list_alt, size: 16),
label: const Text('Drafts'),
),
@@ -754,7 +1243,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
onPressed: _isEditing || _savingDraft || _submitting
onPressed:
_isEditing ||
_savingDraft ||
_submitting ||
_activeLegShare != null
? null
: _saveDraftManually,
icon: _savingDraft
@@ -781,7 +1274,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
),
],
),
if (_activeLegShare != null) ...[
const SizedBox(height: 8),
_buildSharedBanner(),
],
_buildTripSelector(context),
const SizedBox(height: 8),
if (_activeLegShare == null) _buildShareSection(context),
_dateTimeGroup(
context,
title: 'Departure time',
@@ -862,6 +1361,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
border: OutlineInputBorder(),
),
),
const Divider(height: 24),
_trainLocationBlock(
label: 'Origin',
controller: _originController,
@@ -880,6 +1380,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
singleColumn: true,
),
),
const Divider(height: 24),
_trainLocationBlock(
label: 'Destination',
controller: _destinationController,
@@ -887,8 +1388,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
onTimeChanged: _submitting ? null : _toggleDestinationTime,
matchLabel: 'Match entry end',
matchValue: _matchDestinationToEntry,
onMatchChanged:
_submitting ? null : _toggleMatchDestination,
onMatchChanged: _submitting ? null : _toggleMatchDestination,
pickerBuilder: () => _dateTimeGroupSimple(
context,
title: 'Destination arrival',
@@ -899,6 +1399,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
singleColumn: true,
),
),
const Divider(height: 24),
TextFormField(
controller: _networkController,
textCapitalization: TextCapitalization.characters,
@@ -921,7 +1422,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final tractionPanel = _section('Traction', [
Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
child: FilledButton.icon(
onPressed: _openTractionPicker,
icon: const Icon(Icons.search),
label: const Text('Search traction'),
@@ -930,17 +1431,53 @@ class _NewEntryPageState extends State<NewEntryPage> {
_buildTractionList(),
], minHeight: balancedHeight);
final routeStart = _routeResult?.calculatedRoute.isNotEmpty == true
? _routeResult!.calculatedRoute.first
: (_routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute.first
: _startController.text.trim());
final routeEnd = _routeResult?.calculatedRoute.isNotEmpty == true
? _routeResult!.calculatedRoute.last
: (_routeResult?.inputRoute.isNotEmpty == true
? _routeResult!.inputRoute.last
: _endController.text.trim());
final mileagePanel = _section(
'Mileage',
'Your Journey',
[
if (!_useManualMileage)
Align(
alignment: Alignment.centerLeft,
child: ElevatedButton.icon(
Wrap(
spacing: 12,
runSpacing: 8,
children: [
FilledButton.icon(
onPressed: _openCalculator,
icon: const Icon(Icons.calculate, size: 18),
label: const Text('Open mileage calculator'),
),
TextButton.icon(
onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
),
],
)
else
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
),
),
if (routeStart.isNotEmpty || routeEnd.isNotEmpty)
ListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Route'),
subtitle: Text(
'${routeStart.isEmpty ? 'Start' : routeStart}${routeEnd.isEmpty ? 'End' : routeEnd}',
),
),
if (_useManualMileage)
TextFormField(
@@ -948,9 +1485,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Mileage (mi)',
border: OutlineInputBorder(),
decoration: InputDecoration(
labelText: mileageLabel,
helperText:
currentDistanceUnit == DistanceUnit.milesChains
? 'Enter as miles.chains (e.g., 12.40 for 12m 40c)'
: null,
border: const OutlineInputBorder(),
),
)
else if (_routeResult != null)
@@ -958,7 +1499,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
contentPadding: EdgeInsets.zero,
title: const Text('Calculated mileage'),
subtitle: Text(
'${_routeResult!.distance.toStringAsFixed(2)} mi',
_formatDistance(
distanceUnitService,
_routeResult!.distance,
decimals: 2,
),
),
)
else
@@ -973,7 +1518,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
label: Text(_useManualMileage ? 'Manual' : 'Automatic'),
selected: _useManualMileage,
onSelected: (val) {
setState(() => _useManualMileage = val);
setState(() {
_useManualMileage = val;
if (val && _routeResult != null) {
_mileageController.text = _formatDistance(
distanceUnitService,
_routeResult!.distance,
decimals: 2,
includeUnit: false,
);
}
});
_saveDraft();
_scheduleMatchUpdate();
},
@@ -988,26 +1543,29 @@ class _NewEntryPageState extends State<NewEntryPage> {
children: [
entryPanel,
const SizedBox(height: 16),
if (twoCol) ...[
trainPanel,
const SizedBox(height: 16),
twoCol
? Row(
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(child: tractionPanel),
const SizedBox(width: 16),
Expanded(child: mileagePanel),
],
)
: Column(
),
] else
Column(
children: [
tractionPanel,
const SizedBox(height: 16),
mileagePanel,
const SizedBox(height: 16),
trainPanel,
const SizedBox(height: 16),
tractionPanel,
],
),
const SizedBox(height: 12),
ElevatedButton.icon(
FilledButton.icon(
onPressed: _submitting ? null : _submit,
icon: _submitting
? const SizedBox(
@@ -1016,11 +1574,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(
_submitting
? (_isEditing ? 'Saving...' : 'Submitting...')
: (_isEditing ? 'Save changes' : 'Submit entry'),
),
label: () {
final shareMode = _activeLegShare != null;
if (_submitting) {
if (shareMode) return const Text('Accepting...');
return Text(_isEditing ? 'Saving...' : 'Submitting...');
}
if (shareMode) return const Text('Accept entry');
return Text(_isEditing ? 'Save changes' : 'Submit entry');
}(),
),
],
),
@@ -1274,7 +1836,11 @@ class _NewEntryPageState extends State<NewEntryPage> {
return best;
}
void _insertCandidate(List<String> best, String candidate, {required int max}) {
void _insertCandidate(
List<String> best,
String candidate, {
required int max,
}) {
final existingIndex = best.indexOf(candidate);
if (existingIndex >= 0) return;
@@ -1448,44 +2014,6 @@ class _NewEntryPageState extends State<NewEntryPage> {
],
);
}
Widget _timeToggleBlock({
required String label,
required bool value,
required ValueChanged<bool?>? onChanged,
required String matchLabel,
required bool matchValue,
required ValueChanged<bool?>? onMatchChanged,
required bool showMatch,
Widget? picker,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
CheckboxListTile(
value: value,
onChanged: onChanged,
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(label),
),
if (showMatch)
CheckboxListTile(
value: matchValue,
onChanged: onMatchChanged,
dense: true,
contentPadding: const EdgeInsets.only(left: 12),
controlAffinity: ListTileControlAffinity.leading,
title: Text(matchLabel),
),
if (picker != null) ...[
const SizedBox(height: 6),
picker,
],
],
);
}
}
class _UpperCaseTextFormatter extends TextInputFormatter {

View File

@@ -4,11 +4,12 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
Future<bool> _validateRequiredFields() async {
final missing = <String>[];
final units = _distanceUnits(context);
if (_useManualMileage) {
if (_startController.text.trim().isEmpty) missing.add('From');
if (_endController.text.trim().isEmpty) missing.add('To');
final mileageText = _mileageController.text.trim();
if (double.tryParse(mileageText) == null) {
if (mileageText.isEmpty || units.milesFromInput(mileageText) == null) {
missing.add('Mileage');
}
} else {
@@ -51,6 +52,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
if (form == null) return;
if (!form.validate()) return;
if (!await _validateRequiredFields()) return;
if (!mounted) return;
final routeStations = _routeResult?.calculatedRoute ?? [];
final startVal = _useManualMileage
? _startController.text.trim()
@@ -58,8 +60,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final endVal = _useManualMileage
? _endController.text.trim()
: (routeStations.isNotEmpty ? routeStations.last : '');
final units = _distanceUnits(context);
final mileageVal = _useManualMileage
? double.tryParse(_mileageController.text.trim()) ?? 0
? (units.milesFromInput(_mileageController.text.trim()) ?? 0)
: (_routeResult?.distance ?? 0);
final tractionPayload = _buildTractionPayload();
final endTime = _legEndDateTime;
@@ -68,12 +71,14 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final snapshot = _buildSubmissionSnapshot(
final snapshot = _currentSubmissionSnapshot(
routeStations: routeStations,
startVal: startVal,
endVal: endVal,
mileageVal: mileageVal,
tractionPayload: tractionPayload,
beginDelay: beginDelay,
endDelay: endDelay,
);
if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) {
@@ -105,6 +110,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
"leg_begin_delay": beginDelay,
if (_hasEndTime) "leg_end_delay": endDelay,
"locos": tractionPayload,
if (_activeLegShare != null) "leg_share_id": _activeLegShare!.id,
"share_user_ids": _shareUserIds.toList(),
};
if (_useManualMileage) {
final body = {
@@ -133,6 +140,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
if (!mounted) return;
dataService.refreshLegs();
await dataService.fetchNotifications();
if (_shareNotificationId != null) {
await dataService.dismissNotifications([_shareNotificationId!]);
}
if (!mounted) return;
messenger?.showSnackBar(
SnackBar(
@@ -153,16 +163,35 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
}
}
Map<String, dynamic> _buildSubmissionSnapshot({
required List<String> routeStations,
required String startVal,
required String endVal,
required double mileageVal,
required List<Map<String, dynamic>> tractionPayload,
Map<String, dynamic> _currentSubmissionSnapshot({
List<String>? routeStations,
String? startVal,
String? endVal,
double? mileageVal,
List<Map<String, dynamic>>? tractionPayload,
int? beginDelay,
int? endDelay,
}) {
final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
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 {
"legId": widget.editLegId,
"useManualMileage": _useManualMileage,
@@ -174,18 +203,20 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
"hasOriginTime": _hasOriginTime,
"legDestinationTime": _destinationDateTime?.toIso8601String(),
"hasDestinationTime": _hasDestinationTime,
"start": startVal,
"end": endVal,
"start": start,
"end": end,
"origin": _originController.text.trim(),
"destination": _destinationController.text.trim(),
"routeStations": routeStations,
"mileage": mileageVal,
"routeStations": stations,
"mileage": mileage,
"network": _networkController.text.trim(),
"notes": _notesController.text.trim(),
"headcode": _headcodeController.text.trim(),
"beginDelay": beginDelay,
"endDelay": endDelay,
"locos": tractionPayload,
"beginDelay": begin,
"endDelay": endDelayVal,
"legShareId": _activeLegShare?.id,
"shareUserIds": _shareUserIds.toList(),
"locos": traction,
"routeResult": _routeResult == null
? null
: {
@@ -201,16 +232,16 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
if (!mounted) return false;
final result = await showDialog<bool>(
context: context,
builder: (_) => AlertDialog(
builder: (dialogContext) => AlertDialog(
title: const Text('Duplicate entry?'),
content: const Text('Entry already added, are you sure?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.of(context).pop(true),
onPressed: () => Navigator.of(dialogContext).pop(true),
child: const Text('Submit anyway'),
),
],
@@ -220,6 +251,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
}
Future<void> _resetFormState({bool clearDraft = false}) async {
final hadShare = _activeLegShare != null || widget.legShare != null;
_formKey.currentState?.reset();
_startController.clear();
_endController.clear();
@@ -249,6 +281,11 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
_matchDestinationToEntry = false;
_matchUpdateScheduled = false;
_routeResult = null;
_activeLegShare = null;
_sharedFromUser = null;
_shareNotificationId = null;
_shareUserIds.clear();
_shareUsers.clear();
_tractionItems
..clear()
..add(_TractionItem.marker());
@@ -258,6 +295,10 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
_savingDraft = false;
_loadedDraftSnapshot = null;
});
if (hadShare && mounted) {
// Clear any share params from the URL when resetting.
GoRouter.of(context).go('/add');
}
if (clearDraft) {
await _clearDraft();
}

View File

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

File diff suppressed because it is too large Load Diff

View File

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

@@ -1,7 +1,9 @@
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 {
@@ -12,7 +14,6 @@ class StatsPage extends StatefulWidget {
}
class _StatsPageState extends State<StatsPage> {
final NumberFormat _mileageFormat = NumberFormat('#,##0.##');
final NumberFormat _countFormat = NumberFormat.decimalPattern();
@override
@@ -30,16 +31,34 @@ class _StatsPageState extends State<StatsPage> {
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
return Scaffold(
appBar: AppBar(title: const Text('Stats')),
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),
child: _buildContent(data, distanceUnits),
),
);
}
Widget _buildContent(DataService data) {
Widget _buildContent(
DataService data,
DistanceUnitService distanceUnits,
) {
final stats = data.aboutStats;
final loading = data.isAboutStatsLoading;
@@ -79,13 +98,14 @@ class _StatsPageState extends State<StatsPage> {
itemBuilder: (context, index) {
return Padding(
padding: EdgeInsets.only(bottom: index == years.length - 1 ? 0 : 12),
child: _buildYearCard(context, years[index]),
child: _buildYearCard(context, years[index], distanceUnits),
);
},
);
}
Widget _buildYearCard(BuildContext context, StatsYear year) {
Widget _buildYearCard(
BuildContext context, StatsYear year, DistanceUnitService distanceUnits) {
final theme = Theme.of(context);
return Card(
child: Padding(
@@ -109,7 +129,7 @@ class _StatsPageState extends State<StatsPage> {
_buildInfoChip(
context,
label: 'Mileage',
value: '${_mileageFormat.format(year.mileage)} mi',
value: distanceUnits.format(year.mileage, decimals: 1),
),
_buildInfoChip(
context,
@@ -121,6 +141,7 @@ class _StatsPageState extends State<StatsPage> {
],
),
const SizedBox(height: 8),
_buildTypeCountsSection(context, year),
_buildSection<StatsClassMileage>(
context,
title: 'Top classes',
@@ -130,7 +151,9 @@ class _StatsPageState extends State<StatsPage> {
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.locoClass),
trailing: Text('${_mileageFormat.format(item.mileage)} mi'),
trailing: Text(
distanceUnits.format(item.mileage, decimals: 1),
),
),
),
_buildSection<StatsNetworkMileage>(
@@ -142,7 +165,9 @@ class _StatsPageState extends State<StatsPage> {
dense: true,
contentPadding: const EdgeInsets.symmetric(horizontal: 8),
title: Text(item.network),
trailing: Text('${_mileageFormat.format(item.mileage)} mi'),
trailing: Text(
distanceUnits.format(item.mileage, decimals: 1),
),
),
),
_buildSection<StatsStationVisits>(
@@ -177,6 +202,83 @@ class _StatsPageState extends State<StatsPage> {
);
}
List<MapEntry<String, int>> _sortedTypeCounts(Map<String, int> counts) {
final entries = counts.entries.toList();
entries.sort((a, b) {
final countCompare = b.value.compareTo(a.value);
if (countCompare != 0) return countCompare;
return a.key.compareTo(b.key);
});
return entries;
}
Widget _buildTypeCountsSection(BuildContext context, StatsYear year) {
if (year.winnerTypeCounts.isEmpty && year.totalTypeCounts.isEmpty) {
return const SizedBox.shrink();
}
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Type counts',
style: theme.textTheme.titleMedium,
),
const SizedBox(height: 6),
_buildTypeCountsRow(
context,
label: 'Winners',
counts: year.winnerTypeCounts,
),
const SizedBox(height: 8),
_buildTypeCountsRow(
context,
label: 'Total',
counts: year.totalTypeCounts,
),
],
),
);
}
Widget _buildTypeCountsRow(
BuildContext context, {
required String label,
required Map<String, int> counts,
}) {
final theme = Theme.of(context);
final entries = _sortedTypeCounts(counts);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelLarge,
),
const SizedBox(height: 4),
if (entries.isEmpty)
Text(
'No data',
style: theme.textTheme.bodySmall,
)
else
Wrap(
spacing: 6,
runSpacing: 6,
children: entries
.map((entry) => _buildInfoChip(
context,
label: entry.key,
value: _countFormat.format(entry.value),
))
.toList(),
),
],
);
}
Widget _buildSection<T>(
BuildContext context, {
required String title,

View File

@@ -1,11 +1,17 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart';
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:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/utils/download_helper.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,152 @@
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:mileograph_flutter/services/authservice.dart';
import 'package:provider/provider.dart';
class TractionPendingChangesPage extends StatefulWidget {
const TractionPendingChangesPage({super.key});
@override
State<TractionPendingChangesPage> createState() =>
_TractionPendingChangesPageState();
}
class _TractionPendingChangesPageState extends State<TractionPendingChangesPage> {
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 json = await api.get('/event/pending/locos');
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) {
final isElevated = context.select<AuthService, bool>((auth) => auth.isElevated);
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.of(context).maybePop(),
),
title: const Text('Pending changes'),
),
body: isElevated
? RefreshIndicator(
onRefresh: _load,
child: _buildBody(context),
)
: ListView(
padding: const EdgeInsets.all(16),
children: const [
Text('Admin access required.'),
],
),
);
}
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 changes: $_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 changes 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(),
'showPending': true,
},
),
onOpenLegs: () => context.push('/traction/${loco.id}/legs'),
onActionComplete: _load,
),
);
},
);
}
}

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

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart';
class TripsPage extends StatefulWidget {
@@ -99,6 +100,7 @@ class _TripsPageState extends State<TripsPage> {
@override
Widget build(BuildContext context) {
final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>();
final tripDetails = data.tripDetails;
final tripSummaries = data.tripList;
final summaryById = {
@@ -188,7 +190,8 @@ class _TripsPageState extends State<TripsPage> {
return Card(
child: ListTile(
title: Text(trip.tripName),
subtitle: Text('${trip.tripMileage.toStringAsFixed(1)} mi'),
subtitle:
Text(distanceUnits.format(trip.tripMileage, decimals: 1)),
),
);
}
@@ -206,6 +209,7 @@ class _TripsPageState extends State<TripsPage> {
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.watch<DistanceUnitService>();
final legs = trip.legs;
final legCount =
trip.legCount > 0 ? trip.legCount : summary?.legCount ?? legs.length;
@@ -228,7 +232,7 @@ class _TripsPageState extends State<TripsPage> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Trip',
'Trip #${trip.id}',
style: Theme.of(context).textTheme.labelMedium,
),
const SizedBox(height: 4),
@@ -245,18 +249,12 @@ class _TripsPageState extends State<TripsPage> {
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
trip.mileage.toStringAsFixed(1),
distanceUnits.format(trip.mileage, decimals: 1),
style:
Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w800,
),
),
Text(
'miles',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).textTheme.bodySmall?.color,
),
),
],
),
],
@@ -367,6 +365,7 @@ class _TripsPageState extends State<TripsPage> {
}
void _showTripDetail(BuildContext context, TripDetail trip) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -491,7 +490,9 @@ class _TripsPageState extends State<TripsPage> {
),
],
const SizedBox(width: 4),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
],
),
const SizedBox(height: 8),
@@ -506,7 +507,12 @@ class _TripsPageState extends State<TripsPage> {
title: Text('${leg.start}${leg.end}'),
subtitle: Text(_formatDate(leg.beginTime)),
trailing: Text(
leg.mileage?.toStringAsFixed(1) ?? '-',
leg.mileage == null
? '-'
: distanceUnits.format(
leg.mileage!,
decimals: 1,
),
style: Theme.of(context).textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
),
@@ -529,6 +535,7 @@ class _TripsPageState extends State<TripsPage> {
TripDetail trip,
TripSummary? summary,
) {
final distanceUnits = context.read<DistanceUnitService>();
showModalBottomSheet(
context: context,
isScrollControlled: true,
@@ -561,7 +568,9 @@ class _TripsPageState extends State<TripsPage> {
?.copyWith(fontWeight: FontWeight.bold),
),
const Spacer(),
Text('${trip.mileage.toStringAsFixed(1)} mi'),
Text(
distanceUnits.format(trip.mileage, decimals: 1),
),
],
),
const SizedBox(height: 8),

View File

@@ -1,5 +1,12 @@
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/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 {
const TractionCard({
@@ -11,6 +18,9 @@ class TractionCard extends StatelessWidget {
required this.onOpenTimeline,
this.onOpenLegs,
this.onToggleSelect,
this.onReplacePending,
this.onActionComplete,
this.onTransferAllocations,
});
final LocoSummary loco;
@@ -20,14 +30,23 @@ class TractionCard extends StatelessWidget {
final VoidCallback onOpenTimeline;
final VoidCallback? onOpenLegs;
final VoidCallback? onToggleSelect;
final VoidCallback? onReplacePending;
final Future<void> Function()? onActionComplete;
final VoidCallback? onTransferAllocations;
@override
Widget build(BuildContext context) {
final status = loco.status ?? 'Unknown';
final operatorName = loco.operator ?? '';
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 statusColors = _statusChipColors(context, status);
final distanceUnits = context.watch<DistanceUnitService>();
return Card(
child: Padding(
@@ -41,23 +60,14 @@ class TractionCard extends StatelessWidget {
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
loco.number,
style: Theme.of(context).textTheme.headlineSmall
?.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,
),
),
],
_LocoNumberWithHistory(
number: loco.number,
matchedNumber: loco.matchedNumber,
matchedNumberValidTo: loco.matchedNumberValidTo,
hasMileageOrTrips: hasMileageOrTrips,
largeStyle: Theme.of(context).textTheme.headlineSmall,
showPendingChip: isVisibilityPending,
showRejectedChip: isRejected && !isVisibilityPending,
),
Text(
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(
label: Text(status),
backgroundColor: statusColors.$1,
@@ -81,6 +121,8 @@ class TractionCard extends StatelessWidget {
),
],
),
],
),
const SizedBox(height: 8),
LayoutBuilder(
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(
onPressed: onToggleSelect,
icon: Icon(
@@ -112,7 +167,8 @@ class TractionCard extends StatelessWidget {
? Icons.remove_circle_outline
: Icons.add_circle_outline,
),
label: Text(isSelected ? 'Remove' : 'Add to entry'),
label:
Text(isSelected ? 'Remove' : 'Add to entry'),
)
: null;
@@ -151,8 +207,11 @@ class TractionCard extends StatelessWidget {
children: [
_statPill(
context,
label: 'Miles',
value: _formatNumber(loco.mileage),
label: 'Distance',
value: distanceUnits.format(
loco.mileage ?? 0,
decimals: 1,
),
),
_statPill(
context,
@@ -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(
BuildContext context,
LocoSummary loco,
) async {
LocoSummary loco, {
Future<void> Function()? onActionComplete,
}) async {
final navContext = context;
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(
context: context,
isScrollControlled: true,
@@ -227,23 +606,13 @@ Future<void> showTractionDetails(
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
loco.number,
style: Theme.of(context).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,
),
),
],
_LocoNumberWithHistory(
number: loco.number,
matchedNumber: loco.matchedNumber,
matchedNumberValidTo: loco.matchedNumberValidTo,
hasMileageOrTrips: hasMileageOrTrips,
showPendingChip: isVisibilityPending,
showRejectedChip: isRejected && !isVisibilityPending,
),
Text(
loco.locoClass,
@@ -266,6 +635,15 @@ Future<void> showTractionDetails(
child: ListView(
controller: controller,
children: [
if (isRejected && rejectedReason.isNotEmpty)
...[
_detailRow(
context,
'Rejection reason',
rejectedReason,
),
const Divider(),
],
_detailRow(context, 'Status', loco.status ?? 'Unknown'),
_detailRow(context, 'Operator', loco.operator ?? ''),
_detailRow(context, 'Domain', loco.domain ?? ''),
@@ -275,7 +653,10 @@ Future<void> showTractionDetails(
_detailRow(
context,
'Mileage',
_formatNumber(loco.mileage ?? 0),
distanceUnits.format(
loco.mileage ?? 0,
decimals: 1,
),
),
_detailRow(
context,
@@ -285,6 +666,186 @@ Future<void> showTractionDetails(
_detailRow(context, 'EVN', loco.evn ?? ''),
if (loco.notes != null && loco.notes!.isNotEmpty)
_detailRow(context, 'Notes', loco.notes!),
const SizedBox(height: 16),
Text(
'Leaderboard',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 8),
FutureBuilder<List<LeaderboardEntry>>(
future: leaderboardFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12.0),
child: Center(
child: SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
);
}
if (snapshot.hasError) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'Failed to load leaderboard',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.error,
),
),
);
}
final entries = snapshot.data ?? const <LeaderboardEntry>[];
if (entries.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
'No mileage leaderboard yet.',
style: Theme.of(context).textTheme.bodyMedium,
),
);
}
return Column(
children: entries.asMap().entries.map((entry) {
final rank = entry.key + 1;
return _leaderboardRow(
context,
rank,
entry.value,
distanceUnits,
);
}).toList(),
);
},
),
const SizedBox(height: 16),
if (hasMileageOrTrips)
FilledButton.icon(
onPressed: () {
Navigator.of(ctx).pop();
final transferLabel =
'${loco.locoClass} ${loco.number}'.trim();
navContext.push(
Uri(
path: '/traction',
queryParameters: {
'selection': 'single',
'transferFromLocoId': loco.id.toString(),
'transferFromLabel': transferLabel,
'transferAll': '0',
},
).toString(),
extra: {
'selection': 'single',
'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel,
'transferAll': false,
},
);
},
icon: const Icon(Icons.swap_horiz),
label: const Text('Transfer allocations'),
),
if (auth.isElevated || canDeleteAsOwner) ...[
const SizedBox(height: 8),
ExpansionTile(
tilePadding: EdgeInsets.zero,
childrenPadding: EdgeInsets.zero,
title: Text(
'More',
style: Theme.of(context).textTheme.titleSmall,
),
children: [
if (auth.isElevated) ...[
FilledButton.tonal(
onPressed: () {
Navigator.of(ctx).pop();
final transferLabel =
'${loco.locoClass} ${loco.number}'.trim();
navContext.push(
Uri(
path: '/traction',
queryParameters: {
'selection': 'single',
'transferFromLocoId': loco.id.toString(),
'transferFromLabel': transferLabel,
'transferAll': 'true',
},
).toString(),
extra: {
'selection': 'single',
'transferFromLocoId': loco.id,
'transferFromLabel': transferLabel,
'transferAll': true,
},
);
},
child: const Text('Transfer all allocations'),
),
const SizedBox(height: 8),
],
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'),
),
const SizedBox(height: 8),
],
),
],
],
),
),
@@ -297,6 +858,105 @@ Future<void> showTractionDetails(
);
}
Future<List<LeaderboardEntry>> _fetchLocoLeaderboard(
ApiService api,
int locoId,
) async {
try {
final json = await api.get('/loco/leaderboard/id/$locoId');
Iterable<dynamic>? raw;
if (json is List) {
raw = json;
} else if (json is Map) {
for (final key in ['data', 'leaderboard', 'results']) {
final value = json[key];
if (value is List) {
raw = value;
break;
}
}
}
if (raw == null) return const [];
return raw.whereType<Map>().map((e) {
return LeaderboardEntry.fromJson(
e.map((key, value) => MapEntry(key.toString(), value)),
);
}).toList();
} catch (e) {
debugPrint('Failed to fetch loco leaderboard for $locoId: $e');
rethrow;
}
}
int? _leaderboardId(LocoSummary loco) {
int? parse(dynamic value) {
if (value == null) return null;
if (value is int) return value == 0 ? null : value;
if (value is num) return value.toInt() == 0 ? null : value.toInt();
return int.tryParse(value.toString());
}
return parse(loco.extra['loco_id']) ??
parse(loco.extra['id']) ??
parse(loco.id);
}
Widget _leaderboardRow(
BuildContext context,
int rank,
LeaderboardEntry entry,
DistanceUnitService distanceUnits,
) {
final theme = Theme.of(context);
final primaryName =
entry.userFullName.isNotEmpty ? entry.userFullName : entry.username;
final mileageLabel = distanceUnits.format(entry.mileage, decimals: 1);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 6.0),
child: Row(
children: [
Container(
width: 36,
height: 36,
alignment: Alignment.center,
decoration: BoxDecoration(
color: theme.colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(10),
),
child: Text(
'#$rank',
style: theme.textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.w700,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
primaryName,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
),
Text(
mileageLabel,
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
);
}
Widget _detailRow(BuildContext context, String label, String value) {
if (value.isEmpty) return const SizedBox.shrink();
return Padding(
@@ -368,8 +1028,3 @@ bool _hasMileageOrTrips(LocoSummary loco) {
final trips = loco.trips ?? loco.journeys ?? 0;
return mileage > 0 || trips > 0;
}
String _formatNumber(double? value) {
if (value == null) return '0';
return value.toStringAsFixed(1);
}

View File

@@ -0,0 +1,81 @@
import 'package:flutter/material.dart';
class AnimatedCountText extends StatefulWidget {
const AnimatedCountText({
super.key,
required this.value,
required this.formatter,
this.style,
this.duration = const Duration(milliseconds: 900),
this.curve = Curves.easeOutCubic,
this.animateFromZero = true,
});
final double value;
final String Function(double) formatter;
final TextStyle? style;
final Duration duration;
final Curve curve;
final bool animateFromZero;
@override
State<AnimatedCountText> createState() => _AnimatedCountTextState();
}
class _AnimatedCountTextState extends State<AnimatedCountText>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
late CurvedAnimation _curve;
double _currentValue = 0;
@override
void initState() {
super.initState();
_currentValue = widget.animateFromZero ? 0 : widget.value;
_controller = AnimationController(vsync: this, duration: widget.duration);
_curve = CurvedAnimation(parent: _controller, curve: widget.curve);
_controller.addListener(_handleTick);
_configureAnimation(from: _currentValue, to: widget.value);
if (_currentValue != widget.value) {
_controller.forward();
}
}
@override
void didUpdateWidget(covariant AnimatedCountText oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.curve != widget.curve) {
_curve = CurvedAnimation(parent: _controller, curve: widget.curve);
_configureAnimation(from: _currentValue, to: widget.value);
}
if (oldWidget.value != widget.value) {
_controller.duration = widget.duration;
_configureAnimation(from: _currentValue, to: widget.value);
_controller.forward(from: 0);
}
}
void _configureAnimation({required double from, required double to}) {
_animation = Tween<double>(begin: from, end: to).animate(_curve);
}
void _handleTick() {
setState(() => _currentValue = _animation.value);
}
@override
void dispose() {
_controller.removeListener(_handleTick);
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text(
widget.formatter(_currentValue),
style: widget.style,
);
}
}

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,641 @@
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;
LegShareData? _share;
Future<LegShareData?>? _shareFuture;
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(notify: true);
}
}
void _parseNotification({bool notify = false}) {
final rawBody = widget.notification.body.trim();
// Reset
_shareId = null;
_legId = null;
_currentLeg = null;
_changes = null;
_share = null;
_shareFuture = null;
final parsed = _decodeBody(rawBody);
if (parsed != null) {
_shareId = _parseInt(parsed['share_id']);
_legId = _parseInt(parsed['leg_id']);
_currentLeg = _findCurrentLeg(_legId);
final accepted = _asStringKeyedMap(parsed['accepted_changes']);
if (accepted != null) {
_changes = accepted;
}
}
// Fallback: extract share_id from raw string if still missing.
_shareId ??= _extractShareId(rawBody);
_prepareShareFuture();
if (notify) {
setState(() {});
}
}
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
}
}
void _prepareShareFuture() {
if (_shareId == null) return;
_shareFuture = context.read<DataService>().fetchLegShare(_shareId!.toString());
}
Future<void> _loadShareIfNeeded() async {
if (_share != null) return;
if (_shareId == null) return;
try {
final future = _shareFuture ??
context.read<DataService>().fetchLegShare(_shareId!.toString());
final share = await future;
if (!mounted) return;
_share = share;
} 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;
}
}
String _formatDateTime(DateTime dateTime) {
return '${dateTime.year.toString().padLeft(4, '0')}-'
'${dateTime.month.toString().padLeft(2, '0')}-'
'${dateTime.day.toString().padLeft(2, '0')} '
'${dateTime.hour.toString().padLeft(2, '0')}:'
'${dateTime.minute.toString().padLeft(2, '0')}';
}
Widget _buildLegSummary(BuildContext context) {
final future = _shareFuture;
if (future == null) {
final leg = _currentLeg;
return leg == null ? const SizedBox.shrink() : _legSummaryRow(context, leg);
}
return FutureBuilder<LegShareData?>(
future: future,
builder: (context, snapshot) {
final share = snapshot.data;
if (share != null) {
_share = share;
}
final leg = share?.entry ?? _currentLeg;
if (leg == null) {
return const SizedBox.shrink();
}
return _legSummaryRow(context, leg);
},
);
}
Widget _legSummaryRow(BuildContext context, Leg leg) {
final start = leg.route.isNotEmpty ? leg.route.first : leg.start;
final end = leg.route.isNotEmpty ? leg.route.last : leg.end;
return Text('${_formatDateTime(leg.beginTime)}$start$end');
}
String _asString(dynamic value) {
if (value == null) return '';
return value.toString();
}
Loco? _resolveLocoById(int locoId, {Leg? shareLeg}) {
for (final loco in shareLeg?.locos ?? const <Loco>[]) {
if (loco.id == locoId) return loco;
}
for (final loco in _currentLeg?.locos ?? const <Loco>[]) {
if (loco.id == locoId) return loco;
}
for (final loco in context.read<DataService>().traction) {
if (loco.id == locoId) return loco;
}
return null;
}
String _locoDisplayName(Map<String, dynamic> loco, {Leg? shareLeg}) {
final locoId = _parseInt(loco['loco_id']);
var locoClass = _asString(loco['class'] ?? loco['loco_class']);
var number = _asString(loco['number'] ?? loco['loco_number']);
if ((locoClass.isEmpty || number.isEmpty) && locoId != null) {
final resolved = _resolveLocoById(locoId, shareLeg: shareLeg);
if (resolved != null) {
if (locoClass.isEmpty) locoClass = resolved.locoClass;
if (number.isEmpty) number = resolved.number;
}
}
final parts = <String>[];
if (locoClass.isNotEmpty) parts.add(locoClass);
if (number.isNotEmpty) parts.add(number);
if (parts.isNotEmpty) return parts.join(' ');
return 'Loco ${locoId ?? '?'}';
}
@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;
final legSummary = _buildLegSummary(context);
final hasSummary = _shareFuture != null || _currentLeg != null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (hasSummary) legSummary,
if (hasSummary) const SizedBox(height: 8),
...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();
await _loadShareIfNeeded();
_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(
'${_locoDisplayName(loco, shareLeg: _share?.entry)} (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,193 @@
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 StatefulWidget {
const LegShareNotificationCard({super.key, required this.notification});
final UserNotification notification;
@override
State<LegShareNotificationCard> createState() => _LegShareNotificationCardState();
}
class _LegShareNotificationCardState extends State<LegShareNotificationCard> {
bool _accepting = false;
bool _rejecting = false;
@override
Widget build(BuildContext context) {
final data = context.read<DataService>();
final legShareId = _extractLegShareId(widget.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: _accepting ? null : () => _accept(context, share),
child: _accepting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Accept'),
),
OutlinedButton(
onPressed: () => _inspect(context, share),
child: const Text('Inspect'),
),
TextButton(
onPressed: _rejecting ? null : () => _reject(context, share),
child: _rejecting
? const SizedBox(
height: 18,
width: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Reject'),
),
],
),
],
);
},
);
}
Future<void> _accept(BuildContext context, LegShareData share) async {
setState(() => _accepting = true);
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await data.acceptLegShare(share);
if (!context.mounted) return;
await data.dismissNotifications([widget.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')),
);
} finally {
if (mounted) {
setState(() => _accepting = false);
}
}
}
Future<void> _reject(BuildContext context, LegShareData share) async {
setState(() => _rejecting = true);
final data = context.read<DataService>();
final messenger = ScaffoldMessenger.maybeOf(context);
try {
await data.rejectLegShare(share.id);
if (!context.mounted) return;
await data.dismissNotifications([widget.notification.id]);
messenger?.showSnackBar(
const SnackBar(content: Text('Share rejected')),
);
} catch (e) {
messenger?.showSnackBar(
SnackBar(content: Text('Failed to reject share: $e')),
);
} finally {
if (mounted) {
setState(() => _rejecting = false);
}
}
}
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: widget.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;
}
}

View File

@@ -66,6 +66,29 @@ DateTime _asDateTime(dynamic value, [DateTime? fallback]) {
return parsed ?? (fallback ?? DateTime.fromMillisecondsSinceEpoch(0));
}
DateTime? _asNullableDateTime(dynamic value) {
if (value == null) return null;
if (value is DateTime) return value;
return DateTime.tryParse(value.toString());
}
int compareTripsByDateDesc(
DateTime? aDate,
DateTime? bDate,
int aId,
int bId,
) {
if (aDate != null && bDate != null) {
final cmp = bDate.compareTo(aDate);
if (cmp != 0) return cmp;
} else if (aDate != null) {
return -1;
} else if (bDate != null) {
return 1;
}
return bId.compareTo(aId);
}
class DestinationObject {
const DestinationObject(
this.label,
@@ -81,24 +104,315 @@ class DestinationObject {
}
class UserData {
const UserData(this.username, this.fullName, this.userId, this.email);
const UserData({
required this.username,
required this.fullName,
required this.userId,
required this.email,
String? entriesVisibility,
String? mileageVisibility,
bool? elevated,
bool? disabled,
}) : entriesVisibility = entriesVisibility ?? 'private',
mileageVisibility = mileageVisibility ?? 'private',
elevated = elevated ?? false,
disabled = disabled ?? false;
final String userId;
final String username;
final String fullName;
final String email;
final String entriesVisibility;
final String mileageVisibility;
final bool elevated;
final bool disabled;
}
class AuthenticatedUserData extends UserData {
const AuthenticatedUserData({
required String userId,
required String username,
required String fullName,
required String email,
required super.userId,
required super.username,
required super.fullName,
required super.email,
super.entriesVisibility,
super.mileageVisibility,
bool? elevated,
bool? isElevated,
bool? disabled,
bool? isDisabled,
required this.accessToken,
}) : super(username, fullName, userId, email);
}) : super(
elevated: (elevated ?? false) || (isElevated ?? false),
disabled: (disabled ?? false) || (isDisabled ?? false),
);
final String accessToken;
bool get isElevated => elevated;
}
class UserSummary extends UserData {
const UserSummary({
required super.username,
required super.fullName,
required super.userId,
required super.email,
super.entriesVisibility,
super.mileageVisibility,
super.elevated = false,
super.disabled = false,
});
String get displayName => fullName.isNotEmpty ? fullName : username;
factory UserSummary.fromJson(Map<String, dynamic> json) => UserSummary(
username: _asString(json['username'] ?? json['user_name']),
fullName: _asString(json['full_name'] ?? json['name']),
userId: _asString(json['user_id'] ?? json['id']),
email: _asString(json['email']),
entriesVisibility: _asString(
json['user_entries_visibility'] ?? json['entries_visibility'],
'private',
),
mileageVisibility: _asString(
json['user_mileage_visibility'] ?? json['mileage_visibility'],
'private',
),
elevated: _asBool(json['elevated'] ?? json['is_elevated'], false),
disabled: _asBool(json['disabled'], false),
);
}
class UserProfileDetail {
final String username;
final String fullName;
final double mileage;
final List<LocoSummary> topLocos;
final List<Leg> legs;
final Map<String, dynamic> privacyInfo;
final String friendshipStatus;
UserProfileDetail({
required this.username,
required this.fullName,
required this.mileage,
required this.topLocos,
required this.legs,
this.privacyInfo = const {},
this.friendshipStatus = 'none',
});
factory UserProfileDetail.fromJson(Map<String, dynamic> json) {
List<dynamic>? topLocosRaw;
final tl = json['top_locos'];
if (tl is List) {
topLocosRaw = tl;
}
List<dynamic>? legsRaw;
final legData = json['user_legs'];
if (legData is List) {
legsRaw = legData;
}
return UserProfileDetail(
username: _asString(json['username']),
fullName: _asString(json['full_name']),
mileage: _asDouble(json['mileage']),
topLocos: (topLocosRaw ?? const [])
.whereType<Map>()
.map((e) => LocoSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList(),
legs: (legsRaw ?? const [])
.whereType<Map>()
.map((e) => Leg.fromJson(e.map((k, v) => MapEntry(k.toString(), v))))
.toList(),
privacyInfo: json['privacy_info'] is Map
? Map<String, dynamic>.from(json['privacy_info'] as Map)
: const {},
friendshipStatus: _asString(json['friendship_status'], 'none'),
);
}
}
class Friendship {
final String? id;
final String status;
final String requesterId;
final String addresseeId;
final UserSummary? requester;
final UserSummary? addressee;
final DateTime? requestedAt;
final DateTime? respondedAt;
const Friendship({
required this.id,
required this.status,
required this.requesterId,
required this.addresseeId,
this.requester,
this.addressee,
this.requestedAt,
this.respondedAt,
});
String get _statusLower => status.toLowerCase();
bool get isNone => _statusLower == 'none';
bool get isPending => _statusLower == 'pending';
bool get isAccepted => _statusLower == 'accepted';
bool get isBlocked => _statusLower == 'blocked';
bool get isDeclined =>
_statusLower == 'declined' || _statusLower == 'rejected';
factory Friendship.fromJson(Map<String, dynamic> json) {
DateTime? parseDate(dynamic value) {
if (value is DateTime) return value;
return DateTime.tryParse(value?.toString() ?? '');
}
String pickId(Map<String, dynamic> map, List<String> keys) {
for (final key in keys) {
final value = map[key];
if (value == null) continue;
final str = value.toString();
if (str.isNotEmpty) return str;
}
return '';
}
UserSummary? parseUser(dynamic raw, {String? usernameKey, String? fullKey, String? idKey}) {
if (raw is Map) {
return UserSummary.fromJson(
raw.map((key, value) => MapEntry(key.toString(), value)),
);
}
final username = usernameKey != null ? _asString(json[usernameKey]) : '';
final fullName = fullKey != null ? _asString(json[fullKey]) : '';
final id = idKey != null ? _asString(json[idKey]) : '';
if (username.isEmpty && fullName.isEmpty && id.isEmpty) return null;
return UserSummary(
username: username,
fullName: fullName,
userId: id,
email: '',
);
}
final requesterJson = json['requester'] ??
json['requestor'] ??
json['sender'] ??
json['from_user'];
final addresseeJson = json['addressee'] ??
json['receiver'] ??
json['target_user'] ??
json['to_user'];
final requester = parseUser(
requesterJson,
usernameKey: 'requester_username',
fullKey: 'requester_full_name',
idKey: 'requester_id',
);
final addressee = parseUser(
addresseeJson,
usernameKey: 'addressee_username',
fullKey: 'addressee_full_name',
idKey: 'addressee_id',
);
final requesterUsername = _asString(json['requester_username']);
final requesterFullName = _asString(json['requester_full_name']);
final addresseeUsername = _asString(json['addressee_username']);
final addresseeFullName = _asString(json['addressee_full_name']);
final requesterId = requester?.userId.isNotEmpty == true
? requester!.userId
: pickId(json, [
'requester_id',
'requestor_id',
'requestorId',
'sender_id',
'from_user_id',
'from_id',
'user_id',
]);
final addresseeId = addressee?.userId.isNotEmpty == true
? addressee!.userId
: pickId(json, [
'addressee_id',
'addresseeId',
'receiver_id',
'target_user_id',
'to_user_id',
'to_id',
]);
final normalizedStatus = _asString(json['status'], 'none').trim();
return Friendship(
id: pickId(json, ['friendship_id', 'friendshipId', 'id']),
status: normalizedStatus.isNotEmpty ? normalizedStatus : 'none',
requesterId: requesterId,
addresseeId: addresseeId,
requester: requester ??
(requesterUsername.isNotEmpty || requesterFullName.isNotEmpty
? UserSummary(
username: requesterUsername,
fullName: requesterFullName,
userId: requesterId,
email: '',
)
: null),
addressee: addressee ??
(addresseeUsername.isNotEmpty || addresseeFullName.isNotEmpty
? UserSummary(
username: addresseeUsername,
fullName: addresseeFullName,
userId: addresseeId,
email: '',
)
: null),
requestedAt: parseDate(json['requested_at'] ?? json['requestedAt']),
respondedAt: parseDate(json['responded_at'] ?? json['respondedAt']),
);
}
factory Friendship.fromStatusJson(
Map<String, dynamic> json, {
String? targetUserId,
}) {
final statusVal = _asString(json['status'], 'none').trim();
if (statusVal.toLowerCase() == 'none') {
return Friendship(
id: null,
status: 'none',
requesterId: '',
addresseeId: targetUserId ?? '',
);
}
return Friendship.fromJson(json);
}
Friendship copyWith({
String? id,
String? status,
String? requesterId,
String? addresseeId,
UserSummary? requester,
UserSummary? addressee,
DateTime? requestedAt,
DateTime? respondedAt,
}) {
return Friendship(
id: id ?? this.id,
status: status ?? this.status,
requesterId: requesterId ?? this.requesterId,
addresseeId: addresseeId ?? this.addresseeId,
requester: requester ?? this.requester,
addressee: addressee ?? this.addressee,
requestedAt: requestedAt ?? this.requestedAt,
respondedAt: respondedAt ?? this.respondedAt,
);
}
}
class HomepageStats {
@@ -106,6 +420,7 @@ class HomepageStats {
final List<YearlyMileage> yearlyMileage;
final List<LocoSummary> topLocos;
final List<LeaderboardEntry> leaderboard;
final List<LeaderboardEntry> friendsLeaderboard;
final List<TripSummary> trips;
final int legCount;
final UserData? user;
@@ -115,6 +430,7 @@ class HomepageStats {
required this.yearlyMileage,
required this.topLocos,
required this.leaderboard,
required this.friendsLeaderboard,
required this.trips,
required this.legCount,
this.user,
@@ -126,6 +442,17 @@ class HomepageStats {
final totalMileage = mileageData is Map && mileageData['mileage'] != null
? (mileageData['mileage'] as num).toDouble()
: 0.0;
List<LeaderboardEntry> parseLeaderboard(dynamic raw) {
if (raw is List) {
return raw
.whereType<Map>()
.map((e) => LeaderboardEntry.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList();
}
return const [];
}
return HomepageStats(
totalMileage: totalMileage,
yearlyMileage: (json['yearly_mileage'] as List? ?? [])
@@ -134,9 +461,9 @@ class HomepageStats {
topLocos: (json['top_locos'] as List? ?? [])
.map((e) => LocoSummary.fromJson(e))
.toList(),
leaderboard: (json['leaderboard_data'] as List? ?? [])
.map((e) => LeaderboardEntry.fromJson(e))
.toList(),
leaderboard: parseLeaderboard(json['leaderboard_data'] ?? json['leaderboard']),
friendsLeaderboard:
parseLeaderboard(json['friends_leaderboard'] ?? json['friendsLeaderboard']),
trips: (json['trip_data'] as List? ?? [])
.map((e) => TripSummary.fromJson(e))
.toList(),
@@ -147,10 +474,23 @@ class HomepageStats {
user: userData == null
? null
: UserData(
userData['username'] ?? '',
userData['full_name'] ?? '',
userData['user_id'] ?? '',
userData['email'] ?? '',
username: userData['username'] ?? '',
fullName: userData['full_name'] ?? '',
userId: userData['user_id'] ?? '',
email: userData['email'] ?? '',
entriesVisibility: _asString(
userData['user_entries_visibility'] ??
userData['entries_visibility'],
'private',
),
mileageVisibility: _asString(
userData['user_mileage_visibility'] ??
userData['mileage_visibility'],
'private',
),
elevated:
_asBool(userData['elevated'] ?? userData['is_elevated'], false),
disabled: _asBool(userData['disabled'], false),
),
);
}
@@ -179,6 +519,8 @@ class StatsAbout {
final networkByYear = <int, List<StatsNetworkMileage>>{};
final stationByYear = <int, List<StatsStationVisits>>{};
final winnersByYear = <int, int>{};
final winnerTypeCountsByYear = <int, Map<String, int>>{};
final totalTypeCountsByYear = <int, Map<String, int>>{};
void addYearMileage(dynamic entry) {
if (entry is Map<String, dynamic>) {
@@ -249,6 +591,16 @@ class StatsAbout {
}
}
Map<String, int> parseTypeCounts(dynamic value) {
if (value is Map) {
final entries = value.entries
.map((entry) => MapEntry(entry.key.toString(), _asInt(entry.value)))
.where((entry) => entry.key.isNotEmpty);
return Map<String, int>.fromEntries(entries);
}
return const {};
}
parseYearMap<List<StatsClassMileage>>(
json['top_classes'],
classByYear,
@@ -270,6 +622,19 @@ class StatsAbout {
if (year == null) return;
if (value is List) {
winnersByYear[year] = value.length;
} else if (value is Map) {
final mapped = value.map(
(entryKey, entryValue) =>
MapEntry(entryKey.toString(), entryValue),
);
final winners = mapped['winners'];
if (winners is List) {
winnersByYear[year] = winners.length;
}
winnerTypeCountsByYear[year] =
parseTypeCounts(mapped['winner_type_counts']);
totalTypeCountsByYear[year] =
parseTypeCounts(mapped['total_type_counts']);
}
});
}
@@ -280,6 +645,8 @@ class StatsAbout {
...networkByYear.keys,
...stationByYear.keys,
...winnersByYear.keys,
...winnerTypeCountsByYear.keys,
...totalTypeCountsByYear.keys,
}..removeWhere((year) => year == 0);
final yearMap = <int, StatsYear>{};
@@ -291,6 +658,8 @@ class StatsAbout {
topNetworks: networkByYear[year] ?? const [],
topStations: stationByYear[year] ?? const [],
winnerCount: winnersByYear[year] ?? 0,
winnerTypeCounts: winnerTypeCountsByYear[year] ?? const {},
totalTypeCounts: totalTypeCountsByYear[year] ?? const {},
);
}
@@ -311,6 +680,8 @@ class StatsYear {
final List<StatsNetworkMileage> topNetworks;
final List<StatsStationVisits> topStations;
final int winnerCount;
final Map<String, int> winnerTypeCounts;
final Map<String, int> totalTypeCounts;
StatsYear({
required this.year,
@@ -319,6 +690,8 @@ class StatsYear {
required this.topNetworks,
required this.topStations,
required this.winnerCount,
required this.winnerTypeCounts,
required this.totalTypeCounts,
});
}
@@ -375,6 +748,7 @@ class Loco {
final String type, number, locoClass;
final String? name, operator, notes, evn;
final bool powering;
final int allocPos;
Loco({
required this.id,
@@ -386,6 +760,7 @@ class Loco {
this.notes,
this.evn,
this.powering = true,
this.allocPos = 0,
});
factory Loco.fromJson(Map<String, dynamic> json) => Loco(
@@ -398,6 +773,7 @@ class Loco {
notes: json['notes'],
evn: json['evn'],
powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
allocPos: _asInt(json['alloc_pos'], 0),
);
}
@@ -410,6 +786,9 @@ class LocoSummary extends Loco {
final String? owner;
final String? livery;
final String? location;
final String? visibility;
final String? matchedNumber;
final DateTime? matchedNumberValidTo;
final Map<String, dynamic> extra;
LocoSummary({
@@ -429,8 +808,12 @@ class LocoSummary extends Loco {
this.owner,
this.livery,
this.location,
this.visibility,
this.matchedNumber,
this.matchedNumberValidTo,
Map<String, dynamic>? extra,
super.powering = true,
super.allocPos = 0,
}) : extra = extra ?? const {},
super(
id: locoId,
@@ -443,7 +826,7 @@ class LocoSummary extends Loco {
);
factory LocoSummary.fromJson(Map<String, dynamic> json) => LocoSummary(
locoId: json['loco_id'] ?? json['id'] ?? 0,
locoId: _asInt(json['loco_id'] ?? json['id']),
locoType: json['type'] ?? json['loco_type'] ?? '',
locoNumber: json['number'] ?? json['loco_number'] ?? '',
locoName: json['name'] ?? json['loco_name'] ?? "",
@@ -464,8 +847,12 @@ class LocoSummary extends Loco {
owner: json['owner'] ?? json['loco_owner'],
livery: json['livery'],
location: json['location'],
visibility: json['visibility']?.toString(),
matchedNumber: json['matched_number']?.toString(),
matchedNumberValidTo: _asNullableDateTime(json['matched_number_valid_to']),
extra: Map<String, dynamic>.from(json),
powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
allocPos: _asInt(json['alloc_pos'], 0),
);
}
@@ -490,6 +877,8 @@ class LocoAttrVersion {
final String? precisionLevel;
final String? maskedValidFrom;
final dynamic valueNorm;
final bool isPending;
final bool canModeratePending;
const LocoAttrVersion({
required this.attrCode,
@@ -512,6 +901,8 @@ class LocoAttrVersion {
this.precisionLevel,
this.maskedValidFrom,
this.valueNorm,
this.isPending = false,
this.canModeratePending = false,
});
factory LocoAttrVersion.fromJson(Map<String, dynamic> json) {
@@ -536,6 +927,8 @@ class LocoAttrVersion {
precisionLevel: json['precision_level']?.toString(),
maskedValidFrom: json['masked_valid_from']?.toString(),
valueNorm: json['value_norm'],
isPending: json['is_pending'] == true,
canModeratePending: json['can_moderate_pending'] == true,
);
}
@@ -571,15 +964,19 @@ class LocoAttrVersion {
});
}
items.sort(
(a, b) {
final aDate = a.validFrom ?? a.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate = b.validFrom ?? b.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0);
compareByStart,
);
return items;
}
static int compareByStart(LocoAttrVersion a, LocoAttrVersion b) {
final aDate =
a.validFrom ?? a.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0);
final bDate =
b.validFrom ?? b.txnFrom ?? DateTime.fromMillisecondsSinceEpoch(0);
final dateCompare = aDate.compareTo(bDate);
if (dateCompare != 0) return dateCompare;
return a.attrCode.compareTo(b.attrCode);
},
);
return items;
}
String get valueLabel {
@@ -722,9 +1119,12 @@ class TripSummary {
final double tripMileage;
final int legCount;
final List<TripLocoStat> locoStats;
final DateTime? startDate;
final DateTime? endDate;
int get locoHadCount => locoStats.length;
int get winnersCount => locoStats.where((e) => e.won).length;
DateTime? get primaryDate => endDate ?? startDate;
TripSummary({
required this.tripId,
@@ -732,9 +1132,58 @@ class TripSummary {
required this.tripMileage,
this.legCount = 0,
List<TripLocoStat>? locoStats,
this.startDate,
this.endDate,
}) : locoStats = locoStats ?? const [];
factory TripSummary.fromJson(Map<String, dynamic> json) => TripSummary(
static int compareByDateDesc(TripSummary a, TripSummary b) =>
compareTripsByDateDesc(a.primaryDate, b.primaryDate, a.tripId, b.tripId);
factory TripSummary.fromJson(Map<String, dynamic> json) {
DateTime? startDate;
DateTime? endDate;
DateTime? parseDate(dynamic value) => _asNullableDateTime(value);
for (final key in [
'trip_begin_time',
'trip_start',
'trip_start_time',
'trip_date',
'start_date',
'date',
]) {
startDate ??= parseDate(json[key]);
}
for (final key in [
'trip_end_time',
'trip_finish_time',
'trip_end',
'end_date',
]) {
endDate ??= parseDate(json[key]);
}
if (json['trip_legs'] is List) {
for (final leg in json['trip_legs'] as List) {
DateTime? begin;
if (leg is TripLeg) {
begin = leg.beginTime;
} else if (leg is Map) {
begin = parseDate(leg['leg_begin_time']);
}
if (begin == null) continue;
if (startDate == null || begin.isBefore(startDate)) {
startDate = begin;
}
if (endDate == null || begin.isAfter(endDate)) {
endDate = begin;
}
}
}
return TripSummary(
tripId: _asInt(json['trip_id']),
tripName: _asString(json['trip_name']),
tripMileage: _asDouble(json['trip_mileage']),
@@ -745,14 +1194,20 @@ class TripSummary {
locoStats: TripLocoStat.listFromJson(
json['stats'] ?? json['trip_locos'] ?? json['locos'],
),
startDate: startDate,
endDate: endDate,
);
}
}
class Leg {
final int id, tripId, timezone, driving;
final String start, end, network, notes, headcode, user;
final String origin, destination;
final List<String> route;
final String? legShareId;
final LegShareMeta? sharedFrom;
final List<LegShareMeta> sharedTo;
final DateTime beginTime;
final DateTime? endTime;
final DateTime? originTime;
@@ -783,6 +1238,9 @@ class Leg {
this.endDelayMinutes,
this.origin = '',
this.destination = '',
this.legShareId,
this.sharedFrom,
this.sharedTo = const [],
});
factory Leg.fromJson(Map<String, dynamic> json) {
@@ -790,6 +1248,25 @@ class Leg {
final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty)
? null
: _asDateTime(endTimeRaw);
LegShareMeta? sharedFrom;
final sharedFromJson = json['shared_from'];
if (sharedFromJson is Map) {
sharedFrom = LegShareMeta.fromJson(
sharedFromJson.map((k, v) => MapEntry(k.toString(), v)),
);
}
List<LegShareMeta> sharedTo = const [];
final sharedToJson = json['shared_to'];
if (sharedToJson is List) {
sharedTo = sharedToJson
.whereType<Map>()
.map(
(e) => LegShareMeta.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
),
)
.toList();
}
return Leg(
id: _asInt(json['leg_id']),
tripId: _asInt(json['leg_trip']),
@@ -823,6 +1300,156 @@ class Leg {
: _asInt(json['leg_end_delay']),
origin: _asString(json['leg_origin']),
destination: _asString(json['leg_destination']),
legShareId: _asString(json['leg_share_id']),
sharedFrom: sharedFrom,
sharedTo: sharedTo,
);
}
}
class LegShareMeta {
final int? legShareId;
final int? legId;
final String sharedToUserId;
final String sharedByUserId;
final String status;
final DateTime? respondedAt;
final bool? acceptedEdits;
final DateTime? sharedAt;
final String sharedToUsername;
final String sharedToFullName;
final String sharedByUsername;
final String sharedByFullName;
LegShareMeta({
this.legShareId,
this.legId,
required this.sharedToUserId,
required this.sharedByUserId,
required this.status,
this.respondedAt,
this.acceptedEdits,
this.sharedAt,
this.sharedToUsername = '',
this.sharedToFullName = '',
this.sharedByUsername = '',
this.sharedByFullName = '',
});
factory LegShareMeta.fromJson(Map<String, dynamic> json) {
DateTime? parseDate(dynamic raw) {
if (raw == null) return null;
if (raw is DateTime) return raw;
return DateTime.tryParse(raw.toString());
}
return LegShareMeta(
legShareId: _asInt(json['leg_share_id']),
legId: _asInt(json['leg_id']),
sharedToUserId: _asString(json['shared_to_user_id']),
sharedByUserId: _asString(json['shared_by_user_id']),
status: _asString(json['status'], 'pending'),
respondedAt: parseDate(json['responded_at']),
acceptedEdits: json['accepted_edits'] == null
? null
: _asBool(json['accepted_edits'], false),
sharedAt: parseDate(json['shared_at']),
sharedToUsername: _asString(json['shared_to_username']),
sharedToFullName: _asString(json['shared_to_full_name']),
sharedByUsername: _asString(json['shared_by_username']),
sharedByFullName: _asString(json['shared_by_full_name']),
);
}
String get sharedFromDisplay =>
sharedByFullName.isNotEmpty ? sharedByFullName : sharedByUsername;
String get sharedToDisplay =>
sharedToFullName.isNotEmpty ? sharedToFullName : sharedToUsername;
}
class LegShareData {
final String id;
final Leg entry;
final Map<String, dynamic> metadata;
final UserSummary? requester;
final UserSummary? addressee;
final DateTime? sharedAt;
final String status;
final int? notificationId;
LegShareData({
required this.id,
required this.entry,
required this.metadata,
this.requester,
this.addressee,
this.sharedAt,
this.status = 'pending',
this.notificationId,
});
LegShareData copyWith({int? notificationId}) {
return LegShareData(
id: id,
entry: entry,
metadata: metadata,
requester: requester,
addressee: addressee,
sharedAt: sharedAt,
status: status,
notificationId: notificationId ?? this.notificationId,
);
}
String get sharedFromName =>
requester?.displayName.isNotEmpty == true ? requester!.displayName : '';
factory LegShareData.fromJson(Map<String, dynamic> json) {
final metadataRaw = json['metadata'];
final entryRaw = json['entry'];
final metadata = metadataRaw is Map
? metadataRaw.map((k, v) => MapEntry(k.toString(), v))
: <String, dynamic>{};
final shareId = _asString(
metadata['leg_share_id'] ?? metadata['share_id'] ?? json['leg_share_id'],
);
final requester = UserSummary.fromJson({
"user_id": metadata['requester_id'] ?? metadata['from_user_id'] ?? '',
"username": metadata['requester_username'] ?? '',
"full_name": metadata['requester_full_name'] ?? '',
"email": '',
});
final addressee = UserSummary.fromJson({
"user_id": metadata['addressee_id'] ?? metadata['target_user_id'] ?? '',
"username": metadata['addressee_username'] ?? '',
"full_name": metadata['addressee_full_name'] ?? '',
"email": '',
});
DateTime? sharedAt;
for (final key in ['requested_at', 'created_at', 'shared_at']) {
final value = metadata[key];
if (value != null) {
sharedAt = DateTime.tryParse(value.toString());
if (sharedAt != null) break;
}
}
final entryMap = entryRaw is Map
? entryRaw.map((k, v) => MapEntry(k.toString(), v))
: json.map((k, v) => MapEntry(k.toString(), v));
final entryObj = Leg.fromJson(entryMap);
final resolvedId = shareId.isNotEmpty
? shareId
: entryObj.legShareId ?? _asString(json['leg_share_id']);
return LegShareData(
id: resolvedId,
entry: entryObj,
metadata: metadata,
requester: requester.userId.isEmpty ? null : requester,
addressee: addressee.userId.isEmpty ? null : addressee,
sharedAt: sharedAt,
status: _asString(metadata['status'], 'pending'),
);
}
}
@@ -941,6 +1568,34 @@ class TripDetail {
int get locoHadCount => locoStats.length;
int get winnersCount => locoStats.where((e) => e.won).length;
DateTime? get startDate {
DateTime? earliest;
for (final leg in legs) {
final begin = leg.beginTime;
if (begin == null) continue;
if (earliest == null || begin.isBefore(earliest)) {
earliest = begin;
}
}
return earliest;
}
DateTime? get endDate {
DateTime? latest;
for (final leg in legs) {
final begin = leg.beginTime;
if (begin == null) continue;
if (latest == null || begin.isAfter(latest)) {
latest = begin;
}
}
return latest;
}
DateTime? get primaryDate => endDate ?? startDate;
static int compareByDateDesc(TripDetail a, TripDetail b) =>
compareTripsByDateDesc(a.primaryDate, b.primaryDate, a.id, b.id);
TripDetail({
required this.id,
@@ -1073,6 +1728,8 @@ class UserNotification {
final int id;
final String title;
final String body;
final String channel;
final String type;
final DateTime? createdAt;
final bool dismissed;
@@ -1080,6 +1737,8 @@ class UserNotification {
required this.id,
required this.title,
required this.body,
required this.channel,
required this.type,
required this.createdAt,
required this.dismissed,
});
@@ -1096,6 +1755,8 @@ class UserNotification {
id: _asInt(json['notification_id'] ?? json['id']),
title: _asString(json['title']),
body: _asString(json['body']),
channel: _asString(json['channel']),
type: _asString(json['type'] ?? json['notification_type']),
createdAt: createdAt,
dismissed: _asBool(json['dismissed'] ?? false, false),
);
@@ -1151,12 +1812,18 @@ class ClassClearanceProgress {
final int completed;
final int total;
final double percentComplete;
final int activeCompleted;
final int activeTotal;
final double activePercent;
ClassClearanceProgress({
required this.className,
required this.completed,
required this.total,
required this.percentComplete,
required this.activeCompleted,
required this.activeTotal,
required this.activePercent,
});
factory ClassClearanceProgress.fromJson(Map<String, dynamic> json) {
@@ -1174,11 +1841,34 @@ class ClassClearanceProgress {
if (percent == 0 && total > 0) {
percent = (completed / total) * 100;
}
final activeCompleted = _asInt(
json['active_completed'] ??
json['active_done'] ??
json['active_count'] ??
json['active_had'] ??
json['had_active'],
);
final activeTotal =
_asInt(json['active_total'] ??
json['active_required'] ??
json['active_goal'] ??
json['total_active']);
double activePercent = _asDouble(
json['active_percent'] ??
json['active_pct'] ??
json['active_completion'],
);
if (activePercent == 0 && activeTotal > 0) {
activePercent = (activeCompleted / activeTotal) * 100;
}
return ClassClearanceProgress(
className: name.isNotEmpty ? name : 'Class',
completed: completed,
total: total,
percentComplete: percent,
activeCompleted: activeCompleted,
activeTotal: activeTotal,
activePercent: activePercent,
);
}
}

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

@@ -2,7 +2,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
typedef TokenProvider = String? Function();
typedef UnauthorizedHandler = Future<void> Function();
typedef UnauthorizedHandler = Future<bool> Function();
class ApiService {
String _baseUrl;
@@ -36,52 +36,143 @@ class ApiService {
_client.close();
}
Map<String, String> _buildHeaders(Map<String, String>? extra) {
Map<String, String> _buildHeaders(
Map<String, String>? extra, {
bool includeAuth = true,
}) {
final token = _getToken?.call();
final headers = {'accept': 'application/json', ...?extra};
if (token != null && token.isNotEmpty) {
if (includeAuth && token != null && token.isNotEmpty) {
headers['Authorization'] = 'Bearer $token';
}
return headers;
}
Future<dynamic> get(String endpoint, {Map<String, String>? headers}) async {
final response = await _client
.get(
Future<dynamic> get(
String endpoint, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _sendWithRetry(
() => _client.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
)
.timeout(timeout);
headers: _buildHeaders(headers, includeAuth: includeAuth),
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
Future<ApiBinaryResponse> getBytes(
String endpoint, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _sendWithRetry(
() => _client.get(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers, includeAuth: includeAuth),
),
allowRetry: allowRetry,
);
if (response.statusCode >= 200 && response.statusCode < 300) {
final contentDisposition = response.headers['content-disposition'];
return ApiBinaryResponse(
bytes: response.bodyBytes,
statusCode: response.statusCode,
contentType: response.headers['content-type'],
filename: _extractFilename(contentDisposition),
);
}
final body = _decodeBody(response);
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: response.statusCode,
message: message,
body: body,
);
}
Future<dynamic> post(
String endpoint,
dynamic data, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final hasBody = data != null;
final response = await _client
.post(
final response = await _sendWithRetry(
() => _client.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
headers: _buildHeaders(
hasBody ? _jsonHeaders(headers) : headers,
includeAuth: includeAuth,
),
body: hasBody ? jsonEncode(data) : null,
)
.timeout(timeout);
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
Future<dynamic> postForm(String endpoint, Map<String, String> data) async {
final response = await _client
.post(
Future<dynamic> postMultipartFile(
String endpoint, {
required List<int> bytes,
required String filename,
String fieldName = 'file',
Map<String, String>? fields,
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
Future<http.Response> send() async {
final request = http.MultipartRequest(
'POST',
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders({
);
request.headers.addAll(_buildHeaders(headers, includeAuth: includeAuth));
if (fields != null && fields.isNotEmpty) {
request.fields.addAll(fields);
}
request.files.add(
http.MultipartFile.fromBytes(
fieldName,
bytes,
filename: filename,
),
);
final streamed = await _client.send(request);
return http.Response.fromStream(streamed);
}
final response = await _sendWithRetry(send, allowRetry: allowRetry);
return _processResponse(response);
}
Future<dynamic> postForm(
String endpoint,
Map<String, String> data, {
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _sendWithRetry(
() => _client.post(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(
{
'Content-Type': 'application/x-www-form-urlencoded',
'accept': 'application/json',
}),
},
includeAuth: includeAuth,
),
body: data, // http package handles form-encoding for Map<String, String>
)
.timeout(timeout);
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
@@ -89,28 +180,37 @@ class ApiService {
String endpoint,
dynamic data, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final hasBody = data != null;
final response = await _client
.put(
final response = await _sendWithRetry(
() => _client.put(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(hasBody ? _jsonHeaders(headers) : headers),
headers: _buildHeaders(
hasBody ? _jsonHeaders(headers) : headers,
includeAuth: includeAuth,
),
body: hasBody ? jsonEncode(data) : null,
)
.timeout(timeout);
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
Future<dynamic> delete(
String endpoint, {
Map<String, String>? headers,
bool includeAuth = true,
bool allowRetry = true,
}) async {
final response = await _client
.delete(
final response = await _sendWithRetry(
() => _client.delete(
Uri.parse('$baseUrl$endpoint'),
headers: _buildHeaders(headers),
)
.timeout(timeout);
headers: _buildHeaders(headers, includeAuth: includeAuth),
),
allowRetry: allowRetry,
);
return _processResponse(response);
}
@@ -124,10 +224,6 @@ class ApiService {
return body;
}
if (res.statusCode == 401 && _onUnauthorized != null) {
await _onUnauthorized!();
}
final message = _extractErrorMessage(body);
throw ApiException(
statusCode: res.statusCode,
@@ -176,6 +272,34 @@ class ApiService {
}
return body.toString();
}
Future<http.Response> _sendWithRetry(
Future<http.Response> Function() send, {
required bool allowRetry,
}) async {
var response = await send().timeout(timeout);
if (response.statusCode == 401 && allowRetry && _onUnauthorized != null) {
final refreshed = await _onUnauthorized!();
if (refreshed) {
response = await send().timeout(timeout);
}
}
return response;
}
String? _extractFilename(String? contentDisposition) {
if (contentDisposition == null || contentDisposition.isEmpty) return null;
final utf8Match =
RegExp(r"filename\\*=UTF-8''([^;]+)", caseSensitive: false)
.firstMatch(contentDisposition);
if (utf8Match != null) {
return Uri.decodeComponent(utf8Match.group(1) ?? '');
}
final match =
RegExp(r'filename="?([^\";]+)"?', caseSensitive: false)
.firstMatch(contentDisposition);
return match?.group(1);
}
}
class ApiException implements Exception {
@@ -192,3 +316,17 @@ class ApiException implements Exception {
@override
String toString() => 'API error $statusCode: $message';
}
class ApiBinaryResponse {
final List<int> bytes;
final int statusCode;
final String? contentType;
final String? filename;
ApiBinaryResponse({
required this.bytes,
required this.statusCode,
this.contentType,
this.filename,
});
}

View File

@@ -6,21 +6,28 @@ import 'package:mileograph_flutter/services/token_storage_service.dart';
class AuthService extends ChangeNotifier {
final ApiService api;
bool _restoring = false;
String? _accessToken;
Future<bool>? _refreshFuture;
final TokenStorageService _tokenStorage = TokenStorageService();
AuthService({required this.api}) {
api.setTokenProvider(() => token);
api.setUnauthorizedHandler(handleTokenExpired);
api.setUnauthorizedHandler(_handleUnauthorized);
}
AuthenticatedUserData? _user;
bool get isLoggedIn => _user != null;
String? get token => _user?.accessToken;
String? get token => _accessToken;
String? get userId => _user?.userId;
String? get username => _user?.username;
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({
required String userId,
@@ -28,15 +35,25 @@ class AuthService extends ChangeNotifier {
required String fullName,
required String accessToken,
required String email,
String? refreshToken,
String entriesVisibility = 'private',
String mileageVisibility = 'private',
bool isElevated = false,
bool isDisabled = false,
}) {
_accessToken = accessToken;
_user = AuthenticatedUserData(
userId: userId,
username: username,
fullName: fullName,
accessToken: accessToken,
email: email,
entriesVisibility: entriesVisibility,
mileageVisibility: mileageVisibility,
isElevated: isElevated,
disabled: isDisabled,
);
_persistToken(accessToken);
_persistTokens(accessToken, refreshToken);
notifyListeners();
}
@@ -51,8 +68,9 @@ class AuthService extends ChangeNotifier {
};
// 1. Get token
final tokenResponse = await api.postForm('/token', formData);
final tokenResponse = await api.postForm('/token', formData, includeAuth: false);
final accessToken = tokenResponse['access_token'];
final refreshToken = tokenResponse['refresh_token'];
// 2. Get user details
final userResponse = await api.get(
@@ -70,6 +88,17 @@ class AuthService extends ChangeNotifier {
fullName: userResponse['full_name'],
accessToken: accessToken,
email: userResponse['email'],
refreshToken: refreshToken,
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),
);
}
@@ -80,21 +109,29 @@ class AuthService extends ChangeNotifier {
// read token from secure storage (with fallback)
final token = await _tokenStorage.getToken();
if (token == null || token.isEmpty) return;
_accessToken = token;
final userResponse = await api.get(
'/users/me',
headers: {
'Authorization': 'Bearer $token',
'accept': 'application/json',
},
);
final restoredAccessToken = _accessToken ?? token;
setLoginData(
userId: userResponse['user_id'],
username: userResponse['username'],
fullName: userResponse['full_name'],
accessToken: token,
accessToken: restoredAccessToken,
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 (_) {
await _clearToken();
@@ -107,12 +144,9 @@ class AuthService extends ChangeNotifier {
final token = await _tokenStorage.getToken();
if (token == null || token.isEmpty) return false;
try {
_accessToken = token;
await api.get(
'/validate',
headers: {
'Authorization': 'Bearer $token',
'accept': 'application/json',
},
);
return true;
} catch (_) {
@@ -121,11 +155,15 @@ class AuthService extends ChangeNotifier {
}
}
Future<void> _persistToken(String token) async {
await _tokenStorage.setToken(token);
Future<void> _persistTokens(String accessToken, String? refreshToken) async {
await _tokenStorage.setToken(accessToken);
if (refreshToken != null && refreshToken.isNotEmpty) {
await _tokenStorage.setRefreshToken(refreshToken);
}
}
Future<void> _clearToken() async {
_accessToken = null;
await _tokenStorage.clearToken();
}
@@ -148,6 +186,61 @@ class AuthService extends ChangeNotifier {
await api.postForm('/register', formData);
}
Future<bool> _handleUnauthorized() async {
if (_refreshFuture != null) {
return _refreshFuture!;
}
_refreshFuture = _refreshTokens();
final refreshed = await _refreshFuture!;
_refreshFuture = null;
return refreshed;
}
Future<bool> _refreshTokens() async {
final refreshToken = await _tokenStorage.getRefreshToken();
if (refreshToken == null || refreshToken.isEmpty) {
await handleTokenExpired();
return false;
}
try {
final response = await api.post(
'/token/refresh',
{'refresh_token': refreshToken},
includeAuth: false,
allowRetry: false,
);
final accessToken = response['access_token'];
final newRefreshToken = response['refresh_token'];
if (accessToken is! String ||
accessToken.isEmpty ||
newRefreshToken is! String ||
newRefreshToken.isEmpty) {
await handleTokenExpired();
return false;
}
_accessToken = accessToken;
await _persistTokens(accessToken, newRefreshToken);
if (_user != null) {
_user = AuthenticatedUserData(
userId: _user!.userId,
username: _user!.username,
fullName: _user!.fullName,
accessToken: accessToken,
email: _user!.email,
entriesVisibility: _user!.entriesVisibility,
mileageVisibility: _user!.mileageVisibility,
isElevated: _user!.isElevated,
isDisabled: _user!.disabled,
);
}
notifyListeners();
return true;
} catch (_) {
await handleTokenExpired();
return false;
}
}
Future<void> handleTokenExpired() async {
_user = null;
await _clearToken();
@@ -157,4 +250,67 @@ class AuthService extends ChangeNotifier {
void logout() {
handleTokenExpired(); // reuse
}
bool _parseIsElevated(Map<String, dynamic> json) {
dynamic value = json['is_elevated'] ??
json['elevated'] ??
json['is_admin'] ??
json['admin'] ??
json['isAdmin'] ??
json['admin_user'] ??
json['role'] ??
json['roles'] ??
json['permissions'] ??
json['scopes'] ??
json['is_staff'] ??
json['staff'] ??
json['is_superuser'] ??
json['superuser'] ??
json['groups'];
bool parseBoolish(dynamic v) {
if (v is bool) return v;
if (v is num) return v != 0;
final str = v?.toString().toLowerCase().trim();
if (str == null || str.isEmpty) return false;
if (['1', 'true', 'yes', 'y', 'admin', 'superuser', 'staff', 'elevated'].contains(str)) {
return true;
}
return str.contains('admin') || str.contains('superuser') || str.contains('staff');
}
if (value is List) {
for (final entry in value) {
if (parseBoolish(entry)) return true;
final s = entry?.toString().toLowerCase();
if (s != null &&
(s.contains('admin') ||
s.contains('superuser') ||
s.contains('staff') ||
s.contains('elevated') ||
s == 'root')) {
return true;
}
}
return false;
}
return parseBoolish(value);
}
bool _parseIsDisabled(Map<String, dynamic> json) {
dynamic value = json['disabled'] ?? json['is_disabled'];
if (value is bool) return value;
if (value is num) return value != 0;
final str = value?.toString().toLowerCase().trim();
if (str == null || str.isEmpty) return false;
return ['1', 'true', 'yes', 'y', 'disabled'].contains(str);
}
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

@@ -12,3 +12,5 @@ part 'data_service_trips.dart';
part 'data_service_notifications.dart';
part 'data_service_badges.dart';
part 'data_service_stats.dart';
part 'data_service_friendships.dart';
part 'data_service_leg_share.dart';

View File

@@ -52,12 +52,15 @@ extension DataServiceBadges on DataService {
int offset = 0,
int limit = 20,
bool append = false,
bool onlyIncomplete = false,
}) async {
_isClassClearanceProgressLoading = true;
if (!append) _classClearanceProgress = [];
try {
final json =
await api.get('/badge/completion/class?limit=$limit&offset=$offset');
final onlyIncompleteParam =
onlyIncomplete ? '&only_incomplete=true' : '';
final json = await api.get(
'/badge/completion/class?limit=$limit&offset=$offset$onlyIncompleteParam',
);
List<dynamic>? list;
if (json is List) {
list = json;
@@ -80,7 +83,6 @@ extension DataServiceBadges on DataService {
_classClearanceHasMore = items.length >= limit;
} catch (e) {
debugPrint('Failed to fetch class clearance progress: $e');
if (!append) _classClearanceProgress = [];
_classClearanceHasMore = false;
} finally {
_isClassClearanceProgressLoading = false;

View File

@@ -6,6 +6,7 @@ class _LegFetchOptions {
final int sortDirection;
final String? dateRangeStart;
final String? dateRangeEnd;
final bool unallocatedOnly;
const _LegFetchOptions({
this.limit = 100,
@@ -13,6 +14,7 @@ class _LegFetchOptions {
this.sortDirection = 0,
this.dateRangeStart,
this.dateRangeEnd,
this.unallocatedOnly = false,
});
}
@@ -50,6 +52,8 @@ class DataService extends ChangeNotifier {
bool get isTractionLoading => _isTractionLoading;
bool _tractionHasMore = false;
bool get tractionHasMore => _tractionHasMore;
int _pendingLocoCount = 0;
int get pendingLocoCount => _pendingLocoCount;
List<LocoChange> _latestLocoChanges = [];
List<LocoChange> get latestLocoChanges => _latestLocoChanges;
bool _isLatestLocoChangesLoading = false;
@@ -64,6 +68,18 @@ class DataService extends ChangeNotifier {
bool isLocoTimelineLoading(int locoId) =>
_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
List<TripSummary> _trips = [];
List<TripSummary> get trips => _trips;
@@ -80,6 +96,22 @@ class DataService extends ChangeNotifier {
bool _isEventFieldsLoading = false;
bool get isEventFieldsLoading => _isEventFieldsLoading;
Future<void> fetchPendingLocoCount() async {
try {
final json = await api.get('/loco/pending?limit=1000&offset=0');
if (json is List) {
_pendingLocoCount = json.length;
} else {
_pendingLocoCount = 0;
}
} catch (e) {
debugPrint('Failed to fetch pending loco count: $e');
_pendingLocoCount = 0;
} finally {
_notifyAsync();
}
}
// Station Data
final Map<String, List<Station>> _stationCache = {};
final Map<String, Future<List<Station>>?> _stationInFlightByKey = {};
@@ -96,6 +128,10 @@ class DataService extends ChangeNotifier {
bool get isHomepageLoading => _isHomepageLoading;
bool _isOnThisDayLoading = false;
bool get isOnThisDayLoading => _isOnThisDayLoading;
List<LeaderboardEntry> _friendsLeaderboard = [];
List<LeaderboardEntry> get friendsLeaderboard => _friendsLeaderboard;
bool _isFriendsLeaderboardLoading = false;
bool get isFriendsLeaderboardLoading => _isFriendsLeaderboardLoading;
// Notifications
List<UserNotification> _notifications = [];
@@ -103,6 +139,16 @@ class DataService extends ChangeNotifier {
bool _isNotificationsLoading = false;
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
List<BadgeAward> _badgeAwards = [];
List<BadgeAward> get badgeAwards => _badgeAwards;
@@ -145,23 +191,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 {
_isHomepageLoading = true;
try {
final json = await api.get('/stats/homepage');
_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) {
debugPrint('Failed to fetch homepage stats: $e');
_homepageStats = null;
_trips = [];
_friendsLeaderboard = [];
} finally {
_isHomepageLoading = false;
_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({
int offset = 0,
int limit = 100,
@@ -170,6 +390,7 @@ class DataService extends ChangeNotifier {
String? dateRangeStart,
String? dateRangeEnd,
bool append = false,
bool unallocatedOnly = false,
}) async {
_isLegsLoading = true;
if (!append) {
@@ -179,6 +400,7 @@ class DataService extends ChangeNotifier {
sortDirection: sortDirection,
dateRangeStart: dateRangeStart,
dateRangeEnd: dateRangeEnd,
unallocatedOnly: unallocatedOnly,
);
}
final buffer = StringBuffer(
@@ -190,6 +412,9 @@ class DataService extends ChangeNotifier {
if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) {
buffer.write('&date_range_end=$dateRangeEnd');
}
if (unallocatedOnly) {
buffer.write('&unallocated_only=true');
}
try {
final json = await api.get('/user/legs${buffer.toString()}');
@@ -218,6 +443,7 @@ class DataService extends ChangeNotifier {
sortDirection: _lastLegsFetch.sortDirection,
dateRangeStart: _lastLegsFetch.dateRangeStart,
dateRangeEnd: _lastLegsFetch.dateRangeEnd,
unallocatedOnly: _lastLegsFetch.unallocatedOnly,
);
}
@@ -380,6 +606,39 @@ 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() {
_currentUserId = null;
_lastLegsFetch = const _LegFetchOptions();
@@ -412,6 +671,10 @@ class DataService extends ChangeNotifier {
_stationFiltersFetchedAt = null;
_notifications = [];
_isNotificationsLoading = false;
_userEntriesVisibility = 'private';
_userMileageVisibility = 'private';
_isPrivacyLoading = false;
_isPrivacySaving = false;
_badgeAwards = [];
_badgeAwardsHasMore = false;
_isBadgeAwardsLoading = false;
@@ -424,12 +687,25 @@ class DataService extends ChangeNotifier {
_notifyAsync();
}
void handleAuthChanged(String? userId) {
if (_currentUserId == userId) return;
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() {
final currentYear = DateTime.now().year;

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
?.whereType<Map<String, dynamic>>()
.map(UserNotification.fromJson)
.where((n) => !n.dismissed)
.where((n) => !n.dismissed && n.channel.toLowerCase() != 'web')
.toList();
if (parsed != null) {

View File

@@ -25,4 +25,38 @@ extension DataServiceStats on DataService {
_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({
bool hadOnly = false,
int offset = 0,
int limit = 50,
int limit = 100,
String? locoClass,
String? locoNumber,
bool mileageFirst = true,
@@ -64,16 +64,71 @@ extension DataServiceTraction on DataService {
_isTractionLoading = false;
_notifyAsync();
}
}
Future<List<LocoAttrVersion>> fetchLocoTimeline(int locoId) async {
Future<List<LocoAttrVersion>> fetchLocoTimeline(
int locoId, {
bool includeAllPending = false,
}) async {
_isLocoTimelineLoading[locoId] = true;
_notifyAsync();
try {
final json = await api.get('/loco/get-timeline/$locoId');
final timeline = LocoAttrVersion.fromGroupedJson(json);
_locoTimelines[locoId] = timeline;
return timeline;
final baseJson = await api.get('/loco/get-timeline/$locoId');
final timeline = LocoAttrVersion.fromGroupedJson(baseJson);
final baseKeys = 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) {
debugPrint('Failed to fetch loco timeline for $locoId: $e');
_locoTimelines[locoId] = [];
@@ -84,6 +139,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 {
try {
final response = await api.put('/loco/new', payload);
@@ -101,8 +323,9 @@ extension DataServiceTraction on DataService {
}
}
Future<List<String>> fetchClassList() async {
if (_locoClasses.isNotEmpty) return _locoClasses;
Future<List<String>> fetchClassList({bool force = false}) async {
if (!force && _locoClasses.isNotEmpty) return _locoClasses;
if (force) _locoClasses = [];
try {
final json = await api.get('/loco/classlist');
if (json is List) {
@@ -179,4 +402,157 @@ extension DataServiceTraction on DataService {
}
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', {
'from_loco_id': fromLocoId,
'to_loco_id': toLocoId,
});
} catch (e) {
debugPrint('Failed to transfer allocations $fromLocoId -> $toLocoId: $e');
rethrow;
}
}
Future<void> transferAllAllocations({
required int fromLocoId,
required int toLocoId,
}) async {
try {
await api.post('/loco/alloc/transfer?transferAll=true', {
'from_loco_id': fromLocoId,
'to_loco_id': toLocoId,
});
} catch (e) {
debugPrint(
'Failed to transfer all 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

@@ -6,7 +6,7 @@ extension DataServiceTrips on DataService {
try {
final json = await api.get('/trips/info');
final tripDetails = _parseTripInfoList(json);
_tripDetails = [...tripDetails]..sort((a, b) => b.id.compareTo(a.id));
_tripDetails = [...tripDetails]..sort(TripDetail.compareByDateDesc);
_tripList = tripDetails
.map(
(detail) => TripSummary(
@@ -15,10 +15,12 @@ extension DataServiceTrips on DataService {
tripMileage: detail.mileage,
legCount: detail.legCount,
locoStats: detail.locoStats,
startDate: detail.startDate,
endDate: detail.endDate,
),
)
.toList()
..sort((a, b) => b.tripId.compareTo(a.tripId));
..sort(TripSummary.compareByDateDesc);
} catch (e) {
debugPrint('Failed to fetch trip_map: $e');
_tripDetails = [];
@@ -49,7 +51,7 @@ extension DataServiceTrips on DataService {
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId));
_tripList = [...tripMap]..sort(TripSummary.compareByDateDesc);
} else {
debugPrint('Unexpected trip list response: $json');
_tripList = [];
@@ -83,7 +85,7 @@ extension DataServiceTrips on DataService {
.map((e) => TripSummary.fromJson(e))
.toList();
_tripList = [...tripMap]..sort((a, b) => b.tripId.compareTo(a.tripId));
_tripList = [...tripMap]..sort(TripSummary.compareByDateDesc);
} else {
debugPrint('Unexpected trip list response: $json');
_tripList = [];
@@ -105,7 +107,7 @@ extension DataServiceTrips on DataService {
} else {
_tripList = [trip, ..._tripList];
}
_tripList.sort((a, b) => b.tripId.compareTo(a.tripId));
_tripList.sort(TripSummary.compareByDateDesc);
_notifyAsync();
}

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

@@ -10,7 +10,8 @@ class TokenStorageService {
factory TokenStorageService() => _instance;
static const _tokenKey = 'auth_token';
static const _accessTokenKey = 'auth_token';
static const _refreshTokenKey = 'refresh_token';
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
Future<SharedPreferences> get _prefs async =>
@@ -18,17 +19,17 @@ class TokenStorageService {
Future<void> setToken(String token) async {
try {
await _secureStorage.write(key: _tokenKey, value: token);
await _secureStorage.write(key: _accessTokenKey, value: token);
} catch (_) {
// ignore secure storage failures in debug/unsupported environments
}
final prefs = await _prefs;
await prefs.setString(_tokenKey, token);
await prefs.setString(_accessTokenKey, token);
}
Future<String?> getToken() async {
try {
final secured = await _secureStorage.read(key: _tokenKey);
final secured = await _secureStorage.read(key: _accessTokenKey);
if (secured != null && secured.isNotEmpty) {
return secured;
}
@@ -36,22 +37,48 @@ class TokenStorageService {
// ignore and fall back
}
final prefs = await _prefs;
final token = prefs.getString(_tokenKey);
final token = prefs.getString(_accessTokenKey);
return (token == null || token.isEmpty) ? null : token;
}
Future<void> clearToken() async {
try {
await _secureStorage.delete(key: _tokenKey);
await _secureStorage.delete(key: _accessTokenKey);
await _secureStorage.delete(key: _refreshTokenKey);
} catch (_) {
// ignore
}
final prefs = await _prefs;
await prefs.remove(_tokenKey);
await prefs.remove(_accessTokenKey);
await prefs.remove(_refreshTokenKey);
}
Future<bool> hasToken() async {
final token = await getToken();
return token != null && token.isNotEmpty;
}
Future<void> setRefreshToken(String token) async {
try {
await _secureStorage.write(key: _refreshTokenKey, value: token);
} catch (_) {
// ignore secure storage failures in debug/unsupported environments
}
final prefs = await _prefs;
await prefs.setString(_refreshTokenKey, token);
}
Future<String?> getRefreshToken() async {
try {
final secured = await _secureStorage.read(key: _refreshTokenKey);
if (secured != null && secured.isNotEmpty) {
return secured;
}
} catch (_) {
// ignore and fall back
}
final prefs = await _prefs;
final token = prefs.getString(_refreshTokenKey);
return (token == null || token.isEmpty) ? null : token;
}
}

View File

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@@ -11,15 +13,25 @@ import 'package:mileograph_flutter/components/pages/loco_legs.dart';
import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
import 'package:mileograph_flutter/components/pages/logbook.dart';
import 'package:mileograph_flutter/components/pages/more.dart';
import 'package:mileograph_flutter/components/pages/badges.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/profile.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/stats.dart';
import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/pages/traction/traction_pending_page.dart';
import 'package:mileograph_flutter/components/pages/traction/traction_pending_changes_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/accent_color_service.dart';
import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart';
import 'package:mileograph_flutter/services/theme_mode_service.dart';
import 'package:provider/provider.dart';
final GlobalKey<NavigatorState> _shellNavigatorKey =
@@ -94,12 +106,6 @@ class _MyAppState extends State<MyApp> {
late final GoRouter _router;
bool _routerInitialized = false;
final ColorScheme defaultLight = ColorScheme.fromSeed(seedColor: Colors.red);
final ColorScheme defaultDark = ColorScheme.fromSeed(
seedColor: Colors.red,
brightness: Brightness.dark,
);
@override
void didChangeDependencies() {
super.didChangeDependencies();
@@ -162,7 +168,81 @@ class _MyAppState extends State<MyApp> {
),
GoRoute(
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 transferFromLabel = state.uri.queryParameters['transferFromLabel'] ??
(state.extra is Map
? (state.extra as Map)['transferFromLabel']?.toString()
: null);
bool transferAllAllocations = false;
final transferAllParam =
state.uri.queryParameters['transferAll'] ??
(state.extra is Map
? (state.extra as Map)['transferAll']?.toString()
: null);
if (transferAllParam != null) {
transferAllAllocations = transferAllParam.toLowerCase() == 'true' ||
transferAllParam == '1';
}
if (!transferAllAllocations && state.extra is Map) {
final raw = (state.extra as Map)['transferAll'];
if (raw is bool) {
transferAllAllocations = raw;
}
}
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,
transferFromLabel: transferFromLabel,
transferFromLocoId: transferFromLocoId,
transferAllAllocations: transferAllAllocations,
);
},
),
GoRoute(
path: '/traction/pending',
builder: (context, state) => const TractionPendingPage(),
),
GoRoute(
path: '/traction/changes',
builder: (context, state) =>
const TractionPendingChangesPage(),
),
GoRoute(
path: '/profile',
@@ -181,7 +261,15 @@ class _MyAppState extends State<MyApp> {
label = extra;
}
if (label.trim().isEmpty) label = 'Loco $locoId';
return LocoTimelinePage(locoId: locoId, locoLabel: label);
bool showPending = false;
if (extra is Map && extra['showPending'] is bool) {
showPending = extra['showPending'] as bool;
}
return LocoTimelinePage(
locoId: locoId,
locoLabel: label,
forceShowPending: showPending,
);
},
),
GoRoute(
@@ -204,7 +292,15 @@ class _MyAppState extends State<MyApp> {
path: '/traction/new',
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(
path: '/more',
builder: (context, state) => const MorePage(),
@@ -213,6 +309,32 @@ class _MyAppState extends State<MyApp> {
path: '/more/profile',
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(),
@@ -221,6 +343,10 @@ class _MyAppState extends State<MyApp> {
path: '/more/settings',
builder: (context, state) => const SettingsPage(),
),
GoRoute(
path: '/more/admin',
builder: (context, state) => const AdminPage(),
),
GoRoute(
path: '/legs/edit/:id',
builder: (_, state) {
@@ -245,20 +371,34 @@ class _MyAppState extends State<MyApp> {
@override
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(
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(
title: 'Mileograph',
routerConfig: _router,
theme: ThemeData(
useMaterial3: true,
colorScheme: lightDynamic ?? defaultLight,
colorScheme: colorSchemeLight,
),
darkTheme: ThemeData(
useMaterial3: true,
colorScheme: darkDynamic ?? defaultDark,
colorScheme: colorSchemeDark,
),
themeMode: ThemeMode.system,
themeMode: themeModeService.mode,
);
},
);
@@ -307,6 +447,7 @@ class _MyHomePageState extends State<MyHomePage> {
bool _fetched = false;
bool _railCollapsed = false;
Timer? _notificationsTimer;
@override
void didChangeDependencies() {
@@ -343,10 +484,35 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.notifications.isEmpty) {
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
Widget build(BuildContext context) {
final uri = GoRouterState.of(context).uri;
@@ -388,10 +554,7 @@ class _MyHomePageState extends State<MyHomePage> {
)
.toList();
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text.rich(
final logo = Text.rich(
TextSpan(
children: const [
TextSpan(text: "Mile"),
@@ -407,9 +570,25 @@ class _MyHomePageState extends State<MyHomePage> {
fontFamily: "Tomatoes",
),
),
);
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: isWide
? logo
: FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: logo,
),
actions: [
_buildNotificationAction(context, data),
IconButton(
tooltip: 'Profile',
onPressed: () => context.go('/more/profile'),
icon: const Icon(Icons.person),
),
IconButton(
tooltip: 'Settings',
onPressed: () => context.go('/more/settings'),
@@ -620,6 +799,13 @@ class _MyHomePageState extends State<MyHomePage> {
Widget _buildNotificationsContent(BuildContext context, bool isWide) {
final data = context.watch<DataService>();
final notifications = data.notifications;
final dismissibleIds = notifications
.where(
(n) =>
!_isFriendRequestNotification(n) && !_isLegShareNotification(n),
)
.map((e) => e.id)
.toList();
final loading = data.isNotificationsLoading;
final listHeight = isWide
? 380.0
@@ -646,6 +832,10 @@ class _MyHomePageState extends State<MyHomePage> {
separatorBuilder: (_, index) => const SizedBox(height: 8),
itemBuilder: (ctx, index) {
final item = notifications[index];
final isFriendRequest = _isFriendRequestNotification(item);
final isLegShare = _isLegShareNotification(item);
final isLegShareEdit = _isLegShareEditNotification(item);
final isSpecial = isFriendRequest || isLegShare || isLegShareEdit;
return Card(
child: Padding(
padding: const EdgeInsets.all(12.0),
@@ -668,7 +858,13 @@ class _MyHomePageState extends State<MyHomePage> {
),
const SizedBox(height: 4),
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,
),
if (item.createdAt != null) ...[
@@ -694,6 +890,7 @@ class _MyHomePageState extends State<MyHomePage> {
],
),
),
if (!isSpecial) ...[
const SizedBox(width: 8),
TextButton(
onPressed: () =>
@@ -701,6 +898,26 @@ class _MyHomePageState extends State<MyHomePage> {
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),
),
],
),
@@ -731,10 +948,7 @@ class _MyHomePageState extends State<MyHomePage> {
TextButton(
onPressed: notifications.isEmpty
? null
: () => _dismissNotifications(
context,
notifications.map((e) => e.id).toList(),
),
: () => _dismissNotifications(context, dismissibleIds),
child: const Text('Dismiss all'),
),
],
@@ -769,6 +983,44 @@ class _MyHomePageState extends State<MyHomePage> {
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) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
@@ -895,4 +1147,10 @@ class _MyHomePageState extends State<MyHomePage> {
_forwardHistory.clear();
context.go(tabDestinations[index]);
}
@override
void dispose() {
_notificationsTimer?.cancel();
super.dispose();
}
}

View File

@@ -0,0 +1,29 @@
import 'dart:typed_data';
import 'package:file_selector/file_selector.dart';
class SaveResult {
final String? path;
final bool canceled;
const SaveResult({this.path, required this.canceled});
}
Future<SaveResult> saveBytes(
Uint8List bytes,
String filename, {
String? mimeType,
}) async {
final safeName = filename.trim().isEmpty ? 'export.bin' : filename.trim();
final location = await getSaveLocation(suggestedName: safeName);
if (location == null) {
return const SaveResult(canceled: true);
}
final file = XFile.fromData(
bytes,
mimeType: mimeType ?? 'application/octet-stream',
name: safeName,
);
await file.saveTo(location.path);
return SaveResult(path: location.path, canceled: false);
}

View File

@@ -4,10 +4,10 @@ project(runner LANGUAGES CXX)
# The name of the executable created for the application. Change this to change
# the on-disk name of your application.
set(BINARY_NAME "mileograph_flutter")
set(BINARY_NAME "Mileograph")
# The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
set(APPLICATION_ID "com.example.mileograph_flutter")
set(APPLICATION_ID "com.petegregoryy.mileograph_flutter")
# Explicitly opt in to modern CMake behaviors to avoid warnings with recent
# versions of CMake.

View File

@@ -7,12 +7,16 @@
#include "generated_plugin_registrant.h"
#include <dynamic_color/dynamic_color_plugin.h>
#include <file_selector_linux/file_selector_plugin.h>
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
void fl_register_plugins(FlPluginRegistry* registry) {
g_autoptr(FlPluginRegistrar) dynamic_color_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "DynamicColorPlugin");
dynamic_color_plugin_register_with_registrar(dynamic_color_registrar);
g_autoptr(FlPluginRegistrar) file_selector_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin");
file_selector_plugin_register_with_registrar(file_selector_linux_registrar);
g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar =
fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin");
flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar);

View File

@@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
dynamic_color
file_selector_linux
flutter_secure_storage_linux
)

View File

@@ -6,12 +6,14 @@ import FlutterMacOS
import Foundation
import dynamic_color
import file_selector_macos
import flutter_secure_storage_darwin
import path_provider_foundation
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
DynamicColorPlugin.register(with: registry.registrar(forPlugin: "DynamicColorPlugin"))
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))

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