diff --git a/.github/workflows/android.yaml b/.github/workflows/android.yaml index d06a9d54..d1fef70a 100644 --- a/.github/workflows/android.yaml +++ b/.github/workflows/android.yaml @@ -15,7 +15,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: '3.35.1' + flutter-version: '3.47.1' - name: Find and Replace NMS FAA uses: richardrigutins/replace-in-files@v2 diff --git a/.github/workflows/avarex-eu-release.yaml b/.github/workflows/avarex-eu-release.yaml new file mode 100644 index 00000000..8c85dd03 --- /dev/null +++ b/.github/workflows/avarex-eu-release.yaml @@ -0,0 +1,242 @@ +name: AvareX-EU Release + +on: + push: + tags: + - '**' + +permissions: + contents: write + +concurrency: + group: avarex-eu-release-${{ github.ref }} + cancel-in-progress: false + +jobs: + build-and-release: + runs-on: ubuntu-24.04 + + steps: + - name: Check out tagged source + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Validate tag and set release names + shell: bash + run: | + set -euo pipefail + TAG="${GITHUB_REF_NAME}" + if [[ ! "$TAG" =~ ^[A-Za-z0-9._-]+$ ]]; then + echo "Tag '$TAG' cannot be used safely in a release filename." + exit 1 + fi + echo "TAG=$TAG" >> "$GITHUB_ENV" + echo "APK_NAME=AvareX-EU-$TAG.apk" >> "$GITHUB_ENV" + echo "APK_UNSIGNED_NAME=AvareX-EU-$TAG-unsigned.apk" >> "$GITHUB_ENV" + echo "AAB_NAME=AvareX-EU-$TAG.aab" >> "$GITHUB_ENV" + echo "AAB_UNSIGNED_NAME=AvareX-EU-$TAG-unsigned.aab" >> "$GITHUB_ENV" + echo "SOURCE_BASENAME=AvareX-EU-$TAG-source" >> "$GITHUB_ENV" + echo "CHANGELOG_NAME=CHANGELOG-$TAG.md" >> "$GITHUB_ENV" + + - name: Generate changelist + shell: bash + run: | + set -euo pipefail + mkdir -p dist + PREVIOUS_TAG="$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || true)" + { + echo "# AvareX-EU $TAG" + echo + if [[ -n "$PREVIOUS_TAG" ]]; then + echo "Changes since $PREVIOUS_TAG:" + echo + git log --no-merges --pretty='- %s (`%h`)' "$PREVIOUS_TAG..HEAD" + else + echo "Changes included in this release:" + echo + git log --no-merges --pretty='- %s (`%h`)' HEAD + fi + } > "dist/$CHANGELOG_NAME" + + - name: Create source archives + shell: bash + run: | + set -euo pipefail + STAGING="dist/$SOURCE_BASENAME" + mkdir -p "$STAGING" + git archive HEAD | tar -x -C "$STAGING" + cp "dist/$CHANGELOG_NAME" "$STAGING/CHANGELOG-RELEASE.md" + tar -C dist -czf "dist/$SOURCE_BASENAME.tar.gz" "$SOURCE_BASENAME" + (cd dist && zip -qr "$SOURCE_BASENAME.zip" "$SOURCE_BASENAME") + rm -rf "$STAGING" + + - name: Set up Flutter + uses: subosito/flutter-action@v2 + with: + channel: stable + flutter-version: '3.47.1' + cache: true + + - name: Inject optional app service credentials + shell: bash + env: + FAA_NMS_API_CLIENT_ID_SECRET: ${{ secrets.FAA_NMS_API_CLIENT_ID_SECRET }} + run: | + set -euo pipefail + python3 -c " + import os + from pathlib import Path + + replacements = { + '@@__faa_nms_api_client_id_secret__@@': os.environ.get('FAA_NMS_API_CLIENT_ID_SECRET', ''), + } + for path in Path('.').rglob('*.dart'): + text = path.read_text(encoding='utf-8') + updated = text + for needle, replacement in replacements.items(): + updated = updated.replace(needle, replacement) + if updated != text: + path.write_text(updated, encoding='utf-8') + " + + - name: Install project dependencies + run: flutter pub get + + - name: Run tests and analyzer + run: | + flutter test + flutter analyze --no-fatal-infos + + - name: Ensure Android cmdline-tools (apkanalyzer) for AAB build + shell: bash + run: | + set -euo pipefail + # `flutter build appbundle` runs apkanalyzer to strip debug symbols; + # without cmdline-tools it fails with a misleading + # "failed to strip debug symbols". Make sure it's installed. + SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + if ! find "$SDK_ROOT/cmdline-tools" -type f -name apkanalyzer 2>/dev/null | grep -q .; then + echo "apkanalyzer not found; installing cmdline-tools;latest" + yes | "$SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" "cmdline-tools;latest" 2>/dev/null \ + || (command -v sdkmanager >/dev/null && yes | sdkmanager "cmdline-tools;latest") \ + || echo "Could not auto-install cmdline-tools; relying on preinstalled tooling." + fi + find "$SDK_ROOT" -type f -name apkanalyzer -print 2>/dev/null | head -1 || true + + - name: Build release APK and AAB + run: | + flutter build apk --release + flutter build appbundle --release + + - name: Sign and name release artifacts (APK + AAB, signed + unsigned) + shell: bash + env: + SIGNING_KEY_BASE64: ${{ secrets.KEY_STORE }} + KEY_ALIAS: ${{ secrets.KEY_STORE_ALIAS }} + KEYSTORE_PASSWORD: ${{ secrets.KEY_STORE_PASS }} + KEY_PASSWORD: ${{ secrets.KEY_STORE_PASS }} + run: | + set -euo pipefail + + # --- Locate the built artifacts (names vary with signingConfig) --- + RELEASE_APK="" + for candidate in \ + "build/app/outputs/apk/release/app-release.apk" \ + "build/app/outputs/apk/release/app-release-unsigned.apk" \ + "build/app/outputs/flutter-apk/app-release.apk"; do + if [[ -f "$candidate" ]]; then RELEASE_APK="$candidate"; break; fi + done + if [[ -z "$RELEASE_APK" ]]; then + echo "No release APK was generated under build/app/outputs" + find build/app/outputs -type f -name '*.apk' -print || true + exit 1 + fi + RELEASE_AAB="" + for candidate in \ + "build/app/outputs/bundle/release/app-release.aab" \ + "build/app/outputs/bundle/release/app.aab"; do + if [[ -f "$candidate" ]]; then RELEASE_AAB="$candidate"; break; fi + done + if [[ -z "$RELEASE_AAB" ]]; then + echo "No release AAB was generated under build/app/outputs/bundle" + find build/app/outputs/bundle -type f -name '*.aab' -print || true + exit 1 + fi + echo "APK: $RELEASE_APK" + echo "AAB: $RELEASE_AAB" + + # --- Publish the unsigned artifacts verbatim --- + cp "$RELEASE_APK" "dist/$APK_UNSIGNED_NAME" + cp "$RELEASE_AAB" "dist/$AAB_UNSIGNED_NAME" + + # --- Locate signing tools --- + SDK_ROOT="${ANDROID_HOME:-${ANDROID_SDK_ROOT:-/usr/local/lib/android/sdk}}" + ZIPALIGN="$(find "$SDK_ROOT/build-tools" -maxdepth 2 -type f -name zipalign 2>/dev/null | sort -V | tail -1 || true)" + APKSIGNER="$(find "$SDK_ROOT/build-tools" -maxdepth 2 -type f -name apksigner 2>/dev/null | sort -V | tail -1 || true)" + if [[ ! -x "$ZIPALIGN" || ! -x "$APKSIGNER" ]]; then + echo "Android build-tools signing utilities were not found under $SDK_ROOT/build-tools" + find "$SDK_ROOT/build-tools" -maxdepth 2 -type f \( -name zipalign -o -name apksigner \) -print || true + exit 1 + fi + echo "zipalign: $ZIPALIGN" + echo "apksigner: $APKSIGNER" + + # --- Prepare the keystore (repo secret, else a generated fallback) --- + KEYSTORE="${RUNNER_TEMP:-/tmp}/avarex-eu-release.jks" + KS_ALIAS=""; KS_STOREPASS=""; KS_KEYPASS="" + if [[ -n "$SIGNING_KEY_BASE64" && -n "$KEY_ALIAS" && -n "$KEYSTORE_PASSWORD" && -n "$KEY_PASSWORD" ]] \ + && printf '%s' "$SIGNING_KEY_BASE64" | base64 --decode --ignore-garbage > "$KEYSTORE" 2>/dev/null \ + && keytool -list -keystore "$KEYSTORE" -storepass "$KEYSTORE_PASSWORD" -alias "$KEY_ALIAS" >/dev/null 2>&1; then + echo "Using repository signing key." + KS_ALIAS="$KEY_ALIAS"; KS_STOREPASS="$KEYSTORE_PASSWORD"; KS_KEYPASS="$KEY_PASSWORD" + else + echo "Repository signing secrets missing/invalid; generating a fallback signing key." + KEYSTORE="${RUNNER_TEMP:-/tmp}/avarex-eu-release-generated.jks" + KS_ALIAS="avarex-eu-release"; KS_STOREPASS="avarex-eu-release-pass"; KS_KEYPASS="avarex-eu-release-pass" + keytool -genkeypair -keystore "$KEYSTORE" -storepass "$KS_STOREPASS" -keypass "$KS_KEYPASS" \ + -alias "$KS_ALIAS" -keyalg RSA -keysize 2048 -validity 10000 \ + -dname "CN=AvareX EU GitHub Release, O=AvareX, C=US" + fi + + # --- Sign the APK (zipalign + apksigner) --- + ALIGNED_APK="${RUNNER_TEMP:-/tmp}/aligned.apk" + "$ZIPALIGN" -f -p 4 "$RELEASE_APK" "$ALIGNED_APK" + "$APKSIGNER" sign \ + --ks "$KEYSTORE" --ks-key-alias "$KS_ALIAS" \ + --ks-pass "pass:$KS_STOREPASS" --key-pass "pass:$KS_KEYPASS" \ + --out "dist/$APK_NAME" "$ALIGNED_APK" + "$APKSIGNER" verify --verbose --print-certs "dist/$APK_NAME" + + # --- Sign the AAB (jarsigner; apksigner does not handle AABs) --- + # `flutter build appbundle` already signs the bundle via Gradle's + # signingConfig (debug cert in CI, since there is no key.properties). + # jarsigner ADDS a signature rather than replacing it, so signing the + # bundle as-is leaves TWO certificate chains in META-INF and Play + # rejects it with "more than 1 certificate chain". Strip any existing + # signature block first so the bundle carries exactly one chain. + cp "$RELEASE_AAB" "dist/$AAB_NAME" + zip -qd "dist/$AAB_NAME" 'META-INF/*.RSA' 'META-INF/*.DSA' \ + 'META-INF/*.EC' 'META-INF/*.SF' 2>/dev/null || true + jarsigner -sigalg SHA256withRSA -digestalg SHA-256 \ + -keystore "$KEYSTORE" -storepass "$KS_STOREPASS" -keypass "$KS_KEYPASS" \ + "dist/$AAB_NAME" "$KS_ALIAS" + jarsigner -verify "dist/$AAB_NAME" + + rm -f "$KEYSTORE" "$ALIGNED_APK" + echo "Artifacts:"; ls -la dist/*.apk dist/*.aab + + - name: Publish GitHub release + uses: softprops/action-gh-release@v2 + with: + name: AvareX-EU ${{ github.ref_name }} + body_path: dist/${{ env.CHANGELOG_NAME }} + files: | + dist/${{ env.APK_NAME }} + dist/${{ env.APK_UNSIGNED_NAME }} + dist/${{ env.AAB_NAME }} + dist/${{ env.AAB_UNSIGNED_NAME }} + dist/${{ env.SOURCE_BASENAME }}.tar.gz + dist/${{ env.SOURCE_BASENAME }}.zip + dist/${{ env.CHANGELOG_NAME }} + fail_on_unmatched_files: true diff --git a/.github/workflows/ios.yaml b/.github/workflows/ios.yaml deleted file mode 100644 index 2afbcb46..00000000 --- a/.github/workflows/ios.yaml +++ /dev/null @@ -1,115 +0,0 @@ -name: IOS - -on: push - -jobs: - - build-and-release: - runs-on: macos-26 - - steps: - - - uses: actions/checkout@v4 - - name: Set app version - run: | - echo "APP_VERSION=$(sed -n 's/^version: //p' pubspec.yaml)" >> "$GITHUB_ENV" - - uses: futureware-tech/simulator-action@v4 - with: - model: 'iPhone 17' - - - uses: subosito/flutter-action@v2 - with: - channel: 'stable' - flutter-version: '3.35.1' - - - name: Find and Replace NMS FAA - uses: richardrigutins/replace-in-files@v2 - with: - files: '**/*.dart' - search-text: '@@__faa_nms_api_client_id_secret__@@' - replacement-text: ${{ secrets.FAA_NMS_API_CLIENT_ID_SECRET }} - encoding: 'utf8' - max-parallelism: 10 - - - name: Find and Replace RC Key - uses: richardrigutins/replace-in-files@v2 - with: - files: '**/*.dart' - search-text: '@@___revenuecat_ios_api_key__@@' - replacement-text: ${{ secrets.IOS_REVENUECAT_API_KEY }} - encoding: 'utf8' - max-parallelism: 10 - - - name: Install the Apple certificate and provisioning profile - run: | - # create variables - CERTIFICATE_PATH=$RUNNER_TEMP/build_certificate.p12 - PP_PATH=$RUNNER_TEMP/build_pp.mobileprovision - KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db - # import certificate and provisioning profile from secrets - echo -n ${{ secrets.IOS_BUILD_CERTIFICATE_BASE64 }} | base64 --decode --output $CERTIFICATE_PATH - echo -n ${{ secrets.IOS_MOBILE_PROVISIONING_PROFILE_BASE64 }} | base64 --decode --output $PP_PATH - # create temporary keychain - security create-keychain -p ${{ secrets.IOS_GITHUB_KEYCHAIN_PASSWORD }} $KEYCHAIN_PATH - security set-keychain-settings -lut 21600 $KEYCHAIN_PATH - security unlock-keychain -p ${{ secrets.IOS_GITHUB_KEYCHAIN_PASSWORD }} $KEYCHAIN_PATH - # import certificate to keychain - security import $CERTIFICATE_PATH -P ${{ secrets.IOS_BUILD_CERTIFICATE_PASSWORD }} -A -t cert -f pkcs12 -k $KEYCHAIN_PATH - security list-keychain -d user -s $KEYCHAIN_PATH - # apply provisioning profile - mkdir -p ~/Library/MobileDevice/Provisioning\ Profiles - cp $PP_PATH ~/Library/MobileDevice/Provisioning\ Profiles - xcodebuild -downloadPlatform iOS - - - name: Install project dependencies - run: flutter pub get - - # Node 22.23.0 / 24.17.0 / 26 shipped an http.Agent keep-alive - # regression that breaks firebase-tools ADC auth (firebase-tools#10716), - # which makes `flutterfire configure` hang on an interactive prompt. - # Pin to a fixed Node release. - - uses: actions/setup-node@v4 - with: - node-version: '22.23.1' - - - name: Login to Firebase - run: | - # Pinned: firebase-tools 15.22.2 has an ADC/service-account auth - # regression (firebase-tools#10716) that makes `flutterfire - # configure` hang on an interactive "create project?" prompt. - # Unpin once a fixed release ships. - npm install -g firebase-tools@15.22.1 - echo -n ${{ secrets.GOOGLE_SERVICES_KEY }} | base64 -d > key.json - export GOOGLE_APPLICATION_CREDENTIALS="key.json" - dart pub global activate flutterfire_cli - # Redirect stdin so any unexpected prompt fails fast instead of - # hanging the runner waiting on interactive input. - flutterfire configure --project avarex-479ae --platforms ios --yes < /dev/null - - - name: Build artifacts - run: flutter build ipa --release --export-options-plist=ios/GithubActionsExportOptions.plist - - - name: Prepare upload artifact - run: | - mkdir -p build/artifacts - cp build/ios/ipa/*.ipa "build/artifacts/avarex_ios_${APP_VERSION}_${{ github.ref_name }}.ipa" - - - name: Upload Artifact - run: | - ASKPASS_SCRIPT="$RUNNER_TEMP/apps4av_ssh_askpass.sh" - printf '#!/usr/bin/env bash\nprintf "%%s\\n" "$MAMBA_PASSWORD"\n' > "$ASKPASS_SCRIPT" - chmod 700 "$ASKPASS_SCRIPT" - export SSH_ASKPASS="$ASKPASS_SCRIPT" - export SSH_ASKPASS_REQUIRE=force - export DISPLAY=none - scp -o StrictHostKeyChecking=accept-new build/artifacts/* apps4av@apps4av.org:/home/apps4av/builds/ - env: - MAMBA_PASSWORD: ${{ secrets.MAMBA_PASSWORD }} - - - name: Clean up keychain and provisioning profile - if: ${{ always() }} - run: | - security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true - rm -f ~/Library/MobileDevice/Provisioning\ Profiles/build_pp.mobileprovision - - diff --git a/.github/workflows/snap.yaml b/.github/workflows/snap.yaml index 52d1d494..db952980 100644 --- a/.github/workflows/snap.yaml +++ b/.github/workflows/snap.yaml @@ -15,7 +15,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: '3.35.1' + flutter-version: '3.47.1' - name: Find and Replace NMS FAA uses: richardrigutins/replace-in-files@v2 diff --git a/.github/workflows/windows.yaml b/.github/workflows/windows.yaml index ccfcc6f3..3772c332 100644 --- a/.github/workflows/windows.yaml +++ b/.github/workflows/windows.yaml @@ -16,7 +16,7 @@ jobs: - uses: subosito/flutter-action@v2 with: channel: 'stable' - flutter-version: '3.35.1' + flutter-version: '3.47.1' - name: Find and Replace NMS FAA uses: richardrigutins/replace-in-files@v2 diff --git a/.gitignore b/.gitignore index 542500c0..ba1589f9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,18 @@ migrate_working_dir/ .pub-cache/ .pub/ /build/ +.deps/ +/android/build/ + +# Local emulator/debug captures +*-logcat.txt +failure-screenshot.png +flutter-map-error*.png +map-ready-fixed*.png +openaip-*.png +openaip-*.xml +tap-menu-after.png +ui-window*.xml # Symbolication related app.*.symbols @@ -57,3 +69,6 @@ android/app/google-services.json ios/Runner/GoogleService-Info.plist macos/Runner/GoogleService-Info.plist +# oh-my-codex local agent workspace (not part of the app) +.omc/ + diff --git a/README.md b/README.md index db61d3f0..8b856151 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,410 @@ -# AvareX +
-Avare, written in Flutter. Runs on Linux, Windows, MacOS, iOS, Android, and Raspberry Pi. +AvareX-EU -AvareX is a pilot's all in one electronic flight bag solution. +# AvareX‑EU -By Apps4Av. +**A free, open‑source European electronic flight bag (EFB)** — a community fork of +[AvareX](https://github.com/apps4av/avarex) by Apps4Av, written in Flutter. -## User Manual +Georeferenced moving map · European VFR charts · openAIP airspace · internet ADS‑B traffic · +animated weather radar · flight planning — on Android, iOS, Windows, macOS, Linux & Raspberry Pi. -A comprehensive, code-derived user manual is available at: +[![Website](https://img.shields.io/badge/website-AvareX--EU-1d6fe0)](https://wolverine2k.github.io/avarex/eu/) +[![Privacy](https://img.shields.io/badge/privacy-no%20ads%20·%20no%20tracking-16a34a)](https://wolverine2k.github.io/avarex/eu/privacy.html) +[![Releases](https://img.shields.io/github/v/release/wolverine2k/avarex?label=download)](https://github.com/wolverine2k/avarex/releases) +[![Platform](https://img.shields.io/badge/platform-Android%20·%20iOS%20·%20Windows%20·%20macOS%20·%20Linux%20·%20Pi-5a6b82)](#getting-started) -- [USER_MANUAL.md](USER_MANUAL.md) +[**Website**](https://wolverine2k.github.io/avarex/eu/) · +[**Download**](https://github.com/wolverine2k/avarex/releases) · +[**Privacy policy**](https://wolverine2k.github.io/avarex/eu/privacy.html) · +[**User manual**](USER_MANUAL.md) -## Getting Started +
+--- +## What is AvareX‑EU? -### Downloading +AvareX‑EU keeps AvareX's proven US, FAA‑backed workflow and adds **optional European data +providers** on top. European data is **downloaded on demand**, per region and AIRAC cycle, so the +app package stays small and works fully offline once your charts are installed. +It ships **without** the sign‑in, subscription paywall, or Firebase‑backed cloud features of the +upstream app — there are **no accounts, no analytics, no advertising, and no tracking**. Your +routes, logbook, settings, and any API keys stay on your device. See the +[privacy policy](https://wolverine2k.github.io/avarex/eu/privacy.html). -** Windows +> [!WARNING] +> AvareX‑EU is **not** a certified GPS or navigation system and must not be used as a sole means of +> navigation. European data added by this fork is complementary and **not certified**. Always +> cross‑check with official sources and certified equipment. -Download on Windows using Microsoft Store. +--- -** MacOS +## Screenshots -Download on Apple App Store from your Mac with Apple Silicon. +
-** Linux +| European VFR moving map | Ownship & airspace | +| :---: | :---: | +| OpenFlightMaps VFR chart of Malmö (ESMS) with Malmö TMA and CTR airspace, openAIP data and instrument tiles | Red ownship over Malmö with the ES‑R130 restricted area and stepped CTR/TMA airspace on the OpenFlightMaps chart | +| OpenFlightMaps VFR chart of Malmö (ESMS) with TMA/CTR airspace, openAIP layers and GS/ALT/track instrument tiles. | Georeferenced ownship with restricted areas and stepped airspace, and `© openAIP` / `© open flightmaps association` attribution. | -Download on Linux using Snap Store. +| OpenFlightMaps data download | Flight plan & nav log | +| :---: | :---: | +| OpenFlightMaps data screen with VFR map layer, high-resolution @2x toggle, OFMX data and selectable German VFR chart sheets | Flight plan nav log for a Sweden route with distance, ground speed, course, time and fuel per leg | +| Per‑region download of MBTiles + OFMX data, optional `@2x` high‑res tiles, and published VFR chart sheets. | Per‑leg nav log with distance, ground speed, course, time and fuel. | -** iOS +
-Download on Apple App Store from your iPhone or iPad. +--- -** Android +## Features -Download on Google Play Store from your Android device. +### The EU additions -** Raspberry Pi +| | Provider | What it adds | +| :--: | --- | --- | +| 🗺️ | **[OpenFlightMaps](https://openflightmaps.org/)** | Primary European VFR chart tiles (georeferenced EPSG:3857 MBTiles) + OFMX airport, runway, comms, navaid, reporting‑point and airspace data. Region & AIRAC‑cycle aware. | +| 🛩️ | **[openAIP](https://www.openaip.net/)** | Optional supplementary airports, navaids, VFR reporting points, airspace and obstacles via **your own** openAIP Core API key (no shared credential). | +| 📡 | **[OpenSky Network](https://opensky-network.org/)** | Optional internet ADS‑B traffic layer for situational awareness (advisory), alongside GDL90 receivers over Wi‑Fi. | +| 🌧️ | **[RainViewer](https://www.rainviewer.com/)** | Global, animated internet radar mosaic (2‑hour loop, selectable color schemes) — the default internet radar in the EU build. No key required. | +| 🌬️ | **[Open‑Meteo](https://open-meteo.com/)** | Pressure‑level winds aloft outside US FB coverage, mapped to the same altitude bands. Free by default; optional personal key. | +| 📄 | **[FlyBrief](https://flybrief.app/) + [aip.aero](https://aip.aero/)** | Per‑country georeferenced NOTAMs (offline‑capable) and link‑outs to official national AIP charts. | +| ⛰️ | **[AWS Terrain Tiles](https://registry.opendata.aws/terrain-tiles/)** | On‑device, per‑country terrain/elevation transcoding for terrain profile and GPWS anywhere. | +| 🤖 | **Flight Intelligence (BYO AI)** | Optional assistant that talks to an OpenAI‑compatible endpoint **you** configure with your own key. Off until set up. | -Download at https://github.com/apps4av/avarex/actions/workflows/arm64.yaml from your Pi. +### Carried over from AvareX -Tested on 64-bit Raspberry Pi OS (may run on other configurations). - - Pi 5 with 8 GB memory - - Pi 4 with 1 GB memory - - Prerequisites: sudo apt-get install libgtk-3-0 libblkid1 liblzma5 libsqlite3-dev +- Georeferenced moving map with ownship, track‑up / north‑up, range / speed / glide rings. +- Movable instrument tiles (GS, ALT, track, ETA, ETE, distance, bearing, and more). +- Decoded METAR with selectable VFR/IFR threat thresholds. +- Flight planning with a per‑leg nav log. +- ADS‑B / GDL90 traffic and external GPS, NMEA / autopilot output. +- Logbook, checklists, aircraft profiles, weight & balance, and notes. +- Works fully offline once charts and databases are downloaded. -## Store Consoles +--- -Google / Android: https://play.google.com/console +## Getting started -iOS, MacOS: https://appstoreconnect.apple.com/login +### Install -Linux: https://snapcraft.io +Grab the latest build for your platform from the +**[GitHub releases](https://github.com/wolverine2k/avarex/releases)** page (Android APK is provided +for direct install). AvareX‑EU also builds for iOS, Windows, macOS, Linux and Raspberry Pi. -Windows: https://partner.microsoft.com/en-us/dashboard/home +### Add European data in four steps -## Store Locations +1. **Install a chart region** — open **Menu → Data → OpenFlightMaps**, pick a region and AIRAC + cycle, choose VFR chart sheets, and install. Optionally enable the larger `@2x` high‑res tiles. +2. **Add openAIP (optional)** — under **Menu → Data → openAIP**, enter your personal openAIP API + key, **Test Connection**, then download country data by ISO code (e.g. `DE`, `FR`, `SE`). +3. **Enable the layers you want** — turn on `OFM VFR Chart`, `OFM Interactive Data`, and + `openAIP Interactive Data` in the map‑layer controls (enabled by default on fresh installs). +4. **Turn on traffic & radar** — enable the internet ADS‑B traffic layer and the RainViewer + **Radar** weather product when you want them. -Google / Android : https://play.google.com/store/apps/details?id=com.apps4av.avaremp +> European support is suitable for **supplementary VFR situational awareness** and direct waypoint +> navigation when the required regional data is installed. It is **not** equivalent to the US +> implementation for IFR procedures, legal preflight briefing, NOTAMs, terrain clearance, or filing. -iOS, MacOS: https://apps.apple.com/us/app/avarex/id6502421523 +--- -Linux: https://snapcraft.io/avarex +## European data support — details -Windows: https://apps.microsoft.com/detail/9mx4hkl30mww?hl=en-us&gl=US +AvareX keeps its FAA‑backed United States workflow unchanged and adds the optional European +providers below. European data is downloaded on demand rather than bundled in the application +package. -## Building: - -Github Actions builds all store builds. - -Microsoft version scheme: pubspec.yaml (versions go like 1.0.9.0, last digit must be 0) - -Apple version scheme: pubspec.yaml 0.0.9+9 - -Google version scheme: pubspec.yaml 0.0.9+9 (+9) is what shows up in the package) - -Snap version scheme: snap/snapcraft.yaml 0.0.9 +### OpenFlightMaps +[OpenFlightMaps](https://openflightmaps.org/) provides the primary European VFR chart and +aeronautical‑data integration: + +- Georeferenced EPSG:3857 MBTiles for the offline moving map. Standard‑resolution tiles are the + default; larger `@2x` archives are optional. +- OFMX airport, runway, runway‑end, communication, navaid, reporting‑point, and airspace records. +- Regional PDF VFR chart sheets for offline reference. These documents are identified as not + GPS‑referenced and are not moving‑map layers. +- Region and AIRAC‑cycle selection, progress reporting, cancellation, replacement, and removal. +- Separate map controls for `OFM VFR Chart` and `OFM Interactive Data`. +- Source, region, cycle, attribution, and disclaimer information in search and destination details. + +Only one cycle of a region should be active at a time. Missing raster tiles are transparent so +regional chart boundaries do not obscure other map content. +### openAIP + +[openAIP](https://www.openaip.net/) is an optional supplementary provider for European airports, +navaids, VFR reporting points, airspace, and obstacles. AvareX uses the authenticated openAIP Core +API rather than embedding a shared project credential. + +To use it: + +1. Create an openAIP account and personal API client. +2. In AvareX, open **Menu → Data → openAIP**. +3. Enter the personal API key and select **Test Connection**. +4. Enter a two‑letter ISO country code, such as `SE`, `DE`, or `FR`. +5. Select **Download Country Data**. +6. Enable **openAIP Interactive Data** in the map‑layer controls when its airspace display is wanted. + +Credential and data handling: + +- The API key is masked and stored with platform secure storage. It is not included in source code, + application diagnostics, downloaded data, or the APK. +- The settings screen provides Save, Test Connection, Clear Key, country download, and country + removal actions. +- API results are paginated and cached offline in a separate `openaip.db`. +- Downloaded airports and runways participate in search, nearby‑airport lookup, runway‑length + filtering, and destination details. +- Navaids and reporting points participate in search, nearby lookup, route‑point selection, and + nearest‑VOR lookup. +- Obstacles augment the existing obstacle layer when a usable top elevation is available. Records + lacking sufficient elevation information are not used for altitude filtering. +- Airspace polygons include vertical limits and `BY NOTAM`/`ON REQUEST` labels. +- All openAIP results retain source and country provenance and display openAIP attribution. + +openAIP data is licensed under [CC BY‑NC 4.0](https://creativecommons.org/licenses/by-nc/4.0/). It +is community‑maintained supplementary data and must not be treated as certified or as the sole +source for primary navigation or flight planning. See the +[openAIP legal information](https://www.openaip.net/legal) and +[API documentation](https://docs.openaip.net/) before redistributing data or shipping it in a +commercial product. + +### Official AIP charts (aip.aero) + +For any covered European (and several nearby) airport, the destination details provide an +**Official AIP & approach charts** action that opens the airport's page on +[aip.aero](https://aip.aero/) in the platform browser. aip.aero is a free index that links straight +to the country's official national AIP publication (VFR/IFR charts, aerodrome data). + +- This is a link hand‑off only. AvareX does not fetch, cache, or redistribute any chart PDF or AIP + content; it merely builds and opens the correct URL. +- The ICAO identifier is mapped to the aip.aero country entry; where the airport deep link is not + available the country landing page is used instead. +- The action is clearly marked as an external site and reminds the pilot to verify AIRAC currency + before every flight. + +This provides a legal, low‑friction route to official plates and airport diagrams without embedding +third‑party charts. It is not a georeferenced, moving‑map plate overlay. + +### Decoded METAR with selectable VFR/IFR thresholds + +Airport weather panels show, below the raw METAR, a **Decoded METAR** card that translates the +report into plain English (wind, visibility, ceiling, present weather, temperature/dewpoint spread, +and pressure) and color‑codes each element by threat level. A per‑view **VFR/IFR** selector switches +the threshold profile so the same report is assessed against thresholds appropriate to the operation. + +- Uses only the raw METAR AvareX already holds; no third‑party service. +- The selected profile is remembered across sessions. +- Advisory only; it is not a substitute for an official weather briefing. + +### Default map layers + +On a fresh installation the Europe map layers — `OFM VFR Chart`, `OFM Interactive Data`, and +`openAIP Interactive Data` — are enabled by default so installed European data is visible without +first opening the layer controls. These layers render nothing until the corresponding regional data +is installed, so United‑States‑only users are unaffected. Existing users keep their saved layer +choices; the default applies only to new installs. + +### Winds aloft (Open‑Meteo) + +The built‑in winds aloft come from NWS FB text products that only cover the United States and its +territories. Outside that coverage the destination Wind tab retrieves pressure‑level winds from +[Open‑Meteo](https://open-meteo.com/) and converts them into the same altitude bands (surface +through 39,000 ft) so the display is identical. + +- Selection is automatic: within US FB coverage the existing product is used; beyond it (Europe and + the rest of the world) Open‑Meteo is queried for the destination coordinate, with the US data as a + last‑resort fallback. +- The free Open‑Meteo endpoint is used by default. A pilot who needs commercial‑compliant access can + enter a personal Open‑Meteo API key under **Menu → Data → Open‑Meteo Winds** (Save, Test + Connection, Clear). The key is stored in platform secure storage and never embedded in the app. +- Winds are shown with `Winds © Open-Meteo.com, CC BY 4.0` attribution. They are forecast, advisory, + and not a substitute for an official weather briefing. + +Meteostat was evaluated as an additional surface‑observation provider but is not integrated: its +JSON API requires a per‑user paid RapidAPI key and largely duplicates METAR/Open‑Meteo coverage. It +remains a possible future option for historical/station surface observations. + +### NOTAMs (FlyBrief, offline) + +The built‑in NOTAM source is a United States FAA API that returns nothing in Europe. Where it has no +coverage, the airport NOTAM tab falls back to [FlyBrief](https://flybrief.app/) per‑country, +georeferenced NOTAM GeoJSON (polygons with altitude bands, schedules and active‑now flags). + +- Selection is automatic: the built‑in source is tried first; outside its coverage the FlyBrief + country under the destination is used, filtered to NOTAMs near the point (active ones first). +- Offline‑first: **Menu → Data → NOTAMs (FlyBrief)** downloads and stores the current country's + NOTAMs (and obstacles) under `{dataDir}/flybrief/` so they are available without a connection. The + tab loads the stored file when present and only fetches live when it is missing. +- ~28 European countries are covered. NOTAMs carry + `NOTAMs © OpenAIP contributors & national AIS via FlyBrief (CC BY-NC-SA 4.0)` attribution and are + advisory only — always confirm against the official national briefing. + +### Radar mosaic (RainViewer) + +The built‑in NEXRAD radar mosaic comes from the US ADS‑B/GDL90 uplink and the Iowa Mesonet tile +service, both of which only cover the United States. The EU build replaces the internet **Radar** +product with a global, animated mosaic from [RainViewer](https://www.rainviewer.com/). + +- The **Radar** weather product (enable the **Weather** map layer, then the weather‑products control) + shows composite radar reflectivity worldwide, including Europe. +- It is animated: RainViewer publishes the past two hours of frames at 10‑minute steps, which the map + loops through automatically. The frame slider reflects the current position in the loop. +- The color scheme is selectable. A **Radar colors** picker at the top of the weather‑products panel + offers RainViewer's color schemes (Universal Blue, The Weather Channel, NEXRAD Level III, and + others); the choice is remembered across sessions. +- No key and no login are required — tiles are fetched from RainViewer's free public API directly + from the device. +- Radar carries `Radar © RainViewer.com` attribution (shown on the map and in the weather‑products + panel). It is composite third‑party reflectivity, is advisory only, and is not an authoritative + FAA/NWS product or a substitute for an official weather briefing. + +The US builds keep the existing NEXRAD/Iowa Mesonet radar unchanged. The source is selected at build +time (`--dart-define=AVAREX_EU=false` restores the Mesonet mosaic). + +### Flight Intelligence (bring‑your‑own AI provider) + +The optional **Flight Intelligence** assistant is not tied to any bundled cloud account. The pilot +supplies an OpenAI‑compatible provider (base URL, optional API key, and model) under the assistant's +settings; requests go straight from the device to that endpoint. The key is stored in platform +secure storage and is never embedded in the app. Leaving it unconfigured simply disables the +feature. This build ships without the former sign‑in, subscription paywall, or Firebase‑backed cloud +features (backup/sync, community, scheduler); those have been removed rather than gated. + +### Terrain, elevation and GPWS (on‑device, offline) + +AvareX's terrain profile, elevation readout and GPWS are geography‑agnostic but depend on elevation +tiles that were only distributed for the US. They now work anywhere by building the tiles on the +device for a chosen country. + +- **Menu → Data → Terrain (Elevation)** downloads open + [AWS Terrain Tiles](https://registry.opendata.aws/terrain-tiles/) (public domain / permissively + licensed DEM) and transcodes them into AvareX's exact elevation‑tile format (512×512 gray+alpha + PNG, `elevationFt = gray * 80.4712 - 364.43`, slippy X / TMS Y) stored under + `{dataDir}/tiles/6/{z}/{x}/{y}.png`. +- Processing is on‑device and per‑country (defaults to the country under the current GPS), so nothing + multi‑gigabyte is bundled in the app — consistent with the small‑download philosophy. Progress is + shown and the build can be cancelled; existing tiles are skipped so it resumes cheaply. +- Rough sizes (zoom 1‑10): small countries (Slovenia, Switzerland, Netherlands) ~15‑35 MB and a + couple of minutes; mid countries (Germany, Spain, Italy) ~180‑290 MB; large countries (France, + Sweden, Norway) are several hundred MB to ~1 GB and are user‑initiated with an on‑screen estimate. +- Elevation accuracy after transcoding is within one gray step (~40 ft) of the source, matching the + precision of AvareX's own US tiles. Advisory only; not certified for terrain clearance. + +Bundling all‑Europe terrain into the build package is intentionally **not** done: zoom 1‑10 for all +of Europe is multiple gigabytes, which exceeds app‑store limits and the project's small‑APK goal. +Per‑country on‑device transcoding delivers the same offline capability without shipping the data in +the binary. + +### Not available in open form (documented, not faked) + +Some parity items have no authoritative, machine‑readable open source and are deliberately **not** +implemented rather than approximated with fragile scrapers: + +- **SID/STAR and instrument procedures**: there is no open, machine‑readable European equivalent of + the FAA CIFP; this data is commercial/licensed. +- **Georeferenced approach plates / airport diagrams**: no uniform cross‑country open catalog. + AvareX links out to the official national AIP via aip.aero instead (see above); it does not store + or overlay plates. + +### US ↔ Europe parity + +"Partial" means the feature works with available community/open data but does not have the same +coverage, authority, or product depth as the FAA‑backed US implementation. + +| Capability | United States | Europe | Parity | +| --- | --- | --- | --- | +| GPS moving map and direct‑to navigation | Full | Full | Full | +| Offline VFR raster charts | FAA chart products | OFM regional MBTiles | High | +| Offline VFR PDF sheets | FAA documents | OFM reference sheets, not georeferenced | High | +| Airport search and nearby lookup | FAA database | OFM plus openAIP | High | +| Runway dimensions and surfaces | FAA database | OFM plus openAIP | High | +| Runway‑end details and runway awareness | Full where FAA data exists | Available where source runway‑end geometry exists | Partial | +| Minimum‑runway‑length filtering | Full | OFM plus openAIP runway lengths | High | +| Airport frequencies | FAA/NASR | OFMX services plus openAIP frequencies | Partial to high | +| Navaids | FAA/NASR | OFMX plus openAIP VOR/NDB/DME/TACAN data | High for covered countries | +| VFR reporting points and fixes | FAA fixes | OFMX designated points plus openAIP reporting points | High for VFR use | +| Airways and automatic IFR routing | FAA airway graph | No dependable Europe‑wide open airway graph integrated | Low | +| SID, STAR, and instrument procedures | FAA CIFP | Not available from the integrated open sources | None | +| Approach plates and airport diagrams | FAA d‑TPP products | Link‑out to official national AIP via aip.aero (not georeferenced, not in‑app overlay) | Partial | +| Airspace display | FAA airspace/SUA | OFM and optional openAIP polygons | High for static display | +| Airspace schedules and live activation | FAA products where available | Static metadata and `BY NOTAM` flags; no live activation feed | Partial | +| METAR and TAF | Aviation Weather Center | International AWC reports where stations are covered | High | +| Decoded METAR with VFR/IFR threat coloring | Plain‑English decode with selectable profile | Same decode, geography‑independent | Full | +| Radar mosaic | US NEXRAD | Global radar mosaic via RainViewer (animated, 2h loop) | High for display | +| Winds aloft and graphical weather | US AWC/WPC products | Winds aloft from Open‑Meteo pressure‑level forecasts outside US coverage; no graphical products | Partial | +| NOTAMs and temporary restrictions | FAA‑specific services | Per‑country georeferenced NOTAMs via FlyBrief, stored for offline use | Partial | +| Terrain, elevation, and GPWS | US regional terrain packages | On‑device terrain tiles transcoded per country from open DEM; enables terrain profile + GPWS | Partial | +| Obstacles | FAA obstacle data | Supplementary openAIP obstacles with incomplete‑authority warning | Partial | +| ADS‑B/GDL90 traffic and external GPS | Geography‑independent | Geography‑independent | Full | +| NMEA/autopilot output | Geography‑independent | Geography‑independent | Full | +| Logbook, checklists, aircraft, W&B, notes | Geography‑independent | Geography‑independent | Full | +| Flight‑plan filing and briefing | Leidos/1800wxbrief | No European filing/briefing provider integrated | None | + +European support is suitable for supplementary VFR situational awareness and direct waypoint +navigation when the required regional data is installed. It is not equivalent to the US +implementation for IFR procedures, legal preflight briefing, NOTAMs, terrain clearance, or filing. +Pilots remain responsible for obtaining current authoritative AIP, NOTAM, weather, and procedure +information from the applicable national and European services. + +### Data‑source boundaries + +- FAA‑backed US behavior remains unchanged and is preferred for FAA records. +- OFM is the primary European VFR chart/OFMX source. +- openAIP supplements gaps and remains independently attributable and removable. +- Provider data is stored separately instead of being inserted into the FAA database. +- Search and nearby queries merge providers while retaining provenance. +- Neither OFM nor openAIP data is represented as certified. + +--- + +## Building + +GitHub Actions builds all store targets. + +### AvareX‑EU tagged releases + +Pushing any Git tag runs `.github/workflows/avarex-eu-release.yaml`. The workflow runs the Flutter +tests and analyzer, builds the Android release APK and AAB, and creates a GitHub release containing: + +- `AvareX-EU-.apk` +- `AvareX-EU--source.tar.gz` +- `AvareX-EU--source.zip` +- `CHANGELOG-.md` + +The changelist contains commits since the previous tag, is used as the GitHub release description, +and is also included inside both source archives as `CHANGELOG-RELEASE.md`. The EU APK uses the +release build configuration and is intended for direct installation from the GitHub release. The EU +build ships without Firebase or the subscription paywall, so no Firebase login, Firebase +service‑account secret, or RevenueCat key is required, and the former Firebase‑backed cloud features +are unavailable. An openAIP API key is likewise not a build secret; each user supplies and securely +stores a personal key in the application. The RainViewer radar mosaic needs no key. + +### Version schemes + +| Platform | Source | Example | +| --- | --- | --- | +| Microsoft | `pubspec.yaml` (last digit must be 0) | `1.0.9.0` | +| Apple | `pubspec.yaml` | `0.0.9+9` | +| Google | `pubspec.yaml` (`+9` is the package build) | `0.0.9+9` | +| Snap | `snap/snapcraft.yaml` | `0.0.9` | + +--- + +## Website & privacy + +- **Site:** https://wolverine2k.github.io/avarex/eu/ (source in [`docs/eu/`](docs/eu/)) +- **Privacy policy:** https://wolverine2k.github.io/avarex/eu/privacy.html — no accounts, no + analytics, no advertising, no tracking; your data stays on your device. + +## Credits & license + +AvareX‑EU is a community fork of [AvareX](https://github.com/apps4av/avarex) by +[Apps4Av](https://groups.google.com/g/apps4av-forum). It is not affiliated with, or endorsed by, any +aviation authority. See [`LICENSE`](LICENSE). European data belongs to its respective providers +(OpenFlightMaps, openAIP, RainViewer, OpenSky, Open‑Meteo, FlyBrief) under their own licenses and +attribution requirements. diff --git a/analysis_options.yaml b/analysis_options.yaml index 0d290213..bf8d4218 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -7,6 +7,15 @@ # The following line activates a set of recommended lints for Flutter apps, # packages, and plugins designed to encourage good coding practices. +analyzer: + exclude: + - build/** + - android/** + - ios/** + - web/** + - windows/** + - macos/** + - linux/** include: package:flutter_lints/flutter.yaml linter: diff --git a/android/app/build.gradle b/android/app/build.gradle index 20f8b9fc..e9636adc 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,8 +1,5 @@ plugins { id "com.android.application" - // START: FlutterFire Configuration - id 'com.google.gms.google-services' - // END: FlutterFire Configuration id "kotlin-android" id "dev.flutter.flutter-gradle-plugin" } @@ -25,6 +22,22 @@ if (flutterVersionName == null) { flutterVersionName = '1.0' } +// Optional release signing. Provide android/key.properties (gitignored) with: +// storeFile=/absolute/path/to/keystore.jks +// storePassword=... +// keyAlias=... +// keyPassword=... +// When absent (CI, fresh clones), the release build falls back to debug signing +// so `flutter build apk --release` still succeeds locally without secrets. +def keystoreProperties = new Properties() +def keystorePropertiesFile = rootProject.file('key.properties') +def hasReleaseSigning = keystorePropertiesFile.exists() +if (hasReleaseSigning) { + keystorePropertiesFile.withReader('UTF-8') { reader -> + keystoreProperties.load(reader) + } +} + android { namespace "com.apps4av.avaremp" compileSdkVersion flutter.compileSdkVersion @@ -45,8 +58,8 @@ android { } defaultConfig { - // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). - applicationId "com.apps4av.avaremp" + // Application ID for the AvareX-EU fork/build. + applicationId "com.naresh.avarex.eu" // You can update the following values to match your application needs. // For more information, see: https://docs.flutter.dev/deployment/android#reviewing-the-gradle-build-configuration. minSdkVersion flutter.minSdkVersion @@ -55,11 +68,23 @@ android { versionName flutterVersionName } + signingConfigs { + if (hasReleaseSigning) { + release { + storeFile file(keystoreProperties['storeFile']) + storePassword keystoreProperties['storePassword'] + keyAlias keystoreProperties['keyAlias'] + keyPassword keystoreProperties['keyPassword'] + } + } + } + buildTypes { release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - // signingConfig signingConfigs.debug + // Use the release signing config when android/key.properties is + // present; otherwise fall back to debug signing so builds still + // work in CI and on fresh clones without the keystore. + signingConfig hasReleaseSigning ? signingConfigs.release : signingConfigs.debug } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9f400c1e..7dea8be6 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,10 +6,22 @@ - - + + + + + + + + + + + + + + +AvareX-EU — European Electronic Flight Bag (EFB) | VFR Charts, ADS-B, Weather + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+ ● Free & open source · No ads · No tracking +

The European electronic flight bag that flies free.

+

AvareX-EU brings European charts and data to AvareX: OpenFlightMaps VFR charts, openAIP + airspace, internet ADS-B traffic and animated weather radar — with a georeferenced moving map, plates + and flight planning. Your data stays on your device.

+ +

Android, iOS, Windows, macOS, Linux & Raspberry Pi · Works offline once charts are downloaded

+
+
+
AvareX-EU moving map showing OpenFlightMaps VFR chart of Malmö (ESMS) with Malmö TMA/CTR airspace, openAIP data and flight instrument tiles
+
+
+
+ + +
+
+ A community European fork of AvareX (Apps4Av) + Android + iOS + Windows + macOS + Linux + Raspberry Pi +
+
+ + +
+
+
+
🗺️

European charts

OpenFlightMaps VFR charts & OFMX data, downloaded per region & AIRAC cycle.

+
🛩️

openAIP data

Airports, navaids, airspace & obstacles via your own openAIP key.

+
📡

ADS-B traffic

Optional internet ADS-B (advisory) from OpenSky, plus GDL90 receivers.

+
🌧️

Weather radar

Animated global internet radar from RainViewer, right on the map.

+
+
+
+ + +
+
+
+
Everything you fly with
+

A complete cockpit, tuned for Europe

+

All the AvareX electronic flight bag features, with optional European data providers added on top.

+
+ +
+
+
Charts & moving map
+

European VFR charts and a moving map you can trust

+

Fly OpenFlightMaps VFR charts with a georeferenced ownship — track-up or north-up, with range, speed + and glide rings. Regional chart data downloads on demand, so the app stays small.

+
    +
  • OpenFlightMaps VFR chart tiles & OFMX aeronautical data
  • +
  • Movable instrument tiles: GS, ALT, track, ETA, ETE & more
  • +
  • Region & AIRAC-cycle selection with progress and removal
  • +
  • Download once, then fly fully offline
  • +
+
+
AvareX-EU OpenFlightMaps data screen with VFR map layer, high-resolution @2x toggle, OFMX data and selectable German VFR chart sheets
+
+ +
+
+
Traffic & weather
+

Internet ADS-B traffic and animated radar

+

See advisory ADS-B traffic from the OpenSky Network over the internet, and layer animated RainViewer + radar for a quick go/no-go picture — no external hardware required.

+
    +
  • Internet ADS-B traffic (advisory) via OpenSky, plus GDL90 receivers over Wi-Fi
  • +
  • Animated RainViewer internet radar with selectable color schemes
  • +
  • METAR, TAF, winds and TFR products where available
  • +
  • Audible traffic alerts and terrain awareness
  • +
+
+
AvareX-EU moving map with red ownship symbol over Malmö, showing ESR130 restricted area and stepped CTR/TMA airspace on the OpenFlightMaps VFR chart
+
+ +
+
+
openAIP & plates
+

openAIP airspace and geo-referenced plates

+

Add optional openAIP airports, navaids, VFR reporting points, airspace and obstacles using your own + personal API key, and shoot approaches with your ownship drawn right on the plate.

+
    +
  • openAIP country data via your own authenticated API key
  • +
  • Airspace display as a toggle-able interactive data layer
  • +
  • Geo-referenced approach plates & airport diagrams
  • +
  • Data cached offline once downloaded
  • +
+
+
AvareX-EU flight plan nav log for a Sweden route from ESMS to ESSK with distance, ground speed, course, time and fuel per leg
+
+
+
+ + +
+
+
+
Bring your own data
+

European data, downloaded on demand

+

European data is fetched only when you ask for it, and stored locally — not bundled into the app package.

+
+
+
🗺️

OpenFlightMaps

Primary European VFR charts and OFMX airport, runway, comms, navaid, reporting-point and airspace data. Region and AIRAC-cycle aware.

+
🛩️

openAIP

Optional supplementary airports, navaids, reporting points, airspace and obstacles using your own openAIP Core API key — no shared credential.

+
📡

OpenSky Network

Optional internet ADS-B traffic layer for situational awareness (advisory only). Enable it when you want it; leave it off otherwise.

+
🌧️

RainViewer

Animated internet weather radar with global coverage, the default radar product in the EU build, with selectable color schemes.

+
📄

Supplementary docs

Optional aeronautical documents and data from providers such as flybrief and AIP sources, fetched on request.

+
🤖

Flight Intelligence (AI)

Optional AI assistant that talks to an OpenAI-compatible endpoint you configure with your own key. Off until you set it up.

+
+
+
+ + +
+
+
+
Privacy by design
+

Your flying data stays yours

+

AvareX-EU has no backend that we operate. Nothing about your flights is sent to us — ever.

+
+
+
🔒

No accounts

No sign-up, no login. Just install and fly.

+
🚫

No tracking or ads

No analytics, crash-reporting or advertising SDKs of any kind.

+
📱

On-device only

Routes, logbook, settings and keys stay in local storage on your device.

+
+ +
+
+ + +
+
+
+
Get started
+

Flying with European data in four steps

+
+
+
1

Install AvareX-EU

Grab the latest build from the GitHub releases page for your platform.

+
2

Download a region

Open Menu → Data and download an OpenFlightMaps region and AIRAC cycle for the area you fly.

+
3

Add openAIP (optional)

Enter your own openAIP API key, test the connection, and download country data by ISO code (e.g. DE, FR, SE).

+
4

Enable the layers you want

Turn on OFM VFR Chart, openAIP interactive data, internet ADS-B traffic and RainViewer radar in the map controls.

+
+
+
+ + +
+
+
+
Questions
+

Frequently asked questions

+
+
+
What is AvareX-EU?

AvareX-EU is a free, open-source European fork of AvareX (by Apps4Av). It keeps AvareX's electronic flight bag features and adds optional European aeronautical data from OpenFlightMaps and openAIP, internet ADS-B traffic from OpenSky, and RainViewer weather radar. European data downloads on demand rather than being bundled in the app.

+
How do I get European charts?

Open Menu → Data and download an OpenFlightMaps region for the AIRAC cycle you need. For supplementary airspace and obstacle data, enter your own openAIP API key and download country data by ISO code. Everything is stored locally on your device.

+
Does AvareX-EU track me or show ads?

No. There are no accounts, no analytics, no crash-reporting, no advertising and no tracking SDKs. Your routes, logbook, settings and any API keys stay on your device. The app only reaches the internet when you download data or fetch live weather and traffic. See the privacy policy for details.

+
Is it certified for navigation?

No. AvareX-EU is not a certified GPS or navigation system and must not be used as a sole means of navigation. European data added by this fork is complementary and not certified. Always cross-check with official sources and certified equipment.

+
What does it cost?

AvareX-EU is free and open source. You may need your own free openAIP account/API key for openAIP data, and any AI features use an endpoint and key that you supply.

+
+
+
+ + +
+
+

Ready to fly with AvareX-EU?

+

Free and open source. Download the latest build and add the European data you need.

+ +
+
+ +
+ +
+
+
+
+
AvareX EU
+

A community European fork of AvareX (Apps4Av), adding OpenFlightMaps, openAIP, OpenSky and RainViewer. Open source.

+
+
+

Product

+ +
+
+

Get it

+ +
+
+

Data providers

+ +
+
+
+

Safety notice: AvareX-EU is not a certified GPS and must not be used as a sole + means of navigation. European data added by this fork is complementary and not certified. Aviation is inherently + risky — always cross-check with official sources and certified equipment.

+

© AvareX-EU · Not affiliated with any aviation authority

+
+
+
+ + + diff --git a/docs/eu/privacy.html b/docs/eu/privacy.html new file mode 100644 index 00000000..ba3fe87f --- /dev/null +++ b/docs/eu/privacy.html @@ -0,0 +1,260 @@ + + + + + +Privacy Policy — AvareX-EU | Electronic Flight Bag + + + + + + + + + + + +
+
+

Privacy Policy

+

AvareX-EU is built to keep your flying data yours. No accounts, no analytics, no advertising, no tracking.

+

Application: AvareX-EU (Android package com.naresh.avarex.eu)
+ Effective date: 25 August 2026  ·  Last updated: 25 August 2026

+ +
+ The short version. AvareX-EU runs on your device. It has no user accounts and no cloud backend + operated by us. It does not embed any analytics, advertising, crash-reporting, or tracking SDK. Your routes, + logbook, settings, downloaded charts, and any API keys you enter are stored locally on your device. The app only + contacts the internet when you ask it to — to download aviation data or fetch live weather and traffic — + and those requests go directly from your device to the relevant data provider. +
+ + + +

1. Who we are

+

AvareX-EU is a community, open-source fork of AvareX (by Apps4Av), + adapted to add optional European aeronautical data providers. It is maintained by an independent developer and is + not affiliated with, or endorsed by, any aviation authority. For the purposes of the EU General Data Protection + Regulation (GDPR), because the app processes personal data only locally on your device and we operate no server + that receives your data, we do not act as a controller of any personal data collected through the app. Where we + can be reached, our contact details are in section 15.

+ +

2. Scope

+

This policy covers the AvareX-EU application and this documentation website. It does not cover + the third-party services the app can connect to at your request (weather providers, chart providers, the AI + endpoint you configure, etc.). Those services are operated by others under their own privacy policies, which are + linked in section 6.

+ +

3. Information we do not collect

+

AvareX-EU has no backend operated by us and ships without any tracking technology. Specifically, the app does + not:

+
    +
  • require you to create an account or log in;
  • +
  • contain any analytics, advertising, attribution, or crash-reporting SDK (no Google Analytics, no Firebase, no + Crashlytics, no ad networks, no third-party trackers);
  • +
  • collect or transmit your identity, contacts, device identifiers, or advertising ID;
  • +
  • send your position, routes, logbook, or flight history to us or to any server we control;
  • +
  • sell, rent, or share personal data with data brokers.
  • +
+ +

4. Data stored on your device

+

Everything you create or download in AvareX-EU is stored locally in the app's private storage on your device, + under your control:

+
    +
  • flight plans, routes, and per-leg nav logs;
  • +
  • digital logbook entries, aircraft and weight-and-balance profiles, checklists;
  • +
  • downloaded charts, plates, and aeronautical databases (FAA, OpenFlightMaps, openAIP, etc.);
  • +
  • app settings and preferences;
  • +
  • any API keys or credentials you choose to enter (see section 9).
  • +
+

This data never leaves your device unless you explicitly export or share it (for example, exporting a file or + using your operating system's share sheet). Uninstalling the app removes this local data.

+ +

5. When the app connects to the internet

+

AvareX-EU is designed to work fully offline once your charts and databases are downloaded. It reaches the internet + only for actions you initiate, such as:

+
    +
  • downloading FAA or European charts, plates, and databases;
  • +
  • fetching live weather (METAR, TAF, radar, winds, TFRs) when you open weather features;
  • +
  • fetching optional internet ADS-B traffic (advisory) when you enable it;
  • +
  • filing, activating, briefing, or closing a flight plan when you choose to;
  • +
  • using the optional Flight Intelligence (AI) feature you configure (see section 10).
  • +
+

These requests go directly from your device to the relevant provider. We do not proxy, log, or + see them. As with any internet request, the provider necessarily receives your device's IP address and the request + details (for example, the map area or airport you asked about) to fulfil the request.

+ +

6. Third-party data providers

+

Depending on which features you use, AvareX-EU may connect to the providers below. Each is contacted only when the + corresponding feature is used, and each has its own privacy policy governing what it does with the request.

+ + + + + + + + + + + + +
ProviderUsed forWhen contacted
OpenFlightMapsEuropean VFR charts & aeronautical data (MBTiles, OFMX)Only when you download EU chart/data regions
openAIPOptional European airports, navaids, airspace, obstaclesOnly when you enter your own openAIP API key and download country data
RainViewerAnimated internet weather radar (EU build default)Only when you view internet radar
OpenSky NetworkOptional internet ADS-B traffic (advisory)Only when you enable the OpenSky traffic layer
FAA / aviationweather.gov / NOAAUS weather, NEXRAD, TFRs, flight-plan filingOnly when you use the corresponding US features
flybrief.app / aip.aeroOptional supplementary aeronautical documents/dataOnly when you request those documents
OpenStreetMap Nominatim / national basemapsPlace search & base map tilesOnly when you search a place or view those layers
AI endpoint you configureOptional Flight Intelligence assistantOnly when you enable AI and send a prompt (see section 10)
+

We are not responsible for the privacy practices of these independent providers. Please review their policies, + including OpenFlightMaps, + openAIP, + RainViewer, and + OpenSky Network.

+ +

7. Device permissions

+

AvareX-EU requests only the permissions it needs for flight functions:

+
    +
  • Location (coarse & fine): to show your georeferenced ownship on the moving map and plates. + Location is processed on-device and is not transmitted to us.
  • +
  • Internet: to download data and fetch live weather/traffic when you request it.
  • +
  • Bluetooth (Android, optional): to connect to external ADS-B / GPS receivers, if you use one.
  • +
  • Billing (Android): present only where an in-app purchase mechanism exists; it does not give us + your payment details, which are handled by the platform store.
  • +
+ +

8. Location data

+

Your GPS position is used in real time on your device to draw ownship, compute distances, and drive instruments. + It is not sent to us and is not stored except where you deliberately record it (for example, + saving a track or logbook entry). If you enable the optional internet ADS-B traffic layer, the app sends the map + bounding box you are viewing (not your precise ownship position) to the traffic provider so it can return nearby + traffic; you can leave that layer off.

+ +

9. API keys and credentials you enter

+

Some optional features let you enter your own credentials — for example, an openAIP API key, OpenSky client + credentials, or an AI endpoint key. These are stored locally using your platform's secure storage and are sent + only to the corresponding provider to authenticate your own requests. They are never transmitted to us and are + never bundled into the app for other users.

+ +

10. Flight Intelligence (AI) feature

+

The optional AI assistant is off until you configure it. It talks to an OpenAI-compatible endpoint that + you specify (for example, OpenAI, OpenRouter, or your own server) using an API key you provide. + When you send a prompt, the content you submit is sent directly from your device to that endpoint under its own + privacy and data-use terms. If you do not configure and use the AI feature, no such requests are made. Do not + submit information you are not comfortable sharing with your chosen AI provider.

+ +

11. Children

+

AvareX-EU is a tool for pilots and is not directed at children. It does not knowingly collect personal data from + anyone, including children under 16.

+ +

12. Retention and deletion

+

Because we operate no server that holds your data, there is nothing for us to retain. All app data lives on your + device for as long as you keep it. You can delete individual items in-app, clear downloaded data, or uninstall the + app to remove everything. Data already sent to a third-party provider at your request is governed by that + provider's retention policy.

+ +

13. Your GDPR rights

+

Under the GDPR and similar laws you have rights to access, rectify, erase, restrict, and port your personal data, + and to object to processing. Because AvareX-EU processes your data solely on your device and we hold none of it, + you can exercise most of these rights directly: your data is already fully under your control on your device, and + you can view, edit, export, or delete it yourself at any time. For data you sent to a third-party provider, please + contact that provider. If you believe your data-protection rights have been infringed, you may lodge a complaint + with your local supervisory authority.

+ +

14. Changes to this policy

+

If this policy changes, we will update the version on this page and revise the "Last updated" date above. Material + changes will be reflected in the app's release notes or repository. Continued use after an update constitutes + acceptance of the revised policy.

+ +

15. Contact

+

Questions about this policy or AvareX-EU's data handling:

+ + +
+ Safety notice. AvareX-EU is not a certified GPS or navigation system and must not be used as a + sole means of navigation. European aeronautical data added by this fork is complementary and not certified. Always + cross-check with official sources and certified equipment. +
+
+
+ +
+
+

AvareX-EU — a community European fork of AvareX (Apps4Av). Open source.

+

© AvareX-EU · Home

+
+
+ + + diff --git a/docs/eu/sitemap.xml b/docs/eu/sitemap.xml new file mode 100644 index 00000000..ef7b6b10 --- /dev/null +++ b/docs/eu/sitemap.xml @@ -0,0 +1,15 @@ + + + + https://wolverine2k.github.io/avarex/eu/ + 2026-08-25 + weekly + 1.0 + + + https://wolverine2k.github.io/avarex/eu/privacy.html + 2026-08-25 + monthly + 0.8 + + diff --git a/docs/img/eu/data-download.png b/docs/img/eu/data-download.png new file mode 100644 index 00000000..4af5c07e Binary files /dev/null and b/docs/img/eu/data-download.png differ diff --git a/docs/img/eu/flight-plan.png b/docs/img/eu/flight-plan.png new file mode 100644 index 00000000..87843040 Binary files /dev/null and b/docs/img/eu/flight-plan.png differ diff --git a/docs/img/eu/map-malmo.png b/docs/img/eu/map-malmo.png new file mode 100644 index 00000000..4e806e4c Binary files /dev/null and b/docs/img/eu/map-malmo.png differ diff --git a/docs/img/eu/ownship-airspace.png b/docs/img/eu/ownship-airspace.png new file mode 100644 index 00000000..7967c748 Binary files /dev/null and b/docs/img/eu/ownship-airspace.png differ diff --git a/docs/openflightmaps_compliance.md b/docs/openflightmaps_compliance.md new file mode 100644 index 00000000..0e76eb7f --- /dev/null +++ b/docs/openflightmaps_compliance.md @@ -0,0 +1,14 @@ +# OpenFlightMaps compliance checklist + +OpenFlightMaps (OFM) data in AvareX is optional, source-labelled, and complementary. It must not be represented as certified or as a primary navigation source. + +Release requirements: + +- Display “© open flightmaps association” whenever an OFM map layer is visible. +- Show the complementary-data disclaimer before users install OFM products. +- Mark OFM search results and details with their OFM source, region, and cycle. +- Include instructions to report known data errors to open flightmaps association. +- Keep OFM region data independently installable and removable without changing FAA `main.db`. +- Before distributing prebuilt OFM data, obtain human/legal confirmation of the current OFMA General Users License and packaging requirements. + +Current application text is centralized in `lib/ofm/ofm_constants.dart`. diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json index d36b1fab..d0d98aa1 100644 --- a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json +++ b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -1,122 +1 @@ -{ - "images" : [ - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "20x20", - "idiom" : "iphone", - "filename" : "Icon-App-20x20@3x.png", - "scale" : "3x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "iphone", - "filename" : "Icon-App-29x29@3x.png", - "scale" : "3x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "iphone", - "filename" : "Icon-App-40x40@3x.png", - "scale" : "3x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@2x.png", - "scale" : "2x" - }, - { - "size" : "60x60", - "idiom" : "iphone", - "filename" : "Icon-App-60x60@3x.png", - "scale" : "3x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@1x.png", - "scale" : "1x" - }, - { - "size" : "20x20", - "idiom" : "ipad", - "filename" : "Icon-App-20x20@2x.png", - "scale" : "2x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@1x.png", - "scale" : "1x" - }, - { - "size" : "29x29", - "idiom" : "ipad", - "filename" : "Icon-App-29x29@2x.png", - "scale" : "2x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@1x.png", - "scale" : "1x" - }, - { - "size" : "40x40", - "idiom" : "ipad", - "filename" : "Icon-App-40x40@2x.png", - "scale" : "2x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@1x.png", - "scale" : "1x" - }, - { - "size" : "76x76", - "idiom" : "ipad", - "filename" : "Icon-App-76x76@2x.png", - "scale" : "2x" - }, - { - "size" : "83.5x83.5", - "idiom" : "ipad", - "filename" : "Icon-App-83.5x83.5@2x.png", - "scale" : "2x" - }, - { - "size" : "1024x1024", - "idiom" : "ios-marketing", - "filename" : "Icon-App-1024x1024@1x.png", - "scale" : "1x" - } - ], - "info" : { - "version" : 1, - "author" : "xcode" - } -} +{"images":[{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"20x20","idiom":"iphone","filename":"Icon-App-20x20@3x.png","scale":"3x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"29x29","idiom":"iphone","filename":"Icon-App-29x29@3x.png","scale":"3x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"40x40","idiom":"iphone","filename":"Icon-App-40x40@3x.png","scale":"3x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@1x.png","scale":"1x"},{"size":"57x57","idiom":"iphone","filename":"Icon-App-57x57@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@2x.png","scale":"2x"},{"size":"60x60","idiom":"iphone","filename":"Icon-App-60x60@3x.png","scale":"3x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@1x.png","scale":"1x"},{"size":"20x20","idiom":"ipad","filename":"Icon-App-20x20@2x.png","scale":"2x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@1x.png","scale":"1x"},{"size":"29x29","idiom":"ipad","filename":"Icon-App-29x29@2x.png","scale":"2x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@1x.png","scale":"1x"},{"size":"40x40","idiom":"ipad","filename":"Icon-App-40x40@2x.png","scale":"2x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@1x.png","scale":"1x"},{"size":"50x50","idiom":"ipad","filename":"Icon-App-50x50@2x.png","scale":"2x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@1x.png","scale":"1x"},{"size":"72x72","idiom":"ipad","filename":"Icon-App-72x72@2x.png","scale":"2x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@1x.png","scale":"1x"},{"size":"76x76","idiom":"ipad","filename":"Icon-App-76x76@2x.png","scale":"2x"},{"size":"83.5x83.5","idiom":"ipad","filename":"Icon-App-83.5x83.5@2x.png","scale":"2x"},{"size":"1024x1024","idiom":"ios-marketing","filename":"Icon-App-1024x1024@1x.png","scale":"1x"}],"info":{"version":1,"author":"xcode"}} \ No newline at end of file diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png index d984ef5f..8ebd35de 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-1024x1024@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png index 49f5d627..45038a63 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png index 17fd4649..be9e64d6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png index f224e194..7722431c 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-20x20@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png index 8193d019..01d4f7b8 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png index a0413bea..fefa4567 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png index f79e9d96..c0dec9ea 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-29x29@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png index 17fd4649..be9e64d6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png index 1af2c76a..5a3aa590 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png index c8ac6396..147cd6de 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-40x40@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png index 77c82bf1..e9b8eade 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png index 806efe66..2ef5de3e 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-50x50@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png index 69dde69b..46379f83 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png index de774b58..c8a92ddb 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-57x57@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png index c8ac6396..147cd6de 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png index a7992dc3..61d523c6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-60x60@3x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png index 3e1e6b69..77c73535 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png index c34ef68c..ecde5a8f 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-72x72@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png index edb990af..590fda3d 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@1x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png index 8efd7b15..16037af6 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-76x76@2x.png differ diff --git a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png index ac650236..7b908883 100644 Binary files a/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png and b/ios/Runner/Assets.xcassets/AppIcon.appiconset/Icon-App-83.5x83.5@2x.png differ diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9d248b5a..54b99500 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -5,7 +5,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - AvareX + AvareX-EU CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier diff --git a/lib/about_screen.dart b/lib/about_screen.dart new file mode 100644 index 00000000..b357444a --- /dev/null +++ b/lib/about_screen.dart @@ -0,0 +1,248 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:yaml/yaml.dart'; + +import 'constants.dart'; +import 'ofm/ofm_constants.dart'; +import 'openaip/openaip_constants.dart'; +import 'weather/flybrief_notams.dart'; +import 'weather/open_meteo_winds.dart'; +import 'weather/rainviewer_radar.dart'; + +/// A single third-party credit: what it is, the attribution/licence line, and +/// an optional link to the source. +class _Credit { + final String name; + final String detail; + final String? url; + const _Credit(this.name, this.detail, {this.url}); +} + +/// About / Credits screen. +/// +/// Lists the third-party data sources and open-source software AvareX builds +/// on, with their attributions and licences. The full, auto-generated licence +/// text for every bundled package is available via the "Open-source licenses" +/// button (Flutter's built-in [showLicensePage], which enumerates every +/// dependency's LICENSE file). +class AboutScreen extends StatelessWidget { + const AboutScreen({super.key}); + + // Aeronautical and weather DATA providers. Attribution strings are the same + // ones shown next to the data elsewhere in the app. + static const List<_Credit> _dataSources = [ + _Credit( + 'FAA (US aeronautical data)', + 'US charts, airport/NASR data, procedures and obstacles. Public domain, courtesy of the Federal Aviation Administration.', + url: 'https://www.faa.gov/', + ), + _Credit( + 'NWS / Aviation Weather Center', + 'METAR, TAF and US winds-aloft products. Public domain, courtesy of the US National Weather Service.', + url: 'https://aviationweather.gov/', + ), + _Credit( + 'openFlightmaps', + '${OfmConstants.attribution}. European VFR chart tiles and OFMX aeronautical data. Free for non-commercial use.', + url: 'https://www.openflightmaps.org/', + ), + _Credit( + 'openAIP', + '${OpenAipConstants.attribution}. Supplementary European airports, navaids, reporting points, airspace and obstacles.', + url: 'https://www.openaip.net/', + ), + _Credit( + 'Open-Meteo', + '${OpenMeteoWinds.attribution}. Global pressure-level winds aloft outside US coverage.', + url: 'https://open-meteo.com/', + ), + _Credit( + 'FlyBrief', + '${FlybriefNotams.attribution}. Per-country georeferenced European NOTAMs and obstacles.', + url: 'https://flybrief.app/', + ), + _Credit( + 'RainViewer', + '${RainViewerRadar.attribution}. Global, animated composite weather-radar mosaic (EU build).', + url: 'https://www.rainviewer.com/', + ), + _Credit( + 'Iowa Environmental Mesonet', + 'US NEXRAD composite radar tiles. Courtesy of Iowa State University (US build radar).', + url: 'https://mesonet.agron.iastate.edu/', + ), + _Credit( + 'AWS Terrain Tiles / Mapzen', + 'Terrarium elevation tiles used to build on-device terrain/GPWS data. Public domain / permissively licensed DEM sources.', + url: 'https://registry.opendata.aws/terrain-tiles/', + ), + _Credit( + 'USGS The National Map', + 'US topographic base-map tiles. Public domain, courtesy of the US Geological Survey.', + url: 'https://www.usgs.gov/', + ), + _Credit( + 'aip.aero', + 'Link-out index to official national AIP publications for European airports. No content is stored or redistributed.', + url: 'https://aip.aero/', + ), + ]; + + // Notable open-source SOFTWARE components. This is a highlighted subset; the + // complete, authoritative licence list for every bundled package (including + // transitive dependencies) is available via "Open-source licenses" below. + static const List<_Credit> _software = [ + _Credit('Flutter & Dart', 'UI toolkit and language. BSD-3-Clause. © Google LLC.', + url: 'https://flutter.dev/'), + _Credit('flutter_map', 'Slippy-map rendering (charts, radar, overlays). BSD-3-Clause.', + url: 'https://pub.dev/packages/flutter_map'), + _Credit('flutter_map_marker_cluster', 'Marker clustering for the map. BSD-3-Clause.', + url: 'https://pub.dev/packages/flutter_map_marker_cluster'), + _Credit('vector_map_tiles / vector_tile_renderer', 'Vector MBTiles rendering. Apache-2.0.', + url: 'https://pub.dev/packages/vector_map_tiles'), + _Credit('mbtiles', 'MBTiles reader for offline charts. MIT.', + url: 'https://pub.dev/packages/mbtiles'), + _Credit('latlong2', 'Geodesic math. Apache-2.0 / BSD.', + url: 'https://pub.dev/packages/latlong2'), + _Credit('sqflite / sqlite3', 'On-device SQLite databases. MIT / BSD / public domain.', + url: 'https://pub.dev/packages/sqflite'), + _Credit('dio & http', 'HTTP clients for data downloads and APIs. MIT / BSD-3-Clause.', + url: 'https://pub.dev/packages/dio'), + _Credit('image', 'PNG decode/encode for radar and terrain tiles. Apache-2.0 / MIT.', + url: 'https://pub.dev/packages/image'), + _Credit('syncfusion_flutter_pdfviewer', 'PDF viewing (charts, manuals). Syncfusion Community License.', + url: 'https://pub.dev/packages/syncfusion_flutter_pdfviewer'), + _Credit('fl_chart', 'Terrain/altitude and performance charts. MIT.', + url: 'https://pub.dev/packages/fl_chart'), + _Credit('audioplayers', 'GPWS, traffic and runway audio alerts. MIT.', + url: 'https://pub.dev/packages/audioplayers'), + _Credit('flutter_secure_storage', 'Secure storage for user-supplied API keys. BSD-3-Clause.', + url: 'https://pub.dev/packages/flutter_secure_storage'), + _Credit('geolocator', 'GPS position. MIT.', + url: 'https://pub.dev/packages/geolocator'), + _Credit('flutter_bluetooth_serial_ble', 'Bluetooth ADS-B / GPS receivers. Various OSS.', + url: 'https://pub.dev/packages/flutter_bluetooth_serial_ble'), + _Credit('flutter_material_design_icons / Material Icons', 'Iconography. Apache-2.0.', + url: 'https://pub.dev/packages/flutter_material_design_icons'), + _Credit('toastification, dropdown_button2, auto_size_text, introduction_screen', 'Assorted UI widgets. MIT.', + url: 'https://pub.dev/'), + _Credit('archive, xml, csv, geojson_vi, point_in_polygon', 'Parsing and geometry utilities. MIT / Apache-2.0 / BSD.', + url: 'https://pub.dev/'), + _Credit('AI provider (Flight Intelligence)', 'User-supplied OpenAI-compatible endpoint. No AI service is bundled; requests use the pilot\'s own provider and key.', + url: null), + ]; + + Future _open(String url) async { + final uri = Uri.tryParse(url); + if (uri == null) return; + if (await canLaunchUrl(uri)) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + } + + Widget _section(BuildContext context, String title) { + return Padding( + padding: const EdgeInsets.fromLTRB(16, 20, 16, 6), + child: Text( + title.toUpperCase(), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + letterSpacing: 1.1, + color: Theme.of(context).colorScheme.primary, + ), + ), + ); + } + + Widget _creditTile(BuildContext context, _Credit c) { + return ListTile( + dense: true, + title: Text(c.name, style: const TextStyle(fontWeight: FontWeight.w600)), + subtitle: Text(c.detail, style: const TextStyle(fontSize: 12)), + trailing: c.url == null + ? null + : Icon(Icons.open_in_new, + size: 18, color: Theme.of(context).colorScheme.outline), + onTap: c.url == null ? null : () => _open(c.url!), + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Constants.appBarBackgroundColor, + title: const Text('About & Credits'), + ), + body: ListView( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 8), + child: Row( + children: [ + Image.asset('assets/images/logo.png', width: 48, height: 48), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('AvareX', + style: TextStyle( + fontSize: 22, fontWeight: FontWeight.bold)), + FutureBuilder( + future: rootBundle.loadString('pubspec.yaml'), + builder: (context, snapshot) { + String version = 'Unknown'; + if (snapshot.hasData) { + final yaml = loadYaml(snapshot.data!); + version = yaml['version'].toString(); + } + return Text('v$version', + style: TextStyle( + fontSize: 13, + color: + Theme.of(context).colorScheme.outline)); + }, + ), + ], + ), + ), + ], + ), + ), + const Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 4), + child: Text( + 'A pilot\'s electronic flight bag, by Apps4Av. AvareX is built on ' + 'the third-party data sources and open-source software credited ' + 'below. Aeronautical and weather data is advisory only and is not ' + 'a substitute for official, current sources.', + style: TextStyle(fontSize: 12), + ), + ), + _section(context, 'Data sources'), + for (final c in _dataSources) _creditTile(context, c), + _section(context, 'Open-source software'), + for (final c in _software) _creditTile(context, c), + const SizedBox(height: 12), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FilledButton.icon( + icon: const Icon(Icons.article_outlined), + label: const Text('Open-source licenses'), + onPressed: () => showLicensePage( + context: context, + applicationName: 'AvareX', + applicationLegalese: '© Apps4Av. Licensed under the project ' + 'license. Bundled package licenses are listed here.', + ), + ), + ), + const SizedBox(height: 24), + ], + ), + ); + } +} diff --git a/lib/ai/ai_credentials.dart b/lib/ai/ai_credentials.dart new file mode 100644 index 00000000..fadebe3f --- /dev/null +++ b/lib/ai/ai_credentials.dart @@ -0,0 +1,88 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Secure storage for the optional, user-supplied AI provider configuration. +/// +/// Flight Intelligence talks to an OpenAI-compatible chat-completions endpoint +/// that the pilot supplies (base URL + API key + model). Nothing is embedded in +/// the app, source, logs or downloaded data. When no configuration is present +/// the feature simply prompts the pilot to add one. +/// +/// The base URL is the OpenAI-compatible root, e.g.: +/// https://api.openai.com/v1 +/// https://openrouter.ai/api/v1 +/// http://192.168.1.10:11434/v1 (a local llama.cpp / Ollama server) +/// The request is always POSTed to `/chat/completions`. +class AiConfig { + final String baseUrl; + final String apiKey; + final String model; + + const AiConfig({ + required this.baseUrl, + required this.apiKey, + required this.model, + }); + + /// A configuration is usable once a base URL and a model are set. The API key + /// is optional so local, keyless servers (llama.cpp, Ollama, LM Studio) work. + bool get isUsable => baseUrl.isNotEmpty && model.isNotEmpty; + + /// The full chat-completions endpoint derived from [baseUrl]. + Uri get chatCompletionsUri { + final root = baseUrl.endsWith('/') + ? baseUrl.substring(0, baseUrl.length - 1) + : baseUrl; + return Uri.parse('$root/chat/completions'); + } +} + +/// Reads and writes the AI provider configuration in platform secure storage. +class AiCredentials { + static const _urlKey = 'ai-provider-base-url'; + static const _apiKey = 'ai-provider-api-key'; + static const _modelKey = 'ai-provider-model'; + + static const String defaultModel = 'gpt-4o-mini'; + + final FlutterSecureStorage _storage; + + const AiCredentials( + {FlutterSecureStorage storage = const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + )}) + : _storage = storage; + + Future read() async { + final url = (await _storage.read(key: _urlKey))?.trim() ?? ''; + final key = (await _storage.read(key: _apiKey))?.trim() ?? ''; + final model = (await _storage.read(key: _modelKey))?.trim() ?? ''; + return AiConfig(baseUrl: url, apiKey: key, model: model); + } + + Future write(AiConfig config) async { + final url = config.baseUrl.trim(); + final key = config.apiKey.trim(); + final model = config.model.trim(); + if (url.isEmpty) { + await _storage.delete(key: _urlKey); + } else { + await _storage.write(key: _urlKey, value: url); + } + if (key.isEmpty) { + await _storage.delete(key: _apiKey); + } else { + await _storage.write(key: _apiKey, value: key); + } + if (model.isEmpty) { + await _storage.delete(key: _modelKey); + } else { + await _storage.write(key: _modelKey, value: model); + } + } + + Future clear() async { + await _storage.delete(key: _urlKey); + await _storage.delete(key: _apiKey); + await _storage.delete(key: _modelKey); + } +} diff --git a/lib/ai/ai_screen.dart b/lib/ai/ai_screen.dart index 2a1096e6..5bd45548 100644 --- a/lib/ai/ai_screen.dart +++ b/lib/ai/ai_screen.dart @@ -1,14 +1,17 @@ +import 'dart:convert'; + import 'package:avaremp/aircraft/aircraft.dart'; +import 'package:avaremp/ai/ai_credentials.dart'; +import 'package:avaremp/ai/ai_settings_screen.dart'; import 'package:avaremp/constants.dart'; import 'package:avaremp/data/user_database_helper.dart'; import 'package:avaremp/logbook/log_entry.dart'; import 'package:avaremp/plan/plan_route.dart'; -import 'package:avaremp/services/login_screen.dart'; import 'package:avaremp/storage.dart'; import 'package:avaremp/utils/toast.dart'; import 'package:avaremp/weather/winds_cache.dart'; -import 'package:firebase_ai/firebase_ai.dart'; import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; import 'package:latlong2/latlong.dart' show LatLng; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; @@ -22,7 +25,8 @@ class AiScreen extends StatefulWidget { class AiScreenState extends State { bool _clear = false; - final _model = FirebaseAI.vertexAI().generativeModel(model: 'gemini-2.5-pro', tools: [Tool.googleSearch()]); + static const _credentials = AiCredentials(); + AiConfig _config = const AiConfig(baseUrl: '', apiKey: '', model: ''); bool _isSending = false; final TextEditingController _editingController = TextEditingController(); @@ -37,9 +41,25 @@ class AiScreenState extends State { @override void initState() { super.initState(); + _loadConfig(); _loadQueries(); } + Future _loadConfig() async { + final config = await _credentials.read(); + if (mounted) { + setState(() => _config = config); + } + } + + Future _openSettings() async { + await Navigator.push( + context, + MaterialPageRoute(builder: (_) => const AiSettingsScreen()), + ); + await _loadConfig(); + } + Future _loadQueries() async { final queries = await UserDatabaseHelper.db.getAllAiQueries(); if (mounted) { @@ -74,31 +94,34 @@ class AiScreenState extends State { if(myQuery.isEmpty) { return "Please enter a question first"; } - final prompt = TextPart(myQuery); - List parts = []; - parts.add(prompt); + if(!_config.isUsable) { + return "No AI provider is configured. Tap the settings icon to add your provider URL, API key and model."; + } + if(myQuery.length > 8192) { + return "Question length must be less than 8192 characters"; + } + + // Assemble the user message from the question plus any opted-in context. + final StringBuffer content = StringBuffer(myQuery); if(includeAircraft) { List aircraft = await UserDatabaseHelper.db.getAllAircraft(); if(aircraft.isNotEmpty) { Aircraft ac = aircraft.first; - final acText = "Use aircraft ${ac.tail} and make/mode ${ac.type}"; - parts.add(TextPart(acText)); + content.write("\n\nUse aircraft ${ac.tail} and make/mode ${ac.type}"); } } if(includeLogbook) { List entries = await UserDatabaseHelper.db.getAllLogbook(); if(entries.isNotEmpty) { - String logText = "Last 50 log book entries are:\n"; - logText += "${entries.first.toMap().keys.join(",")}\n"; + content.write("\n\nLast 50 log book entries are:\n"); + content.write("${entries.first.toMap().keys.join(",")}\n"); for(int i = 0; i < entries.length && i < 50; i++) { - logText += "${entries[i].toMap().values.join(",")}\n"; + content.write("${entries[i].toMap().values.join(",")}\n"); } - parts.add(TextPart(logText)); } } if(includeWeather) { - String weatherText = ""; PlanRoute route = Storage().route; if (route.isNotEmpty) { LatLng start = route.getAllDestinations().first.coordinate; @@ -106,44 +129,55 @@ class AiScreenState extends State { String? windsStart = WindsCache.getWindsAtAll(start, 6); String? windsEnd = WindsCache.getWindsAtAll(end, 6); if (windsStart != null) { - weatherText += "Winds at departure:\n$windsStart\n"; + content.write("\n\nWinds at departure:\n$windsStart\n"); } if (windsEnd != null) { - weatherText += "Winds at destination:\n$windsStart\n"; + content.write("Winds at destination:\n$windsEnd\n"); } - parts.add(TextPart(weatherText)); } } if(includePlan) { PlanRoute route = Storage().route; if(route.isNotEmpty) { - String planText = "Plan is: ${route.toString()}\n"; - parts.add(TextPart(planText)); + content.write("\n\nPlan is: ${route.toString()}\n"); } } + String ret = "Unable to get an answer."; try { - final query = Content.multi(parts); - final responseT = await _model.countTokens([query]); - final totalTokens = responseT.totalTokens; - if(myQuery.length > 2048) { - ret = "Question length must be less than 2048 characters"; - } - else if (totalTokens > 10000) { - ret = "Please reduce the amount of context included to 10000 tokens - total tokens $totalTokens"; - } - else { - final response = await _model.generateContent([query]); - if (response.text == null) { - ret = "Error: no response from the server"; - } - else { - ret = response.text!; + final response = await http.post( + _config.chatCompletionsUri, + headers: { + 'Content-Type': 'application/json', + if (_config.apiKey.isNotEmpty) + 'Authorization': 'Bearer ${_config.apiKey}', + }, + body: jsonEncode({ + 'model': _config.model, + 'messages': [ + { + 'role': 'system', + 'content': + 'You are an aviation assistant for pilots. Be concise and accurate.' + }, + {'role': 'user', 'content': content.toString()}, + ], + }), + ); + if (response.statusCode != 200) { + ret = "Provider error: HTTP ${response.statusCode}. Check your AI provider settings."; + } else { + final decoded = jsonDecode(utf8.decode(response.bodyBytes)); + final text = decoded['choices']?[0]?['message']?['content']; + if (text is String && text.trim().isNotEmpty) { + ret = text.trim(); + } else { + ret = "Error: no response from the provider."; } } } catch(e) { - ret = "Internet connection needed."; + ret = "Internet connection needed, or the provider is unreachable."; } await UserDatabaseHelper.db.insertAiQueries(myQuery, ret); _loadQueries(); @@ -286,6 +320,11 @@ class AiScreenState extends State { ], ), actions: [ + IconButton( + onPressed: _isSending ? null : _openSettings, + icon: const Icon(Icons.settings), + tooltip: "AI provider settings", + ), IconButton( onPressed: _isSending ? null : () => scaffoldKey.currentState!.openEndDrawer(), icon: const Icon(Icons.history), @@ -551,10 +590,10 @@ class AiScreenState extends State { } static void teleportToAiScreen(BuildContext context, String query) { - if(Constants.shouldShowProServices) { + if(Constants.shouldShowAi) { UserDatabaseHelper.db.insertAiQueries(query, '').then((value) { if(context.mounted) { - LoginScreenState.showPaywall(context, "/ai"); + Navigator.pushNamed(context, "/ai"); } }); } diff --git a/lib/ai/ai_settings_screen.dart b/lib/ai/ai_settings_screen.dart new file mode 100644 index 00000000..11c55958 --- /dev/null +++ b/lib/ai/ai_settings_screen.dart @@ -0,0 +1,211 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; + +import '../constants.dart'; +import 'ai_credentials.dart'; + +/// Lets the pilot supply their own OpenAI-compatible AI provider for the +/// Flight Intelligence feature. Nothing is bundled with the app; the endpoint, +/// key and model are stored in platform secure storage on this device only. +class AiSettingsScreen extends StatefulWidget { + const AiSettingsScreen({super.key}); + + @override + State createState() => _AiSettingsScreenState(); +} + +class _AiSettingsScreenState extends State { + final _credentials = const AiCredentials(); + final _urlController = TextEditingController(); + final _keyController = TextEditingController(); + final _modelController = TextEditingController(); + bool _busy = false; + bool _hideKey = true; + String? _message; + + @override + void initState() { + super.initState(); + _credentials.read().then((config) { + if (!mounted) return; + setState(() { + _urlController.text = config.baseUrl; + _keyController.text = config.apiKey; + _modelController.text = + config.model.isEmpty ? AiCredentials.defaultModel : config.model; + }); + }); + } + + @override + void dispose() { + _urlController.dispose(); + _keyController.dispose(); + _modelController.dispose(); + super.dispose(); + } + + AiConfig get _current => AiConfig( + baseUrl: _urlController.text, + apiKey: _keyController.text, + model: _modelController.text, + ); + + Future _save() async { + await _credentials.write(_current); + if (!mounted) return; + setState(() => _message = _current.isUsable + ? 'Saved securely on this device.' + : 'Enter at least a provider URL and a model to enable AI.'); + } + + Future _clear() async { + await _credentials.clear(); + _urlController.clear(); + _keyController.clear(); + _modelController.text = AiCredentials.defaultModel; + if (!mounted) return; + setState(() => _message = 'AI provider settings cleared.'); + } + + Future _test() async { + final config = _current; + if (!config.isUsable) { + setState(() => _message = 'Enter a provider URL and a model first.'); + return; + } + setState(() { + _busy = true; + _message = 'Testing connection...'; + }); + try { + final response = await http.post( + config.chatCompletionsUri, + headers: { + 'Content-Type': 'application/json', + if (config.apiKey.isNotEmpty) + 'Authorization': 'Bearer ${config.apiKey}', + }, + body: jsonEncode({ + 'model': config.model, + 'messages': [ + {'role': 'user', 'content': 'Reply with the single word: ok'} + ], + 'max_tokens': 5, + }), + ); + if (!mounted) return; + setState(() { + _busy = false; + _message = response.statusCode == 200 + ? 'Connection successful; provider responded.' + : 'Provider returned HTTP ${response.statusCode}. ' + 'Check the URL, key and model.'; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _busy = false; + _message = 'Could not reach the provider. Check the URL and network.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + backgroundColor: Constants.appBarBackgroundColor, + title: const Text('Flight Intelligence Setup'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('AI provider', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + const Text( + 'Flight Intelligence uses an AI provider that you supply. Enter ' + 'the base URL of any OpenAI-compatible chat-completions API, an ' + 'optional API key, and the model name. Requests are sent from ' + 'this device directly to your provider; nothing is routed through ' + 'AvareX servers.'), + const SizedBox(height: 16), + TextField( + controller: _urlController, + keyboardType: TextInputType.url, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Provider base URL', + hintText: 'https://api.openai.com/v1', + helperText: 'OpenAI-compatible root; "/chat/completions" is added', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _keyController, + obscureText: _hideKey, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: 'API key (optional)', + helperText: 'Leave blank for keyless local servers', + suffixIcon: IconButton( + icon: Icon(_hideKey ? Icons.visibility : Icons.visibility_off), + onPressed: () => setState(() => _hideKey = !_hideKey), + ), + ), + ), + const SizedBox(height: 12), + TextField( + controller: _modelController, + autocorrect: false, + decoration: const InputDecoration( + labelText: 'Model', + hintText: 'gpt-4o-mini', + ), + ), + const SizedBox(height: 16), + Wrap( + spacing: 8, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _save, + icon: const Icon(Icons.save), + label: const Text('Save'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _test, + icon: const Icon(Icons.wifi_tethering), + label: const Text('Test Connection'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _clear, + icon: const Icon(Icons.delete_outline), + label: const Text('Clear'), + ), + ], + ), + if (_busy) + const Padding( + padding: EdgeInsets.only(top: 16), + child: LinearProgressIndicator(), + ), + if (_message != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text(_message!), + ), + const SizedBox(height: 24), + const Text( + 'Responses are generated by an AI model and may be inaccurate. ' + 'Do not use this when life, health or property are at stake. Your ' + 'API key is stored only in this device\'s secure storage.', + style: TextStyle(fontWeight: FontWeight.bold)), + ], + ), + ); + } +} diff --git a/lib/aip/aip_aero.dart b/lib/aip/aip_aero.dart new file mode 100644 index 00000000..42837557 --- /dev/null +++ b/lib/aip/aip_aero.dart @@ -0,0 +1,165 @@ +// Deep links to aip.aero, a free index that points to each country's official +// national Aeronautical Information Publication (AIP) and approach charts. +// +// aip.aero does not license or host aeronautical data itself; it links straight +// to the latest official AIP of the respective country (DFS, NATS, ENAIRE, +// skeyes, ...). We only build a URL here (Tier 1 hand-off) and let the platform +// browser open it. No data is fetched, cached, or redistributed by the app. +// +// URL scheme (verified empirically against the live site): +// https://aip.aero/{slug}/vfr/?{ICAO} -> airport detail page (~48 countries) +// https://aip.aero/{slug}/ -> country landing page (search) +// https://aip.aero/ -> global search (last resort) +// +// A small number of countries (currently France and Belgium/Luxembourg) do not +// expose the guessable "vfr" category slug, so for those we fall back to the +// country landing page, which always resolves. + +class AipAero { + AipAero._(); + + static const String baseUrl = 'https://aip.aero'; + + // Maps an aip.aero country to its site slug and whether the "vfr" airport + // deep link is supported. `deepLink == false` means we can only link to the + // country landing page (the airport detail slug is not guessable). + static const Map _countries = { + 'albania': (slug: 'al', deepLink: true), + 'armenia': (slug: 'am', deepLink: true), + 'australia': (slug: 'au', deepLink: true), + 'austria': (slug: 'at', deepLink: true), + 'azerbaijan': (slug: 'az', deepLink: true), + 'belarus': (slug: 'by', deepLink: true), + 'belgium': (slug: 'be', deepLink: false), + 'bosnia': (slug: 'ba', deepLink: true), + 'bulgaria': (slug: 'bg', deepLink: true), + 'croatia': (slug: 'hr', deepLink: true), + 'cyprus': (slug: 'cy', deepLink: true), + 'czechia': (slug: 'cz', deepLink: true), + 'denmark': (slug: 'dk', deepLink: true), + 'estonia': (slug: 'ee', deepLink: true), + 'finland': (slug: 'fi', deepLink: true), + 'france': (slug: 'fr', deepLink: false), + 'georgia': (slug: 'ge', deepLink: true), + 'germany': (slug: 'de', deepLink: true), + 'greece': (slug: 'gr', deepLink: true), + 'hungary': (slug: 'hu', deepLink: true), + 'iceland': (slug: 'is', deepLink: true), + 'ireland': (slug: 'ie', deepLink: true), + 'italy': (slug: 'it', deepLink: true), + 'kazakhstan': (slug: 'kz', deepLink: true), + 'kosovo': (slug: 'xk', deepLink: true), + 'kyrgyzstan': (slug: 'kg', deepLink: true), + 'latvia': (slug: 'lv', deepLink: true), + 'lithuania': (slug: 'lt', deepLink: true), + 'malta': (slug: 'mt', deepLink: true), + 'moldova': (slug: 'md', deepLink: true), + 'netherlands': (slug: 'nl', deepLink: true), + 'newzealand': (slug: 'nz', deepLink: true), + 'macedonia': (slug: 'mk', deepLink: true), + 'norway': (slug: 'no', deepLink: true), + 'poland': (slug: 'pl', deepLink: true), + 'portugal': (slug: 'pt', deepLink: true), + 'romania': (slug: 'ro', deepLink: true), + 'russia': (slug: 'ru', deepLink: true), + 'serbia': (slug: 'rs', deepLink: true), + 'slovakia': (slug: 'sk', deepLink: true), + 'slovenia': (slug: 'si', deepLink: true), + 'spain': (slug: 'es', deepLink: true), + 'sweden': (slug: 'se', deepLink: true), + 'switzerland': (slug: 'ch', deepLink: true), + 'tajikistan': (slug: 'tj', deepLink: true), + 'turkey': (slug: 'tr', deepLink: true), + 'turkmenistan': (slug: 'tm', deepLink: true), + 'ukraine': (slug: 'ua', deepLink: true), + 'uk': (slug: 'uk', deepLink: true), + 'uzbekistan': (slug: 'uz', deepLink: true), + }; + + // Resolves an ICAO location identifier to an aip.aero country key. + // + // ICAO area assignments are prefix-based. We try the most specific match + // first (3-letter blocks that are shared between neighbouring states, e.g. + // the UT* block spanning Turkmenistan / Tajikistan / Uzbekistan), then the + // common 2-letter block, then single-letter (Australia), and finally a broad + // ex-USSR "U" fallback to Russia. + static String? _countryKey(String icao) { + final id = icao.trim().toUpperCase(); + if (id.length < 3) { + return null; + } + + // Shared UT* block must be disambiguated by the third letter. + if (id.startsWith('UT')) { + switch (id[2]) { + case 'A': // UTAx + return 'turkmenistan'; + case 'D': // UTDx + case 'O': // UTOx + return 'tajikistan'; + default: // UTN/UTS/UTK/UTT... + return 'uzbekistan'; + } + } + + const twoLetter = { + 'ED': 'germany', 'ET': 'germany', + 'LO': 'austria', + 'EB': 'belgium', 'EL': 'belgium', // Luxembourg indexed with Belgium + 'LQ': 'bosnia', 'LK': 'czechia', 'EK': 'denmark', 'EE': 'estonia', + 'EF': 'finland', 'LF': 'france', 'UG': 'georgia', 'LG': 'greece', + 'LH': 'hungary', 'BI': 'iceland', 'EI': 'ireland', 'LI': 'italy', + 'EV': 'latvia', 'EY': 'lithuania', 'LM': 'malta', 'LU': 'moldova', + 'EH': 'netherlands', 'NZ': 'newzealand', 'LW': 'macedonia', + 'EN': 'norway', 'EP': 'poland', 'LP': 'portugal', 'LR': 'romania', + 'LY': 'serbia', 'LZ': 'slovakia', 'LJ': 'slovenia', + 'LE': 'spain', 'GC': 'spain', // GC = Canary Islands + 'ES': 'sweden', 'LS': 'switzerland', 'LT': 'turkey', + 'EG': 'uk', 'LA': 'albania', 'UD': 'armenia', 'UB': 'azerbaijan', + 'UM': 'belarus', 'LB': 'bulgaria', 'LC': 'cyprus', 'BK': 'kosovo', + 'UC': 'kyrgyzstan', 'UK': 'ukraine', 'UA': 'kazakhstan', + 'LD': 'croatia', + }; + final two = id.substring(0, 2); + final byTwo = twoLetter[two]; + if (byTwo != null) { + return byTwo; + } + + // Australia: all Y****. + if (id.startsWith('Y')) { + return 'australia'; + } + + // Broad ex-USSR fallback: remaining U-block identifiers are Russia. + if (id.startsWith('U')) { + return 'russia'; + } + + return null; + } + + // True when we can point the user to an aip.aero page for this airport. + static bool hasChartsFor(String icao) => _countryKey(icao) != null; + + // Builds the best available aip.aero URL for the given ICAO identifier. + // + // Returns an airport deep link where supported, otherwise the country + // landing page, otherwise the global search homepage. Never returns null so + // the link is always actionable. + static String urlForAirport(String icao) { + final id = icao.trim().toUpperCase(); + final key = _countryKey(id); + if (key == null) { + return '$baseUrl/'; + } + final country = _countries[key]; + if (country == null) { + return '$baseUrl/'; + } + if (country.deepLink && id.isNotEmpty) { + return '$baseUrl/${country.slug}/vfr/?$id'; + } + return '$baseUrl/${country.slug}/'; + } +} diff --git a/lib/business/airport_businesses_gate.dart b/lib/business/airport_businesses_gate.dart deleted file mode 100644 index ae9299f7..00000000 --- a/lib/business/airport_businesses_gate.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/material.dart'; -import 'package:latlong2/latlong.dart'; - -import '../constants.dart'; -import '../services/login_screen.dart'; -import 'airport_businesses_view.dart'; -import 'data/airport_business_repository.dart'; -import 'models/airport_business.dart'; - -/// Single home for the Firebase-backed "Airport Businesses" logic so the -/// plate and long-press screens don't have to touch FirebaseAuth / Firestore -/// directly. -/// -/// None of this is gated by Pro — only by whether the cloud backend exists on -/// the platform (Firebase is initialized on iOS/Android only, see main.dart) -/// and whether the pilot is signed in. -class AirportBusinessesGate { - AirportBusinessesGate._(); - - /// Whether the cloud businesses feature can run on this platform. - static bool get available => Constants.firebaseAvailable; - - /// True when the businesses feature is usable right now (platform supports - /// the cloud backend and a pilot is signed in). - static bool get isReady => - available && FirebaseAuth.instance.currentUser != null; - - /// Businesses with a map coordinate for the plate airport-diagram overlay. - /// Best-effort: returns an empty list when the feature isn't available, the - /// pilot isn't signed in, or the lookup fails. - static Future> businessesForPlate( - String airport, {LatLng? origin}) async { - if (!isReady) { - return const []; - } - return AirportBusinessRepository.instance - .fetchBusinessesWithLocation(airport, origin: origin); - } -} - -/// Content for the airport long-press "Business" tab. When signed in it shows -/// the crowd-sourced businesses/reviews inline (browse, view and review in -/// place); when not signed in it shows a prompt that sends the pilot to -/// sign-in — the only time this feature navigates away. It reacts to auth -/// state so it refreshes in place after signing in. -class AirportBusinessesTab extends StatelessWidget { - final String airport; // LocationID / FAA id - final LatLng? origin; // airport coordinate, for nearest-first ordering - - const AirportBusinessesTab({super.key, required this.airport, this.origin}); - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: FirebaseAuth.instance.authStateChanges(), - builder: (context, _) { - final signedIn = FirebaseAuth.instance.currentUser != null; - if (signedIn) { - return AirportBusinessesView(airport: airport, origin: origin); - } - final scheme = Theme.of(context).colorScheme; - return ListView( - padding: const EdgeInsets.all(8), - children: [ - Card( - color: scheme.primaryContainer, - child: ListTile( - leading: - Icon(Icons.storefront, color: scheme.onPrimaryContainer), - title: Text("Businesses & Reviews", - style: TextStyle( - fontWeight: FontWeight.bold, - color: scheme.onPrimaryContainer)), - subtitle: Text( - "Pilot-contributed FBOs, services, fuel, hours & reviews. Sign in to view and contribute.", - style: TextStyle( - fontSize: 12, color: scheme.onPrimaryContainer)), - trailing: Icon(Icons.login, color: scheme.onPrimaryContainer), - onTap: () => - LoginScreenState.requireSignInThen(context, (_) {}), - ), - ), - ], - ); - }, - ); - } -} diff --git a/lib/business/airport_businesses_view.dart b/lib/business/airport_businesses_view.dart deleted file mode 100644 index fb6ff4bb..00000000 --- a/lib/business/airport_businesses_view.dart +++ /dev/null @@ -1,881 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:latlong2/latlong.dart'; - -import '../utils/firestore_write.dart'; -import '../utils/toast.dart'; -import 'data/airport_business_repository.dart'; -import 'models/airport_business.dart'; -import 'models/business_review.dart'; -import 'widgets/business_form.dart'; -import 'widgets/star_rating.dart'; - -/// Toast for a contribution write: the normal success message when it synced, -/// or an "offline, will upload later" note when it was queued locally. -void _showWriteResultToast( - BuildContext context, WriteSyncResult result, String syncedMessage) { - if (result == WriteSyncResult.queuedOffline) { - Toast.showToast( - context, - "You're offline — saved on this device and will upload when you reconnect.", - const Icon(Icons.cloud_off, color: Colors.orange), - 4); - } else { - Toast.showToast(context, syncedMessage, - const Icon(Icons.check_circle, color: Colors.green), 2); - } -} - -/// Inline, self-contained view of the crowd-sourced businesses/FBOs for a -/// single airport. It is embedded directly (e.g. in the airport long-press -/// "Business" tab) rather than pushed as a separate screen: the whole -/// browse → view details → review flow happens in place, using modal sheets -/// for add/edit/review actions. No navigation to a new screen occurs here. -/// -/// Reading requires a signed-in user (enforced by Firestore rules), so this -/// view should only be shown to authenticated users; callers gate it and send -/// unauthenticated users to sign-in instead. -class AirportBusinessesView extends StatelessWidget { - final String airport; // LocationID / FAA id - final LatLng? origin; // airport coordinate, for nearest-first ordering - const AirportBusinessesView( - {super.key, required this.airport, this.origin}); - - String get _airport => airport.trim().toUpperCase(); - - Future _addBusiness(BuildContext context) async { - final result = await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => BusinessFormSheet( - title: "Add Business at $_airport", - requireName: true, - ), - ); - if (result == null || !context.mounted) return; - try { - final sync = await commitWithOfflineFallback( - AirportBusinessRepository.instance.addBusiness( - airport: _airport, - name: result.name, - services: result.services, - fuelTypes: result.fuelTypes, - operatingHours: result.operatingHours, - phoneNumber: result.phoneNumber, - radioFrequency: result.radioFrequency, - ), - ); - if (context.mounted) { - _showWriteResultToast(context, sync, "Business added. Thank you!"); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not add business: $e", - const Icon(Icons.error, color: Colors.red), 3); - } - } - } - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: AirportBusinessRepository.instance - .watchBusinesses(_airport, origin: origin), - builder: (context, snapshot) { - if (snapshot.hasError) { - return _ErrorView(error: snapshot.error.toString()); - } - if (!snapshot.hasData) { - return const Center(child: CircularProgressIndicator()); - } - final data = snapshot.data!; - final items = data.items; - return ListView( - padding: const EdgeInsets.fromLTRB(8, 8, 8, 16), - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: Row( - children: [ - Expanded( - child: Text( - "Businesses at $_airport", - style: const TextStyle( - fontWeight: FontWeight.bold, fontSize: 15), - ), - ), - FilledButton.icon( - onPressed: () => _addBusiness(context), - icon: const Icon(Icons.add_business, size: 18), - label: const Text("Add"), - ), - ], - ), - ), - if (data.isFromCache) const _OfflineBanner(), - if (items.isEmpty) - _EmptyView(airport: _airport, offline: data.isFromCache) - else - for (final b in items) _BusinessTile(business: b), - ], - ); - }, - ); - } -} - -/// A single business rendered as an expandable tile. Collapsed it shows the -/// name, a summary of fuel/services and its average rating; expanded it shows -/// the full detail card, an edit action, and the inline list of reviews with -/// an add-review action. -class _BusinessTile extends StatefulWidget { - final AirportBusiness business; - const _BusinessTile({required this.business}); - - @override - State<_BusinessTile> createState() => _BusinessTileState(); -} - -class _BusinessTileState extends State<_BusinessTile> { - final _repo = AirportBusinessRepository.instance; - BusinessStats? _stats; - - @override - void initState() { - super.initState(); - _loadStats(); - } - - Future _loadStats() async { - final stats = await _repo.fetchStats(widget.business.id); - if (mounted) setState(() => _stats = stats); - } - - Future _editDetails(AirportBusiness biz) async { - final result = await showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (_) => BusinessFormSheet( - title: "Edit ${biz.name}", - requireName: false, - initialName: biz.name, - initialServices: biz.services, - initialFuelTypes: biz.fuelTypes, - initialHours: biz.operatingHours, - initialPhone: biz.phoneNumber, - initialFrequency: biz.radioFrequency, - ), - ); - if (result == null || !mounted) return; - try { - final sync = await commitWithOfflineFallback( - _repo.updateDetails( - biz.id, - services: result.services, - fuelTypes: result.fuelTypes, - operatingHours: result.operatingHours, - phoneNumber: result.phoneNumber, - radioFrequency: result.radioFrequency, - ), - ); - if (mounted) { - _showWriteResultToast(context, sync, "Details updated. Thank you!"); - } - } catch (e) { - if (mounted) { - Toast.showToast(context, "Could not update: $e", - const Icon(Icons.error, color: Colors.red), 3); - } - } - } - - Future _setFuelPrices(AirportBusiness biz) async { - final result = await showModalBottomSheet>( - context: context, - isScrollControlled: true, - builder: (_) => _FuelPriceSheet(business: biz), - ); - if (result == null || !mounted) return; - try { - final sync = await commitWithOfflineFallback( - _repo.setFuelPrices( - biz.id, - prices: result, - previous: biz.fuelPrices, - ), - ); - if (mounted) { - _showWriteResultToast(context, sync, "Fuel prices updated. Thank you!"); - } - } catch (e) { - if (mounted) { - Toast.showToast(context, "Could not update prices: $e", - const Icon(Icons.error, color: Colors.red), 3); - } - } - } - - Future _addReview(AirportBusiness biz) async { - final result = await showModalBottomSheet<_ReviewInput>( - context: context, - isScrollControlled: true, - builder: (_) => const _ReviewSheet(), - ); - if (result == null || !mounted) return; - try { - final sync = await commitWithOfflineFallback( - _repo.addReview(biz.id, rating: result.rating, text: result.text), - ); - // Refresh the collapsed-summary rating after a successful post (offline - // this stays unavailable since aggregates need the server). - await _loadStats(); - if (mounted) { - _showWriteResultToast(context, sync, "Review posted. Thank you!"); - } - } catch (e) { - if (mounted) { - final msg = e is StateError ? e.message : e.toString(); - Toast.showToast(context, "Could not post review: $msg", - const Icon(Icons.error, color: Colors.red), 3); - } - } - } - - @override - Widget build(BuildContext context) { - final biz = widget.business; - final scheme = Theme.of(context).colorScheme; - final stats = _stats; - - final summaryBits = []; - if (biz.fuelTypes.isNotEmpty) { - summaryBits.add("Fuel: ${biz.fuelTypes.join(", ")}"); - } - if (biz.services.isNotEmpty) { - summaryBits.add("${biz.services.length} service(s)"); - } - - return Card( - child: Theme( - // Remove ExpansionTile's default divider lines for a cleaner card. - data: Theme.of(context).copyWith(dividerColor: Colors.transparent), - child: ExpansionTile( - leading: Icon(Icons.business, color: scheme.primary), - title: Row( - children: [ - Flexible( - child: Text(biz.name, - style: const TextStyle(fontWeight: FontWeight.w600), - overflow: TextOverflow.ellipsis), - ), - if (biz.hasUserActivity) ...[ - const SizedBox(width: 6), - Tooltip( - message: "Created, updated or reviewed by a pilot", - child: Icon(Icons.verified_user, - size: 15, color: scheme.primary), - ), - ], - ], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (summaryBits.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text(summaryBits.join(" • "), - style: const TextStyle(fontSize: 12)), - ), - Padding( - padding: const EdgeInsets.only(top: 4), - child: Row( - children: [ - StarRating(rating: stats?.averageRating ?? 0, size: 15), - const SizedBox(width: 6), - Text( - stats == null - ? "…" - : (!stats.available - ? "Ratings offline" - : (stats.hasReviews - ? "${stats.averageRating.toStringAsFixed(1)} (${stats.reviewCount})" - : "No reviews")), - style: TextStyle(fontSize: 12, color: scheme.outline), - ), - ], - ), - ), - ], - ), - childrenPadding: const EdgeInsets.fromLTRB(12, 0, 12, 12), - expandedCrossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("${biz.airport} • added by ${biz.createdByName}", - style: TextStyle(fontSize: 12, color: scheme.outline)), - if (biz.lastEditedByName != null && - biz.lastEditedByName!.isNotEmpty) - Text("Details last updated by ${biz.lastEditedByName}", - style: TextStyle(fontSize: 12, color: scheme.outline)), - const SizedBox(height: 8), - _detailCard(biz), - Wrap( - spacing: 4, - children: [ - TextButton.icon( - onPressed: () => _editDetails(biz), - icon: const Icon(Icons.edit, size: 18), - label: const Text("Edit details"), - ), - TextButton.icon( - onPressed: () => _setFuelPrices(biz), - icon: const Icon(Icons.local_gas_station, size: 18), - label: const Text("Set fuel prices"), - ), - ], - ), - const Divider(), - _reviews(biz), - ], - ), - ), - ); - } - - Widget _detailCard(AirportBusiness biz) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _detailRow(Icons.local_gas_station, "Fuel", - biz.fuelTypes.isEmpty ? null : biz.fuelTypes.join(", ")), - _fuelPricesRow(biz), - _detailRow(Icons.build, "Services", - biz.services.isEmpty ? null : biz.services.join(", ")), - _detailRow(Icons.schedule, "Operating hours", - biz.operatingHours.isEmpty ? null : biz.operatingHours), - _detailRow(Icons.phone, "Phone", - biz.phoneNumber.isEmpty ? null : biz.phoneNumber), - _detailRow(Icons.radio, "Radio frequency", - biz.radioFrequency.isEmpty ? null : biz.radioFrequency), - ], - ); - } - - Widget _fuelPricesRow(AirportBusiness biz) { - final scheme = Theme.of(context).colorScheme; - final entries = biz.fuelPrices.entries.toList() - ..sort((a, b) => a.key.toLowerCase().compareTo(b.key.toLowerCase())); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 5), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.attach_money, size: 20, color: scheme.primary), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text("Fuel prices", - style: TextStyle( - fontWeight: FontWeight.w600, fontSize: 13)), - const SizedBox(height: 2), - if (entries.isEmpty) - Text( - "Not provided — use “Set fuel prices” to add", - style: TextStyle( - fontSize: 13, - fontStyle: FontStyle.italic, - color: scheme.outline), - ) - else - for (final e in entries) - Padding( - padding: const EdgeInsets.only(bottom: 2), - child: RichText( - text: TextSpan( - style: DefaultTextStyle.of(context).style, - children: [ - TextSpan( - text: "${e.key}: ", - style: const TextStyle(fontSize: 13)), - TextSpan( - text: - "\$${e.value.price.toStringAsFixed(2)}", - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w600)), - TextSpan( - text: e.value.updatedAt == null - ? " (set just now)" - : " (set ${_formatDate(e.value.updatedAt!)})", - style: TextStyle( - fontSize: 11, color: scheme.outline), - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _detailRow(IconData icon, String label, String? value) { - final scheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.symmetric(vertical: 5), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 20, color: scheme.primary), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, - style: const TextStyle( - fontWeight: FontWeight.w600, fontSize: 13)), - const SizedBox(height: 2), - Text( - value ?? "Not provided — use “Edit details” to add", - style: TextStyle( - fontSize: 13, - fontStyle: value == null ? FontStyle.italic : null, - color: value == null ? scheme.outline : null, - ), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _reviews(AirportBusiness biz) { - final scheme = Theme.of(context).colorScheme; - return StreamBuilder>( - stream: _repo.watchReviews(biz.id), - builder: (context, snap) { - final reviews = snap.data ?? const []; - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text("Reviews", - style: - TextStyle(fontWeight: FontWeight.bold, fontSize: 15)), - const SizedBox(width: 6), - Text("(${reviews.length})", - style: TextStyle(color: scheme.outline)), - const Spacer(), - TextButton.icon( - onPressed: () => _addReview(biz), - icon: const Icon(Icons.rate_review, size: 18), - label: const Text("Add"), - ), - ], - ), - if (snap.connectionState == ConnectionState.waiting && - reviews.isEmpty) - const Padding( - padding: EdgeInsets.symmetric(vertical: 12), - child: Center(child: CircularProgressIndicator()), - ) - else if (reviews.isEmpty) - Padding( - padding: const EdgeInsets.symmetric(vertical: 12), - child: Text("No reviews yet. Be the first!", - style: TextStyle(color: scheme.outline)), - ) - else - for (final r in reviews) _reviewCard(r), - ], - ); - }, - ); - } - - Widget _reviewCard(BusinessReview r) { - final scheme = Theme.of(context).colorScheme; - return Card( - margin: const EdgeInsets.symmetric(vertical: 4), - child: Padding( - padding: const EdgeInsets.all(10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - StarRating(rating: r.rating.toDouble(), size: 16), - const Spacer(), - Text(_formatDate(r.createdAt), - style: TextStyle(fontSize: 11, color: scheme.outline)), - ], - ), - if (r.text.isNotEmpty) ...[ - const SizedBox(height: 6), - Text(r.text), - ], - const SizedBox(height: 6), - Row( - children: [ - Icon(Icons.person, size: 14, color: scheme.outline), - const SizedBox(width: 4), - Text(r.authorName, - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: scheme.outline)), - ], - ), - ], - ), - ), - ); - } - - String _formatDate(DateTime d) { - final l = d.toLocal(); - String two(int v) => v.toString().padLeft(2, '0'); - return "${l.year}-${two(l.month)}-${two(l.day)}"; - } -} - -class _EmptyView extends StatelessWidget { - final String airport; - final bool offline; - const _EmptyView({required this.airport, this.offline = false}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.all(24), - child: Column( - children: [ - Icon(offline ? Icons.cloud_off : Icons.storefront_outlined, - size: 48, color: scheme.outline), - const SizedBox(height: 12), - Text( - offline - ? "You're offline. $airport's businesses aren't saved on " - "this device yet — reconnect to load them." - : "No businesses listed for $airport yet.", - textAlign: TextAlign.center, - style: TextStyle(color: scheme.outline)), - if (!offline) ...[ - const SizedBox(height: 4), - Text("Tap “Add” to be the first to contribute.", - textAlign: TextAlign.center, - style: TextStyle(color: scheme.outline, fontSize: 12)), - ], - ], - ), - ); - } -} - -/// Shown above the list when the businesses came only from the local cache -/// (i.e. the device is offline). -class _OfflineBanner extends StatelessWidget { - const _OfflineBanner(); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Card( - color: scheme.secondaryContainer, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - children: [ - Icon(Icons.cloud_off, size: 20, color: scheme.onSecondaryContainer), - const SizedBox(width: 10), - Expanded( - child: Text( - "You're offline. Showing saved data — ratings and airports you " - "haven't opened before may be missing, and your changes will " - "upload when you reconnect.", - style: TextStyle( - fontSize: 12, color: scheme.onSecondaryContainer), - ), - ), - ], - ), - ), - ); - } -} - -class _ErrorView extends StatelessWidget { - final String error; - const _ErrorView({required this.error}); - - bool get _isPermissionDenied => - error.toLowerCase().contains("permission-denied") || - error.toLowerCase().contains("permission denied"); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.cloud_off, size: 48, color: scheme.error), - const SizedBox(height: 12), - Text( - _isPermissionDenied - ? "You need to be signed in to view airport businesses." - : "Could not load businesses.\n$error", - textAlign: TextAlign.center, - style: TextStyle(color: scheme.outline), - ), - ], - ), - ), - ); - } -} - -/// Bottom sheet for setting a price per fuel type. All standard fuel types -/// (plus any the business already lists or has a price for) are offered. -/// Returns the fuel-type -> price map to keep, or null when cancelled. -class _FuelPriceSheet extends StatefulWidget { - final AirportBusiness business; - const _FuelPriceSheet({required this.business}); - - @override - State<_FuelPriceSheet> createState() => _FuelPriceSheetState(); -} - -class _FuelPriceSheetState extends State<_FuelPriceSheet> { - late final List _types; - late final Map _controllers; - - @override - void initState() { - super.initState(); - // Offer every standard fuel type plus anything this listing already has. - final seen = {}; - _types = []; - for (final t in [ - ...AirportBusiness.fuelOptions, - ...widget.business.fuelTypes, - ...widget.business.fuelPrices.keys, - ]) { - final v = t.trim(); - if (v.isNotEmpty && seen.add(v.toLowerCase())) _types.add(v); - } - _controllers = { - for (final t in _types) - t: TextEditingController( - text: widget.business.fuelPrices[t] != null - ? widget.business.fuelPrices[t]!.price.toStringAsFixed(2) - : "", - ), - }; - } - - @override - void dispose() { - for (final c in _controllers.values) { - c.dispose(); - } - super.dispose(); - } - - String _formatDate(DateTime d) { - final l = d.toLocal(); - String two(int v) => v.toString().padLeft(2, '0'); - return "${l.year}-${two(l.month)}-${two(l.day)}"; - } - - void _submit() { - final out = {}; - _controllers.forEach((type, ctrl) { - final raw = ctrl.text.trim(); - if (raw.isEmpty) return; - final v = double.tryParse(raw); - if (v != null && v >= 0 && v <= 100) { - out[type] = double.parse(v.toStringAsFixed(2)); - } - }); - Navigator.pop(context, out); - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return Padding( - padding: EdgeInsets.only(bottom: bottomInset), - child: DraggableScrollableSheet( - expand: false, - initialChildSize: 0.7, - minChildSize: 0.4, - maxChildSize: 0.95, - builder: (context, scrollController) { - return ListView( - controller: scrollController, - padding: const EdgeInsets.all(16), - children: [ - Row( - children: [ - Expanded( - child: Text("Set fuel prices", - style: Theme.of(context).textTheme.titleLarge), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - Text( - "Enter a price per gallon for any fuel type. Leave blank to " - "clear. Each price shows the date it was last set.", - style: TextStyle(fontSize: 12, color: scheme.outline), - ), - const SizedBox(height: 12), - for (final type in _types) _priceField(type, scheme), - const SizedBox(height: 8), - FilledButton.icon( - onPressed: _submit, - icon: const Icon(Icons.save), - label: const Text("Save prices"), - ), - const SizedBox(height: 12), - ], - ); - }, - ), - ); - } - - Widget _priceField(String type, ColorScheme scheme) { - final existing = widget.business.fuelPrices[type]; - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(type, - style: const TextStyle( - fontWeight: FontWeight.w600, fontSize: 14)), - if (existing?.updatedAt != null) - Text("Last set ${_formatDate(existing!.updatedAt!)}", - style: TextStyle(fontSize: 11, color: scheme.outline)), - ], - ), - ), - SizedBox( - width: 120, - child: TextField( - controller: _controllers[type], - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration( - prefixText: "\$", - hintText: "0.00", - isDense: true, - border: OutlineInputBorder(), - ), - ), - ), - ], - ), - ); - } -} - -class _ReviewInput { - final int rating; - final String text; - const _ReviewInput(this.rating, this.text); -} - -class _ReviewSheet extends StatefulWidget { - const _ReviewSheet(); - - @override - State<_ReviewSheet> createState() => _ReviewSheetState(); -} - -class _ReviewSheetState extends State<_ReviewSheet> { - int _rating = 0; - final _textCtrl = TextEditingController(); - - @override - void dispose() { - _textCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return Padding( - padding: - EdgeInsets.only(bottom: bottomInset, left: 16, right: 16, top: 16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text("Write a review", - style: Theme.of(context).textTheme.titleLarge), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 4), - Center( - child: StarRatingInput( - value: _rating, - onChanged: (v) => setState(() => _rating = v), - ), - ), - const SizedBox(height: 8), - TextField( - controller: _textCtrl, - maxLength: BusinessReview.maxTextLength, - maxLines: 5, - minLines: 2, - decoration: const InputDecoration( - labelText: "Your review (optional)", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - FilledButton.icon( - onPressed: _rating == 0 - ? null - : () => Navigator.pop( - context, _ReviewInput(_rating, _textCtrl.text.trim())), - icon: const Icon(Icons.send), - label: const Text("Post Review"), - ), - const SizedBox(height: 12), - ], - ), - ); - } -} diff --git a/lib/business/data/airport_business_repository.dart b/lib/business/data/airport_business_repository.dart deleted file mode 100644 index ba2deffb..00000000 --- a/lib/business/data/airport_business_repository.dart +++ /dev/null @@ -1,344 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:latlong2/latlong.dart'; - -import '../../community/data/community_repository.dart'; -import '../models/airport_business.dart'; -import '../models/business_review.dart'; - -/// All Firestore interaction for the crowd-sourced Airport Businesses -/// feature is funneled through this repository so screens never touch -/// Firestore directly. -/// -/// Collection layout: -/// airportBusinesses/{bizId} -> AirportBusiness -/// airportBusinesses/{bizId}/reviews/{rid} -> BusinessReview -/// -/// Contributor identity (createdByName / authorName) is bound to the -/// pilot's Community profile display name, both here and in the security -/// rules, so no listing or review can be posted anonymously or under a -/// spoofed name. Entries and reviews can be created but never deleted. -class AirportBusinessRepository { - AirportBusinessRepository._(); - static final AirportBusinessRepository instance = - AirportBusinessRepository._(); - - FirebaseFirestore get _db => FirebaseFirestore.instance; - - String? get _uid => FirebaseAuth.instance.currentUser?.uid; - - String _requireUid() { - final uid = _uid; - if (uid == null) { - throw StateError("Not signed in"); - } - return uid; - } - - CollectionReference> get _col => - _db.collection("airportBusinesses"); - - DocumentReference> _bizRef(String bizId) => - _col.doc(bizId); - - CollectionReference> _reviewsCol(String bizId) => - _bizRef(bizId).collection("reviews"); - - /// Ensure the caller has a Community profile (so their display name is - /// available to tag contributions with) and return that display name. - Future _requireDisplayName() async { - final profile = await CommunityRepository.instance.ensureMyProfile(); - return profile.displayName; - } - - // -------------------- Businesses -------------------- - - /// Live list of businesses at [airport]. Listings that pilots have created - /// or modified are floated to the top; the rest are ordered nearest-first - /// from [origin] (the airport's coordinate) when supplied, else - /// alphabetically. Requires the composite index - /// (airport ASC, nameLower ASC) in firestore.indexes.json. - Stream watchBusinesses(String airport, - {LatLng? origin, int limit = 200}) { - final id = airport.trim().toUpperCase(); - if (id.isEmpty) { - return Stream.value( - const BusinessListSnapshot([], isFromCache: false)); - } - return _col - .where("airport", isEqualTo: id) - .orderBy("nameLower") - .limit(limit) - .snapshots() - .map((s) { - final list = s.docs.map(AirportBusiness.fromDoc).toList(); - list.sort(AirportBusiness.byDistanceInteractedFirst(origin)); - return BusinessListSnapshot(list, isFromCache: s.metadata.isFromCache); - }); - } - - /// One-shot fetch of the businesses at [airport] that carry a map - /// coordinate. Used by the plate diagram to draw business markers. Returns - /// an empty list on any failure (not signed in, offline, missing index), - /// so callers can treat it as a best-effort overlay. - Future> fetchBusinessesWithLocation(String airport, - {LatLng? origin, int limit = 200}) async { - final id = airport.trim().toUpperCase(); - if (id.isEmpty) return const []; - try { - final snap = await _col - .where("airport", isEqualTo: id) - .orderBy("nameLower") - .limit(limit) - .get(); - final list = snap.docs - .map(AirportBusiness.fromDoc) - .where((b) => b.hasLocation) - .toList(); - // Same ordering as the long-press list (interacted first, then - // nearest-first from the airport) so the plate selector matches. - list.sort(AirportBusiness.byDistanceInteractedFirst(origin)); - return list; - } catch (_) { - return const []; - } - } - - Stream watchBusiness(String bizId) { - return _bizRef(bizId) - .snapshots() - .map((s) => s.exists ? AirportBusiness.fromDoc(s) : null); - } - - /// Review aggregates for a business, computed server-side via an - /// aggregation query over the immutable reviews subcollection. There are - /// no stored counters to tamper with, so the result always reflects real - /// review documents. - Future fetchStats(String bizId) async { - try { - final snap = await _reviewsCol(bizId) - .aggregate(count(), average("rating")) - .get(); - final c = snap.count ?? 0; - if (c == 0) return BusinessStats.empty; - final avg = snap.getAverage("rating") ?? 0; - return BusinessStats(reviewCount: c, averageRating: avg.toDouble()); - } catch (_) { - // Aggregation queries are server-only; offline (or on any error) we - // can't compute them. Signal "unavailable" so the UI doesn't show a - // misleading "no reviews". - return BusinessStats.unavailable; - } - } - - /// Add a new business listing for [airport]. The current user becomes the - /// creator of record. - Future addBusiness({ - required String airport, - required String name, - List services = const [], - List fuelTypes = const [], - String operatingHours = "", - String phoneNumber = "", - String radioFrequency = "", - }) async { - final uid = _requireUid(); - final displayName = await _requireDisplayName(); - - final trimmedName = name.trim(); - if (trimmedName.isEmpty) { - throw StateError("Business name is required"); - } - if (trimmedName.length > AirportBusiness.maxNameLength) { - throw StateError("Business name is too long"); - } - - final ref = _col.doc(); - final biz = AirportBusiness( - id: ref.id, - airport: airport.trim().toUpperCase(), - name: trimmedName, - services: _sanitize(services, AirportBusiness.maxServices), - fuelTypes: _sanitize(fuelTypes, AirportBusiness.maxFuelTypes), - operatingHours: _clampHours(operatingHours), - phoneNumber: _clampPhone(phoneNumber), - radioFrequency: _clampFrequency(radioFrequency), - createdByUid: uid, - createdByName: displayName, - source: "user", - createdAt: DateTime.now(), - ); - final data = biz.toCreateMap(); - // Server-pin the creation time; the rules require createdAt == request.time - // so a client-chosen timestamp would be rejected. - data["createdAt"] = FieldValue.serverTimestamp(); - await ref.set(data); - return ref.id; - } - - /// Enrich an existing listing with services / fuel / hours. Any signed-in - /// contributor may add detail (the data is crowd-sourced); the listing's - /// identity, creator and review aggregates are immutable. Every edit is - /// attributed to the editor so detail changes are never anonymous. - Future updateDetails( - String bizId, { - required List services, - required List fuelTypes, - required String operatingHours, - required String phoneNumber, - required String radioFrequency, - }) async { - final uid = _requireUid(); - final displayName = await _requireDisplayName(); - await _bizRef(bizId).update({ - "services": _sanitize(services, AirportBusiness.maxServices), - "fuelTypes": _sanitize(fuelTypes, AirportBusiness.maxFuelTypes), - "operatingHours": _clampHours(operatingHours), - "phoneNumber": _clampPhone(phoneNumber), - "radioFrequency": _clampFrequency(radioFrequency), - "lastEditedByUid": uid, - "lastEditedByName": displayName, - "updatedAt": FieldValue.serverTimestamp(), - }); - } - - /// Set the per-fuel-type prices for a listing. [prices] is the desired - /// fuel-type -> price map (types omitted are removed). Prices that are - /// unchanged from [previous] keep their original "last set" date; new or - /// changed prices are stamped with the server time. Attributed like any - /// other detail edit. - Future setFuelPrices( - String bizId, { - required Map prices, - required Map previous, - }) async { - final uid = _requireUid(); - final displayName = await _requireDisplayName(); - - final Map fuelPrices = {}; - var count = 0; - prices.forEach((type, price) { - final t = type.trim(); - if (t.isEmpty || count >= AirportBusiness.maxFuelPrices) return; - if (!price.isFinite || price < 0 || price > 100) return; - final rounded = double.parse(price.toStringAsFixed(2)); - final prev = previous[t]; - fuelPrices[t] = { - "price": rounded, - // Keep the old date when the price hasn't changed; otherwise pin to - // the server clock so the "last set" date is trustworthy. - "updatedAt": (prev != null && prev.price == rounded) - ? Timestamp.fromDate(prev.updatedAt ?? DateTime.now()) - : FieldValue.serverTimestamp(), - }; - count++; - }); - - await _bizRef(bizId).update({ - "fuelPrices": fuelPrices, - "lastEditedByUid": uid, - "lastEditedByName": displayName, - "updatedAt": FieldValue.serverTimestamp(), - }); - } - - // -------------------- Reviews -------------------- - - Stream> watchReviews(String bizId, {int limit = 200}) { - return _reviewsCol(bizId) - .orderBy("createdAt", descending: true) - .limit(limit) - .snapshots() - .map((s) => - s.docs.map(BusinessReview.fromDoc).toList(growable: false)); - } - - /// Post a review. Creates the immutable review document only. Review - /// aggregates are not stored on the listing; the average and count are - /// computed on demand from the reviews subcollection via [fetchStats], so - /// the displayed rating always reflects real, attributable reviews and - /// cannot be forged from the client. - Future addReview( - String bizId, { - required int rating, - required String text, - }) async { - final uid = _requireUid(); - final displayName = await _requireDisplayName(); - - if (rating < BusinessReview.minRating || rating > BusinessReview.maxRating) { - throw StateError("Rating must be between 1 and 5"); - } - final trimmed = text.trim(); - if (trimmed.length > BusinessReview.maxTextLength) { - throw StateError("Review is too long"); - } - - // One review per pilot per business: use the uid as the review id so a - // repeat review collides with the existing (immutable) one. Enforced by - // the security rules too; the pre-check just yields a friendlier error. - final reviewRef = _reviewsCol(bizId).doc(uid); - final existing = await reviewRef.get(); - if (existing.exists) { - throw StateError("You have already reviewed this business."); - } - final review = BusinessReview( - id: reviewRef.id, - rating: rating, - text: trimmed, - authorUid: uid, - authorName: displayName, - createdAt: DateTime.now(), - ); - final data = review.toCreateMap(); - // Server-pin creation time; rules require createdAt == request.time. - data["createdAt"] = FieldValue.serverTimestamp(); - - // Create the review and stamp the listing's lastReviewedAt in one atomic - // batch so reviewed listings float to the top of the lists. The bump is a - // single-field, server-time-pinned write permitted by the rules. - final batch = _db.batch(); - batch.set(reviewRef, data); - batch.update(_bizRef(bizId), { - "lastReviewedAt": FieldValue.serverTimestamp(), - }); - await batch.commit(); - } - - // -------------------- Helpers -------------------- - - List _sanitize(List values, int max) { - final seen = {}; - final out = []; - for (final v in values) { - final t = v.trim(); - if (t.isEmpty || t.length > 60) continue; - if (seen.add(t.toLowerCase())) { - out.add(t); - } - if (out.length >= max) break; - } - return out; - } - - String _clampHours(String hours) { - final t = hours.trim(); - return t.length > AirportBusiness.maxHoursLength - ? t.substring(0, AirportBusiness.maxHoursLength) - : t; - } - - String _clampPhone(String phone) { - final t = phone.trim(); - return t.length > AirportBusiness.maxPhoneLength - ? t.substring(0, AirportBusiness.maxPhoneLength) - : t; - } - - String _clampFrequency(String freq) { - final t = freq.trim(); - return t.length > AirportBusiness.maxFrequencyLength - ? t.substring(0, AirportBusiness.maxFrequencyLength) - : t; - } -} diff --git a/lib/business/models/airport_business.dart b/lib/business/models/airport_business.dart deleted file mode 100644 index 6733869c..00000000 --- a/lib/business/models/airport_business.dart +++ /dev/null @@ -1,329 +0,0 @@ -import 'dart:math'; - -import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:latlong2/latlong.dart'; - -/// A crowd-sourced business/FBO listing tied to an airport. -/// -/// Business *names* are seeded by an offline import script; every other -/// column (services, fuel, hours, reviews) is filled in by signed-in users -/// from inside the app. Entries can never be deleted, and each carries the -/// contributor's name so nothing is left anonymously. -/// -/// Firestore layout: -/// airportBusinesses/{bizId} -> AirportBusiness -/// airportBusinesses/{bizId}/reviews/{rid} -> BusinessReview -class AirportBusiness { - /// Common fuel types offered to select from when contributing. - static const List fuelOptions = [ - "100LL", - "Jet A", - "Jet A+", - "MOGAS", - "Sustainable Aviation Fuel", - "UL94", - ]; - - /// Common service categories offered to select from when contributing. - static const List serviceOptions = [ - "Fuel", - "Self-Service Fuel", - "Flight Training", - "Aircraft Maintenance", - "Avionics", - "Aircraft Rental", - "Charter", - "Hangar / Tie-Down", - "Courtesy Car", - "Car Rental", - "Catering", - "Oxygen / Nitrogen", - "De-Ice", - "Pilot Lounge", - "WiFi", - ]; - - static const int maxServices = 30; - static const int maxFuelTypes = 10; - static const int maxFuelPrices = 12; - static const int maxHoursLength = 200; - static const int maxNameLength = 120; - static const int maxPhoneLength = 30; - static const int maxFrequencyLength = 30; - - final String id; - final String airport; // uppercase LocationID / FAA id, e.g. "KBED" - final String name; - final List services; - final List fuelTypes; - // Crowd-sourced price per fuel type (e.g. "100LL" -> 6.49), each carrying - // the date it was last set. Empty until a pilot enters prices. - final Map fuelPrices; - final String operatingHours; - final String phoneNumber; - final String radioFrequency; - final String createdByUid; - final String createdByName; - final String source; // "user" (added in-app) or an import source - final DateTime createdAt; - // Attribution for the most recent detail edit (null until first edited). - final String? lastEditedByName; - final DateTime? updatedAt; - // Server time of the most recent review posted against this listing (null - // until first reviewed). Stamped on the listing when a review is created so - // reviewed listings can be floated to the top without counting the reviews - // subcollection on read. - final DateTime? lastReviewedAt; - // Physical location on the field (null for user-added listings that were - // created without a coordinate; seeded listings carry it). - final double? latitude; - final double? longitude; - - const AirportBusiness({ - required this.id, - required this.airport, - required this.name, - this.services = const [], - this.fuelTypes = const [], - this.fuelPrices = const {}, - this.operatingHours = "", - this.phoneNumber = "", - this.radioFrequency = "", - required this.createdByUid, - required this.createdByName, - this.source = "user", - required this.createdAt, - this.lastEditedByName, - this.updatedAt, - this.lastReviewedAt, - this.latitude, - this.longitude, - }); - - /// Whether this listing has a usable map coordinate. - bool get hasLocation => latitude != null && longitude != null; - - /// The listing's coordinate. Only valid when [hasLocation] is true. - LatLng get coordinate => LatLng(latitude!, longitude!); - - /// Whether a real pilot has created, modified or reviewed this listing, as - /// opposed to an untouched seeded/import entry. Derived entirely from - /// persisted Firestore fields: `source` ("user" when created in-app), - /// `updatedAt` (set on a detail edit) and `lastReviewedAt` (set when a - /// review is posted), so the signal is shared across all users, not local - /// device state. - bool get hasUserActivity => - source == "user" || updatedAt != null || lastReviewedAt != null; - - /// Timestamp of the most recent user interaction (latest of: detail edit, - /// review posted, or creation time for user-created listings), or null for - /// untouched seeded listings. Used to float interacted listings to the top. - DateTime? get userActivityAt { - DateTime? latest; - void consider(DateTime? d) { - if (d != null && (latest == null || d.isAfter(latest!))) latest = d; - } - - consider(updatedAt); - consider(lastReviewedAt); - if (source == "user") consider(createdAt); - return latest; - } - - /// Ordering used by the businesses lists: listings a pilot has created or - /// modified come first (most recently touched first); everything else falls - /// back to alphabetical by name. - static int compareInteractedFirst(AirportBusiness a, AirportBusiness b) { - if (a.hasUserActivity != b.hasUserActivity) { - return a.hasUserActivity ? -1 : 1; - } - if (a.hasUserActivity && b.hasUserActivity) { - final c = b.userActivityAt!.compareTo(a.userActivityAt!); - if (c != 0) return c; - } - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); - } - - /// Squared planar distance from [origin] to this listing (cheap ordering - /// metric; no need for a real great-circle distance just to sort). Uses the - /// same equirectangular approximation the old offline FBO list used. Null - /// when this listing has no coordinate. - double? distanceSqTo(LatLng origin) { - if (!hasLocation) return null; - final corr = pow(cos(origin.latitude * pi / 180.0), 2).toDouble(); - final dLon = longitude! - origin.longitude; - final dLat = latitude! - origin.latitude; - return dLon * dLon * corr + dLat * dLat; - } - - /// Ordering used by the businesses lists once an airport [origin] is known: - /// pilot-interacted listings still come first (most recently touched first), - /// then everything else is ordered nearest-first from the airport (matching - /// the old offline FBO list, where distance ordering kept far/noisy Google - /// Places results out of the way). Listings without a coordinate sort last, - /// then alphabetically. Falls back to [compareInteractedFirst] when [origin] - /// is null. - static Comparator byDistanceInteractedFirst( - LatLng? origin) { - if (origin == null) return compareInteractedFirst; - return (a, b) { - if (a.hasUserActivity != b.hasUserActivity) { - return a.hasUserActivity ? -1 : 1; - } - if (a.hasUserActivity && b.hasUserActivity) { - final c = b.userActivityAt!.compareTo(a.userActivityAt!); - if (c != 0) return c; - } - final da = a.distanceSqTo(origin); - final db = b.distanceSqTo(origin); - if (da != null && db != null) { - final c = da.compareTo(db); - if (c != 0) return c; - } else if (da != null) { - return -1; // a has a location, b doesn't -> a first - } else if (db != null) { - return 1; - } - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); - }; - } - - Map toCreateMap() { - final map = { - "airport": airport.toUpperCase(), - "name": name.trim(), - "nameLower": name.trim().toLowerCase(), - "services": services, - "fuelTypes": fuelTypes, - "operatingHours": operatingHours.trim(), - "phoneNumber": phoneNumber.trim(), - "radioFrequency": radioFrequency.trim(), - "createdByUid": createdByUid, - "createdByName": createdByName, - "source": source, - "createdAt": Timestamp.fromDate(createdAt), - }; - // Only include location when present, so user-added listings without a - // coordinate don't write null fields (and stay within the rules). - if (latitude != null && longitude != null) { - map["latitude"] = latitude; - map["longitude"] = longitude; - } - return map; - } - - factory AirportBusiness.fromDoc(DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final created = data["createdAt"]; - final updated = data["updatedAt"]; - return AirportBusiness( - id: doc.id, - airport: (data["airport"] as String?) ?? "", - name: (data["name"] as String?) ?? "", - services: List.from((data["services"] as List?) ?? const []), - fuelTypes: List.from((data["fuelTypes"] as List?) ?? const []), - fuelPrices: _readFuelPrices(data["fuelPrices"]), - operatingHours: (data["operatingHours"] as String?) ?? "", - phoneNumber: (data["phoneNumber"] as String?) ?? "", - radioFrequency: (data["radioFrequency"] as String?) ?? "", - createdByUid: (data["createdByUid"] as String?) ?? "", - createdByName: (data["createdByName"] as String?) ?? "Unknown", - source: (data["source"] as String?) ?? "user", - createdAt: created is Timestamp ? created.toDate() : DateTime.now(), - lastEditedByName: data["lastEditedByName"] as String?, - updatedAt: updated is Timestamp ? updated.toDate() : null, - lastReviewedAt: - data["lastReviewedAt"] is Timestamp ? (data["lastReviewedAt"] as Timestamp).toDate() : null, - latitude: _readDouble(data["latitude"]), - longitude: _readDouble(data["longitude"]), - ); - } - - static double? _readDouble(Object? v) { - if (v is num) return v.toDouble(); - if (v is String) return double.tryParse(v); - return null; - } - - static Map _readFuelPrices(Object? v) { - if (v is! Map) return const {}; - final out = {}; - v.forEach((key, val) { - if (key is String && val is Map) { - final price = _readDouble(val["price"]); - if (price != null) { - final ts = val["updatedAt"]; - out[key] = FuelPrice( - price: price, - updatedAt: ts is Timestamp ? ts.toDate() : null, - ); - } - } - }); - return out; - } -} - -/// A single fuel price plus the date it was last set. -class FuelPrice { - final double price; - // Null only briefly while a just-written server timestamp resolves. - final DateTime? updatedAt; - - const FuelPrice({required this.price, this.updatedAt}); -} - -/// Review aggregates for a business, computed on demand from the reviews -/// subcollection (Firestore aggregation query) rather than stored on the -/// listing document. Keeping this off the document means the average rating -/// cannot be forged -- it always reflects real review documents. -class BusinessStats { - final int reviewCount; - final double averageRating; // 0..5, 0 when there are no reviews - - /// False when the stats couldn't be computed (aggregation queries are - /// server-only, so this is what you get offline). The UI shows a - /// "ratings unavailable" hint rather than a misleading "no reviews". - final bool available; - - const BusinessStats({ - required this.reviewCount, - required this.averageRating, - this.available = true, - }); - - static const BusinessStats empty = - BusinessStats(reviewCount: 0, averageRating: 0); - - /// Stats could not be loaded (e.g. offline — review aggregates require the - /// server and are never served from the local cache). - static const BusinessStats unavailable = - BusinessStats(reviewCount: 0, averageRating: 0, available: false); - - bool get hasReviews => reviewCount > 0; - - /// Build from an in-memory list of ratings (used where reviews are already - /// loaded, e.g. the detail screen's live stream). - factory BusinessStats.fromRatings(Iterable ratings) { - var count = 0; - var sum = 0; - for (final r in ratings) { - count++; - sum += r; - } - return BusinessStats( - reviewCount: count, - averageRating: count == 0 ? 0 : sum / count, - ); - } -} - -/// A page of businesses plus whether it came only from the local cache -/// (Firestore's `isFromCache`), which is a reliable "you're offline" signal -/// for the live list. Used to show an offline banner. -class BusinessListSnapshot { - final List items; - final bool isFromCache; - - const BusinessListSnapshot(this.items, {required this.isFromCache}); -} diff --git a/lib/business/models/business_review.dart b/lib/business/models/business_review.dart deleted file mode 100644 index 9cb59c8e..00000000 --- a/lib/business/models/business_review.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -/// A single review left against an [AirportBusiness]. -/// -/// Reviews are immutable and cannot be deleted; each is tagged with the -/// author's name (bound server-side to their profile display name) so no -/// review is anonymous. -class BusinessReview { - static const int maxTextLength = 1000; - static const int minRating = 1; - static const int maxRating = 5; - - final String id; - final int rating; // 1..5 - final String text; - final String authorUid; - final String authorName; - final DateTime createdAt; - - const BusinessReview({ - required this.id, - required this.rating, - required this.text, - required this.authorUid, - required this.authorName, - required this.createdAt, - }); - - Map toCreateMap() => { - "rating": rating, - "text": text.trim(), - "authorUid": authorUid, - "authorName": authorName, - "createdAt": Timestamp.fromDate(createdAt), - }; - - factory BusinessReview.fromDoc(DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final created = data["createdAt"]; - return BusinessReview( - id: doc.id, - rating: (data["rating"] as num?)?.toInt() ?? 0, - text: (data["text"] as String?) ?? "", - authorUid: (data["authorUid"] as String?) ?? "", - authorName: (data["authorName"] as String?) ?? "Unknown", - createdAt: created is Timestamp ? created.toDate() : DateTime.now(), - ); - } -} diff --git a/lib/business/widgets/business_form.dart b/lib/business/widgets/business_form.dart deleted file mode 100644 index 6caaeab1..00000000 --- a/lib/business/widgets/business_form.dart +++ /dev/null @@ -1,263 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../models/airport_business.dart'; - -/// Result returned by [BusinessFormSheet]. -class BusinessFormResult { - final String name; - final List services; - final List fuelTypes; - final String operatingHours; - final String phoneNumber; - final String radioFrequency; - - const BusinessFormResult({ - required this.name, - required this.services, - required this.fuelTypes, - required this.operatingHours, - required this.phoneNumber, - required this.radioFrequency, - }); -} - -/// Bottom-sheet form for creating a business or editing its details. -/// -/// When [requireName] is true the name field is shown and required (adding a -/// new listing). When false the name is fixed/immutable and only the -/// crowd-sourced detail fields are editable. -class BusinessFormSheet extends StatefulWidget { - final String title; - final bool requireName; - final String? initialName; - final List initialServices; - final List initialFuelTypes; - final String initialHours; - final String initialPhone; - final String initialFrequency; - - const BusinessFormSheet({ - super.key, - required this.title, - this.requireName = true, - this.initialName, - this.initialServices = const [], - this.initialFuelTypes = const [], - this.initialHours = "", - this.initialPhone = "", - this.initialFrequency = "", - }); - - @override - State createState() => _BusinessFormSheetState(); -} - -class _BusinessFormSheetState extends State { - final _formKey = GlobalKey(); - late final TextEditingController _nameCtrl; - late final TextEditingController _hoursCtrl; - late final TextEditingController _phoneCtrl; - late final TextEditingController _freqCtrl; - late final Set _services; - late final Set _fuel; - - @override - void initState() { - super.initState(); - _nameCtrl = TextEditingController(text: widget.initialName ?? ""); - _hoursCtrl = TextEditingController(text: widget.initialHours); - _phoneCtrl = TextEditingController(text: widget.initialPhone); - _freqCtrl = TextEditingController(text: widget.initialFrequency); - _services = {...widget.initialServices}; - _fuel = {...widget.initialFuelTypes}; - } - - @override - void dispose() { - _nameCtrl.dispose(); - _hoursCtrl.dispose(); - _phoneCtrl.dispose(); - _freqCtrl.dispose(); - super.dispose(); - } - - // Present the known options plus anything already chosen that isn't a - // preset (e.g. imported/custom values), so custom entries aren't dropped. - List _chipOptions(List presets, Set selected) { - final out = [...presets]; - for (final s in selected) { - if (!out.contains(s)) out.add(s); - } - return out; - } - - void _submit() { - if (widget.requireName && !_formKey.currentState!.validate()) { - return; - } - Navigator.pop( - context, - BusinessFormResult( - name: _nameCtrl.text.trim(), - services: _services.toList(), - fuelTypes: _fuel.toList(), - operatingHours: _hoursCtrl.text.trim(), - phoneNumber: _phoneCtrl.text.trim(), - radioFrequency: _freqCtrl.text.trim(), - ), - ); - } - - @override - Widget build(BuildContext context) { - final bottomInset = MediaQuery.of(context).viewInsets.bottom; - return Padding( - padding: EdgeInsets.only(bottom: bottomInset), - child: DraggableScrollableSheet( - expand: false, - initialChildSize: 0.85, - minChildSize: 0.5, - maxChildSize: 0.95, - builder: (context, scrollController) { - return Form( - key: _formKey, - child: ListView( - controller: scrollController, - padding: const EdgeInsets.all(16), - children: [ - Row( - children: [ - Expanded( - child: Text(widget.title, - style: Theme.of(context).textTheme.titleLarge), - ), - IconButton( - icon: const Icon(Icons.close), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 8), - if (widget.requireName) ...[ - TextFormField( - controller: _nameCtrl, - maxLength: AirportBusiness.maxNameLength, - textCapitalization: TextCapitalization.words, - decoration: const InputDecoration( - labelText: "Business name", - hintText: "e.g. Signature Flight Support", - border: OutlineInputBorder(), - ), - validator: (v) => (v == null || v.trim().isEmpty) - ? "Please enter a name" - : null, - ), - const SizedBox(height: 8), - ], - _SectionLabel("Services"), - Wrap( - spacing: 6, - runSpacing: 2, - children: [ - for (final s in _chipOptions( - AirportBusiness.serviceOptions, _services)) - FilterChip( - label: Text(s), - selected: _services.contains(s), - onSelected: (sel) => setState(() { - sel ? _services.add(s) : _services.remove(s); - }), - ), - ], - ), - const SizedBox(height: 12), - _SectionLabel("Fuel available"), - Wrap( - spacing: 6, - runSpacing: 2, - children: [ - for (final f - in _chipOptions(AirportBusiness.fuelOptions, _fuel)) - FilterChip( - label: Text(f), - selected: _fuel.contains(f), - onSelected: (sel) => setState(() { - sel ? _fuel.add(f) : _fuel.remove(f); - }), - ), - ], - ), - const SizedBox(height: 16), - _SectionLabel("Operating hours"), - TextFormField( - controller: _hoursCtrl, - maxLength: AirportBusiness.maxHoursLength, - maxLines: 3, - minLines: 1, - decoration: const InputDecoration( - hintText: "e.g. Mon–Fri 0800–1800, Sat 0900–1700, " - "Sun closed", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - _SectionLabel("Phone number"), - TextFormField( - controller: _phoneCtrl, - maxLength: AirportBusiness.maxPhoneLength, - keyboardType: TextInputType.phone, - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'[0-9+\-() ]')), - ], - decoration: const InputDecoration( - hintText: "e.g. (555) 123-4567", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - _SectionLabel("Radio frequency"), - TextFormField( - controller: _freqCtrl, - maxLength: AirportBusiness.maxFrequencyLength, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow( - RegExp(r'[0-9. ]')), - ], - decoration: const InputDecoration( - hintText: "e.g. 122.95", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 12), - FilledButton.icon( - onPressed: _submit, - icon: const Icon(Icons.save), - label: Text(widget.requireName ? "Add Business" : "Save"), - ), - const SizedBox(height: 8), - ], - ), - ); - }, - ), - ); - } -} - -class _SectionLabel extends StatelessWidget { - final String text; - const _SectionLabel(this.text); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.only(bottom: 6), - child: Text(text, - style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)), - ); - } -} diff --git a/lib/business/widgets/star_rating.dart b/lib/business/widgets/star_rating.dart deleted file mode 100644 index 6b967773..00000000 --- a/lib/business/widgets/star_rating.dart +++ /dev/null @@ -1,64 +0,0 @@ -import 'package:flutter/material.dart'; - -/// Read-only row of stars for an average/whole rating. -class StarRating extends StatelessWidget { - final double rating; // 0..5 - final double size; - - const StarRating({super.key, required this.rating, this.size = 18}); - - @override - Widget build(BuildContext context) { - final color = Colors.amber.shade700; - return Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(5, (i) { - final filled = rating - i; - IconData icon; - if (filled >= 0.75) { - icon = Icons.star; - } else if (filled >= 0.25) { - icon = Icons.star_half; - } else { - icon = Icons.star_border; - } - return Icon(icon, size: size, color: color); - }), - ); - } -} - -/// Interactive 1..5 star selector used when composing a review. -class StarRatingInput extends StatelessWidget { - final int value; // 1..5, 0 = none - final ValueChanged onChanged; - final double size; - - const StarRatingInput({ - super.key, - required this.value, - required this.onChanged, - this.size = 36, - }); - - @override - Widget build(BuildContext context) { - final color = Colors.amber.shade700; - return Row( - mainAxisSize: MainAxisSize.min, - children: List.generate(5, (i) { - final index = i + 1; - return IconButton( - padding: const EdgeInsets.symmetric(horizontal: 2), - constraints: const BoxConstraints(), - icon: Icon( - index <= value ? Icons.star : Icons.star_border, - size: size, - color: color, - ), - onPressed: () => onChanged(index), - ); - }), - ); - } -} diff --git a/lib/community/community_screen.dart b/lib/community/community_screen.dart deleted file mode 100644 index bd7782c5..00000000 --- a/lib/community/community_screen.dart +++ /dev/null @@ -1,622 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/community_repository.dart'; -import 'group_create_screen.dart'; -import 'group_detail_screen.dart'; -import 'models/pilot_group.dart'; -import 'models/pilot_profile.dart'; -import 'notifications_screen.dart'; -import 'profile_edit_screen.dart'; -import 'widgets/group_card.dart'; - -/// Main Community landing screen: My Groups / Discover / Profile tabs. -class CommunityScreen extends StatefulWidget { - const CommunityScreen({super.key}); - - @override - State createState() => _CommunityScreenState(); -} - -class _CommunityScreenState extends State { - final _repo = CommunityRepository.instance; - String? _initError; - - @override - void initState() { - super.initState(); - _bootstrap(); - } - - // Lazily create the user's profile doc. Errors (e.g. Firestore rules not - // deployed yet, no network) are caught here so they don't become unhandled - // exceptions; the inline banner below tells the user what's wrong. - Future _bootstrap() async { - try { - await _repo.ensureMyProfile(); - if (mounted && _initError != null) setState(() => _initError = null); - } catch (e) { - if (mounted) setState(() => _initError = e.toString()); - } - } - - @override - Widget build(BuildContext context) { - return DefaultTabController( - length: 3, - child: Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: Row( - children: [ - Icon(MdiIcons.accountGroup, size: 24), - const SizedBox(width: 8), - const Text("Pilot Community"), - ], - ), - actions: [ - const CommunityNotificationsBell(), - IconButton( - icon: const Icon(Icons.info_outline), - tooltip: "Disclaimer", - onPressed: () => showCommunityDisclaimer(context), - ), - ], - bottom: const TabBar( - tabs: [ - Tab(icon: Icon(Icons.groups), text: "My Groups"), - Tab(icon: Icon(Icons.explore), text: "Discover"), - Tab(icon: Icon(Icons.person), text: "Profile"), - ], - ), - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const GroupCreateScreen()), - ); - }, - icon: const Icon(Icons.add), - label: const Text("New Group"), - ), - body: Column( - children: [ - if (_initError != null) _SetupBanner(message: _initError!), - const _DisclaimerStrip(), - const Expanded( - child: TabBarView( - children: [ - _MyGroupsTab(), - _DiscoverTab(), - _ProfileTab(), - ], - ), - ), - ], - ), - ), - ); - } -} - -/// Friendly explainer shown when Firestore returns permission-denied or the -/// backend hasn't been provisioned yet. Tells the user (and the developer) -/// what's missing instead of crashing the screen. -class _SetupBanner extends StatelessWidget { - final String message; - const _SetupBanner({required this.message}); - - bool get _isPermissionDenied => - message.toLowerCase().contains("permission-denied") || - message.toLowerCase().contains("permission denied"); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - width: double.infinity, - color: scheme.errorContainer.withAlpha(120), - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(Icons.warning_amber, color: scheme.error), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _isPermissionDenied - ? "Community backend not ready" - : "Couldn't reach the community backend", - style: TextStyle( - fontWeight: FontWeight.w600, - color: scheme.onErrorContainer, - ), - ), - const SizedBox(height: 4), - Text( - _isPermissionDenied - ? "Firestore rules haven't been deployed for this project yet. " - "From the repo root run: firebase deploy --only firestore" - : message, - style: TextStyle( - fontSize: 12, - color: scheme.onErrorContainer, - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -/// Compact always-visible bar that reminds pilots the Community is -/// unmoderated and not a place for sensitive data. Tap to open the full -/// disclaimer dialog. -class _DisclaimerStrip extends StatelessWidget { - const _DisclaimerStrip(); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return InkWell( - onTap: () => showCommunityDisclaimer(context), - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - color: scheme.tertiaryContainer.withAlpha(120), - child: Row( - children: [ - Icon(Icons.info_outline, - size: 16, color: scheme.onTertiaryContainer), - const SizedBox(width: 8), - Expanded( - child: Text( - "Community is unmoderated. Don't share sensitive info. " - "Tap for full disclaimer.", - style: TextStyle( - fontSize: 11, - color: scheme.onTertiaryContainer, - ), - ), - ), - Icon(Icons.chevron_right, - size: 16, color: scheme.onTertiaryContainer), - ], - ), - ), - ); - } -} - -/// Shows the full Community disclaimer. Called from the AppBar info icon -/// and from the always-visible disclaimer strip on the main Community -/// screen. Kept top-level so future Community screens (Group detail, -/// post compose, etc.) can surface the same text without duplication. -Future showCommunityDisclaimer(BuildContext context) { - return showDialog( - context: context, - builder: (ctx) { - final scheme = Theme.of(ctx).colorScheme; - Widget bullet(IconData icon, String title, String body) { - return Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Icon(icon, size: 18, color: scheme.primary), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - style: const TextStyle( - fontSize: 13, fontWeight: FontWeight.w600)), - const SizedBox(height: 2), - Text(body, - style: TextStyle( - fontSize: 12, color: scheme.onSurfaceVariant)), - ], - ), - ), - ], - ), - ); - } - - return AlertDialog( - title: const Row( - children: [ - Icon(Icons.info_outline), - SizedBox(width: 8), - Text("Community Disclaimer"), - ], - ), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - bullet( - Icons.warning_amber_outlined, - "Not responsible for data loss", - "Apps4Av is not responsible for any data loss in the " - "Pilot Community. Treat posts and group content as " - "ephemeral and keep your own copies of anything you " - "want to keep.", - ), - bullet( - Icons.lock_outline, - "Don't share sensitive information", - "Do not post passwords, government IDs, financial " - "details, medical records, or any other sensitive " - "personal information. Anything you post may be visible " - "to other pilots.", - ), - bullet( - Icons.gavel_outlined, - "No moderation by Apps4Av", - "Apps4Av does not moderate Pilot Community activity. " - "Group owners are responsible for their own groups. " - "Use your own judgment when interacting with other " - "pilots and content.", - ), - bullet( - Icons.shield_outlined, - "Data is not shared with third parties", - "Apps4Av will not share your Pilot Community data with " - "third parties. Data is stored in the project's Firebase " - "backend solely to operate this feature.", - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text("Close"), - ), - ], - ); - }, - ); -} - -class _MyGroupsTab extends StatelessWidget { - const _MyGroupsTab(); - - @override - Widget build(BuildContext context) { - return StreamBuilder>( - stream: CommunityRepository.instance.watchMyGroups(), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return _ErrorView(error: snap.error); - } - final groups = snap.data ?? const []; - if (groups.isEmpty) { - return const _EmptyState( - icon: Icons.groups_2_outlined, - title: "No groups yet", - subtitle: - "Tap Discover to find pilot communities, or New Group to create one.", - ); - } - return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: groups.length, - itemBuilder: (context, i) { - final g = groups[i]; - return GroupCard( - group: g, - onTap: () => _openGroup(context, g.id), - ); - }, - ); - }, - ); - } -} - -class _DiscoverTab extends StatefulWidget { - const _DiscoverTab(); - @override - State<_DiscoverTab> createState() => _DiscoverTabState(); -} - -class _DiscoverTabState extends State<_DiscoverTab> { - final _searchCtrl = TextEditingController(); - String _query = ""; - - @override - void dispose() { - _searchCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: TextField( - controller: _searchCtrl, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.search), - suffixIcon: _query.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchCtrl.clear(); - setState(() => _query = ""); - }, - ), - hintText: "Search groups by name (public + private)", - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - isDense: true, - ), - onChanged: (v) => setState(() => _query = v), - ), - ), - Expanded( - child: StreamBuilder>( - stream: CommunityRepository.instance - .discoverGroups(query: _query), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return _ErrorView(error: snap.error); - } - final groups = snap.data ?? const []; - if (groups.isEmpty) { - return _EmptyState( - icon: Icons.search_off, - title: _query.isEmpty - ? "No public groups yet" - : "No groups match \"$_query\"", - subtitle: _query.isEmpty - ? "Be the first — tap New Group to create one." - : "Try a different search, or create the first group on this topic.", - ); - } - return ListView.builder( - padding: const EdgeInsets.only(bottom: 80), - itemCount: groups.length, - itemBuilder: (context, i) => GroupCard( - group: groups[i], - onTap: () => _openGroup(context, groups[i].id), - ), - ); - }, - ), - ), - ], - ); - } -} - -class _ProfileTab extends StatelessWidget { - const _ProfileTab(); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return StreamBuilder( - stream: CommunityRepository.instance.watchMyProfile(), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - final profile = snap.data; - if (profile == null) { - return const Center(child: Text("Profile unavailable")); - } - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - CircleAvatar( - radius: 28, - backgroundColor: scheme.primaryContainer, - child: Text( - profile.displayName.isNotEmpty - ? profile.displayName.substring(0, 1).toUpperCase() - : "?", - style: TextStyle( - fontSize: 22, - color: scheme.onPrimaryContainer, - fontWeight: FontWeight.bold, - ), - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - profile.displayName, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - if (profile.homeAirport != null) - Text( - "Home: ${profile.homeAirport}", - style: TextStyle(color: scheme.outline), - ), - ], - ), - ), - IconButton( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ProfileEditScreen(profile: profile), - ), - ); - }, - icon: const Icon(Icons.edit), - tooltip: "Edit profile", - ), - ], - ), - ), - ), - const SizedBox(height: 12), - if (profile.bio != null && profile.bio!.isNotEmpty) ...[ - _section(context, "About"), - Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Text(profile.bio!), - ), - ), - const SizedBox(height: 12), - ], - if (profile.ratings.isNotEmpty) ...[ - _section(context, "Ratings"), - Wrap( - spacing: 6, - runSpacing: 6, - children: profile.ratings - .map((r) => Chip(label: Text(r))) - .toList(), - ), - const SizedBox(height: 12), - ], - if (profile.aircraftTypes.isNotEmpty) ...[ - _section(context, "Aircraft I fly"), - Wrap( - spacing: 6, - runSpacing: 6, - children: profile.aircraftTypes - .map((a) => Chip( - avatar: Icon(MdiIcons.airplane, size: 14), - label: Text(a), - )) - .toList(), - ), - const SizedBox(height: 12), - ], - const SizedBox(height: 24), - Center( - child: Text( - "Your profile is visible to other AvareX pilots in groups you join.", - textAlign: TextAlign.center, - style: TextStyle(fontSize: 12, color: scheme.outline), - ), - ), - ], - ); - }, - ); - } - - Widget _section(BuildContext context, String label) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Text( - label.toUpperCase(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - } -} - -void _openGroup(BuildContext context, String groupId) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => GroupDetailScreen(groupId: groupId), - ), - ); -} - -class _EmptyState extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - const _EmptyState({ - required this.icon, - required this.title, - required this.subtitle, - }); - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 48, color: scheme.outline), - const SizedBox(height: 12), - Text(title, - style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 6), - Text( - subtitle, - textAlign: TextAlign.center, - style: TextStyle(color: scheme.outline, fontSize: 13), - ), - ], - ), - ), - ); - } -} - -class _ErrorView extends StatelessWidget { - final Object? error; - const _ErrorView({required this.error}); - @override - Widget build(BuildContext context) { - // Surface backend errors via the existing toast pattern as well. - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) { - Toast.showToast(context, "Community error: $error", - const Icon(Icons.error, color: Colors.red), 4); - } - }); - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - "Something went wrong loading the community.\n$error", - textAlign: TextAlign.center, - ), - ), - ); - } -} diff --git a/lib/community/data/community_repository.dart b/lib/community/data/community_repository.dart deleted file mode 100644 index c67a067d..00000000 --- a/lib/community/data/community_repository.dart +++ /dev/null @@ -1,873 +0,0 @@ -import 'dart:async'; -import 'dart:typed_data'; - -import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:firebase_storage/firebase_storage.dart'; - -import '../models/community_notification.dart'; -import '../models/group_member.dart'; -import '../models/group_post.dart'; -import '../models/notification_prefs.dart'; -import '../models/pilot_group.dart'; -import '../models/pilot_profile.dart'; - -/// All Firestore interaction for the Community feature is funneled through -/// this repository so the rest of the UI never touches Firestore directly. -/// -/// Collection layout: -/// users/{uid} -> PilotProfile -/// usernames/{displayNameLower} -> { uid } (name uniqueness) -/// groups/{gid} -> PilotGroup -/// groups/{gid}/members/{uid} -> GroupMember -/// groups/{gid}/posts/{pid} -> GroupPost -/// userGroups/{uid}/groups/{gid} -> denormalized membership index -/// userNotifications/{uid}/items/{nid} -> CommunityNotification -/// userNotificationPrefs/{uid} -> NotificationPrefs -class CommunityRepository { - CommunityRepository._(); - static final CommunityRepository instance = CommunityRepository._(); - - FirebaseFirestore get _db => FirebaseFirestore.instance; - - String? get _uid => FirebaseAuth.instance.currentUser?.uid; - - String _requireUid() { - final uid = _uid; - if (uid == null) { - throw StateError("Not signed in"); - } - return uid; - } - - // -------------------- Profile -------------------- - - DocumentReference> _profileRef(String uid) => - _db.collection("users").doc(uid); - - DocumentReference> _usernameRef(String nameLower) => - _db.collection("usernames").doc(nameLower); - - Future ensureMyProfile() async { - final uid = _requireUid(); - final ref = _profileRef(uid); - final snap = await ref.get(); - if (snap.exists) { - return PilotProfile.fromDoc(snap); - } - // Pick a globally-unique display name (the security rules require the - // caller to own the matching usernames/{lower} claim before the profile - // can be written). Start from the auth name/email, appending a short uid - // suffix if it's taken. - final base = FirebaseAuth.instance.currentUser?.displayName ?? - FirebaseAuth.instance.currentUser?.email?.split("@").first ?? - "Pilot"; - final displayName = await _pickFreeName(uid, base); - final initial = PilotProfile.empty(uid, displayName); - // Issue the profile create but don't block on the server ack, so first use - // works offline too: the write is applied to the local cache immediately - // and syncs later (rules are re-checked on sync). ensureMyProfile runs - // before every contribution, so blocking here would hang those writes. - unawaited(ref.set(initial.toMap()).catchError((_) => null)); - return initial; - } - - /// Normalize a candidate display name to the 2-40 char window the rules - /// require, and to something usable as the usernames claim document id - /// (the lowercased name is the doc id, so '/', '.' and '..' are illegal). - String _sanitizeDisplayName(String name) { - var n = name.replaceAll("/", "-").trim(); - if (n.length > 40) n = n.substring(0, 40).trim(); - if (n.length < 2 || n == "." || n == "..") n = "Pilot"; - return n; - } - - /// Pick a globally-unique display name for a brand-new profile and stake the - /// matching usernames claim. Derives from [base], falling back to - /// uid-suffixed variants when a candidate is taken. The claim write is - /// issued but not awaited (so this works offline); the rules enforce real - /// uniqueness when the write syncs. - Future _pickFreeName(String uid, String base) async { - final root = _sanitizeDisplayName(base); - final candidates = [ - root, - _sanitizeDisplayName("$root-${uid.substring(0, 4)}"), - _sanitizeDisplayName("$root-${uid.substring(0, 6)}"), - _sanitizeDisplayName("Pilot-${uid.substring(0, 6)}"), - _sanitizeDisplayName("Pilot-$uid"), // uid is unique, so this always frees - ]; - for (final candidate in candidates) { - final lower = candidate.toLowerCase(); - final snap = await _usernameRef(lower).get(); - if (!snap.exists) { - unawaited( - _usernameRef(lower).set({"uid": uid}).catchError((_) => null)); - return candidate; - } - if (snap.data()?["uid"] == uid) return candidate; // already mine - } - return candidates.last; - } - - Stream watchMyProfile() { - final uid = _uid; - if (uid == null) { - return Stream.value(null); - } - return _profileRef(uid).snapshots().map( - (s) => s.exists ? PilotProfile.fromDoc(s) : null); - } - - Future saveMyProfile(PilotProfile profile) async { - final uid = _requireUid(); - // Canonicalize the name so the claim id is a valid doc id and matches the - // stored displayNameLower exactly (the rules bind the two). - final toSave = profile.copyWith( - displayName: _sanitizeDisplayName(profile.displayName)); - final newLower = toSave.displayName.toLowerCase(); - - // Determine the previous name so we can release its claim on a rename. - final currentSnap = await _profileRef(uid).get(); - final data = currentSnap.data(); - final oldLower = (data?["displayNameLower"] as String?) ?? - (data?["displayName"] as String?)?.toLowerCase(); - - // Reject a name already held by another pilot (server truth when online; - // offline this uses the cache and is re-checked by the rules on sync). - final claimSnap = await _usernameRef(newLower).get(); - if (claimSnap.exists && claimSnap.data()?["uid"] != uid) { - throw StateError("That display name is taken. Please choose another."); - } - - // Issue the writes in order (claim before profile) so they apply to the - // local cache at once and, when offline, queue in the order the rules - // need: the profile's ownsUsername check must see the claim first on sync. - // We don't await between them; the caller wraps the whole call in - // commitWithOfflineFallback to decide synced vs queued. - final claimWrite = claimSnap.exists - ? Future.value() - : _usernameRef(newLower).set({"uid": uid}); - final profileWrite = - _profileRef(uid).set(toSave.toMap(), SetOptions(merge: true)); - final rename = Future.wait([claimWrite, profileWrite]); - - // Release the previous name's claim ONLY once the rename actually commits. - // Doing it unconditionally would be unsafe offline: if the new name is - // taken by the time our queued writes sync, the profile write rolls back - // (rules), and deleting the old claim anyway would strand us with a - // profile name we no longer own -- reopening the impersonation gap. - if (oldLower != null && oldLower != newLower) { - unawaited(rename - .then((_) => _usernameRef(oldLower).delete()) - .catchError((_) {})); - } - - await rename; - } - - // -------------------- Groups -------------------- - - CollectionReference> get _groupsCol => - _db.collection("groups"); - - DocumentReference> _groupRef(String gid) => - _groupsCol.doc(gid); - - CollectionReference> _membersCol(String gid) => - _groupRef(gid).collection("members"); - - CollectionReference> _postsCol(String gid) => - _groupRef(gid).collection("posts"); - - DocumentReference> _userGroupRef(String uid, String gid) => - _db.collection("userGroups").doc(uid).collection("groups").doc(gid); - - CollectionReference> _notifsCol(String uid) => - _db.collection("userNotifications").doc(uid).collection("items"); - - DocumentReference> _notifPrefsRef(String uid) => - _db.collection("userNotificationPrefs").doc(uid); - - DocumentReference> _groupReadRef(String uid, String gid) => - _db.collection("userGroupReads").doc(uid).collection("groups").doc(gid); - - Stream watchGroup(String groupId) { - return _groupRef(groupId) - .snapshots() - .map((s) => s.exists ? PilotGroup.fromDoc(s) : null); - } - - /// Discover tab query. - /// - /// * When the user is browsing (no query), only **public** groups are shown - /// so the default Discover view doesn't expose every private group's - /// metadata to strangers. - /// * When the user types a search term, **both** public and private groups - /// match by name-prefix. Private groups are intentionally discoverable by - /// exact-ish name so members can find them and tap "Request to Join"; - /// the feed itself remains locked down at the posts subcollection rule. - Stream> discoverGroups({String? query, int limit = 50}) { - final trimmed = query?.trim() ?? ""; - if (trimmed.isNotEmpty) { - final lower = trimmed.toLowerCase(); - return _groupsCol - .where("nameLower", isGreaterThanOrEqualTo: lower) - .where("nameLower", isLessThan: "$lower\uf8ff") - .orderBy("nameLower") - .limit(limit) - .snapshots() - .map((s) => - s.docs.map(PilotGroup.fromDoc).toList(growable: false)); - } - return _groupsCol - .where("visibility", isEqualTo: "public") - .orderBy("memberCount", descending: true) - .limit(limit) - .snapshots() - .map((s) => s.docs.map(PilotGroup.fromDoc).toList(growable: false)); - } - - /// Groups the current user belongs to. - Stream> watchMyGroups() { - final uid = _uid; - if (uid == null) return Stream.value(const []); - return _db - .collection("userGroups") - .doc(uid) - .collection("groups") - .where("status", isEqualTo: "active") - .snapshots() - .asyncMap((snap) async { - if (snap.docs.isEmpty) return []; - final ids = snap.docs.map((d) => d.id).toList(); - final List groups = []; - // Firestore whereIn caps at 30; chunk if needed. - for (var i = 0; i < ids.length; i += 30) { - final chunk = ids.sublist(i, i + 30 > ids.length ? ids.length : i + 30); - final qs = await _groupsCol - .where(FieldPath.documentId, whereIn: chunk) - .get(); - groups.addAll(qs.docs.map(PilotGroup.fromDoc)); - } - groups.sort((a, b) => b.createdAt.compareTo(a.createdAt)); - return groups; - }); - } - - /// Create a new group; current user becomes the owner. - Future createGroup({ - required String name, - required String description, - required GroupVisibility visibility, - String? homeAirport, - List tags = const [], - }) async { - final uid = _requireUid(); - final profile = await ensureMyProfile(); - - final groupRef = _groupsCol.doc(); - final now = DateTime.now(); - final group = PilotGroup( - id: groupRef.id, - name: name.trim(), - description: description.trim(), - homeAirport: homeAirport?.trim().toUpperCase(), - tags: tags, - visibility: visibility, - ownerUid: uid, - ownerName: profile.displayName, - memberCount: 1, - postCount: 0, - createdAt: now, - ); - - final ownerMember = GroupMember( - uid: uid, - displayName: profile.displayName, - homeAirport: profile.homeAirport, - role: MemberRole.owner, - status: MemberStatus.active, - joinedAt: now, - ); - - final batch = _db.batch(); - batch.set(groupRef, group.toCreateMap()); - batch.set(_membersCol(groupRef.id).doc(uid), ownerMember.toMap()); - batch.set(_userGroupRef(uid, groupRef.id), { - "role": "owner", - "status": "active", - "joinedAt": Timestamp.fromDate(now), - "groupName": group.name, - }); - await batch.commit(); - return groupRef.id; - } - - /// Delete a group (owner only). Removes members + posts in a best-effort - /// cleanup; for very large groups a Cloud Function would be preferable. - Future deleteGroup(String groupId) async { - final uid = _requireUid(); - final groupSnap = await _groupRef(groupId).get(); - if (!groupSnap.exists) return; - final group = PilotGroup.fromDoc(groupSnap); - if (group.ownerUid != uid) { - throw StateError("Only the owner can delete this group"); - } - - final members = await _membersCol(groupId).get(); - final posts = await _postsCol(groupId).get(); - - // Best-effort media cleanup runs FIRST, while the group doc still - // exists: the Storage rules authorize the owner to delete other - // members' photos via isCommunityOwner(groupId), which needs the group - // doc to be present. Failures here only leak Storage objects. - for (final p in posts.docs) { - final post = GroupPost.fromDoc(groupId, p); - await _deletePostMedia(groupId, post.authorUid, p.id); - } - - final batch = _db.batch(); - for (final m in members.docs) { - batch.delete(m.reference); - batch.delete(_userGroupRef(m.id, groupId)); - } - for (final p in posts.docs) { - batch.delete(p.reference); - } - batch.delete(_groupRef(groupId)); - await batch.commit(); - } - - // -------------------- Membership -------------------- - - Stream watchMyMembership(String groupId) { - final uid = _uid; - if (uid == null) return Stream.value(null); - return _membersCol(groupId) - .doc(uid) - .snapshots() - .map((s) => s.exists ? GroupMember.fromDoc(s) : null); - } - - /// One-shot read of the current user's membership, used when opening a - /// thread from a notification (where the streamed membership context - /// isn't already on hand). - Future fetchMyMembership(String groupId) async { - final uid = _uid; - if (uid == null) return null; - final snap = await _membersCol(groupId).doc(uid).get(); - return snap.exists ? GroupMember.fromDoc(snap) : null; - } - - Stream> watchMembers(String groupId, - {MemberStatus? status}) { - Query> q = _membersCol(groupId); - if (status != null) { - q = q.where("status", - isEqualTo: status == MemberStatus.pending ? "pending" : "active"); - } - return q.snapshots().map((s) { - final list = s.docs.map(GroupMember.fromDoc).toList(); - list.sort((a, b) { - if (a.isOwner && !b.isOwner) return -1; - if (b.isOwner && !a.isOwner) return 1; - return a.displayName.toLowerCase().compareTo(b.displayName.toLowerCase()); - }); - return list; - }); - } - - /// Join a group. Public groups go straight to active; private groups - /// land in `pending` until the owner approves. - Future joinGroup(String groupId) async { - final uid = _requireUid(); - final profile = await ensureMyProfile(); - final groupSnap = await _groupRef(groupId).get(); - if (!groupSnap.exists) { - throw StateError("Group not found"); - } - final group = PilotGroup.fromDoc(groupSnap); - final status = - group.isPrivate ? MemberStatus.pending : MemberStatus.active; - - final now = DateTime.now(); - final member = GroupMember( - uid: uid, - displayName: profile.displayName, - homeAirport: profile.homeAirport, - role: MemberRole.member, - status: status, - joinedAt: now, - ); - - final batch = _db.batch(); - batch.set(_membersCol(groupId).doc(uid), member.toMap()); - batch.set(_userGroupRef(uid, groupId), { - "role": "member", - "status": status == MemberStatus.active ? "active" : "pending", - "joinedAt": Timestamp.fromDate(now), - "groupName": group.name, - }); - if (status == MemberStatus.active) { - batch.update(_groupRef(groupId), { - "memberCount": FieldValue.increment(1), - }); - } - await batch.commit(); - return status; - } - - /// Leave a group. Owners cannot leave; they must delete the group instead. - Future leaveGroup(String groupId) async { - final uid = _requireUid(); - final memberSnap = await _membersCol(groupId).doc(uid).get(); - if (!memberSnap.exists) return; - final member = GroupMember.fromDoc(memberSnap); - if (member.isOwner) { - throw StateError( - "Owners cannot leave. Delete the group or transfer ownership."); - } - - final batch = _db.batch(); - batch.delete(_membersCol(groupId).doc(uid)); - batch.delete(_userGroupRef(uid, groupId)); - if (member.isActive) { - batch.update(_groupRef(groupId), { - "memberCount": FieldValue.increment(-1), - }); - } - await batch.commit(); - } - - /// Owner approves a pending join request on a private group. - Future approveMember(String groupId, String memberUid) async { - final uid = _requireUid(); - await _assertOwner(groupId, uid); - final batch = _db.batch(); - batch.update(_membersCol(groupId).doc(memberUid), {"status": "active"}); - batch.update(_userGroupRef(memberUid, groupId), {"status": "active"}); - batch.update(_groupRef(groupId), { - "memberCount": FieldValue.increment(1), - }); - await batch.commit(); - } - - /// Owner removes a member (or rejects a pending request). - Future removeMember(String groupId, String memberUid) async { - final uid = _requireUid(); - await _assertOwner(groupId, uid); - final memberSnap = await _membersCol(groupId).doc(memberUid).get(); - if (!memberSnap.exists) return; - final member = GroupMember.fromDoc(memberSnap); - if (member.isOwner) { - throw StateError("Cannot remove the owner"); - } - final batch = _db.batch(); - batch.delete(_membersCol(groupId).doc(memberUid)); - batch.delete(_userGroupRef(memberUid, groupId)); - if (member.isActive) { - batch.update(_groupRef(groupId), { - "memberCount": FieldValue.increment(-1), - }); - } - await batch.commit(); - } - - Future _assertOwner(String groupId, String uid) async { - final snap = await _groupRef(groupId).get(); - if (!snap.exists) throw StateError("Group not found"); - final g = PilotGroup.fromDoc(snap); - if (g.ownerUid != uid) { - throw StateError("Only the owner can do that"); - } - } - - // -------------------- Posts -------------------- - - /// Top-level topics for a group's feed, newest first. - /// - /// Replies are filtered out client-side (rather than with a server - /// `where("replyToId", isEqualTo: null)`) so that legacy posts written - /// before threading existed -- which have no `replyToId` field at all -- - /// still show up as topics. A server null-equality filter would silently - /// drop those documents. - Stream> watchPosts(String groupId, {int limit = 100}) { - return _postsCol(groupId) - .orderBy("createdAt", descending: true) - .limit(limit) - .snapshots() - .map((s) => s.docs - .map((d) => GroupPost.fromDoc(groupId, d)) - .where((p) => !p.isReply) - .toList(growable: false)); - } - - /// Replies to a single topic, oldest first so a thread reads top to - /// bottom like a conversation. - /// - /// Deliberately an equality-only query (no server-side `orderBy`): an - /// equality filter plus an `orderBy` on a different field would require a - /// deployed composite index, and without it the stream errors and the - /// thread appears empty even though the topic's replyCount is non-zero. - /// Relying only on the automatic single-field index on `replyToId` keeps - /// replies working with no index deployment; they're sorted client-side. - Stream> watchReplies(String groupId, String topicId, - {int limit = 200}) { - return _postsCol(groupId) - .where("replyToId", isEqualTo: topicId) - .limit(limit) - .snapshots() - .map((s) { - final list = - s.docs.map((d) => GroupPost.fromDoc(groupId, d)).toList(); - list.sort((a, b) => a.createdAt.compareTo(b.createdAt)); - return list; - }); - } - - /// Live view of a single post (used by the thread screen to keep the - /// topic header -- including its reply count -- up to date). - Stream watchPost(String groupId, String postId) { - return _postsCol(groupId).doc(postId).snapshots().map( - (s) => s.exists ? GroupPost.fromDoc(groupId, s) : null); - } - - /// Create a post. When [replyToId] is supplied the post is a reply to - /// that topic: it is tagged with the parent id and the parent's - /// [GroupPost.replyCount] is bumped in the same batch. Replies do not - /// affect the group's `postCount` (only topics do), which keeps the - /// group counter changes to ±1 per call. - Future createPost( - String groupId, { - required String text, - String? attachedAirport, - String? attachedRouteText, - String? attachedRouteName, - List images = const [], - String? replyToId, - }) async { - final uid = _requireUid(); - final profile = await ensureMyProfile(); - final memberSnap = await _membersCol(groupId).doc(uid).get(); - if (!memberSnap.exists) { - throw StateError("Join the group before posting"); - } - final member = GroupMember.fromDoc(memberSnap); - if (!member.isActive) { - throw StateError("Membership pending owner approval"); - } - if (images.length > GroupPost.maxImages) { - throw StateError( - "At most ${GroupPost.maxImages} images per post"); - } - final routeText = attachedRouteText?.trim(); - if (routeText != null && routeText.length > GroupPost.maxRouteLength) { - throw StateError("Attached plan is too long to share"); - } - - final parentId = (replyToId != null && replyToId.trim().isNotEmpty) - ? replyToId.trim() - : null; - // Replies attach to a top-level topic only. If the supplied parent is - // itself a reply, re-target its parent so the thread stays one level - // deep and the reply counter lives on the topic. - String? topicId = parentId; - if (parentId != null) { - final parentSnap = await _postsCol(groupId).doc(parentId).get(); - if (!parentSnap.exists) { - throw StateError("The topic you're replying to no longer exists"); - } - final parent = GroupPost.fromDoc(groupId, parentSnap); - topicId = parent.isReply ? parent.replyToId : parent.id; - } - - final postRef = _postsCol(groupId).doc(); - - // Upload images first so the post doc is only created with finalized - // download URLs. Path layout matches storage.rules: - // community/{groupId}/{uid}/{postId}/{index}.jpg - // The uid segment binds each object to its uploader so Storage rules - // can authorize writes/deletes without reading the post doc. - final mediaUrls = []; - for (var i = 0; i < images.length; i++) { - final imgRef = FirebaseStorage.instance - .ref() - .child('community') - .child(groupId) - .child(uid) - .child(postRef.id) - .child('$i.jpg'); - await imgRef.putData( - images[i], - SettableMetadata(contentType: 'image/jpeg'), - ); - mediaUrls.add(await imgRef.getDownloadURL()); - } - - final post = GroupPost( - id: postRef.id, - groupId: groupId, - authorUid: uid, - authorName: profile.displayName, - text: text.trim(), - attachedAirport: attachedAirport?.trim().toUpperCase(), - attachedRouteText: - (routeText == null || routeText.isEmpty) ? null : routeText, - attachedRouteName: - attachedRouteName?.trim().isEmpty ?? true ? null : attachedRouteName!.trim(), - mediaUrls: mediaUrls, - replyToId: topicId, - replyCount: 0, - createdAt: DateTime.now(), - ); - - final batch = _db.batch(); - batch.set(postRef, post.toCreateMap()); - if (topicId == null) { - // A new topic counts toward the group's post total. - batch.update(_groupRef(groupId), { - "postCount": FieldValue.increment(1), - }); - } else { - // A reply bumps its topic's reply counter instead. - batch.update(_postsCol(groupId).doc(topicId), { - "replyCount": FieldValue.increment(1), - }); - } - await batch.commit(); - - // Fan out reply notifications after the reply is durably committed. - // Done separately (and best-effort) so a notification rule rejection - // can never roll back the reply itself. - if (topicId != null) { - final snippet = text.trim().isNotEmpty - ? text.trim() - : (mediaUrls.isNotEmpty ? "[Photo]" : "Replied"); - await _notifyReply( - groupId: groupId, - topicId: topicId, - replyId: postRef.id, - actorUid: uid, - actorName: profile.displayName, - snippet: snippet, - ); - } - } - - /// Write reply notifications to the topic's author and the group owner - /// (excluding the replier, and de-duplicated when they're the same - /// person). Best-effort: any failure here is swallowed so it never - /// affects the reply that already committed. - Future _notifyReply({ - required String groupId, - required String topicId, - required String replyId, - required String actorUid, - required String actorName, - required String snippet, - }) async { - try { - final groupSnap = await _groupRef(groupId).get(); - if (!groupSnap.exists) return; - final group = PilotGroup.fromDoc(groupSnap); - final topicSnap = await _postsCol(groupId).doc(topicId).get(); - if (!topicSnap.exists) return; - final topic = GroupPost.fromDoc(groupId, topicSnap); - - // uid -> reason. Topic author wins over group owner when both apply. - final recipients = {}; - if (topic.authorUid.isNotEmpty && topic.authorUid != actorUid) { - recipients[topic.authorUid] = CommunityNotification.reasonTopicAuthor; - } - if (group.ownerUid.isNotEmpty && - group.ownerUid != actorUid && - !recipients.containsKey(group.ownerUid)) { - recipients[group.ownerUid] = CommunityNotification.reasonGroupOwner; - } - if (recipients.isEmpty) return; - - final trimmed = snippet.length > CommunityNotification.maxSnippet - ? snippet.substring(0, CommunityNotification.maxSnippet) - : snippet; - final now = DateTime.now(); - - final batch = _db.batch(); - recipients.forEach((recipientUid, reason) { - final ref = _notifsCol(recipientUid).doc(); - final notif = CommunityNotification( - id: ref.id, - type: CommunityNotification.typeReply, - groupId: groupId, - groupName: group.name, - topicId: topicId, - postId: replyId, - actorUid: actorUid, - actorName: actorName, - reason: reason, - snippet: trimmed, - read: false, - createdAt: now, - ); - batch.set(ref, notif.toCreateMap()); - }); - await batch.commit(); - } catch (_) { - // Notifications are best-effort; ignore rule/network failures. - } - } - - /// Delete a post. Authors can delete their own; owners can delete any. - /// - /// Deleting a topic cascades to its replies so a thread never outlives - /// the conversation it belonged to; only the topic decrements the - /// group's `postCount`. Deleting a reply decrements its topic's - /// `replyCount` instead. - /// - /// Any attached images are removed from Firebase Storage on a - /// best-effort basis; an orphaned image is harmless and gets cleaned - /// up by lifecycle rules if configured. - Future deletePost(String groupId, String postId) async { - final uid = _requireUid(); - final postSnap = await _postsCol(groupId).doc(postId).get(); - if (!postSnap.exists) return; - final post = GroupPost.fromDoc(groupId, postSnap); - if (post.authorUid != uid) { - await _assertOwner(groupId, uid); - } - - if (post.isReply) { - // A reply: drop the doc and decrement its topic's reply counter. - final batch = _db.batch(); - batch.delete(_postsCol(groupId).doc(postId)); - batch.update(_postsCol(groupId).doc(post.replyToId!), { - "replyCount": FieldValue.increment(-1), - }); - await batch.commit(); - await _deletePostMedia(groupId, post.authorUid, postId); - return; - } - - // A topic: cascade-delete every reply, then the topic itself. Only the - // topic touches the group's postCount (replies never did). - final replies = - await _postsCol(groupId).where("replyToId", isEqualTo: postId).get(); - - final batch = _db.batch(); - for (final r in replies.docs) { - batch.delete(r.reference); - } - batch.delete(_postsCol(groupId).doc(postId)); - batch.update(_groupRef(groupId), { - "postCount": FieldValue.increment(-1), - }); - await batch.commit(); - - // Best-effort Storage cleanup for the topic and all its replies. - await _deletePostMedia(groupId, post.authorUid, postId); - for (final r in replies.docs) { - final reply = GroupPost.fromDoc(groupId, r); - await _deletePostMedia(groupId, reply.authorUid, r.id); - } - } - - Future _deletePostMedia( - String groupId, String authorUid, String postId) async { - try { - final folderRef = FirebaseStorage.instance - .ref() - .child('community') - .child(groupId) - .child(authorUid) - .child(postId); - final list = await folderRef.listAll(); - await Future.wait(list.items.map((i) => i.delete())); - } catch (_) { - // Ignore — Storage cleanup is best effort. - } - } - - // -------------------- Read tracking -------------------- - - /// The last time the current user marked a group's feed as read. Topics - /// created after this are considered unread (shown bold in the feed). - /// Returns null when the user has never opened the group. - Future fetchGroupLastRead(String groupId) async { - final uid = _uid; - if (uid == null) return null; - final snap = await _groupReadRef(uid, groupId).get(); - if (!snap.exists) return null; - final ts = snap.data()?["lastReadAt"]; - return ts is Timestamp ? ts.toDate() : null; - } - - /// Mark a group's feed as read up to now. - Future markGroupRead(String groupId) async { - final uid = _uid; - if (uid == null) return; - await _groupReadRef(uid, groupId) - .set({"lastReadAt": Timestamp.fromDate(DateTime.now())}); - } - - // -------------------- Notifications -------------------- - - /// The current user's notifications, newest first. Preferences are - /// applied by the caller on read (see [NotificationPrefs]). - Stream> watchMyNotifications({int limit = 50}) { - final uid = _uid; - if (uid == null) return Stream.value(const []); - return _notifsCol(uid) - .orderBy("createdAt", descending: true) - .limit(limit) - .snapshots() - .map((s) => s.docs - .map(CommunityNotification.fromDoc) - .toList(growable: false)); - } - - Stream watchMyNotificationPrefs() { - final uid = _uid; - if (uid == null) return Stream.value(NotificationPrefs.defaults); - return _notifPrefsRef(uid).snapshots().map( - (s) => s.exists ? NotificationPrefs.fromDoc(s) : NotificationPrefs.defaults); - } - - Future saveMyNotificationPrefs(NotificationPrefs prefs) async { - final uid = _requireUid(); - await _notifPrefsRef(uid).set(prefs.toMap()); - } - - Future markNotificationRead(String notificationId) async { - final uid = _requireUid(); - await _notifsCol(uid).doc(notificationId).update({"read": true}); - } - - /// Mark every currently-unread notification as read. - Future markAllNotificationsRead() async { - final uid = _requireUid(); - final unread = - await _notifsCol(uid).where("read", isEqualTo: false).get(); - if (unread.docs.isEmpty) return; - final batch = _db.batch(); - for (final d in unread.docs) { - batch.update(d.reference, {"read": true}); - } - await batch.commit(); - } - - Future deleteNotification(String notificationId) async { - final uid = _requireUid(); - await _notifsCol(uid).doc(notificationId).delete(); - } - - /// Remove all of the current user's notifications. - Future clearAllNotifications() async { - final uid = _requireUid(); - final all = await _notifsCol(uid).get(); - if (all.docs.isEmpty) return; - final batch = _db.batch(); - for (final d in all.docs) { - batch.delete(d.reference); - } - await batch.commit(); - } -} diff --git a/lib/community/group_create_screen.dart b/lib/community/group_create_screen.dart deleted file mode 100644 index 09ecd4c7..00000000 --- a/lib/community/group_create_screen.dart +++ /dev/null @@ -1,163 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/community_repository.dart'; -import 'group_detail_screen.dart'; -import 'models/pilot_group.dart'; - -class GroupCreateScreen extends StatefulWidget { - const GroupCreateScreen({super.key}); - - @override - State createState() => _GroupCreateScreenState(); -} - -class _GroupCreateScreenState extends State { - final _nameCtrl = TextEditingController(); - final _descCtrl = TextEditingController(); - final _airportCtrl = TextEditingController(); - GroupVisibility _visibility = GroupVisibility.public; - bool _busy = false; - - @override - void dispose() { - _nameCtrl.dispose(); - _descCtrl.dispose(); - _airportCtrl.dispose(); - super.dispose(); - } - - Future _create() async { - final name = _nameCtrl.text.trim(); - if (name.length < 3) { - Toast.showToast(context, "Group name must be at least 3 characters", - const Icon(Icons.info, color: Colors.orange), 3); - return; - } - setState(() => _busy = true); - try { - final id = await CommunityRepository.instance.createGroup( - name: name, - description: _descCtrl.text.trim(), - visibility: _visibility, - homeAirport: _airportCtrl.text.trim().isEmpty - ? null - : _airportCtrl.text.trim(), - ); - if (!mounted) return; - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => GroupDetailScreen(groupId: id)), - ); - } catch (e) { - if (mounted) { - Toast.showToast(context, "Could not create group: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("New Group"), - ), - body: AbsorbPointer( - absorbing: _busy, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - TextField( - controller: _nameCtrl, - maxLength: 60, - decoration: const InputDecoration( - labelText: "Group name", - hintText: "e.g. KBED Pilots, Vintage Cessna Owners", - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.words, - ), - const SizedBox(height: 8), - TextField( - controller: _descCtrl, - maxLength: 280, - maxLines: 3, - decoration: const InputDecoration( - labelText: "Description", - hintText: "What's this group about?", - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox(height: 8), - TextField( - controller: _airportCtrl, - maxLength: 4, - textCapitalization: TextCapitalization.characters, - decoration: const InputDecoration( - labelText: "Home airport (optional)", - hintText: "ICAO, e.g. KBED", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text("Visibility", - style: TextStyle(fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - RadioGroup( - groupValue: _visibility, - onChanged: (v) => - setState(() => _visibility = v ?? _visibility), - child: const Column( - children: [ - RadioListTile( - contentPadding: EdgeInsets.zero, - value: GroupVisibility.public, - title: Text("Public"), - subtitle: Text( - "Anyone can find this group and join immediately."), - ), - RadioListTile( - contentPadding: EdgeInsets.zero, - value: GroupVisibility.private, - title: Text("Private"), - subtitle: Text( - "Group is discoverable, but you approve every new member."), - secondary: Icon(Icons.lock_outline), - ), - ], - ), - ), - ], - ), - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _busy ? null : _create, - icon: _busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.check), - label: const Text("Create Group"), - ), - ], - ), - ), - ); - } -} diff --git a/lib/community/group_detail_screen.dart b/lib/community/group_detail_screen.dart deleted file mode 100644 index 9a70b228..00000000 --- a/lib/community/group_detail_screen.dart +++ /dev/null @@ -1,555 +0,0 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; - -import '../constants.dart'; -import '../main_screen.dart'; -import '../plan/plan_route.dart'; -import '../storage.dart'; -import '../utils/toast.dart'; -import 'data/community_repository.dart'; -import 'group_members_screen.dart'; -import 'models/group_member.dart'; -import 'models/group_post.dart'; -import 'models/pilot_group.dart'; -import 'post_compose_screen.dart'; -import 'post_thread_screen.dart'; -import 'widgets/join_leave_button.dart'; -import 'widgets/post_card.dart'; - -class GroupDetailScreen extends StatelessWidget { - final String groupId; - const GroupDetailScreen({super.key, required this.groupId}); - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: CommunityRepository.instance.watchGroup(groupId), - builder: (context, gSnap) { - final group = gSnap.data; - return StreamBuilder( - stream: CommunityRepository.instance.watchMyMembership(groupId), - builder: (context, mSnap) { - final membership = mSnap.data; - if (gSnap.connectionState == ConnectionState.waiting || - mSnap.connectionState == ConnectionState.waiting) { - return const Scaffold( - body: Center(child: CircularProgressIndicator())); - } - if (group == null) { - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Group"), - ), - body: const Center( - child: Text("This group has been deleted.")), - ); - } - return _GroupDetailBody(group: group, membership: membership); - }, - ); - }, - ); - } -} - -class _GroupDetailBody extends StatelessWidget { - final PilotGroup group; - final GroupMember? membership; - const _GroupDetailBody({required this.group, this.membership}); - - bool get _isOwner => membership?.isOwner ?? false; - bool get _canPost => membership?.isActive ?? false; - - Future _confirmDelete(BuildContext context) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text("Delete group?"), - content: Text( - "Delete '${group.name}'? This removes all posts and memberships and cannot be undone."), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton.tonal( - style: FilledButton.styleFrom(foregroundColor: Colors.red), - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Delete"), - ), - ], - ), - ); - if (ok != true || !context.mounted) return; - try { - await CommunityRepository.instance.deleteGroup(group.id); - if (!context.mounted) return; - Navigator.pop(context); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Delete failed: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final pendingBadge = _isOwner - ? StreamBuilder>( - stream: CommunityRepository.instance - .watchMembers(group.id, status: MemberStatus.pending), - builder: (context, snap) { - final count = snap.data?.length ?? 0; - if (count == 0) return const SizedBox.shrink(); - return Positioned( - top: 8, - right: 6, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: scheme.error, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - "$count", - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold), - ), - ), - ); - }, - ) - : const SizedBox.shrink(); - - return DefaultTabController( - length: 3, - child: Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: Row( - children: [ - Expanded( - child: Text( - group.name, - overflow: TextOverflow.ellipsis, - ), - ), - if (group.isPrivate) - Padding( - padding: const EdgeInsets.only(left: 6), - child: Icon(Icons.lock_outline, - size: 16, color: scheme.outline), - ), - ], - ), - actions: [ - if (_isOwner) - IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: "Delete group", - onPressed: () => _confirmDelete(context), - ), - ], - bottom: TabBar( - tabs: [ - const Tab(icon: Icon(Icons.forum), text: "Feed"), - Tab( - icon: Stack( - clipBehavior: Clip.none, - children: [ - const Icon(Icons.people), - pendingBadge, - ], - ), - text: "Members", - ), - const Tab(icon: Icon(Icons.info_outline), text: "About"), - ], - ), - ), - floatingActionButton: _canPost - ? FloatingActionButton.extended( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PostComposeScreen( - groupId: group.id, - groupName: group.name, - ), - ), - ); - }, - icon: const Icon(Icons.edit), - label: const Text("Post"), - ) - : null, - body: Column( - children: [ - _MembershipBanner(group: group, membership: membership), - Expanded( - child: TabBarView( - children: [ - _FeedTab(group: group, isOwner: _isOwner, canPost: _canPost), - GroupMembersScreen( - groupId: group.id, - isOwner: _isOwner, - embedded: true, - ), - _AboutTab(group: group), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _MembershipBanner extends StatelessWidget { - final PilotGroup group; - final GroupMember? membership; - const _MembershipBanner({required this.group, this.membership}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), - color: scheme.surfaceContainerHighest.withAlpha(120), - child: Row( - children: [ - Expanded( - child: Text( - membership == null - ? (group.isPrivate - ? "This is a private group. Request to join to see the feed." - : "You aren't a member yet.") - : (membership!.isPending - ? "Your request is waiting for owner approval." - : membership!.isOwner - ? "You own this group." - : "You're a member."), - style: const TextStyle(fontSize: 13), - ), - ), - const SizedBox(width: 8), - JoinLeaveButton( - group: group, - membership: membership, - onMessage: (m) { - if (context.mounted) { - Toast.showToast( - context, m, const Icon(Icons.info), 3); - } - }, - ), - ], - ), - ); - } -} - -class _FeedTab extends StatefulWidget { - final PilotGroup group; - final bool isOwner; - final bool canPost; - const _FeedTab({ - required this.group, - required this.isOwner, - required this.canPost, - }); - - @override - State<_FeedTab> createState() => _FeedTabState(); -} - -class _FeedTabState extends State<_FeedTab> { - PilotGroup get group => widget.group; - bool get isOwner => widget.isOwner; - bool get canPost => widget.canPost; - - // Topics created after this instant are shown bold (unread). Captured - // once when the feed opens; the group is then marked read up to "now" - // so these same topics count as read on the next visit. - DateTime? _readBaseline; - bool _baselineLoaded = false; - - @override - void initState() { - super.initState(); - _initReadState(); - } - - Future _initReadState() async { - final baseline = - await CommunityRepository.instance.fetchGroupLastRead(group.id); - if (mounted) { - setState(() { - _readBaseline = baseline; - _baselineLoaded = true; - }); - } - // Mark the feed read up to now for the next visit. Best-effort. - try { - await CommunityRepository.instance.markGroupRead(group.id); - } catch (_) {/* non-fatal */} - } - - bool _isUnread(GroupPost p, String? myUid) { - // Nothing is bold until we know the baseline, and a pilot's own posts - // are never "unread" to themselves. A null baseline (first ever visit) - // is treated as all-read to avoid a wall of bold text. - if (!_baselineLoaded || _readBaseline == null) return false; - if (myUid != null && myUid == p.authorUid) return false; - return p.createdAt.isAfter(_readBaseline!); - } - - @override - Widget build(BuildContext context) { - if (group.isPrivate && !canPost && !isOwner) { - return const Padding( - padding: EdgeInsets.all(24), - child: Center( - child: Text( - "Posts in this private group are hidden until your membership is approved.", - textAlign: TextAlign.center, - ), - ), - ); - } - return StreamBuilder>( - stream: CommunityRepository.instance.watchPosts(group.id), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return Center(child: Text("Couldn't load feed: ${snap.error}")); - } - final posts = snap.data ?? const []; - if (posts.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text( - "No posts yet. Be the first to say hi.", - textAlign: TextAlign.center, - ), - ), - ); - } - final myUid = FirebaseAuth.instance.currentUser?.uid; - return ListView.builder( - padding: const EdgeInsets.only(top: 8, bottom: 80), - itemCount: posts.length, - itemBuilder: (context, i) { - final p = posts[i]; - final canDelete = isOwner || (myUid != null && myUid == p.authorUid); - void openThread() { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PostThreadScreen( - groupId: group.id, - groupName: group.name, - topicId: p.id, - isOwner: isOwner, - canPost: canPost, - ), - ), - ); - } - - return PostCard( - post: p, - canDelete: canDelete, - unread: _isUnread(p, myUid), - onOpenThread: openThread, - onReply: canPost - ? () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PostComposeScreen( - groupId: group.id, - groupName: group.name, - replyToId: p.id, - replyToAuthorName: p.authorName, - ), - ), - ); - } - : null, - onDelete: () async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text("Delete post?"), - content: const Text("This cannot be undone."), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton.tonal( - style: FilledButton.styleFrom( - foregroundColor: Colors.red), - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Delete"), - ), - ], - ), - ); - if (ok == true) { - try { - await CommunityRepository.instance - .deletePost(group.id, p.id); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Delete failed: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - }, - onTapAirport: () { - Toast.showToast( - context, - "Search for ${p.attachedAirport} in the Find tab", - Icon(MdiIcons.airport), - 3, - ); - }, - onLoadRoute: p.hasRoute - ? () => _confirmLoadRoute(context, p) - : null, - ); - }, - ); - }, - ); - } - - Future _confirmLoadRoute(BuildContext context, GroupPost p) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text("Load shared plan?"), - content: Text( - "This will replace your current flight plan with:\n\n" - "${p.attachedRouteText}\n\n" - "Your current plan will be lost unless you've saved it.", - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton.icon( - onPressed: () => Navigator.pop(ctx, true), - icon: const Icon(Icons.download, size: 18), - label: const Text("Load to PLAN"), - ), - ], - ), - ); - if (ok != true || !context.mounted) return; - try { - final name = (p.attachedRouteName?.isNotEmpty == true) - ? p.attachedRouteName! - : "Shared plan"; - final loaded = await PlanRoute.fromLine(name, p.attachedRouteText!); - Storage().route.copyFrom(loaded); - Storage().route.setCurrentWaypoint(0); - if (!context.mounted) return; - // Pop the group + community stack and switch to the PLAN tab. - Navigator.popUntil(context, (r) => r.isFirst); - MainScreenState.gotoPlan(); - Toast.showToast( - context, - "Loaded \"$name\" into PLAN", - const Icon(Icons.check, color: Colors.green), - 3, - ); - } catch (e) { - if (context.mounted) { - Toast.showToast( - context, - "Couldn't load plan: $e", - const Icon(Icons.error, color: Colors.red), - 4, - ); - } - } - } -} - -class _AboutTab extends StatelessWidget { - final PilotGroup group; - const _AboutTab({required this.group}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(group.name, - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 6), - Text( - group.description.isEmpty - ? "No description provided." - : group.description, - style: TextStyle(color: scheme.onSurfaceVariant), - ), - const SizedBox(height: 12), - _kv(context, "Owner", group.ownerName), - _kv(context, "Visibility", - group.isPrivate ? "Private" : "Public"), - if (group.homeAirport != null) - _kv(context, "Home airport", group.homeAirport!), - _kv(context, "Members", "${group.memberCount}"), - _kv(context, "Posts", "${group.postCount}"), - _kv(context, "Created", - group.createdAt.toLocal().toString().split(' ').first), - ], - ), - ), - ), - ], - ); - } - - Widget _kv(BuildContext context, String k, String v) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 110, - child: Text(k, - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - fontSize: 12)), - ), - Expanded(child: Text(v, style: const TextStyle(fontSize: 13))), - ], - ), - ); - } -} diff --git a/lib/community/group_members_screen.dart b/lib/community/group_members_screen.dart deleted file mode 100644 index 64ca784f..00000000 --- a/lib/community/group_members_screen.dart +++ /dev/null @@ -1,220 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/community_repository.dart'; -import 'models/group_member.dart'; - -/// Members list with owner-only approve/remove controls. -/// -/// Can be used as a top-level screen or embedded inside a TabBarView via -/// the [embedded] flag. -class GroupMembersScreen extends StatelessWidget { - final String groupId; - final bool isOwner; - final bool embedded; - - const GroupMembersScreen({ - super.key, - required this.groupId, - required this.isOwner, - this.embedded = false, - }); - - @override - Widget build(BuildContext context) { - final body = StreamBuilder>( - stream: CommunityRepository.instance.watchMembers(groupId), - builder: (context, allSnap) { - if (allSnap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (allSnap.hasError) { - final err = allSnap.error.toString().toLowerCase(); - final isPrivate = err.contains("permission"); - return Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: Text( - isPrivate - ? "Member list is private. Join this group to see who else is here." - : "Couldn't load members: ${allSnap.error}", - textAlign: TextAlign.center, - ), - ), - ); - } - final all = allSnap.data ?? const []; - final active = all.where((m) => m.isActive).toList(); - final pending = all.where((m) => m.isPending).toList(); - return ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - if (isOwner && pending.isNotEmpty) ...[ - _header(context, "Pending requests (${pending.length})"), - ...pending.map((m) => _memberTile( - context, - m, - actions: [ - IconButton( - tooltip: "Approve", - icon: const Icon(Icons.check_circle, - color: Colors.green), - onPressed: () => _approve(context, m), - ), - IconButton( - tooltip: "Reject", - icon: const Icon(Icons.cancel, color: Colors.red), - onPressed: () => _remove(context, m), - ), - ], - )), - const Divider(), - ], - _header(context, "Members (${active.length})"), - if (active.isEmpty) - const Padding( - padding: EdgeInsets.all(24), - child: Center(child: Text("No members yet")), - ) - else - ...active.map( - (m) => _memberTile( - context, - m, - actions: isOwner && !m.isOwner - ? [ - IconButton( - tooltip: "Remove", - icon: const Icon(Icons.person_remove, - color: Colors.red), - onPressed: () => _confirmRemove(context, m), - ), - ] - : null, - ), - ), - ], - ); - }, - ); - - if (embedded) return body; - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Members"), - ), - body: body, - ); - } - - Widget _header(BuildContext context, String label) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text( - label.toUpperCase(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - } - - Widget _memberTile(BuildContext context, GroupMember m, - {List? actions}) { - final scheme = Theme.of(context).colorScheme; - return ListTile( - leading: CircleAvatar( - backgroundColor: scheme.primaryContainer, - child: Text( - m.displayName.isEmpty ? "?" : m.displayName.substring(0, 1).toUpperCase(), - style: TextStyle( - color: scheme.onPrimaryContainer, fontWeight: FontWeight.w600), - ), - ), - title: Row( - children: [ - Flexible( - child: Text( - m.displayName, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ), - if (m.isOwner) - const Padding( - padding: EdgeInsets.only(left: 6), - child: Chip( - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - label: Text("Owner", style: TextStyle(fontSize: 10)), - ), - ), - ], - ), - subtitle: Text( - [ - if (m.homeAirport != null && m.homeAirport!.isNotEmpty) m.homeAirport!, - "joined ${m.joinedAt.toLocal().toString().split(' ').first}", - ].join(" · "), - style: TextStyle(fontSize: 11, color: scheme.outline), - ), - trailing: actions == null - ? null - : Row(mainAxisSize: MainAxisSize.min, children: actions), - ); - } - - Future _approve(BuildContext context, GroupMember m) async { - try { - await CommunityRepository.instance.approveMember(groupId, m.uid); - if (context.mounted) { - Toast.showToast(context, "Approved ${m.displayName}", - const Icon(Icons.check, color: Colors.green), 2); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not approve: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - Future _remove(BuildContext context, GroupMember m) async { - try { - await CommunityRepository.instance.removeMember(groupId, m.uid); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not remove: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - Future _confirmRemove(BuildContext context, GroupMember m) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text("Remove ${m.displayName}?"), - content: const Text("They'll be removed from this group."), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton.tonal( - style: FilledButton.styleFrom(foregroundColor: Colors.red), - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Remove"), - ), - ], - ), - ); - if (ok == true && context.mounted) { - await _remove(context, m); - } - } -} diff --git a/lib/community/models/community_notification.dart b/lib/community/models/community_notification.dart deleted file mode 100644 index 973c46a2..00000000 --- a/lib/community/models/community_notification.dart +++ /dev/null @@ -1,86 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -/// An in-app notification delivered to a pilot under -/// `userNotifications/{recipientUid}/items/{id}`. -/// -/// There are no Cloud Functions in v1, so notifications are written by the -/// actor (e.g. the person who posted a reply) directly into each -/// recipient's collection at the time of the action. Recipients apply -/// their own [NotificationPrefs] when reading, so muting a group or -/// disabling notifications globally simply hides delivered items rather -/// than preventing the write. -class CommunityNotification { - static const String typeReply = 'reply'; - - /// The recipient is the author of the topic that was replied to. - static const String reasonTopicAuthor = 'topic_author'; - - /// The recipient owns the group the reply was posted in. - static const String reasonGroupOwner = 'group_owner'; - - static const int maxSnippet = 140; - - final String id; - final String type; - final String groupId; - final String groupName; - final String topicId; // the top-level topic the thread belongs to - final String postId; // the reply that triggered this notification - final String actorUid; // who replied - final String actorName; - final String reason; // why this recipient was notified - final String snippet; // short preview of the reply - final bool read; - final DateTime createdAt; - - const CommunityNotification({ - required this.id, - required this.type, - required this.groupId, - required this.groupName, - required this.topicId, - required this.postId, - required this.actorUid, - required this.actorName, - required this.reason, - required this.snippet, - required this.read, - required this.createdAt, - }); - - bool get isOwnerReason => reason == reasonGroupOwner; - - Map toCreateMap() => { - "type": type, - "groupId": groupId, - "groupName": groupName, - "topicId": topicId, - "postId": postId, - "actorUid": actorUid, - "actorName": actorName, - "reason": reason, - "snippet": snippet, - "read": read, - "createdAt": Timestamp.fromDate(createdAt), - }; - - factory CommunityNotification.fromDoc( - DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final ts = data["createdAt"]; - return CommunityNotification( - id: doc.id, - type: (data["type"] as String?) ?? typeReply, - groupId: (data["groupId"] as String?) ?? "", - groupName: (data["groupName"] as String?) ?? "Group", - topicId: (data["topicId"] as String?) ?? "", - postId: (data["postId"] as String?) ?? "", - actorUid: (data["actorUid"] as String?) ?? "", - actorName: (data["actorName"] as String?) ?? "Pilot", - reason: (data["reason"] as String?) ?? reasonTopicAuthor, - snippet: (data["snippet"] as String?) ?? "", - read: (data["read"] as bool?) ?? false, - createdAt: ts is Timestamp ? ts.toDate() : DateTime.now(), - ); - } -} diff --git a/lib/community/models/group_member.dart b/lib/community/models/group_member.dart deleted file mode 100644 index 85a742f0..00000000 --- a/lib/community/models/group_member.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -enum MemberRole { owner, member } - -enum MemberStatus { active, pending } - -MemberRole _roleFromString(String? v) => - v == "owner" ? MemberRole.owner : MemberRole.member; - -String _roleToString(MemberRole r) => r == MemberRole.owner ? "owner" : "member"; - -MemberStatus _statusFromString(String? v) => - v == "pending" ? MemberStatus.pending : MemberStatus.active; - -String _statusToString(MemberStatus s) => - s == MemberStatus.pending ? "pending" : "active"; - -/// Membership record stored under groups/{gid}/members/{uid}. -class GroupMember { - final String uid; - final String displayName; - final String? homeAirport; - final MemberRole role; - final MemberStatus status; - final DateTime joinedAt; - - const GroupMember({ - required this.uid, - required this.displayName, - this.homeAirport, - required this.role, - required this.status, - required this.joinedAt, - }); - - bool get isOwner => role == MemberRole.owner; - bool get isPending => status == MemberStatus.pending; - bool get isActive => status == MemberStatus.active; - - Map toMap() => { - "displayName": displayName, - "homeAirport": homeAirport, - "role": _roleToString(role), - "status": _statusToString(status), - "joinedAt": Timestamp.fromDate(joinedAt), - }; - - factory GroupMember.fromDoc(DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final ts = data["joinedAt"]; - return GroupMember( - uid: doc.id, - displayName: (data["displayName"] as String?) ?? "Pilot", - homeAirport: data["homeAirport"] as String?, - role: _roleFromString(data["role"] as String?), - status: _statusFromString(data["status"] as String?), - joinedAt: ts is Timestamp ? ts.toDate() : DateTime.now(), - ); - } -} diff --git a/lib/community/models/group_post.dart b/lib/community/models/group_post.dart deleted file mode 100644 index 80a43e0c..00000000 --- a/lib/community/models/group_post.dart +++ /dev/null @@ -1,90 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -/// A post in a group's feed. Optionally carries: -/// * an ICAO airport tag (legacy quick-link), -/// * a shareable flight plan (space-separated waypoint IDs that match -/// PlanRoute.toString / PlanRoute.fromLine), with a human-readable -/// name for display, -/// * up to a small number of image download URLs (HTTPS, stored in -/// Firebase Storage under community/{gid}/{pid}/{n}.jpg). -/// -/// Threading: posts form a single level of threaded discussion. A -/// top-level post (a "topic") has [replyToId] == null and tracks the -/// number of direct replies in [replyCount]. A reply carries the id of -/// its parent topic in [replyToId]; replies always attach to a top-level -/// topic (replies cannot themselves be replied to), which keeps a thread -/// scoped to a single conversation and the counters simple. -class GroupPost { - static const int maxImages = 4; - static const int maxRouteLength = 500; - static const int maxRouteNameLength = 60; - - final String id; - final String groupId; - final String authorUid; - final String authorName; - final String text; - final String? attachedAirport; // ICAO - final String? attachedRouteText; // space-separated location IDs - final String? attachedRouteName; // display label for the plan - final List mediaUrls; // HTTPS download URLs from Firebase Storage - final String? replyToId; // parent topic id; null for a top-level topic - final int replyCount; // direct replies; only meaningful for a topic - final DateTime createdAt; - - const GroupPost({ - required this.id, - required this.groupId, - required this.authorUid, - required this.authorName, - required this.text, - this.attachedAirport, - this.attachedRouteText, - this.attachedRouteName, - this.mediaUrls = const [], - this.replyToId, - this.replyCount = 0, - required this.createdAt, - }); - - bool get hasRoute => - attachedRouteText != null && attachedRouteText!.trim().isNotEmpty; - - bool get hasMedia => mediaUrls.isNotEmpty; - - /// True when this post is a reply to a topic rather than a topic itself. - bool get isReply => replyToId != null && replyToId!.isNotEmpty; - - Map toCreateMap() => { - "authorUid": authorUid, - "authorName": authorName, - "text": text, - "attachedAirport": attachedAirport, - "attachedRouteText": attachedRouteText, - "attachedRouteName": attachedRouteName, - "mediaUrls": mediaUrls, - "replyToId": replyToId, - "replyCount": replyCount, - "createdAt": Timestamp.fromDate(createdAt), - }; - - factory GroupPost.fromDoc( - String groupId, DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final ts = data["createdAt"]; - return GroupPost( - id: doc.id, - groupId: groupId, - authorUid: (data["authorUid"] as String?) ?? "", - authorName: (data["authorName"] as String?) ?? "Pilot", - text: (data["text"] as String?) ?? "", - attachedAirport: data["attachedAirport"] as String?, - attachedRouteText: data["attachedRouteText"] as String?, - attachedRouteName: data["attachedRouteName"] as String?, - mediaUrls: List.from((data["mediaUrls"] as List?) ?? const []), - replyToId: data["replyToId"] as String?, - replyCount: (data["replyCount"] as num?)?.toInt() ?? 0, - createdAt: ts is Timestamp ? ts.toDate() : DateTime.now(), - ); - } -} diff --git a/lib/community/models/notification_prefs.dart b/lib/community/models/notification_prefs.dart deleted file mode 100644 index a40fdb84..00000000 --- a/lib/community/models/notification_prefs.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -/// A pilot's notification preferences, stored at -/// `userNotificationPrefs/{uid}`. -/// -/// Preferences are applied when *reading* notifications (the actor who -/// writes a notification can't see the recipient's private prefs), so a -/// disabled global switch or a muted group hides delivered items and zeroes -/// the unread badge for that scope. -class NotificationPrefs { - /// Master switch. When false, no Community notifications are surfaced. - final bool globalEnabled; - - /// Group ids the user has muted individually. - final List mutedGroupIds; - - const NotificationPrefs({ - this.globalEnabled = true, - this.mutedGroupIds = const [], - }); - - static const NotificationPrefs defaults = NotificationPrefs(); - - /// Whether notifications from [groupId] should be shown. - bool allows(String groupId) => - globalEnabled && !mutedGroupIds.contains(groupId); - - bool isMuted(String groupId) => mutedGroupIds.contains(groupId); - - NotificationPrefs copyWith({ - bool? globalEnabled, - List? mutedGroupIds, - }) { - return NotificationPrefs( - globalEnabled: globalEnabled ?? this.globalEnabled, - mutedGroupIds: mutedGroupIds ?? this.mutedGroupIds, - ); - } - - /// Returns a copy with [groupId] muted or un-muted. - NotificationPrefs withGroupMuted(String groupId, bool muted) { - final set = List.from(mutedGroupIds)..remove(groupId); - if (muted) set.add(groupId); - return copyWith(mutedGroupIds: set); - } - - Map toMap() => { - "globalEnabled": globalEnabled, - "mutedGroupIds": mutedGroupIds, - }; - - factory NotificationPrefs.fromDoc( - DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - return NotificationPrefs( - globalEnabled: (data["globalEnabled"] as bool?) ?? true, - mutedGroupIds: - List.from((data["mutedGroupIds"] as List?) ?? const []), - ); - } -} diff --git a/lib/community/models/pilot_group.dart b/lib/community/models/pilot_group.dart deleted file mode 100644 index 97cdea3a..00000000 --- a/lib/community/models/pilot_group.dart +++ /dev/null @@ -1,79 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -enum GroupVisibility { public, private } - -GroupVisibility _visibilityFromString(String? v) { - switch (v) { - case "private": - return GroupVisibility.private; - case "public": - default: - return GroupVisibility.public; - } -} - -String _visibilityToString(GroupVisibility v) => - v == GroupVisibility.private ? "private" : "public"; - -/// A pilot community / group. -class PilotGroup { - final String id; - final String name; - final String description; - final String? homeAirport; // ICAO, uppercase - final List tags; - final GroupVisibility visibility; - final String ownerUid; - final String ownerName; - final int memberCount; - final int postCount; - final DateTime createdAt; - - const PilotGroup({ - required this.id, - required this.name, - required this.description, - this.homeAirport, - this.tags = const [], - required this.visibility, - required this.ownerUid, - required this.ownerName, - this.memberCount = 0, - this.postCount = 0, - required this.createdAt, - }); - - bool get isPrivate => visibility == GroupVisibility.private; - - Map toCreateMap() => { - "name": name, - "nameLower": name.toLowerCase(), - "description": description, - "homeAirport": homeAirport?.toUpperCase(), - "tags": tags, - "visibility": _visibilityToString(visibility), - "ownerUid": ownerUid, - "ownerName": ownerName, - "memberCount": memberCount, - "postCount": postCount, - "createdAt": Timestamp.fromDate(createdAt), - }; - - factory PilotGroup.fromDoc(DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final ts = data["createdAt"]; - return PilotGroup( - id: doc.id, - name: (data["name"] as String?) ?? "Unnamed", - description: (data["description"] as String?) ?? "", - homeAirport: data["homeAirport"] as String?, - tags: List.from((data["tags"] as List?) ?? const []), - visibility: _visibilityFromString(data["visibility"] as String?), - ownerUid: (data["ownerUid"] as String?) ?? "", - ownerName: (data["ownerName"] as String?) ?? "", - memberCount: (data["memberCount"] as int?) ?? 0, - postCount: (data["postCount"] as int?) ?? 0, - createdAt: ts is Timestamp ? ts.toDate() : DateTime.now(), - ); - } -} diff --git a/lib/community/models/pilot_profile.dart b/lib/community/models/pilot_profile.dart deleted file mode 100644 index e057c818..00000000 --- a/lib/community/models/pilot_profile.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -/// A pilot's public profile shared across the Community feature. -class PilotProfile { - final String uid; - final String displayName; - final String? homeAirport; // ICAO, uppercase - final List ratings; - final List aircraftTypes; - final String? bio; - final DateTime createdAt; - final DateTime updatedAt; - - const PilotProfile({ - required this.uid, - required this.displayName, - this.homeAirport, - this.ratings = const [], - this.aircraftTypes = const [], - this.bio, - required this.createdAt, - required this.updatedAt, - }); - - factory PilotProfile.empty(String uid, String? displayName) => PilotProfile( - uid: uid, - displayName: (displayName == null || displayName.isEmpty) ? "Pilot" : displayName, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ); - - PilotProfile copyWith({ - String? displayName, - String? homeAirport, - List? ratings, - List? aircraftTypes, - String? bio, - }) { - return PilotProfile( - uid: uid, - displayName: displayName ?? this.displayName, - homeAirport: homeAirport ?? this.homeAirport, - ratings: ratings ?? this.ratings, - aircraftTypes: aircraftTypes ?? this.aircraftTypes, - bio: bio ?? this.bio, - createdAt: createdAt, - updatedAt: DateTime.now(), - ); - } - - Map toMap() => { - "displayName": displayName, - "displayNameLower": displayName.toLowerCase(), - "homeAirport": homeAirport?.toUpperCase(), - "ratings": ratings, - "aircraftTypes": aircraftTypes, - "bio": bio, - "createdAt": Timestamp.fromDate(createdAt), - "updatedAt": Timestamp.fromDate(updatedAt), - }; - - factory PilotProfile.fromDoc(DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final created = data["createdAt"]; - final updated = data["updatedAt"]; - return PilotProfile( - uid: doc.id, - displayName: (data["displayName"] as String?) ?? "Pilot", - homeAirport: data["homeAirport"] as String?, - ratings: List.from((data["ratings"] as List?) ?? const []), - aircraftTypes: List.from((data["aircraftTypes"] as List?) ?? const []), - bio: data["bio"] as String?, - createdAt: created is Timestamp ? created.toDate() : DateTime.now(), - updatedAt: updated is Timestamp ? updated.toDate() : DateTime.now(), - ); - } -} diff --git a/lib/community/notification_settings_screen.dart b/lib/community/notification_settings_screen.dart deleted file mode 100644 index 9a88fdc9..00000000 --- a/lib/community/notification_settings_screen.dart +++ /dev/null @@ -1,117 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/community_repository.dart'; -import 'models/notification_prefs.dart'; -import 'models/pilot_group.dart'; - -/// Lets a pilot turn Community notifications off globally, or mute -/// individual groups. Preferences are stored in Firestore (so they sync -/// across devices) and applied when notifications are read. -class NotificationSettingsScreen extends StatelessWidget { - const NotificationSettingsScreen({super.key}); - - Future _save(BuildContext context, NotificationPrefs prefs) async { - try { - await CommunityRepository.instance.saveMyNotificationPrefs(prefs); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Couldn't save: $e", - const Icon(Icons.error, color: Colors.red), 3); - } - } - } - - @override - Widget build(BuildContext context) { - final repo = CommunityRepository.instance; - final scheme = Theme.of(context).colorScheme; - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Notification Settings"), - ), - body: StreamBuilder( - stream: repo.watchMyNotificationPrefs(), - builder: (context, prefsSnap) { - if (prefsSnap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - final prefs = prefsSnap.data ?? NotificationPrefs.defaults; - return ListView( - children: [ - SwitchListTile( - title: const Text("Reply notifications"), - subtitle: const Text( - "Get notified about replies to your topics and to posts in groups you own."), - value: prefs.globalEnabled, - onChanged: (v) => - _save(context, prefs.copyWith(globalEnabled: v)), - ), - const Divider(), - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text( - "PER-GROUP", - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: scheme.primary, - ), - ), - ), - if (!prefs.globalEnabled) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Text( - "All notifications are off. Turn the switch above on to manage individual groups.", - style: TextStyle(fontSize: 12, color: scheme.outline), - ), - ), - StreamBuilder>( - stream: repo.watchMyGroups(), - builder: (context, snap) { - final groups = snap.data ?? const []; - if (groups.isEmpty) { - return Padding( - padding: const EdgeInsets.all(16), - child: Text( - "You're not in any groups yet.", - style: TextStyle(color: scheme.outline), - ), - ); - } - return Column( - children: [ - for (final g in groups) - SwitchListTile( - title: Text(g.name), - subtitle: Text( - prefs.isMuted(g.id) ? "Muted" : "On", - style: TextStyle( - fontSize: 12, - color: scheme.outline, - ), - ), - // "On" means not muted. Disabled (greyed) when the - // global switch is off. - value: !prefs.isMuted(g.id), - onChanged: prefs.globalEnabled - ? (on) => _save( - context, prefs.withGroupMuted(g.id, !on)) - : null, - ), - ], - ); - }, - ), - const SizedBox(height: 24), - ], - ); - }, - ), - ); - } -} diff --git a/lib/community/notifications_screen.dart b/lib/community/notifications_screen.dart deleted file mode 100644 index 163ea699..00000000 --- a/lib/community/notifications_screen.dart +++ /dev/null @@ -1,419 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/community_repository.dart'; -import 'models/community_notification.dart'; -import 'models/notification_prefs.dart'; -import 'notification_settings_screen.dart'; -import 'post_thread_screen.dart'; - -/// App-bar bell with an unread badge. The badge respects the user's -/// notification preferences: muted groups and a global "off" switch do not -/// contribute to the count. -class CommunityNotificationsBell extends StatelessWidget { - const CommunityNotificationsBell({super.key}); - - @override - Widget build(BuildContext context) { - final repo = CommunityRepository.instance; - final scheme = Theme.of(context).colorScheme; - return StreamBuilder( - stream: repo.watchMyNotificationPrefs(), - builder: (context, prefsSnap) { - final prefs = prefsSnap.data ?? NotificationPrefs.defaults; - return StreamBuilder>( - stream: repo.watchMyNotifications(), - builder: (context, snap) { - final items = snap.data ?? const []; - final unread = items - .where((n) => !n.read && prefs.allows(n.groupId)) - .length; - return Stack( - clipBehavior: Clip.none, - children: [ - IconButton( - icon: Icon(prefs.globalEnabled - ? Icons.notifications_none - : Icons.notifications_off_outlined), - tooltip: "Notifications", - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const NotificationsScreen()), - ), - ), - if (unread > 0) - Positioned( - top: 6, - right: 4, - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: scheme.error, - borderRadius: BorderRadius.circular(8), - ), - constraints: const BoxConstraints(minWidth: 16), - child: Text( - unread > 99 ? "99+" : "$unread", - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ); - }, - ); - }, - ); - } -} - -/// Compact bell + unread-count badge meant to be overlaid on top of -/// another label (e.g. the "Community" entry on the login screen). Tapping -/// opens the notifications list. The unread count respects the user's -/// notification preferences (muted groups / global off don't count), and -/// the bell shows an "off" glyph when notifications are globally disabled. -class CommunityNotificationsBadge extends StatelessWidget { - final double iconSize; - const CommunityNotificationsBadge({super.key, this.iconSize = 18}); - - @override - Widget build(BuildContext context) { - final repo = CommunityRepository.instance; - final scheme = Theme.of(context).colorScheme; - return StreamBuilder( - stream: repo.watchMyNotificationPrefs(), - builder: (context, prefsSnap) { - final prefs = prefsSnap.data ?? NotificationPrefs.defaults; - return StreamBuilder>( - stream: repo.watchMyNotifications(), - builder: (context, snap) { - final items = snap.data ?? const []; - final unread = items - .where((n) => !n.read && prefs.allows(n.groupId)) - .length; - return InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const NotificationsScreen()), - ), - child: Stack( - clipBehavior: Clip.none, - children: [ - Icon( - prefs.globalEnabled - ? Icons.notifications_none - : Icons.notifications_off_outlined, - size: iconSize, - ), - if (unread > 0) - Positioned( - top: -5, - right: -6, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: scheme.error, - borderRadius: BorderRadius.circular(8), - ), - constraints: const BoxConstraints(minWidth: 14), - child: Text( - unread > 99 ? "99+" : "$unread", - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white, - fontSize: 9, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), - ); - }, - ); - }, - ); - } -} - -class NotificationsScreen extends StatelessWidget { - const NotificationsScreen({super.key}); - - String _relativeTime(DateTime t) { - final d = DateTime.now().difference(t); - if (d.inMinutes < 1) return "just now"; - if (d.inHours < 1) return "${d.inMinutes}m ago"; - if (d.inDays < 1) return "${d.inHours}h ago"; - if (d.inDays < 7) return "${d.inDays}d ago"; - final w = d.inDays ~/ 7; - if (w < 5) return "${w}w ago"; - final mo = d.inDays ~/ 30; - if (mo < 12) return "${mo}mo ago"; - return "${d.inDays ~/ 365}y ago"; - } - - String _reasonText(CommunityNotification n) => n.isOwnerReason - ? "${n.actorName} replied in your group ${n.groupName}" - : "${n.actorName} replied to your topic in ${n.groupName}"; - - Future _open( - BuildContext context, CommunityNotification n) async { - final repo = CommunityRepository.instance; - // Mark read first so the badge updates even if navigation is cancelled. - try { - if (!n.read) await repo.markNotificationRead(n.id); - } catch (_) {/* non-fatal */} - final membership = await repo.fetchMyMembership(n.groupId); - if (!context.mounted) return; - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PostThreadScreen( - groupId: n.groupId, - groupName: n.groupName, - topicId: n.topicId, - isOwner: membership?.isOwner ?? false, - canPost: membership?.isActive ?? false, - ), - ), - ); - } - - @override - Widget build(BuildContext context) { - final repo = CommunityRepository.instance; - final scheme = Theme.of(context).colorScheme; - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Notifications"), - actions: [ - IconButton( - icon: const Icon(Icons.done_all), - tooltip: "Mark all read", - onPressed: () async { - try { - await repo.markAllNotificationsRead(); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Couldn't update: $e", - const Icon(Icons.error, color: Colors.red), 3); - } - } - }, - ), - IconButton( - icon: const Icon(Icons.settings_outlined), - tooltip: "Notification settings", - onPressed: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const NotificationSettingsScreen()), - ), - ), - ], - ), - body: StreamBuilder( - stream: repo.watchMyNotificationPrefs(), - builder: (context, prefsSnap) { - final prefs = prefsSnap.data ?? NotificationPrefs.defaults; - return StreamBuilder>( - stream: repo.watchMyNotifications(), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - final all = snap.data ?? const []; - // Apply preferences on read: hide muted groups, and hide - // everything when notifications are globally off. - final visible = - all.where((n) => prefs.allows(n.groupId)).toList(); - - return Column( - children: [ - if (!prefs.globalEnabled) - _OffBanner( - onManage: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => - const NotificationSettingsScreen()), - ), - ), - Expanded( - child: visible.isEmpty - ? _EmptyNotifications( - globalEnabled: prefs.globalEnabled) - : ListView.separated( - itemCount: visible.length, - separatorBuilder: (_, __) => - const Divider(height: 1), - itemBuilder: (context, i) { - final n = visible[i]; - return Dismissible( - key: ValueKey(n.id), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - color: scheme.errorContainer, - padding: - const EdgeInsets.only(right: 20), - child: Icon(Icons.delete_outline, - color: scheme.onErrorContainer), - ), - onDismissed: (_) async { - try { - await repo.deleteNotification(n.id); - } catch (_) {/* best effort */} - }, - child: ListTile( - leading: CircleAvatar( - backgroundColor: n.read - ? scheme.surfaceContainerHighest - : scheme.primaryContainer, - child: Icon( - Icons.reply, - size: 20, - color: n.read - ? scheme.outline - : scheme.onPrimaryContainer, - ), - ), - title: Text( - _reasonText(n), - style: TextStyle( - fontWeight: n.read - ? FontWeight.normal - : FontWeight.w800, - fontSize: 14, - ), - ), - subtitle: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - if (n.snippet.isNotEmpty) - Padding( - padding: const EdgeInsets.only( - top: 2, bottom: 2), - child: Text( - n.snippet, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: scheme.onSurfaceVariant, - fontWeight: n.read - ? FontWeight.normal - : FontWeight.w600, - ), - ), - ), - Text( - _relativeTime(n.createdAt), - style: TextStyle( - fontSize: 11, - color: scheme.outline), - ), - ], - ), - trailing: n.read - ? null - : Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: scheme.primary, - shape: BoxShape.circle, - ), - ), - isThreeLine: n.snippet.isNotEmpty, - onTap: () => _open(context, n), - ), - ); - }, - ), - ), - ], - ); - }, - ); - }, - ), - ); - } -} - -class _OffBanner extends StatelessWidget { - final VoidCallback onManage; - const _OffBanner({required this.onManage}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - width: double.infinity, - color: scheme.surfaceContainerHighest.withAlpha(150), - padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), - child: Row( - children: [ - Icon(Icons.notifications_off_outlined, - size: 18, color: scheme.outline), - const SizedBox(width: 8), - const Expanded( - child: Text( - "Notifications are turned off. New replies won't be shown.", - style: TextStyle(fontSize: 12), - ), - ), - TextButton(onPressed: onManage, child: const Text("Manage")), - ], - ), - ); - } -} - -class _EmptyNotifications extends StatelessWidget { - final bool globalEnabled; - const _EmptyNotifications({required this.globalEnabled}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.notifications_none, size: 48, color: scheme.outline), - const SizedBox(height: 12), - Text( - globalEnabled ? "You're all caught up" : "Notifications are off", - style: - const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 6), - Text( - globalEnabled - ? "You'll be notified when someone replies to your topics, or to any post in a group you own." - : "Turn notifications on in settings to see replies to your topics and your groups.", - textAlign: TextAlign.center, - style: TextStyle(color: scheme.outline, fontSize: 13), - ), - ], - ), - ), - ); - } -} diff --git a/lib/community/post_compose_screen.dart b/lib/community/post_compose_screen.dart deleted file mode 100644 index bc37830b..00000000 --- a/lib/community/post_compose_screen.dart +++ /dev/null @@ -1,380 +0,0 @@ -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:image_picker/image_picker.dart'; - -import '../constants.dart'; -import '../storage.dart'; -import '../utils/toast.dart'; -import 'data/community_repository.dart'; -import 'models/group_post.dart'; - -class PostComposeScreen extends StatefulWidget { - final String groupId; - final String groupName; - - /// When set, this compose screen creates a reply attached to the topic - /// with this id instead of a new top-level topic. Reply mode hides the - /// airport / flight-plan attachments to keep replies conversational. - final String? replyToId; - - /// Author of the topic being replied to, shown in the app bar so the - /// user knows which conversation they're adding to. - final String? replyToAuthorName; - - const PostComposeScreen({ - super.key, - required this.groupId, - required this.groupName, - this.replyToId, - this.replyToAuthorName, - }); - - bool get isReply => replyToId != null; - - @override - State createState() => _PostComposeScreenState(); -} - -class _PostComposeScreenState extends State { - final _textCtrl = TextEditingController(); - final _airportCtrl = TextEditingController(); - final _picker = ImagePicker(); - - String? _attachedRouteText; - String? _attachedRouteName; - final List _images = []; - bool _busy = false; - - @override - void dispose() { - _textCtrl.dispose(); - _airportCtrl.dispose(); - super.dispose(); - } - - void _attachCurrentPlan() { - final route = Storage().route; - final asString = route.toString().trim(); - if (asString.isEmpty) { - Toast.showToast( - context, - "Your active flight plan is empty.", - const Icon(Icons.info, color: Colors.orange), - 2, - ); - return; - } - setState(() { - _attachedRouteText = asString; - _attachedRouteName = route.name; - }); - } - - void _clearPlan() { - setState(() { - _attachedRouteText = null; - _attachedRouteName = null; - }); - } - - Future _pickImage(ImageSource source) async { - if (_images.length >= GroupPost.maxImages) { - Toast.showToast( - context, - "Maximum ${GroupPost.maxImages} photos per post.", - const Icon(Icons.info, color: Colors.orange), - 2, - ); - return; - } - try { - // maxWidth + imageQuality keep uploads small (~200-400KB typical), - // which also strips EXIF for privacy. - final picked = await _picker.pickImage( - source: source, - maxWidth: 1920, - imageQuality: 80, - ); - if (picked == null) return; - final bytes = await picked.readAsBytes(); - setState(() => _images.add(bytes)); - } catch (e) { - if (mounted) { - Toast.showToast( - context, - "Could not load photo: $e", - const Icon(Icons.error, color: Colors.red), - 3, - ); - } - } - } - - void _showImageSource() { - showModalBottomSheet( - context: context, - builder: (ctx) => SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: const Icon(Icons.photo_library_outlined), - title: const Text("Choose from gallery"), - onTap: () { - Navigator.pop(ctx); - _pickImage(ImageSource.gallery); - }, - ), - ListTile( - leading: const Icon(Icons.camera_alt_outlined), - title: const Text("Take a photo"), - onTap: () { - Navigator.pop(ctx); - _pickImage(ImageSource.camera); - }, - ), - ], - ), - ), - ); - } - - Future _submit() async { - final text = _textCtrl.text.trim(); - final isReply = widget.isReply; - final hasContent = isReply - ? (text.isNotEmpty || _images.isNotEmpty) - : (text.isNotEmpty || _images.isNotEmpty || _attachedRouteText != null); - if (!hasContent) { - Toast.showToast( - context, - isReply ? "Write a reply or add a photo first" - : "Add text, a photo, or a plan first", - const Icon(Icons.info, color: Colors.orange), - 2); - return; - } - setState(() => _busy = true); - try { - await CommunityRepository.instance.createPost( - widget.groupId, - text: text, - attachedAirport: isReply || _airportCtrl.text.trim().isEmpty - ? null - : _airportCtrl.text.trim(), - attachedRouteText: isReply ? null : _attachedRouteText, - attachedRouteName: isReply ? null : _attachedRouteName, - images: List.from(_images), - replyToId: widget.replyToId, - ); - if (!mounted) return; - Navigator.pop(context); - } catch (e) { - if (mounted) { - Toast.showToast(context, "Post failed: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - final isReply = widget.isReply; - final title = isReply - ? (widget.replyToAuthorName?.isNotEmpty == true - ? "Reply to ${widget.replyToAuthorName}" - : "Reply") - : "Post to ${widget.groupName}"; - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: Text(title), - actions: [ - TextButton.icon( - onPressed: _busy ? null : _submit, - icon: _busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.send), - label: Text(isReply ? "Reply" : "Post"), - ), - ], - ), - body: AbsorbPointer( - absorbing: _busy, - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - children: [ - Expanded( - child: SingleChildScrollView( - child: Column( - children: [ - TextField( - controller: _textCtrl, - maxLength: 1000, - maxLines: null, - minLines: 5, - textCapitalization: TextCapitalization.sentences, - autofocus: true, - decoration: InputDecoration( - hintText: isReply - ? "Write a reply..." - : "Share something with the group...", - border: const OutlineInputBorder(), - ), - ), - if (_images.isNotEmpty) ...[ - const SizedBox(height: 8), - _imageStrip(), - ], - if (_attachedRouteText != null) ...[ - const SizedBox(height: 8), - _planChip(), - ], - ], - ), - ), - ), - const Divider(height: 16), - if (!isReply) ...[ - TextField( - controller: _airportCtrl, - maxLength: 4, - textCapitalization: TextCapitalization.characters, - decoration: const InputDecoration( - labelText: "Attach airport (optional)", - hintText: "ICAO, e.g. KBED", - border: OutlineInputBorder(), - prefixIcon: Icon(Icons.location_on_outlined), - ), - ), - const SizedBox(height: 8), - ], - Row( - children: [ - TextButton.icon( - onPressed: _showImageSource, - icon: const Icon(Icons.add_a_photo_outlined), - label: Text("Photo (${_images.length}/${GroupPost.maxImages})"), - ), - if (!isReply) ...[ - const SizedBox(width: 8), - TextButton.icon( - onPressed: _attachedRouteText == null - ? _attachCurrentPlan - : null, - icon: const Icon(Icons.route), - label: const Text("Attach plan"), - ), - ], - ], - ), - ], - ), - ), - ), - ); - } - - Widget _imageStrip() { - return SizedBox( - height: 90, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _images.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (context, i) { - return Stack( - clipBehavior: Clip.none, - children: [ - ClipRRect( - borderRadius: BorderRadius.circular(8), - child: Image.memory( - _images[i], - width: 90, - height: 90, - fit: BoxFit.cover, - ), - ), - Positioned( - top: -6, - right: -6, - child: Material( - color: Colors.black54, - shape: const CircleBorder(), - child: InkWell( - customBorder: const CircleBorder(), - onTap: () => setState(() => _images.removeAt(i)), - child: const Padding( - padding: EdgeInsets.all(2), - child: Icon(Icons.close, size: 14, color: Colors.white), - ), - ), - ), - ), - ], - ); - }, - ), - ); - } - - Widget _planChip() { - final scheme = Theme.of(context).colorScheme; - return Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: scheme.secondaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Row( - children: [ - Icon(Icons.route, size: 18, color: scheme.onSecondaryContainer), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _attachedRouteName?.isNotEmpty == true - ? _attachedRouteName! - : "Attached plan", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: scheme.onSecondaryContainer, - ), - ), - const SizedBox(height: 2), - Text( - _attachedRouteText!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 11, - color: scheme.onSecondaryContainer, - ), - ), - ], - ), - ), - IconButton( - onPressed: _clearPlan, - icon: Icon(MdiIcons.closeCircleOutline, size: 18), - tooltip: "Remove plan", - color: scheme.onSecondaryContainer, - ), - ], - ), - ); - } -} diff --git a/lib/community/post_thread_screen.dart b/lib/community/post_thread_screen.dart deleted file mode 100644 index 73cc7ed6..00000000 --- a/lib/community/post_thread_screen.dart +++ /dev/null @@ -1,263 +0,0 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; - -import '../constants.dart'; -import '../main_screen.dart'; -import '../plan/plan_route.dart'; -import '../storage.dart'; -import '../utils/toast.dart'; -import 'data/community_repository.dart'; -import 'models/group_post.dart'; -import 'post_compose_screen.dart'; -import 'widgets/post_card.dart'; - -/// A single threaded discussion: one topic (top-level post) followed by -/// its replies, oldest first, with a composer to add a reply. Opened from -/// the group feed when a topic is tapped or replied to. -class PostThreadScreen extends StatelessWidget { - final String groupId; - final String groupName; - final String topicId; - final bool isOwner; - final bool canPost; - - const PostThreadScreen({ - super.key, - required this.groupId, - required this.groupName, - required this.topicId, - required this.isOwner, - required this.canPost, - }); - - Future _reply(BuildContext context, GroupPost topic) async { - await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => PostComposeScreen( - groupId: groupId, - groupName: groupName, - replyToId: topic.id, - replyToAuthorName: topic.authorName, - ), - ), - ); - } - - Future _confirmDelete( - BuildContext context, GroupPost post, bool isTopic) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text(isTopic ? "Delete topic?" : "Delete reply?"), - content: Text(isTopic - ? "This deletes the topic and all of its replies. This cannot be undone." - : "This cannot be undone."), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton.tonal( - style: FilledButton.styleFrom(foregroundColor: Colors.red), - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Delete"), - ), - ], - ), - ); - if (ok != true || !context.mounted) return; - try { - await CommunityRepository.instance.deletePost(groupId, post.id); - // Removing the topic closes the thread; a removed reply just - // disappears from the live list below. - if (isTopic && context.mounted) { - Navigator.pop(context); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Delete failed: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - @override - Widget build(BuildContext context) { - final myUid = FirebaseAuth.instance.currentUser?.uid; - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Discussion"), - ), - floatingActionButton: canPost - ? StreamBuilder( - stream: - CommunityRepository.instance.watchPost(groupId, topicId), - builder: (context, snap) { - final topic = snap.data; - if (topic == null) return const SizedBox.shrink(); - return FloatingActionButton.extended( - onPressed: () => _reply(context, topic), - icon: const Icon(Icons.reply), - label: const Text("Reply"), - ); - }, - ) - : null, - body: StreamBuilder( - stream: CommunityRepository.instance.watchPost(groupId, topicId), - builder: (context, topicSnap) { - if (topicSnap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - final topic = topicSnap.data; - if (topic == null) { - return const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text( - "This topic has been deleted.", - textAlign: TextAlign.center, - ), - ), - ); - } - final canDeleteTopic = - isOwner || (myUid != null && myUid == topic.authorUid); - return StreamBuilder>( - stream: - CommunityRepository.instance.watchReplies(groupId, topic.id), - builder: (context, repliesSnap) { - final replies = repliesSnap.data ?? const []; - final repliesError = repliesSnap.error; - return ListView( - padding: const EdgeInsets.only(top: 8, bottom: 88), - children: [ - PostCard( - post: topic, - canDelete: canDeleteTopic, - onDelete: () => _confirmDelete(context, topic, true), - onTapAirport: () { - Toast.showToast( - context, - "Search for ${topic.attachedAirport} in the Find tab", - Icon(MdiIcons.airport), - 3, - ); - }, - onLoadRoute: topic.hasRoute - ? () => _confirmLoadRoute(context, topic) - : null, - ), - Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), - child: Text( - replies.isEmpty - ? "No replies yet" - : replies.length == 1 - ? "1 reply" - : "${replies.length} replies", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - if (repliesSnap.connectionState == ConnectionState.waiting && - replies.isEmpty) - const Padding( - padding: EdgeInsets.all(24), - child: Center(child: CircularProgressIndicator()), - ), - if (repliesError != null) - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Text( - "Couldn't load replies: $repliesError", - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 12), - ), - ), - for (final r in replies) - Padding( - padding: const EdgeInsets.only(left: 16), - child: PostCard( - post: r, - canDelete: isOwner || - (myUid != null && myUid == r.authorUid), - onDelete: () => _confirmDelete(context, r, false), - ), - ), - if (canPost && replies.isEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 0), - child: Text( - "Be the first to reply to this topic.", - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - ), - ), - ), - ], - ); - }, - ); - }, - ), - ); - } - - Future _confirmLoadRoute(BuildContext context, GroupPost p) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text("Load shared plan?"), - content: Text( - "This will replace your current flight plan with:\n\n" - "${p.attachedRouteText}\n\n" - "Your current plan will be lost unless you've saved it.", - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton.icon( - onPressed: () => Navigator.pop(ctx, true), - icon: const Icon(Icons.download, size: 18), - label: const Text("Load to PLAN"), - ), - ], - ), - ); - if (ok != true || !context.mounted) return; - try { - final name = (p.attachedRouteName?.isNotEmpty == true) - ? p.attachedRouteName! - : "Shared plan"; - final loaded = await PlanRoute.fromLine(name, p.attachedRouteText!); - Storage().route.copyFrom(loaded); - Storage().route.setCurrentWaypoint(0); - if (!context.mounted) return; - Navigator.popUntil(context, (r) => r.isFirst); - MainScreenState.gotoPlan(); - Toast.showToast( - context, - "Loaded \"$name\" into PLAN", - const Icon(Icons.check, color: Colors.green), - 3, - ); - } catch (e) { - if (context.mounted) { - Toast.showToast( - context, - "Couldn't load plan: $e", - const Icon(Icons.error, color: Colors.red), - 4, - ); - } - } - } -} diff --git a/lib/community/profile_edit_screen.dart b/lib/community/profile_edit_screen.dart deleted file mode 100644 index 5e7865a2..00000000 --- a/lib/community/profile_edit_screen.dart +++ /dev/null @@ -1,173 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../utils/firestore_write.dart'; -import '../utils/toast.dart'; -import 'data/community_repository.dart'; -import 'models/pilot_profile.dart'; - -class ProfileEditScreen extends StatefulWidget { - final PilotProfile profile; - const ProfileEditScreen({super.key, required this.profile}); - - @override - State createState() => _ProfileEditScreenState(); -} - -class _ProfileEditScreenState extends State { - late final TextEditingController _nameCtrl; - late final TextEditingController _airportCtrl; - late final TextEditingController _bioCtrl; - late final TextEditingController _ratingsCtrl; - late final TextEditingController _aircraftCtrl; - bool _busy = false; - - @override - void initState() { - super.initState(); - _nameCtrl = TextEditingController(text: widget.profile.displayName); - _airportCtrl = TextEditingController(text: widget.profile.homeAirport ?? ""); - _bioCtrl = TextEditingController(text: widget.profile.bio ?? ""); - _ratingsCtrl = TextEditingController(text: widget.profile.ratings.join(", ")); - _aircraftCtrl = - TextEditingController(text: widget.profile.aircraftTypes.join(", ")); - } - - @override - void dispose() { - _nameCtrl.dispose(); - _airportCtrl.dispose(); - _bioCtrl.dispose(); - _ratingsCtrl.dispose(); - _aircraftCtrl.dispose(); - super.dispose(); - } - - List _splitCsv(String input) => input - .split(",") - .map((s) => s.trim()) - .where((s) => s.isNotEmpty) - .toList(growable: false); - - Future _save() async { - final name = _nameCtrl.text.trim(); - if (name.length < 2) { - Toast.showToast(context, "Display name is required", - const Icon(Icons.info, color: Colors.orange), 3); - return; - } - setState(() => _busy = true); - try { - final updated = widget.profile.copyWith( - displayName: name, - homeAirport: _airportCtrl.text.trim().isEmpty - ? null - : _airportCtrl.text.trim().toUpperCase(), - bio: _bioCtrl.text.trim().isEmpty ? null : _bioCtrl.text.trim(), - ratings: _splitCsv(_ratingsCtrl.text), - aircraftTypes: _splitCsv(_aircraftCtrl.text), - ); - final sync = await commitWithOfflineFallback( - CommunityRepository.instance.saveMyProfile(updated)); - if (!mounted) return; - if (sync == WriteSyncResult.queuedOffline) { - Toast.showToast( - context, - "You're offline — profile saved on this device and will upload when you reconnect.", - const Icon(Icons.cloud_off, color: Colors.orange), - 4); - } - Navigator.pop(context); - } catch (e) { - if (mounted) { - final msg = e is StateError ? e.message : e.toString(); - Toast.showToast(context, "Save failed: $msg", - const Icon(Icons.error, color: Colors.red), 4); - } - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Edit Profile"), - actions: [ - TextButton.icon( - onPressed: _busy ? null : _save, - icon: _busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.check), - label: const Text("Save"), - ), - ], - ), - body: AbsorbPointer( - absorbing: _busy, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - TextField( - controller: _nameCtrl, - maxLength: 40, - decoration: const InputDecoration( - labelText: "Display name", - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.words, - ), - const SizedBox(height: 8), - TextField( - controller: _airportCtrl, - maxLength: 4, - textCapitalization: TextCapitalization.characters, - decoration: const InputDecoration( - labelText: "Home airport (ICAO)", - hintText: "e.g. KBED", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - TextField( - controller: _bioCtrl, - maxLength: 200, - maxLines: 3, - decoration: const InputDecoration( - labelText: "Bio (optional)", - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox(height: 8), - TextField( - controller: _ratingsCtrl, - decoration: const InputDecoration( - labelText: "Ratings (comma separated)", - hintText: "PPL, IFR, CFI", - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.characters, - ), - const SizedBox(height: 8), - TextField( - controller: _aircraftCtrl, - decoration: const InputDecoration( - labelText: "Aircraft I fly (comma separated)", - hintText: "C172, PA28, DA40", - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.characters, - ), - ], - ), - ), - ); - } -} diff --git a/lib/community/widgets/group_card.dart b/lib/community/widgets/group_card.dart deleted file mode 100644 index 76d9aae4..00000000 --- a/lib/community/widgets/group_card.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; - -import '../models/pilot_group.dart'; - -class GroupCard extends StatelessWidget { - final PilotGroup group; - final VoidCallback onTap; - final Widget? trailing; - - const GroupCard({ - super.key, - required this.group, - required this.onTap, - this.trailing, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: scheme.primaryContainer, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - group.isPrivate ? MdiIcons.accountGroupOutline : MdiIcons.accountGroup, - color: scheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - group.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (group.isPrivate) - Padding( - padding: const EdgeInsets.only(left: 6), - child: Icon(Icons.lock_outline, - size: 14, color: scheme.outline), - ), - ], - ), - if (group.description.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - group.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: scheme.onSurfaceVariant, - ), - ), - ], - const SizedBox(height: 6), - Wrap( - spacing: 8, - runSpacing: 4, - children: [ - _chip( - context, - icon: Icons.people_outline, - label: "${group.memberCount} member${group.memberCount == 1 ? '' : 's'}", - ), - if (group.homeAirport != null && group.homeAirport!.isNotEmpty) - _chip(context, - icon: MdiIcons.airport, label: group.homeAirport!), - _chip( - context, - icon: Icons.forum_outlined, - label: "${group.postCount} post${group.postCount == 1 ? '' : 's'}", - ), - ], - ), - ], - ), - ), - if (trailing != null) ...[ - const SizedBox(width: 8), - trailing!, - ], - ], - ), - ), - ), - ); - } - - Widget _chip(BuildContext context, - {required IconData icon, required String label}) { - final scheme = Theme.of(context).colorScheme; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 12, color: scheme.outline), - const SizedBox(width: 4), - Text(label, - style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant)), - ], - ), - ); - } -} diff --git a/lib/community/widgets/join_leave_button.dart b/lib/community/widgets/join_leave_button.dart deleted file mode 100644 index ffc327ac..00000000 --- a/lib/community/widgets/join_leave_button.dart +++ /dev/null @@ -1,105 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../data/community_repository.dart'; -import '../models/group_member.dart'; -import '../models/pilot_group.dart'; - -/// Smart button that switches between Join / Requested / Leave / Owner-only -/// based on the user's current membership. -class JoinLeaveButton extends StatefulWidget { - final PilotGroup group; - final GroupMember? membership; - final void Function(String message)? onMessage; - - const JoinLeaveButton({ - super.key, - required this.group, - required this.membership, - this.onMessage, - }); - - @override - State createState() => _JoinLeaveButtonState(); -} - -class _JoinLeaveButtonState extends State { - bool _busy = false; - - void _say(String m) { - // Bail if the button was unmounted while the join/leave future was - // in flight. Otherwise the parent's onMessage closure may call - // Toast.showToast against a deactivated BuildContext, which throws - // "Looking up a deactivated widget's ancestor is unsafe". - if (!mounted) return; - final cb = widget.onMessage; - if (cb != null) cb(m); - } - - Future _join() async { - setState(() => _busy = true); - try { - final status = - await CommunityRepository.instance.joinGroup(widget.group.id); - _say(status == MemberStatus.pending - ? "Request sent. Waiting for owner approval." - : "Joined ${widget.group.name}"); - } catch (e) { - _say("Could not join: $e"); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - Future _leave() async { - setState(() => _busy = true); - try { - await CommunityRepository.instance.leaveGroup(widget.group.id); - _say("Left ${widget.group.name}"); - } catch (e) { - _say("Could not leave: $e"); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - if (_busy) { - return const SizedBox( - width: 28, - height: 28, - child: Padding( - padding: EdgeInsets.all(4), - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - } - - final m = widget.membership; - if (m == null) { - return FilledButton.icon( - onPressed: _join, - icon: Icon(widget.group.isPrivate ? Icons.lock_outline : Icons.add), - label: Text(widget.group.isPrivate ? "Request to Join" : "Join"), - ); - } - if (m.isOwner) { - return const Chip( - avatar: Icon(Icons.star, size: 16), - label: Text("Owner"), - ); - } - if (m.isPending) { - return OutlinedButton.icon( - onPressed: _leave, - icon: const Icon(Icons.hourglass_empty), - label: const Text("Requested"), - ); - } - return OutlinedButton.icon( - onPressed: _leave, - icon: const Icon(Icons.logout), - label: const Text("Leave"), - ); - } -} diff --git a/lib/community/widgets/post_card.dart b/lib/community/widgets/post_card.dart deleted file mode 100644 index 14b61b98..00000000 --- a/lib/community/widgets/post_card.dart +++ /dev/null @@ -1,345 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; -import 'package:widget_zoom/widget_zoom.dart'; - -import '../models/group_post.dart'; - -class PostCard extends StatelessWidget { - final GroupPost post; - final bool canDelete; - final VoidCallback? onDelete; - final VoidCallback? onTapAirport; - final VoidCallback? onLoadRoute; - - /// Tapping the card opens the topic's thread. Supplied in the feed; left - /// null inside the thread screen where the topic is already open. - final VoidCallback? onOpenThread; - - /// Adds a reply to this topic. When supplied, a reply footer (count + - /// "Reply" button) is shown. - final VoidCallback? onReply; - - /// When true the post is rendered with heavier (bold) text to signal it - /// hasn't been read yet. - final bool unread; - - const PostCard({ - super.key, - required this.post, - this.canDelete = false, - this.onDelete, - this.onTapAirport, - this.onLoadRoute, - this.onOpenThread, - this.onReply, - this.unread = false, - }); - - String _relativeTime(DateTime t) { - final d = DateTime.now().difference(t); - if (d.inMinutes < 1) return "just now"; - if (d.inHours < 1) return "${d.inMinutes}m ago"; - if (d.inDays < 1) return "${d.inHours}h ago"; - if (d.inDays < 7) return "${d.inDays}d ago"; - final w = d.inDays ~/ 7; - if (w < 5) return "${w}w ago"; - final mo = d.inDays ~/ 30; - if (mo < 12) return "${mo}mo ago"; - return "${d.inDays ~/ 365}y ago"; - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final initials = post.authorName.isNotEmpty - ? post.authorName.trim().substring(0, 1).toUpperCase() - : "?"; - - final showReplyFooter = onReply != null || post.replyCount > 0; - - return Card( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: onOpenThread, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 8, 10), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - CircleAvatar( - radius: 16, - backgroundColor: scheme.primaryContainer, - child: Text( - initials, - style: TextStyle( - color: scheme.onPrimaryContainer, - fontWeight: FontWeight.w600, - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - post.authorName, - style: TextStyle( - fontWeight: - unread ? FontWeight.w800 : FontWeight.w600), - ), - Text( - _relativeTime(post.createdAt), - style: TextStyle( - fontSize: 11, - color: scheme.outline, - ), - ), - ], - ), - ), - if (canDelete) - IconButton( - onPressed: onDelete, - icon: Icon(Icons.delete_outline, - size: 18, color: scheme.error), - tooltip: "Delete post", - ), - ], - ), - if (post.text.isNotEmpty) ...[ - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 4, right: 8), - child: Text( - post.text, - style: TextStyle( - fontSize: 14, - height: 1.35, - fontWeight: unread ? FontWeight.bold : FontWeight.normal, - ), - ), - ), - ], - if (post.hasMedia) ...[ - const SizedBox(height: 10), - _MediaGrid(urls: post.mediaUrls), - ], - if (post.hasRoute) ...[ - const SizedBox(height: 10), - _RouteChip( - routeText: post.attachedRouteText!, - routeName: post.attachedRouteName, - onLoad: onLoadRoute, - ), - ], - if (post.attachedAirport != null && post.attachedAirport!.isNotEmpty) ...[ - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.only(left: 4), - child: ActionChip( - avatar: Icon(MdiIcons.airport, size: 16), - label: Text(post.attachedAirport!), - onPressed: onTapAirport, - ), - ), - ], - if (showReplyFooter) ...[ - const SizedBox(height: 4), - _ReplyFooter( - replyCount: post.replyCount, - onReply: onReply, - onOpenThread: onOpenThread, - ), - ], - ], - ), - ), - ), - ); - } -} - -class _ReplyFooter extends StatelessWidget { - final int replyCount; - final VoidCallback? onReply; - final VoidCallback? onOpenThread; - const _ReplyFooter({ - required this.replyCount, - this.onReply, - this.onOpenThread, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final label = replyCount == 0 - ? "Reply" - : replyCount == 1 - ? "1 reply" - : "$replyCount replies"; - return Row( - children: [ - if (onReply != null) - TextButton.icon( - onPressed: onReply, - icon: const Icon(Icons.reply, size: 16), - label: const Text("Reply"), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8), - minimumSize: const Size(0, 32), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - const Spacer(), - if (replyCount > 0 && onOpenThread != null) - TextButton.icon( - onPressed: onOpenThread, - icon: Icon(Icons.forum_outlined, size: 15, color: scheme.primary), - label: Text( - label, - style: TextStyle(color: scheme.primary, fontSize: 12), - ), - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 8), - minimumSize: const Size(0, 32), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ) - else if (replyCount > 0) - Padding( - padding: const EdgeInsets.only(right: 8), - child: Text( - label, - style: TextStyle(color: scheme.outline, fontSize: 12), - ), - ), - ], - ); - } -} - -class _MediaGrid extends StatelessWidget { - final List urls; - const _MediaGrid({required this.urls}); - - @override - Widget build(BuildContext context) { - if (urls.length == 1) { - return _thumb(urls.first, double.infinity, 220, BoxFit.cover); - } - return SizedBox( - height: 140, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: urls.length, - separatorBuilder: (_, __) => const SizedBox(width: 6), - itemBuilder: (context, i) => - _thumb(urls[i], 140, 140, BoxFit.cover), - ), - ); - } - - Widget _thumb(String url, double w, double h, BoxFit fit) { - return ClipRRect( - borderRadius: BorderRadius.circular(8), - child: WidgetZoom( - heroAnimationTag: url, - zoomWidget: CachedNetworkImage( - imageUrl: url, - width: w, - height: h, - fit: fit, - placeholder: (_, __) => Container( - width: w == double.infinity ? null : w, - height: h, - color: Colors.black12, - child: const Center( - child: SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ), - ), - errorWidget: (_, __, ___) => Container( - width: w == double.infinity ? null : w, - height: h, - color: Colors.black26, - child: const Icon(Icons.broken_image, color: Colors.white70), - ), - ), - ), - ); - } -} - -class _RouteChip extends StatelessWidget { - final String routeText; - final String? routeName; - final VoidCallback? onLoad; - const _RouteChip({ - required this.routeText, - this.routeName, - this.onLoad, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - margin: const EdgeInsets.only(left: 4, right: 4), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: scheme.secondaryContainer, - borderRadius: BorderRadius.circular(8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.route, size: 16, color: scheme.onSecondaryContainer), - const SizedBox(width: 6), - Expanded( - child: Text( - routeName?.isNotEmpty == true ? routeName! : "Shared flight plan", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: scheme.onSecondaryContainer, - ), - ), - ), - if (onLoad != null) - FilledButton.tonalIcon( - onPressed: onLoad, - icon: const Icon(Icons.download, size: 16), - label: const Text("Load"), - style: FilledButton.styleFrom( - padding: const EdgeInsets.symmetric(horizontal: 10), - minimumSize: const Size(0, 32), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ], - ), - const SizedBox(height: 6), - Text( - routeText, - style: TextStyle( - fontFamily: 'monospace', - fontSize: 12, - color: scheme.onSecondaryContainer, - ), - ), - ], - ), - ); - } -} diff --git a/lib/constants.dart b/lib/constants.dart index 55886524..685af9fe 100644 --- a/lib/constants.dart +++ b/lib/constants.dart @@ -98,11 +98,31 @@ class Constants { static final bool shouldShowPdf = !(Platform.isLinux); static final bool shouldShowBluetoothSpp = (Platform.isAndroid); static final bool shouldShouldReview = (Platform.isMacOS || Platform.isIOS | Platform.isAndroid || Platform.isWindows); - static final bool shouldShowProServices = (Platform.isIOS || Platform.isAndroid); - // Whether the Firebase-backed cloud features (e.g. Airport Businesses & - // Reviews) are available. Firebase is only initialized on these platforms - // (see main.dart), so this is a capability gate, NOT a Pro/paywall gate. - static final bool firebaseAvailable = (Platform.isIOS || Platform.isAndroid); + // Whether the Flight Intelligence (AI) feature is offered. It talks to a + // user-supplied OpenAI-compatible endpoint over plain HTTP, so it works on + // every platform and needs no login or cloud backend. + static final bool shouldShowAi = true; + + // Whether this is the AvareX-EU build. This fork ships the EU app + // (applicationId com.naresh.avarex.eu), so it defaults to true; upstream/US + // builds can override with --dart-define=AVAREX_EU=false. When true, the + // internet "Radar" product is sourced from RainViewer (global coverage, + // animated) instead of the US-only Iowa Mesonet NEXRAD mosaic. + static const bool isEu = bool.fromEnvironment('AVAREX_EU', defaultValue: true); + + // RainViewer radar color scheme IDs (0..8) and their display names. + // See https://www.rainviewer.com/api/color-schemes.html + static const List rainViewerColorSchemes = [ + "Black and White", // 0 + "Original", // 1 + "Universal Blue", // 2 + "TITAN", // 3 + "The Weather Channel", // 4 + "Meteored", // 5 + "NEXRAD Level III", // 6 + "Rainbow (SELEX-SI)", // 7 + "Dark Sky", // 8 + ]; } \ No newline at end of file diff --git a/lib/data/aeronautical_database.dart b/lib/data/aeronautical_database.dart new file mode 100644 index 00000000..03b007c6 --- /dev/null +++ b/lib/data/aeronautical_database.dart @@ -0,0 +1,150 @@ +import 'package:latlong2/latlong.dart'; + +import '../destination/destination.dart'; +import '../storage.dart'; +import 'main_database_helper.dart'; +import '../ofm/ofm_data_provider.dart'; +import '../openaip/openaip_database.dart'; + +class AeronauticalDatabase { + AeronauticalDatabase._(); + + static final AeronauticalDatabase instance = AeronauticalDatabase._(); + + OfmDataProvider get _ofm => OfmDataProvider(dataDir: Storage().dataDir); + + Future get _openAip async => + OpenAipDatabase(database: await OpenAipDatabase.open(Storage().dataDir)); + + Future> findDestinations(String match, {bool exact = false}) async { + final faa = await MainDatabaseHelper.db.findDestinations(match, exact: exact); + List ofm = const []; + try { + ofm = await _ofm.findDestinations(match, exact: exact); + } catch (_) { + // OFM is optional; FAA search remains available before any region is installed. + } + List openAip = const []; + try { + openAip = await (await _openAip).findDestinations(match, exact: exact); + } catch (_) { + // openAIP is optional. + } + final results = [...faa]; + final keys = faa.map((d) => '${d.source}:${d.locationID}:${d.type}').toSet(); + for (final item in ofm) { + if (keys.add('${item.source}:${item.locationID}:${item.type}')) results.add(item); + } + for (final item in openAip) { + if (keys.add('${item.source}:${item.locationID}:${item.type}')) results.add(item); + } + return results; + } + + Future> findNear(LatLng point, {double factor = 0.001}) async { + final faa = await MainDatabaseHelper.db.findNear(point, factor: factor); + List ofm = const []; + try { + ofm = await _ofm.findNear(point, factor: factor); + } catch (_) { + // OFM is optional. + } + List openAip = const []; + try { + openAip = await (await _openAip).findNear(point, factor: factor); + } catch (_) { + // openAIP is optional. + } + final gps = faa.where((d) => Destination.isGps(d.type)).toList(); + return [...faa.where((d) => !Destination.isGps(d.type)), ...ofm, ...openAip, ...gps]; + } + + Future> findNearestAirportsWithRunways( + LatLng point, + int runwayLengthFeet, + ) async { + final faa = await MainDatabaseHelper.db.findNearestAirportsWithRunways(point, runwayLengthFeet); + List ofm = const []; + try { + ofm = await _ofm.findNearestAirportsWithRunways(point, runwayLengthFeet); + } catch (_) { + // OFM is optional. + } + List openAip = const []; + try { + openAip = await (await _openAip).findNearestAirportsWithRunways(point, runwayLengthFeet); + } catch (_) { + // openAIP is optional. + } + final results = [...faa, ...ofm, ...openAip]; + results.sort((a, b) { + final da = const Distance()(point, a.coordinate); + final db = const Distance()(point, b.coordinate); + return da.compareTo(db); + }); + return results; + } + + Future> findNearestVOR(LatLng point) async { + final faa = await MainDatabaseHelper.db.findNearestVOR(point); + List ofm = const []; + try { + ofm = await _ofm.findNearestVOR(point); + } catch (_) { + // OFM is optional. + } + List openAip = const []; + try { + openAip = await (await _openAip).findNearestVOR(point); + } catch (_) { + // openAIP is optional. + } + final results = [...faa, ...ofm, ...openAip]; + results.sort((a, b) => const Distance()(point, a.coordinate) + .compareTo(const Distance()(point, b.coordinate))); + return results.take(3).toList(); + } + + Future findOfmAirport(String code) async { + try { + return await _ofm.findAirport(code); + } catch (_) { + return null; + } + } + + Future findAirport(String code, {String? source}) async { + if (source == 'OFM') return findOfmAirport(code); + if (source == 'openAIP') { + try { + return await (await _openAip).findAirport(code); + } catch (_) { + return null; + } + } + final faa = await MainDatabaseHelper.db.findAirport(code); + if (faa != null) return faa; + final ofm = await findOfmAirport(code); + if (ofm != null) return ofm; + try { + return await (await _openAip).findAirport(code); + } catch (_) { + return null; + } + } + + Future> findObstacles(LatLng point, double minimumMslFeet) async { + final faa = await MainDatabaseHelper.db.findObstacles(point, minimumMslFeet); + List openAip = const []; + try { + openAip = await (await _openAip).findObstacles( + latitude: point.latitude, + longitude: point.longitude, + minimumMslFeet: minimumMslFeet, + ); + } catch (_) { + // openAIP is optional. + } + return [...faa, ...openAip]; + } +} diff --git a/lib/data/app_settings.dart b/lib/data/app_settings.dart index cb212354..b097a34a 100644 --- a/lib/data/app_settings.dart +++ b/lib/data/app_settings.dart @@ -1,6 +1,8 @@ import 'package:avaremp/chart/chart.dart'; import 'package:avaremp/documents_screen.dart'; import 'package:avaremp/data/settings_cache_provider.dart'; +import 'package:avaremp/ofm/ofm_constants.dart'; +import 'package:avaremp/openaip/openaip_constants.dart'; class AppSettings { @@ -46,6 +48,16 @@ class AppSettings { provider.setBool("key-light-mode", lightMode); } + // Weather threat-coloring profile for the decoded METAR view. + // "IFR" (default) or "VFR"; governs the thresholds in MetarDecoder. + String getWeatherProfile() { + return provider.getValue("key-weather-profile", defaultValue: "IFR") as String; + } + + void setWeatherProfile(String profile) { + provider.setString("key-weather-profile", profile); + } + void setZoom(double zoom) { provider.setDouble("key-chart-zoom", zoom); } @@ -141,17 +153,53 @@ class AppSettings { } List getLayers() { - return (provider.getValue("key-layers-v52", defaultValue: - "Nav,Circles,Chart,Topo,Vector Map,CAP Grid,Elevation,Weather,TFR,Game TFR,Plate,Traffic,Obstacles,Tape,GeoJSON,PFD,Tracks") as String).split(","); + final legacyLayers = provider.getValue("key-layers-v54", defaultValue: + "Nav,Circles,Chart,Topo,Vector Map,${OfmConstants.layerName},${OfmConstants.dataLayerName},CAP Grid,Elevation,Weather,TFR,Game TFR,Plate,Traffic,Obstacles,Tape,GeoJSON,PFD,Tracks") as String; + final layers = (provider.getValue("key-layers-v55", defaultValue: legacyLayers) as String).split(","); + final legacy = layers.indexOf(OfmConstants.legacyLayerName); + if (legacy >= 0) layers[legacy] = OfmConstants.layerName; + if (!layers.contains(OpenAipConstants.dataLayerName)) { + final ofmData = layers.indexOf(OfmConstants.dataLayerName); + layers.insert(ofmData < 0 ? layers.length : ofmData + 1, OpenAipConstants.dataLayerName); + } + return layers; } List getLayersOpacity() { - return (provider.getValue("key-layers-opacity-v52", defaultValue: - "1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0") as String).split(",").map((String e) => double.parse(e)).toList(); + final legacy = provider.getValue("key-layers-opacity-v54", defaultValue: + "1,0,1,1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0") as String; + final v55 = provider.getValue("key-layers-opacity-v55", defaultValue: legacy) as String; + final bool hasSavedPreference = + provider.containsKey("key-layers-opacity-v56") || + provider.containsKey("key-layers-opacity-v55") || + provider.containsKey("key-layers-opacity-v54"); + final resolved = provider.getValue("key-layers-opacity-v56", + defaultValue: resolveLayersOpacityDefault(hasSavedPreference, v55)) as String; + final opacity = resolved.split(",").map((String e) => double.parse(e)).toList(); + if (opacity.length < getLayers().length) opacity.insert(7, 0); + return opacity; + } + + // Fresh installs default the Europe map layers ON: OFM VFR Chart (index 5), + // OFM Interactive Data (6) and openAIP Interactive Data (7). These layers + // render nothing until the corresponding regional data is installed, so this + // is harmless for US-only users. Users who already saved a layer-opacity set + // (v54/v55/v56) keep their existing choices untouched. + // + // Order matches getLayers(): + // Nav,Circles,Chart,Topo,Vector Map,OFM VFR Chart,OFM Interactive Data, + // openAIP Interactive Data,CAP Grid,Elevation,Weather,TFR,Game TFR,Plate, + // Traffic,Obstacles,Tape,GeoJSON,PFD,Tracks + static const String europeOnLayersOpacityDefault = + "1,0,1,1,0,1,1,1,0,0,0,0,0,0,0,0,0,0,0,0"; + + static String resolveLayersOpacityDefault( + bool hasSavedPreference, String savedOrLegacy) { + return hasSavedPreference ? savedOrLegacy : europeOnLayersOpacityDefault; } void setLayersOpacity(List opacity) { - provider.setString("key-layers-opacity-v52", opacity.map((double e) => e.toString()).toList().join(",")); + provider.setString("key-layers-opacity-v56", opacity.map((double e) => e.toString()).toList().join(",")); } void setCurrentPlateAirport(String name) { @@ -420,4 +468,14 @@ class AppSettings { provider.setString("key-weather-products-opacity-v2", opacity.map((double e) => e.toString()).toList().join(",")); } + /// RainViewer radar color scheme ID (0..8). Only used by the EU build's + /// internet Radar product. Defaults to 4 (The Weather Channel). + int getRadarColorScheme() { + return (provider.getValue("key-rainviewer-color-scheme", defaultValue: 4) as int); + } + + void setRadarColorScheme(int id) { + provider.setInt("key-rainviewer-color-scheme", id); + } + } \ No newline at end of file diff --git a/lib/data/user_database_helper.dart b/lib/data/user_database_helper.dart index 58389b74..2e09aaf9 100644 --- a/lib/data/user_database_helper.dart +++ b/lib/data/user_database_helper.dart @@ -90,7 +90,7 @@ class UserDatabaseHelper { return await openDatabase( path, - version: 7, + version: 8, onUpgrade: (Database db, int oldVersion, int newVersion) async { if (oldVersion <= 4 && newVersion > 4) { await db.execute("create table aiQueries(" @@ -125,6 +125,9 @@ class UserDatabaseHelper { await db.execute( "ALTER TABLE aircraft ADD COLUMN wnbData TEXT DEFAULT '';"); } + if (oldVersion <= 7 && newVersion > 7) { + await migrateRecentSourceSchema(db); + } }, onCreate: (Database db, int version) async { @@ -177,7 +180,10 @@ class UserDatabaseHelper { "Type text, " "ARPLatitude float, " "ARPLongitude float, " - "unique(LocationID, Type) on conflict replace);"); + "Source text default 'FAA', " + "SourceRegion text, " + "SourceCycle text, " + "unique(LocationID, Type, Source) on conflict replace);"); await db.execute("create table plan (" "id integer primary key autoincrement, " @@ -239,6 +245,37 @@ class UserDatabaseHelper { onOpen: (db) {}); } + static Future migrateRecentSourceSchema(Database db) async { + final columns = await db.rawQuery('pragma table_info(recent)'); + final names = columns.map((row) => row['name']).toSet(); + if (names.contains('Source')) { + return; + } + await db.transaction((transaction) async { + await transaction.execute('alter table recent rename to recent_legacy'); + await transaction.execute(''' +create table recent ( + id integer primary key autoincrement, + LocationID text, + FacilityName text, + Type text, + ARPLatitude float, + ARPLongitude float, + Source text default 'FAA', + SourceRegion text, + SourceCycle text, + unique(LocationID, Type, Source) on conflict replace +) +'''); + await transaction.execute(''' +insert into recent(id, LocationID, FacilityName, Type, ARPLatitude, ARPLongitude, Source) +select id, LocationID, FacilityName, Type, ARPLatitude, ARPLongitude, 'FAA' +from recent_legacy +'''); + await transaction.execute('drop table recent_legacy'); + }); + } + Future addRecent(Destination recent) async { final db = await database; diff --git a/lib/destination/destination.dart b/lib/destination/destination.dart index 5a927d67..9676373d 100644 --- a/lib/destination/destination.dart +++ b/lib/destination/destination.dart @@ -9,6 +9,7 @@ import 'package:latlong2/latlong.dart'; import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'destination_calculations.dart'; import 'package:avaremp/data/main_database_helper.dart'; +import 'package:avaremp/data/aeronautical_database.dart'; class Destination { @@ -16,6 +17,9 @@ class Destination { final String type; final String facilityName; final LatLng coordinate; + final String source; + final String sourceRegion; + final String sourceCycle; double? elevation; double? geoAltitude; double? geoVariation; @@ -27,11 +31,16 @@ class Destination { required this.type, required this.facilityName, required this.coordinate, + this.source = 'FAA', + this.sourceRegion = '', + this.sourceCycle = '', }) { - MainDatabaseHelper.db.getGeoInfo(coordinate).then((value) { - geoAltitude = value.$1; - geoVariation = value.$2; - }); + if (source == 'FAA') { + MainDatabaseHelper.db.getGeoInfo(coordinate).then((value) { + geoAltitude = value.$1; + geoVariation = value.$2; + }); + } } @override @@ -182,7 +191,10 @@ class Destination { } static bool isFix(String type) { - return type == "YREP-PT" || + return type == "FIX" || + type == "VFR-MRP" || + type == "MOUNTAIN-PASS" || + type == "YREP-PT" || type == "YRNAV-WP" || type == "NARTCC-BDRY" || type == "NAWY-INTXN" || @@ -212,6 +224,9 @@ class Destination { locationID: maps['LocationID'] as String, facilityName: maps['FacilityName'] as String, type: maps['Type'] as String, + source: (maps['Source'] ?? 'FAA').toString(), + sourceRegion: (maps['SourceRegion'] ?? '').toString(), + sourceCycle: (maps['SourceCycle'] ?? '').toString(), coordinate: LatLng(maps['ARPLatitude'] as double, maps['ARPLongitude'] as double)); } @@ -222,6 +237,9 @@ class Destination { "Type": type, "ARPLatitude": coordinate.latitude, "ARPLongitude": coordinate.longitude, + "Source": source, + "SourceRegion": sourceRegion, + "SourceCycle": sourceCycle, }; return map; } @@ -245,6 +263,9 @@ class NavDestination extends Destination { required super.type, required super.facilityName, required super.coordinate, + super.source, + super.sourceRegion, + super.sourceCycle, required this.class_, required this.hiwas,}); @@ -277,7 +298,10 @@ class FixDestination extends Destination { required super.locationID, required super.type, required super.facilityName, - required super.coordinate,}); + required super.coordinate, + super.source, + super.sourceRegion, + super.sourceCycle,}); factory FixDestination.fromMap(Map maps) { return FixDestination( @@ -318,6 +342,9 @@ class AirportDestination extends Destination { required super.type, required super.facilityName, required super.coordinate, + super.source, + super.sourceRegion, + super.sourceCycle, required this.frequencies, required this.awos, required this.runways, @@ -450,7 +477,17 @@ class DestinationFactory { String type = d.type; Destination ret = d; - if (Destination.isNav(type)) { + if ((d.source == 'OFM' || d.source == 'openAIP') && Destination.isAirport(type)) { + final destination = await AeronauticalDatabase.instance.findAirport( + d.locationID, + source: d.source, + ); + ret = destination ?? d; + } + else if (d.source == 'OFM' || d.source == 'openAIP') { + ret = d; + } + else if (Destination.isNav(type)) { NavDestination? destination = await MainDatabaseHelper.db.findNav(d.locationID); ret = destination ?? d; } diff --git a/lib/find_screen.dart b/lib/find_screen.dart index 487bc784..aa5dd784 100644 --- a/lib/find_screen.dart +++ b/lib/find_screen.dart @@ -7,7 +7,8 @@ import 'package:latlong2/latlong.dart'; import 'constants.dart'; import 'package:avaremp/destination/destination.dart'; import 'io/gps.dart'; -import 'data/main_database_helper.dart'; + +import 'data/aeronautical_database.dart'; import 'main_screen.dart'; class FindScreen extends StatefulWidget { @@ -36,7 +37,7 @@ class FindScreenState extends State { Widget build(BuildContext context) { bool searching = true; return FutureBuilder( - future: _searchText.isNotEmpty? (MainDatabaseHelper.db.findDestinations(_searchText)) : (_recent ? UserDatabaseHelper.db.getRecent() : MainDatabaseHelper.db.findNearestAirportsWithRunways(Gps.toLatLng(Storage().position), _runwayLength)), + future: _searchText.isNotEmpty? (AeronauticalDatabase.instance.findDestinations(_searchText)) : (_recent ? UserDatabaseHelper.db.getRecent() : AeronauticalDatabase.instance.findNearestAirportsWithRunways(Gps.toLatLng(Storage().position), _runwayLength)), builder: (context, snapshot) { if (snapshot.connectionState == ConnectionState.done) { _currentItems = snapshot.data; @@ -244,6 +245,17 @@ class FindScreenState extends State { style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), + if (item.source == 'OFM' || item.source == 'openAIP') ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.tertiaryContainer, + borderRadius: BorderRadius.circular(4), + ), + child: Text('${item.source} ${item.sourceRegion}', style: const TextStyle(fontSize: 11)), + ), + ], if (item.type == Destination.typeGps) IconButton( icon: Icon(Icons.edit, size: 16, color: Theme.of(context).colorScheme.outline), diff --git a/lib/gdl90/opensky_credentials.dart b/lib/gdl90/opensky_credentials.dart new file mode 100644 index 00000000..9baadb7b --- /dev/null +++ b/lib/gdl90/opensky_credentials.dart @@ -0,0 +1,68 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +/// Secure storage for the optional, user-supplied OpenSky Network API +/// credentials used by the internet (ADS-B) traffic layer. +/// +/// OpenSky uses the OAuth2 client-credentials flow: the pilot creates an API +/// client in their own OpenSky account and supplies the client_id and +/// client_secret here. Nothing is embedded in the app, source, logs, or +/// downloaded data, and each pilot uses their own account/credits. +/// +/// Internet traffic is ADVISORY ONLY: it is crowdsourced, delayed and +/// incomplete, and must never be used for separation or collision avoidance. +class OpenSkyCredentials { + static const _idKey = 'opensky-client-id'; + static const _secretKey = 'opensky-client-secret'; + static const _enabledKey = 'opensky-enabled'; + + final FlutterSecureStorage _storage; + + const OpenSkyCredentials( + {FlutterSecureStorage storage = const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + )}) + : _storage = storage; + + Future readClientId() async => + (await _storage.read(key: _idKey))?.trim() ?? ''; + + Future readClientSecret() async => + (await _storage.read(key: _secretKey))?.trim() ?? ''; + + /// Whether the pilot has turned the internet-traffic layer on. Off by + /// default; only meaningful when credentials are also present. + Future readEnabled() async => + (await _storage.read(key: _enabledKey)) == 'true'; + + /// True when credentials are present AND the feature is enabled. + Future isActive() async { + if (!await readEnabled()) return false; + final id = await readClientId(); + final secret = await readClientSecret(); + return id.isNotEmpty && secret.isNotEmpty; + } + + Future write({required String clientId, required String clientSecret}) async { + final id = clientId.trim(); + final secret = clientSecret.trim(); + if (id.isEmpty) { + await _storage.delete(key: _idKey); + } else { + await _storage.write(key: _idKey, value: id); + } + if (secret.isEmpty) { + await _storage.delete(key: _secretKey); + } else { + await _storage.write(key: _secretKey, value: secret); + } + } + + Future setEnabled(bool value) async => + _storage.write(key: _enabledKey, value: value ? 'true' : 'false'); + + Future clear() async { + await _storage.delete(key: _idKey); + await _storage.delete(key: _secretKey); + await _storage.delete(key: _enabledKey); + } +} diff --git a/lib/gdl90/opensky_service.dart b/lib/gdl90/opensky_service.dart new file mode 100644 index 00000000..5c443ee7 --- /dev/null +++ b/lib/gdl90/opensky_service.dart @@ -0,0 +1,189 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:avaremp/gdl90/opensky_credentials.dart'; +import 'package:avaremp/gdl90/traffic_report_message.dart'; +import 'package:avaremp/io/gps.dart'; +import 'package:avaremp/storage.dart'; +import 'package:avaremp/utils/app_log.dart'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; + +/// Internet (ADS-B) traffic provider backed by the OpenSky Network REST API. +/// +/// ADVISORY ONLY. This is crowdsourced ADS-B with coverage gaps and latency +/// (state vectors update on the order of 5-10 s plus network delay). It is a +/// supplement for situational awareness when no hardware ADS-B receiver is +/// connected. It must never be used for separation or collision avoidance; +/// connected GDL90 hardware traffic remains the real-time source. +/// +/// Auth: OpenSky uses the OAuth2 client-credentials flow. The pilot supplies +/// their own client_id/secret (see [OpenSkyCredentials]); a bearer token is +/// obtained and cached until shortly before expiry. Data is fetched for a +/// bounding box around ownship via GET /states/all and fed into the shared +/// [TrafficCache], so it renders through the existing traffic display. +class OpenSkyService { + OpenSkyService._(); + static final OpenSkyService instance = OpenSkyService._(); + + static const String _tokenUrl = + 'https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token'; + static const String _statesUrl = 'https://opensky-network.org/api/states/all'; + + static const String attribution = 'Traffic © The OpenSky Network'; + + // GDL90 traffic report message type id (cosmetic; used only in the log). + static const int _trafficType = 20; + static const double _mToFt = 3.28084; + + // Half-size of the bounding box (degrees) fetched around ownship. ~0.9 deg + // lat is ~54 NM; keeps the query small and within the traffic puck window. + static const double _boxHalfDeg = 0.9; + + final OpenSkyCredentials _credentials = const OpenSkyCredentials(); + + String? _token; + DateTime _tokenExpiry = DateTime.fromMillisecondsSinceEpoch(0); + bool _fetching = false; + DateTime _lastFetch = DateTime.fromMillisecondsSinceEpoch(0); + + /// Timestamp (UTC) of the last successful state fetch, for the UI banner. + DateTime? lastSuccess; + + /// Obtains a valid bearer token, refreshing via client-credentials when the + /// cached one is missing or within 30 s of expiry. Returns null on failure. + Future _ensureToken(String clientId, String clientSecret) async { + if (_token != null && + DateTime.now().isBefore(_tokenExpiry.subtract(const Duration(seconds: 30)))) { + return _token; + } + try { + final resp = await http.post( + Uri.parse(_tokenUrl), + headers: {'Content-Type': 'application/x-www-form-urlencoded'}, + body: { + 'grant_type': 'client_credentials', + 'client_id': clientId, + 'client_secret': clientSecret, + }, + ); + if (resp.statusCode != 200) { + AppLog.logMessage('OpenSky token HTTP ${resp.statusCode}'); + return null; + } + final Map data = jsonDecode(resp.body); + final token = data['access_token'] as String?; + final expiresIn = (data['expires_in'] as num?)?.toInt() ?? 1800; + if (token == null || token.isEmpty) return null; + _token = token; + _tokenExpiry = DateTime.now().add(Duration(seconds: expiresIn)); + return _token; + } catch (e) { + AppLog.logMessage('OpenSky token failed: $e'); + return null; + } + } + + /// Verifies credentials by requesting a token. Returns null on success, or a + /// short human-readable error message. Used by the settings "Test" button. + Future testCredentials(String clientId, String clientSecret) async { + _token = null; // force refresh + final token = await _ensureToken(clientId, clientSecret); + return token == null + ? 'Could not authenticate. Check the client ID and secret.' + : null; + } + + /// Fetches traffic around ownship if the feature is active. Self-throttles to + /// [minInterval] (OpenSky updates no faster than ~5-10 s and credits are + /// limited). Silently no-ops when disabled, unconfigured, or without a fix. + Future poll({Duration minInterval = const Duration(seconds: 12)}) async { + if (_fetching) return; + if (DateTime.now().difference(_lastFetch) < minInterval) return; + + if (!await _credentials.isActive()) return; + + final pos = Storage().position; + if (Gps.isPositionCloseToZero(pos)) return; // no usable fix yet + + _fetching = true; + _lastFetch = DateTime.now(); + try { + final clientId = await _credentials.readClientId(); + final clientSecret = await _credentials.readClientSecret(); + final token = await _ensureToken(clientId, clientSecret); + if (token == null) return; + + final double lamin = pos.latitude - _boxHalfDeg; + final double lamax = pos.latitude + _boxHalfDeg; + final double lomin = pos.longitude - _boxHalfDeg; + final double lomax = pos.longitude + _boxHalfDeg; + final uri = Uri.parse( + '$_statesUrl?lamin=$lamin&lomin=$lomin&lamax=$lamax&lomax=$lomax'); + + final resp = await http.get(uri, headers: {'Authorization': 'Bearer $token'}); + if (resp.statusCode == 401) { + _token = null; // token rejected; drop so next poll refreshes + AppLog.logMessage('OpenSky states 401 (token dropped)'); + return; + } + if (resp.statusCode != 200) { + AppLog.logMessage('OpenSky states HTTP ${resp.statusCode}'); + return; + } + final Map data = jsonDecode(resp.body); + final List states = (data['states'] as List?) ?? const []; + var count = 0; + for (final s in states) { + final msg = _toTraffic(s); + if (msg != null) { + Storage().trafficCache.putTraffic(msg); + count++; + } + } + lastSuccess = DateTime.now().toUtc(); + if (count > 0) { + // Refresh the traffic layer + distances/alerts like a GPS tick would. + Storage().trafficCache.updateTrafficDistancesAndAlerts(); + } + } catch (e) { + AppLog.logMessage('OpenSky poll failed: $e'); + } finally { + _fetching = false; + } + } + + /// Maps one OpenSky state-vector array into a [TrafficReportMessage], or null + /// when it lacks a usable position. Array layout per the OpenSky REST docs: + /// 0 icao24, 1 callsign, 5 longitude, 6 latitude, 7 baro_altitude(m), + /// 8 on_ground, 9 velocity(m/s), 10 true_track(deg), 11 vertical_rate(m/s), + /// 13 geo_altitude(m). + TrafficReportMessage? _toTraffic(dynamic s) { + if (s is! List || s.length < 12) return null; + final double? lon = _toD(s[5]); + final double? lat = _toD(s[6]); + if (lon == null || lat == null) return null; + + final msg = TrafficReportMessage(_trafficType); + final String icaoHex = (s[0] as String?)?.trim() ?? ''; + msg.icao = icaoHex.isEmpty ? 0 : (int.tryParse(icaoHex, radix: 16) ?? 0); + msg.callSign = ((s[1] as String?) ?? '').trim(); + msg.coordinates = LatLng(lat, lon); + final bool onGround = s[8] == true; + msg.airborne = !onGround; + // Prefer geometric altitude; fall back to barometric. Meters -> feet. + final double? altM = _toD(s.length > 13 ? s[13] : null) ?? _toD(s[7]); + msg.altitude = altM == null ? 0 : altM * _mToFt; + msg.velocity = _toD(s[9]) ?? 0; // m/s (matches TrafficReportMessage) + msg.heading = _toD(s[10]) ?? 0; // true track, deg + msg.verticalSpeed = _toD(s[11]) ?? 0; // m/s (matches TrafficPainter usage) + msg.addressType = 0; + return msg; + } + + static double? _toD(dynamic v) { + if (v is num) return v.toDouble(); + if (v is String) return double.tryParse(v); + return null; + } +} diff --git a/lib/gdl90/opensky_settings_screen.dart b/lib/gdl90/opensky_settings_screen.dart new file mode 100644 index 00000000..be8b98de --- /dev/null +++ b/lib/gdl90/opensky_settings_screen.dart @@ -0,0 +1,209 @@ +import 'package:flutter/material.dart'; + +import '../constants.dart'; +import 'opensky_credentials.dart'; +import 'opensky_service.dart'; + +/// Settings for the optional internet (ADS-B) traffic layer backed by the +/// OpenSky Network. The pilot supplies their own OpenSky API client +/// (client_id/secret); nothing is bundled. The layer is off by default and is +/// clearly labelled advisory-only. +class OpenSkySettingsScreen extends StatefulWidget { + const OpenSkySettingsScreen({super.key}); + + @override + State createState() => _OpenSkySettingsScreenState(); +} + +class _OpenSkySettingsScreenState extends State { + final _credentials = const OpenSkyCredentials(); + final _idController = TextEditingController(); + final _secretController = TextEditingController(); + bool _enabled = false; + bool _busy = false; + bool _hideSecret = true; + String? _message; + + @override + void initState() { + super.initState(); + _credentials.readClientId().then((v) { + if (mounted) setState(() => _idController.text = v); + }); + _credentials.readClientSecret().then((v) { + if (mounted) setState(() => _secretController.text = v); + }); + _credentials.readEnabled().then((v) { + if (mounted) setState(() => _enabled = v); + }); + } + + @override + void dispose() { + _idController.dispose(); + _secretController.dispose(); + super.dispose(); + } + + Future _save() async { + await _credentials.write( + clientId: _idController.text, + clientSecret: _secretController.text, + ); + await _credentials.setEnabled(_enabled); + if (mounted) { + setState(() => _message = 'Saved securely on this device.'); + } + } + + Future _test() async { + setState(() { + _busy = true; + _message = 'Testing OpenSky authentication...'; + }); + final err = await OpenSkyService.instance + .testCredentials(_idController.text.trim(), _secretController.text.trim()); + if (mounted) { + setState(() { + _busy = false; + _message = err ?? 'Authentication successful.'; + }); + } + } + + Future _clear() async { + await _credentials.clear(); + _idController.clear(); + _secretController.clear(); + if (mounted) { + setState(() { + _enabled = false; + _message = 'OpenSky settings cleared.'; + }); + } + } + + @override + Widget build(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Scaffold( + appBar: AppBar( + backgroundColor: Constants.appBarBackgroundColor, + title: const Text('Internet Traffic (OpenSky)'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Prominent advisory / safety notice. + Card( + color: scheme.errorContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.warning_amber, color: scheme.onErrorContainer), + const SizedBox(width: 10), + Expanded( + child: Text( + 'Advisory only. Internet traffic from OpenSky is ' + 'crowd-sourced, delayed and incomplete. It is NOT for ' + 'separation or collision avoidance. A connected ADS-B ' + 'receiver remains the real-time traffic source.', + style: TextStyle( + fontSize: 12, color: scheme.onErrorContainer), + ), + ), + ], + ), + ), + ), + const SizedBox(height: 16), + Text('OpenSky API client', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + const Text( + 'Create a free account at opensky-network.org, open the Account ' + 'page, create an API client, and paste its client ID and secret ' + 'below. Traffic is fetched from your own account; nothing is ' + 'bundled with the app and the credentials stay in this device\'s ' + 'secure storage.'), + const SizedBox(height: 16), + TextField( + controller: _idController, + autocorrect: false, + enableSuggestions: false, + decoration: const InputDecoration( + labelText: 'Client ID', + hintText: 'e.g. yourname-api-client', + ), + ), + const SizedBox(height: 12), + TextField( + controller: _secretController, + obscureText: _hideSecret, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: 'Client secret', + suffixIcon: IconButton( + icon: Icon(_hideSecret ? Icons.visibility : Icons.visibility_off), + onPressed: () => setState(() => _hideSecret = !_hideSecret), + ), + ), + ), + const SizedBox(height: 8), + SwitchListTile( + contentPadding: EdgeInsets.zero, + title: const Text('Show internet traffic on the map'), + subtitle: const Text( + 'Enable the OpenSky layer (needs credentials above). Also ' + 'turn on the "Traffic" map layer to see it.'), + value: _enabled, + onChanged: _busy ? null : (v) => setState(() => _enabled = v), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _save, + icon: const Icon(Icons.save), + label: const Text('Save'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _test, + icon: const Icon(Icons.wifi_tethering), + label: const Text('Test'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _clear, + icon: const Icon(Icons.delete_outline), + label: const Text('Clear'), + ), + ], + ), + if (_busy) + const Padding( + padding: EdgeInsets.only(top: 16), + child: LinearProgressIndicator(), + ), + if (_message != null) + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text(_message!), + ), + const SizedBox(height: 24), + Text(OpenSkyService.attribution, + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text( + 'Data from The OpenSky Network, opensky-network.org, provided for ' + 'non-commercial use. Coverage depends on community receivers and ' + 'varies by region and altitude.', + style: TextStyle(fontSize: 12)), + ], + ), + ); + } +} diff --git a/lib/instruments/flight_status.dart b/lib/instruments/flight_status.dart index e62b60bd..abdcfbef 100644 --- a/lib/instruments/flight_status.dart +++ b/lib/instruments/flight_status.dart @@ -3,7 +3,7 @@ import 'package:avaremp/storage.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; -import '../data/main_database_helper.dart'; +import '../data/aeronautical_database.dart'; import '../destination/destination.dart'; import '../utils/geo_calculations.dart'; @@ -30,7 +30,7 @@ class FlightStatus { return; } // on landing, add to recent the airport we landed at, then set it as current airport - List airports = await MainDatabaseHelper.db.findNearestAirportsWithRunways( + List airports = await AeronauticalDatabase.instance.findNearestAirportsWithRunways( LatLng(Storage().position.latitude, Storage().position.longitude), 0); if (airports.isNotEmpty) { String? plate = await PathUtils.getAirportDiagram(Storage().dataDir, airports[0].locationID); diff --git a/lib/instruments/runway_awareness.dart b/lib/instruments/runway_awareness.dart index ecd37f22..f7c0983b 100644 --- a/lib/instruments/runway_awareness.dart +++ b/lib/instruments/runway_awareness.dart @@ -3,7 +3,7 @@ import 'dart:collection'; import 'dart:math'; import 'package:audioplayers/audioplayers.dart'; -import 'package:avaremp/data/main_database_helper.dart'; +import 'package:avaremp/data/aeronautical_database.dart'; import 'package:avaremp/destination/destination.dart'; import 'package:avaremp/instruments/flight_status.dart'; import 'package:avaremp/io/gps.dart'; @@ -324,7 +324,10 @@ class RunwayAwareness { } if (id == _airportId && !stale && _runways.isNotEmpty) return; - final AirportDestination? ap = await MainDatabaseHelper.db.findAirport(id); + final AirportDestination? ap = await AeronauticalDatabase.instance.findAirport( + id, + source: closest?.source, + ); _airportId = id; _airport = ap; _airportLoadedAt = now; diff --git a/lib/longpress_screen.dart b/lib/longpress_screen.dart index a1a51eb1..fb1c571e 100644 --- a/lib/longpress_screen.dart +++ b/lib/longpress_screen.dart @@ -1,7 +1,7 @@ import 'package:auto_size_text/auto_size_text.dart'; import 'package:avaremp/ai/ai_screen.dart'; -import 'package:avaremp/business/airport_businesses_gate.dart'; -import 'package:avaremp/data/main_database_helper.dart'; +import 'data/main_database_helper.dart'; +import 'data/aeronautical_database.dart'; import 'package:avaremp/data/user_database_helper.dart'; import 'package:avaremp/utils/geo_calculations.dart'; import 'package:avaremp/main_screen.dart'; @@ -17,13 +17,21 @@ import 'package:avaremp/plan/waypoint.dart'; import 'package:avaremp/weather/weather.dart'; import 'package:avaremp/weather/winds_aloft.dart'; import 'package:avaremp/weather/winds_cache.dart'; +import 'package:avaremp/weather/open_meteo_winds.dart'; +import 'package:avaremp/weather/open_meteo_credentials.dart'; +import 'package:avaremp/weather/flybrief_notams.dart'; +import 'package:avaremp/weather/flybrief_store.dart'; import 'package:flutter/material.dart'; import 'package:latlong2/latlong.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'aip/aip_aero.dart'; import 'destination/airport.dart'; import 'constants.dart'; import 'package:avaremp/destination/destination.dart'; import 'weather/metar.dart'; +import 'weather/decoded_metar_view.dart'; +import 'ofm/ofm_constants.dart'; class LongPressScreen extends StatefulWidget { final List destinations; @@ -52,7 +60,7 @@ class LongPressFuture { Future _getAll() async { show = await DestinationFactory.make(_destination); - navs = await MainDatabaseHelper.db.findNearestVOR(_destination.coordinate); + navs = await AeronauticalDatabase.instance.findNearestVOR(_destination.coordinate); saa = await MainDatabaseHelper.db.getSaa(_destination.coordinate); } @@ -65,16 +73,117 @@ class LongPressFuture { class LongPressScreenState extends State { int _index = 0; - static const List labels = ["Main", "AD", "METAR", "NOTAM", "SUA", "Wind", "ST", "Business"]; + static const List labels = ["Main", "AD", "METAR", "NOTAM", "SUA", "Wind", "ST"]; late Future _loadFuture; + // Opens the airport's official-AIP index page on aip.aero in the platform + // browser. aip.aero links straight to the country's official AIP; we only + // hand off the URL (no data is fetched or cached by the app). + Future _openAip(BuildContext context, String icao) async { + final uri = Uri.parse(AipAero.urlForAirport(icao)); + final messenger = ScaffoldMessenger.maybeOf(context); + try { + final ok = await launchUrl(uri, mode: LaunchMode.externalApplication); + if (!ok && messenger != null) { + messenger.showSnackBar( + SnackBar(content: Text('Could not open $uri')), + ); + } + } catch (e) { + if (messenger != null) { + messenger.showSnackBar( + SnackBar(content: Text('Could not open $uri')), + ); + } + } + } + + // Gathers NOTAM lines for a destination. Uses the built-in (US FAA) source + // first; when it yields nothing (e.g. in Europe) it falls back to FlyBrief's + // per-country georeferenced NOTAMs (offline-first). Returns the display + // title, the NOTAM lines, and an optional data-source attribution. + Future<(String, List, String?)> _gatherNotams( + Destination dest) async { + // 1) Built-in source (FAA). + final Notam? n = await Storage().notam.getSync(dest.locationID) as Notam?; + if (n != null) { + var lines = n.toString().split('\n') + .map((e) => e.trim()) + .where((e) => e.isNotEmpty) + .toList(); + if (lines.isNotEmpty) { + final title = lines.removeAt(0); + return (title, lines, null); + } + } + // 2) FlyBrief fallback (Europe / covered countries), offline-first. + final fb = await FlybriefStore.nearbyForPoint( + dest.coordinate.latitude, dest.coordinate.longitude); + if (fb.isNotEmpty) { + final lines = fb.map((e) => e.toLine()).toList(); + return ('NOTAMs near ${dest.locationID}', lines, FlybriefNotams.attribution); + } + return ('', [], null); + } + @override void initState() { super.initState(); _loadFuture = LongPressFuture(widget.destinations[0]).getAll(); } + // Renders the winds-aloft list for a WindsAloft, with an optional data-source + // attribution footer (used for the Open-Meteo fallback). + Widget _windsList(BuildContext context, WindsAloft wa, String? attribution) { + return ListView( + padding: const EdgeInsets.all(8), + children: [ + Card( + color: Theme.of(context).colorScheme.primaryContainer, + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon(Icons.air, color: Theme.of(context).colorScheme.onPrimaryContainer), + const SizedBox(width: 8), + Expanded( + child: Text( + wa.toString(), + style: TextStyle( + fontWeight: FontWeight.bold, + color: Theme.of(context).colorScheme.onPrimaryContainer, + ), + ), + ), + ], + ), + ), + ), + for ((String, String) wl in wa.toList()) + Card( + child: ListTile( + leading: Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + child: Text(wl.$1, style: const TextStyle(fontWeight: FontWeight.bold)), + ), + title: Text(wl.$2), + ), + ), + if (attribution != null) + Padding( + padding: const EdgeInsets.all(8), + child: Text(attribution, + style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.outline)), + ), + ], + ); + } + @override Widget build(BuildContext context) { @@ -111,7 +220,77 @@ class LongPressScreenState extends State { List pages = List.generate(labels.length, (index) => null); String label = "$facility (${showDestination.locationID}) $direction${showDestination.elevation != null ? "; EL ${showDestination.elevation!.round()}" : ""}"; - if (showDestination is AirportDestination) { + if (showDestination.source == 'OFM' || showDestination.source == 'openAIP') { + final isOpenAip = showDestination.source == 'openAIP'; + pages[labels.indexOf("Main")] = ListView( + padding: const EdgeInsets.all(12), + children: [ + ListTile( + title: Text(showDestination.facilityName), + subtitle: Text('${showDestination.locationID} • ${showDestination.source} ${showDestination.sourceRegion} ${showDestination.sourceCycle}'), + ), + Text('Coordinates: ${showDestination.coordinate.latitude.toStringAsFixed(6)}, ${showDestination.coordinate.longitude.toStringAsFixed(6)}'), + if (showDestination.elevation != null) Text('Elevation: ${showDestination.elevation!.round()} ft'), + if (showDestination is AirportDestination) ...[ + const SizedBox(height: 12), + Text('Runways', style: Theme.of(context).textTheme.titleMedium), + for (final runway in showDestination.runways) + Text('${runway['RunwayID']} • ${(runway['Length'] as num).round()} x ${(runway['Width'] as num).round()} ft • ${runway['Surface']}'), + const SizedBox(height: 12), + Text('Communications', style: Theme.of(context).textTheme.titleMedium), + for (final frequency in showDestination.frequencies) + Text('${frequency['Use']}: ${frequency['Frequency']}'), + ], + const SizedBox(height: 16), + Text(isOpenAip ? 'Data © openAIP, CC BY-NC 4.0' : OfmConstants.attribution, + style: const TextStyle(fontWeight: FontWeight.bold)), + Text(isOpenAip + ? 'openAIP is community-maintained supplementary data and is not certified for primary navigation or flight planning.' + : OfmConstants.disclaimer), + if (!isOpenAip) const Text(OfmConstants.corrections), + if (showDestination is AirportDestination && + AipAero.hasChartsFor(showDestination.locationID)) ...[ + const Divider(height: 24), + Card( + child: ListTile( + leading: const Icon(Icons.picture_as_pdf), + title: const Text('Official AIP & approach charts'), + subtitle: Text( + 'Open ${showDestination.locationID} on aip.aero — links to the ' + 'country\u2019s official AIP (VFR/IFR charts, aerodrome data). ' + 'External site; verify AIRAC currency before flight.'), + trailing: const Icon(Icons.open_in_new), + onTap: () => _openAip(context, showDestination.locationID), + ), + ), + ], + ], + ); + if (showDestination is AirportDestination) { + final Metar? metar = Storage().metar.get(showDestination.locationID) as Metar?; + final Taf? taf = Storage().taf.get(showDestination.locationID) as Taf?; + if (metar != null || taf != null) { + pages[labels.indexOf("METAR")] = ListView( + padding: const EdgeInsets.all(8), + children: [ + if (metar != null) Card(child: ListTile( + leading: metar.getIcon(), + title: const Text('METAR'), + subtitle: Text(metar.text), + )), + if (metar != null) DecodedMetarView(metar: metar), + if (taf != null) Card(child: ListTile( + leading: taf.getIcon(), + title: const Text('TAF'), + subtitle: Text(taf.text), + )), + ], + ); + } + } + } + + if (showDestination.source != 'OFM' && showDestination.source != 'openAIP' && showDestination is AirportDestination) { pages[labels.indexOf("Main")] = Airport.parse(showDestination); @@ -149,6 +328,7 @@ class LongPressScreenState extends State { ), ), ), + if (metar != null) DecodedMetarView(metar: metar), if (taf != null) Card( child: ListTile( @@ -163,15 +343,17 @@ class LongPressScreenState extends State { ], ); } - pages[labels.indexOf("NOTAM")] = FutureBuilder( - future: Storage().notam.getSync(showDestination.locationID), + pages[labels.indexOf("NOTAM")] = FutureBuilder<(String, List, String?)>( + future: _gatherNotams(showDestination), builder: (context, snapshot) { - if (snapshot.data != null) { - Notam n = snapshot.data as Notam; - - List lines = n.toString().split("\n"); - lines = lines.map((e) => e.trim()).where((e) => e.isNotEmpty).toList(); - String title = lines.removeAt(0); + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final data = snapshot.data; + if (data != null && data.$2.isNotEmpty) { + final String title = data.$1; + final List lines = data.$2; + final String? attribution = data.$3; return ListView( padding: const EdgeInsets.all(8), children: [ @@ -203,7 +385,7 @@ class LongPressScreenState extends State { ), ), ), - if (Constants.shouldShowProServices && lines.isNotEmpty) + if (Constants.shouldShowAi && lines.isNotEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Align( @@ -225,6 +407,12 @@ class LongPressScreenState extends State { title: Text(v, style: const TextStyle(fontSize: 13)), ), ), + if (attribution != null) + Padding( + padding: const EdgeInsets.all(8), + child: Text(attribution, + style: TextStyle(fontSize: 11, color: Theme.of(context).colorScheme.outline)), + ), ], ); } else { @@ -299,51 +487,48 @@ class LongPressScreenState extends State { Weather? winds; String? station = WindsCache.locateNearestStation(showDestination.coordinate); + // Distance (km) to the nearest US FB winds-aloft station. Beyond the US + // coverage radius the FB product does not apply, so fall back to Open-Meteo. + double? stationKm; if (station != null) { - winds = Storage().winds.get("${station}06H"); - if (winds != null) { - WindsAloft wa = winds as WindsAloft; - pages[labels.indexOf("Wind")] = ListView( - padding: const EdgeInsets.all(8), - children: [ - Card( - color: Theme.of(context).colorScheme.primaryContainer, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon(Icons.air, color: Theme.of(context).colorScheme.onPrimaryContainer), - const SizedBox(width: 8), - Expanded( - child: Text( - winds.toString(), - style: TextStyle( - fontWeight: FontWeight.bold, - color: Theme.of(context).colorScheme.onPrimaryContainer, - ), - ), - ), - ], - ), - ), - ), - for ((String, String) wl in wa.toList()) - Card( - child: ListTile( - leading: Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - child: Text(wl.$1, style: const TextStyle(fontWeight: FontWeight.bold)), - ), - title: Text(wl.$2), - ), - ), - ], - ); + final LatLng? sc = WindsCache.stationLatLng(station); + if (sc != null) { + stationKm = OpenMeteoWinds.distanceKm(showDestination.coordinate, sc); } + winds = Storage().winds.get("${station}06H"); + } + final bool usCovered = + winds != null && stationKm != null && stationKm <= OpenMeteoWinds.usStationMaxKm; + + if (usCovered) { + pages[labels.indexOf("Wind")] = _windsList(context, winds as WindsAloft, null); + } + else { + // Non-US (or no US data): fetch pressure-level winds from Open-Meteo. + pages[labels.indexOf("Wind")] = FutureBuilder( + future: OpenMeteoCredentials().read().then((key) => OpenMeteoWinds.fetch( + showDestination.coordinate, + apiKey: key, + station: showDestination.locationID, + )), + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + final WindsAloft? wa = snapshot.data; + if (wa == null) { + // Last resort: show US data if we have any, else a message. + if (winds != null) { + return _windsList(context, winds as WindsAloft, null); + } + return Center( + child: Text('Winds aloft unavailable for this location.', + style: TextStyle(color: Theme.of(context).colorScheme.outline)), + ); + } + return _windsList(context, wa, OpenMeteoWinds.attribution); + }, + ); } pages[labels.indexOf("ST")] = Sounding.getSoundingImage(showDestination.coordinate, context); @@ -363,16 +548,6 @@ class LongPressScreenState extends State { ); } - // Build the Business tab for any airport. All cloud/Firebase logic lives - // in AirportBusinessesTab; this screen only decides whether the platform - // supports the feature. It is never gated by Pro. - final bool isAirport = showDestination is AirportDestination; - if (isAirport && AirportBusinessesGate.available) { - pages[labels.indexOf("Business")] = AirportBusinessesTab( - airport: showDestination.locationID, - origin: showDestination.coordinate); - } - return Scaffold( appBar: AppBar( title: AutoSizeText(label, maxLines: 2, minFontSize: 10, maxFontSize: 16, style: const TextStyle(fontWeight: FontWeight.w700),), diff --git a/lib/main.dart b/lib/main.dart index c9161d5c..1ae4f0bc 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,27 +1,27 @@ import 'package:avaremp/logbook/logbook_screen.dart'; import 'package:avaremp/longpress_screen.dart'; import 'package:avaremp/plan/plan_action_screen.dart'; -import 'package:avaremp/services/login_screen.dart'; -import 'package:avaremp/services/revenue_cat.dart'; import 'package:avaremp/storage.dart'; import 'package:avaremp/writing_screen.dart'; import 'aircraft/aircraft_performance_screen.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_ui_auth/firebase_ui_auth.dart'; import 'package:flutter/material.dart'; +import 'about_screen.dart'; import 'ai/ai_screen.dart'; import 'checklist/checklist_screen.dart'; -import 'community/community_screen.dart'; +import 'gdl90/opensky_settings_screen.dart'; import 'constants.dart'; import 'destination/destination.dart'; import 'documents_screen.dart'; import 'chart/download_screen.dart'; -import 'firebase_options.dart'; import 'io/io_screen.dart'; import 'main_screen.dart'; -import 'scheduler/scheduler_screen.dart'; import 'onboarding_screen.dart'; -import 'services/backup_screen.dart'; +import 'ofm/ofm_download_screen.dart'; +import 'ofm/ofm_chart_library_screen.dart'; +import 'openaip/openaip_download_screen.dart'; +import 'weather/open_meteo_settings_screen.dart'; +import 'weather/flybrief_download_screen.dart'; +import 'weather/terrain_download_screen.dart'; class CustomWidgetsBinding extends WidgetsFlutterBinding { @override @@ -32,20 +32,6 @@ void main() { // this is to control cache. Nexrad needs it or image caching will make it impossible to animate weather CustomWidgetsBinding(); Storage().init().then((accentColor) async { - if(Constants.shouldShowProServices) { - try { - await Firebase.initializeApp( - options: DefaultFirebaseOptions.currentPlatform, - ); - FirebaseUIAuth.configureProviders([ - EmailAuthProvider(), - ]); - await RevenueCatService.initPlatformState(); - } - catch (e) { - // ignore errors here - } - } runApp(const MainApp()); }); @@ -68,18 +54,22 @@ class MainApp extends StatelessWidget { ? const OnBoardingScreen() : const MainScreen(), '/download': (context) => const DownloadScreen(), + '/ofm_download': (context) => const OfmDownloadScreen(), + '/ofm_charts': (context) => const OfmChartLibraryScreen(), + '/openaip': (context) => const OpenAipDownloadScreen(), + '/open_meteo': (context) => const OpenMeteoSettingsScreen(), + '/flybrief': (context) => const FlybriefDownloadScreen(), + '/terrain': (context) => const TerrainDownloadScreen(), '/documents': (context) => const DocumentsScreen(), '/checklists': (context) => const ChecklistScreen(), '/performance': (context) => const AircraftPerformanceScreen(), '/logbook': (context) => const LogbookScreen(), - '/pro': (context) => const LoginScreen(), if(Constants.shouldShowBluetoothSpp) '/io': (context) => const IoScreen(), '/notes': (context) => const WritingScreen(), '/plan_actions': (context) => const PlanActionScreen(), '/ai': (context) => const AiScreen(), - '/backup': (context) => const BackupScreen(), - '/community': (context) => const CommunityScreen(), - '/scheduler': (context) => const SchedulerScreen(), + '/about': (context) => const AboutScreen(), + '/opensky': (context) => const OpenSkySettingsScreen(), '/popup': (context) { final args = ModalRoute.of(context)!.settings.arguments as List; return LongPressScreen(destinations: args); diff --git a/lib/main_screen.dart b/lib/main_screen.dart index 5e73948e..669fe4f4 100644 --- a/lib/main_screen.dart +++ b/lib/main_screen.dart @@ -299,6 +299,66 @@ class MainScreenState extends State with WidgetsBindingObserver { // Navigator.pushNamed(context, '/download'); }, ), + _buildMenuItem( + context, + icon: MdiIcons.mapMarkerPath, + title: "OpenFlightMaps", + subtitle: "Regional OFM VFR map layers", + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/ofm_download'); + }, + ), + _buildMenuItem( + context, + icon: Icons.public, + title: "openAIP", + subtitle: "Supplementary EU airports, navaids & obstacles", + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/openaip'); + }, + ), + _buildMenuItem( + context, + icon: Icons.air, + title: "Open-Meteo Winds", + subtitle: "Global winds aloft outside US coverage", + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/open_meteo'); + }, + ), + _buildMenuItem( + context, + icon: Icons.warning_amber, + title: "NOTAMs (FlyBrief)", + subtitle: "European NOTAMs for offline use", + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/flybrief'); + }, + ), + _buildMenuItem( + context, + icon: MdiIcons.airplaneMarker, + title: "Internet Traffic (OpenSky)", + subtitle: "Advisory ADS-B traffic without hardware", + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/opensky'); + }, + ), + _buildMenuItem( + context, + icon: Icons.terrain, + title: "Terrain (Elevation)", + subtitle: "Build offline terrain/GPWS by country", + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/terrain'); + }, + ), _buildMenuItem( context, icon: MdiIcons.fileDocument, @@ -380,6 +440,16 @@ class MainScreenState extends State with WidgetsBindingObserver { // } }, ), + _buildMenuItem( + context, + icon: Icons.info_outline, + title: "About & Credits", + subtitle: "Version, data sources & licenses", + onTap: () { + Navigator.pop(context); + Navigator.pushNamed(context, '/about'); + }, + ), ], ), ), diff --git a/lib/map_screen.dart b/lib/map_screen.dart index 761df35f..e249e8a5 100644 --- a/lib/map_screen.dart +++ b/lib/map_screen.dart @@ -14,17 +14,32 @@ import 'package:avaremp/utils/app_log.dart'; import 'package:avaremp/destination/airport.dart'; import 'package:avaremp/documents_screen.dart'; import 'package:avaremp/gdl90/nexrad_cache.dart'; +import 'package:avaremp/gdl90/opensky_credentials.dart'; +import 'package:avaremp/gdl90/opensky_service.dart'; import 'package:avaremp/gdl90/traffic_cache.dart'; import 'package:avaremp/utils/compass_rose.dart'; import 'package:avaremp/utils/geo_calculations.dart'; -import 'package:avaremp/data/main_database_helper.dart'; + import 'package:avaremp/io/gps_recorder.dart'; import 'package:avaremp/instruments/instrument_list.dart'; import 'package:avaremp/instruments/pfd_painter.dart'; +import 'package:avaremp/ofm/ofm_attribution.dart'; +import 'package:avaremp/ofm/ofm_constants.dart'; +import 'package:avaremp/ofm/ofm_map_layer.dart'; +import 'package:avaremp/ofm/ofm_airspace_layer.dart'; +import 'package:avaremp/openaip/openaip_airspace_layer.dart'; +import 'package:avaremp/openaip/openaip_attribution.dart'; +import 'package:avaremp/openaip/openaip_changes.dart'; +import 'package:avaremp/openaip/openaip_constants.dart'; +import 'package:avaremp/openaip/openaip_database.dart'; +import 'package:avaremp/utils/map_controller_guard.dart'; +import 'package:avaremp/ofm/ofm_data_provider.dart'; +import 'package:avaremp/data/aeronautical_database.dart'; import 'package:avaremp/storage.dart'; import 'package:avaremp/weather/airep.dart'; import 'package:avaremp/weather/airsigmet.dart'; import 'package:avaremp/weather/game_tfr.dart'; +import 'package:avaremp/weather/rainviewer_radar.dart'; import 'package:avaremp/weather/taf.dart'; import 'package:avaremp/weather/tfr.dart'; import 'package:avaremp/widgets/warnings_widget.dart'; @@ -62,6 +77,7 @@ class MapScreenState extends State { bool _rubberBanding = false; final Ruler _ruler = Ruler(); final MBTilesLayerManager _mbtilesManager = MBTilesLayerManager(); + final OfmMapLayer _ofmMapLayer = OfmMapLayer(); String _type = Storage().settings.getChartType(); int _maxZoom = ChartCategory.chartTypeToZoom(Storage().settings.getChartType()); final MapController _controller = MapController(); @@ -79,6 +95,8 @@ class MapScreenState extends State { final ValueNotifier<(List, List)> _tapeNotifier = ValueNotifier<(List, List)>(([],[])); ElevationTileProvider elevationTileProvider = ElevationTileProvider(); int _cacheBustElevation = 0; + bool _ofmLoadInProgress = false; + bool _mapReady = false; // memoization for the distance circles and the to-waypoint great-circle path, // which otherwise recompute trig every second even when nothing has changed String? _circlesKey; @@ -102,6 +120,14 @@ class MapScreenState extends State { tileProvider: NetworkTileProvider(), ); + // Periodic RainViewer index refresh (EU build only). + Timer? _rainViewerTimer; + + // Periodic OpenSky internet-traffic poll (active only when the pilot has + // enabled it with their own credentials). Advisory only. + Timer? _openSkyTimer; + bool _openSkyActive = false; + final TileLayer _topoLayer = TileLayer( maxNativeZoom: 16, keepBuffer: 1, // hold fewer off-screen tiles decoded in memory @@ -163,8 +189,30 @@ class MapScreenState extends State { Storage().airSigmet.change.addListener(_airSigmetListen); Storage().tfr.change.addListener(_tfrListen); Storage().geoParser.change.addListener(_geoJsonListen); + OfmMapLayer.changes.addListener(_ofmChanged); + OpenAipChanges.notifier.addListener(_openAipChanged); + // EU build: keep the RainViewer radar index fresh (new frames ~every 10 + // min). Refresh now and periodically; the service self-throttles. + if (Constants.isEu) { + RainViewerRadar.instance.refresh(); + _rainViewerTimer = Timer.periodic( + const Duration(minutes: 5), (_) => RainViewerRadar.instance.refresh()); + } + // Optional internet (ADS-B) traffic via OpenSky, using the pilot's own + // credentials. Polls only when enabled; the service self-throttles and + // no-ops when disabled/unconfigured. Advisory only. + _refreshOpenSkyActive(); + _openSkyTimer = Timer.periodic(const Duration(seconds: 12), (_) async { + await OpenSkyService.instance.poll(); + _refreshOpenSkyActive(); + }); // load vector tiles _mbtilesManager.loadMBTiles(PathUtils.getFilePath(Storage().dataDir, PathUtils.getFilePath("maps", "nasr.mbtiles"))); + _ofmMapLayer.loadInstalled(Storage().dataDir).then((loaded) { + if (loaded && mounted) { + setState(() {}); + } + }); super.initState(); } @@ -179,11 +227,37 @@ class MapScreenState extends State { Storage().airSigmet.change.removeListener(_airSigmetListen); Storage().tfr.change.removeListener(_tfrListen); Storage().geoParser.change.removeListener(_geoJsonListen); + OfmMapLayer.changes.removeListener(_ofmChanged); + OpenAipChanges.notifier.removeListener(_openAipChanged); _previousPosition = null; + _rainViewerTimer?.cancel(); + _openSkyTimer?.cancel(); _mbtilesManager.close(); + _ofmMapLayer.close(); super.dispose(); } + void _ofmChanged() { + _ofmMapLayer.loadInstalled(Storage().dataDir, force: true).then((_) { + if (mounted) setState(() {}); + }); + } + + // Refreshes whether the OpenSky internet-traffic layer is currently active + // (enabled + credentials present), for the advisory banner. Cheap; reads + // secure storage off the poll timer. + void _refreshOpenSkyActive() { + const OpenSkyCredentials().isActive().then((active) { + if (mounted && active != _openSkyActive) { + setState(() => _openSkyActive = active); + } + }); + } + + void _openAipChanged() { + if (mounted) setState(() {}); + } + // for measuring tape void _handleEvent(MapEvent mapEvent) { // The tape markers are only consumed by the Tape layer. Skip the trig loop @@ -506,12 +580,19 @@ class MapScreenState extends State { // no rotation in track up initialRotation: Storage().settings.getRotation(), backgroundColor: Storage().settings.isLightMode() ? Constants.mapBackgroundColorLight: Constants.mapBackgroundColorDark, + onMapReady: () { + if (mounted) { + setState(() => _mapReady = true); + } else { + _mapReady = true; + } + }, onLongPress: (tap, point) async { if(_ruler.isMeasuring()) { _ruler.setPoint(point); // on long press when measuring, set ruler point } else { // otherwise show destination screen - List items = await MainDatabaseHelper.db.findNear(point); + List items = await AeronauticalDatabase.instance.findNear(point); setState(() { showDestination(this.context, items); }); @@ -563,6 +644,64 @@ class MapScreenState extends State { } } + lIndex = _layers.indexOf(OfmConstants.layerName); + if (lIndex >= 0) { + opacity = _layersOpacity[lIndex]; + if (opacity > 0) { + if (!_ofmMapLayer.isLoaded && !_ofmLoadInProgress) { + _ofmLoadInProgress = true; + _ofmMapLayer.loadInstalled(Storage().dataDir).then((loaded) { + _ofmLoadInProgress = false; + if (loaded && mounted) { + setState(() {}); + } + }); + } + layers.addAll(_ofmMapLayer.buildLayers(opacity: opacity)); + } + } + + lIndex = _layers.indexOf(OfmConstants.dataLayerName); + if (lIndex >= 0) { + opacity = _layersOpacity[lIndex]; + final camera = MapControllerGuard.cameraIfReady(_controller, _mapReady); + if (opacity > 0 && camera != null) { + final bounds = camera.visibleBounds; + layers.add(FutureBuilder>( + future: OfmDataProvider(dataDir: Storage().dataDir).findAirspacesInBounds( + minLat: bounds.south, + maxLat: bounds.north, + minLon: bounds.west, + maxLon: bounds.east, + ), + builder: (context, snapshot) => PolygonLayer( + polygons: OfmAirspaceLayer.polygons(snapshot.data ?? const [], opacity: opacity), + ), + )); + } + } + + lIndex = _layers.indexOf(OpenAipConstants.dataLayerName); + if (lIndex >= 0) { + opacity = _layersOpacity[lIndex]; + final camera = MapControllerGuard.cameraIfReady(_controller, _mapReady); + if (opacity > 0 && camera != null) { + final bounds = camera.visibleBounds; + layers.add(FutureBuilder>( + future: OpenAipDatabase.open(Storage().dataDir).then((db) => + OpenAipDatabase(database: db).findAirspacesInBounds( + minLat: bounds.south, + maxLat: bounds.north, + minLon: bounds.west, + maxLon: bounds.east, + )), + builder: (context, snapshot) => PolygonLayer( + polygons: OpenAipAirspaceLayer.polygons(snapshot.data ?? const [], opacity: opacity), + ), + )); + } + } + lIndex = _layers.indexOf('CAP Grid'); opacity = _layersOpacity[lIndex]; if (opacity > 0) { @@ -638,40 +777,89 @@ class MapScreenState extends State { showAltitudeSlider = true; } - // Internet radar (Iowa Mesonet). + // Internet radar. EU build uses RainViewer (global, animated, user- + // selectable color scheme); other builds use the US-only Iowa Mesonet + // NEXRAD mosaic. if (_weatherProductOn("Radar")) { final double productOpacity = opacity * _weatherProductOpacity("Radar"); - layers.add(Opacity(opacity: productOpacity, - child: ValueListenableBuilder( - valueListenable: Storage().timeRadarChange, - builder: (context, value, _) { - int index = value % (_mesonets.length * 2); - if(index > _mesonets.length - 1) { - index = _mesonets.length - 1; - } - _nexradLayer = TileLayer( - userAgentPackageName: 'com.apps4av.avarex', - maxNativeZoom: 5, - keepBuffer: 1, - urlTemplate: _mesonets[index], - tileProvider: NetworkTileProvider(), - ); - return _nexradLayer; - }, + if (Constants.isEu) { + final int colorScheme = Storage().settings.getRadarColorScheme(); + layers.add(Opacity(opacity: productOpacity, + child: ValueListenableBuilder( + // Rebuild both when a new index arrives and on each animation + // tick so the loop advances through the available frames. + valueListenable: Storage().timeRadarChange, + builder: (context, value, _) { + final int frames = RainViewerRadar.instance.frameCount; + if (frames == 0) { + return const SizedBox.shrink(); + } + final int frameIndex = value % frames; + final String? template = RainViewerRadar.instance.tileUrlTemplate( + frameIndex: frameIndex, + colorScheme: colorScheme, + ); + if (template == null) { + return const SizedBox.shrink(); + } + _nexradLayer = TileLayer( + userAgentPackageName: 'com.apps4av.avarex', + maxNativeZoom: 7, + keepBuffer: 1, + urlTemplate: template, + tileProvider: NetworkTileProvider(), + ); + return _nexradLayer; + }, + ))); + + layers.add( + Opacity(opacity: productOpacity, child: Container(height: 30, width: Constants.screenWidth(context) / 3, padding: EdgeInsets.fromLTRB(10, Constants.screenHeightForInstruments(context) + 20, 0, 0), + child: ValueListenableBuilder( + valueListenable: Storage().timeRadarChange, + builder: (context, value, _) { + final int frames = RainViewerRadar.instance.frameCount; + if (frames <= 1) { + return const SizedBox.shrink(); + } + final int frameIndex = value % frames; + return Slider(value: frameIndex / (frames - 1), onChanged: (double value) { }); + }), ))); + } + else { + layers.add(Opacity(opacity: productOpacity, + child: ValueListenableBuilder( + valueListenable: Storage().timeRadarChange, + builder: (context, value, _) { + int index = value % (_mesonets.length * 2); + if(index > _mesonets.length - 1) { + index = _mesonets.length - 1; + } + _nexradLayer = TileLayer( + userAgentPackageName: 'com.apps4av.avarex', + maxNativeZoom: 5, + keepBuffer: 1, + urlTemplate: _mesonets[index], + tileProvider: NetworkTileProvider(), + ); + return _nexradLayer; + }, + ))); - layers.add( - Opacity(opacity: productOpacity, child: Container(height: 30, width: Constants.screenWidth(context) / 3, padding: EdgeInsets.fromLTRB(10, Constants.screenHeightForInstruments(context) + 20, 0, 0), - child: ValueListenableBuilder( - valueListenable: Storage().timeRadarChange, - builder: (context, value, _) { - int index = value % (_mesonets.length * 2); - if(index > _mesonets.length - 1) { - index = _mesonets.length - 1; - } - return Slider(value: index / (_mesonets.length - 1), onChanged: (double value) { }); - }), - ))); + layers.add( + Opacity(opacity: productOpacity, child: Container(height: 30, width: Constants.screenWidth(context) / 3, padding: EdgeInsets.fromLTRB(10, Constants.screenHeightForInstruments(context) + 20, 0, 0), + child: ValueListenableBuilder( + valueListenable: Storage().timeRadarChange, + builder: (context, value, _) { + int index = value % (_mesonets.length * 2); + if(index > _mesonets.length - 1) { + index = _mesonets.length - 1; + } + return Slider(value: index / (_mesonets.length - 1), onChanged: (double value) { }); + }), + ))); + } } // ADS-B NEXRAD. @@ -1396,6 +1584,65 @@ class MapScreenState extends State { body: Stack( children: [ RepaintBoundary(child: map), // map + if(_layers.contains(OfmConstants.layerName) && + _layersOpacity[_layers.indexOf(OfmConstants.layerName)] > 0 && + _ofmMapLayer.isLoaded) + OfmAttribution(opacity: _layersOpacity[_layers.indexOf(OfmConstants.layerName)]), + if (_layers.contains(OpenAipConstants.dataLayerName) && + _layersOpacity[_layers.indexOf(OpenAipConstants.dataLayerName)] > 0) + OpenAipAttribution( + opacity: _layersOpacity[_layers.indexOf(OpenAipConstants.dataLayerName)], + ), + // RainViewer radar attribution (EU build, when the internet Radar + // product is enabled). Required by RainViewer's free API terms. + if (Constants.isEu && _weatherProductOn("Radar")) + Positioned( + bottom: Constants.bottomPaddingSize(context) + 2, + left: 6, + child: IgnorePointer( + child: Text( + RainViewerRadar.attribution, + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onSurface, + backgroundColor: + Theme.of(context).scaffoldBackgroundColor.withAlpha(160), + ), + ), + ), + ), + // Internet-traffic advisory banner: shown only when the OpenSky + // layer is active AND the Traffic map layer is on. Internet + // traffic is delayed/incomplete and not for separation. + if (_openSkyActive && _layersOpacity[_layers.indexOf("Traffic")] > 0) + Positioned( + top: Constants.screenHeightForInstruments(context) + 4, + left: 6, + child: IgnorePointer( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.errorContainer.withAlpha(210), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.wifi_tethering, size: 12, + color: Theme.of(context).colorScheme.onErrorContainer), + const SizedBox(width: 4), + Text( + "Internet traffic (OpenSky) — advisory, delayed; not for separation", + style: TextStyle( + fontSize: 10, + color: Theme.of(context).colorScheme.onErrorContainer, + ), + ), + ], + ), + ), + ), + ), if(_layersOpacity[_layers.indexOf('PFD')] > 0) ValueListenableBuilder( valueListenable: Storage().pfdChange, @@ -1430,7 +1677,7 @@ class MapScreenState extends State { child: Padding( padding: EdgeInsets.fromLTRB(0, Constants.screenHeightForInstruments(context) + 5, 5, 5), child: Column(crossAxisAlignment: CrossAxisAlignment.end, children:[ - if(Constants.shouldShowProServices) IconButton(icon: CircleAvatar(child: Icon(MdiIcons.accountTieHat)), onPressed: () { Navigator.pushNamed(context, '/pro');}), + if(Constants.shouldShowAi) IconButton(icon: CircleAvatar(child: Icon(MdiIcons.robot)), tooltip: "Flight Intelligence", onPressed: () { Navigator.pushNamed(context, '/ai');}), ValueListenableBuilder( valueListenable: Storage().warningChange, builder: (context, value, _) { @@ -2337,6 +2584,7 @@ class _WeatherProductSelectorOverlay extends StatefulWidget { class _WeatherProductSelectorOverlayState extends State<_WeatherProductSelectorOverlay> { late List _localOpacity; + int _radarColorScheme = Storage().settings.getRadarColorScheme(); @override void initState() { @@ -2344,6 +2592,48 @@ class _WeatherProductSelectorOverlayState _localOpacity = List.from(widget.productsOpacity); } + // RainViewer radar color scheme picker (EU build only). Persists immediately + // so the map's next animation tick picks up the new scheme. + Widget _buildRadarColorSchemePicker(BuildContext context) { + final scheme = Theme.of(context).colorScheme; + return Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 4), + child: Row( + children: [ + Icon(Icons.palette, size: 20, color: scheme.primary), + const SizedBox(width: 10), + Text( + "Radar colors", + style: TextStyle(fontSize: 14, color: scheme.onSurface), + ), + const Spacer(), + DropdownButton( + value: _radarColorScheme, + isDense: true, + underline: const SizedBox.shrink(), + items: [ + for (int i = 0; i < Constants.rainViewerColorSchemes.length; i++) + DropdownMenuItem( + value: i, + child: Text( + Constants.rainViewerColorSchemes[i], + style: const TextStyle(fontSize: 13), + ), + ), + ], + onChanged: (value) { + if (value == null) return; + setState(() => _radarColorScheme = value); + Storage().settings.setRadarColorScheme(value); + // Nudge the radar layer to rebuild with the new scheme. + Storage().timeRadarChange.value++; + }, + ), + ], + ), + ); + } + @override Widget build(BuildContext context) { return Align( @@ -2410,6 +2700,7 @@ class _WeatherProductSelectorOverlayState ], ), ), + if (Constants.isEu) _buildRadarColorSchemePicker(context), Flexible( child: ListView.builder( padding: const EdgeInsets.symmetric(vertical: 6), @@ -2528,6 +2819,27 @@ class _WeatherProductSelectorOverlayState }, ), ), + if (Constants.isEu) + Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 0), + child: Row( + children: [ + Icon(Icons.info_outline, + size: 14, + color: Theme.of(context).colorScheme.outline), + const SizedBox(width: 6), + Expanded( + child: Text( + "${RainViewerRadar.attribution} — advisory only.", + style: TextStyle( + fontSize: 11, + color: Theme.of(context).colorScheme.outline, + ), + ), + ), + ], + ), + ), const SizedBox(height: 8), ], ), diff --git a/lib/ofm/ofm_airspace_layer.dart b/lib/ofm/ofm_airspace_layer.dart new file mode 100644 index 00000000..62c6d60a --- /dev/null +++ b/lib/ofm/ofm_airspace_layer.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'dart:math'; + +import 'ofm_data_provider.dart'; + +class OfmAirspaceLayer { + OfmAirspaceLayer._(); + + static List polygons(List airspaces, {required double opacity}) { + return airspaces.where((airspace) => airspace.vertices.length >= 3).map((airspace) { + final color = _classColor(airspace.airspaceClass); + final altitude = [ + if (airspace.lowerFeet != null) '${airspace.lowerFeet!.round()}', + if (airspace.upperFeet != null) '${airspace.upperFeet!.round()}', + ].join('-'); + return Polygon( + points: airspace.geometry.isEmpty ? airspace.vertices : expandVertices(airspace.geometry), + color: color.withValues(alpha: 0.12 * opacity), + borderColor: color.withValues(alpha: opacity), + borderStrokeWidth: 2, + label: '${airspace.codeId} ${airspace.name}${altitude.isEmpty ? '' : ' $altitude ft'}', + labelStyle: TextStyle(color: color.withValues(alpha: opacity), fontSize: 10, fontWeight: FontWeight.bold), + ); + }).toList(); + } + + static List expandVertices(List vertices) { + if (vertices.length < 2) return vertices.map((v) => v.point).toList(); + final result = [vertices.first.point]; + for (var index = 1; index < vertices.length; index++) { + final current = vertices[index]; + final center = current.arcCenter; + if (center == null || (current.codeType != 'CWA' && current.codeType != 'CCA')) { + result.add(current.point); + continue; + } + final start = result.last; + final xScale = cos(center.latitude * pi / 180); + final sx = (start.longitude - center.longitude) * xScale; + final sy = start.latitude - center.latitude; + final ex = (current.point.longitude - center.longitude) * xScale; + final ey = current.point.latitude - center.latitude; + final radius = (sqrt(sx * sx + sy * sy) + sqrt(ex * ex + ey * ey)) / 2; + final startAngle = atan2(sy, sx); + var endAngle = atan2(ey, ex); + if (current.codeType == 'CWA') { + while (endAngle >= startAngle) { + endAngle -= 2 * pi; + } + } else { + while (endAngle <= startAngle) { + endAngle += 2 * pi; + } + } + final sweep = endAngle - startAngle; + final steps = max(2, (sweep.abs() / (5 * pi / 180)).ceil()); + for (var step = 1; step <= steps; step++) { + final angle = startAngle + sweep * step / steps; + result.add(LatLng( + center.latitude + radius * sin(angle), + center.longitude + radius * cos(angle) / xScale, + )); + } + } + return result; + } + + static Color _classColor(String value) { + switch (value.toUpperCase()) { + case 'B': return Colors.blue; + case 'C': return Colors.purple; + case 'D': return Colors.red; + case 'E': return Colors.orange; + default: return Colors.brown; + } + } +} diff --git a/lib/ofm/ofm_attribution.dart b/lib/ofm/ofm_attribution.dart new file mode 100644 index 00000000..b60acdd6 --- /dev/null +++ b/lib/ofm/ofm_attribution.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; + +import 'ofm_constants.dart'; + +class OfmAttribution extends StatelessWidget { + final double opacity; + + const OfmAttribution({super.key, required this.opacity}); + + @override + Widget build(BuildContext context) { + return IgnorePointer( + child: Align( + alignment: Alignment.bottomLeft, + child: Opacity( + opacity: opacity.clamp(0.0, 1.0).toDouble(), + child: Container( + margin: const EdgeInsets.only(left: 8, bottom: 84), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withAlpha(150), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + OfmConstants.attribution, + style: TextStyle(color: Colors.white, fontSize: 11), + ), + ), + ), + ), + ); + } +} diff --git a/lib/ofm/ofm_chart_library_screen.dart b/lib/ofm/ofm_chart_library_screen.dart new file mode 100644 index 00000000..0d31ac8b --- /dev/null +++ b/lib/ofm/ofm_chart_library_screen.dart @@ -0,0 +1,78 @@ +import 'package:universal_io/io.dart'; + +import 'package:flutter/material.dart'; + +import '../storage.dart'; +import '../utils/pdf_viewer.dart'; +import 'ofm_constants.dart'; +import 'ofm_manifest.dart'; +import 'ofm_manifest_store.dart'; + +class OfmChartLibraryScreen extends StatefulWidget { + const OfmChartLibraryScreen({super.key}); + + @override + State createState() => _OfmChartLibraryScreenState(); +} + +class _OfmChartLibraryScreenState extends State { + late Future> _charts; + + @override + void initState() { + super.initState(); + _reload(); + } + + void _reload() { + _charts = OfmManifestStore(Storage().dataDir).load().then((manifest) async { + final charts = []; + for (final product in manifest.products.where((item) => item.type == 'pdf')) { + if (await File(product.localPath).exists()) charts.add(product); + } + charts.sort((a, b) => '${a.region}:${a.cycle}:${a.name}'.compareTo('${b.region}:${b.cycle}:${b.name}')); + return charts; + }); + } + + Future _remove(OfmInstalledProduct product) async { + await OfmManifestStore(Storage().dataDir).removeProduct(product); + setState(_reload); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('OFM VFR Chart Sheets')), + body: FutureBuilder>( + future: _charts, + builder: (context, snapshot) { + if (!snapshot.hasData) return const Center(child: CircularProgressIndicator()); + final charts = snapshot.data!; + if (charts.isEmpty) { + return const Center(child: Text('No OFM PDF chart sheets are installed.')); + } + return ListView( + padding: const EdgeInsets.all(12), + children: [ + const Card(child: Padding( + padding: EdgeInsets.all(12), + child: Text('Document view—not GPS-referenced.\n${OfmConstants.disclaimer}\n${OfmConstants.attribution}'), + )), + for (final chart in charts) + Card(child: ListTile( + leading: const Icon(Icons.picture_as_pdf), + title: Text('${chart.name} — ${chart.details}'), + subtitle: Text('${chart.region} • AIRAC ${chart.cycle}${chart.byteSize == null ? '' : ' • ${(chart.byteSize! / 1048576).toStringAsFixed(1)} MiB'}'), + onTap: () => Navigator.of(context).push(MaterialPageRoute( + builder: (_) => PdfViewer(chart.localPath, title: '${chart.name} — ${chart.details}', notice: 'OFM chart sheet • not GPS-referenced'), + )), + trailing: IconButton(icon: const Icon(Icons.delete_outline), onPressed: () => _remove(chart)), + )), + ], + ); + }, + ), + ); + } +} diff --git a/lib/ofm/ofm_constants.dart b/lib/ofm/ofm_constants.dart new file mode 100644 index 00000000..2d9e381c --- /dev/null +++ b/lib/ofm/ofm_constants.dart @@ -0,0 +1,14 @@ +class OfmConstants { + OfmConstants._(); + + static const String sourceName = 'OpenFlightMaps'; + static const String layerName = 'OFM VFR Chart'; + static const String legacyLayerName = 'OpenFlightMaps'; + static const String dataLayerName = 'OFM Interactive Data'; + static const String attribution = '© open flightmaps association'; + static const String disclaimer = + 'OpenFlightMaps data is community-maintained complementary information ' + 'and is not a primary navigation source.'; + static const String corrections = + 'Report known data errors to the open flightmaps association.'; +} diff --git a/lib/ofm/ofm_data_provider.dart b/lib/ofm/ofm_data_provider.dart new file mode 100644 index 00000000..e42abab6 --- /dev/null +++ b/lib/ofm/ofm_data_provider.dart @@ -0,0 +1,268 @@ +import 'dart:math'; + +import 'package:latlong2/latlong.dart'; +import 'package:sqflite/sqflite.dart'; + +import '../destination/destination.dart'; +import 'ofm_database_helper.dart'; + +abstract class AeronauticalDataProvider { + Future> findDestinations(String match, {bool exact = false}); + Future> findNear(LatLng point, {double factor = 0.001}); + Future findAirport(String code); +} + +class OfmAirspace { + final String id; + final String codeId; + final String name; + final String airspaceClass; + final String region; + final String cycle; + final double? lowerFeet; + final double? upperFeet; + final List vertices; + final List geometry; + + const OfmAirspace({required this.id, required this.codeId, required this.name, + required this.airspaceClass, required this.region, required this.cycle, + required this.lowerFeet, required this.upperFeet, required this.vertices, + this.geometry = const []}); +} + +class OfmAirspaceVertex { + final LatLng point; + final String codeType; + final LatLng? arcCenter; + + const OfmAirspaceVertex({required this.point, required this.codeType, this.arcCenter}); +} + +class OfmDataProvider implements AeronauticalDataProvider { + final Database? _database; + final String? _dataDir; + + const OfmDataProvider({Database? database, String? dataDir}) + : _database = database, + _dataDir = dataDir; + + Future _db() async { + if (_database != null) return _database; + if (_dataDir == null) throw StateError('dataDir is required when no database is injected'); + return OfmDatabaseHelper.db.open(_dataDir); + } + + @override + Future> findDestinations(String match, {bool exact = false}) async { + final db = await _db(); + final normalized = match.trim().toUpperCase(); + if (normalized.isEmpty) return []; + final operator = exact ? '=' : 'like'; + final value = exact ? normalized : '$normalized%'; + final airportRows = await db.rawQuery(''' +select code_id as LocationID, name as FacilityName, + case when type in ('AH', 'AD', 'HP') then 'AIRPORT' else coalesce(type, 'AIRPORT') end as Type, + lat as ARPLatitude, lon as ARPLongitude, + 'OFM' as Source, region as SourceRegion, cycle as SourceCycle +from ofm_airport +where upper(code_id) $operator ? or upper(coalesce(name, '')) like ? +order by case when upper(code_id) = ? then 0 else 1 end, code_id +limit 20 +''', [value, '%$normalized%', normalized]); + final waypointRows = await db.rawQuery(''' +select code_id as LocationID, name as FacilityName, + case when kind = 'FIX' then 'FIX' else kind end as Type, + lat as ARPLatitude, lon as ARPLongitude, + 'OFM' as Source, region as SourceRegion, cycle as SourceCycle +from ofm_waypoint +where upper(code_id) $operator ? or upper(coalesce(name, '')) like ? +order by case when upper(code_id) = ? then 0 else 1 end, code_id +limit 20 +''', [value, '%$normalized%', normalized]); + return [...airportRows, ...waypointRows] + .map((row) => Destination.fromMap(Map.from(row))) + .toList(); + } + + @override + Future> findNear(LatLng point, {double factor = 0.001}) async { + final db = await _db(); + final correction = pow(cos(point.latitude * pi / 180), 2); + final airportRows = await db.rawQuery(''' +select code_id as LocationID, name as FacilityName, 'AIRPORT' as Type, + lat as ARPLatitude, lon as ARPLongitude, + 'OFM' as Source, region as SourceRegion, cycle as SourceCycle, + ((lon - ?) * (lon - ?) * ? + (lat - ?) * (lat - ?)) as distance +from ofm_airport +where ((lon - ?) * (lon - ?) * ? + (lat - ?) * (lat - ?)) < ? +order by distance +limit 20 +''', [point.longitude, point.longitude, correction, point.latitude, point.latitude, + point.longitude, point.longitude, correction, point.latitude, point.latitude, factor]); + final waypointRows = await db.rawQuery(''' +select code_id as LocationID, name as FacilityName, + case when kind = 'FIX' then 'FIX' else kind end as Type, + lat as ARPLatitude, lon as ARPLongitude, + 'OFM' as Source, region as SourceRegion, cycle as SourceCycle, + ((lon - ?) * (lon - ?) * ? + (lat - ?) * (lat - ?)) as distance +from ofm_waypoint +where ((lon - ?) * (lon - ?) * ? + (lat - ?) * (lat - ?)) < ? +order by distance +limit 20 +''', [point.longitude, point.longitude, correction, point.latitude, point.latitude, + point.longitude, point.longitude, correction, point.latitude, point.latitude, factor]); + final rows = [...airportRows, ...waypointRows] + ..sort((a, b) => ((a['distance'] as num?) ?? 0).compareTo((b['distance'] as num?) ?? 0)); + return rows.map((row) => Destination.fromMap(Map.from(row))).toList(); + } + + Future> findNearestAirportsWithRunways( + LatLng point, + int minimumRunwayLengthFeet, + ) async { + final db = await _db(); + final correction = pow(cos(point.latitude * pi / 180), 2); + final minimumMeters = minimumRunwayLengthFeet / 3.280839895013123; + final rows = await db.rawQuery(''' +select a.code_id as LocationID, a.name as FacilityName, 'AIRPORT' as Type, + a.lat as ARPLatitude, a.lon as ARPLongitude, + 'OFM' as Source, a.region as SourceRegion, a.cycle as SourceCycle, + ((a.lon - ?) * (a.lon - ?) * ? + (a.lat - ?) * (a.lat - ?)) as distance +from ofm_airport a +where exists ( + select 1 from ofm_runway r + where r.airport_id = a.id and coalesce(r.length_m, 0) >= ? +) +order by distance +limit 20 +''', [point.longitude, point.longitude, correction, point.latitude, point.latitude, minimumMeters]); + return rows.map((row) => Destination.fromMap(Map.from(row))).toList(); + } + + Future> findNearestVOR(LatLng point) async { + final db = await _db(); + final correction = pow(cos(point.latitude * pi / 180), 2); + final rows = await db.rawQuery(''' +select *, ((lon - ?) * (lon - ?) * ? + (lat - ?) * (lat - ?)) as distance +from ofm_waypoint +where kind = 'VOR' +order by distance +limit 3 +''', [point.longitude, point.longitude, correction, point.latitude, point.latitude]); + return rows.map((row) => NavDestination( + locationID: row['code_id'] as String, + type: (row['type'] ?? 'VOR').toString(), + facilityName: (row['name'] ?? row['code_id']).toString(), + coordinate: LatLng((row['lat'] as num).toDouble(), (row['lon'] as num).toDouble()), + source: 'OFM', + sourceRegion: row['region'] as String, + sourceCycle: row['cycle'] as String, + class_: (row['frequency'] ?? '').toString(), + hiwas: (row['remark'] ?? '').toString(), + )).toList(); + } + + @override + Future findAirport(String code) async { + final db = await _db(); + final rows = await db.query('ofm_airport', where: 'upper(code_id) = ?', whereArgs: [code.toUpperCase()], limit: 1); + if (rows.isEmpty) return null; + final row = rows.single; + final id = row['id'] as String; + final comms = await db.query('ofm_airport_comm', where: 'airport_id = ?', whereArgs: [id], orderBy: 'sequence'); + final runways = await db.query('ofm_runway', where: 'airport_id = ?', whereArgs: [id], orderBy: 'designation'); + final runwayDetails = >[]; + for (final runway in runways) { + final ends = await db.query( + 'ofm_runway_end', + where: 'runway_id = ?', + whereArgs: [runway['id']], + orderBy: 'designation', + ); + final low = ends.isEmpty ? null : ends.first; + final high = ends.length < 2 ? null : ends[1]; + String value(Map? end, String key) => (end?[key] ?? '').toString(); + runwayDetails.add({ + 'RunwayID': (runway['designation'] ?? '').toString(), + 'Length': ((runway['length_m'] as num?)?.toDouble() ?? 0) * 3.280839895013123, + 'Width': ((runway['width_m'] as num?)?.toDouble() ?? 0) * 3.280839895013123, + 'Surface': (runway['surface'] ?? '').toString(), + 'LEIdent': value(low, 'designation'), + 'LELatitude': value(low, 'lat'), + 'LELongitude': value(low, 'lon'), + 'LEHeading': value(low, 'mag_bearing'), + 'LEElevation': value(low, 'tdze_ft'), + 'LEPattern': value(low, 'pattern'), + 'LEVGSI': value(low, 'vasi_type'), + 'HEIdent': value(high, 'designation'), + 'HELatitude': value(high, 'lat'), + 'HELongitude': value(high, 'lon'), + 'HEHeading': value(high, 'mag_bearing'), + 'HEElevation': value(high, 'tdze_ft'), + 'HEPattern': value(high, 'pattern'), + 'HEVGSI': value(high, 'vasi_type'), + }); + } + final destination = AirportDestination( + locationID: row['code_id'] as String, + facilityName: (row['name'] ?? row['code_id']).toString(), + type: 'AIRPORT', + coordinate: LatLng(row['lat'] as double, row['lon'] as double), + source: 'OFM', + sourceRegion: row['region'] as String, + sourceCycle: row['cycle'] as String, + frequencies: comms.map((item) => { + 'Frequency': (item['value'] ?? '').toString(), + 'Use': (item['code_type'] ?? '').toString(), + 'Remark': (item['remark'] ?? '').toString(), + }).toList(), + awos: const [], + runways: runwayDetails, + unicom: '', ctaf: '', use: '', fuelTypes: '', customs: '', beacon: '', + segCircle: '', trafficPatternAltitude: '', atct: '', nonCommercialLandingFee: '', + ); + destination.elevation = (row['elevation_ft'] as num?)?.toDouble(); + return destination; + } + + Future> findAirspacesInBounds({ + required double minLat, required double maxLat, + required double minLon, required double maxLon, + }) async { + final db = await _db(); + final rows = await db.rawQuery(''' +select a.* from ofm_airspace a +where exists ( + select 1 from ofm_airspace_vertex v where v.airspace_id = a.id + group by v.airspace_id + having min(v.lat) <= ? and max(v.lat) >= ? + and min(v.lon) <= ? and max(v.lon) >= ? +) +order by a.name +''', [maxLat, minLat, maxLon, minLon]); + final result = []; + for (final row in rows) { + final vertexRows = await db.query('ofm_airspace_vertex', where: 'airspace_id = ?', whereArgs: [row['id']], orderBy: 'sequence'); + final geometry = vertexRows.map((v) => OfmAirspaceVertex( + point: LatLng((v['lat'] as num).toDouble(), (v['lon'] as num).toDouble()), + codeType: (v['code_type'] ?? 'GRC').toString(), + arcCenter: v['arc_lat'] == null || v['arc_lon'] == null + ? null + : LatLng((v['arc_lat'] as num).toDouble(), (v['arc_lon'] as num).toDouble()), + )).toList(); + result.add(OfmAirspace( + id: row['id'] as String, + codeId: (row['code_id'] ?? '').toString(), + name: (row['name'] ?? '').toString(), + airspaceClass: (row['class'] ?? '').toString(), + region: row['region'] as String, + cycle: row['cycle'] as String, + lowerFeet: (row['alt_lower_ft'] as num?)?.toDouble(), + upperFeet: (row['alt_upper_ft'] as num?)?.toDouble(), + vertices: geometry.map((v) => v.point).toList(), + geometry: geometry, + )); + } + return result; + } +} diff --git a/lib/ofm/ofm_database_helper.dart b/lib/ofm/ofm_database_helper.dart new file mode 100644 index 00000000..b48c0500 --- /dev/null +++ b/lib/ofm/ofm_database_helper.dart @@ -0,0 +1,146 @@ +import 'package:universal_io/io.dart'; + +import 'package:path/path.dart' as path; +import 'package:sqflite/sqflite.dart'; + +import 'ofm_paths.dart'; +import 'ofm_manifest.dart'; +import 'ofm_schema.dart'; +import 'ofmx_importer.dart'; + +class OfmDatabaseHelper { + OfmDatabaseHelper._(); + + static final OfmDatabaseHelper db = OfmDatabaseHelper._(); + static Database? _database; + + Future open(String dataDir) async { + if (_database != null) { + return _database!; + } + final dbPath = OfmPaths(dataDir).ofmDatabasePath; + await Directory(path.dirname(dbPath)).create(recursive: true); + _database = await openDatabase( + dbPath, + version: OfmSchema.version, + onCreate: (database, version) async { + for (final statement in OfmSchema.createStatements) { + await database.execute(statement); + } + }, + onUpgrade: (database, oldVersion, newVersion) async { + for (final statement in OfmSchema.createStatements) { + await database.execute(statement); + } + }, + ); + return _database!; + } + + static Future invalidateConnection() async { + final database = _database; + if (database != null) { + await database.close(); + _database = null; + } + } + + String databasePath(String dataDir) => path.normalize(OfmPaths(dataDir).ofmDatabasePath); + + Future recordInstall({ + required String dataDir, + required OfmInstall install, + String? effective, + String? expiration, + String? ofmxUrl, + String? mbtilesUrl, + }) async { + final database = await open(dataDir); + await database.insert( + 'ofm_region_install', + { + 'region': install.region, + 'cycle': install.cycle, + 'effective': effective, + 'expiration': expiration, + 'publication_url': install.publicationUrl.toString(), + 'ofmx_url': ofmxUrl, + 'mbtiles_url': mbtilesUrl, + 'mbtiles_path': install.mbtilesPath, + 'installed_at': install.installedAt.toUtc().toIso8601String(), + }, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future importResult({ + required String dataDir, + required OfmxImportResult result, + required String region, + required String cycle, + }) async { + final database = await open(dataDir); + await database.transaction((transaction) async { + await _deleteRegionCycle(transaction, region, cycle); + await _insertAll(transaction, 'ofm_airport', result.airports); + await _insertAll(transaction, 'ofm_airport_comm', result.airportComms); + await _insertAll(transaction, 'ofm_runway', result.runways); + await _insertAll(transaction, 'ofm_runway_end', result.runwayEnds); + await _insertAll(transaction, 'ofm_waypoint', result.waypoints); + await _insertAll(transaction, 'ofm_airspace', result.airspaces); + await _insertAll(transaction, 'ofm_airspace_vertex', result.airspaceVertices); + }); + } + + Future deleteRegion({ + required String dataDir, + required String region, + String? cycle, + }) async { + final database = await open(dataDir); + await database.transaction((transaction) async { + await _deleteRegionCycle(transaction, region, cycle); + await transaction.delete( + 'ofm_region_install', + where: cycle == null ? 'region = ?' : 'region = ? and cycle = ?', + whereArgs: cycle == null ? [region] : [region, cycle], + ); + }); + } + + static Future _deleteRegionCycle( + DatabaseExecutor database, + String region, + String? cycle, + ) async { + final clause = cycle == null ? 'region = ?' : 'region = ? and cycle = ?'; + final args = cycle == null ? [region] : [region, cycle]; + final airportRows = await database.query('ofm_airport', columns: ['id'], where: clause, whereArgs: args); + final airportIds = airportRows.map((row) => row['id']).whereType().toList(); + for (final airportId in airportIds) { + final runwayRows = await database.query('ofm_runway', columns: ['id'], where: 'airport_id = ?', whereArgs: [airportId]); + for (final row in runwayRows) { + await database.delete('ofm_runway_end', where: 'runway_id = ?', whereArgs: [row['id']]); + } + await database.delete('ofm_runway', where: 'airport_id = ?', whereArgs: [airportId]); + await database.delete('ofm_airport_comm', where: 'airport_id = ?', whereArgs: [airportId]); + } + await database.delete('ofm_airport', where: clause, whereArgs: args); + await database.delete('ofm_waypoint', where: clause, whereArgs: args); + final airspaceRows = await database.query('ofm_airspace', columns: ['id'], where: clause, whereArgs: args); + for (final row in airspaceRows) { + await database.delete('ofm_airspace_vertex', where: 'airspace_id = ?', whereArgs: [row['id']]); + } + await database.delete('ofm_airspace', where: clause, whereArgs: args); + } + + static Future _insertAll( + DatabaseExecutor database, + String table, + List> rows, + ) async { + for (final row in rows) { + await database.insert(table, row, conflictAlgorithm: ConflictAlgorithm.replace); + } + } +} diff --git a/lib/ofm/ofm_download_manager.dart b/lib/ofm/ofm_download_manager.dart new file mode 100644 index 00000000..e6b9d0f5 --- /dev/null +++ b/lib/ofm/ofm_download_manager.dart @@ -0,0 +1,203 @@ +import 'dart:async'; +import 'package:universal_io/io.dart'; + +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; +import 'package:archive/archive_io.dart'; + +import 'ofm_manifest.dart'; +import 'ofm_paths.dart'; +import 'ofm_publication.dart'; +import 'ofmx_importer.dart'; + +class OfmDownloadManager { + bool _cancelled = false; + + void cancel() { + _cancelled = true; + } + + Future contentLength(Uri uri, {http.Client? client}) async { + final httpClient = client ?? http.Client(); + try { + final response = await httpClient.head(uri); + if (response.statusCode < 200 || response.statusCode >= 300) return null; + return int.tryParse(response.headers['content-length'] ?? ''); + } finally { + if (client == null) httpClient.close(); + } + } + + Future downloadPdf({ + required String dataDir, + required String region, + required String publicationCode, + required String cycle, + required OfmPublicationProduct product, + required void Function(double progress) onProgress, + http.Client? client, + }) async { + if (product.type != OfmProductType.chartPdf) { + throw const OfmDownloadException('Selected product is not an OFM PDF chart.'); + } + _cancelled = false; + final basename = path.basename(product.url.path); + final filename = basename.toLowerCase().endsWith('.pdf') ? basename : '${product.name}.pdf'; + final destination = OfmPaths(dataDir).pdfPath(region: publicationCode, cycle: cycle, filename: filename); + final temporary = '$destination.part'; + await Directory(path.dirname(destination)).create(recursive: true); + await _downloadFile(product.url, temporary, onProgress, client: client); + final header = await File(temporary).openRead(0, 5).fold>([], (bytes, chunk) => bytes..addAll(chunk)); + if (String.fromCharCodes(header) != '%PDF-') { + await File(temporary).delete(); + throw const OfmDownloadException('Downloaded OFM chart is not a valid PDF.'); + } + final destinationFile = File(destination); + if (await destinationFile.exists()) await destinationFile.delete(); + await File(temporary).rename(destination); + return OfmInstalledProduct( + region: region, + publicationCode: publicationCode, + cycle: cycle, + type: 'pdf', + name: product.name, + details: product.details, + sourceUrl: product.url, + localPath: destination, + timestamp: product.timestamp, + byteSize: await File(destination).length(), + ); + } + + Future downloadMbtiles({ + required String dataDir, + required OfmPublication publication, + required Uri publicationUrl, + required void Function(double progress) onProgress, + http.Client? client, + OfmPublicationProduct? selectedProduct, + }) async { + _cancelled = false; + final product = selectedProduct ?? publication.preferredMbtiles; + if (product == null) { + throw const OfmDownloadException('No OFM MBTiles product found in publication.'); + } + + final paths = OfmPaths(dataDir); + final destination = paths.mbtilesPath(region: publication.region, cycle: publication.cycle); + final temporary = '$destination.part'; + await Directory(path.dirname(destination)).create(recursive: true); + await _downloadFile(product.url, temporary, onProgress, client: client); + final header = await File(temporary).openRead(0, 16).fold>([], (bytes, chunk) => bytes..addAll(chunk)); + if (header.length < 16 || String.fromCharCodes(header) != 'SQLite format 3\u0000') { + await File(temporary).delete(); + throw const OfmDownloadException('Downloaded OFM MBTiles is not a valid SQLite database.'); + } + final destinationFile = File(destination); + if (await destinationFile.exists()) await destinationFile.delete(); + await File(temporary).rename(destination); + + return OfmInstall( + region: publication.region, + cycle: publication.cycle, + installedAt: DateTime.now().toUtc(), + publicationUrl: publicationUrl, + mbtilesPath: destination, + ); + } + + Future<(OfmInstall, OfmxImportResult)> downloadOfmx({ + required String dataDir, + required OfmPublication publication, + required Uri publicationUrl, + required void Function(double progress) onProgress, + http.Client? client, + }) async { + _cancelled = false; + final product = publication.ofmx; + if (product == null) throw const OfmDownloadException('No OFM OFMX product found in publication.'); + final paths = OfmPaths(dataDir); + final rawDir = paths.rawRegionDir(region: publication.region, cycle: publication.cycle); + await Directory(rawDir).create(recursive: true); + final zipPath = path.join(rawDir, 'ofmx_${publication.region.toLowerCase()}.zip'); + await _downloadFile(product.url, zipPath, (value) => onProgress(value * 0.7), client: client); + InputFileStream? input; + try { + input = InputFileStream(zipPath); + final archive = ZipDecoder().decodeStream(input); + final entries = archive.where((entry) => entry.isFile && entry.name.toLowerCase().endsWith('.ofmx')).toList(); + if (entries.isEmpty) throw const OfmDownloadException('Downloaded OFMX ZIP contains no .ofmx data.'); + final entry = entries.firstWhere((entry) => entry.name.toLowerCase().contains('isolated/'), orElse: () => entries.first); + final destination = path.join(rawDir, path.basename(entry.name)); + final output = OutputFileStream(destination); + entry.writeContent(output); + output.closeSync(); + final result = OfmxImporter.parse(await File(destination).readAsString(), region: publication.region, cycle: publication.cycle); + onProgress(1); + return ( + OfmInstall(region: publication.region, cycle: publication.cycle, installedAt: DateTime.now().toUtc(), publicationUrl: publicationUrl, ofmxPath: destination), + result, + ); + } finally { + input?.close(); + final zip = File(zipPath); + if (await zip.exists()) await zip.delete(); + } + } + + Future _downloadFile( + Uri uri, + String destination, + void Function(double progress) onProgress, { + http.Client? client, + }) async { + final httpClient = client ?? http.Client(); + IOSink? sink; + try { + final request = http.Request('GET', uri); + final response = await httpClient.send(request); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw OfmDownloadException('Unable to download $uri (${response.statusCode})'); + } + final total = response.contentLength; + var downloaded = 0; + sink = File(destination).openWrite(); + await for (final chunk in response.stream) { + if (_cancelled) { + throw const OfmDownloadException('Download cancelled.'); + } + downloaded += chunk.length; + sink.add(chunk); + if (total != null && total > 0) { + onProgress(downloaded / total); + } + } + await sink.close(); + sink = null; + onProgress(1); + } catch (_) { + try { + await sink?.close(); + } catch (_) { + // ignore cleanup errors + } + final file = File(destination); + if (await file.exists()) { + await file.delete(); + } + rethrow; + } finally { + if (client == null) { + httpClient.close(); + } + } + } +} + +class OfmDownloadException implements Exception { + final String message; + const OfmDownloadException(this.message); + + @override + String toString() => message; +} diff --git a/lib/ofm/ofm_download_screen.dart b/lib/ofm/ofm_download_screen.dart new file mode 100644 index 00000000..dd81e54b --- /dev/null +++ b/lib/ofm/ofm_download_screen.dart @@ -0,0 +1,346 @@ +import 'package:avaremp/storage.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:universal_io/io.dart'; + +import 'ofm_constants.dart'; +import 'ofm_database_helper.dart'; +import 'ofm_download_manager.dart'; +import 'ofm_manifest.dart'; +import 'ofm_manifest_store.dart'; +import 'ofm_map_layer.dart'; +import 'ofm_paths.dart'; +import 'ofm_publication.dart'; +import 'ofm_publication_client.dart'; +import 'ofm_region.dart'; + +class OfmDownloadScreen extends StatefulWidget { + const OfmDownloadScreen({super.key}); + + @override + State createState() => _OfmDownloadScreenState(); +} + +class _OfmDownloadScreenState extends State { + + final OfmPublicationClient _client = OfmPublicationClient(); + final OfmDownloadManager _downloadManager = OfmDownloadManager(); + String _region = 'ED'; + String _cycle = OfmPublicationClient.currentAiracCycle(); + OfmPublication? _publication; + String? _message; + double? _progress; + bool _busy = false; + bool _includeMbtiles = true; + bool _includeOfmx = true; + bool _retinaMbtiles = false; + final Set _selectedPdfUrls = {}; + + Future _fetchPublication() async { + setState(() { + _busy = true; + _message = 'Fetching OFM publication...'; + _progress = null; + }); + try { + final publication = await _client.fetch(region: _region, cycle: _cycle); + setState(() { + _publication = publication; + _selectedPdfUrls.removeWhere((url) => !publication.chartPdfs.any((product) => product.url.toString() == url)); + _message = 'Found ${publication.products.length} products for $_region $_cycle.'; + }); + } catch (e) { + setState(() { + _message = 'Unable to fetch publication: $e'; + }); + } finally { + setState(() { + _busy = false; + }); + } + } + + + Future _downloadSelected() async { + if (kIsWeb) { + setState(() => _message = 'OFM regional downloads are available on mobile/desktop only.'); + return; + } + if (!_includeMbtiles && !_includeOfmx && _selectedPdfUrls.isEmpty) { + setState(() => _message = 'Select at least one OFM product.'); + return; + } + if (_publication == null) await _fetchPublication(); + final pub = _publication; + if (pub == null) return; + setState(() { _busy = true; _progress = 0; _message = 'Installing OFM data...'; }); + try { + OfmInstall? install; + if (_includeMbtiles) { + install = await _downloadManager.downloadMbtiles( + dataDir: Storage().dataDir, + publication: pub, + publicationUrl: _client.publicationUri(region: _region, cycle: _cycle), + onProgress: (value) { if (mounted) setState(() => _progress = value * (_includeOfmx ? 0.45 : 1)); }, + selectedProduct: _retinaMbtiles ? pub.retinaMbtiles : pub.normalMbtiles, + ); + } + if (_includeOfmx) { + final (ofmxInstall, result) = await _downloadManager.downloadOfmx( + dataDir: Storage().dataDir, + publication: pub, + publicationUrl: _client.publicationUri(region: _region, cycle: _cycle), + onProgress: (value) { if (mounted) setState(() => _progress = (_includeMbtiles ? 0.45 : 0) + value * (_includeMbtiles ? 0.55 : 1)); }, + ); + await OfmDatabaseHelper.db.importResult(dataDir: Storage().dataDir, result: result, region: pub.region, cycle: pub.cycle); + install = OfmInstall( + region: ofmxInstall.region, + cycle: ofmxInstall.cycle, + installedAt: ofmxInstall.installedAt, + publicationUrl: ofmxInstall.publicationUrl, + mbtilesPath: install?.mbtilesPath, + ofmxPath: ofmxInstall.ofmxPath, + ); + } + if (install != null) { + await _writeManifest(install); + await OfmDatabaseHelper.db.recordInstall( + dataDir: Storage().dataDir, + install: install, + effective: pub.nearCycles.isNotEmpty ? pub.nearCycles.first.startValidity?.toIso8601String() : null, + expiration: pub.nearCycles.isNotEmpty ? pub.nearCycles.first.endValidity?.toIso8601String() : null, + ofmxUrl: pub.ofmx?.url.toString(), + mbtilesUrl: pub.preferredMbtiles?.url.toString(), + ); + OfmMapLayer.notifyChanged(); + } + final publicationCode = OfmRegions.publicationCode(pub.region); + for (final product in pub.chartPdfs) { + if (!_selectedPdfUrls.contains(product.url.toString())) continue; + final chart = await _downloadManager.downloadPdf( + dataDir: Storage().dataDir, + region: pub.region, + publicationCode: publicationCode, + cycle: pub.cycle, + product: product, + onProgress: (value) { if (mounted) setState(() => _progress = value); }, + ); + await OfmManifestStore(Storage().dataDir).mergeProduct(chart); + } + setState(() => _message = 'Installed selected ${pub.region} ${pub.cycle} OFM products.'); + } catch (e) { + setState(() => _message = 'Unable to install OFM data: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _deleteSelectedRegion() async { + setState(() { _busy = true; _message = 'Removing OFM region...'; }); + try { + final paths = OfmPaths(Storage().dataDir); + final manifestFile = File(paths.manifestPath); + var manifest = OfmManifest.empty(); + if (await manifestFile.exists()) manifest = OfmManifest.fromJsonString(await manifestFile.readAsString()); + final removing = manifest.installs.where((item) => item.region == _region && item.cycle == _cycle).toList(); + for (final item in removing) { + for (final filePath in [item.mbtilesPath, item.ofmxPath]) { + if (filePath != null && await File(filePath).exists()) await File(filePath).delete(); + } + } + final productRemoving = manifest.products.where((item) => item.region == _region && item.cycle == _cycle).toList(); + for (final item in productRemoving) { + if (await File(item.localPath).exists()) await File(item.localPath).delete(); + } + await OfmDatabaseHelper.db.deleteRegion(dataDir: Storage().dataDir, region: _region, cycle: _cycle); + await OfmManifestStore(Storage().dataDir).save(OfmManifest( + installs: manifest.installs.where((item) => !(item.region == _region && item.cycle == _cycle)).toList(), + products: manifest.products.where((item) => !(item.region == _region && item.cycle == _cycle)).toList(), + )); + OfmMapLayer.notifyChanged(); + setState(() => _message = 'Removed $_region $_cycle OFM data.'); + } catch (e) { + setState(() => _message = 'Unable to remove OFM data: $e'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + Future _writeManifest(OfmInstall install) async { + final store = OfmManifestStore(Storage().dataDir); + final manifest = await store.load(); + final installs = manifest.installs + .where((i) => !(i.region == install.region && i.cycle == install.cycle)) + .toList(); + installs.add(install); + await store.save(OfmManifest(installs: installs, products: manifest.products)); + } + + @override + Widget build(BuildContext context) { + final publication = _publication; + return Scaffold( + appBar: AppBar( + title: const Text('OpenFlightMaps'), + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: const [ + Icon(MdiIcons.mapOutline), + SizedBox(width: 8), + Text(OfmConstants.sourceName, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), + ], + ), + const SizedBox(height: 12), + const Text(OfmConstants.disclaimer), + const SizedBox(height: 8), + const Text(OfmConstants.attribution), + const SizedBox(height: 8), + const Text(OfmConstants.corrections), + ], + ), + ), + ), + const SizedBox(height: 12), + DropdownButtonFormField( + initialValue: _region, + decoration: const InputDecoration(labelText: 'OFM Region'), + items: [ + for (final region in OfmRegions.all.where((region) => region.enabled)) + DropdownMenuItem(value: region.code, child: Text('${region.code} - ${region.name}')), + ], + onChanged: _busy + ? null + : (value) { + if (value != null) { + setState(() { + _region = value; + _publication = null; + }); + } + }, + ), + const SizedBox(height: 12), + if (publication != null && publication.nearCycles.isNotEmpty) + DropdownButtonFormField( + initialValue: publication.nearCycles.any((item) => item.id == _cycle) ? _cycle : publication.nearCycles.first.id, + decoration: const InputDecoration(labelText: 'AIRAC Cycle'), + items: [for (final cycle in publication.nearCycles) DropdownMenuItem(value: cycle.id, child: Text('${cycle.id} — ${cycle.label}'))], + onChanged: _busy ? null : (value) { + if (value != null && value != _cycle) { + setState(() { _cycle = value; _publication = null; }); + _fetchPublication(); + } + }, + ) + else + TextFormField( + initialValue: _cycle, + decoration: const InputDecoration(labelText: 'AIRAC Cycle'), + enabled: !_busy, + onChanged: (value) => setState(() { _cycle = value.trim(); _publication = null; }), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _busy ? null : _fetchPublication, + icon: const Icon(Icons.search), + label: const Text('Fetch OFM Products'), + ), + const SizedBox(height: 8), + CheckboxListTile( + value: _includeMbtiles, + onChanged: _busy ? null : (value) => setState(() => _includeMbtiles = value ?? false), + title: const Text('VFR map layer (MBTiles)'), + contentPadding: EdgeInsets.zero, + ), + if (publication?.retinaMbtiles != null) + SwitchListTile( + value: _retinaMbtiles, + onChanged: _busy ? null : (value) => setState(() => _retinaMbtiles = value), + title: const Text('High-resolution VFR map'), + subtitle: const Text('Uses the larger @2x MBTiles download'), + contentPadding: EdgeInsets.zero, + ), + CheckboxListTile( + value: _includeOfmx, + onChanged: _busy ? null : (value) => setState(() => _includeOfmx = value ?? false), + title: const Text('Search/details data (OFMX)'), + contentPadding: EdgeInsets.zero, + ), + if (publication != null && publication.chartPdfs.isNotEmpty) ...[ + Row(children: [ + Expanded(child: Text('Published VFR chart sheets', style: Theme.of(context).textTheme.titleMedium)), + TextButton( + onPressed: _busy ? null : () => setState(() => _selectedPdfUrls + ..clear() + ..addAll(publication.chartPdfs.map((product) => product.url.toString()))), + child: const Text('Select all'), + ), + TextButton(onPressed: _busy ? null : () => setState(_selectedPdfUrls.clear), child: const Text('Clear')), + ]), + for (final product in publication.chartPdfs) + CheckboxListTile( + value: _selectedPdfUrls.contains(product.url.toString()), + onChanged: _busy ? null : (selected) => setState(() { + selected == true ? _selectedPdfUrls.add(product.url.toString()) : _selectedPdfUrls.remove(product.url.toString()); + }), + title: Text(product.name), + subtitle: Text(product.details), + contentPadding: EdgeInsets.zero, + ), + ], + FilledButton.icon( + onPressed: _busy ? null : _downloadSelected, + icon: const Icon(Icons.download), + label: const Text('Install Selected OFM Data'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _busy || kIsWeb ? null : _deleteSelectedRegion, + icon: const Icon(Icons.delete_outline), + label: const Text('Remove Selected Region/Cycle'), + ), + OutlinedButton.icon( + onPressed: () => Navigator.pushNamed(context, '/ofm_charts'), + icon: const Icon(Icons.picture_as_pdf), + label: const Text('Open Installed VFR Chart Sheets'), + ), + if (_busy) + TextButton.icon( + onPressed: _downloadManager.cancel, + icon: const Icon(Icons.cancel_outlined), + label: const Text('Cancel Download'), + ), + if (_progress != null) ...[ + const SizedBox(height: 16), + LinearProgressIndicator(value: _progress), + ], + if (_message != null) ...[ + const SizedBox(height: 16), + Text(_message!), + ], + if (publication != null) ...[ + const SizedBox(height: 16), + Text('Products', style: Theme.of(context).textTheme.titleMedium), + for (final product in publication.products) + ListTile( + dense: true, + title: Text(product.name.isEmpty ? product.rawType : product.name), + subtitle: Text(product.url.toString()), + trailing: Text(product.type.name), + ), + ], + ], + ), + ); + } +} diff --git a/lib/ofm/ofm_manifest.dart b/lib/ofm/ofm_manifest.dart new file mode 100644 index 00000000..4d6f1941 --- /dev/null +++ b/lib/ofm/ofm_manifest.dart @@ -0,0 +1,128 @@ +import 'dart:convert'; + +class OfmManifest { + final List installs; + final List products; + + const OfmManifest({this.installs = const [], this.products = const []}); + + factory OfmManifest.empty() => const OfmManifest(); + + factory OfmManifest.fromJson(Map json) { + final rawInstalls = json['installs']; + final rawProducts = json['products']; + return OfmManifest( + installs: rawInstalls is List + ? rawInstalls.whereType().map((m) => OfmInstall.fromJson(Map.from(m))).toList(growable: false) + : const [], + products: rawProducts is List + ? rawProducts.whereType().map((m) => OfmInstalledProduct.fromJson(Map.from(m))).toList(growable: false) + : const [], + ); + } + + factory OfmManifest.fromJsonString(String jsonString) { + return OfmManifest.fromJson(jsonDecode(jsonString) as Map); + } + + Map toJson() => { + 'installs': installs.map((i) => i.toJson()).toList(growable: false), + 'products': products.map((i) => i.toJson()).toList(growable: false), + }; + + String toJsonString() => const JsonEncoder.withIndent(' ').convert(toJson()); +} + +class OfmInstalledProduct { + final String region; + final String publicationCode; + final String cycle; + final String type; + final String name; + final String details; + final Uri sourceUrl; + final String localPath; + final DateTime? timestamp; + final int? byteSize; + final String? resolution; + + const OfmInstalledProduct({ + required this.region, + required this.publicationCode, + required this.cycle, + required this.type, + required this.name, + required this.details, + required this.sourceUrl, + required this.localPath, + this.timestamp, + this.byteSize, + this.resolution, + }); + + factory OfmInstalledProduct.fromJson(Map json) => OfmInstalledProduct( + region: json['region'] as String, + publicationCode: json['publicationCode'] as String, + cycle: json['cycle'] as String, + type: json['type'] as String, + name: json['name'] as String, + details: (json['details'] ?? '').toString(), + sourceUrl: Uri.parse(json['sourceUrl'] as String), + localPath: json['localPath'] as String, + timestamp: DateTime.tryParse((json['timestamp'] ?? '').toString()), + byteSize: json['byteSize'] as int?, + resolution: json['resolution'] as String?, + ); + + Map toJson() => { + 'region': region, + 'publicationCode': publicationCode, + 'cycle': cycle, + 'type': type, + 'name': name, + 'details': details, + 'sourceUrl': sourceUrl.toString(), + 'localPath': localPath, + if (timestamp != null) 'timestamp': timestamp!.toUtc().toIso8601String(), + if (byteSize != null) 'byteSize': byteSize, + if (resolution != null) 'resolution': resolution, + }; +} + +class OfmInstall { + final String region; + final String cycle; + final DateTime installedAt; + final Uri publicationUrl; + final String? mbtilesPath; + final String? ofmxPath; + + const OfmInstall({ + required this.region, + required this.cycle, + required this.installedAt, + required this.publicationUrl, + this.mbtilesPath, + this.ofmxPath, + }); + + factory OfmInstall.fromJson(Map json) { + return OfmInstall( + region: json['region'] as String, + cycle: json['cycle'] as String, + installedAt: DateTime.parse(json['installedAt'] as String), + publicationUrl: Uri.parse(json['publicationUrl'] as String), + mbtilesPath: json['mbtilesPath'] as String?, + ofmxPath: json['ofmxPath'] as String?, + ); + } + + Map toJson() => { + 'region': region, + 'cycle': cycle, + 'installedAt': installedAt.toUtc().toIso8601String(), + 'publicationUrl': publicationUrl.toString(), + if (mbtilesPath != null) 'mbtilesPath': mbtilesPath, + if (ofmxPath != null) 'ofmxPath': ofmxPath, + }; +} diff --git a/lib/ofm/ofm_manifest_store.dart b/lib/ofm/ofm_manifest_store.dart new file mode 100644 index 00000000..8df06230 --- /dev/null +++ b/lib/ofm/ofm_manifest_store.dart @@ -0,0 +1,64 @@ +import 'package:universal_io/io.dart'; + +import 'ofm_manifest.dart'; +import 'ofm_paths.dart'; + +class OfmManifestStore { + final OfmPaths paths; + + OfmManifestStore(String dataDir) : paths = OfmPaths(dataDir); + + Future load() async { + final file = File(paths.manifestPath); + if (!await file.exists()) return OfmManifest.empty(); + try { + final manifest = OfmManifest.fromJsonString(await file.readAsString()); + return OfmManifest( + installs: manifest.installs.where((item) => _safeNullable(item.mbtilesPath) && _safeNullable(item.ofmxPath)).toList(), + products: manifest.products.where((item) => _isInsideRoot(item.localPath)).toList(), + ); + } catch (_) { + return OfmManifest.empty(); + } + } + + Future save(OfmManifest manifest) async { + final file = File(paths.manifestPath); + final temporary = File('${paths.manifestPath}.part'); + await file.parent.create(recursive: true); + await temporary.writeAsString(manifest.toJsonString(), flush: true); + if (await file.exists()) await file.delete(); + await temporary.rename(file.path); + } + + Future mergeProduct(OfmInstalledProduct product) async { + if (!_isInsideRoot(product.localPath)) throw ArgumentError.value(product.localPath, 'localPath'); + final current = await load(); + final products = current.products.where((item) => !(item.region == product.region && item.cycle == product.cycle && item.type == product.type && item.name == product.name)).toList()..add(product); + final next = OfmManifest(installs: current.installs, products: products); + await save(next); + return next; + } + + Future removeProduct(OfmInstalledProduct product) async { + final current = await load(); + if (_isInsideRoot(product.localPath)) { + final file = File(product.localPath); + if (await file.exists()) await file.delete(); + } + final next = OfmManifest( + installs: current.installs, + products: current.products.where((item) => !(item.region == product.region && item.cycle == product.cycle && item.type == product.type && item.name == product.name)).toList(), + ); + await save(next); + return next; + } + + bool _safeNullable(String? value) => value == null || _isInsideRoot(value); + + bool _isInsideRoot(String candidate) { + final root = Directory(paths.root).absolute.path; + final resolved = File(candidate).absolute.path; + return resolved == root || resolved.startsWith('$root${Platform.pathSeparator}'); + } +} diff --git a/lib/ofm/ofm_map_layer.dart b/lib/ofm/ofm_map_layer.dart new file mode 100644 index 00000000..0474dbd5 --- /dev/null +++ b/lib/ofm/ofm_map_layer.dart @@ -0,0 +1,70 @@ +import 'package:universal_io/io.dart'; + +import 'package:flutter/widgets.dart'; + +import '../utils/mbtiles_layer.dart'; +import 'ofm_manifest.dart'; +import 'ofm_paths.dart'; + +class OfmMapLayer { + static final ValueNotifier changes = ValueNotifier(0); + final List _managers = []; + String? _loadedDataDir; + + bool get isLoaded => _managers.any((manager) => manager.isLoaded); + + static void notifyChanged() => changes.value++; + + Future loadInstalled(String dataDir, {bool force = false}) async { + if (!force && _loadedDataDir == dataDir && isLoaded) { + return true; + } + close(); + _loadedDataDir = dataDir; + + final manifestFile = File(OfmPaths(dataDir).manifestPath); + if (!await manifestFile.exists()) { + return false; + } + + final OfmManifest manifest; + try { + manifest = OfmManifest.fromJsonString(await manifestFile.readAsString()); + } catch (_) { + return false; + } + + for (final install in manifest.installs) { + final mbtilesPath = install.mbtilesPath; + if (mbtilesPath == null || mbtilesPath.isEmpty) { + continue; + } + final manager = MBTilesLayerManager(); + if (await manager.loadMBTiles(mbtilesPath)) { + _managers.add(manager); + } + } + + return isLoaded; + } + + List buildLayers({required double opacity}) { + final widgets = []; + for (final manager in _managers) { + final widget = manager.isVector + ? manager.buildVectorTileLayer(opacity: opacity) + : manager.buildRasterTileLayer(opacity: opacity); + if (widget != null) { + widgets.add(widget); + } + } + return widgets; + } + + void close() { + for (final manager in _managers) { + manager.close(); + } + _managers.clear(); + } +} diff --git a/lib/ofm/ofm_paths.dart b/lib/ofm/ofm_paths.dart new file mode 100644 index 00000000..75f5c069 --- /dev/null +++ b/lib/ofm/ofm_paths.dart @@ -0,0 +1,46 @@ +import 'package:path/path.dart' as path; + +class OfmPaths { + final String dataDir; + + const OfmPaths(this.dataDir); + + String get root => path.join(dataDir, 'ofm'); + String get manifestPath => path.join(root, 'manifest.json'); + String get ofmDatabasePath => path.join(root, 'ofm.db'); + String get rawRoot => path.join(root, 'raw'); + String get mapsRoot => path.join(root, 'maps'); + String get chartsRoot => path.join(root, 'charts'); + + String mbtilesPath({required String region, required String cycle}) { + final safeRegion = _safeCode(region, 'region').toLowerCase(); + final safeCycle = _safeCode(cycle, 'cycle'); + return path.join(mapsRoot, safeCycle, '$safeRegion.mbtiles'); + } + + String rawRegionDir({required String region, required String cycle}) { + final safeRegion = _safeCode(region, 'region').toLowerCase(); + final safeCycle = _safeCode(cycle, 'cycle'); + return path.join(rawRoot, safeCycle, safeRegion); + } + + String pdfRegionDir({required String region, required String cycle}) { + return path.join(chartsRoot, _safeCode(cycle, 'cycle'), _safeCode(region, 'region').toLowerCase()); + } + + String pdfPath({required String region, required String cycle, required String filename}) { + final safeFilename = filename.trim(); + if (safeFilename.isEmpty || path.basename(safeFilename) != safeFilename || !safeFilename.toLowerCase().endsWith('.pdf')) { + throw ArgumentError.value(filename, 'filename', 'must be a simple PDF filename'); + } + return path.join(pdfRegionDir(region: region, cycle: cycle), safeFilename); + } + + static String _safeCode(String value, String fieldName) { + final normalized = value.trim(); + if (!RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(normalized)) { + throw ArgumentError.value(value, fieldName, 'must contain only letters, numbers, underscore, or dash'); + } + return normalized; + } +} diff --git a/lib/ofm/ofm_publication.dart b/lib/ofm/ofm_publication.dart new file mode 100644 index 00000000..4c059f15 --- /dev/null +++ b/lib/ofm/ofm_publication.dart @@ -0,0 +1,205 @@ +import 'package:xml/xml.dart'; + +enum OfmProductType { + ofmx, + mbtiles, + openair, + cup, + arinc424, + chartPdf, + slippyTiles, + other, +} + +class OfmCycle { + final String id; + final String label; + final DateTime? startValidity; + final DateTime? endValidity; + + const OfmCycle({ + required this.id, + required this.label, + this.startValidity, + this.endValidity, + }); +} + +class OfmPublicationProduct { + final OfmProductType type; + final String rawType; + final String name; + final String details; + final Uri url; + final DateTime? timestamp; + final Map variant; + final String productTitle; + + const OfmPublicationProduct({ + required this.type, + required this.rawType, + required this.name, + required this.details, + required this.url, + this.timestamp, + this.variant = const {}, + this.productTitle = '', + }); + + bool get isDoubleResolution => + variant.values.any((v) => v.toLowerCase().contains('@2x') || v.toLowerCase().contains('double')) || + url.path.toLowerCase().contains('@2x'); +} + +class OfmPublication { + final String region; + final String cycle; + final List nearCycles; + final List products; + + const OfmPublication({ + required this.region, + required this.cycle, + required this.nearCycles, + required this.products, + }); + + OfmPublicationProduct? get ofmx => _firstOfType(OfmProductType.ofmx); + + List get chartPdfs => products.where((p) => p.type == OfmProductType.chartPdf).toList(growable: false); + List get slippyTileArchives => products.where((p) => p.type == OfmProductType.slippyTiles).toList(growable: false); + + OfmPublicationProduct? get normalMbtiles { + final matches = products.where((p) => p.type == OfmProductType.mbtiles && !p.isDoubleResolution); + return matches.isEmpty ? null : matches.first; + } + + OfmPublicationProduct? get retinaMbtiles { + final matches = products.where((p) => p.type == OfmProductType.mbtiles && p.isDoubleResolution); + return matches.isEmpty ? null : matches.first; + } + + OfmPublicationProduct? get preferredMbtiles { + final mbtiles = products.where((p) => p.type == OfmProductType.mbtiles).toList(); + if (mbtiles.isEmpty) { + return null; + } + return mbtiles.firstWhere( + (p) => !p.isDoubleResolution, + orElse: () => mbtiles.first, + ); + } + + OfmPublicationProduct? _firstOfType(OfmProductType type) { + for (final product in products) { + if (product.type == type) { + return product; + } + } + return null; + } + + static OfmPublication parse({ + required String region, + required String cycle, + required String xml, + }) { + final document = XmlDocument.parse(xml); + final cycles = document.findAllElements('cycle').map((element) { + return OfmCycle( + id: element.getAttribute('id') ?? '', + label: element.getAttribute('string') ?? '', + startValidity: DateTime.tryParse(element.getAttribute('startValidity') ?? ''), + endValidity: DateTime.tryParse(element.getAttribute('endValidity') ?? ''), + ); + }).where((c) => c.id.isNotEmpty).toList(growable: false); + + final products = []; + for (final productElement in document.findAllElements('product')) { + final rawType = productElement.getAttribute('type') ?? ''; + final type = _productType(rawType); + for (final download in productElement.findElements('download')) { + final directUrl = download.getAttribute('URL'); + if (directUrl != null && directUrl.isNotEmpty) { + products.add(_productFromElement( + rawType: rawType, + type: type, + download: download, + productTitle: productElement.getAttribute('title_english') ?? productElement.getAttribute('title_local') ?? '', + variant: const {}, + url: directUrl, + )); + } + for (final variantElement in download.findElements('variant')) { + final variantUrl = variantElement.getAttribute('URL'); + if (variantUrl == null || variantUrl.isEmpty) { + continue; + } + products.add(_productFromElement( + rawType: rawType, + type: type, + download: download, + productTitle: productElement.getAttribute('title_english') ?? productElement.getAttribute('title_local') ?? '', + variant: Map.fromEntries( + variantElement.attributes.map((a) => MapEntry(a.name.local, a.value)), + ), + url: variantUrl, + )); + } + } + } + + return OfmPublication( + region: region.toUpperCase(), + cycle: cycle, + nearCycles: cycles, + products: products, + ); + } + + static OfmPublicationProduct _productFromElement({ + required String rawType, + required OfmProductType type, + required XmlElement download, + required String productTitle, + required Map variant, + required String url, + }) { + return OfmPublicationProduct( + type: type, + rawType: rawType, + name: download.getAttribute('name') ?? '', + details: download.getAttribute('details_english') ?? download.getAttribute('details_local') ?? '', + url: Uri.parse(url.replaceFirst('http://', 'https://')), + timestamp: DateTime.tryParse(download.getAttribute('timestamp') ?? ''), + variant: variant, + productTitle: productTitle, + ); + } + + static OfmProductType _productType(String rawType) { + final normalized = rawType.toUpperCase(); + if (normalized == 'OFMX') { + return OfmProductType.ofmx; + } + if (normalized.contains('MBTILES')) { + return OfmProductType.mbtiles; + } + if (normalized.contains('SLIPPYTILES')) { + return OfmProductType.slippyTiles; + } + if (normalized == 'OPENAIR') { + return OfmProductType.openair; + } + if (normalized == 'CUP') { + return OfmProductType.cup; + } + if (normalized == 'ARINC424') { + return OfmProductType.arinc424; + } + if (normalized.contains('PDF')) { + return OfmProductType.chartPdf; + } + return OfmProductType.other; + } +} diff --git a/lib/ofm/ofm_publication_client.dart b/lib/ofm/ofm_publication_client.dart new file mode 100644 index 00000000..c3d996cb --- /dev/null +++ b/lib/ofm/ofm_publication_client.dart @@ -0,0 +1,54 @@ +import 'package:http/http.dart' as http; + +import 'ofm_publication.dart'; +import 'ofm_region.dart'; + +class OfmPublicationClient { + final http.Client _client; + final Uri baseUri; + + OfmPublicationClient({ + http.Client? client, + Uri? baseUri, + }) : _client = client ?? http.Client(), + baseUri = baseUri ?? Uri.parse('https://snapshots.openflightmaps.org/publicationServices/'); + + static String currentAiracCycle() => airacCycleAt(DateTime.now().toUtc()); + + static String airacCycleAt(DateTime value) { + final epoch = DateTime.utc(2015, 11, 12); + final cycles = value.toUtc().difference(epoch).inDays ~/ 28; + var year = 15; + var number = 12; + for (var index = 0; index < cycles; index++) { + number++; + if (number > 13) { + year++; + number = 1; + } + } + return '${year.toString().padLeft(2, '0')}${number.toString().padLeft(2, '0')}'; + } + + Uri publicationUri({required String region, required String cycle}) { + final code = OfmRegions.publicationCode(region); + return baseUri.resolve('${code}_$cycle.xml'); + } + + Future fetch({required String region, required String cycle}) async { + final uri = publicationUri(region: region, cycle: cycle); + final response = await _client.get(uri); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw OfmPublicationException('Unable to fetch OFM publication $uri (${response.statusCode})'); + } + return OfmPublication.parse(region: region, cycle: cycle, xml: response.body); + } +} + +class OfmPublicationException implements Exception { + final String message; + const OfmPublicationException(this.message); + + @override + String toString() => message; +} diff --git a/lib/ofm/ofm_region.dart b/lib/ofm/ofm_region.dart new file mode 100644 index 00000000..a3af9e9d --- /dev/null +++ b/lib/ofm/ofm_region.dart @@ -0,0 +1,44 @@ +class OfmRegion { + final String code; + final String publicationCode; + final String name; + final bool enabled; + + const OfmRegion({ + required this.code, + required this.publicationCode, + required this.name, + this.enabled = true, + }); +} + +class OfmRegions { + OfmRegions._(); + + static const List all = [ + OfmRegion(code: 'ED', publicationCode: 'ED', name: 'Germany'), + OfmRegion(code: 'EB', publicationCode: 'EBBU', name: 'Belgium'), + OfmRegion(code: 'EF', publicationCode: 'EFIN', name: 'Finland'), + OfmRegion(code: 'EH', publicationCode: 'EHAA', name: 'Netherlands'), + OfmRegion(code: 'EK', publicationCode: 'EKDK', name: 'Denmark'), + OfmRegion(code: 'EP', publicationCode: 'EPWW', name: 'Poland'), + OfmRegion(code: 'ES', publicationCode: 'ESAA', name: 'Sweden'), + OfmRegion(code: 'FA', publicationCode: 'FA', name: 'South Africa'), + OfmRegion(code: 'FY', publicationCode: 'FYWH', name: 'Namibia'), + OfmRegion(code: 'LF', publicationCode: 'LF', name: 'France'), + OfmRegion(code: 'LI', publicationCode: 'LI', name: 'Italy'), + OfmRegion(code: 'LO', publicationCode: 'LOVV', name: 'Austria'), + OfmRegion(code: 'LS', publicationCode: 'LSAS', name: 'Switzerland'), + OfmRegion(code: 'LZ', publicationCode: 'LZBB', name: 'Slovakia'), + ]; + + static OfmRegion byCode(String code) { + final normalized = code.trim().toUpperCase(); + return all.firstWhere( + (region) => region.code == normalized || region.publicationCode == normalized, + orElse: () => OfmRegion(code: normalized, publicationCode: normalized, name: normalized), + ); + } + + static String publicationCode(String code) => byCode(code).publicationCode; +} diff --git a/lib/ofm/ofm_schema.dart b/lib/ofm/ofm_schema.dart new file mode 100644 index 00000000..d9db4689 --- /dev/null +++ b/lib/ofm/ofm_schema.dart @@ -0,0 +1,90 @@ +class OfmSchema { + OfmSchema._(); + + static const String databaseName = 'ofm.db'; + static const int version = 3; + + static const List createStatements = [ + ''' +create table if not exists ofm_metadata ( + key text primary key, + value text not null +) +''', + ''' +create table if not exists ofm_region_install ( + region text not null, + cycle text not null, + effective text, + expiration text, + publication_url text, + ofmx_url text, + mbtiles_url text, + mbtiles_path text, + installed_at text not null, + primary key(region, cycle) +) +''', + ''' +create table if not exists ofm_airport ( + id text primary key, region text not null, cycle text not null, + code_id text not null, icao text, iata text, gps_code text, + name text, city text, type text, lat real not null, lon real not null, + elevation_ft real, mag_var real, transition_alt_ft real, + source text not null default 'OFM', raw_mid text +) +''', + 'create index if not exists idx_ofm_airport_code on ofm_airport(code_id)', + 'create index if not exists idx_ofm_airport_lat_lon on ofm_airport(lat, lon)', + ''' +create table if not exists ofm_airport_comm ( + id text primary key, airport_id text not null, code_type text, + value text, remark text, sequence integer, + foreign key(airport_id) references ofm_airport(id) on delete cascade +) +''', + ''' +create table if not exists ofm_runway ( + id text primary key, airport_id text not null, designation text, + length_m real, width_m real, surface text, condition text, remark text, + foreign key(airport_id) references ofm_airport(id) on delete cascade +) +''', + ''' +create table if not exists ofm_runway_end ( + id text primary key, runway_id text not null, designation text, + lat real, lon real, true_bearing real, mag_bearing real, tdze_ft real, + pattern text, vasi_type text, remark text, + foreign key(runway_id) references ofm_runway(id) on delete cascade +) +''', + ''' +create table if not exists ofm_waypoint ( + id text primary key, region text not null, cycle text not null, raw_mid text, + code_id text not null, kind text not null, type text, name text, + lat real not null, lon real not null, frequency text, mag_var real, + airport_code text, remark text +) +''', + 'create index if not exists idx_ofm_waypoint_code on ofm_waypoint(code_id)', + 'create index if not exists idx_ofm_waypoint_lat_lon on ofm_waypoint(lat, lon)', + ''' +create table if not exists ofm_airspace ( + id text primary key, region text not null, cycle text not null, + code_id text, code_type text, class text, name text, + alt_upper_code text, alt_upper_value real, alt_upper_uom text, alt_upper_ft real, + alt_lower_code text, alt_lower_value real, alt_lower_uom text, alt_lower_ft real, + selectable text, remark text, raw_mid text +) +''', + ''' +create table if not exists ofm_airspace_vertex ( + airspace_id text not null, sequence integer not null, code_type text, + lat real not null, lon real not null, arc_lat real, arc_lon real, datum text, + primary key(airspace_id, sequence), + foreign key(airspace_id) references ofm_airspace(id) on delete cascade +) +''', + 'create index if not exists idx_ofm_airspace_vertex_lat_lon on ofm_airspace_vertex(lat, lon)', + ]; +} diff --git a/lib/ofm/ofmx_importer.dart b/lib/ofm/ofmx_importer.dart new file mode 100644 index 00000000..e286651d --- /dev/null +++ b/lib/ofm/ofmx_importer.dart @@ -0,0 +1,298 @@ +import 'package:xml/xml.dart'; + +import 'ofmx_units.dart'; + +class OfmxImportResult { + final List> airports; + final List> airportComms; + final List> runways; + final List> runwayEnds; + final List> waypoints; + final List> airspaces; + final List> airspaceVertices; + + const OfmxImportResult({ + required this.airports, + required this.airportComms, + required this.runways, + required this.runwayEnds, + required this.waypoints, + required this.airspaces, + required this.airspaceVertices, + }); +} + +class OfmxImporter { + OfmxImporter._(); + + static OfmxImportResult parse( + String xml, { + required String region, + required String cycle, + }) { + final document = XmlDocument.parse(xml); + final airports = document.findAllElements('Ahp').map((e) => _airport(e, region, cycle)).whereType>().toList(); + final unitAirportIds = {}; + for (final unit in document.findAllElements('Uni')) { + final unitUid = _descendant(unit, 'UniUid'); + final airportUid = _descendant(unit, 'AhpUid'); + final unitId = unitUid?.getAttribute('mid'); + final airportId = airportUid?.getAttribute('mid'); + if (unitId != null && airportId != null) { + unitAirportIds[unitId] = airportId; + } + } + final comms = document.findAllElements('Aha').map((e) => _airportComm(e, region, cycle)).whereType>().toList() + ..addAll(document.findAllElements('Fqy').map((e) => _airportFrequency(e, region, cycle, unitAirportIds)).whereType>()); + final runways = document.findAllElements('Rwy').map((e) => _runway(e, region, cycle)).whereType>().toList(); + final runwayEnds = document.findAllElements('Rdn').map((e) => _runwayEnd(e, region, cycle)).whereType>().toList(); + final waypoints = >[]; + for (final entry in <(String, String)>[('DpnUid', 'FIX'), ('VorUid', 'VOR'), ('NdbUid', 'NDB')]) { + final tag = entry.$1.substring(0, 3); + waypoints.addAll(document.findAllElements(tag) + .map((e) => _waypoint(e, entry.$1, entry.$2, region, cycle)) + .whereType>()); + } + final airspaces = document.findAllElements('Ase').map((e) => _airspace(e, region, cycle)).whereType>().toList(); + final vertices = >[]; + for (final boundary in document.findAllElements('Abd')) { + final rawAirspaceId = _descendant(boundary, 'AseUid')?.getAttribute('mid'); + if (rawAirspaceId == null || rawAirspaceId.isEmpty) continue; + final airspaceId = _scopedId(region, cycle, rawAirspaceId); + var sequence = 0; + for (final vertex in boundary.findElements('Avx')) { + final lat = parseOfmCoordinateOrNull(_text(vertex, 'geoLat')); + final lon = parseOfmCoordinateOrNull(_text(vertex, 'geoLong')); + if (lat == null || lon == null) continue; + vertices.add({ + 'airspace_id': airspaceId, + 'sequence': sequence++, + 'code_type': _text(vertex, 'codeType'), + 'lat': lat, + 'lon': lon, + 'arc_lat': parseOfmCoordinateOrNull(_text(vertex, 'geoLatArc')), + 'arc_lon': parseOfmCoordinateOrNull(_text(vertex, 'geoLongArc')), + 'datum': _text(vertex, 'codeDatum'), + }); + } + } + return OfmxImportResult( + airports: airports, + airportComms: comms, + runways: runways, + runwayEnds: runwayEnds, + waypoints: waypoints, + airspaces: airspaces, + airspaceVertices: vertices, + ); + } + + static Map? _airport(XmlElement element, String fallbackRegion, String cycle) { + final uid = _descendant(element, 'AhpUid'); + final rawId = uid?.getAttribute('mid'); + final code = uid == null ? null : _text(uid, 'codeId'); + final lat = parseOfmCoordinateOrNull(_text(element, 'geoLat')); + final lon = parseOfmCoordinateOrNull(_text(element, 'geoLong')); + if (rawId == null || code == null || code.isEmpty || lat == null || lon == null) return null; + final actualRegion = uid!.getAttribute('region') ?? fallbackRegion; + final id = _scopedId(actualRegion, cycle, rawId); + final elevation = double.tryParse(_text(element, 'valElev') ?? ''); + final transition = double.tryParse(_text(element, 'valTransitionAlt') ?? ''); + return { + 'id': id, + 'region': actualRegion, + 'cycle': cycle, + 'code_id': code, + 'icao': _text(element, 'codeIcao'), + 'iata': _text(element, 'codeIata'), + 'gps_code': _text(element, 'codeGps'), + 'name': _text(element, 'txtName'), + 'city': _text(element, 'txtNameCitySer'), + 'type': _text(element, 'codeType'), + 'lat': lat, + 'lon': lon, + 'elevation_ft': ofmAltitudeFeet(elevation, _text(element, 'uomDistVer')), + 'mag_var': double.tryParse(_text(element, 'valMagVar') ?? ''), + 'transition_alt_ft': ofmAltitudeFeet(transition, _text(element, 'uomTransitionAlt')), + 'source': 'OFM', + 'raw_mid': rawId, + }; + } + + static Map? _airportComm(XmlElement element, String region, String cycle) { + final uid = _descendant(element, 'AhaUid'); + final airportUid = uid == null ? null : _descendant(uid, 'AhpUid'); + final rawId = uid?.getAttribute('mid'); + final rawAirportId = airportUid?.getAttribute('mid'); + if (rawId == null || rawAirportId == null) return null; + return { + 'id': _scopedId(region, cycle, rawId), + 'airport_id': _scopedId(region, cycle, rawAirportId), + 'code_type': _text(uid!, 'codeType'), + 'value': _text(element, 'txtAddress'), + 'remark': _text(element, 'txtRmk'), + 'sequence': int.tryParse(_text(uid, 'noSeq') ?? ''), + }; + } + + static Map? _airportFrequency( + XmlElement element, + String region, + String cycle, + Map unitAirportIds, + ) { + final uid = _descendant(element, 'FqyUid'); + final unitUid = uid == null ? null : _descendant(uid, 'UniUid'); + final rawId = uid?.getAttribute('mid'); + final rawUnitId = unitUid?.getAttribute('mid'); + final rawAirportId = rawUnitId == null ? null : unitAirportIds[rawUnitId]; + final value = uid == null ? null : _text(uid, 'valFreqTrans'); + if (rawId == null || rawAirportId == null || value == null || value.isEmpty) return null; + final frequencyUnit = (_text(element, 'uomFreq') ?? '').trim(); + final serviceUid = _descendant(uid!, 'SerUid'); + return { + 'id': _scopedId(region, cycle, rawId), + 'airport_id': _scopedId(region, cycle, rawAirportId), + 'code_type': serviceUid == null ? _text(unitUid!, 'codeType') : _directText(serviceUid, 'codeType'), + 'value': frequencyUnit.isEmpty ? value : '$value $frequencyUnit', + 'remark': _text(element, 'txtCallSign') ?? _text(element, 'txtRmk'), + 'sequence': serviceUid == null ? null : int.tryParse(_directText(serviceUid, 'noSeq') ?? ''), + }; + } + + static Map? _runway(XmlElement element, String region, String cycle) { + final uid = _descendant(element, 'RwyUid'); + final airportUid = uid == null ? null : _descendant(uid, 'AhpUid'); + final rawId = uid?.getAttribute('mid'); + final rawAirportId = airportUid?.getAttribute('mid'); + if (rawId == null || rawAirportId == null) return null; + return { + 'id': _scopedId(region, cycle, rawId), + 'airport_id': _scopedId(region, cycle, rawAirportId), + 'designation': _text(uid!, 'txtDesig'), + 'length_m': _distanceMeters(_text(element, 'valLen'), _text(element, 'uomDimRwy')), + 'width_m': _distanceMeters(_text(element, 'valWid'), _text(element, 'uomDimRwy')), + 'surface': _text(element, 'codeComposition'), + 'condition': _text(element, 'codeCondSfc'), + 'remark': _text(element, 'txtRmk'), + }; + } + + static Map? _runwayEnd(XmlElement element, String region, String cycle) { + final uid = _descendant(element, 'RdnUid'); + final runwayUid = uid == null ? null : _descendant(uid, 'RwyUid'); + final rawId = uid?.getAttribute('mid'); + final rawRunwayId = runwayUid?.getAttribute('mid'); + if (rawId == null || rawRunwayId == null) return null; + final tdze = double.tryParse(_text(element, 'valElevTdz') ?? ''); + return { + 'id': _scopedId(region, cycle, rawId), + 'runway_id': _scopedId(region, cycle, rawRunwayId), + 'designation': _directText(uid!, 'txtDesig'), + 'lat': parseOfmCoordinateOrNull(_text(element, 'geoLat')), + 'lon': parseOfmCoordinateOrNull(_text(element, 'geoLong')), + 'true_bearing': double.tryParse(_text(element, 'valTrueBrg') ?? ''), + 'mag_bearing': double.tryParse(_text(element, 'valMagBrg') ?? ''), + 'tdze_ft': ofmAltitudeFeet(tdze, _text(element, 'uomElevTdz')), + 'pattern': _text(element, 'codeVfrPattern'), + 'vasi_type': _text(element, 'codeTypeVasis'), + 'remark': _text(element, 'txtRmk'), + }; + } + + static Map? _waypoint( + XmlElement element, + String uidName, + String kind, + String fallbackRegion, + String cycle, + ) { + final uid = _descendant(element, uidName); + final rawId = uid?.getAttribute('mid'); + final code = uid == null ? null : _text(uid, 'codeId'); + final lat = uid == null ? null : parseOfmCoordinateOrNull(_text(uid, 'geoLat')); + final lon = uid == null ? null : parseOfmCoordinateOrNull(_text(uid, 'geoLong')); + if (rawId == null || code == null || code.isEmpty || lat == null || lon == null) return null; + final actualRegion = uid!.getAttribute('region') ?? fallbackRegion; + final frequency = _text(element, 'valFreq'); + final frequencyUnit = _text(element, 'uomFreq'); + final airport = _descendant(element, 'AhpUidAssoc'); + return { + 'id': _scopedId(actualRegion, cycle, rawId), + 'region': actualRegion, + 'cycle': cycle, + 'raw_mid': rawId, + 'code_id': code, + 'kind': kind, + 'type': kind == 'FIX' ? _text(element, 'codeType') : (_text(element, 'codeType') ?? kind), + 'name': _text(element, 'txtName'), + 'lat': lat, + 'lon': lon, + 'frequency': frequency == null + ? null + : [frequency, frequencyUnit].where((value) => value != null && value.isNotEmpty).join(' '), + 'mag_var': double.tryParse(_text(element, 'valMagVar') ?? ''), + 'airport_code': airport == null ? null : _text(airport, 'codeId'), + 'remark': _text(element, 'txtRmk'), + }; + } + + static Map? _airspace(XmlElement element, String fallbackRegion, String cycle) { + final uid = _descendant(element, 'AseUid'); + final rawId = uid?.getAttribute('mid'); + if (rawId == null) return null; + final actualRegion = uid!.getAttribute('region') ?? fallbackRegion; + final id = _scopedId(actualRegion, cycle, rawId); + final upper = double.tryParse(_text(element, 'valDistVerUpper') ?? ''); + final lower = double.tryParse(_text(element, 'valDistVerLower') ?? ''); + return { + 'id': id, + 'region': actualRegion, + 'cycle': cycle, + 'code_id': _text(uid, 'codeId'), + 'code_type': _text(uid, 'codeType'), + 'class': _text(element, 'codeClass'), + 'name': _text(element, 'txtName'), + 'alt_upper_code': _text(element, 'codeDistVerUpper'), + 'alt_upper_value': upper, + 'alt_upper_uom': _text(element, 'uomDistVerUpper'), + 'alt_upper_ft': ofmAltitudeFeet(upper, _text(element, 'uomDistVerUpper')), + 'alt_lower_code': _text(element, 'codeDistVerLower'), + 'alt_lower_value': lower, + 'alt_lower_uom': _text(element, 'uomDistVerLower'), + 'alt_lower_ft': ofmAltitudeFeet(lower, _text(element, 'uomDistVerLower')), + 'selectable': _text(element, 'codeSelAvbl'), + 'remark': _text(element, 'txtRmk'), + 'raw_mid': rawId, + }; + } + + static XmlElement? _descendant(XmlElement element, String name) { + for (final child in element.descendants.whereType()) { + if (child.name.local == name) return child; + } + return null; + } + + static String? _text(XmlElement element, String name) => _descendant(element, name)?.innerText.trim(); + + static String? _directText(XmlElement element, String name) { + for (final child in element.childElements) { + if (child.name.local == name) return child.innerText.trim(); + } + return null; + } + + static String _scopedId(String region, String cycle, String rawId) => '$region:$cycle:$rawId'; + + static double? _distanceMeters(String? raw, String? unit) { + final value = double.tryParse(raw ?? ''); + if (value == null) return null; + switch ((unit ?? '').toUpperCase()) { + case 'FT': return value / 3.280839895013123; + case 'M': + case '': return value; + default: return null; + } + } +} diff --git a/lib/ofm/ofmx_units.dart b/lib/ofm/ofmx_units.dart new file mode 100644 index 00000000..06dcd5c5 --- /dev/null +++ b/lib/ofm/ofmx_units.dart @@ -0,0 +1,49 @@ +const double _metersToFeet = 3.280839895013123; + +double parseOfmCoordinate(String value) { + final parsed = parseOfmCoordinateOrNull(value); + if (parsed == null) { + throw FormatException('Invalid OFMX coordinate', value); + } + return parsed; +} + +double? parseOfmCoordinateOrNull(String? value) { + final normalized = value?.trim().toUpperCase() ?? ''; + if (normalized.length < 2) { + return null; + } + final hemisphere = normalized.substring(normalized.length - 1); + if (!const {'N', 'S', 'E', 'W'}.contains(hemisphere)) { + return null; + } + final number = double.tryParse(normalized.substring(0, normalized.length - 1)); + if (number == null || !number.isFinite || number < 0) { + return null; + } + final latitude = hemisphere == 'N' || hemisphere == 'S'; + if ((latitude && number > 90) || (!latitude && number > 180)) { + return null; + } + return hemisphere == 'S' || hemisphere == 'W' ? -number : number; +} + +double? ofmAltitudeFeet(num? value, String? unit) { + if (value == null) { + return null; + } + switch ((unit ?? '').trim().toUpperCase()) { + case 'FL': + return value.toDouble() * 100; + case 'M': + return value.toDouble() * _metersToFeet; + case 'FT': + case 'F': + case '': + return value.toDouble(); + default: + return null; + } +} + +double? ofmLengthFeet(num? value, String? unit) => ofmAltitudeFeet(value, unit); diff --git a/lib/openaip/openaip_airspace_layer.dart b/lib/openaip/openaip_airspace_layer.dart new file mode 100644 index 00000000..f844ba46 --- /dev/null +++ b/lib/openaip/openaip_airspace_layer.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import 'openaip_database.dart'; + +class OpenAipAirspaceLayer { + OpenAipAirspaceLayer._(); + + static List polygons( + List airspaces, { + required double opacity, + }) => airspaces.where((airspace) => airspace.points.length >= 3).map((airspace) { + final color = _classColor(airspace.icaoClass); + final limits = [ + if (airspace.lowerFeet != null) airspace.lowerFeet!.round(), + if (airspace.upperFeet != null) airspace.upperFeet!.round(), + ].join('-'); + final status = airspace.byNotam ? ' BY NOTAM' : airspace.onRequest ? ' ON REQUEST' : ''; + return Polygon( + points: airspace.points, + color: color.withValues(alpha: 0.10 * opacity), + borderColor: color.withValues(alpha: opacity), + borderStrokeWidth: 2, + label: '${airspace.name}${limits.isEmpty ? '' : ' $limits ft'}$status', + labelStyle: TextStyle( + color: color.withValues(alpha: opacity), + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ); + }).toList(); + + static Color _classColor(int? value) => switch (value) { + 1 => Colors.blue, + 2 => Colors.indigo, + 3 => Colors.purple, + 4 => Colors.red, + 5 => Colors.orange, + _ => Colors.brown, + }; +} diff --git a/lib/openaip/openaip_attribution.dart b/lib/openaip/openaip_attribution.dart new file mode 100644 index 00000000..611e8c25 --- /dev/null +++ b/lib/openaip/openaip_attribution.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'openaip_constants.dart'; + +class OpenAipAttribution extends StatelessWidget { + final double opacity; + + const OpenAipAttribution({super.key, required this.opacity}); + + @override + Widget build(BuildContext context) => IgnorePointer( + child: Align( + alignment: Alignment.bottomLeft, + child: Opacity( + opacity: opacity.clamp(0.0, 1.0), + child: Container( + margin: const EdgeInsets.only(left: 8, bottom: 106), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.black.withAlpha(150), + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + OpenAipConstants.attribution, + style: TextStyle(color: Colors.white, fontSize: 11), + ), + ), + ), + ), + ); +} diff --git a/lib/openaip/openaip_changes.dart b/lib/openaip/openaip_changes.dart new file mode 100644 index 00000000..aa7438b4 --- /dev/null +++ b/lib/openaip/openaip_changes.dart @@ -0,0 +1,9 @@ +import 'package:flutter/foundation.dart'; + +class OpenAipChanges { + OpenAipChanges._(); + + static final ValueNotifier notifier = ValueNotifier(0); + + static void notifyChanged() => notifier.value++; +} diff --git a/lib/openaip/openaip_client.dart b/lib/openaip/openaip_client.dart new file mode 100644 index 00000000..4f1d2a2c --- /dev/null +++ b/lib/openaip/openaip_client.dart @@ -0,0 +1,85 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +enum OpenAipDataset { + airports('airports'), + airspaces('airspaces'), + navaids('navaids'), + obstacles('obstacles'), + reportingPoints('reporting-points'); + + final String path; + const OpenAipDataset(this.path); +} + +class OpenAipException implements Exception { + final String message; + const OpenAipException(this.message); + + @override + String toString() => 'OpenAipException: $message'; +} + +class OpenAipClient { + final String apiKey; + final http.Client _client; + final Uri baseUri; + + OpenAipClient({ + required this.apiKey, + http.Client? client, + Uri? baseUri, + }) : _client = client ?? http.Client(), + baseUri = baseUri ?? Uri.parse('https://api.core.openaip.net/api/'); + + factory OpenAipClient.withKey(String value) => OpenAipClient(apiKey: value); + + Future>> fetchCountry( + OpenAipDataset dataset, + String country, { + int limit = 1000, + }) async { + final key = apiKey.trim(); + if (key.isEmpty) throw const OpenAipException('An openAIP API key is required.'); + final result = >[]; + var page = 1; + while (true) { + final uri = baseUri.resolve(dataset.path).replace(queryParameters: { + 'country': country.trim().toUpperCase(), + 'page': '$page', + 'limit': '$limit', + }); + final response = await _client.get(uri, headers: { + 'x-openaip-api-key': key, + 'Accept': 'application/json', + 'User-Agent': 'AvareX/openAIP', + }); + if (response.statusCode != 200) { + throw OpenAipException('Request failed with HTTP ${response.statusCode}. Check the API key and try again.'); + } + final decoded = jsonDecode(response.body); + if (decoded is! Map) { + throw const OpenAipException('Invalid response from openAIP.'); + } + final items = decoded['items']; + if (items is List) { + result.addAll(items.whereType().map((item) => Map.from(item))); + } + final nextPage = decoded['nextPage']; + if (nextPage is num) { + page = nextPage.toInt(); + continue; + } + final totalPages = decoded['totalPages']; + if (totalPages is num && page < totalPages.toInt()) { + page++; + continue; + } + break; + } + return result; + } + + void close() => _client.close(); +} diff --git a/lib/openaip/openaip_constants.dart b/lib/openaip/openaip_constants.dart new file mode 100644 index 00000000..8ac1c810 --- /dev/null +++ b/lib/openaip/openaip_constants.dart @@ -0,0 +1,9 @@ +class OpenAipConstants { + OpenAipConstants._(); + + static const String sourceName = 'openAIP'; + static const String dataLayerName = 'openAIP Interactive Data'; + static const String attribution = 'Data © openAIP, CC BY-NC 4.0'; + static const String disclaimer = + 'Community-maintained supplementary data; not certified for primary navigation or flight planning.'; +} \ No newline at end of file diff --git a/lib/openaip/openaip_credentials.dart b/lib/openaip/openaip_credentials.dart new file mode 100644 index 00000000..61203dec --- /dev/null +++ b/lib/openaip/openaip_credentials.dart @@ -0,0 +1,27 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +class OpenAipCredentials { + static const _key = 'openaip-api-key'; + final FlutterSecureStorage _storage; + + const OpenAipCredentials({FlutterSecureStorage storage = const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + )}) + : _storage = storage; + + Future read() async { + final value = (await _storage.read(key: _key))?.trim(); + return value == null || value.isEmpty ? null : value; + } + + Future write(String value) async { + final normalized = value.trim(); + if (normalized.isEmpty) { + await clear(); + return; + } + await _storage.write(key: _key, value: normalized); + } + + Future clear() => _storage.delete(key: _key); +} diff --git a/lib/openaip/openaip_database.dart b/lib/openaip/openaip_database.dart new file mode 100644 index 00000000..40dbc33f --- /dev/null +++ b/lib/openaip/openaip_database.dart @@ -0,0 +1,451 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:latlong2/latlong.dart'; +import 'package:path/path.dart' as path; +import 'package:sqflite/sqflite.dart'; +import 'package:universal_io/io.dart'; + +import '../destination/destination.dart'; + +class OpenAipAirspace { + final String id; + final String name; + final String country; + final int? type; + final int? icaoClass; + final double? lowerFeet; + final double? upperFeet; + final bool byNotam; + final bool onRequest; + final List points; + + const OpenAipAirspace({ + required this.id, required this.name, required this.country, + required this.type, required this.icaoClass, required this.lowerFeet, + required this.upperFeet, required this.byNotam, required this.onRequest, + required this.points, + }); +} + +class OpenAipDatabase { + final Database database; + + static const int schemaVersion = 2; + + const OpenAipDatabase({required this.database}); + + static Future createSchema(Database db) async { + for (final statement in _statements) { + await db.execute(statement); + } + } + + Future replaceCountry({ + required String country, + required List> airports, + required List> navaids, + required List> reportingPoints, + required List> airspaces, + required List> obstacles, + }) async { + final code = country.toUpperCase(); + await database.transaction((tx) async { + for (final table in ['openaip_airport', 'openaip_waypoint', 'openaip_airspace', 'openaip_obstacle']) { + await tx.delete(table, where: 'country = ?', whereArgs: [code]); + } + for (final item in airports) { + final coordinate = _coordinate(item); + if (coordinate == null) continue; + await tx.insert('openaip_airport', { + 'id': item['_id'], 'country': code, 'code_id': _airportCode(item), + 'name': item['name'], 'type': item['type'], 'lat': coordinate.latitude, + 'lon': coordinate.longitude, 'elevation_ft': _metersToFeet(_nestedNumber(item, 'elevation')), + 'max_runway_ft': _maxRunwayFeet(item), + 'mag_var': item['magneticDeclination'], 'updated_at': item['updatedAt'], + 'raw_json': jsonEncode(item), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + for (final item in [...navaids.map((item) => (item, 'NAVAID')), ...reportingPoints.map((item) => (item, 'REPORTING_POINT'))]) { + final record = item.$1; + final coordinate = _coordinate(record); + if (coordinate == null) continue; + final isNav = item.$2 == 'NAVAID'; + final frequency = record['frequency'] is Map ? (record['frequency'] as Map)['value'] : null; + await tx.insert('openaip_waypoint', { + 'id': record['_id'], 'country': code, + 'code_id': isNav ? record['identifier'] : record['name'], + 'name': record['name'], 'kind': isNav ? _navaidType(record['type']) : 'FIX', + 'type': record['type']?.toString(), 'lat': coordinate.latitude, + 'lon': coordinate.longitude, 'frequency': frequency, + 'mag_var': record['magneticDeclination'], + 'compulsory': record['compulsory'] == true ? 1 : 0, + 'updated_at': record['updatedAt'], 'raw_json': jsonEncode(record), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + for (final item in obstacles) { + final coordinate = _coordinate(item); + if (coordinate == null) continue; + await tx.insert('openaip_obstacle', { + 'id': item['_id'], 'country': code, 'name': item['name'], + 'type': item['type'], 'lat': coordinate.latitude, 'lon': coordinate.longitude, + 'elevation_ft': _metersToFeet(_nestedNumber(item, 'elevation')), + 'height_ft': _metersToFeet(_nestedNumber(item, 'height')), + 'updated_at': item['updatedAt'], 'raw_json': jsonEncode(item), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + for (final item in airspaces) { + final geometry = item['geometry']; + if (geometry is! Map || geometry['coordinates'] is! List) continue; + await tx.insert('openaip_airspace', { + 'id': item['_id'], 'country': code, 'name': item['name'], + 'type': item['type'], 'icao_class': item['icaoClass'], + 'geometry_json': jsonEncode(item['geometry']), + 'lower_ft': _verticalFeet(item['lowerLimit']), + 'upper_ft': _verticalFeet(item['upperLimit']), + 'by_notam': item['byNotam'] == true ? 1 : 0, + 'on_request': item['onRequest'] == true ? 1 : 0, + 'updated_at': item['updatedAt'], 'raw_json': jsonEncode(item), + }, conflictAlgorithm: ConflictAlgorithm.replace); + } + await tx.insert('openaip_country_install', { + 'country': code, 'installed_at': DateTime.now().toUtc().toIso8601String(), + 'airport_count': airports.length, 'waypoint_count': navaids.length + reportingPoints.length, + 'airspace_count': airspaces.length, 'obstacle_count': obstacles.length, + }, conflictAlgorithm: ConflictAlgorithm.replace); + }); + } + + Future> findDestinations(String match, {bool exact = false}) async { + final normalized = match.trim().toUpperCase(); + if (normalized.isEmpty) return []; + final operator = exact ? '=' : 'like'; + final value = exact ? normalized : '$normalized%'; + final airportRows = await database.rawQuery(''' +select code_id as LocationID, name as FacilityName, 'AIRPORT' as Type, + lat as ARPLatitude, lon as ARPLongitude, 'openAIP' as Source, + country as SourceRegion, '' as SourceCycle +from openaip_airport where upper(code_id) $operator ? or upper(coalesce(name,'')) like ? limit 20 +''', [value, '%$normalized%']); + final waypointRows = await database.rawQuery(''' +select code_id as LocationID, name as FacilityName, + case when kind = 'FIX' then 'FIX' else kind end as Type, + lat as ARPLatitude, lon as ARPLongitude, 'openAIP' as Source, + country as SourceRegion, '' as SourceCycle +from openaip_waypoint where upper(code_id) $operator ? or upper(coalesce(name,'')) like ? limit 20 +''', [value, '%$normalized%']); + return [...airportRows, ...waypointRows].map(_destinationFromRow).toList(); + } + + Future> findNear(LatLng point, {double factor = 0.001}) async { + final correction = cos(point.latitude * pi / 180) * cos(point.latitude * pi / 180); + Future>> query(String table, String typeExpression) => database.rawQuery(''' +select code_id as LocationID, name as FacilityName, $typeExpression as Type, + lat as ARPLatitude, lon as ARPLongitude, 'openAIP' as Source, + country as SourceRegion, '' as SourceCycle, + ((lon - ?) * (lon - ?) * ? + (lat - ?) * (lat - ?)) as distance +from $table where ((lon - ?) * (lon - ?) * ? + (lat - ?) * (lat - ?)) < ? +''', [point.longitude, point.longitude, correction, point.latitude, point.latitude, + point.longitude, point.longitude, correction, point.latitude, point.latitude, factor]); + final rows = >[ + ...await query('openaip_airport', "'AIRPORT'"), + ...await query('openaip_waypoint', "case when kind = 'FIX' then 'FIX' else kind end"), + ]..sort((a, b) => ((a['distance'] as num?) ?? 0).compareTo((b['distance'] as num?) ?? 0)); + return rows.take(20).map(_destinationFromRow).toList(); + } + + Future> findNearestAirportsWithRunways( + LatLng point, + int runwayLengthFeet, + ) async { + final correction = cos(point.latitude * pi / 180) * cos(point.latitude * pi / 180); + final rows = await database.rawQuery(''' +select code_id as LocationID, name as FacilityName, 'AIRPORT' as Type, + lat as ARPLatitude, lon as ARPLongitude, 'openAIP' as Source, + country as SourceRegion, '' as SourceCycle, + ((lon - ?) * (lon - ?) * ? + (lat - ?) * (lat - ?)) as distance +from openaip_airport where coalesce(max_runway_ft, 0) >= ? +order by distance limit 20 +''', [point.longitude, point.longitude, correction, point.latitude, point.latitude, runwayLengthFeet]); + return rows.map(_destinationFromRow).toList(); + } + + Future> findNearestVOR(LatLng point) async { + final correction = cos(point.latitude * pi / 180) * cos(point.latitude * pi / 180); + final rows = await database.rawQuery(''' +select code_id, name, kind, lat, lon, frequency, mag_var, country, + ((lon - ?) * (lon - ?) * ? + (lat - ?) * (lat - ?)) as distance +from openaip_waypoint where kind like 'VOR%' order by distance limit 3 +''', [point.longitude, point.longitude, correction, point.latitude, point.latitude]); + return rows.map((row) => NavDestination( + locationID: row['code_id'] as String, + type: row['kind'] as String, + facilityName: (row['name'] ?? row['code_id']).toString(), + coordinate: LatLng((row['lat'] as num).toDouble(), (row['lon'] as num).toDouble()), + source: 'openAIP', sourceRegion: row['country'] as String, + class_: (row['frequency'] ?? '').toString(), + hiwas: '', + )).toList(); + } + + Future> findObstacles({ + required double latitude, + required double longitude, + required double minimumMslFeet, + }) async { + final rows = await database.query('openaip_obstacle', where: ''' +elevation_ft is not null and elevation_ft + coalesce(height_ft, 0) > ? and +lat between ? and ? and lon between ? and ? +''', whereArgs: [minimumMslFeet, latitude - 0.4, latitude + 0.4, longitude - 0.4, longitude + 0.4]); + return rows.map((row) => LatLng((row['lat'] as num).toDouble(), (row['lon'] as num).toDouble())).toList(); + } + + Future findAirport(String code) async { + final rows = await database.query('openaip_airport', + where: 'upper(code_id) = ?', whereArgs: [code.trim().toUpperCase()], limit: 1); + if (rows.isEmpty) return null; + final row = rows.single; + final decoded = jsonDecode((row['raw_json'] ?? '{}').toString()); + final payload = decoded is Map ? Map.from(decoded) : {}; + final frequencies = (payload['frequencies'] as List? ?? const []).whereType().map((item) { + final map = Map.from(item); + return { + 'Frequency': (map['value'] ?? '').toString(), + 'Use': (map['name'] ?? _frequencyType(map['type'])).toString(), + 'Remark': (map['remarks'] ?? '').toString(), + }; + }).toList(); + final runways = (payload['runways'] as List? ?? const []).whereType().map((item) { + final map = Map.from(item); + final dimension = map['dimension'] is Map ? Map.from(map['dimension']) : const {}; + final length = dimension['length'] is Map ? (dimension['length'] as Map)['value'] : null; + final width = dimension['width'] is Map ? (dimension['width'] as Map)['value'] : null; + final designator = (map['designator'] ?? '').toString(); + return { + 'RunwayID': designator, + 'Length': _metersToFeet(length is num ? length.toDouble() : null) ?? 0, + 'Width': _metersToFeet(width is num ? width.toDouble() : null) ?? 0, + 'Surface': _surfaceName(map['surface'] is Map ? (map['surface'] as Map)['mainComposite'] : null), + 'LEIdent': designator, 'LELatitude': row['lat'].toString(), + 'LELongitude': row['lon'].toString(), 'LEHeading': (map['trueHeading'] ?? '').toString(), + 'LEElevation': (row['elevation_ft'] ?? '').toString(), + 'LEPattern': _turnDirection(map['turnDirection']), 'LEVGSI': '', + 'HEIdent': '', 'HELatitude': '', 'HELongitude': '', 'HEHeading': '', + 'HEElevation': '', 'HEPattern': '', 'HEVGSI': '', + }; + }).toList(); + final destination = AirportDestination( + locationID: row['code_id'] as String, type: 'AIRPORT', + facilityName: (row['name'] ?? row['code_id']).toString(), + coordinate: LatLng((row['lat'] as num).toDouble(), (row['lon'] as num).toDouble()), + source: 'openAIP', sourceRegion: row['country'] as String, + frequencies: frequencies, runways: runways, awos: const [], + unicom: '', ctaf: '', use: '', fuelTypes: '', customs: '', beacon: '', + segCircle: '', trafficPatternAltitude: '', atct: '', nonCommercialLandingFee: '', + ); + destination.elevation = (row['elevation_ft'] as num?)?.toDouble(); + return destination; + } + + Future> findAirspacesInBounds({ + required double minLat, required double maxLat, + required double minLon, required double maxLon, + }) async { + final rows = await database.query('openaip_airspace'); + final result = []; + for (final row in rows) { + final decoded = jsonDecode((row['geometry_json'] ?? '{}').toString()); + if (decoded is! Map || decoded['coordinates'] is! List) continue; + final rings = decoded['coordinates'] as List; + if (rings.isEmpty || rings.first is! List) continue; + final points = (rings.first as List).whereType().where((pair) => pair.length >= 2) + .map((pair) => LatLng((pair[1] as num).toDouble(), (pair[0] as num).toDouble())).toList(); + if (points.isEmpty) continue; + final south = points.map((p) => p.latitude).reduce(min); + final north = points.map((p) => p.latitude).reduce(max); + final west = points.map((p) => p.longitude).reduce(min); + final east = points.map((p) => p.longitude).reduce(max); + if (south > maxLat || north < minLat || west > maxLon || east < minLon) continue; + result.add(OpenAipAirspace( + id: row['id'] as String, name: (row['name'] ?? '').toString(), + country: row['country'] as String, type: row['type'] as int?, + icaoClass: row['icao_class'] as int?, + lowerFeet: (row['lower_ft'] as num?)?.toDouble(), + upperFeet: (row['upper_ft'] as num?)?.toDouble(), + byNotam: row['by_notam'] == 1, onRequest: row['on_request'] == 1, + points: points, + )); + } + return result; + } + + Future deleteCountry(String country) async { + final code = country.trim().toUpperCase(); + await database.transaction((tx) async { + for (final table in ['openaip_airport', 'openaip_waypoint', 'openaip_airspace', 'openaip_obstacle', 'openaip_country_install']) { + await tx.delete(table, where: 'country = ?', whereArgs: [code]); + } + }); + } + + static LatLng? _coordinate(Map item) { + final geometry = item['geometry']; + if (geometry is! Map || geometry['coordinates'] is! List) return null; + final coordinates = geometry['coordinates'] as List; + if (coordinates.length < 2 || coordinates[0] is! num || coordinates[1] is! num) return null; + return LatLng((coordinates[1] as num).toDouble(), (coordinates[0] as num).toDouble()); + } + + static Destination _destinationFromRow(Map row) { + final type = row['Type'] as String; + final locationID = row['LocationID'] as String; + final facilityName = (row['FacilityName'] ?? locationID).toString(); + final coordinate = LatLng( + (row['ARPLatitude'] as num).toDouble(), + (row['ARPLongitude'] as num).toDouble(), + ); + final sourceRegion = (row['SourceRegion'] ?? '').toString(); + if (Destination.isAirport(type)) { + return AirportDestination( + locationID: locationID, type: type, facilityName: facilityName, + coordinate: coordinate, source: 'openAIP', sourceRegion: sourceRegion, + frequencies: const [], runways: const [], awos: const [], unicom: '', ctaf: '', + use: '', fuelTypes: '', customs: '', beacon: '', segCircle: '', + trafficPatternAltitude: '', atct: '', nonCommercialLandingFee: '', + ); + } + if (Destination.isNav(type)) { + return NavDestination( + locationID: locationID, type: type, facilityName: facilityName, + coordinate: coordinate, source: 'openAIP', sourceRegion: sourceRegion, + class_: '', hiwas: '', + ); + } + return FixDestination( + locationID: locationID, type: type, facilityName: facilityName, + coordinate: coordinate, source: 'openAIP', sourceRegion: sourceRegion, + ); + } + + static String _airportCode(Map item) { + for (final key in ['icaoCode', 'altIdentifier', 'iataCode', 'name']) { + final value = item[key]?.toString().trim() ?? ''; + if (value.isNotEmpty) return value; + } + return item['_id'].toString(); + } + + static double? _nestedNumber(Map item, String key) { + final value = item[key]; + return value is Map && value['value'] is num ? (value['value'] as num).toDouble() : null; + } + + static double? _metersToFeet(double? meters) => meters == null ? null : meters * 3.280839895013123; + + static double? _maxRunwayFeet(Map item) { + final runways = item['runways']; + if (runways is! List) return null; + double? longest; + for (final runway in runways.whereType()) { + final dimension = runway['dimension']; + final length = dimension is Map ? dimension['length'] : null; + final value = length is Map && length['value'] is num + ? (length['value'] as num).toDouble() + : null; + final feet = _metersToFeet(value); + if (feet != null && (longest == null || feet > longest)) longest = feet; + } + return longest; + } + + static double? _verticalFeet(Object? raw) { + if (raw is! Map || raw['value'] is! num) return null; + final value = (raw['value'] as num).toDouble(); + return raw['unit'] == 6 ? value * 100 : raw['unit'] == 0 ? _metersToFeet(value) : value; + } + + static String _navaidType(Object? type) => switch (type) { + 0 => 'DME', + 1 => 'TACAN', + 2 => 'NDB', + 3 || 6 => 'VOR', + 4 || 7 => 'VOR/DME', + 5 || 8 => 'VORTAC', + _ => 'DME', + }; + + static String _frequencyType(Object? type) => switch (type) { + 4 => 'CTAF', + 5 => 'Delivery', + 6 => 'Departure', + 7 => 'FIS', + 9 => 'Ground', + 10 => 'Information', + 12 => 'Unicom', + 13 => 'Radar', + 14 => 'Tower', + 15 => 'ATIS', + 16 => 'Radio', + _ => 'Frequency', + }; + + static String _surfaceName(Object? type) => switch (type) { + 0 => 'Asphalt', + 1 => 'Concrete', + 2 => 'Grass', + 3 => 'Sand', + 4 => 'Water', + _ => type?.toString() ?? '', + }; + + static String _turnDirection(Object? type) => switch (type) { + 0 => 'R', + 1 => 'L', + _ => '', + }; + + static const _statements = [ + '''create table if not exists openaip_country_install ( + country text primary key, installed_at text not null, + airport_count integer, waypoint_count integer, airspace_count integer, obstacle_count integer)''', + '''create table if not exists openaip_airport ( + id text primary key, country text not null, code_id text not null, name text, + type integer, lat real not null, lon real not null, elevation_ft real, + max_runway_ft real, mag_var real, updated_at text, raw_json text)''', + 'create index if not exists idx_openaip_airport_code on openaip_airport(code_id)', + '''create table if not exists openaip_waypoint ( + id text primary key, country text not null, code_id text not null, name text, + kind text not null, type text, lat real not null, lon real not null, + frequency text, mag_var real, compulsory integer, updated_at text, raw_json text)''', + 'create index if not exists idx_openaip_waypoint_code on openaip_waypoint(code_id)', + '''create table if not exists openaip_airspace ( + id text primary key, country text not null, name text, type integer, icao_class integer, + geometry_json text, lower_ft real, upper_ft real, by_notam integer, + on_request integer, updated_at text, raw_json text)''', + '''create table if not exists openaip_obstacle ( + id text primary key, country text not null, name text, type integer, + lat real not null, lon real not null, elevation_ft real, height_ft real, + updated_at text, raw_json text)''', + 'create index if not exists idx_openaip_obstacle_lat_lon on openaip_obstacle(lat, lon)', + ]; + + static Future open(String dataDir) async { + final dbPath = path.join(dataDir, 'openaip', 'openaip.db'); + await Directory(path.dirname(dbPath)).create(recursive: true); + final db = await openDatabase( + dbPath, + version: schemaVersion, + onCreate: (db, _) => createSchema(db), + onUpgrade: (db, oldVersion, newVersion) async { + if (oldVersion < 2) { + try { + await db.execute('alter table openaip_airport add column max_runway_ft real'); + } catch (_) { + // The column may already exist in databases created by development builds. + } + } + }, + ); + return db; + } +} diff --git a/lib/openaip/openaip_download_screen.dart b/lib/openaip/openaip_download_screen.dart new file mode 100644 index 00000000..58bb9aa2 --- /dev/null +++ b/lib/openaip/openaip_download_screen.dart @@ -0,0 +1,184 @@ +import 'package:flutter/material.dart'; + +import '../storage.dart'; +import 'openaip_client.dart'; +import 'openaip_changes.dart'; +import 'openaip_credentials.dart'; +import 'openaip_database.dart'; +import 'openaip_sync_service.dart'; + +class OpenAipDownloadScreen extends StatefulWidget { + const OpenAipDownloadScreen({super.key}); + + @override + State createState() => _OpenAipDownloadScreenState(); +} + +class _OpenAipDownloadScreenState extends State { + final _credentials = const OpenAipCredentials(); + final _keyController = TextEditingController(); + String _country = 'SE'; + bool _busy = false; + bool _hideKey = true; + double? _progress; + String? _message; + + @override + void initState() { + super.initState(); + _credentials.read().then((value) { + if (mounted && value != null) setState(() => _keyController.text = value); + }); + } + + @override + void dispose() { + _keyController.dispose(); + super.dispose(); + } + + Future _saveKey() async { + await _credentials.write(_keyController.text); + if (mounted) setState(() => _message = 'API key saved securely on this device.'); + } + + Future _clearKey() async { + await _credentials.clear(); + _keyController.clear(); + if (mounted) setState(() => _message = 'API key cleared from this device.'); + } + + Future _testConnection() async { + final key = _keyController.text.trim(); + if (key.isEmpty) { + setState(() => _message = 'Enter your personal openAIP API key first.'); + return; + } + setState(() { _busy = true; _message = 'Testing openAIP connection...'; }); + final client = OpenAipClient.withKey(key); + try { + await client.fetchCountry(OpenAipDataset.airports, _country, limit: 1); + await _credentials.write(key); + if (mounted) setState(() => _message = 'Connection successful; API key saved securely.'); + } catch (error) { + if (mounted) setState(() => _message = 'Connection failed: $error'); + } finally { + client.close(); + if (mounted) setState(() => _busy = false); + } + } + + Future _download() async { + final key = _keyController.text.trim(); + if (key.isEmpty) { + setState(() => _message = 'Enter your personal openAIP API key first.'); + return; + } + setState(() { _busy = true; _progress = 0; _message = 'Starting openAIP download...'; }); + try { + await _credentials.write(key); + final database = OpenAipDatabase( + database: await OpenAipDatabase.open(Storage().dataDir), + ); + final client = OpenAipClient.withKey(key); + final syncService = OpenAipSyncService.create( + client: client, + database: database, + ); + final result = await syncService.syncCountry(_country, onProgress: (progress, message) { + if (mounted) setState(() { _progress = progress; _message = message; }); + }); + client.close(); + OpenAipChanges.notifyChanged(); + if (mounted) { + setState(() => _message = 'Installed ${result.airports} airports, ${result.navaids} navaids, ' + '${result.reportingPoints} reporting points, ${result.airspaces} airspaces, ' + 'and ${result.obstacles} obstacles for $_country.'); + } + } catch (error) { + if (mounted) setState(() => _message = 'Unable to install openAIP data: $error'); + } finally { + if (mounted) setState(() { _busy = false; _progress = null; }); + } + } + + Future _remove() async { + setState(() { _busy = true; _message = 'Removing openAIP data...'; }); + try { + final database = OpenAipDatabase(database: await OpenAipDatabase.open(Storage().dataDir)); + await database.deleteCountry(_country); + OpenAipChanges.notifyChanged(); + if (mounted) setState(() => _message = 'Removed openAIP data for $_country.'); + } catch (error) { + if (mounted) setState(() => _message = 'Unable to remove openAIP data: $error'); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('openAIP Data')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + const Text( + 'openAIP is community-maintained supplementary data, not certified for primary navigation. ' + 'Data is licensed CC BY-NC 4.0. Your personal API key is stored securely on this device.', + ), + const SizedBox(height: 16), + TextField( + controller: _keyController, + obscureText: _hideKey, + autocorrect: false, + enableSuggestions: false, + decoration: InputDecoration( + labelText: 'Personal openAIP API key', + suffixIcon: IconButton( + onPressed: () => setState(() => _hideKey = !_hideKey), + icon: Icon(_hideKey ? Icons.visibility : Icons.visibility_off), + ), + ), + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + children: [ + OutlinedButton.icon(onPressed: _busy ? null : _saveKey, icon: const Icon(Icons.key), label: const Text('Save Key')), + OutlinedButton.icon(onPressed: _busy ? null : _testConnection, icon: const Icon(Icons.wifi_tethering), label: const Text('Test Connection')), + TextButton.icon(onPressed: _busy ? null : _clearKey, icon: const Icon(Icons.delete_outline), label: const Text('Clear Key')), + ], + ), + const SizedBox(height: 16), + TextFormField( + initialValue: _country, + maxLength: 2, + textCapitalization: TextCapitalization.characters, + decoration: const InputDecoration(labelText: 'ISO country code', helperText: 'Examples: SE, DE, FR'), + onChanged: (value) => _country = value.trim().toUpperCase(), + ), + FilledButton.icon( + onPressed: _busy ? null : _download, + icon: const Icon(Icons.download), + label: const Text('Download Country Data'), + ), + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: _busy ? null : _remove, + icon: const Icon(Icons.delete_outline), + label: const Text('Remove Country Data'), + ), + if (_progress != null) ...[ + const SizedBox(height: 16), + LinearProgressIndicator(value: _progress), + ], + if (_message != null) ...[ + const SizedBox(height: 16), + Text(_message!), + ], + const SizedBox(height: 16), + const Text('Data used comes from openAIP and is licensed under the Creative Commons Attribution-NonCommercial 4.0 International License. Visit https://www.openaip.net and contribute to better aviation data, free for everyone to use and share.'), + ], + ), + ); +} diff --git a/lib/openaip/openaip_sync_service.dart b/lib/openaip/openaip_sync_service.dart new file mode 100644 index 00000000..18f60478 --- /dev/null +++ b/lib/openaip/openaip_sync_service.dart @@ -0,0 +1,66 @@ +import 'openaip_client.dart'; +import 'openaip_database.dart'; + +class OpenAipSyncResult { + final int airports; + final int navaids; + final int reportingPoints; + final int airspaces; + final int obstacles; + + const OpenAipSyncResult({ + required this.airports, + required this.navaids, + required this.reportingPoints, + required this.airspaces, + required this.obstacles, + }); +} + +class OpenAipSyncService { + final OpenAipClient client; + final OpenAipDatabase database; + + const OpenAipSyncService({required this.client, required this.database}); + + static OpenAipSyncService create({ + required OpenAipClient client, + required OpenAipDatabase database, + }) => OpenAipSyncService(client: client, database: database); + + Future syncCountry( + String country, { + void Function(double progress, String message)? onProgress, + }) async { + final datasets = [ + OpenAipDataset.airports, + OpenAipDataset.navaids, + OpenAipDataset.reportingPoints, + OpenAipDataset.airspaces, + OpenAipDataset.obstacles, + ]; + final values = >>{}; + for (var index = 0; index < datasets.length; index++) { + final dataset = datasets[index]; + onProgress?.call(index / datasets.length, 'Downloading ${dataset.path}...'); + values[dataset] = await client.fetchCountry(dataset, country); + } + onProgress?.call(0.95, 'Importing openAIP data...'); + await database.replaceCountry( + country: country, + airports: values[OpenAipDataset.airports]!, + navaids: values[OpenAipDataset.navaids]!, + reportingPoints: values[OpenAipDataset.reportingPoints]!, + airspaces: values[OpenAipDataset.airspaces]!, + obstacles: values[OpenAipDataset.obstacles]!, + ); + onProgress?.call(1, 'Installed openAIP data.'); + return OpenAipSyncResult( + airports: values[OpenAipDataset.airports]!.length, + navaids: values[OpenAipDataset.navaids]!.length, + reportingPoints: values[OpenAipDataset.reportingPoints]!.length, + airspaces: values[OpenAipDataset.airspaces]!.length, + obstacles: values[OpenAipDataset.obstacles]!.length, + ); + } +} diff --git a/lib/place/area.dart b/lib/place/area.dart index 68f84f97..253135e7 100644 --- a/lib/place/area.dart +++ b/lib/place/area.dart @@ -1,4 +1,5 @@ import 'package:avaremp/data/main_database_helper.dart'; +import 'package:avaremp/data/aeronautical_database.dart'; import 'package:avaremp/destination/destination.dart'; import 'package:avaremp/instruments/gpws_alerts.dart'; import 'package:avaremp/instruments/runway_awareness.dart'; @@ -32,7 +33,7 @@ class Area { (geo, declination) = await MainDatabaseHelper.db.getGeoInfo(Gps.toLatLng(position)); geoAltitude = geo; variation = declination; - List d = await MainDatabaseHelper.db.findNearestAirportsWithRunways(Gps.toLatLng(position), 1000); + List d = await AeronauticalDatabase.instance.findNearestAirportsWithRunways(Gps.toLatLng(position), 1000); if(d.isNotEmpty) { closestAirport = d[0]; } @@ -41,7 +42,7 @@ class Area { final List layersOpacity = Storage().settings.getLayersOpacity(); int lIndex = layers.indexOf('Obstacles'); if(layersOpacity[lIndex] > 0) { - obstacles = await MainDatabaseHelper.db.findObstacles(Gps.toLatLng(position), GeoCalculations.convertAltitude(position.altitude)); + obstacles = await AeronauticalDatabase.instance.findObstacles(Gps.toLatLng(position), GeoCalculations.convertAltitude(position.altitude)); } // get surface wind from nearest airport String wind = WindsCache.getWind0kFromMetar(Gps.toLatLng(position)); diff --git a/lib/plate_screen.dart b/lib/plate_screen.dart index afaf9e37..56e9047a 100644 --- a/lib/plate_screen.dart +++ b/lib/plate_screen.dart @@ -4,8 +4,6 @@ import 'dart:ui' as ui; import 'package:auto_size_text/auto_size_text.dart'; import 'package:avaremp/airport_satellite.dart'; -import 'package:avaremp/business/airport_businesses_gate.dart'; -import 'package:avaremp/business/models/airport_business.dart'; import 'package:avaremp/data/user_database_helper.dart'; import 'package:avaremp/destination/destination.dart'; import 'package:avaremp/instruments/plate_cifp_route.dart'; @@ -142,7 +140,6 @@ class PlatesFuture { List _plates = []; List _airports = []; List _procedures = []; - List _businesses = []; AirportDestination? _airportDestination; String _currentPlateAirport = Storage().settings.getCurrentPlateAirport(); @@ -168,12 +165,6 @@ class PlatesFuture { _plates = await PathUtils.getPlatesAndCSupSorted(Storage().dataDir, _currentPlateAirport); _procedures = await MainDatabaseHelper.db.findProcedures(_currentPlateAirport); _airportDestination = await MainDatabaseHelper.db.findAirport(_currentPlateAirport); - // Businesses drawn on the airport diagram come from the crowd-sourced - // cloud directory; the gate handles platform/sign-in and returns empty - // when unavailable. - _businesses = await AirportBusinessesGate.businessesForPlate( - _currentPlateAirport, - origin: _airportDestination?.coordinate); } } @@ -185,7 +176,6 @@ class PlatesFuture { AirportDestination? get airportDestination => _airportDestination; List get airports => _airports; List get plates => _plates; - List get business => _businesses; List get procedures => _procedures; String get currentPlateAirport => _currentPlateAirport; } @@ -509,11 +499,10 @@ class PlateScreenState extends State { double height = 0; if(future == null || future.airports.isEmpty) { - return makePlateView([], [], [], [], height, _notifier, null); + return makePlateView([], [], [], height, _notifier, null); } List plates = future.plates; - List business = future.business; List airports = future.airports; List procedures = future.procedures; Storage().settings.setCurrentPlateAirport(future.currentPlateAirport); @@ -538,7 +527,7 @@ class PlateScreenState extends State { } } - return makePlateView(airports, plates, procedureNames, business, height, _notifier, future.airportDestination); + return makePlateView(airports, plates, procedureNames, height, _notifier, future.airportDestination); } Future _downloadSatellite(AirportDestination airport) async { @@ -579,13 +568,9 @@ class PlateScreenState extends State { } } - Widget makePlateView(List airports, List plates, List procedures, List business, double height, ValueNotifier notifier, AirportDestination? airportDestination) { + Widget makePlateView(List airports, List plates, List procedures, double height, ValueNotifier notifier, AirportDestination? airportDestination) { bool notAd = !PathUtils.isAirportDiagram(Storage().currentPlate); - if (notAd) { - // The business marker only makes sense on the airport diagram. - Storage().business = null; - } final List layers = Storage().settings.getLayers(); final List layersOpacity = Storage().settings.getLayersOpacity(); @@ -827,56 +812,6 @@ class PlateScreenState extends State { ), ), - // Business selector (center right, only on airport diagrams) - if (business.isNotEmpty && !notAd) - Positioned( - right: 8, - top: Constants.screenHeight(context) / 2 - 20, - child: Container( - decoration: BoxDecoration( - color: overlayBg, - shape: BoxShape.circle, - ), - child: DropdownButtonHideUnderline( - child: DropdownButton2( - isDense: true, - customButton: Padding( - padding: const EdgeInsets.all(8), - child: Icon(Icons.business, color: Theme.of(context).colorScheme.primary), - ), - buttonStyleData: ButtonStyleData( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(10), color: Colors.transparent), - ), - dropdownStyleData: DropdownStyleData( - decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), - width: Constants.screenWidth(context) * 0.75, - ), - isExpanded: false, - value: business.any((b) => b.id == Storage().business?.id) - ? Storage().business!.id - : business[0].id, - items: business.map((AirportBusiness item) { - return DropdownMenuItem( - value: item.id, - child: ListTile( - dense: true, - leading: const Icon(Icons.location_on, size: 18), - title: Text(item.name, maxLines: 1, overflow: TextOverflow.ellipsis), - ), - ); - }).toList(), - onChanged: (value) { - setState(() { - Storage().business = value == null - ? business[0] - : business.firstWhere((element) => element.id == value, orElse: () => business[0]); - }); - }, - ), - ), - ), - ), - // Airport selector and procedures (bottom right) Positioned( bottom: Constants.bottomPaddingSize(context) + 8, @@ -1109,7 +1044,6 @@ class PlateScreenState extends State { class _PlatePainter extends CustomPainter { List? _matrix; - AirportBusiness? _business; ui.Image? _image; ui.Image? _imagePlane; double? _variation; @@ -1134,11 +1068,6 @@ class _PlatePainter extends CustomPainter { ..strokeWidth = 3 ..color = Colors.red; - final _paintBusiness = Paint() - ..strokeWidth = 3 - ..color = Colors.blueAccent.withAlpha(200) - ..style = PaintingStyle.fill; - final _paintTerrain = Paint() ..style = PaintingStyle.fill; @@ -1189,7 +1118,6 @@ class _PlatePainter extends CustomPainter { void paint(Canvas canvas, Size size) { _image = Storage().imagePlate; - _business = Storage().business; _variation = Storage().area.variation; _imagePlane = Storage().imagePlane; _matrix = Storage().matrixPlate; @@ -1233,19 +1161,6 @@ class _PlatePainter extends CustomPainter { // draw circle at center of airport canvas.drawCircle(offsetCircle, 16 , _paintCenter); - if(_business != null && _business!.hasLocation) { - // draw selected business - Offset offsetBiz = const Offset(0, 0); - (offsetBiz, _) = _calculateOffset(_business!.coordinate); - canvas.drawCircle(offsetBiz, 10, _paintBusiness); - offsetBiz = Offset(offsetBiz.dx + 12, offsetBiz.dy - 12); - TextSpan span = TextSpan(text: _business!.name.substring(0, min(_business!.name.length, 24)), - style: TextStyle(color: Colors.red, backgroundColor: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)); - TextPainter tp = TextPainter(text: span, textAlign: TextAlign.left, textDirection: TextDirection.ltr); - tp.layout(); - tp.paint(canvas, offsetBiz); - } - //draw airplane canvas.translate(offsetPlane.dx, offsetPlane.dy); canvas.rotate((heading + angle) * pi / 180); diff --git a/lib/scheduler/data/scheduler_repository.dart b/lib/scheduler/data/scheduler_repository.dart deleted file mode 100644 index f5f66bf7..00000000 --- a/lib/scheduler/data/scheduler_repository.dart +++ /dev/null @@ -1,1028 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; -import 'package:firebase_auth/firebase_auth.dart'; - -import '../../community/data/community_repository.dart'; -import '../models/lesson_pack.dart'; -import '../models/reservation.dart'; -import '../models/schedulable_resource.dart'; -import '../models/scheduler_group.dart'; -import '../models/scheduler_member.dart'; -import '../models/squawk.dart'; - -/// Outcome of a booking attempt so the UI can tell the member whether they -/// got the slot outright or were queued as a backup. -class BookingResult { - final bool isBackup; - final int backupOrder; - const BookingResult({required this.isBackup, required this.backupOrder}); -} - -/// All Firestore interaction for the Aircraft Scheduler feature is funneled -/// through this repository so the UI never touches Firestore directly. -/// -/// Collection layout (mirrors the Community feature): -/// schedulerGroups/{sgid} -> SchedulerGroup -/// schedulerGroups/{sgid}/members/{uid} -> SchedulerMember -/// schedulerGroups/{sgid}/resources/{rid} -> SchedulableResource -/// schedulerGroups/{sgid}/reservations/{resvId} -> Reservation -/// schedulerGroups/{sgid}/squawks/{sid} -> Squawk -/// schedulerGroups/{sgid}/lessonPacks/{pid} -> LessonPack -/// userSchedulerGroups/{uid}/groups/{sgid} -> denormalized index -class SchedulerRepository { - SchedulerRepository._(); - static final SchedulerRepository instance = SchedulerRepository._(); - - /// Upper bound on a single reservation's length. This also bounds the - /// "look-back" window for day/overlap queries: any reservation overlapping - /// a given day must have started within [maxBookingDays] before it, which - /// lets us keep a single-field range query on `start` (no second range - /// field) while still catching multi-day reservations that began earlier. - static const int maxBookingDays = 14; - - FirebaseFirestore get _db => FirebaseFirestore.instance; - - String? get _uid => FirebaseAuth.instance.currentUser?.uid; - - String _requireUid() { - final uid = _uid; - if (uid == null) { - throw StateError("Not signed in"); - } - return uid; - } - - // Reuse the Community profile so display names are consistent across the - // app's Pro features. - Future _myDisplayName() async { - final profile = await CommunityRepository.instance.ensureMyProfile(); - return profile.displayName; - } - - // -------------------- Collection refs -------------------- - - CollectionReference> get _groupsCol => - _db.collection("schedulerGroups"); - - DocumentReference> _groupRef(String sgid) => - _groupsCol.doc(sgid); - - CollectionReference> _membersCol(String sgid) => - _groupRef(sgid).collection("members"); - - CollectionReference> _resourcesCol(String sgid) => - _groupRef(sgid).collection("resources"); - - CollectionReference> _reservationsCol(String sgid) => - _groupRef(sgid).collection("reservations"); - - CollectionReference> _squawksCol(String sgid) => - _groupRef(sgid).collection("squawks"); - - CollectionReference> _lessonPacksCol(String sgid) => - _groupRef(sgid).collection("lessonPacks"); - - DocumentReference> _userGroupRef( - String uid, String sgid) => - _db - .collection("userSchedulerGroups") - .doc(uid) - .collection("groups") - .doc(sgid); - - // -------------------- Groups -------------------- - - Stream watchGroup(String sgid) { - return _groupRef(sgid) - .snapshots() - .map((s) => s.exists ? SchedulerGroup.fromDoc(s) : null); - } - - /// Discover tab. All schedulers are private, so there is no public browse - /// list — members find a scheduler by searching its name and then request - /// to join. An empty query returns nothing. - Stream> discoverGroups({String? query, int limit = 50}) { - final trimmed = query?.trim() ?? ""; - if (trimmed.isEmpty) { - return Stream.value(const []); - } - final lower = trimmed.toLowerCase(); - return _groupsCol - .where("nameLower", isGreaterThanOrEqualTo: lower) - .where("nameLower", isLessThan: "$lower\uf8ff") - .orderBy("nameLower") - .limit(limit) - .snapshots() - .map((s) => - s.docs.map(SchedulerGroup.fromDoc).toList(growable: false)); - } - - /// Scheduler groups the current user belongs to. - Stream> watchMyGroups() { - final uid = _uid; - if (uid == null) return Stream.value(const []); - return _db - .collection("userSchedulerGroups") - .doc(uid) - .collection("groups") - .where("status", isEqualTo: "active") - .snapshots() - .asyncMap((snap) async { - if (snap.docs.isEmpty) return []; - final ids = snap.docs.map((d) => d.id).toList(); - final List groups = []; - for (var i = 0; i < ids.length; i += 30) { - final chunk = - ids.sublist(i, i + 30 > ids.length ? ids.length : i + 30); - final qs = await _groupsCol - .where(FieldPath.documentId, whereIn: chunk) - .get(); - groups.addAll(qs.docs.map(SchedulerGroup.fromDoc)); - } - groups.sort((a, b) => b.createdAt.compareTo(a.createdAt)); - return groups; - }); - } - - /// Create a new scheduler group; current user becomes the owner. - /// - /// All schedulers are **private** — discoverable by name, but the owner - /// approves every member. - Future createGroup({ - required String name, - required String description, - String? homeAirport, - }) async { - final uid = _requireUid(); - final displayName = await _myDisplayName(); - - final groupRef = _groupsCol.doc(); - final now = DateTime.now(); - final group = SchedulerGroup( - id: groupRef.id, - name: name.trim(), - description: description.trim(), - homeAirport: homeAirport?.trim().toUpperCase(), - visibility: SchedulerVisibility.private, - ownerUid: uid, - ownerName: displayName, - memberCount: 1, - resourceCount: 0, - maxReservationsPerMember: 0, - maxWeekendReservations: 0, - createdAt: now, - ); - - final ownerMember = SchedulerMember( - uid: uid, - displayName: displayName, - role: SchedulerRole.owner, - status: SchedulerMemberStatus.active, - clubRole: ClubRole.dispatcher, - joinedAt: now, - ); - - final batch = _db.batch(); - batch.set(groupRef, group.toCreateMap()); - batch.set(_membersCol(groupRef.id).doc(uid), ownerMember.toMap()); - batch.set(_userGroupRef(uid, groupRef.id), { - "role": "owner", - "status": "active", - "joinedAt": Timestamp.fromDate(now), - "groupName": group.name, - }); - await batch.commit(); - return groupRef.id; - } - - /// Delete a group (owner only) along with its members, resources and - /// reservations. - Future deleteGroup(String sgid) async { - final uid = _requireUid(); - final groupSnap = await _groupRef(sgid).get(); - if (!groupSnap.exists) return; - final group = SchedulerGroup.fromDoc(groupSnap); - if (group.ownerUid != uid) { - throw StateError("Only the owner can delete this scheduler"); - } - - final members = await _membersCol(sgid).get(); - final resources = await _resourcesCol(sgid).get(); - final reservations = await _reservationsCol(sgid).get(); - final squawks = await _squawksCol(sgid).get(); - final packs = await _lessonPacksCol(sgid).get(); - - final batch = _db.batch(); - for (final m in members.docs) { - batch.delete(m.reference); - batch.delete(_userGroupRef(m.id, sgid)); - } - for (final r in resources.docs) { - batch.delete(r.reference); - } - for (final r in reservations.docs) { - batch.delete(r.reference); - } - for (final s in squawks.docs) { - batch.delete(s.reference); - } - for (final p in packs.docs) { - batch.delete(p.reference); - } - batch.delete(_groupRef(sgid)); - await batch.commit(); - } - - /// Owner sets the booking rules for the scheduler. A value of 0 means - /// "unlimited". - Future updateBookingRules( - String sgid, { - required int maxReservationsPerMember, - required int maxWeekendReservations, - }) async { - final uid = _requireUid(); - await _assertOwner(sgid, uid); - await _groupRef(sgid).update({ - "maxReservationsPerMember": - maxReservationsPerMember < 0 ? 0 : maxReservationsPerMember, - "maxWeekendReservations": - maxWeekendReservations < 0 ? 0 : maxWeekendReservations, - }); - } - - // -------------------- Membership -------------------- - - Stream watchMyMembership(String sgid) { - final uid = _uid; - if (uid == null) return Stream.value(null); - return _membersCol(sgid) - .doc(uid) - .snapshots() - .map((s) => s.exists ? SchedulerMember.fromDoc(s) : null); - } - - Stream> watchMembers(String sgid, - {SchedulerMemberStatus? status}) { - Query> q = _membersCol(sgid); - if (status != null) { - q = q.where("status", - isEqualTo: - status == SchedulerMemberStatus.pending ? "pending" : "active"); - } - return q.snapshots().map((s) { - final list = s.docs.map(SchedulerMember.fromDoc).toList(); - list.sort((a, b) { - if (a.isOwner && !b.isOwner) return -1; - if (b.isOwner && !a.isOwner) return 1; - return a.displayName - .toLowerCase() - .compareTo(b.displayName.toLowerCase()); - }); - return list; - }); - } - - /// Join a group. Public groups go straight to active; private groups land - /// in `pending` until the owner approves. - Future joinGroup(String sgid) async { - final uid = _requireUid(); - final displayName = await _myDisplayName(); - final groupSnap = await _groupRef(sgid).get(); - if (!groupSnap.exists) { - throw StateError("Scheduler not found"); - } - final group = SchedulerGroup.fromDoc(groupSnap); - final status = group.isPrivate - ? SchedulerMemberStatus.pending - : SchedulerMemberStatus.active; - - final now = DateTime.now(); - final member = SchedulerMember( - uid: uid, - displayName: displayName, - role: SchedulerRole.member, - status: status, - joinedAt: now, - ); - - final batch = _db.batch(); - batch.set(_membersCol(sgid).doc(uid), member.toMap()); - batch.set(_userGroupRef(uid, sgid), { - "role": "member", - "status": status == SchedulerMemberStatus.active ? "active" : "pending", - "joinedAt": Timestamp.fromDate(now), - "groupName": group.name, - }); - if (status == SchedulerMemberStatus.active) { - batch.update(_groupRef(sgid), { - "memberCount": FieldValue.increment(1), - }); - } - await batch.commit(); - return status; - } - - /// Leave a group. Owners cannot leave; they must delete the group instead. - Future leaveGroup(String sgid) async { - final uid = _requireUid(); - final memberSnap = await _membersCol(sgid).doc(uid).get(); - if (!memberSnap.exists) return; - final member = SchedulerMember.fromDoc(memberSnap); - if (member.isOwner) { - throw StateError("Owners cannot leave. Delete the scheduler instead."); - } - - final batch = _db.batch(); - batch.delete(_membersCol(sgid).doc(uid)); - batch.delete(_userGroupRef(uid, sgid)); - if (member.isActive) { - batch.update(_groupRef(sgid), { - "memberCount": FieldValue.increment(-1), - }); - } - await batch.commit(); - } - - /// Owner approves a pending join request on a private group. - Future approveMember(String sgid, String memberUid) async { - final uid = _requireUid(); - await _assertOwner(sgid, uid); - final batch = _db.batch(); - batch.update(_membersCol(sgid).doc(memberUid), {"status": "active"}); - batch.update(_userGroupRef(memberUid, sgid), {"status": "active"}); - batch.update(_groupRef(sgid), { - "memberCount": FieldValue.increment(1), - }); - await batch.commit(); - } - - /// Owner removes a member (or rejects a pending request). - Future removeMember(String sgid, String memberUid) async { - final uid = _requireUid(); - await _assertOwner(sgid, uid); - final memberSnap = await _membersCol(sgid).doc(memberUid).get(); - if (!memberSnap.exists) return; - final member = SchedulerMember.fromDoc(memberSnap); - if (member.isOwner) { - throw StateError("Cannot remove the owner"); - } - final batch = _db.batch(); - batch.delete(_membersCol(sgid).doc(memberUid)); - batch.delete(_userGroupRef(memberUid, sgid)); - if (member.isActive) { - batch.update(_groupRef(sgid), { - "memberCount": FieldValue.increment(-1), - }); - } - await batch.commit(); - } - - /// Owner sets a member's club role and optional student→instructor link. - Future updateMemberClubRole( - String sgid, - String memberUid, { - required ClubRole clubRole, - String? assignedInstructorUid, - String? assignedInstructorName, - }) async { - final uid = _requireUid(); - await _assertOwner(sgid, uid); - final memberSnap = await _membersCol(sgid).doc(memberUid).get(); - if (!memberSnap.exists) { - throw StateError("Member not found"); - } - - String? instructorUid = assignedInstructorUid; - String? instructorName = assignedInstructorName; - if (clubRole != ClubRole.student) { - instructorUid = null; - instructorName = null; - } else if (instructorUid != null && instructorUid.isNotEmpty) { - final iSnap = await _membersCol(sgid).doc(instructorUid).get(); - if (!iSnap.exists) { - throw StateError("Assigned instructor is not a member"); - } - final instructor = SchedulerMember.fromDoc(iSnap); - instructorName = instructor.displayName; - } else { - instructorUid = null; - instructorName = null; - } - - await _membersCol(sgid).doc(memberUid).update({ - "clubRole": clubRoleToString(clubRole), - "assignedInstructorUid": instructorUid, - "assignedInstructorName": instructorName, - }); - } - - Future _assertOwner(String sgid, String uid) async { - final snap = await _groupRef(sgid).get(); - if (!snap.exists) throw StateError("Scheduler not found"); - final g = SchedulerGroup.fromDoc(snap); - if (g.ownerUid != uid) { - throw StateError("Only the owner can do that"); - } - return g; - } - - Future _assertCanDispatch(String sgid, String uid) async { - final memberSnap = await _membersCol(sgid).doc(uid).get(); - if (!memberSnap.exists) { - throw StateError("Join this scheduler first"); - } - final member = SchedulerMember.fromDoc(memberSnap); - if (!member.isActive) { - throw StateError("Membership pending owner approval"); - } - if (!member.canDispatch) { - throw StateError("Only the owner or a dispatcher can do that"); - } - return member; - } - - // -------------------- Resources -------------------- - - Stream> watchResources(String sgid) { - return _resourcesCol(sgid).snapshots().map((s) { - final list = s.docs.map(SchedulableResource.fromDoc).toList(); - list.sort((a, b) { - // Aircraft first, then instructors, then by name. - if (a.type != b.type) return a.isAircraft ? -1 : 1; - return a.name.toLowerCase().compareTo(b.name.toLowerCase()); - }); - return list; - }); - } - - /// Owner adds a schedulable resource (aircraft or instructor). - Future addResource( - String sgid, { - required String name, - required ResourceType type, - String? identifier, - bool available = true, - double? hobbs, - double? tach, - }) async { - final uid = _requireUid(); - await _assertOwner(sgid, uid); - final ref = _resourcesCol(sgid).doc(); - final resource = SchedulableResource( - id: ref.id, - name: name.trim(), - type: type, - identifier: identifier?.trim().isEmpty ?? true ? null : identifier!.trim(), - available: available, - createdAt: DateTime.now(), - hobbs: type == ResourceType.aircraft ? hobbs : null, - tach: type == ResourceType.aircraft ? tach : null, - ); - final batch = _db.batch(); - batch.set(ref, resource.toMap()); - batch.update(_groupRef(sgid), { - "resourceCount": FieldValue.increment(1), - }); - await batch.commit(); - } - - /// Owner toggles a resource between available and out-of-service. - Future setResourceAvailability( - String sgid, String resourceId, bool available) async { - final uid = _requireUid(); - await _assertCanDispatch(sgid, uid); - await _resourcesCol(sgid).doc(resourceId).update({"available": available}); - } - - /// Owner/dispatcher updates aircraft meters and MX due fields. - Future updateResourceDispatchStatus( - String sgid, - String resourceId, { - double? hobbs, - double? tach, - DateTime? annualDue, - double? hundredHourDueHobbs, - DateTime? transponderDue, - DateTime? eltDue, - String? mxNotes, - bool clearAnnualDue = false, - bool clearHundredHourDueHobbs = false, - bool clearTransponderDue = false, - bool clearEltDue = false, - }) async { - final uid = _requireUid(); - await _assertCanDispatch(sgid, uid); - final Map patch = {}; - if (hobbs != null) patch["hobbs"] = hobbs; - if (tach != null) patch["tach"] = tach; - if (clearAnnualDue) { - patch["annualDue"] = null; - } else if (annualDue != null) { - patch["annualDue"] = Timestamp.fromDate(annualDue); - } - if (clearHundredHourDueHobbs) { - patch["hundredHourDueHobbs"] = null; - } else if (hundredHourDueHobbs != null) { - patch["hundredHourDueHobbs"] = hundredHourDueHobbs; - } - if (clearTransponderDue) { - patch["transponderDue"] = null; - } else if (transponderDue != null) { - patch["transponderDue"] = Timestamp.fromDate(transponderDue); - } - if (clearEltDue) { - patch["eltDue"] = null; - } else if (eltDue != null) { - patch["eltDue"] = Timestamp.fromDate(eltDue); - } - if (mxNotes != null) { - patch["mxNotes"] = mxNotes.trim().isEmpty ? null : mxNotes.trim(); - } - if (patch.isEmpty) return; - await _resourcesCol(sgid).doc(resourceId).update(patch); - } - - /// Owner removes a resource along with all its reservations. - Future deleteResource(String sgid, String resourceId) async { - final uid = _requireUid(); - await _assertOwner(sgid, uid); - final resvs = await _reservationsCol(sgid) - .where("resourceId", isEqualTo: resourceId) - .get(); - final batch = _db.batch(); - for (final r in resvs.docs) { - batch.delete(r.reference); - } - batch.delete(_resourcesCol(sgid).doc(resourceId)); - batch.update(_groupRef(sgid), { - "resourceCount": FieldValue.increment(-1), - }); - await batch.commit(); - } - - // -------------------- Reservations -------------------- - - /// Watch the current user's reservations in this scheduler, soonest first. - Stream> watchMyReservations(String sgid) { - final uid = _uid; - if (uid == null) return Stream.value(const []); - return _reservationsCol(sgid) - .where("schedulerUid", isEqualTo: uid) - .orderBy("start") - .snapshots() - .map((s) => - s.docs.map(Reservation.fromDoc).toList(growable: false)); - } - - static bool _isWeekend(DateTime d) => - d.weekday == DateTime.saturday || d.weekday == DateTime.sunday; - - /// Watch every reservation that overlaps the given calendar [day], - /// including multi-day reservations that started on an earlier day. - Stream> watchReservationsForDay(String sgid, DateTime day) { - final dayStart = DateTime(day.year, day.month, day.day); - final dayEnd = dayStart.add(const Duration(days: 1)); - final lower = dayStart.subtract(const Duration(days: maxBookingDays)); - return _reservationsCol(sgid) - .where("start", isGreaterThanOrEqualTo: Timestamp.fromDate(lower)) - .where("start", isLessThan: Timestamp.fromDate(dayEnd)) - .orderBy("start") - .snapshots() - .map((s) => s.docs - .map(Reservation.fromDoc) - // Keep only the ones that actually overlap this day. - .where((r) => r.end.isAfter(dayStart)) - .toList(growable: false)); - } - - /// Reserve [resource] from [start] to [end]. If the resource is already - /// booked (a main reservation overlaps), the caller is queued as a backup. - /// - /// Returns a [BookingResult] describing whether the caller got the slot or - /// landed on the backup queue. - Future createReservation( - String sgid, { - required SchedulableResource resource, - required DateTime start, - required DateTime end, - String? note, - String? instructorUid, - String? instructorName, - String? studentUid, - String? studentName, - String? lessonPackId, - }) async { - final uid = _requireUid(); - final displayName = await _myDisplayName(); - - if (!end.isAfter(start)) { - throw StateError("End time must be after start time"); - } - if (end.difference(start) > const Duration(days: maxBookingDays)) { - throw StateError( - "A reservation can be at most $maxBookingDays days long"); - } - if (!resource.available) { - throw StateError("${resource.name} is currently unavailable"); - } - if (resource.isAircraft && resource.hundredHourOverdue) { - throw StateError( - "${resource.name} is past its 100-hour inspection hobbs"); - } - - // Membership check. - final memberSnap = await _membersCol(sgid).doc(uid).get(); - if (!memberSnap.exists) { - throw StateError("Join this scheduler before booking"); - } - final member = SchedulerMember.fromDoc(memberSnap); - if (!member.isActive) { - throw StateError("Membership pending owner approval"); - } - - // Open grounding squawk blocks aircraft dispatch. - if (resource.isAircraft) { - final openSquawks = await _squawksCol(sgid) - .where("resourceId", isEqualTo: resource.id) - .where("status", isEqualTo: "open") - .get(); - final grounding = openSquawks.docs - .map(Squawk.fromDoc) - .where((s) => s.isGrounding) - .toList(); - if (grounding.isNotEmpty) { - throw StateError( - "${resource.name} has an open grounding squawk: " - "${grounding.first.title}"); - } - } - - // Enforce the owner's booking rules. The owner themselves is exempt so - // they can always manage the schedule. - if (!member.isOwner) { - final groupSnap = await _groupRef(sgid).get(); - final group = - groupSnap.exists ? SchedulerGroup.fromDoc(groupSnap) : null; - final maxTotal = group?.maxReservationsPerMember ?? 0; - final maxWeekend = group?.maxWeekendReservations ?? 0; - final bookingIsWeekend = _isWeekend(start); - if (maxTotal > 0 || (maxWeekend > 0 && bookingIsWeekend)) { - final mineSnap = await _reservationsCol(sgid) - .where("schedulerUid", isEqualTo: uid) - .get(); - final now = DateTime.now(); - // Only count current/upcoming reservations against the limits. - final active = mineSnap.docs - .map(Reservation.fromDoc) - .where((r) => r.end.isAfter(now)) - .toList(); - if (maxTotal > 0 && active.length >= maxTotal) { - throw StateError( - "Booking limit reached: you can hold at most $maxTotal " - "reservation${maxTotal == 1 ? '' : 's'} at a time"); - } - if (maxWeekend > 0 && bookingIsWeekend) { - final weekendCount = - active.where((r) => _isWeekend(r.start)).length; - if (weekendCount >= maxWeekend) { - throw StateError( - "Weekend limit reached: you can hold at most $maxWeekend " - "weekend reservation${maxWeekend == 1 ? '' : 's'} at a time"); - } - } - } - } - - // Default student→instructor from membership when booking as a student. - String? resolvedInstructorUid = instructorUid; - String? resolvedInstructorName = instructorName; - String? resolvedStudentUid = studentUid; - String? resolvedStudentName = studentName; - if (member.isStudent) { - resolvedStudentUid ??= uid; - resolvedStudentName ??= displayName; - if (resolvedInstructorUid == null && - member.assignedInstructorUid != null) { - resolvedInstructorUid = member.assignedInstructorUid; - resolvedInstructorName = member.assignedInstructorName; - } - } - - if (lessonPackId != null) { - final packSnap = await _lessonPacksCol(sgid).doc(lessonPackId).get(); - if (!packSnap.exists) { - throw StateError("Lesson pack not found"); - } - final pack = LessonPack.fromDoc(packSnap); - if (!pack.isActive) { - throw StateError("Lesson pack is not active"); - } - if (pack.hoursRemaining <= 0) { - throw StateError("Lesson pack has no hours remaining"); - } - resolvedStudentUid ??= pack.studentUid; - resolvedStudentName ??= pack.studentName; - resolvedInstructorUid ??= pack.instructorUid; - resolvedInstructorName ??= pack.instructorName; - } - - // Find existing reservations on this resource that overlap the requested - // window (which may span multiple days). NOTE: there is a small race - // window here (no Cloud Function/transactional query) — two simultaneous - // bookings could both think they are the main reservation. This mirrors - // the existing rules-only compromises in the Community feature and is - // acceptable for v1; the owner can always cancel a duplicate. - final lower = start.subtract(const Duration(days: maxBookingDays)); - final existingSnap = await _reservationsCol(sgid) - .where("resourceId", isEqualTo: resource.id) - .where("start", isGreaterThanOrEqualTo: Timestamp.fromDate(lower)) - .where("start", isLessThan: Timestamp.fromDate(end)) - .get(); - - final overlapping = existingSnap.docs - .map(Reservation.fromDoc) - .where((r) => r.overlaps(start, end)) - .toList(); - - if (overlapping.any((r) => r.schedulerUid == uid)) { - throw StateError("You already have a reservation in this window"); - } - - final hasMain = overlapping.any((r) => r.isMain); - final bool isBackup = hasMain; - int backupOrder = 0; - if (isBackup) { - final maxBackup = overlapping - .where((r) => r.isBackup) - .fold(0, (m, r) => r.backupOrder > m ? r.backupOrder : m); - backupOrder = maxBackup + 1; - } - - final ref = _reservationsCol(sgid).doc(); - final reservation = Reservation( - id: ref.id, - resourceId: resource.id, - resourceName: resource.name, - schedulerUid: uid, - schedulerName: displayName, - start: start, - end: end, - isBackup: isBackup, - backupOrder: backupOrder, - note: (note?.trim().isEmpty ?? true) ? null : note!.trim(), - createdAt: DateTime.now(), - instructorUid: resolvedInstructorUid, - instructorName: resolvedInstructorName, - studentUid: resolvedStudentUid, - studentName: resolvedStudentName, - lessonPackId: lessonPackId, - ); - await ref.set(reservation.toCreateMap()); - return BookingResult(isBackup: isBackup, backupOrder: backupOrder); - } - - /// Cancel a reservation. Only the member who made it or the group owner may - /// cancel. If a **main** reservation is cancelled, the next backup in line - /// (lowest [Reservation.backupOrder]) overlapping the same window is - /// promoted to the main reservation. - Future cancelReservation(String sgid, Reservation reservation) async { - final uid = _requireUid(); - final group = await _groupRef(sgid).get(); - final isOwner = group.exists && - (SchedulerGroup.fromDoc(group).ownerUid == uid); - if (reservation.schedulerUid != uid && !isOwner) { - throw StateError("Only the owner or the booking member can cancel this"); - } - - // Delete the reservation first. - await _reservationsCol(sgid).doc(reservation.id).delete(); - - // Only a main reservation triggers a promotion. - if (reservation.isBackup) return; - - final lower = - reservation.start.subtract(const Duration(days: maxBookingDays)); - final snap = await _reservationsCol(sgid) - .where("resourceId", isEqualTo: reservation.resourceId) - .where("start", isGreaterThanOrEqualTo: Timestamp.fromDate(lower)) - .where("start", isLessThan: Timestamp.fromDate(reservation.end)) - .get(); - - final backups = snap.docs - .map(Reservation.fromDoc) - .where((r) => - r.isBackup && r.overlaps(reservation.start, reservation.end)) - .toList() - ..sort((a, b) => a.backupOrder.compareTo(b.backupOrder)); - - if (backups.isEmpty) return; - - final promoted = backups.first; - await _reservationsCol(sgid).doc(promoted.id).update({ - "isBackup": false, - "backupOrder": 0, - }); - } - - // -------------------- Squawks -------------------- - - Stream> watchSquawks(String sgid, {SquawkStatus? status}) { - Query> q = _squawksCol(sgid); - if (status != null) { - q = q.where("status", isEqualTo: squawkStatusToString(status)); - } - return q.snapshots().map((s) { - final list = s.docs.map(Squawk.fromDoc).toList(); - list.sort((a, b) { - if (a.isOpen != b.isOpen) return a.isOpen ? -1 : 1; - if (a.severity != b.severity) { - return a.severity.index.compareTo(b.severity.index); - } - return b.createdAt.compareTo(a.createdAt); - }); - return list; - }); - } - - Stream> watchOpenSquawksForResource( - String sgid, String resourceId) { - return _squawksCol(sgid) - .where("resourceId", isEqualTo: resourceId) - .where("status", isEqualTo: "open") - .snapshots() - .map((s) { - final list = s.docs.map(Squawk.fromDoc).toList(); - list.sort((a, b) => a.severity.index.compareTo(b.severity.index)); - return list; - }); - } - - /// Any active member can file a squawk on a fleet aircraft. - Future createSquawk( - String sgid, { - required SchedulableResource resource, - required String title, - required String description, - required SquawkSeverity severity, - }) async { - final uid = _requireUid(); - final displayName = await _myDisplayName(); - final memberSnap = await _membersCol(sgid).doc(uid).get(); - if (!memberSnap.exists || !SchedulerMember.fromDoc(memberSnap).isActive) { - throw StateError("Join this scheduler before filing a squawk"); - } - if (!resource.isAircraft) { - throw StateError("Squawks apply to aircraft only"); - } - final trimmed = title.trim(); - if (trimmed.isEmpty) { - throw StateError("Enter a squawk title"); - } - final ref = _squawksCol(sgid).doc(); - final squawk = Squawk( - id: ref.id, - resourceId: resource.id, - resourceName: resource.name, - title: trimmed, - description: description.trim(), - severity: severity, - status: SquawkStatus.open, - reportedByUid: uid, - reportedByName: displayName, - createdAt: DateTime.now(), - ); - // Grounding squawks block booking via open-squawk checks and show as - // GROUNDED on the fleet board; availability toggle remains owner/dispatcher. - await ref.set(squawk.toCreateMap()); - } - - /// Reporter, owner, or dispatcher can resolve an open squawk. - Future resolveSquawk(String sgid, Squawk squawk) async { - final uid = _requireUid(); - final displayName = await _myDisplayName(); - final memberSnap = await _membersCol(sgid).doc(uid).get(); - if (!memberSnap.exists) { - throw StateError("Not a member of this scheduler"); - } - final member = SchedulerMember.fromDoc(memberSnap); - if (!member.isActive) { - throw StateError("Membership pending owner approval"); - } - if (!member.canDispatch && squawk.reportedByUid != uid) { - throw StateError("Only the reporter, owner, or dispatcher can resolve"); - } - await _squawksCol(sgid).doc(squawk.id).update({ - "status": "resolved", - "resolvedAt": Timestamp.fromDate(DateTime.now()), - "resolvedByUid": uid, - "resolvedByName": displayName, - }); - } - - Future deleteSquawk(String sgid, String squawkId) async { - final uid = _requireUid(); - await _assertCanDispatch(sgid, uid); - await _squawksCol(sgid).doc(squawkId).delete(); - } - - // -------------------- Lesson packs -------------------- - - Stream> watchLessonPacks(String sgid) { - return _lessonPacksCol(sgid).snapshots().map((s) { - final list = s.docs.map(LessonPack.fromDoc).toList(); - list.sort((a, b) { - if (a.isActive != b.isActive) return a.isActive ? -1 : 1; - return a.studentName - .toLowerCase() - .compareTo(b.studentName.toLowerCase()); - }); - return list; - }); - } - - /// Owner creates a prepaid lesson block for a student. - Future createLessonPack( - String sgid, { - required String name, - String description = "", - required double totalHours, - required String studentUid, - required String studentName, - String? instructorUid, - String? instructorName, - DateTime? expiresAt, - }) async { - final uid = _requireUid(); - await _assertOwner(sgid, uid); - if (totalHours <= 0) { - throw StateError("Lesson pack hours must be greater than zero"); - } - final ref = _lessonPacksCol(sgid).doc(); - final pack = LessonPack( - id: ref.id, - name: name.trim().isEmpty ? "Lesson pack" : name.trim(), - description: description.trim(), - totalHours: totalHours, - hoursUsed: 0, - studentUid: studentUid, - studentName: studentName, - instructorUid: instructorUid, - instructorName: instructorName, - status: LessonPackStatus.active, - createdAt: DateTime.now(), - expiresAt: expiresAt, - ); - await ref.set(pack.toCreateMap()); - } - - /// Owner / assigned instructor logs hours against a pack (e.g. after a lesson). - Future logLessonPackHours( - String sgid, - String packId, { - required double hours, - }) async { - final uid = _requireUid(); - if (hours <= 0) { - throw StateError("Hours must be greater than zero"); - } - final packSnap = await _lessonPacksCol(sgid).doc(packId).get(); - if (!packSnap.exists) throw StateError("Lesson pack not found"); - final pack = LessonPack.fromDoc(packSnap); - if (!pack.isActive) throw StateError("Lesson pack is not active"); - - final memberSnap = await _membersCol(sgid).doc(uid).get(); - if (!memberSnap.exists) throw StateError("Not a member"); - final member = SchedulerMember.fromDoc(memberSnap); - final allowed = member.isOwner || - member.isDispatcher || - (pack.instructorUid != null && pack.instructorUid == uid) || - pack.studentUid == uid; - if (!allowed) { - throw StateError("Not allowed to log hours on this pack"); - } - - final used = pack.hoursUsed + hours; - final Map patch = {"hoursUsed": used}; - if (used >= pack.totalHours) { - patch["status"] = lessonPackStatusToString(LessonPackStatus.completed); - } - await _lessonPacksCol(sgid).doc(packId).update(patch); - } - - Future setLessonPackStatus( - String sgid, - String packId, - LessonPackStatus status, - ) async { - final uid = _requireUid(); - await _assertOwner(sgid, uid); - await _lessonPacksCol(sgid).doc(packId).update({ - "status": lessonPackStatusToString(status), - }); - } - - Future deleteLessonPack(String sgid, String packId) async { - final uid = _requireUid(); - await _assertOwner(sgid, uid); - await _lessonPacksCol(sgid).doc(packId).delete(); - } -} diff --git a/lib/scheduler/models/lesson_pack.dart b/lib/scheduler/models/lesson_pack.dart deleted file mode 100644 index 885f5f0e..00000000 --- a/lib/scheduler/models/lesson_pack.dart +++ /dev/null @@ -1,101 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -enum LessonPackStatus { active, completed, cancelled } - -LessonPackStatus lessonPackStatusFromString(String? v) { - switch (v) { - case "completed": - return LessonPackStatus.completed; - case "cancelled": - return LessonPackStatus.cancelled; - case "active": - default: - return LessonPackStatus.active; - } -} - -String lessonPackStatusToString(LessonPackStatus s) { - switch (s) { - case LessonPackStatus.completed: - return "completed"; - case LessonPackStatus.cancelled: - return "cancelled"; - case LessonPackStatus.active: - return "active"; - } -} - -/// Prepaid / block lesson hours assigned to a student in a club scheduler. -/// Stored under schedulerGroups/{sgid}/lessonPacks/{pid}. -class LessonPack { - final String id; - final String name; - final String description; - final double totalHours; - final double hoursUsed; - final String studentUid; - final String studentName; - final String? instructorUid; - final String? instructorName; - final LessonPackStatus status; - final DateTime createdAt; - final DateTime? expiresAt; - - const LessonPack({ - required this.id, - required this.name, - required this.description, - required this.totalHours, - required this.hoursUsed, - required this.studentUid, - required this.studentName, - this.instructorUid, - this.instructorName, - required this.status, - required this.createdAt, - this.expiresAt, - }); - - double get hoursRemaining { - final left = totalHours - hoursUsed; - return left < 0 ? 0 : left; - } - - bool get isActive => status == LessonPackStatus.active; - - Map toCreateMap() => { - "name": name, - "description": description, - "totalHours": totalHours, - "hoursUsed": hoursUsed, - "studentUid": studentUid, - "studentName": studentName, - "instructorUid": instructorUid, - "instructorName": instructorName, - "status": lessonPackStatusToString(status), - "createdAt": Timestamp.fromDate(createdAt), - "expiresAt": - expiresAt == null ? null : Timestamp.fromDate(expiresAt!), - }; - - factory LessonPack.fromDoc(DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final c = data["createdAt"]; - final e = data["expiresAt"]; - double asDouble(dynamic v) => v is num ? v.toDouble() : 0; - return LessonPack( - id: doc.id, - name: (data["name"] as String?) ?? "Lesson pack", - description: (data["description"] as String?) ?? "", - totalHours: asDouble(data["totalHours"]), - hoursUsed: asDouble(data["hoursUsed"]), - studentUid: (data["studentUid"] as String?) ?? "", - studentName: (data["studentName"] as String?) ?? "Student", - instructorUid: data["instructorUid"] as String?, - instructorName: data["instructorName"] as String?, - status: lessonPackStatusFromString(data["status"] as String?), - createdAt: c is Timestamp ? c.toDate() : DateTime.now(), - expiresAt: e is Timestamp ? e.toDate() : null, - ); - } -} diff --git a/lib/scheduler/models/reservation.dart b/lib/scheduler/models/reservation.dart deleted file mode 100644 index ba3b71cf..00000000 --- a/lib/scheduler/models/reservation.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -/// A reservation of a resource for a time window made by a member. -/// -/// Stored under schedulerGroups/{sgid}/reservations/{resvId}. -/// -/// When more than one member books the same resource for overlapping times, -/// the first becomes the "main" reservation ([isBackup] == false) and the -/// rest are queued as backups ([isBackup] == true) ordered by [backupOrder]. -/// If the main reservation is cancelled, the next backup in line is promoted -/// to main. -class Reservation { - final String id; - final String resourceId; - final String resourceName; - final String schedulerUid; // who made the reservation - final String schedulerName; - final DateTime start; - final DateTime end; - final bool isBackup; - final int backupOrder; // 0 for main; 1..n for backups in queue order - final String? note; - final DateTime createdAt; - - /// Optional dual / training assignment on this booking. - final String? instructorUid; - final String? instructorName; - final String? studentUid; - final String? studentName; - final String? lessonPackId; - - const Reservation({ - required this.id, - required this.resourceId, - required this.resourceName, - required this.schedulerUid, - required this.schedulerName, - required this.start, - required this.end, - this.isBackup = false, - this.backupOrder = 0, - this.note, - required this.createdAt, - this.instructorUid, - this.instructorName, - this.studentUid, - this.studentName, - this.lessonPackId, - }); - - bool get isMain => !isBackup; - bool get hasTrainingAssignment => - instructorUid != null || studentUid != null || lessonPackId != null; - - /// True if this reservation's time window overlaps [otherStart, otherEnd). - bool overlaps(DateTime otherStart, DateTime otherEnd) { - return start.isBefore(otherEnd) && otherStart.isBefore(end); - } - - Map toCreateMap() => { - "resourceId": resourceId, - "resourceName": resourceName, - "schedulerUid": schedulerUid, - "schedulerName": schedulerName, - "start": Timestamp.fromDate(start), - "end": Timestamp.fromDate(end), - "isBackup": isBackup, - "backupOrder": backupOrder, - "note": note, - "createdAt": Timestamp.fromDate(createdAt), - "instructorUid": instructorUid, - "instructorName": instructorName, - "studentUid": studentUid, - "studentName": studentName, - "lessonPackId": lessonPackId, - }; - - factory Reservation.fromDoc(DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final s = data["start"]; - final e = data["end"]; - final c = data["createdAt"]; - return Reservation( - id: doc.id, - resourceId: (data["resourceId"] as String?) ?? "", - resourceName: (data["resourceName"] as String?) ?? "", - schedulerUid: (data["schedulerUid"] as String?) ?? "", - schedulerName: (data["schedulerName"] as String?) ?? "Pilot", - start: s is Timestamp ? s.toDate() : DateTime.now(), - end: e is Timestamp ? e.toDate() : DateTime.now(), - isBackup: (data["isBackup"] as bool?) ?? false, - backupOrder: (data["backupOrder"] as int?) ?? 0, - note: data["note"] as String?, - createdAt: c is Timestamp ? c.toDate() : DateTime.now(), - instructorUid: data["instructorUid"] as String?, - instructorName: data["instructorName"] as String?, - studentUid: data["studentUid"] as String?, - studentName: data["studentName"] as String?, - lessonPackId: data["lessonPackId"] as String?, - ); - } -} diff --git a/lib/scheduler/models/schedulable_resource.dart b/lib/scheduler/models/schedulable_resource.dart deleted file mode 100644 index d5b63558..00000000 --- a/lib/scheduler/models/schedulable_resource.dart +++ /dev/null @@ -1,125 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -enum ResourceType { aircraft, instructor } - -ResourceType resourceTypeFromString(String? v) => - v == "instructor" ? ResourceType.instructor : ResourceType.aircraft; - -String resourceTypeToString(ResourceType t) => - t == ResourceType.instructor ? "instructor" : "aircraft"; - -/// A schedulable resource owned by a scheduler group: an aircraft or a -/// flight instructor. Stored under schedulerGroups/{sgid}/resources/{rid}. -/// -/// Aircraft may carry club-dispatch fields (hobbs/tach, MX due dates). -class SchedulableResource { - final String id; - final String name; - final ResourceType type; - final String? identifier; // tail number for aircraft, etc. - final bool available; // false == out of service / unavailable (red) - final DateTime createdAt; - - /// Current Hobbs meter reading (aircraft only). - final double? hobbs; - - /// Current tach meter reading (aircraft only). - final double? tach; - - /// Annual inspection due date. - final DateTime? annualDue; - - /// Hobbs reading at which the next 100-hour inspection is due. - final double? hundredHourDueHobbs; - - /// Transponder / ADS-B inspection due date. - final DateTime? transponderDue; - - /// ELT battery / inspection due date. - final DateTime? eltDue; - - /// Free-text MX notes (ADs, oil change, etc.). - final String? mxNotes; - - const SchedulableResource({ - required this.id, - required this.name, - required this.type, - this.identifier, - this.available = true, - required this.createdAt, - this.hobbs, - this.tach, - this.annualDue, - this.hundredHourDueHobbs, - this.transponderDue, - this.eltDue, - this.mxNotes, - }); - - bool get isAircraft => type == ResourceType.aircraft; - - /// True when any calendar MX item is overdue or within [warnDays]. - bool mxDueSoon({int warnDays = 30}) { - if (!isAircraft) return false; - final now = DateTime.now(); - final warn = now.add(Duration(days: warnDays)); - bool due(DateTime? d) => d != null && !d.isAfter(warn); - return due(annualDue) || due(transponderDue) || due(eltDue); - } - - /// True when hobbs has reached or passed the 100-hour due meter. - bool get hundredHourOverdue { - if (hobbs == null || hundredHourDueHobbs == null) return false; - return hobbs! >= hundredHourDueHobbs!; - } - - /// True when the aircraft should not be dispatched (OOS, grounding MX). - bool get needsAttention => - !available || mxDueSoon(warnDays: 0) || hundredHourOverdue; - - Map toMap() => { - "name": name, - "type": resourceTypeToString(type), - "identifier": identifier, - "available": available, - "createdAt": Timestamp.fromDate(createdAt), - "hobbs": hobbs, - "tach": tach, - "annualDue": - annualDue == null ? null : Timestamp.fromDate(annualDue!), - "hundredHourDueHobbs": hundredHourDueHobbs, - "transponderDue": transponderDue == null - ? null - : Timestamp.fromDate(transponderDue!), - "eltDue": eltDue == null ? null : Timestamp.fromDate(eltDue!), - "mxNotes": mxNotes, - }; - - factory SchedulableResource.fromDoc( - DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final ts = data["createdAt"]; - DateTime? asDate(dynamic v) => v is Timestamp ? v.toDate() : null; - double? asDouble(dynamic v) { - if (v is num) return v.toDouble(); - return null; - } - - return SchedulableResource( - id: doc.id, - name: (data["name"] as String?) ?? "Resource", - type: resourceTypeFromString(data["type"] as String?), - identifier: data["identifier"] as String?, - available: (data["available"] as bool?) ?? true, - createdAt: ts is Timestamp ? ts.toDate() : DateTime.now(), - hobbs: asDouble(data["hobbs"]), - tach: asDouble(data["tach"]), - annualDue: asDate(data["annualDue"]), - hundredHourDueHobbs: asDouble(data["hundredHourDueHobbs"]), - transponderDue: asDate(data["transponderDue"]), - eltDue: asDate(data["eltDue"]), - mxNotes: data["mxNotes"] as String?, - ); - } -} diff --git a/lib/scheduler/models/scheduler_group.dart b/lib/scheduler/models/scheduler_group.dart deleted file mode 100644 index e7bf5da1..00000000 --- a/lib/scheduler/models/scheduler_group.dart +++ /dev/null @@ -1,88 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -enum SchedulerVisibility { public, private } - -SchedulerVisibility _visibilityFromString(String? v) { - switch (v) { - case "private": - return SchedulerVisibility.private; - case "public": - default: - return SchedulerVisibility.public; - } -} - -String _visibilityToString(SchedulerVisibility v) => - v == SchedulerVisibility.private ? "private" : "public"; - -/// An aircraft scheduler group (e.g. a flying club). Members reserve the -/// resources the owner adds; the owner manages resources and reservations. -class SchedulerGroup { - final String id; - final String name; - final String description; - final String? homeAirport; // ICAO, uppercase - final SchedulerVisibility visibility; - final String ownerUid; - final String ownerName; - final int memberCount; - final int resourceCount; - - /// Booking rules set by the owner. 0 means "unlimited". - final int maxReservationsPerMember; - final int maxWeekendReservations; - - final DateTime createdAt; - - const SchedulerGroup({ - required this.id, - required this.name, - required this.description, - this.homeAirport, - required this.visibility, - required this.ownerUid, - required this.ownerName, - this.memberCount = 0, - this.resourceCount = 0, - this.maxReservationsPerMember = 0, - this.maxWeekendReservations = 0, - required this.createdAt, - }); - - bool get isPrivate => visibility == SchedulerVisibility.private; - - Map toCreateMap() => { - "name": name, - "nameLower": name.toLowerCase(), - "description": description, - "homeAirport": homeAirport?.toUpperCase(), - "visibility": _visibilityToString(visibility), - "ownerUid": ownerUid, - "ownerName": ownerName, - "memberCount": memberCount, - "resourceCount": resourceCount, - "maxReservationsPerMember": maxReservationsPerMember, - "maxWeekendReservations": maxWeekendReservations, - "createdAt": Timestamp.fromDate(createdAt), - }; - - factory SchedulerGroup.fromDoc(DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final ts = data["createdAt"]; - return SchedulerGroup( - id: doc.id, - name: (data["name"] as String?) ?? "Unnamed", - description: (data["description"] as String?) ?? "", - homeAirport: data["homeAirport"] as String?, - visibility: _visibilityFromString(data["visibility"] as String?), - ownerUid: (data["ownerUid"] as String?) ?? "", - ownerName: (data["ownerName"] as String?) ?? "", - memberCount: (data["memberCount"] as int?) ?? 0, - resourceCount: (data["resourceCount"] as int?) ?? 0, - maxReservationsPerMember: - (data["maxReservationsPerMember"] as int?) ?? 0, - maxWeekendReservations: (data["maxWeekendReservations"] as int?) ?? 0, - createdAt: ts is Timestamp ? ts.toDate() : DateTime.now(), - ); - } -} diff --git a/lib/scheduler/models/scheduler_member.dart b/lib/scheduler/models/scheduler_member.dart deleted file mode 100644 index 5fa82935..00000000 --- a/lib/scheduler/models/scheduler_member.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -enum SchedulerRole { owner, member } - -enum SchedulerMemberStatus { active, pending } - -/// Club ops role within a scheduler (independent of ownership). -enum ClubRole { pilot, student, instructor, dispatcher } - -SchedulerRole _roleFromString(String? v) => - v == "owner" ? SchedulerRole.owner : SchedulerRole.member; - -String _roleToString(SchedulerRole r) => - r == SchedulerRole.owner ? "owner" : "member"; - -SchedulerMemberStatus _statusFromString(String? v) => - v == "pending" ? SchedulerMemberStatus.pending : SchedulerMemberStatus.active; - -String _statusToString(SchedulerMemberStatus s) => - s == SchedulerMemberStatus.pending ? "pending" : "active"; - -ClubRole clubRoleFromString(String? v) { - switch (v) { - case "student": - return ClubRole.student; - case "instructor": - return ClubRole.instructor; - case "dispatcher": - return ClubRole.dispatcher; - case "pilot": - default: - return ClubRole.pilot; - } -} - -String clubRoleToString(ClubRole r) { - switch (r) { - case ClubRole.student: - return "student"; - case ClubRole.instructor: - return "instructor"; - case ClubRole.dispatcher: - return "dispatcher"; - case ClubRole.pilot: - return "pilot"; - } -} - -String clubRoleLabel(ClubRole r) { - switch (r) { - case ClubRole.student: - return "Student"; - case ClubRole.instructor: - return "Instructor"; - case ClubRole.dispatcher: - return "Dispatcher"; - case ClubRole.pilot: - return "Pilot"; - } -} - -/// Membership record stored under schedulerGroups/{sgid}/members/{uid}. -class SchedulerMember { - final String uid; - final String displayName; - final SchedulerRole role; - final SchedulerMemberStatus status; - final DateTime joinedAt; - - /// Club ops role (student / instructor / dispatcher / pilot). - final ClubRole clubRole; - - /// For students: the assigned primary instructor (member uid). - final String? assignedInstructorUid; - final String? assignedInstructorName; - - const SchedulerMember({ - required this.uid, - required this.displayName, - required this.role, - required this.status, - required this.joinedAt, - this.clubRole = ClubRole.pilot, - this.assignedInstructorUid, - this.assignedInstructorName, - }); - - bool get isOwner => role == SchedulerRole.owner; - bool get isPending => status == SchedulerMemberStatus.pending; - bool get isActive => status == SchedulerMemberStatus.active; - bool get isStudent => clubRole == ClubRole.student; - bool get isInstructor => clubRole == ClubRole.instructor; - bool get isDispatcher => clubRole == ClubRole.dispatcher; - - /// Owner or dispatcher may manage fleet / MX / squawk resolution. - bool get canDispatch => isOwner || isDispatcher; - - Map toMap() => { - "displayName": displayName, - "role": _roleToString(role), - "status": _statusToString(status), - "joinedAt": Timestamp.fromDate(joinedAt), - "clubRole": clubRoleToString(clubRole), - "assignedInstructorUid": assignedInstructorUid, - "assignedInstructorName": assignedInstructorName, - }; - - factory SchedulerMember.fromDoc(DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final ts = data["joinedAt"]; - return SchedulerMember( - uid: doc.id, - displayName: (data["displayName"] as String?) ?? "Pilot", - role: _roleFromString(data["role"] as String?), - status: _statusFromString(data["status"] as String?), - joinedAt: ts is Timestamp ? ts.toDate() : DateTime.now(), - clubRole: clubRoleFromString(data["clubRole"] as String?), - assignedInstructorUid: data["assignedInstructorUid"] as String?, - assignedInstructorName: data["assignedInstructorName"] as String?, - ); - } -} diff --git a/lib/scheduler/models/squawk.dart b/lib/scheduler/models/squawk.dart deleted file mode 100644 index 5beac475..00000000 --- a/lib/scheduler/models/squawk.dart +++ /dev/null @@ -1,118 +0,0 @@ -import 'package:cloud_firestore/cloud_firestore.dart'; - -enum SquawkSeverity { grounding, caution, info } - -enum SquawkStatus { open, resolved } - -SquawkSeverity squawkSeverityFromString(String? v) { - switch (v) { - case "grounding": - return SquawkSeverity.grounding; - case "caution": - return SquawkSeverity.caution; - case "info": - default: - return SquawkSeverity.info; - } -} - -String squawkSeverityToString(SquawkSeverity s) { - switch (s) { - case SquawkSeverity.grounding: - return "grounding"; - case SquawkSeverity.caution: - return "caution"; - case SquawkSeverity.info: - return "info"; - } -} - -String squawkSeverityLabel(SquawkSeverity s) { - switch (s) { - case SquawkSeverity.grounding: - return "Grounding"; - case SquawkSeverity.caution: - return "Caution"; - case SquawkSeverity.info: - return "Info"; - } -} - -SquawkStatus squawkStatusFromString(String? v) => - v == "resolved" ? SquawkStatus.resolved : SquawkStatus.open; - -String squawkStatusToString(SquawkStatus s) => - s == SquawkStatus.resolved ? "resolved" : "open"; - -/// A maintenance / discrepancy report on a club aircraft. -/// Stored under schedulerGroups/{sgid}/squawks/{sid}. -class Squawk { - final String id; - final String resourceId; - final String resourceName; - final String title; - final String description; - final SquawkSeverity severity; - final SquawkStatus status; - final String reportedByUid; - final String reportedByName; - final DateTime createdAt; - final DateTime? resolvedAt; - final String? resolvedByUid; - final String? resolvedByName; - - const Squawk({ - required this.id, - required this.resourceId, - required this.resourceName, - required this.title, - required this.description, - required this.severity, - required this.status, - required this.reportedByUid, - required this.reportedByName, - required this.createdAt, - this.resolvedAt, - this.resolvedByUid, - this.resolvedByName, - }); - - bool get isOpen => status == SquawkStatus.open; - bool get isGrounding => severity == SquawkSeverity.grounding; - - Map toCreateMap() => { - "resourceId": resourceId, - "resourceName": resourceName, - "title": title, - "description": description, - "severity": squawkSeverityToString(severity), - "status": squawkStatusToString(status), - "reportedByUid": reportedByUid, - "reportedByName": reportedByName, - "createdAt": Timestamp.fromDate(createdAt), - "resolvedAt": null, - "resolvedByUid": null, - "resolvedByName": null, - }; - - factory Squawk.fromDoc(DocumentSnapshot> doc) { - final data = doc.data() ?? {}; - final c = data["createdAt"]; - final r = data["resolvedAt"]; - return Squawk( - id: doc.id, - resourceId: (data["resourceId"] as String?) ?? "", - resourceName: (data["resourceName"] as String?) ?? "", - title: (data["title"] as String?) ?? "Squawk", - description: (data["description"] as String?) ?? "", - severity: squawkSeverityFromString(data["severity"] as String?), - status: squawkStatusFromString(data["status"] as String?), - reportedByUid: (data["reportedByUid"] as String?) ?? "", - reportedByName: (data["reportedByName"] as String?) ?? "Pilot", - createdAt: c is Timestamp ? c.toDate() : DateTime.now(), - resolvedAt: r is Timestamp ? r.toDate() : null, - resolvedByUid: data["resolvedByUid"] as String?, - resolvedByName: data["resolvedByName"] as String?, - ); - } -} diff --git a/lib/scheduler/scheduler_admin_screen.dart b/lib/scheduler/scheduler_admin_screen.dart deleted file mode 100644 index dbe2d5dc..00000000 --- a/lib/scheduler/scheduler_admin_screen.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/scheduler_repository.dart'; -import 'models/scheduler_group.dart'; - -/// Owner-only screen to configure booking rules for a scheduler: -/// * how many reservations a member may hold at a time -/// * how many of those may fall on a weekend -/// -/// A value of 0 means "unlimited". -class SchedulerAdminScreen extends StatefulWidget { - final SchedulerGroup group; - const SchedulerAdminScreen({super.key, required this.group}); - - @override - State createState() => _SchedulerAdminScreenState(); -} - -class _SchedulerAdminScreenState extends State { - late int _maxPerMember; - late int _maxWeekend; - bool _busy = false; - - @override - void initState() { - super.initState(); - _maxPerMember = widget.group.maxReservationsPerMember; - _maxWeekend = widget.group.maxWeekendReservations; - } - - Future _save() async { - setState(() => _busy = true); - try { - await SchedulerRepository.instance.updateBookingRules( - widget.group.id, - maxReservationsPerMember: _maxPerMember, - maxWeekendReservations: _maxWeekend, - ); - if (!mounted) return; - Toast.showToast(context, "Booking rules saved", - const Icon(Icons.check, color: Colors.green), 2); - Navigator.pop(context); - } catch (e) { - if (mounted) { - Toast.showToast(context, "Could not save rules: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Booking Rules"), - ), - body: AbsorbPointer( - absorbing: _busy, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: ListTile( - leading: const Icon(Icons.lock_outline), - title: const Text("Private scheduler"), - subtitle: const Text( - "All schedulers are private. Members must be approved " - "before they can book."), - ), - ), - const SizedBox(height: 8), - Text( - "RESERVATION LIMITS", - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: scheme.primary, - ), - ), - const SizedBox(height: 4), - _StepperTile( - icon: Icons.event_note, - title: "Reservations per member", - subtitle: - "Most current/upcoming reservations a member can hold at once.", - value: _maxPerMember, - onChanged: (v) => setState(() => _maxPerMember = v), - ), - _StepperTile( - icon: Icons.weekend, - title: "Weekend reservations", - subtitle: - "Most weekend (Sat/Sun) reservations a member can hold at once.", - value: _maxWeekend, - onChanged: (v) => setState(() => _maxWeekend = v), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: Text( - "Set a value to 0 for unlimited. The scheduler owner is exempt " - "from these limits.", - style: TextStyle(fontSize: 12, color: scheme.outline), - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _busy ? null : _save, - icon: _busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.check), - label: const Text("Save rules"), - ), - ], - ), - ), - ); - } -} - -class _StepperTile extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - final int value; - final ValueChanged onChanged; - - const _StepperTile({ - required this.icon, - required this.title, - required this.subtitle, - required this.value, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 8, 8, 8), - child: Row( - children: [ - Icon(icon, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, - style: const TextStyle(fontWeight: FontWeight.w600)), - const SizedBox(height: 2), - Text(subtitle, - style: TextStyle( - fontSize: 11, - color: Theme.of(context).colorScheme.outline)), - ], - ), - ), - IconButton( - icon: const Icon(Icons.remove_circle_outline), - onPressed: value <= 0 ? null : () => onChanged(value - 1), - ), - SizedBox( - width: 56, - child: Text( - value == 0 ? "∞" : "$value", - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold), - ), - ), - IconButton( - icon: const Icon(Icons.add_circle_outline), - onPressed: value >= 99 ? null : () => onChanged(value + 1), - ), - ], - ), - ), - ); - } -} diff --git a/lib/scheduler/scheduler_create_screen.dart b/lib/scheduler/scheduler_create_screen.dart deleted file mode 100644 index b54ef764..00000000 --- a/lib/scheduler/scheduler_create_screen.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/scheduler_repository.dart'; -import 'scheduler_detail_screen.dart'; - -class SchedulerCreateScreen extends StatefulWidget { - const SchedulerCreateScreen({super.key}); - - @override - State createState() => _SchedulerCreateScreenState(); -} - -class _SchedulerCreateScreenState extends State { - final _nameCtrl = TextEditingController(); - final _descCtrl = TextEditingController(); - final _airportCtrl = TextEditingController(); - bool _busy = false; - - @override - void dispose() { - _nameCtrl.dispose(); - _descCtrl.dispose(); - _airportCtrl.dispose(); - super.dispose(); - } - - Future _create() async { - final name = _nameCtrl.text.trim(); - if (name.length < 3) { - Toast.showToast(context, "Scheduler name must be at least 3 characters", - const Icon(Icons.info, color: Colors.orange), 3); - return; - } - setState(() => _busy = true); - try { - final id = await SchedulerRepository.instance.createGroup( - name: name, - description: _descCtrl.text.trim(), - homeAirport: _airportCtrl.text.trim().isEmpty - ? null - : _airportCtrl.text.trim(), - ); - if (!mounted) return; - Navigator.pushReplacement( - context, - MaterialPageRoute(builder: (_) => SchedulerDetailScreen(groupId: id)), - ); - } catch (e) { - if (mounted) { - Toast.showToast(context, "Could not create scheduler: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("New Scheduler"), - ), - body: AbsorbPointer( - absorbing: _busy, - child: ListView( - padding: const EdgeInsets.all(16), - children: [ - TextField( - controller: _nameCtrl, - maxLength: 60, - decoration: const InputDecoration( - labelText: "Scheduler name", - hintText: "e.g. KBED Flying Club, Skyhawk Partnership", - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.words, - ), - const SizedBox(height: 8), - TextField( - controller: _descCtrl, - maxLength: 280, - maxLines: 3, - decoration: const InputDecoration( - labelText: "Description", - hintText: "What's this scheduler for?", - border: OutlineInputBorder(), - ), - textCapitalization: TextCapitalization.sentences, - ), - const SizedBox(height: 8), - TextField( - controller: _airportCtrl, - maxLength: 4, - textCapitalization: TextCapitalization.characters, - decoration: const InputDecoration( - labelText: "Home airport (optional)", - hintText: "ICAO, e.g. KBED", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - Card( - child: ListTile( - leading: const Icon(Icons.lock_outline), - title: const Text("Private scheduler"), - subtitle: const Text( - "All schedulers are private. Members find it by name and " - "you approve every join request. You can set booking " - "limits afterwards from the Rules screen."), - ), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _busy ? null : _create, - icon: _busy - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.check), - label: const Text("Create Scheduler"), - ), - ], - ), - ), - ); - } -} diff --git a/lib/scheduler/scheduler_detail_screen.dart b/lib/scheduler/scheduler_detail_screen.dart deleted file mode 100644 index 5e3b5d92..00000000 --- a/lib/scheduler/scheduler_detail_screen.dart +++ /dev/null @@ -1,1377 +0,0 @@ -import 'package:firebase_auth/firebase_auth.dart'; -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/scheduler_repository.dart'; -import 'models/lesson_pack.dart'; -import 'models/reservation.dart'; -import 'models/schedulable_resource.dart'; -import 'models/scheduler_group.dart'; -import 'models/scheduler_member.dart'; -import 'scheduler_admin_screen.dart'; -import 'scheduler_dispatch_screen.dart'; -import 'scheduler_members_screen.dart'; -import 'widgets/schedule_grid.dart'; -import 'widgets/scheduler_join_leave_button.dart'; - -class SchedulerDetailScreen extends StatelessWidget { - final String groupId; - const SchedulerDetailScreen({super.key, required this.groupId}); - - @override - Widget build(BuildContext context) { - return StreamBuilder( - stream: SchedulerRepository.instance.watchGroup(groupId), - builder: (context, gSnap) { - final group = gSnap.data; - return StreamBuilder( - stream: SchedulerRepository.instance.watchMyMembership(groupId), - builder: (context, mSnap) { - final membership = mSnap.data; - if (gSnap.connectionState == ConnectionState.waiting || - mSnap.connectionState == ConnectionState.waiting) { - return const Scaffold( - body: Center(child: CircularProgressIndicator())); - } - if (group == null) { - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Scheduler"), - ), - body: const Center( - child: Text("This scheduler has been deleted.")), - ); - } - return _SchedulerDetailBody(group: group, membership: membership); - }, - ); - }, - ); - } -} - -class _SchedulerDetailBody extends StatelessWidget { - final SchedulerGroup group; - final SchedulerMember? membership; - const _SchedulerDetailBody({required this.group, this.membership}); - - bool get _isOwner => membership?.isOwner ?? false; - bool get _isActive => membership?.isActive ?? false; - - Future _confirmDelete(BuildContext context) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text("Delete scheduler?"), - content: Text( - "Delete '${group.name}'? This removes all resources, reservations and memberships and cannot be undone."), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton.tonal( - style: FilledButton.styleFrom(foregroundColor: Colors.red), - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Delete"), - ), - ], - ), - ); - if (ok != true || !context.mounted) return; - try { - await SchedulerRepository.instance.deleteGroup(group.id); - if (!context.mounted) return; - Navigator.pop(context); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Delete failed: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - final pendingBadge = _isOwner - ? StreamBuilder>( - stream: SchedulerRepository.instance - .watchMembers(group.id, status: SchedulerMemberStatus.pending), - builder: (context, snap) { - final count = snap.data?.length ?? 0; - if (count == 0) return const SizedBox.shrink(); - return Positioned( - top: 8, - right: 6, - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 5, vertical: 1), - decoration: BoxDecoration( - color: scheme.error, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - "$count", - style: const TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold), - ), - ), - ); - }, - ) - : const SizedBox.shrink(); - - return DefaultTabController( - length: 5, - child: Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: Row( - children: [ - Expanded( - child: Text( - group.name, - overflow: TextOverflow.ellipsis, - ), - ), - if (group.isPrivate) - Padding( - padding: const EdgeInsets.only(left: 6), - child: Icon(Icons.lock_outline, - size: 16, color: scheme.outline), - ), - ], - ), - actions: [ - if (_isOwner) - IconButton( - icon: const Icon(Icons.tune), - tooltip: "Booking rules", - onPressed: () { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => SchedulerAdminScreen(group: group), - ), - ); - }, - ), - if (_isOwner) - IconButton( - icon: const Icon(Icons.delete_outline), - tooltip: "Delete scheduler", - onPressed: () => _confirmDelete(context), - ), - ], - bottom: TabBar( - isScrollable: true, - tabs: [ - const Tab(icon: Icon(Icons.calendar_month), text: "Schedule"), - const Tab(icon: Icon(Icons.local_airport), text: "Dispatch"), - const Tab(icon: Icon(Icons.event_note), text: "Mine"), - Tab( - icon: Stack( - clipBehavior: Clip.none, - children: [ - const Icon(Icons.people), - pendingBadge, - ], - ), - text: "Members", - ), - const Tab(icon: Icon(Icons.info_outline), text: "About"), - ], - ), - ), - body: Column( - children: [ - _MembershipBanner(group: group, membership: membership), - Expanded( - child: TabBarView( - children: [ - _ScheduleTab( - group: group, - isOwner: _isOwner, - canBook: _isActive, - ), - SchedulerDispatchScreen( - groupId: group.id, - canDispatch: membership?.canDispatch ?? false, - isOwner: _isOwner, - canUse: _isActive, - embedded: true, - ), - _MyReservationsTab(group: group), - SchedulerMembersScreen( - groupId: group.id, - isOwner: _isOwner, - embedded: true, - ), - _AboutTab(group: group), - ], - ), - ), - ], - ), - ), - ); - } -} - -class _MembershipBanner extends StatelessWidget { - final SchedulerGroup group; - final SchedulerMember? membership; - const _MembershipBanner({required this.group, this.membership}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(12, 10, 12, 10), - color: scheme.surfaceContainerHighest.withAlpha(120), - child: Row( - children: [ - Expanded( - child: Text( - membership == null - ? (group.isPrivate - ? "This is a private scheduler. Request to join to book resources." - : "Join this scheduler to book aircraft and instructors.") - : (membership!.isPending - ? "Your request is waiting for owner approval." - : membership!.isOwner - ? "You own this scheduler." - : "You're a member — tap a green slot to book."), - style: const TextStyle(fontSize: 13), - ), - ), - const SizedBox(width: 8), - SchedulerJoinLeaveButton( - group: group, - membership: membership, - onMessage: (m) { - if (context.mounted) { - Toast.showToast(context, m, const Icon(Icons.info), 3); - } - }, - ), - ], - ), - ); - } -} - -class _ScheduleTab extends StatefulWidget { - final SchedulerGroup group; - final bool isOwner; - final bool canBook; - const _ScheduleTab({ - required this.group, - required this.isOwner, - required this.canBook, - }); - - @override - State<_ScheduleTab> createState() => _ScheduleTabState(); -} - -class _ScheduleTabState extends State<_ScheduleTab> { - late DateTime _day; - - @override - void initState() { - super.initState(); - final now = DateTime.now(); - _day = DateTime(now.year, now.month, now.day); - } - - String _dayLabel(DateTime d) => _fmtDay(d); - - Future _pickDay() async { - final picked = await showDatePicker( - context: context, - initialDate: _day, - firstDate: DateTime.now().subtract(const Duration(days: 365)), - lastDate: DateTime.now().add(const Duration(days: 365)), - ); - if (picked != null) { - setState(() => _day = DateTime(picked.year, picked.month, picked.day)); - } - } - - @override - Widget build(BuildContext context) { - final repo = SchedulerRepository.instance; - final uid = FirebaseAuth.instance.currentUser?.uid; - - return Column( - children: [ - _DayBar( - label: _dayLabel(_day), - isToday: _isToday(_day), - onPrev: () => setState( - () => _day = _day.subtract(const Duration(days: 1))), - onNext: () => - setState(() => _day = _day.add(const Duration(days: 1))), - onPick: _pickDay, - onToday: () { - final now = DateTime.now(); - setState(() => _day = DateTime(now.year, now.month, now.day)); - }, - ), - const _Legend(), - if (widget.isOwner) - Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: TextButton.icon( - onPressed: () => _showAddResourceDialog(context), - icon: const Icon(Icons.add, size: 18), - label: const Text("Add resource"), - ), - ), - ), - Expanded( - child: StreamBuilder>( - stream: repo.watchResources(widget.group.id), - builder: (context, resSnap) { - if (resSnap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - final resources = resSnap.data ?? const []; - return StreamBuilder>( - stream: repo.watchReservationsForDay(widget.group.id, _day), - builder: (context, resvSnap) { - final reservations = resvSnap.data ?? const []; - return ScheduleGrid( - day: _day, - resources: resources, - reservations: reservations, - currentUid: uid, - onTapEmpty: (resource, startHour) { - if (!widget.canBook) { - Toast.showToast( - context, - "Join this scheduler to book resources", - const Icon(Icons.info, color: Colors.orange), - 3); - return; - } - _showBookingDialog(context, resource, startHour); - }, - onTapReservation: (r) => - _showReservationSheet(context, r, uid), - onTapResource: widget.isOwner - ? (resource) => - _showManageResourceDialog(context, resource) - : null, - ); - }, - ); - }, - ), - ), - ], - ); - } - - bool _isToday(DateTime d) { - final now = DateTime.now(); - return d.year == now.year && d.month == now.month && d.day == now.day; - } - - // -------------------- Booking -------------------- - - Future _showBookingDialog(BuildContext context, - SchedulableResource resource, DateTime initialStart) async { - DateTime startDate = - DateTime(initialStart.year, initialStart.month, initialStart.day); - int startHour = initialStart.hour; - final defaultEnd = initialStart.add(const Duration(hours: 1)); - DateTime endDate = - DateTime(defaultEnd.year, defaultEnd.month, defaultEnd.day); - int endHour = defaultEnd.hour; - - DateTime composeStart() => - DateTime(startDate.year, startDate.month, startDate.day, startHour); - DateTime composeEnd() => - DateTime(endDate.year, endDate.month, endDate.day, endHour); - - final firstDate = DateTime.now().subtract(const Duration(days: 1)); - final lastDate = DateTime.now().add(const Duration(days: 365)); - - // Training assignment options (aircraft bookings). - final members = await SchedulerRepository.instance - .watchMembers(widget.group.id) - .first; - final instructors = - members.where((m) => m.isActive && m.isInstructor).toList(); - final packs = resource.isAircraft - ? (await SchedulerRepository.instance - .watchLessonPacks(widget.group.id) - .first) - .where((p) => p.isActive && p.hoursRemaining > 0) - .toList() - : []; - final myMembership = members.cast().firstWhere( - (m) => m?.uid == FirebaseAuth.instance.currentUser?.uid, - orElse: () => null, - ); - SchedulerMember? selectedInstructor; - if (myMembership?.assignedInstructorUid != null) { - selectedInstructor = instructors.cast().firstWhere( - (i) => i?.uid == myMembership!.assignedInstructorUid, - orElse: () => null, - ); - } - String? selectedPackId; - - if (!context.mounted) return; - final result = await showDialog( - context: context, - builder: (ctx) { - return StatefulBuilder( - builder: (ctx, setLocal) { - final scheme = Theme.of(ctx).colorScheme; - final start = composeStart(); - final end = composeEnd(); - final spanDays = end.difference(start).inDays; - final valid = end.isAfter(start) && - end.difference(start) <= - const Duration( - days: SchedulerRepository.maxBookingDays); - - Widget pickerRow(String label, String value, VoidCallback onTap) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - SizedBox(width: 64, child: Text(label)), - Expanded( - child: OutlinedButton( - onPressed: onTap, - child: Align( - alignment: Alignment.centerLeft, - child: Text(value), - ), - ), - ), - ], - ), - ); - } - - Widget hourRow(String label, int hour, ValueChanged onChanged) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - SizedBox(width: 64, child: Text(label)), - Expanded( - child: DropdownButton( - isExpanded: true, - value: hour, - items: [ - for (int h = 0; h < 24; h++) - DropdownMenuItem( - value: h, child: Text(_fmtHour(h))), - ], - onChanged: (v) => onChanged(v ?? hour), - ), - ), - ], - ), - ); - } - - return AlertDialog( - title: Text("Book ${resource.name}"), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("Starts", - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.1, - color: scheme.primary)), - pickerRow("Date", _fmtDay(startDate), () async { - final picked = await showDatePicker( - context: ctx, - initialDate: startDate, - firstDate: firstDate, - lastDate: lastDate, - ); - if (picked != null) { - setLocal(() { - startDate = DateTime( - picked.year, picked.month, picked.day); - if (endDate.isBefore(startDate)) endDate = startDate; - }); - } - }), - hourRow("Time", startHour, - (v) => setLocal(() => startHour = v)), - const SizedBox(height: 8), - Text("Ends", - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.1, - color: scheme.primary)), - pickerRow("Date", _fmtDay(endDate), () async { - final picked = await showDatePicker( - context: ctx, - initialDate: - endDate.isBefore(startDate) ? startDate : endDate, - firstDate: startDate, - lastDate: lastDate, - ); - if (picked != null) { - setLocal(() => endDate = DateTime( - picked.year, picked.month, picked.day)); - } - }), - hourRow("Time", endHour, - (v) => setLocal(() => endHour = v)), - if (resource.isAircraft && - (instructors.isNotEmpty || packs.isNotEmpty)) ...[ - const SizedBox(height: 12), - Text("Training (optional)", - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.1, - color: scheme.primary)), - if (instructors.isNotEmpty) - DropdownButtonFormField( - initialValue: selectedInstructor, - decoration: const InputDecoration( - labelText: "Instructor", - border: OutlineInputBorder(), - isDense: true, - ), - items: [ - const DropdownMenuItem( - value: null, child: Text("None")), - for (final i in instructors) - DropdownMenuItem( - value: i, child: Text(i.displayName)), - ], - onChanged: (v) => - setLocal(() => selectedInstructor = v), - ), - if (packs.isNotEmpty) ...[ - const SizedBox(height: 8), - DropdownButtonFormField( - initialValue: selectedPackId, - decoration: const InputDecoration( - labelText: "Lesson pack", - border: OutlineInputBorder(), - isDense: true, - ), - items: [ - const DropdownMenuItem( - value: null, child: Text("None")), - for (final p in packs) - DropdownMenuItem( - value: p.id, - child: Text( - "${p.name} (${p.hoursRemaining.toStringAsFixed(1)} hrs)", - overflow: TextOverflow.ellipsis, - ), - ), - ], - onChanged: (v) => - setLocal(() => selectedPackId = v), - ), - ], - ], - const SizedBox(height: 10), - Text( - "${_fmtDay(start)} ${_fmtHour(start.hour)} → " - "${_fmtDay(end)} ${_fmtHour(end.hour)}", - style: const TextStyle(fontWeight: FontWeight.w600), - ), - if (spanDays >= 1) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - "Spans $spanDays day${spanDays == 1 ? '' : 's'}", - style: TextStyle( - fontSize: 11, color: scheme.onSurfaceVariant), - ), - ), - const SizedBox(height: 4), - if (!valid) - Text( - end.isAfter(start) - ? "A reservation can be at most ${SchedulerRepository.maxBookingDays} days long." - : "End must be after start.", - style: TextStyle(fontSize: 11, color: scheme.error), - ) - else - Text( - "If this resource is already booked for this time, " - "you'll be queued as a backup.", - style: TextStyle( - fontSize: 11, color: scheme.outline), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton( - onPressed: - valid ? () => Navigator.pop(ctx, true) : null, - child: const Text("Book")), - ], - ); - }, - ); - }, - ); - - if (result != true || !context.mounted) return; - - final start = composeStart(); - final end = composeEnd(); - - try { - final r = await SchedulerRepository.instance.createReservation( - widget.group.id, - resource: resource, - start: start, - end: end, - instructorUid: selectedInstructor?.uid, - instructorName: selectedInstructor?.displayName, - lessonPackId: selectedPackId, - ); - if (!context.mounted) return; - if (r.isBackup) { - Toast.showToast( - context, - "Added as backup #${r.backupOrder} for ${resource.name}", - const Icon(Icons.hourglass_bottom, color: Colors.blue), - 3); - } else { - Toast.showToast(context, "Booked ${resource.name}", - const Icon(Icons.check, color: Colors.green), 3); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not book: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - // -------------------- Reservation details / cancel -------------------- - - void _showReservationSheet( - BuildContext context, Reservation r, String? uid) { - final isOwner = widget.isOwner; - final mine = uid != null && r.schedulerUid == uid; - final canCancel = isOwner || mine; - final scheme = Theme.of(context).colorScheme; - - showModalBottomSheet( - context: context, - builder: (ctx) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(r.isMain ? Icons.event_available : Icons.hourglass_bottom, - color: Colors.blue), - const SizedBox(width: 8), - Expanded( - child: Text( - r.resourceName, - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.bold), - ), - ), - if (r.isBackup) - Chip( - visualDensity: VisualDensity.compact, - label: Text("Backup #${r.backupOrder}", - style: const TextStyle(fontSize: 11)), - ), - ], - ), - const SizedBox(height: 8), - Text("Reserved by ${r.schedulerName}"), - const SizedBox(height: 4), - Text( - _fmtRange(r.start, r.end), - style: TextStyle(color: scheme.outline, fontSize: 13), - ), - if (r.instructorName != null) ...[ - const SizedBox(height: 6), - Text("Instructor: ${r.instructorName}"), - ], - if (r.studentName != null) ...[ - const SizedBox(height: 2), - Text("Student: ${r.studentName}"), - ], - if (r.lessonPackId != null) ...[ - const SizedBox(height: 2), - Text("Lesson pack linked", - style: TextStyle(fontSize: 12, color: scheme.outline)), - ], - if (r.note != null && r.note!.isNotEmpty) ...[ - const SizedBox(height: 8), - Text(r.note!), - ], - const SizedBox(height: 16), - if (canCancel) - FilledButton.tonalIcon( - style: - FilledButton.styleFrom(foregroundColor: Colors.red), - onPressed: () async { - Navigator.pop(ctx); - await _cancel(context, r); - }, - icon: const Icon(Icons.delete_outline), - label: Text(r.isMain && !mine - ? "Cancel this reservation" - : "Cancel my reservation"), - ) - else - Text( - "Only ${r.schedulerName} or the owner can cancel this.", - style: TextStyle(fontSize: 12, color: scheme.outline), - ), - ], - ), - ), - ); - }, - ); - } - - Future _cancel(BuildContext context, Reservation r) => - _confirmCancelReservation(context, widget.group.id, r); - - // -------------------- Resource management (owner) -------------------- - - Future _showAddResourceDialog(BuildContext context) async { - final nameCtrl = TextEditingController(); - final idCtrl = TextEditingController(); - ResourceType type = ResourceType.aircraft; - - final ok = await showDialog( - context: context, - builder: (ctx) { - return StatefulBuilder(builder: (ctx, setLocal) { - return AlertDialog( - title: const Text("Add resource"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SegmentedButton( - segments: const [ - ButtonSegment( - value: ResourceType.aircraft, - icon: Icon(Icons.flight), - label: Text("Aircraft")), - ButtonSegment( - value: ResourceType.instructor, - icon: Icon(Icons.person), - label: Text("Instructor")), - ], - selected: {type}, - onSelectionChanged: (s) => - setLocal(() => type = s.first), - ), - const SizedBox(height: 12), - TextField( - controller: nameCtrl, - maxLength: 40, - textCapitalization: TextCapitalization.words, - decoration: InputDecoration( - labelText: type == ResourceType.aircraft - ? "Aircraft name / model" - : "Instructor name", - border: const OutlineInputBorder(), - ), - ), - TextField( - controller: idCtrl, - maxLength: 20, - textCapitalization: TextCapitalization.characters, - decoration: InputDecoration( - labelText: type == ResourceType.aircraft - ? "Tail number (optional)" - : "Identifier (optional)", - border: const OutlineInputBorder(), - ), - ), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Add")), - ], - ); - }); - }, - ); - - if (ok != true || !context.mounted) return; - final name = nameCtrl.text.trim(); - if (name.isEmpty) { - Toast.showToast(context, "Enter a name for the resource", - const Icon(Icons.info, color: Colors.orange), 3); - return; - } - try { - await SchedulerRepository.instance.addResource( - widget.group.id, - name: name, - type: type, - identifier: idCtrl.text.trim(), - ); - if (context.mounted) { - Toast.showToast(context, "Added $name", - const Icon(Icons.check, color: Colors.green), 2); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not add resource: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - Future _showManageResourceDialog( - BuildContext context, SchedulableResource resource) async { - showModalBottomSheet( - context: context, - builder: (ctx) { - return SafeArea( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - leading: Icon( - resource.isAircraft ? Icons.flight : Icons.person), - title: Text(resource.name, - style: const TextStyle(fontWeight: FontWeight.bold)), - subtitle: Text(resource.available - ? "Available" - : "Unavailable (out of service)"), - ), - const Divider(height: 1), - SwitchListTile( - title: const Text("Available for booking"), - subtitle: Text(resource.available - ? "Members can book this resource" - : "Shown in red; bookings are blocked"), - value: resource.available, - onChanged: (v) async { - Navigator.pop(ctx); - try { - await SchedulerRepository.instance.setResourceAvailability( - widget.group.id, resource.id, v); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Update failed: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - }, - ), - ListTile( - leading: const Icon(Icons.delete_outline, color: Colors.red), - title: const Text("Delete resource", - style: TextStyle(color: Colors.red)), - subtitle: const Text("Removes the resource and its reservations"), - onTap: () async { - Navigator.pop(ctx); - await _confirmDeleteResource(context, resource); - }, - ), - ], - ), - ); - }, - ); - } - - Future _confirmDeleteResource( - BuildContext context, SchedulableResource resource) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text("Delete ${resource.name}?"), - content: const Text( - "This removes the resource and all of its reservations. This cannot be undone."), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton.tonal( - style: FilledButton.styleFrom(foregroundColor: Colors.red), - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Delete"), - ), - ], - ), - ); - if (ok != true || !context.mounted) return; - try { - await SchedulerRepository.instance - .deleteResource(widget.group.id, resource.id); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not delete: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } -} - -String _fmtDay(DateTime d) { - const months = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" - ]; - const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]; - return "${weekdays[d.weekday - 1]} ${months[d.month - 1]} ${d.day}"; -} - -bool _isWeekendDay(DateTime d) => - d.weekday == DateTime.saturday || d.weekday == DateTime.sunday; - -/// Shared cancel confirmation + execution used by both the Schedule grid and -/// the "Mine" reservations list. -Future _confirmCancelReservation( - BuildContext context, String sgid, Reservation r) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text("Cancel reservation?"), - content: Text(r.isMain - ? "This frees up ${r.resourceName}. If anyone is on the backup list, the next backup becomes the main reservation." - : "This removes your backup reservation for ${r.resourceName}."), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Keep")), - FilledButton.tonal( - style: FilledButton.styleFrom(foregroundColor: Colors.red), - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Cancel reservation"), - ), - ], - ), - ); - if (ok != true || !context.mounted) return; - try { - await SchedulerRepository.instance.cancelReservation(sgid, r); - if (context.mounted) { - Toast.showToast(context, "Reservation cancelled", - const Icon(Icons.check, color: Colors.green), 2); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not cancel: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } -} - -String _fmtHour(int h) { - if (h == 0) return "12:00 AM"; - if (h == 24) return "12:00 AM"; - if (h == 12) return "12:00 PM"; - if (h < 12) return "$h:00 AM"; - return "${h - 12}:00 PM"; -} - -String _fmtTime(DateTime t) { - final h = t.hour; - final m = t.minute.toString().padLeft(2, '0'); - final ampm = h < 12 ? "AM" : "PM"; - final h12 = h % 12 == 0 ? 12 : h % 12; - return "$h12:$m $ampm"; -} - -/// Formats a reservation window. Single-day bookings read -/// "Sat Jun 27 · 9:00 AM – 11:00 AM"; multi-day bookings include the end -/// date so it isn't lost: "Sat Jun 27 9:00 AM – Sun Jun 28 11:00 AM". -String _fmtRange(DateTime start, DateTime end) { - final sameDay = start.year == end.year && - start.month == end.month && - start.day == end.day; - if (sameDay) { - return "${_fmtDay(start)} · ${_fmtTime(start)} – ${_fmtTime(end)}"; - } - return "${_fmtDay(start)} ${_fmtTime(start)} – " - "${_fmtDay(end)} ${_fmtTime(end)}"; -} - -class _MyReservationsTab extends StatelessWidget { - final SchedulerGroup group; - const _MyReservationsTab({required this.group}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return StreamBuilder>( - stream: SchedulerRepository.instance.watchMyReservations(group.id), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text("Couldn't load your reservations: ${snap.error}", - textAlign: TextAlign.center), - ), - ); - } - final all = snap.data ?? const []; - final now = DateTime.now(); - final upcoming = all.where((r) => r.end.isAfter(now)).toList(); - final past = all.where((r) => !r.end.isAfter(now)).toList() - ..sort((a, b) => b.start.compareTo(a.start)); - - if (all.isEmpty) { - return Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.event_busy, size: 48, color: scheme.outline), - const SizedBox(height: 12), - const Text("No reservations yet", - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 6), - Text( - "Book a resource from the Schedule tab and it'll show up here.", - textAlign: TextAlign.center, - style: TextStyle(color: scheme.outline, fontSize: 13), - ), - ], - ), - ), - ); - } - - final limits = [ - if (group.maxReservationsPerMember > 0) - "${upcoming.length}/${group.maxReservationsPerMember} active", - if (group.maxWeekendReservations > 0) - "${upcoming.where((r) => _isWeekendDay(r.start)).length}/${group.maxWeekendReservations} weekend", - ]; - - return ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - if (limits.isNotEmpty) - Padding( - padding: const EdgeInsets.fromLTRB(16, 4, 16, 8), - child: Wrap( - spacing: 8, - children: limits - .map((l) => Chip( - visualDensity: VisualDensity.compact, - avatar: const Icon(Icons.rule, size: 16), - label: Text(l, - style: const TextStyle(fontSize: 11)), - )) - .toList(), - ), - ), - _header(context, "Upcoming (${upcoming.length})"), - if (upcoming.isEmpty) - const Padding( - padding: EdgeInsets.all(16), - child: Center(child: Text("No upcoming reservations")), - ) - else - ...upcoming.map((r) => _tile(context, r, cancellable: true)), - if (past.isNotEmpty) ...[ - _header(context, "Past (${past.length})"), - ...past.map((r) => _tile(context, r, cancellable: false)), - ], - ], - ); - }, - ); - } - - Widget _header(BuildContext context, String label) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text( - label.toUpperCase(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - } - - Widget _tile(BuildContext context, Reservation r, - {required bool cancellable}) { - final scheme = Theme.of(context).colorScheme; - return ListTile( - leading: Icon( - r.isMain ? Icons.event_available : Icons.hourglass_bottom, - color: cancellable ? Colors.blue : scheme.outline, - ), - title: Row( - children: [ - Flexible( - child: Text(r.resourceName, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w600)), - ), - if (r.isBackup) - Padding( - padding: const EdgeInsets.only(left: 6), - child: Chip( - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - label: Text("Backup #${r.backupOrder}", - style: const TextStyle(fontSize: 10)), - ), - ), - if (_isWeekendDay(r.start)) - const Padding( - padding: EdgeInsets.only(left: 6), - child: Chip( - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - label: Text("Weekend", style: TextStyle(fontSize: 10)), - ), - ), - ], - ), - subtitle: Text( - _fmtRange(r.start, r.end), - style: TextStyle(fontSize: 12, color: scheme.outline), - ), - trailing: cancellable - ? IconButton( - tooltip: "Cancel", - icon: const Icon(Icons.delete_outline, color: Colors.red), - onPressed: () => - _confirmCancelReservation(context, group.id, r), - ) - : null, - ); - } -} - -class _DayBar extends StatelessWidget { - final String label; - final bool isToday; - final VoidCallback onPrev; - final VoidCallback onNext; - final VoidCallback onPick; - final VoidCallback onToday; - const _DayBar({ - required this.label, - required this.isToday, - required this.onPrev, - required this.onNext, - required this.onPick, - required this.onToday, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 4), - child: Row( - children: [ - IconButton( - onPressed: onPrev, icon: const Icon(Icons.chevron_left)), - Expanded( - child: TextButton.icon( - onPressed: onPick, - icon: const Icon(Icons.calendar_today, size: 16), - label: Text(label, - style: const TextStyle(fontWeight: FontWeight.w600)), - ), - ), - if (!isToday) - TextButton(onPressed: onToday, child: const Text("Today")), - IconButton( - onPressed: onNext, icon: const Icon(Icons.chevron_right)), - ], - ), - ); - } -} - -class _Legend extends StatelessWidget { - const _Legend(); - @override - Widget build(BuildContext context) { - Widget swatch(Color c, String label) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: c, - borderRadius: BorderRadius.circular(3), - ), - ), - const SizedBox(width: 4), - Text(label, style: const TextStyle(fontSize: 11)), - ], - ); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), - child: Wrap( - spacing: 14, - runSpacing: 4, - children: [ - swatch(Colors.green.withValues(alpha: 0.4), "Available"), - swatch(Colors.blue.shade600, "Booked"), - swatch(Colors.red.withValues(alpha: 0.5), "Unavailable"), - ], - ), - ); - } -} - -class _AboutTab extends StatelessWidget { - final SchedulerGroup group; - const _AboutTab({required this.group}); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return ListView( - padding: const EdgeInsets.all(16), - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(group.name, - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - const SizedBox(height: 6), - Text( - group.description.isEmpty - ? "No description provided." - : group.description, - style: TextStyle(color: scheme.onSurfaceVariant), - ), - const SizedBox(height: 12), - _kv(context, "Owner", group.ownerName), - _kv(context, "Visibility", - group.isPrivate ? "Private" : "Public"), - if (group.homeAirport != null) - _kv(context, "Home airport", group.homeAirport!), - _kv(context, "Members", "${group.memberCount}"), - _kv(context, "Resources", "${group.resourceCount}"), - _kv( - context, - "Max per member", - group.maxReservationsPerMember == 0 - ? "Unlimited" - : "${group.maxReservationsPerMember}"), - _kv( - context, - "Max weekend", - group.maxWeekendReservations == 0 - ? "Unlimited" - : "${group.maxWeekendReservations}"), - _kv(context, "Created", - group.createdAt.toLocal().toString().split(' ').first), - ], - ), - ), - ), - const SizedBox(height: 8), - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text("How booking works", - style: TextStyle( - fontSize: 13, - fontWeight: FontWeight.bold, - color: scheme.primary)), - const SizedBox(height: 8), - const Text( - "• Tap a green slot in the Schedule to book a resource.\n" - "• If a resource is already booked for that time, you join " - "the backup queue and are promoted automatically if the " - "main reservation is cancelled.\n" - "• Only you or the owner can cancel your reservation.\n" - "• Use the Dispatch tab for shared fleet status, hobbs/tach " - "and MX due, squawks, and student lesson packs.\n" - "• The owner (or a dispatcher) can mark aircraft unavailable " - "and update meters; grounding squawks block booking.", - style: TextStyle(fontSize: 12), - ), - ], - ), - ), - ), - ], - ); - } - - Widget _kv(BuildContext context, String k, String v) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 3), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - width: 110, - child: Text(k, - style: TextStyle( - color: Theme.of(context).colorScheme.outline, - fontSize: 12)), - ), - Expanded(child: Text(v, style: const TextStyle(fontSize: 13))), - ], - ), - ); - } -} diff --git a/lib/scheduler/scheduler_dispatch_screen.dart b/lib/scheduler/scheduler_dispatch_screen.dart deleted file mode 100644 index 9d01f6db..00000000 --- a/lib/scheduler/scheduler_dispatch_screen.dart +++ /dev/null @@ -1,1260 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/scheduler_repository.dart'; -import 'models/lesson_pack.dart'; -import 'models/schedulable_resource.dart'; -import 'models/scheduler_member.dart'; -import 'models/squawk.dart'; - -/// Club dispatch board: shared fleet status, squawks, and lesson packs. -/// -/// Embedded in [SchedulerDetailScreen] as the Dispatch tab, or pushed as a -/// standalone screen via [embedded] == false. -class SchedulerDispatchScreen extends StatefulWidget { - final String groupId; - final bool canDispatch; - final bool isOwner; - final bool canUse; - final bool embedded; - - const SchedulerDispatchScreen({ - super.key, - required this.groupId, - required this.canDispatch, - required this.isOwner, - required this.canUse, - this.embedded = false, - }); - - @override - State createState() => - _SchedulerDispatchScreenState(); -} - -class _SchedulerDispatchScreenState extends State - with SingleTickerProviderStateMixin { - late final TabController _tabs; - - @override - void initState() { - super.initState(); - _tabs = TabController(length: 3, vsync: this); - } - - @override - void dispose() { - _tabs.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - final body = Column( - children: [ - TabBar( - controller: _tabs, - tabs: const [ - Tab(icon: Icon(Icons.flight), text: "Fleet"), - Tab(icon: Icon(Icons.report_problem_outlined), text: "Squawks"), - Tab(icon: Icon(Icons.school_outlined), text: "Lessons"), - ], - ), - Expanded( - child: TabBarView( - controller: _tabs, - children: [ - _FleetTab( - groupId: widget.groupId, - canDispatch: widget.canDispatch, - canUse: widget.canUse, - ), - _SquawksTab( - groupId: widget.groupId, - canDispatch: widget.canDispatch, - canUse: widget.canUse, - ), - _LessonPacksTab( - groupId: widget.groupId, - isOwner: widget.isOwner, - canUse: widget.canUse, - ), - ], - ), - ), - ], - ); - - if (widget.embedded) return body; - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Dispatch"), - ), - body: body, - ); - } -} - -// --------------------------------------------------------------------------- -// Fleet -// --------------------------------------------------------------------------- - -class _FleetTab extends StatelessWidget { - final String groupId; - final bool canDispatch; - final bool canUse; - const _FleetTab({ - required this.groupId, - required this.canDispatch, - required this.canUse, - }); - - @override - Widget build(BuildContext context) { - if (!canUse) { - return const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text( - "Join this scheduler to see the shared fleet status board.", - textAlign: TextAlign.center, - ), - ), - ); - } - - return StreamBuilder>( - stream: SchedulerRepository.instance.watchResources(groupId), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - final aircraft = - (snap.data ?? const []).where((r) => r.isAircraft).toList(); - if (aircraft.isEmpty) { - return const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text( - "No aircraft in this fleet yet.\n" - "The owner can add aircraft from the Schedule tab.", - textAlign: TextAlign.center, - ), - ), - ); - } - return ListView.builder( - padding: const EdgeInsets.fromLTRB(12, 12, 12, 24), - itemCount: aircraft.length, - itemBuilder: (context, i) { - final r = aircraft[i]; - return _FleetAircraftCard( - groupId: groupId, - resource: r, - canDispatch: canDispatch, - canUse: canUse, - ); - }, - ); - }, - ); - } -} - -class _FleetAircraftCard extends StatelessWidget { - final String groupId; - final SchedulableResource resource; - final bool canDispatch; - final bool canUse; - const _FleetAircraftCard({ - required this.groupId, - required this.resource, - required this.canDispatch, - required this.canUse, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return StreamBuilder>( - stream: SchedulerRepository.instance - .watchOpenSquawksForResource(groupId, resource.id), - builder: (context, sSnap) { - final open = sSnap.data ?? const []; - final grounding = open.where((s) => s.isGrounding).length; - final caution = open.where((s) => s.severity == SquawkSeverity.caution).length; - - Color statusColor; - String statusLabel; - if (!resource.available || grounding > 0) { - statusColor = Colors.red; - statusLabel = grounding > 0 ? "GROUNDED" : "OUT OF SERVICE"; - } else if (resource.hundredHourOverdue || - resource.mxDueSoon(warnDays: 0)) { - statusColor = Colors.orange; - statusLabel = "MX DUE"; - } else if (resource.mxDueSoon() || caution > 0) { - statusColor = Colors.amber.shade800; - statusLabel = "ATTENTION"; - } else { - statusColor = Colors.green; - statusLabel = "READY"; - } - - return Card( - margin: const EdgeInsets.only(bottom: 10), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => _openDetail(context), - child: Padding( - padding: const EdgeInsets.all(14), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Icon(Icons.flight, color: scheme.primary), - const SizedBox(width: 8), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - resource.name, - style: const TextStyle( - fontWeight: FontWeight.w700, fontSize: 16), - ), - if (resource.identifier != null) - Text( - resource.identifier!, - style: TextStyle( - fontSize: 12, color: scheme.outline), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 4), - decoration: BoxDecoration( - color: statusColor.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(6), - border: Border.all(color: statusColor), - ), - child: Text( - statusLabel, - style: TextStyle( - color: statusColor, - fontWeight: FontWeight.bold, - fontSize: 11, - ), - ), - ), - ], - ), - const SizedBox(height: 10), - Wrap( - spacing: 8, - runSpacing: 6, - children: [ - _meterChip("Hobbs", resource.hobbs), - _meterChip("Tach", resource.tach), - if (resource.annualDue != null) - _dateChip("Annual", resource.annualDue!), - if (resource.hundredHourDueHobbs != null) - Chip( - visualDensity: VisualDensity.compact, - label: Text( - "100hr @ ${resource.hundredHourDueHobbs!.toStringAsFixed(1)}", - style: const TextStyle(fontSize: 11), - ), - ), - if (open.isNotEmpty) - Chip( - visualDensity: VisualDensity.compact, - avatar: Icon(Icons.report_problem, - size: 16, - color: grounding > 0 - ? Colors.red - : scheme.onSurfaceVariant), - label: Text( - "${open.length} open squawk${open.length == 1 ? '' : 's'}", - style: const TextStyle(fontSize: 11), - ), - ), - ], - ), - if (resource.mxNotes != null && - resource.mxNotes!.trim().isNotEmpty) ...[ - const SizedBox(height: 8), - Text( - resource.mxNotes!, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, color: scheme.onSurfaceVariant), - ), - ], - ], - ), - ), - ), - ); - }, - ); - } - - Widget _meterChip(String label, double? value) { - return Chip( - visualDensity: VisualDensity.compact, - label: Text( - value == null ? "$label —" : "$label ${value.toStringAsFixed(1)}", - style: const TextStyle(fontSize: 11), - ), - ); - } - - Widget _dateChip(String label, DateTime d) { - final overdue = d.isBefore(DateTime.now()); - return Chip( - visualDensity: VisualDensity.compact, - backgroundColor: overdue ? Colors.orange.withValues(alpha: 0.2) : null, - label: Text( - "$label ${_fmtShortDate(d)}${overdue ? ' (due)' : ''}", - style: TextStyle( - fontSize: 11, color: overdue ? Colors.orange.shade900 : null), - ), - ); - } - - void _openDetail(BuildContext context) { - showModalBottomSheet( - context: context, - isScrollControlled: true, - builder: (ctx) => _AircraftDispatchSheet( - groupId: groupId, - resource: resource, - canDispatch: canDispatch, - canUse: canUse, - ), - ); - } -} - -class _AircraftDispatchSheet extends StatelessWidget { - final String groupId; - final SchedulableResource resource; - final bool canDispatch; - final bool canUse; - const _AircraftDispatchSheet({ - required this.groupId, - required this.resource, - required this.canDispatch, - required this.canUse, - }); - - @override - Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(resource.name, - style: const TextStyle( - fontSize: 18, fontWeight: FontWeight.bold)), - if (resource.identifier != null) - Text(resource.identifier!, - style: TextStyle( - color: Theme.of(context).colorScheme.outline)), - const SizedBox(height: 12), - _kv("Hobbs", resource.hobbs?.toStringAsFixed(1) ?? "—"), - _kv("Tach", resource.tach?.toStringAsFixed(1) ?? "—"), - _kv("Annual due", - resource.annualDue == null - ? "—" - : _fmtShortDate(resource.annualDue!)), - _kv( - "100-hour due (hobbs)", - resource.hundredHourDueHobbs?.toStringAsFixed(1) ?? "—"), - _kv( - "Transponder due", - resource.transponderDue == null - ? "—" - : _fmtShortDate(resource.transponderDue!)), - _kv( - "ELT due", - resource.eltDue == null - ? "—" - : _fmtShortDate(resource.eltDue!)), - if (resource.mxNotes != null && resource.mxNotes!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text(resource.mxNotes!), - ), - const SizedBox(height: 16), - if (canDispatch) ...[ - FilledButton.icon( - onPressed: () { - Navigator.pop(context); - _editStatus(context); - }, - icon: const Icon(Icons.edit), - label: const Text("Update meters / MX"), - ), - const SizedBox(height: 8), - SwitchListTile( - contentPadding: EdgeInsets.zero, - title: const Text("Available for booking"), - value: resource.available, - onChanged: (v) async { - Navigator.pop(context); - try { - await SchedulerRepository.instance - .setResourceAvailability(groupId, resource.id, v); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Update failed: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - }, - ), - ], - if (canUse) - OutlinedButton.icon( - onPressed: () { - Navigator.pop(context); - _fileSquawk(context); - }, - icon: const Icon(Icons.report_problem_outlined), - label: const Text("File squawk"), - ), - ], - ), - ), - ), - ); - } - - Widget _kv(String k, String v) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - children: [ - SizedBox(width: 150, child: Text(k, style: const TextStyle(fontSize: 13))), - Expanded( - child: Text(v, - style: const TextStyle( - fontSize: 13, fontWeight: FontWeight.w600))), - ], - ), - ); - } - - Future _editStatus(BuildContext context) async { - final hobbsCtrl = - TextEditingController(text: resource.hobbs?.toStringAsFixed(1) ?? ""); - final tachCtrl = - TextEditingController(text: resource.tach?.toStringAsFixed(1) ?? ""); - final hundredCtrl = TextEditingController( - text: resource.hundredHourDueHobbs?.toStringAsFixed(1) ?? ""); - final notesCtrl = TextEditingController(text: resource.mxNotes ?? ""); - DateTime? annualDue = resource.annualDue; - DateTime? xpdrDue = resource.transponderDue; - DateTime? eltDue = resource.eltDue; - - final ok = await showDialog( - context: context, - builder: (ctx) { - return StatefulBuilder(builder: (ctx, setLocal) { - return AlertDialog( - title: Text("Update ${resource.name}"), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: hobbsCtrl, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')) - ], - decoration: const InputDecoration( - labelText: "Hobbs", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - TextField( - controller: tachCtrl, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')) - ], - decoration: const InputDecoration( - labelText: "Tach", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - TextField( - controller: hundredCtrl, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[0-9.]')) - ], - decoration: const InputDecoration( - labelText: "100-hour due (hobbs)", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(annualDue == null - ? "Annual due: not set" - : "Annual due: ${_fmtShortDate(annualDue!)}"), - trailing: const Icon(Icons.calendar_today), - onTap: () async { - final picked = await showDatePicker( - context: ctx, - initialDate: annualDue ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime.now().add(const Duration(days: 3650)), - ); - if (picked != null) setLocal(() => annualDue = picked); - }, - ), - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(xpdrDue == null - ? "Transponder due: not set" - : "Transponder due: ${_fmtShortDate(xpdrDue!)}"), - trailing: const Icon(Icons.calendar_today), - onTap: () async { - final picked = await showDatePicker( - context: ctx, - initialDate: xpdrDue ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime.now().add(const Duration(days: 3650)), - ); - if (picked != null) setLocal(() => xpdrDue = picked); - }, - ), - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(eltDue == null - ? "ELT due: not set" - : "ELT due: ${_fmtShortDate(eltDue!)}"), - trailing: const Icon(Icons.calendar_today), - onTap: () async { - final picked = await showDatePicker( - context: ctx, - initialDate: eltDue ?? DateTime.now(), - firstDate: DateTime(2000), - lastDate: DateTime.now().add(const Duration(days: 3650)), - ); - if (picked != null) setLocal(() => eltDue = picked); - }, - ), - TextField( - controller: notesCtrl, - maxLength: 500, - maxLines: 3, - decoration: const InputDecoration( - labelText: "MX notes", - border: OutlineInputBorder(), - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Save")), - ], - ); - }); - }, - ); - if (ok != true || !context.mounted) return; - - double? parse(String s) { - final t = s.trim(); - if (t.isEmpty) return null; - return double.tryParse(t); - } - - try { - await SchedulerRepository.instance.updateResourceDispatchStatus( - groupId, - resource.id, - hobbs: parse(hobbsCtrl.text), - tach: parse(tachCtrl.text), - hundredHourDueHobbs: parse(hundredCtrl.text), - annualDue: annualDue, - transponderDue: xpdrDue, - eltDue: eltDue, - mxNotes: notesCtrl.text, - clearHundredHourDueHobbs: hundredCtrl.text.trim().isEmpty, - ); - if (context.mounted) { - Toast.showToast(context, "Fleet status updated", - const Icon(Icons.check, color: Colors.green), 2); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not update: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - Future _fileSquawk(BuildContext context) async { - await showFileSquawkDialog(context, groupId, resource); - } -} - -// --------------------------------------------------------------------------- -// Squawks -// --------------------------------------------------------------------------- - -class _SquawksTab extends StatelessWidget { - final String groupId; - final bool canDispatch; - final bool canUse; - const _SquawksTab({ - required this.groupId, - required this.canDispatch, - required this.canUse, - }); - - @override - Widget build(BuildContext context) { - if (!canUse) { - return const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text("Join this scheduler to view and file squawks.", - textAlign: TextAlign.center), - ), - ); - } - - return StreamBuilder>( - stream: SchedulerRepository.instance.watchSquawks(groupId), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - final all = snap.data ?? const []; - final open = all.where((s) => s.isOpen).toList(); - final resolved = all.where((s) => !s.isOpen).toList(); - - return Column( - children: [ - if (canUse) - Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), - child: TextButton.icon( - onPressed: () => _pickAircraftAndFile(context), - icon: const Icon(Icons.add, size: 18), - label: const Text("File squawk"), - ), - ), - ), - Expanded( - child: ListView( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 24), - children: [ - _section(context, "Open (${open.length})"), - if (open.isEmpty) - const Padding( - padding: EdgeInsets.all(16), - child: Text("No open squawks."), - ) - else - ...open.map((s) => _squawkTile(context, s)), - if (resolved.isNotEmpty) ...[ - _section(context, "Resolved (${resolved.length})"), - ...resolved.take(20).map((s) => _squawkTile(context, s)), - ], - ], - ), - ), - ], - ); - }, - ); - } - - Widget _section(BuildContext context, String label) { - return Padding( - padding: const EdgeInsets.fromLTRB(4, 12, 4, 4), - child: Text( - label.toUpperCase(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - } - - Widget _squawkTile(BuildContext context, Squawk s) { - final scheme = Theme.of(context).colorScheme; - Color sevColor; - switch (s.severity) { - case SquawkSeverity.grounding: - sevColor = Colors.red; - break; - case SquawkSeverity.caution: - sevColor = Colors.orange; - break; - case SquawkSeverity.info: - sevColor = scheme.outline; - break; - } - return Card( - child: ListTile( - leading: Icon(Icons.report_problem, color: sevColor), - title: Text(s.title, style: const TextStyle(fontWeight: FontWeight.w600)), - subtitle: Text( - "${s.resourceName} · ${squawkSeverityLabel(s.severity)}" - "${s.description.isEmpty ? '' : '\n${s.description}'}" - "\nby ${s.reportedByName}", - ), - isThreeLine: true, - trailing: s.isOpen - ? IconButton( - tooltip: "Resolve", - icon: const Icon(Icons.check_circle_outline, color: Colors.green), - onPressed: () async { - try { - await SchedulerRepository.instance.resolveSquawk(groupId, s); - if (context.mounted) { - Toast.showToast(context, "Squawk resolved", - const Icon(Icons.check, color: Colors.green), 2); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not resolve: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - }, - ) - : (canDispatch - ? IconButton( - tooltip: "Delete", - icon: const Icon(Icons.delete_outline, color: Colors.red), - onPressed: () async { - try { - await SchedulerRepository.instance - .deleteSquawk(groupId, s.id); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not delete: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - }, - ) - : null), - ), - ); - } - - Future _pickAircraftAndFile(BuildContext context) async { - final resources = - await SchedulerRepository.instance.watchResources(groupId).first; - final aircraft = resources.where((r) => r.isAircraft).toList(); - if (aircraft.isEmpty) { - if (context.mounted) { - Toast.showToast(context, "Add an aircraft before filing a squawk", - const Icon(Icons.info, color: Colors.orange), 3); - } - return; - } - if (!context.mounted) return; - SchedulableResource? picked = - aircraft.length == 1 ? aircraft.first : null; - picked ??= await showDialog( - context: context, - builder: (ctx) => SimpleDialog( - title: const Text("Which aircraft?"), - children: [ - for (final a in aircraft) - SimpleDialogOption( - onPressed: () => Navigator.pop(ctx, a), - child: Text(a.identifier == null - ? a.name - : "${a.name} (${a.identifier})"), - ), - ], - ), - ); - if (picked == null || !context.mounted) return; - await showFileSquawkDialog(context, groupId, picked); - } -} - -Future showFileSquawkDialog( - BuildContext context, - String groupId, - SchedulableResource resource, -) async { - final titleCtrl = TextEditingController(); - final descCtrl = TextEditingController(); - SquawkSeverity severity = SquawkSeverity.caution; - - final ok = await showDialog( - context: context, - builder: (ctx) { - return StatefulBuilder(builder: (ctx, setLocal) { - return AlertDialog( - title: Text("Squawk on ${resource.name}"), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: titleCtrl, - maxLength: 80, - decoration: const InputDecoration( - labelText: "Title", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - TextField( - controller: descCtrl, - maxLength: 500, - maxLines: 3, - decoration: const InputDecoration( - labelText: "Description", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - initialValue: severity, - decoration: const InputDecoration( - labelText: "Severity", - border: OutlineInputBorder(), - ), - items: [ - for (final s in SquawkSeverity.values) - DropdownMenuItem( - value: s, - child: Text(squawkSeverityLabel(s)), - ), - ], - onChanged: (v) => - setLocal(() => severity = v ?? SquawkSeverity.caution), - ), - if (severity == SquawkSeverity.grounding) - Padding( - padding: const EdgeInsets.only(top: 8), - child: Text( - "Grounding squawks block booking and show as GROUNDED " - "on the fleet board.", - style: TextStyle( - fontSize: 12, - color: Theme.of(ctx).colorScheme.error), - ), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text("File")), - ], - ); - }); - }, - ); - if (ok != true || !context.mounted) return; - try { - await SchedulerRepository.instance.createSquawk( - groupId, - resource: resource, - title: titleCtrl.text, - description: descCtrl.text, - severity: severity, - ); - if (context.mounted) { - Toast.showToast(context, "Squawk filed", - const Icon(Icons.check, color: Colors.green), 2); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not file squawk: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } -} - -// --------------------------------------------------------------------------- -// Lesson packs -// --------------------------------------------------------------------------- - -class _LessonPacksTab extends StatelessWidget { - final String groupId; - final bool isOwner; - final bool canUse; - const _LessonPacksTab({ - required this.groupId, - required this.isOwner, - required this.canUse, - }); - - @override - Widget build(BuildContext context) { - if (!canUse) { - return const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text("Join this scheduler to see lesson packs.", - textAlign: TextAlign.center), - ), - ); - } - - return StreamBuilder>( - stream: SchedulerRepository.instance.watchLessonPacks(groupId), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - final packs = snap.data ?? const []; - return Column( - children: [ - if (isOwner) - Align( - alignment: Alignment.centerRight, - child: Padding( - padding: const EdgeInsets.fromLTRB(8, 4, 8, 0), - child: TextButton.icon( - onPressed: () => _createPack(context), - icon: const Icon(Icons.add, size: 18), - label: const Text("New lesson pack"), - ), - ), - ), - Expanded( - child: packs.isEmpty - ? const Center( - child: Padding( - padding: EdgeInsets.all(24), - child: Text( - "No lesson packs yet.\n" - "Owners can create prepaid hour blocks for students.", - textAlign: TextAlign.center, - ), - ), - ) - : ListView.builder( - padding: const EdgeInsets.fromLTRB(12, 0, 12, 24), - itemCount: packs.length, - itemBuilder: (context, i) => - _packTile(context, packs[i]), - ), - ), - ], - ); - }, - ); - } - - Widget _packTile(BuildContext context, LessonPack p) { - final scheme = Theme.of(context).colorScheme; - final progress = - p.totalHours <= 0 ? 0.0 : (p.hoursUsed / p.totalHours).clamp(0.0, 1.0); - return Card( - child: Padding( - padding: const EdgeInsets.all(12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text(p.name, - style: const TextStyle(fontWeight: FontWeight.w700)), - ), - Chip( - visualDensity: VisualDensity.compact, - label: Text( - lessonPackStatusToString(p.status), - style: const TextStyle(fontSize: 10), - ), - ), - ], - ), - Text( - "${p.studentName}" - "${p.instructorName == null ? '' : ' · CFI ${p.instructorName}'}", - style: TextStyle(fontSize: 12, color: scheme.onSurfaceVariant), - ), - const SizedBox(height: 8), - LinearProgressIndicator(value: progress), - const SizedBox(height: 4), - Text( - "${p.hoursRemaining.toStringAsFixed(1)} of " - "${p.totalHours.toStringAsFixed(1)} hrs remaining" - " (${p.hoursUsed.toStringAsFixed(1)} used)", - style: const TextStyle(fontSize: 12), - ), - if (p.isActive) ...[ - const SizedBox(height: 8), - Row( - children: [ - TextButton( - onPressed: () => _logHours(context, p), - child: const Text("Log hours"), - ), - if (isOwner) ...[ - TextButton( - onPressed: () async { - try { - await SchedulerRepository.instance.setLessonPackStatus( - groupId, p.id, LessonPackStatus.cancelled); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "$e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - }, - child: const Text("Cancel pack"), - ), - IconButton( - tooltip: "Delete", - icon: const Icon(Icons.delete_outline, color: Colors.red), - onPressed: () async { - try { - await SchedulerRepository.instance - .deleteLessonPack(groupId, p.id); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "$e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - }, - ), - ], - ], - ), - ], - ], - ), - ), - ); - } - - Future _logHours(BuildContext context, LessonPack p) async { - final ctrl = TextEditingController(text: "1.0"); - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text("Log lesson hours"), - content: TextField( - controller: ctrl, - autofocus: true, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration( - labelText: "Hours", - border: OutlineInputBorder(), - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Log")), - ], - ), - ); - if (ok != true || !context.mounted) return; - final hours = double.tryParse(ctrl.text.trim()); - if (hours == null || hours <= 0) { - Toast.showToast(context, "Enter a positive number of hours", - const Icon(Icons.info, color: Colors.orange), 3); - return; - } - try { - await SchedulerRepository.instance - .logLessonPackHours(groupId, p.id, hours: hours); - if (context.mounted) { - Toast.showToast(context, "Logged ${hours.toStringAsFixed(1)} hrs", - const Icon(Icons.check, color: Colors.green), 2); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not log hours: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - Future _createPack(BuildContext context) async { - final members = - await SchedulerRepository.instance.watchMembers(groupId).first; - final students = members - .where((m) => m.isActive && (m.isStudent || !m.isOwner)) - .toList(); - final instructors = - members.where((m) => m.isActive && m.isInstructor).toList(); - if (students.isEmpty) { - if (context.mounted) { - Toast.showToast( - context, - "Add and approve members first, then set a student role on Members", - const Icon(Icons.info, color: Colors.orange), - 4); - } - return; - } - - final nameCtrl = TextEditingController(text: "10-hour block"); - final hoursCtrl = TextEditingController(text: "10"); - SchedulerMember student = students.first; - SchedulerMember? instructor = - instructors.isEmpty ? null : instructors.first; - - if (!context.mounted) return; - final ok = await showDialog( - context: context, - builder: (ctx) { - return StatefulBuilder(builder: (ctx, setLocal) { - return AlertDialog( - title: const Text("New lesson pack"), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - TextField( - controller: nameCtrl, - maxLength: 60, - decoration: const InputDecoration( - labelText: "Pack name", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - TextField( - controller: hoursCtrl, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - decoration: const InputDecoration( - labelText: "Total hours", - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - initialValue: student, - decoration: const InputDecoration( - labelText: "Student", - border: OutlineInputBorder(), - ), - items: [ - for (final m in students) - DropdownMenuItem(value: m, child: Text(m.displayName)), - ], - onChanged: (v) => setLocal(() => student = v ?? student), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - initialValue: instructor, - decoration: const InputDecoration( - labelText: "Instructor (optional)", - border: OutlineInputBorder(), - ), - items: [ - const DropdownMenuItem( - value: null, child: Text("None")), - for (final m in instructors) - DropdownMenuItem(value: m, child: Text(m.displayName)), - ], - onChanged: (v) => setLocal(() => instructor = v), - ), - ], - ), - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Create")), - ], - ); - }); - }, - ); - if (ok != true || !context.mounted) return; - final hours = double.tryParse(hoursCtrl.text.trim()); - if (hours == null || hours <= 0) { - Toast.showToast(context, "Enter total hours greater than zero", - const Icon(Icons.info, color: Colors.orange), 3); - return; - } - try { - await SchedulerRepository.instance.createLessonPack( - groupId, - name: nameCtrl.text, - totalHours: hours, - studentUid: student.uid, - studentName: student.displayName, - instructorUid: instructor?.uid, - instructorName: instructor?.displayName, - ); - if (context.mounted) { - Toast.showToast(context, "Lesson pack created", - const Icon(Icons.check, color: Colors.green), 2); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not create pack: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } -} - -String _fmtShortDate(DateTime d) { - const months = [ - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec" - ]; - return "${months[d.month - 1]} ${d.day}, ${d.year}"; -} diff --git a/lib/scheduler/scheduler_members_screen.dart b/lib/scheduler/scheduler_members_screen.dart deleted file mode 100644 index 65021343..00000000 --- a/lib/scheduler/scheduler_members_screen.dart +++ /dev/null @@ -1,343 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/scheduler_repository.dart'; -import 'models/scheduler_member.dart'; - -/// Members list with owner-only approve/remove controls and club-role -/// assignment (student → instructor). -/// -/// Can be used as a top-level screen or embedded inside a TabBarView via the -/// [embedded] flag. -class SchedulerMembersScreen extends StatelessWidget { - final String groupId; - final bool isOwner; - final bool embedded; - - const SchedulerMembersScreen({ - super.key, - required this.groupId, - required this.isOwner, - this.embedded = false, - }); - - @override - Widget build(BuildContext context) { - final body = StreamBuilder>( - stream: SchedulerRepository.instance.watchMembers(groupId), - builder: (context, allSnap) { - if (allSnap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (allSnap.hasError) { - final err = allSnap.error.toString().toLowerCase(); - final isPrivate = err.contains("permission"); - return Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: Text( - isPrivate - ? "Member list is private. Join this scheduler to see who else is here." - : "Couldn't load members: ${allSnap.error}", - textAlign: TextAlign.center, - ), - ), - ); - } - final all = allSnap.data ?? const []; - final active = all.where((m) => m.isActive).toList(); - final pending = all.where((m) => m.isPending).toList(); - final instructors = active.where((m) => m.isInstructor).toList(); - return ListView( - padding: const EdgeInsets.symmetric(vertical: 8), - children: [ - if (isOwner && pending.isNotEmpty) ...[ - _header(context, "Pending requests (${pending.length})"), - ...pending.map((m) => _memberTile( - context, - m, - instructors: instructors, - actions: [ - IconButton( - tooltip: "Approve", - icon: const Icon(Icons.check_circle, - color: Colors.green), - onPressed: () => _approve(context, m), - ), - IconButton( - tooltip: "Reject", - icon: const Icon(Icons.cancel, color: Colors.red), - onPressed: () => _remove(context, m), - ), - ], - )), - const Divider(), - ], - _header(context, "Members (${active.length})"), - if (active.isEmpty) - const Padding( - padding: EdgeInsets.all(24), - child: Center(child: Text("No members yet")), - ) - else - ...active.map( - (m) => _memberTile( - context, - m, - instructors: instructors, - actions: isOwner && !m.isOwner - ? [ - IconButton( - tooltip: "Club role", - icon: const Icon(Icons.badge_outlined), - onPressed: () => - _editClubRole(context, m, instructors), - ), - IconButton( - tooltip: "Remove", - icon: const Icon(Icons.person_remove, - color: Colors.red), - onPressed: () => _confirmRemove(context, m), - ), - ] - : (isOwner && m.isOwner - ? [ - IconButton( - tooltip: "Club role", - icon: const Icon(Icons.badge_outlined), - onPressed: () => - _editClubRole(context, m, instructors), - ), - ] - : null), - ), - ), - ], - ); - }, - ); - - if (embedded) return body; - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Members"), - ), - body: body, - ); - } - - Widget _header(BuildContext context, String label) { - return Padding( - padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), - child: Text( - label.toUpperCase(), - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.2, - color: Theme.of(context).colorScheme.primary, - ), - ), - ); - } - - Widget _memberTile( - BuildContext context, - SchedulerMember m, { - required List instructors, - List? actions, - }) { - final scheme = Theme.of(context).colorScheme; - return ListTile( - leading: CircleAvatar( - backgroundColor: scheme.primaryContainer, - child: Text( - m.displayName.isEmpty - ? "?" - : m.displayName.substring(0, 1).toUpperCase(), - style: TextStyle( - color: scheme.onPrimaryContainer, fontWeight: FontWeight.w600), - ), - ), - title: Row( - children: [ - Flexible( - child: Text( - m.displayName, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w600), - ), - ), - if (m.isOwner) - const Padding( - padding: EdgeInsets.only(left: 6), - child: Chip( - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - label: Text("Owner", style: TextStyle(fontSize: 10)), - ), - ), - Padding( - padding: const EdgeInsets.only(left: 6), - child: Chip( - visualDensity: VisualDensity.compact, - padding: EdgeInsets.zero, - label: Text(clubRoleLabel(m.clubRole), - style: const TextStyle(fontSize: 10)), - ), - ), - ], - ), - subtitle: Text( - [ - "joined ${m.joinedAt.toLocal().toString().split(' ').first}", - if (m.isStudent && m.assignedInstructorName != null) - "CFI: ${m.assignedInstructorName}", - ].join(" · "), - style: TextStyle(fontSize: 11, color: scheme.outline), - ), - trailing: actions == null - ? null - : Row(mainAxisSize: MainAxisSize.min, children: actions), - ); - } - - Future _editClubRole( - BuildContext context, - SchedulerMember m, - List instructors, - ) async { - ClubRole role = m.clubRole; - SchedulerMember? assigned = instructors - .cast() - .firstWhere( - (i) => i?.uid == m.assignedInstructorUid, - orElse: () => null, - ); - - final ok = await showDialog( - context: context, - builder: (ctx) { - return StatefulBuilder(builder: (ctx, setLocal) { - return AlertDialog( - title: Text("Role for ${m.displayName}"), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - DropdownButtonFormField( - initialValue: role, - decoration: const InputDecoration( - labelText: "Club role", - border: OutlineInputBorder(), - ), - items: [ - for (final r in ClubRole.values) - DropdownMenuItem( - value: r, child: Text(clubRoleLabel(r))), - ], - onChanged: (v) => setLocal(() => role = v ?? role), - ), - if (role == ClubRole.student) ...[ - const SizedBox(height: 12), - DropdownButtonFormField( - initialValue: assigned, - decoration: const InputDecoration( - labelText: "Assigned instructor", - border: OutlineInputBorder(), - ), - items: [ - const DropdownMenuItem( - value: null, child: Text("None")), - for (final i in instructors.where((x) => x.uid != m.uid)) - DropdownMenuItem( - value: i, child: Text(i.displayName)), - ], - onChanged: (v) => setLocal(() => assigned = v), - ), - ], - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton( - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Save")), - ], - ); - }); - }, - ); - if (ok != true || !context.mounted) return; - try { - await SchedulerRepository.instance.updateMemberClubRole( - groupId, - m.uid, - clubRole: role, - assignedInstructorUid: assigned?.uid, - assignedInstructorName: assigned?.displayName, - ); - if (context.mounted) { - Toast.showToast(context, "Updated ${m.displayName}", - const Icon(Icons.check, color: Colors.green), 2); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not update role: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - Future _approve(BuildContext context, SchedulerMember m) async { - try { - await SchedulerRepository.instance.approveMember(groupId, m.uid); - if (context.mounted) { - Toast.showToast(context, "Approved ${m.displayName}", - const Icon(Icons.check, color: Colors.green), 2); - } - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not approve: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - Future _remove(BuildContext context, SchedulerMember m) async { - try { - await SchedulerRepository.instance.removeMember(groupId, m.uid); - } catch (e) { - if (context.mounted) { - Toast.showToast(context, "Could not remove: $e", - const Icon(Icons.error, color: Colors.red), 4); - } - } - } - - Future _confirmRemove(BuildContext context, SchedulerMember m) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: Text("Remove ${m.displayName}?"), - content: const Text("They'll be removed from this scheduler."), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text("Cancel")), - FilledButton.tonal( - style: FilledButton.styleFrom(foregroundColor: Colors.red), - onPressed: () => Navigator.pop(ctx, true), - child: const Text("Remove"), - ), - ], - ), - ); - if (ok == true && context.mounted) { - await _remove(context, m); - } - } -} diff --git a/lib/scheduler/scheduler_screen.dart b/lib/scheduler/scheduler_screen.dart deleted file mode 100644 index 0a47997b..00000000 --- a/lib/scheduler/scheduler_screen.dart +++ /dev/null @@ -1,246 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; - -import '../constants.dart'; -import '../utils/toast.dart'; -import 'data/scheduler_repository.dart'; -import 'models/scheduler_group.dart'; -import 'scheduler_create_screen.dart'; -import 'scheduler_detail_screen.dart'; -import 'widgets/scheduler_card.dart'; - -/// Main Aircraft Scheduler landing screen: My Schedulers / Discover tabs. -class SchedulerScreen extends StatelessWidget { - const SchedulerScreen({super.key}); - - @override - Widget build(BuildContext context) { - return DefaultTabController( - length: 2, - child: Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: Row( - children: [ - Icon(MdiIcons.calendarClock, size: 24), - const SizedBox(width: 8), - const Text("Aircraft Scheduler"), - ], - ), - bottom: const TabBar( - tabs: [ - Tab(icon: Icon(Icons.calendar_month), text: "My Schedulers"), - Tab(icon: Icon(Icons.explore), text: "Discover"), - ], - ), - ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => const SchedulerCreateScreen()), - ); - }, - icon: const Icon(Icons.add), - label: const Text("New Scheduler"), - ), - body: const TabBarView( - children: [ - _MySchedulersTab(), - _DiscoverTab(), - ], - ), - ), - ); - } -} - -class _MySchedulersTab extends StatelessWidget { - const _MySchedulersTab(); - - @override - Widget build(BuildContext context) { - return StreamBuilder>( - stream: SchedulerRepository.instance.watchMyGroups(), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return _ErrorView(error: snap.error); - } - final groups = snap.data ?? const []; - if (groups.isEmpty) { - return const _EmptyState( - icon: Icons.calendar_today_outlined, - title: "No schedulers yet", - subtitle: - "Tap Discover to find a flying club, or New Scheduler to create one and add aircraft.", - ); - } - return ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: groups.length, - itemBuilder: (context, i) { - final g = groups[i]; - return SchedulerCard( - group: g, - onTap: () => _openGroup(context, g.id), - ); - }, - ); - }, - ); - } -} - -class _DiscoverTab extends StatefulWidget { - const _DiscoverTab(); - @override - State<_DiscoverTab> createState() => _DiscoverTabState(); -} - -class _DiscoverTabState extends State<_DiscoverTab> { - final _searchCtrl = TextEditingController(); - String _query = ""; - - @override - void dispose() { - _searchCtrl.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8), - child: TextField( - controller: _searchCtrl, - decoration: InputDecoration( - prefixIcon: const Icon(Icons.search), - suffixIcon: _query.isEmpty - ? null - : IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchCtrl.clear(); - setState(() => _query = ""); - }, - ), - hintText: "Search schedulers by name", - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - isDense: true, - ), - onChanged: (v) => setState(() => _query = v), - ), - ), - Expanded( - child: StreamBuilder>( - stream: - SchedulerRepository.instance.discoverGroups(query: _query), - builder: (context, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center(child: CircularProgressIndicator()); - } - if (snap.hasError) { - return _ErrorView(error: snap.error); - } - final groups = snap.data ?? const []; - if (groups.isEmpty) { - return _EmptyState( - icon: Icons.search_off, - title: _query.isEmpty - ? "Search for a scheduler" - : "No schedulers match \"$_query\"", - subtitle: _query.isEmpty - ? "Schedulers are private. Type a name to find one to join, or tap New Scheduler to create your own." - : "Try a different search, or tap New Scheduler to create your own.", - ); - } - return ListView.builder( - padding: const EdgeInsets.only(bottom: 80), - itemCount: groups.length, - itemBuilder: (context, i) => SchedulerCard( - group: groups[i], - onTap: () => _openGroup(context, groups[i].id), - ), - ); - }, - ), - ), - ], - ); - } -} - -void _openGroup(BuildContext context, String groupId) { - Navigator.push( - context, - MaterialPageRoute( - builder: (_) => SchedulerDetailScreen(groupId: groupId), - ), - ); -} - -class _EmptyState extends StatelessWidget { - final IconData icon; - final String title; - final String subtitle; - const _EmptyState({ - required this.icon, - required this.title, - required this.subtitle, - }); - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 48, color: scheme.outline), - const SizedBox(height: 12), - Text(title, - style: const TextStyle( - fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 6), - Text( - subtitle, - textAlign: TextAlign.center, - style: TextStyle(color: scheme.outline, fontSize: 13), - ), - ], - ), - ), - ); - } -} - -class _ErrorView extends StatelessWidget { - final Object? error; - const _ErrorView({required this.error}); - @override - Widget build(BuildContext context) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (context.mounted) { - Toast.showToast(context, "Scheduler error: $error", - const Icon(Icons.error, color: Colors.red), 4); - } - }); - return Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Text( - "Something went wrong loading the scheduler.\n$error", - textAlign: TextAlign.center, - ), - ), - ); - } -} diff --git a/lib/scheduler/widgets/schedule_grid.dart b/lib/scheduler/widgets/schedule_grid.dart deleted file mode 100644 index 99bb5fe8..00000000 --- a/lib/scheduler/widgets/schedule_grid.dart +++ /dev/null @@ -1,405 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; - -import '../models/reservation.dart'; -import '../models/schedulable_resource.dart'; - -/// A day timeline grid: resources are stacked vertically (rows), time runs -/// horizontally (columns of hours). -/// -/// Colour key (per product spec): -/// * green = available (free) slot on an in-service resource -/// * blue = booked (a reservation occupies the slot) -/// * red = unavailable (resource is out of service) -class ScheduleGrid extends StatefulWidget { - final DateTime day; - final List resources; - final List reservations; - final String? currentUid; - - /// Called when a member taps an empty (green) part of an available - /// resource row. [startHour] is the whole hour that was tapped. - final void Function(SchedulableResource resource, DateTime startHour) - onTapEmpty; - - /// Called when a reservation block is tapped. - final void Function(Reservation reservation) onTapReservation; - - /// Called when a resource's left-hand label is tapped (owner management). - final void Function(SchedulableResource resource)? onTapResource; - - const ScheduleGrid({ - super.key, - required this.day, - required this.resources, - required this.reservations, - required this.currentUid, - required this.onTapEmpty, - required this.onTapReservation, - this.onTapResource, - }); - - static const double labelWidth = 120; - static const double rowHeight = 64; - static const double hourWidth = 64; - static const double headerHeight = 28; - static const int startHour = 0; - static const int endHour = 24; - - @override - State createState() => _ScheduleGridState(); -} - -class _ScheduleGridState extends State { - final ScrollController _headerH = ScrollController(); - final ScrollController _bodyH = ScrollController(); - bool _syncing = false; - - @override - void initState() { - super.initState(); - _headerH.addListener(() => _sync(_headerH, _bodyH)); - _bodyH.addListener(() => _sync(_bodyH, _headerH)); - } - - // Keep the header and body horizontal scroll positions locked together. - void _sync(ScrollController from, ScrollController to) { - if (_syncing) return; - if (!to.hasClients || !from.hasClients) return; - if (to.offset == from.offset) return; - _syncing = true; - to.jumpTo(from.offset.clamp( - to.position.minScrollExtent, - to.position.maxScrollExtent, - )); - _syncing = false; - } - - @override - void dispose() { - _headerH.dispose(); - _bodyH.dispose(); - super.dispose(); - } - - int get _hours => ScheduleGrid.endHour - ScheduleGrid.startHour; - double get _totalWidth => _hours * ScheduleGrid.hourWidth; - DateTime get _dayStart => - DateTime(widget.day.year, widget.day.month, widget.day.day); - - String _hourLabel(int hour) { - final h = hour % 24; - if (h == 0) return "12a"; - if (h == 12) return "12p"; - if (h < 12) return "${h}a"; - return "${h - 12}p"; - } - - @override - Widget build(BuildContext context) { - if (widget.resources.isEmpty) { - return const _GridEmptyState(); - } - final scheme = Theme.of(context).colorScheme; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ----- Header: corner + hour labels ----- - Row( - children: [ - Container( - width: ScheduleGrid.labelWidth, - height: ScheduleGrid.headerHeight, - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(left: 8), - child: Text( - "Resource", - style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.bold, - letterSpacing: 1.1, - color: scheme.primary, - ), - ), - ), - Expanded( - child: SingleChildScrollView( - controller: _headerH, - scrollDirection: Axis.horizontal, - child: SizedBox( - width: _totalWidth, - height: ScheduleGrid.headerHeight, - child: Row( - children: [ - for (int h = ScheduleGrid.startHour; - h < ScheduleGrid.endHour; - h++) - Container( - width: ScheduleGrid.hourWidth, - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(left: 4), - decoration: BoxDecoration( - border: Border( - left: BorderSide( - color: scheme.outlineVariant, - width: 0.5, - ), - ), - ), - child: Text( - _hourLabel(h), - style: TextStyle( - fontSize: 11, color: scheme.onSurfaceVariant), - ), - ), - ], - ), - ), - ), - ), - ], - ), - const Divider(height: 1), - // ----- Body: labels + timeline rows ----- - Expanded( - child: SingleChildScrollView( - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Column( - children: - widget.resources.map(_buildLabelCell).toList(), - ), - Expanded( - child: SingleChildScrollView( - controller: _bodyH, - scrollDirection: Axis.horizontal, - child: SizedBox( - width: _totalWidth, - child: Column( - children: - widget.resources.map(_buildTimelineRow).toList(), - ), - ), - ), - ), - ], - ), - ), - ), - ], - ); - } - - Widget _buildLabelCell(SchedulableResource resource) { - final scheme = Theme.of(context).colorScheme; - final cell = Container( - width: ScheduleGrid.labelWidth, - height: ScheduleGrid.rowHeight, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: scheme.outlineVariant, width: 0.5), - right: BorderSide(color: scheme.outlineVariant, width: 0.5), - ), - ), - child: Row( - children: [ - Icon( - resource.isAircraft ? MdiIcons.airplane : MdiIcons.accountTie, - size: 18, - color: resource.available ? scheme.primary : Colors.red, - ), - const SizedBox(width: 6), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - resource.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 12, fontWeight: FontWeight.w600), - ), - if (resource.identifier != null && - resource.identifier!.isNotEmpty) - Text( - resource.identifier!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: - TextStyle(fontSize: 10, color: scheme.onSurfaceVariant), - ), - if (!resource.available) - const Text( - "Unavailable", - style: TextStyle( - fontSize: 10, - color: Colors.red, - fontWeight: FontWeight.w600), - ), - ], - ), - ), - if (widget.onTapResource != null) - Icon(Icons.more_vert, size: 16, color: scheme.outline), - ], - ), - ); - if (widget.onTapResource == null) return cell; - return InkWell(onTap: () => widget.onTapResource!(resource), child: cell); - } - - Widget _buildTimelineRow(SchedulableResource resource) { - final scheme = Theme.of(context).colorScheme; - final available = resource.available; - final resvs = widget.reservations - .where((r) => r.resourceId == resource.id) - .toList(); - final mains = resvs.where((r) => r.isMain).toList(); - final backups = resvs.where((r) => r.isBackup).toList(); - - return SizedBox( - width: _totalWidth, - height: ScheduleGrid.rowHeight, - child: Stack( - children: [ - // Background hour cells (green available / red unavailable) with - // tap-to-book on free space. - Row( - children: [ - for (int h = ScheduleGrid.startHour; - h < ScheduleGrid.endHour; - h++) - GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: available - ? () => widget.onTapEmpty( - resource, _dayStart.add(Duration(hours: h))) - : null, - child: Container( - width: ScheduleGrid.hourWidth, - height: ScheduleGrid.rowHeight, - decoration: BoxDecoration( - color: available - ? Colors.green.withValues(alpha: 0.16) - : Colors.red.withValues(alpha: 0.28), - border: Border( - left: BorderSide( - color: scheme.outlineVariant, width: 0.5), - bottom: BorderSide( - color: scheme.outlineVariant, width: 0.5), - ), - ), - ), - ), - ], - ), - // Main reservations (blue). - for (final r in mains) _buildReservationBlock(r, isBackup: false), - // Backup reservations (lighter blue, bottom strip). - for (final r in backups) _buildReservationBlock(r, isBackup: true), - ], - ), - ); - } - - Widget _buildReservationBlock(Reservation r, {required bool isBackup}) { - final startOffsetHours = - r.start.difference(_dayStart).inMinutes / 60.0 - ScheduleGrid.startHour; - final durationHours = r.end.difference(r.start).inMinutes / 60.0; - - double left = startOffsetHours * ScheduleGrid.hourWidth; - double width = durationHours * ScheduleGrid.hourWidth; - // Clamp to the visible window. - if (left < 0) { - width += left; - left = 0; - } - if (width <= 0) return const SizedBox.shrink(); - if (left + width > _totalWidth) { - width = _totalWidth - left; - } - - final mine = - widget.currentUid != null && r.schedulerUid == widget.currentUid; - final color = - isBackup ? Colors.blue.withValues(alpha: 0.45) : Colors.blue.shade600; - - final block = Container( - width: width, - height: isBackup ? 18 : ScheduleGrid.rowHeight - 24, - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: color, - borderRadius: BorderRadius.circular(4), - border: Border.all( - color: mine ? Colors.white : Colors.blue.shade900, - width: mine ? 1.5 : 0.5, - ), - ), - child: ClipRect( - child: Row( - children: [ - if (isBackup) - const Icon(Icons.hourglass_bottom, size: 10, color: Colors.white), - Expanded( - child: Text( - isBackup - ? "Backup ${r.backupOrder}: ${r.schedulerName}" - : r.schedulerName, - maxLines: isBackup ? 1 : 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 10, - color: Colors.white, - fontWeight: FontWeight.w600), - ), - ), - ], - ), - ), - ); - - return Positioned( - left: left, - top: isBackup ? ScheduleGrid.rowHeight - 22 : 4, - child: GestureDetector( - onTap: () => widget.onTapReservation(r), - child: block, - ), - ); - } -} - -class _GridEmptyState extends StatelessWidget { - const _GridEmptyState(); - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(MdiIcons.airplaneOff, size: 48, color: scheme.outline), - const SizedBox(height: 12), - const Text("No resources yet", - style: - TextStyle(fontSize: 16, fontWeight: FontWeight.w600)), - const SizedBox(height: 6), - Text( - "The scheduler owner can add aircraft and instructors to book.", - textAlign: TextAlign.center, - style: TextStyle(color: scheme.outline, fontSize: 13), - ), - ], - ), - ), - ); - } -} diff --git a/lib/scheduler/widgets/scheduler_card.dart b/lib/scheduler/widgets/scheduler_card.dart deleted file mode 100644 index 421fe645..00000000 --- a/lib/scheduler/widgets/scheduler_card.dart +++ /dev/null @@ -1,137 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; - -import '../models/scheduler_group.dart'; - -class SchedulerCard extends StatelessWidget { - final SchedulerGroup group; - final VoidCallback onTap; - final Widget? trailing; - - const SchedulerCard({ - super.key, - required this.group, - required this.onTap, - this.trailing, - }); - - @override - Widget build(BuildContext context) { - final scheme = Theme.of(context).colorScheme; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: scheme.primaryContainer, - borderRadius: BorderRadius.circular(10), - ), - child: Icon( - MdiIcons.calendarClock, - color: scheme.onPrimaryContainer, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - group.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - overflow: TextOverflow.ellipsis, - ), - ), - if (group.isPrivate) - Padding( - padding: const EdgeInsets.only(left: 6), - child: Icon(Icons.lock_outline, - size: 14, color: scheme.outline), - ), - ], - ), - if (group.description.isNotEmpty) ...[ - const SizedBox(height: 4), - Text( - group.description, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 12, - color: scheme.onSurfaceVariant, - ), - ), - ], - const SizedBox(height: 6), - Wrap( - spacing: 8, - runSpacing: 4, - children: [ - _chip( - context, - icon: Icons.people_outline, - label: - "${group.memberCount} member${group.memberCount == 1 ? '' : 's'}", - ), - if (group.homeAirport != null && - group.homeAirport!.isNotEmpty) - _chip(context, - icon: MdiIcons.airport, - label: group.homeAirport!), - _chip( - context, - icon: MdiIcons.airplane, - label: - "${group.resourceCount} resource${group.resourceCount == 1 ? '' : 's'}", - ), - ], - ), - ], - ), - ), - if (trailing != null) ...[ - const SizedBox(width: 8), - trailing!, - ], - ], - ), - ), - ), - ); - } - - Widget _chip(BuildContext context, - {required IconData icon, required String label}) { - final scheme = Theme.of(context).colorScheme; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: scheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(20), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 12, color: scheme.outline), - const SizedBox(width: 4), - Text(label, - style: TextStyle(fontSize: 11, color: scheme.onSurfaceVariant)), - ], - ), - ); - } -} diff --git a/lib/scheduler/widgets/scheduler_join_leave_button.dart b/lib/scheduler/widgets/scheduler_join_leave_button.dart deleted file mode 100644 index 229b4201..00000000 --- a/lib/scheduler/widgets/scheduler_join_leave_button.dart +++ /dev/null @@ -1,102 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../data/scheduler_repository.dart'; -import '../models/scheduler_group.dart'; -import '../models/scheduler_member.dart'; - -/// Smart button that switches between Join / Requested / Leave / Owner-only -/// based on the user's current membership in a scheduler group. -class SchedulerJoinLeaveButton extends StatefulWidget { - final SchedulerGroup group; - final SchedulerMember? membership; - final void Function(String message)? onMessage; - - const SchedulerJoinLeaveButton({ - super.key, - required this.group, - required this.membership, - this.onMessage, - }); - - @override - State createState() => - _SchedulerJoinLeaveButtonState(); -} - -class _SchedulerJoinLeaveButtonState extends State { - bool _busy = false; - - void _say(String m) { - if (!mounted) return; - final cb = widget.onMessage; - if (cb != null) cb(m); - } - - Future _join() async { - setState(() => _busy = true); - try { - final status = - await SchedulerRepository.instance.joinGroup(widget.group.id); - _say(status == SchedulerMemberStatus.pending - ? "Request sent. Waiting for owner approval." - : "Joined ${widget.group.name}"); - } catch (e) { - _say("Could not join: $e"); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - Future _leave() async { - setState(() => _busy = true); - try { - await SchedulerRepository.instance.leaveGroup(widget.group.id); - _say("Left ${widget.group.name}"); - } catch (e) { - _say("Could not leave: $e"); - } finally { - if (mounted) setState(() => _busy = false); - } - } - - @override - Widget build(BuildContext context) { - if (_busy) { - return const SizedBox( - width: 28, - height: 28, - child: Padding( - padding: EdgeInsets.all(4), - child: CircularProgressIndicator(strokeWidth: 2), - ), - ); - } - - final m = widget.membership; - if (m == null) { - return FilledButton.icon( - onPressed: _join, - icon: Icon(widget.group.isPrivate ? Icons.lock_outline : Icons.add), - label: Text(widget.group.isPrivate ? "Request to Join" : "Join"), - ); - } - if (m.isOwner) { - return const Chip( - avatar: Icon(Icons.star, size: 16), - label: Text("Owner"), - ); - } - if (m.isPending) { - return OutlinedButton.icon( - onPressed: _leave, - icon: const Icon(Icons.hourglass_empty), - label: const Text("Requested"), - ); - } - return OutlinedButton.icon( - onPressed: _leave, - icon: const Icon(Icons.logout), - label: const Text("Leave"), - ); - } -} diff --git a/lib/services/backup_screen.dart b/lib/services/backup_screen.dart deleted file mode 100644 index a53a87ce..00000000 --- a/lib/services/backup_screen.dart +++ /dev/null @@ -1,491 +0,0 @@ -import 'package:avaremp/constants.dart'; -import 'package:avaremp/data/user_database_helper.dart'; -import 'package:avaremp/utils/toast.dart'; -import 'package:firebase_auth/firebase_auth.dart' hide EmailAuthProvider; -import 'package:firebase_storage/firebase_storage.dart'; -import 'package:flutter/material.dart'; -import 'package:universal_io/universal_io.dart'; - -class BackupScreen extends StatefulWidget { - const BackupScreen({super.key}); - - @override - BackupScreenState createState() => BackupScreenState(); -} - -class BackupScreenState extends State { - - final storageRef = FirebaseStorage.instance.ref(); - String _status = ""; - double _progress = 0; - bool _isUploading = false; - bool _isDownloading = false; - bool _confirmingBackup = false; - bool _confirmingRestore = false; - - static final String dbRefUserDb = "user.db"; - - Map files = { - dbRefUserDb: null, - }; - - static String getPath(String key) { - final storageRef = FirebaseStorage.instance.ref(); - final dbRef = storageRef.child("users/").child(FirebaseAuth.instance.currentUser!.uid).child(key); - final bucket = FirebaseStorage.instance.app.options.storageBucket; - final fullPath = dbRef.fullPath; - return 'gs://$bucket/$fullPath'; - } - - static Future> getFileList() async { - List fileList = []; - final storageRef = FirebaseStorage.instance.ref(); - final dbRef = storageRef.child("users/").child(FirebaseAuth.instance.currentUser!.uid); - final bucket = FirebaseStorage.instance.app.options.storageBucket; - await dbRef.listAll().then((listResult) { - for (var item in listResult.items) { - final fullPath = item.fullPath; - fileList.add('gs://$bucket/$fullPath'); - } - }); - return fileList; - } - - @override - void initState() { - super.initState(); - for (var key in files.keys) { - files[key] = storageRef.child("users/").child(FirebaseAuth.instance.currentUser!.uid).child(key); - } - } - - void _setStatus(TaskSnapshot snapshot) { - switch (snapshot.state) { - case TaskState.running: - if (snapshot.bytesTransferred > 0 && snapshot.totalBytes > 0) { - _progress = snapshot.bytesTransferred / snapshot.totalBytes; - _status = "${(_progress * 100).round()}%"; - } - break; - case TaskState.success: - Toast.showToast(context, "Operation Completed", const Icon(Icons.check_circle, color: Colors.green), 3); - _status = ""; - _progress = 0; - _isUploading = false; - _isDownloading = false; - break; - case TaskState.paused: - break; - case TaskState.canceled: - case TaskState.error: - Toast.showToast(context, "Operation Failed", const Icon(Icons.error, color: Colors.red), 3); - _status = ""; - _progress = 0; - _isUploading = false; - _isDownloading = false; - break; - } - } - - bool get _isBusy => _isUploading || _isDownloading; - - @override - Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Row( - children: [ - Icon(Icons.cloud_sync, size: 24), - SizedBox(width: 8), - Text("Backup & Sync"), - ], - ), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primaryContainer, - borderRadius: BorderRadius.circular(12), - ), - child: Icon(Icons.cloud_done, size: 32, color: Theme.of(context).colorScheme.onPrimaryContainer), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - "Cloud Storage", - style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold), - ), - const SizedBox(height: 4), - Text( - "Keep your data safe and synced across devices", - style: TextStyle(fontSize: 13, color: Theme.of(context).colorScheme.outline), - ), - ], - ), - ), - ], - ), - ), - ), - - const SizedBox(height: 16), - - if (_isBusy) ...[ - Card( - color: Theme.of(context).colorScheme.primaryContainer.withAlpha(50), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - Row( - children: [ - SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - value: _progress > 0 ? _progress : null, - strokeWidth: 3, - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _isUploading ? "Uploading..." : "Downloading...", - style: const TextStyle(fontWeight: FontWeight.w600), - ), - if (_status.isNotEmpty) - Text(_status, style: TextStyle(color: Theme.of(context).colorScheme.outline)), - ], - ), - ), - ], - ), - if (_progress > 0) ...[ - const SizedBox(height: 12), - LinearProgressIndicator(value: _progress, borderRadius: BorderRadius.circular(4)), - ], - ], - ), - ), - ), - const SizedBox(height: 16), - ], - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - child: Text( - "ACTIONS", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.primary, - letterSpacing: 1, - ), - ), - ), - - _buildBackupCard(), - - const SizedBox(height: 8), - - _buildRestoreCard(), - - const SizedBox(height: 24), - - Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - child: Text( - "WHAT GETS BACKED UP", - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Theme.of(context).colorScheme.primary, - letterSpacing: 1, - ), - ), - ), - - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - children: [ - _backupItem(Icons.flight, "Aircraft profiles"), - const SizedBox(height: 12), - _backupItem(Icons.book, "Logbook entries"), - const SizedBox(height: 12), - _backupItem(Icons.checklist, "Checklists"), - const SizedBox(height: 12), - _backupItem(Icons.balance, "Weight & balance data"), - const SizedBox(height: 12), - _backupItem(Icons.route, "Plans"), - const SizedBox(height: 12), - _backupItem(Icons.settings, "Settings"), - const SizedBox(height: 12), - _backupItem(Icons.history, "AI chat history"), - ], - ), - ), - ), - - const SizedBox(height: 16), - - Card( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - child: Padding( - padding: const EdgeInsets.all(12), - child: Row( - children: [ - Icon(Icons.info_outline, size: 20, color: Theme.of(context).colorScheme.outline), - const SizedBox(width: 12), - Expanded( - child: Text( - "Your data is securely stored in the cloud and linked to your account.", - style: TextStyle(fontSize: 12, color: Theme.of(context).colorScheme.outline), - ), - ), - ], - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildBackupCard() { - return Card( - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - ListTile( - enabled: !_isBusy && !_confirmingRestore, - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.blue.withAlpha(30), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.cloud_upload, color: Colors.blue), - ), - title: const Text("Backup to Cloud", style: TextStyle(fontWeight: FontWeight.w600)), - subtitle: const Text("Upload your local data to secure cloud storage"), - trailing: _isUploading - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) - : Icon(_confirmingBackup ? Icons.expand_less : Icons.chevron_right), - onTap: !_isBusy && !_confirmingRestore ? () { - setState(() { - _confirmingBackup = !_confirmingBackup; - _confirmingRestore = false; - }); - } : null, - ), - if (_confirmingBackup) - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - color: Colors.blue.withAlpha(15), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - Row( - children: [ - Icon(Icons.warning_amber, size: 18, color: Colors.orange[700]), - const SizedBox(width: 8), - const Expanded( - child: Text( - "This will overwrite any existing cloud backup.", - style: TextStyle(fontSize: 13), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => setState(() => _confirmingBackup = false), - child: const Text("Cancel"), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: () { - setState(() => _confirmingBackup = false); - _performBackup(); - }, - icon: const Icon(Icons.cloud_upload, size: 18), - label: const Text("Backup Now"), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildRestoreCard() { - return Card( - clipBehavior: Clip.antiAlias, - child: Column( - children: [ - ListTile( - enabled: !_isBusy && !_confirmingBackup, - leading: Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.green.withAlpha(30), - borderRadius: BorderRadius.circular(8), - ), - child: const Icon(Icons.cloud_download, color: Colors.green), - ), - title: const Text("Restore from Cloud", style: TextStyle(fontWeight: FontWeight.w600)), - subtitle: const Text("Download and restore your data from cloud backup"), - trailing: _isDownloading - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2)) - : Icon(_confirmingRestore ? Icons.expand_less : Icons.chevron_right), - onTap: !_isBusy && !_confirmingBackup ? () { - setState(() { - _confirmingRestore = !_confirmingRestore; - _confirmingBackup = false; - }); - } : null, - ), - if (_confirmingRestore) - Container( - width: double.infinity, - padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), - color: Colors.orange.withAlpha(15), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - Row( - children: [ - Icon(Icons.warning_amber, size: 18, color: Colors.orange[700]), - const SizedBox(width: 8), - const Expanded( - child: Text( - "This will overwrite your local data. Any changes not backed up will be lost.", - style: TextStyle(fontSize: 13), - ), - ), - ], - ), - const SizedBox(height: 12), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - TextButton( - onPressed: () => setState(() => _confirmingRestore = false), - child: const Text("Cancel"), - ), - const SizedBox(width: 8), - FilledButton.icon( - onPressed: () { - setState(() => _confirmingRestore = false); - _performRestore(); - }, - icon: const Icon(Icons.cloud_download, size: 18), - label: const Text("Restore Now"), - ), - ], - ), - ], - ), - ), - ], - ), - ); - } - - Widget _backupItem(IconData icon, String label) { - return Row( - children: [ - Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary), - const SizedBox(width: 12), - Text(label), - const Spacer(), - Icon(Icons.check_circle, size: 18, color: Colors.green.withAlpha(180)), - ], - ); - } - - Future _performBackup() async { - final dbFile = File(UserDatabaseHelper.getPath()); - try { - setState(() { - _isUploading = true; - _progress = 0; - }); - - files[dbRefUserDb]!.putFile(dbFile).snapshotEvents.listen((taskSnapshot) { - if (mounted) { - setState(() { - _setStatus(taskSnapshot); - }); - } - }); - } catch (e) { - setState(() { - _isUploading = false; - _progress = 0; - }); - if (mounted) { - Toast.showToast(context, "Backup Failed", const Icon(Icons.error, color: Colors.red), 3); - } - } - } - - Future _performRestore() async { - final dbFile = File(UserDatabaseHelper.getPath()); - try { - await UserDatabaseHelper.invalidateConnection(); - setState(() { - _isDownloading = true; - _progress = 0; - }); - - files[dbRefUserDb]!.writeToFile(dbFile).snapshotEvents.listen((taskSnapshot) async { - if (taskSnapshot.state == TaskState.success && _isDownloading) { - await UserDatabaseHelper.invalidateConnection(); - } - if (mounted) { - setState(() { - _setStatus(taskSnapshot); - }); - } - }); - } catch (e) { - setState(() { - _isDownloading = false; - _progress = 0; - }); - if (mounted) { - Toast.showToast(context, "Restore Failed", const Icon(Icons.error, color: Colors.red), 3); - } - } - } -} diff --git a/lib/services/login_screen.dart b/lib/services/login_screen.dart deleted file mode 100644 index 8dd4f072..00000000 --- a/lib/services/login_screen.dart +++ /dev/null @@ -1,166 +0,0 @@ -import 'package:avaremp/community/notifications_screen.dart'; -import 'package:avaremp/constants.dart'; -import 'package:avaremp/services/revenue_cat.dart'; -import 'package:avaremp/storage.dart'; -import 'package:avaremp/utils/toast.dart'; -import 'package:firebase_auth/firebase_auth.dart' hide EmailAuthProvider; -import 'package:firebase_ui_auth/firebase_ui_auth.dart'; -import 'package:flutter/material.dart'; - -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); - - @override - LoginScreenState createState() => LoginScreenState(); -} - -class LoginScreenState extends State { - - bool isLoggedIn = FirebaseAuth.instance.currentUser != null; - @override - void initState() { - super.initState(); - // add listener for auth state change - FirebaseAuth.instance.authStateChanges().listen((User? user) { - isLoggedIn = user != null; - }); - } - - static void showPaywall(BuildContext context, String route) async { - showPaywallThen(context, (ctx) => Navigator.pushNamed(ctx, route)); - } - - /// Sign-in-only gate (no Pro entitlement required). Runs [onSignedIn] when - /// the user is authenticated, otherwise sends them to the sign-in screen. - /// Used by features that are free but still need an accountable identity - /// (e.g. Airport Businesses & Reviews). - static void requireSignInThen( - BuildContext context, void Function(BuildContext context) onSignedIn) { - if (FirebaseAuth.instance.currentUser == null) { - Navigator.pushNamed(context, "/pro"); - } else { - onSignedIn(context); - } - } - - /// Like [showPaywall] but runs [onEntitled] once the user is signed in and - /// has an active Pro entitlement, instead of navigating to a fixed named - /// route. Used by features (e.g. Airport Businesses) that need to open a - /// screen with runtime arguments. - static void showPaywallThen( - BuildContext context, void Function(BuildContext context) onEntitled) async { - if(FirebaseAuth.instance.currentUser == null) { - Navigator.pushNamed(context, "/pro"); - } - else { - try { - RevenueCatService.presentPaywallIfNeeded().then((entitled) { - if (context.mounted) { - if (entitled) { - onEntitled(context); - } - else { - Toast.showToast( - context, "Please subscribe before proceeding. Thank you.", - Icon(Icons.info, color: Colors.red), 3); - } - } - }); - } - catch (e) { - Storage().setException("Unable to initialize Pro Services: $e"); - } - } - } - - @override - Widget build(BuildContext context) { - final providers = [EmailAuthProvider()]; - - final user = FirebaseAuth.instance.currentUser; - if(user != null) { - RevenueCatService.logIn( - user.uid, - email: user.email, - displayName: user.displayName, - ); - } - - return Scaffold( - appBar: AppBar( - backgroundColor: Constants.appBarBackgroundColor, - title: const Text("Pro Services"), - ), - bottomSheet: SizedBox( - height: 58, - child: isLoggedIn ? SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - TextButton( - child: const Text("Flight Intelligence"), - onPressed: () { - // Offerings and purchase options - showPaywall(context, '/ai'); - }, - ), - TextButton( - child: const Text("Backup/Sync"), - onPressed: () { - showPaywall(context, '/backup'); - }, - ), - // Community entry carries a notifications bell on top of the - // label so unread replies are visible from the login screen. - Stack( - clipBehavior: Clip.none, - alignment: Alignment.topCenter, - children: [ - Padding( - padding: const EdgeInsets.only(top: 14), - child: TextButton( - child: const Text("Community"), - onPressed: () { - showPaywall(context, '/community'); - }, - ), - ), - const Positioned( - top: 0, - child: CommunityNotificationsBadge(), - ), - ], - ), - TextButton( - child: const Text("Scheduler"), - onPressed: () { - showPaywall(context, '/scheduler'); - }, - ), - ], - )) : Padding(padding: EdgeInsets.all(10), child:Text("Please register/sign in to access Pro Services")), - ), - body: isLoggedIn ? - ProfileScreen( - providers: providers, - actions: [ - SignedOutAction((context) { - setState(() {}); - }), - ], - ) : - SignInScreen( - providers: providers, - actions: [ - AuthStateChangeAction((context, state) { - setState(() {}); - }), - AuthStateChangeAction((context, state) { - setState(() {}); - }), - ], - ) - ); - } -} diff --git a/lib/services/revenue_cat.dart b/lib/services/revenue_cat.dart deleted file mode 100644 index bb033764..00000000 --- a/lib/services/revenue_cat.dart +++ /dev/null @@ -1,78 +0,0 @@ -import 'package:purchases_flutter/purchases_flutter.dart'; -import 'package:purchases_ui_flutter/purchases_ui_flutter.dart'; -import 'package:universal_io/io.dart'; - - -class RevenueCatService { - static String iosKey = "@@___revenuecat_ios_api_key__@@"; - static String androidKey = "@@___revenuecat_android_api_key__@@"; - static String entitlementId = "Pro"; - static Future initPlatformState() async { - await Purchases.setLogLevel(LogLevel.debug); - - PurchasesConfiguration configuration; - if (Platform.isAndroid) { - configuration = PurchasesConfiguration(androidKey); - await Purchases.configure(configuration); - } else if (Platform.isIOS) { - configuration = PurchasesConfiguration(iosKey); - await Purchases.configure(configuration); - } - } - - static Future getCustomerInfo() async { - final CustomerInfo info = await Purchases.getCustomerInfo(); - return info; - } - - static Future logOut() async { - await Purchases.logOut(); - } - - static Future logIn(String userId, {String? email, String? displayName}) async { - bool loggedIn = false; - try { - await Purchases.logIn(userId); - if (email != null && email.isNotEmpty) { - await Purchases.setEmail(email); - } - if (displayName != null && displayName.isNotEmpty) { - await Purchases.setDisplayName(displayName); - } - loggedIn = true; - } - catch(e) {// ignore - loggedIn = false; - } - return loggedIn; - } - - static Future presentPaywallIfNeeded() async { - bool entitled = false; - try { - final Offerings offerings = await Purchases.getOfferings(); - final paywallResult = await RevenueCatUI.presentPaywallIfNeeded( - entitlementId, offering: offerings.current); - // Handle result if needed. - switch (paywallResult) { - case PaywallResult.restored: - case PaywallResult.notPresented: - case PaywallResult.purchased: - case PaywallResult.cancelled: - case PaywallResult.error: - break; - } - await RevenueCatService.getCustomerInfo().then((customerInfo) { - if (customerInfo.entitlements.all[entitlementId] != null && - customerInfo.entitlements.all[entitlementId]!.isActive) { - entitled = true; - } - }); - } - catch(e) { - return false; - } - - return entitled; - } -} \ No newline at end of file diff --git a/lib/storage.dart b/lib/storage.dart index 7b009f64..a578d92b 100644 --- a/lib/storage.dart +++ b/lib/storage.dart @@ -9,7 +9,6 @@ import 'dart:ui' as ui; // put all singletons here. import 'package:avaremp/aircraft/aircraft.dart'; -import 'package:avaremp/business/models/airport_business.dart'; import 'package:avaremp/utils/app_log.dart'; import 'package:avaremp/place/area.dart'; import 'package:avaremp/data/main_database_helper.dart'; @@ -85,7 +84,6 @@ class Storage { final rubberBandChange = ValueNotifier(0); // when route is changed via rubber band, for testing with GPS final warningChange = ValueNotifier(false); final flightStatus = FlightStatus(); - AirportBusiness? business; // currently selected business on the plate diagram late WindsCache winds; late MetarCache metar; late TafCache taf; diff --git a/lib/utils/map_controller_guard.dart b/lib/utils/map_controller_guard.dart new file mode 100644 index 00000000..0f7d6f45 --- /dev/null +++ b/lib/utils/map_controller_guard.dart @@ -0,0 +1,10 @@ +import 'package:flutter_map/flutter_map.dart'; + +class MapControllerGuard { + MapControllerGuard._(); + + static MapCamera? cameraIfReady(MapController controller, bool ready) { + if (!ready) return null; + return controller.camera; + } +} diff --git a/lib/utils/mbtiles_layer.dart b/lib/utils/mbtiles_layer.dart index 5badee42..11109d5e 100644 --- a/lib/utils/mbtiles_layer.dart +++ b/lib/utils/mbtiles_layer.dart @@ -511,7 +511,13 @@ class MbTilesVectorTileProvider extends VectorTileProvider { class MBTilesRasterTileProvider extends TileProvider { final MbTiles mbtiles; - static const AssetImage assetImage = AssetImage("assets/images/512.png"); + static final MemoryImage transparentImage = MemoryImage(Uint8List.fromList(const [ + 137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, + 0, 0, 0, 1, 0, 0, 0, 1, 8, 6, 0, 0, 0, 31, 21, 196, 137, + 0, 0, 0, 13, 73, 68, 65, 84, 8, 215, 99, 96, 96, 96, 0, 0, + 0, 5, 0, 1, 94, 242, 106, 36, 0, 0, 0, 0, 73, 69, 78, 68, + 174, 66, 96, 130, + ])); MBTilesRasterTileProvider(this.mbtiles); @@ -527,7 +533,7 @@ class MBTilesRasterTileProvider extends TileProvider { ); if (data == null) { - return assetImage; + return transparentImage; } return MemoryImage(data); diff --git a/lib/utils/pdf_viewer.dart b/lib/utils/pdf_viewer.dart index d9340357..3a8d00a8 100644 --- a/lib/utils/pdf_viewer.dart +++ b/lib/utils/pdf_viewer.dart @@ -10,8 +10,10 @@ import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; class PdfViewer extends StatefulWidget { final String url; + final String? title; + final String? notice; - const PdfViewer(this.url, {super.key}); + const PdfViewer(this.url, {this.title, this.notice, super.key}); @override State createState() => PdfViewerState(); @@ -91,7 +93,7 @@ class PdfViewerState extends State { ) : AppBar( title: Text( - PathUtils.filename(widget.url), + widget.title ?? PathUtils.filename(widget.url), ), leading: const BackButton(), actions: [ @@ -117,6 +119,19 @@ class PdfViewerState extends State { controller: _pdfViewerController, canShowScrollHead: _showScrollHead, ), + if (widget.notice != null) + Positioned( + left: 8, + right: 8, + bottom: 8, + child: IgnorePointer( + child: Container( + padding: const EdgeInsets.all(8), + color: Colors.black.withAlpha(180), + child: Text(widget.notice!, textAlign: TextAlign.center, style: const TextStyle(color: Colors.white)), + ), + ), + ), Visibility( visible: _textSearchKey.currentState?._showToast ?? false, child: Align( diff --git a/lib/utils/toast.dart b/lib/utils/toast.dart index 7c9a6974..147057f3 100644 --- a/lib/utils/toast.dart +++ b/lib/utils/toast.dart @@ -16,7 +16,7 @@ class Toast { closeButton: ToastCloseButton(showType: CloseButtonShowType.none), description: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(text, style: TextStyle(fontWeight: FontWeight.w500),), - if(Constants.shouldShowProServices && translate) + if(Constants.shouldShowAi && translate) TextButton(onPressed:() { Toastification().dismissAll(); // put in database for AI query history to pick up, go to pro screen diff --git a/lib/weather/decoded_metar_view.dart b/lib/weather/decoded_metar_view.dart new file mode 100644 index 00000000..30f5482e --- /dev/null +++ b/lib/weather/decoded_metar_view.dart @@ -0,0 +1,120 @@ +// A decoded-METAR card with a VFR/IFR profile toggle and per-element threat +// coloring. Drop-in widget used on airport detail screens to complement the +// raw METAR text and flight-category badge. Uses only the raw METAR the app +// already holds (see MetarDecoder); no network calls. + +import 'package:flutter/material.dart'; + +import '../storage.dart'; +import 'metar.dart'; +import 'metar_decoder.dart'; + +class DecodedMetarView extends StatefulWidget { + final Metar metar; + + const DecodedMetarView({super.key, required this.metar}); + + @override + State createState() => _DecodedMetarViewState(); +} + +class _DecodedMetarViewState extends State { + late WxProfile _profile; + + @override + void initState() { + super.initState(); + _profile = _readProfile(); + } + + // Read the persisted profile defensively: in contexts where Storage/settings + // are not initialized (e.g. widget tests) fall back to IFR. + WxProfile _readProfile() { + try { + return Storage().settings.getWeatherProfile() == 'VFR' + ? WxProfile.vfr + : WxProfile.ifr; + } catch (_) { + return WxProfile.ifr; + } + } + + void _setProfile(WxProfile p) { + setState(() => _profile = p); + try { + Storage().settings.setWeatherProfile(p.label); + } catch (_) { + // Persistence unavailable (e.g. tests); selection still applies for the + // current view. + } + } + + @override + Widget build(BuildContext context) { + final elements = MetarDecoder.decode(widget.metar.text, _profile); + + return Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Expanded( + child: Text('Decoded METAR', + style: TextStyle(fontWeight: FontWeight.bold)), + ), + // VFR / IFR threshold selector. + SegmentedButton( + segments: const [ + ButtonSegment(value: WxProfile.vfr, label: Text('VFR')), + ButtonSegment(value: WxProfile.ifr, label: Text('IFR')), + ], + selected: {_profile}, + showSelectedIcon: false, + onSelectionChanged: (s) => _setProfile(s.first), + ), + ], + ), + const SizedBox(height: 4), + Text( + 'Threat colors for ${_profile.label} operations. ' + 'Advisory only — not a substitute for an official briefing.', + style: Theme.of(context).textTheme.bodySmall, + ), + const Divider(), + for (final e in elements) + Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: const EdgeInsets.only(top: 5, right: 8), + width: 10, + height: 10, + decoration: BoxDecoration( + color: e.threat.color, + shape: BoxShape.circle, + ), + ), + SizedBox( + width: 116, + child: Text(e.label, + style: + const TextStyle(fontWeight: FontWeight.w600)), + ), + Expanded( + child: Text(e.value, + style: TextStyle(color: e.threat.color)), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/weather/flybrief_download_screen.dart b/lib/weather/flybrief_download_screen.dart new file mode 100644 index 00000000..80872c46 --- /dev/null +++ b/lib/weather/flybrief_download_screen.dart @@ -0,0 +1,136 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; + +import '../storage.dart'; +import 'flybrief_notams.dart'; +import 'flybrief_store.dart'; + +// Download and store per-country NOTAMs (and obstacles) from FlyBrief for +// offline use. Fills the European NOTAM gap where the built-in FAA source is +// empty. Defaults to the country under the current GPS position. +class FlybriefDownloadScreen extends StatefulWidget { + const FlybriefDownloadScreen({super.key}); + + @override + State createState() => _FlybriefDownloadScreenState(); +} + +class _FlybriefDownloadScreenState extends State { + FbCountry? _country; + bool _busy = false; + double _progress = 0; + String? _message; + + @override + void initState() { + super.initState(); + // Default to the country under the current position, else Germany. + LatLng where; + try { + where = LatLng(Storage().position.latitude, Storage().position.longitude); + } catch (_) { + where = const LatLng(50.03, 8.55); + } + _country = FlybriefNotams.forPoint(where.latitude, where.longitude) ?? + FlybriefNotams.byIso('DE'); + } + + Future _download() async { + final c = _country; + if (c == null) return; + setState(() { + _busy = true; + _progress = 0; + _message = 'Starting...'; + }); + final count = await FlybriefStore.downloadCountry(c, + onProgress: (p, m) { + if (mounted) setState(() { _progress = p; _message = m; }); + }); + if (mounted) { + setState(() { + _busy = false; + _message = count != null + ? 'Stored $count NOTAMs for ${c.path} (offline).' + : 'Download failed. Check your connection and try again.'; + }); + } + } + + Future _remove() async { + final c = _country; + if (c == null) return; + await FlybriefStore.removeCountry(c); + if (mounted) setState(() => _message = 'Removed offline data for ${c.path}.'); + } + + @override + Widget build(BuildContext context) { + final c = _country; + final bool offline = c != null && FlybriefStore.hasOffline(c); + return Scaffold( + appBar: AppBar(title: const Text('NOTAMs (FlyBrief)')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('European NOTAMs for offline use', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + const Text( + 'Downloads per-country, georeferenced NOTAMs so they are available ' + 'offline on the airport NOTAM tab. Used automatically where the ' + 'built-in (US) NOTAM source has no coverage.'), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: c?.iso2, + decoration: const InputDecoration(labelText: 'Country'), + items: FlybriefNotams.countries + .map((x) => DropdownMenuItem( + value: x.iso2, child: Text('${x.path} (${x.iso2})'))) + .toList(), + onChanged: _busy + ? null + : (v) => setState(() => + _country = v == null ? null : FlybriefNotams.byIso(v)), + ), + const SizedBox(height: 8), + if (offline) + Text('Offline data present for ${c.path}.', + style: TextStyle(color: Theme.of(context).colorScheme.primary)), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _download, + icon: const Icon(Icons.download), + label: const Text('Download for Offline'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _remove, + icon: const Icon(Icons.delete_outline), + label: const Text('Remove'), + ), + ], + ), + if (_busy) Padding( + padding: const EdgeInsets.only(top: 16), + child: LinearProgressIndicator(value: _progress > 0 ? _progress : null), + ), + if (_message != null) Padding( + padding: const EdgeInsets.only(top: 16), + child: Text(_message!), + ), + const SizedBox(height: 24), + Text(FlybriefNotams.attribution, + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text( + 'NOTAM data is community/AIS-sourced via FlyBrief and is advisory ' + 'only. Always confirm against the official national briefing ' + 'before flight.'), + ], + ), + ); + } +} diff --git a/lib/weather/flybrief_notams.dart b/lib/weather/flybrief_notams.dart new file mode 100644 index 00000000..c9946aa0 --- /dev/null +++ b/lib/weather/flybrief_notams.dart @@ -0,0 +1,297 @@ +// FlyBrief per-country NOTAM provider. +// +// AvareX's built-in NOTAM source is a US FAA API that returns nothing in +// Europe. FlyBrief (flybrief.app) publishes per-country, georeferenced NOTAM +// GeoJSON (polygons with altitudes, schedules, active-now flags) with no token, +// which fills the European NOTAM gap and can be stored for offline use. +// +// Data © OpenAIP contributors and national AIS, re-published by FlyBrief +// (CC BY-NC-SA 4.0). NOTAMs are advisory; always confirm against the official +// national briefing before flight. +// +// This file is PURE (no I/O) so it is unit-tested: country resolution, URL +// building, GeoJSON parsing, nearest filtering and one-line formatting. The +// network fetch and offline file storage live in flybrief_store.dart. + +import 'dart:convert'; +import 'dart:math' as math; + +// A supported FlyBrief country: URL path segment, file slug, and a bounding box +// used to pick the country from a GPS position. +class FbCountry { + final String iso2; + final String path; // e.g. 'Germany' in /Airspace/EU/Germany/ + final String slug; // e.g. 'germany' in germany_notams.geojson + final double minLat, maxLat, minLon, maxLon; + + const FbCountry(this.iso2, this.path, this.slug, this.minLat, this.maxLat, + this.minLon, this.maxLon); + + bool contains(double lat, double lon) => + lat >= minLat && lat <= maxLat && lon >= minLon && lon <= maxLon; +} + +// One parsed NOTAM with optional geometry for nearest filtering. +class FbNotam { + final String id; + final String category; + final String text; + final String raw; + final String? lower; // altitude lower (al) + final String? upper; // altitude upper (ah) + final String? start; + final String? end; + final String? schedule; + final bool perm; + final bool activeNow; + final bool highPriority; + final double? radiusNm; + // Representative point (centroid of geometry) for distance filtering; null + // when the NOTAM has no precise geometry. + final double? lat; + final double? lon; + + const FbNotam({ + required this.id, + required this.category, + required this.text, + required this.raw, + this.lower, + this.upper, + this.start, + this.end, + this.schedule, + this.perm = false, + this.activeNow = false, + this.highPriority = false, + this.radiusNm, + this.lat, + this.lon, + }); + + // A compact, pilot-readable one-line summary matching the app's NOTAM style. + String toLine() { + final bits = ['NOTAM $id']; + if (category.isNotEmpty) bits.add('[${category.toUpperCase()}]'); + if (activeNow) bits.add('(ACTIVE)'); + final header = bits.join(' '); + + final range = []; + if (perm) { + range.add('PERM'); + } else { + if (start != null && start!.isNotEmpty) range.add(_shortTime(start!)); + if (end != null && end!.isNotEmpty) range.add(_shortTime(end!)); + } + final alt = []; + if (lower != null && lower!.isNotEmpty) alt.add(lower!); + if (upper != null && upper!.isNotEmpty) alt.add(upper!); + + final parts = [header]; + if (range.isNotEmpty) parts.add(range.join('-')); + if (alt.isNotEmpty) parts.add(alt.join('/')); + if (schedule != null && schedule!.isNotEmpty) parts.add('SKED ${schedule!}'); + if (text.isNotEmpty) parts.add(text); + return parts.join(' | ').replaceAll(RegExp(r'\s+'), ' ').trim(); + } + + // Trims an ISO timestamp to "MM-DD HHmmZ" for compact display. + static String _shortTime(String iso) { + final t = DateTime.tryParse(iso); + if (t == null) return iso; + final u = t.toUtc(); + String two(int v) => v.toString().padLeft(2, '0'); + return '${two(u.month)}-${two(u.day)} ${two(u.hour)}${two(u.minute)}Z'; + } +} + +class FlybriefNotams { + FlybriefNotams._(); + + static const String host = 'flybrief.app'; + static const String attribution = + 'NOTAMs © OpenAIP contributors & national AIS via FlyBrief (CC BY-NC-SA 4.0)'; + + // Default search radius (nm) around a point when filtering NOTAMs. + static const double defaultRadiusNm = 50; + + static const List countries = [ + FbCountry('IE', 'Ireland', 'ireland', 51.2, 55.5, -10.6, -5.9), + FbCountry('FR', 'France', 'france', 41.3, 51.2, -5.2, 9.7), + FbCountry('ES', 'Spain', 'spain', 35.9, 43.9, -9.4, 4.4), + FbCountry('PT', 'Portugal', 'portugal', 36.9, 42.2, -9.6, -6.1), + FbCountry('BE', 'Belgium', 'belgium', 49.5, 51.6, 2.5, 6.4), + FbCountry('NL', 'Netherlands', 'netherlands', 50.7, 53.7, 3.3, 7.3), + FbCountry('DE', 'Germany', 'germany', 47.2, 55.1, 5.8, 15.1), + FbCountry('CH', 'Switzerland', 'switzerland', 45.8, 47.9, 5.9, 10.6), + FbCountry('AT', 'Austria', 'austria', 46.3, 49.1, 9.5, 17.2), + FbCountry('IT', 'Italy', 'italy', 35.4, 47.1, 6.6, 18.6), + FbCountry('SI', 'Slovenia', 'slovenia', 45.4, 46.9, 13.3, 16.7), + FbCountry('HR', 'Croatia', 'croatia', 42.3, 46.6, 13.4, 19.5), + FbCountry('BA', 'Bosnia', 'bosnia', 42.5, 45.3, 15.7, 19.7), + FbCountry('RS', 'Serbia', 'serbia', 42.2, 46.2, 18.8, 23.0), + FbCountry('HU', 'Hungary', 'hungary', 45.7, 48.6, 16.1, 22.9), + FbCountry('CZ', 'Czechia', 'czechia', 48.5, 51.1, 12.1, 18.9), + FbCountry('SK', 'Slovakia', 'slovakia', 47.7, 49.6, 16.8, 22.6), + FbCountry('PL', 'Poland', 'poland', 49.0, 54.9, 14.1, 24.2), + FbCountry('DK', 'Denmark', 'denmark', 54.5, 57.8, 8.0, 15.2), + FbCountry('NO', 'Norway', 'norway', 57.9, 71.2, 4.5, 31.2), + FbCountry('SE', 'Sweden', 'sweden', 55.3, 69.1, 11.0, 24.2), + FbCountry('FI', 'Finland', 'finland', 59.7, 70.1, 20.5, 31.6), + FbCountry('GR', 'Greece', 'greece', 34.8, 41.8, 19.3, 28.3), + FbCountry('BG', 'Bulgaria', 'bulgaria', 41.2, 44.2, 22.3, 28.6), + FbCountry('RO', 'Romania', 'romania', 43.6, 48.3, 20.2, 29.7), + FbCountry('AL', 'Albania', 'albania', 39.6, 42.7, 19.2, 21.1), + FbCountry('MK', 'NorthMacedonia', 'northmacedonia', 40.8, 42.4, 20.4, 23.0), + FbCountry('TR', 'Turkey', 'turkey', 35.8, 42.1, 25.6, 44.8), + ]; + + static FbCountry? byIso(String iso2) { + final u = iso2.trim().toUpperCase(); + for (final c in countries) { + if (c.iso2 == u) return c; + } + return null; + } + + // Picks the FlyBrief country whose bbox contains the point; if several match + // (overlapping bboxes) the one whose center is nearest is chosen. + static FbCountry? forPoint(double lat, double lon) { + FbCountry? best; + double bestD = double.infinity; + for (final c in countries) { + if (!c.contains(lat, lon)) continue; + final cLat = (c.minLat + c.maxLat) / 2; + final cLon = (c.minLon + c.maxLon) / 2; + final d = (cLat - lat) * (cLat - lat) + (cLon - lon) * (cLon - lon); + if (d < bestD) { + bestD = d; + best = c; + } + } + return best; + } + + // Builds the NOTAM GeoJSON URL for a country. + static Uri notamUrl(FbCountry c) => Uri.https( + host, '/Airspace/EU/${c.path}/${c.slug}_notams.geojson'); + + // Builds the obstacles GeoJSON URL for a country. + static Uri obstacleUrl(FbCountry c) => Uri.https( + host, '/Airspace/EU/${c.path}/${c.slug}_obstacles.geojson'); + + // Parses a FlyBrief NOTAM GeoJSON body into FbNotam records. Tolerates null + // geometry (non-precise NOTAMs) and malformed features. + static List parse(String body) { + final out = []; + final Map json; + try { + json = jsonDecode(body) as Map; + } catch (_) { + return out; + } + final feats = json['features']; + if (feats is! List) return out; + for (final f in feats) { + if (f is! Map) continue; + final p = f['properties']; + if (p is! Map) continue; + final id = (p['id'] ?? '').toString(); + if (id.isEmpty) continue; + final (clat, clon) = _centroid(f['geometry']); + out.add(FbNotam( + id: id, + category: (p['category'] ?? '').toString(), + text: (p['text'] ?? '').toString(), + raw: (p['raw'] ?? '').toString(), + lower: p['al']?.toString(), + upper: p['ah']?.toString(), + start: p['start']?.toString(), + end: p['end']?.toString(), + schedule: p['schedule']?.toString(), + perm: p['perm'] == true, + activeNow: p['active_now'] == true, + highPriority: p['hp'] == true, + radiusNm: (p['radius_nm'] is num) ? (p['radius_nm'] as num).toDouble() : null, + lat: clat, + lon: clon, + )); + } + return out; + } + + // Filters to NOTAMs within [radiusNm] of (lat,lon). NOTAMs without geometry + // are always included (country-wide / imprecise). Active NOTAMs sort first, + // then by distance. + static List nearby( + List all, + double lat, + double lon, { + double radiusNm = defaultRadiusNm, + }) { + final scored = <(double, FbNotam)>[]; + for (final n in all) { + if (n.lat == null || n.lon == null) { + scored.add((-1, n)); // no geometry -> always include, sort first + continue; + } + final d = distanceNm(lat, lon, n.lat!, n.lon!); + if (d <= radiusNm + (n.radiusNm ?? 0)) { + scored.add((d, n)); + } + } + scored.sort((a, b) { + if (a.$2.activeNow != b.$2.activeNow) { + return a.$2.activeNow ? -1 : 1; + } + return a.$1.compareTo(b.$1); + }); + return scored.map((e) => e.$2).toList(); + } + + // Formats a NOTAM list into the newline-separated text the NOTAM tab shows. + static String format(List notams) => + notams.map((n) => n.toLine()).join('\n\n'); + + // Great-circle distance in nautical miles. + static double distanceNm(double lat1, double lon1, double lat2, double lon2) { + const double rNm = 3440.065; + final dLat = _rad(lat2 - lat1); + final dLon = _rad(lon2 - lon1); + final a = math.sin(dLat / 2) * math.sin(dLat / 2) + + math.cos(_rad(lat1)) * + math.cos(_rad(lat2)) * + math.sin(dLon / 2) * + math.sin(dLon / 2); + return rNm * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)); + } + + static double _rad(double d) => d * math.pi / 180.0; + + // Centroid (mean vertex) of a GeoJSON geometry; returns (null,null) when the + // geometry is null or unusable. + static (double?, double?) _centroid(dynamic geometry) { + if (geometry is! Map) return (null, null); + final coords = geometry['coordinates']; + final pts = >[]; + _collectPoints(coords, pts); + if (pts.isEmpty) return (null, null); + double sLon = 0, sLat = 0; + for (final pt in pts) { + sLon += pt[0]; + sLat += pt[1]; + } + return (sLat / pts.length, sLon / pts.length); + } + + static void _collectPoints(dynamic node, List> out) { + if (node is! List) return; + if (node.length >= 2 && node[0] is num && node[1] is num) { + out.add([(node[0] as num).toDouble(), (node[1] as num).toDouble()]); + return; + } + for (final child in node) { + _collectPoints(child, out); + } + } +} diff --git a/lib/weather/flybrief_store.dart b/lib/weather/flybrief_store.dart new file mode 100644 index 00000000..c8d75c83 --- /dev/null +++ b/lib/weather/flybrief_store.dart @@ -0,0 +1,124 @@ +// Offline storage + fetch for FlyBrief per-country NOTAM (and obstacle) GeoJSON. +// +// Files are saved under {dataDir}/flybrief/_notams.geojson so they are +// available offline. Fetch is done in a background isolate. All parsing is +// delegated to the pure FlybriefNotams helpers so this file only does I/O. + +import 'dart:isolate'; + +import 'package:avaremp/storage.dart'; +import 'package:avaremp/utils/app_log.dart'; +import 'package:avaremp/weather/flybrief_notams.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; +import 'package:universal_io/io.dart'; + +class FlybriefStore { + FlybriefStore._(); + + static String _dir() => path.join(Storage().dataDir, 'flybrief'); + + static String notamPath(FbCountry c) => + path.join(_dir(), '${c.slug}_notams.geojson'); + + static String obstaclePath(FbCountry c) => + path.join(_dir(), '${c.slug}_obstacles.geojson'); + + // True if an offline NOTAM file exists for the country. + static bool hasOffline(FbCountry c) => File(notamPath(c)).existsSync(); + + // Downloads and stores the country's NOTAM (and optionally obstacle) GeoJSON + // for offline use. Returns the parsed NOTAM count, or null on failure. + static Future downloadCountry( + FbCountry c, { + bool includeObstacles = true, + void Function(double progress, String message)? onProgress, + }) async { + try { + await Directory(_dir()).create(recursive: true); + + onProgress?.call(0.1, 'Downloading NOTAMs for ${c.path}...'); + final notamBody = await _get(FlybriefNotams.notamUrl(c)); + if (notamBody == null) { + return null; + } + // Validate it parses before persisting. + final notams = FlybriefNotams.parse(notamBody); + await File(notamPath(c)).writeAsString(notamBody); + + if (includeObstacles) { + onProgress?.call(0.6, 'Downloading obstacles for ${c.path}...'); + final obsBody = await _get(FlybriefNotams.obstacleUrl(c)); + if (obsBody != null) { + await File(obstaclePath(c)).writeAsString(obsBody); + } + } + onProgress?.call(1.0, 'Stored ${notams.length} NOTAMs for ${c.path}.'); + return notams.length; + } catch (e) { + AppLog.logMessage('FlybriefStore.downloadCountry failed: $e'); + return null; + } + } + + // Loads offline NOTAMs for a country, if present. + static Future> loadOffline(FbCountry c) async { + try { + final f = File(notamPath(c)); + if (!f.existsSync()) return const []; + return FlybriefNotams.parse(await f.readAsString()); + } catch (e) { + AppLog.logMessage('FlybriefStore.loadOffline failed: $e'); + return const []; + } + } + + // Removes stored offline files for a country. + static Future removeCountry(FbCountry c) async { + for (final p in [notamPath(c), obstaclePath(c)]) { + try { + final f = File(p); + if (f.existsSync()) await f.delete(); + } catch (_) {} + } + } + + // Returns nearby NOTAMs for a point: offline first, else fetches the country + // file live (and caches it). Empty when no FlyBrief country covers the point. + static Future> nearbyForPoint( + double lat, + double lon, { + double radiusNm = FlybriefNotams.defaultRadiusNm, + }) async { + final c = FlybriefNotams.forPoint(lat, lon); + if (c == null) return const []; + List all = await loadOffline(c); + if (all.isEmpty) { + final body = await _get(FlybriefNotams.notamUrl(c)); + if (body != null) { + all = FlybriefNotams.parse(body); + try { + await Directory(_dir()).create(recursive: true); + await File(notamPath(c)).writeAsString(body); + } catch (_) {} + } + } + if (all.isEmpty) return const []; + return FlybriefNotams.nearby(all, lat, lon, radiusNm: radiusNm); + } + + // HTTP GET in a background isolate; returns the body or null. + static Future _get(Uri uri) async { + try { + final http.Response r = await Isolate.run(() => http.get(uri)); + if (r.statusCode != 200) { + AppLog.logMessage('FlybriefStore GET ${uri.path} -> ${r.statusCode}'); + return null; + } + return r.body; + } catch (e) { + AppLog.logMessage('FlybriefStore GET failed: $e'); + return null; + } + } +} diff --git a/lib/weather/metar_decoder.dart b/lib/weather/metar_decoder.dart new file mode 100644 index 00000000..bcd1d7a7 --- /dev/null +++ b/lib/weather/metar_decoder.dart @@ -0,0 +1,382 @@ +// Plain-English METAR decoding and selectable VFR/IFR threat coloring. +// +// This complements the existing FAA flight-category coloring (Metar.getColor) +// with a per-operation threat view inspired by route-briefing tools: the pilot +// picks an operating profile (VFR vs IFR) and key METAR elements are colored by +// thresholds appropriate to that profile, alongside a human-readable decode. +// +// All inputs come from data AvareX already holds (the raw METAR string). No +// network calls, no third-party services. + +import 'package:flutter/material.dart'; + +import 'metar.dart'; + +// Severity of a decoded weather element under the selected profile. +enum WxThreat { none, caution, hazard } + +extension WxThreatColor on WxThreat { + // Colors mirror the app's existing green/amber/red weather semantics and + // stay legible on both light and dark themes. + Color get color { + switch (this) { + case WxThreat.none: + return const Color(0xFF2E7D32); // green 800 + case WxThreat.caution: + return const Color(0xFFF9A825); // amber 800 + case WxThreat.hazard: + return const Color(0xFFC62828); // red 800 + } + } +} + +// Operating profile that governs threat thresholds. +enum WxProfile { vfr, ifr } + +extension WxProfileLabel on WxProfile { + String get label => this == WxProfile.vfr ? 'VFR' : 'IFR'; +} + +// A single decoded, plain-English METAR element with a threat level. +class WxElement { + final String label; // e.g. "Wind", "Visibility" + final String value; // plain-English decode, e.g. "From 090° at 12 kt" + final WxThreat threat; + + const WxElement(this.label, this.value, this.threat); +} + +// Threshold set for a profile. Ceilings in feet AGL, visibility in statute +// miles, wind/gust in knots. Values below/above these flip caution/hazard. +class WxThresholds { + final double ceilingHazardFt; // at/below → hazard + final double ceilingCautionFt; // at/below → caution + final double visHazardSM; // below → hazard + final double visCautionSM; // below → caution + final double windCautionKt; // steady wind at/above → caution + final double windHazardKt; // steady wind at/above → hazard + final double gustCautionKt; // gust at/above → caution + final double gustHazardKt; // gust at/above → hazard + + const WxThresholds({ + required this.ceilingHazardFt, + required this.ceilingCautionFt, + required this.visHazardSM, + required this.visCautionSM, + required this.windCautionKt, + required this.windHazardKt, + required this.gustCautionKt, + required this.gustHazardKt, + }); + + // Defaults chosen to be conservative and easy to reason about: + // - VFR: keys off VFR/MVFR boundaries (3 SM / 1000 ft are hard IFR limits + // for a VFR pilot; below the VFR minima of 5 SM / 3000 ft is caution). + // - IFR: keys off approach-minima-scale numbers (200 ft / 0.5 SM hazard, + // below ~600 ft / 1 SM caution) plus higher wind tolerance. + static const WxThresholds vfr = WxThresholds( + ceilingHazardFt: 1000, + ceilingCautionFt: 3000, + visHazardSM: 3, + visCautionSM: 5, + windCautionKt: 15, + windHazardKt: 25, + gustCautionKt: 20, + gustHazardKt: 30, + ); + + static const WxThresholds ifr = WxThresholds( + ceilingHazardFt: 200, + ceilingCautionFt: 600, + visHazardSM: 0.5, + visCautionSM: 1, + windCautionKt: 25, + windHazardKt: 35, + gustCautionKt: 30, + gustHazardKt: 40, + ); + + static WxThresholds forProfile(WxProfile profile) => + profile == WxProfile.vfr ? vfr : ifr; +} + +class MetarDecoder { + MetarDecoder._(); + + static const double _metersPerSM = 1609.344; + + // Decodes a raw METAR into an ordered list of plain-English elements with + // per-element threat levels for the selected profile. Elements that cannot + // be parsed are simply omitted (never guessed). + static List decode(String raw, WxProfile profile) { + final thresholds = WxThresholds.forProfile(profile); + final report = raw.trim(); + final elements = []; + + // Flight category (reuses the existing FAA-category logic). + final category = Metar.getCategory(report); + elements.add(WxElement('Flight category', category, + _categoryThreat(category, profile))); + + final wind = _decodeWind(report, thresholds); + if (wind != null) elements.add(wind); + + final vis = _decodeVisibility(report, thresholds); + if (vis != null) elements.add(vis); + + final ceiling = _decodeCeiling(report, thresholds); + if (ceiling != null) elements.add(ceiling); + + final wx = _decodePhenomena(report); + if (wx != null) elements.add(wx); + + final temp = _decodeTemp(report); + if (temp != null) elements.add(temp); + + final qnh = _decodePressure(report); + if (qnh != null) elements.add(qnh); + + return elements; + } + + // MVFR/IFR/LIFR matter more to a VFR pilot than an IFR one. + static WxThreat _categoryThreat(String category, WxProfile profile) { + if (profile == WxProfile.vfr) { + switch (category) { + case 'VFR': + return WxThreat.none; + case 'MVFR': + return WxThreat.caution; + default: // IFR / LIFR + return WxThreat.hazard; + } + } else { + switch (category) { + case 'VFR': + case 'MVFR': + return WxThreat.none; + case 'IFR': + return WxThreat.caution; + default: // LIFR + return WxThreat.hazard; + } + } + } + + static WxElement? _decodeWind(String report, WxThresholds t) { + // Calm wind is a special all-zero token; handle before the general regex + // (which would otherwise decode it as "From 000° at 0 kt"). + if (RegExp(r'(?<=\s)00000(KT|MPS)(?=\s)').hasMatch(' $report ')) { + return const WxElement('Wind', 'Calm', WxThreat.none); + } + final RegExp wind = RegExp( + r'(?\d{3}|VRB)P?(?\d{2,3})(G(P)?(?\d{2,3}))?(?KT|MPS)'); + for (final token in report.split(' ')) { + final m = wind.firstMatch(token); + if (m == null) continue; + final dir = m.namedGroup('dir')!; + final unit = m.namedGroup('units')!; + double speed = double.parse(m.namedGroup('speed')!); + final gustStr = m.namedGroup('gust'); + double? gust = gustStr == null ? null : double.parse(gustStr); + // Normalize m/s to knots for threshold comparison and display. + if (unit == 'MPS') { + speed = speed * 1.943844; + if (gust != null) gust = gust * 1.943844; + } + final dirText = dir == 'VRB' ? 'Variable' : 'From $dir°'; + final gustText = gust == null ? '' : ', gusting ${gust.round()} kt'; + final value = '$dirText at ${speed.round()} kt$gustText'; + + WxThreat threat = WxThreat.none; + if (speed >= t.windHazardKt || + (gust != null && gust >= t.gustHazardKt)) { + threat = WxThreat.hazard; + } else if (speed >= t.windCautionKt || + (gust != null && gust >= t.gustCautionKt)) { + threat = WxThreat.caution; + } + return WxElement('Wind', value, threat); + } + return null; + } + + static WxElement? _decodeVisibility(String report, WxThresholds t) { + double? visSM; + String? text; + + if (report.contains('CAVOK')) { + visSM = 6; + text = 'CAVOK (ceiling and visibility OK)'; + } else { + // US statute-mile form: "10SM", "1 1/2SM", "1/2SM", "M1/4SM". + final smMatch = RegExp( + r'(?\d{1,2})\s+)?(?\d/\d)?(?\d{1,2})?SM') + .allMatches(report); + for (final m in smMatch) { + double v = 0; + final intPart = m.namedGroup('int'); + final frac = m.namedGroup('frac'); + final whole = m.namedGroup('whole'); + if (intPart != null) v += double.tryParse(intPart) ?? 0; + if (frac != null) { + final p = frac.split('/'); + if (p.length == 2) { + final n = double.tryParse(p[0]); + final d = double.tryParse(p[1]); + if (n != null && d != null && d != 0) v += n / d; + } + } + if (whole != null && frac == null && intPart == null) { + v += double.tryParse(whole) ?? 0; + } + if (v > 0 || frac != null) { + visSM = v; + text = '${_trimNum(v)} SM'; + break; + } + } + // ICAO 4-digit metre form: "9999", "0800". Only when no SM form present. + if (visSM == null) { + final mMatch = + RegExp(r'(?<=\s)(?\d{4})(?[NSEW]{1,2})?(?=\s)') + .firstMatch(' $report '); + if (mMatch != null) { + final meters = double.tryParse(mMatch.namedGroup('vis')!); + if (meters != null) { + visSM = meters / _metersPerSM; + if (meters >= 9999) { + text = '10 km or more'; + } else if (meters >= 5000) { + text = '${(meters / 1000).round()} km'; + } else { + text = '${meters.round()} m'; + } + } + } + } + } + + if (visSM == null || text == null) return null; + + WxThreat threat = WxThreat.none; + if (visSM < t.visHazardSM) { + threat = WxThreat.hazard; + } else if (visSM < t.visCautionSM) { + threat = WxThreat.caution; + } + return WxElement('Visibility', text, threat); + } + + static WxElement? _decodeCeiling(String report, WxThresholds t) { + final int? ceilingFt = Metar.getCeilingFtFromReport(report); + if (ceilingFt == null) { + // No BKN/OVC/VV layer → no ceiling. Report sky-clear when explicit. + if (RegExp(r'\b(CAVOK|CLR|SKC|NSC|NCD)\b').hasMatch(report)) { + return const WxElement('Ceiling', 'No ceiling (sky clear)', WxThreat.none); + } + return null; + } + WxThreat threat = WxThreat.none; + if (ceilingFt <= t.ceilingHazardFt) { + threat = WxThreat.hazard; + } else if (ceilingFt <= t.ceilingCautionFt) { + threat = WxThreat.caution; + } + return WxElement('Ceiling', '$ceilingFt ft AGL', threat); + } + + // Decode significant present-weather phenomena into plain English. Any + // present weather beyond plain rain is at least a caution. + static WxElement? _decodePhenomena(String report) { + const Map descriptors = { + 'MI': 'shallow', 'BC': 'patches of', 'DR': 'low drifting', 'BL': 'blowing', + 'SH': 'showers of', 'TS': 'thunderstorm', 'FZ': 'freezing', + }; + const Map phenomena = { + 'DZ': 'drizzle', 'RA': 'rain', 'SN': 'snow', 'SG': 'snow grains', + 'IC': 'ice crystals', 'PL': 'ice pellets', 'GR': 'hail', + 'GS': 'small hail', 'UP': 'unknown precip', 'BR': 'mist', 'FG': 'fog', + 'FU': 'smoke', 'VA': 'volcanic ash', 'DU': 'widespread dust', + 'SA': 'sand', 'HZ': 'haze', 'PY': 'spray', 'PO': 'dust whirls', + 'SQ': 'squalls', 'FC': 'funnel cloud', 'SS': 'sandstorm', 'DS': 'duststorm', + }; + final parts = []; + bool hazard = false; + // Match tokens like -SHRA, +TSRA, VCFG, FZFG, BR. + final tokenRe = RegExp( + r'^(?[-+]|VC)?(?(MI|BC|DR|BL|SH|TS|FZ|DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|PO|SQ|FC|SS|DS)+)$'); + for (final token in report.split(' ')) { + final m = tokenRe.firstMatch(token); + if (m == null) continue; + final intensityRaw = m.namedGroup('int'); + final groups = m.namedGroup('groups')!; + // Split the concatenated 2-letter codes. + final codes = RegExp(r'..').allMatches(groups).map((e) => e.group(0)!); + final words = []; + for (final c in codes) { + if (descriptors.containsKey(c)) { + words.add(descriptors[c]!); + if (c == 'TS' || c == 'FZ') hazard = true; + } else if (phenomena.containsKey(c)) { + words.add(phenomena[c]!); + if (['GR', 'GS', 'FC', 'SS', 'DS', 'VA', 'PL'].contains(c)) { + hazard = true; + } + } + } + if (words.isEmpty) continue; + String intensity = ''; + if (intensityRaw == '-') { + intensity = 'light '; + } else if (intensityRaw == '+') { + intensity = 'heavy '; + hazard = true; + } else if (intensityRaw == 'VC') { + intensity = 'in the vicinity: '; + } + parts.add('$intensity${words.join(' ')}'); + } + if (parts.isEmpty) return null; + return WxElement('Weather', _capitalize(parts.join(', ')), + hazard ? WxThreat.hazard : WxThreat.caution); + } + + static WxElement? _decodeTemp(String report) { + final m = RegExp(r'(?<=\s)(M?\d{2})/(M?\d{2})(?=\s)').firstMatch(' $report '); + if (m == null) return null; + int parse(String s) => + s.startsWith('M') ? -int.parse(s.substring(1)) : int.parse(s); + final temp = parse(m.group(1)!); + final dew = parse(m.group(2)!); + final spread = temp - dew; + // Small temp/dew spread → fog/low-cloud risk → caution. + final threat = spread <= 2 ? WxThreat.caution : WxThreat.none; + return WxElement('Temp / Dewpoint', '$temp°C / $dew°C (spread $spread°C)', + threat); + } + + static WxElement? _decodePressure(String report) { + final q = RegExp(r'(?<=\s)Q(\d{4})(?=\s)').firstMatch(' $report '); + if (q != null) { + return WxElement('Pressure', 'QNH ${int.parse(q.group(1)!)} hPa', + WxThreat.none); + } + final a = RegExp(r'(?<=\s)A(\d{4})(?=\s)').firstMatch(' $report '); + if (a != null) { + final v = a.group(1)!; + return WxElement('Pressure', + 'Altimeter ${v.substring(0, 2)}.${v.substring(2)} inHg', + WxThreat.none); + } + return null; + } + + static String _trimNum(double v) { + if (v == v.roundToDouble()) return v.round().toString(); + return v.toStringAsFixed(2).replaceFirst(RegExp(r'0+$'), '').replaceFirst(RegExp(r'\.$'), ''); + } + + static String _capitalize(String s) => + s.isEmpty ? s : s[0].toUpperCase() + s.substring(1); +} diff --git a/lib/weather/open_meteo_credentials.dart b/lib/weather/open_meteo_credentials.dart new file mode 100644 index 00000000..72c34aee --- /dev/null +++ b/lib/weather/open_meteo_credentials.dart @@ -0,0 +1,34 @@ +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; + +// Secure storage for the optional, user-supplied Open-Meteo API key. +// +// Open-Meteo's free endpoint is used by default (non-commercial, CC BY 4.0). +// A user who needs commercial-compliant access may enter their own key, which +// routes requests to the customer endpoint. The key is never embedded in the +// app, source, logs, or downloaded data. +class OpenMeteoCredentials { + static const _key = 'open-meteo-api-key'; + final FlutterSecureStorage _storage; + + const OpenMeteoCredentials( + {FlutterSecureStorage storage = const FlutterSecureStorage( + aOptions: AndroidOptions(encryptedSharedPreferences: true), + )}) + : _storage = storage; + + Future read() async { + final value = (await _storage.read(key: _key))?.trim(); + return value == null || value.isEmpty ? null : value; + } + + Future write(String value) async { + final normalized = value.trim(); + if (normalized.isEmpty) { + await clear(); + return; + } + await _storage.write(key: _key, value: normalized); + } + + Future clear() => _storage.delete(key: _key); +} diff --git a/lib/weather/open_meteo_settings_screen.dart b/lib/weather/open_meteo_settings_screen.dart new file mode 100644 index 00000000..cd42c977 --- /dev/null +++ b/lib/weather/open_meteo_settings_screen.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; + +import '../storage.dart'; +import 'open_meteo_credentials.dart'; +import 'open_meteo_winds.dart'; +import 'package:latlong2/latlong.dart'; + +// Optional Open-Meteo API key management. +// +// Open-Meteo powers global winds-aloft outside US FB coverage. The free, +// non-commercial endpoint is used by default; a user may enter a personal +// Open-Meteo API key for commercial-compliant access. The key is stored in +// platform secure storage and never embedded in the app. +class OpenMeteoSettingsScreen extends StatefulWidget { + const OpenMeteoSettingsScreen({super.key}); + + @override + State createState() => _OpenMeteoSettingsScreenState(); +} + +class _OpenMeteoSettingsScreenState extends State { + final _credentials = const OpenMeteoCredentials(); + final _keyController = TextEditingController(); + bool _busy = false; + bool _hideKey = true; + String? _message; + + @override + void initState() { + super.initState(); + _credentials.read().then((value) { + if (mounted && value != null) setState(() => _keyController.text = value); + }); + } + + @override + void dispose() { + _keyController.dispose(); + super.dispose(); + } + + Future _saveKey() async { + await _credentials.write(_keyController.text); + if (mounted) { + setState(() => _message = _keyController.text.trim().isEmpty + ? 'Using the free Open-Meteo endpoint (non-commercial).' + : 'API key saved securely on this device.'); + } + } + + Future _clearKey() async { + await _credentials.clear(); + _keyController.clear(); + if (mounted) { + setState(() => _message = 'API key cleared. Using the free endpoint.'); + } + } + + Future _testConnection() async { + setState(() { + _busy = true; + _message = 'Testing Open-Meteo connection...'; + }); + final key = _keyController.text.trim(); + // Test at the current map center; falls back to a well-covered point. + LatLng where; + try { + where = LatLng(Storage().settings.getCenterLatitude(), + Storage().settings.getCenterLongitude()); + } catch (_) { + where = const LatLng(50.03, 8.55); // Frankfurt + } + final winds = await OpenMeteoWinds.fetch(where, apiKey: key.isEmpty ? null : key); + if (mounted) { + setState(() { + _busy = false; + _message = winds != null + ? 'Connection successful; winds aloft retrieved.' + : 'No winds returned. Check the key or try again later.'; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('Open-Meteo Winds')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Open-Meteo winds aloft', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + const Text( + 'Winds aloft outside US coverage are retrieved from Open-Meteo ' + 'using its pressure-level forecast. The free endpoint is used by ' + 'default and requires no key.'), + const SizedBox(height: 16), + TextField( + controller: _keyController, + obscureText: _hideKey, + decoration: InputDecoration( + labelText: 'Optional Open-Meteo API key', + helperText: 'Only needed for commercial-compliant use', + suffixIcon: IconButton( + icon: Icon(_hideKey ? Icons.visibility : Icons.visibility_off), + onPressed: () => setState(() => _hideKey = !_hideKey), + ), + ), + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _saveKey, + icon: const Icon(Icons.save), + label: const Text('Save'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _testConnection, + icon: const Icon(Icons.wifi_tethering), + label: const Text('Test Connection'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _clearKey, + icon: const Icon(Icons.delete_outline), + label: const Text('Clear Key'), + ), + ], + ), + if (_busy) const Padding( + padding: EdgeInsets.only(top: 16), + child: LinearProgressIndicator(), + ), + if (_message != null) Padding( + padding: const EdgeInsets.only(top: 16), + child: Text(_message!), + ), + const SizedBox(height: 24), + Text(OpenMeteoWinds.attribution, + style: const TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text( + 'Weather data by Open-Meteo.com, licensed under CC BY 4.0. ' + 'The free API is intended for non-commercial use; supply your own ' + 'API key for commercial-compliant access. Forecast winds are ' + 'advisory and not a substitute for an official weather briefing.'), + ], + ), + ); + } +} diff --git a/lib/weather/open_meteo_winds.dart b/lib/weather/open_meteo_winds.dart new file mode 100644 index 00000000..519b00c4 --- /dev/null +++ b/lib/weather/open_meteo_winds.dart @@ -0,0 +1,260 @@ +// Open-Meteo winds-aloft provider. +// +// Fills the winds-aloft gap outside the US: the built-in winds come from NWS +// FB text products that only cover the US and its territories, so anywhere else +// (Europe, etc.) the "nearest station" is thousands of miles away and useless. +// Open-Meteo exposes pressure-level wind speed/direction plus geopotential +// height globally, which we convert into the app's existing [WindsAloft] slots +// (0/3k/6k/9k/12k/18k/24k/30k/34k/39k ft) so the Wind tab renders unchanged. +// +// Data © Open-Meteo.com, CC BY 4.0. The free endpoint is used by default; a +// user may supply a personal Open-Meteo API key for commercial-compliant use, +// in which case the customer endpoint is used. No key is embedded in the app. +// +// The parsing is pure and unit-tested; only [fetch] performs I/O. + +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:avaremp/utils/app_log.dart'; +import 'package:avaremp/weather/weather.dart'; +import 'package:avaremp/weather/winds_aloft.dart'; +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; + +class OpenMeteoWinds { + OpenMeteoWinds._(); + + static const String attribution = 'Winds © Open-Meteo.com, CC BY 4.0'; + + // Beyond this great-circle distance (km) from the nearest US winds-aloft + // station, the US FB product does not apply and Open-Meteo should be used. + static const double usStationMaxKm = 550; + + static const String freeHost = 'api.open-meteo.com'; + static const String customerHost = 'customer-api.open-meteo.com'; + + // Pressure levels (hPa) requested; span the surface up to ~45,000 ft so the + // fixed winds-aloft slots can be bracketed for interpolation. + static const List _levelsHpa = [ + 1000, 925, 850, 700, 600, 500, 400, 300, 250, 200, 150, + ]; + + // Winds-aloft altitude slots in feet, in WindsAloft field order. + static const List slotFt = [ + 0, 3000, 6000, 9000, 12000, 18000, 24000, 30000, 34000, 39000, + ]; + + static const double _mToFt = 3.28084; + + static const Distance _distance = Distance(); + + // Great-circle distance in km between two coordinates (unit-independent, + // unlike GeoCalculations which scales by the user's display units). + static double distanceKm(LatLng a, LatLng b) => _distance.as(LengthUnit.Kilometer, a, b); + + // Builds the Open-Meteo forecast URL for pressure-level winds at a point. + static Uri buildUrl(double lat, double lon, {String? apiKey}) { + final hourly = []; + for (final l in _levelsHpa) { + hourly.add('wind_speed_${l}hPa'); + hourly.add('wind_direction_${l}hPa'); + hourly.add('geopotential_height_${l}hPa'); + } + final params = { + 'latitude': lat.toStringAsFixed(4), + 'longitude': lon.toStringAsFixed(4), + 'hourly': hourly.join(','), + 'wind_speed_unit': 'kn', + 'forecast_days': '2', + 'timezone': 'UTC', + }; + final key = apiKey?.trim(); + if (key != null && key.isNotEmpty) { + params['apikey'] = key; + } + return Uri.https(key != null && key.isNotEmpty ? customerHost : freeHost, + '/v1/forecast', params); + } + + // Encodes a direction (deg) + speed (kt) into the 4-char FB winds-aloft token + // understood by [WindsAloft.decodeWind]. Returns '' when data is missing and + // '9900' (light and variable) for calm/near-calm winds. + static String encodeWind(int? dirDeg, double? speedKt) { + if (dirDeg == null || speedKt == null) { + return ''; + } + final int spd = speedKt.round(); + if (spd < 5) { + return '9900'; + } + int d = (((dirDeg % 360) / 10).round()) % 36; // 0..35 tens of degrees + int s = spd; + if (s > 99) { + // FB high-speed encoding: add 50 to the tens-of-degrees, subtract 100 kt. + s -= 100; + d += 50; + if (s > 99) { + s = 99; // clamp absurd speeds so the token stays 4 chars + } + } + return '${d.toString().padLeft(2, '0')}${s.toString().padLeft(2, '0')}'; + } + + // Parses an Open-Meteo forecast body into a [WindsAloft] for [station]. + // [now] and [foreHours] select which forecast hour to use (default: the hour + // nearest to now + 6 h, matching the app's 06H product). Returns null if the + // payload cannot yield any usable level. + static WindsAloft? parse( + String station, + String body, { + DateTime? now, + int foreHours = 6, + }) { + final Map json; + try { + json = jsonDecode(body) as Map; + } catch (e) { + AppLog.logMessage('OpenMeteoWinds.parse: bad JSON: $e'); + return null; + } + final hourly = json['hourly']; + if (hourly is! Map) { + return null; + } + final times = (hourly['time'] as List?)?.cast(); + if (times == null || times.isEmpty) { + return null; + } + + final DateTime target = + (now ?? DateTime.now().toUtc()).add(Duration(hours: foreHours)); + final int idx = _nearestHourIndex(times, target); + if (idx < 0) { + return null; + } + final DateTime validAt = DateTime.tryParse('${times[idx]}Z') ?? + DateTime.now().toUtc().add(Duration(hours: foreHours)); + + // Build (altitudeFt, dir, speedKt) samples from each pressure level. + final samples = <({double altFt, double dir, double spd})>[]; + for (final l in _levelsHpa) { + final gh = _at(hourly['geopotential_height_${l}hPa'], idx); + final ws = _at(hourly['wind_speed_${l}hPa'], idx); + final wd = _at(hourly['wind_direction_${l}hPa'], idx); + if (gh == null || ws == null || wd == null) { + continue; + } + samples.add((altFt: gh * _mToFt, dir: wd, spd: ws)); + } + if (samples.isEmpty) { + return null; + } + samples.sort((a, b) => a.altFt.compareTo(b.altFt)); + + final encoded = []; + for (final ft in slotFt) { + final v = _interpolate(samples, ft.toDouble()); + encoded.add(v == null ? '' : encodeWind(v.$1.round(), v.$2)); + } + + return WindsAloft( + station, + validAt, + now ?? DateTime.now().toUtc(), + Weather.sourceInternet, + encoded[0], encoded[1], encoded[2], encoded[3], encoded[4], + encoded[5], encoded[6], encoded[7], encoded[8], encoded[9], + ); + } + + // Fetches and parses winds for a coordinate. Returns null on any failure so + // callers can silently fall back. [apiKey] is optional (free endpoint used + // when absent). + static Future fetch( + LatLng coordinate, { + String? apiKey, + String station = 'Open-Meteo', + int foreHours = 6, + }) async { + final uri = buildUrl(coordinate.latitude, coordinate.longitude, apiKey: apiKey); + try { + final http.Response response = + await Isolate.run(() => http.get(uri)); + if (response.statusCode != 200) { + AppLog.logMessage('OpenMeteoWinds.fetch: HTTP ${response.statusCode}'); + return null; + } + return parse(station, response.body, foreHours: foreHours); + } catch (e) { + AppLog.logMessage('OpenMeteoWinds.fetch failed: $e'); + return null; + } + } + + static double? _at(dynamic list, int idx) { + if (list is! List || idx < 0 || idx >= list.length) { + return null; + } + final v = list[idx]; + if (v is num) { + return v.toDouble(); + } + return null; + } + + // Index of the forecast hour nearest [target]. Returns -1 if none parse. + static int _nearestHourIndex(List times, DateTime target) { + int best = -1; + int bestDiff = 1 << 30; + for (int i = 0; i < times.length; i++) { + final t = DateTime.tryParse('${times[i]}Z'); + if (t == null) { + continue; + } + final diff = (t.difference(target).inMinutes).abs(); + if (diff < bestDiff) { + bestDiff = diff; + best = i; + } + } + return best; + } + + // Linear interpolation of (dir, speed) at [targetFt] from altitude-sorted + // samples. Direction uses shortest-arc interpolation. Returns null when the + // target is outside the sampled range (caller leaves that slot empty; the + // WindsAloft slot-fill logic then carries a neighbouring value). + static (double, double)? _interpolate( + List<({double altFt, double dir, double spd})> samples, + double targetFt, + ) { + if (samples.isEmpty) { + return null; + } + // At or below the lowest sample: use the lowest (surface winds). + if (targetFt <= samples.first.altFt) { + return (samples.first.dir, samples.first.spd); + } + if (targetFt >= samples.last.altFt) { + return null; // above the data + } + for (int i = 0; i < samples.length - 1; i++) { + final lo = samples[i]; + final hi = samples[i + 1]; + if (targetFt >= lo.altFt && targetFt <= hi.altFt) { + final span = hi.altFt - lo.altFt; + final f = span == 0 ? 0.0 : (targetFt - lo.altFt) / span; + final spd = lo.spd + (hi.spd - lo.spd) * f; + // Shortest-arc direction interpolation. + double delta = hi.dir - lo.dir; + if (delta > 180) delta -= 360; + if (delta < -180) delta += 360; + double dir = (lo.dir + delta * f) % 360; + if (dir < 0) dir += 360; + return (dir, spd); + } + } + return null; + } +} diff --git a/lib/weather/rainviewer_radar.dart b/lib/weather/rainviewer_radar.dart new file mode 100644 index 00000000..173b478c --- /dev/null +++ b/lib/weather/rainviewer_radar.dart @@ -0,0 +1,111 @@ +import 'dart:convert'; + +import 'package:avaremp/constants.dart'; +import 'package:avaremp/utils/app_log.dart'; +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; + +/// Fetches the RainViewer weather-maps index and exposes animated global radar +/// frames as flutter_map tile-URL templates. +/// +/// RainViewer serves standard {z}/{x}/{y} PNG tiles, so the map renders them +/// with a plain [TileLayer] just like the Iowa Mesonet mosaic — no bitmap +/// decoding or coordinate math. The free public API needs no key and no login. +/// +/// API: https://www.rainviewer.com/api/weather-maps-api.html +/// GET https://api.rainviewer.com/public/weather-maps.json +/// -> { host, radar: { past: [ { time, path }, ... ] } } +/// Tile URL: {host}{path}/{size}/{z}/{x}/{y}/{color}/{smooth}_{snow}.png +class RainViewerRadar { + RainViewerRadar._(); + static final RainViewerRadar instance = RainViewerRadar._(); + + static const String _indexUrl = + 'https://api.rainviewer.com/public/weather-maps.json'; + + static const String attribution = 'Radar © RainViewer.com'; + + // Notifies listeners when a fresh index has been loaded. + final ValueNotifier change = ValueNotifier(0); + + String _host = ''; + // Ordered oldest -> newest frame base paths (e.g. "/v2/radar/1609401600"). + List _framePaths = const []; + DateTime? _lastFetch; + + /// Whether at least one radar frame is available to draw. + bool get hasFrames => _host.isNotEmpty && _framePaths.isNotEmpty; + + /// Number of animation frames currently available. + int get frameCount => _framePaths.length; + + /// Fetches the latest index. Safe to call repeatedly; it no-ops when the + /// current data is younger than [minInterval]. Returns true on success. + Future refresh( + {Duration minInterval = const Duration(minutes: 5)}) async { + final now = DateTime.now(); + if (_lastFetch != null && + now.difference(_lastFetch!) < minInterval && + hasFrames) { + return true; + } + try { + final response = await http.get(Uri.parse(_indexUrl)); + if (response.statusCode != 200) { + AppLog.logMessage('RainViewerRadar.refresh: HTTP ${response.statusCode}'); + return false; + } + final Map data = jsonDecode(response.body); + final String host = (data['host'] as String?) ?? ''; + final radar = data['radar']; + final List past = + (radar is Map && radar['past'] is List) ? radar['past'] as List : const []; + final List paths = []; + for (final frame in past) { + if (frame is Map && frame['path'] is String) { + paths.add(frame['path'] as String); + } + } + if (host.isEmpty || paths.isEmpty) { + AppLog.logMessage('RainViewerRadar.refresh: empty index'); + return false; + } + _host = host; + _framePaths = paths; + _lastFetch = now; + change.value++; + return true; + } catch (e) { + AppLog.logMessage('RainViewerRadar.refresh failed: $e'); + return false; + } + } + + /// Builds a flutter_map tile URL template for the frame at [frameIndex] + /// (clamped; negative indexes count from the newest frame, so -1 is latest). + /// [colorScheme] is a RainViewer color ID (0..8). Returns null when no data. + /// + /// The returned string still contains the flutter_map {z}/{x}/{y} + /// placeholders for [TileLayer.urlTemplate]. + String? tileUrlTemplate({ + int frameIndex = -1, + int colorScheme = 4, + int size = 256, + bool smooth = true, + bool snow = true, + }) { + if (!hasFrames) { + return null; + } + int idx = frameIndex < 0 ? _framePaths.length + frameIndex : frameIndex; + if (idx < 0) idx = 0; + if (idx >= _framePaths.length) idx = _framePaths.length - 1; + final int color = colorScheme < 0 + ? 0 + : (colorScheme >= Constants.rainViewerColorSchemes.length + ? Constants.rainViewerColorSchemes.length - 1 + : colorScheme); + final String opts = '${smooth ? 1 : 0}_${snow ? 1 : 0}'; + return '$_host${_framePaths[idx]}/$size/{z}/{x}/{y}/$color/$opts.png'; + } +} diff --git a/lib/weather/terrain_download_manager.dart b/lib/weather/terrain_download_manager.dart new file mode 100644 index 00000000..e436dd4a --- /dev/null +++ b/lib/weather/terrain_download_manager.dart @@ -0,0 +1,152 @@ +// On-device terrain (elevation) tile downloader for a country/region. +// +// Fetches open AWS Terrain Tiles, transcodes them to AvareX's elevation-tile +// format (see terrain_transcode.dart) and writes them to +// {dataDir}/tiles/6/{z}/{x}/{y}.png +// so the existing terrain profile, elevation readout and GPWS work offline +// outside the US. Nothing is bundled in the app; tiles are built on device for +// the chosen country only. + +import 'dart:async'; +import 'dart:isolate'; + +import 'package:avaremp/storage.dart'; +import 'package:avaremp/utils/app_log.dart'; +import 'package:avaremp/weather/flybrief_notams.dart' show FbCountry, FlybriefNotams; +import 'package:avaremp/weather/terrain_transcode.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.dart' as path; +import 'package:universal_io/io.dart'; + +class TerrainDownloadManager { + bool _cancel = false; + + void cancel() => _cancel = true; + + // Estimated download bytes for a country (terrarium tiles average ~136 KB). + static int estimatedBytes(FbCountry c) => + TerrainTranscode.countTilesForBounds( + c.minLat, c.maxLat, c.minLon, c.maxLon) * + 136 * + 1024; + + static int tileCount(FbCountry c) => TerrainTranscode.countTilesForBounds( + c.minLat, c.maxLat, c.minLon, c.maxLon); + + static String _tilePath(String dataDir, TerrainTile t) => path.join( + dataDir, 'tiles', '6', '${t.z}', '${t.x}', '${t.yTms}.png'); + + // Whether any terrain tiles are already present for the country (z10 sample). + static bool hasSome(FbCountry c) { + final dataDir = Storage().dataDir; + final tiles = TerrainTranscode.tilesForBounds( + c.minLat, c.maxLat, c.minLon, c.maxLon, + minZoom: kTerrainMaxZoom, maxZoom: kTerrainMaxZoom); + for (final t in tiles.take(20)) { + if (File(_tilePath(dataDir, t)).existsSync()) return true; + } + return false; + } + + // Downloads and transcodes all terrain tiles for the country. Calls + // [onProgress] with (0..1, message). Skips tiles already on disk. Returns the + // number of tiles written, or null if cancelled/failed before completion. + Future download( + FbCountry c, { + void Function(double progress, String message)? onProgress, + }) async { + _cancel = false; + final dataDir = Storage().dataDir; + final tiles = TerrainTranscode.tilesForBounds( + c.minLat, c.maxLat, c.minLon, c.maxLon); + final total = tiles.length; + var done = 0; + var written = 0; + var lastReport = 0.0; + + onProgress?.call(0, 'Preparing $total terrain tiles for ${c.path}...'); + + // Bounded concurrency to keep memory/network sane. + const int concurrency = 6; + var index = 0; + + Future worker() async { + while (true) { + if (_cancel) return; + final int i; + if (index >= tiles.length) return; + i = index++; + final t = tiles[i]; + final outPath = _tilePath(dataDir, t); + try { + final f = File(outPath); + if (!f.existsSync()) { + final bytes = await _fetchAndTranscode(t); + if (bytes != null) { + await Directory(path.dirname(outPath)).create(recursive: true); + await f.writeAsBytes(bytes, flush: false); + written++; + } + } else { + written++; // already present counts as available + } + } catch (e) { + AppLog.logMessage('Terrain tile $t failed: $e'); + } + done++; + final p = done / total; + if (p - lastReport >= 0.01 || done == total) { + lastReport = p; + onProgress?.call( + p * 0.99, 'Building terrain ${(p * 100).toStringAsFixed(0)}% ' + '($done/$total)...'); + } + } + } + + await Future.wait(List.generate(concurrency, (_) => worker())); + + if (_cancel) { + onProgress?.call(lastReport, 'Cancelled after $written tiles.'); + return null; + } + onProgress?.call(1, 'Installed $written terrain tiles for ${c.path}.'); + return written; + } + + // Fetches a terrarium tile and transcodes it in a background isolate. + static Future?> _fetchAndTranscode(TerrainTile t) async { + final uri = TerrainTranscode.terrariumUrl(t.z, t.x, t.yXyz); + return Isolate.run(() async { + try { + final r = await http.get(uri); + if (r.statusCode != 200) return null; + return TerrainTranscode.transcodeTerrarium(r.bodyBytes); + } catch (_) { + return null; + } + }); + } + + // Removes downloaded terrain tiles for a country (best-effort). + static Future remove(FbCountry c) async { + final dataDir = Storage().dataDir; + final tiles = TerrainTranscode.tilesForBounds( + c.minLat, c.maxLat, c.minLon, c.maxLon); + var removed = 0; + for (final t in tiles) { + try { + final f = File(_tilePath(dataDir, t)); + if (f.existsSync()) { + await f.delete(); + removed++; + } + } catch (_) {} + } + return removed; + } + + // Convenience: the FlyBrief country under a point (shares the bbox table). + static FbCountry? countryForPoint(double lat, double lon) => + FlybriefNotams.forPoint(lat, lon); +} diff --git a/lib/weather/terrain_download_screen.dart b/lib/weather/terrain_download_screen.dart new file mode 100644 index 00000000..12293741 --- /dev/null +++ b/lib/weather/terrain_download_screen.dart @@ -0,0 +1,156 @@ +import 'package:flutter/material.dart'; +import 'package:latlong2/latlong.dart'; + +import '../storage.dart'; +import 'flybrief_notams.dart' show FbCountry, FlybriefNotams; +import 'terrain_download_manager.dart'; + +// Download and transcode terrain (elevation) tiles for a country so the terrain +// profile, elevation readout and GPWS work offline outside the US. Tiles are +// built on device from open AWS Terrain Tiles; nothing is bundled in the app. +class TerrainDownloadScreen extends StatefulWidget { + const TerrainDownloadScreen({super.key}); + + @override + State createState() => _TerrainDownloadScreenState(); +} + +class _TerrainDownloadScreenState extends State { + FbCountry? _country; + TerrainDownloadManager? _manager; + bool _busy = false; + double _progress = 0; + String? _message; + + @override + void initState() { + super.initState(); + LatLng where; + try { + where = LatLng(Storage().position.latitude, Storage().position.longitude); + } catch (_) { + where = const LatLng(50.03, 8.55); + } + _country = FlybriefNotams.forPoint(where.latitude, where.longitude) ?? + FlybriefNotams.byIso('DE'); + } + + String _estimate(FbCountry c) { + final tiles = TerrainDownloadManager.tileCount(c); + final mb = TerrainDownloadManager.estimatedBytes(c) / (1024 * 1024); + return '$tiles tiles, ~${mb.toStringAsFixed(0)} MB download'; + } + + Future _download() async { + final c = _country; + if (c == null) return; + _manager = TerrainDownloadManager(); + setState(() { + _busy = true; + _progress = 0; + _message = 'Starting...'; + }); + final count = await _manager!.download(c, onProgress: (p, m) { + if (mounted) setState(() { _progress = p; _message = m; }); + }); + if (mounted) { + setState(() { + _busy = false; + _message = count != null + ? 'Installed $count terrain tiles for ${c.path} (offline).' + : (_message ?? 'Cancelled.'); + }); + } + } + + void _cancel() { + _manager?.cancel(); + } + + Future _remove() async { + final c = _country; + if (c == null) return; + setState(() { _busy = true; _message = 'Removing...'; }); + final n = await TerrainDownloadManager.remove(c); + if (mounted) { + setState(() { _busy = false; _message = 'Removed $n terrain tiles for ${c.path}.'; }); + } + } + + @override + Widget build(BuildContext context) { + final c = _country; + return Scaffold( + appBar: AppBar(title: const Text('Terrain (Elevation)')), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + Text('Terrain elevation for offline use', + style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + const Text( + 'Builds elevation tiles on this device for the chosen country so ' + 'the terrain profile, elevation readout and GPWS work offline ' + 'outside the US. Larger countries take longer and use more space; ' + 'you can cancel any time.'), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: c?.iso2, + decoration: const InputDecoration(labelText: 'Country'), + items: FlybriefNotams.countries + .map((x) => DropdownMenuItem( + value: x.iso2, child: Text('${x.path} (${x.iso2})'))) + .toList(), + onChanged: _busy + ? null + : (v) => setState(() => + _country = v == null ? null : FlybriefNotams.byIso(v)), + ), + const SizedBox(height: 8), + if (c != null) + Text(_estimate(c), + style: TextStyle(color: Theme.of(context).colorScheme.outline)), + const SizedBox(height: 12), + Wrap( + spacing: 8, + children: [ + FilledButton.icon( + onPressed: _busy ? null : _download, + icon: const Icon(Icons.download), + label: const Text('Download for Offline'), + ), + if (_busy) + OutlinedButton.icon( + onPressed: _cancel, + icon: const Icon(Icons.stop), + label: const Text('Cancel'), + ), + OutlinedButton.icon( + onPressed: _busy ? null : _remove, + icon: const Icon(Icons.delete_outline), + label: const Text('Remove'), + ), + ], + ), + if (_busy) Padding( + padding: const EdgeInsets.only(top: 16), + child: LinearProgressIndicator(value: _progress > 0 ? _progress : null), + ), + if (_message != null) Padding( + padding: const EdgeInsets.only(top: 16), + child: Text(_message!), + ), + const SizedBox(height: 24), + const Text('Terrain © AWS Terrain Tiles / Mapzen contributors', + style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + const Text( + 'Elevation data is compiled from open public-domain and ' + 'permissively licensed sources (SRTM, and others) via AWS Terrain ' + 'Tiles. It is advisory only and not certified for terrain ' + 'clearance; always maintain safe altitudes.'), + ], + ), + ); + } +} diff --git a/lib/weather/terrain_transcode.dart b/lib/weather/terrain_transcode.dart new file mode 100644 index 00000000..82fae23b --- /dev/null +++ b/lib/weather/terrain_transcode.dart @@ -0,0 +1,182 @@ +// Pure terrain-tile transcoding and enumeration. +// +// AvareX renders terrain from elevation tiles stored at +// {dataDir}/tiles/6/{z}/{x}/{y}.png +// as 512x512 8-bit gray+alpha PNGs where +// elevationFt = gray * 80.4711845056 - 364.431597044586 +// (see ElevationImageProvider / ElevationCache). The tile grid uses standard +// slippy X but a TMS (flipped) Y: yTile = 2^z - 1 - yXyz. Alpha 0 marks +// no-data (ocean); gray 0 there. +// +// Outside the US, these tiles are not distributed. This transcoder builds them +// on-device from open, public-domain AWS Terrain Tiles ("terrarium" RGB PNG, +// 256x256, standard slippy XYZ) where +// elevationMeters = R*256 + G + B/256 - 32768. +// +// Everything here is PURE and unit-tested (no I/O): the network fetch and file +// writing live in terrain_download_manager.dart. +// +// Terrain data © AWS Terrain Tiles / Mapzen contributors (public domain / CC0 +// and permissively licensed sources). Advisory only. + +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; + +// AvareX elevation encoding constants (must match ElevationImageProvider). +const double kElevSlope = 80.4711845056; +const double kElevIntercept = -364.431597044586; + +// AvareX elevation pyramid zoom range and native (max) zoom. +const int kTerrainMinZoom = 1; +const int kTerrainMaxZoom = 10; + +// A single tile address in AvareX's grid (slippy X, TMS Y). +class TerrainTile { + final int z; + final int x; + final int yTms; // AvareX/TMS y as stored on disk + const TerrainTile(this.z, this.x, this.yTms); + + // The corresponding slippy/XYZ y used by terrarium. + int get yXyz => (1 << z) - 1 - yTms; + + @override + bool operator ==(Object other) => + other is TerrainTile && other.z == z && other.x == x && other.yTms == yTms; + + @override + int get hashCode => Object.hash(z, x, yTms); + + @override + String toString() => '$z/$x/$yTms'; +} + +class TerrainTranscode { + TerrainTranscode._(); + + // Terrarium (AWS Terrain Tiles) URL for a slippy XYZ tile. + static Uri terrariumUrl(int z, int x, int yXyz) => Uri.https( + 's3.amazonaws.com', '/elevation-tiles-prod/terrarium/$z/$x/$yXyz.png'); + + // Encodes an elevation in feet to an AvareX gray byte (0..255), clamped. + static int encodeGray(double elevationFt) { + final g = ((elevationFt - kElevIntercept) / kElevSlope).round(); + if (g < 0) return 0; + if (g > 255) return 255; + return g; + } + + // Decodes an AvareX gray byte back to feet (inverse of encodeGray, ignoring + // clamping/quantization). Used by tests to assert round-trip fidelity. + static double decodeGray(int gray) => gray * kElevSlope + kElevIntercept; + + // Slippy X tile index for a longitude at zoom z. + static int lonToTileX(double lon, int z) { + final n = 1 << z; + var x = ((lon + 180.0) / 360.0 * n).floor(); + if (x < 0) x = 0; + if (x > n - 1) x = n - 1; + return x; + } + + // Slippy (XYZ) Y tile index for a latitude at zoom z. + static int latToTileYXyz(double lat, int z) { + final n = 1 << z; + final r = math.log(math.tan(_rad(lat)) + 1 / math.cos(_rad(lat))); + var y = ((1 - r / math.pi) / 2 * n).floor(); + if (y < 0) y = 0; + if (y > n - 1) y = n - 1; + return y; + } + + // Enumerates every AvareX tile (all zooms kTerrainMinZoom..kTerrainMaxZoom) + // covering a lat/lon bounding box. + static List tilesForBounds( + double minLat, + double maxLat, + double minLon, + double maxLon, { + int minZoom = kTerrainMinZoom, + int maxZoom = kTerrainMaxZoom, + }) { + final tiles = []; + for (var z = minZoom; z <= maxZoom; z++) { + final x0 = lonToTileX(minLon, z); + final x1 = lonToTileX(maxLon, z); + // North latitude -> smaller slippy y. + final yTop = latToTileYXyz(maxLat, z); + final yBottom = latToTileYXyz(minLat, z); + for (var x = math.min(x0, x1); x <= math.max(x0, x1); x++) { + for (var yx = math.min(yTop, yBottom); yx <= math.max(yTop, yBottom); yx++) { + tiles.add(TerrainTile(z, x, (1 << z) - 1 - yx)); + } + } + } + return tiles; + } + + // Counts tiles for a bounding box without allocating them all. + static int countTilesForBounds( + double minLat, + double maxLat, + double minLon, + double maxLon, { + int minZoom = kTerrainMinZoom, + int maxZoom = kTerrainMaxZoom, + }) { + var total = 0; + for (var z = minZoom; z <= maxZoom; z++) { + final x0 = lonToTileX(minLon, z); + final x1 = lonToTileX(maxLon, z); + final yTop = latToTileYXyz(maxLat, z); + final yBottom = latToTileYXyz(minLat, z); + final nx = (x1 - x0).abs() + 1; + final ny = (yBottom - yTop).abs() + 1; + total += nx * ny; + } + return total; + } + + // Transcodes terrarium PNG bytes into AvareX elevation-tile PNG bytes. + // Returns null if the input cannot be decoded. The output is a 512x512 PNG + // with a gray channel (elevation) and an alpha channel (255 = data). + static List? transcodeTerrarium(List terrariumPng) { + final Uint8List bytes = terrariumPng is Uint8List + ? terrariumPng + : Uint8List.fromList(terrariumPng); + final src = img.decodePng(bytes); + if (src == null) return null; + + // Build a 512x512 grayscale-with-alpha output by sampling the source + // (typically 256x256) with nearest-neighbour scaling. + const int outSize = 512; + final out = img.Image(width: outSize, height: outSize, numChannels: 2); + final double sx = src.width / outSize; + final double sy = src.height / outSize; + + for (var oy = 0; oy < outSize; oy++) { + final int syi = (oy * sy).floor().clamp(0, src.height - 1); + for (var ox = 0; ox < outSize; ox++) { + final int sxi = (ox * sx).floor().clamp(0, src.width - 1); + final p = src.getPixel(sxi, syi); + final r = p.r.toInt(); + final g = p.g.toInt(); + final b = p.b.toInt(); + final double meters = r * 256.0 + g + b / 256.0 - 32768.0; + final double feet = meters * 3.28084; + // Terrarium has no alpha/no-data; treat deep-ocean sentinel as no-data + // so the app renders nothing there (matches US tiles' ocean handling). + final bool noData = meters <= -11000; // below the deepest ocean trench + final int gray = noData ? 0 : encodeGray(feet); + final int alpha = noData ? 0 : 255; + out.setPixelRgba(ox, oy, gray, gray, gray, alpha); + } + } + // Encode as gray+alpha PNG to match the US tiles' LA format. + return img.encodePng(out); + } + + static double _rad(double deg) => deg * math.pi / 180.0; +} diff --git a/lib/weather/winds_cache.dart b/lib/weather/winds_cache.dart index 15c41eeb..92fd3939 100644 --- a/lib/weather/winds_cache.dart +++ b/lib/weather/winds_cache.dart @@ -223,7 +223,6 @@ class WindsCache extends WeatherCache { } static String? locateNearestStation(LatLng location) { - // find distance GeoCalculations geo = GeoCalculations(); double distanceMin = double.maxFinite; String? station; @@ -237,6 +236,17 @@ class WindsCache extends WeatherCache { return station; } + // Coordinate of a winds-aloft station by its key. Tolerates a trailing + // forecast-hour suffix (e.g. "BOS06H") so callers can pass either form. + static LatLng? stationLatLng(String station) { + if (_stationMap.containsKey(station)) { + return _stationMap[station]; + } + final RegExpMatch? m = RegExp(r'^(.*?)(\d{2}H)?$').firstMatch(station); + final String bare = m?.group(1) ?? station; + return _stationMap[bare]; + } + // dir, speed static (double?, double?) getWindAtAltitude(double altitude, WindsAloft? w) { // find distance diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index c68b47ab..1608d688 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -7,9 +7,8 @@ #include "generated_plugin_registrant.h" #include -#include #include -#include +#include #include #include #include @@ -18,15 +17,12 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) audioplayers_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "AudioplayersLinuxPlugin"); audioplayers_linux_plugin_register_with_registrar(audioplayers_linux_registrar); - g_autoptr(FlPluginRegistrar) desktop_webview_auth_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "DesktopWebviewAuthPlugin"); - desktop_webview_auth_plugin_register_with_registrar(desktop_webview_auth_registrar); g_autoptr(FlPluginRegistrar) file_selector_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "FileSelectorPlugin"); file_selector_plugin_register_with_registrar(file_selector_linux_registrar); - g_autoptr(FlPluginRegistrar) gtk_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "GtkPlugin"); - gtk_plugin_register_with_registrar(gtk_registrar); + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 5594240e..560ca6e6 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -4,9 +4,8 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux - desktop_webview_auth file_selector_linux - gtk + flutter_secure_storage_linux sqlite3_flutter_libs syncfusion_pdfviewer_linux url_launcher_linux diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 63736a90..fd92b08f 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,22 +5,15 @@ import FlutterMacOS import Foundation -import app_links import audioplayers_darwin -import cloud_firestore -import desktop_webview_auth import device_info_plus import file_picker import file_selector_macos -import firebase_app_check -import firebase_auth -import firebase_core -import firebase_storage +import flutter_secure_storage_macos import geolocator_apple import in_app_review import package_info_plus import path_provider_foundation -import purchases_flutter import share_plus import sqflite_darwin import sqlite3_flutter_libs @@ -29,22 +22,15 @@ import url_launcher_macos import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { - AppLinksMacosPlugin.register(with: registry.registrar(forPlugin: "AppLinksMacosPlugin")) AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) - FLTFirebaseFirestorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseFirestorePlugin")) - DesktopWebviewAuthPlugin.register(with: registry.registrar(forPlugin: "DesktopWebviewAuthPlugin")) DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) - FLTFirebaseAppCheckPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAppCheckPlugin")) - FLTFirebaseAuthPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseAuthPlugin")) - FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) - FLTFirebaseStoragePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseStoragePlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) InAppReviewPlugin.register(with: registry.registrar(forPlugin: "InAppReviewPlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) - PurchasesFlutterPlugin.register(with: registry.registrar(forPlugin: "PurchasesFlutterPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png index 118ada6d..6dfa9166 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_1024.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png index f7772fa1..29cb6e9d 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_128.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png index 64ea65d2..b684814d 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_16.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png index 5ad7e3ae..48cd6928 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_256.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png index 84a182b9..a1c80a24 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_32.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png index bd219680..b266f68f 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_512.png differ diff --git a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png index c27fa5b6..b2c43e77 100644 Binary files a/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png and b/macos/Runner/Assets.xcassets/AppIcon.appiconset/app_icon_64.png differ diff --git a/pubspec.lock b/pubspec.lock index cf137642..b2fcb9f4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,14 +9,6 @@ packages: url: "https://pub.dev" source: hosted version: "88.0.0" - _flutterfire_internals: - dependency: transitive - description: - name: _flutterfire_internals - sha256: "8a1f5f3020ef2a74fb93f7ab3ef127a8feea33a7a2276279113660784ee7516a" - url: "https://pub.dev" - source: hosted - version: "1.3.64" analyzer: dependency: transitive description: @@ -33,38 +25,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.4" - app_links: - dependency: transitive - description: - name: app_links - sha256: "5f88447519add627fe1cbcab4fd1da3d4fed15b9baf29f28b22535c95ecee3e8" - url: "https://pub.dev" - source: hosted - version: "6.4.1" - app_links_linux: - dependency: transitive - description: - name: app_links_linux - sha256: f5f7173a78609f3dfd4c2ff2c95bd559ab43c80a87dc6a095921d96c05688c81 - url: "https://pub.dev" - source: hosted - version: "1.0.3" - app_links_platform_interface: - dependency: transitive - description: - name: app_links_platform_interface - sha256: "05f5379577c513b534a29ddea68176a4d4802c46180ee8e2e966257158772a3f" - url: "https://pub.dev" - source: hosted - version: "2.0.2" - app_links_web: - dependency: transitive - description: - name: app_links_web - sha256: af060ed76183f9e2b87510a9480e56a5352b6c249778d07bd2c95fc35632a555 - url: "https://pub.dev" - source: hosted - version: "1.0.4" archive: dependency: "direct main" description: @@ -281,30 +241,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" - cloud_firestore: - dependency: "direct main" - description: - name: cloud_firestore - sha256: fc1de79a62fe21615e9012f396070e6121838ef0d879475a4ec8320e79378208 - url: "https://pub.dev" - source: hosted - version: "6.1.0" - cloud_firestore_platform_interface: - dependency: transitive - description: - name: cloud_firestore_platform_interface - sha256: "2d2ee96a32ec3dd22fb682295e9bed6336e49a43f056d7841690228adca3ee7d" - url: "https://pub.dev" - source: hosted - version: "7.0.4" - cloud_firestore_web: - dependency: transitive - description: - name: cloud_firestore_web - sha256: "28c39f3d050bf669787ef13fa0890df2b4af236de864e2db0cc3897b857066cb" - url: "https://pub.dev" - source: hosted - version: "5.1.0" code_builder: dependency: transitive description: @@ -417,14 +353,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.11" - desktop_webview_auth: - dependency: transitive - description: - name: desktop_webview_auth - sha256: cd47d8cc97e2121adda213ea600470fd3e8d0e0967ed260b7d9362fc9df38c5c - url: "https://pub.dev" - source: hosted - version: "0.0.16" dev_build: dependency: transitive description: @@ -481,14 +409,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.3.9" - email_validator: - dependency: transitive - description: - name: email_validator - sha256: e9a90f27ab2b915a27d7f9c2a7ddda5dd752d6942616ee83529b686fc086221b - url: "https://pub.dev" - source: hosted - version: "2.1.17" equatable: dependency: transitive description: @@ -577,142 +497,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.9.3+5" - firebase_ai: - dependency: "direct main" - description: - name: firebase_ai - sha256: "7788c5be9ec66c5ce09e79e392c95f095eacfca149a3a8499f35c70df51f9907" - url: "https://pub.dev" - source: hosted - version: "3.6.0" - firebase_app_check: - dependency: transitive - description: - name: firebase_app_check - sha256: "4d00b502f510ee97cdb395e95a31a8b871fc96cb917ffc60591528d3c9735986" - url: "https://pub.dev" - source: hosted - version: "0.4.1+2" - firebase_app_check_platform_interface: - dependency: transitive - description: - name: firebase_app_check_platform_interface - sha256: "7d104d01b00e5dec367dc79184ad99bd1941f2d839b5ef41156b2330c18af13f" - url: "https://pub.dev" - source: hosted - version: "0.2.1+2" - firebase_app_check_web: - dependency: transitive - description: - name: firebase_app_check_web - sha256: "885a1a7b8e33c52afaf9c5d75eca616ae310d6ea90322e9a462f8c154ad16b64" - url: "https://pub.dev" - source: hosted - version: "0.2.2" - firebase_auth: - dependency: "direct main" - description: - name: firebase_auth - sha256: e54fb3ba57de041d832574126a37726eedf0f57400869f1942b0ca8ce4a6e209 - url: "https://pub.dev" - source: hosted - version: "6.1.2" - firebase_auth_platform_interface: - dependency: transitive - description: - name: firebase_auth_platform_interface - sha256: "421f95dc553cb283ed9d4d140e719800c0331d49ed37b962e513c9d1d61b090b" - url: "https://pub.dev" - source: hosted - version: "8.1.4" - firebase_auth_web: - dependency: transitive - description: - name: firebase_auth_web - sha256: a064ffee202f7d42d62e2c01775899d4ffcb83c602af07632f206acd46a0964e - url: "https://pub.dev" - source: hosted - version: "6.1.0" - firebase_core: - dependency: "direct main" - description: - name: firebase_core - sha256: "1f2dfd9f535d81f8b06d7a50ecda6eac1e6922191ed42e09ca2c84bd2288927c" - url: "https://pub.dev" - source: hosted - version: "4.2.1" - firebase_core_platform_interface: - dependency: transitive - description: - name: firebase_core_platform_interface - sha256: cccb4f572325dc14904c02fcc7db6323ad62ba02536833dddb5c02cac7341c64 - url: "https://pub.dev" - source: hosted - version: "6.0.2" - firebase_core_web: - dependency: transitive - description: - name: firebase_core_web - sha256: ff18fabb0ad0ed3595d2f2c85007ecc794aadecdff5b3bb1460b7ee47cded398 - url: "https://pub.dev" - source: hosted - version: "3.3.0" - firebase_storage: - dependency: "direct main" - description: - name: firebase_storage - sha256: "3438f38590186010ce76ece683ebf3b842cd637f31f83a13620917d7438a58fd" - url: "https://pub.dev" - source: hosted - version: "13.0.4" - firebase_storage_platform_interface: - dependency: transitive - description: - name: firebase_storage_platform_interface - sha256: "5d56021a9d30f7ca89559c96cc4c7250ce6ff8881382ff7238fde64a1f449e39" - url: "https://pub.dev" - source: hosted - version: "5.2.15" - firebase_storage_web: - dependency: transitive - description: - name: firebase_storage_web - sha256: a06775d1df6dd90f5fa3fe9e221b988dcbc221e73a0f8951136536e6d5e548e6 - url: "https://pub.dev" - source: hosted - version: "3.11.0" - firebase_ui_auth: - dependency: "direct main" - description: - name: firebase_ui_auth - sha256: "980dec84aabb6d2845381a517a225392244aea08a10a458a5242b0344cae914e" - url: "https://pub.dev" - source: hosted - version: "3.0.1" - firebase_ui_localizations: - dependency: transitive - description: - name: firebase_ui_localizations - sha256: "53a44bd518a34bf0830229ff7edc1b360b77741d85b1801f24846637a3440e0b" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - firebase_ui_oauth: - dependency: transitive - description: - name: firebase_ui_oauth - sha256: "4321f8aa4655fa0731da13a49c53357239d2aeff0deef599bf06fec072284a51" - url: "https://pub.dev" - source: hosted - version: "2.0.1" - firebase_ui_shared: - dependency: transitive - description: - name: firebase_ui_shared - sha256: "468fce5ba061f4443a5d382a9fef9f98e0fed02352118d5e3fd5279878d4261d" - url: "https://pub.dev" - source: hosted - version: "1.4.2" fixnum: dependency: transitive description: @@ -819,11 +603,6 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" - flutter_localizations: - dependency: transitive - description: flutter - source: sdk - version: "0.0.0" flutter_map: dependency: "direct main" description: @@ -864,14 +643,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.29" - flutter_svg: + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "9cad52d75ebc511adfae3d447d5d13da15a55a92c9410e50f67335b6d21d16ea" + url: "https://pub.dev" + source: hosted + version: "9.2.4" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: be76c1d24a97d0b98f8b54bce6b481a380a6590df992d0098f868ad54dc8f688 + url: "https://pub.dev" + source: hosted + version: "1.2.3" + flutter_secure_storage_macos: dependency: transitive description: - name: flutter_svg - sha256: "87fbd7c534435b6c5d9d98b01e1fd527812b82e68ddd8bd35fc45ed0fa8f0a95" + name: flutter_secure_storage_macos + sha256: "6c0a2795a2d1de26ae202a0d78527d163f4acbb11cde4c75c670f3a0fc064247" url: "https://pub.dev" source: hosted - version: "2.2.3" + version: "3.1.3" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: cf91ad32ce5adef6fba4d736a542baca9daf3beac4db2d04be350b87f69ac4a8 + url: "https://pub.dev" + source: hosted + version: "1.1.2" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: f4ebff989b4f07b2656fb16b47852c0aab9fed9b4ec1c70103368337bc1886a9 + url: "https://pub.dev" + source: hosted + version: "1.2.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: b20b07cb5ed4ed74fc567b78a72936203f587eba460af1df11281c9326cd3709 + url: "https://pub.dev" + source: hosted + version: "3.1.2" flutter_test: dependency: "direct dev" description: flutter @@ -1007,14 +826,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.2.8" - gtk: - dependency: transitive - description: - name: gtk - sha256: e8ce9ca4b1df106e4d72dad201d345ea1a036cc12c360f1a7d5a758f78ffa42c - url: "https://pub.dev" - source: hosted - version: "2.1.0" html: dependency: "direct main" description: @@ -1160,10 +971,10 @@ packages: dependency: "direct main" description: name: intl - sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" + sha256: "1ca20c894b1717686a2319b8548763d812bc0aabdac580420a44c5178c57a867" url: "https://pub.dev" source: hosted - version: "0.20.2" + version: "0.20.3" introduction_screen: dependency: "direct main" description: @@ -1180,6 +991,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: f2c445dce49627136094980615a031419f7f3eb393237e4ecd97ac15dea343f3 + url: "https://pub.dev" + source: hosted + version: "0.6.7" json_annotation: dependency: transitive description: @@ -1256,10 +1075,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.20" material_color_utilities: dependency: transitive description: @@ -1280,10 +1099,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" url: "https://pub.dev" source: hosted - version: "1.17.0" + version: "1.19.0" mgrs_dart: dependency: transitive description: @@ -1366,14 +1185,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" - path_parsing: - dependency: transitive - description: - name: path_parsing - sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" - url: "https://pub.dev" - source: hosted - version: "1.1.0" path_provider: dependency: "direct main" description: @@ -1542,30 +1353,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.5.0" - purchases_flutter: - dependency: "direct main" - description: - name: purchases_flutter - sha256: d91ca4d2abaca098af7b9e5736490950811c0f2e9c2b236f6a467ff52ecdfd39 - url: "https://pub.dev" - source: hosted - version: "9.9.9" - purchases_flutter_ui: - dependency: "direct main" - description: - name: purchases_flutter_ui - sha256: "8454598b9c847da05156ef413cb2c1e2d62c170e3fa289ea89767758a20c4f44" - url: "https://pub.dev" - source: hosted - version: "0.0.1" - purchases_ui_flutter: - dependency: "direct main" - description: - name: purchases_ui_flutter - sha256: "0916258357fb016b020d65e660e028cf0501038eaf870ddd37803f891e86c8dd" - url: "https://pub.dev" - source: hosted - version: "9.9.9" rxdart: dependency: transitive description: @@ -1847,10 +1634,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.12" timing: dependency: transitive description: @@ -1892,7 +1679,7 @@ packages: source: hosted version: "2.3.1" url_launcher: - dependency: transitive + dependency: "direct main" description: name: url_launcher sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 @@ -1971,30 +1758,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.2" - vector_graphics: - dependency: transitive - description: - name: vector_graphics - sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6 - url: "https://pub.dev" - source: hosted - version: "1.1.19" - vector_graphics_codec: - dependency: transitive - description: - name: vector_graphics_codec - sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" - url: "https://pub.dev" - source: hosted - version: "1.1.13" - vector_graphics_compiler: - dependency: transitive - description: - name: vector_graphics_compiler - sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc - url: "https://pub.dev" - source: hosted - version: "1.1.19" vector_map_tiles: dependency: "direct main" description: @@ -2007,10 +1770,10 @@ packages: dependency: transitive description: name: vector_math - sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 url: "https://pub.dev" source: hosted - version: "2.2.0" + version: "2.4.2" vector_tile: dependency: "direct main" description: @@ -2156,5 +1919,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.10.0 <4.0.0" + dart: ">=3.11.0-0 <4.0.0" flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index 74302fb8..d6f62d14 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 # 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. -version: 0.0.118+118 +version: 0.0.122+122 environment: sdk: '>=3.2.0 <4.0.0' @@ -63,6 +63,7 @@ dependencies: widget_zoom: ^0.0.3 xml: ^6.5.0 flutter_keyboard_visibility: ^6.0.0 + flutter_secure_storage: ^9.2.4 html: ^0.15.4 image: ^4.1.7 share_plus: ^11.1.0 @@ -93,15 +94,7 @@ dependencies: ref: main yaml: any - firebase_core: ^4.2.1 - firebase_auth: ^6.1.2 - firebase_ui_auth: ^3.0.1 - firebase_ai: ^3.6.0 - firebase_storage: ^13.0.4 - cloud_firestore: ^6.0.0 - purchases_flutter: ^9.9.9 - purchases_flutter_ui: ^0.0.1 - purchases_ui_flutter: ^9.9.9 + url_launcher: ^6.3.0 flutter_launcher_icons: android: "launcher_icon" ios: true diff --git a/test/aip_aero_test.dart b/test/aip_aero_test.dart new file mode 100644 index 00000000..b3979f8f --- /dev/null +++ b/test/aip_aero_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/aip/aip_aero.dart'; + +void main() { + group('AipAero.urlForAirport', () { + test('builds vfr deep links for supported countries', () { + expect(AipAero.urlForAirport('EDDF'), 'https://aip.aero/de/vfr/?EDDF'); + expect(AipAero.urlForAirport('LOWW'), 'https://aip.aero/at/vfr/?LOWW'); + expect(AipAero.urlForAirport('ESSA'), 'https://aip.aero/se/vfr/?ESSA'); + expect(AipAero.urlForAirport('LSGG'), 'https://aip.aero/ch/vfr/?LSGG'); + expect(AipAero.urlForAirport('EGLL'), 'https://aip.aero/uk/vfr/?EGLL'); + expect(AipAero.urlForAirport('EIDW'), 'https://aip.aero/ie/vfr/?EIDW'); + expect(AipAero.urlForAirport('EPWA'), 'https://aip.aero/pl/vfr/?EPWA'); + expect(AipAero.urlForAirport('UKBB'), 'https://aip.aero/ua/vfr/?UKBB'); + expect(AipAero.urlForAirport('YSSY'), 'https://aip.aero/au/vfr/?YSSY'); + expect(AipAero.urlForAirport('NZAA'), 'https://aip.aero/nz/vfr/?NZAA'); + }); + + test('Spanish Canary Islands (GC) resolve to Spain', () { + expect(AipAero.urlForAirport('GCTS'), 'https://aip.aero/es/vfr/?GCTS'); + }); + + test('German military (ET) resolves to Germany', () { + expect(AipAero.urlForAirport('ETAR'), 'https://aip.aero/de/vfr/?ETAR'); + }); + + test('countries without a guessable slug fall back to the landing page', () { + // France and Belgium/Luxembourg do not expose the vfr airport slug. + expect(AipAero.urlForAirport('LFPG'), 'https://aip.aero/fr/'); + expect(AipAero.urlForAirport('EBBR'), 'https://aip.aero/be/'); + expect(AipAero.urlForAirport('ELLX'), 'https://aip.aero/be/'); // Luxembourg + }); + + test('disambiguates the shared UT* central-Asia block', () { + expect(AipAero.urlForAirport('UTAA'), 'https://aip.aero/tm/vfr/?UTAA'); // Turkmenistan + expect(AipAero.urlForAirport('UTDD'), 'https://aip.aero/tj/vfr/?UTDD'); // Tajikistan + expect(AipAero.urlForAirport('UTTT'), 'https://aip.aero/uz/vfr/?UTTT'); // Uzbekistan + }); + + test('normalizes case and surrounding whitespace', () { + expect(AipAero.urlForAirport(' eddf '), 'https://aip.aero/de/vfr/?EDDF'); + }); + + test('falls back to the homepage for unknown or invalid identifiers', () { + expect(AipAero.urlForAirport('KJFK'), 'https://aip.aero/'); // US, not covered + expect(AipAero.urlForAirport(''), 'https://aip.aero/'); + expect(AipAero.urlForAirport('X'), 'https://aip.aero/'); + }); + }); + + group('AipAero.hasChartsFor', () { + test('true for covered European/Oceania identifiers', () { + expect(AipAero.hasChartsFor('EDDF'), isTrue); + expect(AipAero.hasChartsFor('LFPG'), isTrue); // covered via landing page + expect(AipAero.hasChartsFor('YSSY'), isTrue); + }); + + test('false for uncovered identifiers', () { + expect(AipAero.hasChartsFor('KJFK'), isFalse); // United States + expect(AipAero.hasChartsFor(''), isFalse); + }); + }); +} diff --git a/test/decoded_metar_view_test.dart b/test/decoded_metar_view_test.dart new file mode 100644 index 00000000..671c3df1 --- /dev/null +++ b/test/decoded_metar_view_test.dart @@ -0,0 +1,48 @@ +import 'package:avaremp/weather/metar.dart'; +import 'package:avaremp/weather/decoded_metar_view.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +// Widget-level smoke test for the decoded METAR card: verifies it renders the +// decoded elements and the VFR/IFR selector without needing the emulator UI. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + Metar makeMetar(String raw) => Metar( + 'EGLL', + DateTime.now().toUtc().add(const Duration(hours: 1)), + DateTime.now().toUtc(), + 'Internet', + raw, + Metar.getCategory(raw), + const LatLng(51.47, -0.45), + ); + + testWidgets('DecodedMetarView shows decoded elements and profile toggle', + (tester) async { + final metar = makeMetar( + 'METAR EGLL 240920Z 10018G28KT 3000 BR OVC008 12/11 Q1004'); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: SingleChildScrollView(child: DecodedMetarView(metar: metar)), + ), + )); + await tester.pumpAndSettle(); + + expect(find.text('Decoded METAR'), findsOneWidget); + // Profile selector present. + expect(find.text('VFR'), findsWidgets); + expect(find.text('IFR'), findsWidgets); + // Some decoded rows present. + expect(find.text('Wind'), findsOneWidget); + expect(find.text('Ceiling'), findsOneWidget); + expect(find.textContaining('gusting 28 kt'), findsOneWidget); + + // Tapping VFR should not throw and should keep the card rendered. + await tester.tap(find.text('VFR').first); + await tester.pumpAndSettle(); + expect(find.text('Decoded METAR'), findsOneWidget); + }); +} diff --git a/test/fixtures/ofm/airport_sample.ofmx b/test/fixtures/ofm/airport_sample.ofmx new file mode 100644 index 00000000..7932c11d --- /dev/null +++ b/test/fixtures/ofm/airport_sample.ofmx @@ -0,0 +1,11 @@ + + + + EBAW + ANTWERPEN/DEURNEEBAWANREBDEURNEAH + 51.18945833N004.46039722E39FTANTWERPEN-1.024500FT + + EBAWRADIO1135.2A/A outside HOR TWR + EBAW11/29151045MASPHGOOD + EBAW11/291151.19186111N004.454775E109.811239FTPAPIE + diff --git a/test/fixtures/ofm/airspace_sample.ofmx b/test/fixtures/ofm/airspace_sample.ofmx new file mode 100644 index 00000000..2378d67a --- /dev/null +++ b/test/fixtures/ofm/airspace_sample.ofmx @@ -0,0 +1,9 @@ + + + CTRLFBGCOGNACDALT1500FTHEI0FTYFixture airspace + CTRLFBG + GRC45.70000000N000.40000000WWGE + CCA45.80000000N000.30000000W45.75000000N000.35000000WWGE + GRC45.70000000N000.20000000WWGE + + diff --git a/test/fixtures/terrarium_alps_10_534_362.png b/test/fixtures/terrarium_alps_10_534_362.png new file mode 100644 index 00000000..18193e60 Binary files /dev/null and b/test/fixtures/terrarium_alps_10_534_362.png differ diff --git a/test/flybrief_notams_test.dart b/test/flybrief_notams_test.dart new file mode 100644 index 00000000..ac5fcf42 --- /dev/null +++ b/test/flybrief_notams_test.dart @@ -0,0 +1,182 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/weather/flybrief_notams.dart'; + +String feature({ + required String id, + String category = 'danger', + String text = 'TEST NOTAM', + bool activeNow = false, + bool perm = false, + String? al = 'GND', + String? ah = 'FL032', + List>? polygon, // [lon,lat] ring +}) { + final props = { + 'id': id, + 'category': category, + 'text': text, + 'raw': 'RAW $id', + 'al': al, + 'ah': ah, + 'start': '2026-07-20T06:30:00+00:00', + 'end': '2026-10-20T10:00:00+00:00', + 'schedule': '0435-1836', + 'perm': perm, + 'active_now': activeNow, + 'hp': false, + 'radius_nm': 3, + }; + final geom = polygon == null + ? null + : {'type': 'Polygon', 'coordinates': [polygon]}; + return jsonEncode({'type': 'Feature', 'geometry': geom, 'properties': props}); +} + +String collection(List feats) => + '{"type":"FeatureCollection","features":[${feats.join(',')}]}'; + +void main() { + group('country resolution', () { + test('forPoint picks Germany for Frankfurt', () { + final c = FlybriefNotams.forPoint(50.03, 8.55); + expect(c?.iso2, 'DE'); + }); + + test('forPoint picks France for Paris', () { + final c = FlybriefNotams.forPoint(48.85, 2.35); + expect(c?.iso2, 'FR'); + }); + + test('forPoint returns null for the mid-Atlantic', () { + expect(FlybriefNotams.forPoint(30.0, -40.0), isNull); + }); + + test('byIso is case-insensitive', () { + expect(FlybriefNotams.byIso('se')?.path, 'Sweden'); + }); + }); + + group('URL building', () { + test('notam and obstacle URLs follow the FlyBrief scheme', () { + final c = FlybriefNotams.byIso('DE')!; + expect(FlybriefNotams.notamUrl(c).toString(), + 'https://flybrief.app/Airspace/EU/Germany/germany_notams.geojson'); + expect(FlybriefNotams.obstacleUrl(c).toString(), + 'https://flybrief.app/Airspace/EU/Germany/germany_obstacles.geojson'); + }); + }); + + group('parse', () { + test('parses features and computes polygon centroid', () { + final body = collection([ + feature(id: 'D1/26', polygon: [ + [8.0, 50.0], + [8.0, 51.0], + [9.0, 51.0], + [9.0, 50.0], + [8.0, 50.0], + ]), + ]); + final list = FlybriefNotams.parse(body); + expect(list.length, 1); + final n = list.first; + expect(n.id, 'D1/26'); + expect(n.lat, isNotNull); + expect(n.lon, isNotNull); + // Centroid should be inside the box. + expect(n.lat! > 50 && n.lat! < 51, isTrue); + expect(n.lon! > 8 && n.lon! < 9, isTrue); + }); + + test('tolerates null geometry (non-precise NOTAM)', () { + final body = collection([feature(id: 'D2/26', polygon: null)]); + final list = FlybriefNotams.parse(body); + expect(list.length, 1); + expect(list.first.lat, isNull); + }); + + test('skips features without id and malformed json', () { + expect(FlybriefNotams.parse('not json'), isEmpty); + final body = collection(['{"type":"Feature","properties":{}}']); + expect(FlybriefNotams.parse(body), isEmpty); + }); + }); + + group('nearby', () { + List sample() => FlybriefNotams.parse(collection([ + // near Frankfurt (~50.0, 8.5) + feature(id: 'NEAR', polygon: [ + [8.4, 49.9], + [8.4, 50.1], + [8.6, 50.1], + [8.6, 49.9], + [8.4, 49.9], + ]), + // far (Berlin ~52.5, 13.4) + feature(id: 'FAR', polygon: [ + [13.3, 52.4], + [13.3, 52.6], + [13.5, 52.6], + [13.5, 52.4], + [13.3, 52.4], + ]), + // no geometry -> always included + feature(id: 'NOGEO', polygon: null), + // near AND active -> should sort first + feature(id: 'ACTIVE', activeNow: true, polygon: [ + [8.45, 49.95], + [8.45, 50.05], + [8.55, 50.05], + [8.55, 49.95], + [8.45, 49.95], + ]), + ])); + + test('filters out far NOTAMs within radius', () { + final near = FlybriefNotams.nearby(sample(), 50.0, 8.5, radiusNm: 50); + final ids = near.map((e) => e.id).toSet(); + expect(ids.contains('NEAR'), isTrue); + expect(ids.contains('ACTIVE'), isTrue); + expect(ids.contains('NOGEO'), isTrue); // no geometry always included + expect(ids.contains('FAR'), isFalse); // Berlin is >100 nm away + }); + + test('active NOTAMs sort before inactive', () { + final near = FlybriefNotams.nearby(sample(), 50.0, 8.5, radiusNm: 50); + expect(near.first.activeNow, isTrue); + }); + }); + + group('formatting', () { + test('toLine includes id, category, active flag, altitudes and text', () { + final n = FlybriefNotams.parse(collection([ + feature(id: 'D9/26', category: 'danger', text: 'BLASTING', activeNow: true), + ])).first; + final line = n.toLine(); + expect(line, contains('NOTAM D9/26')); + expect(line, contains('[DANGER]')); + expect(line, contains('(ACTIVE)')); + expect(line, contains('GND')); + expect(line, contains('FL032')); + expect(line, contains('BLASTING')); + }); + + test('permanent NOTAM shows PERM instead of a date range', () { + final n = FlybriefNotams.parse(collection([ + feature(id: 'P1/26', perm: true), + ])).first; + expect(n.toLine(), contains('PERM')); + }); + }); + + group('distance', () { + test('distanceNm Frankfurt to Paris is ~250-300 nm', () { + final d = FlybriefNotams.distanceNm(50.03, 8.55, 48.85, 2.35); + expect(d, greaterThan(230)); + expect(d, lessThan(320)); + }); + }); +} diff --git a/test/layers_default_test.dart b/test/layers_default_test.dart new file mode 100644 index 00000000..0b861860 --- /dev/null +++ b/test/layers_default_test.dart @@ -0,0 +1,78 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/data/app_settings.dart'; +import 'package:avaremp/ofm/ofm_constants.dart'; +import 'package:avaremp/openaip/openaip_constants.dart'; + +// Locks in the "Europe map layers ON by default for fresh installs" behavior +// without needing the full settings database. The layer ORDER is asserted +// against the canonical getLayers() default list so the hard-coded opacity +// indices can never silently drift. +void main() { + // Canonical fresh-install layer order (mirrors AppSettings.getLayers default). + const layerOrder = [ + 'Nav', + 'Circles', + 'Chart', + 'Topo', + 'Vector Map', + OfmConstants.layerName, // OFM VFR Chart + OfmConstants.dataLayerName, // OFM Interactive Data + OpenAipConstants.dataLayerName, // openAIP Interactive Data + 'CAP Grid', + 'Elevation', + 'Weather', + 'TFR', + 'Game TFR', + 'Plate', + 'Traffic', + 'Obstacles', + 'Tape', + 'GeoJSON', + 'PFD', + 'Tracks', + ]; + + List parse(String s) => + s.split(',').map((e) => double.parse(e)).toList(); + + test('fresh install turns the three Europe layers ON and nothing else new', () { + final opacity = parse( + AppSettings.resolveLayersOpacityDefault(false, 'ignored-when-fresh')); + + expect(opacity.length, layerOrder.length); + + double at(String name) => opacity[layerOrder.indexOf(name)]; + + // Europe-relevant layers ON. + expect(at(OfmConstants.layerName), 1.0, reason: 'OFM VFR Chart on'); + expect(at(OfmConstants.dataLayerName), 1.0, reason: 'OFM Interactive Data on'); + expect(at(OpenAipConstants.dataLayerName), 1.0, + reason: 'openAIP Interactive Data on'); + + // Pre-existing US defaults preserved. + expect(at('Nav'), 1.0); + expect(at('Chart'), 1.0); + expect(at('Topo'), 1.0); + + // Everything else remains OFF by default. + for (final name in [ + 'Circles', 'Vector Map', 'CAP Grid', 'Elevation', 'Weather', 'TFR', + 'Game TFR', 'Plate', 'Traffic', 'Obstacles', 'Tape', 'GeoJSON', 'PFD', + 'Tracks', + ]) { + expect(at(name), 0.0, reason: '$name stays off'); + } + }); + + test('existing user preference is preserved (no forced Europe-on)', () { + // A user who had everything but Nav off keeps exactly that. + const saved = '1,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0'; + expect(AppSettings.resolveLayersOpacityDefault(true, saved), saved); + }); + + test('europe-on default matches the documented constant', () { + expect(AppSettings.resolveLayersOpacityDefault(false, ''), + AppSettings.europeOnLayersOpacityDefault); + }); +} diff --git a/test/map_controller_guard_test.dart b/test/map_controller_guard_test.dart new file mode 100644 index 00000000..41230cec --- /dev/null +++ b/test/map_controller_guard_test.dart @@ -0,0 +1,12 @@ +import 'package:flutter_map/flutter_map.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/utils/map_controller_guard.dart'; + +void main() { + test('does not read camera before FlutterMap attaches the controller', () { + final controller = MapController(); + + expect(MapControllerGuard.cameraIfReady(controller, false), isNull); + }); +} diff --git a/test/metar_decoder_test.dart b/test/metar_decoder_test.dart new file mode 100644 index 00000000..f6eb7cc5 --- /dev/null +++ b/test/metar_decoder_test.dart @@ -0,0 +1,150 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/weather/metar_decoder.dart'; + +void main() { + WxElement? byLabel(List els, String label) { + for (final e in els) { + if (e.label == label) return e; + } + return null; + } + + group('MetarDecoder.decode elements', () { + test('decodes wind with gusts', () { + const raw = 'METAR EGLL 240920Z 10012G22KT 9999 BKN030 18/11 Q1021'; + final wind = byLabel(MetarDecoder.decode(raw, WxProfile.vfr), 'Wind'); + expect(wind, isNotNull); + expect(wind!.value, 'From 100° at 12 kt, gusting 22 kt'); + }); + + test('decodes variable and calm wind', () { + final vrb = byLabel( + MetarDecoder.decode('METAR KXYZ 010000Z VRB03KT 10SM CLR 20/10 A2992', + WxProfile.vfr), + 'Wind'); + expect(vrb!.value, 'Variable at 3 kt'); + final calm = byLabel( + MetarDecoder.decode('METAR KXYZ 010000Z 00000KT 10SM CLR 20/10 A2992', + WxProfile.vfr), + 'Wind'); + expect(calm!.value, 'Calm'); + }); + + test('converts m/s wind to knots', () { + final wind = byLabel( + MetarDecoder.decode('METAR ULLI 010000Z 09010MPS 9999 SCT040 05/01 Q1013', + WxProfile.vfr), + 'Wind'); + expect(wind!.value, 'From 090° at 19 kt'); // 10 m/s ≈ 19.4 kt + }); + + test('decodes statute-mile and metric visibility', () { + final sm = byLabel( + MetarDecoder.decode('METAR KJFK 010000Z 09010KT 1 1/2SM BR OVC004 12/11 A2990', + WxProfile.ifr), + 'Visibility'); + expect(sm!.value, '1.5 SM'); + + final metric = byLabel( + MetarDecoder.decode('METAR EDDF 010000Z 09010KT 0800 FG OVC002 05/05 Q1013', + WxProfile.ifr), + 'Visibility'); + expect(metric!.value, '800 m'); + + final cavok = byLabel( + MetarDecoder.decode('METAR LOWW 010000Z 09010KT CAVOK 20/07 Q1021', + WxProfile.vfr), + 'Visibility'); + expect(cavok!.value, 'CAVOK (ceiling and visibility OK)'); + }); + + test('decodes ceiling and reports sky clear', () { + final ceil = byLabel( + MetarDecoder.decode('METAR KABC 010000Z 09010KT 5SM OVC012 10/05 A2990', + WxProfile.vfr), + 'Ceiling'); + expect(ceil!.value, '1200 ft AGL'); + + final clear = byLabel( + MetarDecoder.decode('METAR KABC 010000Z 09010KT 10SM CLR 10/05 A2990', + WxProfile.vfr), + 'Ceiling'); + expect(clear!.value, 'No ceiling (sky clear)'); + }); + + test('decodes present weather phenomena', () { + final wx = byLabel( + MetarDecoder.decode('METAR KABC 010000Z 09010KT 2SM +TSRA BKN008 18/17 A2990', + WxProfile.vfr), + 'Weather'); + expect(wx, isNotNull); + expect(wx!.value.toLowerCase(), contains('thunderstorm')); + expect(wx.value.toLowerCase(), contains('rain')); + expect(wx.threat, WxThreat.hazard); + }); + + test('decodes temperature/dewpoint with negative values', () { + final t = byLabel( + MetarDecoder.decode('METAR ENGM 010000Z 09010KT 9999 SCT030 M02/M05 Q1013', + WxProfile.vfr), + 'Temp / Dewpoint'); + expect(t!.value, '-2°C / -5°C (spread 3°C)'); + }); + + test('decodes QNH and altimeter pressure', () { + final q = byLabel( + MetarDecoder.decode('METAR EGLL 010000Z 09010KT 9999 SCT030 18/11 Q1021', + WxProfile.vfr), + 'Pressure'); + expect(q!.value, 'QNH 1021 hPa'); + + final a = byLabel( + MetarDecoder.decode('METAR KJFK 010000Z 09010KT 10SM SCT030 18/11 A2992', + WxProfile.vfr), + 'Pressure'); + expect(a!.value, 'Altimeter 29.92 inHg'); + }); + }); + + group('MetarDecoder profile-dependent threat coloring', () { + // 2500 ft ceiling, 4 SM: MVFR. Caution for VFR, fine for IFR. + const marginal = 'METAR KABC 010000Z 09010KT 4SM BR OVC025 12/09 A2990'; + + test('MVFR ceiling/vis is caution for VFR but none for IFR', () { + final vfr = MetarDecoder.decode(marginal, WxProfile.vfr); + final ifr = MetarDecoder.decode(marginal, WxProfile.ifr); + + expect(byLabel(vfr, 'Flight category')!.threat, WxThreat.caution); + expect(byLabel(ifr, 'Flight category')!.threat, WxThreat.none); + + expect(byLabel(vfr, 'Ceiling')!.threat, WxThreat.caution); + expect(byLabel(ifr, 'Ceiling')!.threat, WxThreat.none); + + expect(byLabel(vfr, 'Visibility')!.threat, WxThreat.caution); + expect(byLabel(ifr, 'Visibility')!.threat, WxThreat.none); + }); + + test('same wind rated harsher under VFR thresholds', () { + const windy = 'METAR KABC 010000Z 09018KT 10SM SCT050 20/05 A2992'; + final vfr = byLabel(MetarDecoder.decode(windy, WxProfile.vfr), 'Wind'); + final ifr = byLabel(MetarDecoder.decode(windy, WxProfile.ifr), 'Wind'); + expect(vfr!.threat, WxThreat.caution); // ≥15 kt VFR caution + expect(ifr!.threat, WxThreat.none); // <25 kt IFR fine + }); + + test('low IFR conditions are hazard even for IFR profile', () { + const lowIfr = 'METAR KABC 010000Z 09010KT 1/4SM FG VV002 10/10 A2990'; + final ifr = MetarDecoder.decode(lowIfr, WxProfile.ifr); + expect(byLabel(ifr, 'Visibility')!.threat, WxThreat.hazard); + expect(byLabel(ifr, 'Ceiling')!.threat, WxThreat.hazard); + }); + }); + + test('always emits a flight-category element first', () { + final els = MetarDecoder.decode( + 'METAR EGLL 240920Z 10012KT 9999 BKN050 18/11 Q1021', WxProfile.vfr); + expect(els.first.label, 'Flight category'); + expect(els.first.value, 'VFR'); + }); +} diff --git a/test/ofm_airspace_layer_test.dart b/test/ofm_airspace_layer_test.dart new file mode 100644 index 00000000..ee471f14 --- /dev/null +++ b/test/ofm_airspace_layer_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:avaremp/ofm/ofm_airspace_layer.dart'; +import 'package:avaremp/ofm/ofm_data_provider.dart'; + +void main() { + test('converts OFM airspace vertices to a labelled polygon', () { + const airspace = OfmAirspace( + id: 's', codeId: 'LFBG', name: 'COGNAC', airspaceClass: 'D', + region: 'LF', cycle: '2601', lowerFeet: 0, upperFeet: 1500, + vertices: [LatLng(45.7, -0.4), LatLng(45.8, -0.3), LatLng(45.7, -0.2)], + ); + final polygons = OfmAirspaceLayer.polygons([airspace], opacity: 0.5); + + expect(polygons, hasLength(1)); + expect(polygons.single.points, hasLength(3)); + expect(polygons.single.label, contains('LFBG')); + }); + + test('interpolates clockwise OFMX arc vertices', () { + const vertices = [ + OfmAirspaceVertex(point: LatLng(45.7, -0.4), codeType: 'GRC'), + OfmAirspaceVertex( + point: LatLng(45.8, -0.3), + codeType: 'CWA', + arcCenter: LatLng(45.75, -0.35), + ), + OfmAirspaceVertex(point: LatLng(45.7, -0.2), codeType: 'GRC'), + ]; + + final points = OfmAirspaceLayer.expandVertices(vertices); + + expect(points.length, greaterThan(3)); + expect(points.first, const LatLng(45.7, -0.4)); + expect(points.last, const LatLng(45.7, -0.2)); + }); +} diff --git a/test/ofm_constants_test.dart b/test/ofm_constants_test.dart new file mode 100644 index 00000000..2a7ef433 --- /dev/null +++ b/test/ofm_constants_test.dart @@ -0,0 +1,13 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/ofm/ofm_constants.dart'; + +void main() { + test('OFM constants include required attribution and disclaimer wording', () { + expect(OfmConstants.sourceName, 'OpenFlightMaps'); + expect(OfmConstants.attribution.toLowerCase(), contains('open flightmaps')); + expect(OfmConstants.disclaimer.toLowerCase(), contains('complementary')); + expect(OfmConstants.disclaimer.toLowerCase(), contains('not a primary navigation source')); + expect(OfmConstants.corrections.toLowerCase(), contains('report')); + }); +} diff --git a/test/ofm_data_provider_test.dart b/test/ofm_data_provider_test.dart new file mode 100644 index 00000000..b1aed53f --- /dev/null +++ b/test/ofm_data_provider_test.dart @@ -0,0 +1,133 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:avaremp/ofm/ofm_data_provider.dart'; +import 'package:avaremp/ofm/ofm_schema.dart'; + +void main() { + late Database database; + + setUpAll(sqfliteFfiInit); + setUp(() async { + database = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + for (final statement in OfmSchema.createStatements) { + await database.execute(statement); + } + await database.insert('ofm_airport', { + 'id': 'airport-1', 'region': 'EB', 'cycle': '2601', 'code_id': 'EBAW', + 'name': 'ANTWERPEN/DEURNE', 'type': 'AH', 'lat': 51.18945833, + 'lon': 4.46039722, 'elevation_ft': 39.0, 'source': 'OFM', + }); + await database.insert('ofm_airport_comm', { + 'id': 'comm-1', 'airport_id': 'airport-1', 'code_type': 'RADIO', + 'value': '135.2', 'sequence': 1, + }); + await database.insert('ofm_runway', { + 'id': 'runway-1', 'airport_id': 'airport-1', 'designation': '11/29', + 'length_m': 1510.0, 'width_m': 45.0, 'surface': 'ASPH', + }); + await database.insert('ofm_runway_end', { + 'id': 'end-11', 'runway_id': 'runway-1', 'designation': '11', + 'lat': 51.19186111, 'lon': 4.454775, 'true_bearing': 109.8, + 'mag_bearing': 112.0, 'tdze_ft': 39.0, 'pattern': 'E', + 'vasi_type': 'PAPI', + }); + await database.insert('ofm_waypoint', { + 'id': 'vor-1', 'region': 'EB', 'cycle': '2601', 'raw_mid': 'vor-1', + 'code_id': 'BUN', 'kind': 'VOR', 'type': 'VOR', 'name': 'BRUSSELS', + 'lat': 50.9, 'lon': 4.5, 'frequency': '110.6 MHZ', 'mag_var': 2.0, + }); + await database.insert('ofm_waypoint', { + 'id': 'fix-1', 'region': 'EB', 'cycle': '2601', 'raw_mid': 'fix-1', + 'code_id': 'N2', 'kind': 'FIX', 'type': 'VFR-MRP', 'name': 'NOVEMBER2', + 'lat': 51.0, 'lon': 4.6, 'airport_code': 'EBAW', + }); + }); + tearDown(() => database.close()); + + test('finds source-aware OFM airport results by prefix', () async { + final provider = OfmDataProvider(database: database); + final results = await provider.findDestinations('EBA'); + + expect(results, hasLength(1)); + expect(results.single.locationID, 'EBAW'); + expect(results.single.source, 'OFM'); + expect(results.single.sourceRegion, 'EB'); + }); + + test('loads OFM airport details and nearby airports', () async { + final provider = OfmDataProvider(database: database); + final airport = await provider.findAirport('EBAW'); + final nearby = await provider.findNear(const LatLng(51.19, 4.46), factor: 0.01); + + expect(airport, isNotNull); + expect(airport!.frequencies.single['Frequency'], '135.2'); + expect(airport.runways.single['RunwayID'], '11/29'); + expect(airport.runways.single['LEIdent'], '11'); + expect(airport.runways.single['LELatitude'], '51.19186111'); + expect(airport.runways.single['LELongitude'], '4.454775'); + expect(airport.runways.single['LEVGSI'], 'PAPI'); + expect(nearby.single.locationID, 'EBAW'); + }); + + test('finds nearest OFM airports meeting runway length', () async { + final provider = OfmDataProvider(database: database); + final results = await provider.findNearestAirportsWithRunways( + const LatLng(51.19, 4.46), + 4000, + ); + + expect(results.map((item) => item.locationID), contains('EBAW')); + expect(await provider.findNearestAirportsWithRunways( + const LatLng(51.19, 4.46), + 6000, + ), isEmpty); + }); + + test('searches OFM navaids and designated points', () async { + final provider = OfmDataProvider(database: database); + final vor = await provider.findDestinations('BUN'); + final point = await provider.findDestinations('NOVEMBER'); + + expect(vor.single.type, 'VOR'); + expect(vor.single.locationID, 'BUN'); + expect(point.single.type, 'FIX'); + expect(point.single.locationID, 'N2'); + }); + + test('finds nearby OFM airports, navaids, and points', () async { + final provider = OfmDataProvider(database: database); + final results = await provider.findNear(const LatLng(51.0, 4.6), factor: 0.1); + + expect(results.map((item) => item.locationID), containsAll(['EBAW', 'BUN', 'N2'])); + }); + + test('finds nearest OFM VORs as fully populated nav destinations', () async { + final provider = OfmDataProvider(database: database); + final results = await provider.findNearestVOR(const LatLng(51.0, 4.6)); + + expect(results.single.locationID, 'BUN'); + expect(results.single.source, 'OFM'); + expect(results.single.class_, '110.6 MHZ'); + }); + + test('finds an enclosing airspace when its vertices are outside the viewport', () async { + await database.insert('ofm_airspace', { + 'id': 'space-1', 'region': 'EB', 'cycle': '2601', 'code_id': 'EBR', + 'code_type': 'R', 'class': 'R', 'name': 'ENCLOSING', + }); + for (final entry in <(double, double)>[(50.0, 3.0), (52.0, 3.0), (52.0, 6.0), (50.0, 6.0)].indexed) { + await database.insert('ofm_airspace_vertex', { + 'airspace_id': 'space-1', 'sequence': entry.$1, + 'lat': entry.$2.$1, 'lon': entry.$2.$2, + }); + } + final provider = OfmDataProvider(database: database); + final results = await provider.findAirspacesInBounds( + minLat: 50.9, maxLat: 51.1, minLon: 4.4, maxLon: 4.6, + ); + + expect(results.single.codeId, 'EBR'); + }); +} diff --git a/test/ofm_database_helper_test.dart b/test/ofm_database_helper_test.dart new file mode 100644 index 00000000..d5e57649 --- /dev/null +++ b/test/ofm_database_helper_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/ofm/ofm_schema.dart'; + +void main() { + test('OFM schema creates separate ofm.db tables and never main.db tables', () { + expect(OfmSchema.databaseName, 'ofm.db'); + expect(OfmSchema.createStatements.join('\n'), contains('create table if not exists ofm_region_install')); + expect(OfmSchema.createStatements.join('\n'), contains('create table if not exists ofm_metadata')); + expect(OfmSchema.createStatements.join('\n'), contains('create table if not exists ofm_airport')); + expect(OfmSchema.createStatements.join('\n'), contains('create table if not exists ofm_airport_comm')); + expect(OfmSchema.createStatements.join('\n'), contains('create table if not exists ofm_runway')); + expect(OfmSchema.createStatements.join('\n'), contains('create table if not exists ofm_runway_end')); + expect(OfmSchema.createStatements.join('\n'), contains('create table if not exists ofm_airspace')); + expect(OfmSchema.createStatements.join('\n'), contains('create table if not exists ofm_waypoint')); + expect(OfmSchema.createStatements.join('\n'), contains('create table if not exists ofm_airspace_vertex')); + expect(OfmSchema.createStatements.join('\n'), isNot(contains('create table airports'))); + expect(OfmSchema.createStatements.join('\n'), isNot(contains('main.db'))); + }); +} diff --git a/test/ofm_download_manager_test.dart b/test/ofm_download_manager_test.dart new file mode 100644 index 00000000..3cad8d68 --- /dev/null +++ b/test/ofm_download_manager_test.dart @@ -0,0 +1,51 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:avaremp/ofm/ofm_download_manager.dart'; +import 'package:avaremp/ofm/ofm_publication.dart'; + +void main() { + test('downloads and validates an OFM PDF atomically', () async { + final root = await Directory.systemTemp.createTemp('ofm_pdf_test'); + addTearDown(() => root.delete(recursive: true)); + final client = MockClient((_) async => http.Response.bytes(utf8.encode('%PDF-1.4\nfixture'), 200)); + final product = OfmPublicationProduct( + type: OfmProductType.chartPdf, rawType: 'PDF CHART COLLECTION', productTitle: 'VFR Charts 1:500k', + name: 'ES-1', details: 'Malmö', url: Uri.parse('https://example.test/es-1.pdf'), + ); + + final installed = await OfmDownloadManager().downloadPdf( + dataDir: root.path, region: 'ES', publicationCode: 'ESAA', cycle: '2601', + product: product, onProgress: (_) {}, client: client, + ); + + expect(await File(installed.localPath).readAsString(), startsWith('%PDF-')); + expect(File('${installed.localPath}.part').existsSync(), isFalse); + }); + + test('invalid PDF preserves an existing installed chart', () async { + final root = await Directory.systemTemp.createTemp('ofm_pdf_test'); + addTearDown(() => root.delete(recursive: true)); + final manager = OfmDownloadManager(); + final product = OfmPublicationProduct( + type: OfmProductType.chartPdf, rawType: 'PDF CHART COLLECTION', + name: 'ES-1', details: 'Malmö', url: Uri.parse('https://example.test/es-1.pdf'), + ); + final first = await manager.downloadPdf( + dataDir: root.path, region: 'ES', publicationCode: 'ESAA', cycle: '2601', product: product, + onProgress: (_) {}, client: MockClient((_) async => http.Response.bytes(utf8.encode('%PDF-valid'), 200)), + ); + expect( + () => manager.downloadPdf( + dataDir: root.path, region: 'ES', publicationCode: 'ESAA', cycle: '2601', product: product, + onProgress: (_) {}, client: MockClient((_) async => http.Response('bad', 200)), + ), + throwsA(isA()), + ); + expect(await File(first.localPath).readAsString(), '%PDF-valid'); + }); +} diff --git a/test/ofm_import_integration_test.dart b/test/ofm_import_integration_test.dart new file mode 100644 index 00000000..1aad53bd --- /dev/null +++ b/test/ofm_import_integration_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:avaremp/ofm/ofm_data_provider.dart'; +import 'package:avaremp/ofm/ofm_schema.dart'; +import 'package:avaremp/ofm/ofmx_importer.dart'; + +void main() { + setUpAll(sqfliteFfiInit); + + test('imports fixtures and queries airport and airspace end to end', () async { + final database = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + addTearDown(database.close); + for (final statement in OfmSchema.createStatements) { + await database.execute(statement); + } + + final airportImport = OfmxImporter.parse(_airportFixture, region: 'EB', cycle: '2601'); + final airspaceImport = OfmxImporter.parse(_airspaceFixture, region: 'LF', cycle: '2601'); + for (final entry in >>{ + 'ofm_airport': airportImport.airports, + 'ofm_airport_comm': airportImport.airportComms, + 'ofm_runway': airportImport.runways, + 'ofm_runway_end': airportImport.runwayEnds, + 'ofm_waypoint': airportImport.waypoints, + 'ofm_airspace': airspaceImport.airspaces, + 'ofm_airspace_vertex': airspaceImport.airspaceVertices, + }.entries) { + for (final row in entry.value) { + await database.insert(entry.key, row); + } + } + + final provider = OfmDataProvider(database: database); + final airport = await provider.findAirport('EBAW'); + final bounds = await provider.findAirspacesInBounds(minLat: 45, maxLat: 46, minLon: -1, maxLon: 1); + + expect(airport?.source, 'OFM'); + expect(airport?.runways, hasLength(1)); + expect(bounds.single.codeId, 'LFBG'); + expect(bounds.single.vertices, hasLength(3)); + }); +} + +const _airportFixture = '''EBAWANTWERPENAH51.18N4.46EEBAW11/29151045M'''; +const _airspaceFixture = '''CTRLFBGCOGNACDLFBGGRC45.7N0.4WGRC45.8N0.3WGRC45.7N0.2W'''; diff --git a/test/ofm_manifest_test.dart b/test/ofm_manifest_test.dart new file mode 100644 index 00000000..be07273e --- /dev/null +++ b/test/ofm_manifest_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/ofm/ofm_manifest.dart'; +import 'package:avaremp/ofm/ofm_paths.dart'; + +void main() { + test('OFM paths stay under OFM storage root', () { + final paths = OfmPaths('/tmp/avarex'); + + expect(paths.root, '/tmp/avarex/ofm'); + expect(paths.manifestPath, '/tmp/avarex/ofm/manifest.json'); + expect(paths.ofmDatabasePath, '/tmp/avarex/ofm/ofm.db'); + expect(paths.mbtilesPath(region: 'ED', cycle: '2601'), '/tmp/avarex/ofm/maps/2601/ed.mbtiles'); + expect( + () => paths.mbtilesPath(region: '../ED', cycle: '2601'), + throwsArgumentError, + ); + }); + + test('OFM manifest round trips installed regions', () { + final manifest = OfmManifest(installs: [ + OfmInstall( + region: 'ED', + cycle: '2601', + installedAt: DateTime.utc(2026, 2, 1), + publicationUrl: Uri.parse('https://example.test/ED_2601.xml'), + mbtilesPath: '/tmp/avarex/ofm/maps/2601/ed.mbtiles', + ofmxPath: '/tmp/avarex/ofm/raw/2601/ed/ofmx_ed.ofmx', + ), + ]); + + final decoded = OfmManifest.fromJson(manifest.toJson()); + + expect(decoded.installs, hasLength(1)); + expect(decoded.installs.single.region, 'ED'); + expect(decoded.installs.single.cycle, '2601'); + expect(decoded.installs.single.publicationUrl.toString(), 'https://example.test/ED_2601.xml'); + }); + + test('OFM manifest round trips installed PDF chart products', () { + final product = OfmInstalledProduct( + region: 'ES', publicationCode: 'ESAA', cycle: '2601', type: 'pdf', + name: 'ES-1', details: 'Malmö', sourceUrl: Uri.parse('https://example.test/es-1.pdf'), + localPath: '/tmp/avarex/ofm/charts/2601/esaa/es-1.pdf', + timestamp: DateTime.utc(2026, 1, 22), byteSize: 24112396, + ); + final decoded = OfmManifest.fromJsonString(OfmManifest(products: [product]).toJsonString()); + expect(decoded.products.single.name, 'ES-1'); + expect(decoded.products.single.publicationCode, 'ESAA'); + expect(decoded.products.single.byteSize, 24112396); + }); + + test('OFM chart paths are sanitized and stay inside OFM storage', () { + final paths = OfmPaths('/tmp/avarex'); + expect(paths.pdfPath(region: 'ESAA', cycle: '2601', filename: 'ES-1.pdf'), + '/tmp/avarex/ofm/charts/2601/esaa/ES-1.pdf'); + expect(() => paths.pdfPath(region: 'ESAA', cycle: '2601', filename: '../escape.pdf'), throwsArgumentError); + }); +} diff --git a/test/ofm_map_layer_test.dart b/test/ofm_map_layer_test.dart new file mode 100644 index 00000000..0a164ebb --- /dev/null +++ b/test/ofm_map_layer_test.dart @@ -0,0 +1,10 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/ofm/ofm_constants.dart'; + +void main() { + test('OFM layer name is stable for settings and map rendering', () { + expect(OfmConstants.layerName, 'OFM VFR Chart'); + expect(OfmConstants.dataLayerName, 'OFM Interactive Data'); + }); +} diff --git a/test/ofm_publication_client_test.dart b/test/ofm_publication_client_test.dart new file mode 100644 index 00000000..de91e9d8 --- /dev/null +++ b/test/ofm_publication_client_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:avaremp/ofm/ofm_publication_client.dart'; + +void main() { + test('uses the OFM FIR publication code for Sweden', () { + final client = OfmPublicationClient(); + expect( + client.publicationUri(region: 'ES', cycle: '2601').toString(), + 'https://snapshots.openflightmaps.org/publicationServices/ESAA_2601.xml', + ); + }); + + test('fetch parses a publication from the mapped endpoint', () async { + late Uri requested; + final client = OfmPublicationClient(client: MockClient((request) async { + requested = request.url; + return http.Response('', 200); + })); + + final publication = await client.fetch(region: 'ES', cycle: '2601'); + expect(requested.path, endsWith('/ESAA_2601.xml')); + expect(publication.region, 'ES'); + }); + + test('non-success status produces a typed publication exception', () async { + final client = OfmPublicationClient(client: MockClient((_) async => http.Response('missing', 404))); + expect(() => client.fetch(region: 'ES', cycle: '2601'), throwsA(isA())); + }); + + test('calculates current AIRAC cycle without a hard-coded year', () { + expect(OfmPublicationClient.airacCycleAt(DateTime.utc(2026, 8, 20)), '2609'); + expect(OfmPublicationClient.airacCycleAt(DateTime.utc(2026, 9, 3)), '2610'); + }); +} diff --git a/test/ofm_publication_test.dart b/test/ofm_publication_test.dart new file mode 100644 index 00000000..1627b680 --- /dev/null +++ b/test/ofm_publication_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/ofm/ofm_publication.dart'; + +void main() { + const fixture = ''' + + + + + +
+ + + +
+
+ + + + + + + + + + + + + +
+
+
+'''; + + test('parses near cycles and OFM download products from publication XML', () { + final publication = OfmPublication.parse( + region: 'ED', + cycle: '2601', + xml: fixture, + ); + + expect(publication.region, 'ED'); + expect(publication.cycle, '2601'); + expect(publication.nearCycles.single.id, '2601'); + expect(publication.products.where((p) => p.type == OfmProductType.ofmx), hasLength(1)); + expect(publication.products.where((p) => p.type == OfmProductType.mbtiles), hasLength(2)); + expect(publication.preferredMbtiles?.url.toString(), endsWith('ed_256.mbtiles')); + expect(publication.ofmx?.url.toString(), endsWith('ofmx_ed.zip')); + expect(publication.chartPdfs.map((p) => p.name), ['ES-1', 'ES-2']); + expect(publication.normalMbtiles?.url.toString(), endsWith('ed_256.mbtiles')); + expect(publication.retinaMbtiles?.url.toString(), endsWith('ed_256@2x.mbtiles')); + expect(publication.slippyTileArchives.single.url.toString(), endsWith('slippy.zip')); + expect(publication.chartPdfs.first.productTitle, 'VFR Charts 1:500k'); + }); +} diff --git a/test/ofm_region_test.dart b/test/ofm_region_test.dart new file mode 100644 index 00000000..4f4f2bd3 --- /dev/null +++ b/test/ofm_region_test.dart @@ -0,0 +1,17 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/ofm/ofm_region.dart'; + +void main() { + test('maps download-page codes to OFM publication service identifiers', () { + expect(OfmRegions.publicationCode('ES'), 'ESAA'); + expect(OfmRegions.publicationCode('EB'), 'EBBU'); + expect(OfmRegions.publicationCode('ED'), 'ED'); + expect(OfmRegions.publicationCode('LO'), 'LOVV'); + }); + + test('finds region labels by public download code', () { + expect(OfmRegions.byCode('ES').name, 'Sweden'); + expect(OfmRegions.byCode('ES').publicationCode, 'ESAA'); + }); +} diff --git a/test/ofmx_airport_importer_test.dart b/test/ofmx_airport_importer_test.dart new file mode 100644 index 00000000..138632da --- /dev/null +++ b/test/ofmx_airport_importer_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/ofm/ofmx_importer.dart'; + +void main() { + test('parses airport, communication, runway, and runway end', () { + final result = OfmxImporter.parse( + _airportFixture, + region: 'EB', + cycle: '2601', + ); + + expect(result.airports, hasLength(1)); + final airport = result.airports.single; + expect(airport['id'], 'EB:2601:airport-1'); + expect(airport['code_id'], 'EBAW'); + expect(airport['name'], 'ANTWERPEN/DEURNE'); + expect(airport['lat'], closeTo(51.18945833, 0.000000001)); + expect(airport['lon'], closeTo(4.46039722, 0.000000001)); + expect(airport['elevation_ft'], 39); + + expect(result.airportComms, hasLength(2)); + final radio = result.airportComms.singleWhere((row) => row['code_type'] == 'TWR'); + expect(radio['airport_id'], 'EB:2601:airport-1'); + expect(radio['value'], '120.055 MHZ'); + expect(radio['remark'], 'ANTWERP TWR (EN)'); + expect(result.runways.single['designation'], '11/29'); + expect(result.runways.single['id'], 'EB:2601:runway-1'); + expect(result.runways.single['length_m'], 1510); + expect(result.runwayEnds.single['designation'], '11'); + expect(result.runwayEnds.single['runway_id'], 'EB:2601:runway-1'); + expect(result.runwayEnds.single['mag_bearing'], 112); + }); +} + +const _airportFixture = ''' + + EBAWANTWERPEN/DEURNEEBAWANREBDEURNEAH51.18945833N004.46039722E39FTANTWERPEN + EBAWPHONE1+32 3 285 65 00Airport office + ANTWERPENTWREBAW + ANTWERPENTWRTWR2 + ANTWERPENTWRTWR2120.055MHZSTDANTWERP TWR (EN) + EBAW11/29151045MASPHGOOD + EBAW11/291151.19186111N004.454775E109.811239FTPAPIE + +'''; diff --git a/test/ofmx_airspace_importer_test.dart b/test/ofmx_airspace_importer_test.dart new file mode 100644 index 00000000..b7fb9593 --- /dev/null +++ b/test/ofmx_airspace_importer_test.dart @@ -0,0 +1,35 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/ofm/ofmx_importer.dart'; + +void main() { + test('parses airspace metadata and preserves vertex order', () { + final result = OfmxImporter.parse( + _airspaceFixture, + region: 'LF', + cycle: '2601', + ); + + expect(result.airspaces, hasLength(1)); + final airspace = result.airspaces.single; + expect(airspace['id'], 'LF:2601:airspace-1'); + expect(airspace['code_id'], 'LFBG'); + expect(airspace['class'], 'D'); + expect(airspace['alt_upper_ft'], 1500); + expect(airspace['alt_lower_ft'], 0); + + expect(result.airspaceVertices, hasLength(3)); + expect(result.airspaceVertices.map((v) => v['sequence']), [0, 1, 2]); + expect(result.airspaceVertices[1]['code_type'], 'CCA'); + expect(result.airspaceVertices[1]['airspace_id'], 'LF:2601:airspace-1'); + expect(result.airspaceVertices[1]['arc_lat'], 45.75); + expect(result.airspaceVertices[1]['arc_lon'], -0.35); + }); +} + +const _airspaceFixture = ''' + + CTRLFBGCOGNACDALT1500FTHEI0FTY + CTRLFBGGRC45.7N000.4WCCA45.8N000.3W45.75N000.35WGRC45.7N000.2W + +'''; diff --git a/test/ofmx_navigation_importer_test.dart b/test/ofmx_navigation_importer_test.dart new file mode 100644 index 00000000..30aea057 --- /dev/null +++ b/test/ofmx_navigation_importer_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/ofm/ofmx_importer.dart'; + +void main() { + test('parses designated points, VORs, and NDBs', () { + final result = OfmxImporter.parse(_fixture, region: 'ED', cycle: '2601'); + + expect(result.waypoints, hasLength(3)); + final point = result.waypoints.singleWhere((item) => item['code_id'] == 'N2'); + expect(point['kind'], 'FIX'); + expect(point['type'], 'VFR-MRP'); + expect(point['airport_code'], 'ETNG'); + + final vor = result.waypoints.singleWhere((item) => item['code_id'] == 'MHV'); + expect(vor['kind'], 'VOR'); + expect(vor['frequency'], '109.80 MHZ'); + expect(vor['mag_var'], 2); + + final ndb = result.waypoints.singleWhere((item) => item['code_id'] == 'LAA'); + expect(ndb['kind'], 'NDB'); + expect(ndb['frequency'], '352.0 KHZ'); + }); +} + +const _fixture = ''' + + N250.99777778N006.11972222EETNGVFR-MRPNOVEMBER2 + MHV51.23730000N006.49024444EMÖNCHENGLADBACHVOR109.80MHZ22025Operational coverage + LAA51.60168611N006.17270000ENIEDERRHEIN352.0KHZOperational range 23 NM above MVA. + +'''; diff --git a/test/ofmx_units_test.dart b/test/ofmx_units_test.dart new file mode 100644 index 00000000..6a60af13 --- /dev/null +++ b/test/ofmx_units_test.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:avaremp/ofm/ofmx_units.dart'; + +void main() { + group('OFMX units', () { + test('parses hemisphere-suffixed coordinates', () { + expect(parseOfmCoordinate('51.18945833N'), closeTo(51.18945833, 0.000000001)); + expect(parseOfmCoordinate('004.46039722E'), closeTo(4.46039722, 0.000000001)); + expect(parseOfmCoordinate('002.10675400W'), closeTo(-2.106754, 0.000000001)); + expect(parseOfmCoordinate('12.5S'), -12.5); + }); + + test('rejects invalid coordinates safely', () { + expect(parseOfmCoordinateOrNull(null), isNull); + expect(parseOfmCoordinateOrNull(''), isNull); + expect(parseOfmCoordinateOrNull('91N'), isNull); + expect(parseOfmCoordinateOrNull('-2W'), isNull); + expect(parseOfmCoordinateOrNull('NaNN'), isNull); + expect(() => parseOfmCoordinate('invalid'), throwsFormatException); + }); + + test('normalizes flight levels and metric distances to feet', () { + expect(ofmAltitudeFeet(115, 'FL'), 11500); + expect(ofmAltitudeFeet(1000, 'M'), closeTo(3280.839895, 0.000001)); + expect(ofmLengthFeet(1510, 'M'), closeTo(4954.06824, 0.00001)); + expect(ofmLengthFeet(4500, 'FT'), 4500); + }); + }); +} diff --git a/test/open_meteo_winds_test.dart b/test/open_meteo_winds_test.dart new file mode 100644 index 00000000..dc67f436 --- /dev/null +++ b/test/open_meteo_winds_test.dart @@ -0,0 +1,148 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:avaremp/weather/open_meteo_winds.dart'; + +// Builds a minimal Open-Meteo hourly payload with a single forecast hour, +// providing geopotential height (m), wind speed (kn) and direction (deg) per +// pressure level. +String buildBody({ + required String time, + required Map levels, +}) { + final hourly = {'time': [time]}; + levels.forEach((hpa, v) { + hourly['geopotential_height_${hpa}hPa'] = [v.ghM]; + hourly['wind_speed_${hpa}hPa'] = [v.spdKn]; + hourly['wind_direction_${hpa}hPa'] = [v.dirDeg]; + }); + return jsonEncode({'hourly': hourly}); +} + +void main() { + group('OpenMeteoWinds.encodeWind (FB token)', () { + test('encodes direction (tens of deg) and speed', () { + expect(OpenMeteoWinds.encodeWind(240, 35), '2435'); + expect(OpenMeteoWinds.encodeWind(90, 12), '0912'); + }); + + test('light and variable / calm below 5 kt', () { + expect(OpenMeteoWinds.encodeWind(120, 3), '9900'); + expect(OpenMeteoWinds.encodeWind(0, 0), '9900'); + }); + + test('high-speed encoding adds 50 to direction and subtracts 100 kt', () { + // 270 deg at 120 kt -> dir tens 27 + 50 = 77, speed 20 -> "7720" + expect(OpenMeteoWinds.encodeWind(270, 120), '7720'); + }); + + test('missing inputs yield empty token', () { + expect(OpenMeteoWinds.encodeWind(null, 20), ''); + expect(OpenMeteoWinds.encodeWind(180, null), ''); + }); + }); + + group('OpenMeteoWinds.parse', () { + // A simple monotonic profile: higher altitude -> stronger, veering wind. + final body = buildBody( + time: '2026-08-24T06:00', + levels: { + 1000: (ghM: 110, spdKn: 8, dirDeg: 200), // ~360 ft + 850: (ghM: 1500, spdKn: 20, dirDeg: 230), // ~4921 ft + 700: (ghM: 3000, spdKn: 30, dirDeg: 250), // ~9843 ft + 500: (ghM: 5600, spdKn: 55, dirDeg: 270), // ~18,373 ft + 300: (ghM: 9000, spdKn: 80, dirDeg: 280), // ~29,528 ft + 250: (ghM: 10400, spdKn: 90, dirDeg: 285), // ~34,121 ft + 200: (ghM: 11800, spdKn: 100, dirDeg: 290), // ~38,714 ft + }, + ); + + test('produces a WindsAloft with the requested station and valid time', () { + final wa = OpenMeteoWinds.parse('EDDF', body, + now: DateTime.utc(2026, 8, 24, 0), foreHours: 6); + expect(wa, isNotNull); + expect(wa!.station, 'EDDF'); + // Valid time is the selected forecast hour. + expect(wa.expires.toUtc(), DateTime.utc(2026, 8, 24, 6)); + }); + + test('interpolates a mid-level slot between samples', () { + final wa = OpenMeteoWinds.parse('EDDF', body, + now: DateTime.utc(2026, 8, 24, 0), foreHours: 6)!; + // 6000 ft sits between 850 hPa (~4921 ft, 230 deg / 20 kt) and 700 hPa + // (~9843 ft, 250 deg / 30 kt). The decoded FB token is direction in tens + // of degrees + speed in kt; interpolated speed must be between 20 and 30. + // (getWindAtAltitude decodes via Storage, unavailable in unit tests, so we + // assert the encoded slot token which is the product of interpolation.) + final token = wa.w6k; + expect(token.length, 4); + final dirTens = int.parse(token.substring(0, 2)); + final spd = int.parse(token.substring(2, 4)); + expect(dirTens, inInclusiveRange(23, 25)); // 230..250 deg + expect(spd, greaterThan(20)); + expect(spd, lessThan(30)); + }); + + test('surface slot uses the lowest level', () { + final wa = OpenMeteoWinds.parse('EDDF', body, + now: DateTime.utc(2026, 8, 24, 0), foreHours: 6)!; + // 0 ft maps to the 1000 hPa sample (200 deg / 8 kt). + expect(wa.w0k, OpenMeteoWinds.encodeWind(200, 8)); + }); + + test('selects the forecast hour nearest now + foreHours', () { + final multi = jsonEncode({ + 'hourly': { + 'time': ['2026-08-24T00:00', '2026-08-24T06:00', '2026-08-24T12:00'], + 'geopotential_height_850hPa': [1500, 1510, 1520], + 'wind_speed_850hPa': [10, 20, 30], + 'wind_direction_850hPa': [200, 230, 260], + } + }); + final wa = OpenMeteoWinds.parse('X', multi, + now: DateTime.utc(2026, 8, 24, 0), foreHours: 6)!; + // Should pick the 06:00 sample (20 kt / 230 deg) for the 850 hPa-derived + // altitude (~4921 ft), so the 6000 ft neighbourhood reflects that hour. + expect(wa.expires.toUtc(), DateTime.utc(2026, 8, 24, 6)); + }); + + test('returns null on malformed JSON', () { + expect(OpenMeteoWinds.parse('X', 'not json'), isNull); + }); + + test('returns null when no usable levels present', () { + final empty = jsonEncode({'hourly': {'time': ['2026-08-24T06:00']}}); + expect(OpenMeteoWinds.parse('X', empty, + now: DateTime.utc(2026, 8, 24, 0), foreHours: 6), isNull); + }); + }); + + group('OpenMeteoWinds.buildUrl', () { + test('uses the free host and knots unit without a key', () { + final uri = OpenMeteoWinds.buildUrl(50.03, 8.55); + expect(uri.host, OpenMeteoWinds.freeHost); + expect(uri.queryParameters['wind_speed_unit'], 'kn'); + expect(uri.queryParameters.containsKey('apikey'), isFalse); + expect(uri.queryParameters['hourly'], contains('wind_speed_850hPa')); + expect(uri.queryParameters['hourly'], contains('geopotential_height_500hPa')); + }); + + test('uses the customer host when an API key is supplied', () { + final uri = OpenMeteoWinds.buildUrl(50.03, 8.55, apiKey: 'secret'); + expect(uri.host, OpenMeteoWinds.customerHost); + expect(uri.queryParameters['apikey'], 'secret'); + }); + }); + + group('OpenMeteoWinds.distanceKm', () { + test('is unit-independent kilometres', () { + // Frankfurt to Paris is ~480 km. + final d = OpenMeteoWinds.distanceKm( + const LatLng(50.03, 8.55), const LatLng(48.85, 2.35)); + expect(d, greaterThan(430)); + expect(d, lessThan(530)); + }); + }); +} diff --git a/test/openaip_client_test.dart b/test/openaip_client_test.dart new file mode 100644 index 00000000..df0999d2 --- /dev/null +++ b/test/openaip_client_test.dart @@ -0,0 +1,78 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +import 'package:avaremp/openaip/openaip_client.dart'; + +void main() { + test('sends API key and follows paginated country results', () async { + final requests = []; + final client = OpenAipClient( + apiKey: 'test-key', + client: MockClient((request) async { + requests.add(request); + final page = request.url.queryParameters['page']; + return http.Response(jsonEncode({ + 'page': int.parse(page!), + 'limit': 2, + 'totalCount': 3, + 'totalPages': 2, + if (page == '1') 'nextPage': 2, + 'items': page == '1' ? [{'_id': 'a'}, {'_id': 'b'}] : [{'_id': 'c'}], + }), 200); + }), + ); + + final items = await client.fetchCountry(OpenAipDataset.obstacles, 'SE', limit: 2); + + expect(items.map((item) => item['_id']), ['a', 'b', 'c']); + expect(requests, hasLength(2)); + expect(requests.first.headers['x-openaip-api-key'], 'test-key'); + expect(requests.first.url.queryParameters['country'], 'SE'); + }); + + test('rejects an empty API key before making a request', () async { + final client = OpenAipClient(apiKey: ''); + expect( + () => client.fetchCountry(OpenAipDataset.airports, 'SE'), + throwsA(isA()), + ); + }); + + test('reports authentication failures without exposing the key', () async { + final client = OpenAipClient( + apiKey: 'secret-key', + client: MockClient((_) async => http.Response('{"message":"forbidden"}', 403)), + ); + await expectLater( + client.fetchCountry(OpenAipDataset.navaids, 'SE'), + throwsA(predicate((error) => + error is OpenAipException && + error.toString().contains('403') && + !error.toString().contains('secret-key'))), + ); + }); + + test('uses totalPages when the API omits nextPage', () async { + final requestedPages = []; + final client = OpenAipClient( + apiKey: 'test-key', + client: MockClient((request) async { + final page = request.url.queryParameters['page']!; + requestedPages.add(page); + return http.Response(jsonEncode({ + 'page': int.parse(page), + 'totalPages': 2, + 'items': [{'_id': 'item-$page'}], + }), 200); + }), + ); + + final items = await client.fetchCountry(OpenAipDataset.obstacles, 'SE'); + + expect(requestedPages, ['1', '2']); + expect(items, hasLength(2)); + }); +} diff --git a/test/openaip_database_test.dart b/test/openaip_database_test.dart new file mode 100644 index 00000000..c76a2891 --- /dev/null +++ b/test/openaip_database_test.dart @@ -0,0 +1,187 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:latlong2/latlong.dart'; + +import 'package:avaremp/openaip/openaip_database.dart'; + +void main() { + late Database database; + setUpAll(sqfliteFfiInit); + setUp(() async { + database = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + await OpenAipDatabase.createSchema(database); + }); + tearDown(() => database.close()); + + test('replaces a country dataset and queries navigation plus obstacles', () async { + final store = OpenAipDatabase(database: database); + await store.replaceCountry( + country: 'SE', + airports: const [], + navaids: [ + { + '_id': 'nav-1', 'name': 'ALMA', 'identifier': 'ALM', 'type': 3, + 'country': 'SE', 'geometry': {'coordinates': [13.5575, 55.41139]}, + 'frequency': {'value': '116.400'}, + } + ], + reportingPoints: [ + { + '_id': 'rpp-1', 'name': 'MARK', 'compulsory': true, 'country': 'SE', + 'geometry': {'coordinates': [20.27028, 63.90361]}, + } + ], + airspaces: const [], + obstacles: [ + { + '_id': 'obs-1', 'name': 'Tower', 'type': 4, 'country': 'SE', + 'geometry': {'coordinates': [13.0, 55.0]}, + 'elevation': {'value': 300}, 'height': {'value': 100}, + } + ], + ); + + final search = await store.findDestinations('ALM'); + final obstacles = await store.findObstacles( + latitude: 55.0, longitude: 13.0, minimumMslFeet: 900, + ); + + expect(search.single.locationID, 'ALM'); + expect(search.single.source, 'openAIP'); + expect(obstacles.single.latitude, 55.0); + }); + + test('loads airport details from the original openAIP payload', () async { + final store = OpenAipDatabase(database: database); + await store.replaceCountry( + country: 'SE', + airports: [ + { + '_id': 'apt-1', 'name': 'ALINGSAS', 'icaoCode': 'ESGI', 'type': 2, + 'country': 'SE', 'geometry': {'coordinates': [12.57556, 57.94861]}, + 'elevation': {'value': 67}, + 'frequencies': [{'value': '123.650', 'type': 10, 'name': 'ALINGSAS RADIO'}], + 'runways': [{ + 'designator': '01', 'trueHeading': 10, + 'dimension': {'length': {'value': 750}, 'width': {'value': 30}}, + 'surface': {'mainComposite': 2}, + }], + } + ], + navaids: const [], reportingPoints: const [], airspaces: const [], obstacles: const [], + ); + + final airport = await store.findAirport('ESGI'); + + expect(airport, isNotNull); + expect(airport!.source, 'openAIP'); + expect(airport.elevation, closeTo(219.8, 0.2)); + expect(airport.frequencies.single['Frequency'], '123.650'); + expect(airport.runways.single['LEIdent'], '01'); + }); + + test('queries nearby openAIP airports and waypoints', () async { + final store = OpenAipDatabase(database: database); + await store.replaceCountry( + country: 'SE', + airports: [{ + '_id': 'apt-1', 'name': 'MALMO', 'icaoCode': 'ESMS', 'type': 3, + 'country': 'SE', 'geometry': {'coordinates': [13.37, 55.53]}, + }], + navaids: [{ + '_id': 'nav-1', 'name': 'ALMA', 'identifier': 'ALM', 'type': 3, + 'country': 'SE', 'geometry': {'coordinates': [13.55, 55.41]}, + }], + reportingPoints: const [], airspaces: const [], obstacles: const [], + ); + + final results = await store.findNear(const LatLng(55.5, 13.4), factor: 0.1); + + expect(results.map((item) => item.locationID), containsAll(['ESMS', 'ALM'])); + }); + + test('queries openAIP airspaces intersecting map bounds', () async { + final store = OpenAipDatabase(database: database); + await store.replaceCountry( + country: 'SE', airports: const [], navaids: const [], reportingPoints: const [], + obstacles: const [], + airspaces: [{ + '_id': 'asp-1', 'name': 'TEST CTR', 'country': 'SE', 'type': 4, + 'icaoClass': 4, 'lowerLimit': {'value': 0, 'unit': 1, 'referenceDatum': 0}, + 'upperLimit': {'value': 1500, 'unit': 1, 'referenceDatum': 1}, + 'geometry': {'type': 'Polygon', 'coordinates': [[ + [12.0, 55.0], [14.0, 55.0], [14.0, 57.0], [12.0, 57.0], [12.0, 55.0] + ]]}, + }], + ); + + final results = await store.findAirspacesInBounds( + minLat: 55.4, maxLat: 55.6, minLon: 12.9, maxLon: 13.1, + ); + + expect(results.single.name, 'TEST CTR'); + expect(results.single.points, hasLength(5)); + expect(results.single.upperFeet, 1500); + }); + + test('filters nearby airports by openAIP runway length', () async { + final store = OpenAipDatabase(database: database); + await store.replaceCountry( + country: 'SE', + airports: [{ + '_id': 'apt-long', 'name': 'LONG', 'icaoCode': 'ESLG', 'type': 3, + 'country': 'SE', 'geometry': {'coordinates': [13.4, 55.5]}, + 'runways': [{'designator': '01', 'dimension': {'length': {'value': 1500}}}], + }, { + '_id': 'apt-short', 'name': 'SHORT', 'icaoCode': 'ESSH', 'type': 3, + 'country': 'SE', 'geometry': {'coordinates': [13.5, 55.5]}, + 'runways': [{'designator': '02', 'dimension': {'length': {'value': 300}}}], + }], + navaids: const [], reportingPoints: const [], airspaces: const [], obstacles: const [], + ); + + final results = await store.findNearestAirportsWithRunways( + const LatLng(55.5, 13.45), 3000, + ); + + expect(results.map((item) => item.locationID), ['ESLG']); + }); + + test('finds nearest openAIP VOR records', () async { + final store = OpenAipDatabase(database: database); + await store.replaceCountry( + country: 'SE', airports: const [], reportingPoints: const [], + airspaces: const [], obstacles: const [], + navaids: [{ + '_id': 'vor-1', 'name': 'ALMA', 'identifier': 'ALM', 'type': 3, + 'country': 'SE', 'geometry': {'coordinates': [13.55, 55.41]}, + 'frequency': {'value': '116.400'}, + }, { + '_id': 'ndb-1', 'name': 'BEACON', 'identifier': 'BCN', 'type': 2, + 'country': 'SE', 'geometry': {'coordinates': [13.50, 55.40]}, + }], + ); + + final results = await store.findNearestVOR(const LatLng(55.5, 13.4)); + + expect(results.single.locationID, 'ALM'); + expect(results.single.class_, '116.400'); + }); + + test('ignores obstacles without a known top elevation for altitude filtering', () async { + final store = OpenAipDatabase(database: database); + await store.replaceCountry( + country: 'SE', airports: const [], navaids: const [], reportingPoints: const [], airspaces: const [], + obstacles: [{ + '_id': 'unknown-height', 'name': 'Obstacle', 'type': 0, 'country': 'SE', + 'geometry': {'coordinates': [13.0, 55.0]}, + }], + ); + + final results = await store.findObstacles( + latitude: 55.0, longitude: 13.0, minimumMslFeet: -1000, + ); + + expect(results, isEmpty); + }); +} diff --git a/test/terrain_transcode_test.dart b/test/terrain_transcode_test.dart new file mode 100644 index 00000000..a4d46566 --- /dev/null +++ b/test/terrain_transcode_test.dart @@ -0,0 +1,92 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; + +import 'package:avaremp/weather/terrain_transcode.dart'; + +void main() { + group('elevation gray encoding (matches AvareX decoder)', () { + test('encode/decode round-trips within one quantization step', () { + for (final ft in [0.0, 500.0, 1000.0, 5280.0, 10000.0, 14000.0]) { + final g = TerrainTranscode.encodeGray(ft); + final back = TerrainTranscode.decodeGray(g); + expect((back - ft).abs(), lessThanOrEqualTo(kElevSlope), + reason: 'ft=$ft gray=$g back=$back'); + } + }); + + test('clamps out-of-range elevations to byte bounds', () { + expect(TerrainTranscode.encodeGray(-100000), 0); + expect(TerrainTranscode.encodeGray(1000000), 255); + }); + + test('gray 5 decodes to ~38 ft (validated against a real US tile)', () { + expect(TerrainTranscode.decodeGray(5), closeTo(38.0, 1.0)); + }); + }); + + group('tile grid (slippy X, TMS Y)', () { + test('Frankfurt maps to AvareX tile 536/676 at z10', () { + final x = TerrainTranscode.lonToTileX(8.55, 10); + final yx = TerrainTranscode.latToTileYXyz(50.03, 10); + final yTms = (1 << 10) - 1 - yx; + expect(x, 536); + expect(yTms, 676); + }); + + test('TerrainTile.yXyz is the TMS complement of yTms', () { + const t = TerrainTile(10, 536, 676); + expect(t.yXyz, (1 << 10) - 1 - 676); // 347 + }); + + test('tilesForBounds covers all zooms and is non-empty', () { + final tiles = TerrainTranscode.tilesForBounds(45.8, 47.9, 5.9, 10.6); // CH + final zooms = tiles.map((t) => t.z).toSet(); + expect(zooms, containsAll(List.generate(10, (i) => i + 1))); + // count helper agrees with enumeration + expect(tiles.length, + TerrainTranscode.countTilesForBounds(45.8, 47.9, 5.9, 10.6)); + }); + + test('countTilesForBounds is reasonable for Switzerland', () { + final n = TerrainTranscode.countTilesForBounds(45.8, 47.9, 5.9, 10.6); + expect(n, greaterThan(100)); + expect(n, lessThan(400)); + }); + }); + + group('terrarium URL', () { + test('builds the AWS Terrain Tiles URL for slippy XYZ', () { + final u = TerrainTranscode.terrariumUrl(10, 534, 362); + expect(u.toString(), + 'https://s3.amazonaws.com/elevation-tiles-prod/terrarium/10/534/362.png'); + }); + }); + + group('transcode a real terrarium tile', () { + test('produces a 512x512 gray+alpha PNG that decodes to Alpine elevations', + () { + final bytes = File( + 'test/fixtures/terrarium_alps_10_534_362.png') + .readAsBytesSync(); + final out = TerrainTranscode.transcodeTerrarium(bytes); + expect(out, isNotNull); + + final decoded = img.decodePng(Uint8List.fromList(out!)); + expect(decoded, isNotNull); + expect(decoded!.width, 512); + expect(decoded.height, 512); + + // Sample the center and decode via AvareX's own formula. + final p = decoded.getPixel(256, 256); + final ft = TerrainTranscode.decodeGray(p.r.toInt()); + // This Alps tile ranges ~2000-13000 ft; the center should be high alpine. + expect(ft, greaterThan(1500)); + expect(ft, lessThan(14000)); + // Alpha present (data), so it renders. + expect(p.a.toInt(), greaterThan(0)); + }); + }); +} diff --git a/test/user_database_migration_test.dart b/test/user_database_migration_test.dart new file mode 100644 index 00000000..b6a3d058 --- /dev/null +++ b/test/user_database_migration_test.dart @@ -0,0 +1,41 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; + +import 'package:avaremp/data/user_database_helper.dart'; + +void main() { + setUpAll(sqfliteFfiInit); + + test('recent migration adds source columns and preserves existing rows', () async { + final database = await databaseFactoryFfi.openDatabase(inMemoryDatabasePath); + addTearDown(database.close); + await database.execute(''' +create table recent ( + id integer primary key autoincrement, + LocationID text, + FacilityName text, + Type text, + ARPLatitude float, + ARPLongitude float, + unique(LocationID, Type) on conflict replace +) +'''); + await database.insert('recent', { + 'LocationID': 'KJFK', 'FacilityName': 'JOHN F KENNEDY', + 'Type': 'AIRPORT', 'ARPLatitude': 40.64, 'ARPLongitude': -73.78, + }); + + await UserDatabaseHelper.migrateRecentSourceSchema(database); + + final columns = await database.rawQuery('pragma table_info(recent)'); + expect(columns.map((row) => row['name']), containsAll(['Source', 'SourceRegion', 'SourceCycle'])); + final rows = await database.query('recent'); + expect(rows.single['LocationID'], 'KJFK'); + expect(rows.single['Source'], 'FAA'); + await database.insert('recent', { + 'LocationID': 'ESMS', 'FacilityName': 'MALMO', 'Type': 'AIRPORT', + 'ARPLatitude': 55.53, 'ARPLongitude': 13.37, + 'Source': 'OFM', 'SourceRegion': 'ES', 'SourceCycle': '2609', + }); + }); +} diff --git a/web/favicon.png b/web/favicon.png index 64ea65d2..b684814d 100644 Binary files a/web/favicon.png and b/web/favicon.png differ diff --git a/web/icons/Icon-192.png b/web/icons/Icon-192.png index ea1df9b0..93206a63 100644 Binary files a/web/icons/Icon-192.png and b/web/icons/Icon-192.png differ diff --git a/web/icons/Icon-512.png b/web/icons/Icon-512.png index bd219680..b266f68f 100644 Binary files a/web/icons/Icon-512.png and b/web/icons/Icon-512.png differ diff --git a/web/icons/Icon-maskable-192.png b/web/icons/Icon-maskable-192.png index ea1df9b0..93206a63 100644 Binary files a/web/icons/Icon-maskable-192.png and b/web/icons/Icon-maskable-192.png differ diff --git a/web/icons/Icon-maskable-512.png b/web/icons/Icon-maskable-512.png index bd219680..b266f68f 100644 Binary files a/web/icons/Icon-maskable-512.png and b/web/icons/Icon-maskable-512.png differ diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 22693539..8ce50cd5 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -6,14 +6,9 @@ #include "generated_plugin_registrant.h" -#include #include -#include -#include #include -#include -#include -#include +#include #include #include #include @@ -22,22 +17,12 @@ #include void RegisterPlugins(flutter::PluginRegistry* registry) { - AppLinksPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("AppLinksPluginCApi")); AudioplayersWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin")); - CloudFirestorePluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("CloudFirestorePluginCApi")); - DesktopWebviewAuthPluginRegisterWithRegistrar( - registry->GetRegistrarForPlugin("DesktopWebviewAuthPlugin")); FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); - FirebaseAuthPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FirebaseAuthPluginCApi")); - FirebaseCorePluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FirebaseCorePluginCApi")); - FirebaseStoragePluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("FirebaseStoragePluginCApi")); + FlutterSecureStorageWindowsPluginRegisterWithRegistrar( + registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); GeolocatorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("GeolocatorWindows")); MsvcredistPluginCApiRegisterWithRegistrar( diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 344f8617..05e60c63 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -3,14 +3,9 @@ # list(APPEND FLUTTER_PLUGIN_LIST - app_links audioplayers_windows - cloud_firestore - desktop_webview_auth file_selector_windows - firebase_auth - firebase_core - firebase_storage + flutter_secure_storage_windows geolocator_windows msvcredist share_plus diff --git a/windows/runner/resources/app_icon.ico b/windows/runner/resources/app_icon.ico index 1e658600..90c8f75a 100644 Binary files a/windows/runner/resources/app_icon.ico and b/windows/runner/resources/app_icon.ico differ