Compare commits

..

19 Commits

Author SHA1 Message Date
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
33 changed files with 4490 additions and 987 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

@@ -13,6 +13,7 @@ env:
BUILD_WINDOWS: "false" # Windows build disabled (no runner available) BUILD_WINDOWS: "false" # Windows build disabled (no runner available)
GITEA_BASE_URL: https://git.tgj.services GITEA_BASE_URL: https://git.tgj.services
WEB_IMAGE: "git.tgj.services/petegregoryy/mileograph-web" WEB_IMAGE: "git.tgj.services/petegregoryy/mileograph-web"
REGISTRY: git.tgj.services
jobs: jobs:
meta: meta:
@@ -80,21 +81,21 @@ jobs:
android-build: android-build:
runs-on: runs-on:
- mileograph - tgj-arc
needs: meta needs: meta
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
#
- name: Install OS deps (Android) # - name: Install OS deps (Android)
run: | # run: |
if command -v sudo >/dev/null 2>&1; then # if command -v sudo >/dev/null 2>&1; then
SUDO="sudo" # SUDO="sudo"
else # else
SUDO="" # SUDO=""
fi # fi
$SUDO apt-get update # $SUDO apt-get update
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq # $SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq
- name: Setup Java - name: Setup Java
uses: actions/setup-java@v4 uses: actions/setup-java@v4
@@ -120,19 +121,19 @@ jobs:
echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH" echo "$ANDROID_SDK_ROOT/platform-tools" >> "$GITHUB_PATH"
echo "$ANDROID_SDK_ROOT/build-tools/33.0.2" >> "$GITHUB_PATH" echo "$ANDROID_SDK_ROOT/build-tools/33.0.2" >> "$GITHUB_PATH"
- name: Install Flutter SDK # - name: Install Flutter SDK
run: | # run: |
set -euo pipefail # set -euo pipefail
FLUTTER_HOME="$HOME/flutter" # FLUTTER_HOME="$HOME/flutter"
# Avoid git ownership issues when Flutter checks out deps. # # Avoid git ownership issues when Flutter checks out deps.
git config --global --add safe.directory "$FLUTTER_HOME" || true # git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then # if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME" # rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz" # curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
tar -C "$HOME" -xf /tmp/flutter.tar.xz # tar -C "$HOME" -xf /tmp/flutter.tar.xz
fi # fi
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH" # echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
"$FLUTTER_HOME/bin/flutter" --version # "$FLUTTER_HOME/bin/flutter" --version
- name: Allow all git directories (CI) - name: Allow all git directories (CI)
run: git config --global --add safe.directory '*' run: git config --global --add safe.directory '*'
@@ -221,36 +222,12 @@ jobs:
linux-build: linux-build:
runs-on: runs-on:
- mileograph - tgj-arc
needs: meta needs: meta
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install OS deps (Linux desktop)
run: |
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 libglu1-mesa clang cmake ninja-build pkg-config libgtk-3-dev libsecret-1-dev liblzma-dev curl jq
- name: Install Flutter SDK
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
# 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) - name: Allow all git directories (CI)
run: git config --global --add safe.directory '*' run: git config --global --add safe.directory '*'
@@ -276,38 +253,12 @@ jobs:
web-build: web-build:
runs-on: runs-on:
- mileograph - tgj-arc
needs: meta needs: meta
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Install OS deps (Web)
run: |
if command -v sudo >/dev/null 2>&1; then
SUDO="sudo"
else
SUDO=""
fi
$SUDO apt-get update
$SUDO apt-get install -y unzip xz-utils zip libstdc++6 liblzma-dev curl jq docker.io
if ! docker info >/dev/null 2>&1; then
$SUDO systemctl start docker 2>/dev/null || $SUDO service docker start 2>/dev/null || true
fi
- name: Install Flutter SDK
run: |
set -euo pipefail
FLUTTER_HOME="$HOME/flutter"
git config --global --add safe.directory "$FLUTTER_HOME" || true
if [ ! -x "$FLUTTER_HOME/bin/flutter" ]; then
rm -rf "$FLUTTER_HOME"
curl -fsSL -o /tmp/flutter.tar.xz "https://storage.googleapis.com/flutter_infra_release/releases/stable/linux/flutter_linux_${FLUTTER_VERSION}-stable.tar.xz"
tar -C "$HOME" -xf /tmp/flutter.tar.xz
fi
echo "$FLUTTER_HOME/bin" >> "$GITHUB_PATH"
"$FLUTTER_HOME/bin/flutter" --version
- name: Allow all git directories (CI) - name: Allow all git directories (CI)
run: git config --global --add safe.directory '*' run: git config --global --add safe.directory '*'
@@ -331,50 +282,38 @@ jobs:
name: web-build name: web-build
path: app-web.tar.gz path: app-web.tar.gz
- name: Compute web image tags - name: Set up docker buildx
id: web_meta uses: docker/setup-buildx-action@v3
env:
BASE_VERSION: ${{ needs.meta.outputs.base_version }}
DEV_SUFFIX: ${{ needs.meta.outputs.dev_suffix }}
run: |
IMAGE="${WEB_IMAGE}"
TAG=""
ALIAS=""
if [ "${GITHUB_REF}" = "refs/heads/dev" ]; then
TAG="${BASE_VERSION}${DEV_SUFFIX}"
ALIAS="dev"
elif [ "${GITHUB_REF}" = "refs/heads/master" ]; then
TAG="${BASE_VERSION}"
ALIAS="latest"
fi
echo "image=${IMAGE}" >> "$GITHUB_OUTPUT" - name: Docker meta
echo "tag=${TAG}" >> "$GITHUB_OUTPUT" id: dmeta
echo "alias=${ALIAS}" >> "$GITHUB_OUTPUT" 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 registry - name: Login to the docker registry
if: ${{ secrets.DOCKERHUB_TOKEN != '' && steps.web_meta.outputs.tag != '' }} uses: docker/login-action@v3
env: with:
REGISTRY_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} registry: ${{ env.REGISTRY }}
run: | username: petegregoryy
echo "$REGISTRY_TOKEN" | docker login git.tgj.services -u petegregoryy --password-stdin password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push web image - name: Build and push
if: ${{ secrets.DOCKERHUB_TOKEN != '' && steps.web_meta.outputs.tag != '' }} id: docker_build
env: uses: docker/build-push-action@v6
IMAGE: ${{ steps.web_meta.outputs.image }} with:
TAG: ${{ steps.web_meta.outputs.tag }} file: Dockerfile.web
ALIAS: ${{ steps.web_meta.outputs.alias }} context: .
run: | push: true
docker buildx create --name buildx --driver=docker-container --use || docker buildx use buildx tags: ${{ steps.dmeta.outputs.tags }}
TAG_ARGS=(-t "${IMAGE}:${TAG}") labels: ${{ steps.dmeta.outputs.labels }}
if [ -n "$ALIAS" ]; then cache-from: type=registry,ref=${{ env.REGISTRY }}/petegregoryy/railframe-web:buildcache
TAG_ARGS+=(-t "${IMAGE}:${ALIAS}") cache-to: type=registry,ref=${{ env.REGISTRY }}/petegregoryy/railframe-web:buildcache,mode=max
fi
docker buildx build --builder buildx --platform linux/amd64 \
-f Dockerfile.web \
--push \
"${TAG_ARGS[@]}" .
release-dev: release-dev:
runs-on: runs-on:

View File

@@ -2,6 +2,7 @@
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. 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.
## Features ## Features
- Add and edit journey legs with traction, timings, routes, notes, and delays. - Add and edit journey legs with traction, timings, routes, notes, and delays.
- Group legs into trips and see mileage totals and traction stats. - Group legs into trips and see mileage totals and traction stats.

View File

