Compare commits
23 Commits
v0.5.4-dev
...
0.6.5-dev.
| Author | SHA1 | Date | |
|---|---|---|---|
| a755644c31 | |||
| a14faeedbe | |||
| 8ab3f53c0d | |||
| 42ac7a97e1 | |||
| af37e25692 | |||
|
|
1689869ce5 | ||
|
|
425ab46656 | ||
| 196511dfab | |||
|
|
23f294c6f0 | ||
|
|
8e1bd05040 | ||
|
|
2b8cb8342a | ||
|
|
f8bc962c84 | ||
|
|
0650a140c3 | ||
|
|
7d4db0af5f | ||
|
|
a4ab84e9a9 | ||
|
|
e3f35cf1a0 | ||
|
|
b5fc68c6f6 | ||
|
|
30fab18946 | ||
| ff38c3f838 | |||
| 69bd6f688a | |||
| 89b760508d | |||
| 42af39b442 | |||
| 2fa66ff9c6 |
411
.gitea/workflows/dev.yml
Normal file
411
.gitea/workflows/dev.yml
Normal 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
|
||||||
@@ -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:
|
||||||
@@ -43,7 +44,7 @@ jobs:
|
|||||||
|
|
||||||
DEV_SUFFIX="-dev.${DEV_ITER}"
|
DEV_SUFFIX="-dev.${DEV_ITER}"
|
||||||
VERSION="${BASE_VERSION}${DEV_SUFFIX}"
|
VERSION="${BASE_VERSION}${DEV_SUFFIX}"
|
||||||
TAG="v${VERSION}"
|
TAG="${VERSION}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
echo "base=${BASE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -40,6 +41,12 @@ Mileograph is a Flutter client for logging and analysing railway journeys. It le
|
|||||||
```bash
|
```bash
|
||||||
flutter build apk --release
|
flutter build apk --release
|
||||||
```
|
```
|
||||||
|
- Web (release + CanvasKit renderer for best performance/icons):
|
||||||
|
```bash
|
||||||
|
flutter build web --release --web-renderer canvaskit --tree-shake-icons
|
||||||
|
# or for local profiling:
|
||||||
|
flutter run -d chrome --profile --web-renderer canvaskit
|
||||||
|
```
|
||||||
|
|
||||||
## Testing and linting
|
## Testing and linting
|
||||||
- Static analysis: `flutter analyze`
|
- Static analysis: `flutter analyze`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -35,7 +35,11 @@ class App extends StatelessWidget {
|
|||||||
create: (context) => DataService(api: context.read<ApiService>()),
|
create: (context) => DataService(api: context.read<ApiService>()),
|
||||||
update: (context, auth, data) {
|
update: (context, auth, data) {
|
||||||
data ??= DataService(api: context.read<ApiService>());
|
data ??= DataService(api: context.read<ApiService>());
|
||||||
data.handleAuthChanged(auth.userId);
|
data.handleAuthChanged(
|
||||||
|
auth.userId,
|
||||||
|
entriesVisibility: auth.entriesVisibility,
|
||||||
|
mileageVisibility: auth.mileageVisibility,
|
||||||
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -61,14 +61,7 @@ class _StationAutocompleteState extends State<StationAutocomplete> {
|
|||||||
controller: textEditingController,
|
controller: textEditingController,
|
||||||
focusNode: focusNode,
|
focusNode: focusNode,
|
||||||
textInputAction: TextInputAction.done,
|
textInputAction: TextInputAction.done,
|
||||||
onSubmitted: (_) {
|
onSubmitted: (_) => onFieldSubmitted(),
|
||||||
final matches = _findTopMatches(textEditingController.text);
|
|
||||||
final firstMatch = matches.isEmpty ? null : matches.first;
|
|
||||||
if (firstMatch == null) return;
|
|
||||||
_controller.text = firstMatch;
|
|
||||||
widget.onChanged(firstMatch);
|
|
||||||
focusNode.unfocus(); // optionally close keyboard
|
|
||||||
},
|
|
||||||
decoration: const InputDecoration(
|
decoration: const InputDecoration(
|
||||||
labelText: 'Select station',
|
labelText: 'Select station',
|
||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
@@ -181,6 +174,14 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _calculateRoute(List<String> stations) async {
|
Future<void> _calculateRoute(List<String> stations) async {
|
||||||
|
final cleaned = stations.where((s) => s.trim().isNotEmpty).toList();
|
||||||
|
if (cleaned.length < 2) {
|
||||||
|
setState(() {
|
||||||
|
_routeResult = null;
|
||||||
|
_errorMessage = 'Add at least two stations before calculating.';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
setState(() {
|
setState(() {
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
_routeResult = null;
|
_routeResult = null;
|
||||||
@@ -188,7 +189,7 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
final api = context.read<ApiService>(); // context is valid here
|
final api = context.read<ApiService>(); // context is valid here
|
||||||
try {
|
try {
|
||||||
final res = await api.post('/route/distance2', {
|
final res = await api.post('/route/distance2', {
|
||||||
'route': stations.where((s) => s.trim().isNotEmpty).toList(),
|
'route': cleaned,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (res is Map && res['error'] == false) {
|
if (res is Map && res['error'] == false) {
|
||||||
@@ -232,11 +233,28 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _clearCalculator() {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
setState(() {
|
||||||
|
data.stations = [''];
|
||||||
|
_routeResult = null;
|
||||||
|
_errorMessage = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final data = context.watch<DataService>();
|
final data = context.watch<DataService>();
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: IconButton(
|
||||||
|
tooltip: 'Clear calculator',
|
||||||
|
icon: const Icon(Icons.clear_all),
|
||||||
|
onPressed: _clearCalculator,
|
||||||
|
),
|
||||||
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
|
||||||
child: Wrap(
|
child: Wrap(
|
||||||
@@ -366,18 +384,34 @@ class _RouteCalculatorState extends State<RouteCalculator> {
|
|||||||
spacing: 12,
|
spacing: 12,
|
||||||
runSpacing: 8,
|
runSpacing: 8,
|
||||||
children: [
|
children: [
|
||||||
ElevatedButton.icon(
|
...(() {
|
||||||
|
final reverseButton = ElevatedButton.icon(
|
||||||
|
icon: const Icon(Icons.swap_horiz),
|
||||||
|
label: const Text('Reverse route'),
|
||||||
|
onPressed: () async {
|
||||||
|
setState(() {
|
||||||
|
data.stations = data.stations.reversed.toList();
|
||||||
|
});
|
||||||
|
await _calculateRoute(data.stations);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
final addButton = ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.add),
|
icon: const Icon(Icons.add),
|
||||||
label: const Text('Add Station'),
|
label: const Text('Add Station'),
|
||||||
onPressed: _addStation,
|
onPressed: _addStation,
|
||||||
),
|
);
|
||||||
ElevatedButton.icon(
|
final calculateButton = ElevatedButton.icon(
|
||||||
icon: const Icon(Icons.route),
|
icon: const Icon(Icons.route),
|
||||||
label: const Text('Calculate Route'),
|
label: const Text('Calculate Route'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await _calculateRoute(data.stations);
|
await _calculateRoute(data.stations);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
final isMobile = MediaQuery.of(context).size.width < 600;
|
||||||
|
return isMobile
|
||||||
|
? [addButton, reverseButton, calculateButton]
|
||||||
|
: [reverseButton, addButton, calculateButton];
|
||||||
|
})(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,19 +1,33 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mileograph_flutter/services/authservice.dart';
|
||||||
import 'package:mileograph_flutter/services/data_service.dart';
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
import 'package:mileograph_flutter/services/distance_unit_service.dart';
|
import 'package: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 +52,8 @@ class LeaderboardPanel extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (leaderboard.isNotEmpty)
|
if (leaderboard.isNotEmpty &&
|
||||||
|
MediaQuery.of(context).size.width > 600)
|
||||||
Container(
|
Container(
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||||
@@ -54,6 +69,40 @@ class LeaderboardPanel extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
|
Center(
|
||||||
|
child: SegmentedButton<_LeaderboardScope>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: _LeaderboardScope.global,
|
||||||
|
label: Text('Global'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: _LeaderboardScope.friends,
|
||||||
|
label: Text('Friends'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_scope},
|
||||||
|
onSelectionChanged: (vals) async {
|
||||||
|
if (vals.isEmpty) return;
|
||||||
|
final selected = vals.first;
|
||||||
|
setState(() => _scope = selected);
|
||||||
|
if (selected == _LeaderboardScope.friends &&
|
||||||
|
data.friendsLeaderboard.isEmpty &&
|
||||||
|
!data.isFriendsLeaderboardLoading) {
|
||||||
|
await data.fetchFriendsLeaderboard();
|
||||||
|
} else if (selected == _LeaderboardScope.global &&
|
||||||
|
(data.homepageStats?.leaderboard.isEmpty ?? true) &&
|
||||||
|
!data.isHomepageLoading) {
|
||||||
|
await data.fetchHomepageStats();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: SegmentedButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
if (leaderboard.isEmpty)
|
if (leaderboard.isEmpty)
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.all(8.0),
|
padding: EdgeInsets.all(8.0),
|
||||||
@@ -83,7 +132,11 @@ class LeaderboardPanel extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: Text(
|
trailing: Wrap(
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
spacing: 8,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
distanceUnits.format(
|
distanceUnits.format(
|
||||||
leaderboard[index].mileage,
|
leaderboard[index].mileage,
|
||||||
decimals: 1,
|
decimals: 1,
|
||||||
@@ -92,6 +145,26 @@ class LeaderboardPanel extends StatelessWidget {
|
|||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Builder(
|
||||||
|
builder: (ctx) => IconButton(
|
||||||
|
tooltip: 'View profile',
|
||||||
|
icon: const Icon(Icons.open_in_new, size: 20),
|
||||||
|
onPressed: () {
|
||||||
|
final auth = ctx.read<AuthService>();
|
||||||
|
final userId = leaderboard[index].userId;
|
||||||
|
if (auth.userId == userId) {
|
||||||
|
ctx.go('/more/profile');
|
||||||
|
} else {
|
||||||
|
ctx.pushNamed(
|
||||||
|
'user-profile',
|
||||||
|
queryParameters: {'user_id': userId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (index != leaderboard.length - 1) const Divider(height: 12),
|
if (index != leaderboard.length - 1) const Divider(height: 12),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -27,10 +27,17 @@ class _LegCardState extends State<LegCard> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final leg = widget.leg;
|
final leg = widget.leg;
|
||||||
|
final isShared = leg.legShareId != null && leg.legShareId!.isNotEmpty;
|
||||||
|
final sharedFrom = leg.sharedFrom;
|
||||||
|
final sharedTo = leg.sharedTo;
|
||||||
final distanceUnits = context.watch<DistanceUnitService>();
|
final distanceUnits = context.watch<DistanceUnitService>();
|
||||||
final routeSegments = _parseRouteSegments(leg.route);
|
final routeSegments = _parseRouteSegments(leg.route);
|
||||||
final textTheme = Theme.of(context).textTheme;
|
final textTheme = Theme.of(context).textTheme;
|
||||||
return Card(
|
return Card(
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
elevation: 1,
|
||||||
|
child: Theme(
|
||||||
|
data: Theme.of(context).copyWith(dividerColor: Colors.transparent),
|
||||||
child: ExpansionTile(
|
child: ExpansionTile(
|
||||||
onExpansionChanged: (v) => setState(() => _expanded = v),
|
onExpansionChanged: (v) => setState(() => _expanded = v),
|
||||||
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
tilePadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
@@ -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(
|
||||||
@@ -246,6 +257,34 @@ class _LegCardState extends State<LegCard> {
|
|||||||
..._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),
|
||||||
@@ -256,6 +295,7 @@ class _LegCardState extends State<LegCard> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,6 +375,21 @@ class _RegisterPanelContentState extends State<RegisterPanelContent> {
|
|||||||
border: OutlineInputBorder(),
|
border: OutlineInputBorder(),
|
||||||
labelText: "Username",
|
labelText: "Username",
|
||||||
),
|
),
|
||||||
|
onChanged: (_) {
|
||||||
|
if (_usernameError != null &&
|
||||||
|
!_usernameController.text.contains(' ') &&
|
||||||
|
!_usernameController.text.contains('@')) {
|
||||||
|
setState(() => _usernameError = null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (_usernameError != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(
|
||||||
|
_usernameError!,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
|
|||||||
705
lib/components/pages/badges.dart
Normal file
705
lib/components/pages/badges.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
DateTime? _startDate;
|
DateTime? _startDate;
|
||||||
DateTime? _endDate;
|
DateTime? _endDate;
|
||||||
bool _initialised = false;
|
bool _initialised = false;
|
||||||
|
bool _unallocatedOnly = false;
|
||||||
|
bool _showMoreFilters = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
@@ -33,6 +35,7 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
sortDirection: _sortDirection,
|
sortDirection: _sortDirection,
|
||||||
dateRangeStart: _formatDate(_startDate),
|
dateRangeStart: _formatDate(_startDate),
|
||||||
dateRangeEnd: _formatDate(_endDate),
|
dateRangeEnd: _formatDate(_endDate),
|
||||||
|
unallocatedOnly: _unallocatedOnly,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +47,7 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
dateRangeEnd: _formatDate(_endDate),
|
dateRangeEnd: _formatDate(_endDate),
|
||||||
offset: data.legs.length,
|
offset: data.legs.length,
|
||||||
append: true,
|
append: true,
|
||||||
|
unallocatedOnly: _unallocatedOnly,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,6 +88,8 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
_startDate = null;
|
_startDate = null;
|
||||||
_endDate = null;
|
_endDate = null;
|
||||||
_sortDirection = 0;
|
_sortDirection = 0;
|
||||||
|
_unallocatedOnly = false;
|
||||||
|
_showMoreFilters = false;
|
||||||
});
|
});
|
||||||
_refreshLegs();
|
_refreshLegs();
|
||||||
}
|
}
|
||||||
@@ -177,8 +183,46 @@ class _LegsPageState extends State<LegsPage> {
|
|||||||
: _formatDate(_endDate!)!,
|
: _formatDate(_endDate!)!,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: () => setState(
|
||||||
|
() => _showMoreFilters = !_showMoreFilters,
|
||||||
|
),
|
||||||
|
icon: Icon(
|
||||||
|
_showMoreFilters
|
||||||
|
? Icons.expand_less
|
||||||
|
: Icons.expand_more,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
_showMoreFilters ? 'Hide filters' : 'More filters',
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
AnimatedCrossFade(
|
||||||
|
crossFadeState: _showMoreFilters
|
||||||
|
? CrossFadeState.showFirst
|
||||||
|
: CrossFadeState.showSecond,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
firstChild: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 12.0),
|
||||||
|
child: Wrap(
|
||||||
|
spacing: 12,
|
||||||
|
runSpacing: 12,
|
||||||
|
children: [
|
||||||
|
FilterChip(
|
||||||
|
avatar: const Icon(Icons.flash_off),
|
||||||
|
label: const Text('Unallocated only'),
|
||||||
|
selected: _unallocatedOnly,
|
||||||
|
onSelected: (selected) async {
|
||||||
|
setState(() => _unallocatedOnly = selected);
|
||||||
|
await _refreshLegs();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
secondChild: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
476
lib/components/pages/more/admin_page.dart
Normal file
476
lib/components/pages/more/admin_page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
lib/components/pages/more/more_home_page.dart
Normal file
59
lib/components/pages/more/more_home_page.dart
Normal 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'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
529
lib/components/pages/more/user_profile_page.dart
Normal file
529
lib/components/pages/more/user_profile_page.dart
Normal file
@@ -0,0 +1,529 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import 'package:mileograph_flutter/components/legs/leg_card.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
|
import 'package:mileograph_flutter/services/authservice.dart';
|
||||||
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
|
|
||||||
|
class UserProfilePage extends StatefulWidget {
|
||||||
|
const UserProfilePage({super.key, this.userId, this.initialUser});
|
||||||
|
|
||||||
|
final String? userId;
|
||||||
|
final UserSummary? initialUser;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<UserProfilePage> createState() => _UserProfilePageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _UserProfilePageState extends State<UserProfilePage> {
|
||||||
|
static const int _pageSize = 22;
|
||||||
|
UserProfileDetail? _profile;
|
||||||
|
List<Leg> _legs = const [];
|
||||||
|
bool _loading = false;
|
||||||
|
bool _loadingMore = false;
|
||||||
|
bool _hasMore = false;
|
||||||
|
bool _lastFetchReturnedData = true;
|
||||||
|
Friendship? _friendship;
|
||||||
|
bool _actionsLoading = false;
|
||||||
|
|
||||||
|
String? get _userId => widget.initialUser?.userId ?? widget.userId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_loadProfile();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadProfile() async {
|
||||||
|
final userId = _userId;
|
||||||
|
if (userId == null || userId.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('No user selected.')));
|
||||||
|
context.pop();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() {
|
||||||
|
_loading = true;
|
||||||
|
_hasMore = false;
|
||||||
|
_legs = const [];
|
||||||
|
});
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
try {
|
||||||
|
final profile = await data.fetchUserProfileDetail(userId);
|
||||||
|
final friendship = await data.fetchFriendshipStatus(userId);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (profile == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Failed to load user profile.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final legs = profile.legs;
|
||||||
|
setState(() {
|
||||||
|
_profile = profile;
|
||||||
|
_legs = legs;
|
||||||
|
_lastFetchReturnedData = legs.isNotEmpty;
|
||||||
|
_hasMore = _lastFetchReturnedData && _legs.length >= _pageSize;
|
||||||
|
_friendship = friendship;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMore() async {
|
||||||
|
final userId = _userId;
|
||||||
|
if (userId == null || userId.isEmpty || _loadingMore || !_hasMore) return;
|
||||||
|
setState(() => _loadingMore = true);
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
try {
|
||||||
|
final more = await data.fetchUserLegs(
|
||||||
|
userId: userId,
|
||||||
|
offset: _legs.length,
|
||||||
|
limit: _pageSize,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_legs = [..._legs, ...more];
|
||||||
|
_lastFetchReturnedData = more.isNotEmpty;
|
||||||
|
_hasMore = _lastFetchReturnedData && _legs.length >= _pageSize;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _loadingMore = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleBack() {
|
||||||
|
final router = GoRouter.of(context);
|
||||||
|
if (router.canPop()) {
|
||||||
|
router.pop();
|
||||||
|
} else {
|
||||||
|
router.go('/more/profile');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProfileHeader(ThemeData theme) {
|
||||||
|
final profile = _profile;
|
||||||
|
final username = profile?.username ?? widget.initialUser?.username ?? '';
|
||||||
|
final fullName = profile?.fullName ?? widget.initialUser?.fullName ?? '';
|
||||||
|
final mileage = profile?.mileage;
|
||||||
|
final privacy = profile?.privacyInfo;
|
||||||
|
final mileageHidden =
|
||||||
|
(mileage == null || mileage == 0) &&
|
||||||
|
privacy != null &&
|
||||||
|
privacy.isNotEmpty;
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const CircleAvatar(child: Icon(Icons.person)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
fullName.isNotEmpty ? fullName : username,
|
||||||
|
style: theme.textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
if (username.isNotEmpty)
|
||||||
|
Text('@$username', style: theme.textTheme.bodySmall),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
mileageHidden
|
||||||
|
? 'Mileage hidden'
|
||||||
|
: 'Mileage: ${(mileage ?? 0).toStringAsFixed(1)}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTopLocos() {
|
||||||
|
final profile = _profile;
|
||||||
|
if (profile == null || profile.topLocos.isEmpty) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
final topTen = [...profile.topLocos]
|
||||||
|
..sort((a, b) => (b.mileage ?? 0).compareTo(a.mileage ?? 0));
|
||||||
|
final displayLocos = topTen.take(10).toList();
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Top locos by mileage',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ListView.separated(
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: displayLocos.length,
|
||||||
|
separatorBuilder: (context, index) => const Divider(height: 1),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final loco = displayLocos[index];
|
||||||
|
final mileage = loco.mileage ?? 0;
|
||||||
|
return ListTile(
|
||||||
|
leading: CircleAvatar(
|
||||||
|
backgroundColor: Theme.of(
|
||||||
|
context,
|
||||||
|
).colorScheme.primary.withValues(alpha: 0.1),
|
||||||
|
child: Text(
|
||||||
|
'${index + 1}',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
loco.number.isNotEmpty ? loco.number : 'Unknown',
|
||||||
|
style: Theme.of(context).textTheme.bodyLarge,
|
||||||
|
),
|
||||||
|
subtitle: Text(loco.locoClass),
|
||||||
|
trailing: Text(
|
||||||
|
'${mileage.toStringAsFixed(1)} mi',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildLegsWithDividers(
|
||||||
|
BuildContext context,
|
||||||
|
List<Leg> legs, {
|
||||||
|
required bool showEditButton,
|
||||||
|
}) {
|
||||||
|
final widgets = <Widget>[];
|
||||||
|
String? currentDate;
|
||||||
|
final dayLegs = <Leg>[];
|
||||||
|
|
||||||
|
void flushDay() {
|
||||||
|
final date = currentDate;
|
||||||
|
if (date == null) return;
|
||||||
|
widgets.add(
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Text(
|
||||||
|
date,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelMedium?.copyWith(fontWeight: FontWeight.w700),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widgets.add(const Divider());
|
||||||
|
widgets.addAll(
|
||||||
|
dayLegs.map(
|
||||||
|
(leg) => LegCard(
|
||||||
|
leg: leg,
|
||||||
|
showDate: false,
|
||||||
|
showEditButton: showEditButton,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
dayLegs.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final leg in legs) {
|
||||||
|
final dateStr = _formatDate(leg.beginTime) ?? '';
|
||||||
|
if (currentDate != null && dateStr != currentDate) {
|
||||||
|
flushDay();
|
||||||
|
}
|
||||||
|
currentDate = dateStr;
|
||||||
|
dayLegs.add(leg);
|
||||||
|
}
|
||||||
|
|
||||||
|
flushDay();
|
||||||
|
return widgets;
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _formatDate(DateTime? date) {
|
||||||
|
if (date == null) return null;
|
||||||
|
return '${date.year.toString().padLeft(4, '0')}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFriendSection(AuthService auth) {
|
||||||
|
final friendship = _friendship;
|
||||||
|
if (friendship == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Friendship',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
_buildStatusChip(friendship, auth),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildActions(friendship, auth),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildStatusChip(Friendship status, AuthService auth) {
|
||||||
|
String label = status.status;
|
||||||
|
Color color = Colors.grey;
|
||||||
|
switch (status.status.toLowerCase()) {
|
||||||
|
case 'accepted':
|
||||||
|
label = 'Friends';
|
||||||
|
color = Colors.green;
|
||||||
|
break;
|
||||||
|
case 'pending':
|
||||||
|
final isRequester = status.requesterId == auth.userId;
|
||||||
|
label = isRequester
|
||||||
|
? 'Pending (you sent)'
|
||||||
|
: 'Pending (needs your reply)';
|
||||||
|
color = Colors.orange;
|
||||||
|
break;
|
||||||
|
case 'blocked':
|
||||||
|
color = Colors.red;
|
||||||
|
label = 'Blocked';
|
||||||
|
break;
|
||||||
|
case 'declined':
|
||||||
|
case 'rejected':
|
||||||
|
label = 'Declined';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
label = 'Not friends';
|
||||||
|
}
|
||||||
|
final bg = Color.alphaBlend(
|
||||||
|
color.withValues(alpha: 0.15),
|
||||||
|
Theme.of(context).colorScheme.surface,
|
||||||
|
);
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(left: 6),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: bg,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildActions(Friendship status, AuthService auth) {
|
||||||
|
final targetUserId = _userId;
|
||||||
|
final isSelf = targetUserId != null && targetUserId == auth.userId;
|
||||||
|
if (isSelf) return const Text('This is you.');
|
||||||
|
final isRequester = status.requesterId == auth.userId;
|
||||||
|
final id = status.id;
|
||||||
|
final buttons = <Widget>[];
|
||||||
|
|
||||||
|
Future<void> run(Future<void> Function() action) async {
|
||||||
|
setState(() => _actionsLoading = true);
|
||||||
|
try {
|
||||||
|
await action();
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _actionsLoading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
|
||||||
|
if (status.isNone || status.isDeclined) {
|
||||||
|
buttons.add(
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _actionsLoading
|
||||||
|
? null
|
||||||
|
: () => run(() async {
|
||||||
|
final updated = await data.requestFriendship(
|
||||||
|
status.addresseeId,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _friendship = updated);
|
||||||
|
}),
|
||||||
|
icon: const Icon(Icons.person_add),
|
||||||
|
label: const Text('Send friend request'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else if (status.isPending) {
|
||||||
|
if (isRequester) {
|
||||||
|
buttons.add(
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _actionsLoading || id == null || id.isEmpty
|
||||||
|
? null
|
||||||
|
: () => run(() async {
|
||||||
|
await data.cancelFriendship(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(
|
||||||
|
() => _friendship = status.copyWith(status: 'none'),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
child: const Text('Cancel request'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
buttons.add(
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _actionsLoading || id == null || id.isEmpty
|
||||||
|
? null
|
||||||
|
: () => run(() async {
|
||||||
|
final updated = await data.acceptFriendship(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _friendship = updated);
|
||||||
|
}),
|
||||||
|
child: const Text('Accept'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
buttons.add(
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: _actionsLoading || id == null || id.isEmpty
|
||||||
|
? null
|
||||||
|
: () => run(() async {
|
||||||
|
final updated = await data.rejectFriendship(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _friendship = updated);
|
||||||
|
}),
|
||||||
|
child: const Text('Reject'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (status.isAccepted) {
|
||||||
|
buttons.add(
|
||||||
|
ElevatedButton.icon(
|
||||||
|
onPressed: _actionsLoading || id == null || id.isEmpty
|
||||||
|
? null
|
||||||
|
: () => run(() async {
|
||||||
|
await data.deleteFriendship(id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _friendship = status.copyWith(status: 'none'));
|
||||||
|
}),
|
||||||
|
icon: const Icon(Icons.person_remove),
|
||||||
|
label: const Text('Unfriend'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (buttons.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
return Wrap(spacing: 8, runSpacing: 8, children: buttons);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final auth = context.watch<AuthService>();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final canEdit = auth.userId != null && auth.userId == _userId;
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('User profile'),
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back),
|
||||||
|
onPressed: _handleBack,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _loadProfile,
|
||||||
|
child: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
_buildProfileHeader(theme),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildFriendSection(auth),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_buildTopLocos(),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(12.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Entries', style: theme.textTheme.titleMedium),
|
||||||
|
if (_loading)
|
||||||
|
const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_loading && _legs.isEmpty)
|
||||||
|
const Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 16.0),
|
||||||
|
child: CircularProgressIndicator(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else if (_legs.isEmpty)
|
||||||
|
Text(
|
||||||
|
(_profile?.privacyInfo.isNotEmpty ?? false)
|
||||||
|
? 'Hidden due to privacy settings.'
|
||||||
|
: 'No entries found.',
|
||||||
|
)
|
||||||
|
else ...[
|
||||||
|
..._buildLegsWithDividers(
|
||||||
|
context,
|
||||||
|
_legs,
|
||||||
|
showEditButton: canEdit,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if ((_hasMore || _loadingMore) && _legs.isNotEmpty)
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _loadingMore ? null : _loadMore,
|
||||||
|
icon: _loadingMore
|
||||||
|
? const SizedBox(
|
||||||
|
height: 14,
|
||||||
|
width: 14,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Icon(Icons.expand_more),
|
||||||
|
label: Text(
|
||||||
|
_loadingMore ? 'Loading...' : 'Load more',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
try {
|
||||||
await _saveDraftEntry(draftId: _activeDraftId);
|
await _saveDraftEntry(draftId: _activeDraftId);
|
||||||
|
} catch (_) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
} else if (choice == _ExitChoice.discard) {
|
} else if (choice == _ExitChoice.discard) {
|
||||||
// Delay reset to avoid setState during the dialog/build phase.
|
// Delay reset to avoid setState during the dialog/build phase.
|
||||||
await Future<void>.delayed(Duration.zero);
|
await Future<void>.delayed(Duration.zero);
|
||||||
@@ -53,6 +63,7 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
|
|
||||||
Future<_ExitChoice> _promptSaveDraft() async {
|
Future<_ExitChoice> _promptSaveDraft() async {
|
||||||
if (!mounted) return _ExitChoice.cancel;
|
if (!mounted) return _ExitChoice.cancel;
|
||||||
|
try {
|
||||||
final result = await showDialog<_ExitChoice>(
|
final result = await showDialog<_ExitChoice>(
|
||||||
context: context,
|
context: context,
|
||||||
barrierDismissible: false,
|
barrierDismissible: false,
|
||||||
@@ -78,10 +89,15 @@ extension _NewEntryDraftLogic on _NewEntryPageState {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (!mounted) return _ExitChoice.cancel;
|
||||||
return result ?? _ExitChoice.cancel;
|
return result ?? _ExitChoice.cancel;
|
||||||
|
} catch (_) {
|
||||||
|
return _ExitChoice.cancel;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _openDrafts() async {
|
Future<void> _openDrafts() async {
|
||||||
|
if (_activeLegShare != null) return;
|
||||||
final selected = await Navigator.of(context).push<_StoredDraft>(
|
final selected = await Navigator.of(context).push<_StoredDraft>(
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => _DraftListPage(
|
builder: (_) => _DraftListPage(
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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'),
|
||||||
),
|
),
|
||||||
@@ -820,7 +1249,10 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
minimumSize: const Size(0, 36),
|
minimumSize: const Size(0, 36),
|
||||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||||
),
|
),
|
||||||
onPressed: _isEditing || _savingDraft || _submitting
|
onPressed: _isEditing ||
|
||||||
|
_savingDraft ||
|
||||||
|
_submitting ||
|
||||||
|
_activeLegShare != null
|
||||||
? null
|
? null
|
||||||
: _saveDraftManually,
|
: _saveDraftManually,
|
||||||
icon: _savingDraft
|
icon: _savingDraft
|
||||||
@@ -847,7 +1279,13 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
if (_activeLegShare != null) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_buildSharedBanner(),
|
||||||
|
],
|
||||||
_buildTripSelector(context),
|
_buildTripSelector(context),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
if (_activeLegShare == null) _buildShareSection(context),
|
||||||
_dateTimeGroup(
|
_dateTimeGroup(
|
||||||
context,
|
context,
|
||||||
title: 'Departure time',
|
title: 'Departure time',
|
||||||
@@ -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,17 +1437,53 @@ 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)
|
||||||
Align(
|
Wrap(
|
||||||
alignment: Alignment.centerLeft,
|
spacing: 12,
|
||||||
child: ElevatedButton.icon(
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ElevatedButton.icon(
|
||||||
onPressed: _openCalculator,
|
onPressed: _openCalculator,
|
||||||
icon: const Icon(Icons.calculate, size: 18),
|
icon: const Icon(Icons.calculate, size: 18),
|
||||||
label: const Text('Open mileage calculator'),
|
label: const Text('Open mileage calculator'),
|
||||||
),
|
),
|
||||||
|
OutlinedButton.icon(
|
||||||
|
onPressed: _reverseRouteAndEndpoints,
|
||||||
|
icon: const Icon(Icons.swap_horiz),
|
||||||
|
label: const Text('Reverse route'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: OutlinedButton.icon(
|
||||||
|
onPressed: _reverseRouteAndEndpoints,
|
||||||
|
icon: const Icon(Icons.swap_horiz),
|
||||||
|
label: const Text('Reverse route'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (routeStart.isNotEmpty || routeEnd.isNotEmpty)
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
title: const Text('Route'),
|
||||||
|
subtitle: Text(
|
||||||
|
'${routeStart.isEmpty ? 'Start' : routeStart} → ${routeEnd.isEmpty ? 'End' : routeEnd}',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (_useManualMileage)
|
if (_useManualMileage)
|
||||||
TextFormField(
|
TextFormField(
|
||||||
@@ -1072,22 +1549,25 @@ class _NewEntryPageState extends State<NewEntryPage> {
|
|||||||
children: [
|
children: [
|
||||||
entryPanel,
|
entryPanel,
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
if (twoCol) ...[
|
||||||
trainPanel,
|
trainPanel,
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
twoCol
|
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
|
||||||
|
Column(
|
||||||
children: [
|
children: [
|
||||||
tractionPanel,
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
mileagePanel,
|
mileagePanel,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
trainPanel,
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
tractionPanel,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -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');
|
||||||
|
}(),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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
@@ -3,8 +3,6 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:mileograph_flutter/services/authservice.dart';
|
|
||||||
import 'package:mileograph_flutter/services/api_service.dart';
|
|
||||||
import 'package:mileograph_flutter/services/distance_unit_service.dart';
|
import 'package:mileograph_flutter/services/distance_unit_service.dart';
|
||||||
import 'package:mileograph_flutter/services/endpoint_service.dart';
|
import 'package:mileograph_flutter/services/endpoint_service.dart';
|
||||||
import 'package:mileograph_flutter/services/data_service.dart';
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
@@ -20,27 +18,16 @@ class SettingsPage extends StatefulWidget {
|
|||||||
class _SettingsPageState extends State<SettingsPage> {
|
class _SettingsPageState extends State<SettingsPage> {
|
||||||
late final TextEditingController _endpointController;
|
late final TextEditingController _endpointController;
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
bool _changingPassword = false;
|
|
||||||
final _passwordFormKey = GlobalKey<FormState>();
|
|
||||||
late final TextEditingController _currentPasswordController;
|
|
||||||
late final TextEditingController _newPasswordController;
|
|
||||||
late final TextEditingController _confirmPasswordController;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
final endpoint = context.read<EndpointService>().baseUrl;
|
final endpoint = context.read<EndpointService>().baseUrl;
|
||||||
_endpointController = TextEditingController(text: endpoint);
|
_endpointController = TextEditingController(text: endpoint);
|
||||||
_currentPasswordController = TextEditingController();
|
|
||||||
_newPasswordController = TextEditingController();
|
|
||||||
_confirmPasswordController = TextEditingController();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_currentPasswordController.dispose();
|
|
||||||
_newPasswordController.dispose();
|
|
||||||
_confirmPasswordController.dispose();
|
|
||||||
_endpointController.dispose();
|
_endpointController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -139,47 +126,10 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _changePassword() async {
|
|
||||||
final messenger = ScaffoldMessenger.of(context);
|
|
||||||
final formState = _passwordFormKey.currentState;
|
|
||||||
if (formState == null || !formState.validate()) return;
|
|
||||||
|
|
||||||
FocusScope.of(context).unfocus();
|
|
||||||
setState(() => _changingPassword = true);
|
|
||||||
try {
|
|
||||||
final api = context.read<ApiService>();
|
|
||||||
await api.post('/user/password/change', {
|
|
||||||
'old_password': _currentPasswordController.text,
|
|
||||||
'new_password': _newPasswordController.text,
|
|
||||||
});
|
|
||||||
if (!mounted) return;
|
|
||||||
messenger.showSnackBar(
|
|
||||||
const SnackBar(content: Text('Password updated successfully.')),
|
|
||||||
);
|
|
||||||
formState.reset();
|
|
||||||
_currentPasswordController.clear();
|
|
||||||
_newPasswordController.clear();
|
|
||||||
_confirmPasswordController.clear();
|
|
||||||
} catch (e) {
|
|
||||||
if (mounted) {
|
|
||||||
messenger.showSnackBar(
|
|
||||||
SnackBar(content: Text('Failed to change password: $e')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (mounted) {
|
|
||||||
setState(() => _changingPassword = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final endpointService = context.watch<EndpointService>();
|
final endpointService = context.watch<EndpointService>();
|
||||||
final distanceUnitService = context.watch<DistanceUnitService>();
|
final distanceUnitService = context.watch<DistanceUnitService>();
|
||||||
final loggedIn = context.select<AuthService, bool>(
|
|
||||||
(auth) => auth.isLoggedIn,
|
|
||||||
);
|
|
||||||
if (!endpointService.isLoaded || !distanceUnitService.isLoaded) {
|
if (!endpointService.isLoaded || !distanceUnitService.isLoaded) {
|
||||||
return const Scaffold(
|
return const Scaffold(
|
||||||
body: Center(child: CircularProgressIndicator()),
|
body: Center(child: CircularProgressIndicator()),
|
||||||
@@ -285,99 +235,6 @@ class _SettingsPageState extends State<SettingsPage> {
|
|||||||
'Current: ${endpointService.baseUrl}',
|
'Current: ${endpointService.baseUrl}',
|
||||||
style: Theme.of(context).textTheme.labelSmall,
|
style: Theme.of(context).textTheme.labelSmall,
|
||||||
),
|
),
|
||||||
if (loggedIn) ...[
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
Text(
|
|
||||||
'Account',
|
|
||||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Change your password for this account.',
|
|
||||||
style: Theme.of(context).textTheme.bodySmall,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Form(
|
|
||||||
key: _passwordFormKey,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
TextFormField(
|
|
||||||
controller: _currentPasswordController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Current password',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
obscureText: true,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autocorrect: false,
|
|
||||||
autofillHints: const [AutofillHints.password],
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Please enter your current password.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextFormField(
|
|
||||||
controller: _newPasswordController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'New password',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
obscureText: true,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autocorrect: false,
|
|
||||||
autofillHints: const [AutofillHints.newPassword],
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Please enter a new password.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
TextFormField(
|
|
||||||
controller: _confirmPasswordController,
|
|
||||||
decoration: const InputDecoration(
|
|
||||||
labelText: 'Confirm new password',
|
|
||||||
border: OutlineInputBorder(),
|
|
||||||
),
|
|
||||||
obscureText: true,
|
|
||||||
enableSuggestions: false,
|
|
||||||
autocorrect: false,
|
|
||||||
autofillHints: const [AutofillHints.newPassword],
|
|
||||||
validator: (value) {
|
|
||||||
if (value == null || value.isEmpty) {
|
|
||||||
return 'Please confirm the new password.';
|
|
||||||
}
|
|
||||||
if (value != _newPasswordController.text) {
|
|
||||||
return 'New passwords do not match.';
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
FilledButton.icon(
|
|
||||||
onPressed: _changingPassword ? null : _changePassword,
|
|
||||||
icon: _changingPassword
|
|
||||||
? const SizedBox(
|
|
||||||
width: 18,
|
|
||||||
height: 18,
|
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(Icons.lock_reset),
|
|
||||||
label: Text(
|
|
||||||
_changingPassword ? 'Updating...' : 'Change password',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|
||||||
|
|||||||
@@ -33,6 +33,14 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
String? _classStatsError;
|
String? _classStatsError;
|
||||||
String? _classStatsForClass;
|
String? _classStatsForClass;
|
||||||
Map<String, dynamic>? _classStats;
|
Map<String, dynamic>? _classStats;
|
||||||
|
bool _showClassLeaderboardPanel = false;
|
||||||
|
bool _classLeaderboardLoading = false;
|
||||||
|
String? _classLeaderboardError;
|
||||||
|
String? _classLeaderboardForClass;
|
||||||
|
String? _classFriendsLeaderboardForClass;
|
||||||
|
List<LeaderboardEntry> _classLeaderboard = [];
|
||||||
|
List<LeaderboardEntry> _classFriendsLeaderboard = [];
|
||||||
|
_ClassLeaderboardScope _classLeaderboardScope = _ClassLeaderboardScope.global;
|
||||||
|
|
||||||
final Map<String, TextEditingController> _dynamicControllers = {};
|
final Map<String, TextEditingController> _dynamicControllers = {};
|
||||||
final Map<String, String?> _enumSelections = {};
|
final Map<String, String?> _enumSelections = {};
|
||||||
@@ -211,6 +219,13 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
_classStats = null;
|
_classStats = null;
|
||||||
_classStatsError = null;
|
_classStatsError = null;
|
||||||
_classStatsForClass = null;
|
_classStatsForClass = null;
|
||||||
|
_showClassLeaderboardPanel = false;
|
||||||
|
_classLeaderboard = [];
|
||||||
|
_classFriendsLeaderboard = [];
|
||||||
|
_classLeaderboardError = null;
|
||||||
|
_classLeaderboardForClass = null;
|
||||||
|
_classFriendsLeaderboardForClass = null;
|
||||||
|
_classLeaderboardScope = _ClassLeaderboardScope.global;
|
||||||
});
|
});
|
||||||
_refreshTraction();
|
_refreshTraction();
|
||||||
}
|
}
|
||||||
@@ -223,6 +238,7 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
_refreshClassStatsIfOpen();
|
_refreshClassStatsIfOpen();
|
||||||
|
_refreshClassLeaderboardIfOpen();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<EventField> _activeEventFields(List<EventField> fields) {
|
List<EventField> _activeEventFields(List<EventField> fields) {
|
||||||
@@ -405,6 +421,7 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
});
|
});
|
||||||
_refreshTraction();
|
_refreshTraction();
|
||||||
_refreshClassStatsIfOpen(immediate: true);
|
_refreshClassStatsIfOpen(immediate: true);
|
||||||
|
_refreshClassLeaderboardIfOpen(immediate: true);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -518,6 +535,19 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
sliver: SliverToBoxAdapter(
|
||||||
|
child: AnimatedCrossFade(
|
||||||
|
crossFadeState: (_showClassLeaderboardPanel && _hasClassQuery)
|
||||||
|
? CrossFadeState.showFirst
|
||||||
|
: CrossFadeState.showSecond,
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
firstChild: _buildClassLeaderboardCard(context),
|
||||||
|
secondChild: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
SliverPadding(
|
SliverPadding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||||
sliver: _buildTractionSliver(context, data, traction),
|
sliver: _buildTractionSliver(context, data, traction),
|
||||||
@@ -569,6 +599,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,
|
||||||
@@ -586,8 +617,21 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
_showClassStatsPanel ? 'Hide class stats' : 'Class stats',
|
_showClassStatsPanel ? 'Hide class stats' : 'Class stats',
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
final classLeaderboardButton = !_hasClassQuery
|
||||||
|
? null
|
||||||
|
: FilledButton.tonalIcon(
|
||||||
|
onPressed: _toggleClassLeaderboardPanel,
|
||||||
|
icon: Icon(
|
||||||
|
_showClassLeaderboardPanel ? Icons.emoji_events : Icons.leaderboard,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
_showClassLeaderboardPanel ? 'Hide class leaderboard' : 'Class leaderboard',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
final newTractionButton = FilledButton.icon(
|
final newTractionButton = !isElevated
|
||||||
|
? null
|
||||||
|
: FilledButton.icon(
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
final createdClass = await context.push<String>(
|
final createdClass = await context.push<String>(
|
||||||
'/traction/new',
|
'/traction/new',
|
||||||
@@ -608,12 +652,14 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
final desktopActions = [
|
final desktopActions = [
|
||||||
refreshButton,
|
refreshButton,
|
||||||
if (classStatsButton != null) classStatsButton,
|
if (classStatsButton != null) classStatsButton,
|
||||||
newTractionButton,
|
if (classLeaderboardButton != null) classLeaderboardButton,
|
||||||
|
if (newTractionButton != null) newTractionButton,
|
||||||
];
|
];
|
||||||
|
|
||||||
final mobileActions = [
|
final mobileActions = [
|
||||||
newTractionButton,
|
if (newTractionButton != null) newTractionButton,
|
||||||
if (classStatsButton != null) classStatsButton,
|
if (classStatsButton != null) classStatsButton,
|
||||||
|
if (classLeaderboardButton != null) classLeaderboardButton,
|
||||||
refreshButton,
|
refreshButton,
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -941,6 +987,42 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _placementBadge(BuildContext context, int index) {
|
||||||
|
const size = 32.0;
|
||||||
|
const iconSize = 18.0;
|
||||||
|
if (index == 0) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: size / 2,
|
||||||
|
backgroundColor: Colors.amber.shade400,
|
||||||
|
child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (index == 1) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: size / 2,
|
||||||
|
backgroundColor: Colors.blueGrey.shade200,
|
||||||
|
child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (index == 2) {
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: size / 2,
|
||||||
|
backgroundColor: Colors.brown.shade300,
|
||||||
|
child: const Icon(Icons.emoji_events, color: Colors.white, size: iconSize),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CircleAvatar(
|
||||||
|
radius: size / 2,
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||||
|
child: Text(
|
||||||
|
'${index + 1}',
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Color _statusColor(String status, ColorScheme scheme) {
|
Color _statusColor(String status, ColorScheme scheme) {
|
||||||
final key = status.toLowerCase();
|
final key = status.toLowerCase();
|
||||||
if (key.contains('scrap')) return Colors.red.shade600;
|
if (key.contains('scrap')) return Colors.red.shade600;
|
||||||
@@ -1147,4 +1229,212 @@ class _TractionPageState extends State<TractionPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleClassLeaderboardPanel() async {
|
||||||
|
if (!_hasClassQuery) return;
|
||||||
|
final targetState = !_showClassLeaderboardPanel;
|
||||||
|
setState(() {
|
||||||
|
_showClassLeaderboardPanel = targetState;
|
||||||
|
});
|
||||||
|
if (targetState) {
|
||||||
|
await _loadClassLeaderboard(friends: _classLeaderboardScope == _ClassLeaderboardScope.friends);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _refreshClassLeaderboardIfOpen({bool immediate = false}) {
|
||||||
|
if (!_showClassLeaderboardPanel || !_hasClassQuery) return;
|
||||||
|
final query = (_selectedClass ?? _classController.text).trim();
|
||||||
|
final scope = _classLeaderboardScope;
|
||||||
|
final currentData = scope == _ClassLeaderboardScope.global
|
||||||
|
? _classLeaderboard
|
||||||
|
: _classFriendsLeaderboard;
|
||||||
|
final currentClass = scope == _ClassLeaderboardScope.global
|
||||||
|
? _classLeaderboardForClass
|
||||||
|
: _classFriendsLeaderboardForClass;
|
||||||
|
if (!immediate && currentClass == query && currentData.isNotEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_loadClassLeaderboard(
|
||||||
|
friends: scope == _ClassLeaderboardScope.friends,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadClassLeaderboard({required bool friends}) async {
|
||||||
|
final query = (_selectedClass ?? _classController.text).trim();
|
||||||
|
if (query.isEmpty) return;
|
||||||
|
final currentClass = friends ? _classFriendsLeaderboardForClass : _classLeaderboardForClass;
|
||||||
|
final currentData = friends ? _classFriendsLeaderboard : _classLeaderboard;
|
||||||
|
if (currentClass == query && currentData.isNotEmpty) return;
|
||||||
|
setState(() {
|
||||||
|
_classLeaderboardLoading = true;
|
||||||
|
_classLeaderboardError = null;
|
||||||
|
if (friends && _classFriendsLeaderboardForClass != query) {
|
||||||
|
_classFriendsLeaderboard = [];
|
||||||
|
} else if (!friends && _classLeaderboardForClass != query) {
|
||||||
|
_classLeaderboard = [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final leaderboard = await data.fetchClassLeaderboard(
|
||||||
|
query,
|
||||||
|
friends: friends,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
if (friends) {
|
||||||
|
_classFriendsLeaderboard = leaderboard;
|
||||||
|
_classFriendsLeaderboardForClass = query;
|
||||||
|
} else {
|
||||||
|
_classLeaderboard = leaderboard;
|
||||||
|
_classLeaderboardForClass = query;
|
||||||
|
}
|
||||||
|
_classLeaderboardError = null;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_classLeaderboardError = 'Failed to load class leaderboard: $e';
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_classLeaderboardLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildClassLeaderboardCard(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final distanceUnits = context.watch<DistanceUnitService>();
|
||||||
|
final leaderboard = _classLeaderboardScope == _ClassLeaderboardScope.global
|
||||||
|
? _classLeaderboard
|
||||||
|
: _classFriendsLeaderboard;
|
||||||
|
final loading = _classLeaderboardLoading;
|
||||||
|
final error = _classLeaderboardError;
|
||||||
|
return Card(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
(_selectedClass ?? _classController.text).trim().isEmpty
|
||||||
|
? 'Class leaderboard'
|
||||||
|
: '${(_selectedClass ?? _classController.text).trim()} leaderboard',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SegmentedButton<_ClassLeaderboardScope>(
|
||||||
|
segments: const [
|
||||||
|
ButtonSegment(
|
||||||
|
value: _ClassLeaderboardScope.global,
|
||||||
|
label: Text('Global'),
|
||||||
|
),
|
||||||
|
ButtonSegment(
|
||||||
|
value: _ClassLeaderboardScope.friends,
|
||||||
|
label: Text('Friends'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
selected: {_classLeaderboardScope},
|
||||||
|
onSelectionChanged: (vals) async {
|
||||||
|
if (vals.isEmpty) return;
|
||||||
|
final selected = vals.first;
|
||||||
|
setState(() => _classLeaderboardScope = selected);
|
||||||
|
if (selected == _ClassLeaderboardScope.friends &&
|
||||||
|
_classFriendsLeaderboard.isEmpty &&
|
||||||
|
!_classLeaderboardLoading) {
|
||||||
|
await _loadClassLeaderboard(friends: true);
|
||||||
|
} else if (selected == _ClassLeaderboardScope.global &&
|
||||||
|
_classLeaderboard.isEmpty &&
|
||||||
|
!_classLeaderboardLoading) {
|
||||||
|
await _loadClassLeaderboard(friends: false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
style: SegmentedButton.styleFrom(
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Refresh leaderboard',
|
||||||
|
icon: const Icon(Icons.refresh),
|
||||||
|
onPressed: () => _loadClassLeaderboard(
|
||||||
|
friends: _classLeaderboardScope == _ClassLeaderboardScope.friends,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
if (loading)
|
||||||
|
Row(
|
||||||
|
children: const [
|
||||||
|
SizedBox(
|
||||||
|
height: 16,
|
||||||
|
width: 16,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('Loading leaderboard...'),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
else if (error != null)
|
||||||
|
Text(
|
||||||
|
error,
|
||||||
|
style: TextStyle(color: scheme.error),
|
||||||
|
)
|
||||||
|
else if (leaderboard.isEmpty)
|
||||||
|
const Text('No leaderboard data yet.')
|
||||||
|
else
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
for (int i = 0; i < leaderboard.length; i++) ...[
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
dense: true,
|
||||||
|
leading: _placementBadge(context, i),
|
||||||
|
title: Text(
|
||||||
|
leaderboard[i].userFullName,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: Text(
|
||||||
|
distanceUnits.format(
|
||||||
|
leaderboard[i].mileage,
|
||||||
|
decimals: 1,
|
||||||
|
),
|
||||||
|
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
final auth = context.read<AuthService>();
|
||||||
|
final userId = leaderboard[i].userId;
|
||||||
|
if (auth.userId == userId) {
|
||||||
|
context.go('/more/profile');
|
||||||
|
} else {
|
||||||
|
context.pushNamed(
|
||||||
|
'user-profile',
|
||||||
|
queryParameters: {'user_id': userId},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
if (i != leaderboard.length - 1) const Divider(height: 12),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum _ClassLeaderboardScope { global, friends }
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
127
lib/components/widgets/friend_request_notification_card.dart
Normal file
127
lib/components/widgets/friend_request_notification_card.dart
Normal 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')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
532
lib/components/widgets/leg_share_edit_notification_card.dart
Normal file
532
lib/components/widgets/leg_share_edit_notification_card.dart
Normal file
@@ -0,0 +1,532 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class LegShareEditNotificationCard extends StatefulWidget {
|
||||||
|
const LegShareEditNotificationCard({super.key, required this.notification});
|
||||||
|
|
||||||
|
final UserNotification notification;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LegShareEditNotificationCard> createState() => _LegShareEditNotificationCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LegShareEditNotificationCardState extends State<LegShareEditNotificationCard> {
|
||||||
|
Map<String, dynamic>? _changes;
|
||||||
|
int? _legId;
|
||||||
|
int? _shareId;
|
||||||
|
Leg? _currentLeg;
|
||||||
|
bool _loading = false;
|
||||||
|
|
||||||
|
static const int _summaryLimit = 3;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_parseNotification();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant LegShareEditNotificationCard oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.notification != widget.notification) {
|
||||||
|
_parseNotification();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _parseNotification() {
|
||||||
|
final rawBody = widget.notification.body.trim();
|
||||||
|
|
||||||
|
// Reset
|
||||||
|
_shareId = null;
|
||||||
|
_legId = null;
|
||||||
|
_currentLeg = null;
|
||||||
|
_changes = null;
|
||||||
|
|
||||||
|
final parsed = _decodeBody(rawBody);
|
||||||
|
if (parsed != null) {
|
||||||
|
_shareId = _parseInt(parsed['share_id']);
|
||||||
|
_legId = _parseInt(parsed['leg_id']);
|
||||||
|
final accepted = _asStringKeyedMap(parsed['accepted_changes']);
|
||||||
|
if (accepted != null) {
|
||||||
|
_changes = accepted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: extract share_id from raw string if still missing.
|
||||||
|
_shareId ??= _extractShareId(rawBody);
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _parseInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value.trim());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _decodeBody(String rawBody) {
|
||||||
|
final attempts = <String>[
|
||||||
|
rawBody,
|
||||||
|
_stripWrappingQuotes(rawBody),
|
||||||
|
_replaceSingleQuotes(rawBody),
|
||||||
|
].where((s) => s.trim().isNotEmpty).toSet();
|
||||||
|
|
||||||
|
for (final attempt in attempts) {
|
||||||
|
final parsed = _decodeJsonToMap(attempt);
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _decodeJsonToMap(String source) {
|
||||||
|
dynamic parsed = source;
|
||||||
|
for (int i = 0; i < 3 && parsed is String; i++) {
|
||||||
|
try {
|
||||||
|
parsed = jsonDecode(parsed);
|
||||||
|
} catch (e) {
|
||||||
|
parsed = null;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (parsed is Map) {
|
||||||
|
final map = parsed.map((k, v) => MapEntry(k.toString(), v));
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stripWrappingQuotes(String input) {
|
||||||
|
final trimmed = input.trim();
|
||||||
|
if (trimmed.length >= 2 &&
|
||||||
|
((trimmed.startsWith('"') && trimmed.endsWith('"')) ||
|
||||||
|
(trimmed.startsWith("'") && trimmed.endsWith("'")))) {
|
||||||
|
return trimmed.substring(1, trimmed.length - 1);
|
||||||
|
}
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _replaceSingleQuotes(String input) {
|
||||||
|
if (!input.contains("'")) return input;
|
||||||
|
return input.replaceAll(RegExp(r"(?<!\\)'"), '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _asStringKeyedMap(dynamic value) {
|
||||||
|
if (value is Map) {
|
||||||
|
return value.map((k, v) => MapEntry(k.toString(), v));
|
||||||
|
}
|
||||||
|
if (value is String && value.trim().isNotEmpty) {
|
||||||
|
for (final attempt in [value, _replaceSingleQuotes(value)]) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(attempt);
|
||||||
|
if (decoded is Map) {
|
||||||
|
return decoded.map((k, v) => MapEntry(k.toString(), v));
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore; handled by caller.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _extractShareId(String raw) {
|
||||||
|
final patterns = [
|
||||||
|
RegExp(r'"share_id"\s*:\s*(\d+)'),
|
||||||
|
RegExp(r"'share_id'\s*:\s*(\d+)"),
|
||||||
|
RegExp(r'share_id\s*:\s*(\d+)'),
|
||||||
|
RegExp(r'"share_id"\s*:\s*"(\d+)"'),
|
||||||
|
];
|
||||||
|
for (final pattern in patterns) {
|
||||||
|
final match = pattern.firstMatch(raw);
|
||||||
|
if (match != null) {
|
||||||
|
final parsed = int.tryParse(match.group(1)!);
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadLegIdIfNeeded() async {
|
||||||
|
if (_legId != null) return;
|
||||||
|
if (_shareId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
final share = await context.read<DataService>().fetchLegShare(_shareId!.toString());
|
||||||
|
if (!mounted) return;
|
||||||
|
_legId = share?.entry.id;
|
||||||
|
_currentLeg ??= _findCurrentLeg(_legId);
|
||||||
|
} catch (e) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Leg? _findCurrentLeg(int? legId) {
|
||||||
|
if (legId == null) return null;
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
try {
|
||||||
|
return data.legs.firstWhere((l) => l.id == legId);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final changes = _changes;
|
||||||
|
if (changes == null || changes.isEmpty) {
|
||||||
|
return const Text('No changes supplied.');
|
||||||
|
}
|
||||||
|
final entries = changes.entries.toList();
|
||||||
|
final shown = entries.take(_summaryLimit).toList();
|
||||||
|
final remaining = entries.length - shown.length;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
...shown.map((e) => _changePreview(context, e)),
|
||||||
|
if (remaining > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text('+$remaining others…', style: Theme.of(context).textTheme.bodySmall),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loading ? null : () => _openDrawer(changes),
|
||||||
|
child: const Text('View changes'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: _loading ? null : _dismiss,
|
||||||
|
child: const Text('Dismiss changes'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _changePreview(BuildContext context, MapEntry<String, dynamic> change) {
|
||||||
|
final key = _prettyField(change.key);
|
||||||
|
final value = change.value;
|
||||||
|
String display;
|
||||||
|
if (change.key == 'locos' && value is List) {
|
||||||
|
display = '${value.length} traction update${value.length == 1 ? '' : 's'}';
|
||||||
|
} else {
|
||||||
|
display = _stringify(value);
|
||||||
|
}
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text('$key: $display'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _prettyField(String raw) {
|
||||||
|
switch (raw) {
|
||||||
|
case 'leg_notes':
|
||||||
|
return 'Notes';
|
||||||
|
case 'locos':
|
||||||
|
return 'Traction';
|
||||||
|
default:
|
||||||
|
return raw.replaceAll('_', ' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dynamic _currentValueForField(Leg leg, String key) {
|
||||||
|
switch (key) {
|
||||||
|
case 'leg_begin_time':
|
||||||
|
return leg.beginTime;
|
||||||
|
case 'leg_end_time':
|
||||||
|
return leg.endTime;
|
||||||
|
case 'leg_origin_time':
|
||||||
|
return leg.originTime;
|
||||||
|
case 'leg_destination_time':
|
||||||
|
return leg.destinationTime;
|
||||||
|
case 'leg_notes':
|
||||||
|
return leg.notes;
|
||||||
|
case 'leg_headcode':
|
||||||
|
return leg.headcode;
|
||||||
|
case 'leg_network':
|
||||||
|
return leg.network;
|
||||||
|
case 'leg_start':
|
||||||
|
return leg.start;
|
||||||
|
case 'leg_end':
|
||||||
|
return leg.end;
|
||||||
|
case 'leg_origin':
|
||||||
|
return leg.origin;
|
||||||
|
case 'leg_destination':
|
||||||
|
return leg.destination;
|
||||||
|
case 'leg_route':
|
||||||
|
return leg.route;
|
||||||
|
case 'leg_mileage':
|
||||||
|
return leg.mileage;
|
||||||
|
case 'leg_begin_delay':
|
||||||
|
return leg.beginDelayMinutes;
|
||||||
|
case 'leg_end_delay':
|
||||||
|
return leg.endDelayMinutes;
|
||||||
|
case 'locos':
|
||||||
|
return leg.locos;
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildChangeValueWidget(
|
||||||
|
String key,
|
||||||
|
dynamic newValue,
|
||||||
|
Leg? currentLeg,
|
||||||
|
Widget Function(List<dynamic>) buildLocos,
|
||||||
|
) {
|
||||||
|
final currentValue = currentLeg == null ? null : _currentValueForField(currentLeg, key);
|
||||||
|
if (key == 'locos' && newValue is List) {
|
||||||
|
final currentCount = (currentValue is List) ? currentValue.length : 0;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Current: $currentCount locos'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
const Text('New:'),
|
||||||
|
buildLocos(newValue),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final currentStr = _stringify(currentValue);
|
||||||
|
final newStr = _stringify(newValue);
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(child: Text(currentStr, maxLines: 3, overflow: TextOverflow.ellipsis)),
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(horizontal: 6.0),
|
||||||
|
child: Icon(Icons.arrow_right_alt, size: 18),
|
||||||
|
),
|
||||||
|
Expanded(child: Text(newStr, maxLines: 3, overflow: TextOverflow.ellipsis)),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stringify(dynamic value) {
|
||||||
|
if (value is DateTime) return value.toIso8601String();
|
||||||
|
if (value == null) return '—';
|
||||||
|
if (value is List || value is Map) {
|
||||||
|
return jsonEncode(value);
|
||||||
|
}
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _openDrawer(Map<String, dynamic> changes) async {
|
||||||
|
setState(() => _loading = true);
|
||||||
|
await _loadLegIdIfNeeded();
|
||||||
|
_currentLeg ??= _findCurrentLeg(_legId);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _loading = false);
|
||||||
|
final legId = _legId;
|
||||||
|
if (legId == null) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Unable to load shared leg.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final selected = Map<String, bool>.fromEntries(
|
||||||
|
changes.keys.map((k) => MapEntry(k, false)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
builder: (ctx) {
|
||||||
|
return StatefulBuilder(
|
||||||
|
builder: (context, setSheetState) {
|
||||||
|
Future<void> apply() async {
|
||||||
|
final payload = <String, dynamic>{};
|
||||||
|
for (final entry in changes.entries) {
|
||||||
|
if (selected[entry.key] == true) {
|
||||||
|
payload[entry.key] = entry.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.isEmpty) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final messenger = ScaffoldMessenger.of(context);
|
||||||
|
setSheetState(() => _loading = true);
|
||||||
|
try {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
await data.applyLegPartialUpdates(
|
||||||
|
legId: legId,
|
||||||
|
updates: payload,
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await data.dismissNotifications([widget.notification.id]);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
messenger.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Changes applied.')),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
messenger.showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to apply changes: $e')),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSheetState(() => _loading = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget buildLocos(List<dynamic> locos) {
|
||||||
|
final parsed = locos
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => e.map((k, v) => MapEntry(k.toString(), v)))
|
||||||
|
.toList();
|
||||||
|
parsed.sort((a, b) => (b['alloc_pos'] ?? 0).compareTo(a['alloc_pos'] ?? 0));
|
||||||
|
final leading = parsed.where((e) => (e['alloc_pos'] ?? 0) > 0).toList();
|
||||||
|
final trailing = parsed.where((e) => (e['alloc_pos'] ?? 0) <= 0).toList();
|
||||||
|
|
||||||
|
List<Widget> chipsFor(List<Map<String, dynamic>> list) {
|
||||||
|
return list
|
||||||
|
.map(
|
||||||
|
(loco) => Chip(
|
||||||
|
backgroundColor:
|
||||||
|
(loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
|
||||||
|
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.12)
|
||||||
|
: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||||
|
label: Text('Loco ${loco['loco_id'] ?? '?'} (pos ${loco['alloc_pos'] ?? '?'}'),
|
||||||
|
avatar: Icon(
|
||||||
|
Icons.train,
|
||||||
|
size: 16,
|
||||||
|
color: (loco['alloc_powering'] == 1 || loco['alloc_powering'] == true)
|
||||||
|
? Theme.of(context).colorScheme.primary
|
||||||
|
: Theme.of(context).hintColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
...chipsFor(leading),
|
||||||
|
if (leading.isNotEmpty && trailing.isNotEmpty)
|
||||||
|
const SizedBox(
|
||||||
|
width: 24,
|
||||||
|
child: Center(child: Divider(height: 16)),
|
||||||
|
),
|
||||||
|
...chipsFor(trailing),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: MediaQuery.of(context).viewInsets.bottom + 16,
|
||||||
|
top: 16,
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text('Review changes', style: Theme.of(context).textTheme.titleMedium),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => setSheetState(() {
|
||||||
|
for (final key in selected.keys) {
|
||||||
|
selected[key] = true;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
child: const Text('Select all'),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
...changes.entries.map((entry) {
|
||||||
|
final key = entry.key;
|
||||||
|
final prettyKey = _prettyField(key);
|
||||||
|
final value = entry.value;
|
||||||
|
final currentLeg = _currentLeg ?? _findCurrentLeg(_legId);
|
||||||
|
final valueWidget = _buildChangeValueWidget(
|
||||||
|
key,
|
||||||
|
value,
|
||||||
|
currentLeg,
|
||||||
|
buildLocos,
|
||||||
|
);
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 12.0),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Checkbox(
|
||||||
|
value: selected[key] ?? false,
|
||||||
|
onChanged: (v) => setSheetState(() {
|
||||||
|
selected[key] = v ?? false;
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(prettyKey, style: Theme.of(context).textTheme.titleSmall),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
valueWidget,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerRight,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: _loading ? null : apply,
|
||||||
|
child: _loading
|
||||||
|
? const SizedBox(
|
||||||
|
height: 18,
|
||||||
|
width: 18,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
)
|
||||||
|
: const Text('Apply changes'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _dismiss() async {
|
||||||
|
await context.read<DataService>().dismissNotifications([widget.notification.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
163
lib/components/widgets/leg_share_notification_card.dart
Normal file
163
lib/components/widgets/leg_share_notification_card.dart
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
|
import 'package:mileograph_flutter/services/data_service.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
class LegShareNotificationCard extends StatelessWidget {
|
||||||
|
const LegShareNotificationCard({super.key, required this.notification});
|
||||||
|
|
||||||
|
final UserNotification notification;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final legShareId = _extractLegShareId(notification.body);
|
||||||
|
if (legShareId == null) {
|
||||||
|
return const Text('Invalid leg share notification.');
|
||||||
|
}
|
||||||
|
final future = data.fetchLegShare(legShareId);
|
||||||
|
return FutureBuilder<LegShareData?>(
|
||||||
|
future: future,
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.connectionState == ConnectionState.waiting) {
|
||||||
|
return const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 6.0),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 20,
|
||||||
|
width: 20,
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (snapshot.hasError) {
|
||||||
|
return const Text('Failed to load shared entry.');
|
||||||
|
}
|
||||||
|
final share = snapshot.data;
|
||||||
|
final entry = share?.entry;
|
||||||
|
if (share == null || entry == null) {
|
||||||
|
return const Text('Shared entry unavailable.');
|
||||||
|
}
|
||||||
|
final begin = entry.beginTime;
|
||||||
|
final beginStr =
|
||||||
|
'${begin.year.toString().padLeft(4, '0')}-${begin.month.toString().padLeft(2, '0')}-${begin.day.toString().padLeft(2, '0')} ${begin.hour.toString().padLeft(2, '0')}:${begin.minute.toString().padLeft(2, '0')}';
|
||||||
|
final start = entry.route.isNotEmpty ? entry.route.first : entry.start;
|
||||||
|
final end = entry.route.isNotEmpty ? entry.route.last : entry.end;
|
||||||
|
final sharedAt = share.sharedAt;
|
||||||
|
final sharedAtStr = sharedAt == null
|
||||||
|
? null
|
||||||
|
: '${sharedAt.year.toString().padLeft(4, '0')}-${sharedAt.month.toString().padLeft(2, '0')}-${sharedAt.day.toString().padLeft(2, '0')} ${sharedAt.hour.toString().padLeft(2, '0')}:${sharedAt.minute.toString().padLeft(2, '0')}';
|
||||||
|
final from = share.sharedFromName;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('$beginStr • $start → $end'),
|
||||||
|
if (from.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(
|
||||||
|
'From $from',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (sharedAtStr != null)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4.0),
|
||||||
|
child: Text(
|
||||||
|
'Shared at $sharedAtStr',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Wrap(
|
||||||
|
spacing: 8,
|
||||||
|
runSpacing: 8,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: () => _accept(context, share),
|
||||||
|
child: const Text('Accept'),
|
||||||
|
),
|
||||||
|
OutlinedButton(
|
||||||
|
onPressed: () => _inspect(context, share),
|
||||||
|
child: const Text('Inspect'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => _reject(context, share),
|
||||||
|
child: const Text('Reject'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _accept(BuildContext context, LegShareData share) async {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
try {
|
||||||
|
await data.acceptLegShare(share);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await data.dismissNotifications([notification.id]);
|
||||||
|
// Refresh legs in the background.
|
||||||
|
unawaited(data.refreshLegs());
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Shared entry added to logbook')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to add shared entry: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _reject(BuildContext context, LegShareData share) async {
|
||||||
|
final data = context.read<DataService>();
|
||||||
|
final messenger = ScaffoldMessenger.maybeOf(context);
|
||||||
|
try {
|
||||||
|
await data.rejectLegShare(share.id);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await data.dismissNotifications([notification.id]);
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
const SnackBar(content: Text('Share rejected')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
messenger?.showSnackBar(
|
||||||
|
SnackBar(content: Text('Failed to reject share: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _inspect(BuildContext context, LegShareData share) async {
|
||||||
|
final router = GoRouter.of(context);
|
||||||
|
// Close notifications panel if open.
|
||||||
|
if (Navigator.canPop(context)) {
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
final target = share.copyWith(notificationId: notification.id);
|
||||||
|
final ts = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final path = '/add?share=${Uri.encodeComponent(share.id)}&ts=$ts';
|
||||||
|
router.go(path, extra: target);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _extractLegShareId(String rawBody) {
|
||||||
|
final trimmed = rawBody.trim();
|
||||||
|
if (trimmed.isEmpty) return null;
|
||||||
|
if (RegExp(r'^[0-9]+$').hasMatch(trimmed)) return trimmed;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(trimmed);
|
||||||
|
if (decoded is Map) {
|
||||||
|
final id = decoded['share_id'] ?? decoded['leg_share_id'];
|
||||||
|
final str = id?.toString() ?? '';
|
||||||
|
if (RegExp(r'^[0-9]+$').hasMatch(str)) return str;
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -104,24 +104,315 @@ 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,
|
||||||
|
String? entriesVisibility,
|
||||||
|
String? mileageVisibility,
|
||||||
|
bool? elevated,
|
||||||
|
bool? disabled,
|
||||||
|
}) : entriesVisibility = entriesVisibility ?? 'private',
|
||||||
|
mileageVisibility = mileageVisibility ?? 'private',
|
||||||
|
elevated = elevated ?? false,
|
||||||
|
disabled = disabled ?? false;
|
||||||
|
|
||||||
final String userId;
|
final String userId;
|
||||||
final String username;
|
final String username;
|
||||||
final String fullName;
|
final String fullName;
|
||||||
final String email;
|
final String email;
|
||||||
|
final String entriesVisibility;
|
||||||
|
final String mileageVisibility;
|
||||||
|
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,
|
||||||
|
super.entriesVisibility,
|
||||||
|
super.mileageVisibility,
|
||||||
|
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.entriesVisibility,
|
||||||
|
super.mileageVisibility,
|
||||||
|
super.elevated = false,
|
||||||
|
super.disabled = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get displayName => fullName.isNotEmpty ? fullName : username;
|
||||||
|
|
||||||
|
factory UserSummary.fromJson(Map<String, dynamic> json) => UserSummary(
|
||||||
|
username: _asString(json['username'] ?? json['user_name']),
|
||||||
|
fullName: _asString(json['full_name'] ?? json['name']),
|
||||||
|
userId: _asString(json['user_id'] ?? json['id']),
|
||||||
|
email: _asString(json['email']),
|
||||||
|
entriesVisibility: _asString(
|
||||||
|
json['user_entries_visibility'] ?? json['entries_visibility'],
|
||||||
|
'private',
|
||||||
|
),
|
||||||
|
mileageVisibility: _asString(
|
||||||
|
json['user_mileage_visibility'] ?? json['mileage_visibility'],
|
||||||
|
'private',
|
||||||
|
),
|
||||||
|
elevated: _asBool(json['elevated'] ?? json['is_elevated'], false),
|
||||||
|
disabled: _asBool(json['disabled'], false),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
class UserProfileDetail {
|
||||||
|
final String username;
|
||||||
|
final String fullName;
|
||||||
|
final double mileage;
|
||||||
|
final List<LocoSummary> topLocos;
|
||||||
|
final List<Leg> legs;
|
||||||
|
final Map<String, dynamic> privacyInfo;
|
||||||
|
final String friendshipStatus;
|
||||||
|
|
||||||
|
UserProfileDetail({
|
||||||
|
required this.username,
|
||||||
|
required this.fullName,
|
||||||
|
required this.mileage,
|
||||||
|
required this.topLocos,
|
||||||
|
required this.legs,
|
||||||
|
this.privacyInfo = const {},
|
||||||
|
this.friendshipStatus = 'none',
|
||||||
|
});
|
||||||
|
|
||||||
|
factory UserProfileDetail.fromJson(Map<String, dynamic> json) {
|
||||||
|
List<dynamic>? topLocosRaw;
|
||||||
|
final tl = json['top_locos'];
|
||||||
|
if (tl is List) {
|
||||||
|
topLocosRaw = tl;
|
||||||
|
}
|
||||||
|
List<dynamic>? legsRaw;
|
||||||
|
final legData = json['user_legs'];
|
||||||
|
if (legData is List) {
|
||||||
|
legsRaw = legData;
|
||||||
|
}
|
||||||
|
return UserProfileDetail(
|
||||||
|
username: _asString(json['username']),
|
||||||
|
fullName: _asString(json['full_name']),
|
||||||
|
mileage: _asDouble(json['mileage']),
|
||||||
|
topLocos: (topLocosRaw ?? const [])
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => LocoSummary.fromJson(
|
||||||
|
e.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
))
|
||||||
|
.toList(),
|
||||||
|
legs: (legsRaw ?? const [])
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => Leg.fromJson(e.map((k, v) => MapEntry(k.toString(), v))))
|
||||||
|
.toList(),
|
||||||
|
privacyInfo: json['privacy_info'] is Map
|
||||||
|
? Map<String, dynamic>.from(json['privacy_info'] as Map)
|
||||||
|
: const {},
|
||||||
|
friendshipStatus: _asString(json['friendship_status'], 'none'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Friendship {
|
||||||
|
final String? id;
|
||||||
|
final String status;
|
||||||
|
final String requesterId;
|
||||||
|
final String addresseeId;
|
||||||
|
final UserSummary? requester;
|
||||||
|
final UserSummary? addressee;
|
||||||
|
final DateTime? requestedAt;
|
||||||
|
final DateTime? respondedAt;
|
||||||
|
|
||||||
|
const Friendship({
|
||||||
|
required this.id,
|
||||||
|
required this.status,
|
||||||
|
required this.requesterId,
|
||||||
|
required this.addresseeId,
|
||||||
|
this.requester,
|
||||||
|
this.addressee,
|
||||||
|
this.requestedAt,
|
||||||
|
this.respondedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get _statusLower => status.toLowerCase();
|
||||||
|
bool get isNone => _statusLower == 'none';
|
||||||
|
bool get isPending => _statusLower == 'pending';
|
||||||
|
bool get isAccepted => _statusLower == 'accepted';
|
||||||
|
bool get isBlocked => _statusLower == 'blocked';
|
||||||
|
bool get isDeclined =>
|
||||||
|
_statusLower == 'declined' || _statusLower == 'rejected';
|
||||||
|
|
||||||
|
factory Friendship.fromJson(Map<String, dynamic> json) {
|
||||||
|
DateTime? parseDate(dynamic value) {
|
||||||
|
if (value is DateTime) return value;
|
||||||
|
return DateTime.tryParse(value?.toString() ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
String pickId(Map<String, dynamic> map, List<String> keys) {
|
||||||
|
for (final key in keys) {
|
||||||
|
final value = map[key];
|
||||||
|
if (value == null) continue;
|
||||||
|
final str = value.toString();
|
||||||
|
if (str.isNotEmpty) return str;
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
UserSummary? parseUser(dynamic raw, {String? usernameKey, String? fullKey, String? idKey}) {
|
||||||
|
if (raw is Map) {
|
||||||
|
return UserSummary.fromJson(
|
||||||
|
raw.map((key, value) => MapEntry(key.toString(), value)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final username = usernameKey != null ? _asString(json[usernameKey]) : '';
|
||||||
|
final fullName = fullKey != null ? _asString(json[fullKey]) : '';
|
||||||
|
final id = idKey != null ? _asString(json[idKey]) : '';
|
||||||
|
if (username.isEmpty && fullName.isEmpty && id.isEmpty) return null;
|
||||||
|
return UserSummary(
|
||||||
|
username: username,
|
||||||
|
fullName: fullName,
|
||||||
|
userId: id,
|
||||||
|
email: '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final requesterJson = json['requester'] ??
|
||||||
|
json['requestor'] ??
|
||||||
|
json['sender'] ??
|
||||||
|
json['from_user'];
|
||||||
|
final addresseeJson = json['addressee'] ??
|
||||||
|
json['receiver'] ??
|
||||||
|
json['target_user'] ??
|
||||||
|
json['to_user'];
|
||||||
|
|
||||||
|
final requester = parseUser(
|
||||||
|
requesterJson,
|
||||||
|
usernameKey: 'requester_username',
|
||||||
|
fullKey: 'requester_full_name',
|
||||||
|
idKey: 'requester_id',
|
||||||
|
);
|
||||||
|
final addressee = parseUser(
|
||||||
|
addresseeJson,
|
||||||
|
usernameKey: 'addressee_username',
|
||||||
|
fullKey: 'addressee_full_name',
|
||||||
|
idKey: 'addressee_id',
|
||||||
|
);
|
||||||
|
|
||||||
|
final requesterUsername = _asString(json['requester_username']);
|
||||||
|
final requesterFullName = _asString(json['requester_full_name']);
|
||||||
|
final addresseeUsername = _asString(json['addressee_username']);
|
||||||
|
final addresseeFullName = _asString(json['addressee_full_name']);
|
||||||
|
|
||||||
|
final requesterId = requester?.userId.isNotEmpty == true
|
||||||
|
? requester!.userId
|
||||||
|
: pickId(json, [
|
||||||
|
'requester_id',
|
||||||
|
'requestor_id',
|
||||||
|
'requestorId',
|
||||||
|
'sender_id',
|
||||||
|
'from_user_id',
|
||||||
|
'from_id',
|
||||||
|
'user_id',
|
||||||
|
]);
|
||||||
|
final addresseeId = addressee?.userId.isNotEmpty == true
|
||||||
|
? addressee!.userId
|
||||||
|
: pickId(json, [
|
||||||
|
'addressee_id',
|
||||||
|
'addresseeId',
|
||||||
|
'receiver_id',
|
||||||
|
'target_user_id',
|
||||||
|
'to_user_id',
|
||||||
|
'to_id',
|
||||||
|
]);
|
||||||
|
|
||||||
|
final normalizedStatus = _asString(json['status'], 'none').trim();
|
||||||
|
|
||||||
|
return Friendship(
|
||||||
|
id: pickId(json, ['friendship_id', 'friendshipId', 'id']),
|
||||||
|
status: normalizedStatus.isNotEmpty ? normalizedStatus : 'none',
|
||||||
|
requesterId: requesterId,
|
||||||
|
addresseeId: addresseeId,
|
||||||
|
requester: requester ??
|
||||||
|
(requesterUsername.isNotEmpty || requesterFullName.isNotEmpty
|
||||||
|
? UserSummary(
|
||||||
|
username: requesterUsername,
|
||||||
|
fullName: requesterFullName,
|
||||||
|
userId: requesterId,
|
||||||
|
email: '',
|
||||||
|
)
|
||||||
|
: null),
|
||||||
|
addressee: addressee ??
|
||||||
|
(addresseeUsername.isNotEmpty || addresseeFullName.isNotEmpty
|
||||||
|
? UserSummary(
|
||||||
|
username: addresseeUsername,
|
||||||
|
fullName: addresseeFullName,
|
||||||
|
userId: addresseeId,
|
||||||
|
email: '',
|
||||||
|
)
|
||||||
|
: null),
|
||||||
|
requestedAt: parseDate(json['requested_at'] ?? json['requestedAt']),
|
||||||
|
respondedAt: parseDate(json['responded_at'] ?? json['respondedAt']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory Friendship.fromStatusJson(
|
||||||
|
Map<String, dynamic> json, {
|
||||||
|
String? targetUserId,
|
||||||
|
}) {
|
||||||
|
final statusVal = _asString(json['status'], 'none').trim();
|
||||||
|
if (statusVal.toLowerCase() == 'none') {
|
||||||
|
return Friendship(
|
||||||
|
id: null,
|
||||||
|
status: 'none',
|
||||||
|
requesterId: '',
|
||||||
|
addresseeId: targetUserId ?? '',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Friendship.fromJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
Friendship copyWith({
|
||||||
|
String? id,
|
||||||
|
String? status,
|
||||||
|
String? requesterId,
|
||||||
|
String? addresseeId,
|
||||||
|
UserSummary? requester,
|
||||||
|
UserSummary? addressee,
|
||||||
|
DateTime? requestedAt,
|
||||||
|
DateTime? respondedAt,
|
||||||
|
}) {
|
||||||
|
return Friendship(
|
||||||
|
id: id ?? this.id,
|
||||||
|
status: status ?? this.status,
|
||||||
|
requesterId: requesterId ?? this.requesterId,
|
||||||
|
addresseeId: addresseeId ?? this.addresseeId,
|
||||||
|
requester: requester ?? this.requester,
|
||||||
|
addressee: addressee ?? this.addressee,
|
||||||
|
requestedAt: requestedAt ?? this.requestedAt,
|
||||||
|
respondedAt: respondedAt ?? this.respondedAt,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class HomepageStats {
|
class HomepageStats {
|
||||||
@@ -129,6 +420,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 +430,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 +442,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 +461,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 +474,23 @@ 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'] ?? '',
|
||||||
|
entriesVisibility: _asString(
|
||||||
|
userData['user_entries_visibility'] ??
|
||||||
|
userData['entries_visibility'],
|
||||||
|
'private',
|
||||||
|
),
|
||||||
|
mileageVisibility: _asString(
|
||||||
|
userData['user_mileage_visibility'] ??
|
||||||
|
userData['mileage_visibility'],
|
||||||
|
'private',
|
||||||
|
),
|
||||||
|
elevated:
|
||||||
|
_asBool(userData['elevated'] ?? userData['is_elevated'], false),
|
||||||
|
disabled: _asBool(userData['disabled'], false),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -398,6 +715,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 +727,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 +740,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 +774,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 +810,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 +1153,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 +1186,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 +1196,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 +1248,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 +1676,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 +1685,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 +1703,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),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,6 +21,11 @@ class AuthService extends ChangeNotifier {
|
|||||||
String? get userId => _user?.userId;
|
String? get userId => _user?.userId;
|
||||||
String? get username => _user?.username;
|
String? get username => _user?.username;
|
||||||
String? get fullName => _user?.fullName;
|
String? get fullName => _user?.fullName;
|
||||||
|
String get entriesVisibility => _user?.entriesVisibility ?? 'private';
|
||||||
|
String get mileageVisibility => _user?.mileageVisibility ?? 'private';
|
||||||
|
bool get isElevated => _user?.isElevated ?? false;
|
||||||
|
bool get isAdmin => isElevated; // alias for old name
|
||||||
|
bool get isDisabled => _user?.disabled ?? false;
|
||||||
|
|
||||||
void setLoginData({
|
void setLoginData({
|
||||||
required String userId,
|
required String userId,
|
||||||
@@ -28,6 +33,10 @@ class AuthService extends ChangeNotifier {
|
|||||||
required String fullName,
|
required String fullName,
|
||||||
required String accessToken,
|
required String accessToken,
|
||||||
required String email,
|
required String email,
|
||||||
|
String entriesVisibility = 'private',
|
||||||
|
String mileageVisibility = 'private',
|
||||||
|
bool isElevated = false,
|
||||||
|
bool isDisabled = false,
|
||||||
}) {
|
}) {
|
||||||
_user = AuthenticatedUserData(
|
_user = AuthenticatedUserData(
|
||||||
userId: userId,
|
userId: userId,
|
||||||
@@ -35,6 +44,10 @@ class AuthService extends ChangeNotifier {
|
|||||||
fullName: fullName,
|
fullName: fullName,
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
email: email,
|
email: email,
|
||||||
|
entriesVisibility: entriesVisibility,
|
||||||
|
mileageVisibility: mileageVisibility,
|
||||||
|
isElevated: isElevated,
|
||||||
|
disabled: isDisabled,
|
||||||
);
|
);
|
||||||
_persistToken(accessToken);
|
_persistToken(accessToken);
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
@@ -70,6 +83,16 @@ class AuthService extends ChangeNotifier {
|
|||||||
fullName: userResponse['full_name'],
|
fullName: userResponse['full_name'],
|
||||||
accessToken: accessToken,
|
accessToken: accessToken,
|
||||||
email: userResponse['email'],
|
email: userResponse['email'],
|
||||||
|
entriesVisibility: _parseVisibility(
|
||||||
|
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
|
||||||
|
'private',
|
||||||
|
),
|
||||||
|
mileageVisibility: _parseVisibility(
|
||||||
|
userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'],
|
||||||
|
'private',
|
||||||
|
),
|
||||||
|
isElevated: _parseIsElevated(userResponse),
|
||||||
|
isDisabled: _parseIsDisabled(userResponse),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +118,16 @@ class AuthService extends ChangeNotifier {
|
|||||||
fullName: userResponse['full_name'],
|
fullName: userResponse['full_name'],
|
||||||
accessToken: token,
|
accessToken: token,
|
||||||
email: userResponse['email'],
|
email: userResponse['email'],
|
||||||
|
entriesVisibility: _parseVisibility(
|
||||||
|
userResponse['user_entries_visibility'] ?? userResponse['entries_visibility'],
|
||||||
|
'private',
|
||||||
|
),
|
||||||
|
mileageVisibility: _parseVisibility(
|
||||||
|
userResponse['user_mileage_visibility'] ?? userResponse['mileage_visibility'],
|
||||||
|
'private',
|
||||||
|
),
|
||||||
|
isElevated: _parseIsElevated(userResponse),
|
||||||
|
isDisabled: _parseIsDisabled(userResponse),
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
await _clearToken();
|
await _clearToken();
|
||||||
@@ -157,4 +190,67 @@ class AuthService extends ChangeNotifier {
|
|||||||
void logout() {
|
void logout() {
|
||||||
handleTokenExpired(); // reuse
|
handleTokenExpired(); // reuse
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _parseIsElevated(Map<String, dynamic> json) {
|
||||||
|
dynamic value = json['is_elevated'] ??
|
||||||
|
json['elevated'] ??
|
||||||
|
json['is_admin'] ??
|
||||||
|
json['admin'] ??
|
||||||
|
json['isAdmin'] ??
|
||||||
|
json['admin_user'] ??
|
||||||
|
json['role'] ??
|
||||||
|
json['roles'] ??
|
||||||
|
json['permissions'] ??
|
||||||
|
json['scopes'] ??
|
||||||
|
json['is_staff'] ??
|
||||||
|
json['staff'] ??
|
||||||
|
json['is_superuser'] ??
|
||||||
|
json['superuser'] ??
|
||||||
|
json['groups'];
|
||||||
|
|
||||||
|
bool parseBoolish(dynamic v) {
|
||||||
|
if (v is bool) return v;
|
||||||
|
if (v is num) return v != 0;
|
||||||
|
final str = v?.toString().toLowerCase().trim();
|
||||||
|
if (str == null || str.isEmpty) return false;
|
||||||
|
if (['1', 'true', 'yes', 'y', 'admin', 'superuser', 'staff', 'elevated'].contains(str)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return str.contains('admin') || str.contains('superuser') || str.contains('staff');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is List) {
|
||||||
|
for (final entry in value) {
|
||||||
|
if (parseBoolish(entry)) return true;
|
||||||
|
final s = entry?.toString().toLowerCase();
|
||||||
|
if (s != null &&
|
||||||
|
(s.contains('admin') ||
|
||||||
|
s.contains('superuser') ||
|
||||||
|
s.contains('staff') ||
|
||||||
|
s.contains('elevated') ||
|
||||||
|
s == 'root')) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseBoolish(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _parseIsDisabled(Map<String, dynamic> json) {
|
||||||
|
dynamic value = json['disabled'] ?? json['is_disabled'];
|
||||||
|
if (value is bool) return value;
|
||||||
|
if (value is num) return value != 0;
|
||||||
|
final str = value?.toString().toLowerCase().trim();
|
||||||
|
if (str == null || str.isEmpty) return false;
|
||||||
|
return ['1', 'true', 'yes', 'y', 'disabled'].contains(str);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _parseVisibility(dynamic value, String fallback) {
|
||||||
|
const allowed = ['private', 'friends', 'public'];
|
||||||
|
final str = value?.toString().toLowerCase().trim();
|
||||||
|
if (str != null && allowed.contains(str)) return str;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class _LegFetchOptions {
|
|||||||
final int sortDirection;
|
final int sortDirection;
|
||||||
final String? dateRangeStart;
|
final String? dateRangeStart;
|
||||||
final String? dateRangeEnd;
|
final String? dateRangeEnd;
|
||||||
|
final bool unallocatedOnly;
|
||||||
|
|
||||||
const _LegFetchOptions({
|
const _LegFetchOptions({
|
||||||
this.limit = 100,
|
this.limit = 100,
|
||||||
@@ -13,6 +14,7 @@ class _LegFetchOptions {
|
|||||||
this.sortDirection = 0,
|
this.sortDirection = 0,
|
||||||
this.dateRangeStart,
|
this.dateRangeStart,
|
||||||
this.dateRangeEnd,
|
this.dateRangeEnd,
|
||||||
|
this.unallocatedOnly = false,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,6 +66,18 @@ class DataService extends ChangeNotifier {
|
|||||||
bool isLocoTimelineLoading(int locoId) =>
|
bool isLocoTimelineLoading(int locoId) =>
|
||||||
_isLocoTimelineLoading[locoId] ?? false;
|
_isLocoTimelineLoading[locoId] ?? false;
|
||||||
|
|
||||||
|
// Friendships
|
||||||
|
List<Friendship> _friendships = [];
|
||||||
|
List<Friendship> _pendingIncoming = [];
|
||||||
|
List<Friendship> _pendingOutgoing = [];
|
||||||
|
List<Friendship> get friendships => _friendships;
|
||||||
|
List<Friendship> get pendingIncoming => _pendingIncoming;
|
||||||
|
List<Friendship> get pendingOutgoing => _pendingOutgoing;
|
||||||
|
bool _isFriendshipsLoading = false;
|
||||||
|
bool get isFriendshipsLoading => _isFriendshipsLoading;
|
||||||
|
bool _isPendingFriendshipsLoading = false;
|
||||||
|
bool get isPendingFriendshipsLoading => _isPendingFriendshipsLoading;
|
||||||
|
|
||||||
// Trips
|
// Trips
|
||||||
List<TripSummary> _trips = [];
|
List<TripSummary> _trips = [];
|
||||||
List<TripSummary> get trips => _trips;
|
List<TripSummary> get trips => _trips;
|
||||||
@@ -96,6 +110,10 @@ class DataService extends ChangeNotifier {
|
|||||||
bool get isHomepageLoading => _isHomepageLoading;
|
bool get isHomepageLoading => _isHomepageLoading;
|
||||||
bool _isOnThisDayLoading = false;
|
bool _isOnThisDayLoading = false;
|
||||||
bool get isOnThisDayLoading => _isOnThisDayLoading;
|
bool get isOnThisDayLoading => _isOnThisDayLoading;
|
||||||
|
List<LeaderboardEntry> _friendsLeaderboard = [];
|
||||||
|
List<LeaderboardEntry> get friendsLeaderboard => _friendsLeaderboard;
|
||||||
|
bool _isFriendsLeaderboardLoading = false;
|
||||||
|
bool get isFriendsLeaderboardLoading => _isFriendsLeaderboardLoading;
|
||||||
|
|
||||||
// Notifications
|
// Notifications
|
||||||
List<UserNotification> _notifications = [];
|
List<UserNotification> _notifications = [];
|
||||||
@@ -103,6 +121,16 @@ class DataService extends ChangeNotifier {
|
|||||||
bool _isNotificationsLoading = false;
|
bool _isNotificationsLoading = false;
|
||||||
bool get isNotificationsLoading => _isNotificationsLoading;
|
bool get isNotificationsLoading => _isNotificationsLoading;
|
||||||
|
|
||||||
|
// Privacy
|
||||||
|
String _userEntriesVisibility = 'private';
|
||||||
|
String _userMileageVisibility = 'private';
|
||||||
|
bool _isPrivacyLoading = false;
|
||||||
|
bool _isPrivacySaving = false;
|
||||||
|
String get userEntriesVisibility => _userEntriesVisibility;
|
||||||
|
String get userMileageVisibility => _userMileageVisibility;
|
||||||
|
bool get isPrivacyLoading => _isPrivacyLoading;
|
||||||
|
bool get isPrivacySaving => _isPrivacySaving;
|
||||||
|
|
||||||
// Badges
|
// Badges
|
||||||
List<BadgeAward> _badgeAwards = [];
|
List<BadgeAward> _badgeAwards = [];
|
||||||
List<BadgeAward> get badgeAwards => _badgeAwards;
|
List<BadgeAward> get badgeAwards => _badgeAwards;
|
||||||
@@ -145,6 +173,127 @@ class DataService extends ChangeNotifier {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int _visibilityRank(String value) {
|
||||||
|
switch (value.toLowerCase()) {
|
||||||
|
case 'public':
|
||||||
|
return 2;
|
||||||
|
case 'friends':
|
||||||
|
return 1;
|
||||||
|
default:
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _normaliseVisibility(
|
||||||
|
dynamic value, {
|
||||||
|
required String fallback,
|
||||||
|
}) {
|
||||||
|
const allowed = ['private', 'friends', 'public'];
|
||||||
|
final str = value?.toString().toLowerCase().trim();
|
||||||
|
if (str != null && allowed.contains(str)) return str;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _clampMileageVisibility(String entries, String mileage) {
|
||||||
|
return _visibilityRank(mileage) < _visibilityRank(entries)
|
||||||
|
? entries
|
||||||
|
: mileage;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _applyPrivacy(dynamic source) {
|
||||||
|
String entries = _userEntriesVisibility;
|
||||||
|
String mileage = _userMileageVisibility;
|
||||||
|
if (source is Map) {
|
||||||
|
entries = _normaliseVisibility(
|
||||||
|
source['user_entries_visibility'] ?? source['entries_visibility'],
|
||||||
|
fallback: entries,
|
||||||
|
);
|
||||||
|
mileage = _normaliseVisibility(
|
||||||
|
source['user_mileage_visibility'] ?? source['mileage_visibility'],
|
||||||
|
fallback: mileage,
|
||||||
|
);
|
||||||
|
} else if (source is UserData) {
|
||||||
|
entries = _normaliseVisibility(
|
||||||
|
source.entriesVisibility,
|
||||||
|
fallback: entries,
|
||||||
|
);
|
||||||
|
mileage = _normaliseVisibility(
|
||||||
|
source.mileageVisibility,
|
||||||
|
fallback: mileage,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
_userEntriesVisibility = entries;
|
||||||
|
_userMileageVisibility = _clampMileageVisibility(entries, mileage);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchPrivacySettings({String? targetUserId}) async {
|
||||||
|
_isPrivacyLoading = true;
|
||||||
|
_notifyAsync();
|
||||||
|
try {
|
||||||
|
Map<String, dynamic>? payload;
|
||||||
|
final hasTarget = targetUserId?.isNotEmpty ?? false;
|
||||||
|
if (!hasTarget) {
|
||||||
|
try {
|
||||||
|
final json = await api.get('/users/me');
|
||||||
|
if (json is Map<String, dynamic>) {
|
||||||
|
payload = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
payload = json.map((k, v) => MapEntry(k.toString(), v));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch /users/me: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload == null) {
|
||||||
|
final query = hasTarget ? '?target_user_id=$targetUserId' : '';
|
||||||
|
try {
|
||||||
|
final json = await api.get('/users/privacy$query');
|
||||||
|
if (json is Map<String, dynamic>) {
|
||||||
|
payload = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
payload = json.map((k, v) => MapEntry(k.toString(), v));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch /users/privacy: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload != null) {
|
||||||
|
_applyPrivacy(payload);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch privacy settings: $e');
|
||||||
|
} finally {
|
||||||
|
_isPrivacyLoading = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> updatePrivacySettings({
|
||||||
|
required String entriesVisibility,
|
||||||
|
required String mileageVisibility,
|
||||||
|
String? targetUserId,
|
||||||
|
}) async {
|
||||||
|
_isPrivacySaving = true;
|
||||||
|
_notifyAsync();
|
||||||
|
try {
|
||||||
|
final query = (targetUserId?.isNotEmpty ?? false)
|
||||||
|
? '?target_user_id=$targetUserId'
|
||||||
|
: '';
|
||||||
|
await api.post('/users/privacy$query', {
|
||||||
|
'user_entries_visibility': entriesVisibility,
|
||||||
|
'user_mileage_visibility': mileageVisibility,
|
||||||
|
});
|
||||||
|
_userEntriesVisibility = entriesVisibility;
|
||||||
|
_userMileageVisibility = mileageVisibility;
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to update privacy settings: $e');
|
||||||
|
rethrow;
|
||||||
|
} finally {
|
||||||
|
_isPrivacySaving = false;
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetchHomepageStats() async {
|
Future<void> fetchHomepageStats() async {
|
||||||
_isHomepageLoading = true;
|
_isHomepageLoading = true;
|
||||||
|
|
||||||
@@ -153,16 +302,68 @@ 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 ?? [];
|
||||||
|
if (_homepageStats?.user != null) {
|
||||||
|
_applyPrivacy(_homepageStats!.user!);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('Failed to fetch homepage stats: $e');
|
debugPrint('Failed to fetch homepage stats: $e');
|
||||||
_homepageStats = null;
|
_homepageStats = null;
|
||||||
_trips = [];
|
_trips = [];
|
||||||
|
_friendsLeaderboard = [];
|
||||||
} finally {
|
} finally {
|
||||||
_isHomepageLoading = false;
|
_isHomepageLoading = false;
|
||||||
_notifyAsync();
|
_notifyAsync();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<UserProfileDetail?> fetchUserProfileDetail(String userId) async {
|
||||||
|
try {
|
||||||
|
final json = await api.get('/user/$userId');
|
||||||
|
if (json is Map) {
|
||||||
|
return UserProfileDetail.fromJson(
|
||||||
|
json.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch user profile for $userId: $e');
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<Leg>> fetchUserLegs({
|
||||||
|
required String userId,
|
||||||
|
int offset = 0,
|
||||||
|
int limit = 25,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final json =
|
||||||
|
await api.get('/legs/user/$userId?offset=$offset&limit=$limit');
|
||||||
|
List<dynamic>? list;
|
||||||
|
if (json is List) {
|
||||||
|
list = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
for (final key in ['legs', 'data', 'results', 'items']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) {
|
||||||
|
list = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (list == null) return const [];
|
||||||
|
return list
|
||||||
|
.whereType<Map>()
|
||||||
|
.map((e) => Leg.fromJson(
|
||||||
|
e.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch user legs for $userId: $e');
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> fetchLegs({
|
Future<void> fetchLegs({
|
||||||
int offset = 0,
|
int offset = 0,
|
||||||
int limit = 100,
|
int limit = 100,
|
||||||
@@ -171,6 +372,7 @@ class DataService extends ChangeNotifier {
|
|||||||
String? dateRangeStart,
|
String? dateRangeStart,
|
||||||
String? dateRangeEnd,
|
String? dateRangeEnd,
|
||||||
bool append = false,
|
bool append = false,
|
||||||
|
bool unallocatedOnly = false,
|
||||||
}) async {
|
}) async {
|
||||||
_isLegsLoading = true;
|
_isLegsLoading = true;
|
||||||
if (!append) {
|
if (!append) {
|
||||||
@@ -180,6 +382,7 @@ class DataService extends ChangeNotifier {
|
|||||||
sortDirection: sortDirection,
|
sortDirection: sortDirection,
|
||||||
dateRangeStart: dateRangeStart,
|
dateRangeStart: dateRangeStart,
|
||||||
dateRangeEnd: dateRangeEnd,
|
dateRangeEnd: dateRangeEnd,
|
||||||
|
unallocatedOnly: unallocatedOnly,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final buffer = StringBuffer(
|
final buffer = StringBuffer(
|
||||||
@@ -191,6 +394,9 @@ class DataService extends ChangeNotifier {
|
|||||||
if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) {
|
if (dateRangeEnd != null && dateRangeEnd.isNotEmpty) {
|
||||||
buffer.write('&date_range_end=$dateRangeEnd');
|
buffer.write('&date_range_end=$dateRangeEnd');
|
||||||
}
|
}
|
||||||
|
if (unallocatedOnly) {
|
||||||
|
buffer.write('&unallocated_only=true');
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
final json = await api.get('/user/legs${buffer.toString()}');
|
final json = await api.get('/user/legs${buffer.toString()}');
|
||||||
|
|
||||||
@@ -219,6 +425,7 @@ class DataService extends ChangeNotifier {
|
|||||||
sortDirection: _lastLegsFetch.sortDirection,
|
sortDirection: _lastLegsFetch.sortDirection,
|
||||||
dateRangeStart: _lastLegsFetch.dateRangeStart,
|
dateRangeStart: _lastLegsFetch.dateRangeStart,
|
||||||
dateRangeEnd: _lastLegsFetch.dateRangeEnd,
|
dateRangeEnd: _lastLegsFetch.dateRangeEnd,
|
||||||
|
unallocatedOnly: _lastLegsFetch.unallocatedOnly,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +620,10 @@ class DataService extends ChangeNotifier {
|
|||||||
_stationFiltersFetchedAt = null;
|
_stationFiltersFetchedAt = null;
|
||||||
_notifications = [];
|
_notifications = [];
|
||||||
_isNotificationsLoading = false;
|
_isNotificationsLoading = false;
|
||||||
|
_userEntriesVisibility = 'private';
|
||||||
|
_userMileageVisibility = 'private';
|
||||||
|
_isPrivacyLoading = false;
|
||||||
|
_isPrivacySaving = false;
|
||||||
_badgeAwards = [];
|
_badgeAwards = [];
|
||||||
_badgeAwardsHasMore = false;
|
_badgeAwardsHasMore = false;
|
||||||
_isBadgeAwardsLoading = false;
|
_isBadgeAwardsLoading = false;
|
||||||
@@ -425,12 +636,25 @@ class DataService extends ChangeNotifier {
|
|||||||
_notifyAsync();
|
_notifyAsync();
|
||||||
}
|
}
|
||||||
|
|
||||||
void handleAuthChanged(String? userId) {
|
void handleAuthChanged(
|
||||||
if (_currentUserId == userId) return;
|
String? userId, {
|
||||||
|
String? entriesVisibility,
|
||||||
|
String? mileageVisibility,
|
||||||
|
}) {
|
||||||
|
final sameUser = _currentUserId == userId;
|
||||||
_currentUserId = userId;
|
_currentUserId = userId;
|
||||||
|
if (!sameUser) {
|
||||||
clear();
|
clear();
|
||||||
_currentUserId = userId;
|
_currentUserId = userId;
|
||||||
}
|
}
|
||||||
|
if (entriesVisibility != null || mileageVisibility != null) {
|
||||||
|
_applyPrivacy({
|
||||||
|
'user_entries_visibility': entriesVisibility,
|
||||||
|
'user_mileage_visibility': mileageVisibility,
|
||||||
|
});
|
||||||
|
_notifyAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
double getMileageForCurrentYear() {
|
double getMileageForCurrentYear() {
|
||||||
final currentYear = DateTime.now().year;
|
final currentYear = DateTime.now().year;
|
||||||
|
|||||||
282
lib/services/data_service/data_service_friendships.dart
Normal file
282
lib/services/data_service/data_service_friendships.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
109
lib/services/data_service/data_service_leg_share.dart
Normal file
109
lib/services/data_service/data_service_leg_share.dart
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
part of 'data_service.dart';
|
||||||
|
|
||||||
|
extension DataServiceLegShare on DataService {
|
||||||
|
Future<LegShareData?> fetchLegShare(String legShareId) async {
|
||||||
|
try {
|
||||||
|
final json = await api.get('/legs/share/$legShareId');
|
||||||
|
if (json is Map) {
|
||||||
|
return LegShareData.fromJson(
|
||||||
|
json.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to fetch leg share $legShareId: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> rejectLegShare(String legShareId) async {
|
||||||
|
try {
|
||||||
|
await api.post('/legs/share/$legShareId/reject', {});
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to reject leg share $legShareId: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<int?> acceptLegShare(LegShareData share) async {
|
||||||
|
final entry = share.entry;
|
||||||
|
final hasRoute = entry.route.isNotEmpty;
|
||||||
|
final locosPayload = <Map<String, dynamic>>[];
|
||||||
|
for (var i = 0; i < entry.locos.length; i++) {
|
||||||
|
final loco = entry.locos[i];
|
||||||
|
final pos = loco.allocPos != 0 ? loco.allocPos : i;
|
||||||
|
locosPayload.add({
|
||||||
|
"loco_id": loco.id,
|
||||||
|
"alloc_pos": pos,
|
||||||
|
"alloc_powering": loco.powering ? 1 : 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final payload = <String, dynamic>{
|
||||||
|
"leg_share_id": share.id,
|
||||||
|
"leg_trip": null,
|
||||||
|
"leg_begin_time": entry.beginTime.toIso8601String(),
|
||||||
|
if (entry.endTime != null) "leg_end_time": entry.endTime!.toIso8601String(),
|
||||||
|
if (entry.originTime != null)
|
||||||
|
"leg_origin_time": entry.originTime!.toIso8601String(),
|
||||||
|
if (entry.destinationTime != null)
|
||||||
|
"leg_destination_time": entry.destinationTime!.toIso8601String(),
|
||||||
|
"leg_notes": entry.notes,
|
||||||
|
"leg_headcode": entry.headcode,
|
||||||
|
"leg_network": entry.network,
|
||||||
|
"leg_origin": entry.origin,
|
||||||
|
"leg_destination": entry.destination,
|
||||||
|
"leg_begin_delay": entry.beginDelayMinutes ?? 0,
|
||||||
|
if (entry.endDelayMinutes != null) "leg_end_delay": entry.endDelayMinutes,
|
||||||
|
"locos": locosPayload,
|
||||||
|
"share_user_ids": <String>[],
|
||||||
|
};
|
||||||
|
|
||||||
|
dynamic response;
|
||||||
|
if (hasRoute) {
|
||||||
|
response = await api.post(
|
||||||
|
'/add',
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
"leg_route": entry.route,
|
||||||
|
"leg_mileage": entry.mileage,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
response = await api.post(
|
||||||
|
'/add/manual',
|
||||||
|
{
|
||||||
|
...payload,
|
||||||
|
"leg_start": entry.start,
|
||||||
|
"leg_end": entry.end,
|
||||||
|
"leg_distance": entry.mileage,
|
||||||
|
"isKilometers": false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final legId = _parseNullableInt(
|
||||||
|
response is Map ? response['leg_id'] : null,
|
||||||
|
);
|
||||||
|
if (legId != null) return legId;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> applyLegPartialUpdates({
|
||||||
|
required int legId,
|
||||||
|
required Map<String, dynamic> updates,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
await api.post('/leg/update/$legId/partial', updates);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to apply partial updates for leg $legId: $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
int? _parseNullableInt(dynamic value) {
|
||||||
|
if (value is int) return value;
|
||||||
|
if (value is num) return value.toInt();
|
||||||
|
if (value is String) return int.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -179,4 +179,39 @@ extension DataServiceTraction on DataService {
|
|||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<List<LeaderboardEntry>> fetchClassLeaderboard(
|
||||||
|
String locoClass, {
|
||||||
|
bool friends = false,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
final path = Uri.encodeComponent(locoClass);
|
||||||
|
final suffix = friends ? '/friends' : '';
|
||||||
|
final json = await api.get('/stats/class/$path/leaderboard$suffix');
|
||||||
|
List<dynamic>? list;
|
||||||
|
if (json is List) {
|
||||||
|
list = json;
|
||||||
|
} else if (json is Map) {
|
||||||
|
for (final key in ['leaderboard', 'data', 'items', 'results']) {
|
||||||
|
final value = json[key];
|
||||||
|
if (value is List) {
|
||||||
|
list = value;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list
|
||||||
|
?.whereType<Map>()
|
||||||
|
.map((e) => LeaderboardEntry.fromJson(
|
||||||
|
e.map((k, v) => MapEntry(k.toString(), v)),
|
||||||
|
))
|
||||||
|
.toList() ??
|
||||||
|
const [];
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint(
|
||||||
|
'Failed to fetch class leaderboard for $locoClass (friends=$friends): $e',
|
||||||
|
);
|
||||||
|
return const [];
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,18 @@ 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/pages/more/user_profile_page.dart';
|
||||||
|
import 'package:mileograph_flutter/components/widgets/friend_request_notification_card.dart';
|
||||||
|
import 'package:mileograph_flutter/components/widgets/leg_share_edit_notification_card.dart';
|
||||||
|
import 'package:mileograph_flutter/components/widgets/leg_share_notification_card.dart';
|
||||||
|
import 'package:mileograph_flutter/objects/objects.dart';
|
||||||
import 'package:mileograph_flutter/services/authservice.dart';
|
import 'package:mileograph_flutter/services/authservice.dart';
|
||||||
import 'package:mileograph_flutter/services/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 +212,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 +229,32 @@ class _MyAppState extends State<MyApp> {
|
|||||||
path: '/more/profile',
|
path: '/more/profile',
|
||||||
builder: (context, state) => const ProfilePage(),
|
builder: (context, state) => const ProfilePage(),
|
||||||
),
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/more/user-profile',
|
||||||
|
name: 'user-profile',
|
||||||
|
builder: (context, state) {
|
||||||
|
final extra = state.extra;
|
||||||
|
UserSummary? user;
|
||||||
|
String? userId;
|
||||||
|
if (extra is UserSummary) {
|
||||||
|
user = extra;
|
||||||
|
userId = extra.userId;
|
||||||
|
} else if (extra is Map) {
|
||||||
|
final value = extra['user'];
|
||||||
|
if (value is UserSummary) user = value;
|
||||||
|
userId = extra['userId']?.toString();
|
||||||
|
}
|
||||||
|
userId ??= state.uri.queryParameters['user_id'];
|
||||||
|
return UserProfilePage(
|
||||||
|
userId: userId,
|
||||||
|
initialUser: user,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
GoRoute(
|
||||||
|
path: '/more/badges',
|
||||||
|
builder: (context, state) => const BadgesPage(),
|
||||||
|
),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/more/stats',
|
path: '/more/stats',
|
||||||
builder: (context, state) => const StatsPage(),
|
builder: (context, state) => const StatsPage(),
|
||||||
@@ -221,6 +263,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 +353,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 +390,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 +482,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 +697,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 +730,10 @@ 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 isLegShareEdit = _isLegShareEditNotification(item);
|
||||||
|
final isSpecial = isFriendRequest || isLegShare || isLegShareEdit;
|
||||||
return Card(
|
return Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(12.0),
|
padding: const EdgeInsets.all(12.0),
|
||||||
@@ -668,7 +756,13 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
item.body,
|
isSpecial
|
||||||
|
? isFriendRequest
|
||||||
|
? 'Accept to share entries'
|
||||||
|
: isLegShareEdit
|
||||||
|
? 'Shared leg edits below.'
|
||||||
|
: 'Shared entry details below.'
|
||||||
|
: item.body,
|
||||||
style: Theme.of(context).textTheme.bodyMedium,
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
),
|
),
|
||||||
if (item.createdAt != null) ...[
|
if (item.createdAt != null) ...[
|
||||||
@@ -694,6 +788,7 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (!isSpecial) ...[
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () =>
|
onPressed: () =>
|
||||||
@@ -701,6 +796,26 @@ 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 (_isLegShareEditNotification(item))
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: LegShareEditNotificationCard(
|
||||||
|
notification: item,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isLegShare)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
|
child: LegShareNotificationCard(notification: item),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -731,10 +846,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 +881,44 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
return '$y-$m-$d $hh:$mm';
|
return '$y-$m-$d $hh:$mm';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _isFriendRequestNotification(UserNotification notification) {
|
||||||
|
final type = notification.type.trim().toLowerCase();
|
||||||
|
final channel = notification.channel.trim().toLowerCase();
|
||||||
|
final title = notification.title.trim().toLowerCase();
|
||||||
|
bool matchesChannel =
|
||||||
|
channel == 'friend_request' || channel == 'friend-request';
|
||||||
|
if (!matchesChannel) {
|
||||||
|
matchesChannel = channel.contains('friend_request') ||
|
||||||
|
channel.contains('friend-request') ||
|
||||||
|
channel.contains('friend');
|
||||||
|
}
|
||||||
|
return matchesChannel ||
|
||||||
|
type == 'friend_request' ||
|
||||||
|
type == 'friend-request' ||
|
||||||
|
title.contains('friend request');
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isLegShareNotification(UserNotification notification) {
|
||||||
|
final channel = notification.channel.trim().toLowerCase();
|
||||||
|
final type = notification.type.trim().toLowerCase();
|
||||||
|
final isAcceptEdits =
|
||||||
|
_isLegShareAcceptEdits(channel) || _isLegShareAcceptEdits(type);
|
||||||
|
return (channel.contains('leg_share') || type.contains('leg_share')) &&
|
||||||
|
!isAcceptEdits;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isLegShareEditNotification(UserNotification notification) {
|
||||||
|
final channel = notification.channel.trim().toLowerCase();
|
||||||
|
final type = notification.type.trim().toLowerCase();
|
||||||
|
return _isLegShareAcceptEdits(channel) || _isLegShareAcceptEdits(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _isLegShareAcceptEdits(String value) {
|
||||||
|
final normalized = value.trim().toLowerCase();
|
||||||
|
// Match both singular/plural: leg_share_accept_edit / leg_share_accept_edits
|
||||||
|
return normalized.contains('leg_share_accept_edit');
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildBadge(String label) {
|
Widget _buildBadge(String label) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||||
@@ -895,4 +1045,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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.5+7
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.8.1
|
sdk: ^3.8.1
|
||||||
|
|||||||
Reference in New Issue
Block a user