@@ -1,3 +1,4 @@
import java.io.File
import java.util.Properties import java.util.Properties
plugins { plugins {
@@ -22,8 +23,21 @@ val releaseKeyAlias = System.getenv("ANDROID_KEY_ALIAS")
val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD") val releaseKeyPassword = System.getenv("ANDROID_KEY_PASSWORD")
?: keystoreProperties.getProperty("keyPassword") ?: keystoreProperties.getProperty("keyPassword")
val releaseStoreFilePath: File? = releaseStoreFile?.let { path ->
val candidate = File(path)
if (candidate.isAbsolute) return@let candidate
val candidates = listOfNotNull(
rootProject.file(path),
rootProject.projectDir.parentFile?.let { File(it, path) },
project.file(path),
)
candidates.firstOrNull { it.exists() }
}
val hasReleaseKeystore = listOf( val hasReleaseKeystore = listOf(
releaseStoreFile, releaseStoreFilePath?.path,
releaseStorePassword, releaseStorePassword,
releaseKeyAlias, releaseKeyAlias,
releaseKeyPassword releaseKeyPassword
@@ -31,7 +45,7 @@ val hasReleaseKeystore = listOf(
android { android {
namespace = "com.example.mileograph_flutter" namespace = "com.example.mileograph_flutter"
compileSdk = flutter.compileSdkVersion compileSdk = 36
ndkVersion = "27.0.12077973" ndkVersion = "27.0.12077973"
compileOptions { compileOptions {
@@ -48,8 +62,8 @@ android {
applicationId = "com.petegregoryy.mileograph_flutter" applicationId = "com.petegregoryy.mileograph_flutter"
// You can update the following values to match your application needs. // You can update the following values to match your application needs.
// For more information, see: https://flutter.dev/to/review-gradle-config. // For more information, see: https://flutter.dev/to/review-gradle-config.
minSdk = flutter.minSdkVersion minSdk = 24
targetSdk = flutter.targetSdkVersion targetSdk = 36
versionCode = flutter.versionCode versionCode = flutter.versionCode
versionName = flutter.versionName versionName = flutter.versionName
} }
@@ -57,7 +71,7 @@ android {
signingConfigs { signingConfigs {
if (hasReleaseKeystore) { if (hasReleaseKeystore) {
create("release") { create("release") {
storeFile = file(releaseStoreFile!!) storeFile = releaseStoreFilePath
storePassword = releaseStorePassword storePassword = releaseStorePassword
keyAlias = releaseKeyAlias!! keyAlias = releaseKeyAlias!!
keyPassword = releaseKeyPassword keyPassword = releaseKeyPassword

View File

@@ -366,6 +366,16 @@ class _RouteCalculatorState extends State<RouteCalculator> {
spacing: 12, spacing: 12,
runSpacing: 8, runSpacing: 8,
children: [ children: [
ElevatedButton.icon(
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
onPressed: () async {
setState(() {
data.stations = data.stations.reversed.toList();
});
await _calculateRoute(data.stations);
},
),
ElevatedButton.icon( ElevatedButton.icon(
icon: const Icon(Icons.add), icon: const Icon(Icons.add),
label: const Text('Add Station'), label: const Text('Add Station'),

View File

@@ -1,19 +1,31 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/distance_unit_service.dart'; import 'package:mileograph_flutter/services/distance_unit_service.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
enum _LeaderboardScope { global, friends }
class LeaderboardPanel extends StatelessWidget { class LeaderboardPanel extends StatefulWidget {
const LeaderboardPanel({super.key}); const LeaderboardPanel({super.key});
@override
State<LeaderboardPanel> createState() => _LeaderboardPanelState();
}
class _LeaderboardPanelState extends State<LeaderboardPanel> {
_LeaderboardScope _scope = _LeaderboardScope.global;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final distanceUnits = context.watch<DistanceUnitService>(); final distanceUnits = context.watch<DistanceUnitService>();
final leaderboard = data.homepageStats?.leaderboard ?? []; final leaderboard = _scope == _LeaderboardScope.global
? (data.homepageStats?.leaderboard ?? [])
: data.friendsLeaderboard;
final loading = _scope == _LeaderboardScope.global
? data.isHomepageLoading
: data.isFriendsLeaderboardLoading;
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
if (data.isHomepageLoading && leaderboard.isEmpty) { if (loading && leaderboard.isEmpty) {
return const Padding( return const Padding(
padding: EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: Center(child: CircularProgressIndicator()), child: Center(child: CircularProgressIndicator()),
@@ -38,7 +50,8 @@ class LeaderboardPanel extends StatelessWidget {
), ),
), ),
), ),
if (leaderboard.isNotEmpty) if (leaderboard.isNotEmpty &&
MediaQuery.of(context).size.width > 600)
Container( Container(
padding: padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 4), const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
@@ -54,6 +67,40 @@ class LeaderboardPanel extends StatelessWidget {
], ],
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Center(
child: SegmentedButton<_LeaderboardScope>(
segments: const [
ButtonSegment(
value: _LeaderboardScope.global,
label: Text('Global'),
),
ButtonSegment(
value: _LeaderboardScope.friends,
label: Text('Friends'),
),
],
selected: {_scope},
onSelectionChanged: (vals) async {
if (vals.isEmpty) return;
final selected = vals.first;
setState(() => _scope = selected);
if (selected == _LeaderboardScope.friends &&
data.friendsLeaderboard.isEmpty &&
!data.isFriendsLeaderboardLoading) {
await data.fetchFriendsLeaderboard();
} else if (selected == _LeaderboardScope.global &&
(data.homepageStats?.leaderboard.isEmpty ?? true) &&
!data.isHomepageLoading) {
await data.fetchHomepageStats();
}
},
style: SegmentedButton.styleFrom(
visualDensity: VisualDensity.compact,
padding: const EdgeInsets.symmetric(horizontal: 8),
),
),
),
const SizedBox(height: 8),
if (leaderboard.isEmpty) if (leaderboard.isEmpty)
const Padding( const Padding(
padding: EdgeInsets.all(8.0), padding: EdgeInsets.all(8.0),

View File

@@ -27,15 +27,22 @@ class _LegCardState extends State<LegCard> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final leg = widget.leg; final leg = widget.leg;
final isShared = leg.legShareId != null && leg.legShareId!.isNotEmpty;
final sharedFrom = leg.sharedFrom;
final sharedTo = leg.sharedTo;
final distanceUnits = context.watch<DistanceUnitService>(); final distanceUnits = context.watch<DistanceUnitService>();
final routeSegments = _parseRouteSegments(leg.route); final routeSegments = _parseRouteSegments(leg.route);
final textTheme = Theme.of(context).textTheme; final textTheme = Theme.of(context).textTheme;
return Card( return Card(
child: ExpansionTile( clipBehavior: Clip.antiAlias,
onExpansionChanged: (v) => setState(() => _expanded = v), elevation: 1,
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), child: Theme(
leading: const Icon(Icons.train), data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
title: LayoutBuilder( child: ExpansionTile(
onExpansionChanged: (v) => setState(() => _expanded = v),
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
leading: const Icon(Icons.train),
title: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth > 520; final isWide = constraints.maxWidth > 520;
final beginTimeWidget = _timeWithDelay( final beginTimeWidget = _timeWithDelay(
@@ -108,7 +115,7 @@ class _LegCardState extends State<LegCard> {
); );
}, },
), ),
subtitle: LayoutBuilder( subtitle: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final isWide = constraints.maxWidth > 520; final isWide = constraints.maxWidth > 520;
final tractionWrap = !_expanded && leg.locos.isNotEmpty final tractionWrap = !_expanded && leg.locos.isNotEmpty
@@ -168,7 +175,7 @@ class _LegCardState extends State<LegCard> {
); );
}, },
), ),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Column( Column(
@@ -193,6 +200,10 @@ class _LegCardState extends State<LegCard> {
], ],
], ],
), ),
if (isShared || sharedFrom != null || (sharedTo.isNotEmpty)) ...[
const SizedBox(width: 8),
_SharedIcons(sharedFrom: sharedFrom, sharedTo: sharedTo, isShared: isShared),
],
if (widget.showEditButton) ...[ if (widget.showEditButton) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
IconButton( IconButton(
@@ -217,12 +228,12 @@ class _LegCardState extends State<LegCard> {
], ],
], ],
], ],
), ),
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12), padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
if (leg.notes.isNotEmpty) ...[ if (leg.notes.isNotEmpty) ...[
Text('Notes', style: textTheme.titleSmall), Text('Notes', style: textTheme.titleSmall),
@@ -235,26 +246,55 @@ class _LegCardState extends State<LegCard> {
const SizedBox(height: 6), const SizedBox(height: 6),
Wrap( Wrap(
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: _buildLocoChips(context, leg), children: _buildLocoChips(context, leg),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
if (_hasTrainDetails(leg)) ...[ if (_hasTrainDetails(leg)) ...[
Text('Train', style: textTheme.titleSmall), Text('Train', style: textTheme.titleSmall),
const SizedBox(height: 6), const SizedBox(height: 6),
..._buildTrainDetails(leg, textTheme), ..._buildTrainDetails(leg, textTheme),
const SizedBox(height: 12), const SizedBox(height: 12),
], ],
if (sharedFrom != null)
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Text(
'Shared from ${sharedFrom.sharedFromDisplay.isNotEmpty ? sharedFrom.sharedFromDisplay : 'another user'}.',
style: textTheme.bodyMedium,
),
),
if (sharedTo.isNotEmpty) ...[
Text(
'Shared to:',
style: textTheme.bodyMedium,
),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 4,
children: sharedTo
.map((s) => Chip(
label: Text(s.sharedToDisplay.isNotEmpty
? s.sharedToDisplay
: s.sharedToUserId),
visualDensity: VisualDensity.compact,
))
.toList(),
),
const SizedBox(height: 12),
],
if (routeSegments.isNotEmpty) ...[ if (routeSegments.isNotEmpty) ...[
Text('Route', style: textTheme.titleSmall), Text('Route', style: textTheme.titleSmall),
const SizedBox(height: 6), const SizedBox(height: 6),
_buildRouteList(routeSegments), _buildRouteList(routeSegments),
], ],
], ],
),
), ),
), ],
], ),
), ),
); );
} }
@@ -444,3 +484,61 @@ class _LegCardState extends State<LegCard> {
return route.map((e) => e.toString()).where((e) => e.trim().isNotEmpty).toList(); 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

@@ -294,6 +294,7 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _inviteController = TextEditingController(); final _inviteController = TextEditingController();
bool _registering = false; bool _registering = false;
String? _usernameError;
@override @override
void dispose() { void dispose() {
@@ -306,10 +307,21 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
} }
Future<void> _register() async { Future<void> _register() async {
final username = _usernameController.text.trim();
if (username.contains(' ') || username.contains('@')) {
setState(() {
_usernameError = 'Username cannot contain spaces or @';
});
return;
} else {
setState(() {
_usernameError = null;
});
}
setState(() => _registering = true); setState(() => _registering = true);
try { try {
await widget.authService.register( await widget.authService.register(
username: _usernameController.text.trim(), username: username,
email: _emailController.text.trim(), email: _emailController.text.trim(),
fullName: _displayNameController.text.trim(), fullName: _displayNameController.text.trim(),
password: _passwordController.text, password: _passwordController.text,
@@ -363,7 +375,22 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
labelText: "Username", labelText: "Username",
), ),
onChanged: (_) {
if (_usernameError != null &&
!_usernameController.text.contains(' ') &&
!_usernameController.text.contains('@')) {
setState(() => _usernameError = null);
}
},
), ),
if (_usernameError != null)
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
_usernameError!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
TextField( TextField(
controller: _displayNameController, controller: _displayNameController,

View File

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

View File

@@ -27,7 +27,7 @@ class _DashboardState extends State<Dashboard> {
final distanceUnits = context.watch<DistanceUnitService>(); final distanceUnits = context.watch<DistanceUnitService>();
final stats = data.homepageStats; final stats = data.homepageStats;
final isInitialLoading = data.isHomepageLoading || stats == null; final isInitialLoading = data.isHomepageLoading && stats == null;
return RefreshIndicator( return RefreshIndicator(
onRefresh: () async { onRefresh: () async {

View File

@@ -1,83 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/components/pages/profile.dart'; import 'package:mileograph_flutter/components/pages/more/more_home_page.dart';
import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/stats.dart'; export 'more/admin_page.dart';
class MorePage extends StatelessWidget { class MorePage extends StatelessWidget {
const MorePage({super.key}); const MorePage({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Navigator( return const MoreHomePage();
onGenerateRoute: (settings) {
final name = settings.name ?? '/';
Widget page;
switch (name) {
case '/settings':
page = const SettingsPage();
break;
case '/profile':
page = const ProfilePage();
break;
case '/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 {
const _MoreHome({super.key});
@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'),
),
],
),
),
],
);
} }
} }

View File

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

View File

@@ -0,0 +1,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

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

View File

@@ -4,14 +4,24 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
Future<bool> _handleExitIntent() async { Future<bool> _handleExitIntent() async {
if (!mounted) return false; if (!mounted) return false;
if (_isEditing) return true; if (_isEditing) return true;
if (_activeLegShare != null) return true;
if (_formIsEmpty()) return true; if (_formIsEmpty()) return true;
if (_activeDraftId != null && !_draftChangedFromBaseline()) { if (_activeDraftId != null && !_draftChangedFromBaseline()) {
return true; return true;
} }
final currentSnapshot = _currentSubmissionSnapshot();
if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, currentSnapshot)) {
return true;
}
final choice = await _promptSaveDraft(); final choice = await _promptSaveDraft();
if (choice == _ExitChoice.cancel) return false; if (choice == _ExitChoice.cancel) return false;
if (choice == _ExitChoice.save) { if (choice == _ExitChoice.save) {
await _saveDraftEntry(draftId: _activeDraftId); try {
await _saveDraftEntry(draftId: _activeDraftId);
} catch (_) {
return true;
}
} else if (choice == _ExitChoice.discard) { } else if (choice == _ExitChoice.discard) {
// Delay reset to avoid setState during the dialog/build phase. // Delay reset to avoid setState during the dialog/build phase.
await Future<void>.delayed(Duration.zero); await Future<void>.delayed(Duration.zero);
@@ -53,35 +63,41 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
Future<_ExitChoice> _promptSaveDraft() async { Future<_ExitChoice> _promptSaveDraft() async {
if (!mounted) return _ExitChoice.cancel; if (!mounted) return _ExitChoice.cancel;
final result = await showDialog<_ExitChoice>( try {
context: context, final result = await showDialog<_ExitChoice>(
barrierDismissible: false, context: context,
useRootNavigator: false, barrierDismissible: false,
builder: (_) => AlertDialog( useRootNavigator: false,
title: const Text('Save draft?'), builder: (_) => AlertDialog(
content: const Text( title: const Text('Save draft?'),
'Do you want to save this entry as a draft before leaving?', content: const Text(
'Do you want to save this entry as a draft before leaving?',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(_ExitChoice.discard),
child: const Text('No'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_ExitChoice.save),
child: const Text('Yes'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_ExitChoice.cancel),
child: const Text('Cancel'),
),
],
), ),
actions: [ );
TextButton( if (!mounted) return _ExitChoice.cancel;
onPressed: () => Navigator.of(context).pop(_ExitChoice.discard), return result ?? _ExitChoice.cancel;
child: const Text('No'), } catch (_) {
), return _ExitChoice.cancel;
TextButton( }
onPressed: () => Navigator.of(context).pop(_ExitChoice.save),
child: const Text('Yes'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(_ExitChoice.cancel),
child: const Text('Cancel'),
),
],
),
);
return result ?? _ExitChoice.cancel;
} }
Future<void> _openDrafts() async { Future<void> _openDrafts() async {
if (_activeLegShare != null) return;
final selected = await Navigator.of(context).push<_StoredDraft>( final selected = await Navigator.of(context).push<_StoredDraft>(
MaterialPageRoute( MaterialPageRoute(
builder: (_) => _DraftListPage( builder: (_) => _DraftListPage(
@@ -97,6 +113,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
} }
Future<void> _saveDraftManually() async { Future<void> _saveDraftManually() async {
if (_activeLegShare != null) return;
if (_savingDraft) return; if (_savingDraft) return;
if (_formIsEmpty()) { if (_formIsEmpty()) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar( ScaffoldMessenger.maybeOf(context)?.showSnackBar(
@@ -123,7 +140,9 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
} }
Future<void> _saveDraft() async { Future<void> _saveDraft() async {
if (_restoringDraft || !_draftPersistenceEnabled) return; if (_restoringDraft || !_draftPersistenceEnabled || _activeLegShare != null) {
return;
}
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final draft = { final draft = {
"date": _selectedDate.toIso8601String(), "date": _selectedDate.toIso8601String(),
@@ -169,6 +188,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
"distance": _routeResult!.distance, "distance": _routeResult!.distance,
}, },
"tractionItems": _serializeTractionItems(), "tractionItems": _serializeTractionItems(),
"shareUserIds": _shareUserIds.toList(),
"shareUsers": _shareUsers
.map((u) => {
"user_id": u.userId,
"username": u.username,
"full_name": u.fullName,
})
.toList(),
}; };
await prefs.setString(_kDraftPrefsKey, jsonEncode(draft)); await prefs.setString(_kDraftPrefsKey, jsonEncode(draft));
} }
@@ -297,6 +324,14 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
"distance": _routeResult!.distance, "distance": _routeResult!.distance,
}, },
"tractionItems": _serializeTractionItems(), "tractionItems": _serializeTractionItems(),
"shareUserIds": _shareUserIds.toList(),
"shareUsers": _shareUsers
.map((u) => {
"user_id": u.userId,
"username": u.username,
"full_name": u.fullName,
})
.toList(),
}; };
} }
@@ -444,6 +479,19 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
..clear() ..clear()
..add(_TractionItem.marker()); ..add(_TractionItem.marker());
} }
final shareIdsRaw = data['shareUserIds'];
final shareUsersRaw = data['shareUsers'];
_shareUserIds = shareIdsRaw is List
? shareIdsRaw.map((e) => e.toString()).toSet()
: {};
_shareUsers = shareUsersRaw is List
? shareUsersRaw
.whereType<Map>()
.map((e) => UserSummary.fromJson(
e.map((k, v) => MapEntry(k.toString(), v)),
))
.toList()
: [];
_lastSubmittedSnapshot = null; _lastSubmittedSnapshot = null;
final idRaw = data['id']; final idRaw = data['id'];
if (idRaw != null) { if (idRaw != null) {

View File

@@ -1,9 +1,10 @@
part of 'new_entry.dart'; part of 'new_entry.dart';
class NewEntryPage extends StatefulWidget { class NewEntryPage extends StatefulWidget {
const NewEntryPage({super.key, this.editLegId}); const NewEntryPage({super.key, this.editLegId, this.legShare});
final int? editLegId; final int? editLegId;
final LegShareData? legShare;
@override @override
State<NewEntryPage> createState() => _NewEntryPageState(); State<NewEntryPage> createState() => _NewEntryPageState();
@@ -52,9 +53,16 @@ class _NewEntryPageState extends State<NewEntryPage> {
const DeepCollectionEquality(); const DeepCollectionEquality();
String? _activeDraftId; String? _activeDraftId;
DistanceUnit? _lastDistanceUnit; 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 _isEditing => widget.editLegId != null;
bool get _draftPersistenceEnabled => bool get _draftPersistenceEnabled =>
_activeLegShare == null &&
false; // legacy single draft disabled in favor of draft list false; // legacy single draft disabled in favor of draft list
@override @override
@@ -69,16 +77,28 @@ class _NewEntryPageState extends State<NewEntryPage> {
data.fetchClassList(); data.fetchClassList();
data.fetchTripOptions(); data.fetchTripOptions();
_loadStations(); _loadStations();
if (_draftPersistenceEnabled) { if (_draftPersistenceEnabled && widget.legShare == null) {
_loadDraft(); _loadDraft();
} }
_loadStations(); _loadStations();
if (_isEditing && widget.editLegId != null) { if (_isEditing && widget.editLegId != null) {
_loadLegForEdit(widget.editLegId!); _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 @override
void dispose() { void dispose() {
NavigationGuard.unregister(_exitGuard); NavigationGuard.unregister(_exitGuard);
@@ -128,12 +148,330 @@ class _NewEntryPageState extends State<NewEntryPage> {
} }
} }
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) { double _milesFromInputWithUnit(DistanceUnit unit) {
return DistanceFormatter(unit) return DistanceFormatter(unit)
.parseInputMiles(_mileageController.text.trim()) ?? .parseInputMiles(_mileageController.text.trim()) ??
0; 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) { void _syncManualFieldUnit(DistanceUnit currentUnit) {
if (!_useManualMileage) { if (!_useManualMileage) {
_lastDistanceUnit = currentUnit; _lastDistanceUnit = currentUnit;
@@ -369,7 +707,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
final enabled = value ?? false; final enabled = value ?? false;
setState(() { setState(() {
_matchDestinationToEntry = enabled; _matchDestinationToEntry = enabled;
if (enabled) _hasDestinationTime = true; if (enabled && _hasEndTime) _hasDestinationTime = true;
}); });
_scheduleMatchUpdate(); _scheduleMatchUpdate();
_saveDraft(); _saveDraft();
@@ -571,6 +909,96 @@ 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) { List<String> _parseRouteStations(dynamic raw) {
if (raw is List) { if (raw is List) {
return raw.map((e) => e.toString()).toList(); return raw.map((e) => e.toString()).toList();
@@ -699,7 +1127,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
if (_destinationController.text != endVal) { if (_destinationController.text != endVal) {
_destinationController.text = endVal; _destinationController.text = endVal;
} }
if (_hasDestinationTime) { if (_hasDestinationTime && _hasEndTime) {
final endTime = _legEndDateTime ?? _legDateTime; final endTime = _legEndDateTime ?? _legDateTime;
_selectedDestinationDate = DateTime( _selectedDestinationDate = DateTime(
endTime.year, endTime.year,
@@ -809,7 +1237,8 @@ class _NewEntryPageState extends State<NewEntryPage> {
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
onPressed: _isEditing ? null : _openDrafts, onPressed:
_isEditing || _activeLegShare != null ? null : _openDrafts,
icon: const Icon(Icons.list_alt, size: 16), icon: const Icon(Icons.list_alt, size: 16),
label: const Text('Drafts'), label: const Text('Drafts'),
), ),
@@ -817,23 +1246,26 @@ class _NewEntryPageState extends State<NewEntryPage> {
TextButton.icon( TextButton.icon(
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
tapTargetSize: MaterialTapTargetSize.shrinkWrap, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
), ),
onPressed: _isEditing || _savingDraft || _submitting onPressed: _isEditing ||
? null _savingDraft ||
: _saveDraftManually, _submitting ||
icon: _savingDraft _activeLegShare != null
? const SizedBox( ? null
width: 16, : _saveDraftManually,
height: 16, icon: _savingDraft
child: CircularProgressIndicator(strokeWidth: 2), ? const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.save_alt, size: 16), : const Icon(Icons.save_alt, size: 16),
label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'), label: Text(_savingDraft ? 'Saving...' : 'Save to drafts'),
), ),
const Spacer(), const Spacer(),
TextButton.icon( TextButton.icon(
style: TextButton.styleFrom( style: TextButton.styleFrom(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
minimumSize: const Size(0, 36), minimumSize: const Size(0, 36),
@@ -844,11 +1276,17 @@ class _NewEntryPageState extends State<NewEntryPage> {
: () => _resetFormState(clearDraft: true), : () => _resetFormState(clearDraft: true),
icon: const Icon(Icons.clear, size: 16), icon: const Icon(Icons.clear, size: 16),
label: const Text('Clear form'), label: const Text('Clear form'),
),
],
), ),
],
),
if (_activeLegShare != null) ...[
const SizedBox(height: 8),
_buildSharedBanner(),
],
_buildTripSelector(context), _buildTripSelector(context),
_dateTimeGroup( const SizedBox(height: 8),
if (_activeLegShare == null) _buildShareSection(context),
_dateTimeGroup(
context, context,
title: 'Departure time', title: 'Departure time',
onDateTap: _pickDate, onDateTap: _pickDate,
@@ -928,6 +1366,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
border: OutlineInputBorder(), border: OutlineInputBorder(),
), ),
), ),
const Divider(height: 24),
_trainLocationBlock( _trainLocationBlock(
label: 'Origin', label: 'Origin',
controller: _originController, controller: _originController,
@@ -946,6 +1385,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
singleColumn: true, singleColumn: true,
), ),
), ),
const Divider(height: 24),
_trainLocationBlock( _trainLocationBlock(
label: 'Destination', label: 'Destination',
controller: _destinationController, controller: _destinationController,
@@ -965,6 +1405,7 @@ class _NewEntryPageState extends State<NewEntryPage> {
singleColumn: true, singleColumn: true,
), ),
), ),
const Divider(height: 24),
TextFormField( TextFormField(
controller: _networkController, controller: _networkController,
textCapitalization: TextCapitalization.characters, textCapitalization: TextCapitalization.characters,
@@ -996,16 +1437,52 @@ class _NewEntryPageState extends State<NewEntryPage> {
_buildTractionList(), _buildTractionList(),
], minHeight: balancedHeight); ], 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( final mileagePanel = _section(
'Mileage', 'Your Journey',
[ [
if (!_useManualMileage) if (!_useManualMileage)
Wrap(
spacing: 12,
runSpacing: 8,
children: [
ElevatedButton.icon(
onPressed: _openCalculator,
icon: const Icon(Icons.calculate, size: 18),
label: const Text('Open mileage calculator'),
),
OutlinedButton.icon(
onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.swap_horiz),
label: const Text('Reverse route'),
),
],
)
else
Align( Align(
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: ElevatedButton.icon( child: OutlinedButton.icon(
onPressed: _openCalculator, onPressed: _reverseRouteAndEndpoints,
icon: const Icon(Icons.calculate, size: 18), icon: const Icon(Icons.swap_horiz),
label: const Text('Open mileage calculator'), 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) if (_useManualMileage)
@@ -1072,24 +1549,27 @@ class _NewEntryPageState extends State<NewEntryPage> {
children: [ children: [
entryPanel, entryPanel,
const SizedBox(height: 16), const SizedBox(height: 16),
trainPanel, if (twoCol) ...[
const SizedBox(height: 16), trainPanel,
twoCol const SizedBox(height: 16),
? Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded(child: tractionPanel), Expanded(child: tractionPanel),
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded(child: mileagePanel), Expanded(child: mileagePanel),
], ],
) ),
: Column( ] else
children: [ Column(
tractionPanel, children: [
const SizedBox(height: 16), mileagePanel,
mileagePanel, const SizedBox(height: 16),
], trainPanel,
), const SizedBox(height: 16),
tractionPanel,
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
ElevatedButton.icon( ElevatedButton.icon(
onPressed: _submitting ? null : _submit, onPressed: _submitting ? null : _submit,
@@ -1100,11 +1580,15 @@ class _NewEntryPageState extends State<NewEntryPage> {
child: CircularProgressIndicator(strokeWidth: 2), child: CircularProgressIndicator(strokeWidth: 2),
) )
: const Icon(Icons.send), : const Icon(Icons.send),
label: Text( label: () {
_submitting final shareMode = _activeLegShare != null;
? (_isEditing ? 'Saving...' : 'Submitting...') if (_submitting) {
: (_isEditing ? 'Save changes' : 'Submit entry'), 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');
}(),
), ),
], ],
), ),

View File

@@ -71,12 +71,14 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
final beginDelay = _parseDelayMinutes(_beginDelayController.text); final beginDelay = _parseDelayMinutes(_beginDelayController.text);
final endDelay = final endDelay =
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; _hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0;
final snapshot = _buildSubmissionSnapshot( final snapshot = _currentSubmissionSnapshot(
routeStations: routeStations, routeStations: routeStations,
startVal: startVal, startVal: startVal,
endVal: endVal, endVal: endVal,
mileageVal: mileageVal, mileageVal: mileageVal,
tractionPayload: tractionPayload, tractionPayload: tractionPayload,
beginDelay: beginDelay,
endDelay: endDelay,
); );
if (_lastSubmittedSnapshot != null && if (_lastSubmittedSnapshot != null &&
_snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) { _snapshotEquality.equals(_lastSubmittedSnapshot, snapshot)) {
@@ -108,6 +110,8 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
"leg_begin_delay": beginDelay, "leg_begin_delay": beginDelay,
if (_hasEndTime) "leg_end_delay": endDelay, if (_hasEndTime) "leg_end_delay": endDelay,
"locos": tractionPayload, "locos": tractionPayload,
if (_activeLegShare != null) "leg_share_id": _activeLegShare!.id,
"share_user_ids": _shareUserIds.toList(),
}; };
if (_useManualMileage) { if (_useManualMileage) {
final body = { final body = {
@@ -136,6 +140,9 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
if (!mounted) return; if (!mounted) return;
dataService.refreshLegs(); dataService.refreshLegs();
await dataService.fetchNotifications(); await dataService.fetchNotifications();
if (_shareNotificationId != null) {
await dataService.dismissNotifications([_shareNotificationId!]);
}
if (!mounted) return; if (!mounted) return;
messenger?.showSnackBar( messenger?.showSnackBar(
SnackBar( SnackBar(
@@ -156,16 +163,35 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
} }
} }
Map<String, dynamic> _buildSubmissionSnapshot({ Map<String, dynamic> _currentSubmissionSnapshot({
required List<String> routeStations, List<String>? routeStations,
required String startVal, String? startVal,
required String endVal, String? endVal,
required double mileageVal, double? mileageVal,
required List<Map<String, dynamic>> tractionPayload, List<Map<String, dynamic>>? tractionPayload,
int? beginDelay,
int? endDelay,
}) { }) {
final beginDelay = _parseDelayMinutes(_beginDelayController.text); final stations = routeStations ?? (_routeResult?.calculatedRoute ?? []);
final endDelay = final start = startVal ??
_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0; (_useManualMileage
? _startController.text.trim()
: (stations.isNotEmpty ? stations.first : ''));
final end = endVal ??
(_useManualMileage
? _endController.text.trim()
: (stations.isNotEmpty ? stations.last : ''));
final mileage = mileageVal ??
(_useManualMileage
? (_distanceUnits(context)
.milesFromInput(_mileageController.text.trim()) ??
0)
: (_routeResult?.distance ?? 0));
final traction = tractionPayload ?? _buildTractionPayload();
final begin = beginDelay ?? _parseDelayMinutes(_beginDelayController.text);
final endDelayVal =
endDelay ?? (_hasEndTime ? _parseDelayMinutes(_endDelayController.text) : 0);
return { return {
"legId": widget.editLegId, "legId": widget.editLegId,
"useManualMileage": _useManualMileage, "useManualMileage": _useManualMileage,
@@ -177,18 +203,20 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
"hasOriginTime": _hasOriginTime, "hasOriginTime": _hasOriginTime,
"legDestinationTime": _destinationDateTime?.toIso8601String(), "legDestinationTime": _destinationDateTime?.toIso8601String(),
"hasDestinationTime": _hasDestinationTime, "hasDestinationTime": _hasDestinationTime,
"start": startVal, "start": start,
"end": endVal, "end": end,
"origin": _originController.text.trim(), "origin": _originController.text.trim(),
"destination": _destinationController.text.trim(), "destination": _destinationController.text.trim(),
"routeStations": routeStations, "routeStations": stations,
"mileage": mileageVal, "mileage": mileage,
"network": _networkController.text.trim(), "network": _networkController.text.trim(),
"notes": _notesController.text.trim(), "notes": _notesController.text.trim(),
"headcode": _headcodeController.text.trim(), "headcode": _headcodeController.text.trim(),
"beginDelay": beginDelay, "beginDelay": begin,
"endDelay": endDelay, "endDelay": endDelayVal,
"locos": tractionPayload, "legShareId": _activeLegShare?.id,
"shareUserIds": _shareUserIds.toList(),
"locos": traction,
"routeResult": _routeResult == null "routeResult": _routeResult == null
? null ? null
: { : {
@@ -223,6 +251,7 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
} }
Future<void> _resetFormState({bool clearDraft = false}) async { Future<void> _resetFormState({bool clearDraft = false}) async {
final hadShare = _activeLegShare != null || widget.legShare != null;
_formKey.currentState?.reset(); _formKey.currentState?.reset();
_startController.clear(); _startController.clear();
_endController.clear(); _endController.clear();
@@ -252,6 +281,11 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
_matchDestinationToEntry = false; _matchDestinationToEntry = false;
_matchUpdateScheduled = false; _matchUpdateScheduled = false;
_routeResult = null; _routeResult = null;
_activeLegShare = null;
_sharedFromUser = null;
_shareNotificationId = null;
_shareUserIds.clear();
_shareUsers.clear();
_tractionItems _tractionItems
..clear() ..clear()
..add(_TractionItem.marker()); ..add(_TractionItem.marker());
@@ -261,6 +295,10 @@ extension _NewEntrySubmitLogic on _NewEntryPageState {
_savingDraft = false; _savingDraft = false;
_loadedDraftSnapshot = null; _loadedDraftSnapshot = null;
}); });
if (hadShare && mounted) {
// Clear any share params from the URL when resetting.
GoRouter.of(context).go('/add');
}
if (clearDraft) { if (clearDraft) {
await _clearDraft(); await _clearDraft();
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -569,6 +569,7 @@ class _TractionPageState extends State<TractionPage> {
} }
Widget _buildHeaderActions(BuildContext context, bool isMobile) { Widget _buildHeaderActions(BuildContext context, bool isMobile) {
final isElevated = context.read<AuthService>().isElevated;
final refreshButton = IconButton( final refreshButton = IconButton(
tooltip: 'Refresh', tooltip: 'Refresh',
onPressed: _refreshTraction, onPressed: _refreshTraction,
@@ -587,32 +588,34 @@ class _TractionPageState extends State<TractionPage> {
), ),
); );
final newTractionButton = FilledButton.icon( final newTractionButton = !isElevated
onPressed: () async { ? null
final createdClass = await context.push<String>( : FilledButton.icon(
'/traction/new', onPressed: () async {
); final createdClass = await context.push<String>(
if (!mounted) return; '/traction/new',
if (createdClass != null && createdClass.isNotEmpty) { );
_classController.text = createdClass; if (!mounted) return;
_selectedClass = createdClass; if (createdClass != null && createdClass.isNotEmpty) {
_refreshTraction(); _classController.text = createdClass;
} else if (createdClass == '') { _selectedClass = createdClass;
_refreshTraction(); _refreshTraction();
} } else if (createdClass == '') {
}, _refreshTraction();
icon: const Icon(Icons.add), }
label: const Text('New Traction'), },
); icon: const Icon(Icons.add),
label: const Text('New Traction'),
);
final desktopActions = [ final desktopActions = [
refreshButton, refreshButton,
if (classStatsButton != null) classStatsButton, if (classStatsButton != null) classStatsButton,
newTractionButton, if (newTractionButton != null) newTractionButton,
]; ];
final mobileActions = [ final mobileActions = [
newTractionButton, if (newTractionButton != null) newTractionButton,
if (classStatsButton != null) classStatsButton, if (classStatsButton != null) classStatsButton,
refreshButton, refreshButton,
]; ];

View File

@@ -1,4 +1,3 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:mileograph_flutter/objects/objects.dart'; import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/api_service.dart'; import 'package:mileograph_flutter/services/api_service.dart';

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

View File

@@ -104,24 +104,245 @@ class DestinationObject {
} }
class UserData { class UserData {
const UserData(this.username, this.fullName, this.userId, this.email); const UserData({
required this.username,
required this.fullName,
required this.userId,
required this.email,
bool? elevated,
bool? disabled,
}) : elevated = elevated ?? false,
disabled = disabled ?? false;
final String userId; final String userId;
final String username; final String username;
final String fullName; final String fullName;
final String email; final String email;
final bool elevated;
final bool disabled;
} }
class AuthenticatedUserData extends UserData { class AuthenticatedUserData extends UserData {
const AuthenticatedUserData({ const AuthenticatedUserData({
required String userId, required super.userId,
required String username, required super.username,
required String fullName, required super.fullName,
required String email, required super.email,
bool? elevated,
bool? isElevated,
bool? disabled,
bool? isDisabled,
required this.accessToken, required this.accessToken,
}) : super(username, fullName, userId, email); }) : super(
elevated: (elevated ?? false) || (isElevated ?? false),
disabled: (disabled ?? false) || (isDisabled ?? false),
);
final String accessToken; final String accessToken;
bool get isElevated => elevated;
}
class UserSummary extends UserData {
const UserSummary({
required super.username,
required super.fullName,
required super.userId,
required super.email,
super.elevated = false,
super.disabled = false,
});
String get displayName => fullName.isNotEmpty ? fullName : username;
factory UserSummary.fromJson(Map<String, dynamic> json) => UserSummary(
username: _asString(json['username'] ?? json['user_name']),
fullName: _asString(json['full_name'] ?? json['name']),
userId: _asString(json['user_id'] ?? json['id']),
email: _asString(json['email']),
elevated: _asBool(json['elevated'] ?? json['is_elevated'], false),
disabled: _asBool(json['disabled'], false),
);
}
class 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 { class HomepageStats {
@@ -129,6 +350,7 @@ class HomepageStats {
final List<YearlyMileage> yearlyMileage; final List<YearlyMileage> yearlyMileage;
final List<LocoSummary> topLocos; final List<LocoSummary> topLocos;
final List<LeaderboardEntry> leaderboard; final List<LeaderboardEntry> leaderboard;
final List<LeaderboardEntry> friendsLeaderboard;
final List<TripSummary> trips; final List<TripSummary> trips;
final int legCount; final int legCount;
final UserData? user; final UserData? user;
@@ -138,6 +360,7 @@ class HomepageStats {
required this.yearlyMileage, required this.yearlyMileage,
required this.topLocos, required this.topLocos,
required this.leaderboard, required this.leaderboard,
required this.friendsLeaderboard,
required this.trips, required this.trips,
required this.legCount, required this.legCount,
this.user, this.user,
@@ -149,6 +372,17 @@ class HomepageStats {
final totalMileage = mileageData is Map && mileageData['mileage'] != null final totalMileage = mileageData is Map && mileageData['mileage'] != null
? (mileageData['mileage'] as num).toDouble() ? (mileageData['mileage'] as num).toDouble()
: 0.0; : 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( return HomepageStats(
totalMileage: totalMileage, totalMileage: totalMileage,
yearlyMileage: (json['yearly_mileage'] as List? ?? []) yearlyMileage: (json['yearly_mileage'] as List? ?? [])
@@ -157,9 +391,9 @@ class HomepageStats {
topLocos: (json['top_locos'] as List? ?? []) topLocos: (json['top_locos'] as List? ?? [])
.map((e) => LocoSummary.fromJson(e)) .map((e) => LocoSummary.fromJson(e))
.toList(), .toList(),
leaderboard: (json['leaderboard_data'] as List? ?? []) leaderboard: parseLeaderboard(json['leaderboard_data'] ?? json['leaderboard']),
.map((e) => LeaderboardEntry.fromJson(e)) friendsLeaderboard:
.toList(), parseLeaderboard(json['friends_leaderboard'] ?? json['friendsLeaderboard']),
trips: (json['trip_data'] as List? ?? []) trips: (json['trip_data'] as List? ?? [])
.map((e) => TripSummary.fromJson(e)) .map((e) => TripSummary.fromJson(e))
.toList(), .toList(),
@@ -170,10 +404,13 @@ class HomepageStats {
user: userData == null user: userData == null
? null ? null
: UserData( : UserData(
userData['username'] ?? '', username: userData['username'] ?? '',
userData['full_name'] ?? '', fullName: userData['full_name'] ?? '',
userData['user_id'] ?? '', userId: userData['user_id'] ?? '',
userData['email'] ?? '', email: userData['email'] ?? '',
elevated:
_asBool(userData['elevated'] ?? userData['is_elevated'], false),
disabled: _asBool(userData['disabled'], false),
), ),
); );
} }
@@ -398,6 +635,7 @@ class Loco {
final String type, number, locoClass; final String type, number, locoClass;
final String? name, operator, notes, evn; final String? name, operator, notes, evn;
final bool powering; final bool powering;
final int allocPos;
Loco({ Loco({
required this.id, required this.id,
@@ -409,6 +647,7 @@ class Loco {
this.notes, this.notes,
this.evn, this.evn,
this.powering = true, this.powering = true,
this.allocPos = 0,
}); });
factory Loco.fromJson(Map<String, dynamic> json) => Loco( factory Loco.fromJson(Map<String, dynamic> json) => Loco(
@@ -421,6 +660,7 @@ class Loco {
notes: json['notes'], notes: json['notes'],
evn: json['evn'], evn: json['evn'],
powering: _asBool(json['alloc_powering'] ?? json['powering'], true), powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
allocPos: _asInt(json['alloc_pos'], 0),
); );
} }
@@ -454,6 +694,7 @@ class LocoSummary extends Loco {
this.location, this.location,
Map<String, dynamic>? extra, Map<String, dynamic>? extra,
super.powering = true, super.powering = true,
super.allocPos = 0,
}) : extra = extra ?? const {}, }) : extra = extra ?? const {},
super( super(
id: locoId, id: locoId,
@@ -489,6 +730,7 @@ class LocoSummary extends Loco {
location: json['location'], location: json['location'],
extra: Map<String, dynamic>.from(json), extra: Map<String, dynamic>.from(json),
powering: _asBool(json['alloc_powering'] ?? json['powering'], true), powering: _asBool(json['alloc_powering'] ?? json['powering'], true),
allocPos: _asInt(json['alloc_pos'], 0),
); );
} }
@@ -831,6 +1073,9 @@ class Leg {
final String start, end, network, notes, headcode, user; final String start, end, network, notes, headcode, user;
final String origin, destination; final String origin, destination;
final List<String> route; final List<String> route;
final String? legShareId;
final LegShareMeta? sharedFrom;
final List<LegShareMeta> sharedTo;
final DateTime beginTime; final DateTime beginTime;
final DateTime? endTime; final DateTime? endTime;
final DateTime? originTime; final DateTime? originTime;
@@ -861,6 +1106,9 @@ class Leg {
this.endDelayMinutes, this.endDelayMinutes,
this.origin = '', this.origin = '',
this.destination = '', this.destination = '',
this.legShareId,
this.sharedFrom,
this.sharedTo = const [],
}); });
factory Leg.fromJson(Map<String, dynamic> json) { factory Leg.fromJson(Map<String, dynamic> json) {
@@ -868,6 +1116,25 @@ class Leg {
final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty) final parsedEndTime = (endTimeRaw == null || '$endTimeRaw'.isEmpty)
? null ? null
: _asDateTime(endTimeRaw); : _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( return Leg(
id: _asInt(json['leg_id']), id: _asInt(json['leg_id']),
tripId: _asInt(json['leg_trip']), tripId: _asInt(json['leg_trip']),
@@ -901,6 +1168,156 @@ class Leg {
: _asInt(json['leg_end_delay']), : _asInt(json['leg_end_delay']),
origin: _asString(json['leg_origin']), origin: _asString(json['leg_origin']),
destination: _asString(json['leg_destination']), 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'),
); );
} }
} }
@@ -1179,6 +1596,8 @@ class UserNotification {
final int id; final int id;
final String title; final String title;
final String body; final String body;
final String channel;
final String type;
final DateTime? createdAt; final DateTime? createdAt;
final bool dismissed; final bool dismissed;
@@ -1186,6 +1605,8 @@ class UserNotification {
required this.id, required this.id,
required this.title, required this.title,
required this.body, required this.body,
required this.channel,
required this.type,
required this.createdAt, required this.createdAt,
required this.dismissed, required this.dismissed,
}); });
@@ -1202,6 +1623,8 @@ class UserNotification {
id: _asInt(json['notification_id'] ?? json['id']), id: _asInt(json['notification_id'] ?? json['id']),
title: _asString(json['title']), title: _asString(json['title']),
body: _asString(json['body']), body: _asString(json['body']),
channel: _asString(json['channel']),
type: _asString(json['type'] ?? json['notification_type']),
createdAt: createdAt, createdAt: createdAt,
dismissed: _asBool(json['dismissed'] ?? false, false), dismissed: _asBool(json['dismissed'] ?? false, false),
); );

View File

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

View File

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

View File

@@ -64,6 +64,18 @@ class DataService extends ChangeNotifier {
bool isLocoTimelineLoading(int locoId) => bool isLocoTimelineLoading(int locoId) =>
_isLocoTimelineLoading[locoId] ?? false; _isLocoTimelineLoading[locoId] ?? false;
// Friendships
List<Friendship> _friendships = [];
List<Friendship> _pendingIncoming = [];
List<Friendship> _pendingOutgoing = [];
List<Friendship> get friendships => _friendships;
List<Friendship> get pendingIncoming => _pendingIncoming;
List<Friendship> get pendingOutgoing => _pendingOutgoing;
bool _isFriendshipsLoading = false;
bool get isFriendshipsLoading => _isFriendshipsLoading;
bool _isPendingFriendshipsLoading = false;
bool get isPendingFriendshipsLoading => _isPendingFriendshipsLoading;
// Trips // Trips
List<TripSummary> _trips = []; List<TripSummary> _trips = [];
List<TripSummary> get trips => _trips; List<TripSummary> get trips => _trips;
@@ -96,6 +108,10 @@ class DataService extends ChangeNotifier {
bool get isHomepageLoading => _isHomepageLoading; bool get isHomepageLoading => _isHomepageLoading;
bool _isOnThisDayLoading = false; bool _isOnThisDayLoading = false;
bool get isOnThisDayLoading => _isOnThisDayLoading; bool get isOnThisDayLoading => _isOnThisDayLoading;
List<LeaderboardEntry> _friendsLeaderboard = [];
List<LeaderboardEntry> get friendsLeaderboard => _friendsLeaderboard;
bool _isFriendsLeaderboardLoading = false;
bool get isFriendsLeaderboardLoading => _isFriendsLeaderboardLoading;
// Notifications // Notifications
List<UserNotification> _notifications = []; List<UserNotification> _notifications = [];
@@ -153,10 +169,12 @@ class DataService extends ChangeNotifier {
_homepageStats = HomepageStats.fromJson(json); _homepageStats = HomepageStats.fromJson(json);
_trips = [...(_homepageStats?.trips ?? const [])] _trips = [...(_homepageStats?.trips ?? const [])]
..sort(TripSummary.compareByDateDesc); ..sort(TripSummary.compareByDateDesc);
_friendsLeaderboard = _homepageStats?.friendsLeaderboard ?? [];
} catch (e) { } catch (e) {
debugPrint('Failed to fetch homepage stats: $e'); debugPrint('Failed to fetch homepage stats: $e');
_homepageStats = null; _homepageStats = null;
_trips = []; _trips = [];
_friendsLeaderboard = [];
} finally { } finally {
_isHomepageLoading = false; _isHomepageLoading = false;
_notifyAsync(); _notifyAsync();

View File

@@ -0,0 +1,282 @@
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];
_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();
_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();
_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();
_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,97 @@
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;
}
int? _parseNullableInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
if (value is String) return int.tryParse(value);
return null;
}
}

View File

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

View File

@@ -25,4 +25,38 @@ extension DataServiceStats on DataService {
_notifyAsync(); _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

@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:dynamic_color/dynamic_color.dart'; import 'package:dynamic_color/dynamic_color.dart';
import 'package:flutter/gestures.dart'; import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -11,12 +13,16 @@ import 'package:mileograph_flutter/components/pages/loco_legs.dart';
import 'package:mileograph_flutter/components/pages/loco_timeline.dart'; import 'package:mileograph_flutter/components/pages/loco_timeline.dart';
import 'package:mileograph_flutter/components/pages/logbook.dart'; import 'package:mileograph_flutter/components/pages/logbook.dart';
import 'package:mileograph_flutter/components/pages/more.dart'; import 'package:mileograph_flutter/components/pages/more.dart';
import 'package:mileograph_flutter/components/pages/badges.dart';
import 'package:mileograph_flutter/components/pages/new_entry.dart'; import 'package:mileograph_flutter/components/pages/new_entry.dart';
import 'package:mileograph_flutter/components/pages/new_traction.dart'; import 'package:mileograph_flutter/components/pages/new_traction.dart';
import 'package:mileograph_flutter/components/pages/profile.dart'; import 'package:mileograph_flutter/components/pages/profile.dart';
import 'package:mileograph_flutter/components/pages/settings.dart'; import 'package:mileograph_flutter/components/pages/settings.dart';
import 'package:mileograph_flutter/components/pages/stats.dart'; import 'package:mileograph_flutter/components/pages/stats.dart';
import 'package:mileograph_flutter/components/pages/traction.dart'; import 'package:mileograph_flutter/components/pages/traction.dart';
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart';
import 'package:mileograph_flutter/objects/objects.dart';
import 'package:mileograph_flutter/services/authservice.dart'; import 'package:mileograph_flutter/services/authservice.dart';
import 'package:mileograph_flutter/services/data_service.dart'; import 'package:mileograph_flutter/services/data_service.dart';
import 'package:mileograph_flutter/services/navigation_guard.dart'; import 'package:mileograph_flutter/services/navigation_guard.dart';
@@ -204,7 +210,15 @@ class _MyAppState extends State<MyApp> {
path: '/traction/new', path: '/traction/new',
builder: (context, state) => const NewTractionPage(), builder: (context, state) => const NewTractionPage(),
), ),
GoRoute(path: '/add', builder: (context, state) => NewEntryPage()), GoRoute(
path: '/add',
builder: (context, state) {
final extra = state.extra;
return NewEntryPage(
legShare: extra is LegShareData ? extra : null,
);
},
),
GoRoute( GoRoute(
path: '/more', path: '/more',
builder: (context, state) => const MorePage(), builder: (context, state) => const MorePage(),
@@ -213,6 +227,10 @@ class _MyAppState extends State<MyApp> {
path: '/more/profile', path: '/more/profile',
builder: (context, state) => const ProfilePage(), builder: (context, state) => const ProfilePage(),
), ),
GoRoute(
path: '/more/badges',
builder: (context, state) => const BadgesPage(),
),
GoRoute( GoRoute(
path: '/more/stats', path: '/more/stats',
builder: (context, state) => const StatsPage(), builder: (context, state) => const StatsPage(),
@@ -221,6 +239,10 @@ class _MyAppState extends State<MyApp> {
path: '/more/settings', path: '/more/settings',
builder: (context, state) => const SettingsPage(), builder: (context, state) => const SettingsPage(),
), ),
GoRoute(
path: '/more/admin',
builder: (context, state) => const AdminPage(),
),
GoRoute( GoRoute(
path: '/legs/edit/:id', path: '/legs/edit/:id',
builder: (_, state) { builder: (_, state) {
@@ -307,6 +329,7 @@ class _MyHomePageState extends State<MyHomePage> {
bool _fetched = false; bool _fetched = false;
bool _railCollapsed = false; bool _railCollapsed = false;
Timer? _notificationsTimer;
@override @override
void didChangeDependencies() { void didChangeDependencies() {
@@ -343,10 +366,35 @@ class _MyHomePageState extends State<MyHomePage> {
if (data.notifications.isEmpty) { if (data.notifications.isEmpty) {
data.fetchNotifications(); data.fetchNotifications();
} }
if (data.friendships.isEmpty && !data.isFriendshipsLoading) {
data.fetchFriendships();
}
if ((data.pendingIncoming.isEmpty && data.pendingOutgoing.isEmpty) &&
!data.isPendingFriendshipsLoading) {
data.fetchPendingFriendships();
}
_startNotificationPolling();
}); });
}); });
} }
void _startNotificationPolling() {
_notificationsTimer?.cancel();
final auth = context.read<AuthService>();
if (!auth.isLoggedIn) return;
_notificationsTimer = Timer.periodic(const Duration(minutes: 2), (_) async {
if (!mounted) return;
final auth = context.read<AuthService>();
if (!auth.isLoggedIn) return;
final data = context.read<DataService>();
try {
await data.fetchNotifications();
} catch (_) {
// Errors already logged inside data service.
}
});
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final uri = GoRouterState.of(context).uri; final uri = GoRouterState.of(context).uri;
@@ -410,6 +458,11 @@ class _MyHomePageState extends State<MyHomePage> {
), ),
actions: [ actions: [
_buildNotificationAction(context, data), _buildNotificationAction(context, data),
IconButton(
tooltip: 'Profile',
onPressed: () => context.go('/more/profile'),
icon: const Icon(Icons.person),
),
IconButton( IconButton(
tooltip: 'Settings', tooltip: 'Settings',
onPressed: () => context.go('/more/settings'), onPressed: () => context.go('/more/settings'),
@@ -620,6 +673,13 @@ class _MyHomePageState extends State<MyHomePage> {
Widget _buildNotificationsContent(BuildContext context, bool isWide) { Widget _buildNotificationsContent(BuildContext context, bool isWide) {
final data = context.watch<DataService>(); final data = context.watch<DataService>();
final notifications = data.notifications; final notifications = data.notifications;
final dismissibleIds = notifications
.where(
(n) =>
!_isFriendRequestNotification(n) && !_isLegShareNotification(n),
)
.map((e) => e.id)
.toList();
final loading = data.isNotificationsLoading; final loading = data.isNotificationsLoading;
final listHeight = isWide final listHeight = isWide
? 380.0 ? 380.0
@@ -646,6 +706,9 @@ class _MyHomePageState extends State<MyHomePage> {
separatorBuilder: (_, index) => const SizedBox(height: 8), separatorBuilder: (_, index) => const SizedBox(height: 8),
itemBuilder: (ctx, index) { itemBuilder: (ctx, index) {
final item = notifications[index]; final item = notifications[index];
final isFriendRequest = _isFriendRequestNotification(item);
final isLegShare = _isLegShareNotification(item);
final isSpecial = isFriendRequest || isLegShare;
return Card( return Card(
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
@@ -668,7 +731,11 @@ class _MyHomePageState extends State<MyHomePage> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
item.body, isFriendRequest || isLegShare
? isFriendRequest
? 'Accept to share entries'
: 'Shared entry details below.'
: item.body,
style: Theme.of(context).textTheme.bodyMedium, style: Theme.of(context).textTheme.bodyMedium,
), ),
if (item.createdAt != null) ...[ if (item.createdAt != null) ...[
@@ -691,9 +758,10 @@ class _MyHomePageState extends State<MyHomePage> {
), ),
), ),
], ],
], ],
),
), ),
),
if (!isSpecial) ...[
const SizedBox(width: 8), const SizedBox(width: 8),
TextButton( TextButton(
onPressed: () => onPressed: () =>
@@ -701,7 +769,20 @@ class _MyHomePageState extends State<MyHomePage> {
child: const Text('Dismiss'), child: const Text('Dismiss'),
), ),
], ],
), ],
),
if (isFriendRequest)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: FriendRequestNotificationCard(
notification: item,
),
),
if (isLegShare)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LegShareNotificationCard(notification: item),
),
], ],
), ),
), ),
@@ -731,10 +812,7 @@ class _MyHomePageState extends State<MyHomePage> {
TextButton( TextButton(
onPressed: notifications.isEmpty onPressed: notifications.isEmpty
? null ? null
: () => _dismissNotifications( : () => _dismissNotifications(context, dismissibleIds),
context,
notifications.map((e) => e.id).toList(),
),
child: const Text('Dismiss all'), child: const Text('Dismiss all'),
), ),
], ],
@@ -769,6 +847,29 @@ class _MyHomePageState extends State<MyHomePage> {
return '$y-$m-$d $hh:$mm'; return '$y-$m-$d $hh:$mm';
} }
bool _isFriendRequestNotification(UserNotification notification) {
final type = notification.type.trim().toLowerCase();
final channel = notification.channel.trim().toLowerCase();
final title = notification.title.trim().toLowerCase();
bool matchesChannel =
channel == 'friend_request' || channel == 'friend-request';
if (!matchesChannel) {
matchesChannel = channel.contains('friend_request') ||
channel.contains('friend-request') ||
channel.contains('friend');
}
return matchesChannel ||
type == 'friend_request' ||
type == 'friend-request' ||
title.contains('friend request');
}
bool _isLegShareNotification(UserNotification notification) {
final channel = notification.channel.trim().toLowerCase();
final type = notification.type.trim().toLowerCase();
return channel.contains('leg_share') || type.contains('leg_share');
}
Widget _buildBadge(String label) { Widget _buildBadge(String label) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
@@ -895,4 +996,10 @@ class _MyHomePageState extends State<MyHomePage> {
_forwardHistory.clear(); _forwardHistory.clear();
context.go(tabDestinations[index]); context.go(tabDestinations[index]);
} }
@override
void dispose() {
_notificationsTimer?.cancel();
super.dispose();
}
} }

View File

@@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
# In Windows, build-name is used as the major, minor, and patch parts # In Windows, build-name is used as the major, minor, and patch parts
# of the product and file versions while build-number is used as the build suffix. # of the product and file versions while build-number is used as the build suffix.
version: 0.5.4+1 version: 0.6.3+6
environment: environment:
sdk: ^3.8.1 sdk: ^3.8.1